pleach
Architecture

Seams

The per-call-class provider entry point — four seam factories, the singleton synthesize seam invariant, and the sync stream-observer verdict ladder.

Every LLM call in @pleach/core routes through exactly one of four seams. The seam factory carries the callClass literal that downstream code threads as a type parameter, so a node consuming a seam never re-introduces the literal at the call site. The synthesize seam is a per-runtime singleton, which means the rendered string and the audited string are the same string by construction.

A seam is the routing cluster's entry point — it carries the CallClass literal into resolution and dispatches the result against the session's family lock. See Family-lock → the routing cluster for the cluster framing.

Subpath@pleach/coreSourcesrc/graph/seams/

The four seams

SeamFileRole
synthesizeSeamgraph/seams/synthesizeSeam.tsUser-facing answer; exactly one per turn
reasoningSeamgraph/seams/reasoningSeam.tsReserved/planned — the holder is initialized per runtime but no node in the default runtime routes through it today; the contract reserves reasoning for future answer-sufficiency judging, plan compose/revise, and tool-result interpretation
utilitySeamgraph/seams/utilitySeam.tsInternal classification (intent, planner, cache routing)
converseSeamgraph/seams/converseSeam.tsShort user-facing prose (refusal hints, retry narration)

A seam IS the per-call-class factory that builds a ProviderSeam<C>. The factory binds the locked callClass, the resolved transport, and the per-runtime stream-observer registry into one entry point; nodes invoke that entry point without seeing the underlying provider plumbing. The type parameter C carries the call class through to the return shape — a synthesizeSeam call returns a ProviderSeamResult<"synthesize">, distinct at compile time from a utilitySeam result.

A seam is NOT a provider adapter — that's the transport layer covered in Providers. A seam is also NOT model resolution — the (family × callClass) matrix lives in Family lock. The seam sits above both: it holds the call class, calls into resolution, and dispatches the observer ladder around the resulting stream.

Coming from LangGraph?

LangGraph has no seam concept. A LangGraph node calls a model directly (await model.invoke(messages)) and the LLM is just another resource. Pleach factors that call out so the call class (synthesize, reasoning, utility, converse) is a type-level invariant — a node can't accidentally fire a synthesize call during the tool-loop stage because the decision node there consumes a ProviderSeam<"utility"> and the synthesize seam is a different type.

The translation: where LangGraph's nodes know about the model, pleach's nodes know about the kind of model call they need. The seam carries the binding. The benefit: one runtime can route the four call classes to four different models (utility → cheap; synthesize → flagship) without the node knowing.

The singleton synthesize seam

The user sees the synthesis. If the runtime fires two synthesize calls and renders one of them, the audit ledger records one string and the UI shows a different one. Capping at exactly one synthesize per turn means the audited string and the rendered string are the same string.

The cap is enforced by two pieces. SynthesizeSeamHolder is a per- runtime singleton, and the synthesizer node — plus the recovery path that stands in for it when a provider fails — is the only consumer that asks the holder for the seam. Nothing else reaches it. The tool-loop's continuation call, the "call another tool or finish?" decision, is utility-class and consumes a separate utility seam, so it physically can't bump the synthesize counter. That separation is what makes the cap structural rather than policed: "exactly one synthesize per turn" holds because exactly one node class reaches the synthesize seam, by construction. TurnSynthesizeCounter is idempotent on messageId; a second synthesize call for the same message is a no-op against the counter and never reaches the provider.

npm run test:graphnoderef-wire-check is the wire-check that catches code reaching around the holder. A node that constructs its own synthesizeSeam instead of consuming the shared one fails the check before CI green. The check walks the compiled graph, collects every node whose acceptsSeam is "synthesize", and asserts they share one underlying ProviderSeam<"synthesize"> identity. See Architecture § Call classes for the broader invariant the singleton pins down.

The callClass lint

The callClass literal — the strings "utility", "reasoning", "converse", "synthesize" — appears only inside the four seam factories and the matrix module. Outside those files, code resolves a model through AgentAdapter.resolveModel<C>(), and the locked call class threads through as a type parameter without a literal.

import type { CallClass } from "@pleach/core/modelfamily"

// inside a node — no "synthesize" literal at the call site
async function synthesizer<C extends CallClass>(
  ctx: NodeContext<C>,
): Promise<Partial<State>> {
  const model = await ctx.adapter.resolveModel<C>()
  return { /* ... */ }
}

npm run lint:callclass-literals is the CI gate. A literal that escapes the seam factories fails the lint. See Architecture § Call classes for the rationale and Call classes for the four-class taxonomy.

Stream-observer ladder

Each seam dispatches a per-chunk observer ladder on the inbound stream. For every chunk, every registered observer returns one verdict:

VerdictEffect
continueNo-op; pass the chunk through
amendReplace the chunk content 1:1 — strict, no multiplex
emitForward the chunk verbatim AND emit a named-channel envelope
stopStop the stream; the downstream consumer reads the stop sentinel
bufferHold the chunk; the seam suppresses the yield and accumulates state
releaseFlush previously-buffered chunks in order; optionally strip N chars from the seam's accumulated mirror

Observers register through HarnessPlugin.contributeStreamObservers. The seam queries the per-runtime StreamObserverRegistry at invocation time and invokes each observer's onChunk(chunk, ctx) in registration order. See Plugin contract for the registration shape and Stream events for how verdicts surface to the consumer.

onChunk is sync only — no Promise<ObserverChunkVerdict> overload. Two async observers resolving in a different order on replay than on record would race; the runtime would then schedule a different next superstep and the diff harness would flag the divergence. Keeping the verdict sync is the armor that holds the replay-determinism property.

amend being 1:1 is the matching armor on the chunk side. An observer that returned multiple chunks for one input would break byte-replay: the chunk count itself is part of the recorded stream. Plugins that need fan-out emit named-channel envelopes via the emit verdict — the main stream stays untouched, the side channel carries the structured side-effect.

The buffer / release pair is the additive extension for observers that need to hold chunks until a boundary settles — e.g. an early-coherence observer that buffers until it can prove the prefix is clean, then releases the held chunks in order. release carries an optional stripAccumulated count so the seam trims its own accumulated-content mirror; the outer consumer loops subscribe to that envelope to keep their mirrors in sync.

Plugin observers are canonical

The chunk-time detection surface lives in plugin observers, not in the seam itself. Hosts register factories via HarnessPlugin.contributeStreamObservers; the per-runtime StreamObserverRegistry collects them at construction, and the seam queries the registry at invocation time and dispatches every chunk through the verdict ladder above.

A reference host ships observers as a worked example — force- synthesis echo, same-tool repetition, markup sanitization, halluci- nated tool id, plus emit-only detectors for phrase-loop, fim- token, substring-repetition, prefix-garble, early-coherence, and body-garble. @pleach/core provides the contract and the registry; every host ships its own observers.

The historical helper-barrel dispatchers in runDecisionBody (dispatchHallucinationDetector, dispatchPhraseLoopDetector, dispatchFimTokenDetector, dispatchSubstringRepetition, dispatchPrefixGarbleDetector, dispatchEarlyCoherenceDetector, dispatchDsmlDetector) retired in the Track 3 cutover. Plugin observers carry the same predicates and route their verdicts onto named channels; the flag-router consumer maps each channel back into the same graph-state fields the dispatchers wrote.

The body-garble dispatcher break-path is the deliberate exception and stays in createLlmDecisionNode.ts. The predicate is time- dependent — it needs mid-stream chunks to settle into legitimate technical notation, CJK, and foreign-language compound names before it can distinguish garble from valid content. A stop-at-chunk verdict can't carry that asymmetry; an observer that fired at the wrong moment would false-positive on every long technical token. The kept dispatcher is the regression-detection armor against that class of false positive, and the asymmetric close is the documented shape — chunk-time observers for everything time-invariant, the inline dispatcher for the one time-dependent case.

How a node consumes a seam

A node declares which seam it consumes via acceptsSeam in its metadata. The field is CallClass | null — a call-class literal for nodes that reserve a seam, null for pure state transforms and deterministic anchor builders that take no LLM call. The reservation is the seam-attachment contract; future LLM growth attaches without re-typing the node signature. The substrate threads the bound seam into the node's signature, and the node never imports a seam factory directly.

const toolLoopMetadata = {
  stageId: "tool-loop",
  acceptsSeam: "reasoning",
  subscribes: ["messages"],
  writes: ["messages"],
}

async function toolLoop(state, ctx) {
  // ctx.seam is the bound reasoningSeam — no factory import
  const result = await ctx.seam.invoke({ messages: state.messages })
  return { messages: [...state.messages, result.message] }
}

graph.addNode("toolLoop", toolLoop, toolLoopMetadata)

npm run lint:harness-boundary is the CI gate. A node that imports from src/graph/seams/ fails the lint — the bound seam on the node context is the only path. See Nodes for the full metadata shape and Graph for how the substrate wires the binding.

Determinism contract

Seams compose with the rest of the determinism story. The observer dispatch is sync, so the verdict ladder produces the same output shape on replay as on record. The call returns a stable ProviderSeamResult<C> whose fields are typed by call class. The provider response feeds channel reducers that are commutative and associative, so concurrent writes from a fan-out resolve the same way on every replay.

The byte-replay property — that a recorded turn replayed against the same package version + the same input produces a byte-identical fingerprint stream — depends on every link in this chain. See Determinism for the full five-contract set.

Where to go next

On this page