pleach
Architecture

Edge catalog

The nine allowed and seven forbidden cross-stage edge patterns, the chain skeleton, and the two conditional routers that wire registered nodes together.

The default agent graph composes from four static chains plus two conditional routers. The chains carry the deterministic skeleton — START to END — and the routers carry the branching: post-LLM tool dispatch and post-hallucination retry. Every edge that compiles is matched against the nine-pattern ALLOWED_EDGE_PATTERNS table; the audit:graph-stages script refuses a build with any unmatched edge.

The companion page is Node catalog. Conceptual framing lives in Graph; the structural-pin rationale lives in Graph § lattice gate.

Subpath@pleach/core/graphSourcesrc/graph/topology.ts

The allowed-edge table

Every compiled-graph edge matches exactly one pattern in this list. The lint walks the compiled topology and reports a [graph-stages] FORBIDDEN edge ... violation for any edge whose (from-stage, to-stage) pair isn't here.

#from-stageto-stagerole
1anchor-plananchor-planIntra-stage chain — sessionAnchor → contextProjection → planReconciler, etc.
2anchor-plantool-loopThe pre-LLM chain enters the loop.
3tool-looptool-loopIntra-loop chain — tools → guard → enrichment → … → llm.
4tool-loopsynthesizeForced or natural escalation to synthesis.
5tool-looppost-turnRecovery dispatch — recovery shaping is post-turn work, so this edge sits in the allowed table (an earlier revision promoted it from the forbidden table).
6synthesizesynthesizeThe synthesis retry self-loop, guarded by messageId equality.
7synthesizepost-turnThe terminal chain enters post-turn cleanup.
8post-turnpost-turnThe post-turn chain — citation → sessionMemoryWrite → costRollup → …
9post-turnanchor-planNext-turn rollover. The only legal cross-turn edge.

The forbidden-edge table

The forbidden patterns are enumerated exhaustively over the 4×4 stage cross-product so that lint failures point at the exact violation, not at "not in the allowed table." All seven rows fail CI on first match. (tool-loop → post-turn is absent here — it lives in the allowed table as the recovery-dispatch edge.)

#from-stageto-stagefailure mode
1anchor-plansynthesizeSkipping the tool loop.
2anchor-planpost-turnSkipping the loop and synthesis.
3tool-loopanchor-planRe-planning mid-turn.
4synthesizetool-loopPost-synthesis tool dispatch — the headline risk surface.
5synthesizeanchor-planPost-synthesis re-planning.
6post-turntool-loopPost-turn re-entering an active turn.
7post-turnsynthesizePost-turn re-entering synthesis.

The chain skeleton

The builder composes four static chains. Each is an inline array literal in defaultAgentGraph.ts; the audit script's CHAIN_ARRAY_RE regex parses the literals at lint time to synthesize the adjacency edges, so the array shape itself is load-bearing.

Pre-LLM chain — anchor-plan interior

START → intentDetector → fileIntentDetector → costRouter → skillActivation → mainLlmNode

Every member is .filter(hasNode)-gated, so an absent executor collapses the chain rather than breaking it. mainLlmNode is "llmDecision" when the host wires a streaming decision executor, "llm" otherwise.

Enrich-route chain — tool-loop interior

garbledContentGuard → repetitionGuard → syntheticExpansionDetector → dsmlDetector → forceSynthesizer → hallucination

The post-stream detector chain. The enrich arm of the post-LLM shouldContinue router (below) routes into this chain's first member; the chain terminates at hallucination, which then routes via afterHallucination.

Post-tool chain — tool-loop interior

tools → dataSilo → guard → enrichment → safetyReview → quality → continuation
      → coupling → fabrication → diagnostics → asyncTaskDispatch
      → creditBudget → eventLogger → mainLlmNode

The sequential post-tool inspection chain. Each node operates on the accumulated completedTools channel and may decide to short-circuit by writing shouldContinue: falsecontinuation, creditBudget, safetyReview, and quality are the four nodes that exercise that short-circuit.

Terminal chain — synthesize + post-turn interior

synthesizer → citation → contextSummarizer → memoryExtraction → consolidation → END
                 ├─▶ sessionMemoryWrite ─┐
                 └─▶ costRollup ──────────┴─▶ (fan back in at contextSummarizer)

synthesizer is the lone stage-3 node; the synthesize → post-turn transition is the citation entry. sessionMemoryWrite and costRollup fan out as parallel siblings from citation and fan back in at contextSummarizer — their subscribes/writes are disjoint, so a failure in one doesn't cascade into the other. Each node is filter-gated; the chain composes cleanly when sub-segments are absent.

The recovery shaping that earlier revisions placed in this chain — refusalHint, retryNarration — is gone. An earlier revision retired both graph nodes; they fire now as post-turn stream filters (refusalHintFilter at priority 100, retryNarrationFilter at 200), not as members of terminalChainBase.

The conditional routers

Two route functions drive the branching topology. Each is wired through .addConditionalEdges(from, routeFn, edgeMap) and produces one edge per arm in the map — wireStageTransitionEdges.ts has exactly two .addConditionalEdges(...) call sites (shouldContinue on the LLM node and afterHallucination on the detector node). The maps are inline object literals — the audit script's ADD_COND_RE regex requires this so every conditional destination is visible to static topology analysis. Recovery dispatch is not a third router — it is a collection of post-stage stream filters (below), fired at stage completion through the StreamObserverRegistry rather than through a conditional edge.

shouldContinue — post-LLM four-arm

Source: mainLlmNode. The post-LLM router. An earlier revision retired the shouldContinueWithGarbleRoute wrapper that used to sit in front of it: once the garbleRecovery graph node retired, the wrapper's garble arm had no consumer and always short-circuited to bare shouldContinue. The conditional edge now consumes shouldContinue directly with a four-arm destination map.

armtargetmeaning
toolstoolsThe decision call emitted pending tool calls; dispatch them.
subagentsubagentThe decision call emitted a subagent delegation.
enrichenrichRouteChain[0]Route through the post-stream detector chain.
retrymainLlmNodeSelf-loop for provider cascade fallback — same node, new model.

Mid-stream body or prefix garble is no longer a graph arm. The garbleRecoveryFilter (priority 300) detects it at stage completion and dispatches recovery shaping through the StreamObserverRegistry — see Recovery dispatch is a filter collection below.

Recovery dispatch is a filter collection

Earlier revisions dispatched recovery through a recoveryDispatchPredicate graph node and a four-arm conditional edge into a recovery node. Both retired. Recovery dispatch is now four post-stage stream filters, registered on the StreamObserverRegistry and fired at stage completion in priority order:

filterpriorityfires on
refusalHintFilter100A model refusal that should be reshaped before the user sees it.
retryNarrationFilter200A provider retry or fallback model switch worth narrating.
garbleRecoveryFilter300Body or prefix garble detected in the streamed output.
standardRecoveryFilter400The cohort dispatch for zero-tool / missing-params / max-steps scenarios.

standardRecoveryFilter is the sole recovery-dispatch surface — the audit:recovery-dispatch-single-surface gate fails any build that re-introduces a graph-side recovery dispatch. The filters still consume the recoveryDispatch / RecoveryDispatchArm classifier the retired node used; only the dispatch mechanism moved (graph edge → stream filter), not the scenario taxonomy (zero-tool, missing-params, max-steps).

afterHallucination — post-detector two-arm

Source: hallucination. The router decides whether to retry the LLM or proceed to synthesis based on the synthesisState.exhausted latch and the turnFlags.hallucinationDetected and turnFlags.modelFallback flags.

armtargetmeaning
llmmainLlmNodeRetry the decision — hallucination detected or model fallback pending.
citationstage3EntryNodeProceed to synthesis; the entry point is synthesizer when active, citation otherwise.

The synthesisState.exhausted latch is set sticky by multiInvocationTracker after the same disjunctive predicate fires twice in a turn. The latch short-circuits both retry branches and routes directly to citation, terminating loops that would otherwise cycle indefinitely between mainLlmNode and enrich → hallucination → llm.

Auxiliary edges

One static edge sits outside the four chains:

fromtotriggerrole
subagentenrichment (or guard, or mainLlmNode)staticSubagent results enter the post-tool chain at the first available enrichment-stage node.

The recovery and garbleRecovery outgoing edges that earlier revisions listed here are gone — both graph nodes retired from the lattice. Their work happens in the recovery stream filters, which write through the channel set rather than routing a graph edge.

Adding or removing edges — three paths

Edge wiring is one degree more constrained than node registration. A node either wires or doesn't; an edge wires and picks one of the nine allowed (from-stage, to-stage) patterns. The three extension paths:

PathWhat you ownWhat you can doLattice gate
Config omissionThe config passed to buildDefaultAgentGraphCollapse chain segments by removing the nodes they linkEnforced — chains skip absent nodes silently
Plugin pathA HarnessPlugin with extraGraphNodes()Add edges around plugin-registered nodesEnforced — every edge must match an ALLOWED_EDGE_PATTERNS row
Custom builderA raw StateGraph you compose from scratchWire any edge whose (from-stage, to-stage) matches an allowed rowEnforced — but you control which rows fire

The lattice gate is non-negotiable on every path. The nine allowed and seven forbidden rows are a property of the substrate, not of the host's build choices.

Config omission — collapse a chain segment

The four static chains (preLlmChain, enrichRouteChain, postToolChain, terminalChain) all run through .filter(hasNode) before adjacency wiring. Drop a gated node and the chain composes around its absence; the edges that would have entered and exited it become a single edge from its predecessor to its successor.

const graph = buildDefaultAgentGraph({
  llmExecutor,
  toolExecutor,
  // intentDetector omitted — the pre-LLM chain collapses:
  // START → fileIntentDetector → costRouter → skillActivation → llm
  // (the edge START → intentDetector becomes START → fileIntentDetector;
  //  the edge intentDetector → fileIntentDetector is dropped.)
})

This is the most common form of edge "removal." You can't directly suppress an edge, but you can suppress the node it terminates at, and the chain wiring follows.

A conditional router's arm is a different case: the addConditionalEdges map is an inline object literal the audit script reads, so every listed arm is visible to static analysis. The builder keeps these maps tight — an arm whose destination node can be absent is populated conditionally rather than left dangling. The afterHallucination router adds its citation arm only when stage3EntryNode is registered; the map is { llm } otherwise. The retired garble arm of the old shouldContinueWithGarbleRoute wrapper was the cautionary case — it named garbleRecovery even in compiles that never registered the node, so the wrapper short-circuited to bare shouldContinue before the arm could fire. That wrapper and its dangling arm are both retired.

Plugin path — edges around a plugin-registered node

A plugin-registered node has the same edge story as a canonical node: the builder wires it through metadata-declared subscribes and writes, and the reactive engine schedules it when a subscribed channel advances. The chain-relative wiring happens automatically — the plugin doesn't need to add static edges for a node that's reading and writing channels other registered nodes consume.

When the plugin's node needs a static edge — typically because it should fire at a specific point in a chain rather than reactively — the plugin registers the node in extraGraphNodes() and the builder's chain-extension hook places it.

import type { HarnessPlugin } from "@pleach/core"

const metadata = {
  stageId: "post-turn",
  acceptsSeam: null,
  // Reactive: schedule whenever messages advances and write costAudit
  subscribes: ["messages"],
  writes: ["costAudit"],
}

export const myPlugin: HarnessPlugin = {
  name: "cost-audit-writer",
  version: "0.1.0",
  extraGraphNodes() {
    return [
      {
        name: "costAuditWriter",
        factory: (ctx) =>
          async (state) => ({ costAudit: await audit(state.messages) }),
        metadata,
      },
    ]
  },
}

Three rules hold for any edge involving a plugin node:

  • The edge's (from-stage, to-stage) must match ALLOWED_EDGE_PATTERNS. A plugin node declaring stageId: "tool-loop" cannot directly hand off to a synthesize node if the chain it inserts into doesn't already make that transition. audit:graph-stages refuses the build.
  • The plugin cannot add a synthesize → tool-loop edge — the M-04 risk surface. This sits in FORBIDDEN_EDGE_PATTERNS and no extension path can re-add it.
  • The plugin cannot wire its node into the singleton synthesize seam path. Only synthesizer may consume acceptsSeam: "synthesize". A plugin node that declares it fails the singleton invariant at compile time.

See Plugin contract for the full extraGraphNodes contract and the constraint set the substrate enforces against plugin registrations.

Custom builder — full edge control

A custom StateGraph is the only path where you compose every edge directly. The lattice gate still runs — the audit script can be pointed at any compiled graph — but you choose which ALLOWED_EDGE_PATTERNS rows your topology exercises.

const graph = new StateGraph(StateSchema)
  .addNode("anchor", anchorFn, { stageId: "anchor-plan", acceptsSeam: null })
  .addNode("llm", llmFn, { stageId: "tool-loop", acceptsSeam: "converse" })
  .addNode("synth", synthFn, { stageId: "synthesize", acceptsSeam: "synthesize" })
  .addEdge(START, "anchor")
  .addEdge("anchor", "llm")           // anchor-plan → tool-loop  ✓ row 2
  .addConditionalEdges("llm", route, {
    tools: "llm",                     // tool-loop → tool-loop    ✓ row 3
    done: "synth",                    // tool-loop → synthesize   ✓ row 4
  })
  .addEdge("synth", END)
  .compile()

The custom path is the only one where you can omit an entire allowed pattern. A topology that never makes the synthesize → synthesize retry transition simply never wires an edge with that (from, to) stage pair — the gate accepts the build because no edge violates the table.

The trade-off is the loss of every canonical-builder construction: the pre-LLM chain, the post-tool detector chain, the recovery stream filters, the singleton synthesize seam wiring. You're back to authoring the topology from the lattice up.

Removing an allowed pattern

Removing a pattern from ALLOWED_EDGE_PATTERNS is structural — it tightens the lattice and breaks every topology that exercised the row. The only path is upstream contribution to the substrate, with the same four-step ratchet as adding one:

  1. Remove the row from ALLOWED_EDGE_PATTERNS in topology.ts.
  2. Add the corresponding row to FORBIDDEN_EDGE_PATTERNS so the two tables stay symmetric.
  3. Update the four-stage diagram in Graph, Nodes, and this page.
  4. Append a Changed row to the changelog naming the row and the topologies it breaks.

The audit script will then fail every PR whose compiled graph exercises the removed row — the regression-detection surface for the lattice change.

Adding an edge to the upstream canonical builder

When the new edge belongs in the substrate itself, the contributor path applies. Two cases:

  • Edge within an existing allowed pattern. Add the .addEdge(...) or .addConditionalEdges(...) call; the audit's per-config byte-identity check picks up the count change and the PR documents the ratchet.
  • Edge requiring a new allowed pattern. Add the row to ALLOWED_EDGE_PATTERNS, remove the symmetric row from FORBIDDEN_EDGE_PATTERNS, update the four-stage diagram, and append a Changed row to the changelog. Every existing compile now passes a broader gate; document why the broader lattice is the right call.

See Audit gates for the gate invocation and the rest of the pre-merge gate set.

Where to go next

On this page