From 1e19edd133aa19f071aaa7940a4a722e12ef03c5 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sun, 16 Aug 2026 23:06:38 +0200 Subject: [PATCH] docs(agent): use typed register tokens --- packages/agent/docs/registers.md | 571 +++++++++++++++++++++++-------- 1 file changed, 431 insertions(+), 140 deletions(-) diff --git a/packages/agent/docs/registers.md b/packages/agent/docs/registers.md index d8e66eb3d..4222a6f32 100644 --- a/packages/agent/docs/registers.md +++ b/packages/agent/docs/registers.md @@ -1,54 +1,223 @@ -# Scalar and list registers — implementation handoff +# Typed scalar and list registers — implementation handoff -This document specifies the storage primitive only. It is intended to be implemented before tool durability and before any harness-native streaming tool API. +This document specifies the register storage primitive used by the harness. -The existing register store becomes two statically typed namespace kinds: +Registers are identified by ordinary typed token values rather than a global `RegisterValues` or `ListRegisterValues` namespace map: -- **scalar:** one current value per key; `set` replaces it; -- **list:** immutable elements appended under a key and read in bounded sequence pages. +- **scalar register:** one current value per `(token, key)`; set replaces it; +- **list register:** immutable values appended under `(token, key)` and read in bounded sequence pages. -List elements are append-only. Deleting a whole list key remains necessary for lifecycle cleanup. There is no element update, element delete, insertion, or truncation. +Built-in tokens live together in `packages/agent/src/harness/session/registers.ts` and are imported directly by their consumers. There is no `HarnessRegisters` bundle, runtime catalog, declaration merging, or raw register access from tools. ## Goals -1. Preserve the existing scalar register API and behavior. -2. Append a list element without reading the existing list. -3. Use the existing transaction sequence as the element cursor and order. -4. Produce identical pages on Memory, JSONL, and SQLite. -5. Keep list reads bounded and explicit. -6. Allow list writes to commit atomically with entries, usage, and scalar writes. +1. Keep persisted register IDs stable while moving their value types onto tokens. +2. Allow built-in and future application register definitions without editing a global type map. +3. Preserve type inference through reads, writes, scans, and list appends. +4. Preserve existing scalar replacement semantics. +5. Append a list element without reading or rewriting existing elements. +6. Use the existing transaction sequence as list order and cursor. +7. Produce identical logical behavior on Memory, JSONL, and SQLite. +8. Keep list reads bounded and explicit. +9. Commit scalar/list writes atomically with entries and usage. ## Non-goals This slice does not define: -- tool output or assistant stream element types; -- retention catalogs; +- assistant-frame contents or reduction semantics; +- tool progress semantics; +- per-token runtime value validation; - per-element or per-key byte limits; -- empty-list existence markers; -- list truncation; -- audit logging; -- a generic event log or reducer for operation recovery. +- list truncation or per-element deletion; +- a generic event log or operation-state reducer; +- dynamic registration, token catalogs, or dependency-injected built-in token bundles. -Consumers own their key grammar, content limits, cleanup points, and restore hydration policy. +Consumers own key grammar, content limits, cleanup points, and restore hydration policy. Values remain trusted typed in-process data under the validation boundary in `harness.md`. -## Types +## Token model -Keep the existing scalar value map and APIs. Do not rename `RegisterValues`, `Register`, `getRegister`, or `listRegisters` as part of this slice. +A token carries its register kind, stable persisted ID, and compile-time value type: ```ts -export interface RegisterValues { - // Existing scalar namespaces, unchanged. +declare const registerValueType: unique symbol; + +interface RegisterTokenBase { + readonly id: string; + readonly kind: "scalar" | "list"; } -/** The value is one element, not an array. */ -export interface ListRegisterValues { - // Concrete namespaces are added by their consuming slices. +export interface ScalarRegisterToken extends RegisterTokenBase { + readonly kind: "scalar"; + /** Compile-time only; makes the value type part of the token. */ + readonly [registerValueType]?: (value: T) => T; } -export type RegisterNamespace = keyof RegisterValues; -export type ListRegisterNamespace = keyof ListRegisterValues; +export interface ListRegisterToken extends RegisterTokenBase { + readonly kind: "list"; + /** Compile-time only; T is one element, not the whole list. */ + readonly [registerValueType]?: (value: T) => T; +} +export function scalarRegister(id: string): ScalarRegisterToken { + validateRegisterId(id); + return Object.freeze({ id, kind: "scalar" }); +} + +export function listRegister(id: string): ListRegisterToken { + validateRegisterId(id); + return Object.freeze({ id, kind: "list" }); +} +``` + +The phantom function keeps the type invariant: a token for one value type cannot silently widen to a token for another. It has no runtime field. + +A register ID must be non-empty and must not contain the internal Memory-map separator (`\u0000`). It is serialized into JSONL and SQLite and therefore remains stable across reopen. Changing an ID or changing its scalar/list kind requires a storage migration. + +Token object identity carries no durability meaning. Storage addresses a register by `(kind, token.id, key)`. + +### No global value map + +Delete these shapes: + +```ts +interface RegisterValues { /* namespace → value */ } +interface ListRegisterValues { /* namespace → element */ } + +type RegisterNamespace = keyof RegisterValues; +type ListRegisterNamespace = keyof ListRegisterValues; +``` + +A type belongs to its token regardless of where the token is defined: + +```ts +export const laneLeaf = scalarRegister("lane.leaf"); +export const applicationState = scalarRegister("my-app.state"); +``` + +The storage API does not need to know either type centrally. + +## Built-in tokens + +Define built-ins directly in `packages/agent/src/harness/session/registers.ts`: + +```ts +export const laneLeaf = scalarRegister("lane.leaf"); +export const laneConfig = scalarRegister("lane.config"); +export const laneState = scalarRegister("lane.state"); +export const laneLastResult = scalarRegister("lane.lastResult"); + +export const operationMeta = scalarRegister("op.meta"); +export const operationState = scalarRegister("op.state"); +export const operationToolArgs = + scalarRegister>("op.tool_args"); +export const operationToolFact = scalarRegister("op.tool_fact"); +export const operationPreparation = + scalarRegister("op.preparation"); + +export const pendingEntry = scalarRegister("pending.entry"); +export const pendingToolOutput = + scalarRegister>("pending.tool_output"); + +/** Compact frames defined by the assistant-partial durability slice. */ +export const pendingAssistantFrames = + listRegister("pending.assistant_frame"); + +export const factName = scalarRegister("fact.name"); +export const factLabel = scalarRegister("fact.label"); +export const factCustom = scalarRegister("fact.custom"); +``` + +These are normal constants, not a configuration object. Harness code imports the token it uses: + +```ts +await reader.getRegister(operationState, operationId); +await reader.readList(pendingAssistantFrames, frameKey, options); +``` + +A future application token may be defined in the application's own `registers.ts`. Persisted IDs must be globally unique within a session. Defining two tokens with the same `(kind, id)` but different TypeScript value types is a trusted-programming defect. Defining scalar and list tokens with the same ID is also a defect. + +Tests assert that all built-in exported tokens have unique IDs and fixed kinds. + +## Typed scalar API + +```ts +export interface ScalarRegisterValue { + key: string; + value: T; + seq: number; +} + +interface SessionReader { + getRegister( + register: ScalarRegisterToken, + key: string, + ): Promise | undefined>; + + listRegisters( + register: ScalarRegisterToken, + keyPrefix?: string, + ): Promise[]>; +} +``` + +`listRegisters` lists scalar keys under one token; it is unrelated to list-register element reads. It retains its existing bounded-use contract: harness callers use exact operation-owned prefixes, and no public API exposes an unrestricted session dump. + +Register writes are created through typed helpers. The stored transaction representation is erased only after the helper checks the token/value relationship: + +```ts +interface RegisterSetWrite { + kind: "register"; + op: "set"; + namespace: string; + key: string; + value: unknown; +} + +interface RegisterDeleteWrite { + kind: "register"; + op: "delete"; + namespace: string; + key: string; +} + +export function setRegister( + register: ScalarRegisterToken, + key: string, + value: NoInfer, +): RegisterSetWrite { + return { + kind: "register", + op: "set", + namespace: register.id, + key, + value, + }; +} + +export function deleteRegister( + register: ScalarRegisterToken, + key: string, +): RegisterDeleteWrite { + if (register.kind !== "scalar") throw new Error("Expected scalar register"); + return { + kind: "register", + op: "delete", + namespace: register.id, + key, + }; +} +``` + +`NoInfer` makes the token authoritative. TypeScript must not widen `T` from an incompatible value argument. + +The raw write interfaces are storage internals. Harness and application code use `setRegister()` and `deleteRegister()` rather than manually constructing erased writes. + +## Typed list API + +A list token's `T` is one immutable element: + +```ts export interface ListRegisterElement { /** Global transaction-write sequence assigned by storage. */ seq: number; @@ -67,87 +236,105 @@ export interface ListReadOptions { /** Default: 1,000. Maximum: 10,000. */ limit?: number; } + +interface SessionReader { + readList( + register: ListRegisterToken, + key: string, + options?: ListReadOptions, + ): Promise[]>; +} ``` -Values remain trusted in-process values under the validation boundary in `harness.md`. Storage serializes them where required but does not runtime-validate their application shape or defensively clone them. - -## Writes - -One append write carries one element: +List writes also use typed helpers: ```ts -export type ListRegisterAppendWrite = { - [N in ListRegisterNamespace]: { - kind: "list-register"; - op: "append"; - namespace: N; - key: string; - value: ListRegisterValues[N]; - }; -}[ListRegisterNamespace]; +interface ListRegisterAppendWrite { + kind: "list-register"; + op: "append"; + namespace: string; + key: string; + value: unknown; +} -export type Write = - | ExistingWrite - | ListRegisterAppendWrite - | { - kind: "list-register"; - op: "delete"; - namespace: ListRegisterNamespace; - key: string; - }; +interface ListRegisterDeleteWrite { + kind: "list-register"; + op: "delete"; + namespace: string; + key: string; +} + +export function appendList( + register: ListRegisterToken, + key: string, + value: NoInfer, +): ListRegisterAppendWrite { + return { + kind: "list-register", + op: "append", + namespace: register.id, + key, + value, + }; +} + +export function deleteList( + register: ListRegisterToken, + key: string, +): ListRegisterDeleteWrite { + if (register.kind !== "list") throw new Error("Expected list register"); + return { + kind: "list-register", + op: "delete", + namespace: register.id, + key, + }; +} ``` -A transaction appending several elements contains several append writes. This deliberately reuses the existing rule that every write receives its own strictly increasing sequence number. No ordinal or list-local counter is needed. +`Write` adds the four erased scalar/list write representations alongside entries and usage. A transaction may mix every write kind atomically. + +## List write semantics + +One append write carries one element. A transaction appending several elements contains several append writes. Every write receives its existing globally increasing transaction sequence: ```text TX[ - append list/ns/key = A, // seq 41 - set scalar/ns/key = X, // seq 42 - append list/ns/key = B // seq 43 + append pendingAssistantFrames/O:R = A, // seq 41 + set operationState/O = X, // seq 42 + append pendingAssistantFrames/O:R = B // seq 43 ] ``` -The elements under the list key are ordered `A, B` by sequence. Gaps caused by unrelated writes are irrelevant. +Elements under list key `O:R` are ordered `A, B` by sequence. Gaps caused by unrelated writes are irrelevant. Rules: -- append never reads the list; -- whole-key delete removes every element under `(namespace, key)`; +- append never reads existing elements; +- an element is immutable after commit; +- whole-key delete removes every element under `(token.id, key)`; - delete of an absent list is a no-op; - delete followed by append in one transaction creates a fresh list atomically; -- all validation and serialization needed to admit a transaction occurs before any Memory state is changed; +- there is no per-element update, delete, insertion, or truncation; +- all validation and serialization needed to admit a transaction completes before Memory state changes; - a failed transaction exposes none of its list or non-list writes. -“Append-only” describes an element while its key exists. Whole-key deletion is a lifecycle operation, not an element mutation. +“Append-only” describes elements while the key exists. Whole-key deletion is lifecycle cleanup, not element mutation. -## Reads - -Add the same method to `Storage`, `SessionReader`, and `SessionMutator`: - -```ts -readList( - namespace: N, - key: string, - options?: ListReadOptions, -): Promise[]>; -``` - -Semantics: +## List read semantics - ascending reads return `seq > cursor.seq`; - descending reads return `seq < cursor.seq`; - results are ordered according to `order` before `limit` is applied; -- absent and empty are both represented as `[]`; +- absent and empty both return `[]`; - callers continue with the last returned element's `seq`; - an empty page ends iteration; - `limit` must be a positive safe integer, defaults to 1,000, and is capped at 10,000. -Example: - ```ts let cursor: ListCursor | undefined; while (true) { - const page = await reader.readList("example.items", "k", { + const page = await reader.readList(pendingAssistantFrames, frameKey, { cursor, order: "asc", limit: 100, @@ -158,43 +345,96 @@ while (true) { } ``` -A cursor is a sequence filter, not a snapshot or key-incarnation token. Concurrent later appends may appear on later ascending pages. A whole-key delete may make a cursor stale; reads simply apply its sequence comparison to the current elements. +A cursor is a sequence filter, not a snapshot or key-incarnation token. Concurrent later appends may appear on later ascending pages. A whole-key delete may make a cursor stale; reads simply apply its sequence comparison to the currently surviving elements. Do not add an unbounded “read the whole list” helper. +## Assistant partial frames + +Assistant partial durability is the first list consumer: + +```text +register: pendingAssistantFrames +key: {operationId}:{responseEntryId} +value: one bounded DurableAssistantFrame +``` + +The frame type is finalized with the assistant-stream design, not by storage. It must be a compact incremental frame. Do not append raw `AssistantMessageEvent`: update events contain a growing `partial` message and would reintroduce write amplification inside the list elements. + +The assistant procedure may batch several frame appends into one transaction, but each frame remains an independently sequenced list element. Its scalar `effect_pending` state remains authoritative. + +On restore: + +1. restore and validate scalar lane/operation state; +2. derive the exact frame key from the valid assistant effect-pending state; +3. read bounded pages from `pendingAssistantFrames`; +4. reduce frames into the latest durable partial assistant message. + +A missing frame list is valid and means no partial frame committed. Frames never prove provider completion and never select a restart state. + +Final assistant settlement atomically commits the complete immutable assistant entry and deletes the whole frame-list key: + +```text +TX[ + insert final assistant entry, + insert usage, + deleteList(pendingAssistantFrames, O:R), + set operationState/O = next state +] +``` + +Unknown-effect recovery may reduce the latest durable frames into its synthetic interrupted assistant response, but that policy belongs in the assistant durability specification. + ## Restore policy -Scalar state remains authoritative: +Scalar state remains authoritative for every list consumer: 1. restore scalar lane and operation registers; 2. perform current-state semantic validation; -3. derive any auxiliary list keys from that valid scalar state; +3. derive auxiliary list keys from valid scalar state; 4. optionally hydrate bounded list pages for snapshots, diagnostics, or activation. -A missing list never invalidates an otherwise valid operation. List contents never prove that an external effect completed and never select the operation restart point. +A missing list never invalidates an otherwise valid operation. List contents never prove that an external effect completed. -This permits auxiliary append streams to be restored without making an unbounded list part of current-state validation. Each concrete consumer must define a hydration byte/page budget. +Each concrete list consumer defines: + +- key grammar; +- element and total byte bounds; +- page/hydration budget; +- cleanup transitions; +- fork and migration policy. ## Memory backend -Use one map keyed like scalar registers: +Keep scalar registers in the existing map, now addressed by token ID: + +```ts +const scalars = new Map>(); +// map key: `${register.id}\u0000${key}` +``` + +Add list storage: ```ts type StoredListElement = { seq: number; value: unknown }; const lists = new Map(); -// map key: `${namespace}\u0000${key}` +// map key: `${register.id}\u0000${key}` ``` -- append pushes the already-sequenced element; -- delete removes the map entry; -- read filters by the exclusive cursor and slices to the validated limit; -- transaction preparation must complete before mutating entries, scalar registers, lists, usage, or stats. +- scalar set replaces the map value; +- scalar delete removes it; +- list append pushes the already-sequenced element; +- list delete removes the complete array; +- list read filters by exclusive cursor and slices to the validated limit; +- transaction preparation completes before entries, scalar registers, lists, usage, or stats mutate. -The storage snapshot used by JSONL/fork tooling must include current list elements with their original sequence numbers. +Storage snapshots used by JSONL/fork tooling include current scalar values and surviving list elements with original sequence numbers. ## SQLite backend -Add a schema migration rather than modifying an already-applied migration: +The existing scalar register table continues to store `token.id` in its namespace column. No scalar schema change is required. + +Add a schema migration for lists rather than modifying an applied migration: ```sql CREATE TABLE list_registers ( @@ -228,72 +468,111 @@ DELETE FROM list_registers WHERE namespace = ? AND key = ?; ``` -For a missing cursor, omit the sequence predicate. All writes run in the existing `BEGIN IMMEDIATE` transaction under the existing writer lease. No marker table, counter row, additional connection, or lock order is needed. +For a missing cursor, omit the sequence predicate. All writes run in the existing `BEGIN IMMEDIATE` transaction under the writer lease. No marker table, counter row, connection, or new lock order is needed. -Assert with `EXPLAIN QUERY PLAN` that paging uses the primary key and does not sort through a temporary b-tree. +Assert with `EXPLAIN QUERY PLAN` that paging uses the primary key and no temporary sort. ## JSONL backend -Committed records retain the write sequence: +Scalar records retain their current persisted namespace string. Typed tokens are an API change, not a JSONL format rewrite. + +List records use the token ID as `namespace`: ```jsonl -{"kind":"list-register","op":"append","seq":41,"namespace":"example.items","key":"k","value":{"text":"a"}} -{"kind":"list-register","op":"delete","seq":52,"namespace":"example.items","key":"k"} +{"kind":"list-register","op":"append","seq":41,"namespace":"pending.assistant_frame","key":"O:R","value":{"type":"text_delta","contentIndex":0,"delta":"hi"}} +{"kind":"list-register","op":"delete","seq":52,"namespace":"pending.assistant_frame","key":"O:R"} ``` -Replay folds them into the Memory list map: +Replay folds records into the Memory list map: - append adds `{ seq, value }`; -- delete removes the complete map entry. +- delete removes the complete list key. -A transaction continues to occupy one physical JSONL line, using an array when it has multiple writes. Torn-tail handling therefore remains atomic for list writes without a new framing mechanism. +A transaction remains one physical JSONL line, using an array for multiple writes. Torn-tail handling therefore remains atomic without new framing. ### Snapshot compaction -Compaction must preserve list cursors. Write every surviving list element as an append record carrying its original `seq`, merged into sequence order with surviving entries, scalar registers, and usage rows. +Compaction preserves list cursors. Write every surviving element as an append record carrying its original `seq`, merged in sequence order with surviving entries, scalar registers, and usage rows. -Do not collapse a live list into one synthetic append and do not assign new sequence numbers. Either action would change cursors and backend ordering. +Do not collapse a live list into one synthetic append or assign new sequence numbers. Either changes cursors and backend ordering. -Deleted lists produce no snapshot records. Preserve the storage sequence high-water mark using the same mechanism as the rest of JSONL snapshot compaction; dropping the latest delete must not permit sequence reuse after reopen. +Deleted lists produce no snapshot records. Preserve the storage sequence high-water mark through the existing mechanism so dropping the latest delete cannot permit sequence reuse. ## Forks and rewrites -The generic storage primitive does not decide whether a list namespace is copied. Session-level fork and precise-rewrite code must make an explicit decision for each concrete namespace. +Fork and precise-rewrite code decides policy per concrete token, using token identity in source code and the stable ID in storage. -Operation-owned auxiliary list namespaces are not copied into an idle fork. A future explicit application list may choose different behavior. +Operation-owned tokens are not copied into an idle fork: -Precise rewrites that retain a list element preserve its sequence cursor unless that rewrite explicitly defines sequence remapping for the entire destination store. +- `operationMeta` and other `op.*` scalar tokens; +- `pendingEntry` and `pendingToolOutput`; +- `pendingAssistantFrames`. + +Application-defined persistent tokens may define a different policy when their consuming feature is added. + +A precise rewrite retaining a list element preserves its sequence cursor unless the rewrite explicitly remaps the entire destination sequence space. ## Schema evolution -A namespace's kind is static for one storage version. Changing a namespace from scalar to list or list to scalar requires an explicit migration; storage must never infer or coerce the change. +A token's persisted ID and kind are static for one storage version. -A list element migration iterates current elements in sequence order and either maps each value while preserving `seq` or explicitly deletes the whole key/namespace. The migration mechanism must page rather than load an unbounded namespace at once. +- changing an ID requires an explicit old-ID to new-ID migration; +- changing scalar to list or list to scalar requires an explicit migration; +- storage never infers or coerces a kind change from observed records; +- changing only the TypeScript value type requires a total value migration when old stored values are not already valid under the new type. -Adding the generic SQLite table is a storage schema migration. Adding the first logical list namespace does not require rewriting sessions that contain no values for it. +A list migration pages current elements in sequence order and either maps values while preserving `seq` or deletes the whole key/token. It must not load an unbounded logical list at once. + +Adding the generic SQLite list table is a storage schema migration. Adding a new token with no stored values does not rewrite existing sessions. ## Instrumentation -The instrumented storage decorator must expose list reads and continue recording committed writes exactly in transaction order. Telemetry's session-write item kinds gain `list-register` or otherwise count list writes explicitly; do not report them as scalar sets. +The instrumented storage decorator exposes token-based scalar/list reads and records committed erased writes in exact transaction order. -Append-path tests must be able to prove that no `readList` call occurred before commit. +Telemetry session-write item kinds include `list-register` explicitly; list appends/deletes are not reported as scalar writes. Token IDs may be attributes, but values and assistant-frame contents never enter telemetry. + +Append-path tests prove no `readList` call occurred before commit. ## Invariants -1. Every namespace has one static kind in a storage version. -2. A scalar write cannot target a list namespace, and a list write cannot target a scalar namespace at the typed call site. -3. Every list element is immutable and addressed by its globally unique write `seq`. -4. Elements under one key are returned in sequence order on every backend. -5. Append performs no read of the target list. -6. List writes are atomic with entries, usage rows, and scalar writes in the same transaction. -7. Whole-key delete leaves no elements under the key. -8. Missing and empty have the same read result: `[]`. -9. Scalar restore and validation never depend on list contents. -10. JSONL compaction preserves surviving element sequences. +1. Every token has one stable persisted ID and one static kind in a storage version. +2. Built-in token IDs are unique. +3. Token-based reads and helper-constructed writes preserve the token's value type. +4. A scalar helper cannot target a list token; a list helper cannot target a scalar token. +5. Every list element is immutable and addressed by its globally unique write `seq`. +6. Elements under one list key are returned in sequence order on every backend. +7. Append performs no read of the target list. +8. Scalar/list writes are atomic with entries and usage in the same transaction. +9. Whole-key list delete leaves no elements under that key. +10. Missing and empty lists both read as `[]`. +11. Scalar restore and validation never depend on list contents. +12. JSONL compaction preserves surviving element sequences. +13. Deleting operation-owned scalar and list state at terminal cleanup leaves no recoverable partial assistant content. ## Required tests -Extend the existing backend conformance suite; do not create a separate list-backend suite. +### Token typing and identity + +- scalar reads infer the token's value type; +- list reads infer the token's element type; +- `setRegister` rejects an incompatible value at compile time; +- `appendList` rejects an incompatible element at compile time; +- scalar helpers reject list tokens and list helpers reject scalar tokens; +- independently defined application tokens work without declaration merging; +- built-in IDs are non-empty, separator-free, unique, and have fixed kinds; +- token IDs round-trip through Memory, JSONL, and SQLite; +- two differently typed definitions using one persisted ID are documented/tested as a programming defect rather than supported aliasing. + +### Scalar regression + +- existing scalar set/get/list/delete/recreate behavior is unchanged; +- replacement retains only the latest logical value; +- token-based helpers preserve mixed transaction write order; +- existing scalar JSONL and SQLite files open without a format rewrite. + +### List conformance + +Extend the shared backend conformance suite: - append one element and page it; - multiple appends to one key in one transaction; @@ -303,34 +582,46 @@ Extend the existing backend conformance suite; do not create a separate list-bac - absent key returns `[]`; - whole-key delete and delete of absent key; - delete followed by append in one transaction; -- rollback when a later write in the transaction is invalid; +- rollback when a later write is invalid; - atomic list + entry + usage + scalar transaction; - identical pages and sequence cursors on Memory, JSONL, and SQLite; -- JSONL torn multi-write transaction exposes none of its list elements; +- JSONL torn multi-write transaction exposes no list element; - JSONL replay and snapshot compaction preserve cursors; - SQLite paging query plans use the primary key; - append path performs no list read; -- restore validation succeeds without reading lists, followed by bounded auxiliary hydration where a consumer requests it; -- close rejects later list reads and drains admitted list commits under the existing contract. +- restore validation succeeds without reading lists, followed by bounded hydration; +- close rejects later list reads and honors already-admitted list commits. + +### Assistant-frame integration + +- compact frames append under the exact effect-pending response key; +- raw events with growing `partial` snapshots are never persisted; +- frame pages reduce to the same partial message as uninterrupted streaming; +- missing/empty frame list restores as no durable partial; +- final assistant settlement atomically deletes the frame list; +- unknown-effect recovery reads only the bounded list named by scalar state; +- external finalization deletes the operation-owned frame list; +- idle forks contain no assistant-frame list; +- backend byte-growth tests show append-linear rather than repeated-snapshot growth. ## Implementation map Primary files expected to change: -- `packages/agent/src/harness/session/types.ts` -- `packages/agent/src/harness/session/storage-state.ts` -- `packages/agent/src/harness/session/memory.ts` -- `packages/agent/src/harness/session/session.ts` -- `packages/agent/src/harness/session/jsonl/codec.ts` -- `packages/agent/src/harness/session/jsonl/storage.ts` -- `packages/agent/src/harness/session/testing/storage-decorator.ts` -- `packages/agent/src/harness/session/testing/instrumented-storage.ts` -- `packages/agent/src/harness/session/testing/conformance/storage.ts` -- `packages/session-backends/sqlite-node/src/sqlite/migrations.ts` -- a new migration under `packages/session-backends/sqlite-node/src/sqlite/migrations/` -- `packages/session-backends/sqlite-node/src/sqlite/session.ts` -- `packages/session-backends/sqlite-node/src/sqlite/storage.ts` -- a focused SQLite list-register module under `packages/session-backends/sqlite-node/src/sqlite/session/` -- SQLite storage tests and query-plan tests +- new `packages/agent/src/harness/session/registers.ts` for token types, factories, helpers, and built-in constants; +- `packages/agent/src/harness/session/types.ts` to remove global value maps and expose token-based reader methods; +- `packages/agent/src/harness/session/storage-state.ts`; +- `packages/agent/src/harness/session/memory.ts`; +- `packages/agent/src/harness/session/session.ts`; +- `packages/agent/src/harness/session/jsonl/codec.ts`; +- `packages/agent/src/harness/session/jsonl/storage.ts`; +- `packages/agent/src/harness/session/testing/storage-decorator.ts`; +- `packages/agent/src/harness/session/testing/instrumented-storage.ts`; +- `packages/agent/src/harness/session/testing/conformance/storage.ts`; +- `packages/session-backends/sqlite-node/src/sqlite/migrations.ts`; +- a new SQLite migration for `list_registers`; +- `packages/session-backends/sqlite-node/src/sqlite/session.ts`; +- `packages/session-backends/sqlite-node/src/sqlite/storage.ts`; +- focused SQLite list paging and query-plan tests. -After implementation, update the register portions of `harness.md` to point at the concrete API rather than maintaining a divergent second design. +After implementation, update `harness.md` to use imported tokens rather than namespace-string/type-map examples and add the concrete assistant-frame lifecycle.