@earendil-works/pi-durable 0.0.1 → 0.86.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2031 @@
1
+ # Pico5 specification
2
+
3
+ Pico5 is a durable, extensible agent harness. This document is normative.
4
+ Pico5 uses existing package types as follows:
5
+
6
+ ```ts
7
+ import type { Context, JsonValue } from "@earendil-works/chord";
8
+ import { applyImmutable, type Op } from "@earendil-works/chord/delta";
9
+ import type {
10
+ Message,
11
+ Models,
12
+ ModelThinkingLevel,
13
+ TextContent,
14
+ Tool,
15
+ ToolReference,
16
+ ToolResultMessage,
17
+ UserMessage,
18
+ } from "@earendil-works/pi-ai";
19
+
20
+ type JsonObject = { [key: string]: JsonValue };
21
+ type StoredError = { message: string; detail?: JsonValue };
22
+ ```
23
+
24
+ Pico5 targets the transcript `SystemMessage` contract from pi-ai PR
25
+ [#9548](https://github.com/earendil-works/pi/pull/9548). `Message` includes that
26
+ type once the PR lands.
27
+
28
+ The core rule is:
29
+
30
+ > A Session atomically commits immutable entries, full task records, and
31
+ > Chord-tracked documents. Only committed state is observable.
32
+
33
+ ## 1. Terms and invariants
34
+
35
+ - A **Session** owns one mutation line, conversations, entries, tasks, inputs,
36
+ and documents.
37
+ - A **conversation** is a transcript scope. It may fork another conversation.
38
+ - An **entry** is an immutable transcript record.
39
+ - A **task** is a durable state machine attached to one conversation.
40
+ - A **document** is mutable JSON state represented by Chord operations and
41
+ occasional complete bases.
42
+ - A **definition** is a typed token describing one document or document family.
43
+ - A **source** exposes committed document changes to Chord without exposing a
44
+ mutable object.
45
+
46
+ Required invariants:
47
+
48
+ 1. One Session commit is atomic across all record and document writes.
49
+ 2. A document update is published only after its storage commit succeeds.
50
+ 3. All visible progress is durable. There is no volatile publication path.
51
+ 4. External effects do not run inside the Session mutation transaction.
52
+ 5. Entries and IDs are immutable and never reused after a committed write.
53
+ 6. Document drafts and assigned mutable objects must not escape their
54
+ transaction. This restriction is initially documented, not enforced by a
55
+ membrane.
56
+ 7. The mutation line remains held through storage settlement and committed-state
57
+ adoption. Listener callbacks run later, off the line.
58
+ 8. A failure after document flush, including checkpoint or storage failure, is
59
+ fatal to the open Session. It publishes nothing and must be reopened.
60
+
61
+ ## 2. Core records
62
+
63
+ The concrete JSON representations may add bookkeeping fields, but must preserve
64
+ these contracts.
65
+
66
+ ```ts
67
+ type Id = number;
68
+ type Seq = number;
69
+ const ROOT_CONVERSATION_ID: Id = 1;
70
+
71
+ type ConversationRecord = {
72
+ readonly id: Id;
73
+ readonly parent?: {
74
+ readonly conversationId: Id;
75
+ readonly at: Id;
76
+ };
77
+ readonly owner?: {
78
+ readonly conversationId: Id;
79
+ readonly taskId: Id;
80
+ };
81
+ };
82
+ ```
83
+
84
+ The referenced pi-ai member is:
85
+
86
+ ```ts
87
+ interface SystemMessage {
88
+ role: "system";
89
+ content: string | TextContent[];
90
+ sections?: Record<string, string | null>;
91
+ toolsAdded?: Tool[];
92
+ toolsRemoved?: ToolReference[];
93
+ timestamp: number;
94
+ }
95
+ ```
96
+
97
+ `content` is the base prompt on the leading message and additional instruction
98
+ text on later messages. `sections` is an ordered named patch: a string adds or
99
+ replaces a section, while `null` removes it. `toolsRemoved` is applied before
100
+ `toolsAdded` within one message. Replaying every system message in transcript
101
+ order yields the effective prompt and tool set.
102
+
103
+ ```ts
104
+ type ContextEdit = {
105
+ readonly target: Id;
106
+ } & (
107
+ | { readonly action: "omit"; readonly messages?: never }
108
+ | { readonly action: "replace"; readonly messages: readonly Message[] }
109
+ );
110
+
111
+ type EntryRecord = {
112
+ readonly id: Id;
113
+ readonly conversationId: Id;
114
+ readonly kind: string;
115
+ readonly model?: readonly Message[];
116
+ readonly data?: JsonValue;
117
+ readonly head?: Id;
118
+ readonly edits?: readonly ContextEdit[];
119
+ readonly byTaskId?: Id;
120
+ };
121
+
122
+ type EntryDraft = Omit<EntryRecord, "id" | "conversationId" | "byTaskId" | "head"> & {
123
+ readonly head?: Id | "self";
124
+ };
125
+
126
+ type InputBase = {
127
+ readonly id: Id;
128
+ readonly conversationId: Id;
129
+ readonly requestId?: string;
130
+ };
131
+
132
+ type Input = InputBase & (
133
+ | { readonly status: "queued" }
134
+ | { readonly status: "placed"; readonly entry: Id }
135
+ | {
136
+ readonly status: "done";
137
+ readonly entry: Id;
138
+ readonly answer?: Id; // absent for a completed passive write
139
+ }
140
+ | {
141
+ readonly status: "unanswered";
142
+ readonly entry?: Id; // present when placement preceded failure
143
+ readonly reason: string;
144
+ readonly detail?: JsonValue;
145
+ }
146
+ );
147
+
148
+ type InputCreate = Input extends infer Record
149
+ ? Record extends Input
150
+ ? Omit<Record, "id">
151
+ : never
152
+ : never;
153
+ ```
154
+
155
+ Conversation history parenting and task ownership are separate:
156
+
157
+ - `parent` controls inherited entries and historical documents.
158
+ - `owner` controls task authorization, subtree abort, and subtree idle waits.
159
+
160
+ A conversation's owner remains recorded after the owning task becomes terminal.
161
+
162
+ ### 2.1 Entries and context
163
+
164
+ Entry IDs are Session-global and ordered. `parent.at` is an entry in the parent
165
+ history visible to the child.
166
+
167
+ The active transcript is the raw entry range from the newest applicable `head`
168
+ through the tail. A head on an entry changes subsequent context; it does not
169
+ remove older entries from storage. Fork traversal is child entries followed by
170
+ parent entries through each `parent.at` cap.
171
+
172
+ Context derivation:
173
+
174
+ 1. Find the newest visible entry `H` at or before the cutoff that has `head`.
175
+ 2. Let `from = H.head`, or transcript start when `H` is absent.
176
+ 3. Scan visible entries from `from` through the cutoff.
177
+ 4. For each target, the newest edit in that range wins. `omit` contributes no
178
+ model messages; `replace` contributes its `messages` instead of the target's.
179
+ 5. If `H` exists, context entries are `H` followed by non-head entries in the
180
+ range. Otherwise they are the range.
181
+ 6. Keep every positional system message and its tool/section changes.
182
+ 7. Order tool results by assistant tool-call order.
183
+ 8. Synthesize missing tool results after a fork when required by the provider
184
+ message protocol.
185
+ 9. Exclude model-less entries and assistant messages with `aborted`, `error`, or
186
+ `deferred` stop reasons from future provider requests.
187
+
188
+ Views carry raw active entries. UI reduction and model-context reduction are
189
+ separate consumers. Older stored history is available through the owning
190
+ `Conversation` object's cursor-based `entries()` scan.
191
+
192
+ ### 2.2 Public Harness surface
193
+
194
+ This is the v1 host-facing API. Pico5 is not implemented yet, but implementations
195
+ must expose this shape rather than inventing a different facade during package
196
+ 24.
197
+
198
+ ```ts
199
+ type ModelRef = {
200
+ readonly provider: string;
201
+ readonly modelId: string;
202
+ };
203
+
204
+ type UserInput = UserMessage["content"];
205
+
206
+ type SendInput = {
207
+ readonly content: UserInput;
208
+ readonly requestId?: string;
209
+ readonly whenBusy?: "steer" | "followUp" | "reject";
210
+ };
211
+
212
+ type SectionSeed =
213
+ | { readonly key: string; readonly value: JsonValue }
214
+ | { readonly key: string; readonly remove: true };
215
+
216
+ type ConversationSpec = {
217
+ readonly parent?: { readonly conversationId: Id; readonly at: Id };
218
+ readonly model?: ModelRef;
219
+ readonly sections?: readonly SectionSeed[];
220
+ readonly activeTools?: readonly string[];
221
+ };
222
+
223
+ type AnyTask = {
224
+ readonly definition: {
225
+ readonly name: string;
226
+ readonly version: number;
227
+ readonly initial: unknown;
228
+ readonly phases: Readonly<Record<string, unknown>>;
229
+ readonly abort: unknown;
230
+ readonly migrate?: unknown;
231
+ readonly hooks?: object;
232
+ };
233
+ };
234
+
235
+ type HarnessOptions = {
236
+ readonly models: Models; // the pi-ai Models interface
237
+ readonly tools?: readonly ToolRegistration[];
238
+ readonly taskKinds?: readonly AnyTask[];
239
+ readonly sections?: readonly SystemSection<JsonValue>[];
240
+ readonly now?: () => number;
241
+ readonly root?: Omit<ConversationSpec, "parent">;
242
+ readonly onReport?: (error: unknown) => void;
243
+ };
244
+
245
+ interface Entry<E extends EntryRecord = EntryRecord> {
246
+ readonly kind: string;
247
+ is(entry: EntryRecord | undefined): entry is E;
248
+ }
249
+
250
+ function defineEntry<E extends EntryRecord>(kind: string): Entry<E>;
251
+
252
+ type ContextView = {
253
+ readonly head: EntryRecord | undefined;
254
+ readonly entries: readonly EntryRecord[];
255
+ readonly messages: readonly Message[];
256
+ };
257
+
258
+ type SettledInput = Input & {
259
+ readonly status: "done" | "unanswered";
260
+ };
261
+
262
+ interface InputHandle {
263
+ readonly id: Id;
264
+ result(context: Context): Promise<Input | undefined>;
265
+ wait(context: Context): Promise<SettledInput>;
266
+ abort(
267
+ context: Context,
268
+ ): Promise<"aborted" | "already_placed" | "not_found">;
269
+ }
270
+
271
+ type SettledTask<R> = TaskRecord<JsonValue, JsonValue, R> & {
272
+ readonly state: Extract<TaskState<JsonValue, R>, { status: "terminal" }>;
273
+ };
274
+
275
+ type ConversationWatch = WatchHandle<ConversationView>;
276
+
277
+ type HooksOf<K> = K extends Task<infer _I, infer _S, infer _R, infer H>
278
+ ? H
279
+ : never;
280
+
281
+ interface Conversation {
282
+ readonly id: Id;
283
+ send(input: SendInput, context: Context): Promise<InputHandle>;
284
+ write(entry: EntryDraft, context: Context): Promise<InputHandle>;
285
+
286
+ getModel(context: Context): Promise<ModelRef | undefined>;
287
+ setModel(model: ModelRef | undefined, context: Context): Promise<void>;
288
+ getThinkingLevel(context: Context): Promise<ModelThinkingLevel>;
289
+ setThinkingLevel(level: ModelThinkingLevel, context: Context): Promise<void>;
290
+ getActiveTools(context: Context): Promise<readonly string[]>;
291
+ setActiveTools(names: readonly string[], context: Context): Promise<void>;
292
+ getSection<T extends JsonValue>(
293
+ section: SystemSection<T>,
294
+ context: Context,
295
+ ): Promise<T | undefined>;
296
+ setSection<T extends JsonValue>(
297
+ section: SystemSection<T>,
298
+ value: T | undefined,
299
+ context: Context,
300
+ ): Promise<void>;
301
+
302
+ commit<T>(
303
+ change: (tx: Tx) => T | Promise<T>,
304
+ context: Context,
305
+ ): Promise<T>;
306
+ context(context: Context): Promise<ContextView>;
307
+ entries(
308
+ query: Omit<EntryQuery, "conversationId">,
309
+ cursor: Cursor | undefined,
310
+ limit: number,
311
+ context: Context,
312
+ ): Promise<Page<EntryRecord, Cursor>>;
313
+ fork(
314
+ at: Id,
315
+ spec: Omit<ConversationSpec, "parent">,
316
+ context: Context,
317
+ ): Promise<Conversation>;
318
+ collapse(instructions: string | undefined, context: Context): Promise<Id>;
319
+ reset(handoff: string | undefined, context: Context): Promise<void>;
320
+ abort(context: Context): Promise<void>;
321
+ waitForIdle(context: Context): Promise<void>;
322
+ hooks<K extends AnyTask>(
323
+ owner: string,
324
+ task: K,
325
+ handlers: Partial<HooksOf<K>>,
326
+ options?: { readonly subtree?: boolean },
327
+ ): () => void;
328
+ watch(context: Context): Promise<ConversationWatch>;
329
+ }
330
+
331
+ interface Harness extends Session {
332
+ resume(): void;
333
+ suspend(context: Context): Promise<void>;
334
+ quiescent(): boolean;
335
+ hold(): () => void;
336
+
337
+ registerTaskKind(task: AnyTask): () => void;
338
+ registerTool(tool: ToolRegistration): () => void;
339
+ registerSection(section: SystemSection<JsonValue>): () => void;
340
+ hooks<K extends AnyTask>(
341
+ owner: string,
342
+ task: K,
343
+ handlers: Partial<HooksOf<K>>,
344
+ ): () => void;
345
+
346
+ root(context: Context): Promise<Conversation>;
347
+ onConversation(listener: (conversation: Conversation) => void): () => void;
348
+ conversation(id: Id, context: Context): Promise<Conversation | undefined>;
349
+ createConversation(
350
+ spec: ConversationSpec & { readonly input?: UserInput },
351
+ context: Context,
352
+ ): Promise<Conversation>;
353
+
354
+ getTask(id: Id, context: Context): Promise<TaskRecord<JsonValue, JsonValue, JsonValue> | undefined>;
355
+ abortInput(
356
+ id: Id,
357
+ context: Context,
358
+ conversationId?: Id,
359
+ ): Promise<"aborted" | "already_placed" | "not_found">;
360
+ abortTask(id: Id, context: Context): Promise<"marked" | "terminal">;
361
+ markTask(id: Id, context: Context): Promise<"marked" | "terminal">;
362
+ waitForTask<R>(ref: TaskRef<R>, context: Context): Promise<SettledTask<R>>;
363
+ waitForIdle(context: Context): Promise<void>;
364
+ }
365
+
366
+ declare const Harness: {
367
+ open(
368
+ storage: Storage,
369
+ options: HarnessOptions,
370
+ context: Context,
371
+ ): Promise<Harness>;
372
+ };
373
+ ```
374
+
375
+ This intentionally retains the useful Pico3 host shape. It removes Pico3's
376
+ fixed `rewindable()`/`sticky()` accessors, namespace router, semantic view events,
377
+ and manual Chord view bridge. Typed Pico5 documents and the structural
378
+ conversation watch replace those surfaces. `write()` now returns an
379
+ `InputHandle`, because a busy passive write may remain queued before it receives
380
+ an entry ID.
381
+
382
+ `Harness.open()` installs built-in task, tool, and section definitions followed
383
+ by supplied task kinds, tools, and sections. It changes
384
+ surviving `running` tasks to `pending`, migrates task records, and settles unknown
385
+ or unmigratable live task kinds as `orphaned` before resolving. Any built-in
386
+ document touched by that recovery migrates through its ordinary typed access
387
+ path. Open does not scan or migrate other documents. IDs and names in registries must be unique. No
388
+ handler dispatches during open. Dynamic registration is available after open and
389
+ does not resurrect a task already settled by that pass.
390
+
391
+ The root conversation always has reserved ID `ROOT_CONVERSATION_ID` (`1`). Empty
392
+ storage creates that conversation from `options.root`; reopen looks it up by the
393
+ reserved ID. `options.root` never overwrites existing state. `root()` returns
394
+ that handle. A conversation with no configured model produces a durable
395
+ `no_model` generation failure.
396
+
397
+ `resume()` is idempotent while running and only enables scheduling. It does not
398
+ repeat open-time reconciliation. `suspend()` is terminal for that Harness
399
+ instance and follows the close semantics below; `resume()` after suspend/close
400
+ rejects. `quiescent()` means no task invocation is currently executing; eligible
401
+ or delayed durable tasks may still exist. `hold()` is available only while
402
+ quiescent and pauses reservation until its idempotent release function runs.
403
+
404
+ Runtime registration rejects duplicate IDs/names and returns an idempotent
405
+ function that unregisters only that exact token. It supports declaration
406
+ movement during normal operation, not replacement of executing extension code;
407
+ section 7.4 governs code reload.
408
+
409
+ Conversation creation atomically commits the conversation, built-in
410
+ configuration and section/tool seeds, and optional input admission. For a fork,
411
+ omitted model, section, and active-tool values follow their document definition's
412
+ fork policy; provided values override those forked built-in values in the same
413
+ commit. Other documents follow their own definitions without special handling.
414
+
415
+ The built-in conversation configuration document contains the selected model,
416
+ thinking level, section values, and active tool names. Its initial thinking level
417
+ is `"off"`. It is rewindable with `fork: "asOf"`, so a child starts from the
418
+ configuration visible at its selected entry unless explicit creation seeds
419
+ override it. Conversation creation eagerly
420
+ creates it, so `getModel()`, `getThinkingLevel()`, `getActiveTools()`, and
421
+ `getSection()` return detached committed snapshots without a get-or-create
422
+ write. Each setter performs one ordinary Session commit against that document;
423
+ `setSection(section, undefined)` removes the value. A setter does not start
424
+ generation or append a system entry. Request preparation later compares the
425
+ desired committed configuration with transcript history and appends the required
426
+ positional system baseline or delta.
427
+
428
+ Tool declarations and executable functions are process-local Harness registry
429
+ entries, supplied through `HarnessOptions.tools` or `registerTool()`. The durable
430
+ configuration document stores only active tool names. A new independent
431
+ conversation defaults to every tool registered when its creation is admitted;
432
+ `ConversationSpec.activeTools` overrides that default. A fork with no explicit
433
+ `activeTools` uses the configuration document's `asOf` value.
434
+
435
+ `registerTool()` changes only the runtime registry. It does not activate the tool
436
+ in existing conversations or write a document. `setActiveTools()` is the only
437
+ host operation that replaces one existing conversation's durable active set.
438
+ Explicit `ConversationSpec.activeTools` and `setActiveTools()` values must contain
439
+ unique names that are registered when their commit is admitted; otherwise the
440
+ whole operation rejects without a write. Inherited historical names are not
441
+ revalidated during a fork and may later be unavailable after registry movement.
442
+ Unregistering a tool does not rewrite any conversation.
443
+
444
+ If request preparation finds unavailable active names, it performs no provider
445
+ request. It atomically terminalizes the generation task as `failed` with
446
+ `detail: { code: "missing_active_tool", names }`, makes its placed inputs
447
+ `unanswered` with reason `missing_active_tool`, clears matching turn control, and
448
+ appends a visible model-less diagnostic entry. It does not silently change the
449
+ durable loadout. Historical system entries remain replayable because they store
450
+ the exact declarations actually offered to prior requests.
451
+
452
+ A `Conversation.commit()` is a Session commit bound to that conversation.
453
+ `tx.createTask()` defaults `TaskOptions.conversationId` to the bound conversation.
454
+ `Conversation.entries()` binds the query to that conversation and paginates its
455
+ fork-aware stored history; callers cannot substitute another conversation ID.
456
+ Generic Session-wide document operations remain available directly on `Harness`
457
+ because `Harness extends Session`.
458
+
459
+ `fork()` requires a concrete visible parent entry and applies section 3.7.
460
+ `collapse()` returns the newly admitted background collapse task ID, not its
461
+ future summary entry. `reset()` durably admits a passive self-head reset or
462
+ handoff write and then resolves; while busy, placement follows section 6 and may
463
+ occur later. Observe its placement through the conversation watch. An idle wait
464
+ does not guarantee placement of queued passive writes.
465
+
466
+ `markTask()` only commits `abortRequested`; it neither signals nor joins an
467
+ active invocation. The scheduler notices the mark on its next drain.
468
+ `abortTask()` also signals and joins an active run before starting the abort
469
+ invocation. `Conversation.abort()` withdraws queued non-write inputs, marks
470
+ non-background tasks in the conversation and owned subtree, signals them, and
471
+ resolves only after that subtree is ordinarily idle. Passive writes and
472
+ background tasks survive. Conversation idle means no non-background live task
473
+ in that conversation or its ownership subtree; Harness idle applies the same
474
+ rule Session-wide. Pending dependency- or deadline-blocked work is still live
475
+ and therefore not idle. Cancelling an idle wait aborts only that waiter.
476
+
477
+ `onConversation()` synchronously visits the currently loaded committed
478
+ conversations in ascending ID order, then reports each later creation after its
479
+ commit. Listener failures go to `onReport` and do not stop other listeners. Its
480
+ idempotent disposer removes the listener; Harness close removes all remaining
481
+ listeners.
482
+
483
+ `send()` and `write()` return after durable admission, not settlement. `send`
484
+ creates a user message with the admission timestamp. `whenBusy` defaults to
485
+ `followUp`. `write()` uses the ordered passive path in section 6 and never starts
486
+ generation. A completed passive write identifies its entry in `Input.entry`; a
487
+ completed send identifies the answer in `Input.answer`. Cancelling `wait()` only
488
+ cancels that wait. It does not withdraw or abort the input; `InputHandle.abort()`
489
+ is the explicit queued-input withdrawal operation.
490
+
491
+ `abortTask()` durably requests cancellation and returns `marked` after the mark
492
+ is committed, any active run invocation has joined, and an abort invocation has
493
+ been scheduled; it does not await terminal settlement. `waitForTask()` observes
494
+ the terminal receipt. Aborting an already terminal task returns `terminal`; an
495
+ unknown ID rejects. Explicit task abort includes a background task. Cancelling a
496
+ task or idle wait does not abort work.
497
+
498
+ `Conversation.watch()` atomically captures an immutable structural view and
499
+ registers for later complete Session commits. Its `WatchHandle` uses the same
500
+ serialized asynchronous update and reset-compaction contract as `watchDoc()` in
501
+ section 9.2. It carries no semantic events and owns no second tracker.
502
+
503
+ `close()` is equivalent to `suspend()` for v1. It seals mutation admission and
504
+ task reservation, signals invocations, and stops watches. Outside the Session
505
+ line it lets already-admitted storage commits settle, joins task/tool/hook
506
+ invocations and in-flight watch callbacks, then closes sources and storage. It
507
+ writes no task outcome. Handles belong to that open Harness and must be
508
+ reacquired after reopen. A non-cooperative watch callback can delay graceful
509
+ close just like a non-cooperative task invocation.
510
+
511
+ ## 3. Documents
512
+
513
+ ### 3.1 Definitions
514
+
515
+ Scope directly determines document ownership and lifetime. Only conversation
516
+ documents declare history and fork behavior.
517
+
518
+ ```ts
519
+ type LatestConversationSemantics = {
520
+ readonly scope: "conversation";
521
+ readonly history: "latest";
522
+ readonly fork: "current" | "initial";
523
+ };
524
+
525
+ type RewindableConversationSemantics = {
526
+ readonly scope: "conversation";
527
+ readonly history: "rewindable";
528
+ readonly fork: "asOf" | "current" | "initial";
529
+ };
530
+
531
+ type DocumentSemantics =
532
+ | { readonly scope: "session" }
533
+ | LatestConversationSemantics
534
+ | RewindableConversationSemantics
535
+ | { readonly scope: "task" };
536
+
537
+ type CommonDocDefinition<T extends JsonObject> = {
538
+ readonly kind: string;
539
+ readonly version: number;
540
+ initial(): T;
541
+ migrate?(value: JsonObject, fromVersion: number): T;
542
+ checkpointWhen?(value: Readonly<T>, ops: readonly Op[]): boolean;
543
+ };
544
+
545
+ type DocDefinition<T extends JsonObject> =
546
+ CommonDocDefinition<T> & DocumentSemantics;
547
+
548
+ type DocFamilyDefinition<T extends JsonObject, I extends JsonValue> =
549
+ Omit<CommonDocDefinition<T>, "initial"> & DocumentSemantics & {
550
+ readonly family: true;
551
+ initial(input: I): T;
552
+ };
553
+
554
+ function defineDoc<T extends JsonObject>(
555
+ definition: CommonDocDefinition<T> & RewindableConversationSemantics,
556
+ ): RewindableDocToken<T>;
557
+ function defineDoc<T extends JsonObject>(definition: DocDefinition<T>): DocToken<T>;
558
+
559
+ function defineDocFamily<T extends JsonObject, I extends JsonValue>(
560
+ definition: Omit<CommonDocDefinition<T>, "initial"> &
561
+ RewindableConversationSemantics & {
562
+ readonly family: true;
563
+ initial(input: I): T;
564
+ },
565
+ ): RewindableDocFamilyToken<T, I>;
566
+ function defineDocFamily<T extends JsonObject, I extends JsonValue>(
567
+ definition: DocFamilyDefinition<T, I>,
568
+ ): DocFamilyToken<T, I>;
569
+
570
+ declare const docType: unique symbol;
571
+ interface DocToken<T extends JsonObject> {
572
+ readonly definition: DocDefinition<T>;
573
+ readonly [docType]?: T;
574
+ }
575
+ interface RewindableDocToken<T extends JsonObject> extends DocToken<T> {
576
+ readonly definition: CommonDocDefinition<T> & RewindableConversationSemantics;
577
+ }
578
+ interface DocFamilyToken<T extends JsonObject, I extends JsonValue> {
579
+ readonly definition: DocFamilyDefinition<T, I>;
580
+ readonly [docType]?: T;
581
+ }
582
+ interface RewindableDocFamilyToken<T extends JsonObject, I extends JsonValue>
583
+ extends DocFamilyToken<T, I> {
584
+ readonly definition: Omit<CommonDocDefinition<T>, "initial"> &
585
+ RewindableConversationSemantics & {
586
+ readonly family: true;
587
+ initial(input: I): T;
588
+ };
589
+ }
590
+ ```
591
+
592
+ Validation rules:
593
+
594
+ - Versions are positive integers.
595
+ - Typed access rejects when the token's scope or conversation history/fork policy
596
+ disagrees with the persisted incarnation. A migration cannot reinterpret those
597
+ lifetime semantics.
598
+ - Session documents are current-only and belong to the Session. Closing and
599
+ reopening the Session does not retire them.
600
+ - Conversation documents declare `history` and `fork`; `fork: "asOf"` requires
601
+ `history: "rewindable"`.
602
+ - Task documents are current-only, are never copied by a conversation fork, and
603
+ retire atomically when their task becomes terminal.
604
+ - `initial()` and `migrate()` return JSON objects.
605
+
606
+ `checkpointWhen()` only selects complete storage bases to bound replay. It does
607
+ not change scope, lifetime, history, or fork semantics.
608
+
609
+ Concrete built-in document grouping and semantics are declared when the built-in
610
+ definitions are implemented. The generic document mechanism does not special
611
+ case model, tool, inbox, or presentation state.
612
+
613
+ ### 3.2 Records and lifetimes
614
+
615
+ A persisted document instance has one lifecycle record:
616
+
617
+ ```ts
618
+ type DocumentRecord = {
619
+ readonly id: Id; // unique incarnation
620
+ readonly kind: string; // stable definition kind
621
+ readonly key?: string; // families only
622
+ readonly createdAt: Seq; // stamped by the committing storage
623
+ readonly retiredAt?: Seq;
624
+ } & (
625
+ | { readonly scope: { readonly kind: "session" } }
626
+ | ({ readonly scope: { readonly kind: "conversation"; readonly conversationId: Id } } & (
627
+ | { readonly history: "latest"; readonly fork: "current" | "initial" }
628
+ | {
629
+ readonly history: "rewindable";
630
+ readonly fork: "asOf" | "current" | "initial";
631
+ }
632
+ ))
633
+ | { readonly scope: { readonly kind: "task"; readonly taskId: Id } }
634
+ );
635
+
636
+ type DocumentCreate = DocumentRecord extends infer Record
637
+ ? Record extends DocumentRecord
638
+ ? Omit<Record, "createdAt" | "retiredAt">
639
+ : never
640
+ : never;
641
+ ```
642
+
643
+ `id` is never reused. Retiring and recreating the same logical kind, scope, and
644
+ family key creates a new incarnation. Membership is the half-open interval
645
+ `createdAt <= at < retiredAt`; an unretired incarnation has no upper bound. A
646
+ creation retired in the same commit has an empty lifetime.
647
+
648
+ A singleton is identified logically by kind and scope. A family is identified
649
+ logically by kind, scope, and `key`. The record preserves scope and conversation
650
+ history/fork semantics so unavailable extension code does not make existing data
651
+ disappear. Definition versions belong to stored bases and deltas because one
652
+ incarnation may contain records written by multiple definition versions.
653
+ `DocumentCreate` is not another persisted record; it is the same scoped union
654
+ without storage-assigned lifetime fields.
655
+
656
+ ### 3.3 Access and creation
657
+
658
+ There is no mutable `session.document()` API.
659
+
660
+ ```ts
661
+ interface Session extends DocumentObserver {
662
+ commit<T>(
663
+ change: (tx: Tx) => T | Promise<T>,
664
+ context: Context,
665
+ ): Promise<T>;
666
+
667
+ close(context: Context): Promise<void>;
668
+
669
+ snapshot<T extends JsonObject>(
670
+ token: DocToken<T>,
671
+ target: DocTarget,
672
+ context: Context,
673
+ ): Promise<Readonly<T>>;
674
+
675
+ snapshot<T extends JsonObject, I extends JsonValue>(
676
+ token: DocFamilyToken<T, I>,
677
+ target: FamilyTarget<I>,
678
+ context: Context,
679
+ ): Promise<Readonly<T>>;
680
+
681
+ snapshotAsOf<T extends JsonObject>(
682
+ token: RewindableDocToken<T>,
683
+ conversationId: Id,
684
+ at: Id,
685
+ context: Context,
686
+ ): Promise<Readonly<T> | undefined>;
687
+
688
+ snapshotAsOf<T extends JsonObject, I extends JsonValue>(
689
+ token: RewindableDocFamilyToken<T, I>,
690
+ target: HistoricalFamilyTarget,
691
+ at: Id,
692
+ context: Context,
693
+ ): Promise<Readonly<T> | undefined>;
694
+
695
+ documentSource<T extends JsonObject>(
696
+ token: DocToken<T>,
697
+ target: DocTarget,
698
+ context: Context,
699
+ ): Promise<DocumentSource<T>>;
700
+
701
+ documentSource<T extends JsonObject, I extends JsonValue>(
702
+ token: DocFamilyToken<T, I>,
703
+ target: FamilyTarget<I>,
704
+ context: Context,
705
+ ): Promise<DocumentSource<T>>;
706
+ }
707
+
708
+ interface Tx {
709
+ conversation(id: Id): Promise<ConversationRecord | undefined>;
710
+ entry(id: Id): Promise<EntryRecord | undefined>;
711
+ input(id: Id): Promise<Input | undefined>;
712
+ task(id: Id): Promise<TaskRecord<JsonValue, JsonValue, JsonValue> | undefined>;
713
+ scanEntries(query: EntryQuery): Promise<readonly EntryRecord[]>;
714
+ scanTasks(query: TaskQuery): Promise<readonly TaskRecord<JsonValue, JsonValue, JsonValue>[]>;
715
+
716
+ createConversation(value: Omit<ConversationRecord, "id">): ConversationRecord;
717
+ appendEntry(conversationId: Id, value: EntryDraft): EntryRecord;
718
+ createInput(value: InputCreate): Input;
719
+ setInput(value: Input): void;
720
+ createTask<I, S extends { phase: string }, R, H extends object>(
721
+ task: Task<I, S, R, H>, input: I, options?: TaskOptions,
722
+ ): TaskRef<R>;
723
+ setTask(value: TaskRecord<JsonValue, JsonValue, JsonValue>): void;
724
+
725
+ doc<T extends JsonObject>(token: DocToken<T>, target: DocTarget): Promise<T>;
726
+ doc<T extends JsonObject, I extends JsonValue>(token: DocFamilyToken<T, I>, target: FamilyTarget<I>): Promise<T>;
727
+ retireDoc<T extends JsonObject>(token: DocToken<T>, target: DocTarget): void;
728
+ retireDoc<T extends JsonObject, I extends JsonValue>(token: DocFamilyToken<T, I>, target: FamilyKey): void;
729
+ }
730
+
731
+ type DocTarget =
732
+ | { readonly scope: "session" }
733
+ | { readonly scope: "conversation"; readonly conversationId: Id }
734
+ | { readonly scope: "task"; readonly taskId: Id };
735
+
736
+ type FamilyKey = DocTarget & { readonly key: string };
737
+
738
+ type FamilyTarget<I> = FamilyKey & { readonly initial: I };
739
+
740
+ type HistoricalFamilyTarget = {
741
+ readonly scope: "conversation";
742
+ readonly conversationId: Id;
743
+ readonly key: string;
744
+ };
745
+ ```
746
+
747
+ Every normal access is get-or-create and receives the definition token that
748
+ supplies its static type, initializer, migration, and checkpoint policy. Ordinary
749
+ document definitions are not registered and ordinary documents are not scanned
750
+ at open.
751
+
752
+ - Existing instances are reconstructed and migrated lazily according to section
753
+ 3.6. Standalone access may commit a required current-only migration before its
754
+ method returns. `tx.doc()` instead stages that base in its enclosing transaction.
755
+ - Missing instances are created in a serialized commit with an initial base.
756
+ - Concurrent acquisition initializes once.
757
+ - The target scope must match the token's declared scope.
758
+ - Task-scoped access validates against the transaction's latest candidate task
759
+ record, falling back to committed state. This internal validation is not a
760
+ caller table read and does not trigger `ReadAfterWrite`. Its conversation is
761
+ derived from the task record rather than repeated in the target.
762
+ - A terminal candidate rejects later task-document access. Terminal settlement
763
+ retires both existing task documents and task documents created earlier in the
764
+ same transaction.
765
+ - `snapshot()` returns a detached JSON copy.
766
+ - `documentSource()` returns an opaque committed source.
767
+ - `tx.doc()` returns the transaction's mutable tracked draft.
768
+ - Historical reads never create documents in the past.
769
+
770
+ A family initializer input matters only on first creation. Later accesses must
771
+ supply the same logical target; their `initial` value is ignored.
772
+
773
+ `snapshotAsOf()` is available only for rewindable conversation documents. It
774
+ validates that `at` is visible through the requested conversation's ancestry,
775
+ selects the ancestor conversation that owns that entry, then finds the logical
776
+ singleton/family incarnation whose creation/retirement interval contains the
777
+ entry's commit. It never starts from today's incarnation and never creates an
778
+ instance. It returns `undefined` when no such instance existed.
779
+
780
+ After `B` forks `A` at entry `E`, asking for `B`'s state at inherited `E` reads
781
+ `A`'s historical instance; `B`'s copied incarnation was created later. If an
782
+ instance was retired and recreated, historical lookup selects the incarnation
783
+ alive at the target commit.
784
+
785
+ ### 3.4 Mutation ownership
786
+
787
+ Chord mutates the tracked working object immediately while retaining the
788
+ previous baseline until flush.
789
+
790
+ Transaction behavior:
791
+
792
+ ```text
793
+ begin transaction
794
+ acquire document drafts
795
+ mutate ordinary JSON
796
+ callback fails
797
+ restore each changed tracker from its unchanged baseline
798
+ callback succeeds
799
+ flush each changed tracker -> incremental ops + candidate value
800
+ Session evaluates each ordinary mutation's checkpoint predicate exactly once
801
+ and gives Storage only the selected base or delta representation
802
+ Storage.commit persists it while the Session line remains held
803
+ storage succeeds
804
+ materialize each changed immutable published value with applyImmutable(previous, ops)
805
+ adopt committed baselines and enqueue value/ops publication while holding the line
806
+ release the line; invoke listeners later
807
+ post-flush or storage failure
808
+ poison Session; publish nothing; close/reopen required
809
+ ```
810
+
811
+ The immutable published value is separate from the tracker's mutable working
812
+ object and is created once per changed document, not once per watch. Initial
813
+ publication similarly applies the complete base operation to `undefined`.
814
+ Storage's detached retained copy does not satisfy this Session-side ownership
815
+ requirement.
816
+
817
+ No defensive document copy is required solely for storage failure because the
818
+ open Session cannot continue after that failure.
819
+
820
+ Unsupported:
821
+
822
+ ```ts
823
+ let escaped: LiveState;
824
+ await session.commit(async tx => {
825
+ escaped = await tx.doc(LiveDoc, { scope: "conversation", conversationId });
826
+ });
827
+ escaped.message = message; // unsupported
828
+ ```
829
+
830
+ The same rule applies to nested proxies and mutable objects assigned into a
831
+ document.
832
+
833
+ ### 3.5 Bases and checkpoints
834
+
835
+ Creation always stores a complete base.
836
+
837
+ For an ordinary later mutation, the definition alone decides whether the
838
+ storage record is a base:
839
+
840
+ ```ts
841
+ const useBase = definition.checkpointWhen?.(candidateValue, ops) ?? false;
842
+ ```
843
+
844
+ The Session evaluates this predicate exactly once after tracker flush. Creation
845
+ and version transitions require bases and do not call it. The Session then gives
846
+ Storage only the selected representation:
847
+
848
+ ```text
849
+ required or predicate true -> base with complete value
850
+ otherwise -> delta with incremental ops
851
+ ```
852
+
853
+ A predicate failure occurs after flush and therefore poisons the open Session as
854
+ described in section 1. Storage executes no definition code and never receives
855
+ an unused complete candidate with a selected delta.
856
+
857
+ - For Session, task, and latest conversation documents, a committed base permits
858
+ physical reclamation of older records.
859
+ - For rewindable conversation documents, bases bound replay but never permit
860
+ removal of addressable history.
861
+ - A definition that never checkpoints may create an unbounded replay tail. That
862
+ is a definition bug, not a backend heuristic.
863
+ - Storage does not count encoded bytes, compare against `initial()`, or invent
864
+ checkpoints.
865
+
866
+ A high-churn live document can checkpoint when it becomes empty:
867
+
868
+ ```ts
869
+ checkpointWhen: (value, _ops) =>
870
+ value.message === undefined &&
871
+ value.tools.length === 0
872
+ ```
873
+
874
+ ### 3.6 Versions and migrations
875
+
876
+ One migration callback handles every supported older version.
877
+
878
+ ```text
879
+ stored == token -> use value
880
+ stored < token -> call migrate(value, storedVersion)
881
+ stored > token -> reject typed access
882
+ no migrate -> reject older stored version
883
+ ```
884
+
885
+ `migrate()` is pure and returns a complete current-version value. Migration is
886
+ access-driven: `tx.doc()`, `snapshot()`, `snapshotAsOf()`, `documentSource()`, and
887
+ `watchDoc()` reconstruct and migrate through the token supplied to that call.
888
+ Harness open does not sweep ordinary documents.
889
+
890
+ - A standalone current-only acquisition persists the migrated value as a required
891
+ current-version base before its method returns. `tx.doc()` stages the migration
892
+ in the enclosing transaction; callback failure persists nothing, and later
893
+ draft edits coalesce into one final required base.
894
+ - Rewindable history is not rewritten. Current and historical reconstructed
895
+ values are migrated after replay.
896
+ - The first mutation of a migrated rewindable value stores a required
897
+ current-version base before subsequent current-version deltas.
898
+ - A fork copies the selected stored value and version. Typed access in the child
899
+ migrates it later when necessary.
900
+ - Unaccessed documents and documents with unavailable definitions preserve their stored instances,
901
+ versions, and bytes.
902
+
903
+ ### 3.7 Forks
904
+
905
+ A conversation fork points to one concrete visible entry `E`.
906
+
907
+ The child transcript includes entries through `E`, even if the same commit also
908
+ appended later entries. Document state at `E` is the final state of the commit
909
+ containing `E`. Different document states require separate commits.
910
+
911
+ Each conversation document follows the history/fork policy persisted in its
912
+ lifecycle record:
913
+
914
+ | conversation setting | child value |
915
+ |---|---|
916
+ | `fork: "asOf"` | parent value at `E`'s commit |
917
+ | `fork: "current"` | parent value when the fork commit runs |
918
+ | `fork: "initial"` | no copied instance; initializer on first child access |
919
+
920
+ `current` and `asOf` copy logically present conversation singleton and family
921
+ instances, preserving unknown definitions and their stored versions. Copied
922
+ values become independent child instances with new IDs and initial bases.
923
+ `initial` copies no instance; first access in the child creates it from the
924
+ supplied definition. Task documents and tasks are never copied. Session documents
925
+ remain shared and are not rewindable.
926
+
927
+ ## 4. Transactions and storage ownership
928
+
929
+ A Session commit callback may be asynchronous. It owns the Session mutation
930
+ line through callback execution, preparation, storage settlement, committed
931
+ baseline adoption, and publication enqueue. Listener callbacks run later.
932
+ External model, process, tool, network, and human effects run outside it.
933
+
934
+ ```ts
935
+ await session.commit(async tx => {
936
+ const input = await tx.input(inputId); // table read
937
+ const live = await tx.doc(LiveDoc, { scope: "conversation", conversationId });
938
+
939
+ const entry = tx.appendEntry(conversationId, message); // first table write
940
+ live.message = undefined; // document mutation remains valid
941
+ tx.setInput({ ...input, status: "done", answer: entry.id });
942
+ }, context);
943
+ ```
944
+
945
+ Mutation admission occurs on the Session line before a commit callback or
946
+ get-or-create acquisition starts. Closing seals mutation admission and task
947
+ reservation. Already-admitted commits settle before storage closes. Once a
948
+ commit is admitted, caller cancellation does not interrupt storage settlement or
949
+ undo the commit. Cancelling a close wait does not reopen admission.
950
+
951
+ Table rules:
952
+
953
+ - Tables are conversations, entries, tasks, and inputs.
954
+ - Table reads are allowed before the first table write.
955
+ - Any table read after the first table write throws `ReadAfterWrite`.
956
+ - Document access and read-your-writes remain available after table writes.
957
+ - Creation methods return their created ID/record; callers do not read it back.
958
+
959
+ Storage ownership:
960
+
961
+ - Commit arguments are borrowed until `Storage.commit()` settles.
962
+ - Anything retained after settlement is detached first.
963
+ - Memory storage recursively copies retained JSON containers.
964
+ - JSONL and SQLite detach through serialization and decoded indexes.
965
+ - Every storage read returns a detached JSON value.
966
+ - Immutable strings may be shared; mutable arrays and objects may not.
967
+
968
+ ## 5. Tasks
969
+
970
+ ### 5.1 Definition
971
+
972
+ ```ts
973
+ type TaskOutcome<R> =
974
+ | { readonly status: "completed"; readonly result: R }
975
+ | { readonly status: "failed"; readonly error: StoredError; readonly result?: R }
976
+ | { readonly status: "aborted"; readonly reason?: string; readonly result?: R }
977
+ | { readonly status: "orphaned"; readonly reason: string }
978
+ | { readonly status: "faulted"; readonly error: StoredError };
979
+
980
+ type TaskState<S, R> =
981
+ | { readonly status: "pending"; readonly checkpoint: S }
982
+ | { readonly status: "running"; readonly checkpoint: S }
983
+ | { readonly status: "terminal"; readonly outcome: TaskOutcome<R> };
984
+
985
+ type TaskRecord<I, S, R> = {
986
+ readonly id: Id;
987
+ readonly conversationId: Id;
988
+ readonly kind: string;
989
+ readonly version: number;
990
+ readonly input: I;
991
+ readonly after: readonly Id[];
992
+ readonly background: boolean;
993
+ readonly abortRequested: boolean;
994
+ } & (
995
+ | {
996
+ readonly state: Extract<TaskState<S, R>, { status: "pending" | "running" }>;
997
+ readonly memos?: Readonly<Record<string, JsonValue>>;
998
+ }
999
+ | {
1000
+ readonly state: Extract<TaskState<S, R>, { status: "terminal" }>;
1001
+ readonly memos?: never;
1002
+ }
1003
+ );
1004
+
1005
+ type RunningTask<I, S, R> = TaskRecord<I, S, R> & {
1006
+ readonly state: Extract<TaskState<S, R>, { status: "running" }>;
1007
+ };
1008
+
1009
+ interface HookRunner<H extends object> {
1010
+ each<K extends keyof H>(name: K, invoke: (handler: H[K]) => void | Promise<void>): Promise<void>;
1011
+ }
1012
+
1013
+ type PhaseHandler<I, P, S, R, H extends object> = (
1014
+ task: RunningTask<I, P, R>,
1015
+ runtime: TaskRuntime<I, S, R, H>,
1016
+ context: Context,
1017
+ ) => Promise<void>;
1018
+
1019
+ interface TaskRuntime<I, S, R, H extends object> extends DocumentObserver {
1020
+ readonly taskId: Id;
1021
+ readonly conversationId: Id;
1022
+ readonly signal: AbortSignal;
1023
+ readonly hooks: HookRunner<H>;
1024
+
1025
+ commit(
1026
+ change: (tx: Tx, current: RunningTask<I, S, R>) => void | Promise<void>,
1027
+ context: Context,
1028
+ ): Promise<void>;
1029
+
1030
+ memo<T extends JsonValue>(name: string, context: Context): Promise<T | undefined>;
1031
+ memo<T extends JsonValue>(name: string, candidate: T, context: Context): Promise<T>;
1032
+ sleep(until: number, context: Context): Promise<void>;
1033
+ }
1034
+
1035
+ type TaskDefinition<I, S extends { phase: string }, R, H extends object> = {
1036
+ readonly name: string;
1037
+ readonly version: number;
1038
+ initial(input: I): S;
1039
+ readonly phases: {
1040
+ [P in S["phase"]]: PhaseHandler<I, Extract<S, { phase: P }>, S, R, H>;
1041
+ };
1042
+ abort(task: TaskRecord<I, S, R>, runtime: TaskRuntime<I, S, R, H>, context: Context): Promise<void>;
1043
+ migrate?(input: JsonValue, checkpoint: JsonValue, fromVersion: number): {
1044
+ input: I;
1045
+ checkpoint: S;
1046
+ };
1047
+ readonly hooks?: H;
1048
+ };
1049
+
1050
+ interface Task<I, S extends { phase: string }, R, H extends object> {
1051
+ readonly definition: TaskDefinition<I, S, R, H>;
1052
+ }
1053
+
1054
+ declare const taskResultType: unique symbol;
1055
+ type TaskRef<R> = { readonly id: Id; readonly [taskResultType]?: R };
1056
+ type TaskOptions = {
1057
+ readonly conversationId?: Id;
1058
+ readonly after?: readonly Id[];
1059
+ readonly background?: boolean;
1060
+ };
1061
+
1062
+ function defineTask<I, S extends { phase: string }, R, H extends object = {}>(
1063
+ definition: TaskDefinition<I, S, R, H>,
1064
+ ): Task<I, S, R, H>;
1065
+ ```
1066
+
1067
+ The phase map is exhaustive and phase-narrowed. A handler may perform several
1068
+ commits around one effect, but each durable checkpoint is a full replacement.
1069
+ `TaskRuntime.commit()` rereads and gates the current durable task on the Session
1070
+ line before invoking its callback. Transaction methods replace its checkpoint
1071
+ or write its terminal outcome.
1072
+
1073
+ Reservation durably changes `pending` to `running`. One invocation runs phase
1074
+ handlers in sequence; checkpoint commits retain `running`. After a handler
1075
+ settles, the scheduler rereads the task and applies the first matching rule:
1076
+
1077
+ 1. Terminal: stop.
1078
+ 2. Session closing: stop; preserve the checkpoint and any abort mark for reopen.
1079
+ 3. Run mode with a durable abort mark: end and join the run invocation, then
1080
+ dispatch a fresh abort invocation.
1081
+ 4. Uncaught error: write terminal `faulted`.
1082
+ 5. Checkpoint changed, including progress within the same phase: invoke its
1083
+ phase handler in the same task invocation.
1084
+ 6. Checkpoint unchanged: write terminal `faulted` because no durable progress
1085
+ was made.
1086
+
1087
+ On open, running-task reconciliation changes surviving `running` tasks back to
1088
+ `pending`, preserving their checkpoint and abort mark. Task migration then runs
1089
+ before dispatch. One callback handles every supported older version; newer or
1090
+ unmigratable live tasks become orphaned.
1091
+ `close()` marks the runtime closing, seals admission and reservation, signals
1092
+ invocations, and stops watches. Outside the Session line it settles admitted
1093
+ commits and joins invocations plus in-flight watch callbacks before closing
1094
+ storage. Later runtime commits reject, and close writes no task outcome. Closing
1095
+ starts no fresh phase or abort invocation. It does not set abort marks,
1096
+ terminalize tasks, retire task documents, or publish document retirement. The
1097
+ hosting layer withdraws services and
1098
+ detaches clients; reconnecting to a reopened Session hydrates the last committed
1099
+ state and resumes recovery from its durable checkpoints.
1100
+
1101
+ ### 5.2 Effect sandwich
1102
+
1103
+ ```text
1104
+ commit intent phase
1105
+ perform external effect
1106
+ commit outcome or next phase
1107
+ ```
1108
+
1109
+ Reopening in an intent phase means the effect may have happened. The phase
1110
+ handler retries safely, polls an external handle, or records interruption.
1111
+ Deferred providers are represented by a durable phase containing their handle
1112
+ and next poll time.
1113
+
1114
+ Runtime-owned memos are small first-writer-wins values stored in the live task
1115
+ envelope. Candidate insertion and reading the winner are one Session commit, so
1116
+ concurrent candidates return the same durable winner. Memos survive checkpoints
1117
+ and disappear in the terminal replacement. Bulk progress belongs in a document.
1118
+
1119
+ ### 5.3 Terminal tasks and dependencies
1120
+
1121
+ The terminal task record is the durable result receipt. Its result may directly
1122
+ contain a small value or reference an entry:
1123
+
1124
+ ```ts
1125
+ { status: "completed", result: { entryId: toolResultId } }
1126
+ ```
1127
+
1128
+ A terminal transition atomically:
1129
+
1130
+ 1. Writes the terminal task record.
1131
+ 2. Appends any result entries.
1132
+ 3. Retires all documents scoped to that task.
1133
+ 4. Resolves any inputs settled by the task.
1134
+
1135
+ The execution checkpoint and memos disappear from the terminal representation.
1136
+ Terminal records remain queryable for dependencies, waiters, inspection, and
1137
+ reopen. A normal run becomes eligible when every `after` task is terminal. An abort
1138
+ mark bypasses dependencies so pending work can always reach its abort handler.
1139
+
1140
+ ### 5.4 Scheduler, abort, and ownership
1141
+
1142
+ The scheduler serially reserves eligible tasks, then runs handlers off the
1143
+ Session line. One in-memory `TaskInvocation` contains mode, abort controller,
1144
+ and completion promise.
1145
+
1146
+ Abort protocol:
1147
+
1148
+ ```text
1149
+ commit abortRequested
1150
+ signal and join active run invocation
1151
+ start a fresh abort invocation
1152
+ abort handler commits terminal outcome
1153
+ ```
1154
+
1155
+ A run invocation may not commit after its durable abort mark appears. Every
1156
+ runtime operation rejects after its owning invocation ends, even while the
1157
+ Session remains open. Returning from one phase handler does not end an invocation
1158
+ that continues into another phase. Invocation mode is volatile and derived from
1159
+ the durable mark on reopen. Cancelling one caller's `Context` only cancels
1160
+ that call or wait; it does not durably abort shared work unless the invoked API
1161
+ commits an abort mark.
1162
+
1163
+ A task may create owned conversations. Abort and idle operations traverse the
1164
+ conversation ownership tree. History parents are irrelevant to this traversal.
1165
+ Background tasks do not block ordinary idle waits and survive ordinary
1166
+ conversation abort unless explicitly included.
1167
+
1168
+ Initial task definitions are registered before open performs live-task migration
1169
+ and orphan reconciliation. Dynamic registration begins only after that pass.
1170
+ Document migration remains access-driven. Unknown or unmigratable live task kinds
1171
+ become terminal `orphaned`;
1172
+ affected inputs become unanswered, any matching active turn control is cleared,
1173
+ task-scoped documents retire, and a visible notice entry is appended in one
1174
+ commit. Faulting a turn task performs the same control/input cleanup with a
1175
+ `faulted` outcome.
1176
+
1177
+ ## 6. Inputs and inbox
1178
+
1179
+ Input records back awaitable host handles. The inbox itself is an ordered
1180
+ conversation document containing tagged items:
1181
+
1182
+ ```ts
1183
+ type InboxItem =
1184
+ | { readonly id: Id; readonly mode: "steer" | "followUp"; readonly input: Message }
1185
+ | { readonly id: Id; readonly mode: "write"; readonly entry: EntryDraft };
1186
+ ```
1187
+
1188
+ A built-in turn-control document has an optional `active` value naming the task
1189
+ currently responsible for the turn and its placed input IDs. `active !==
1190
+ undefined` defines `busy`; get-or-create of the idle document does not. The
1191
+ value remains active while generation, tools, and post-tools hand work to one
1192
+ another.
1193
+
1194
+ Admission and terminal transitions:
1195
+
1196
+ | action | input state | other writes |
1197
+ |---|---|---|
1198
+ | idle `send` | `placed`, with user entry | create turn controller/generation |
1199
+ | busy `send` | `queued` | append steer/follow-up inbox item |
1200
+ | idle passive `write` | `done`, with entry | append entry; no turn |
1201
+ | busy passive `write` | `queued` | append write inbox item |
1202
+ | boundary places user item | `placed`, with entry | add ID to current/successor turn |
1203
+ | boundary places write | `done`, with entry | append entry |
1204
+ | turn answers | `done`, with answer entry | clear/hand off turn controller |
1205
+ | turn fails or aborts | `unanswered`, with reason | clear/hand off turn controller |
1206
+ | withdraw queued item | `unanswered`, reason `aborted` | remove inbox item |
1207
+ | stale item | `unanswered`, reason `stale` | remove inbox item |
1208
+
1209
+ `requestId` deduplicates within one conversation before any write. Busy send
1210
+ with `whenBusy: "reject"` writes nothing and reports `ConversationBusy`. Before
1211
+ an idle send places its own entry, it runs a final boundary to drain any older
1212
+ eligible queued items. A handle waits until `done` or `unanswered`; abort
1213
+ withdraws only a still-queued input and otherwise reports that placement already
1214
+ occurred. Conversation abort withdraws queued steer/follow-up inputs but keeps
1215
+ passive writes for later placement.
1216
+
1217
+ Boundary selection is deterministic by item ID:
1218
+
1219
+ | boundary | write | steer | follow-up |
1220
+ |---|---|---|---|
1221
+ | `postTools` | all | first/all by mode | none |
1222
+ | `final` | all | first/all by mode | first/all by mode |
1223
+
1224
+ A queued self-head write cuts older pending user items: those inputs become
1225
+ stale, the write is placed, and the current turn terminates. Other head writes
1226
+ whose target predates the caller's newest known head are stale.
1227
+
1228
+ At ordinary `postTools`, generation continues even with no queued trigger;
1229
+ selected steer IDs join that continuation. A terminating/handoff post-tools
1230
+ boundary uses final behavior instead. At `final`, the current turn's placed
1231
+ inputs settle first; selected user IDs start one successor generation. Writes
1232
+ never trigger generation by themselves. A final boundary without continuation
1233
+ or user triggers leaves the conversation idle.
1234
+
1235
+ Selected and stale items are removed positionally while retained item order is
1236
+ preserved.
1237
+ Chord must encode scattered removals without retransmitting retained payloads.
1238
+ The exact tracker optimization is implementation work; IDs are not substituted
1239
+ for positional inbox semantics.
1240
+
1241
+ ## 7. Hooks, tools, and system sections
1242
+
1243
+ ### 7.1 Hooks
1244
+
1245
+ A hook is a typed question asked by a task before it commits a decision. Hooks
1246
+ are declared by task kind and registered in registration order Session-wide or
1247
+ for a conversation and its owned subtree. They run off the line; a crash before
1248
+ the consuming commit may rerun them. Abort errors always propagate.
1249
+
1250
+ | hook | composition | ordinary throw |
1251
+ |---|---|---|
1252
+ | system instructions | all; draft changes compose; last tool override wins | roll back that handler, report, continue |
1253
+ | `beforeRequest` | replacement chain | report, continue |
1254
+ | `afterResponse` | all observers | report, continue |
1255
+ | `onYield` | first continuation wins | report, continue |
1256
+ | `beforeTool` | call replacement chain; first block wins | block tool with error text |
1257
+ | `afterTool` | result replacement chain | report, continue |
1258
+ | `afterTools` | all observers | report, continue |
1259
+ | `beforeCollapse` | first decision wins | report, continue |
1260
+
1261
+ Hooks use task memos for durable first-writer-wins decisions. There is no public
1262
+ semantic event channel; current UI status is document state.
1263
+
1264
+ ### 7.2 Tools
1265
+
1266
+ ```ts
1267
+ type ToolControl = {
1268
+ readonly addTools?: readonly string[];
1269
+ readonly terminate?: true;
1270
+ readonly handoff?: string;
1271
+ };
1272
+
1273
+ type ToolExecutionResult = {
1274
+ readonly content?: ToolResultMessage["content"];
1275
+ readonly isError?: boolean;
1276
+ readonly details?: JsonValue;
1277
+ readonly control?: ToolControl;
1278
+ };
1279
+
1280
+ interface OwnedConversation {
1281
+ readonly id: Id;
1282
+ send(input: SendInput, context: Context): Promise<InputHandle>;
1283
+ abort(context: Context): Promise<void>;
1284
+ waitForIdle(context: Context): Promise<void>;
1285
+ }
1286
+
1287
+ interface ToolExecutionApi extends DocumentObserver {
1288
+ readonly taskId: Id;
1289
+ readonly conversationId: Id;
1290
+ readonly callId: string;
1291
+ stream(chunk: string | Uint8Array): void;
1292
+ commit<T>(change: (tx: Tx) => T | Promise<T>, context: Context): Promise<T>;
1293
+ progress(value: JsonObject, context: Context): Promise<void>;
1294
+ memo<T extends JsonValue>(name: string, context: Context): Promise<T | undefined>;
1295
+ memo<T extends JsonValue>(name: string, candidate: T, context: Context): Promise<T>;
1296
+ createTask<I, S extends { phase: string }, R, H extends object>(
1297
+ task: Task<I, S, R, H>,
1298
+ input: I,
1299
+ options: Omit<TaskOptions, "conversationId">,
1300
+ context: Context,
1301
+ ): Promise<TaskRef<R>>;
1302
+ getTask<R>(ref: TaskRef<R>, context: Context): Promise<TaskRecord<JsonValue, JsonValue, R> | undefined>;
1303
+ waitForTask<R>(ref: TaskRef<R>, context: Context): Promise<SettledTask<R>>;
1304
+ createConversation(
1305
+ spec: Omit<ConversationSpec, "parent"> & { readonly inherit?: boolean },
1306
+ context: Context,
1307
+ ): Promise<OwnedConversation>;
1308
+ }
1309
+
1310
+ type ToolRegistration = Tool & {
1311
+ readonly replay?: "safe" | "unsafe";
1312
+ readonly output?: {
1313
+ readonly maxBytes?: number;
1314
+ readonly maxLines?: number;
1315
+ readonly retain?: "head" | "tail";
1316
+ };
1317
+ execute(
1318
+ args: JsonValue,
1319
+ api: ToolExecutionApi,
1320
+ context: Context,
1321
+ ): Promise<ToolExecutionResult>;
1322
+ };
1323
+ ```
1324
+
1325
+ Omitted `replay` is `unsafe`. Omitted output bounds are 64 KiB, 200 lines, and
1326
+ `retain: "head"`. `stream()` synchronously accepts UTF-8 output into that
1327
+ invocation-owned bounded buffer and throws after invocation end. Throttled
1328
+ commits publish the retained output and dropped byte/line counts in the tool
1329
+ presentation document. If `execute()` omits `content`, the final retained stream
1330
+ becomes one text content item; no stream becomes an empty content list. Explicit
1331
+ text in explicit result content is bounded by the same limits before transcript
1332
+ persistence; non-text content is retained as declared by its pi-ai type.
1333
+
1334
+ `progress(value)` replaces the invocation's complete JSON `progress` payload; it
1335
+ does not merge keys. Its promise resolves after the corresponding or coalesced
1336
+ document commit. During normal settlement, accepted output updates drain before
1337
+ the tool-result entry and terminal task record commit. Abort and close obey
1338
+ invocation and Session admission gates: uncommitted buffered updates may be
1339
+ discarded, while admitted commits settle. Pre-flush cancellation or callback
1340
+ failure does not poison the Session. Post-flush, checkpoint, or storage failure
1341
+ follows the fatal Session rule.
1342
+
1343
+ Tools are dynamically registered declarations with name, description, JSON
1344
+ schema, replay policy, and execute function. A tool call is accepted only if it
1345
+ was offered in the request's effective system/tool history. Arguments are
1346
+ validated before and after `beforeTool` hooks.
1347
+
1348
+ After hooks and validation, the tool task durably records the final call and
1349
+ resolved replay policy before execution. Recovery does not rerun `beforeTool`
1350
+ and does not let a changed registry declaration alter that stored policy.
1351
+
1352
+ A tool executes in a durable task. It may:
1353
+
1354
+ - write bounded progress/output to a presentation or task-scoped document;
1355
+ - commit memos;
1356
+ - create and wait for tasks;
1357
+ - create owned conversations;
1358
+ - observe documents for which it has a token/reference;
1359
+ - mutate authorized documents through `commit()`;
1360
+ - return bounded model content and separate diagnostic details.
1361
+
1362
+ Tool operations use the invoking task's admission and
1363
+ invocation-lifetime gates. Task creation defaults to that task's conversation.
1364
+ Trusted document access follows sections 3.3 and 9.2; there is no additional
1365
+ document subtree authorization layer. `createConversation()` records the
1366
+ invoking task as owner and returns an invocation-bound handle. `inherit` defaults
1367
+ to `false`. When true, creation atomically selects the invoking conversation's
1368
+ newest committed visible entry as `parent.at`; if no entry exists, it creates no
1369
+ history parent. Conversation documents then apply section 3.7 at that entry, and
1370
+ explicit model/section seeds override their inherited built-in values. With no
1371
+ parent, documents initialize normally. That handle's
1372
+ operations reject after the invocation ends. An `InputHandle` returned by its
1373
+ `send()` is invocation-bound in the same way; the admitted input itself remains
1374
+ durable after those methods reject. Invocation-owned document watches stop when
1375
+ the invocation ends.
1376
+
1377
+ A tool result may request `addTools`, `terminate`, or `handoff`. Post-tools
1378
+ applies added tool names to configured loadout, uses a final boundary for
1379
+ terminate/handoff, and writes a headed handoff entry when requested.
1380
+
1381
+ On reopen, a tool reruns only when both its stored intent policy and the current
1382
+ registered declaration say `safe`. A current `unsafe` declaration may veto a
1383
+ stored-safe replay; a current-safe declaration never upgrades stored unsafe.
1384
+ Every other orphaned effect produces an interrupted result containing the
1385
+ durable partial output. Completed, failed, and aborted
1386
+ tool terminal outcomes retain their tool-result entry ID for post-tools.
1387
+
1388
+ ### 7.3 System sections and dynamic tools
1389
+
1390
+ ```ts
1391
+ interface SystemSection<T extends JsonValue = JsonValue> {
1392
+ readonly key: string;
1393
+ render(value: T): string;
1394
+ }
1395
+
1396
+ function defineSystemSection<T extends JsonValue>(definition: {
1397
+ readonly key: string;
1398
+ render(value: T): string;
1399
+ }): SystemSection<T>;
1400
+ ```
1401
+
1402
+ Pico stores prompt and tool changes directly as PR #9548 `SystemMessage` values
1403
+ at their transcript positions:
1404
+
1405
+ ```ts
1406
+ type SystemEntry = EntryRecord & {
1407
+ readonly kind: "pi.system";
1408
+ readonly model: readonly [SystemMessage];
1409
+ };
1410
+
1411
+ const baseline: SystemMessage = {
1412
+ role: "system",
1413
+ content: basePrompt,
1414
+ sections: { persona: renderedPersona, cwd: renderedCwd },
1415
+ toolsAdded: allEffectiveTools,
1416
+ timestamp: now,
1417
+ };
1418
+
1419
+ const delta: SystemMessage = {
1420
+ role: "system",
1421
+ content: "",
1422
+ sections: { cwd: nextRenderedCwd, legacy: null },
1423
+ toolsRemoved: [{ name: "read" }],
1424
+ toolsAdded: [nextRead],
1425
+ timestamp: now,
1426
+ };
1427
+ ```
1428
+
1429
+ System sections are registered by stable, non-integer-like key. Generation
1430
+ prepares the desired rendered section values and effective tool roster, compares
1431
+ them with the state obtained by replaying the active transcript, and appends a
1432
+ positional `pi.system` baseline or delta.
1433
+
1434
+ Replay applies messages in transcript order. Non-empty `content` appends
1435
+ instructions. A section string adds or replaces that name without moving an
1436
+ existing section; `null` removes it, and a later re-addition appends it to the
1437
+ ordered section map. Within one message, tool removals happen before additions,
1438
+ so a same-name replacement gets the new declaration and position.
1439
+
1440
+ The built-in configuration document preserves section insertion order.
1441
+ `setSection(section, undefined)` removes the key; setting it later appends it at
1442
+ the end. Preparation compares both values and order. If values can be patched
1443
+ without changing order, it emits the minimal patch. If effective and desired
1444
+ section order differ, one commit appends two `pi.system` entries: the first
1445
+ removes every effective section with `null`, and the second re-adds every desired
1446
+ section in desired order. This makes order-only changes and deletion/re-addition
1447
+ between requests replay exactly; merely restating equal values is insufficient.
1448
+
1449
+ A PR #9548 `SystemMessage` is always a patch, not a reset: it cannot remove
1450
+ previous `content` or restore section order merely by restating current values.
1451
+ Therefore, when a head removes the previous request-visible baseline, the new
1452
+ `pi.system` entry adds `ContextEdit` omissions for every earlier `pi.system`
1453
+ entry still retained after the cut. Its own message is then a complete baseline
1454
+ containing the base `content`, every desired section in order, and every effective
1455
+ tool declaration. Model-context replay sees the new baseline instead of the
1456
+ omitted retained deltas. Head rebaselining takes precedence over ordinary
1457
+ order/value patching. Without a head cut, an order mismatch uses the two-entry
1458
+ remove/re-add sequence above; only when order already matches does preparation
1459
+ emit the minimal changed values, `null` removals, and tool additions/removals.
1460
+ Same-name tool replacements remove before adding. Registry or document changes
1461
+ while preparation hooks run cause preparation to retry against a new snapshot.
1462
+
1463
+ Conversation creation may seed section values or explicit removals. Preparation
1464
+ uses a mutable section draft with get/set/delete/wrap; each throwing hook loses
1465
+ only its own draft changes. The rendered strings stored in historical
1466
+ `SystemMessage.sections` remain authoritative even if the current renderer
1467
+ changes. Pi-ai decides whether to send the messages positionally to a capable
1468
+ provider or fold them into one leading system message; Pico does not rewrite its
1469
+ stored transcript for provider compatibility.
1470
+
1471
+ ### 7.4 Host extension reload
1472
+
1473
+ A host extension generation is the Session-side code implementing its task kinds,
1474
+ hooks, tools, sections, and document definitions. Registry APIs may
1475
+ change registered declarations during normal product operation, but they do not
1476
+ make replacement of that implementation code safe while its callbacks are
1477
+ running. Document definitions are passed explicitly to typed access rather than
1478
+ registered.
1479
+
1480
+ In v1, changing host extension code is a Harness generation boundary:
1481
+
1482
+ 1. Stop new admission and task reservation.
1483
+ 2. Close the Harness, signalling and joining its active task, tool, and hook
1484
+ invocations plus in-flight watch callbacks without writing abort marks or
1485
+ terminal outcomes.
1486
+ 3. Dispose the old facets and registrations.
1487
+ 4. Construct a new Harness over the same storage and new document tokens.
1488
+ 5. Register the complete task definition set before open performs live-task
1489
+ migration and orphan reconciliation. Ordinary documents migrate on later
1490
+ typed access.
1491
+ 6. Resume scheduling from the durable checkpoints.
1492
+
1493
+ A durable task does not need to become terminal before this restart; only its
1494
+ current invocation must settle. A task definition must increase its version when
1495
+ the meaning of persisted input or checkpoint state changes and migrate supported
1496
+ older state. Uncommitted hook work may rerun under the new generation; committed
1497
+ memos remain part of the live task.
1498
+
1499
+ If old extension code ignores cancellation and never settles, graceful in-process
1500
+ reload cannot complete. The host must terminate that isolated worker/process
1501
+ before opening the Session under the new generation. Old and new generations
1502
+ must never own the same Session concurrently.
1503
+
1504
+ Generation-pinned registries that drain old callbacks while routing new work to
1505
+ new code, plus explicit compatible task takeover, are possible future work. Safe
1506
+ forced takeover of arbitrary non-cooperative JavaScript requires worker/process
1507
+ isolation and is not a v1 promise.
1508
+
1509
+ ## 8. Built-in tasks
1510
+
1511
+ The initial implementation provides:
1512
+
1513
+ | kind | responsibility |
1514
+ |---|---|
1515
+ | generation | prepare system/loadout, request or poll model, retry, classify response |
1516
+ | tool | validate, hook, execute, persist progress, append result |
1517
+ | post-tools | wait for tools, apply controls, run boundary, continue generation |
1518
+ | collapse | select a transcript range, summarize, append a headed summary |
1519
+
1520
+ Generation uses `HarnessOptions.models` without a Pico-specific model adapter. It
1521
+ resolves `models.getModel(ref.provider, ref.modelId)`, builds a pi-ai `Context`
1522
+ from the prepared prompt/messages/tools, and calls `models.streamSimple()` with
1523
+ the task invocation's abort signal and configured reasoning/options. Deferred
1524
+ continuation calls `models.fetchDeferred()` and `models.cancelDeferred()` with
1525
+ that same model and signal. Missing models and synchronous/streamed pi-ai errors
1526
+ are classified into the durable generation outcomes below.
1527
+
1528
+ Generation and tool progress are throttled durable document commits. A crash may
1529
+ lose only the uncommitted throttle window. Recovery converts committed partials
1530
+ to normal interrupted/aborted transcript entries, clears presentation state,
1531
+ and then retries or terminates according to the task phase. Retry deadlines,
1532
+ attempts, compaction, and tool progress are current document state for late
1533
+ joiners; completed-attempt usage/accounting is an entry or terminal detail.
1534
+ Bounded output records whether content was truncated and any retained file path.
1535
+
1536
+ Compaction changes model context by appending a summary entry with a head. It
1537
+ does not delete transcript history.
1538
+
1539
+
1540
+ ## 9. Document observation and Chord
1541
+
1542
+ ### 9.1 Document source
1543
+
1544
+ ```ts
1545
+ declare const documentSourceType: unique symbol;
1546
+ interface DocumentSource<T extends JsonObject> {
1547
+ readonly [documentSourceType]: T;
1548
+ }
1549
+
1550
+ type WatchEnd =
1551
+ | { readonly reason: "stopped" | "cancelled" | "session_closed" | "retired" }
1552
+ | { readonly reason: "listener_error"; readonly error: Error };
1553
+
1554
+ interface WatchHandle<T> {
1555
+ /** Immutable acquisition snapshot. This reference never changes. */
1556
+ readonly value: T;
1557
+ /** Installs the sole serialized asynchronous listener. */
1558
+ start(listener: (ops: readonly Op[], context: Context) => Promise<void>): void;
1559
+ /** Prevents another callback from starting and signals an in-flight callback. */
1560
+ stop(): void;
1561
+ /** Settles after termination and any in-flight callback. */
1562
+ readonly closed: Promise<WatchEnd>;
1563
+ }
1564
+
1565
+ type DocumentWatch<T extends JsonObject> = WatchHandle<Readonly<T> | null>;
1566
+
1567
+ interface DocumentObserver {
1568
+ watchDoc<T extends JsonObject>(token: DocToken<T>, target: DocTarget, context: Context): Promise<DocumentWatch<T>>;
1569
+ watchDoc<T extends JsonObject, I extends JsonValue>(token: DocFamilyToken<T, I>, target: FamilyTarget<I>, context: Context): Promise<DocumentWatch<T>>;
1570
+ }
1571
+ ```
1572
+
1573
+ `DocumentSource` is opaque. Pico5 adds this adapter at the Chord boundary:
1574
+
1575
+ ```ts
1576
+ interface ReplicatedDocument<T extends JsonObject> {
1577
+ readonly state: ReplicatedState<T | null>;
1578
+ dispose(): void;
1579
+ }
1580
+
1581
+ function documentReplicatedState<T extends JsonObject>(
1582
+ source: DocumentSource<T>,
1583
+ context: Context,
1584
+ ): Promise<ReplicatedDocument<T>>;
1585
+ ```
1586
+
1587
+ The adapter registers the source with Chord and exposes it without a second
1588
+ tracker or re-diff. Normal source and watch acquisition are get-or-create and
1589
+ bind one concrete incarnation. Retirement publishes a JSON `null` replacement
1590
+ and ends that incarnation's stream. The service may then withdraw itself; if it
1591
+ remains exposed, consumers see `null`, never stale state. A later recreation
1592
+ requires acquiring a new source/watch.
1593
+
1594
+ Each Chord adapter assigns its own in-memory contiguous delivery sequence; Pico
1595
+ does not persist or expose that sequence through `WatchHandle`. Hydration
1596
+ atomically captures a matching immutable value and adapter sequence before
1597
+ subsequent source operations are delivered. Reopen creates a new source
1598
+ lifetime and hydration.
1599
+
1600
+ ### 9.2 `watchDoc`
1601
+
1602
+ Tasks, hooks, and tools may observe any document for which their code has a token
1603
+ and target. There is no additional subtree permission system inside trusted
1604
+ Session code.
1605
+
1606
+ `watchDoc()` is available on task, hook, and tool APIs. Acquisition performs
1607
+ normal get-or-create and, on the Session line, binds one concrete incarnation,
1608
+ captures its immutable committed value, and registers the handle for every later
1609
+ committed operation batch. `watch.value` is that fixed acquisition snapshot and
1610
+ never changes.
1611
+
1612
+ ```ts
1613
+ const watch = await api.watchDoc(JobOutputDoc, target, context);
1614
+ let value = watch.value;
1615
+ try {
1616
+ await initializeConsumer(value, context);
1617
+ watch.start(async (ops, deliveryContext) => {
1618
+ value = applyImmutable(value, ops);
1619
+ await consume(value, deliveryContext);
1620
+ });
1621
+ } catch (error) {
1622
+ watch.stop();
1623
+ await watch.closed;
1624
+ throw error;
1625
+ }
1626
+ ```
1627
+
1628
+ The caller initializes from `value` before `start()`. `start()` synchronously
1629
+ installs the sole listener, changes the prepared handle to active, and schedules
1630
+ delivery; it never invokes the listener inline. A second `start()`, or `start()`
1631
+ after stop, throws.
1632
+
1633
+ One per-watch delivery line awaits each callback before starting the next, so
1634
+ callbacks never overlap. Commits never wait for callback settlement: while one
1635
+ batch is in flight, later batches append to the pending queue. Empty operation
1636
+ batches are discarded before enqueueing and cannot consume queue bookkeeping. An
1637
+ update accepted between acquisition and return, or between return and `start()`,
1638
+ is therefore not lost. The listener's promise covers all work the watch
1639
+ serializes; fire-and-forget work started by the listener is outside that
1640
+ guarantee.
1641
+
1642
+ The pending queue is bounded only by the total number of operations in its
1643
+ undelivered batches. It never estimates serialized size or calls
1644
+ `JSON.stringify()` to make a compaction decision. When the operation-count limit
1645
+ is exceeded, the handle leaves the in-flight batch untouched and replaces the
1646
+ entire pending suffix with one newly allocated root replacement batch:
1647
+
1648
+ ```ts
1649
+ [["r", latestImmutablePublishedValue]]
1650
+ ```
1651
+
1652
+ The reset counts as one pending operation regardless of the document's in-memory
1653
+ size. Later deltas append behind it. If the operation-count limit is exceeded
1654
+ again, the whole pending suffix is replaced with a newer reset rather than
1655
+ accumulating an unbounded operation list. This bounds delta bookkeeping, not the
1656
+ document value itself. This watch observes convergent committed state, not every
1657
+ intermediate transition.
1658
+
1659
+ The replacement value is the immutable publication value corresponding exactly
1660
+ to the last batch compacted into it. After storage succeeds, Pico materializes
1661
+ that value once per changed document with
1662
+ `applyImmutable(previousPublishedValue, ops)`; it never retains mutable
1663
+ `Tracker.state`, `Tracker.target`, or a borrowed storage candidate. Watches may
1664
+ share the immutable publication value and operation payloads. A mutable consumer
1665
+ must detach them before using mutable `apply()` or `track()`.
1666
+
1667
+ A watch remains bound to its original incarnation. Retirement replaces the
1668
+ pending suffix with `[["r", null]]`. Before start, `value` remains the original
1669
+ snapshot and the terminal reset becomes the first delivered batch. While active,
1670
+ the reset follows any in-flight callback. Successful terminal delivery closes as
1671
+ `retired`; recreation requires another watch.
1672
+
1673
+ `stop()` is idempotent, unregisters the handle, discards pending batches,
1674
+ prevents another callback from starting, and signals the watch delivery context.
1675
+ An in-flight callback is allowed to settle; `closed` resolves only afterward.
1676
+ The acquisition `Context` governs the watch lifetime. Cancellation during
1677
+ acquisition cleans up any registration before rejecting; an already-admitted
1678
+ get-or-create commit still settles and is not undone. Cancellation immediately
1679
+ after successful acquisition may therefore return an already-stopped handle,
1680
+ whose `start()` throws and whose `closed` reports `cancelled`. Invocation
1681
+ termination and Session close stop owned watches similarly. Listener rejection
1682
+ is reported, discards pending work, and closes only that watch as
1683
+ `listener_error`. The first termination reason wins, and every late rejection is
1684
+ observed.
1685
+
1686
+ ### 9.3 Conversation view
1687
+
1688
+ The public view is a fixed structural mount of selected built-in documents:
1689
+
1690
+ ```ts
1691
+ type ConversationView = {
1692
+ readonly conversation: ConversationRecord;
1693
+ readonly entries: readonly EntryRecord[];
1694
+ readonly docs: Readonly<Record<string, JsonObject>>;
1695
+ };
1696
+ ```
1697
+
1698
+ The concrete built-in document IDs and fields are public protocol once their
1699
+ implementation layer is approved. Third-party documents are initially exposed
1700
+ through their own Chord services, not automatically mounted.
1701
+
1702
+ The mount consumes one complete Session commit and publishes one Chord batch:
1703
+
1704
+ ```text
1705
+ document op ["s", ["message"], value]
1706
+ -> view op ["s", ["docs", "pi.live", "message"], value]
1707
+ ```
1708
+
1709
+ Entry appends/head changes and every changed mounted document are included in
1710
+ the same publication. The mount owns no tracker and performs no semantic
1711
+ projection. It materializes one immutable published view per batch with
1712
+ `applyImmutable(previousView, mountedOps)` so every conversation watch can share
1713
+ that value for reset compaction. A Chord adapter assigns a contiguous in-memory
1714
+ delivery sequence per view source lifetime.
1715
+
1716
+ `Conversation.watch()` exposes that mount through the same immutable acquisition
1717
+ snapshot, serialized asynchronous listener, and reset-compacted pending queue as
1718
+ `watchDoc()`. Each non-empty operation batch represents one complete Session
1719
+ commit; a commit that does not change the mounted view emits no watch batch. Chord
1720
+ Session facets may forward the captured value and committed operations through
1721
+ services, but that product wiring is not part of the Harness facade and must not
1722
+ add another tracker or semantic event envelope.
1723
+
1724
+ ### 9.4 Agent-mode notifications
1725
+
1726
+ The Session kernel and Chord structural sources do not maintain a semantic event
1727
+ journal. Coding-agent JSON/RPC compatibility uses a thin agent-mode adapter
1728
+ derived from each uncoalesced committed publication before per-watch queue
1729
+ compaction. It owns no tracker or persistence and emits notifications only after
1730
+ the commit that makes them true.
1731
+
1732
+ The adapter protocol covers run start/settlement, committed assistant progress,
1733
+ message entry settlement, tool intent/progress/result, input queue/outcome,
1734
+ retry/deferred/compaction state, configuration changes, and faults. One commit
1735
+ may produce an ordered batch. Progress notifications represent Pico's throttled
1736
+ durable partials, not every raw provider frame. The exact legacy `AgentEvent`
1737
+ wire format is not preserved.
1738
+
1739
+ Notifications have no hydration or replay contract. A consumer requiring a
1740
+ complete lifecycle subscribes before admitting the input; a late or reconnecting
1741
+ consumer hydrates structural state and history instead. Product adapters apply
1742
+ these rules:
1743
+
1744
+ - TUI hydrates and renders `ConversationView`, then applies structural updates;
1745
+ notifications may drive transient animation but are not its authority.
1746
+ - Print awaits its submitted `InputHandle` and prints that input's answer.
1747
+ - JSON/RPC expose correlated commands plus the ordered agent notification
1748
+ protocol, with transport backpressure and disconnect policy owned by that
1749
+ adapter.
1750
+
1751
+ This adapter is allowed even though a public Session-kernel semantic stream is a
1752
+ non-goal. It must not derive notifications from a lossy, reset-compacted watch
1753
+ when complete subscribed lifecycle delivery is promised.
1754
+
1755
+ ## 10. Storage contract
1756
+
1757
+ Ordered scans are cursor-based. Exact identity lookups are keyed.
1758
+
1759
+ ```ts
1760
+ type Page<T, C> = {
1761
+ readonly items: readonly T[];
1762
+ readonly next?: C;
1763
+ };
1764
+
1765
+ type Cursor = Readonly<Record<string, JsonValue>>;
1766
+
1767
+ type EntryQuery = {
1768
+ readonly conversationId: Id;
1769
+ readonly minEntryId?: Id; // inclusive
1770
+ readonly maxEntryId?: Id; // inclusive
1771
+ };
1772
+
1773
+ type TaskQuery = {
1774
+ readonly conversationId?: Id;
1775
+ readonly kind?: string;
1776
+ readonly status?: "pending" | "running" | "terminal";
1777
+ readonly abortRequested?: boolean;
1778
+ readonly background?: boolean;
1779
+ };
1780
+
1781
+ type DocumentPoint = Seq | "current";
1782
+
1783
+ type DocumentAddress = {
1784
+ readonly kind: string;
1785
+ readonly scope: DocumentRecord["scope"];
1786
+ readonly key?: string;
1787
+ };
1788
+
1789
+ type DocumentQuery = {
1790
+ readonly scope: DocumentRecord["scope"];
1791
+ readonly at: DocumentPoint;
1792
+ readonly kind?: string;
1793
+ };
1794
+
1795
+ type DocumentContent =
1796
+ | { readonly version: number; readonly kind: "base"; readonly value: JsonObject }
1797
+ | { readonly version: number; readonly kind: "delta"; readonly ops: readonly Op[] };
1798
+
1799
+ type StoredDocumentRecord =
1800
+ | { readonly seq: Seq; readonly version: number; readonly kind: "base"; readonly value: JsonObject }
1801
+ | { readonly seq: Seq; readonly version: number; readonly kind: "delta"; readonly ops: readonly Op[] };
1802
+
1803
+ type StoredDocument = {
1804
+ readonly record: DocumentRecord;
1805
+ readonly records: readonly [
1806
+ Extract<StoredDocumentRecord, { kind: "base" }>,
1807
+ ...Extract<StoredDocumentRecord, { kind: "delta" }>[],
1808
+ ];
1809
+ };
1810
+
1811
+ type StorageWrite =
1812
+ | { readonly type: "conversation"; readonly value: ConversationRecord }
1813
+ | { readonly type: "entry"; readonly value: EntryRecord }
1814
+ | { readonly type: "task"; readonly value: TaskRecord<JsonValue, JsonValue, JsonValue> }
1815
+ | { readonly type: "input"; readonly value: Input }
1816
+ | {
1817
+ readonly type: "document.create";
1818
+ readonly record: DocumentCreate;
1819
+ readonly content: Extract<DocumentContent, { kind: "base" }>;
1820
+ }
1821
+ | {
1822
+ readonly type: "document.change";
1823
+ readonly id: Id;
1824
+ readonly content: DocumentContent;
1825
+ }
1826
+ | { readonly type: "document.retire"; readonly id: Id };
1827
+
1828
+ /**
1829
+ * Trusts the owning Session to supply semantically valid records, references,
1830
+ * ancestry, and transitions. Enforces atomicity, global ID ownership, immutable
1831
+ * conversation/entry creation, document lifecycle consistency, and detached
1832
+ * values; Session serializes commits.
1833
+ */
1834
+ interface Storage {
1835
+ commit(writes: readonly StorageWrite[], context: Context): Promise<Seq>;
1836
+ mintId(): Id;
1837
+
1838
+ conversation(id: Id, context: Context): Promise<ConversationRecord | undefined>;
1839
+ scanConversations(cursor: Cursor | undefined, limit: number, context: Context): Promise<Page<ConversationRecord, Cursor>>;
1840
+
1841
+ entry(id: Id, context: Context): Promise<{ readonly entry: EntryRecord; readonly commitSeq: Seq } | undefined>;
1842
+ findLatestHeadMarker(conversationId: Id, atOrBeforeEntryId: Id | undefined, context: Context): Promise<(EntryRecord & { readonly head: Id }) | undefined>;
1843
+ scanEntries(query: EntryQuery, cursor: Cursor | undefined, limit: number, context: Context): Promise<Page<EntryRecord, Cursor>>;
1844
+
1845
+ task(id: Id, context: Context): Promise<TaskRecord<JsonValue, JsonValue, JsonValue> | undefined>;
1846
+ scanTasks(query: TaskQuery, cursor: Cursor | undefined, limit: number, context: Context): Promise<Page<TaskRecord<JsonValue, JsonValue, JsonValue>, Cursor>>;
1847
+
1848
+ input(id: Id, context: Context): Promise<Input | undefined>;
1849
+ inputByRequest(conversationId: Id, requestId: string, context: Context): Promise<Input | undefined>;
1850
+
1851
+ findDocument(address: DocumentAddress, at: DocumentPoint, context: Context): Promise<DocumentRecord | undefined>;
1852
+ document(id: Id, at: DocumentPoint, context: Context): Promise<StoredDocument | undefined>;
1853
+ scanDocuments(query: DocumentQuery, cursor: Cursor | undefined, limit: number, context: Context): Promise<Page<DocumentRecord, Cursor>>;
1854
+
1855
+ close(context: Context): Promise<void>;
1856
+ }
1857
+ ```
1858
+
1859
+ Cursors are backend-owned JSON objects. Callers only round-trip them to the same
1860
+ scan on the same storage; cross-storage or cross-query use is unsupported. The
1861
+ Session owns the mutation line, so storage implementations do not add a second
1862
+ caller-facing commit mutex. Each backend still makes one admitted batch atomic.
1863
+
1864
+ `findLatestHeadMarker()` returns the newest visible entry carrying `head` at or
1865
+ below its optional inclusive cutoff. The returned entry is the marker; its
1866
+ `head` value is the actual lower bound for context. `scanEntries()` pages the
1867
+ inclusive ID range in newest-first order while applying every conversation
1868
+ ancestry cap. With no bounds it pages complete visible history. To read context
1869
+ through entry `E`, find the marker at or before `E`, then scan from
1870
+ `marker?.head` through `E`. For current context the upper bound is omitted.
1871
+ `entry()` combines exact global lookup with the commit sequence required by
1872
+ historical document reads. `limit` is always the maximum page size.
1873
+ `findDocument()` resolves one exact logical kind/scope/key address at current or
1874
+ historical membership. A missing key means the singleton, not every family
1875
+ member. `scanDocuments()` enumerates only the incarnations alive in one exact
1876
+ scope at its selected point and may restrict one family/singleton kind. It uses
1877
+ ascending incarnation IDs. There is no ordinary open-time all-document scan.
1878
+ Task queries support conversation, kind, live/terminal status, abort mark, and
1879
+ background status.
1880
+
1881
+ `document(id, at)` returns the newest applicable base plus its ordered delta tail
1882
+ and never scans unrelated documents. An unknown ID returns `undefined`. At
1883
+ `"current"`, a retired incarnation returns `undefined`. A numeric lookup of a
1884
+ rewindable conversation incarnation returns `undefined` outside its half-open
1885
+ lifetime and reconstructs the selected value inside it. A numeric lookup of a
1886
+ known current-only incarnation rejects rather than depending on content that
1887
+ reclamation may have removed. Metadata membership remains queryable historically.
1888
+ A missing required base inside an addressable rewindable lifetime is storage
1889
+ corruption, not absence.
1890
+
1891
+ One normalized batch contains at most one create/change content command per
1892
+ incarnation and may also retire that incarnation. Storage applies content before
1893
+ retirement independent of write-array order. Create plus retire stamps both
1894
+ lifetime bounds with the batch sequence. Retire plus create at one logical
1895
+ address makes the new incarnation current at that sequence. Deltas cannot cross
1896
+ a stored version boundary; a version transition must be a base.
1897
+
1898
+ The semantic conformance suite covers memory, SQLite, and JSONL.
1899
+
1900
+ ## 11. Backends
1901
+
1902
+ ### 11.1 Memory
1903
+
1904
+ Memory storage is the reference semantics. It copies retained write values and
1905
+ all read results. This deliberately simulates the ownership boundary naturally
1906
+ created by SQLite encoding/decoding and JSONL serialization; it is not defensive
1907
+ validation. It preserves rewindable records and reclaims latest records only
1908
+ after a committed base or retirement.
1909
+
1910
+ ### 11.2 SQLite
1911
+
1912
+ One SQL transaction is one Session commit. SQLite stores:
1913
+
1914
+ - conversation, entry, task, and input records;
1915
+ - document lifecycle records;
1916
+ - indexed document bases/deltas by document and commit sequence.
1917
+
1918
+ Live task transitions replace one row. Terminal tasks remain as small records.
1919
+ Document reads use indexed base-plus-tail ranges. The first implementation stores
1920
+ Chord records directly; it does not translate generic operations to SQLite JSON
1921
+ functions.
1922
+
1923
+ Schema shape, WAL checkpoint cadence, and synchronous defaults are backend
1924
+ implementation choices validated by conformance, reopen, query-plan, and storage
1925
+ benchmarks.
1926
+
1927
+ ### 11.3 JSONL
1928
+
1929
+ JSONL uses reclaimable sidecars without exposing them to the harness. Persistence
1930
+ alone does not provide the ownership boundary: any decoded indexes, materialized
1931
+ values, or caches retained in memory must be detached from commit arguments and
1932
+ must not be exposed directly by reads. A JSONL backend cannot simply add file
1933
+ appends around aliasing memory tables.
1934
+
1935
+ ```text
1936
+ main.jsonl table writes, lifecycle, and one marker per commit
1937
+ doc-<id>.jsonl one document incarnation
1938
+ task-<id>.jsonl live task replacements
1939
+ ```
1940
+
1941
+ Publication protocol:
1942
+
1943
+ 1. Append complete prepared records to every affected sidecar.
1944
+ 2. Append one complete main marker listing those records.
1945
+ 3. Publish in memory only after the marker write succeeds.
1946
+
1947
+ Every commit uses this protocol; there is no standalone-sidecar fast path.
1948
+ Without `fsync`, it guarantees ordinary process-crash consistency, not survival
1949
+ of power, host, kernel, or filesystem failure. Durable mode flushes sidecars
1950
+ before the marker.
1951
+
1952
+ Recovery:
1953
+
1954
+ - Remove torn final lines.
1955
+ - Ignore and remove unconfirmed sidecar tails.
1956
+ - Apply confirmed records only.
1957
+ - Missing required confirmed data is corruption and opening fails.
1958
+ - A later committed latest base or retirement may prove an earlier physical
1959
+ record unnecessary.
1960
+ - Any uncertain append failure poisons the open backend.
1961
+
1962
+ Reclamation starts only after the authorizing base/retirement commits. It writes
1963
+ a temporary replacement, renames it, and invalidates cached file descriptors so
1964
+ future appends cannot target an unlinked inode. `main.jsonl` is not compacted in
1965
+ the initial implementation.
1966
+
1967
+ ## 12. API footguns
1968
+
1969
+ These are contracts, not invitations to add defensive machinery:
1970
+
1971
+ - **Draft escape:** `tx.doc()` values, nested proxies, and array methods are valid
1972
+ only during that transaction. Retaining them can contaminate a later commit.
1973
+ - **Inserted aliases:** after assigning an object or array into a draft, do not
1974
+ mutate the original value. The tracker owns it.
1975
+ - **Read after write:** read every required table row before the first table
1976
+ write. Document drafts remain usable afterward; table reads do not.
1977
+ - **Long transactions:** an async commit callback holds the Session mutation
1978
+ line. Never await models, tools, processes, network calls, humans, a nested
1979
+ Session commit, or a Session waiter inside it. Use methods on the current `Tx`.
1980
+ - **Get-or-create reads:** `snapshot()` and `documentSource()` can create and
1981
+ commit an absent document. They are not historical or side-effect-free reads.
1982
+ - **Family initialization:** `initial` input is used only for a new incarnation.
1983
+ It does not update an existing instance.
1984
+ - **Checkpoint starvation:** if `checkpointWhen()` never returns true, replay
1985
+ and current-only document storage can grow without bound while the document is
1986
+ live.
1987
+ - **Wrong fork setting:** `current`, `initial`, and `asOf` are product semantics,
1988
+ not optimizations. Changing one changes child conversation behavior.
1989
+ - **Schema stability:** document kinds and visible mount paths are
1990
+ persisted/public protocol. Value migration cannot rename a kind; a kind
1991
+ change requires explicit copy and retirement. Passing incompatible definition
1992
+ tokens that claim the same kind is unsupported caller misuse; Session does not
1993
+ maintain a document-definition registry to detect it.
1994
+ - **Watch activation:** `watch.value` is the immutable acquisition snapshot.
1995
+ Initialize the consumer from it before `start()`; queued operations are based
1996
+ on exactly that value.
1997
+ - **Coalesced watches:** slow or unstarted watches may replace an undelivered
1998
+ suffix with a complete reset, omitting intermediate committed states. A facet
1999
+ that must audit every transition must persist each fact as an immutable entry
2000
+ or in its own journal and scan that history explicitly.
2001
+ - **Watch self-join:** a listener may call `stop()`, but must not await its own
2002
+ `closed` promise or a Session close that joins that listener.
2003
+ - **Durable progress cadence:** clients see only committed progress. A crash may
2004
+ lose the current uncommitted throttle window.
2005
+ - **Large terminal results:** terminal task records remain queryable. Put large
2006
+ results in entries or longer-lived documents and retain only their IDs in the
2007
+ outcome. Never reference a task-scoped document retired by that same outcome.
2008
+ - **Raw transcript:** view entries are not model context. Rendering edits,
2009
+ display-only entries, and model filtering require the appropriate reducer.
2010
+ - **Fatal storage errors:** after an uncertain storage failure the Session is
2011
+ poisoned. Do not catch the error and continue using it.
2012
+ - **JSONL durability:** default JSONL ordering handles ordinary process crashes;
2013
+ without durable mode it does not promise acknowledged commits survive power or
2014
+ host failure.
2015
+
2016
+ ## 13. Non-goals
2017
+
2018
+ Pico5 initially has no:
2019
+
2020
+ - whole-Session DOM;
2021
+ - visible-undurable publication;
2022
+ - Session-kernel semantic event journal or independently maintained event state;
2023
+ - transaction membrane;
2024
+ - session-scoped rewindable documents;
2025
+ - automatic checkpoint heuristic;
2026
+ - automatic third-party view mounting;
2027
+ - CRDT/offline multi-writer merge;
2028
+ - SQL translation of arbitrary Chord operations;
2029
+ - JSONL global compaction or automatic corruption repair;
2030
+ - compatibility layer for removed Pico prototypes;
2031
+ - in-process hot replacement of Session-side extension implementations.