Back to Pi Mono

AgentHarness — implementation specification

packages/agent/docs/harness.md

0.84.2224.2 KB
Original Source

AgentHarness — implementation specification

Part 0 — Orientation

0.1 What this is

A durable runtime for agent conversations. It persists conversation and operation state so interrupted work can resume without repeating settled effects.

0.2 System model

Session

A session groups related work and has four parts:

  • Entry tree. An entry is a message, compaction, branch summary, or application-defined custom entry. Entries are immutable. Each branch is a conversational thread; the shared tree enables branching, compaction, forking, and parallel work while preserving history.

    text
    a ── b ── c ── d
          └── e ── f
    
  • Facts. Mutable, namespaced key-value state. Built-ins include the session name and entry labels; applications may store custom facts.

  • Lanes. Named cursors into the tree. Every session has main. A lane owns its leaf, model configuration, queues, and at most one operation. Additional lanes support Slack threads, subagents, and other parallel work over shared history.

  • Usage ledger. Append-only token and cost events for the session.

Harness and operations

The session layer manages durable data and exposes typed tree views. The harness drives lanes: it accepts prompts, runs model and tool steps, manages queues, compacts or navigates the tree, and resumes interrupted work. It also owns harness-wide registries of available tools and prompt resources, hooks that intercept and transform execution, passive events that report activity and durable changes, and runtime configuration.

An operation is one accepted unit of lane work: a run, compaction, or navigation. Its immutable metadata records its identity, intent, and starting point; its total current state records its phase, control, queues, and recovery data. Each durable transition replaces the current state. Completion removes the operation state and records the lane's result.

Storage

Below the session and harness, Storage exposes atomic transactions and queries over three durable forms: immutable entries, mutable registers, and append-only usage rows. Registers form a mutable, namespaced key-value store. Facts live there; internal harness namespaces durably store pending content and lane and operation state needed for crash recovery. In particular, op.meta is written once with an operation's metadata, while op.state is replaced after each transition with its complete current state. The terminal transaction deletes both and writes lane.lastResult. No partial transaction is visible.

0.3 The three stores

Everything in Parts 1–5 follows from these.

1. Three stores, one invariant. Everything durable is one of:

text
entries        the conversation tree — write-once, append-only
registers      current mutable state — namespaced typed cells, overwrite or delete
usage ledger   cost history — append-only rows

Every payload is in an entry, a register, or the ledger; there is no third place. An entry is the complete conversation record — placement and payload in one row. A register holds its current typed value directly; overwriting discards the old value, and deletion removes the key. Content that durably exists before it has a place in the tree (queued input, deferred writes) waits in a pending.entry register and becomes an entry in the transaction that places it. Per-backend projections — branch index, full-text search, stats — are rebuildable from the three stores and carry no authority.

2. Atomic transactions. A transaction is a set of entry inserts, usage inserts, and register writes (set or delete), committed all-or-none with strictly increasing sequence numbers. There is no crash state inside a transaction. This is the only write primitive.

3. The durable program counter. After every step, the harness overwrites one register — op.state/{operationId} — with the complete current state of the operation. Recovery does not replay a journal or infer position from what is missing; it reads that register and switches on it. The state is total — it never depends on a previous state. Small captured values (configuration, stream options, retry policy) are inline; large stable payloads live in sibling op.* registers or are named by id. When the operation ends, the terminal transaction deletes its registers: a finished session holds exactly the conversation, the ledger, and a handful of lane and fact registers. There is no dead state to collect.

4. The effect sandwich. Provider requests and real tool calls are wrapped in two commits:

commit:  "about to do X; its output will use ids R and U"     ← intent
         do X                                                  ← the uncertain part
commit:  output + usage + next state                           ← settlement

Hooks follow their replay contract instead: a result becomes durable in the transaction that consumes it, and a crash before that transaction may rerun the hook. Thus every external effect can still happen without durable settlement. Provider/tool intents make that uncertainty explicit where replay policy depends on it; idempotent hooks accept it as a non-goal.

0.4 Worked example — a Slack thread

A user posts in a channel that already has 400 entries of history. The application creates a lane for the thread, anchored at the channel's current leaf. Entry ids are UUIDv7s (§1.2); examples abbreviate them.

harness.createLane("slack:1719432.0021", at: "0195c8d1-4a2e-7b31-…")
lane.prompt("what changed in auth last week?")

What happens, in order:

  1. Acceptance. The harness validates, runs the before_run hook, and commits one transaction: the user-message entry, the operation's op.meta register, and its first op.state"I am at a checkpoint, and I need an assistant response."
  2. Intent. After an internal ready-state commit, it commits the request intent: "I am about to make a provider request. The response will be entry 0195c8d1-53a0-7c44-… and the usage row will be 0195c8d1-53a0-7d18-…." Both ids are minted now; nothing has been sent yet.
  3. The request. Streaming happens. This is the only part that is not durable.
  4. Settlement. One transaction commits the response entry, its usage row, and the next state: "the response has tool calls; here is the batch plan, with result ids already assigned."
  5. Tool calls follow the same intent → effect → settlement shape, one pair of commits each.
  6. When the model stops without tool calls, a terminal transaction deletes the operation's registers, records the outcome in lane.lastResult, and leaves the lane idle.

As a trace (ids abbreviated; every TX[...] is one atomic commit):

text
TX[ insert entry n1 (user msg), upsert op.meta/O, upsert op.state/O = checkpoint,
    upsert lane.leaf = n1, upsert lane.state = { currentOperationId: O } ]
TX[ upsert op.state/O = assistant ready (config snapshot) ]
TX[ upsert op.state/O = effect_pending (reserves response n2, usage u1) ]
… provider streams …                                  ← the uncertain window
TX[ insert entry n2, insert usage u1, upsert lane.leaf = n2,
    upsert op.state/O = tools (result id n3 reserved) ]
TX[ upsert op.tool_args/O:s1:0, upsert op.state/O = call 0 effect_pending ]
… tool runs …
TX[ insert entry n3, upsert lane.leaf = n3, upsert op.state/O = checkpoint ]
… second turn: ready · intent · stream · settle (n4, u2) …
TX[ delete op.meta/O, op.state/O, op.tool_args/O:*,
    upsert lane.lastResult = { O, completed, n4 },
    upsert lane.state = { currentOperationId: null } ]

Kill the process between any two of those transactions and restart. The harness reads the lane's registers, sees exactly which of those sentences was the last one committed, and continues. If it died in step 3, it knows a request may have been billed and may or may not have produced output — that is the one genuinely uncertain window in the whole system, and there is a stated policy for it.

Meanwhile a second thread in the same channel is running its own lane, over the same 400 entries of shared history, with no coordination between them.

0.5 Worked example — a crash mid-tool

lane.prompt("delete the stale migrations and run the test suite")

The model returns two tool calls. The harness commits the batch plan, then commits call 0 is about to execute, with these exact arguments, and it declares itself unsafe to replay. The tool starts deleting files. The process is killed.

text
TX[ insert entry n2 (assistant, 2 calls), insert usage u1, upsert lane.leaf = n2,
    upsert op.state/O = tools (result ids n3, n4 reserved) ]
TX[ upsert op.tool_args/O:s1:0, upsert op.state/O = call 0 effect_pending,
                                                    replay: "never" ]
… tool deletes files …  ← CRASH

On restart the harness reads one register and finds calls[0].status = "effect_pending", replay = "never". It does not re-run the deletion. It appends a synthetic error result under the result id that was reserved before the effect started, marks the call complete, and continues to call 1:

text
TX[ insert entry n3 (synthetic "interrupted" result), upsert lane.leaf = n3,
    upsert op.state/O = call 0 completed ]

The conversation stays coherent — every tool call has a result — and nothing ran twice.

Had the tool declared replay: "safe" (a read, a query), the harness would have re-executed it with the persisted arguments instead.

0.6 Non-goals

  • Exactly-once external effects. See above. Hooks with their own side effects must be idempotent, keyed by operation id.
  • Provider stream resumption. Partial streams are process-local, never persisted. A settled response is persisted completely before anything classifies it.
  • Multiple writers. One process per session. The serving layer routes accordingly, and the SQLite backend enforces it with a fenced lease (§1.7). Lanes cover the workload that looks like multi-writer.
  • Replication. A session lives in one place.
  • Durable write history. Registers hold only current values: an overwritten register is gone, and no API or table exposes write history. Order-of-write assertions in tests use an instrumented storage decorator around commit() (Part 9); production auditing belongs to the telemetry layer (§5.8).
  • Deletion as a runtime feature. Entries and usage rows are never deleted: compaction changes provider context, not storage, and terminal cleanup deletes only registers. Note that retainedTail copies old messages forward into newer compaction entries and summaries derive from old content, so compaction is not erasure either. Compliance-grade "erase this" is the administrative precise rewrite (§2.9), the sole sanctioned exception.

0.7 Notation and source types

  • TX[ a, b, c ] — one atomic commit containing writes a, b, c in that order. The write vocabulary is insert entry, insert usage, upsert namespace/key = value, and delete namespace/key.
  • Ids are UUIDv7s (§1.2). Examples abbreviate them: short tags — e_* entry ids, u_* usage ids, op_* operation ids — stand in for full ids where the time prefix is irrelevant; where the prefix matters, examples show it (0195c8d1-4a2e-7b31-…).
  • S(next) — overwrite the op.state/{operationId} register with the next total operation state. L(next) — the same for lane.state/{lane}.
  • must / must not are normative. Everything else is explanation.

Source type provenance:

  • AgentMessage, AgentTool, AgentToolResult, QueueMode, and ThinkingLevel: packages/agent/src/types.ts.
  • AgentEventSink: packages/agent/src/agent-loop.ts.
  • Skill, PromptTemplate, AgentHarnessResources (Resources below), AgentHarnessTool, AgentHarnessStreamOptions, and AgentHarnessStreamOptionsPatch: packages/agent/src/harness/types.ts.
  • Model, Models, Usage, RetryPolicy, StopReason, AssistantMessage, ImageContent, provider messages, stream options, and deferred handles: packages/ai.
  • CompactionSettings, CompactionPreparation, CompactResult, BranchPreparation, and BranchSummaryResult: packages/agent/src/harness/compaction/. Existing preparation and split-turn algorithms remain the implementation starting point unless this document explicitly changes them.
  • TelemetryContext and typed schema helpers: packages/telemetry; the agent-owned schemas remain in packages/agent/src/harness/telemetry.ts.
  • TSchema for durable custom-message registration: typebox.

The public QueueMode remains "all" | "one-at-a-time". Public RetryPolicy remains the pi-ai shape { enabled, maxRetries, baseDelayMs }; operation state stores its normalized { maxAttempts, baseDelayMs } equivalent. maxRetries and baseDelayMs must be finite non-negative safe integers and maxRetries + 1 must remain safe; disabled retry normalizes to one attempt. Exponential delay and notBefore arithmetic saturate at Number.MAX_SAFE_INTEGER. Public CompactionSettings remains { enabled, reserveTokens, keepRecentTokens }; both token counts must be finite non-negative safe integers. Constructors and setters reject invalid settings before publication. This design adds deferred?: boolean | { window?: "15m" | "1h" | "24h" } to AgentHarnessStreamOptions and its patch type; structural requests always force it to false.

ts
type SettledAssistantMessage = AssistantMessage & {
  stopReason: Exclude<StopReason, "pending">;
};

// Provider dispatch resolves the durable { provider, modelId } identity
// through Models at request time, which also applies auth. A missing or
// swapped registry entry fails the request in-band, like an unknown tool.

Part 1 — Storage

Storage knows nothing about agents, lanes, or conversations. It stores entries and usage rows, updates registers, and answers a small fixed set of queries. Parts 2–4 are built entirely on this.

1.1 The model

ts
type JsonValue = null | boolean | number | string | JsonValue[] | { [k: string]: JsonValue };

/** Write-once. The complete conversation record: placement and payload in one
    row. Created in exactly one transaction, never modified or deleted. The
    four concrete entry types extending this base are defined in §2.1. */
interface EntryBase {
  id: string;                // UUIDv7 (§1.2)
  parentId: string | null;
  seq: number;               // storage-assigned at commit
  timestamp: number;         // Unix ms, storage-assigned at commit
  type: EntryType;
  customType?: string;       // when type === "custom"
  // ...payload fields per entry type (§2.1)
}

type EntryType = "message" | "compaction" | "branch_summary" | "custom";

/** The only mutable store. A namespaced key holding its current typed value
    directly. Overwrite replaces the value; delete removes the key. */
interface Register<N extends RegisterNamespace = RegisterNamespace> {
  namespace: N;
  key: string;
  value: RegisterValues[N];
  seq: number;               // seq of the write that last set this register
}

/** Append-only cost ledger row. Never modified, never deleted (§1.6). */
interface UsageRow {
  id: string;                // UUIDv7 (§1.2)
  seq: number;               // storage-assigned at commit
  usage: Usage;
  entryId?: string;          // the entry this cost belongs to, when there is one
  adjustment: boolean;       // true = caller-supplied reconciliation, not a provider report
  details?: JsonValue;
}

1.2 Identity

Every id — entry, usage, and every reserved id — is a UUIDv7 from the session's id generator (§2.8); legacy imports re-mint to conform (Appendix B). The first 48 bits are the mint time, so every reference is self-describing and time-sortable. Cost accepted: ids leak creation time. (A future partitioned Postgres backend would build on this prefix — informative Part 6.)

Minting rules:

  1. Ids are minted with now() at reservation. Direct appends place in the same transaction; assistant/tool ids trail placement by at most the request duration.
  2. Tool-result ids inherit their assistant id's timestamp (idGenerator.next(timestampMs?), fresh random tail), so a call-and-results group is time-cohesive under id order even across a midnight boundary.
  3. Synthetic settlements write under already-reserved ids (§4.5) — no special case.

Opaque payloads — custom entry data, details, fact.custom values, message text, hook resumeData — may embed entry ids. The harness never tracks those references and they may go stale; copy content, don't reference it.

Absolutes. Within a session, entries and usage rows are never deleted — the precise rewrite (§2.9) is the sole exception. A missing parent is always corruption.

1.3 Register namespaces

ts
interface RegisterValues {
  "lane.leaf":       string | null;                // entry id; null = lane at the root
  "lane.config":     LaneConfiguration;            // §2.3
  "lane.state":      LaneState;                    // §3.3
  "lane.lastResult": LaneLastResult;               // §3.13
  "op.meta":         Operation;                    // §3.1
  "op.state":        OperationState;               // §3.2 — the program counter
  "op.tool_args":    Record<string, JsonValue>;    // effective tool arguments (§3.8)
  "op.preparation":  DurableStructuralPreparation; // §3.9
  "pending.entry":   PendingEntry;                 // §2.2
  "fact.name":       string;
  "fact.label":      string;
  "fact.custom":     JsonValue;                    // JSON null is a legal value
}
type RegisterNamespace = keyof RegisterValues;

/** Unplaced content: current mutable state until the placement transaction
    writes the complete entry and deletes this register (§2.2). */
interface PendingEntry {
  type: "message" | "custom";
  customType?: string;
  payload?: JsonValue;       // the content that becomes the entry's payload;
                             // absent = a custom entry with no data
}

interface DurableFileOperations {
  read: string[]; written: string[]; edited: string[];
}
type DurableStructuralPreparation =
  | { kind: "compaction"; messagesToSummarize: AgentMessage[];
      turnPrefixMessages: AgentMessage[]; retainedTail: AgentMessage[];
      isSplitTurn: boolean; tokensBefore: number; previousSummary?: string;
      fileOps: DurableFileOperations; settings: CompactionSettings }
  | { kind: "branch_summary"; messages: AgentMessage[];
      fileOps: DurableFileOperations; totalTokens: number };
NamespaceKeyValueMeaning
lane.leaflane nameentry id or nullwhere this lane appends next
lane.configlane nameLaneConfigurationtotal lane configuration
lane.statelane nameLaneState (§3.3)currentOperationId, pendingNextRun
lane.lastResultlane nameLaneLastResult (§3.13)terminal outcome of the lane's most recent operation
op.metaoperation idOperation (§3.1)acceptance data; written once, never overwritten
op.stateoperation idOperationState (§3.2)total operation state — the program counter
op.tool_args{opId}:{stepId}:{sourceIndex}effective argumentswritten once at tool clearance (§3.8)
op.preparation{opId}:{taskId}DurableStructuralPreparationwritten once before the decision hook (§3.9)
pending.entryreserved entry idPendingEntryqueued content awaiting placement (§2.2)
fact.name""stringsession name
fact.labelentry idstringentry label
fact.customapplication keyJsonValueapplication state

That is the complete set. Two lifetimes are visible in the key shape:

text
lane.*  fact.*     session-lived; facts are deleted only by explicit application action
op.*               operation-lived; deleted by the terminal transaction (§3.13)
pending.entry      lives until its content is placed or cancelled
  • op.meta and op.preparation keys are written exactly once; op.tool_args keys are written once per key, keyed by the producing step so batches never collide. All are deleted no later than the terminal transaction; only op.state is overwritten during the operation.
  • Operation-owned pending.entry registers still unconsumed at the end (remaining inbox items and abort-drained items) are deleted by the terminal transaction — a consumed item's register dies in its placement transaction; lane-owned ones (pendingNextRun) outlive operations and die when consumed or cancelled (§3.11).
  • lane.lastResult is written only by terminal transactions and overwritten by the next one on its lane — one bounded register per lane, forever. Recovery never reads it; it exists so an application that accepted an operation, crashed, and reopened can still learn its outcome (§3.13).
  • Deleting a fact removes its register. Storing JSON null in fact.custom is a different, legal state; there are no tombstones.
  • Cancellations leave no trace: cancelQueued triages as pending → cancelled, entry exists → already_consumed, else → not_found (§3.11). A client retrying a lost cancel treats not_found as success.

1.4 Transactions

ts
/** Mapped discriminated union: the namespace forces the value type. */
type RegisterSetWrite = {
  [N in RegisterNamespace]: { kind: "register"; op: "set"; namespace: N;
                              key: string; value: RegisterValues[N] }
}[RegisterNamespace];

type Write =
  | { kind: "entry"; entry: Omit<Entry, "seq" | "timestamp"> }
  | { kind: "usage"; row: Omit<UsageRow, "seq"> }
  | RegisterSetWrite
  | { kind: "register"; op: "delete"; namespace: RegisterNamespace; key: string };

interface Transaction { writes: Write[] }

interface CommitResult { firstSeq: number; seqs: number[]; timestamp: number }

Rules:

  1. A transaction commits all-or-none. There is no observable state in which some of its writes exist and others do not.
  2. Writes receive strictly increasing seq values in the order given; gaps are legal, within and between transactions. seq is monotonic session-wide across all lanes and all write kinds. A register set stamps the register with its assigned seq.
  3. Within a transaction, writes apply in order: an entry may name a parent created earlier in the same transaction; a register value may reference entry or usage ids created earlier in the same transaction. A placement transaction inserts the complete entry and deletes its pending.entry register together (§2.2) — there is never a moment where both exist.
  4. Entry and usage ids share one session-wide id namespace. Writing either kind under any existing id is corruption, not an update.
  5. A register set with the same (namespace, key) replaces the current value; delete removes the key; a later set recreates it. No history is retained. A delete naming an absent key is a no-op, so public deletions such as clearing an unset label stay legal.
  6. Transactions on one session are serialized. There is one writer and one queue.

Session validates the complete transaction, including JSON serialization and runtime schemas, before storage admission. A failed admitted commit faults the harness: all effects stop, all calls reject, and the process must be restarted. A partially applied transaction is not tolerated.

1.5 Queries

One Storage instance serves one session. Repository discovery and lifecycle are outside this interface (§2.8).

ts
interface Storage {
  commit(tx: Transaction): Promise<CommitResult>;

  getEntries(ids: string[]): Promise<ReadonlyMap<string, Entry>>;

  getRegister<N extends RegisterNamespace>(namespace: N, key: string):
    Promise<Register<N> | undefined>;
  /** keyPrefix is an indexed prefix listing over (namespace, key); terminal
      cleanup's op.* prefix scans use it (§3.13). */
  listRegisters<N extends RegisterNamespace>(namespace: N, keyPrefix?: string):
    Promise<Register<N>[]>;

  scanBranch(q: BranchScan): Promise<Entry[]>;            // §2.5
  scanBranchStructure(q: BranchScan): Promise<EntryStructure[]>;
  scanEntries(q: EntryScan): Promise<Entry[]>;            // session-wide tree inventory
  scanUsage(q: UsageScan): Promise<UsageRow[]>;           // seq-ranged ledger read (§1.6)
  getStats(): Promise<SessionStats>;                      // maintained projection (§1.6)

  close(): Promise<void>;
}

/** Placement metadata without payload fields. */
type EntryStructure = Pick<Entry, "id" | "parentId" | "seq" | "timestamp" | "type" | "customType">;

interface EntryScan {
  type?: EntryType; customType?: string;
  fromSeq?: number; toSeq?: number;
  order?: "asc" | "desc"; limit?: number;
}

interface UsageScan {
  fromSeq?: number; toSeq?: number;
  order?: "asc" | "desc"; limit?: number;
}

There is deliberately no cross-namespace register scan and no durable write log. Restore, facts, forks, and execution follow exact ids and keys; entry inventory uses scanEntries; ledger reads use scanUsage; totals use the stats projection (§1.6); test-order assertions wrap commit() with the instrumented-storage decorator (Part 9); production auditing belongs to telemetry (§5.8).

Recovery and execution reads must be index-driven and bounded. They may not infer state from an absent value, and there is no register history to fold. Exact dereference is allowed: one current state may name a bounded set of entries and registers, fetched in one batch without order-dependent reduction. Public inventory and debugging APIs may intentionally read more than a hot path; their limit/pagination behavior is explicit at the SessionTree layer.

close() is idempotent. It seals admission, rejects later reads/commits on that instance, drains commits admitted before the seal, then releases resources and the writer claim. Durable data is reopened through the repository.

1.6 Usage ledger

Every settled provider attempt writes one UsageRow — successful, failed, retried, and synthetic attempts alike, including attempts whose operation later aborts. Settlement transactions write the response entry and its usage row together (§3.7); synthetic settlements write zero usage under the reserved usage id. Rows are append-only: terminal cleanup deletes an operation's registers but never its ledger rows, so billing survives everything that can happen to orchestration state.

jsonc
{ "id": "u_7", "seq": 815, "entryId": "e_51", "adjustment": false,
  "usage": { "input": 12000, "output": 431, "cost": { ... } } }
  • entryId names the entry the cost belongs to, when there is one. Structural (summary) attempts that fail before producing an entry, and standalone adjustments, have none.
  • adjustment: true marks a caller-supplied reconciliation (recordUsage, §5.1) rather than a provider report. The format-3 import writes one aggregate adjustment row (Appendix B).
  • Provider-attempt usage ids are UUIDv7s reserved in the intent commit (§1.2), so a settlement writes under exactly the id its intent promised. Adjustment rows, tool-reported usage rows, hook-supplied compaction/navigation usage rows (§3.9, §3.10), and import aggregates mint their ids at commit; nothing reserves them.
  • getStats() is a maintained projection over the ledger and the message-entry count — messageCount counts message entries only, not compactions, summaries, or custom entries. After every commit it equals the ledger sum; the conformance suite asserts this (Part 9). Individual rows reach the application through the usage event at commit time (§5.5), and scanUsage (§1.5) reads them back by seq range — a consumer that persists the greatest event seq it applied catches up after downtime with scanUsage({ fromSeq }). Recovery never reads the ledger.

1.7 Backends

Three encodings of one model ship now — Memory, JSONL, SQLite — and all three pass the same conformance suite (Part 9). Each backend records the session's storageVersion (Part 7): a JSONL header field, a SQLite catalog column. Memory sessions are always current. A possible fourth backend — partitioned Postgres — is sketched informatively in Part 6; nothing here depends on it.

Memory

ts
entries:   Map<string, Entry>
registers: Map<string, Register>       // key: `${namespace}\u0000${key}`
usage:     Map<string, UsageRow>
children:  Map<string, string[]>       // parentId → entry ids, for tree walks

One queue serializes commits. A commit validates and applies writes to temporary transactional state, then publishes the maps together. A register delete is a map delete. Reads are map lookups; scanBranch walks parentId and filters in RAM. There is no log: Memory holds exactly the live state and nothing else.

JSONL

The file is not the state; it is the replay recipe for the Memory maps above. One physical line per commit(). Storage assigns sequence/timestamp fields first, then encodes one committed write as a JSON object line or several as one array line.

jsonl
{"v":4,"kind":"header","id":"s_1","storageVersion":1,"createdAt":1700000000000,"cwd":"..."}
[{"kind":"entry","seq":101,"timestamp":1700000000000,"id":"e_50","parentId":"e_41","type":"message","message":{"role":"user","content":[...]}},
 {"kind":"register","op":"set","seq":102,"namespace":"op.meta","key":"op_9","value":{...}},
 {"kind":"register","op":"set","seq":103,"namespace":"op.state","key":"op_9","value":{...}},
 {"kind":"register","op":"set","seq":104,"namespace":"lane.leaf","key":"main","value":"e_50"},
 {"kind":"register","op":"set","seq":105,"namespace":"lane.state","key":"main","value":{...}}]
{"kind":"usage","seq":110,"id":"u_7","entryId":"e_51","adjustment":false,"usage":{...}}
{"kind":"register","op":"delete","seq":131,"namespace":"op.state","key":"op_9"}
  • This is format 4. The incompatible format-4 code currently in the source tree is unfinished and is replaced in place; no migration for it is required. Coding-agent format 3 remains supported (Appendix B).
  • Open replays lines in order into the Memory maps: entries and usage rows accumulate; a later register set overwrites the key, delete removes it. That is decoding, not recovery logic. Open verifies persisted sequence monotonicity — strictly increasing, gaps legal (§1.4) — and timestamps, and never regenerates committed timestamps. All queries then run in RAM.
  • A torn final line is discarded whole, including every element of an array, and is truncated before new writes are admitted. This is what makes "no crash prefix inside a transaction" true here.
  • A malformed interior line, or a complete-but-invalid transaction, is corruption. The one exception: superseded old-shape register lines from before a schema migration decode leniently as keyed raw JSON during replay (Part 7); compaction retires them.
  • Durability is process-crash level: a resolved commit() survives process death. No fsync promise.
  • Optional: retain (offset, length) per entry and load payloads lazily, keeping only structure and registers resident. Do this only if profiling demands it.

Snapshot compaction. In SQLite a register set is an in-place upsert — a 30-turn run leaves one op.state row and then zero. In JSONL every set appends, so the same run appends ~10 full op.state lines, all dead the moment the terminal delete line lands: the file grows with write history even though the logical state does not. The fix is rewriting the file as header + current entries + current registers + usage rows, via temp file + atomic rename; surviving lines keep their original seq values, and the gaps the dropped lines leave are legal (§1.4), so compaction needs no renumbering machinery. For a four-entry run:

text
before compaction:  ~10 transaction lines, ~27 writes — op.state revisions,
                    tool args, pending payloads, all dead since the terminal line
after compaction:   header + 4 entry lines + 2 usage lines + 4 lane register lines

When to compact: on open when the dead-bytes ratio crosses a threshold; optionally after terminal transactions; always after a schema migration (Part 7). Between compactions, normal operation is append-only and O(1) per commit. One consequence worth stating: deleted pending payloads and superseded state revisions linger as bytes until compaction — logical deletion is immediate, physical deletion is deferred. A deployment that needs prompt physical removal of sensitive cancelled content compacts eagerly at terminal boundaries.

SQLite

One database file per session. The file is the session, exactly as a JSONL file is. Corruption is confined to one session, deletion is unlinking a file, and SQLite's one-writer-per-file rule coincides with the design's one-writer-per-session rule by construction.

sql
entries(id TEXT PRIMARY KEY, parent_id TEXT, seq INTEGER, type TEXT,
        custom_type TEXT, timestamp INTEGER, payload TEXT) WITHOUT ROWID;
CREATE INDEX ix_entry_parent ON entries(parent_id);
CREATE INDEX ix_entry_seq ON entries(seq, type);

registers(namespace TEXT, key TEXT, seq INTEGER, value TEXT,
          PRIMARY KEY (namespace, key));

usage_ledger(id TEXT PRIMARY KEY, seq INTEGER, entry_id TEXT, adjustment INTEGER,
             usage TEXT, details TEXT) WITHOUT ROWID;
CREATE INDEX ix_usage_seq ON usage_ledger(seq);

-- Private branch index (§2.6). Not registers; no equivalent in the other backends.
branch_entries(branch_id TEXT, entry_id TEXT, entry_seq INTEGER, entry_type TEXT,
               PRIMARY KEY (branch_id, entry_id)) WITHOUT ROWID;
-- Ordered scans. entry_seq must follow branch_id directly or ORDER BY needs a
-- temp b-tree; entry_id and entry_type trail so the index covers id-only reads.
CREATE INDEX ix_be_seq  ON branch_entries(branch_id, entry_seq, entry_id, entry_type);
-- Type-filtered scans.
CREATE INDEX ix_be_type ON branch_entries(branch_id, entry_type, entry_seq, entry_id);
CREATE INDEX ix_be_entry ON branch_entries(entry_id);
branch_meta(branch_id TEXT PRIMARY KEY, tip_entry_id TEXT, tip_seq INTEGER,
            base_branch_id TEXT, base_seq INTEGER);
CREATE UNIQUE INDEX ix_bm_tip ON branch_meta(tip_entry_id);

-- One row each: the file is the session.
session(created_at, parent_session_id, storage_version, metadata,
        message_count, usage_payload, next_seq);
writer_lease(owner_id TEXT, fence INTEGER, expires_at_ms INTEGER);

One commit() is one SQL transaction: insert entries, insert ledger rows, upsert or delete registers, maintain the branch index, bump session_stats. Never an UPDATE or DELETE on an entry or ledger row; mutability is confined to registers, the branch index (branch_meta tips and bases), stats, sequences, the session catalog row, and leases.

Every transaction must open with BEGIN IMMEDIATE. A deferred BEGIN that reads before it writes takes a read snapshot and must later upgrade to the write lock; if another writer committed in between, SQLite fails that upgrade — and busy_timeout does not rescue it, because no amount of waiting can refresh a stale snapshot. The only recovery is rollback and full retry.

Every commit has this shape, not just a few. Allocating the sequence range reads the session row's next_seq and then writes it, so a read precedes a write in every transaction the system performs. Branch creation (§2.6) adds a second instance, reading the newest compaction before inserting. BEGIN IMMEDIATE takes the write lock up front and avoids an unrecoverable stale-snapshot upgrade, so there is no case where a deferred BEGIN is the right choice here.

writer_lease enforces the single-writer rule. WAL happily lets two processes alternate writes to one file, which is exactly the interleaving the design forbids — so per-session files do not remove the need for the lease. Expiring fenced ownership: open() acquires the claim, storage renews it on appends and while idle, and close stops renewal after the queue drains and deletes only its matching (owner_id, fence) pair — so a stale owner cannot release the replacement that succeeded it. This is what makes "one process owns one session" an enforced property rather than a convention the serving layer is trusted to uphold. Memory and JSONL have no equivalent and rely on process ownership; a JSONL session opened twice is corrupt and undetected.

Atomicity itself needs no special handling. A multi-write transaction is all-or-none by the file format: WAL frames become visible only when the commit record lands, so a concurrent reader observes either none of a transaction's writes or all of them.

Each physical segment of scanBranch uses one JOIN; §2.6 combines segment ranges:

sql
SELECT e.id, e.parent_id, e.seq, e.type, e.custom_type, e.timestamp, e.payload
FROM branch_entries b
CROSS JOIN entries e ON e.id = b.entry_id
WHERE b.branch_id = ? AND b.entry_seq > ? AND b.entry_seq <= ?
ORDER BY b.entry_seq;

CROSS JOIN is load-bearing: it forces branch_entries to be the outer loop. Left to itself the planner may drive from entries, scan the table, and sort through a temporary b-tree. Assert the plan in a test:

SEARCH b USING COVERING INDEX ix_be_seq (branch_id=? AND entry_seq>?)
SEARCH e USING PRIMARY KEY (id=?)

Any plan containing USE TEMP B-TREE FOR ORDER BY or a scan of entries is a regression.

scanBranchStructure is the same query without the payload column. getEntries is a primary-key lookup keyed by e.id IN (...).

Because the file is the session, the precise rewrite (§2.9) and forks are file operations: build a fresh database (VACUUM INTO or row copy over one read snapshot) and, for the rewrite, atomically swap it over the old path — the same shape JSONL uses.

1.8 Why write-once plus registers

  • Recovery is a read. Five register point-lookups per lane, then exact-id dereference (§4.4). No reducer exists to have a bug.
  • Crash states are enumerable. Between transactions, never inside one.
  • Cleanup is deletion, not collection. A 30-turn run overwrites one op.state register ~30 times and then deletes it. What remains is exactly the conversation, the ledger, and a handful of lane and fact registers — no dead state values, no history rows, nothing to garbage-collect. (JSONL defers physical reclamation to snapshot compaction; the logical state is identical.)
  • No repair-by-rewrite. Recovery appends entries and overwrites only the registers it owns, with the same transitions normal execution would commit; interrupt it and rerun it and you get the same result.
  • Concurrency is trivial. Readers never see partial state; there is nothing to lock.
  • The one deliberate double-write. Queued content is serialized twice: into its pending.entry register at enqueue and into its entry at placement. Only queued items pay it — assistant and tool settlements, the hot path, write their entries once. In exchange every queue item is one id, cancellation deletes content outright, and no payload ever exists without an owner.

Part 2 — The conversation tree

2.1 Entries

An entry is the complete stored row (§1.1): placement fields and payload together. What getEntries and the scans return is exactly what was committed — there is no materialization step and no join.

ts
interface MessageEntry       extends EntryBase { type: "message"; message: AgentMessage;
                                                 terminate?: true }
interface CompactionEntry    extends EntryBase { type: "compaction"; summary: string;
                                                 retainedTail: AgentMessage[]; tokensBefore: number;
                                                 details?: JsonValue; usage?: Usage; fromHook: boolean }
/** fromId is the summarized branch's pre-navigation leaf: the producing
    operation's sourceLeafId (§3.10). */
interface BranchSummaryEntry extends EntryBase { type: "branch_summary"; fromId: string;
                                                 summary: string; details?: JsonValue;
                                                 usage?: Usage; fromHook: boolean }
interface CustomEntry        extends EntryBase { type: "custom"; customType: string; data?: JsonValue }

type Entry = MessageEntry | CompactionEntry | BranchSummaryEntry | CustomEntry;

Rules:

  • type and customType are structural fields: branch queries filter on them and the branch index denormalizes them (§2.6). customType is set exactly on custom entries; payload fields never drive structure.
  • Assistant entries always contain a SettledAssistantMessage. Reject pending before writing.
  • Tool-result entries carry terminate?: true. It is orchestration state that ToolResultMessage has no field for.
  • Every compaction and branch summary carries fromHook: true for hook output, false for generated.
  • Every compaction stores a complete retainedTail ([] when empty). Context never reads past a compaction. This is what makes a compaction a self-contained checkpoint rather than a pointer into history.
  • A custom entry may carry no data. An entry either decodes against its type's runtime schema or is corruption.
  • Payloads are inline, so two entries never share stored content; there is no deduplication layer.

2.2 Placement

The tree's central rule:

An entry is created, complete, when placement happens. Content that is durable before placement is current mutable state and waits in a pending.entry register; the placement transaction writes the entry and deletes the register. Neither is ever modified after that.

Three cases, all mechanical:

Born placed — assistant responses, tool results, direct appends to an idle lane. Content and placement arrive together; one transaction:

TX[ insert e_a4 = { parent: e_q1, type: "message", message: <assistant response> },
    upsert lane.leaf/main = "e_a4" ]

Content first, placement later — queued input (steer, followUp, nextRun) and deferred tree writes. The entry id is minted at enqueue and doubles as the register key; queue state references content by that one id. Two transactions, possibly far apart:

t0  TX[ upsert pending.entry/e_q1 = { type: "message", payload: <200KB message> },
        S(next){ ...inbox.steer += "e_q1" } ]

t1  TX[ insert e_q1 = { parent: e_a3, type: "message", message: <from the register> },
        delete pending.entry/e_q1,
        upsert lane.leaf/main = "e_q1",
        S(next){ ...inbox.steer -= "e_q1" } ]

The register dies in the transaction that places the entry. Crash before t1: the item is still queued. Crash after: it is placed and the register is gone. There is no third state — until placement or cancellation, exactly one of register and entry exists at every commit boundary, never both and never neither. Cancellation is the other exit: cancelQueued deletes the register, and the content is simply gone, never having touched the tree (§3.11).

Id reserved before content exists — assistant responses and tool results. The reserved id is a plain minted string inside op.state; no register and no row exist until settlement inserts the complete entry. Reserving costs nothing.

These are the two reservation regimes: settlement-family ids (responses, tool results, usage rows) are strings in operation state; queued-content ids are pending.entry registers. "A reserved id is just a string" is true only of the first family.

Consequences to rely on:

  • A pending item is invisible to tree queries (no entry) but visible in snapshots: the owning state lists its id, and the payload is dereferenced from its register.
  • "Has this been placed yet?" is answered by the owning queue list and the register's existence — never by the absence of an entry.
  • The double write is the model's one deliberate redundancy (§1.8). SQLite and Postgres can implement placement as INSERT … SELECT from the register row inside the placement transaction; in JSONL both copies persist as bytes until snapshot compaction (§1.7). Only queued items pay it; settlement never does.

2.3 Lanes

A configured lane is three registers — plus lane.lastResult once its first operation has ended (§3.13). Fresh or normalized-v3 main may temporarily lack lane.config until first harness attachment:

lane.leaf/{name}    = entry id or null
lane.config/{name}  = LaneConfiguration      // absent only for unconfigured main
lane.state/{name}   = LaneState
ts
interface LaneConfiguration {
  model: { provider: string; modelId: string };
  thinkingLevel: ThinkingLevel;
  activeToolNames: string[];
}
  • A lane's leaf moves in exactly two ways: the lane appends an entry (leaf becomes that entry), or the lane navigates (leaf jumps to an existing entry).
  • LaneConfiguration is total. A setter overwrites the whole register; it is never a patch and never a tree entry.
  • Creating a lane copies no tree content, no history, and no configuration from its anchor:
TX[ upsert lane.config/{name} = <seed configuration>,
    upsert lane.leaf/{name}   = anchorEntryId,
    upsert lane.state/{name}  = { currentOperationId: null, pendingNextRun: [] } ]
  • Lanes are never deleted or renamed. Names are permanent application keys.
  • main exists in every session.
  • Two lanes at the same leaf simply diverge on their next append.

2.4 Facts

Session-scoped, latest-wins, not part of the tree.

fact.name/""          = string
fact.label/{entryId}  = string
fact.custom/{key}     = JsonValue

Setting a fact to undefined deletes its register — real deletion, not a tombstone; deleting an unset fact is a no-op (§1.4). JSON null is a legitimate custom value, stored directly, and is distinguishable from deletion because the register itself exists or does not. The built-in and custom namespaces never overlap. Fact writes commit immediately and never move a leaf.

2.5 Branch queries and context

ts
interface BranchScan {
  start?: string;               // required at the Storage layer; the Session
                                // tree view defaults it to the view's lane leaf
  stopAtType?: EntryType;       // scan ends after the first match, inclusive
  stopAtId?: string;
  type?: EntryType;
  customType?: string;
  order?: "newestFirst" | "oldestFirst";   // default newestFirst
  limit?: number;
  cursor?: EntryCursor;
}
type EntryCursor = { seq: number };

Semantics: take the path from start toward the root, order it (default newestFirst), stop inclusively at the first stopAt match, filter by type/customType, apply the exclusive cursor, then apply limit. For newestFirst, a cursor retains seq < cursor.seq; for oldestFirst, it retains seq > cursor.seq. A stopAt entry is returned only if it also passes the filter.

Context projection — how a provider request is built:

  1. scanBranch({ start: leaf, order: "newestFirst", stopAtType: "compaction" }).
  2. Reverse to oldest-first. If a compaction terminated the scan, the context is: its summary, then its retainedTail, then every entry after it. Nothing earlier is read.
  3. Drop assistant responses whose stop reason is error, aborted, or deferred. Retain genuine output-limit length.
  4. Run custom entries through entryProjectors. An unprojected custom entry never enters context.
  5. Run transform_context, then toProviderMessages.

An overflow response needs no dedicated omission rule: it is committed with stop reason error (§3.7) and is therefore dropped by rule 3 like any other error, and by any downstream transformMessages that filters the same way.

Append-only context invariant. Across the requests of one lane, provider context must only grow at the tail. An insertion before the previous request's tail invalidates the provider's KV cache and multiplies cost. This is why mid-run writes defer to checkpoints, where they append at the tail. Compaction is the one deliberate cache invalidation, and it trades that for a smaller context.

2.6 The branch index

Memory and JSONL walk parent pointers in RAM. SQLite maintains a private segmented branch cache so a diverging append does not copy an unbounded root prefix.

branch_entries stores the entries physically present in one segment. branch_meta stores its tip and optional { baseBranchId, baseSeq }. A segment logically contains its own rows above baseSeq plus the referenced base prefix through baseSeq.

Append:

  1. If a branch tip equals the lane leaf, append one row and move that tip.
  2. Otherwise resolve a branch that actually covers the leaf, find the newest compaction at or below the leaf through the complete segment chain, copy only rows after that compaction through the leaf, and set the older prefix as the new segment's base.
  3. Append the new entry and make it the new segment tip.

Read newest segment first. If the requested range crosses baseSeq, continue through the base chain with the upper bound capped at that boundary. Merge segment results into the requested order before filtering/limiting.

Two correctness rules are mandatory:

  • The base branch must itself cover the leaf within its logical range; merely containing the leaf in an ancestor is insufficient.
  • The newest compaction search must traverse the base chain; checking only the newest physical segment can miss it.

The cache must preserve:

  • following a segment chain yields the exact root path with no gaps or duplicates;
  • all chains containing an entry agree below it;
  • runtime reads never fall back to a table scan or parent walk;
  • stale branches remain valid cache history;
  • only an explicit repair operation rebuilds the cache from entries.

Tests assert these invariants and the required query plans. No wall-clock threshold is normative.

2.7 Forks

A fork is a repository operation over one coherent source-session snapshot. It copies selected entries, latest facts, lane leaves, and total configuration; it never copies op.*, pending.entry, or lane.lastResult registers or ledger rows — destination lanes start with a fresh empty LaneState.

ts
type ForkOptions =
  | { scope?: "branch"; entryId?: string; position?: "before" | "at" }
  | { scope: "tree" };
  • Memory and JSONL obtain the snapshot as one job on the source storage queue. SQLite uses one read transaction.
  • Branch scope copies one path and creates only destination main. Tree scope copies the whole tree and every lane leaf/configuration.
  • The destination is idle and its token/cost ledger starts at zero. Entry-local display usage remains on copied entries.
  • Facts follow the selected scope: name/custom facts always copy; labels copy only when their target copies unless tree scope copies all targets.
  • Any message may be the fork point. Request construction heals orphaned tool calls.
  • Copied entries keep their ids.
  • The destination metadata records parentSessionId.

A source with only fresh/unconfigured main—new format 4 or read-only normalized v3—may have no configuration. Either fork scope then creates one unconfigured destination main, which first harness attachment seeds normally. Every configured format-4 lane copied by a fork keeps its current total configuration.

2.8 Session and repository boundary

Storage is deliberately one-session only. Session supplies typed validation, lane-bound views, and typed entry/register decoding. SessionRepo owns discovery and storage-instance lifecycle:

ts
interface SessionMetadata {
  id: string;
  createdAt: number;
  /** Current storage schema version (Part 7). */
  storageVersion: number;      // starts at 1 for new format-4 sessions
  cwd?: string;                // working directory, when the application records one
  parentSessionId?: string;
  /** Only when a v3 parent path cannot be resolved to an available header id. */
  legacyParentSessionPath?: string;
}

interface SessionCodecOptions {
  /** Built-in provider-message roles are registered by default. */
  customMessageSchemas?: Record<string, TSchema>;  // keyed by custom `role`
}

interface SessionRepo<M extends SessionMetadata = SessionMetadata,
                      C extends { id?: string; parentSessionId?: string } =
                        { id?: string; parentSessionId?: string },
                      L = void> {
  create(options: C): Promise<Session<M>>;
  open(metadata: M): Promise<Session<M>>;
  list(options?: L): Promise<M[]>;
  delete(metadata: M): Promise<void>;
  fork(source: M, options: ForkOptions & C): Promise<Session<M>>;
}

interface Session<M extends SessionMetadata = SessionMetadata> extends SessionTree {
  readonly metadata: M;
  /** Mints UUIDv7 ids; a supplied timestamp mints a follower id (§1.2). */
  readonly idGenerator: { next(timestampMs?: number): string };
  view(lane: string): SessionTree;

  /** Package-internal harness storage surface; validates before delegating to Storage. */
  commit(tx: Transaction): Promise<CommitResult>;
  getEntries(ids: string[]): Promise<ReadonlyMap<string, Entry>>;
  getRegister<N extends RegisterNamespace>(namespace: N, key: string):
    Promise<Register<N> | undefined>;
  listRegisters<N extends RegisterNamespace>(namespace: N, keyPrefix?: string):
    Promise<Register<N>[]>;

  close(): Promise<void>;
}

Repository constructors accept SessionCodecOptions. Every declaration-merged custom AgentMessage must have a string role and a registered runtime schema; unknown custom roles are rejected before persistence and on decode. A new repository session creates main with null leaf and an empty LaneState, but no configuration; first harness attachment writes its seed configuration.

open() compares the stored storageVersion with the binary's: equal proceeds; older runs chained migrations under the writer lease before returning (Part 7); newer refuses to open. Old coding-agent v3 JSONL sessions open through the same repository and normalize on load (Appendix B — "v3" there names the legacy JSONL session format, not this document).

Repository implementations resolve fork(source, ...) to the source's serialized snapshot boundary: an active Memory/JSONL storage queues the snapshot with commits; an inactive JSONL file is read as one immutable prefix; SQLite uses one read snapshot of the session's file. Repositories may keep an active-storage registry by session id for this purpose. This is repository coordination, not part of the one-session Storage contract.

How a repository organizes its sessions is its own choice, constrained only by the storage backend: JSONL and SQLite storage are one file per session, so their repositories are file-based; a Postgres storage could hold every session in one database.

Search is a standalone service over the repository, with its own store. The dependency points one way: the service consumes repo.list() and read-only session opens; the repository knows nothing about search and exposes no search methods, and no conformance test covers any of this. An application that wants search constructs the service and queries it directly:

ts
const search = createSqliteSearchService({ repo, dbPath });    // reference impl
await search.sync();                                           // catch up cursors
events.on("entry_added", (e) => search.notify(e.sessionId));   // optional freshness

const hits = await search.searchSessions({ text: "auth migration", limit: 10 });
ts
interface SessionSearchService {
  /** Sessions ranked by best match. Required. */
  searchSessions(query: SearchQuery): Promise<SessionSearchHit[]>;
  /** Entries ranked by match. Optional capability. */
  searchEntries?(query: SearchQuery): Promise<EntrySearchHit[]>;

  sync(): Promise<void>;              // enumerate sessions, catch up all cursors
  notify(sessionId: string): void;    // freshness hint; debounced single-session pull
  remove(sessionId: string): Promise<void>;
  close(): Promise<void>;
}

interface SearchQuery { text: string; limit?: number }  // limit counts the method's unit

interface SessionSearchHit {
  sessionId: string;
  score?: number;
  top?: { entryId: string; snippet?: string; timestamp: number };  // best match, for display
}

interface EntrySearchHit {
  sessionId: string; entryId: string; timestamp: number;
  snippet?: string; score?: number;
}

The application owns the lifecycle: sync() at startup or on a schedule, notify() wired to its event stream when it wants freshness, remove() alongside repo.delete() (or left to the next sync(), which reconciles against repo.list()). Hits carry sessionId; callers join metadata through the repository they already hold.

Indexing is pull-based; events are only hints. The service keeps a durable cursor per session — the highest entry seq it has indexed. sync() enumerates sessions via the repository (old, new, and files that arrived by copy alike), reads scanEntries({ fromSeq: cursor + 1 }) on each, indexes message-entry text idempotently per (sessionId, entryId), and advances the cursor. A crash mid-batch re-indexes a few rows into the same state; a service deployed against years of existing sessions starts empty and catches up with the same loop. notify() never carries content — it is a poke that triggers a debounced pull of one session; a lost poke is caught by the next sweep. The index is a rebuildable projection with zero authority: indexing failures never affect the harness or commits.

Two mechanical notes. Reading a session another process is writing is legal — the writer lease gates writers, and WAL gives cross-process snapshot reads — but a sweep may skip lease-held sessions as an optimization, since notify() covers the hot ones. The precise rewrite (§2.9) swaps a session's store and may renumber seqs, so cursors key on (sessionId, storeGeneration); the rewrite bumps a generation counter in metadata and a mismatch triggers a full re-index of that session.

The reference implementation is one standalone SQLite database — an FTS5 table over (session_id, entry_id, text) plus the cursor table — and works unchanged over JSONL session files. Several processes may share it under the usual discipline (WAL, busy_timeout, BEGIN IMMEDIATE, idempotent rows, monotonic cursor updates); writers serialize.

Open question — metadata filtering. Coding-agent's resume flow filters sessions by cwd; other repositories have no cwd concept at all. Repositories already model implementation-specific listing through their L options generic (list(options?: L)), but SearchQuery is deliberately generic — how does a repo-specific filter reach the index? Candidates, to be settled by the people who will fight over it:

ts
// (a) typed filter passthrough — service becomes generic over a filter type
await search.searchSessions({ text: "auth", filter: { cwd: "/repo" } });

// (b) pre-restrict via the repo's own listing; pass the candidate id set
const local = await repo.list({ cwd: "/repo" });
await search.searchSessions({ text: "auth", within: local.map((m) => m.id) });

// (c) post-filter in the app — breaks ranking: limit applies before the filter
const all = await search.searchSessions({ text: "auth", limit: 10 });
const hits = all.filter((h) => byId.get(h.sessionId)?.cwd === "/repo");

// (d) index chosen metadata fields at sync time; filter natively in the index
createSqliteSearchService({ repo, dbPath, metadataFields: ["cwd"] });
await search.searchSessions({ text: "auth", where: { cwd: "/repo" } });

(a) keeps one round trip but makes the service generic over each repo's filter vocabulary; (b) composes with any repo unchanged but ships a possibly huge id set into the query; (c) is unsound as shown — filtering after limit drops results; (d) is what the index does best but couples the service to the metadata fields chosen at sync time and needs re-sync when they change.

2.9 The precise rewrite

Entries and usage rows are never deleted (§1.2). The sole sanctioned exception is the precise rewrite: an administrative repository operation that copies the retained set — entries, usage rows, facts, lane registers — into a fresh session store over a coherent snapshot, exactly as a fork does (§2.8), then atomically swaps it for the old store. Its keep-predicate can express what no runtime mechanism may: compliance-grade erasure (including content copied forward into retainedTails and summaries), pruning abandoned branches, and re-minting legacy-format ids (Appendix B). It is tooling above the harness — no harness surface exposes it, and no core rule depends on it.

Part 3 — The operation state machine

3.1 Operations

ts
interface Operation {
  operationId: string;
  lane: string;
  sourceLeafId: string | null;
  startedAt: number;
  intent:
    | { kind: "run"; promptEntryIds: string[];
        systemPromptOverride?: string; resumeData?: Record<string, JsonValue> }
    | { kind: "compaction"; customInstructions?: string }
    | { kind: "navigation"; targetId: string | null; summarize: boolean;
        label?: string; customInstructions?: string };
}

Acceptance data lives in the op.meta/{operationId} register: written once at acceptance, never overwritten, and deleted by the terminal transaction (§3.13). sourceLeafId is the lane's leaf before the operation; entries the operation itself appends come after it. promptEntryIds name the caller's normalized prompt entries, born placed in the acceptance transaction (§3.6).

3.2 Operation state — the program counter

op.state/{operationId} holds one total OperationState directly. Every transition overwrites the whole register; the terminal transaction deletes it (§3.13). There is no finished member of the union — an ended operation has no state at all, and its outcome lives in lane.lastResult.

ts
type OperationState = RunState | CompactionState | NavigationState;

type Control =
  | { status: "running" }
  | { status: "cancel_requested"; requestedAt: number;
      /** Drained queue ids. Their pending.entry registers survive the drain
          and are deleted only by the terminal transaction (§3.11, §3.13). */
      drainedSteer: string[]; drainedFollowUp: string[] };

interface RunState {
  kind: "run";
  control: Control;
  /** Captured atomically at acceptance; setters affect later operations. */
  settings: {
    compaction: CompactionSettings;
    steeringMode: QueueMode;
    followUpMode: QueueMode;
    toolExecution: "sequential" | "parallel";
  };
  phase: RunPhase;
  inbox: Inbox;
  /** Newest durable assistant generation/fetch response in this operation. */
  latestAssistantEntryId: string | null;
}

interface CheckpointPhase {
  kind: "checkpoint";
  continuation: Continuation;
  /** Durable correlation source for the next generation step. */
  triggerEntryId: string;
  /** Threshold compaction is attempted at most once per trigger boundary. */
  thresholdCheckedTriggerEntryId?: string;
  /** Generate before draining another queued input after one-at-a-time drain. */
  skipInboxOnce?: boolean;
}

type RunPhase =
  | CheckpointPhase
  | { kind: "assistant"; generation: Generation }
  | { kind: "tools"; batch: ToolBatch }
  | { kind: "compaction"; reason: "threshold" | "overflow";
      structural: StructuralDecision; resumeAfter: CheckpointPhase }
  | { kind: "deferred"; deferred: Deferred }
  | { kind: "failure_drain"; error: OperationError; provenance:
      | { kind: "response"; entryId: string }
      | { kind: "structural"; taskId: string } };

type Continuation =
  | { kind: "need_assistant"; overflowRecoveryUsed: boolean }
  | { kind: "may_finish"; includeFinalAssistant: boolean };

interface Inbox {
  /** Reserved entry ids. Payloads — and, for writes, the entry type and
      customType — live in each id's pending.entry register (§1.3, §2.2). */
  steer: string[];
  followUp: string[];
  writes: string[];
}

interface OperationError { code: string; message: string; details?: JsonValue }

A queue item is one entry id; everything else about it — payload, write type, customType — is dereferenced from its pending.entry register.

latestAssistantEntryId updates in the same settlement transaction as every assistant generation or deferred-fetch response. It lets finish and resume construct results/events without a branch scan. A tool batch retains its producing turn id while tool work remains active.

Any transition that appends conversational input or tool results and requires another assistant writes a checkpoint with need_assistant(false) and the appended entry as triggerEntryId. A may_finish checkpoint sets triggerEntryId to the entry that caused the boundary: the settled response for a stop/genuine-length settlement (§3.7), the newest result entry for an all-terminating tool batch (§3.8) — so threshold dedup (§3.12) and restore validation (§3.3) always name an existing entry. An unprojected custom write preserves the current checkpoint, including trigger and overflow flag. Entering threshold compaction first copies the checkpoint to resumeAfter with thresholdCheckedTriggerEntryId = triggerEntryId; decline, empty preparation, success, and crash therefore cannot recheck the same boundary.

Generation

ts
interface NormalizedRetryPolicy { maxAttempts: number; baseDelayMs: number }

interface GenerationContext {
  stepId: string;
  triggerEntryId: string;
  /** Inline snapshot of the lane configuration at step start. */
  configuration: LaneConfiguration;
  streamOptions: AgentHarnessStreamOptions;
  retryPolicy: NormalizedRetryPolicy;
  /** Copied from the producing checkpoint's need_assistant continuation so a
      settlement classified after crash-restore still knows whether overflow
      recovery was already spent (§3.7, §3.9). */
  overflowRecoveryUsed: boolean;
}

type Generation =
  | { status: "ready"; context: GenerationContext; nextAttempt: number }
  | { status: "effect_pending"; context: GenerationContext; attempt: number;
      responseEntryId: string; usageId: string;
      intendedOutputLimit: number; contextWindow: number }
  | { status: "retry_wait"; context: GenerationContext; nextAttempt: number;
      notBefore: number; errorMessage: string };

The context snapshots configuration, stream options, and retry policy inline; LaneConfiguration is small. Recovery can therefore report exactly what is missing without resolving anything (§4.4). For each attempt, before_request runs from generation ready (an elapsed retry wait first returns to ready). Its curated patch is composed with the context's captured base stream options, then intendedOutputLimit and contextWindow are calculated and persisted in the effect_pending intent before dispatch. A pre-intent crash may rerun the hook. Harness-owned before_payload/after_response callbacks are mounted only after intent and cannot be replaced through stream options.

Tool batch

ts
interface ToolBatch {
  assistantEntryId: string;
  /** Producing generation/fetch snapshot; active tool names come from here. */
  configuration: LaneConfiguration;
  /** The assistant generation step id; recovered tool events use it as turnId. */
  turnId: string;
  calls: ToolCall[];
}

type ToolCall =
  | { status: "planned"; sourceIndex: number; resultEntryId: string }
  | { status: "effect_pending"; sourceIndex: number; resultEntryId: string;
      replay: "never" | "safe" }
  | { status: "completed"; sourceIndex: number; resultEntryId: string;
      terminate: boolean };

The source call comes from assistantEntryId plus sourceIndex; large effective arguments live once in the op.tool_args/{operationId}:{stepId}:{sourceIndex} register — the producing generation's stepId disambiguates batches across turns — written at clearance (§3.8) and located by that deterministic key — the state carries no per-call argument reference. Persist them unconditionally because prepareArguments, not only before_tool, may change them. Parallel calls may be effect-pending together; result entries commit in source order.

Deferred

ts
type Deferred =
  | { status: "suspended"; stepId: string; sourceEntryId: string; poll: number;
      configuration: LaneConfiguration; streamOptions: AgentHarnessStreamOptions }
  | { status: "effect_pending"; stepId: string; sourceEntryId: string; poll: number;
      responseEntryId: string; usageId: string;
      configuration: LaneConfiguration; streamOptions: AgentHarnessStreamOptions };

One resume() performs at most one fetchDeferred(handle, { wait: 0 }). Suspended poll is the number of completed polls; a fresh intent uses poll + 1, and that 1-based value is before_request.attempt and the poll turn-id suffix. A poll starts from the original generation's copied base stream options, forces deferred:false, runs before_request, mounts before_payload/after_response, then commits its fresh intent and dispatches like assistant generation. Current global stream settings do not affect it. There is no polling retry cap, backoff, or internal loop. A pending response must have a completely equal handle and becomes the next source. A mismatched pending handle is normalized to a durable error response explaining the mismatch; response, usage, latestAssistantEntryId, and response-provenance failure_drain commit atomically.

The complete transition table — every row is one commit(); classification order (§3.7) applies to every poll settlement, cancellation first:

FromTriggerTransactionTo
assistant effect_pendingsettlement classifies deferred with a valid handle§3.7's deferred rowsuspended, poll: 0, sourceEntryId: R
suspended, poll kresume(): the poll's before_request settlement commits its intent, consuming the invocation's single poll permitmint fresh R′ and U′, then TX[ S(deferred{effect_pending, poll k+1, responseEntryId R′, usageId U′}) ]effect_pending, poll k+1
effect_pending, poll k+1fetch returns pending with a completely equal handleTX[ insert response entry R′, upsert lane.leaf = R′, insert usage U′, S(latestAssistantEntryId=R′, deferred{suspended, sourceEntryId R′, poll k+1}) ] — the pending response becomes the next source and the operation re-suspends; no second poll this invocationsuspended, poll k+1
effect_pendingfetch returns pending with a mismatched handlenormalize to a durable error response explaining the mismatch: TX[ insert normalized response R′, upsert lane.leaf = R′, insert usage U′, S(latestAssistantEntryId=R′, failure_drain{error, provenance:response R′}) ]failure_drain
effect_pendingfetch returns ready with tool callsTX[ insert response R′, upsert lane.leaf = R′, insert usage U′, S(latestAssistantEntryId=R′, tools{plan with reserved result ids}) ] — result ids minted as followers of R′ (§1.2)tools
effect_pendingfetch returns ready without tool callsTX[ insert response R′, upsert lane.leaf = R′, insert usage U′, S(latestAssistantEntryId=R′, checkpoint{may_finish, includeFinalAssistant:true}) ]checkpoint
effect_pendingfetch settles as a provider errorTX[ insert response R′, upsert lane.leaf = R′, insert usage U′, S(latestAssistantEntryId=R′, failure_drain{error, provenance:response R′}) ] — polls have no retry pathfailure_drain
effect_pending, restored, running controlcrash left the poll's outcome unknown; the next resume() replaces itmint fresh R″/U″ and commit a fresh intent at the same poll number — an unknown-outcome poll never completed, so poll does not increment; the old reserved id strings are abandoned, never materializedeffect_pending, poll k+1
effect_pending, cancelled controlreconciliation, live or restored (§4.5, §4.6)synthetic settlement under the existing reserved ids: TX[ insert synthetic aborted response R′, upsert lane.leaf = R′, insert zero usage U′, S(latestAssistantEntryId=R′, cancelled checkpoint{may_finish}) ]cancelled checkpoint → aborted finish
suspended, cancelled controlreconciliationno fetch starts; best-effort cancel_deferred targets the newest source (§4.6), and the operation finishes through the aborted terminal transactionterminal

Structural work

ts
type StructuralDecision = { taskId: string } & (
  | { status: "deciding" }
  | { status: "generating"; generation: SummaryGeneration }
);

interface SummaryContext {
  taskId: string;
  resultEntryId: string;
  kind: "compaction" | "branch_summary";
  configuration: LaneConfiguration;
  streamOptions: AgentHarnessStreamOptions;
  retryPolicy: NormalizedRetryPolicy;
  reason?: "manual" | "threshold" | "overflow";
}

type SummaryGeneration =
  | { status: "ready"; context: SummaryContext; nextAttempt: number }
  | { status: "effect_pending"; context: SummaryContext; attempt: number;
      /** Current nested request intent; absent between requests. */
      request?: { index: number; usageId: string };
      usageIds: string[] }
  | { status: "retry_wait"; context: SummaryContext; nextAttempt: number;
      notBefore: number; errorMessage: string };

interface CompactionState {
  kind: "compaction";
  control: Control;
  customInstructions?: string;
  structural: StructuralDecision;
}

type NavigationState =
  | { kind: "navigation"; control: Control; targetId: string | null; label?: string;
      summarize: false; phase: { kind: "ready_to_commit" } }
  | { kind: "navigation"; control: Control; targetId: string; label?: string;
      customInstructions?: string; summarize: true;
      phase: { kind: "summary"; structural: StructuralDecision } };

Structural preparation is built from the reserved source leaf and settings snapshot, normalized (Set<string> file-operation fields become sorted arrays), and written once to the op.preparation/{operationId}:{taskId} register before the decision hook, in the same transaction as the deciding state (§3.9). State carries only taskId; the deterministic key locates the register, and hooks/generators hydrate arrays back to the source preparation types. Reopen never rebuilds it from current settings, so the provider sees the same summary input the hook approved.

One structural attempt may make one or two provider requests using the existing compaction implementation. Its request callback first commits request:{index,usageId}, then performs that provider request through a nested Effects action, then atomically writes usage and clears/advances the request field. Intermediate content remains process-local; any restored effect_pending attempt is treated as wholly uncertain and starts a later attempt under the captured policy rather than continuing request two. A durable generating decision prevents its decision hook from rerunning.

3.3 Lane state and current-state validity

ts
interface LaneState {
  currentOperationId: string | null;
  /** Reserved entry ids; payloads in pending.entry registers (§2.2). */
  pendingNextRun: string[];
}

Restore validates only the current lane and operation registers and the entries/registers they directly name; there is no history to audit and none exists. Required checks:

  • lane.state/{lane} holds a LaneState; when it names operation O, op.meta/O holds an Operation for that lane, and op.state/O holds an OperationState compatible with O's intent kind;
  • every entry id the current state or op.meta names — trigger, latest assistant, batch assistant, deferred source, completed results, prompt entries, a non-null sourceLeafId, a navigation intent's non-null targetId, the lane leaf — resolves to an existing entry of the expected type;
  • reserved response/result/usage ids, if materialized, contain the intended kind and identity; an unmaterialized reserved id resolves to nothing, which is the expected pre-settlement condition, never an error;
  • every id in inbox.*, control.drained*, and pendingNextRun has a pending.entry register with a valid payload; every effect-pending call has its op.tool_args register; every structural decision has its op.preparation register;
  • tool source indices are complete, ordered, unique, in range, and use unique result ids; completed result entries match their source calls;
  • cancellation, navigation source/target, and structural-source combinations satisfy the state discriminants.

Runtime schemas validate every decoded register value before publication. lane.lastResult is validated on its public read path — outcome/error/runCompletion combinations must be legal for the operation kind, and a completed run omits its final assistant only with runCompletion: "terminated_tools" — but it is never a recovery input (§3.13). These bounded checks reject corrupted/imported state that TypeScript transition functions could not have produced.

3.4 The atomic transition rule

Compute the next total state in memory, then atomically commit every entry insert, usage insert, and register write that makes that state true.

A transaction writing total LaneState rereads the latest register value inside the lane mutation line and changes only the fields owned by that transition. In particular, the terminal transaction clears currentOperationId while preserving concurrently accepted pendingNextRun. Conditional transitions identify the state they extend by register seq — the op.state seq, the lane.state seq, and, where a transition snapshots configuration, the expected lane.config seq (§4.1) — never by a value id; the CAS token changed, the linearization did not. Every edge below is exactly one commit().

3.5 The graph

mermaid
stateDiagram-v2
    [*] --> idle
    idle --> checkpoint : prompt() accepted

    checkpoint --> assistant : continuation = need_assistant
    checkpoint --> compaction : context threshold
    checkpoint --> checkpoint : apply write / consume steer / consume follow-up
    checkpoint --> terminal : may_finish + empty inbox

    assistant --> assistant : retryable error (retry_wait)
    assistant --> tools : toolUse
    assistant --> compaction : overflow (first time)
    assistant --> deferred : stopReason deferred
    assistant --> checkpoint : stop / genuine length
    assistant --> failure_drain : terminal error / retries exhausted / 2nd overflow

    tools --> tools : per-call intent + settlement
    tools --> checkpoint : batch complete

    compaction --> checkpoint : resumeAfter restored
    compaction --> failure_drain : overflow declined; threshold/overflow generation failed

    deferred --> deferred : poll returns pending
    deferred --> tools : ready response with calls
    deferred --> checkpoint : ready response without calls
    deferred --> failure_drain : provider error

    failure_drain --> checkpoint : new user-context input applied
    failure_drain --> terminal : inbox drained (failed)

    checkpoint --> terminal : abort reconciled (aborted)
    compaction --> terminal : abort before structural commit (aborted)
    failure_drain --> terminal : abort reconciled after writes drain (aborted)
    terminal --> [*]

terminal is not a state. It is the terminal transaction (§3.13): after it commits, the operation has no op.state register at all.

Standalone operations:

compaction:  deciding ──hook declines───────────→ terminal TX (declined)
                      ──hook supplies result────→ terminal TX (completed)
                      ──hook selects generation─→ generating ──→ terminal TX (completed|failed)

navigation:  ready_to_commit ───────────────────→ terminal TX (completed)
             summary.deciding ──hook declines───→ terminal TX (declined; no move)
                              ──→ generating ───→ terminal TX (completed|failed)

A declined summarized navigation moves nothing: the leaf stays at the source, and the terminal transaction records outcome declined. Abort before any structural commit finishes aborted, likewise without a move (§4.6).

3.6 Acceptance

FromTriggerTransaction
idle laneprompt() after before_runTX[ insert entries for captured nextRun items (payloads from their pending.entry registers) and the new messages (caller prompt, hook injections) in order, delete the captured pending.entry registers, upsert lane.leaf = newest entry, upsert op.meta/O, S(run{captured settings, checkpoint need_assistant(false), trigger = newest entry, skipInboxOnce, empty inbox}), L({currentOperationId: O, captured ids removed from pendingNextRun}) ]
reserved idle lanecompact() with non-empty preparationTX[ upsert op.preparation/O:{taskId} = P, upsert op.meta/O, S(compaction{deciding, taskId}), L({currentOperationId: O}) ]
idle laneunsummarized navigateTree() after validationTX[ upsert op.meta/O, S(navigation{ready_to_commit}), L ]
reserved idle lanesummarized navigateTree() with preparationTX[ upsert op.preparation/O:{taskId} = P, upsert op.meta/O, S(navigation{summary.deciding, taskId}), L ]

Captured nextRun items already have their payloads in pending.entry registers; acceptance inserts their entries from those payloads, deletes the registers, and removes the ids from pendingNextRun — the placement half of the one deliberate double write (§1.8). A late-captured item keeps its enqueue-minted id (§1.2).

Manual compaction first allocates its operation id and takes a process-local lane admission reservation, then reads preparation. Summarized navigation uses the same reservation while collecting/building branch preparation; unsummarized navigation needs none because validation and acceptance share one lane-line job. While reserved, competing operations receive LaneBusy naming that provisional id/kind and idle tree writes wait; nextRun and configuration changes may still commit because they do not move the leaf. Empty compaction preparation releases the reservation and returns NothingToCompact with no operation write. Non-empty preparation is accepted only against the unchanged reserved source leaf. Process death drops the reservation and leaves the lane idle.

Pre-acceptance rejections write nothing: LaneBusy, NothingToCompact, InvalidNavigation (target is the current leaf, label on the root target, summarize from root, or a null target with summarize), UnknownTarget (non-null target missing), MissingIdentities (model, provider, or an active tool name does not resolve), and InvalidMessage when acceptance would append zero entries — an empty normalized prompt with no hook injections and no captured nextRun items leaves no newest entry to anchor the checkpoint's trigger. Prompt allocates its operation id before before_run so hook idempotency keys are stable. The hook still runs before acceptance; if a concurrent caller wins the lane, its output and provisional id are discarded and no operation exists.

Acceptance must observe currentOperationId === null. Because acceptance is on the lane mutation line, this is validation, not compare-and-swap.

3.7 Assistant generation

FromTriggerTransactionTo
checkpoint need_assistantdriveconditionally snapshot current lane config, stream options, and normalized retry policy inline into the context in TX[ S(assistant{ready, nextAttempt:1}) ]ready
assistant readybefore_request aggregate completesmint R and U, then TX[ S(assistant{effect_pending, attempt=nextAttempt, responseEntryId R, usageId U, intendedOutputLimit, contextWindow}) ]effect_pending
effect_pendingsettles with tool callsTX[ insert response entry R, upsert lane.leaf = R, insert usage U, S(latestAssistantEntryId=R, tools{plan with reserved result ids}) ]tools
effect_pendingretryable error, attempts remainTX[ insert response entry R, upsert lane.leaf = R, insert usage U, S(latestAssistantEntryId=R, assistant{retry_wait, nextAttempt k+1, notBefore}) ]retry_wait
effect_pendingfirst overflow, preparation non-emptyTX[ insert response entry R **normalized to error**, upsert lane.leaf = R, insert usage U, upsert op.preparation/O:{taskId} = P, S(latestAssistantEntryId=R, compaction{reason:overflow, structural:{deciding, taskId}, resumeAfter:{checkpoint, prior trigger, need_assistant(true)}}) ]compaction
effect_pendingfirst overflow, preparation emptyTX[ insert normalized response entry R, upsert lane.leaf = R, insert usage U, S(latestAssistantEntryId=R, failure_drain{error, provenance:response R}) ]failure_drain
effect_pendingstopReason: "deferred"TX[ insert response entry R, upsert lane.leaf = R, insert usage U, S(latestAssistantEntryId=R, deferred{suspended, sourceEntryId R, poll 0, configuration/options copied}) ]deferred
effect_pendingstop or genuine lengthTX[ insert response entry R, upsert lane.leaf = R, insert usage U, S(latestAssistantEntryId=R, checkpoint{may_finish, includeFinalAssistant:true}) ]checkpoint
effect_pendingterminal error, retries exhausted, or 2nd overflowTX[ insert response entry R, upsert lane.leaf = R, insert usage U, S(latestAssistantEntryId=R, failure_drain{error, provenance:response R}) ]failure_drain
retry_waitnotBefore elapsedTX[ S(assistant{ready, nextAttempt:k+1}) ]ready

There is never a durable "response without usage" or "response and usage without a decision." All three land together or none do. R and U are minted at intent and exist only as strings in the state until settlement inserts the complete rows (§2.2). A settlement that plans tools mints each resultEntryId as a follower of R, inheriting its 48-bit timestamp (§1.2), so the assistant and its results form one id-cohesive group by construction.

Classification order

Pure, computed in memory before the settlement transaction. First match wins.

ConditionResult
control.status === "cancel_requested"normalize stop reason to aborted; commit checkpoint{may_finish, includeFinalAssistant:true} under cancelled control, then reconcile writes/finish
overflow: adapter-reported, or error whose message matches the context-limit patterns, or length with output below intendedOutputLimitnormalize stop reason to error; compact (first time) or failure_drain (second)
deferred with a valid handledeferred suspended
retryable error, attempts remain / otherwiseretry_wait / failure_drain
toolUse, or an accepted response carrying callstools
stop or genuine output-limit lengthcheckpoint may_finish

Two normalizations happen at commit, and both are deliberate. A cancelled response commits as aborted. An overflow-classified response commits as error. In both cases the original stop reason is overwritten and the reason is preserved in human-readable form in errorMessage.

Because the committed response is error, §2.5 rule 3 drops it from context automatically — the compaction and the operation state carry no reference to it, and no dedicated omission rule exists. The response stays in the tree as durable history, because a provider request happened and was billed.

Overflow detection is a heuristic and must be labelled as one. Three sources, in decreasing reliability:

  1. Adapter-reported. A provider adapter that can compute usage.input + usage.cacheRead > contextWindow at settlement sets stopReason: "error" with a message matching the context-limit patterns. This requires no new stop reason and no change to any adapter's stop-reason mapping, which matters because those mappings typically throw on unknown values. An adapter doing this should also require negligible output, so a substantive answer that merely trips a counter is not discarded.
  2. Error-message matching. Providers usually return a context-limit failure as an HTTP error, which arrives as error with a message. Matching it is string matching, and it is brittle wherever it lives.
  3. length below intendedOutputLimit. Harness-side only. An adapter must not apply this rule, because it cannot distinguish an oversized request from a response truncated mid-thinking — and those need opposite treatment, since a genuine truncation must stay in context.

Overflow is checked before retryable error, so an oversized request compacts rather than retrying unchanged.

aborted is not a classification input. It means the harness's own abort signal fired (§4.6), and abort() commits control before signalling — so a settled aborted response always has control.status === "cancel_requested" and is caught by the first row. An aborted response with control.status === "running" is unreachable and is corruption (Part 9).

An overflow classification never produces a tool plan. A genuine length that carries tool calls does produce the full plan, executes nothing, and appends one isError: true result per call explaining that truncation may have corrupted the arguments — those results then require another assistant turn.

3.8 Tools

FromTriggerTransactionTo
call i plannedclearance passed (before_tool, lookup, arg validation)TX[ upsert op.tool_args/O:{stepId}:{i} = effective args, S(call i = effect_pending, replay) ]dispatch
call i effect_pendingeffect settled, after_tool appliedTX[ insert result entry, upsert lane.leaf, insert tool usage row (if reported), S(call i = completed, terminate) ]tools or checkpoint
call i plannedunknown tool / invalid args / before_tool blocks or throws / control cancelledTX[ insert synthetic error result entry, upsert lane.leaf, S(call i = completed, terminate from an intentional block, otherwise false) ]tools
all calls completedfolded into the last settlement, which also deletes the batch's op.tool_args/{O}:{stepId}:* registerscheckpoint

The batch's completion transition is:

  • every completed call set terminate: truecheckpoint{may_finish, includeFinalAssistant: false}
  • otherwise → checkpoint{need_assistant(overflowRecoveryUsed: false)}

terminate exists so a tool can end the run without another provider turn. The motivating case is a "submit final result" tool used in place of structured output: the model calls it, the harness commits the result, and the run finishes with those tool results as its final entries — run_end then carries no finalMessage. Without this, every such run would pay for one more model turn whose only job is to stop.

Modes:

  • Sequential (option, or any called tool declares executionMode: "sequential"): clear → intent → execute → finalize → commit, one call at a time.
  • Parallel (default): clearance and intent commits happen in source order; dispatch does not await earlier calls; effects settle concurrently; phase 3, result-message lifecycle, and result commits are awaited and finalized in source order.

Blocked and invalid calls skip the intent commit and the effect, but still commit a result at their source position. Their op.tool_args register is never written.

Calls are tracked internally by sourceIndex. Hooks, events, and tool context see the provider toolCallId and tool name — never the index.

3.9 Summary generation — compaction and navigation summaries

Both operations generate a summary through the same deciding → generating → result machinery, which is why they are specified together. The axes:

compactionnavigation
standalone operationlane.compact() — reason manuallane.navigateTree(target)
phase inside a runreasons threshold, overflow
reasonwho askedon hook decline
manualthe calleroperation finishes declined
thresholdcontext-size check at a checkpointback to the stored resumeAfter
overflowa request that did not fitfailure_drain

"Auto compaction" is the in-run row: threshold and overflow. Non-empty preparation and the transition into deciding commit together (upsert op.preparation/O:{taskId} plus the structural state and, for threshold, marked resumeAfter). Preparation returning undefined never creates StructuralDecision: threshold atomically marks the checkpoint checked and continues; overflow atomically enters response-provenance failure_drain using the normalized overflow response. Neither path emits structural lifecycle. Empty standalone preparation is rejected before acceptance.

FromTriggerTransaction
decidinghook declinesstandalone: the terminal transaction (§3.13) with outcome declined · threshold: TX[ S(restore marked resumeAfter) ] · overflow: TX[ S(failure_drain{error, provenance:structural taskId}) ]
decidinghook supplies compactionstandalone: TX[ insert hook usage row?, insert compaction entry, upsert lane.leaf, terminal writes (§3.13) ]; in-run: same result-publication writes plus S(resumeAfter)
decidinghook supplies navigation summaryuse §3.10's final transaction with the hook usage/result
decidinghook selects generationconditionally snapshot current config/policy inline in TX[ S(generating{ready}) ]the decision hook will never run again
generating ready / retry elapseddriveTX[ S(effect_pending, attempt k) ]
generating effect_pendingone nested request returnsTX[ insert usage row under request.usageId, S(effect_pending, request cleared, usageIds += id) ]; commit another request intent before request two
generating effect_pendingretryable attempt outcomeusage is already durable; TX[ S(retry_wait) ]
generating effect_pendingterminal or attempts exhaustedstandalone: the terminal transaction (§3.13) with outcome failed · in-run: TX[ S(failure_drain{provenance:structural taskId}) ]
generating effect_pendingcompaction succeededstandalone: TX[ insert result entry, upsert lane.leaf, terminal writes (§3.13) ]; in-run: result-publication writes plus S(resumeAfter)

Structural provider streams are internal: they emit no public assistant-message lifecycle. The existing summary generator is retained, but its one/two request callback uses the nested request intent/effect/usage boundaries from §3.2 and §4.2. Intermediate content is not persisted; a crash before the final transaction makes the whole attempt unknown, and a later numbered attempt starts only under the captured retry policy. Failed-attempt usage stays in the ledger regardless — terminal cleanup deletes registers, never ledger rows (§1.6).

Worked example — overflow

e_40 is a tool result awaiting an assistant turn. The request does not fit.

… e_38 ── e_39 ── e_40                     phase: assistant, effect_pending
                                           continuation was need_assistant(false)

1. Settlement. Classification says overflow. Preparation is built against the would-be branch; because the known response is normalized to error, ordinary projection excludes it. Response and preparation then commit together:

TX[ insert e_41 = { …assistant response, stopReason: "error",
                    errorMessage: "context window exceeded: …" },
    upsert lane.leaf/main = "e_41", insert usage u_41,
    upsert op.preparation/op_9:t_1 = <structural preparation>,
    S(compaction{ reason: overflow,
                  structural: { deciding, taskId: "t_1" },
                  resumeAfter: { checkpoint, triggerEntryId: "e_40",
                                 continuation: need_assistant(true) } }) ]

… e_38 ── e_39 ── e_40 ── e_41

2. Compaction. The durable preparation was built by the ordinary rules in §2.5. e_41 is an error response, so rule 3 dropped it — from the summary input and from retainedTail alike, with no special case:

… e_40 ── e_41 ── e_42 (compaction)
                  retainedTail: [e_39, e_40]        ← e_41 absent by rule 3

The tail ends on e_40, a tool result, which is the correct shape for a request that is about to ask for an assistant turn.

3. Resume. resumeAfter restores need_assistant(overflowRecoveryUsed: true). Context is now summary + tail + anything after e_42, which is small:

… e_41 ── e_42 ── e_43        the answer to e_40
   ✗ (error, out of context)

e_41 remains in the tree forever as durable history — a request was made and billed. If the retry overflows again, overflowRecoveryUsed is already true and the run goes to failure_drain rather than compacting in a loop. Consuming new user input appends to the tree and resets the flag to false.

3.10 Navigation

Unsummarized and summarized both finish in one transaction — navigation's terminal transaction (§3.13) with its result-publication writes inline:

TX[ insert hook-reported usage row (only for a hook-supplied summary),
    upsert lane.leaf = target,
    insert summary entry with its display usage snapshot (when summarize;
      parent is the target; fromId = the operation's sourceLeafId — the
      pre-navigation source leaf),
    upsert lane.leaf = summary entry (when summarize),
    upsert fact.label (when a label is present),
    delete the operation's op.* registers,
    upsert lane.lastResult = { kind: "navigation", outcome: "completed", leafId },
    L({ currentOperationId: null }) ]

Writes apply in order inside the transaction. Generated provider usage was already written per request in §3.9 and is not written again here; the summary payload only snapshots its producing attempt's usage. The summary entry explicitly names the target as parent, and the following register write makes that summary the completed lane leaf. A crash sees either an untouched navigation still at its source, or a fully completed one. No prepared-summary state and no post-move recovery state exist. Abort before this transaction ends in an aborted terminal transaction with no entry appended; abort after it means the operation completed.

3.11 Inbox, queues, deferred writes

Every queued admission mints the item's entry id (§1.2) and writes its payload once into pending.entry/{id}; queue lists carry only the id.

Public inputAdmitted whenTransaction
nextRun(msg)any state, including idleTX[ upsert pending.entry/{id} = payload, L(pendingNextRun += id) ] — never starts a run
steer(msg)open run with running control — including deferred suspension; under cancel_requestedNoActiveRunTX[ upsert pending.entry/{id} = payload, S(inbox.steer += id) ]
followUp(msg)open run with running control — including deferred suspension; under cancel_requestedNoActiveRunTX[ upsert pending.entry/{id} = payload, S(inbox.followUp += id) ]
tree write, run activeincluding suspended and cancellingTX[ upsert pending.entry/{id} = payload, S(inbox.writes += id) ] — survives abort
tree write, lane idleidleTX[ insert entry, upsert lane.leaf ]
tree write, structural op openwait for the operation to end, then re-evaluate
cancelQueued(id)item still pendingTX[ S or L with the id removed, delete pending.entry/{id} ]
checkpoint consumes inputeligibleTX[ insert entries from the register payloads, delete their pending.entry registers, upsert lane.leaf, S(ids removed, continuation → need_assistant(false), triggerEntryId = newest entry, skipInboxOnce = true) ]
first abort()run activeTX[ S(control = cancel_requested, requestedAt, drainedSteer, drainedFollowUp, steer/followUp emptied) ] — drained pending.entry registers are not deleted
finishinbox empty, no required continuationthe terminal transaction (§3.13)

cancelQueued triage, in order: the id is still pending in a queue list → remove it and delete its pending.entry register in one transaction; the content is gone, never having touched the tree, and the call returns cancelled. An entry under that id exists → already_consumed. Neither → not_found — previously cancelled, cleared by abort, or never existed. A client retrying a lost cancel treats not_found as success. There are no disposition registers, and nothing here is ever a recovery input.

The first abort() moves steer/follow-up ids into control.drainedSteer/control.drainedFollowUp but deletes none of their pending.entry registers: AbortResult and a post-crash SuspendedOperation.aborting dereference the drained payloads from those registers. They die in the terminal transaction (§3.13), never earlier. Deferred writes stay in inbox.writes and are applied during reconciliation.

Because acceptance, cancellation, consumption, abort, and finish all serialize on the lane mutation line, every race has exactly two possible histories, and no item can be both pending and applied in durable state: at every commit boundary a queued id has its register (pending or drained), its entry (consumed), or neither (cancelled) — never both.

3.12 The checkpoint procedure

Order matters. At each queue drain point, "all" consumes every currently eligible item in acceptance order; "one-at-a-time" consumes only the oldest and leaves the rest pending. Any projecting drain sets durable skipInboxOnce; on that next pass the planner skips steps 1–2, starts generation, and clears the flag in the ready-state transition. Thus a crash cannot turn one-at-a-time into an all-item drain.

  1. Unless skipInboxOnce, atomically apply accepted deferred writes.
  2. Unless skipInboxOnce, atomically consume eligible steering, per the steering mode.
  3. Run threshold compaction only when thresholdCheckedTriggerEntryId !== triggerEntryId, preserving the marked checkpoint in resumeAfter.
  4. If the continuation is need_assistant, start generation and clear skipInboxOnce.
  5. Once assistant and tool continuation are exhausted, atomically consume eligible follow-up.
  6. If the continuation is may_finish and the inbox is empty, invoke before_run_end.
  7. Conditionally finish — the terminal transaction (§3.13).

Consumed steer/follow-up and projecting message writes enter need_assistant(false), set triggerEntryId to the newest appended entry, and set skipInboxOnce. Tool results do the same unless every result terminates. An unprojected custom write is appended and removed from the inbox but preserves the prior continuation, failure provenance, and overflow flag. Under cancelled control, every deferred write is appended and removed without changing phase/continuation or starting work; reconciliation ends in an aborted terminal transaction after writes drain.

before_run_end may return a follow-up. It commits only if control is still running and the operation is still at the same finish boundary; otherwise the stale hook result is dropped. The follow-up is born placed — its entry and the need_assistant state commit together, with no pending register.

failure_drain applies accepted writes, then eligible steer and follow-up input in the same order. Projecting user-context input atomically enters checkpoint{need_assistant(false)} and clears the failure. Unprojected custom writes do not. With no such input, it finishes failed without before_run_end or another provider request.

3.13 Terminal transactions

There is no finished state. An operation ends by ceasing to exist: one terminal transaction deletes every register the operation owns, records the outcome in lane.lastResult, and clears the lane's currentOperationId. After it commits, the operation's only durable footprint is the conversation entries and ledger rows it produced.

The result is computed in memory, pre-commit, from the final operation state — the same value the caller's promise resolves with. What lands durably is its register form:

ts
type LaneLastResult = {
  operationId: string;
  kind: "run" | "compaction" | "navigation";
  leafId: string | null;
  /** Newest settled assistant, when the outcome includes one (runs only). */
  finalAssistantEntryId?: string;
} & (
  | { outcome: "failed"; error: OperationError; runCompletion?: never }
  | { outcome: "completed"; error?: never;
      runCompletion?: "assistant" | "terminated_tools" }
  | { outcome: "declined" | "aborted"; error?: never; runCompletion?: never }
);

A normal run finish copies RunState.latestAssistantEntryId and records runCompletion: "assistant" when may_finish.includeFinalAssistant is true. An all-terminating tool batch records runCompletion: "terminated_tools" and omits the final assistant. Failed and aborted run outcomes include the newest settled assistant when non-null and omit the field otherwise. Structural operations omit runCompletion and the final assistant. Only terminal transitions construct a LaneLastResult.

Every terminal transaction, for every operation kind and outcome, has one shape:

TX[ <result-publication writes, when the terminal transition also publishes
     content: §3.9's standalone summary entry and leaf move, §3.10's
     navigation writes>,
    delete op.meta/{O},
    delete op.state/{O},
    delete op.tool_args/{O}:*        defensive prefix scan — listRegisters with
                                     keyPrefix (§1.5); batch completion already
                                     deletes these atomically (§3.8),
    delete op.preparation/{O}:*      prefix scan; in-run compactions leave their
                                     preparation after resume,
    delete pending.entry/{id}        for every operation-owned pending id,
    upsert lane.lastResult/{lane} = <computed result>,
    L({ currentOperationId: null }) ]

Operation-owned pending ids are the remaining inbox.steer ∪ inbox.followUp ∪ inbox.writes plus control.drainedSteer ∪ control.drainedFollowUp — registers that survived an abort drain die here (§3.11). Never lane.state.pendingNextRun: those registers are lane-owned, outlive operations, and die only when consumed or cancelled. Ledger rows are never deleted (§1.6). The L write rereads the latest LaneState on the lane mutation line and clears only currentOperationId, preserving concurrently accepted pendingNextRun (§3.4).

For the completed run of §0.4's shape — prompt e_50, tool call e_51/e_52, final answer e_53:

TX[ delete op.meta/op_9,
    delete op.state/op_9,
    delete op.tool_args/op_9:s_1:0,   ← usually already gone at batch completion
    upsert lane.lastResult/main = { operationId: "op_9", kind: "run",
                                    outcome: "completed", leafId: "e_53",
                                    finalAssistantEntryId: "e_53",
                                    runCompletion: "assistant" },
    upsert lane.state/main = { currentOperationId: null, pendingNextRun: [] } ]

After it, the session holds exactly the conversation entries, the ledger rows, and the lane's registers (lane.leaf, lane.config, lane.state, lane.lastResult). The run's ~10 op.state revisions, its tool-args register, and any pending payloads existed only as register overwrites and are gone — nothing to collect (§1.8).

The observation contract. A terminal outcome is observable once through the live caller's promise (and the corresponding run_end/compaction_end/navigation_end event), which carries the full in-memory result, and thereafter through lane.lastResult until the next terminal transaction on the same lane overwrites it. lane.lastResult is written only by terminal transactions — one bounded register per lane, forever. Recovery never reads it: restore treats a lane with currentOperationId: null as idle regardless of the register's content. It exists so an application that accepted an operation, lost its process, and reopened can still answer "what happened to op_9?" — including outcomes the tree alone cannot reconstruct: a structural failure's error, declined, and the aborted-versus-completed ambiguity of a leaf that moved.

The invariant this section carries (restated in Part 9): op.* registers and operation-owned pending.entry registers exist iff their operation is open, because the terminal transaction deletes them atomically with clearing currentOperationId. There is no partial-cleanup state to observe or repair.

Part 4 — Execution, recovery, abort, close

4.1 The interpreter

The runtime plans from total durable state plus a small process-local scheduler. Entries and stable register values named by the state are batch-loaded before planning. The driver also snapshots the current settings revision into RuntimeSnapshot; this performs no provider request. Providers and tools are resolved from their registries at dispatch time by the durable identities captured in state — a missing or replaced entry fails that dispatch in-band (synthetic error settlement), exactly like an unknown tool. When a tool batch first becomes current, the driver resolves toolContext once and retains it in DriveState.toolBatches for every sequential/parallel call in that batch. nextAction is then pure over those inputs.

ts
interface CurrentOperation {
  operation: Operation;
  state: OperationState;
  /** Register seqs at load time; conditional commits compare these (§3.4). */
  operationStateSeq: number;
  laneState: LaneState;
  laneStateSeq: number;
  leafId: string | null;
  configuration: LaneConfiguration;
  configurationSeq: number;
}

type EffectKey = string; // deterministic from durable step/attempt or assistant/sourceIndex

interface LiveEffect { plan: EffectPlan; promise: Promise<EffectOutput> }

interface DriveState {
  deferredPollsRemaining: 0 | 1;
  running: Map<EffectKey, LiveEffect>;
  /** One context/tool-definition snapshot per live or restored batch. */
  /** toolContext resolved once per batch; key: assistantEntryId. */
  toolBatches: Map<string, unknown>;
  /** Process-local best-effort attempts; reopen may attempt again. */
  deferredCancellations: Set<string>;
}

type EffectPlan = { telemetryContext: TelemetryContext } & (
  | { kind: "assistant"; key: EffectKey;
      generation: Extract<Generation, { status: "effect_pending" }>;
      streamOptions: AgentHarnessStreamOptions }
  | { kind: "summary"; key: EffectKey;
      generation: Extract<SummaryGeneration, { status: "effect_pending" }> }
  | { kind: "tool"; key: EffectKey; assistantEntryId: string;
      sourceIndex: number;
      /** Full op.tool_args register key: {opId}:{stepId}:{sourceIndex} (§3.8). */
      argsKey: string }
  | { kind: "deferred"; key: EffectKey;
      deferred: Extract<Deferred, { status: "effect_pending" }>;
      streamOptions: AgentHarnessStreamOptions }
  | { kind: "cancel_deferred"; key: EffectKey; sourceEntryId: string;
      handle: DeferredHandle }
  | { kind: "hook"; key: EffectKey; name: keyof HookMap; event: unknown }
);

type SummaryAttemptOutcome =
  | { kind: "success"; result: CompactResult | BranchSummaryResult }
  | { kind: "retry" | "failure"; error: OperationError };

type EffectOutput =
  | { kind: "not_started"; key: EffectKey }
  | { kind: "assistant" | "deferred"; key: EffectKey;
      message: SettledAssistantMessage }
  | { kind: "summary"; key: EffectKey; outcome: SummaryAttemptOutcome }
  | { kind: "tool_raw"; key: EffectKey;
      result: AgentToolResult<unknown>; isError: boolean }
  | { kind: "hook"; key: EffectKey; result: unknown }
  | { kind: "cancel_deferred"; key: EffectKey };

type SettlementOutput = Exclude<EffectOutput, { kind: "tool_raw" }> |
  { kind: "tool"; key: EffectKey; result: AgentToolResult<unknown>;
    isError: boolean; terminate: boolean };

interface SettlementResult {
  current: CurrentOperation;
  /** Immediate live dispatch prepared by a successful pre-intent hook. */
  dispatch?: EffectPlan;
  /** Identity resolution failed while durable state was still safely dispatchable. */
  suspend?: OperationResult;
  /** Poll intent committed; consume this resume invocation's sole permit. */
  consumeDeferredPoll?: true;
}

interface RuntimeSnapshot {
  settingsRevision: number;
  streamOptions: AgentHarnessStreamOptions;
  retryPolicy: NormalizedRetryPolicy;
}

type PlannerInputs = {
  /** Exact process-local plans; never reconstruct a live plan from durable ids. */
  running: ReadonlyMap<EffectKey, EffectPlan>;
  deferredPollsRemaining: 0 | 1;
  deferredCancellations: ReadonlySet<string>;
  /** Entries plus loaded op.tool_args/op.preparation/pending.entry register
      values — written once per key or stable until consumed, so safe as
      immutable planner inputs. Keyed by entry id or register key. */
  loaded: ReadonlyMap<string, Entry | Register>;
  runtime: RuntimeSnapshot;
  context?: AgentMessage[];
  now: number;
};

type OperationResult = RunOutcome | CompactionOutcome | NavigationOutcome;

type Action =
  | { kind: "transition"; next: OperationState; telemetryContext: TelemetryContext;
      /** Required when this transition snapshots current mutable request state. */
      expectedConfigurationSeq?: number;
      expectedSettingsRevision?: number }
  | { kind: "dispatch"; intent?: OperationState; effect: EffectPlan;
      consumeDeferredPoll?: true }
  | { kind: "await_effect"; key: EffectKey }
  | { kind: "wait"; until: number; telemetryContext: TelemetryContext }
  | { kind: "suspend"; result: OperationResult }
  | { kind: "finish"; result: OperationResult };

async function drive(current: CurrentOperation, live: DriveState): Promise<OperationResult> {
  while (true) {
    const inputs = await loadPlannerInputs(current, live); // bounded entry/register reads
    const action = nextAction(current.state, inputs);       // pure and exhaustive

    switch (action.kind) {
      case "transition": {
        const committed = await commitTransitionIfCurrent(
          current, action.next, action.telemetryContext,
          action.expectedConfigurationSeq, action.expectedSettingsRevision);
        current = committed ?? await reloadCurrent(current.operation.operationId);
        break;
      }

      case "dispatch": {
        if (action.intent) {
          const committed = await commitTransitionIfCurrent(
            current, action.intent, action.effect.telemetryContext);
          if (!committed) {
            current = await reloadCurrent(current.operation.operationId);
            break;                         // a lane mutation won; do not dispatch
          }
          current = committed;
        }
        if (action.consumeDeferredPoll) live.deferredPollsRemaining = 0;
        if (action.effect.kind === "cancel_deferred")
          live.deferredCancellations.add(action.effect.sourceEntryId);
        live.running.set(action.effect.key,
          { plan: action.effect, promise: fx.run(action.effect) });
        break;                             // permits source-ordered parallel dispatch
      }

      case "await_effect": {
        const liveEffect = live.running.get(action.key);
        if (!liveEffect) throw new Error("planned effect is not running");
        const { plan } = liveEffect;
        const output = await liveEffect.promise;
        live.running.delete(action.key);
        if (plan.kind === "cancel_deferred") {
          current = await reloadCurrent(current.operation.operationId); // no durable write
          break;
        }
        let settlement: SettlementOutput;
        if (output.kind === "tool_raw") {
          if (plan.kind !== "tool") throw new Error("tool output/plan mismatch");
          settlement = await fx.finalizeTool(plan, output); // source-ordered after_tool
        } else {
          settlement = output; // not_started settles synthetically without hooks
        }
        const settled = await commitEffectSettlement(
          current, plan, settlement, plan.telemetryContext);
        current = settled.current;
        if (settled.suspend) return settled.suspend;
        if (settled.consumeDeferredPoll) live.deferredPollsRemaining = 0;
        if (settled.dispatch)
          live.running.set(settled.dispatch.key,
            { plan: settled.dispatch, promise: fx.run(settled.dispatch) });
        break;
      }

      case "wait":
        await fx.sleep(
          Math.max(0, action.until - Date.now()), action.telemetryContext);
        current = await reloadCurrent(current.operation.operationId);
        break;

      case "finish":
        current = await fx.commitTerminal(current, action.result) ?? current;
        return action.result;

      case "suspend":
        return action.result;
    }
  }
}

An intent/ordinary transition requires the op.state register still to carry its expected operationStateSeq; otherwise it returns undefined and the loop replans without dispatch. If a conditional commit or reloadCurrent instead finds the operation's registers gone — it is no longer the lane's current operation — the drive stops through external finalization (§4.9). A successful before_request/before_tool hook settlement atomically commits the effect intent (and the effective op.tool_args register) and returns the complete process-local dispatch plan; the drive installs that promise immediately. A crash in the remaining process-only gap is conservatively the ordinary unknown-effect case. A transition that creates a generation/summary ready state also supplies the lane.config register seq and harness-settings revision it read; the settings/lane commit requires both still match, giving setter-first or step-start-first ordering. The resulting context durably captures the inline configuration, normalized retry policy, and base stream options. Immediately before ordinary external execution, fx.run enters the lane mutation line once more: cancellation-first returns not_started, while start-first registers the live effect/controller so a later abort signals it. Dispatch then resolves the provider or tool from its registry by the captured durable identity; resolution failure settles in-band. Thus no effect starts in the gap after intent without belonging to one of the two serialized orders. Settlement reloads latest total state, verifies the same effect key remains pending, merges the output into that state, and applies current cancellation control. Thus steer/write acceptance, abort, and other parallel-tool intents cannot erase a live result or overwrite newer inbox/control state.

Parallel tool calls dispatch phase two in source order into DriveState.running. The planner may dispatch later calls while earlier promises run, but it emits await_effect only for the first incomplete source position. That raw result then crosses source-ordered fx.finalizeTool/after_tool before settlement. A later settled raw promise remains process-local until its turn. After restart running is empty, so durable effect_pending follows recovery policy rather than being mistaken for a live effect.

Recovery rules:

  • not_started under cancelled control settles assistant/fetch under reserved ids as aborted, settles a tool with its planned aborted result without after_tool, drops an uncommitted hook decision, discards structural work before finishing aborted, and drops a stale deferred-cancel action without settlement;
  • ready generation/summary and cleared tools commit effect_pending before dispatch;
  • restored generation/summary pending with no live key advances under captured retry policy or settles synthetically at the cap;
  • restored tools replay only when persisted and current declarations are safe, otherwise settle interrupted;
  • restored deferred pending normally suspends until an application resume() replaces it with one fresh poll intent; cancelled control instead settles the existing reserved response/usage ids synthetically as aborted before finishing;
  • committing a deferred intent through its before_request settlement returns consumeDeferredPoll:true; the drive clears the invocation's sole permit before installing dispatch, so a pending response re-suspends rather than polling again;
  • retry wait crosses fx.sleep, which is visible to manual drive and reloads cancellation afterward;
  • structural decision hooks run from deciding; their consumer transaction either finishes the structure or records generating, so only a pre-commit crash reruns them.

A fresh operation drive starts with zero deferred permits; resume() starts with one. Repairs and non-poll work do not consume it.

4.2 The effects boundary

Every operation-procedure commit, provider request, tool invocation, hook call, and timer crosses exactly one injected Effects (fx) method. Procedures receive fx, their telemetry context, and a read-only runtime view — never Session, Models, the tool registry, or the hook runner directly. Ungated lane-surface commits—acceptance, queue/configuration calls, facts, lane creation, and idle writes—use the same lane mutation line and typed Session transaction API directly.

ts
type SummaryRequestOutput =
  | { kind: "response"; message: SettledAssistantMessage }
  | { kind: "not_started" };

interface Effects {
  commitTransition(current: CurrentOperation, next: OperationState,
                   telemetry: TelemetryContext,
                   expectedConfigurationSeq?: number,
                   expectedSettingsRevision?: number):
    Promise<CurrentOperation | undefined>;
  commitEffectSettlement(current: CurrentOperation, plan: EffectPlan,
                         output: SettlementOutput, telemetry: TelemetryContext):
    Promise<SettlementResult>;
  /** The terminal transaction (§3.13): register deletes, lane.lastResult,
      lane.state clear — plus any final entry/label writes the outcome carries
      (§3.10). Conditional on op.state still being present at its expected seq;
      undefined = externally finalized first (§4.9). Transition commits derive
      their entry/usage writes from the state diff the same way. */
  commitTerminal(current: CurrentOperation, result: OperationResult):
    Promise<CurrentOperation | undefined>;
  /** Runs after_tool for the raw phase-two result selected in source order. */
  finalizeTool(plan: Extract<EffectPlan, { kind: "tool" }>,
               output: Extract<EffectOutput, { kind: "tool_raw" }>):
    Promise<Extract<SettlementOutput, { kind: "tool" }>>;
  /** Composite summary plans use this reentrantly for each provider request. */
  runSummaryRequest(plan: { taskId: string; attempt: number; requestIndex: number;
                            usageId: string; configuration: LaneConfiguration;
                            messages: AgentMessage[];
                            telemetryContext: TelemetryContext }):
    Promise<SummaryRequestOutput>;
  settleSummaryRequest(current: CurrentOperation,
                       plan: { taskId: string; attempt: number; requestIndex: number;
                               usageId: string },
                       response: SettledAssistantMessage,
                       telemetry: TelemetryContext): Promise<CurrentOperation>;
  /** Revalidates/registers effect start on the lane mutation line before execution. */
  run(plan: EffectPlan): Promise<EffectOutput>;
  sleep(delayMs: number, telemetry: TelemetryContext): Promise<void>;
}

The commit helpers shown in §4.1 delegate to these methods. Expected provider, tool, structural, and deferred-cancel failures return in-band EffectOutput variants; run rejects only for close, harness fault, or invariant defects. cancel_deferred is the explicit exception to ordinary start/settlement: its start check requires the same open cancelled operation and the process-local source target registered by abort() (the durable phase may already have advanced), uses a close-only signal rather than the already-pulled operation signal, and its awaited output bypasses commitEffectSettlement with no durable write. Automatic effects execute directly; manual effects gate the same calls. Passive event-listener delivery is observation, not an interpreter effect: it is isolated and telemetry-wrapped after publication but never parked by manual drive. sleep resolves early when the harness signal is pulled, after which the loop reloads cancellation control. For split-turn summary work, request-intent commitTransition, runSummaryRequest, and usage/state settleSummaryRequest are three distinct nested gated actions. runSummaryRequest performs the same serialized start check as run; abort-first returns not_started, leaves no usage, and makes the outer summary plan return its own not_started settlement, which discards structural work under cancelled control. The outer summary orchestration action is only process-local composition; manual drive and crash tests still stop between each nested boundary. These methods are the complete procedure crash-site catalog; ungated public mutations are the race boundaries in Part 9.

The provider signal is harness-owned. fx supplies the AbortSignal passed to every provider request. No caller can supply one: signal is absent from the options type at every public surface (§5.2), and the harness strips any signal from a streamOptions patch before dispatch. Only abort() and close() can pull it. This is what makes §4.6's guarantee hold.

Manual drive. With drive: "manual" the harness parks before each effect and exposes one JSON-safe action at a time:

ts
peekAction(): Promise<ActionInfo | undefined>;      // stable, side-effect free
executeAction(): Promise<ActionInfo | undefined>;   // release exactly one
runToCompletion(): Promise<void>;

Lane-surface calls—including operation acceptance, steer, abort, config setters, and tree writes—stay ungated, so a test can drive both orders of any race. In manual mode a before_run handler parks before acceptance; with no handler, acceptance commits immediately and the first parked action is the run's first procedure transition. The gate is reentrant: nested fx calls (notably request hooks inside a stream) park independently, and the driver releases them before their parent continues. Closing while an action is parked rejects it unexecuted; durable state is exactly the committed prefix.

Enforced by construction and by a test: an operation driven in manual mode performs zero storage writes and zero provider or tool calls while parked.

4.3 The lane mutation line

Every state-dependent mutation on a lane is linearized: validate, at most one atomic commit, and the in-memory update complete before the next mutation starts. Provider, tool, hook, and retry work never occupies the line.

What serializes here: operation acceptance, queue enqueue and cancel, queue consumption, deferred-write acceptance and application, abort, lane-configuration setters, finish, lane creation. Harness-global stream/retry/compaction/queue settings use a second mutation line with a monotonically increasing process revision. Operation acceptance and generation/summary starts snapshot settings by taking the settings line before the lane line and conditionally committing both expected tokens; global setters take only the settings line. No code acquires them in the reverse order.

Consequence: every race between two public calls has exactly two possible durable histories, and both must be tested (Part 9).

4.4 Restore

Recovery is point lookups against registers. No history, no folding, no journal replay, no tree walk. Per lane:

ts
async function restore(lane: string): Promise<
  { kind: "idle"; lane: string } | { kind: "suspended"; current: CurrentOperation }
> {
  const config = await storage.getRegister("lane.config", lane);
  const state  = await storage.getRegister("lane.state", lane);
  const leaf   = await storage.getRegister("lane.leaf", lane);

  const opId = state.value.currentOperationId;
  const meta    = opId ? await storage.getRegister("op.meta", opId) : undefined;
  const opState = opId ? await storage.getRegister("op.state", opId) : undefined;

  // Idle lanes are validated too: leaf existence and every pendingNextRun
  // id's pending.entry register (§3.3). Only the operation checks are
  // conditional on an open operation.
  const entryIds     = directEntryIds(opState?.value, meta?.value, state.value, leaf.value);
  const registerKeys = directRegisterKeys(opState?.value, state.value);
  const [entries, registers] = await Promise.all([
    storage.getEntries(entryIds), getRegisters(registerKeys),
  ]);
  validateCurrent({ config, state, leaf, meta, opState }, entries, registers); // §3.3

  if (!opId) {
    // lane.lastResult is there if the application wants to reconcile a
    // pre-crash outcome; restore itself never reads it.
    return { kind: "idle", lane };
  }

  return { kind: "suspended", current: {
    operation: meta.value, state: opState.value,
    operationStateSeq: opState.seq,
    laneState: state.value, laneStateSeq: state.seq,
    leafId: leaf.value,
    configuration: config.value, configurationSeq: config.seq,
  } };
}

Five register point-lookups: three lane registers, then — only when an operation is open — op.meta and op.state. op.state is the program counter: everything the interpreter needs to pick the next action is either in it or reachable from it by exact entry id or deterministic register key.

Bounded hydration and validation. From the loaded state, collect what it names directly and fetch it in one batch:

  • entries: triggerEntryId, latestAssistantEntryId, batch.assistantEntryId, deferred sourceEntryId, completed resultEntryIds, the lane leaf, and from op.metameta.value is a hydration input, not merely presence-checked — promptEntryIds, a non-null sourceLeafId, and a navigation intent's non-null targetId;
  • registers: op.tool_args/… for effect-pending calls, op.preparation/… for structural work, pending.entry/… for every inbox.*, control.drained*, and pendingNextRun id.

Then §3.3's bounded validation over exactly that set: every named thing exists and has the right shape; reserved ids that are materialized contain what the intent promised; tool call indices are complete and unique. Configuration, stream options, and retry policy need no lookups at all — they are inline in the state itself.

What restore never does: read register history (none exists), fold anything, scan tables, build provider context, probe for missing planned entries, audit completed operations, or infer state from what is absent.

Restore already fetched the directly named entries and registers for validation. The driver reuses/caches them and lazily builds only derived provider context or additional branch projections needed by the next action; nextAction itself switches on scalars and the supplied loaded map (§4.1).

Worked example — crash in the uncertain window

The process died mid-stream after an assistant intent (§3.7's effect_pending row; the §0.4 run). Reopen:

lane.state/main -> { currentOperationId: "op_9" }
op.meta/op_9    -> { intent: run, sourceLeafId: "e_41" }
op.state/op_9   -> { phase: assistant effect_pending, attempt: 1,
                     responseEntryId: "e_51", usageId: "u_7",
                     context: { configuration: { model: {...}, ... },
                                retryPolicy: { maxAttempts: 3, ... } } }

getEntries(["e_50"]) -> exists ✓        the placed prompt
getEntries(["e_51"]) -> absent          reserved, unsettled — expected

The harness restores without starting any effect and reports the operation as suspended. When the application calls resume(), the interpreter sees effect_pending with no live key (the process-local running map died with the process) and applies the §4.5 uncertain-window policy — from the captured state itself:

  • attempt 1 < maxAttempts 3 → a fresh attempt 2 under the captured configuration and policy, even if the user changed the model yesterday;
  • at the cap → synthesize an error response: insert entry e_51 { stopReason: "error", … }, insert zero usage u_7, enter failure drain — using exactly the ids reserved in the intent;
  • control was cancel_requested → synthesize aborted under e_51 instead, and never retry.

Same shape for tools (replay only if the captured and current declarations say safe, else a synthetic interrupted result under the reserved result id) and deferred (wait for the application's next resume(); each poll reserves fresh ids).

Per backend

  • Memory: the maps are the state; nothing to do.
  • JSONL: replay the file into the entry/register/usage maps — that is decoding, not recovery logic (§1.7); a torn final line is discarded whole. After decoding, restore is the same register reads.
  • SQLite (and future Postgres): literally the point lookups above.

Missing identities

Admission resolves configured identities and returns Err(MissingIdentities) before writing when any are absent. After that, dispatch trusts the environment: providers and tools are looked up by their captured durable identities at use time, and a lookup that fails settles in-band as an error — the same contract as an unknown tool. If resolution fails while state is still safely dispatchable (ready, planned, or between summary requests), the accepted call resolves Ok({kind:"suspended", reason:"missing_identities", ...}) instead of burning an attempt; state is unchanged and the operation stays open. A later resume() precheck returns Err(MissingIdentities) on the same condition. Registering missing pieces does not auto-drive. Because the captured configuration is inline, restore reports exactly what is missing without resolving anything. Restored effect_pending follows unknown-effect recovery rather than claiming the effect never started. Synthetic settlement, usage repair, queue application, finish, and non-replay reconciliation need no identities.

4.5 Crash positions and recovery policy

Atomic transactions have no internal prefix, so for any repeat-sensitive effect there are exactly these durable positions:

Crash pointWhat is durableRecovery
before the intent committhe previous stateplan the effect normally, as if nothing happened
after intent, before dispatcheffect_pending; the effect did not run, or you cannot tellapply the policy below
during or after the effect, before settlementeffect_pending; the outcome is unknownsame
after the settlement commitoutput + usage + next statecontinue; never re-settle
before / after a queue-application committhe item is fully pending / the entry exists and its register is goneapply later / never apply twice
before the final structural commitsource leaf intact, generated work uncommittedrecompute per the current state and policy
after the final structural commitmove + summary entry + label + usage + terminal cleanupdone
after the first abort commitcancellation and drained ids durable; drained payloads still in their pending registersstart no new ordinary effects; reconcile
after the terminal commitop registers deleted, lane.lastResult written, currentOperationId nullthe lane is idle

The one uncertain interval in the entire system is: intent durable, settlement absent. Three policies cover it:

Restored statePolicy
generation effect_pendingstart a later numbered attempt only if the captured retry policy allows. Otherwise persist a synthetic error under the already-reserved response id. If cancellation is durable, persist synthetic aborted under that id instead, and never retry.
tool effect_pendingre-execute the persisted op.tool_args arguments only if the stored declaration and the current tool declaration both say safe. Otherwise append a synthetic interrupted error under the reserved result id.
deferred effect_pendingwith running control, wait for the application's next resume(), which reserves fresh poll/response/usage ids; with cancelled control, synthetically settle the existing reserved response/usage ids as aborted. No cap.

4.6 Abort

Abort is not a phase. It is control.

  • First abort(): one commit sets control = cancel_requested, records requestedAt, moves the exact drained steer and follow-up ids into control.drained*, and leaves phase untouched. The drained items' pending.entry registers are not deleted: AbortResult and a post-crash SuspendedOperation.aborting dereference the exact payloads from them, and they survive until the terminal transaction (§3.11, §3.13). After the commit, the harness pulls the signal and cancels unreleased gated effects. The call resolves once the marker is durable; reconciliation runs in the background (automatic drive) or parks at its next action (manual drive).
  • Later abort() while the operation is open: appends nothing, signals nothing, returns the same drained payloads. After the terminal state: NoActiveOperation.
  • Still allowed after cancellation: settling effects that were already intended, writing their usage, applying accepted deferred writes, committing configuration changes, and completing the cancellation.
  • Forbidden: starting any new provider request, tool, decision hook, or retry.
  • Post-effect hooks: abort and a not-yet-started after_response/after_tool serialize on the effect-start check. Abort-first skips the hook; assistant/fetch settlement uses the raw response then normalizes it to aborted, while a live tool keeps its raw result with terminate:false. Hook-first lets it finish and uses its transformed value. A hook already running is not forcibly interrupted.
  • Per-output reconciliation: planned tool calls get an aborted error result; restored started calls get interrupted; live started calls keep their finalized or raw result as above; an assistant or fetch settlement after cancellation is stored under the reserved response id with stop reason aborted and moves to cancelled checkpoint state.

Signal ownership makes aborted unambiguous. Provider implementations must set stopReason: "aborted" if and only if the signal they were given was pulled, and the harness owns that signal exclusively (§4.2). Since abort() commits control before pulling it, a settled aborted response always has cancellation already durable. Timeouts, transport failures, malformed streams, and provider-side refusals all settle as error and take the ordinary retry path — which is correct, because those should retry and a user abort should not. An aborted response with control.status === "running" is unreachable; if one exists, the session is corrupt (Part 9).

On a deferred source, the abort() lane job registers the newest persisted handle as a process-local cancellation target and immediately installs EffectPlan{kind:"cancel_deferred"} in DriveState.running, even when the drive is awaiting a live fetch. It is the one external action permitted to start under cancelled control, remains valid if fetch settlement advances the durable phase, crosses normal manual gating and pi.ai.request, calls Models.cancelDeferred with the captured identity, converts success/failure to an in-band output, and never writes operation state. Cancellation reconciliation awaits/removes that live plan before terminal finish. Failure is telemetry only and never blocks finish. deferredCancellations prevents repetition in one process; crash/reopen during reconciliation may retry. Missing provider identity skips cancellation but not durable reconciliation.

There is no universal assistant closure. The harness never starts a request or appends an assistant message solely to manufacture one. An abort between steps, during tool work, or while suspended can therefore produce no abort-specific assistant event at all.

For structural operations the commit point decides the race: a marker committed first discards in-memory generated work and finishes aborted; if the structural commit won, the procedure completes that already-committed compaction or navigation and finishes completed.

4.7 Close — a controlled crash

Close is not abort. Close writes nothing: no cancellation, no terminal state, no settlement.

close()
  → stop admitting new work
  → pull the signal, so in-flight provider requests and cooperative tools stop
  → reject parked manual actions and unresolved local promises
  → let commits already accepted by storage drain
  → close storage, release the writer lease (§1.7)

A harness-wide admission barrier linearizes close against every operation and surface commit. A commit that acquires admission first is allowed to finish and close waits for it; close that seals admission first prevents the commit from entering storage. A stream cut after sealing settles locally as aborted, but its settlement transaction is never admitted. Durable state therefore stops at effect_pending, exactly as after process death.

So close needs no recovery machinery of its own: reopening finds effect_pending and applies the §4.5 policy — a later numbered attempt under the captured retry policy, or a synthetic error at the cap. Open operations remain open and resumable.

This also keeps the aborted-implies-cancelled invariant (Part 9) true. Close pulls the same signal as abort, but the sealed admission barrier prevents that locally aborted response from committing with running control.

4.8 Faults

A failed storage commit faults the whole harness. A faulted harness stops all effects and rejects pending and future calls with HarnessFault; it is never an Err result. faulted: true appears in snapshots obtained before the fault closes observation. After the cause is fixed, reopening restores each lane from its registers. Close likewise rejects already-accepted local operation promises with HarnessClosed; calls not yet accepted return Err(Closed). Surfaces without a Result channel — configuration and fact setters returning Promise<void>, SessionTree appends returning an id string — reject with HarnessClosed on and after close. Provider, tool, and isolated hook failures remain per-lane and in-band. A throw/rejection from a trusted deterministic application computation (systemPrompt, toolContext, toProviderMessages, or an entryProjector) is an application defect and faults the harness; it never escapes as an undeclared operation error. AgentTool.prepareArguments is the deliberate exception handled by the tool pipeline as a synthetic tool error.

4.9 External finalization

An operation can end from outside its own drive: administrative force-kill tooling — or any future repairer (Part 6) — may commit the terminal transaction (§3.13), with or without synthetic settlements under the reserved ids, while a live drive still holds the operation in memory. The drive discovers this in exactly one way: a conditional commit or reloadCurrent finds the operation is no longer the lane's current operation — its registers are absent.

The rule: the drive stops. It pulls the operation signal so in-flight effects cancel, discards every in-memory result without writing — no register remains to own a settlement — emits the operation's end events, and resolves the live caller's promise from lane.lastResult, which the finalizing transaction wrote (dereferencing finalAssistantEntryId to reconstruct finalMessage when present).

On the shipping backends a finalizer is either in-process — an admin surface committing on the lane mutation line like any other job — or a separate process that first takes over the writer lease after close/crash. Every terminal transaction, the drive's own included, is conditional on op.state still existing at its expected seq, which is what makes invariant 21 (at most one terminal transaction per operation) hold under the race. It never re-creates registers, never commits a competing terminal transaction, and never treats the absence as corruption: absent op.* registers with a cleared currentOperationId is the ordinary post-terminal shape (§3.13).

A suspended operation needs no drive to stop. The finalizer's terminal transaction leaves the lane idle; a later resume() finds currentOperationId: null and returns NothingToResume, and the application reads the outcome from getLastResult() (§5.1) — the same reconciliation path as any post-crash outcome.


Part 5 — Public surface

5.1 The lane surface

Expected rejection returns Result.err. Accepted operations return Result.ok, including failed, aborted, and suspended outcomes. Storage faults, close during accepted work, and invariant defects reject the promise.

ts
interface AgentLane {
  readonly name: string;
  getLeafId(): Promise<string | null>;
  /** The lane's most recent terminal outcome (§3.13); undefined before the
      first terminal transaction. Never consulted by recovery. */
  getLastResult(): Promise<LaneLastResult | undefined>;

  prompt(text: string, images?: ImageContent[]): Promise<RunResult>;
  prompt(message: AgentMessage | AgentMessage[]): Promise<RunResult>;
  skill(name: string, additionalInstructions?: string): Promise<RunResult>;
  promptFromTemplate(name: string, args?: string[]): Promise<RunResult>;
  compact(options?: { customInstructions?: string }): Promise<CompactionResult>;
  navigateTree(targetId: string | null, options?: NavigateOptions): Promise<NavigationResult>;
  resume(): Promise<ResumeResult>;
  abort(): Promise<AbortResult>;

  steer(message: string | AgentMessage, images?: ImageContent[]): Promise<QueueResult>;
  followUp(message: string | AgentMessage, images?: ImageContent[]): Promise<QueueResult>;
  nextRun(message: string | AgentMessage, images?: ImageContent[]): Promise<NextRunResult>;
  cancelQueued(entryId: string): Promise<CancelQueuedResult>;

  recordUsage(usage: Usage, options?: { entryId?: string; details?: JsonValue }):
    Promise<RecordUsageResult>;
  waitForIdle(): Promise<void>;
  runWhenIdle(callback: () => void | Promise<void>): Promise<void>;

  peekAction(): Promise<ActionInfo | undefined>;
  executeAction(): Promise<ActionInfo | undefined>;
  runToCompletion(): Promise<void>;

  /** Undefined when the durable provider/model identity is not registered. */
  getModel(): Promise<Model | undefined>;
  setModel(model: Model): Promise<void>;
  getThinkingLevel(): Promise<ThinkingLevel>; setThinkingLevel(l: ThinkingLevel): Promise<void>;
  getActiveTools(): Promise<string[]>;        setActiveTools(names: string[]): Promise<void>;

  session: SessionTree;
  watch(): Promise<WatchHandle<LaneSnapshot>>;
}

interface NavigateOptions { summarize?: boolean; label?: string; customInstructions?: string }
interface ActionInfo { kind: string; description: string; details?: JsonValue }
interface WatchHandle<T> { snapshot: T; start(listener: EventListener): void; unsubscribe(): void }

Skill/template expansion precedes storage. Prompt intent names only normalized caller messages, excluding captured nextRun and hook injections.

getLastResult() is the post-crash reconciliation path: an application that accepted an operation, lost its process, and reopened reads the lane.lastResult register for the outcome its promise never delivered (§3.13). It is also how a caller learns the outcome of an operation finalized externally (§4.9).

waitForIdle() registers on the lane mutation line and resolves when all earlier admitted lane jobs have settled, currentOperationId is null, and no process-local operation/admission reservation is held. Later operations may start immediately after it resolves. Multiple waiters resolve together; close/fault rejects pending waiters.

runWhenIdle(callback) waits by the same rule, then takes a process-local lane admission reservation for the callback. The reservation is released on return or throw; callback rejection propagates. The callback must not invoke a state-mutating method on the same lane, which would deadlock behind its own reservation. Close rejects callbacks not yet started and waits for an already-running callback, which cannot be forcibly interrupted.

Results and errors

ts
type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };
type Tagged<Tag extends string, P extends object = Record<never, never>> =
  Error & { readonly _tag: Tag } & Readonly<P>;

type OptionalFinalAssistant =
  | { finalEntryId: string; finalMessage: AssistantMessage }
  | { finalEntryId?: never; finalMessage?: never };

type MissingIdentitySuspension = {
  kind: "suspended"; reason: "missing_identities";
  missing: { tools: string[]; models: string[] };
};

type RunOutcome =
  | ({ kind: "completed"; leafId: string } & OptionalFinalAssistant)
  | ({ kind: "aborted"; leafId: string } & OptionalFinalAssistant)
  | ({ kind: "failed"; leafId: string; error: OperationError } & OptionalFinalAssistant)
  | { kind: "suspended"; reason: "deferred"; leafId: string;
      finalEntryId: string; deferred: DeferredHandle }
  | (MissingIdentitySuspension & { leafId: string });

type CompactionOutcome =
  | { kind: "completed"; leafId: string; entry: CompactionEntry }
  | { kind: "declined" | "aborted"; leafId: string }
  | { kind: "failed"; leafId: string; error: OperationError }
  | (MissingIdentitySuspension & { leafId: string });

type NavigationOutcome =
  | { kind: "completed"; oldLeafId: string | null; newLeafId: string | null;
      summaryEntry?: BranchSummaryEntry }
  | { kind: "declined" | "aborted"; leafId: string | null }
  | { kind: "failed"; leafId: string | null; error: OperationError }
  | (MissingIdentitySuspension & { leafId: string | null });

type ResumeOutcome =
  | ({ operation: "run"; runId: string } & RunOutcome)
  | ({ operation: "compaction"; runId: string } & CompactionOutcome)
  | ({ operation: "navigation"; runId: string } & NavigationOutcome);

A completed run may omit final assistant fields when every finalized tool result terminates. The two fields are always both present or both absent.

Expected errors use the existing TaggedError implementation in harness/result.ts:

tagfields beyond message
LaneBusylane, operationId, operationKind
MissingIdentitieslane, tools, models
NoActiveRun, NoActiveOperation, NothingToResume, NothingToCompactlane
InvalidMessage, InvalidNavigationlane, reason
UnknownSkill, UnknownTemplatename
UnknownTargettargetId
LaneExists, InvalidLanelane (InvalidLane also has reason)
Closednone
ts
type RunResult = Result<{ runId: string } & RunOutcome,
  LaneBusy | MissingIdentities | InvalidMessage | UnknownSkill | UnknownTemplate | Closed>;
type CompactionResult = Result<{ runId: string } & CompactionOutcome,
  LaneBusy | MissingIdentities | NothingToCompact | Closed>;
type NavigationResult = Result<{ runId: string } & NavigationOutcome,
  LaneBusy | MissingIdentities | InvalidNavigation | UnknownTarget | Closed>;
type ResumeResult = Result<ResumeOutcome,
  LaneBusy | NothingToResume | MissingIdentities | Closed>;
type QueueResult = Result<{ entryId: string }, NoActiveRun | InvalidMessage | Closed>;
type NextRunResult = Result<{ entryId: string }, InvalidMessage | Closed>;
type CancelQueuedResult = Result<
  { kind: "cancelled" | "already_consumed" | "not_found" }, Closed>;
type AbortResult = Result<{ runId: string; steer: AgentMessage[]; followUp: AgentMessage[] },
  NoActiveOperation | Closed>;
type RecordUsageResult = Result<{ usageId: string }, Closed>;

class HarnessFault extends Error {
  readonly cause: unknown;
  constructor(message: string, cause: unknown) { super(message); this.cause = cause; }
}
class HarnessClosed extends Error {}

cancelQueued has no unknown-item error: an id that is neither pending nor materialized returns not_found (§3.11) — previously cancelled, cleared by abort, or never existed — and a client retrying a lost cancel treats it as success. AbortResult's steer/follow-up payloads are dereferenced from the drained items' surviving pending.entry registers (§4.6). recordUsage mints its ledger row id at commit (§1.6) and returns it.

runId is the operation's durable operationId; the public name remains for compatibility. HarnessFault and HarnessClosed reject promises; they are not tagged expected errors and not members of these unions.

5.2 The harness

ts
class AgentHarness<TContext extends object | undefined = object | undefined>
  implements AgentLane {
  /** Initializes an unconfigured main when needed, then restores every lane
      without starting provider, tool, hook, or timer effects. One suspension
      descriptor per lane with an open operation. */
  static create<TContext extends object | undefined>(options: AgentHarnessOptions<TContext>): Promise<{
    harness: AgentHarness<TContext>;
    suspended: SuspendedOperation[];
  }>;

  lane(name: string): Promise<AgentLane | undefined>;      // lookup, never creates
  createLane(name: string, at: string | null): Promise<Result<AgentLane, LaneExists | InvalidLane | UnknownTarget | Closed>>;
  lanes(): Promise<LaneInfo[]>;                            // always includes "main"

  // Harness-global. Tool implementations are code and cannot persist; active
  // names live in each lane's configuration. setTools replaces only the registry.
  getTools(): Promise<AgentHarnessTool<TContext>[]>;
  setTools(t: AgentHarnessTool<TContext>[]): Promise<void>;
  getResources(): Promise<Resources>;            setResources(r: Resources): Promise<void>;
  getStreamOptions(): Promise<AgentHarnessStreamOptions>;
  setStreamOptions(o: AgentHarnessStreamOptions): Promise<void>;
  getRetryPolicy(): Promise<RetryPolicy>;        setRetryPolicy(p: RetryPolicy): Promise<void>;
  getCompactionSettings(): Promise<CompactionSettings>;
                                                 setCompactionSettings(s: CompactionSettings): Promise<void>;
  getSteeringMode(): Promise<QueueMode>;         setSteeringMode(m: QueueMode): Promise<void>;
  getFollowUpMode(): Promise<QueueMode>;         setFollowUpMode(m: QueueMode): Promise<void>;

  watchSession(): Promise<{ snapshot: SessionSnapshot;
                            start: (l: EventListener) => void; unsubscribe: () => void }>;

  hooks: Hooks;
  events: Events;

  /** Detach cleanly (§4.7). Open operations stay resumable. */
  close(): Promise<void>;
}

interface LaneInfo {
  name: string;
  leafId: string | null;
  operation: null | { id: string; kind: "run" | "compaction" | "navigation";
                      status: "running" | "suspended" | "aborting" };
}

interface SuspendedOperation {
  lane: string; operationId: string;
  kind: "run" | "compaction" | "navigation";
  reason: "crash" | "deferred" | "missing_identities";
  startedAt: number;
  prompt?: AgentMessage[];
  deferred?: DeferredHandle;
  /** Payloads dereferenced from the drained items' surviving pending.entry
      registers (§4.6). */
  aborting?: { steer: AgentMessage[]; followUp: AgentMessage[] };
  missing: { tools: string[]; models: string[] };
}

// QueueMode, RetryPolicy, and CompactionSettings use the source types named in §0.7.

Options

ts
/** AgentHarnessStreamOptions is the curated source type from §0.7. It excludes
    signal and provider lifecycle callbacks, which the harness owns. */
interface AgentHarnessOptions<TContext extends object | undefined = object | undefined> {
  session: Session;
  models: Models;

  // Immutable lane seed captured at create(). Initializes main when the session
  // is first attached, and every lane later created by this harness. Never a
  // fallback for a lane that already has a configuration.
  model: Model;
  thinkingLevel?: ThinkingLevel;          // default "off"
  activeToolNames?: string[];             // default: initial tool names

  tools?: AgentHarnessTool<TContext>[];
  toolContext?: TContext | (() => TContext | Promise<TContext>);
  systemPrompt?: string | ((ctx: TContext) => string | Promise<string>);  // per request
  resources?: Resources;                  // skills, prompt templates

  streamOptions?: AgentHarnessStreamOptions;
  retry?: RetryPolicy;
  compaction?: CompactionSettings;
  steeringMode?: QueueMode;
  followUpMode?: QueueMode;
  toolExecution?: "sequential" | "parallel";   // default parallel
  drive?: "automatic" | "manual";              // default automatic

  toProviderMessages?: (m: AgentMessage[]) => Message[] | Promise<Message[]>;
  entryProjectors?: Record<string, EntryProjector>;
  /** Existing typed telemetry contract; defaults to no-op. */
  telemetryContext?: TelemetryContext;
}

type Resources = AgentHarnessResources<Skill, PromptTemplate>;
type EntryProjector = (entry: CustomEntry) =>
  AgentMessage[] | undefined | Promise<AgentMessage[] | undefined>;

create() copies the three seed fields into one immutable LaneConfiguration, storing the model as { provider, modelId }. Before restore, it commits that seed as the first lane.config for a fresh or normalized-v3 main. Existing lanes use only their current config; the seed never overrides them. A configuration-less lane in a format-4 session is corrupt.

createLane(name, at) atomically writes its registers and the original captured seed, regardless of later changes. Setters replace only their lane's register value. Reopen options can seed new lanes but cannot alter existing ones without a setter. Applications opt into deferred generation through setStreamOptions({ deferred: ... }) or initial streamOptions; before_request may patch the same curated field per attempt.

Initial, replacement, and hook-patched stream options are normalized to detached JSON-safe values before publication because ready states persist them. Functions, symbols, bigint values, cycles, non-finite numbers, and unsupported prototypes in metadata reject construction/the setter without changing settings; an invalid hook patch is isolated as handler_error and ignored without changing operation state. Patch deletion semantics are applied before this validation.

systemPrompt, toolContext, toProviderMessages, and entryProjectors are deterministic/idempotent computation callbacks and may repeat after a crash; effectful interception belongs in hooks. before_run receives one preview evaluation of systemPrompt. A hook override is fixed in Operation; without one, the callback is evaluated again per provider request.

5.3 SessionTree

ts
interface SessionTree {
  getLeafId(): Promise<string | null>;
  getEntry(id: string): Promise<Entry | undefined>;
  getStats(): Promise<SessionStats>;

  // Global facts. Latest wins; not branch-scoped. undefined deletes the
  // register; JSON null is a legitimate custom value. Custom keys cannot
  // collide with name or labels.
  getName(): Promise<string | undefined>;
  setName(name: string | undefined): Promise<void>;
  getLabel(targetId: string): Promise<string | undefined>;
  setLabel(targetId: string, label: string | undefined): Promise<void>;
  getCustomFact(key: string): Promise<JsonValue | undefined>;
  setCustomFact(key: string, value: JsonValue | undefined): Promise<void>;

  /** Session-wide, all branches, sequence order. */
  findEntries(query?: EntryQuery): Promise<Entry[]>;
  findEntry(query?: EntryQuery): Promise<Entry | undefined>;

  /** Branch-scoped: the path from start toward root (§2.5). */
  findEntriesOnBranch(query?: BranchScan): Promise<Entry[]>;
  findEntryOnBranch(query?: BranchScan): Promise<Entry | undefined>;

  // Writes resolve on durable acceptance; the returned id is the entry id,
  // reserved when the write defers.
  appendMessage(message: AgentMessage): Promise<string>;
  appendCustomEntry(customType: string, data?: JsonValue): Promise<string>;
}

interface EntryQuery { type?: EntryType; customType?: string;
                       order?: "asc" | "desc"; limit?: number; cursor?: EntryCursor }
interface SessionStats { messageCount: number; usage: Usage }

Global queries filter first, then apply the exclusive cursor, then limit; default order is "desc". A descending cursor retains seq < cursor.seq, and an ascending cursor retains seq > cursor.seq.

Useful patterns: effective extension state is findEntryOnBranch({ type: "custom", customType }); a collection is findEntriesOnBranch(...); a global inventory is findEntries(...). Note that extension-state lookups have no stopAt and therefore walk past compactions — which is exactly why §2.6 segments rather than truncates.

SessionTree has no navigation; moving a lane is navigateTree() on the lane. Finders and getEntry return only committed entries: a deferred write is invisible here until applied, but appears in snapshots by its reserved id.

5.4 Snapshots and subscription

ts
const { snapshot, start, unsubscribe } = await lane.watch();
await send(client, { kind: "snapshot", snapshot });   // snapshot on the wire first
start((event) => send(client, event));                // flush buffer in order, then live

watch() atomically snapshots and begins buffering. start(listener) flushes in order, then delivers live; each event arrives once, in order, without sequence numbers or registration races. unsubscribe() drops the watcher and its buffer. A never-started watcher buffers without bound.

ts
interface QueuedItem { entryId: string; message: AgentMessage }

interface LaneSnapshot {
  lane: string;
  transcript: Entry[];       // this lane's context window plus its compaction entry
  leafId: string | null;

  operation: null | {
    id: string;
    kind: "run" | "compaction" | "navigation";
    status: "running" | "suspended" | "aborting";
    startedAt: number;
    suspended?: SuspendedOperation;
    streamingMessage?: AssistantMessage;     // message_start until entry commit
    runningTools: { toolCallId: string; toolName: string; args: unknown;
                    partialResult?: AgentToolResult<unknown> }[];
    retry?: { attempt: number; maxAttempts: number; nextAttemptAt: number };
  };

  queues: { steer: QueuedItem[]; followUp: QueuedItem[]; nextRun: QueuedItem[] };
  pendingWrites: { entryId: string; type: EntryType; customType?: string;
                   message?: AgentMessage; data?: JsonValue }[];
  faulted: boolean;
}

interface SessionSnapshot {
  lanes: (LaneInfo & { suspended?: SuspendedOperation })[];
  faulted: boolean;
}

operation.status derives from durable state plus a process-local suspension marker: suspended for deferred, restored, or missing-identity suspension; aborting when control.status === "cancel_requested"; otherwise running. The missing-identity marker stores the exact SuspendedOperation, survives until a successful resume attempt or abort in this process, and is reconstructed as reason:"crash" after reopen. It changes snapshots but never durable recovery state. queues and pendingWrites derive from inbox and pendingNextRun, with content dereferenced from each id's pending.entry register; abort-drained items are exposed only through AbortResult and SuspendedOperation.aborting, never as still-queued. streamingMessage and runningTools are process-local extras layered on top.

Rules:

  • Configuration is not in snapshots. Getters return current values; config_update events tell a UI when to re-read. One source of truth.
  • streamingMessage is not part of transcript. message_end replaces it with the final post-hook value but does not clear it; the matching entry_added confirms the append, adds the entry to transcript, and clears the draft.
  • Direct messages and finalized tool results use the same immediate message_startmessage_end lifecycle and enter transcript only on entry_added. They never populate streamingMessage.
  • An aborting snapshot reports only state that actually exists. It never synthesizes a streaming assistant message.
  • Reconnect means a new watch(). Only process death loses stream state; a restored harness shows the suspended operation instead. Every entry in the durable transcript is complete — a lost draft was never an entry.
  • A lane watcher receives events whose lane matches, plus events with no lane. The harness-global usage event is the explicit exception: it carries its originating lane but reaches every watcher, because its totals are session-wide.

5.5 Events

One flat stream. events.on(type, listener) matches across the harness; lane watchers filter as above. Events are passive: listeners cannot mutate execution, payloads are isolated from procedure state, and a throw produces handler_error plus telemetry without affecting execution. Only hooks intercept.

Durable-fact events fire after commit — entry_added means queryable. Multi-write events wait for full success, then follow mutation order. Process-local lifecycle events need not be durable: message_end precedes the entry insert.

ts
type HarnessEventPayload =
  // Run lifecycle
  | { type: "run_start"; runId: string }
  | { type: "run_resume"; runId: string }
  | { type: "run_suspend"; runId: string; reason: "deferred";
      deferred: DeferredHandle }
  | { type: "run_suspend"; runId: string; reason: "missing_identities";
      missing: { tools: string[]; models: string[] } }
  | { type: "run_abort"; runId: string; steer: AgentMessage[]; followUp: AgentMessage[] }
  | ({ type: "run_end"; runId: string; leafId: string | null } & (
      | ({ outcome: "completed" | "aborted" } & OptionalFinalAssistant)
      | ({ outcome: "failed"; error: OperationError } & OptionalFinalAssistant)))
  | { type: "fault"; code: string; message: string }
  | ({ type: "handler_error"; error: string; stack?: string } &
     ({ kind: "hook"; hook: string } | { kind: "event"; event: string }))

  // Steps and retries. First-try success emits no retry events.
  | { type: "turn_start"; runId: string; turnId: string }
  | { type: "turn_end"; runId: string; turnId: string;
      message: AssistantMessage; toolResults: ToolResultMessage[] }
  | { type: "retry_scheduled"; runId: string; step: string; attempt: number;
      maxAttempts: number; delayMs: number; errorMessage: string }
  | { type: "retry_start"; runId: string; step: string; attempt: number }
  | { type: "retry_end"; runId: string; step: string; attempt: number;
      success: boolean; finalError?: string }

  // Messages
  | { type: "message_start"; runId?: string; message: AgentMessage }
  | { type: "message_update"; runId: string; message: AgentMessage;
      event: AssistantMessageEvent }
  | { type: "message_end"; runId?: string; message: AgentMessage; entryId?: string }

  // Tools
  | { type: "tool_start"; runId: string; turnId: string; toolCallId: string;
      toolName: string; args: unknown }
  | { type: "tool_update"; runId: string; turnId: string; toolCallId: string;
      toolName: string; partialResult: AgentToolResult<unknown> }
  | { type: "tool_end"; runId: string; turnId: string; toolCallId: string;
      toolName: string; result: AgentToolResult<unknown>; isError: boolean; terminate: boolean }

  // Tree, queues, facts
  | { type: "entry_added"; entry: Entry }
  | { type: "write_pending"; runId: string; entryId: string; entryType: EntryType }
  | { type: "queue_update"; steer: QueuedItem[]; followUp: QueuedItem[];
      nextRun: QueuedItem[] }
  | ({ type: "fact_update" } & (
      | { fact: "name"; name: string | undefined }
      | { fact: "label"; targetId: string; label: string | undefined }
      | { fact: "custom"; key: string; value: JsonValue | undefined }))

  // Configuration
  | ({ type: "config_update" } & (
      | { property: "model"; value: { provider: string; modelId: string }; previous: unknown }
      | { property: "thinkingLevel"; value: ThinkingLevel; previous: ThinkingLevel }
      | { property: "activeTools"; value: string[]; previous: string[] }
      | { property: "tools" | "resources" | "streamOptions" | "retryPolicy"
                  | "compactionSettings" | "steeringMode" | "followUpMode" }))

  // Structural
  | { type: "compaction_start"; runId: string; reason: "manual" | "threshold" | "overflow" }
  | ({ type: "compaction_end"; runId: string; reason: "manual" | "threshold" | "overflow" } & (
      | { outcome: "completed"; entry: CompactionEntry; fromHook: boolean }
      | { outcome: "declined" | "aborted" }
      | { outcome: "failed"; error: OperationError }))
  | { type: "navigation_start"; runId: string; targetId: string | null }
  | ({ type: "navigation_end"; runId: string;
       oldLeafId: string | null; newLeafId: string | null } & (
      | { outcome: "completed"; summaryEntry?: BranchSummaryEntry }
      | { outcome: "declined" | "aborted"; summaryEntry?: never; error?: never }
      | { outcome: "failed"; error: OperationError; summaryEntry?: never }))

  // Lanes and cost
  | { type: "lane_created"; at: string | null }
  | { type: "usage"; lane: string; row: UsageRow; totals: Usage };

type SpecialEventPayload = Extract<HarnessEventPayload,
  { type: "fault" | "fact_update" | "usage" | "config_update" | "handler_error" }>;
type LaneEventPayload = Exclude<HarnessEventPayload, SpecialEventPayload>;
type ConfigEventPayload = Extract<HarnessEventPayload, { type: "config_update" }>;
type LaneConfigEventPayload = Extract<ConfigEventPayload,
  { property: "model" | "thinkingLevel" | "activeTools" }>;
type GlobalConfigEventPayload = Exclude<ConfigEventPayload, LaneConfigEventPayload>;
type HandlerErrorPayload = Extract<HarnessEventPayload, { type: "handler_error" }>;

type HarnessEvent =
  | (LaneEventPayload & { lane: string; recovery?: true })
  | (LaneConfigEventPayload & { lane: string; recovery?: true })
  | (Extract<HarnessEventPayload, { type: "fault" | "fact_update" }> &
      { lane?: never; recovery?: never })
  | (Extract<HarnessEventPayload, { type: "usage" }> & { recovery?: never })
  | (GlobalConfigEventPayload & { lane?: never; recovery?: never })
  | (HandlerErrorPayload & (
      | { lane: string; recovery?: true }
      | { lane?: never; recovery?: never }
    ));

type HarnessEventType = HarnessEvent["type"];
type EventListener<E extends HarnessEvent = HarnessEvent> =
  (event: E) => void | Promise<void>;

interface Events {
  on<T extends HarnessEventType>(
    type: T,
    listener: EventListener<Extract<HarnessEvent, { type: T }>>,
  ): () => void;
}

lane is required on run/turn/retry/message/tool, entry/write/queue, lane model/thinking/active-tool configuration, structural, and lane-created events. It is absent on facts, faults, and harness-global configuration. handler_error follows the failed handler's scope. usage is the global-delivery exception: base lane is absent, while its payload carries the origin lane and the complete ledger row, including its durable seq (§1.6). recovery: true appears on process-local lifecycle re-emitted by resume(), never on events for already-existing durable entries. Cross-lane events are process ordered, not globally sequence ordered. A totals consumer keeps the greatest usage row.seq it has applied, preventing a late older event from regressing totals.

Ordering for a streamed assistant response, asserted exactly by the conformance tests:

message_start → message_update* → after_response hook → message_end (final value,
optional reserved id) → atomic response + usage + classified-state commit
→ entry_added → usage

Only entry_added proves durability. Classification is computed before the transaction and becomes durable with it; it is not a separate event. Abort and overflow classification may normalize the committed response after message_end, so entry_added is authoritative for those two cases. A synthetic settlement performs no provider effect, update, or response hook: message_start → message_end → atomic commit → entry_added → usage.

Nesting:

run_start
  message_start / message_end / entry_added         consumed prompt and queue messages
  turn_start
    message_start / message_update* / message_end    assistant stream finished
    entry_added                                     response committed
    tool_start / tool_update* / tool_end             per real call
    message_start / message_end                      tool results, source order
    entry_added                                     each result committed
  turn_end
  compaction_start … entry_added … compaction_end   auto, at a checkpoint
  turn_start … turn_end                              until nothing is pending
run_end

Deferred and recovery brackets are deterministic:

  • initial assistant generation uses turnId = stepId; a durable deferred response ends that turn, then emits run_suspend;
  • every application resume() emits run_resume; recovery:true is present only when this harness restored the operation after process loss, not for same-process deferred resume;
  • one deferred poll opens a turn whose durable id is ${stepId}:poll:${poll}. Pending/error/ready settlement and any ready tool batch complete inside that turn, followed by turn_end and then suspend/failure/checkpoint;
  • restored unresolved tools re-open their persisted ToolBatch.turnId with recovery:true, emit only new replay/interruption tool lifecycle, then close that recovery turn. Existing message/entry events are never replayed;
  • resumed structural work re-emits its structural start with recovery:true; structural streams emit no message lifecycle and their typed result alone emits entry_added.

Deferred polls emit no retry lifecycle. Events may contain sensitive conversation and tool content. Serving layers own authorization and redaction. Event payloads are isolated from mutable procedure state. Telemetry alone is content- and secret-free by default.

5.6 Hooks

Hooks are awaited interception points. Registration is harness-global; every payload carries lane.

ts
type BeforeResumePrepared =
  | { kind: "run"; prompt: AgentMessage[]; systemPromptOverride?: string }
  | { kind: "compaction"; sourceLeafId: string | null;
      customInstructions?: string }
  | { kind: "navigation"; sourceLeafId: string | null; targetId: string | null;
      summarize: boolean; label?: string; customInstructions?: string };

interface HookMap {
  before_run: {
    event: { prompt: AgentMessage[]; systemPrompt: string; resources: Resources };
    result: { messages?: AgentMessage[]; systemPrompt?: string; resumeData?: JsonValue } | undefined;
  };
  before_resume: {
    event: BeforeResumePrepared & { resumeData?: JsonValue };
    result: void;
  };
  before_run_end: {
    event: { runId: string; messages: AgentMessage[] };
    result: { followUp?: string } | undefined;
  };
  transform_context: {
    event: { messages: AgentMessage[] };
    result: { messages: AgentMessage[] } | undefined;
  };
  before_request: {
    event: { model: Model;
             step: "assistant" | "deferred" | "compaction" | "branch_summary";
             attempt: number; streamOptions: AgentHarnessStreamOptions };
    result: { streamOptions?: AgentHarnessStreamOptionsPatch } | undefined;
  };
  before_payload: {
    event: { model: Model; payload: unknown };
    result: { payload: unknown } | undefined;
  };
  after_response: {
    event: { status?: number; headers?: Record<string, string>;
             message: SettledAssistantMessage };
    result: { message?: SettledAssistantMessage } | undefined;
  };
  before_tool: {
    event: { toolCallId: string; toolName: string; args: Record<string, JsonValue> };
    result: { args?: Record<string, JsonValue>;
              block?: { reason: string; terminate?: boolean } } | undefined;
  };
  after_tool: {
    event: { toolCallId: string; toolName: string; args: Record<string, JsonValue>;
             content: AgentToolResult<unknown>["content"]; details?: JsonValue;
             isError: boolean; usage?: Usage };
    result: { content?: AgentToolResult<unknown>["content"]; details?: JsonValue;
              isError?: boolean; usage?: Usage; terminate?: boolean } | undefined;
  };
  before_compaction: {
    event: { reason: "manual" | "threshold" | "overflow";
             preparation: CompactionPreparation; customInstructions?: string };
    result: { decline?: boolean; compaction?: CompactResult } | undefined;
  };
  before_navigation: {
    event: { targetId: string; preparation: BranchPreparation;
             customInstructions?: string };
    result: { decline?: boolean; summary?: BranchSummaryResult } | undefined;
  };
}

type HookName = keyof HookMap;
type HookInvocation<K extends HookName> = HookMap[K]["event"] & {
  lane: string;
  /** Durable operation id, provisional for pre-acceptance before_run. */
  runId: string;
};
type HookHandler<K extends HookName> =
  (event: HookInvocation<K>) => Promise<HookMap[K]["result"]> | HookMap[K]["result"];

interface Hooks {
  on<K extends HookName>(name: K, handler: HookHandler<K>,
                         options?: { id?: string }): () => void;
}

Uniform semantics:

  • before_run and before_resume require a stable id, unique within each hook name; duplicates reject synchronously. An extension reuses its id across both hooks and across restarts; the runner stores resumeData by id and gives each resume handler only its own value.
  • Handlers run in registration order, each seeing the prior output. messages append; systemPrompt replaces.
  • A throw emits handler_error, skips that handler, and lets the rest continue. before_tool instead fails closed and blocks the tool.
  • Durable hook outputs commit before execution continues. A return alone is not durable; a pre-commit crash may rerun the hook.
  • Events expose post-hook values. Passive listeners cannot transform them.

One EffectPlan{kind:"hook"} runs the complete registered pipeline for that hook name and returns its final aggregate; individual handlers are not separate durable/manual actions. The runner still isolates and telemetry-wraps each handler internally. Aggregation is deterministic:

  • before_run appends messages and lets the latest defined system prompt replace the prior one; resume data is stored under each handler id.
  • context/request/payload/response and after_tool transformations run in registration order, each seeing the prior transformed value; option/result patches merge field by field.
  • before_tool argument replacements chain and are revalidated; the first block is terminal and later handlers do not run.
  • before_compaction/before_navigation stop at the first decline or supplied result; if all handlers return neither, generation is selected. Returning decline plus a result is a handler error and is ignored like a throw.
  • before_run_end uses the latest defined follow-up.
HookWhenEventResult
before_runonce, before acceptance, outside the mutation line{ prompt, systemPrompt, resources }{ messages?, systemPrompt?, resumeData? }
before_resumeon resume(), before any effect; must be idempotentBeforeResumePrepared + { lane, runId, resumeData? }void
before_run_endat a normal finish boundary{ runId, messages }{ followUp? }
transform_contextper request, AgentMessage level, before toProviderMessages{ messages }{ messages }
before_requestper request, provider-neutral options{ model, step, attempt, streamOptions }{ streamOptions? }
before_payloadper request, provider-specific wire payload{ model, payload }{ payload }
after_responseper response, after streaming settles, before message_end and the commit{ status, headers, message }{ message? } (must keep role)
before_toolafter validation, before execution{ toolCallId, toolName, args }{ args?, block?: { reason: string; terminate?: boolean } }
after_toolafter execution, before the result commits; patch semantics{ toolCallId, toolName, args, content, details, isError, usage? }{ content?, details?, isError?, usage?, terminate? }
before_compactionin deciding{ reason, preparation, customInstructions? }{ decline?, compaction? }
before_navigationin deciding{ targetId, preparation, customInstructions? }{ decline?, summary? }

before_request receives AgentHarnessStreamOptions and returns AgentHarnessStreamOptionsPatch; neither can contain a signal or provider lifecycle callback. after_response must preserve the assistant role and may return aborted only when the harness signal is already aborted. before_navigation runs only for summarized navigation; unsummarized navigation cannot decline.

Replay across retry and resume:

Hookfreshretryresume
before_runoncenono (persisted in Operation)
before_resumenonoyes, idempotent
transform_context, before_request, before_payloadper requestyesyes
after_responseper response unless abort wins before it startsper responsesame rule
before_toolper callnot when the call is already effect_pending
after_toolper executed result unless abort wins before it startson safe replay only, with the same abort rule
before_compaction, before_navigationonce, until a structural source commitsnonever once generating is durable
before_run_endper normal finish boundaryat the boundary resume reaches (may repeat); never for abort, terminal failure, or exhausted auto-compaction

before_run_end may fire again after a crash at the same boundary. Handlers that must not double-fire keep their own durable marker. This is the exactly-once non-goal (§0.6) surfacing in the hook layer.

5.7 Agent-loop building blocks

The existing agent-loop.ts remains behavior-compatible and is refactored into these exported phases. Existing fields on AgentTool, AgentToolResult, and provider messages are retained. Add recovery declaration replay?: "never" | "safe" to AgentTool; omission means "never". AgentHarnessTool inherits it. The AgentEventSink below is the existing agent-loop sink, not the harness event listener; the harness adapts agent events into §5.5 events.

ts
interface StreamAssistantConfig {
  model: Model;
  thinkingLevel: ThinkingLevel;
  systemPrompt?: string;
  tools?: AgentTool[];
  transformContext?: (messages: AgentMessage[], signal: AbortSignal) =>
    Promise<AgentMessage[]>;
  toProviderMessages: (messages: AgentMessage[]) => Message[] | Promise<Message[]>;
  models: Models;                           // resolves identity + auth per request
  streamOptions?: AgentHarnessStreamOptions;
  /** Harness-owned before_payload adapter; undefined keeps the payload. */
  transformPayload?: (payload: unknown, model: Model) =>
    unknown | undefined | Promise<unknown | undefined>;
  /** Final settled-message transform used by after_response, before message_end. */
  transformResponse?: (message: SettledAssistantMessage,
                       metadata: { status?: number; headers?: Record<string, string> }) =>
    Promise<SettledAssistantMessage>;
  telemetryContext: TelemetryContext;
  signal: AbortSignal;
}

function streamAssistant(messages: AgentMessage[], config: StreamAssistantConfig,
                         emit: AgentEventSink): Promise<SettledAssistantMessage>;
// The implementation converts curated streamOptions to provider options and
// installs harness-owned payload/response callbacks; callers cannot replace them.
// Existing summary helpers keep their Models-based request path.

type PreparedToolCall = { kind: "prepared"; toolCall: AgentToolCall;
  tool: AgentTool; args: Record<string, JsonValue> };
type ImmediateOutcome = { kind: "immediate"; result: AgentToolResult<unknown>;
  isError: true; terminate: boolean };
type FinalizedToolCall = { toolCall: AgentToolCall; result: AgentToolResult<unknown>;
  isError: boolean; terminate: boolean };

interface ToolCallbacks {
  beforeToolCall?(call: AgentToolCall, args: Record<string, JsonValue>):
    Promise<HookMap["before_tool"]["result"]>;
  afterToolCall?(call: AgentToolCall, args: Record<string, JsonValue>,
                 result: AgentToolResult<unknown>, isError: boolean):
    Promise<HookMap["after_tool"]["result"]>;
  executeTool?(call: PreparedToolCall):
    Promise<{ result: AgentToolResult<unknown>; isError: boolean }>;
  onToolStart?(call: AgentToolCall, effectiveArgs: Record<string, JsonValue>): Promise<void>;
  onToolResult?(call: AgentToolCall, message: ToolResultMessage,
                terminate: boolean): Promise<void>;
}

function prepareToolCall(call: AgentToolCall, tools: AgentTool[], callbacks: ToolCallbacks,
                         telemetry: TelemetryContext, signal: AbortSignal):
  Promise<PreparedToolCall | ImmediateOutcome>;
function executeToolCall(call: PreparedToolCall, emit: AgentEventSink,
                         telemetry: TelemetryContext, signal: AbortSignal):
  Promise<{ result: AgentToolResult<unknown>; isError: boolean }>;
function finalizeToolCall(call: PreparedToolCall,
                          executed: { result: AgentToolResult<unknown>; isError: boolean },
                          callbacks: ToolCallbacks, telemetry: TelemetryContext,
                          signal: AbortSignal): Promise<FinalizedToolCall>;

External output that violates durable JSON/schema contracts is converted before settlement: an invalid provider message becomes a synthetic assistant error under the reserved response id; an invalid tool result becomes a synthetic error under its planned result id. Valid reported usage is retained when it can be validated independently, otherwise the synthetic entry reports zero. Invalid hook output is handled like a throwing handler (before_tool still fails closed); invalid caller input returns InvalidMessage before acceptance. No invalid payload reaches Storage.commit().

AgentTool.prepareArguments is deterministic/idempotent computation and may repeat before intent; effectful policy belongs in before_tool. ToolCallbacks contains the existing before/after callbacks plus executeTool, onToolStart, and onToolResult durability callbacks described in §3.8. onToolStart receives effective arguments after prepareArguments, validation, and before_tool; onToolResult receives the finalized message and terminate decision. Blocked calls may terminate when before_tool.block.terminate is true. Replacement arguments are validated again.

For each live tool batch, the harness resolves toolContext exactly once, caches bound AgentHarnessTool<TContext> adapters in DriveState.toolBatches, and passes that same context as the fifth execute argument for every call. Safe replay after restart creates one new batch snapshot; context is environmental and never persisted.

executeToolBatch (the exported successor of the source's private executeToolCalls) preserves the existing sequential/parallel behavior: source-ordered preparation and dispatch, concurrent effects in parallel mode, source-ordered finalization/results, no effect for blocked/invalid/genuine-length calls, and terminate: true only when every finalized outcome terminates. Compatibility wrappers keep existing public loop signatures and events.

5.8 Telemetry

Use the existing callback-based TelemetryContext, no-op/reference implementations, typed schema machinery, and agent-owned schemas. Do not invent a second contract. Context is passed explicitly; no core AsyncLocalStorage or global active span.

Required spans remain:

text
pi.harness.run | compaction | navigation
pi.harness.checkpoint | turn | step | tool | hook | sleep | event_handler
pi.session.write
pi.ai.request

Operation, step, tool, hook, event, and write parents follow the actual interpreter/effect nesting. Sleep spans permit run, compaction, navigation, turn, and checkpoint parents. stepId/taskId correlate retries and recovery. Every provider request/fetch/cancel uses pi.ai.request; each real or safely replayed phase-two tool effect uses one tool span.

Every storage transaction uses one pi.session.write. Its start attributes include pi.session.item_count and pi.session.item_kinds (entry, usage, register). A calling procedure may supply its lane/operation ids; storage never infers them from payloads. End attributes include first and last committed sequence. Update the existing schema from old single-mutation vocabulary to this transaction shape; no span is emitted for a conditional no-write result. Synthetic settlements and blocked/invalid tools emit no provider/tool-effect span.

Telemetry attributes may contain declared ids, names, counts, durations, statuses, and usage. They must never contain prompts, completions, tool arguments/results, file contents, provider payloads, headers, handles, or credentials. Events and hooks may contain such content. The existing generated schema document and adapter/runtime conformance tests remain authoritative; implementation slices extend instrumentation only through those schemas.

Part 6 — Future: partitioned retention (Postgres)

This part is informative. Nothing in it binds the shipping backends: Memory, JSONL, and SQLite never partition and never delete entries or usage rows (§1.2), and no core rule references this part for its correctness. It exists to show that the identity choices in §1.2 are sufficient for the one backend that would eventually retire old data — a possible Postgres deployment with TTL retention. It is a bridge we cross when we get there; this sketch is the current best guess, not a contract.

  • The id is the partition key. UUIDv7 sorts bytewise in time order, so the bulk tables — entries, usage ledger — use PARTITION BY RANGE (id) on the uuid id column, with period-boundary UUIDs (zeroed tails) as bounds. No partition column exists anywhere; §1.2's time prefix is the whole mechanism. Registers, branch_meta, stats, leases, and sessions stay in a hot unpartitioned catalog. branch_entries partitions by entry_id with the same bounds, so dropping a period cleans the branch index for free; branch_meta stays hot, and base pointers dangling into a dropped period are trimmed lazily on first access.
  • Pre-pass repair. Before a period P is dropped, an online repairer makes live state stop referencing it: reparent edges crossing into P onto the nearest retained ancestor, found by an indexed uuid-range query; null any dormant lane.leaf decoding into P via a register-seq CAS; force-expire open operations still referencing P register-only — the terminal transaction of §3.13 writing lane.lastResult, no synthetic entries, with any live drive stopping through external finalization (§4.9); delete fact.label registers whose keys decode into P with one uuid-range delete.
  • The commit barrier. Repair races ordinary commits, so the final step is atomic against all of them: BEGIN; LOCK entries, registers IN ACCESS EXCLUSIVE MODE; <delta repair for anything committed since the online pass>; ALTER TABLE … DETACH PARTITION p; COMMIT; — plain DETACH, not CONCURRENTLY, precisely because it is transactional under the lock; the DROP TABLE happens later, unhurried. The barrier makes repair-plus-detach one linearization point: every commit sees either the fully attached period or a fully repaired store without it.
  • The default partition. A DEFAULT partition absorbs stray inserts whose ids predate every attached partition — an ancient pendingNextRun item consumed years after its mint still places under its reserved id and simply lands there. Nothing errors and nothing is lost; the default partition stays small and is never dropped.
  • Register access under an external repairer. A backend that admits an external repairer must perform register reads and CAS checks inside the commit transaction itself, so a repairer holding the barrier cannot interleave between a harness's read and its dependent write. The shipping backends need no such rule: single-writer sessions have no external repairer.

Everything else a real deployment would need — retention policy, per-session versus per-deployment periods, operational partition-count limits — is deliberately unspecified until the backend is real.

Part 7 — Schema evolution

7.1 The problem

Full durability means snapshotting in-flight state, and in-flight state has the shape of today's state machine. Ship a new version with a different machine and the durable state written by the old one still exists — mid-run, mid-batch, mid-drain. Most durable-execution systems answer this badly or not at all. This design cannot: sessions are long-lived by intent.

7.2 Why this design shrinks the problem

Migration cost is proportional to what must be converted, and this design keeps the convertible surface small (§1.8):

text
what exists at upgrade time            migration burden
────────────────────────────       ────────────────
entries, usage rows (years)            cannot rewrite — must stay read-compatible
lane/fact registers (a few per lane)   trivial: a for-loop at open
op.* registers                         only for OPEN operations — usually zero
pending.entry registers                open-operation inbox items plus
                                       lane-owned queued nextRun items

Because no history is retained, the entire mutable surface is a few dozen current registers — which is what makes migrate-on-open tractable at all. And the fenced single-writer lease (§1.7) means the opening process owns the session exclusively — migration has no concurrency story to solve.

7.3 The mechanism: storage version plus migrate-on-open

One session-level storageVersion lives in the catalog or header (§1.7, §2.8). A version number is preferable to versioned namespace suffixes (lane.state.v2): one number to check, chained v1→v2→v3 migrations, no probing of historical namespace names, and register keys stay stable for point lookups.

text
open session:
  version == current → proceed
  version  < current → run migrations in order, each one transaction:
                         convert lane/fact/pending register values
                         handle open operations (§7.4)
                         bump the version
  version  > current → refuse to open (older binary, newer session)

Chained migrations run under the writer lease before open() returns (§2.8). Each step commits its conversions and version bump atomically, so a crash mid-chain resumes at the recorded version; conversions must be idempotent over already-converted values, which field mappings are by construction.

JSONL has one wrinkle in each direction. Replay must decode superseded old-shape register lines leniently — as keyed raw JSON, overwrite-by-key only — because pre-migration bytes remain in the file (§1.7). And a migration must trigger snapshot compaction, whose temp-file-and-rename both persists the new header version atomically and retires the old-shape bytes. Between crash and compaction, lenient replay plus idempotent conversion make the intermediate state harmless.

Legacy coding-agent format 3 predates storageVersion entirely; it normalizes through Appendix B on load and receives the current version with its first format-4 write.

7.4 Migrations are total

Register conversion is a field mapping; a state-machine shape change is more. If the next version removes failure_drain, or restructures the tool-batch lifecycle, an old op.state sitting mid-failure_drain has no field-by-field equivalent in the new machine. The rule: migrations are total. A vN→vN+1 migration translates every register value — lane and fact registers, pending.entry payloads, and open operations' op.meta and op.state included. The author of a state-machine change writes the mapping that carries every reachable old state into a well-defined new one, in the same change, reviewed and tested with it. A state with no natural successor maps to an explicit choice — typically the nearest safe pre-intent state, from which ordinary recovery (§4.5) proceeds. There is no force-settle path and no partial escape hatch.

This is tractable for the same reason migrate-on-open is tractable at all (§7.2): the entire mutable surface is a few dozen current registers, and migration runs at open under the writer lease, so it sees quiescent registers — no drive is running, no effect is in flight, and every op.state is exactly the total state some transaction committed. A migration is a pure function over a small, fully enumerable, fully typed set of values.

7.5 The three strata, restated as policy

text
entries + usage      the stability budget goes HERE. Payloads are provider-shaped
                     messages plus three simple structural types; changes must be
                     read-compatible forever, because years of entries cannot
                     be rewritten at open time — the precise rewrite (§2.9)
                     exists, but it is administrative, not an open-time step. Custom
                     entry payloads are the application's contract.

lane / fact          migrate on open, mechanically. A few registers per lane,
registers            cheap forever.

op.* / pending.*     ephemeral by construction and few in number. Every
                     state-machine change ships the total register mapping for
                     its own states (§7.4). This is where the machine is allowed
                     to churn between versions, because the mapping cost is
                     bounded by open operations — usually zero.

The design conclusion: the volatile part of the system — orchestration — was made ephemeral, and the durable part — the conversation — was made structurally boring. Schema evolution is exactly as hard as the boring part, which is the best available outcome.

Part 8 — Build order

One shared slice lands the complete type surface; everything after it splits into two independent tracks. Track S (storage, search, dev TUI) parallelizes across owners — its slices depend only on slices 1–2 and never on each other. Track R (runtime) is sequential, runs entirely against the Memory backend, and never waits on Track S. The tracks cannot block each other.

Each slice implements its named behavior end to end and adds focused tests for its normal path, every state it introduces, every owned crash boundary, and both orders of owned races. Passing those tests and npm run check is its acceptance criterion. If implementation exposes a design contradiction, missing transition, or materially simpler design, stop and send it for review — do not silently improvise a new durable contract inside a slice.

#SliceImplementRequired focused tests
1TypesThe complete shared type surface, behavior-free: Entry/Register/UsageRow and RegisterValues including the full Part 3 state tree, Write/Transaction/Storage/Session/SessionTree/SessionRepo, scans, the id-generator and SessionSearchService interfaces, storageVersion, and the Part 5 surface types (results, errors, events, snapshots, hooks). Delete packages/agent/src/harness/** and its tests outright; patch remaining consumers. The repo may not compile mid-slice; it compiles again — npm run check clean — at the end.Type-level only; no behavior.
2Session layer, Memory, conformanceEntry materialization with inline payloads, lane/config/state registers, facts, branch/global queries, context projection, SessionTree/views, codec plus runtime entry/register/custom-message schemas, UUIDv7 generator with follower minting, stats projection, the Memory backend with repository lifecycle/forks and the storageVersion gate at open, the backend conformance suite, and the instrumented-storage decorator (Part 9).Rollback, sequence order, duplicate ids, register set/delete/recreate, delete-of-absent-key no-op, fact deletion vs JSON null, schema validation, unknown custom roles, immutable reads, stats-equals-ledger, follower minting, placement, divergence, filters/cursors/stops, custom entries with and without data, context projection, fork before first attachment, configured fork snapshots/facts/zero ledger, close.
S1JSONLFormat 4: single-item/array transaction lines, register set/delete replay, header storageVersion, torn-tail handling, snapshot compaction (GC keep-predicate), the file-based repository, format-3 read normalization and first-write temp/rename conversion with id re-minting (Appendix B). Replace the unfinished current v4 without migration.Backend conformance, corrupt interior/final lines, whole-array tear, compaction logical-equivalence, every format-3 rule including id re-minting and reference remapping, resolved/unresolved parent paths, aggregate imported usage adjustment.
S2SQLiteOne database file per session: entries/registers/usage-ledger tables, one-row session/lease rows, transactions, storageVersion, the file-based repository, segmented branch cache, VACUUM INTO-based rewrite/fork, and explicit repair. No values table, no slot_history, no getLog, no search projection, no migration.Shared conformance, BEGIN IMMEDIATE, fencing, query plans, segment-chain soundness, register upsert/delete, forks/stats/repair.
S3SearchThe standalone SessionSearchService (§2.8): durable per-session cursors, sync() enumeration and catch-up, debounced notify(), remove()/reconciliation, (sessionId, storeGeneration) cursor keys, and the reference SQLite FTS5 implementation working over any backend's repository.Cursor catch-up from empty against existing sessions, idempotent re-index after crash mid-batch, notify/sweep equivalence, sessions-vs-entries queries and ranking, removal and reconciliation, shared-index multi-process discipline.
S4Dev TUI and ClientA minimal AgentClient over one lane — LaneSnapshot plus watch() events, prompt/steer/followUp/abort/resume/cancelQueued, lane.lastResult read — and a throwaway alt-screen TUI on packages/tui: transcript from snapshot and events, input box, status/queue display, abort key. Built first against a scripted fake client on the slice-1 types; binds to the real harness as Track R lands. Not final.Compiles; fake-client smoke test. No durability obligations.
R1Runtime shellLane/settings mutation lines, total-state validation (idle lanes included), register-seq CAS tokens, runtime snapshots, Effects, manual scheduler/gate, hook/event primitives, restore inventory (five register reads plus bounded hydration), dispatch-time identity resolution, fault/close plumbing. Public operations may still report not implemented.State/action exhaustiveness, seq-token settlement, parallel scheduler order, hook aggregation, event buffering, gate nesting, zero effects while parked, restore without history reads, idle-lane validation.
R2Minimal no-tool runPrompt expansion, before_run, atomic acceptance with pending-capture placement, captured request options/thinking inline, payload/response hooks, one generation intent/effect/settlement, usage, the terminal transaction (register cleanup plus lane.lastResult), results, basic events/telemetry.Successful run with final assistant fields, invalid caller/provider/hook output, exact transaction/event order, terminal cleanup completeness and lastResult, automatic/manual identical state, close at every boundary.
R3Generation recovery and retryRetry waits, unknown-effect recovery, synthetic cap settlement, ordinary stop/error/deferred classification, provider-compliant aborted, and failure-drain foundation. Overflow classification remains explicitly unimplemented until R9.Every generation state before/after reopen, caps/backoff, stop/error/aborted/deferred classification, missing identities.
R4ToolsRefactor the existing loop into three phases, bind AgentHarnessTool context, durable complete plans, op.tool_args/{opId}:{stepId}:{i} registers with batch-completion deletion, replay, sequential/parallel modes, blocked terminate, genuine-length results, tool events/hooks/usage.Existing loop compatibility plus a built-in context-bound tool, invalid args/results, every planned/pending/completed state, tool-args register lifecycle including crash-leak prefix cleanup, safe/unsafe replay, ordering, termination, abort-ready states.
R5Inbox, configuration, and writesnextRun/steer/follow-up via pending.entry registers, cancelQueued triage (not_found), durable drain markers, checkpoint consumption with register deletion, immediate total config setters, deferred tree writes, adjustments.Capture/cancel/consume races, repeated cancellation answering not_found, one-at-a-time crash after one drain, register/entry exclusivity at every boundary, custom-write continuation, config-step race, writes surviving reopen.
R6Abort, close, and failure drainOrthogonal control, drained ids in control with surviving pending registers, signalling, per-phase reconciliation, best-effort cancellation of the current deferred source, waiters/run-when-idle, controlled-crash close, terminal deletion of inbox-and-drained registers, and the external-finalization stop on absent operation registers (§4.9).Abort at every existing state, repeated abort, deferred cancellation, live/restore tool outcomes, writes before finish, drained-register survival and terminal deletion, close races, an externally finalized operation stopping the drive without writes and resolving from lastResult, failure revived only by projecting input.
R7Deferred provider redemptionOne poll per resume, copied configuration/options inline, per-poll request hooks, exact source lineage/equality, fresh intent after unknown poll, mismatch-to-error, ready tools, and advancement of R6 cancellation to each newest source.Repeated pending, ready/error/aborted/mismatch, crash positions, no cap/backoff/loop, newest-handle cancellation.
R8Manual compactionReserved-lane admission, the op.preparation/{opId}:{taskId} register, total structural state, hook/generated sources, nested request intents/usage, retained tail, retry/recovery/abort.Empty/reservation race, hook decline/result, crash after request one of split-turn generation, every state/crash, no public summary-stream messages.
R9Threshold and overflow compactionIn-run structural decision, durable once-per-trigger threshold marker, continuation preservation, all overflow predicates, atomic response/preparation publication, specified normalization/projection, one overflow recovery flag, bounded second failure.Threshold decline/empty across reopen, all overflow classifier/preparation inputs, no overflow tool plan, genuine length, crash/reopen at every transition.
R10NavigationValidation, summarized decision/generation, and one final transaction combining move/summary/leaf/label with the terminal writes; summary-only navigation hook.Root/current/unknown rejection, summarized/unsummarized paths, final leaf at summary, abort race, exact atomic publication including register cleanup.
R11Schema version and migrationsChained migrate-on-open under the writer lease, migration registry with total register mappings — open operations' op.meta/op.state included (§7.4), JSONL lenient old-shape replay and mandatory post-migration compaction, refuse-newer.Version gate (equal/older/newer), chained idempotent migrations across crash, an open-operation state mapped across a state-machine change and resuming correctly, lenient replay of superseded shapes, compaction retiring old bytes.
R12Surface completionComplete snapshots/watch, event catalog/order/filtering, telemetry instrumentation/schema freshness, public exports, backend parity, and remove any remaining dead scaffold code — including the S4 fake client.Snapshot/event gap, attach during every live state, sensitive-event/content-free-telemetry assertions, full race/crash matrix on all backends.

Existing source guidance:

  • packages/agent/src/harness/** and all of its tests are deletable outright in slice 1 — no obligation to adapt anything. Salvaging pieces (the compaction preparation/split-turn algorithms for R8–R9, session/codec fragments) is optional and never required.
  • packages/agent/src/agent-loop.ts: preserve behavior; R4 extracts its phases.
  • packages/session-backends/sqlite-node: S2 may keep the working transaction and lease primitives or start clean.
  • Telemetry contracts (packages/telemetry, the agent-owned schemas) remain authoritative.
  • Existing tests are evidence, not authority. Keep those that assert unchanged behavior; delete the rest with the code they tested.

Part 9 — Invariants and tests

9.1 Invariants

Storage:

  1. Entries and usage rows are write-once and share one session-wide id namespace. Writing either kind under any existing id is corruption.
  2. Transactions are all-or-none, with strictly increasing seq in write order; gaps are legal. seq is monotonic session-wide.
  3. Registers are the only mutable state. A register delete removes the key; there are no tombstones, and JSON null is a legal value only where a namespace's type permits it.
  4. Every payload lives in exactly one place: an entry, a register, or the ledger. There is no third place data can hide.
  5. No read on a hot path may fold history or infer state from an absent value — no history exists to fold. Execution, recovery, and branch hot paths must be index-driven; inventory and debugging APIs page through indexes.

Tree:

  1. An entry's parent chain never changes. Branches share prefixes; nothing is copied.
  2. An entry either decodes against its type's runtime schema or is corruption. Only a custom entry may omit payload data.
  3. Configuration and orchestration never enter the tree. Deleting every op.* and pending.entry register must leave a complete, valid conversation and ledger.
  4. A lane's leaf moves only by append or navigation.
  5. A branch segment chain, followed to its end, yields the full root path (§2.6).
  6. A missing parent is corruption — always (§1.2).

Operations:

  1. lane.state/{lane} confers lane ownership, and op.state/{operationId} confers operation-state ownership. An open lane names operation O, op.meta/O holds that lane's compatible Operation, and op.state/O holds an OperationState compatible with O's intent kind; state values carry no duplicate owner metadata.
  2. op.* registers and operation-owned pending.entry registers exist iff their operation is open: the terminal transaction deletes them atomically with clearing currentOperationId (§3.13). Lane-owned pendingNextRun registers are never deleted by it.
  3. Acceptance must observe currentOperationId === null.
  4. A reserved id may exist only with the content its intent named. There are exactly two reservation regimes (§2.2): settlement-family ids are strings in op.state; queued-content ids are pending.entry registers — until placement or cancellation, exactly one of register and entry exists.
  5. Only terminal transitions construct a LaneLastResult. A terminal outcome is observable once through the live promise and thereafter through lane.lastResult until the next terminal transaction on that lane; recovery never reads it.
  6. At most one operation is open per lane. Two is corruption.
  7. overflowRecoveryUsed is true only after overflow compaction. A transition that adds projecting conversational input or tool results and requires an assistant writes false; an unprojected custom write preserves it.
  8. The settlement transaction that commits a response with stopReason: "aborted" must, in that same transaction, write an operation state with control.status === "cancel_requested". The invariant is scoped to the committing transaction — later terminal cleanup or forks may remove the state without violating it. Providers must comply with the harness-owned signal contract; violation is corruption.
  9. Current-state validation (§3.3) runs on every decoded latest lane/operation state before execution — idle lanes included (§4.4). lane.lastResult never determines an open operation's next action.
  10. At most one terminal transaction ever commits per operation. A drive whose conditional commit or reload finds its operation's registers absent stops without writing and resolves from lane.lastResult (§4.9).

9.2 Race catalog

Each race has exactly two durable histories. Test both, in manual drive, in both orders.

RaceOrders
prompt vs prompt on one laneone accepts, one gets LaneBusy
abort vs response settlementmarker first → normalized aborted; response first → stop reason preserved
abort vs tool result commitplanned result synthesized; or the real result stands
abort vs before_run_end follow-upfollow-up dropped; or committed and the run continues
cancelQueued vs checkpoint consumptioncancelled; or already_consumed
setModel vs generation step startold snapshot used; or new snapshot used
abort vs structural commitaborted with no entry; or completed
nextRun vs acceptancecaptured by this run; or stays for the next
manual-compaction reservation vs idle tree writereservation first → write waits; write first → preparation uses the new leaf
deferred write vs abortwrite survives abort either way
close vs parked manual actionaction rejected unexecuted; durable state is the committed prefix
close vs settlementsettlement abandoned, state stays effect_pending; or it committed before the flag was set

9.3 Test tiers

Tier A — state and resume. For every state in Part 3, construct it durably, close, reopen, and assert the next action. Coverage must include: restore with no branch walk and no configuration dereference; assistant intent with no settlement, below and at the retry cap; settlement followed by each classification branch; every settled stop reason surviving except the two deliberate normalizations; a self-contained deferred step with copied configuration, consecutive polls, repeated equal-handle pending responses, ready and terminal responses, and handle-mismatch normalization into durable failure; every tool state including planned, effect_pending safe and unsafe, and completed; a batch where every call sets terminate finishing the run with no further request; genuine-length batches proving no execution and one explanatory result per call; every overflow crash position, including that the compacted retainedTail omits the normalized-error response by the ordinary projection rule; every navigation state with no post-move generation; abort at every position; missing identities on accept and on resume; every terminal transaction proving complete register deletion (including tool-args prefix-scan cleanup of crash-leaked keys), lane.lastResult correctness, and preserved pendingNextRun; register/entry exclusivity for every queued id at every crash boundary; and every half-completed recovery prefix.

For each recovery prefix: close, reopen, resume, and compare against uninterrupted recovery. Invoking recovery twice from the initial prefix is not sufficient.

One corruption assertion constructs an aborted response with running control directly and requires load rejection. Provider conformance separately proves implementations emit aborted only for the supplied signal.

Tier B — writer conformance. Run the public harness against the instrumented-storage decorator: a spy wrapping Storage.commit() that records every transaction's writes in order. Assert exact write order and content against the Part 3 transaction tables and the §5.5 ordering rules. There is no durable log to compare against; the decorator is the oracle. Faux provider/tool/hook spies interleave their start events with the decorator's commit record, so effect timing is observable. This tier catches the critical regression classes: an effect starting before its intent commit, a response omitted for one stop reason, classification starting before usage is durable, a result id reserved after clearance began, or a terminal transaction leaking a register.

Tier C — deterministic interleavings. Every race in §9.2, both orders, manual drive.

Cross-cutting:

  • Backend conformance. One suite, three backends, identical results — identical query results, register states, and stats after every scenario, including register set/delete/recreate semantics and torn-transaction handling. Write-order assertions use the instrumented decorator, never a durable log.
  • Drive equivalence. The same scenario in automatic and manual drive must produce byte-identical durable state.
  • Signal ownership. No public surface accepts a signal; a before_request patch carrying one has it stripped. Assert by type and by test.
  • Ledger completeness. Every settled attempt commits its response and its usage. Failed structural attempts retain their cost. getStats() equals the ledger sum after every commit. A fork starts at zero.
  • Query-plan guards. EXPLAIN QUERY PLAN for scanBranch matches §1.7 exactly — no entries scan or temporary ordering b-tree. Segment tests assert copied rows are bounded by the newest compaction interval.
  • Transaction discipline. Assert every SQLite transaction opens with BEGIN IMMEDIATE. Add a regression test that reads, lets a second connection commit, then writes — it must succeed, and would fail with database is locked under a deferred BEGIN.
  • Segment chain soundness. Build a chain by alternating branch-and-append across several compactions, then assert that a full-to-root scan through the chain returns exactly the entries a flat branch would, with no duplicates and no gaps. Both §2.6 rules — resolve-through-base coverage and the chain-searched newest compaction — fail this test when violated, and fail silently without it.

Appendix A — Glossary

TermMeaning
EntryWrite-once conversation record: placement and payload in one row. Its id is the public entry id.
RegisterNamespaced mutable cell holding its current typed value directly. Overwrite replaces; delete removes the key.
Usage rowAppend-only cost ledger row. Never modified, never deleted.
Pending entryUnplaced content in a pending.entry register keyed by its reserved entry id, until placement or cancellation.
SessionOne conversation: tree, facts, ledger, lanes.
LaneNamed cursor into the tree with its own config, queues, and one operation.
OperationOne accepted unit of work: run, compaction, or navigation.
EffectAnything not pure computation: commit, provider request, tool, hook, timer.
Repeat-sensitive effectOne whose repetition is observable outside the harness.
Operation stateThe complete state of one operation at one moment — the op.state register, the program counter.
Reserved idAn id minted before its content exists: a string in op.state (settlement family) or a pending.entry key (queued content).
Follower idAn id minted with its leader's 48-bit timestamp so a call/result group shares one time prefix (§1.2).
Lane mutation linePer-lane serialization point where all state-dependent mutations queue.
ControlOrthogonal cancellation flag: running or cancel_requested.
CheckpointThe state between turns where queues, writes, and finishing are decided.
ContinuationDurable answer to "does this run still owe an assistant turn?"
Terminal transactionThe commit that deletes an operation's registers, writes lane.lastResult, and clears currentOperationId.
SegmentA branch-index range that references an older branch instead of copying it.
External finalizationA terminal transaction committed from outside the live drive; the drive detects absent registers, stops without writing, and resolves from lane.lastResult (§4.9).
Precise rewriteThe administrative copy-retained-and-swap rebuild of a session store — the sole sanctioned path that removes entries or usage rows (§2.9).

Appendix B — Coding-agent v3-format compatibility

"v3" in this appendix names the legacy coding-agent JSONL session format, not this document. Old coding-agent v3 JSONL files must open unchanged and restore idle. Normalization on load:

  • custom_message becomes a custom agent message.
  • label and session_info become facts (latest by file position wins) and leave the tree. A label targets its nearest retained parent.
  • Legacy model_change, thinking_level_change, and active_tools_change nodes disappear. They do not initialize or alter LaneConfiguration; a normalized main uses the immutable options seed.
  • Each retained child of a discarded node is reparented to its nearest retained ancestor.
  • main's leaf is the final physical node resolved through discarded nodes to its nearest retained ancestor.
  • An old compaction resolves its legacy firstKeptEntryId field against its own branch and materializes that range as retainedTail. Format 4 never exposes or persists that field.
  • Existing details, usage, and fromHook are preserved; an absent fromHook normalizes to false.
  • v3 ISO timestamps convert to Unix milliseconds.
  • A v3 parentSession path resolves to an available parent header id; otherwise metadata and first-write conversion preserve it as legacyParentSessionPath.
  • On first format-4 write, append one aggregate adjustment usage row with details: { source: "v3-import" }, summing v3 node usage so ledger-derived totals remain unchanged.
  • Legacy v3 ids are re-minted at import: each entry gets a UUIDv7 whose prefix is the legacy entry's own timestamp (random tail for uniqueness), preserving time order and §1.2's every-id-is-time-prefixed property. All references the format knows are remapped — parent chains, main's leaf, label keys, fromId, usage entryId. Ids embedded in opaque payloads (custom entry data, details, message text) are not rewritten; the opaque-payload contract (§1.2) already covers them.

Read-only open leaves the file unchanged and computes stats from normalized entry snapshots. The first format-4 write persists normalization through a temporary file and atomic rename over the original path, including the aggregate adjustment so subsequent stats are ledger-derived, and stamps the current storageVersion (§7.3). A fork from an unconfigured read-only v3 session follows §2.7 and leaves destination main for first harness attachment to seed.

Appendix C — Open questions

  1. Repairing a missing model captured inside an open operation. Registering the same provider/model identity unblocks it without changing state. Replacing it with a different durable identity needs an explicit repair API and is not silently performed by setModel.
  2. Overflow detection remains heuristic. The normalization specified in §3.7 is authoritative. Preserve the original reason in errorMessage for diagnosis.
  3. Pending-payload write amplification. The deliberate double write (§1.8) is paid only by queued items; measure it for pathological payloads before optimizing (INSERT … SELECT placement exists on SQL backends, eager compaction on JSONL).