Reference Architecture · Build-vs-Depend Contract · Portable across stacks

The operator is not a person.
The agent is.

When an agent drives the application instead of a human, the UI becomes a passive surface and every action — human tap or agent tool-call — must be indistinguishable downstream. This reference teaches the four load-bearing ideas that make that safe and replayable, then shows the bright line: you build two kinds of tools — UI tools and domain tools — and depend on the runtime for the loop, the bus, state derivation, replay, concurrency, and enforcement.

1 idea
everything is a tool, even the UI
2 tool kinds
UI tools + domain tools — your per-feature work
1 signed bus
every action, human or agent, on one replayable stream
0 loop code
the agent loop is a dependency, not a place for logic

Per feature you write two kinds of tool. Per app you add a thin wiring layer, written once — your domain types, your fold arms, the view projection, the boundary, and the composition root. You depend on the runtime for the spine itself — the loop, the lifecycle, the provider, the command bus, the fold, replay, concurrency, and the enforcement gate — and build only the tools plus that thin wiring, never the spine.

01

What this is

This is a project-agnostic reference for the Agent-Driven Architecture: a way to build software in which the primary operator is an autonomous agent, not a human, and in which that fact is made safe and testable by routing every action — human or agent — through a single signed, replayable channel. The shape is the same whether you build a support console, a copilot, or an ambient assistant. Read it once; drop it into any stack.

What kind of document this is

This is a defined, opinionated architecture — not a survey of options — in the lineage of Clean Architecture, ports-and-adapters (hexagonal), and unidirectional MVI. It fixes layers (7.4), boundaries (7.6), a nomenclature (17.6), and a set of invariants enforced as build gates (G1–G14). Its goal is the one good architectures share: make the common case correct by construction, so a coder — or an agent — makes the fewest mistakes possible. It is prescriptive and batteries-included, with a single, contract-bounded door (8.5) for the cases that genuinely need more.

Concretely, it is delivered as a library on top of a generic agent-loop runtime (the shape the Vercel AI SDK popularised): the runtime gives you the loop and tool-calling; this gives you the spine — the signed bus, the fold, replay, the mailbox, the gate — and the structure, so you build agent-driven apps in tools, not plumbing. Its one move no agentic-UI framework copies: a human action and an agent action are the same signed command on one replayable stream, so the whole session replays and a human override is free.

1.1The thesis

Conventional applications assume a human sits at the controls and the software reacts. Invert that. Treat the agent as the primary operator — the thing that observes, decides, and acts — and treat the human as a rare, optional participant who can do exactly what the agent can do, no more and no less. The danger of that inversion is obvious: an autonomous operator that can mutate state through arbitrary code paths is impossible to reason about, audit, or reproduce. The discipline that makes it tractable is equally simple. Every action, regardless of who originates it, becomes one Command on one observable stream, and the application's entire visible state is a pure fold over that stream. Nothing happens off the record. Any session can be reconstructed from its commands alone.

1.2The four invariants

Four numbered invariants carry the rest of this document. Every later section refers back to them by number.

  • (I1) Everything is a tool — even the UI. The agent reaches the world only through tool calls. There is no privileged "the agent does X" path that bypasses the tool boundary, and presentation actions are tools on equal footing with business actions. See Everything is a tool.
  • (I2) The agent reasons, then routes a tool call by intent. A step is: read the prompt and context, decide, fire one tool. That tool targets either the presentation surface (a UI tool) or the domain/business logic (a domain tool), chosen by what the agent is trying to accomplish — not by two different mechanisms.
  • (I3) You write only two kinds of tools. UI tools and domain tools. Everything structural — the agent loop, the command bus, state derivation, replay, concurrency and barge-in, and the enforcement gate — is supplied by the runtime ("the client"), a generic agent-runtime dependency you consume, not code you hand-write per project. See Two tools you build and Runtime vs. you.
  • (I4) Every action is one signed command, and state is a pure fold. Each action is a single Command stamped with its Actor on one append-only, observable stream; state derives from that stream by a pure reducer. Therefore any session replays deterministically. See The signed command bus and Replay and recovery.
THE SHAPE

I1 makes the agent's reach uniform. I2 makes its intent legible. I3 tells you what to build versus depend on. I4 makes the whole thing reproducible. Lose any one and the others degrade: an un-signed action breaks replay; a non-tool side door breaks enforcement; a deciding UI breaks the single source of truth.

1.3Build versus depend — the headline split

The single most useful thing to internalize in the first two minutes is the line between what you author and what you inherit. You author two leaf concerns: the tools that touch your screen and the tools that touch your business logic. A generic agent-loop runtime owns the loop; this architecture, shipped as a library on top of it, owns the spine that connects your tools.

You build (two kinds of tools)

UI tools — declarations that drive the presentation surface (show a view, surface a message, toggle an auxiliary panel). Domain tools — declarations that advance business logic (set a value, attach evidence, request an irreversible step). Each tool is pure: it reads a read-only context and returns a payload. It mutates nothing.

You inherit (the loop + the spine)

A generic agent-loop runtime (the shape the Vercel AI SDK popularised) gives you the loop, step lifecycle, and provider abstraction. This architecture, as a library on top, gives you the spine: the signed command bus, the fold and state derivation, replay, the mailbox, and the enforcement gate. Both are dependencies; zero of their source lives in your repository as code you wrote — or, on a bare stack where no runtime provides it, the spine tier is stood up once to the pattern (8.4), never re-authored per feature. The honest headline: two tools + thin wiring + a spine you inherit or stand up once.

Read that split as the contract: if you find yourself hand-writing the loop, the bus plumbing, the fold driver, the replay machinery, or the barge-in mailbox, you have crossed into the runtime’s territory and the architecture is fighting you. The rest of this reference is mostly an unpacking of that one line.

One honest caveat on that line: the spine is the architectural core, not the entire app. Once-per-app seams sit deliberately outside it and are yours to supply — authorization (who may act, as distinct from the Actor stamp that records who did; see 14.3), persistence (where the timeline physically lives and how it is bounded; see 14.6), and configuration/secrets. Read the spine as load-bearing, not as exhaustive. (On a bare stack the spine tier may itself be supplied once behind its contracts — 8.4–8.5 — but it is inherited by default, not per-app work.)

1.4The running example, and how to read this

Where an abstract pattern needs a concrete anchor, this document uses a single illustrative scenario — a support-ticket triage console: an agent reads an incoming ticket stream, drafts replies, sets priority, and requests escalation; a rare human operator (the host) can take the same actions through the same commands; and the beneficiary — the customer — receives a resolution summary without ever touching the interface. This example is a teaching device only. It is not a product, and nothing in the architecture depends on it; substitute your own domain freely.

READING PATHS

Core (01–09) teaches the mental model and the build-vs-depend boundary — enough to design an agent-driven application end to end. Advanced (10–15) goes deeper on capability-as-a-tool, tiered cognition, concurrency and barge-in, the end-to-end journey, replay, and the enforcement gate. Read the core in order; reach for the advanced sections as the problems they name come up.

02

The inversion

One decision shapes everything downstream: when an agent — not a human — is the primary operator, the user interface stops being a control panel and becomes a passive surface. It renders state and dispatches commands. It never decides. Get this one inversion right and the signed command, the pure fold, and the replay guarantee follow almost mechanically. Get it wrong and no amount of structure downstream will save you.

2.1Who is at the controls

In a conventional application the human is the operator and the software is the surface: a person reads the screen, forms intent, and clicks; the application responds. The Agent-Driven Architecture swaps the subject. The agent is now the thing that observes the world, forms intent, and acts. The screen is no longer where decisions enter the system — it is one of the places where the consequences of decisions are shown, and one of the places from which a command can be dispatched. That is a demotion, and it is deliberate.

The practical consequence is a rule with no exceptions: the surface decides nothing. A presentation handler emits exactly one action and stops — no branching, no conditional logic, no business rules embedded in a click handler. The decision of what should happen lives in the reducer (see The stateless reducer agent), reached through the command bus. A surface that contains an if over domain state has quietly become a second operator, and now you have two sources of truth competing — exactly the condition this architecture exists to prevent.

THE LOAD-BEARING RULE

The UI renders state and dispatches commands; it never decides. Everything else in this reference is downstream of taking that sentence literally — including the guarantee that a recorded session replays identically, which only holds if no decision was ever made inside the view layer.

2.2Three roles, one action vocabulary

The inversion produces a clean three-party split. The roles are generic; name them in any domain.

flowchart TB
  A["Agent operator<br/>observes, reasons, acts<br/>the primary driver"]:::agent
  H["Human host<br/>optional, rare<br/>same actions, same commands"]:::user
  B["Beneficiary<br/>receives the artifact<br/>never touches the UI"]:::sink
  A --> ART(["The generated artifact"]):::state
  H -. "can step in" .-> A
  ART --> B
  classDef agent fill:#1a1012,stroke:#f23a48,stroke-width:1.5px,color:#ffd7da;
  classDef state fill:#14161d,stroke:#f23a48,color:#fff;
  classDef user  fill:#11151c,stroke:#7c8aa0,stroke-width:1.5px,color:#cdd5e0;
  classDef sink  fill:#11131a,stroke:#3a3f4a,color:#c7ccd6;
    
Fig 2.1   Three roles. The agent operator drives; the human host is an optional fallback who acts through the same commands; the beneficiary receives the output and never touches the interface.

The agent operator

The primary driver. It reads the inbound stream, reasons over a prompt, and acts — only ever through tool calls. In the illustrative triage console it reads incoming tickets, drafts replies, sets priority, and requests escalation. It is the default author of nearly every command.

The human host

Optional and rare. A person who can step in and take exactly the actions the agent can take, through the same commands — to correct, override, or handle a case policy reserves for a human. The host is not a privileged operator with a richer API; it is a second author on one shared channel.

The third role, the beneficiary, sits outside the loop entirely. In the example it is the customer who receives a resolution summary. The beneficiary consumes the generated artifact and never operates the surface. Keeping this role distinct matters: it is a reminder that the interface exists for the operators, while the artifact — the folded, replayable result of the session — exists for the recipient.

2.3Why human and agent must be indistinguishable

The defining constraint of the three-role split is what it demands of everything downstream of an action: a human tap and an agent tool-call must be indistinguishable in mechanism. They cannot flow through different code paths, hit different validation, or land in different stores. The only permitted difference is a label — who acted — carried as data on an otherwise identical command. Indistinguishable here means in mechanism, not in permission: who may act — authorization — is a separate seam the boundary enforces before the fold (14.3), and the host and the agent legitimately often hold different capability sets. Same channel, possibly different rights.

The reason is straightforward. If the host has a private path into state, then (a) the system has two ways to mutate the same thing and they will drift; (b) a recorded session no longer replays, because human actions and agent actions reconstruct differently; and (c) the enforcement gate now has two boundaries to police instead of one. Collapsing both authors onto a single channel — distinguished only by an Actor stamp — is precisely what lets one reducer, one bus, and one replay path serve both. This requirement is the direct cause of the signed Command introduced in section 04; the inversion forces the signature.

THE CONSEQUENCE

Because the host and the agent must act identically, every action needs a name for its author and nothing more. That single requirement — "same channel, different signature" — is the seed of the entire command-bus design. The inversion does not merely permit the signed command; it makes it inevitable.

2.4This is not a platform pattern

Nothing in the inversion is tied to a device, a form factor, or a rendering technology. The same shape appears wherever an autonomous operator drives an application that a human could also drive:

  • a server-side autonomous agent processing an inbound queue, where the "surface" is an admin console a human rarely opens;
  • a desktop copilot that edits a document the user can also edit by hand;
  • an ambient assistant acting on events in the background, with an occasional human override;
  • a browser-automation agent driving pages a person could click through manually.

In every case the topology is identical: a primary agent operator, an optional human host on the same command channel, and a beneficiary downstream. The lessons here transfer without translation because the architecture is about who decides and how actions are recorded — not about pixels, sensors, or any particular runtime. Treat the form factor as an implementation detail of the surface, and the surface as the least important part of the system.

03

Everything is a tool

This section makes invariants I1 and I2 concrete. The agent's entire reach into the world is the set of tool calls available to it — including the ones that drive the screen. A human tap and an agent tool-call are not analogous; they are the same signed Command, differing only by their Actor. Once you accept that, "everything is a tool, even the UI" stops being a slogan and becomes the data model.

3.1The agent reaches the world only through tools

There is exactly one boundary between the agent and everything it can affect: the tool contract. The agent does not write to a database, mutate a view, or call a service directly. It cannot. Its only verbs are the tools you expose. Concretely, a tool is a small declaration —

the tool contractpseudocode
TOOL(
  name:   "set_priority",
  input:  { level: Priority },
  output: { accepted: Bool },
  run(input, ctx) -> output         // ctx is READ-ONLY; run mutates nothing
)

The critical discipline: a tool is pure. Its run reads the read-only context and returns a payload. It does not write to that context, does not reach into a store, does not perform a side effect. Where do its results go? Out — through the step lifecycle — to the reducer boundary, which folds them into state and performs any effects (see section 06). This is what makes "the agent's only reach is tools" enforceable rather than aspirational: there is no imperative "the agent does X" path that bypasses the boundary, because the tools themselves have no power to mutate. They can only propose a result; the boundary decides what it means.

NO SIDE DOOR

If any code lets the agent change state without going through a tool, I1 is broken and the rest collapses: that change is unsigned, unfolded, and unreplayable. The purity of tools is the lock; the boundary is the only key.

3.2UI actions are tools too

The move that distinguishes this architecture from "an agent with some tools" is that presentation actions are tools on equal footing with business actions. Showing a view, surfacing a message, toggling an auxiliary panel — each is a tool the agent can call. And because a human host dispatches through the same channel, a person tapping a control and the agent calling a tool resolve to the identical Command. They are not two events that happen to look alike. They are one command type, carrying one Actor field that says Human or Agent, flowing onto one bus.

flowchart TB
  TAP["Human tap<br/>on a surface control"]:::user
  CALL["Agent tool-call<br/>from a reasoning step"]:::agent
  TAP --> CMD
  CALL --> CMD
  CMD["one signed Command<br/>Actor = Human | Agent"]:::bus
  CMD --> BUS["the command bus<br/>append-only, observable, replayable"]:::bus
  BUS --> S1["lifecycle<br/>(start / end of work)"]:::sink
  BUS --> S2["surface text<br/>(messages shown)"]:::sink
  BUS --> S3["view toggles<br/>(panels, modes)"]:::sink
  BUS --> S4["prompts<br/>(operator guidance)"]:::sink
  classDef user  fill:#11151c,stroke:#7c8aa0,stroke-width:1.5px,color:#cdd5e0;
  classDef agent fill:#1a1012,stroke:#f23a48,stroke-width:1.5px,color:#ffd7da;
  classDef bus   fill:#0f1116,stroke:#f23a48,stroke-width:2px,color:#fff;
  classDef sink  fill:#11131a,stroke:#3a3f4a,color:#c7ccd6;
    
Fig 3.1   A human tap and an agent tool-call converge into one signed Command on one bus. Both authors are peers; the only difference is the Actor stamp. The bus fans out to passive sinks that render the consequence — those arrows are state-driven rendering, not necessarily a direct Command dispatch (a UI tool usually folds into surface state without minting one; 6.8).

Notice what the sinks are not: they are not decision points. Each consumes the folded state and renders it — surface text appears, a panel toggles, a prompt updates. The command already encoded the intent; the sinks merely reflect the new state. This is the inversion of section 02 expressed as wiring.

3.3Routing by intent

Invariant I2 describes what a single step actually looks like. The agent reasons over a prompt and the read-only context, decides what it is trying to accomplish, and fires one tool call. That call routes — by the agent's intent — to one of exactly two destinations:

A UI tool → the presentation surface

When the intent is to communicate or display: show a panel, surface a draft reply for review, toggle a view. The tool's result folds into the state the surface renders. Nothing about business truth changes.

A domain tool → the business logic

When the intent is to advance the work: set a priority, attach evidence to a ticket, request an escalation. The tool's result folds into domain state and may emit an effect the boundary performs.

flowchart LR
  P["prompt + context<br/>+ reasoning"]:::agent --> D{"intent?"}:::core
  D -- "communicate / display" --> U["UI-surface tool"]:::tool
  D -- "advance the work" --> B["domain / business-logic tool"]:::tool
  U --> R[["fold → state → replay"]]:::sdk
  B --> R
  R -. "loop, bus, fold, replay<br/>provided by the runtime" .- R
  classDef agent fill:#1a1012,stroke:#f23a48,stroke-width:1.5px,color:#ffd7da;
  classDef core  fill:#0f1116,stroke:#f23a48,stroke-width:1.5px,color:#fff;
  classDef tool  fill:#13161d,stroke:#caa,color:#e7e8ec;
  classDef sdk   fill:#11151c,stroke:#7c8aa0,color:#cdd5e0;
    
Fig 3.2   One step, routed by intent. Reasoning fires a single tool call to either a UI-surface tool or a domain tool. Everything after the tool — the loop that drove the step, the bus, the fold into state, and replay — is provided by the runtime, not written per project.

This fork is the entire authoring surface of the application. You will spend almost all of your design time deciding which tools exist and which destination each serves — a point developed in Two tools you build. The runtime supplies the machinery around the fork: the loop that ran the step, the bus the command landed on, the fold that turned results into state, and the replay that lets you reconstruct it all.

3.4Why this is the data model, not a slogan

Unifying a tap and a tool-call as one signed command is not a stylistic preference — it is what makes the system's strongest properties cheap. Because there is one command type per action and one bus:

  • One logic path serves both authors. The reducer that folds a command does not branch on whether a human or the agent produced it; it reads the Actor only where policy genuinely differs (for example, gating an irreversible step). The same code runs either way.
  • The whole session is replayable from its recorded timeline. Since every decision is a signed command on an append-only stream and state is a pure fold, re-folding the recorded timeline reconstructs the session exactly — agent steps and human interventions alike. That timeline is the signed commands plus the captured tool-results and any off-bus input the fold consumed (see what rides the bus); replay re-folds those records, it does not re-run perception or the model. See Replay and recovery.
  • Enforcement has one boundary to police. Invariants like "the actor is stamped at the boundary" and "tools read context only" are checkable precisely because every action funnels through the same place (see Enforced constraints).
THE PAYOFF

"Everything is a tool, even the UI" earns its keep because it makes one stream the truth. One stream means one logic path, one replay, one enforcement boundary. The slogan is just the shape of the data model said out loud.

04

The two tools you build

Strip away the runtime and the surface area you actually author collapses to one shape repeated twice. An engineer building on this architecture writes tools — and only two kinds of them. A UI tool changes what is shown; a domain tool changes what is true. The loop, the lifecycle, and the provider are the runtime's job, not yours. Tools are the bulk of what you author per feature; a thin once-per-app wiring layer — your domain types, fold arms, the view projection, the boundary, and the composition root — is the rest, wired onto the runtime’s spine. Master the tool contract and you have mastered the part you touch every day.

4.1One contract, two intents

A tool is a named, typed, side-effect-free unit of work the agent can invoke. The contract is uniform regardless of what the tool ultimately affects: a name the model selects by, a structured input schema the model fills, an output schema describing the payload it returns, and a run body that reads the read-only context and produces that payload. The input schema is the structured/JSON contract the model decodes into — there is no second, hidden parameter channel.

the tool contractpseudocode
TOOL<In, Out> {
  name:   Text                         // how the model selects this tool
  input:  Schema<In>                    // the structured contract the model fills
  output: Schema<Out>                   // the payload shape the tool returns
  run(input: In, ctx: ReadOnlyContext) -> Out   // pure: read ctx, return Out
}

The single most important property is in the signature, not the prose: run takes the context by read and returns a value. It does not receive a mutable store. It does not call back into application state. It cannot reach the command bus. A tool is a function from (input, context) to payload — nothing more. Whatever the runtime does with that payload is decided downstream, at the boundary, never inside the tool.

THE RULE

A tool reads the read-only context, returns a payload, and mutates nothing. The two kinds — UI and domain — differ only in what their payload means to the reducer downstream, never in how they are written. Both fold identically.

4.2The UI tool: targeting the surface

A UI tool's intent is presentational. Its payload describes a change to what the operator sees — focus a panel, surface a draft for review, dismiss a hint. It does not assert anything new about the world; it asks the surface to reveal or rearrange what is already known. The reducer reads the payload and emits a UI-targeting Command onto the stream. The tool itself still only returns — it never touches a renderer.

illustrative — a UI tool (support-triage console)pseudocode
// Intent: bring a specific ticket into focus on the operator surface.
UI_TOOL focusTicket {
  input  = { ticketId: Text, highlight: Bool }
  output = { focused: Text, highlightApplied: Bool }

  run(input, ctx) -> Out {
    let known = ctx.staged.ticket(input.ticketId)   // READ-only lookup
    return {
      focused:          known.id,
      highlightApplied: input.highlight && known.isOpen
    }
    // no renderer call, no dispatch, no mutation — just the payload
  }
}

Note the payload is descriptive, not a bare confirmation. It names which ticket was focused and whether the highlight actually applied. That payload is enough for the reducer to derive the next UI state by pure fold — and enough for a replay to reconstruct exactly what the surface showed.

4.3The domain tool: targeting business state

A domain tool's intent is substantive. Its payload asserts a new fact the system should fold into truth — a priority set, evidence attached, an escalation requested. The reducer folds the payload into immutable state and may emit an effect descriptor for any real-world consequence. Crucially, the payload is an acknowledgement-with-payload: it carries the decided values back, never a hollow { accepted: true }. A bare boolean throws away exactly the information the fold needs.

illustrative — a domain tool (support-triage console)pseudocode
// Intent: set a ticket's priority based on the reasoner's judgment.
DOMAIN_TOOL setPriority {
  input  = { ticketId: Text, level: Priority, rationale: Text }
  output = { ticketId: Text, level: Priority, rationale: Text, supersedes: Priority? }

  run(input, ctx) -> Out {
    let prior = ctx.staged.priorityOf(input.ticketId)   // READ-only lookup
    return {
      ticketId:   input.ticketId,
      level:      input.level,
      rationale:  input.rationale,
      supersedes: prior            // the value being replaced, for the fold
    }
    // returns the FULL decision — not { accepted: true }
  }
}
WHY PAYLOAD-RICH

An opaque acknowledgement forces the truth to live somewhere other than the result channel — a shared bag, a side write, a re-query. That is precisely the coupling this architecture forbids. Return the decided values and the fold is total, deterministic, and replayable; return a boolean and you have smuggled state out of band.

WHO OWNS THE TRANSITION

"Return the decided values" can tempt a tool into computing the transition — e.g. reading supersedes from context and deciding the new truth. Keep the division clean: a tool may package a read-only context snapshot into its payload for the fold's convenience, but the authoritative transition is always the reducer's. Prefer returning the raw inputs plus a minimal snapshot and letting the fold derive things like supersedes from its own current state, so two places never compute the same transition and a stale snapshot can never overrule the live fold.

4.4Two lanes, one fold

Both tool kinds are pure functions that read context and return payloads. They diverge only when their payloads reach the reducer: a UI-tool payload becomes a UI-targeting command and reshapes the surface; a domain-tool payload becomes new immutable state and possibly an effect. The authoring discipline is identical, the testing discipline is identical (call run with a fake context, assert the payload), and the downstream machinery is identical. You build two lanes; the runtime merges them into one stream.

flowchart TD
  M["the agent loop<br/>(selects a tool)"]:::agent
  UT["UI tool<br/>run(input, ctx) -> payload<br/>pure, read-only ctx"]:::tool
  DT["domain tool<br/>run(input, ctx) -> payload<br/>pure, read-only ctx"]:::tool
  R["the pure reducer<br/>folds every payload"]:::core
  S["surface state<br/>(what is shown)"]:::state
  B["business state<br/>(what is true)"]:::state

  M --> UT
  M --> DT
  UT -- "presentational payload" --> R
  DT -- "factual payload" --> R
  R --> S
  R --> B

  classDef agent fill:#1a1012,stroke:#f23a48,stroke-width:1.5px,color:#ffd7da;
  classDef tool  fill:#13161d,stroke:#caa,color:#e7e8ec;
  classDef core  fill:#0f1116,stroke:#f23a48,stroke-width:1.5px,color:#fff;
  classDef state fill:#14161d,stroke:#f23a48,color:#fff;
Fig 4.1   Two lanes of authored tools — UI-targeting and domain-targeting — both pure, both returning payloads that fold the same way into the two faces of state.

This is the whole job. If a capability cannot be expressed as (input, context) -> payload, it does not belong in a tool — it belongs in the runtime or behind a port. Hold that line and the rest of the architecture — the command bus, the reducer, replay — falls out for free.

4.5The tool is the public face of a self-contained block

The two tools you build are not only the agent's verbs — they are the only public symbols a feature need expose. That observation lets a feature be authored as a self-contained block: a single directory that privately owns its whole internal stack and presents exactly one thin face to the rest of the system — its tool(s). The mental model is Lego. The studs that snap into the baseplate are the block's tool registrations; the brick's interior — its state slice, its fold arms, its view-model, its port and adapter — is opaque, and nothing outside the folder is permitted to name a single symbol in it except the registration entry. You plug a block in by registering its tool(s) at the one composition root; you pull it out by deleting the folder and that one registration line. Nothing else references it, so nothing else breaks.

The reason the tool is the right public face is that the tool contract is already the universal seam. A tool is a name the model selects by, a typed input, a typed output, and a pure run(input, ctx) -> payload that reads the read-only context and mutates nothing (4.1). Making it the sole public symbol means the rest of the application reaches the block exactly the way the agent does — by intent, across a typed boundary — and never by reaching into the block's view-model, slice, or adapter. The encapsulation payoff is direct: the block's interior can be rewritten wholesale (swap the database, re-shape the slice, restructure the projection) and nothing outside changes, because nothing outside ever imported anything but the registration. “Plug it in via its tool(s)” is therefore not a metaphor — the tool registration is the plug, and the import rule (a block names no sibling's internals) is the housing that keeps that plug the only connection.

THE LEGO INVARIANT

A block is self-sufficient internally but parasitic on the spine externally: it carries its full internal stack privately, yet owns no spine of its own. It borrows the one bus, the one State, the one log, and the one boundary by contributing typed pieces into them — never by standing up parallel copies. A block that tries to own its own bus, log, or boundary is not a brick; it is a second baseplate, and that is the one shape this model forbids. The block owns the leaves; the spine owns the trunk, and the trunk is never per-block.

4.6Anatomy of a block — and the reconciliation that makes it sound

Two block shapes recur, and each privately owns the full stack for its concern. The danger is also in each: “owns its own database call” and “owns its own view-model” are exactly the phrasings that, read naively, break purity, replay, and the single source of truth. The reconciliation is what keeps the block self-sufficient and lawful.

A domain block — owns its repository

Privately owns: its domain tool(s) (the public face); its Command case(s); its fold arm(s); its namespaced state slice; a port interface declared in domain-pure terms (e.g. EscalationRepository); and the live adapter behind that port — the only file in the block that holds a client or touches a database. The DB call genuinely ships inside the block — as port + adapter — never inline in a tool.

A UI block — owns its view-model

Privately owns: its UI tool(s) (the public face); a pure view-model — a projection slice -> ViewModel of the one folded State, holding no truth of its own; ephemeral presentational view-state (hover, scroll, expanded panel, unsubmitted text) that is never folded; its view, which renders by applying the projection's pre-decided flags and never computes a presentational decision (6.9); and its fold arm(s) over its own surface slice.

The domain reconciliation (G2 + G9 hold). Split the call into a read seam and a write seam, and put neither in the tool body. For the read, the block's adapter is injected into the read-only context as a capability handle exactly per capability-as-a-tool; the tool does ctx.repo.read(args) — a read of an injected port — and returns the row as its payload. It opened no connection, so it still “reads context and returns a payload, mutating nothing” (G2). Because that row is an external-source result, it is captured as an ordered fixture on the one timeline, keyed to the consuming step, and fed back on re-fold — never re-queried — so replay stays deterministic (G9, stated in full). For the write, the tool never writes: the fold emits an effect descriptor (plain data) and the one boundary performs it through the single perform() seam, idempotent on the deterministic id, stubbed on replay. The block ships its own repository; purity and replay are untouched because the call is reached through an injected port and its result is a captured fixture.

The UI reconciliation (G8 holds). Draw the line by replay-relevance. The view-model is a derived, private projection of the one State — it stores nothing, so it can never be a second source of truth. Ephemeral view-state is presentation only: lose it on a refresh and what the system believes is unchanged. Everything else — anything folded from a tool result, read by another block, part of the artifact, or needed to reconstruct the session — is business truth, and it lives only as the block's namespaced slice of the single folded State, which the controller exposes as a view-model projection (6.9; G8: exactly one immutable value + one onAction). A block that parked truth in its private view-model would be a competing reducer; the rule is to fold it whenever in doubt. The block emits its decisions as auditable Commands on the one bus — it owns its command cases, never a private bus or log, and it never stamps the Actor: it emits an unsigned intent and the one boundary signs it.

THE LINE, IN ONE SENTENCE

Ephemeral view-state is permitted and private; business truth is forbidden in the block and lives only as a folded slice. If losing a field on a re-fold would change what the system believes or what the artifact contains, it is truth — fold it.

4.7What a block owns, contributes, and may never fork

Self-sufficiency is bounded precisely. A block owns what is private to its concern, contributes typed pieces into the singular spine, and may never fork a spine artifact. The three columns are the whole governance of the model.

ConcernDispositionWhy
view-model (slice -> VM), ephemeral view-stateblock owns privatelyPure projection of the one State + presentation-only state; holds no truth, never folded. Rewriting it is invisible outside the block.
the block's tool(s)block owns privatelyThe sole public face. No sibling imports the body; the registration is the one stud that snaps into the spine.
the port interface + its live adapterblock owns privatelyThe block's private seam to its repository/backend and the only file holding a client. Bound at the root; never imported by a sibling adapter (G10).
Command case(s) the feature addscontributes to sharedOwn cases on the one sealed Command, riding the one append-only bus — never a per-feature bus.
the state slice + its fold arm(s)contributes to sharedSole writer of one namespaced sub-tree of the one immutable State; arms register into the one total fold, never a second reducer.
the external-read result (DB row, search hit)never forkedCaptured as an ordered fixture on the one timeline (G9); a block gets no private capture log.
the bus · the replay log · the boundary · the root · the one controllernever forkedOne signed bus, one log, one mint/stamp/perform boundary, one wireApp, one state+onAction. A block contributes into each; instantiating any is a second baseplate (G1, replay, G8).

Across blocks, the rule is the inward one applied at a new boundary: a block imports only shared domain types and its own internals, never a sibling's view-model, slice internals, adapter, or fold arm. If block A needs what block B knows, A reads B's slice off the one folded State as a value, or A dispatches a Command on the one bus whose B-owned arm folds — never a direct call. Composition happens only at the root.

flowchart TB
    subgraph SPINE["The ONE shared spine · written once, never forked"]
      BUS["one signed Command bus<br/>append-only · replayable"]:::core
      ST["one immutable State<br/>product of namespaced slices"]:::core
      FOLD["one total fold<br/>dispatches to block arms"]:::core
      BND["one boundary<br/>mints id · stamps Actor · performs"]:::core
    end
    TRI["triage block<br/>view-model · slice · arms · tool(s)"]:::block
    ESC["escalation block<br/>port · adapter · slice · arms · tool(s)"]:::block
    ROOT["app composition root<br/>the only cross-layer importer"]:::edge

    TRI -->|"registers tool seam"| BND
    ESC -->|"registers tool seam"| BND
    BND --> FOLD
    FOLD --> ST
    BND --> BUS
    TRI -.->|"reads slice as a value"| ST
    ESC -.->|"reads slice as a value"| ST
    ROOT -->|"plugs each block into the spine"| TRI
    ROOT -->|"plugs each block into the spine"| ESC
    TRI -.->|"FORBIDDEN: block imports sibling internals"| ESC

    linkStyle 9 stroke:#f23a48,stroke-dasharray:4 4;

    classDef core fill:#0f1116,stroke:#f23a48,stroke-width:1.5px,color:#fff;
    classDef ok fill:#11151c,stroke:#7c8aa0,color:#cdd5e0;
    classDef edge fill:#13161d,stroke:#caa,color:#e7e8ec;
    classDef block fill:#14161d,stroke:#f23a48,color:#fff;
Fig 4.2   Two self-contained blocks plug into the one shared spine through their tool seams, registered at the single composition root. Each reads any slice of the one State as a value; neither may import the other's internals — the dashed red edge is a build failure, not a smell.
05

The signed command bus

If tools are how the agent reaches the world, the command bus is how every action — human or agent — enters it. There is exactly one observable, append-only stream, and every action on it is a single Command carrying an Actor stamp. A human's action and the agent's action are not two code paths that happen to converge; they are peers on the same stream, differing only by who signed them. This is the spine of replay.

5.1The contract: a signed Command

A Command is a sealed, closed set of verbs — the things that can happen in your application. Each case carries its own typed payload, and every case carries the same first field: the Actor that issued it. The actor is a two-value enum, Human or Agent. That is the entire contract — roughly a dozen lines — and it does not grow with the application; only the set of verbs does.

the command bus, in ~12 lines (support-triage console)pseudocode
enum Actor { Human, Agent }            // who issued the action — only two kinds

sealed Command {
  by: Actor                            // every command is signed, no exceptions

  case StartSession   { by, channel: Text }
  case AttachEvidence { by, ticketId: Text, blobRef: Ref }
  case Suggest        { by, ticketId: Text, text: Text }
  case SetPriority    { by, ticketId: Text, level: Priority }
  case RequestEndSession { by }        // non-destructive — see the gate
  case EndSession     { by }           // promoted only by explicit confirm
}

// ONE stream. Append-only. Replayable. Every action lands here.
bus: AppendOnlyStream<Command>

The verbs are the application's vocabulary; in the running support-triage example they are AttachEvidence, Suggest, SetPriority, and so on. Swap the verbs and you have a different application — but the shape, the signature, and the bus are invariant across every project that adopts this architecture.

5.2One stream, two actors, identical downstream

Both a rare human operator ("the host") and the agent emit the same command type onto the same stream. When the host taps to set a ticket's priority, the stream receives SetPriority(by: Human, …). When the agent's setPriority tool result is folded, the stream receives SetPriority(by: Agent, …). Everything downstream of the bus — the reducer, the surface, the generated artifact — consumes the command without caring who signed it. The actor is preserved for audit, not for branching logic.

flowchart LR
  H["the host<br/>(rare human action)"]:::user
  A["the agent<br/>(tool result, folded)"]:::agent
  C{{"signed Command<br/>by: Human | Agent"}}:::bus
  ST[("one stream<br/>append-only · replayable")]:::bus
  RD["the reducer"]:::core
  SU["surface"]:::sink
  AR["generated artifact"]:::sink

  H -- "stamp Human" --> C
  A -- "stamp Agent" --> C
  C --> ST
  ST --> RD
  RD --> SU
  RD --> AR

  classDef user  fill:#11151c,stroke:#7c8aa0,stroke-width:1.5px,color:#cdd5e0;
  classDef agent fill:#1a1012,stroke:#f23a48,stroke-width:1.5px,color:#ffd7da;
  classDef bus   fill:#0f1116,stroke:#f23a48,stroke-width:2px,color:#fff;
  classDef core  fill:#0f1116,stroke:#f23a48,stroke-width:1.5px,color:#fff;
  classDef sink  fill:#11131a,stroke:#3a3f4a,color:#c7ccd6;
Fig 5.1   Two actors, one signed Command, one append-only stream. Consumers downstream read the command identically; the Actor stamp survives only as audit metadata.
WHAT THE STREAM BELONGS TO

“Exactly one stream” is scoped to a unit of work — one bus and one serial consumer per session, not one global stream for the whole deployment. Sessions are independent: they need no global order and a server agent runs many concurrently, each with its own bus. The tiers do not violate “one serial consumer” because a deep tier never writes the fast tier's bus — it publishes to the relay, and its conclusion enters the fast stream only as a fast-tier-issued Command after recall and fold. Cross-session global ordering and causal consistency across independent streams are out of scope for this reference; if your product needs them, they are a layer above the per-session guarantee, not a property it provides.

5.3The stamp is applied at the boundary — never forged

The Actor is set in exactly one place: the boundary adapter. It is not a parameter the agent can fill, and it is not a value the surface can choose. The surface emits one intent per handler and the boundary stamps Human; the agent's folded tool result is stamped Agent. Neither side can mint the other's identity — the surface cannot forge Actor.Agent, and the agent path cannot forge Actor.Human. This is a load-bearing invariant, enforced by the gate (the actor-stamped-at-boundary guarantee), not a convention you are trusted to honor.

INVARIANT

The actor is stamped at the boundary, never forged in the surface and never chosen by the model. One authority writes the signature; everyone else inherits it. Without this, "replay" and "audit" mean nothing — you could not trust who did what.

5.4What rides the bus, and what does not

Not everything belongs on the signed stream. The bus carries discrete, auditable, low-frequency actions — the decisions a session is accountable for. Byte-heavy or high-rate payloads do not. A large blob, a continuous sensor reading, a streamed observation: these are read through a port and folded directly, because forcing megabytes through an append-only log you intend to retain and replay is wasteful and pointless — there is nothing to audit in a pixel buffer.

Rides the signed bus

Discrete actions with accountability: setting a priority, attaching named evidence, suggesting a reply, requesting end-of-session. Small, enumerable, replay-critical. The Actor stamp matters here because who decided is part of the record.

Rides a direct fold

Byte-heavy or high-frequency input: blobs, raw observations, streamed readings. Read through a port, folded straight into state. No signature, because there is no discrete decision to attribute — only data to incorporate.

The discriminator is auditability, not size alone: does a human need to be able to ask "who did this, and when?" If yes, it is a signed Command. If it is just data flowing in, it folds directly. Keeping the bus lean is what makes whole-session replay cheap — the log is a record of decisions, not a tape of every byte the system ever saw.

DIRECT-FOLD INPUT IS STILL CAPTURED

A direct-fold input is off the signed bus, but it is not off the record. For replay to reconstruct a session, every off-bus input that influences state must still be captured deterministically: the bytes live out of band, but a content-addressed reference to them — and their staging order, keyed to the consuming step — is recorded on the timeline, so a re-fold resolves the same fixture. "Rides a direct fold" means "skips the signature and the audit trail," never "escapes capture." The replay equation in section 14 folds this recorded timeline — signed commands and ordered input-fixture references — not the signed commands alone.

06

The stateless reducer

This is the heart. Tools return payloads; a single pure function folds those payloads into new immutable state plus a list of effect descriptors; and a thin boundary mints identity, performs the effects, and stamps the Actor. No tool ever writes state. The entire mutation surface of the application is one fold and one boundary — which is exactly why the whole thing replays.

6.1The canonical fold

State derivation lives in one signature, taught verbatim. Given the current state, the batch of tool results from a step, and the current clock reading, the reducer returns the next state and a list of effects to perform:

the fold — a pure functionpseudocode
fold(state: State, results: [ToolResult], now: Timestamp)
    -> (newState: State, effects: [Effect])

Three properties make this the load-bearing seam. It is pure — same inputs, same outputs, no I/O, no clock read inside (the clock is passed in as now). It is total — every tool result has a fold arm. And it is directly testable — you call it with a literal state, literal results, and a fixed now, then assert on the returned pair. No mocks, no harness, no agent. The fold is the part of the system you can prove correct in isolation.

6.2The one-way path

Data flows in exactly one direction and never loops back through the tool. The model selects a tool; the tool returns a payload; the runtime surfaces that payload on its lifecycle (the result channel of the finished step); the boundary collects the step's results and calls fold; the fold returns new immutable state and effect descriptors; the boundary commits the state, performs the effects, and dispatches the resulting signed commands onto the bus. The tool's job ended the moment it returned.

flowchart LR
  M["the agent loop<br/>selects a tool"]:::agent
  T["tool.run(input, ctx)<br/>returns payload"]:::tool
  RC["runtime result channel<br/>(step lifecycle)"]:::sdk
  RD["fold(state, results, now)"]:::core
  S["new immutable state"]:::state
  BD["the boundary<br/>mint id · stamp Actor · perform effects"]:::core
  BUS[("signed command bus")]:::bus

  M --> T --> RC --> RD --> S --> BD --> BUS
  RD -- "effects (data)" --> BD
  BD -. "next step ambient input" .-> M

  classDef agent fill:#1a1012,stroke:#f23a48,stroke-width:1.5px,color:#ffd7da;
  classDef tool  fill:#13161d,stroke:#caa,color:#e7e8ec;
  classDef sdk   fill:#11151c,stroke:#7c8aa0,color:#cdd5e0;
  classDef core  fill:#0f1116,stroke:#f23a48,stroke-width:1.5px,color:#fff;
  classDef state fill:#14161d,stroke:#f23a48,color:#fff;
  classDef bus   fill:#0f1116,stroke:#f23a48,stroke-width:2px,color:#fff;
Fig 6.1   One-way dataflow. The model never reads state back through the tool; payloads flow forward through the result channel into the fold, and only the boundary mints identity, stamps the Actor, and performs effects.

6.3Identity and clock are minted at the boundary

Two things must not be generated inside the model or the tool: identity (ids, sequence numbers) and time. The boundary mints both. The reasoning is concrete, not stylistic:

  • Not in the model. A model-minted id does not survive a context summary or a replay. When the context is compacted or the session is reconstructed, the model re-emits a different id for "the same" action — producing phantom retries and duplicated effects. Identity generated by the reasoner is identity that evaporates.
  • Not in the tool. A tool that reads the clock or draws a fresh id is no longer pure — it returns different payloads for identical inputs, and the fold that consumes it stops being deterministic. Purity is the property that makes the whole pipeline testable and replayable; minting in the tool throws it away.

So the boundary passes now into the fold and assigns ids after the fold, deterministically from the committed sequence. Replay feeds the same recorded now and the same sequence, and reconstruction is bit-for-bit identical.

INVARIANT

Identity and clock are minted at the boundary — once, deterministically. The model proposes; the boundary names and timestamps. This single rule is what separates "replayable" from "approximately re-runnable."

6.4The anti-pattern it replaces: Hidden State Coupling

The failure mode this architecture exists to prevent is the tool that reaches back. Instead of returning a payload, such a tool mutates a shared context bag — writing its result into ambient state that something else later reads. It communicates backwards through spooky action at a distance: the caller learns what happened not from the return value but from a side write it has to know to look for. Such a tool is untestable in isolation (you cannot assert on a return that carries no information), and it almost always returns an opaque { accepted: true } because the real result went out the side door.

WRONG — Hidden State Couplingpseudocode
// Anti-pattern: the tool mutates shared state and returns nothing useful.
tool setPriority(input, ctx) -> Out {
  ctx.sharedStore.write(input.ticketId, input.level)   // ✗ side write
  ctx.sharedStore.bumpClock()                           // ✗ minting time in-tool
  return { accepted: true }                             // ✗ opaque ack
}
// Result downstream learns nothing from the return.
// Untestable without standing up sharedStore. Replay diverges.
RIGHT — pure return-payloadpseudocode
// Pattern: the tool reads read-only ctx and RETURNS the full decision.
tool setPriority(input, ctx) -> Out {
  let prior = ctx.staged.priorityOf(input.ticketId)    // read-only
  return { ticketId: input.ticketId,
           level: input.level,
           supersedes: prior }                          // payload-rich
}
// Folds deterministically. Testable by direct call. Replays identically.

6.5Routing the fold by tool name

Inside the fold, the canonical shape is a closed dispatch on which tool produced each result — one arm per tool, each arm a pure transformation of state plus the effects to emit. Nothing in an arm performs an effect; arms only describe effects as data, which the boundary later carries out (quarantining I/O so replay can stub it). The else arm makes the fold total — an unrecognized tool name can never crash it — but totality must not mean silent loss: in an architecture whose pitch is "nothing happens off the record," an unhandled result that left no trace would be the fold-side twin of the opaque-ack anti-pattern. So the else arm folds an explicit Unhandled marker and emits a diagnostic effect; it is total and observable, never silently dropped.

fold — routing by tool namepseudocode
fold(state, results, now) -> (State, [Effect]) {
  var s = state
  var fx = []

  for result in results {
    match result.toolName {
      "setPriority" -> {
        s  = s.withPriority(result.ticketId, result.level)
        fx += Effect.LogDecision(result, at = now)      // effect as DATA
      }
      "focusTicket" -> {
        s  = s.withFocus(result.focused)                // UI-targeting fold
      }
      "requestEscalation" -> {
        s  = s.markEscalationRequested(result.ticketId)
        fx += Effect.NotifyHost(result.ticketId, at = now)
      }
      else -> {
        // total AND observable: an unknown name never crashes, but it is not
        // silently dropped — it is folded as an explicit, auditable marker.
        s  = s.withUnhandled(result.toolName)
        fx += Effect.Diagnostic("unhandled tool result", result.toolName, at = now)
      }
    }
  }
  return (s, fx)        // pure: no I/O performed here, only described
}
VALIDATE ARGS, DON'T JUST PARSE THEM

The else arm above makes the fold total over unknown names. It does not make it total over invalid values: a schema-valid but out-of-policy argument — a setPriority on a ticket that is closed or out of scope, an attachEvidence pointing outside the work item — parses fine and, in the slice as written (13.2's state.withPriority(...)), would fold straight into truth unguarded. Apply the same discipline the else arm uses: each domain arm validates its payload against current state and policy, and on failure folds an explicit Rejected(reason) marker plus a diagnostic effect rather than committing the mutation. “Total” must mean total over values, not merely over tool names — so a bad argument is observable and replayable, never a silent out-of-scope write. Catalog row: Schema-valid but out-of-policy args → the reducer folds a Rejected marker, the agent re-asks → invariant: no out-of-scope mutation is ever folded.

6.6Fold per step, not per turn

The fold runs on each step finishing, not once after the whole turn completes. This matters for failure. A turn may take several steps; if the fold ran only at the end, a crash mid-turn would discard every completed step's work. By folding each step's results as they land — committing state and performing effects incrementally — a mid-loop failure leaves all completed steps durably folded. The session resumes from the last committed state, not from zero.

RECOVERY

Per-step folding is what makes recovery graceful. State advances one committed step at a time, so interruption costs you at most the in-flight step — never the turn.

6.7The seams

Pulling back, the moving parts are few and each has a single role. The runtime owns the agent interface and the result channel; the application owns the reducer, the read-only context, and the domain port. The boundary is the only object that touches more than one of them.

classDiagram
  class AgentLoop {
    <<runtime>>
    +run() Steps
    +resultChannel() ToolResults
  }
  class ReadOnlyContext {
    +staged() View
    +sensingHandle() Port
    +recall() Port
  }
  class Reducer {
    +fold(state, results, now) StateAndEffects
  }
  class DomainPort {
    <<interface>>
    +dispatch(action) void
    +observe() State
  }
  class Boundary {
    +onStepFinish(results) void
    -mintId() Id
    -stampActor() Actor
    -perform(effects) void
  }

  Boundary --> AgentLoop : reads result channel
  Boundary --> Reducer : calls fold(state, results, now)
  Boundary --> DomainPort : performs effects / dispatch
  AgentLoop --> ReadOnlyContext : tools read

  style AgentLoop fill:#11151c,stroke:#7c8aa0,color:#cdd5e0
  style DomainPort fill:#11151c,stroke:#7c8aa0,color:#cdd5e0
  style ReadOnlyContext fill:#13161d,stroke:#ccaaaa,color:#e7e8ec
  style Reducer fill:#0f1116,stroke:#f23a48,stroke-width:1.5px,color:#fff
  style Boundary fill:#0f1116,stroke:#f23a48,stroke-width:1.5px,color:#fff
Fig 6.2   The seams. The runtime supplies the loop and result channel; the application supplies the pure reducer, the read-only context, and the domain port. Only the boundary spans them — minting identity, stamping the Actor, and performing effects.

Everything else in this reference — the loop as a declaration, runtime versus you, replay — is a consequence of these few seams holding. Keep the fold pure, keep minting at the boundary, and the rest is bookkeeping the runtime already does for you.

6.8How a tool result becomes a command

Two name-keyed maps live at the boundary, and naming them removes the one ambiguity left by “UI and domain tools differ only downstream” (4.4). The fold owns a name → arm map (which transition runs). The boundary owns a name → Command map (which signed verb, if any, is dispatched). Both key off the same toolName, so a tool is registered in one logical place even though it touches two maps:

Tool kindFold arm reshapesDispatches a signed Command?
domain toolbusiness state (+ effect descriptor)yes — an auditable decision
UI toolsurface state onlyno — folds, does not sign

This resolves the apparent tension between the fan-out diagram (Fig 3.1, which shows view toggles downstream of the bus) and the “discrete, auditable actions only” rule (5.4): a UI tool's payload folds into surface state, but it does not mint a signed Command unless the presentation change is itself something a human must be able to audit (a host explicitly toggling a mode). The discriminator is the same one 5.4 already gives — does someone need to ask “who did this, and when?”

ADDING A TOOL IS A BOUNDED CHANGE

A new domain verb touches three declared sites: the Command case, the fold arm, and the boundary's name→Command entry. A new UI tool touches one: the fold arm. The change is “additive” (16) because each site is a single appended entry in a closed map — never an edit to existing logic — not because it is a single line.

6.9The view projection: a separate type, every decision pre-computed

The fold produces one immutable domain State. The surface does not render that State. It renders a view-model — a distinct type — produced from the State by a pure view projection: a function project(state) -> ViewModel. The UI block already names this seam in passing (4.6 gives a UI block “a pure view-model — a projection slice -> ViewModel of the one folded State, holding no truth of its own”). This subsection elevates that aside into a rule, because two disciplines hang on it that the block card only gestures at.

One naming note first, to keep two “projections” distinct. The fold is sometimes called the projection — it projects the stream into State (events → truth). The view projection is a second, downstream map — it projects State into a ViewModel (truth → what the surface shows). Stream → State → ViewModel: two pure maps, in series, never fused. The fold owns truth; the view projection owns presentation; the surface owns neither.

Separate the view-model from the domain state — always

The ViewModel is a different type from State even when the two look identical today. This reads as redundant the first time; treat it as a discipline anyway. The instant a presentation-only field is needed — a label, a sort order, a derived count, a pre-computed flag — it has somewhere to live that is not domain truth, and no field on the State ever has to grow a presentational reason to exist. Folding the two types together is cheap now and expensive in six months; keeping them apart is the reverse. The redundancy you pay for up front is the seam that lets presentation evolve without disturbing truth.

Pre-compute every presentational decision in the projection

Every decision the surface would otherwise make is decided here and lands on the ViewModel as a value the surface only reads. “Show the empty state” is not if results are empty in the view — it is a showEmptyState flag the projection set. “Enable the confirm control” is not a condition the view evaluates — it is an isEnabled flag the projection set. The surface applies the flag; it never computes it. The view becomes purely declarative: every branch it takes was decided before the data reached it.

the view projection · truth in, a fully-decided ViewModel outpseudocode
// A SEPARATE type from State — presentational, never folded, holds no truth.
type ViewModel = {
  rows:           [TicketRow]      // shaped for display, not the domain shape
  showEmptyState: Bool            // PRE-COMPUTED — the view only reads it
  confirmEnabled: Bool            // PRE-COMPUTED — not an `if` in the view
  banner:         BannerView      // a discriminated union, fully decided here
}

// Pure: State in, a fully-decided ViewModel out. No I/O, no clock, no decision deferred.
project(state: State) -> ViewModel {
  return {
    rows:           state.tickets.map(toRow),
    showEmptyState: state.tickets.isEmpty,          // decided HERE, once
    confirmEnabled: state.run is AwaitingConfirm,    // decided HERE, once
    banner:         bannerFor(state.run)             // decided HERE, once
  }
}
// The surface reads vm.showEmptyState / vm.confirmEnabled and renders. It branches
// on a pre-computed flag; it never recomputes the decision behind the flag.

Where the projection lives — not the controller. The pre-computation is part of the pure domain-to-view map, not the surface controller. A controller that computes showEmptyState from State on the way out has merely moved the decision one layer up from the view — it is still presentation logic outside the projection, still a place a second reader could compute the flag differently. Push every presentational decision down into project, where it is computed exactly once and testable by direct call (literal State in, assert on the ViewModel out) with no surface and no model in the loop.

This is the functional-core / imperative-shell split, drawn one notch finer than 6.1 drew it. The functional core is now two pure maps — the fold (stream→State) and the view projection (State→ViewModel). The imperative shell is the surface: it observes, it renders, it dispatches one intent per handler — and it decides nothing, because every decision was already made in the core. The surface is the thinnest possible shell precisely because the projection is where presentation is computed.

Reconciliation with G8, and with what the surface observes. The one immutable value the controller exposes (G8: exactly one state + one onAction) is this ViewModel — the projected, fully-decided value, not the raw domain State. The view-model holds no truth of its own (4.6), so exposing it does not create a second source of truth; it is a derived view of the one folded State, recomputed by project whenever that State changes. And it is observed, not pulled: the surface subscribes to a stream of ViewModel snapshots, so a single composed pipeline — state-stream → project → view-model-stream — drives the screen, rather than the surface reaching out to read the latest value on demand. One-shot reads do not compose into that pipeline; a stream does (the consistency reason to return a stream even for a single value, developed at the domain port).

THE PROJECTION RULE

The surface renders a view-model — a separate type from the domain State, even when they look identical — produced by a pure projection that pre-computes every presentational decision as a value (showEmptyState, isEnabled). The view applies those values; it never computes them, and neither does the controller. Truth→ViewModel is a pure map in the core; the shell only observes the stream of its outputs.

6.10Model state, commands, and status as discriminated unions

The single source of truth for any closed set of possibilities is a discriminated union — a sealed, named, finite set of variants, each variant carrying its own payload bundled inside it. The signed Command is already one: a closed set of verbs, each case carrying exactly the fields that verb needs. Apply the same shape everywhere a value is “one of a fixed set”: the run status, a banner, a per-entity lifecycle, a tool outcome. The discriminator (the tag) and the payload travel together, so a variant's related values cannot be separated, mis-paired, or read in a combination that was never meant to exist.

Flag soup — the failure mode

Status modeled as loose booleans — isLoading, hasError, isDone — or as a bare string the reader compares against. The representable space is the product of the flags, most of it nonsense: isLoading && isDone, hasError with no error message, a string typo no compiler catches. Adding a new state means touching every site that reads the flags, and nothing tells you which sites you missed.

A discriminated union — the fix

One sealed type whose variants are the only states that can exist, each carrying its own payload: Loading, Ready(rows), Failed(reason), Done(summary). The impossible combinations are unrepresentable — there is no Loading-and-Done, and a Failed always carries its reason because the payload lives inside the variant. The error message cannot go missing; it is part of the case.

status as a discriminated union — payload bundled inside each variantpseudocode
// Each variant carries its OWN payload. Related values travel together; the
// impossible combinations of flag-soup are simply not representable.
sealed type RunStatus =
  | Idle
  | Working   { step: Int, since: Timestamp }
  | Failed    { reason: Text, at: Timestamp }     // reason can NEVER be missing
  | Done      { summary: Summary }

// Exhaustive handling: the compiler demands an arm for every variant. Adding a
// fifth case fails to compile HERE and at every other site that handles RunStatus,
// until each is updated — the missing case is found at build time, not at runtime.
render(status: RunStatus) -> BannerView = match status {
  Idle              -> BannerView.None
  Working{step,...} -> BannerView.Progress(step)
  Failed{reason,..} -> BannerView.Error(reason)
  Done{summary}     -> BannerView.Summary(summary)
  // no `else`: exhaustiveness is the point — a new variant must be handled,
  // not silently swallowed by a catch-all.
}

The decisive property is compile-time exhaustiveness. When a value is a discriminated union and it is consumed by a closed match with no catch-all, adding a variant fails to compile at every site that must handle it — the fold arm, the view projection, the boundary's command map — until each is updated. The type system hands you the complete list of edits the change requires; you never discover a missed case at runtime, in front of a user, at the end. This is the same no-silent-drop dispatch the fold uses for the cases it owns (one arm per case, an observable marker for the rest) and the same reason 14.4 models failure as a sealed status rather than flags — generalized into a modeling default: wherever a value is one of a fixed set, make the set a discriminated union and let the compiler prove every case is handled.

EXHAUSTIVE BY CONSTRUCTION

A discriminated union with the payload inside each variant makes illegal states unrepresentable and makes adding a state a compile error everywhere it is handled — never a surprise at runtime. Prefer it over flag soup and stringly-typed status anywhere a value is “one of a fixed set”: State, Command, run status, and the ViewModel's own presentational variants (6.9).

CLOSED SETS, NOT OPEN INPUTS

Exhaustiveness applies to a union you own and close — State, a run status, a command outcome, the view-model's variants. It does not apply to an open boundary input whose set your type system cannot close: a tool-name string the model emitted, an external event kind. There, the fold's defensive else → Unhandled (6.5) is correct and is not a G12 violation — it is the right handling of a set that is open by nature, and it stays observable rather than silently swallowed. Close what you own; guard what you do not.

07

System map: ports and adapters

The mental model is hexagonal. One pure domain core sits in the middle; every framework detail is pushed to the edges behind an interface. That single discipline is what lets a replay harness and a live deployment run the same brain against different worlds.

The architecture is a hybrid, chosen deliberately over any single orthodoxy: a passive reactive surface at the top, a pure framework-free core for orchestration and policy in the middle, and the agent loop, the model provider, and the sensing sources as adapters at the rim. The highest-value decision is the pure core. It imports nothing platform-specific, so it is the one piece of the system that is identical whether you run it under test, in a browser, on a server, or as an ambient background process. Everything that varies between those worlds is an adapter you swap.

7.1One pure core, adapters at the edges

The folder a unit of code lives in determines what it is allowed to depend on, and the enforcement gate keys off exactly these boundaries. The core knows only its own domain types and the ports it declares; it never reaches outward. The adapters reach inward to satisfy a port, never sideways into each other.

flowchart TB
    subgraph SURF["Surface · reactive view (passive)"]
      VW["surface controller<br/>one state + one action sink"]:::user
    end
    subgraph CORE["Pure domain core (zero platform / UI / transport imports)"]
      UC["use cases"]:::core
      SE["orchestration · policy · fusion"]:::core
      PT{{"ports (interfaces)<br/>domain port · deep-analysis port"}}:::state
    end
    subgraph AG["Agent adapter · the loop + tools + boundary adapter"]
      BA["boundary adapter<br/>(the reducer boundary)"]:::agent
      LP["the agent loop<br/>(declaration only)"]:::agent
      TL["the tools<br/>pure · stateless"]:::tool
    end
    subgraph INF["Inference adapter · the only model / transport boundary"]
      PV["model provider<br/>structured output + tool calls"]:::sdk
    end
    subgraph SEN["Sensing adapters · device / input sources"]
      EV["event source · sampler"]:::sink
    end

    VW -->|"dispatch Command"| UC
    UC --> SE --> PT
    PT -. "live adapter satisfies the port" .-> BA
    BA --> LP --> TL
    LP --> PV
    TL -->|"capability-as-a-tool"| PV
    EV -->|"raw events"| BA
    classDef user fill:#11151c,stroke:#7c8aa0,stroke-width:1.5px,color:#cdd5e0;
    classDef core fill:#0f1116,stroke:#f23a48,stroke-width:1.5px,color:#fff;
    classDef state fill:#14161d,stroke:#f23a48,color:#fff;
    classDef agent fill:#1a1012,stroke:#f23a48,stroke-width:1.5px,color:#ffd7da;
    classDef tool fill:#13161d,stroke:#caa,color:#e7e8ec;
    classDef sdk fill:#11151c,stroke:#7c8aa0,color:#cdd5e0;
    classDef sink fill:#11131a,stroke:#3a3f4a,color:#c7ccd6;
Fig 7.1   Ports and adapters. The pure core depends on interfaces; the loop, the provider, and the sensing sources live at the edges.

7.2The port that matters most

The seam the whole design hangs on is the domain port: a single-state, action-driven interface. The domain dispatches an Action and observes one immutable State snapshot, never a tangle of callbacks or multiple streams. Events go in one door; state comes out one door. Because the domain depends on this interface and not on the runtime's loop, you can put a fake behind it under test and the real cognition behind it in production, and neither the domain nor the test can tell which one is wired in.

the domain port · single-state, action-drivenpseudocode
// The input port: the domain DISPATCHES an Action and OBSERVES one immutable State.
// One door in (events), one door out (a single state snapshot). No callbacks, no
// fan-out of streams. The live adapter satisfies this; a fake satisfies it in test.
PORT DomainPort {
    onAction(action: Action)              // one door in: a raw event, or "flush" at end-of-unit
    state: Observable<State>              // one door out: exactly one immutable snapshot
}
Why this seam is load-bearing

A second cognition seam, the deep-analysis port, follows the same shape for the deep tier. Both are plain interfaces. A fake-in-test and the real-in-production runtime swap behind one declaration, which is what makes the entire system verifiable without a device, a network, or a model in the loop.

7.3The single composition root

Exactly one place in the system knows every layer at once: the composition root. It is where each port is bound to its adapter, where the model provider is constructed, and where the prompts are injected as editable assets rather than hardcoded strings. Wiring is explicit constructor injection; there are no service locators and no ambient globals that an adapter can reach for. A reader who wants to know what is real and what is faked in any given build reads this one file and nothing else.

the composition root · explicit wiring, no service locatorspseudocode
// The ONE place that knows every layer. Ports bound to adapters by hand; prompts
// injected as assets, never hardcoded. Swap a single line to go from test to live.
FUNCTION wireApp(env) -> App:
    provider = buildProvider(env.transport, env.backend)      // inference adapter
    domain   = liveDomainAdapter(provider, loadPrompt("orchestrator"))
    deep     = liveDeepAdapter(provider, loadPrompt("analyzer"))
    sensing  = env.sensingSource()                            // device or recorded
    return App(
        domainPort: domain,                                   // <- fake under test
        deepPort:   deep,
        events:     sensing,
    )

7.4The layer table, generalized

The boundaries are not conventions; they are enforced import rules. Each layer earns its keep precisely because of what it is forbidden to depend on:

LayerRoleHard import rule
core / domainPure orchestration, policy, criteria fusion, tier selectionno platform / UI / transport / model imports
inferenceThe model provider and transport boundarythe only place a network client may appear
sensingCapture, sampling, and device input sources behind a portguards source configuration; no domain logic
agentThe loop, the tools, the boundary adapterthe loop is a declaration; tools read-only
surfaceThe reactive view + its controllerrenders state, dispatches commands; decides nothing
The same map, four worlds

Nothing in this map mentions a screen, a camera, or a network. That is the point. Re-target the adapters and the identical core ships as a server agent (sensing = an inbound event queue, surface = an API), a desktop copilot (sensing = the editor buffer, surface = a panel), an ambient assistant (sensing = microphones and sensors), or a browser automation (sensing = the DOM, surface = injected overlays). The ports stay fixed; only what plugs into them changes. For the running example, a support-triage console binds the sensing port to the incoming ticket stream and the surface to an operator panel, while the core that drafts replies and sets priority is untouched.

7.5Where each responsibility physically lives

The layer table in 7.4 names the layers and their import rules but never says what the folders are called or what sits inside each one. Here is the concrete shape. Read it as the physical projection of that table: every box in Fig 7.1 is a directory, and the directory a file lives in is exactly what the gate keys off to decide what that file may import. Read it inside-out — the pure domain/ core sits at the center, every adapter wraps around it, and exactly one folder, app/, is allowed to see across all of them.

a responsibility-based source tree (illustrative — support-triage console)tree
src/
├── domain/                  # THE PURE CORE — imports NOTHING foreign (G4)
│   ├── Command              #   the signed action type (carries Actor)
│   ├── State                #   the single immutable state (sealed)
│   ├── Action               #   one door in — what a surface may dispatch
│   ├── fold                 #   the pure reducer: (state, results, now) -> state, effects
│   ├── project              #   the pure VIEW PROJECTION: State -> ViewModel, every flag pre-decided (6.9)
│   ├── policy/              #   orchestration · tier selection · criteria fusion
│   └── ports/               #   INTERFACES = the published, frozen CONTRACTS (7.9); a coordination note ships beside each
│       ├── DomainPort       #     single-state, action-driven cognition seam
│       ├── DeepPort         #     deep-analysis seam (same shape)
│       └── EventSource      #     the sensing seam (raw events in)
│
├── tools/                   # PURE · STATELESS — import domain TYPES only (G2)
│   ├── ui/                  #   UI tools: the payload describes what is SHOWN
│   └── domain/              #   domain tools: the payload asserts what is TRUE
│
├── agent/                   # the agent adapter (satisfies DomainPort)
│   ├── loop/                #   the loop DECLARATION — model + tools + sampling (G3)
│   └── boundary/            #   THE ONE MUTATION SEAM: mints id, stamps Actor,
│                            #   calls fold, performs effects, dispatches signed Commands
│
├── inference/               # the model/transport adapter — the ONLY network client
├── sensing/                 # capture / sampling sources behind EventSource
├── persistence/             # the append-only timeline log + fixture store (replay)
├── surface/                 # the passive view + controller
│   ├── view/                #   renders by applying pre-decided flags; decides nothing (6.9)
│   └── controller/          #   one immutable `state` + one `onAction` sink — else nothing (G8)
│
└── app/                     # THE COMPOSITION ROOT — the ONLY cross-layer importer (G7)
    └── wireApp(env)         #   binds every port to an adapter by hand; no service locators

[external dependency — you do NOT write this]
the runtime  ·····  the loop engine, step lifecycle, command bus, fold driver,
                    replay, concurrency / barge-in, and the enforcement gate.
                    Consumed as an artifact; zero of its source lives in this tree.

Four placements carry the whole design. One: domain/ sits at the root with nothing below it that it may reach — it is a sink in the import graph, not a source, and the one folder identical across every target world. Two: tools/ splits into ui/ and domain/ — the two kinds you write — but both import domain types only, so the two lanes are authored identically and diverge only at the fold. Three: surface/ separates the view/ (where rendering-by-state is legitimate) from the controller/ (one state, one onAction, nothing else public). Four: app/ is the single folder permitted to name every layer at once — and because it is the only one, “what is real and what is faked in this build” is answerable by reading the one file that holds 7.3's wireApp. Everything outside the brackets is the runtime: a dependency you import, not a folder you fill.

PACKAGE-BY-FEATURE IS EQUALLY VALID

This tree groups by layer for clarity, but you may group by feature instead — triage/, escalation/, each holding its own state slice, tools, and adapter — just as soundly, provided the dependency rule below still holds inside every feature folder: the feature's view imports its state and command types only, its tools stay pure, and nothing reaches across to a sibling feature's reducer or adapter. The folders are negotiable; the directions an import may point (7.6) are not.

7.6The dependency rule: which way an import may point

Fig 7.1 draws how data flows at runtime — a command travelling from surface to tools, a port being satisfied. It deliberately says nothing about which way an import may point at compile time, and those are different graphs. The second is the one the gate enforces, and it reduces to a single rule: all dependencies point inward, toward the pure domain; the domain depends on nothing; only the composition root spans layers; adapters never import each other. Every architectural decay in this system is one of those edges drawn backwards or sideways. Lead with the forbidden edges, because each names a concrete corruption of unidirectional flow and replay:

Forbidden edgeThe violation it isWhat it corrupts
surface → inference / sensing / persistenceThe view imports a transport or an adapter directly, reaching past the core to touch infrastructure.a second mutation path the fold never sees — off-record, unreplayable
surface → reducer / policyThe view imports the fold or a business rule and computes what is true instead of rendering it.G5 — the surface becomes a second operator; truth splits in two
domain → surface / inference / frameworkThe pure core names a UI widget, a client, or a platform type.G4 — the core stops being portable; the replay harness can no longer run it
adapter → adapterOne adapter imports a sibling sideways instead of meeting it at a core-declared port.port substitution — a fake can no longer stand in; test and live diverge
tool → framework / store / busA tool reaches a renderer, a shared store, or the bus instead of returning a payload.G2 — the tool is impure; its real result left by a side door, breaking the fold

The figure renders the same rule as a graph. Solid arrows are imports you may write — every one points inward to domain/. Dashed red arrows are imports that, if they appear, fail the build.

flowchart TB
    SURF["surface<br/>view + controller"]:::ok
    INF["inference<br/>provider + transport"]:::ok
    SEN["sensing<br/>capture sources"]:::ok
    PER["persistence<br/>timeline log + fixtures"]:::ok
    TOOL["tools/ui · tools/domain<br/>pure · stateless"]:::edge
    BND["agent boundary<br/>the one mutation seam"]:::edge
    DOM["domain/<br/>State · Command · fold · ports"]:::core
    APP["app/ composition root<br/>the only cross-layer importer"]:::edge

    SURF -->|"imports State + Command TYPES only"| DOM
    TOOL -->|"imports domain TYPES only"| DOM
    BND  -->|"folds via the domain port"| DOM
    INF  -->|"satisfies a domain port"| DOM
    SEN  -->|"satisfies EventSource"| DOM
    PER  -->|"records domain types"| DOM
    APP  -->|"binds ports to adapters"| SURF
    APP  -->|"binds ports to adapters"| INF
    APP  -->|"binds ports to adapters"| DOM

    SURF -. "FORBIDDEN: UI reaching infra" .-> INF
    SURF -. "FORBIDDEN: UI importing the reducer" .-> BND
    DOM  -. "FORBIDDEN: core naming a surface" .-> SURF
    INF  -. "FORBIDDEN: adapter sideways" .-> SEN
    TOOL -. "FORBIDDEN: tool reaching a framework" .-> INF

    linkStyle 9 stroke:#f23a48,stroke-dasharray:4 4;
    linkStyle 10 stroke:#f23a48,stroke-dasharray:4 4;
    linkStyle 11 stroke:#f23a48,stroke-dasharray:4 4;
    linkStyle 12 stroke:#f23a48,stroke-dasharray:4 4;
    linkStyle 13 stroke:#f23a48,stroke-dasharray:4 4;

    classDef core fill:#0f1116,stroke:#f23a48,stroke-width:1.5px,color:#fff;
    classDef ok   fill:#11151c,stroke:#7c8aa0,color:#cdd5e0;
    classDef edge fill:#13161d,stroke:#caa,color:#e7e8ec;
Fig 7.2   The import-direction graph (read this for imports; Fig 7.1 is for runtime data flow, whose port and dispatch arrows run the opposite way). Solid edges point inward to the pure domain, which imports nothing; only app spans layers. Dashed red edges are forbidden — each is a build failure, not a smell.

Note what the allowed edges have in common: they all terminate at domain, and domain originates none of them. The core is a pure sink — the deepest node, depended upon by all, depending on nothing. That is G4 drawn as a graph property rather than asserted as a rule. The surface and the tools reach it for types only — State, Command, Action — never for the fold or a policy, which is what makes G5 structural rather than aspirational: a view that cannot import the reducer cannot decide what is true, only render what it received. Because the four adapters fan in to the core but never to each other, each stays substitutable behind its port — the precondition for binding a fake under test. And the one node that touches everything is app/, which is exactly why dependency injection lives in a single composition root (G7): concentrate all cross-layer knowledge in one auditable file so no other layer needs an ambient locator to find its collaborators.

THE RULE, IN ONE LINE

An import may point inward toward the core, or it is the composition root; it may never point outward from the core, sideways between adapters, or from a passive node — a surface or a tool — into anything but domain types. Forbid those five edges and unidirectional flow, port substitution, and replay stop being conventions you maintain — they become consequences you cannot avoid. This is the structural invariant the next section turns into a single named guarantee.

7.7Unidirectional flow is what the graph permits

Unidirectional data flow and state-based rendering are usually sold as conventions — disciplines a team agrees to keep. Under this tree they are neither optional nor aspirational: they are the only shape the import graph in Fig 7.2 leaves possible. Trace what an inward-only graph forbids and the loop draws itself. The surface cannot call the fold — it does not import it — so it cannot mutate state; it can only emit an intent. A tool cannot write the context (G2), so it cannot push a change back; it can only return a payload. Nothing imports the surface, so nothing can hand it state imperatively; it can only observe one snapshot. Cut every back-edge the graph disallows and exactly one cycle remains.

flowchart LR
    I["intent in<br/>one Action (tap or tool-call)"]:::ok
    F["reducer folds<br/>(state, results, now)"]:::core
    S["one immutable State<br/>out"]:::core
    V["passive surface renders<br/>(applies pre-decided flags)"]:::ok

    I --> F --> S --> V
    V -->|"next intent — a NEW Action, never a write-back"| I

    classDef core fill:#0f1116,stroke:#f23a48,stroke-width:1.5px,color:#fff;
    classDef ok   fill:#11151c,stroke:#7c8aa0,color:#cdd5e0;
Fig 7.3   The one-direction loop. Intent → fold → one immutable state → passive render → next intent. The closing edge is a fresh Action, never a write-back into the fold — so there is no back-edge, and every cycle is a pure step the timeline can replay.

The figure has no arrow that skips a stage and none that runs backward — not because we drew it tidily, but because there is no import that would let one exist. An intent enters as a single Action, a human tap or an agent tool-call, indistinguishable downstream; the boundary collects the step's results and the reducer folds them into one new immutable State; the passive surface renders that state and holds none of its own to drift out of sync, because it was never allowed to import the means to mutate one. Rendering may branch on a value it was handed — show this panel because showEmptyState is set, disable that control because isEnabled is false — but it applies a flag the view projection already decided (6.9); it never computes that flag itself. Applying a pre-computed presentational value is rendering; computing the presentational decision in the view is deciding, and belongs in the projection. Each stage maps to an invariant the tree already enforces: the surface exposes one state and one sink with nothing else public (G8), it renders by state and decides nothing (G5), and the fold is fed by tool payloads that flowed out, never written back in (G2). This is the same one-way path the reducer chapter teaches as 6.2 — but seen from the structural side: 6.2 shows payloads flow forward and never loop back through the tool; 7.6 shows why they cannot — the import that would carry a value backward is forbidden to exist. Because the loop has no cycle that mutates outside the fold, every turn is a deterministic function of the prior state and the captured inputs, which is precisely the property replay needs. State-based rendering, then, is not a framework feature you opted into; it is the only thing a view can do when it is forbidden to import the machinery that changes truth. Make the graph point inward and the loop is the residue.

7.8Packaging a self-contained block on disk

The package-by-feature note in 7.5 already blesses grouping by feature instead of by layer — a folder holding its own state slice, tools, and adapter — “provided the dependency rule still holds inside every feature folder.” The self-contained block (4.5–4.7) is that note taken to its conclusion: every feature folder is a mini-hexagon whose only public symbol is its tool registration. The shared spine and the one composition root stay singular; each block contributes into them.

a per-feature, block-isolated source tree (illustrative — support-triage console)tree
src/
├── spine/                    # YOUR SHARED CORE — wired ONCE onto the runtime spine, never per block
│   ├── Command               #   the ONE sealed signed type; blocks append CASES
│   ├── State                 #   the ONE immutable State = product of block SLICES
│   ├── fold                  #   the ONE total reducer; dispatches to block ARMS
│   ├── boundary/             #   the ONE seam: mints id, stamps Actor, performs (G1/G9)
│   ├── (the bus)             #   the runtime’s append-only replayable stream — you consume it
│   ├── persistence/          #   the ONE timeline log + fixture store (replay)
│   ├── controller/           #   the ONE surface controller: one state + one onAction (G8)
│   └── ports/                #   the two CANONICAL core ports (DomainPort · DeepPort)
│
├── blocks/
│   ├── triage/               # ===== A SELF-CONTAINED BLOCK (mini-hexagon) =====
│   │   ├── slice             #   this block's namespaced sub-tree of State (sole writer)
│   │   ├── command           #   this block's CASES on the sealed Command
│   │   ├── domain/           #   PURE (G4): imports domain TYPES only
│   │   │   └── TriagePort     #     this block's PRIVATE port = its frozen CONTRACT (7.9)
│   │   ├── tools/            #   PURE (G2): the block's PUBLIC FACE — read ctx, return payload
│   │   ├── fold/             #   the block's ARM(s): pure, touch ONLY this slice
│   │   ├── project/         #   PURE: slice -> ViewModel, pre-decides every flag (6.9)
│   │   ├── view/            #   the shell: renders by applying flags, dispatches — decides nothing
│   │   ├── view-state/      #   EPHEMERAL presentational state — never folded
│   │   ├── adapter/          #   THE RIM — NOT pure; may hold a client; bound at root ONLY
│   │   └── register          #   the studs: {tools, slice-key, arms, name->Command, port binding}
│   │
│   └── escalation/           # ===== ANOTHER BLOCK — identical shape, domain-heavy =====
│       ├── slice · command · fold/ · register
│       ├── domain/EscalationRepository   #   private port to its backend
│       ├── tools/            #   requestEscalation (pure; reaches repo via injected ctx)
│       └── adapter/          #   LiveEscalationApi — NEVER imports triage/adapter
│
└── app/                      # THE ONE COMPOSITION ROOT (G7) — only cross-layer importer
    └── wireApp(env)          #   calls each block's register(): binds port->adapter,
                              #   mounts tools/slice/arms into the spine.
                              #   PLUG IN = add one register() line. PULL OUT = delete folder + line.

[external dependency — you do NOT write this]
the runtime  ·····  loop engine, step lifecycle, command-bus plumbing, fold driver,
                    replay, concurrency / barge-in, and the enforcement gate.

The purity boundary is drawn inside each block by sub-path, exactly as 7.5/7.6 key off the top-level folder: blocks/*/domain, tools, view, and fold are pure (G2/G4); only blocks/*/adapter is the rim that may hold a client. Package-by-feature therefore never smuggles infrastructure into the pure core — it relocates where the same boundary is drawn. The inter-block import isolation is the 7.6 dependency rule extended one boundary outward, and it is the same forbidden-edge family the gate already denies:

  • No cross-block symbol import. blocks/A may not name any symbol under blocks/B — not its view-model, slice internals, fold arm, port, and least of all its adapter (the adapter → adapter edge of 7.6, now block → block). The only outward symbol is register, imported solely by app/.
  • Communicate via State or the bus, never a direct call. A block reads a sibling's slice off the one folded State as a value, or dispatches a Command the sibling's arm folds — there is no method handle from A into B.
  • One writer per slice; no private spine. A fold arm writes only its own keyed slice, and no block instantiates a bus, a log, a boundary, or a second controller.

Registration at the single root is what keeps composition auditable. wireApp is the one place that sees every block at once: it binds each block's port to its adapter and mounts each block's tools, slice, and arms into the spine — so “what is real and what is faked in this build” (7.3) stays answerable per block by reading the register lines. Adding a block is additive (a new folder + one line); removing one is subtractive — but note the one history caveat: a removed block is gone from the active menu, never from the record. Its old fold arms are version-pinned and retained (or upcast through) so any retained timeline carrying its commands still re-folds. Pulling a block out governs the forward surface; it never rewrites history.

7.9Contracts are the unit of decoupling

Every seam this section has drawn — the domain port of 7.2, a block's private port-and-adapter (4.6), the tool registration that is a block's only public symbol (7.8) — is the same thing wearing different clothes: a published, frozen contract. Read structurally, a port is not a convenience for testing; it is the unit of decoupling. This matters more here than in a hand-written codebase because the architecture assumes code is produced at volume, often by independent authors working in parallel — the premise the whole reference is built on and cashes out in 15. Parallel authors cannot coordinate by reading each other's source; they coordinate by depending on each other's contracts. So the contract is promoted from documentation to load-bearing infrastructure.

Four disciplines turn a seam into a contract you can build against blind:

  • Stable interface, mutable internals. A module publishes a frozen public surface — its inputs, its outputs, the shape invariants it upholds, its error semantics, and any tolerances — and downstream depends on that surface, never on the implementation behind it. Rewriting the internals — swapping a backend, changing an algorithm, re-folding a slice differently — breaks nobody, because nobody was permitted to see past the interface. This is the freedom 7.2 already grants the domain port (fake-in-test, live-in-production, indistinguishable to the caller), generalized to every seam in the system.
  • Contract-first, implementation second. A module lands as an empty skeleton with a signed contract — the interface, the declared shape invariants, the error and tolerance semantics, written down — before a line of its body exists. Siblings can compile, wire, and test against the skeleton immediately; the implementation arrives later without moving the seam. The contract is the schedule, not an afterthought.
  • No hidden shared state. Two modules communicate only through their declared contracts — never through ambient mutable state neither one names. This is the same prohibition G7 (no service locators) and G11 (a block couples only through shared domain types, the one bus, and the one folded State) already enforce, restated as a property of the contract: anything not in the contract is not a channel.
  • Independent test and integration per module. A module's tests run against its own contract without the rest of the workspace present — a per-module check, with a short coordination note (a contract document travelling beside the code) recording the inputs, outputs, invariants, error semantics, and tolerances it promises. A block's pure tools and pure fold arm are testable by direct call (the property 4.6 already reconciles); its adapter is verified against the port in isolation. If a module can only be tested with the whole application assembled, its contract has leaked.

Narrow interface, deep module. The contract is deliberately thin: for the domain port, two doors — dispatch an action in, observe one state out (7.2); for a block, a single tool registration. The implementation behind it may be substantial — orchestration, fusion, a networked backend, a multi-step fold. A wide contract over a shallow body is the anti-pattern: it forces every downstream author to learn the internals, which is precisely the coupling the contract was meant to prevent. Keep the published surface as small as the job allows and let depth hide behind it.

THE TEST

The single question that decides whether a seam is a real contract: can an unfamiliar author — a new teammate, or an independent agent — read one module's contract and implement it conformingly, without reading any other module's source? If yes, the module is decoupled. If the answer requires opening a sibling's body, the contract has leaked an implementation detail and must be tightened until it stands alone. This is the parallel-authorship premise made falsifiable: building at volume is only safe when every contract passes this test.

This is the same governance 4.7 draws — a block owns its internals, contributes typed pieces to the spine, and never forks a spine artifact — re-read as a statement about contracts rather than ownership: the block's outward contract is its tool registration plus its declared port and the slice shape siblings read off State; everything else is private and free to change. Note what is not frozen by this: a block's contribution into the shared sealed Command is an additive case on a union the spine owns, absorbed by that spine's own exhaustiveness (G12) — so the spine grows by additive cases while each block's outward face stays fixed. The distinction G13 makes enforceable — frozen outward contract, open-for-extension spine — is what lets the per-feature economics of 16 hold: when the connective tissue is identical for every feature and every seam is a standalone contract, the marginal cost of a feature falls toward a small constant — a tool, its render, and a few additive declarations (a Command case, a fold arm, a registration, per 6.8) — never a slice of bespoke plumbing.

08

What the runtime gives you

The hardest, most reusable parts of an agent are not yours to write. The agent loop, the command bus, state derivation, replay, concurrency, and enforcement are capabilities you consume from a generic runtime. Your job is two kinds of tool and a handful of declarations. This section draws the bright line precisely, so you know what not to write and can hold any candidate runtime against the contract.

8.1The bright line: a two-column contract

Everything in the left column is product-specific and belongs to you. Everything in the right column is generic machinery that has been solved once and is shared. The art of adopting this architecture is resisting the urge to rebuild the right column.

flowchart LR
    subgraph YOU["YOU BUILD · product-specific"]
      Y1["UI tools<br/>(act on the surface)"]:::agent
      Y2["domain tools<br/>(act on business logic)"]:::agent
      Y3["prompts<br/>(as assets)"]:::tool
      Y4["tier policy"]:::tool
      Y5["provider wiring"]:::tool
    end
    subgraph RT["THE RUNTIME PROVIDES · generic · swappable by contract (8.5)"]
      R1["tool-calling loop"]:::sdk
      R2["step lifecycle"]:::sdk
      R3["command bus plumbing"]:::sdk
      R4["fold dispatch + commit plumbing<br/>(you write the arms)"]:::sdk
      R5["replay"]:::sdk
      R6["concurrency / barge-in mailbox"]:::sdk
      R7["provider abstraction"]:::sdk
      R8["middleware / hooks"]:::sdk
      R9["enforcement gate"]:::core
    end
    YOU ==>|"declared into"| RT
    classDef agent fill:#1a1012,stroke:#f23a48,stroke-width:1.5px,color:#ffd7da;
    classDef tool fill:#13161d,stroke:#caa,color:#e7e8ec;
    classDef sdk fill:#11151c,stroke:#7c8aa0,color:#cdd5e0;
    classDef core fill:#0f1116,stroke:#f23a48,stroke-width:1.5px,color:#fff;
Fig 8.1   The contract. You supply tools, prompts, policy, and provider wiring; the runtime supplies the loop and everything around it.
Bought, not built

The runtime is a dependency you pull from a package registry, not source you maintain. There is zero runtime source in your repository. That is not a convenience; it is the leverage. The loop, the lifecycle, and the provider abstraction are written once, tested in isolation, and shared across every product that adopts the pattern. Your repo stays exactly as large as the part that is genuinely unique to your product.

8.2The runtime as a capability contract

Treat the runtime as a set of roles to look for, not a specific API to memorize. Any runtime worth adopting exposes these capabilities under some name. Concrete method names below are written only as e.g. sketches; what matters is the role each one plays.

  • A tool-calling loop. Drives the model to choose and invoke tools, executes them, feeds results back, and repeats until a stop condition fires. This is the part you must never hand-roll.
  • A step lifecycle. Each turn is a sequence of observable steps; the runtime surfaces tool calls and tool results on a structured StepResult your reducer boundary folds.
  • A per-step sampling hook. Before each step, you may set temperature, the active tool subset, and the tool-choice mode. This is where per-step cleverness lives, never in a loop body.
  • Stop-condition combinators. Combinable predicates (e.g. anyOf(hasToolCall(t), stepCountIs(n))) that bound a turn so it cannot spin.
  • Tool registration. A typed declaration that names a tool, its input and output schema, and its pure body, registered into a set the loop may call.
  • Lifecycle hooks. Begin/step/end callbacks for cross-cutting concerns like logging and error capture, off the hot path.
  • A model-wrapping middleware seam. Decorate the model once (logging, reasoning persistence, retries) so every agent sharing that model is covered with no per-agent wiring.
  • A command bus, replay, and a concurrency mailbox. The append-only signed stream, the fold that reconstructs state from it, and the single-consumer mailbox that serializes overlapping inputs are all runtime plumbing, covered in the bus and barge-in.
the runtime surface · capabilities, not an API to callpseudocode
// ---- provided by the runtime; you DECLARE against it, you don't implement it ----
// Stream<T> below is whatever async-sequence shape your host stack already uses
// (a callback, an async iterator, an observable) — not any one library's type.

INTERFACE Agent<Ctx, Out> {
    run(input, context: Ctx) -> Stream<StepResult>             // the tool-calling loop
}

// tool registration: name + typed schemas + a PURE body (input in, payload out)
tool<In, Out>(
    name,
    inSchema:  Schema<In>, outSchema: Schema<Out>,
    run: (In, ctx: Ctx) -> Out,                               // reads ctx, mutates nothing
) -> Tool

// stop-condition combinators (combine freely)
anyOf(...conditions) -> StopCondition
hasToolCall(name)    -> StopCondition
stepCountIs(n)       -> StopCondition

// per-step hook: set sampling + active tools BEFORE each step
BeforeStep = (StepState) -> StepSettings                      // temperature, toolChoice, activeTools

// middleware seam: wrap the model once, cover every agent
wrapModel(model, middlewares: [Middleware]) -> Model
STREAMING IS PRESENTATION-ONLY

A provider may stream tokens and partial tool-call arguments before a step completes, and a surface may render that in-flight (a draft reply typing out). That partial output is never folded onto the signed bus: the fold operates only on completed steps, which are the discrete, auditable units (5.4). Render the provider stream directly to the surface for liveness; it is ephemeral and never replay-critical. Only the finished StepResult folds — so streaming costs nothing in determinism and adds nothing to the timeline.

8.3The provider story

The runtime ships one provider abstraction and assumes two model capabilities: tool-calling and constrained or structured decoding. Behind that abstraction, backends are interchangeable. A typical setup runs a networked development backend for fast desktop iteration and an in-process production backend on the target device or server, with the loop running identically against either, so there is no separate fallback path to maintain.

One detail is yours to supply: the transport. The runtime ships protocol only, not a network engine, so you plug in the transport that suits your platform. That is the entire integration cost on the provider side, and the only reason the runtime touches your dependency list at all beyond the artifact itself.

How to evaluate any runtime

Walk the list in 8.2 and ask, for each role: does this candidate provide it, or am I being asked to write it? Every capability you are forced to hand-roll is product code that should have been generic, untested machinery on your critical path, and a future maintenance liability. A good runtime leaves you with only the left column of Fig 8.1, the UI tools and domain tools of the two tools you build.

8.4When no runtime gives you everything

Be honest about the common case: on an unfamiliar stack there is often no library that supplies the whole right column, and sometimes none at all. The capabilities split into two tiers, and knowing which is which keeps "bought, not built" from becoming a false promise.

Where this architecture sits

This reference is the spine tier, delivered as a library: it assumes the table-stakes loop tier from a generic agent-loop runtime and provides the spine on top. So “the spine is provided” is not aspirational — it is what adopting this library means; 8.5 is how you swap a piece of it.

  • Genuinely table-stakes — what most existing agent libraries already give you: the tool-calling loop, the step lifecycle with structured per-step results, the provider abstraction, and usually stop conditions and a middleware seam. If a candidate lacks these, reject it; they are the parts hardest to get right and least worth re-deriving.
  • The spine tier — what a complete runtime provides as a dependency, though a minimal library may stop short of it: the signed command bus, the fold driver and state derivation, replay (scrubber, fork, diff), the barge-in mailbox, and the enforcement-gate engine. The reference implementation this pattern was distilled from supplies all of them. If your chosen runtime stops at the loop, you implement this tier once to the pattern — small, fixed, never per-feature — but treat it as spine you would rather inherit than author, and prefer a runtime that hands it to you — and whichever way you get it, it plugs in behind the contracts of 8.5, never as a fork.

So "pick a runtime" is a search with a known starting point — the reference implementation this pattern was distilled from, or any library in your ecosystem that exposes a tool-loop with a per-step result and a stop hook — not a leap of faith that one dependency drops in the entire spine. Treat the table-stakes tier as a buy decision and the second tier as a thin build you do deliberately, knowing it is bounded.

8.5The spine is provided, and swappable

The spine is opinionated and provided by default — but it is not a sealed black box, and that distinction is what separates an opinionated architecture from a rigid one. Every durable framework works this way: the reconciler you never touch still exposes a host contract; the convention-over-configuration core still lets you replace a layer. Here the same rule holds. Every spine component sits behind a contract (G13), so when the default does not fit, you replace that one component without forking the rest. The same contracts serve two moves — swapping a component a complete runtime already provided, and supplying one a minimal runtime omitted (8.4) — both behind the seam, neither a fork.

The default is the path. For the large majority of apps the provided spine — the command bus, the fold, replay, the mailbox, the gate — is the whole answer: you never open it, never tune it, and cannot get it wrong, because it is not yours to write. Adopt it and spend your attention on tools. The swap door below exists so the heavy minority is not forced to fork; it is not an invitation to decorate.

When you outgrow the default, you supply a different implementation of a spine component behind its existing contract — a new adapter at the composition root, never a change to the spine's semantics. The cases this is for are named and few: a distributed or sharded bus across processes; a bespoke persistence and retention strategy; a custom concurrency / ordering policy (priority lanes, fairness); a different snapshot / replay strategy for very long sessions; multi-tenant isolation. Each is one adapter, swapped at one seam, keeping every other component and every invariant. One honest qualifier: “one adapter” bounds the change's blast radius, not its difficulty. For the hardest swaps — a sharded, networked bus — the adapter must still honor the law in the right column (one ordered stream), and total ordering under partition is a genuinely hard distributed-systems problem; the contract relocates that difficulty behind a single seam rather than dissolving it. The thesis holds — one seam, every invariant intact — but the work behind the seam is real, and “one adapter” prices the integration, not the algorithm.

What makes that safe rather than a free-for-all is that the swap set is closed, and the architecture's laws are not in it. This table is the constitution:

Swappable — policy, behind a contractInvariant — the law, across every spine
the bus transport & distribution (in-process, sharded, networked)every action is one signed Command(by: Actor) on one ordered stream
persistence, retention, snapshot & replay strategystate = a pure fold over the recorded timeline; re-fold is deterministic
the concurrency / ordering policy of the mailboxone serial consumer per unit of work; two folds never overlap
the provider, transport & model backendtools are pure; the agent reaches the world only through tool calls
the enforcement gate's host/engine & any product-added checksthe rules encode the invariants; identity & actor are minted only at the boundary

The left column is policy you may set per app; the right column is law that holds across every spine, default or custom. A change that can only be made by weakening the right column has left the architecture — it is a different pattern wearing this one's names. That line, enforced by G14, is what lets the default serve the majority and the swap door serve the rest without the two diverging into incompatible dialects.

BLOCKS NEVER TOUCH THE SPINE

Swapping a spine component is an application-level act, done once at the composition root by whoever owns the platform — never inside a block. A feature block still only contributes tools, a slice, fold arms, and Command cases (4.7); it never sees which bus or which persistence the app wired. The swap door and the block boundary are different seams at different scopes — both behind contracts, neither forking anything.

09

The loop is a declaration

An agent definition is configuration, not code. It names a model, lists its tools, declares when to stop, and declares how each step is sampled. Nothing else. There is no if, no when, no for, no try in the loop body, because the loop body contains no logic at all.

Read the agent below as a declaration. It is model plus tools plus sampling plus lifecycle, and every line is a fact about the agent rather than a step it performs. The control flow lives entirely inside the runtime; the agent merely parameterizes it.

an agent definition · pure configuration, no control flowpseudocode
// An agent is model + tools + sampling + lifecycle. There is no method with logic.
AGENT Orchestrator = AgentLoop {

    model        = model,
    instructions = instructions,                       // the prompt, injected as an asset

    tools = [                                           // the menu the model may call
        observeTool, recordTool, escalateTool, finishTool, recallTool,
    ],

    stopAt = anyOf(                                     // end the turn the instant work is done
        hasToolCall(RECORD),                           // the terminal "I did the work" tool
        hasToolCall(FINISH),
        stepCountIs(MAX_STEPS),                         // bound the turn so it can't spin
    ),

    beforeStep = (step) -> {                            // per-step choices live HERE, declaratively
        observed = step.priorCalls.contains(OBSERVE)
        StepSettings {
            temperature = 0.0,                         // greedy: maximal tool-call validity
            toolChoice  = REQUIRED,                     // force a call; weak models under-call
            // scope the menu by phase of the turn:
            activeTools = observed ? POST_OBSERVE : PRE_OBSERVE,
        }
    },
}

9.1Where the cleverness actually lives

Logic does not vanish; it is relocated to designated homes that are not the loop body. Each home has a single responsibility, and none of them is a place where a turn's control flow is open-coded.

flowchart TB
    L["the loop body<br/>(declaration only — no logic)"]:::core
    PS["per-step sampling hook<br/>prompt · active tools · temperature · tool-choice"]:::agent
    BA["boundary adapter<br/>map a turn onto the domain"]:::tool
    MW["middleware + lifecycle hooks<br/>logging · errors (cross-cutting)"]:::sdk
    L -. "per-step choices" .-> PS
    L -. "turn → domain" .-> BA
    L -. "off the hot path" .-> MW
    classDef core fill:#0f1116,stroke:#f23a48,stroke-width:1.5px,color:#fff;
    classDef agent fill:#1a1012,stroke:#f23a48,stroke-width:1.5px,color:#ffd7da;
    classDef tool fill:#13161d,stroke:#caa,color:#e7e8ec;
    classDef sdk fill:#11151c,stroke:#7c8aa0,color:#cdd5e0;
Fig 9.1   Where cleverness lives: per-step choices in the sampling hook, turn-to-domain mapping in the boundary adapter, cross-cutting concerns in middleware.
  • Per-step choices (which prompt, which tools are active, temperature, tool-choice mode) go in the per-step sampling hook. In the agent above it forces a tool call every step and narrows the visible menu by phase: until the model has observed this turn it may only observe; once it has, the recording tools open up. That makes observe, then record the loop's shape rather than a hope, with no branch in the loop body.
  • Mapping a turn onto the domain goes in the boundary adapter, the reducer boundary, never the loop. The loop produces tool results; the boundary folds them.
  • Cross-cutting concerns (logging, error capture) ride middleware on the model plus lifecycle hooks, never the hot path. Wrap the model once and every agent sharing it is covered.

9.2Forcing the call on weak models

Small or under-trained models under-call tools; left to themselves they narrate prose where a tool call was required. The remedy is structural, not a better prompt. Combine tool-choice = required with a tight stop condition that ends the turn on the terminal tool or a step cap. The model now cannot reply with anything but a tool call, and the turn ends the moment the meaningful one lands. A single turn can then reliably walk a short pipeline, observe, then recall, then record, then end, without any imperative sequencing.

A turn is a bounded shape

Tool-choice = required guarantees a call; the stop condition guarantees the turn terminates. Together they convert "please use a tool" from a request into an invariant. For the running example, a triage turn becomes: observe the incoming ticket, recall prior context, record a drafted reply, and stop, four forced calls bounded by a step cap, never an open-ended chat.

9.3An allowlist governs which loops exist

Defining a loop is itself a gated act. An allowlist names the classes permitted to be agent loops, and the enforcement gate blocks any new loop until it is deliberately registered. This keeps ad-hoc agents from accumulating over time: every loop in the system is one someone consciously sanctioned, with its tools, its stop condition, and its sampling all declared in one reviewable place.

Why a declaration, and not a function

When the loop is a declaration, the whole behavior of a turn is visible in one block: its tools, its bound, its per-step policy. There is no hidden branch to trace, no accumulated special case, no place for business logic to leak onto the model's critical path. The agent becomes something you read, not something you debug, and the runtime's loop, exercised across every adopting product, is the only loop that ever runs.

10

Capability as a tool

The reasoner has no senses of its own. To perceive anything that did not arrive in its prompt — an image, a document, a sensor reading, a database row, an inbound event, a search hit — it calls a tool that returns text. Perception is not a privileged channel; it is just another tool call, which keeps the reasoning context lean and the input source freely swappable.

Required, not advanced

This is a prerequisite for almost every agent, not a pattern for fancy ones. Any reasoner that must see a row, an image, a search hit, or an inbound event reaches it only through a capability tool — there is no other channel into a modality-blind model. If your agent perceives or retrieves anything, you build this on day one.

10.1The reasoner is modality-blind by design

A language reasoner consumes and emits tokens. It cannot natively see a picture, parse a binary blob, or query a store — and in this architecture it is never asked to. Any out-of-band input is something the reasoner must request, through the same tool contract it uses for everything else: TOOL(name, input, output, run(input, ctx) -> output). The tool does the modality-specific work — decode the image, OCR the document, read the sensor, run the query — and hands back a textual result. The reasoner then reasons over that text exactly as it would over any other tool output.

This is the same boundary established in Everything is a tool, applied to sensing. There is no secret pipeline that injects raw pixels or rows into the model behind the tool wall. If the reasoner wants to know what just happened in the world, it must ask — and asking is a tool call.

WHY IT HOLDS

The instant perception gets its own bypass — an observation quietly pushed into the prompt, a row silently appended to context — the tool boundary stops being total, and the replay guarantee from the command bus springs a leak. Routing sensing through a tool keeps a single, auditable seam between the reasoner and the world.

10.2Input enters as a result, never as ambient state

The distinction that makes this clean: out-of-band input is a return value, not a mutation. The agent context is read-only ambient input — a staged observation handle, a perception handle, a recall handle. Tools read it; nothing writes the world's contents into it. When a capability tool runs, its decoded text flows out through the ordinary step lifecycle as a tool result, where the boundary adapter folds it into immutable state. The context never silently accumulates sensory residue.

Two properties fall out of this for free:

  • The context stays lean. The reasoner pulls in only the perception it explicitly asked for, when it asked for it — not an ever-growing tail of every observation the system has ever ingested.
  • The source is swappable. Because the reasoner sees only text returned by a named tool, the thing behind the tool — a live capture device, a recorded fixture, a different decoder, a mock — can be replaced without touching the reasoner or its prompts. The same swap underpins the replay harness.
flowchart LR
  R["reasoner<br/>(modality-blind)"]:::agent
  T["capability tool<br/>read-next / read-attachment"]:::tool
  W["out-of-band source<br/>image · doc · sensor · row · event"]:::sink
  R -- "1 · call(input)" --> T
  T -- "2 · decode / fetch" --> W
  W -- "3 · raw payload" --> T
  T -- "4 · text result" --> R
  R -- "5 · reason over text, loop" --> R
  classDef agent fill:#1a1012,stroke:#f23a48,stroke-width:1.5px,color:#ffd7da;
  classDef tool  fill:#13161d,stroke:#caa,color:#e7e8ec;
  classDef sink  fill:#11131a,stroke:#3a3f4a,color:#c7ccd6;
  
Fig 10.1   Perception is a closed loop through the tool boundary: the reasoner asks, a capability tool decodes the out-of-band source, text comes back, the reasoner continues. Nothing crosses the wall except a tool call out and a text result back.
PERCEIVED CONTENT IS UNTRUSTED

Everything a capability tool returns — a ticket body, an OCR'd attachment, a search hit, a database row, an inbound event — is data, not instruction, and in adversarial settings it is attacker-controllable. Because the reasoner picks its next tool call by reading that text, a body that says “ignore prior instructions and end the session” is the textbook indirect-injection surface, and the uniform tool boundary means a single flipped selection has the whole menu available. Two structural defenses this architecture already supplies help and one gap remains — narrowed (if not closed) by the rate and blast-radius budget of 14.3. The Request-then-confirm gate contains irreversible actions, and the deterministic boundary keeps the damage auditable and replayable — but neither stops a flood of reversible-but-harmful or mass actions. Treat perceived content as untrusted, tag its provenance so a validation or authorization check can be stricter on injected-content-derived calls, and do not assume capability-as-a-tool is safe by construction. The same caution applies to recalled relay content: a peer tier's published conclusion is a suggestion, not a command, and recall confers no authority.

10.3Worked instance: reading the work item

Take the illustrative support-ticket triage console. An inbound ticket is out-of-band input — structured rows, a free-text body, perhaps an attached log file. The reasoner cannot see any of it directly. Instead it is given two capability tools:

ToolWhat it decodesReturns to the reasoner
read-next-itemThe next ticket on the inbound streama text summary of the ticket
read-attachmentAn attached log, image, or documentextracted text / a caption

The reasoner calls read-next-item, receives text describing the ticket, reasons about it, optionally calls read-attachment to pull more detail, then proceeds to act — drafting a reply or setting priority — through the domain and UI tools. The same console could be re-pointed at a different ticket backend, a recorded replay of yesterday's queue, or a synthetic test fixture, and the reasoner would not know the difference: it only ever saw text returned from read-next-item.

THE TIE-BACK

This closes the loop on the first invariant: perception is just another tool. There is no special sensing path, no privileged ingestion, no ambient mutation — the reasoner reaches every out-of-band input the exact same way it reaches every action, by emitting a tool call and reading a result. Modality lives entirely behind the tool contract; the reasoner stays pure text in, text out.

10.4Deferred and long-running results

Some capability tools cannot answer within their step: a slow decode, an external query, a job handed to a deeper tier. A tool must not block the serial consumer — it is the heartbeat — so it cannot simply wait. The pattern keeps the tool pure and fast: it returns a correlation token immediately, the slow work runs off the consumer, and the completion re-enters as a new Input that folds in a later step.

  • The requesting step folds a pending marker. State records “awaiting token”; the turn proceeds or ends without the result.
  • Completion is an ordinary input. When the work finishes, its result is posted to the mailbox like any other stimulus, carrying the token, and folds where it lands — never retroactively into the step that requested it.
  • Capture keys to the landing step. For replay, the deferred result is its own ordered input-fixture keyed to the step it actually folded into (5.4), so a re-fold resolves the same value at the same position. Purity holds because the result is, again, a recorded external input — not something recomputed.
CATALOG ROW

Add to the failure catalog: Deferred result never arrives / arrives after session end → detection: the pending marker outlives its deadline → recovery: the pending marker expires to a typed timeout that folds like any status; the awaiting state is sealed, never a dangling promise → invariant: a session has no unresolved in-flight tokens at finalize.

11

Tiered & multi-agent cognition

One reasoner rarely fits every deadline. A fast tier answers per-event in milliseconds; a deep tier reasons carefully per unit of work over seconds. They run at different cadences and neither blocks the other, because they are coupled by exactly one thing: an append-only relay store the slow tier publishes to and the fast tier recalls from — through a tool, never an inline call.

Optional — multi-model only

Most apps run a single tier and never need this. Tiering earns its keep only when one model genuinely cannot meet two deadlines at once — a fast per-event responder and a slow, careful analyst. If one model serves your latency budget, skip this section entirely; nothing else depends on it.

11.1Two clocks, never one stall

The two tiers exist because two demands conflict. The fast tier must keep the hot loop moving — classify each incoming event, react, emit a Command — on a tight per-event budget. The deep tier does the expensive thinking — synthesize across the whole session, cross-reference, draft the final artifact — on a slow per-unit-of-work budget. If the fast loop ever waited on the deep tier, it would inherit the slow clock and the whole system would stutter.

So they never wait on each other. Each tier owns its own cadence and its own model backend. The fast tier fires continuously; the deep tier wakes on its own schedule, takes as long as it needs, and the fast loop neither knows nor cares whether a deep pass is currently running.

11.2The relay store is the only coupling

The single channel between tiers is a relay: an append-only shared store. The deep tier publishes its conclusions there as they finish. The fast tier recalls them — but it does so the only way it reaches anything, by calling a recall tool that returns the latest relevant entries as text (see Capability as a tool). There is no method handle from one tier into the other, no shared mutable object, no synchronous request. Communication is decoupled in both time and space: the deep tier may publish while the fast tier is idle, and the fast tier may recall long after the deep tier finished.

WHAT APPEND-ONLY BUYS, AND WHAT IT DOES NOT

Append-only removes write-write conflicts and gives consistent-prefix reads, so no lock is needed on the store. But the relay is still shared storage with a visibility boundary: append is a mutation, and the fast tier sees a deep-tier conclusion only after that publish commits and the next recall runs. Two requirements make "never stalls the hot loop" structural rather than aspirational: (1) recall must read with a bounded deadline and degrade to last-known on timeout, so a slow store can never block the fast path — but the degrade must be typed, never silent: a timeout returns LastKnown(text, age), distinct from a Fresh result and from an Empty one (the deep tier simply has not published yet), so the fast tier never mistakes stale-or-missing for current; and (2) for replay, a recall result is off-bus input — it must be captured as an ordered fixture keyed to the consuming step, exactly like any direct-fold input, so a re-fold resolves the same relay snapshot the original run saw. Without that capture, an asynchronously-published relay would let a replay recall different entries than the live run, and the fast tier's decisions would stop being reproducible.

flowchart TB
  subgraph FAST["fast tier · per-event cadence"]
    F["fast reasoner"]:::agent
  end
  subgraph DEEP["deep tier · per-unit-of-work cadence"]
    D["deep reasoner"]:::core
  end
  subgraph MORE["a third tier slots in the same way"]
    X["another reasoner<br/>own cadence · own backend"]:::sdk
  end
  RELAY["relay store<br/>append-only · the only coupling"]:::bus
  RECALL["recall tool"]:::tool
  D -- "publish" --> RELAY
  X -. "publish" .-> RELAY
  RELAY --> RECALL
  RECALL -- "text result" --> F
  F -- "emits Command, never blocks" --> F
  classDef agent fill:#1a1012,stroke:#f23a48,stroke-width:1.5px,color:#ffd7da;
  classDef core  fill:#0f1116,stroke:#f23a48,stroke-width:1.5px,color:#fff;
  classDef tool  fill:#13161d,stroke:#caa,color:#e7e8ec;
  classDef sdk   fill:#11151c,stroke:#7c8aa0,color:#cdd5e0;
  classDef bus   fill:#0f1116,stroke:#f23a48,stroke-width:2px,color:#fff;
  
Fig 11.1   Tiers fan into one append-only relay and the fast loop recalls through a tool. A third tier (dashed) attaches by the identical contract — publish to the relay, be recalled by tool — without touching the existing two.

11.3Adding a tier is a checklist, not a refactor

Because the coupling is one append-only store and one recall tool, the architecture scales to N tiers. A new tier slots in when, and only when, it satisfies every clause:

  1. Its own cadence. It runs on a clock independent of every other tier — faster, slower, or event-triggered.
  2. Its own backend. It may use a different model provider sized to its job; tiers do not share a model.
  3. Publishes to the relay. It writes conclusions to the append-only store — it never returns them by calling another tier.
  4. Recalled via a tool. Consumers reach its output only through a recall tool that returns text — and that text is untrusted: a peer tier's published conclusion is a suggestion, not a command, and recall confers no authority.
  5. Never stalls the hot loop. No tier may introduce a synchronous wait into the fast path. The enforcing mechanism, not just the wish: the recall tool reads with a bounded deadline and degrades to last-known on timeout, so even a slow or mid-write relay cannot block the fast loop. If a recall could wait unbounded, it is doing it wrong.

Satisfy the five and a third tier — a medium-latency summarizer, a compliance checker, a cost estimator — drops in beside the first two with no edits to either.

11.4Governing agent proliferation

The same freedom that lets a tier slot in cleanly would let tiers, and whole agent loops, multiply uncontrolled. They must not. Every reasoning loop and every tier is declared in a single registry — an allowlist of the agents that are permitted to exist. A new loop is blocked until it is deliberately registered: the enforcement gate denies an unregistered agent at author or build time. Proliferation becomes a conscious act with a name and an owner, not an accident that accretes background reasoners no one audits.

WORKED INSTANCE

In the triage console: a fast classifier reads each inbound ticket and reacts immediately — tag it, set a tentative priority, emit a Suggest. A deep analyzer wakes per ticket-thread, reads the full history, cross-references prior resolutions, and publishes a considered escalation rationale and a draft resolution summary to the relay. The classifier never waits on the analyzer; when it next reasons, it calls the recall tool, sees the analyzer's published rationale as text, and folds it into its next Command. Two clocks, one relay, no stall.

12

Concurrency & barge-in

Real-time input is messy: events pile up, an interrupt arrives mid-thought, a shutdown is requested while a turn is still running. The architecture tames this without a single lock. One serial consumer drains one mailbox, so turns are inherently serialized; three message policies — newest-wins, interrupt-preempts, drain-defers — cover the messiness. Serialization comes from the consumer's structure, not from a mutex.

Optional — streaming / real-time input only

A request/response agent needs only the one serial consumer (so two turns never overlap) — not the barge-in machinery. Conflation, interrupt-preempts, and drain-defers matter only when input streams faster than the model thinks (live audio, a sensor feed, a busy event queue). Answer one request at a time? Take the serial consumer and skip the rest.

12.1One mailbox, one serial consumer

Every stimulus — an inbound event, an interrupt, a finalize request — is posted as a message to a single mailbox. A single serial consumer drains it, one message at a time, and runs a reasoning turn to completion before pulling the next. Because only one consumer touches the mailbox and it never overlaps turns, the turns are inherently serialized. There is no shared mutable state to guard, so there is no mutex, no semaphore, and no hand-held "is a job running" slot to reason about. The single-consumer discipline is the concurrency model.

DESIGN STANCE

Serialization should fall out of the program's shape, not be bolted on with shared-mutable-state locks. A lone consumer over a lone mailbox gives you mutual exclusion for free — you hold no mutex across application state, so a whole class of races and lock-ordering deadlocks cannot occur. This is not "no synchronization at all": the consumer's structure, and the cancel-and-join in 12.3, still establish happens-before edges. The win is that those edges come from the program's shape, not from a lock you must remember to take and order correctly.

12.2Three message policies

Three kinds of message arrive, and each gets a distinct handling policy. De-branded to the participants — Input, Interrupt, Drain — the rules are:

MessagePolicyBehavior
Inputnewest-input-wins (perishable only)Conflate to the latest; if a turn is in flight, busy-drop stale inputs (folding a conflation counter, never silently) rather than queue them. For a durable work queue, do not conflate — see the note below.
InterruptpreemptCancel the in-flight turn, then handle the interrupt — cancel-then-handle.
DraindeferWait for the running turn to finish, then finalize. Never preempts.
  • Newest-input-wins. Input is perishable. While a turn runs, newer inputs supersede older ones and the stale ones are dropped on the floor — the consumer always acts on the freshest observation, never replays a backlog of obsolete ones.
  • Interrupt preempts. An interrupt is a barge-in: it cannot politely wait behind a long turn. It cancels the in-flight turn and is handled immediately.
  • Drain defers. A finalize/drain request is the opposite: it must not lose the work in progress. It waits for the running turn to complete, then finalizes — for example, sealing the generated artifact.
PERISHABLE STREAM vs DURABLE WORK QUEUE

Newest-input-wins / busy-drop is the right policy for a perishable source — a live sensor, a transcript — where only the freshest observation matters and a stale one is noise. It is exactly wrong for a durable work queue (the flagship server-agent deployment, 02): there every item must be processed and none may be dropped, and the queue delivers at-least-once, so the same item can arrive twice. For queue-backed sources, do not conflate: process each item, dedupe on a source-provided id (the same idempotency discipline as 14.6), and acknowledge the queue only after the commit (the log append) so a crash before ack re-delivers rather than loses. Catalog row: Duplicate input delivered (queue redelivery) → detection: a source id already seen → recovery: dedupe on the source id / idempotent fold → invariant: each work item folds exactly once.

12.3Cancel-and-join: folds never overlap

The subtlety lives in preempt. When an Interrupt preempts an Input turn, the consumer does not merely signal cancellation and rush ahead — it cancel-and-joins: it cancels the in-flight turn and waits for it to fully unwind before starting the interrupt's turn. This guarantees the two turns' folds into the reducer can never interleave. The retired mutex used to provide exactly this happens-before edge; the cancel-and-join provides it now, structurally, with no lock held across the await.

The payoff: the pure reducer only ever sees one turn's results applied at a time, in order. Its fold(state, results, now) -> (newState, effects) contract stays honest because nothing concurrent can sneak a second result set in mid-fold.

CANCELLATION IS AT A STEP BOUNDARY

Cancel-and-join discards only the in-flight step. Per 6.6, the cancelled turn's already-completed steps remain durably folded and their effects performed — that per-step durability is the whole point — so a cancel is not an atomic rollback, and the interrupt's turn begins from the partially-advanced committed state, by design. There is no automatic undo; if a partial turn must be compensated, that is the domain's job via an explicit compensating Command on the bus, not a hidden rollback.

TWO HONEST CAVEATS

Cancel must be cooperative and bounded. The cancel-and-join is itself a synchronization barrier: the consumer blocks on join until the cancelled turn unwinds. If a turn ignores cancellation or cannot unwind, that join is exactly a hang — the consumer is the heartbeat, so cancellation must be cooperative and bounded by the turn deadline, never an unconditional wait.

Replay is deterministic given the recorded stream, not the raw firehose. Which inputs get busy-dropped depends on turn duration, which depends on model and backend latency — so the command stream that lands on the bus is timing-dependent. A session is reproducible from its recorded surviving commands; it is not reproducible from the raw input firehose, because the system never recorded which inputs were conflated away. The guarantee is replay-determinism over the recorded timeline, not input-determinism over everything that arrived.

serial-consumer · drain looppseudocode
loop:
    msg = mailbox.take()              // blocks until a message arrives
    match msg:
        Input(obs):                   // newest-input-wins (PERISHABLE sources only)
            if turnInFlight: dropConflated(obs)   // observable: folds a conflation counter, never silent
            else: inFlight = startTurn(obs)

        Interrupt(signal):            // preempt: cancel-then-handle
            if turnInFlight:
                cancel(inFlight)
                join(inFlight)        // wait until it fully unwinds
            inFlight = startTurn(signal)

        Drain(request):               // defer: never preempts
            if turnInFlight: join(inFlight)
            finalize(request)         // seal the artifact, then exit

    // a turn that threw degrades OBSERVABLY, with its cause — it never kills the loop
    outcome = await(inFlight)              // Ok(step) | Threw(fault) | Idle
    match outcome:
        Ok(step)     -> applyStep(step)   // applyStep = fold(state, step.toolResults, now) -> commit -> perform
        Threw(fault) -> publishStatus(Error(fault))   // cause preserved; never a causeless DEGRADED
        Idle         -> publishStatus(Ready)          // "nothing to do" is not a failure

12.4A failed turn degrades, it does not crash

Turns fail — a backend times out, a tool errors, the reasoner returns nothing usable. A failure must never take down the serial consumer, because the consumer is the whole system's heartbeat. So a turn that throws is caught and degraded to a typed status that carries its cause (Error(fault), distinct from a legitimately-idle turn): the fold proceeds with no emissions, and the consumer publishes that status rather than propagating the exception or dropping the reason. The next message is drained on schedule. One bad turn is a momentary, observable dip — surfaced as state per the signed-command discipline — not a fatal stop.

sequenceDiagram
  participant SRC as input source
  participant ISR as interrupt source
  participant MBX as mailbox
  participant CON as serial consumer
  participant AGT as agent loop
  SRC->>MBX: post Input(obs-1)
  MBX->>CON: take Input(obs-1)
  CON->>AGT: start turn (obs-1)
  SRC->>MBX: post Input(obs-2)
  Note over MBX,CON: turn in flight → obs-2 busy-dropped (newest-wins)
  ISR->>MBX: post Interrupt
  MBX->>CON: take Interrupt
  CON->>AGT: cancel + join turn(obs-1)
  AGT-->>CON: unwound
  CON->>AGT: start turn (interrupt)
  Note over MBX: Drain posted while interrupt runs
  MBX->>CON: take Drain
  CON-->CON: join in-flight turn (defer)
  CON->>CON: finalize
  
Fig 12.1   An Interrupt preempts an in-flight Input turn via cancel-and-join, while a stale Input is busy-dropped and a Drain defers until the running turn completes. Generic participants; no domain-specific stimulus.
DE-BRANDED ON PURPOSE

The participants here are Input, Interrupt, and Drain — not any particular sensor, voice channel, or flush button. The three policies hold for any real-time source: a perishable stream that should conflate, a barge-in that must preempt, and a shutdown that must drain. Map your concrete stimuli onto the three and the consumer is unchanged.

13

End to end: one event's journey

Putting every layer together. Follow a single raw input event from the source to a rendered surface and a line in the generated artifact. This is the whole system in one annotated trace, told with generic participants.

The running example throughout is a support-ticket triage console (illustrative only): an agent reads an incoming ticket stream, drafts replies, sets priority, and requests escalation, and a rare human operator can take the same actions through the same commands. Watch one ticket arrive and become durable state. The named participants are the Source (an event sampler), the Boundary (the boundary adapter), the Agent (the agent loop), a Capability tool (perception), a domain tool (record), the Reducer, and the passive Surfaces.

sequenceDiagram
    autonumber
    participant SRC as Source / sampler
    participant B as Boundary (adapter)
    participant AG as Agent loop
    participant CT as Capability tool (perceive)
    participant DT as Domain tool (record)
    participant RD as Reducer (pure fold)
    participant UI as Surfaces / artifact
    SRC->>B: dispatch(Event(ticket, payload))
    B->>B: stage event · wake serial consumer
    B->>AG: run(prompt, read-only context)
    AG->>CT: perceive (tool call)
    CT-->>AG: text summary + signal hits
    AG->>DT: record (tool returns payload)
    DT-->>AG: payload (mutates nothing)
    AG-->>B: onStepFinish(step.toolResults)
    B->>RD: fold(state, results, now)
    RD-->>B: newState + Effect.Recorded
    B->>UI: update published State (entry + draft)
    B->>UI: dispatch Suggest(Actor.Agent)
    B->>UI: perform delivery effect (artifact line)
    Note over UI: surface re-renders · line in the work product
Fig 13.1   One event, every layer. Perception is a tool; state is a fold; the actor is stamped once, at the boundary.

13.1The trace, step by step

Read the sequence as seven mechanical moves. Each names the property it exercises, so the trace doubles as a checklist for the whole architecture.

  1. The source hands one raw event to the boundary via dispatch(Event). The boundary stages it (newest wins) and wakes the single serial consumer. The raw payload, an incoming ticket here, never enters the agent's context directly.
  2. The consumer starts one agent turn, handing the reasoner a text prompt that carries the unit-of-work's running memory. The raw event is not in this prompt; the reasoner is modality-blind and must ask to perceive it.
  3. The agent calls the capability tool to perceive. The tool reads the read-only agent context, routes the staged payload to the sensing adapter, and returns a short text summary plus any business signals the event clearly shows. No raw blob crosses into the loop.
  4. Having "perceived" the event, the agent calls the domain tool record with a one-line summary, signal verdicts, and a keep-this flag. The tool is pure: it returns that payload unchanged and mutates nothing.
  5. On step finish, the runtime surfaces the tool results onto the step. The boundary folds them through the pure reducer fold(state, results, now), which returns new immutable state plus an Effect.Recorded descriptor. Identity and the clock value are injected here, by the boundary, never minted by the model or the tool.
  6. The boundary performs the descriptor: it appends the recorded entry (and the kept draft) to the published state, dispatches a Suggest command stamped Actor.Agent, and performs the delivery effect that writes one line to the work product. This dispatch is the only place the actor is stamped.
  7. The passive surfaces react. The console shows the updated entry and draft reply; the generated artifact, the customer's resolution summary, accumulates one more line. The human operator did nothing.
What just happened, architecturally

A model decision became durable product state without one line of imperative "the agent does X" glue. Perception was a tool, memory was the growing context, the new state was a pure fold, the side effects were declared as data and performed at one boundary, and the human-versus-agent distinction was stamped exactly once. Every property this reference claims showed up in seven steps, in a domain that has nothing to do with the original example.

Notice what is absent from the trace. No surface decided anything. No tool wrote back into the context. No identity was minted inside the model, so the same turn replays to the same state. The trace is identical whether the triggering actor was the agent or, rarely, a human operator pressing the same command, the only difference is the value stamped at step 6. That uniformity is exactly what makes the replay and recovery story in the next section possible.

13.2The vertical slice: one verb, end to end

The fragments scattered through sections 4–9 assemble for a single capability here. This is the artifact to copy first when you adopt the pattern — one verb, setPriority, shown as four coherent pieces in the order data flows: the command case, the domain tool, the fold arm, and the boundary call that stamps the actor and performs the effect. Everything else in the application is a variation on this one slice.

one capability, four pieces, in dataflow orderpseudocode
// 1 — THE COMMAND CASE  (the signed verb that lands on the bus)
sealed Command { ...
  case SetPriority { by: Actor, ticketId: Text, level: Priority, supersedes: Priority? }
}

// 2 — THE DOMAIN TOOL  (pure: reads read-only ctx, RETURNS the decision; mutates nothing)
DOMAIN_TOOL setPriority {
  input  = { ticketId: Text, level: Priority }
  output = { ticketId: Text, level: Priority }     // raw inputs; the fold derives the rest
  run(input, ctx) -> Out:
    return { ticketId: input.ticketId, level: input.level }
}

// 3 — THE FOLD ARM  (pure: matches the tool's name, derives the transition + an effect as DATA)
fold(state, results, now) -> (State, [Effect]):
  var s = state                                               // accumulate from the prior state
  var fx = []
  for result in results:
    match result.toolName:
      "setPriority" ->
        prior = s.priorityOf(result.ticketId)                 // fold owns the transition
        s     = s.withPriority(result.ticketId, result.level)
        fx   += Effect.LogDecision(result.ticketId, result.level, supersedes = prior, at = now)
  return (s, fx)

// 4 — THE BOUNDARY CALL  (the ONE place: mint id + clock, fold, stamp Actor, perform effects)
onStepFinish(step):
  (newState, effects) = fold(currentState, step.toolResults, now = clock.read())
  append(step.signedCommands, step.toolResults)              // COMMIT = the durable log write (14.6)
  commit(newState)                                            // derived cache; ids from the committed sequence
  for fx in effects: perform(fx, mode = LIVE)                 // gated on append; one seam; stub on replay
  bus.dispatch(SetPriority(by = Actor.Agent, ...newState.lastDecision))   // actor stamped HERE, only

Read top to bottom and the seams the earlier sections taught in isolation are now visible as one flow: the tool returns raw inputs (§4), the fold derives supersedes from its own state rather than trusting the tool (§4 division-of-labor), the effect is data not an action (§6 / §14.2), and the actor is stamped exactly once at the boundary (§5). A human tap differs only by passing by = Actor.Human at the final line.

14

Replay, safety, and recovery

Invariant I4, everything is replayable, is the load-bearing claim of this architecture. This section makes it rigorous, then cashes it out for the two concerns that depend on it: gating irreversible actions, and recovering from realistic failures without losing finished work.

14.1Replay, made first-class

Every decision, human or agent, is one signed Command on one append-only stream, and any off-bus input the fold consumed (see what rides the bus) is captured as an ordered fixture reference on the same timeline. State is a pure fold over that recorded timeline. Those facts compose into a single equation that is the whole of replay:

the replay equationpseudocode
// State is never stored as a primary truth. It is DERIVED, always, from the
// recorded timeline = the signed commands AND the captured tool-results /
// ordered direct-fold input references. Re-folding the timeline re-runs neither
// perception nor the model — recorded results are fed back in as inputs.
recordedTimeline = signedCommands  +  capturedToolResults  +  inputFixtureRefs

// foldAll iterates the step-level fold(state, results, now) over the timeline,
// drawing each step's `now` and results from the captured fixtures — never live.
state          = foldAll(initialState, recordedTimeline)

// Any historical state is just the same fold over a prefix of the timeline:
stateAtStep(k) = foldAll(initialState, recordedTimeline.take(k))

Because the timeline is append-only and the fold is pure, history is not merely kept, it is recomputable. The bus is the audit log of decisions; the captured tool-results and input fixtures are what make the fold reproducible. Three capabilities fall out of the same fold (each with one discipline noted below):

  • Scrub a cursor. Move a playhead to step k and re-fold the prefix to see exactly what the system believed at that moment, no snapshots required.
  • Fork a what-if branch. Take the prefix up to step k, append a different command, and fold the alternate tail. The original stream is untouched.
  • Diff two runs. Fold two timelines and compare the resulting states (and emitted effects) field by field. A regression is a diff, not a guess.
DIFF NEEDS VALUE-EQUALITY

Field-by-field diff only works on state that compares by value. State that embeds binary payloads — images, audio, sensor buffers — breaks this: a raw byte buffer typically compares by identity, so two folds producing semantically identical state come out unequal. The fix is to keep blobs out of diffed state. Store a stable content hash or reference in the folded state (as AttachEvidence(blobRef: Ref) already does) and keep the bytes in a side-store keyed by that reference. Then the diffed state is all small, value-comparable fields, and the scrubber/fork/diff stay trustworthy.

CONTENT HASHES NEED A CANONICAL ENCODING

Value-equality diff and content-addressed references hold only if the log has a canonical, versioned serialization — fixed field order and deterministic encoding of floats, enums, and optional/absent fields. Two stacks that agree on the logical shape but disagree on the wire encoding will produce different hashes for the same record and a spurious replay divergence. Cross-language portability (17.3) therefore requires agreeing on this wire format, not just the contract shape, and the content-address scheme must pin the canonicalization rule alongside the schema version (14.7).

FOLD FROM GENESIS ASSUMES BOUNDED SESSIONS

“No snapshots required” (14.1) is a virtue for a short turn and a trap for a long one: re-folding from genesis on every restart, scrub, or recovery is O(timeline length), and the deployments this architecture most recommends — a server agent, an ambient assistant (14.3) — run indefinitely. The extension is purity-preserving: a snapshot is a memoized fold prefix, so fold(snapshot@k, timeline[k..]) equals fold(initialState, timeline) at bounded cost, and the snapshot remains a derived cache the log can always rebuild. Two rules keep it honest: tag every snapshot with the reducer version and timeline offset it covers (a snapshot taken under an old reducer is untrustworthy under a new one — see 14.7), and accept that compacting below a snapshot trades away the ability to scrub or fork before that point. Retention is a product policy, not an architectural constant.

flowchart LR
    LOG[["Command log (append-only)<br/>c0 · c1 · c2 ... ck ... cn"]]:::bus
    FOLD["fold(initialState, prefix)<br/>pure"]:::core
    ST(("state @ cursor")):::state
    CUR["scrubber cursor at k"]:::user
    FORK["what-if: append c'k<br/>fold alternate tail"]:::agent
    LOG --> FOLD --> ST
    CUR -. "re-fold prefix.take(k)" .-> FOLD
    FORK -. "branch at k" .-> FOLD
    classDef bus fill:#0f1116,stroke:#f23a48,stroke-width:2px,color:#fff;
    classDef core fill:#0f1116,stroke:#f23a48,stroke-width:1.5px,color:#fff;
    classDef state fill:#14161d,stroke:#f23a48,color:#fff;
    classDef user fill:#11151c,stroke:#7c8aa0,stroke-width:1.5px,color:#cdd5e0;
    classDef agent fill:#1a1012,stroke:#f23a48,stroke-width:1.5px,color:#ffd7da;
Fig 14.1   The replay strip: the command log folds to state at any cursor; a what-if forks at step k without disturbing the original.
Replay has a hard prerequisite

Replay is only correct because the boundary injects the clock and every id (6.3), so no tool reads ambient time or mints its own. This is not an advanced add-on — it is what makes the boundary the boundary (just below). Skip it and your replay silently diverges the first time a tool re-reads a live value on re-fold — the most expensive way to learn this. Surviving a process crash is a further, separable step (14.6): in-memory replay does not need it, a production system does.

14.2The determinism boundary

Replay is only a contract if the fold is deterministic, and a fold is only deterministic if nothing inside it reaches for the wall clock, a random number, or the network. The architecture quarantines all three behind one rule: identity and clock are injected at the boundary adapter, never minted by the model or a tool (invariant G9).

RE-FOLD IS NOT RE-RUN

Two operations are easy to conflate, and only one is deterministic. Re-folding a recorded timeline — feeding the captured tool-results and input fixtures back through the pure fold — is deterministic, and is what the scrubber, fork, and diff below rely on. Re-running the agent — re-invoking the model to regenerate tool calls — is not deterministic: a model at any non-zero temperature, or with nondeterministic kernels or batching, emits different tool calls for the same input. Replay never re-invokes the model. It treats each recorded model output as an external, already-decided input, exactly like a recorded clock reading. (Stable re-runs are a separate, stricter goal that additionally requires greedy decoding and a deterministic provider — see forcing the call; they are not what makes replay a contract.)

Injected, not minted

The boundary passes the clock value into fold(state, results, now). A model-minted id dies on a context summary or a replay and causes phantom retries; a tool-minted id breaks tool purity. So neither does it, the boundary owns identity and time.

Effects as data

The reducer never performs a side effect. It emits an effect descriptor, plain data, which the boundary performs through one perform(effects, mode) seam. Because every effect routes through that single seam, replay mode can stub or no-op the descriptors there — re-folding a timeline changes state without re-sending a notification or re-charging a customer. The stubbing only works if effects route through that one gate; scatter notify()/append() through the fold and the replay seam is lost.

Why this turns a slogan into a contract

"Replayable" is meaningless if folding the same log twice yields two different states. By injecting time and identity at one seam and turning every non-deterministic effect into a stubbed descriptor, the fold becomes a pure function of its inputs. Same log in, same state out, every time. That is what makes the cursor, the fork, and the diff trustworthy rather than decorative.

14.3Gating irreversible actions

One bad inference must never trigger an irreversible effect. The architecture classifies every action as reversible or irreversible and applies a single load-bearing rule to the latter: an agent may only dispatch a non-destructive Request; the destructive Command is minted only after an explicit, out-of-band confirmation.

In the running console, "end the session" is irreversible, it finalizes and ships the artifact. The agent's tool dispatches RequestEndSession, never EndSession. That request opens a pending-confirmation state. Only an explicit confirmation, a deliberate human "yes", promotes it to the destructive EndSession. A single misheard transcript or a hallucinated tool call cannot finalize anything.

stateDiagram-v2
    [*] --> Active
    Active --> PendingConfirm : Agent dispatches RequestEndSession (non-destructive)
    PendingConfirm --> Ended : explicit confirm  =>  mint EndSession (irreversible)
    PendingConfirm --> Active : timeout / deny  =>  fold Expired marker
    Ended --> [*]
Fig 14.2   The irreversible-action gate. The agent can only ask; an explicit out-of-band confirm is the only path that mints the destructive command.

The pattern generalizes to any destructive action, refund, delete, publish, send. The agent's reach stops at the Request; the promotion to the irreversible Command is a separate, explicit, auditable command on the same bus, so even the gate itself replays.

WHEN NO HUMAN IS PRESENT

The fully autonomous deployments this architecture most recommends — a server agent, an ambient assistant — often have no human in the loop, so "a deliberate human yes" cannot be the only confirmer. The gate still holds: the confirmer is simply a separate authority on the same bus rather than necessarily a person. It may be a policy tier that approves against rules, a second-agent reviewer, a deferred human-approval queue, or a deny-on-timeout default that folds an Expired marker (observable, never a silent drop) if nothing confirms within a window — and because an all-timeout regime is a silent liveness failure (irreversible work never finalizes and nobody is told), surface “N requests expired unconfirmed” as state. Whatever the authority, the promotion remains a distinct, signed Command from a different actor than the one that issued the Request — so the gate is meaningful unattended, and it still replays.

AUTHORIZATION IS NOT ATTRIBUTION

The Actor stamp records who did an action; it is “preserved for audit, not for branching” (5.2, G1). It is not who may. Nothing in the contract scopes which tool an actor can invoke, on which entity, now — the human host and the agent legitimately often have different capability sets, and the “indistinguishable downstream” claim is about mechanism, not permission. If your deployment needs authorization, it belongs at the boundary, before the fold commits: a deterministic check keyed on (actor, tool, args, current-state), and a denial folded as an explicit Denied marker (mirroring the 6.5 else-arm) so it replays like any decision — never a silent drop. Note that activeTools (9) is a quality/sequencing device, not an authorization boundary; do not mistake it for one. Authorization, persistence (14.6), and configuration/secrets are real once-per-app seams this reference deliberately leaves to your product — the runtime spine plus your thin once-per-app wiring is the architectural core, not the entire app.

THE GATE IS ONLY AS GOOD AS THE CLASSIFICATION

“Safety is structural” (16.1) rests on a labeling exercise the gate itself never scrutinizes: an action mislabeled reversible skips the gate entirely. Three rules harden it. Default-deny: a tool not explicitly classified reversible is gated as irreversible, so adding a tool forces a classification decision. Make it a reviewed artifact: the classification is a row in the registry, checked by the gate like the loop allowlist — it has an owner, not a guess. Classify by external effect: reversibility is context-dependent (a draft is reversible internally, irreversible once it reaches the recipient), so classify by whether the action crosses a trust or visibility boundary, not by whether an internal undo exists.

BEYOND REVERSIBILITY: RATE AND BLAST RADIUS

Per-action gating catches the destructive single act; it does nothing for aggregate damage. An agent firing ten thousand individually-reversible Suggests, mass-reprioritizing a whole queue, or draining a token budget trips no irreversibility gate, and the step-count stop condition bounds one turn, not a session. Add a cumulative budget folded into state and checked at the boundary: caps on actions-per-window, per-entity and per-tenant blast-radius limits, and a cost/token ceiling. Exceeding any of them is a sealed Degraded status (12.4), not an exception — and because the budget is folded, it replays. Catalog row: Rate / blast-radius / cost budget exceeded → the boundary folds Degraded and stops issuing → invariant: aggregate action is bounded, not just per-action reversibility.

14.4The failure-mode catalog

Failure is modeled as a sealed status, not flag soup, so the surface can render exactly one of a closed set of states and never an impossible combination of booleans.

status as a sealed type, not flagspseudocode
sealed type RunStatus =
    | Idle                       // nothing in flight
    | Working(step)              // a turn is running
    | Interrupted(reason)        // a barge-in preempted the turn
    | Degraded(cause)            // backend slow / partial; reduced surface
    | Done(result)               // turn finished cleanly
    | Error(fault)               // unrecoverable for this turn; prior work kept

Each realistic failure has a detection point and a recovery that preserves an invariant rather than papering over the fault:

FailureDetectionRecoveryInvariant preserved
Malformed tool calloutput fails to parse against the schemarepair / reprompt the model to fix the callno bad payload ever folded
Mid-loop model failurea step throws or returns nothingper-step fold keeps every completed step's workfinished work survives
Runaway / repeating loopstep count or repeat-guard tripsstop-condition ends the turn deterministicallya turn is always bounded
Stalled backendbounded turn deadline elapsesfall to Degraded; surface a reduced statethe consumer never blocks
Stale input conflated away (perishable sources)a newer event overwrote a pending onenewest-wins by design; the drop folds a conflation counter (observable, not silent) — for durable queues, never conflate (12.2)reason over the freshest view; nothing leaves the record unseen
Capability source unavailablea perceive/recall tool's external source fails — decode error, missing row, search timeoutthe tool returns a typed empty/error result; the turn degrades or the model re-asks; no raw blob crosses the wallperception stays behind the tool boundary
Command / reducer schema evolvedan old log is replayed against a newer reducer whose arms or payload shapes changedversion the Command and pin the reducer version on replay; migrate the log or keep old fold armsold traces stay re-foldable
Schema-valid but out-of-policy argsa payload parses against the schema but violates current state or policythe reducer folds a Rejected marker plus a diagnostic; the agent re-asksno out-of-scope mutation is ever folded
Duplicate input delivered (queue redelivery)a source id already seen arrives again on an at-least-once queuededupe on the source id; an idempotent fold makes the redelivery a no-opeach work item folds exactly once
Rate / blast-radius / cost budget exceededa folded cumulative budget — actions-per-window, per-entity, or cost/token ceiling — is crossed at the boundarythe boundary folds Degraded and stops issuingaggregate action is bounded, not just per-action reversibility
Effect failed to perform (live)perform() returns an error or throws on a folded effectthe failed effect re-enters as a typed input (Effect.Failed(id, cause)); the fold marks it and the domain compensates with an explicit Command (no hidden rollback, 12.3)a committed decision whose effect failed is visible, never silently divergent
Append (the durable commit) failedthe log write throws — disk full, store unreachable, a sharded-bus partitionperform is gated on append: no effect fires; fold Degraded(persistence); retry from the same tool-resultsno effect fires for an un-recorded decision — the log-vs-state tear cannot open
Cross-tier recall timed outthe relay read exceeds its bounded deadline (11.2)degrade to last-known, but typed: LastKnown(text, age)FreshEmptya slow store never stalls the hot path, and stale is never presented as fresh
Irreversible effect partially appliedperform() half-completes an un-compensable effect — the external action fired, its confirmation write did not (the un-compensable twin of Effect failed to perform above; the split is whether a compensating Command can still reach the world)no later Command can undo the world, so the boundary folds Error(partial, id) and emits a typed reconciliation marker for an out-of-band seam (like persistence, 16.2); serial perform bounds this to at most one in-flight effect, and append-before-perform guarantees none ever fires for an un-recorded decisionthe un-compensable case is recorded and flagged for reconciliation, never silently divergent
Why per-step folding matters here

Because the fold runs per step rather than after the whole generation, a model that dies on step four still keeps the durable results of steps one through three. The unit of loss is one step, never one turn. That single choice converts "the model glitched" from a data-loss event into a logged, recoverable status.

14.5Testing falls out of replay

Nothing about testing is bolted on, it is a direct consequence of purity and the log. Four layers of test, each cheaper than the last:

  • The pure reducer is tested by direct call: hand it a state, a list of tool results, and a fixed now; assert the returned state and effect list. No model, no surface, no clock.
  • A tool is tested by direct call against a fake read-only context. Because the tool only reads and returns a payload, the assertion is a plain input/output check, no mocks of a mutable bag.
  • A recorded-trace harness replays a captured command stream through the real domain via the domain port (with a fake adapter standing in for the live runtime), and asserts a golden folded state plus a golden effect sequence.
  • Production traces are the fixtures. A real session is already a command stream, so capturing one and checking it into the harness turns a real incident into a permanent regression test, for free.

One scope boundary keeps the claim honest: every layer above validates the pure core and the fold — the reducer's reaction to results and the determinism of the re-fold. None of them validates model behavior. Whether the live reasoner emits the right tool calls (the failure mode weak models exhibit) is a separate model-in-the-loop evaluation; the recorded-trace harness feeds scripted results past the model entirely, so off-target validation covers reducer logic, not the reasoner.

The chain closes on itself: replay makes the system debuggable, the determinism boundary makes replay a contract, the contract makes tests trivial, and captured traces feed the tests. The single-event trace you just read is, in test terms, one such fixture.

14.6Recovery is not replay

Replay and recovery look alike — both re-fold the recorded timeline — but they end in different modes, and the difference is load-bearing. Replay re-folds to inspect, and stubs every effect at the perform seam. Recovery re-folds after a real crash and then returns to live, driving real effects again. The replay machinery makes the first safe; it says nothing about the second. Closing that gap needs two definitions the prose so far only gestures at: a commit point, and an idempotency key.

THE COMMIT IS THE LOG APPEND

Section 14.1 already insists state is never stored as a primary truth — it is derived, always. Take that literally: the durable write that constitutes commit is appending the step's signed commands and captured results to the append-only log. Folded state is a rebuildable cache, not the source of truth. On restart the boundary re-folds the log to reconstruct state, then resumes. This is what makes “resumes from the last committed state” (6.6) and “recovery is just state” true rather than asserted: the log is the commit, so there is no state-vs-log disagreement to tear.

That fixes ordering but opens a window: the boundary appends (commits), then performs effects. If the process dies after commit and before (or during) perform, recovery re-folds the committed step and re-emits its effects. Re-driving them is at-least-once delivery — and at-least-once is the right target, because exactly-once across a crash is generally impossible. The architecture already supplies the key that makes at-least-once safe: ids are minted deterministically from the committed sequence (6.3), so the effect's id is its idempotency key. The rule:

commit-then-perform, idempotent on the deterministic idpseudocode
onStepFinish(step):
  (newState, effects) = fold(currentState, step.toolResults, now = clock.read())
  append(step.signedCommands, step.capturedResults)   // COMMIT = the durable log write
  commit(newState)                                     // derived cache; rebuildable by re-fold
  for fx in effects:
    perform(fx, mode = LIVE, key = fx.id)              // id is deterministic from the sequence
  // crash anywhere after append -> recovery re-folds, re-emits effects with the SAME ids;
  // external sinks MUST dedupe on key, so a re-driven effect is a no-op, not a second charge.
THREE MODES, ONE FOLD

The fold is identical in all three; only the perform seam differs. Replay stubs effects (inspect only). Recovery re-drives un-acknowledged effects keyed by their deterministic id; idempotent sinks make the re-drive harmless. Live performs once. The “never re-charge a customer” guarantee thus holds on every path — on the replay path because effects are stubbed, on the recovery path because sinks dedupe on the effect id. A sink that cannot dedupe is the one place this architecture pushes a requirement outward.

14.7Schema evolution without rewriting history

The Command set and the fold will change over an application's life, and old logs must still replay — that is the whole premise of “production traces are permanent fixtures” (14.5). Two strategies are tempting and one is wrong. Rewriting historical commands to the new shape contradicts append-only (14.1): it destroys the audit trail and invalidates any hash or signature over the original bytes. The correct path is upcasting — never touch history; transform an old record into the current shape on the way into the fold.

  • Version at the envelope. Wrap every record as { schemaVersion, type, by, payload }. The version travels with the record, so a log written across five deployments is self-describing.
  • Upcast on read. A chain of pure upcasters lifts v(n) to v(n+1) at load time. The fold only ever sees current-shape records; old fold arms can retire once no live log needs them. This keeps the dispatch “closed” (6.5) instead of accreting historical arms forever.
  • Pin the golden trace to a version. A field-by-field state diff (14.1) over an evolved State shape is trivially unequal, which silently breaks the regression story. So store each captured trace's expected folded state alongside it, tagged with the reducer version that produced it, and re-baseline only on an intentional change — never let a shape change masquerade as a regression.
PROMPT VERSION IS A CAPTURED FIXTURE

Prompts and tier policy are injected as assets (7.3), and the prompt that shaped a recorded run is off-bus input just like a captured tool-result. Re-folding does not re-run the model, so the prompt does not affect a re-fold — but an audit (“why did the agent decide this?”) is meaningless without it. Add the active prompt/policy version to the captured-fixture set so every trace names the prompt that produced it.

15

Executable architecture: enforce, don't review

An architecture survives high-volume change, much of it AI-written, only when its invariants are enforced by blocking checks that deny-and-fix at the tool boundary, not by hope or by code review. This section presents the guarantees as named, portable invariants you can adopt verbatim.

15.1The failure mode

The defining way this architecture rots is quiet: an author, human or model, writes idiomatic code from a different paradigm that happens to break a contract. A surface handler grows a branch. A tool writes back into the context "just this once." A loop body gains control flow. None of it looks wrong in isolation, each is a perfectly normal habit imported from another stack, and that is exactly why review misses it. The contract is violated structurally, and the violation compounds.

The lesson is blunt: do not trust review to catch architectural drift. A reviewer reads for intent and correctness, not for "does this file import from a layer it must not see." Models, which write most of the code at volume, ignore warnings entirely. The only reliable enforcement is a machine that refuses the change.

15.2Enforce, don't review

Make the architecture executable. Encode each invariant as a check that denies at author or build time, a pre-write gate, a commit hook, or a CI stage, with a fix-it message that states the remedy. A warning is a suggestion a model will skip; a denial is a wall it must route around correctly. Two discipline rules keep the gate honest:

  • Every check ships a paired block-test and allow-test. The block-test proves the violating shape is rejected; the allow-test proves idiomatic, compliant code passes untouched. Without the allow-test, a check drifts into a nuisance that authors disable.
  • A wrong rule is fixed, never disabled. When a check rejects legitimate code, the remedy is to correct the check and add a test, not to switch off enforcement. The gate is a living specification, not a static wall.

15.3The guarantees, as named portable invariants

The invariants below are deliberately stack-agnostic. Each carries a generic id and states the single guarantee it enforces. Adopt the ids as-is; the checks that implement them differ per language, the guarantee does not.

IdInvariantWhat it guarantees
G1actor-stamped-at-boundaryEvery Command carries its Actor; the actor is stamped only at the boundary adapter and can never be forged on a surface.
G2tool-reads-context-onlyA tool may read the agent context but never write to it. Results flow out as a returned payload, folded at the boundary.
G3loop-is-declarationThe agent loop is configuration only, no control flow, no business logic in the loop body or its lifecycle side-methods.
G4domain-imports-nothing-foreignThe pure domain core imports no framework, transport, UI, or platform types. I/O lives only at the named edges.
G5surface-decides-nothingA surface handler maps one interaction to exactly one action and contains no decision — no domain/policy branch on business state to decide what is true, and no presentational decision (whether to show an empty state, whether a control is enabled) computed in the view or the controller. The view renders by pre-computed values: it applies flags the view projection already decided (showEmptyState, isEnabled) and reads which variant of a state to draw — it never computes the flag behind the branch. Rendering by a pre-decided value stays in the view; computing the decision belongs to the projection (6.9).
G6irreversible-action-gatedAn agent may dispatch only a non-destructive Request for an irreversible action; the destructive command needs explicit confirmation.
G7no-service-locatorsDependencies are injected at one composition root; no global, no ambient locator, no module-level mutable state.
G8single-state-single-sinkA surface controller exposes exactly one immutable value — the ViewModel projected from the one domain State (6.9) — and one action sink, nothing else public. The projection is derived and read-only, so it is no second source of truth.
G9identity-and-clock-injected-at-boundaryNo model or tool reads the wall clock, draws a random number, or mints an id. The boundary passes now into the fold and assigns ids deterministically from the committed sequence. The same discipline extends to any tool whose result depends on an external source: it must be recorded as an ordered fixture and fed back on re-fold, never recomputed. This is the rule replay depends on most.
G10dependencies-point-inwardEvery import points inward toward the pure domain, which imports nothing foreign; only the single composition root spans layers. A surface imports State and Command types only — never the fold, a policy, or any adapter — adapters never import one another (they meet at core-declared ports, wired at the root), and the core never names a surface or transport. Each forbidden edge — surface→infra, surface→reducer, domain→surface, adapter→adapter, tool→framework — is a structural break in unidirectional flow, port substitution, or replay, not a style note.
G11component-isolationA feature may be authored as a self-contained block whose public face is one or more tools and whose internals — a namespaced State slice, fold arms, a view-model, a port interface, and a private adapter behind that port — are owned by the block and invisible to siblings. Isolation grants no new power over the spine: the block's tools stay pure (input, ctx) -> payload and perform no I/O (its repository is an injected read-only capability for reads, an effect descriptor performed at the one boundary for writes); every external read it makes is captured as an ordered fixture on the one timeline and replayed, never recomputed; it contributes cases to the one sealed Command and arms to the one fold but owns no bus, log, reducer, boundary, or composition-root wiring; its state is a slice of the one immutable State, never a separate root; it holds only ephemeral, non-replay-relevant view-state and never business truth; it emits an unsigned intent and never stamps the Actor; and blocks couple only through shared domain types, the one bus, and the one folded State — never by importing a sibling's internals. The block owns the leaves; the spine owns the trunk, and the trunk is never per-block. The failure this catches that G10 cannot: a sibling coupling to this block's State slice by its internal shape rather than through the bus and the block's published slice contract — import-legal, since the slice sits on the one shared State both are entitled to read, yet exactly the cross-block coupling isolation forbids. G10 governs import direction; G11 governs what a block may expose and what a sibling may lean on — a failure no forbidden import would reveal.
G12exhaustive-discriminated-modelingA value that is “one of a fixed set” — State, the run status, a command outcome, a presentational variant on the view-model — is modeled as a sealed discriminated union whose variants each carry their own payload, never as loose booleans (flag soup) or a bare string. Consumers handle it with a closed match and no catch-all, so the type system proves every case is handled and adding a variant fails to compile at every site that must handle it — the fold arm, the view projection, the boundary's command map — until each is updated. Illegal combinations (a Failed with no reason, a Loading-and-Done) are unrepresentable rather than merely avoided. The missing case surfaces at build time, never at runtime (6.10).
G13contract-first-stable-interfaceEvery seam a module presents outward — a core port, a block's port-and-adapter, a block's tool registration plus the slice shape siblings read off State — is a published, frozen contract: a stable public surface (inputs, outputs, shape invariants, error and tolerance semantics, written in a per-module coordination note) that downstream depends on instead of the implementation behind it. The contract lands first, as a skeleton, before its body; internals may then change freely without breaking any consumer; modules couple only through declared contracts, never hidden shared state, and each is tested against its own contract in isolation, without the rest of the workspace assembled. The governing check: an unfamiliar author can implement the module from its contract alone, never reading a sibling's source. This is narrower and more procedural than dependencies-point-inward (G10, import direction) or component-isolation (G11, ownership) — it adds the written, versioned contract, the skeleton-before-implementation order, and per-module test isolation those do not state. The outward interface is narrow and frozen; the module behind it may be deep, and the spine it contributes additive cases into stays open for extension (7.9). The failure this catches that G10 and G11 do not: a block that ships its body before its port is frozen, leaving a sibling nothing stable to depend on, so it couples to internals that then change underneath it — import direction (G10) and ownership (G11) both hold at that instant; only contract-first ordering prevents it. What the gate mechanically keys on is a snapshot: a published contract exists, the implementation conforms to it, and the module compiles and tests in isolation against that contract alone — so a block that imports a sibling's internals fails its own isolation build, whatever the authoring order — while shape-coupling through the shared State is caught not here but by G11, since that coupling rides a type both blocks may legitimately import. Contract-first ordering is itself a temporal property a tree-snapshot gate cannot read — it is the authoring discipline those structural checks make cheap to follow, enforceable directly only by a history-aware CI check and never claimed of the snapshot gate, with the fresh-author criterion as the heuristic behind it.
G14spine-swapped-only-by-contractA spine component — the bus, the fold, replay, the mailbox, or the gate — is replaced only by supplying a new adapter behind its published contract, never forked, bypassed, or weakened. The swap seams are a closed, named set; the laws (G1–G13) hold across every spine, default or custom. A spine change that needs more than one adapter, or that weakens an invariant, has left the architecture (8.5).

These fourteen are the structural spine, but a real gate also carries idiom checks that block the small tells of code written in the wrong paradigm: a forced unwrap of a nullable, an unchecked downcast, a raw runtime exception thrown as control flow, a stray console print, a loose top-level function where a typed boundary belongs. None of these are style preferences. Each closes one specific way an invariant above could quietly erode.

G9, STATED IN FULL

The enumeration “no clock, no random, no id” is necessary but not sufficient for a deterministic re-fold. A fourth, larger class hides in plain sight: a tool whose result depends on the live world — a search hit, a database row, a model caption — is “pure” under G2 (it read context and returned a payload) yet still diverges on re-fold if it re-hits the source. The capture discipline (5.4, 11.2) already covers this — but state it as part of G9 so the gate can enforce it: any tool result that depends on an external source must be recorded as an ordered fixture and fed back on re-fold, never recomputed. Add the matching self-check to 15.4: Can a tool's result change on re-fold because it re-hit a live source? If yes, the result is not captured and replay is unsound.

The management read

This is the answer to "how do you keep AI-written code correct at volume?" You make the architecture executable: a specification that fails the build rather than a wiki page nobody reads. In one implementation of this pattern, roughly four dozen checks back the invariants; the exact count is irrelevant, the principle is that each invariant has at least one check that denies, and each check has a block-test and an allow-test. Correctness then scales with the volume of generated code instead of degrading under it.

15.4A compliance self-check

The fastest way to know whether a codebase actually implements this architecture, rather than merely resembling it, is to ask falsifiable questions. Each maps to an invariant; a "no" where you expect "yes" (or vice versa) names the exact gap.

  • Can you re-fold a recorded timeline and get identical state? If folding the same recorded timeline (signed commands plus captured tool-results and input fixtures) twice diverges, the determinism boundary (G9 identity-and-clock-injected-at-boundary) is leaking. Note this asks about re-folding recorded results, not re-running the model.
  • Can a tool read the wall clock or call random()? If a tool body reads ambient time, draws an id, or pulls a random number instead of receiving them at the boundary, G9 is violated and re-folds diverge.
  • Can you unit-test a tool with no model and no surface? If a tool needs a running model or a live surface to test, it is not pure, G2 is violated.
  • Does any surface handler branch on domain state, or compute a presentational decision in the view? An if over business state in a handler — deciding what is true rather than rendering by state — means the surface is deciding, G5 is violated. The tightened reading also catches presentation: an if in the view (or the controller) that computes whether to show an empty state or whether a control is enabled is a presentational decision in the wrong place — it belongs in the view projection as a pre-computed flag (6.9), and G5 is violated. Rendering by a flag the projection already decided — applying showEmptyState, drawing the variant a control is in — stays in the view and is fine; the line is apply-a-pre-computed-value (allowed) versus compute-the-decision (not).
  • Can the UI mint Actor.Agent? If a surface can forge the agent actor (or an agent the human one), G1 is violated.
  • Can a tool write the context? If any tool body mutates the ambient context instead of returning a payload, G2 is violated and replay is unsound.
  • Can a tool's result change on re-fold because it re-hit a live source? If a tool re-queries a search API, a database row, or any external source on re-fold instead of replaying a captured fixture, G9 is violated and the re-fold diverges. The result is not captured and replay is unsound.
  • Can a surface file import an adapter or a domain internal? If a view or controller imports the inference, sensing, or persistence adapter, or the fold, a policy, or a use case — rather than State and Command types only — the dependency points outward or reaches past the core and G10 (dependencies-point-inward) is violated: it has opened a second mutation path the fold never sees, corrupting unidirectional flow and replay. The same failure is any outward edge from domain/, any sideways edge between two adapters, or any tool reaching a framework instead of returning a payload. Only the composition root may span layers; everywhere else, an import that does not point inward is a build failure.
  • Can a self-contained block fork a spine artifact, or hold business truth privately? If a block instantiates its own bus, replay log, boundary, reducer, or surface controller — rather than contributing Command cases, fold arms, and a namespaced State slice into the singular spine — or if its tool body performs I/O instead of reaching an injected read-only port, or its view-model holds any value that is folded, read by another block, part of the artifact, or needed to reconstruct the session, then G11 (component-isolation) is violated: the block has become a second baseplate and one of the one-bus / one-log / one-state / one-boundary guarantees has split in two. A block may own its leaves; it may never own the trunk.
  • Does adding a state leave any consumer silently unhandled? If a value that is one of a fixed set is modeled as loose booleans or a bare string rather than a discriminated union — or if a consumer matches it with a catch-all else — then adding a variant compiles cleanly while quietly falling through somewhere, and G12 (exhaustive-discriminated-modeling) is violated. The check: introduce a new variant and confirm the build breaks at every site that must handle it (the fold arm, the view projection, the boundary's command map) until each is updated. If it does not break, the case will surface at runtime instead — exactly the “surprise at the end” the discriminated union exists to prevent (6.10).
  • Could an unfamiliar author implement a module from its contract alone? Hand a fresh author — a new teammate, or an independent agent — one module's contract (a port, or a block's tool registration plus its declared port and slice shape, with its coordination note) and nothing else. If they cannot produce a conforming implementation without opening a sibling's source, the contract has leaked an implementation detail and G13 (contract-first-stable-interface) is violated: the seam is wiring, not a contract, and parallel authorship is no longer safe. The same failure is any module that can only be tested with the whole workspace assembled, any consumer that depends on an implementation behind a contract rather than the contract itself, any pair of modules that communicate through shared state neither one declares, or a contract written only after its body — which by then describes the implementation rather than constraining it (7.9).
  • Can the spine be changed without weakening an invariant? If adapting a spine component — a different bus, persistence, mailbox policy, or provider — is done by implementing one existing contract as a new adapter at the composition root, the architecture holds. If it requires forking the spine, touching more than one seam, or relaxing a law, G14 (spine-swapped-only-by-contract) is violated: you are no longer extending this architecture, you are leaving it.

If each answers the way the invariants demand, the architecture is real and enforced. If any answer is wrong, the corresponding check is missing, and the drift the next section warns against has already begun. The payoff the whole reference promises is contingent on these checks being present and blocking.

16

Why it pays off, and when not to use it

The same small set of decisions, one bus, pure folds, ports, and a gate, pay off repeatedly. This section cashes each one out as a concrete advantage you now own, then draws the honest boundary: the cases where this architecture is the wrong choice.

16.1The payoff grid

Each card below is tied to a decision the rest of this reference taught. None is academic; each is a property you can claim because you paid for it on purpose.

Harness and production share one core

Because the domain depends on the domain port and the logic is pure, a recorded/replay harness and the live deployment run the identical core. The reducer logic is validated off the target, then shipped unchanged. The fake behind the port replaces the model and loop, so this validates the core's reaction to scripted tool results — not that the live model will emit them; whether the reasoner calls the right tool is a separate model-in-the-loop evaluation the port-swap deliberately excludes. Decision: a pure core behind a port.

The whole session is replayable

One signed-command bus plus state-as-a-fold means any unit of work reconstructs from its command stream. Debugging becomes replay; there is no hidden mutable state to reconstruct. Decision: one append-only command stream.

Tools are trivially testable

A pure tool and a pure reducer are tested by direct call, no model, no surface, no mock of a mutable context. A new capability adds one reducer arm and one unit test. Decision: tools return payloads, never mutate.

Backends swap without ripples

One model-provider abstraction makes a networked dev backend and an in-process production backend interchangeable; relocating cognition touches one file. Decision: a single provider seam.

Safety is structural — the +Safety rung

Irreversible actions are gated by construction: the agent can only request; a single bad inference cannot fire a destructive effect because the destructive command needs an explicit confirm. Structural describes the mechanism, not an unconditional default: human override is part of the core, while the automated irreversibility gate is the +Safety rung (17.4) you add once an irreversible action exists to gate — and once added, safety holds by construction rather than by vigilance. Decision: the Request-then-confirm gate.

Human override is free

Every agent action already exists as a signed command, so letting a human take it is not new code, it is the same command with a different Actor on the same bus. Decision: human and agent are peers on one bus.

Recovery is just state

Failure is a sealed status folded into the same stream, and per-step folding keeps finished work when a turn dies. Recovery is a transition, not a special-cased rescue path. Decision: failure modeled as data.

Cognition scales in tiers

Adding a slower, deeper reasoner is additive: a second model at its own cadence, talking through an append-only relay. The fast loop never stalls on it. Decision: tiered cognition via a relay.

The surface is purely declarative

Because a pure view projection pre-computes every presentational decision onto a separate view-model type, the surface only applies flags it was handed — no if over data, no branch it has to get right. Presentation can be re-shaped in one pure, directly-testable function without touching domain truth, and the view holds nothing that can drift. Decision: a separate view-model, every decision pre-computed.

The compiler finds your missing cases

Because state, commands, and status are discriminated unions with the payload inside each variant, illegal combinations are unrepresentable and adding a state fails the build at every site that must handle it — the fold arm, the projection, the command map — instead of surfacing as a runtime surprise. Exhaustiveness is a property the type system proves, not a review you hope for. Decision: exhaustive discriminated modeling.

Plumbing is free

The connective tissue every feature would otherwise re-implement — dispatch, the boundary, the bus, routing a result to a fold arm, replay, the lifecycle, concurrency and barge-in — is identical for every feature and is provided once by the runtime spine, so you add no connective code per feature: that cost is about constant. What stays feature-specific is a small, fixed set of additive declarations — a tool, its fold arm, its Command case, its render — each an append to a closed map or sealed union (6.8), never a rewrite of shared logic. Per-feature cost goes from “scales with the codebase” toward “a few appends.” Decision: one reducer, one bus, one boundary, shared by every feature.

No re-fetch within a turn-chain

A tool result, once folded into the one shared State, is projected back into the next turn's read-only context, so the reasoner re-reads its own prior conclusion instead of re-issuing the tool to re-derive it — fewer external round-trips, and a turn that would have re-hit a live source (and diverged on replay) reads a captured value instead. It does this without reading mutable shared state — the context stays written-once-by-the-boundary, read-only-to-tools (G2, never the Hidden State Coupling anti-pattern) — and without bypassing cross-tier recall, which still arrives only through the bounded recall tool (11.2). The saving is within one agent's own folded results. Decision: state is a durable fold, not a per-turn scratchpad.

No per-screen lifecycle plumbing

Because the one State is scoped to the whole session, not to a screen, the usual per-screen ceremony — each view serializing its slice on teardown and restoring it on return — does not exist: a surface is a projection of state it never owns, so navigating away destroys nothing and arriving re-projects rather than reloads. This removes in-memory navigation lifecycle, not durability across process death: the log store, snapshots, retention, and crash recovery remain the product-supplied seams the reference is honest about (16.2, 14.6), with recovery still re-folding the committed timeline. Decision: session-scoped state; the surface is a projection, not a source.

The through-line: the architecture spends its complexity budget once, on the right abstractions, and afterward most new work is additive and cheap. That is the opposite of the usual trajectory for a fast-moving, heavily generated codebase, and it is the reason the pattern is worth adopting rather than merely admiring.

16.2Non-goals: when not to use this

A credible reference names its own boundary. This architecture is overhead, not leverage, in these cases:

  • Trivial CRUD with no agent. If a human taps buttons and the system stores rows, the inversion buys nothing. A command bus and a reducer are ceremony around a form.
  • Hard-real-time inner loops. Where a control loop must close in microseconds, a tool round-trip through a model is far too costly. Keep the agent out of the hot path; let it supervise, not actuate.
  • Single-shot prompts. If the work is one prompt in, one answer out, with no multi-step tool use, the loop, the bus, and the fold are scaffolding with no load on them.
  • No replay or audit requirement. If you will never need to reconstruct, diff, or audit a session, the append-only stream and the determinism boundary are cost without a corresponding payoff.
  • A managed log store and retention policy. This reference specifies the logical timeline, not where it physically lives, how it is bounded, or how long it is kept. Snapshots, compaction, and retention (14.1) are product policy you supply; if your deployment needs none of that durability, the spine still applies but the persistence seam is overhead.
The honest read

The cost of this architecture is real: one bus, a pure reducer, a boundary that mints identity, a set of blocking checks, and the discipline to keep tools pure. That cost is justified by replay, testability, safety, and override, properties that only matter when an agent is a primary operator and sessions must be trustworthy. Pay it when those properties are load-bearing; skip it when they are not.

16.3A decision rubric

Score the situation against four signals. The more that hold, the more the architecture pays for itself:

SignalIt pays off whenIt is overkill when
Who operatesthe agent is a primary operator acting on its owna human drives every action
Auditabilitysessions must be replayable, diffable, auditableno run ever needs reconstructing
Tool surfacethere are many tools, added over timea single fixed call does the job
Authorship volumecode is generated fast, at volumea small, slow-changing codebase

The compact heuristic: it pays when the agent is a primary operator, sessions must be replayable and auditable, the tool surface is broad, and code is written at volume. It is overkill for a single linear script. If three or four signals hold, adopt the full pattern; if one or none, a simpler shape is the right call.

16.4The default is complete: the discipline turns inward

The hazard of any prescriptive architecture is that its readers treat the full set of invariants as a checklist to finish — that fourteen laws must look more finished than four. This one does not work that way, and it holds itself to the rule it imposes on your code. The core — tools, the bus, the fold, the boundary — is the default, and the default is complete: for the large majority of agent-driven apps it already delivers replay, testable tools, and human override, and nothing past it is owed. Every tier beyond the core — the safety gate, the barge-in mailbox, tiered cognition, capability inputs, the full enforcement set — exists because it answers a named production failure, never because the set looked incomplete. The architecture adds a law only when an unprevented failure earns it; that is the same additive, name-the-failure discipline it asks of every feature you write (6.8).

So the same restraint is yours. The honest read of 16.2 judged whole apps; this judges each rung within an app you have already committed to the pattern. The test is one question, and it points the same direction the gate does: can you name the production failure this invariant prevents in your app? If you can, you have paid for the rung — take it. If you cannot, you do not need it yet: adopting it pre-emptively buys ceremony, not safety, and the architecture would rather you skip a rung than carry it unloaded. You climb only when an invariant you already hold begins to cost you (17.4), never to make the set look whole.

The discipline, turned inward

The architecture's central anti-accretion move — a feature is an append to a closed map, never a rewrite of shared logic (6.8) — is the move it makes on its own growth: the spine extends only through the single contract-bounded door (8.5, G14), never by accreting bespoke law. A prescriptive architecture that can never say “you are done” becomes the wall it set out to replace. This one says it: stop at the tier your app's failures justify, and treat every rung past the core as opt-in by named cost. The rigor is a gift to the majority precisely because the majority is licensed to stop early. The next section turns “adopt it” into a concrete checklist, a per-stack mapping, and the ladder (17.4) that makes stopping early actionable.

17

Reference and adoption

One place to look up every recurring term, then the actionable layer: a numbered adoption checklist tied to the invariants it satisfies, a per-stack mapping table, and a minimum-viable-versus-full ladder so you can start small and grow into the pattern.

17.1Glossary

The named contracts and terms that recur across this reference, in one table.

TermWhat it is
the runtimeA generic agent-loop library consumed as a dependency. Provides the tool-loop, the step lifecycle, a provider abstraction, stop conditions, a per-step hook, and a middleware seam. Zero runtime source in your repository.
the agent loopProvided by the runtime; your app configures it as a declaration only, model, tools, sampling, lifecycle, no control flow.
Command / ActorThe signed action type. Every command carries by: Actor (Human or Agent); both ride one bus, differing only by who signed.
the command busOne observable, append-only, replayable stream that every command flows through. The single source of truth for a session.
the boundary adapterThe reducer boundary. Depends on the runtime's agent seam, owns immutable state, folds tool results, mints identity and the clock, performs effects, and is the only place the Actor is stamped.
the pure reducerA pure function, fold(state, results, now) -> (newState, effects). Unit-testable by direct call. Distinct from the view projection (State→ViewModel, 6.9).
the agent contextRead-only ambient input per unit of work: the staged observation, the sensing handle, the deep-tier recall handle. Tools read it; nothing writes domain results to it; identity is never minted here.
the domain portA ports-and-adapters seam: dispatch an action, observe one immutable state. Lets a fake-in-test and the real runtime swap behind one interface.
capability-as-a-toolAny out-of-band input, an image, a document, a sensor blob, a DB row, an incoming event, a search result, reached by a tool that returns text. The reasoner is modality-blind.
fast tier / deep tierTwo (or N) models at different cadences communicating only through an append-only relay store, recalled via a tool. Neither blocks the other.
an effect descriptorA side effect emitted by the reducer as plain data and performed by the boundary. Quarantined here so replay can stub it.
the model providerOne provider abstraction over interchangeable backends. Requires two capabilities: tool-calling and constrained / structured decoding.
the view projectionA pure map project(state) -> ViewModel producing a presentational type distinct from domain State, pre-computing every presentational decision as a value the surface only applies. Functional core, not the controller (6.9). Distinct from the fold (stream→State).
discriminated unionA sealed, finite set of named variants, each carrying its own payload, consumed by a closed match with no catch-all so the compiler proves exhaustiveness. The canonical model for State, Command, run status, and ViewModel variants (6.10 / G12).
a contractThe published, frozen public surface of a seam — a port, or a block's tool registration plus the slice shape siblings read off State: inputs, outputs, shape invariants, error semantics, in a coordination note beside the code. The unit of decoupling; lands before its implementation; downstream depends on it, never the internals (7.9 / G13).
a product-owned seamA once-per-app concern the spine deliberately leaves to you, wired once at the root outside it: authorization (who may act, 14.3), persistence & retention (where the timeline lives, 14.6), configuration / secrets, and out-of-band reconciliation of an un-compensable partial effect (14.4). Load-bearing but not the spine — “the spine is the core, not the entire app” (1.3).

17.2Adoption checklist

A concrete path to standing the architecture up. Each step names the invariant it satisfies, so you can verify coverage as you go.

  1. Pick a runtime — and scope what it does not give you. Choose an agent-loop library that exposes a tool-loop, returns per-step tool results, and accepts a stop condition. Do not hand-write the loop. Most libraries stop there: prefer one that also provides the spine — the command bus, the fold, replay, the barge-in mailbox, and the gate; if none on your stack does, supply that tier once behind the contracts of 8.5, never forked into the spine. (satisfies G3 loop-is-declaration.)
  2. Define your Command with an Actor stamp. A sealed action type where every case carries by: Actor { Human, Agent }. (satisfies G1 actor-stamped-at-boundary.)
  3. Adopt the runtime's observable, replayable bus. Append-only, observable by surfaces, with the full stream retained for replay — the runtime spine provides the stream; you define your Command type on it rather than hand-rolling it. (satisfies the replay contract, I4.)
  4. Write your UI tools and domain tools as pure return-payload functions. Each reads the read-only context and returns a payload; none mutates anything. (satisfies G2 tool-reads-context-only.)
  5. Write the pure reducer fold(state, results, now). Route by tool name, parse payloads, fold every result totally, return new state plus effect descriptors — no clock read, no id minting, no I/O inside. (satisfies G4 domain-imports-nothing-foreign + G9 identity-and-clock-injected-at-boundary; the fold receives now, never reads it.)
  6. Write the boundary adapter. It mints identity and the clock, folds the step's results, performs the effect descriptors through one perform seam, and stamps the Actor, here and nowhere else. (satisfies G1 actor-stamped-at-boundary + G9 identity-and-clock-injected-at-boundary.)
  7. Define the domain port and inject a fake in tests. The domain dispatches an action and observes one state through this interface; a fake stands in for the runtime under test. (satisfies the port-seam guarantee — one interface, fake-in-test and live-in-production swap behind it — and the G8 single-state-single-sink shape the port exposes.)
  8. Gate irreversible actions. The agent may dispatch only a non-destructive Request; an explicit out-of-band confirmation promotes it to the destructive Command. (satisfies G6 irreversible-action-gated.)
  9. Wire it at one composition root. A single place binds every port to its adapter and injects prompts as editable assets, never hardcoded; no service locators. (satisfies G7 no-service-locators.)
  10. Add the blocking enforcement checks. Each invariant gets a check that denies at author or build time, with a paired block-test and allow-test. (satisfies the executable-architecture mandate.)

17.3How each contract lands in your stack

The contracts are platform-neutral; their shape in code differs by stack. This table maps each contract onto four common environments so the seams are recognizable wherever you adopt.

ContractTyped compiled languageDynamic scripting languageServer / web backendCLI / TUI
the surface controllera component + view-model with one state field and one action methoda component hook returning [state, dispatch]a request handler returning a rendered view from statea render loop reading state, one keypress to one action
Command / Actora sealed type with an actor fielda tagged object { type, by }a typed request DTO with an actor claima parsed command struct with a source field
the command busan observable append-only logan event emitter over an array logan append-only event table / streaman in-memory log + a transcript file
the pure reducera pure function over immutable valuesa pure function returning a new objecta pure fold over eventsa pure step function
the boundary adaptera class implementing the porta module closing over the dispatchera service that folds and persistsa driver loop owning the clock
the domain portan interfacea duck-typed contract / protocola repository / gateway interfacea function-table seam
capability-as-a-toola typed tool returning texta function registered as a toola tool endpoint returning texta shell-out tool returning text
The invariant, not the idiom

Across all four columns the guarantee is identical: the surface holds one state and one sink, the command carries its actor, the reducer is pure, and identity is minted at the boundary. Only the syntax moves. When you port the pattern, port the invariants, not a particular language's spelling of them.

17.4Minimum-viable versus full adoption

You do not need the whole pattern on day one. There is an irreducible core that delivers the central payoff, and an advanced layer you grow into as the agent becomes more central and the stakes rise. The discipline that matters: stop at the tier your app needs. The simple app — the large majority — takes the core and never meets the deep formalism (tiered cognition, the barge-in mailbox, recovery across a crash, the swap door). Those are opt-in by scale, not a checklist to finish. You climb a rung only when an invariant you already hold starts to cost you — never pre-emptively. The operational test is one question (16.4): can you name the production failure this rung prevents in your app? If you cannot, you do not need it yet — the restraint the architecture applies to its own growth is the same one that licenses you to stop here.

Minimum viable, the irreducible core

Four pieces, and you have a replayable agent: tools (pure, return-payload), the command bus (append-only, observable), the pure reducer (fold), and the boundary (mints identity, performs effects, stamps the actor). This alone buys replay, testable tools, and human override.

Full adoption, the advanced layer

Layer on as needed: tiered cognition via a relay store, a barge-in mailbox for serial concurrency, capability-as-a-tool for arbitrary inputs, replay tooling (scrubber, fork, diff), and the full enforcement gate.

TierYou buildYou get
Coretools + bus + fold + boundaryreplay · testable tools · human override
+ Safetythe irreversible-action gateone bad inference can't fire a destructive effect
+ Concurrencya serial mailbox with barge-innewest-wins · preemption · no locks
+ Cognitiona deep tier and a relay storedeeper reasoning without stalling the fast loop
+ Inputscapability-as-a-tool adaptersperceive any out-of-band input as text
+ Enforcementblocking checks per invariantcorrectness scales with generated-code volume

Start at the core and ship something replayable in a day; climb the ladder as the agent takes on more, the inputs diversify, and the cost of an unaudited or unsafe action rises. The architecture is designed so each rung is additive, you never rewrite the core to add a tier, you only attach another seam to it.

17.5The smallest complete app

Every fragment in this reference assembles into one runnable shape. Here it is whole, in the same neutral pseudocode — a one-verb console plus the replay harness that proves the central claim. This is the thing to copy on day one.

the whole spine, assembledpseudocode
// --- the command, the tool, the fold, the boundary, the root, the harness ---
enum Actor { Human, Agent }
sealed Command { case SetPriority { by: Actor, id: Text, level: Priority } }

DOMAIN_TOOL setPriority { input = { id, level }
  run(input, ctx) -> { id: input.id, level: input.level } }   // pure

fold(state, results, now) -> (State, [Effect]):
  for r in results:
    match r.toolName:
      "setPriority" -> (state.withPriority(r.id, r.level),
                        [Effect.Log(r.id, r.level, at = now)])
      else -> (state.withUnhandled(r.toolName), [Effect.Diag(r.toolName)])

boundary.onStepFinish(step):                  // the one impure seam
  (s, fx) = fold(state, step.toolResults, now = clock.read())
  append(step.signedCommands, step.toolResults)   // COMMIT (14.6)
  state = s
  for f in fx: perform(f, mode = LIVE, key = f.id)
  bus.dispatch(SetPriority(by = Actor.Agent, ...s.last))

main():                                       // composition root (7.3)
  app = wireApp(env)                          // ports bound, prompts as assets
  app.events.drain()                          // the serial consumer runs turns

// --- replay harness: fold the recorded timeline TWICE, assert identical ---
replayTest(recorded):
  port = fakeDomainPort()                     // no model, no network
  a = fold(initialState, recorded)            // recorded = commands + fixtures
  b = fold(initialState, recorded)
  assert a.state == b.state                   // determinism (G9)
  effects = collectPerform(recorded, mode = REPLAY)   // stubbed at the seam
  assert effects == recorded.goldenEffects    // golden effect sequence (14.5)

Two test seams are worth calling out because the doc claims them but the wiring is where builders stall. The recorded-trace harness (above) needs a fake domain port, a recorded now and input fixtures, and perform(mode = REPLAY) collecting descriptors instead of firing them. The boundary itself — the one impure object — is tested by injecting a fake clock and a fake id-sequence and a recording perform-sink, then asserting the order of commit, perform, and dispatch. Both fall out of purity; only their wiring is new.

17.6Nomenclature: the names the architecture fixes

A defined architecture fixes its vocabulary, because the gate keys off names and because a shared nomenclature is how a team — or a fleet of agents — writes the same code without coordinating. These are the canonical names and shapes; honor them and your code reads as this architecture to every other reader and every check.

RoleCanonical name / shapeRule
the signed action typeCommand — a discriminated union; each case carries by: Actorone type, one bus
who actedActorHuman | Agentstamped only at the boundary (G1)
the pure reducerthe pure reducer (a.k.a. the fold) — fold(state, results, now) -> (newState, effects)pure; no I/O (G4/G9)
the one mutation seamthe boundary (a.k.a. the boundary adapter) — mints id, stamps Actor, performs effectsthe only place identity is minted (G1/G9)
read-only ambient inputthe agent context (ctx) a tool readsread-only; a tool never writes it (G2)
domain truthState — a discriminated unionone immutable snapshot (G8/G12)
the view projectionproject(state) -> ViewModelpure; pre-decides every flag (6.9)
what the surface exposesone state (a ViewModel) + one onActionnothing else public (G8)
a unit of work the agent can calla *Toolrun(input, ctx) -> payload; UI or domainpure; reads ctx, mutates nothing (G2)
a seam to the outsidea port — the published, frozen contractimports point inward (G10/G13)
a self-contained featurea block (mini-hexagon); its only public symbol is its tool registrationnever imports a sibling (G11)
the one wiring sitewireApp(env) — the composition rootthe only cross-layer importer (G7/G10)
a side effectan effect descriptor — plain data, performed at the boundarynever performed inside a tool (G2)
the input seamthe sensing layer behind an EventSource portraw events in; no domain logic
the provided corethe spine — bus, fold, replay, mailbox, gate, from the runtimeswapped only by contract (G14)

The lineage is explicit. This is an opinionated, named architecture in the line of ports-and-adapters (hexagonal) and unidirectional MVI — layers defined (7.4), boundaries defined (7.6), nomenclature fixed (here), and laws enforced as build gates (G1–G14). Like Clean Architecture it is prescriptive, not descriptive: a paved path that makes the common case correct by construction, with a single, contract-bounded door (8.5) for the cases that need more. You are not meant to choose among its parts; you are meant to inherit them and spend your judgment on your domain.

Two kinds of tool, plus a thin fixed spine. One stream to replay. A handful of invariants to enforce.

Drop this contract onto any language, framework, or platform — the spine does not move.