Architecture
The substrate view of @pleach/core — six pieces, one stage lattice, and the boundary rules CI enforces. Each piece links to its deep-dive page.
Every LLM call in @pleach/core routes through one of four seams,
resolves against a family-locked model matrix, and lands as one
append-only AuditableCall row. Six pieces compose that path:
SessionRuntime, CompiledGraph, Channels, Seams, the
AuditableCall ledger, and the event log — the lattice constrains
where calls live, the ledger carries what they did.
These six are the structural members of the hedge — the rigid frame the agent's branches grow against. The four-stage lattice says where a call can go; the channels say what a call can read and write; the seams say which model a call routes to; the ledger says where its outcome lands. No piece is optional; no piece is replaced silently. CI gates fail the build when any of them drifts.
This page is the substrate-wide map. Each section names a piece, the role it plays, and the page that owns the deep-dive. The boundary rules at the bottom are unique to this page — they're the architectural invariants the rest of the design depends on.
This page mirrors the @pleach/core architecture deep-dive
in the package repo. If the two disagree, the repo wins — file
an issue against pleachhq/core.
TL;DR — six pieces, one substrate
SessionRuntimeowns per-session state, the compiled graph, and the lifecycle.CompiledGraphis the declarative topology — nodes wired to channels, edges constrained to a four-stage lattice.- Channels are typed reactive state slots. A node fires when one of its subscribed channels advances; concurrent writes have well- defined reducer semantics.
- Seams are per-call-class provider entry points. Every LLM call
goes through exactly one seam; the
callClassliteral is lint- restricted to the seam factories. - Modelfamily matrix resolves
(family × callClass)→ model id + transport. Family is locked at session start; the matrix never silently widens. - Audit ledger + event log carry the two write streams: every addressable decision lands in the ledger; every observable event lands in the log.
The execution-graph cluster
Three of the six pieces — CompiledGraph, Channels, and the
nodes the graph compiles — form a tighter cluster: the per-turn
execution substrate that lives inside the lattice. The full
triplet framing lives at
Concept clusters → Execution-graph;
the deep-dive pages are Graph, Nodes,
and Channels.
The piece that runs that cluster is the engine — the
engine/ scheduler (SuperstepRunner and its primitives). Where
CompiledGraph is the declarative topology, the engine is the
deterministic superstep executor that drives it: each superstep
fires the set of triggered nodes in parallel
(shouldTriggerNode resolves channel-version advances into node
activations), accumulates their writes, and commits the resulting
channel updates atomically at the step boundary. That Pregel-style
discipline — run, accumulate, commit — is what makes graph
scheduling deterministic and race-free between concurrent writers,
and it underwrites the byte-replay property in §9. The engine also
owns the cross-cutting execution plumbing the graph depends on:
retry-with-backoff (RetryPolicy), abort-signal composition
(AbortComposer), and graph↔session state mirroring
(GraphStateSynchronizer). It ships in the main @pleach/core
entry today, not a separate subpath.
1. Stage lattice
Every node in the compiled graph belongs to exactly one of four
stages — anchor-plan → tool-loop → synthesize → post-turn.
ALLOWED_EDGE_PATTERNS enumerates nine legal
(from-stage, to-stage) pairs: five cross-stage transitions (the
three forward edges, the tool-loop → post-turn recovery-dispatch
edge, and the post-turn → anchor-plan
next-turn rollover) and four intra-stage chains — anchor-plan,
tool-loop, post-turn, plus the messageId-guarded
synthesize → synthesize retry. Every other pair is forbidden.
The lattice is structural, not advisory — audit:graph-stages
fails CI on any out-of-lattice edge.
Why structural matters: once stages are structural, cost
allocation, observability, and time-travel become structural
too. A GROUP BY stage_id on the audit ledger returns per-stage
spend for any turn against a row shape that exists by construction
— every row carries a non-null stage_id because a node that
doesn't declare a stage fails CI before it ever fires.
See Graph for the substrate API, Nodes for the per-node metadata, and Turn lifecycle for the per-stage stream-event and ledger-payload tables.
2. Call classes
Every LLM call declares one of four classes — utility,
reasoning, converse, synthesize — which the seam factory
carries through to the matrix. The class drives three things: the
seam that carries the invocation, the per-turn allotment, and the
audit-row slice the call lands in. synthesize is structurally
capped at exactly one per turn (singleton seam + idempotent
counter) so the rendered string and the audited string are the
same string.
See Call classes for the four-class taxonomy,
the per-class roles, and the lint:callclass-literals gate that
restricts the literal.
3. Seams
A seam is the per-call-class provider entry point. Four factories
live in graph/seams/, one per call class. The seam carries the
locked callClass, threads it as a type parameter into matrix
resolution, and dispatches a sync per-chunk observer ladder on the
inbound stream — observers return continue, amend, emit, or
halt. onChunk is sync-only because async would race on replay.
See Seams for the four factories, the singleton synthesize seam, the observer verdict ladder, and how a node consumes a seam.
4. Family-locked routing
(ProviderFamily × CallClass) keys the model resolution matrix.
Family and transport lock at session start. When a provider call
fails, the cascade walks the next rung in the same family
(pickNextInFamily); when the column is exhausted, the runtime
emits family-exhausted and surfaces the state to the host. No
silent cross-family widening. The only carve-out is BYOK and
other non-matrix-resolvable models, which never participated in a
lock to begin with.
See Family-locked routing for the lock contract, the four properties it freezes (tokenizer, prompt-cache key, tool-call dialect, refusal pattern), and the family-strict cascade. The per-family per-callclass lookup lives at Model resolution matrix.
5. Audit ledger
Every seam writes one row per call. The four-field identity tuple
is non-null by construction. The row is append-only by interface
contract — no update, no delete; a row that needs to mutate is
a wire-format break that bumps auditRecordVersion instead. The
ledger is also what makes per-axis attribution work inside one
Anthropic Workspace or OpenAI Project: every row carries the
opaque tenantId the host wires to its billing axis, and the
rollup is GROUP BY tenant_id.
Three compliance plug-points (TamperEvidence, PIIRedactor,
GDPRSoftDelete) ride on top of the ledger. Each ships as a
no-op default in @pleach/core; production implementations land
in @pleach/compliance@0.1.0 (Phase A: scrubber cohort,
C8 redaction substrate, attestation Phase A). Hash-chain
verification lives in @pleach/core/eventLog
(verifyChainForChat, generateProof); @pleach/replay@0.1.0
composes them.
@pleach/core/attestation is the cryptographic signing layer on
top of the canonical row hash — ed25519 over RFC 8785-canonicalized
payload bytes, with a pluggable AttestationKeyStore (AWS KMS and
Vault Transit stubs at v1 contract; a file-backed test adapter
under the /testing subpath). A signed row makes any post-hoc
mutation detectable per-row; the hash chain makes deletion or
reordering detectable across rows. See Attestation.
See Audit ledger for the write interface and the plug-points, AuditableCall row for the row shape, and Hash chain for the tamper-evidence layer.
6. Event log
Distinct from the ledger. The event log is the broader stream of
observable events — messages, tool dispatches, interrupts,
subagent spawns, exports, plugin domain events. Three layers:
EventLogWriter (fire-and-forget enqueue), durableFlush
(waitUntil-routed flush with status-code-aware retries,
idempotent on client-generated id), and hydrateFromEvents (a
walk that rebuilds session state from a slice of events).
Every write passes through the Scrubber allowlist before
persistence. The audit:c8-event-type-allowlist-coverage CI gate
enforces full scrubber coverage — an event type that lands without
a scrubber fails the build (see Scrubbers).
See Event log for the full per-layer contract.
7. Sessions, storage, sync
SessionRuntime is constructed with three swappable backends —
storage adapter, checkpointer, and (optional) sync. Memory /
IndexedDB / Supabase implementations of each share one interface;
swapping is a one-line change.
The runtime also ships memoryCacheBackend as the default
cacheBackend — runtime-side memoization of prepared LLM inputs,
default-constructed in the SessionRuntime constructor since PA-2
C2 Phase 3 and threaded through the four seam factories. On by
day zero, no opt-in required. This is distinct from provider-side
prompt caching (see Cache and
Prompt caching).
SessionRuntime exposes its capabilities through named accessor
groups (runtime.sessions, runtime.events, runtime.spans,
runtime.tenant, runtime.graph.{recovery, heuristics, config}),
which is what makes the runtime LLM-agent friendly — an agent
reading runtime.<TAB> sees a grouped surface rather than a flat
40-method dump. runtime.spans in particular is the in-process
OTel facet, exposing inFlightCount, isShutdown, and snapshot
introspection over the four read-side span types — useful for
graceful-shutdown gates and live dashboards that need to know
whether the in-process exporter has drained (see
OTel observability). The cross-session
dependency graph — typed edges between sessions, artifact
provenance — is read-side observability sitting alongside the
span surface; see Lineage. See Facets
for the full inventory and the audit gates that enforce coverage.
Sync uses version vectors per session — compareVectors detects
concurrent writes at push time and the coordinator surfaces them
through sync.conflict rather than overwriting. See
Storage, Checkpointing,
and Sync for the per-axis deep-dives.
8. Plugins
HarnessPlugin is the consumer extension contract. A plugin can
register tier nodes into the lattice's enrichment slots, stream
observers, prompt contributors, safety contributions, and domain
event handlers — and crucially, cannot add an out-of-lattice edge,
bypass the singleton synthesize seam, reach across the seams/
boundary, or register an async observer dispatch.
Sibling SKUs (@pleach/compliance, @pleach/eval,
@pleach/gateway, @pleach/replay, @pleach/mcp,
@pleach/coding-agent, @pleach/sandbox, @pleach/langchain,
@pleach/base-tools, @pleach/observe, @pleach/recipes) land
as plugins on top of this contract. Every shipping sibling
publishes at 0.1.0 · FSL-1.1-Apache-2.0 in the first-wave cut;
@pleach/trust-pack alone remains a reserved npm name. See
Packages for the canonical per-SKU status table.
The plugin contract is stable so a host implementing the same
plug-points manually swaps into the sibling SKUs without
graph-shape changes.
See Plugin contract for the full
definePleachPlugin surface, every slot, and the breadcrumbs that
fire when a capability isn't contributed.
9. Determinism
Several pieces of the substrate are deliberately deterministic —
the fingerprint module, channel reducers, stream observers,
prompt composition, and the engine/ superstep scheduler
(SuperstepRunner). Together they buy the
byte-replay property: a recorded turn replayed against the same
package version + the same input produces a byte-identical
fingerprint stream. This is the property @pleach/eval@0.1.0
and @pleach/replay@0.1.0 are built around (both shipping
real bodies for the entry-point surfaces; remaining slices throw
typed NotImplemented sentinels per Packages).
See Determinism for the five contracts and Fingerprint for the pure-function fingerprint that platform-uniformly hashes prepared inputs.
10. Boundary rules
Lint gates enforce the architectural boundaries:
| Gate | Forbids |
|---|---|
lint:harness-boundary | Imports from seams/** into modelfamily/**; imports from app-layer into core |
lint:callclass-literals | callClass: "..." literal outside the four seam factories + matrix module |
lint:no-model-reassignment-in-fallback | Reintroduction of tier1Model / tier2Model / SYNTHESIS_CANDIDATES |
lint:stream-observer-registration | Direct seam-binding of detectors (must go through plugin) |
audit:graph-stages | Out-of-lattice edges |
audit:auditable-call | Audit record version drift |
audit:domain-string-purity | Domain-specific literals in packages/core/src/** — host vocabulary, vendor backend names, sandbox tool prefixes, identity discriminators, domain phrasing (five pattern families; ~50 patterns; baseline-gated :strict mode) |
These aren't bureaucracy; they're the structural guarantees the rest
of the design depends on. If callClass could appear anywhere, the
seam model leaks — a node could call synthesize outside the
singleton seam and the rendered string would diverge from the
audited string. If family could silently widen, the modelfamily lock
is advisory — a tool that worked on anthropic's tool-call dialect
would start failing mid-session when the cascade reached an
openai-shaped call. If observers could be async, replay determinism
is gone — @pleach/replay strict mode would throw
ReplayDivergenceError on the first race between two observer
promises resolving in a different order on replay than on record.
And if a host name, a vendor backend, or a sandbox tool prefix
could land in packages/core/src/**, the language-agnostic claim
leaks — the Go runtime would have to mirror string literals that
only mean something to one TS consumer.
audit:domain-string-purity makes plugins the only legitimate
channel for consumer-specific content.
The full inventory of CI- and PR-time gates — package shape, plugin contracts, tool coverage, event-log integrity, runtime soak ledgers, cross-repo reservations — lives at Audit gates.
Where to go next
SessionRuntime
Construct the runtime, create sessions, execute messages, abort turns.
Audit ledger
The harness_auditable_calls schema and the three compliance plug-points.
The AuditableCall row
Per-call row shape — turnId, toolName, subagentDepth, tokenUsage.
Plugin contract
The HarnessPlugin extension surface — what plugins can do and what they can't.
@pleach/transport-vertex
First-party Google Vertex AI transport for cloud-routed Pleach runtimes — workload-identity credentials, region pinning, and Vertex-specific cost-event emission.
Concept clusters
Seven cluster triplets organize the runtime — session/turn/row on top, then execution-graph, routing, audit-ledger, lifecycle, and persistence one click in.