# Edge catalog (/docs/graph-edge-catalog)



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](/docs/graph-node-catalog). Conceptual framing lives in [Graph](/docs/graph); the structural-pin rationale lives in [Graph § lattice gate](/docs/graph#the-lattice-gate).

<SourceMeta subpath="@pleach/core/graph" source="{ label: &#x22;src/graph/topology.ts&#x22;, href: &#x22;https://github.com/pleachhq/core/blob/main/src/graph/topology.ts&#x22; }" />

## The allowed-edge table [#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-stage    | to-stage      | role                                                                                                                                                       |
| - | ------------- | ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 1 | `anchor-plan` | `anchor-plan` | Intra-stage chain — sessionAnchor → contextProjection → planReconciler, etc.                                                                               |
| 2 | `anchor-plan` | `tool-loop`   | The pre-LLM chain enters the loop.                                                                                                                         |
| 3 | `tool-loop`   | `tool-loop`   | Intra-loop chain — tools → guard → enrichment → … → llm.                                                                                                   |
| 4 | `tool-loop`   | `synthesize`  | Forced or natural escalation to synthesis.                                                                                                                 |
| 5 | `tool-loop`   | `post-turn`   | Recovery dispatch — recovery shaping is post-turn work, so this edge sits in the allowed table (an earlier revision promoted it from the forbidden table). |
| 6 | `synthesize`  | `synthesize`  | The synthesis retry self-loop, guarded by `messageId` equality.                                                                                            |
| 7 | `synthesize`  | `post-turn`   | The terminal chain enters post-turn cleanup.                                                                                                               |
| 8 | `post-turn`   | `post-turn`   | The post-turn chain — citation → sessionMemoryWrite → costRollup → …                                                                                       |
| 9 | `post-turn`   | `anchor-plan` | Next-turn rollover. The only legal cross-turn edge.                                                                                                        |

## The forbidden-edge table [#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-stage    | to-stage      | failure mode                                              |
| - | ------------- | ------------- | --------------------------------------------------------- |
| 1 | `anchor-plan` | `synthesize`  | Skipping the tool loop.                                   |
| 2 | `anchor-plan` | `post-turn`   | Skipping the loop and synthesis.                          |
| 3 | `tool-loop`   | `anchor-plan` | Re-planning mid-turn.                                     |
| 4 | `synthesize`  | `tool-loop`   | Post-synthesis tool dispatch — the headline risk surface. |
| 5 | `synthesize`  | `anchor-plan` | Post-synthesis re-planning.                               |
| 6 | `post-turn`   | `tool-loop`   | Post-turn re-entering an active turn.                     |
| 7 | `post-turn`   | `synthesize`  | Post-turn re-entering synthesis.                          |

## The chain skeleton [#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 [#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 [#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 [#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 [#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 [#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 [#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.

| arm        | target                | meaning                                                         |
| ---------- | --------------------- | --------------------------------------------------------------- |
| `tools`    | `tools`               | The decision call emitted pending tool calls; dispatch them.    |
| `subagent` | `subagent`            | The decision call emitted a subagent delegation.                |
| `enrich`   | `enrichRouteChain[0]` | Route through the post-stream detector chain.                   |
| `retry`    | `mainLlmNode`         | Self-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](#recovery-dispatch-is-a-filter-collection) below.

### Recovery dispatch is a filter collection [#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:

| filter                   | priority | fires on                                                                  |
| ------------------------ | -------- | ------------------------------------------------------------------------- |
| `refusalHintFilter`      | 100      | A model refusal that should be reshaped before the user sees it.          |
| `retryNarrationFilter`   | 200      | A provider retry or fallback model switch worth narrating.                |
| `garbleRecoveryFilter`   | 300      | Body or prefix garble detected in the streamed output.                    |
| `standardRecoveryFilter` | 400      | The 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 [#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.

| arm        | target            | meaning                                                                                   |
| ---------- | ----------------- | ----------------------------------------------------------------------------------------- |
| `llm`      | `mainLlmNode`     | Retry the decision — hallucination detected or model fallback pending.                    |
| `citation` | `stage3EntryNode` | Proceed 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 [#auxiliary-edges]

One static edge sits outside the four chains:

| from       | to                                          | trigger | role                                                                                     |
| ---------- | ------------------------------------------- | ------- | ---------------------------------------------------------------------------------------- |
| `subagent` | `enrichment` (or `guard`, or `mainLlmNode`) | static  | Subagent 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 [#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:

| Path                | What you own                                    | What you can do                                                     | Lattice gate                                                    |
| ------------------- | ----------------------------------------------- | ------------------------------------------------------------------- | --------------------------------------------------------------- |
| **Config omission** | The `config` passed to `buildDefaultAgentGraph` | Collapse chain segments by removing the nodes they link             | Enforced — chains skip absent nodes silently                    |
| **Plugin path**     | A `HarnessPlugin` with `extraGraphNodes()`      | Add edges around plugin-registered nodes                            | Enforced — every edge must match an `ALLOWED_EDGE_PATTERNS` row |
| **Custom builder**  | A raw `StateGraph` you compose from scratch     | Wire any edge whose `(from-stage, to-stage)` matches an allowed row | Enforced — 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 [#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.

```typescript
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 [#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.

```typescript
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](/docs/plugin-contract) for the full `extraGraphNodes` contract and the constraint set the substrate enforces against plugin registrations.

### Custom builder — full edge control [#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.

```typescript
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-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](/docs/graph), [Nodes](/docs/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 [#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](/docs/audit-gates) for the gate invocation and the rest of the pre-merge gate set.

## Where to go next [#where-to-go-next]

<Cards>
  <Card title="Node catalog" href="/docs/graph-node-catalog" description="The 44-name node registry the audit script accepts, grouped by lattice stage." />

  <Card title="Graph" href="/docs/graph" description="How nodes and edges compose into the CompiledGraph the runtime drives." />

  <Card title="Nodes" href="/docs/nodes" description="The node-shape contract — metadata, seam consumption, determinism rule." />

  <Card title="Audit gates" href="/docs/audit-gates" description="The audit:graph-stages script and the rest of the pre-merge gate set." />
</Cards>
