pleach
Architecture

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

  1. SessionRuntime owns per-session state, the compiled graph, and the lifecycle.
  2. CompiledGraph is the declarative topology — nodes wired to channels, edges constrained to a four-stage lattice.
  3. Channels are typed reactive state slots. A node fires when one of its subscribed channels advances; concurrent writes have well- defined reducer semantics.
  4. Seams are per-call-class provider entry points. Every LLM call goes through exactly one seam; the callClass literal is lint- restricted to the seam factories.
  5. Modelfamily matrix resolves (family × callClass) → model id + transport. Family is locked at session start; the matrix never silently widens.
  6. 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-plantool-loopsynthesizepost-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:

GateForbids
lint:harness-boundaryImports from seams/** into modelfamily/**; imports from app-layer into core
lint:callclass-literalscallClass: "..." literal outside the four seam factories + matrix module
lint:no-model-reassignment-in-fallbackReintroduction of tier1Model / tier2Model / SYNTHESIS_CANDIDATES
lint:stream-observer-registrationDirect seam-binding of detectors (must go through plugin)
audit:graph-stagesOut-of-lattice edges
audit:auditable-callAudit record version drift
audit:domain-string-purityDomain-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

On this page