Skip to content
pleach
Architecture

Edge catalog

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

The default agent graph composes from four static chains plus three conditional routers. The chains carry the deterministic skeleton — START to END — and the routers carry the branching: post-LLM tool dispatch, post-hallucination retry, and post-synthesis re-entry. 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).
6synthesizetool-loopBounded re-entry — a post-synthesis validator that finds unaddressed planned work sets needsMoreToolWork, and the tool-loop entry node consumes it. Fires at most once per turn (maxSynthesizePerTurn: 2, same messageId across the hop). Replaced the retired synthesize → synthesize retry self-loop.
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.
4synthesizesynthesizeThe retired retry self-loop — a second full synthesis pass over an already-written final body. Superseded by the bounded synthesize → tool-loop re-entry (allowed row 6), which redoes the work, not the answer.
5synthesizeanchor-planPost-synthesis re-planning.
6post-turntool-loopPost-turn re-entering an active turn.
7post-turnsynthesizePost-turn re-entering synthesis.

The lattice binds more than declared edges: the engine routes by subscription — a channel write schedules every node whose subscribes names that channel — so a subscription to a channel written by a later stage is a real runtime transition even with no addEdge call. The topology audit derives those writer→subscriber stage pairs from every node's declared subscribes/writes and holds them to this same table; a small reviewed exemption set covers passive projectors (nodes that refresh their own output channel mid-loop without touching control flow). If a node needs mid-loop scheduling that its stage forbids, split that arm into a node of the correct stage — skillRearm is the canonical example (the mid-loop half of skillActivation, registered as its own tool-loop node over the same closure).

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: false — continuation, 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

Three 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 three .addConditionalEdges(...) call sites (shouldContinue on the LLM node, afterHallucination on the detector node, and afterSynthesis on the synthesizer). 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 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.

afterSynthesis — post-synthesis two-arm

Source: synthesizer. The router that carries the bounded synthesize → tool-loop re-entry (allowed row 6). A post-synthesis validator that finds genuinely unaddressed planned work writes needsMoreToolWork: true; the router reads it and decides whether the turn is done or owes another pass through the loop.

armtargetmeaning
continuethe terminal-chain successor (post-turn)The synthesis stands; proceed to post-turn cleanup.
llmmainLlmNodeUnaddressed planned work — re-enter the tool loop once, same messageId.

The re-entry is bounded via the synthesis terminal arbiter, not the channel: needsMoreToolWork is a plain per-turn LWW channel that can be left stale-true by an earlier synthesis pass, so the arbiter holds a turn-scoped 1-shot re-entry guard and an arbiter-terminated gate that both short-circuit to continue regardless of the channel's value. maxSynthesizePerTurn: 2 caps the second synthesis the re-entry can produce. This router replaced the retired synthesize → synthesize retry self-loop — the re-entry redoes the work, where the old self-loop redrafted the answer.

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 takes the synthesize → tool-loop re-entry 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