agentfootprint 9.87.1 → 9.89.0

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.
Files changed (143) hide show
  1. package/CHANGELOG.md +946 -0
  2. package/dist/core/Agent.js +32 -0
  3. package/dist/core/Agent.js.map +1 -1
  4. package/dist/core/LLMCall.js +7 -10
  5. package/dist/core/LLMCall.js.map +1 -1
  6. package/dist/core/agent/buildAgentMessageApiChart.js +2 -4
  7. package/dist/core/agent/buildAgentMessageApiChart.js.map +1 -1
  8. package/dist/core/agent/buildDynamicAgentChart.js +10 -0
  9. package/dist/core/agent/buildDynamicAgentChart.js.map +1 -1
  10. package/dist/core/agent/buildMessageApiChart.js +2 -4
  11. package/dist/core/agent/buildMessageApiChart.js.map +1 -1
  12. package/dist/core/agent/composeRequest.js +134 -0
  13. package/dist/core/agent/composeRequest.js.map +1 -0
  14. package/dist/core/agent/stages/callLLM.js +62 -25
  15. package/dist/core/agent/stages/callLLM.js.map +1 -1
  16. package/dist/core/agent/stages/seed.js +12 -0
  17. package/dist/core/agent/stages/seed.js.map +1 -1
  18. package/dist/core/agent/stages/toolCalls.js +2 -4
  19. package/dist/core/agent/stages/toolCalls.js.map +1 -1
  20. package/dist/core/flowchartAsTool.js.map +1 -1
  21. package/dist/esm/core/Agent.d.ts +3 -0
  22. package/dist/esm/core/Agent.js +32 -0
  23. package/dist/esm/core/Agent.js.map +1 -1
  24. package/dist/esm/core/LLMCall.js +7 -10
  25. package/dist/esm/core/LLMCall.js.map +1 -1
  26. package/dist/esm/core/agent/buildAgentMessageApiChart.js +2 -4
  27. package/dist/esm/core/agent/buildAgentMessageApiChart.js.map +1 -1
  28. package/dist/esm/core/agent/buildDynamicAgentChart.js +10 -0
  29. package/dist/esm/core/agent/buildDynamicAgentChart.js.map +1 -1
  30. package/dist/esm/core/agent/buildMessageApiChart.js +2 -4
  31. package/dist/esm/core/agent/buildMessageApiChart.js.map +1 -1
  32. package/dist/esm/core/agent/composeRequest.d.ts +121 -0
  33. package/dist/esm/core/agent/composeRequest.js +127 -0
  34. package/dist/esm/core/agent/composeRequest.js.map +1 -0
  35. package/dist/esm/core/agent/stages/callLLM.d.ts +28 -0
  36. package/dist/esm/core/agent/stages/callLLM.js +61 -24
  37. package/dist/esm/core/agent/stages/callLLM.js.map +1 -1
  38. package/dist/esm/core/agent/stages/seed.d.ts +26 -0
  39. package/dist/esm/core/agent/stages/seed.js +12 -0
  40. package/dist/esm/core/agent/stages/seed.js.map +1 -1
  41. package/dist/esm/core/agent/stages/toolCalls.js +2 -4
  42. package/dist/esm/core/agent/stages/toolCalls.js.map +1 -1
  43. package/dist/esm/core/agent/types.d.ts +64 -0
  44. package/dist/esm/core/flowchartAsTool.d.ts +15 -7
  45. package/dist/esm/core/flowchartAsTool.js.map +1 -1
  46. package/dist/esm/index.d.ts +1 -0
  47. package/dist/esm/index.js +9 -0
  48. package/dist/esm/index.js.map +1 -1
  49. package/dist/esm/lib/context-bisect/llmEdgeWeigher.d.ts +8 -0
  50. package/dist/esm/lib/context-bisect/llmEdgeWeigher.js +0 -0
  51. package/dist/esm/lib/context-bisect/llmEdgeWeigher.js.map +1 -1
  52. package/dist/esm/lib/context-bisect/trajectory.js +3 -7
  53. package/dist/esm/lib/context-bisect/trajectory.js.map +1 -1
  54. package/dist/esm/lib/context-ledger/contextLedger.js +5 -12
  55. package/dist/esm/lib/context-ledger/contextLedger.js.map +1 -1
  56. package/dist/esm/lib/time-travel/epochs.d.ts +210 -0
  57. package/dist/esm/lib/time-travel/epochs.js +274 -0
  58. package/dist/esm/lib/time-travel/epochs.js.map +1 -0
  59. package/dist/esm/lib/time-travel/index.d.ts +10 -4
  60. package/dist/esm/lib/time-travel/index.js +10 -4
  61. package/dist/esm/lib/time-travel/index.js.map +1 -1
  62. package/dist/esm/lib/time-travel/keyedFold.d.ts +112 -0
  63. package/dist/esm/lib/time-travel/keyedFold.js +286 -0
  64. package/dist/esm/lib/time-travel/keyedFold.js.map +1 -0
  65. package/dist/esm/lib/time-travel/milestoneStops.d.ts +57 -36
  66. package/dist/esm/lib/time-travel/milestoneStops.js +75 -82
  67. package/dist/esm/lib/time-travel/milestoneStops.js.map +1 -1
  68. package/dist/esm/lib/time-travel/receipt.d.ts +496 -0
  69. package/dist/esm/lib/time-travel/receipt.js +434 -0
  70. package/dist/esm/lib/time-travel/receipt.js.map +1 -0
  71. package/dist/esm/lib/time-travel/servedView.d.ts +525 -0
  72. package/dist/esm/lib/time-travel/servedView.js +820 -0
  73. package/dist/esm/lib/time-travel/servedView.js.map +1 -0
  74. package/dist/esm/lib/time-travel/sha256.d.ts +24 -0
  75. package/dist/esm/lib/time-travel/sha256.js +103 -0
  76. package/dist/esm/lib/time-travel/sha256.js.map +1 -0
  77. package/dist/esm/recorders/observability/BoundaryRecorder.d.ts +21 -0
  78. package/dist/esm/recorders/observability/BoundaryRecorder.js +6 -0
  79. package/dist/esm/recorders/observability/BoundaryRecorder.js.map +1 -1
  80. package/dist/index.js +124 -103
  81. package/dist/index.js.map +1 -1
  82. package/dist/lib/context-bisect/llmEdgeWeigher.js +0 -0
  83. package/dist/lib/context-bisect/llmEdgeWeigher.js.map +1 -1
  84. package/dist/lib/context-bisect/trajectory.js +3 -7
  85. package/dist/lib/context-bisect/trajectory.js.map +1 -1
  86. package/dist/lib/context-ledger/contextLedger.js +5 -12
  87. package/dist/lib/context-ledger/contextLedger.js.map +1 -1
  88. package/dist/lib/time-travel/epochs.js +284 -0
  89. package/dist/lib/time-travel/epochs.js.map +1 -0
  90. package/dist/lib/time-travel/index.js +35 -5
  91. package/dist/lib/time-travel/index.js.map +1 -1
  92. package/dist/lib/time-travel/keyedFold.js +290 -0
  93. package/dist/lib/time-travel/keyedFold.js.map +1 -0
  94. package/dist/lib/time-travel/milestoneStops.js +74 -81
  95. package/dist/lib/time-travel/milestoneStops.js.map +1 -1
  96. package/dist/lib/time-travel/receipt.js +442 -0
  97. package/dist/lib/time-travel/receipt.js.map +1 -0
  98. package/dist/lib/time-travel/servedView.js +826 -0
  99. package/dist/lib/time-travel/servedView.js.map +1 -0
  100. package/dist/lib/time-travel/sha256.js +107 -0
  101. package/dist/lib/time-travel/sha256.js.map +1 -0
  102. package/dist/recorders/observability/BoundaryRecorder.js +6 -0
  103. package/dist/recorders/observability/BoundaryRecorder.js.map +1 -1
  104. package/dist/types/core/Agent.d.ts +3 -0
  105. package/dist/types/core/Agent.d.ts.map +1 -1
  106. package/dist/types/core/LLMCall.d.ts.map +1 -1
  107. package/dist/types/core/agent/buildAgentMessageApiChart.d.ts.map +1 -1
  108. package/dist/types/core/agent/buildDynamicAgentChart.d.ts.map +1 -1
  109. package/dist/types/core/agent/buildMessageApiChart.d.ts.map +1 -1
  110. package/dist/types/core/agent/composeRequest.d.ts +122 -0
  111. package/dist/types/core/agent/composeRequest.d.ts.map +1 -0
  112. package/dist/types/core/agent/stages/callLLM.d.ts +28 -0
  113. package/dist/types/core/agent/stages/callLLM.d.ts.map +1 -1
  114. package/dist/types/core/agent/stages/seed.d.ts +26 -0
  115. package/dist/types/core/agent/stages/seed.d.ts.map +1 -1
  116. package/dist/types/core/agent/stages/toolCalls.d.ts.map +1 -1
  117. package/dist/types/core/agent/types.d.ts +64 -0
  118. package/dist/types/core/agent/types.d.ts.map +1 -1
  119. package/dist/types/core/flowchartAsTool.d.ts +15 -7
  120. package/dist/types/core/flowchartAsTool.d.ts.map +1 -1
  121. package/dist/types/index.d.ts +1 -0
  122. package/dist/types/index.d.ts.map +1 -1
  123. package/dist/types/lib/context-bisect/llmEdgeWeigher.d.ts +8 -0
  124. package/dist/types/lib/context-bisect/llmEdgeWeigher.d.ts.map +1 -1
  125. package/dist/types/lib/context-bisect/trajectory.d.ts.map +1 -1
  126. package/dist/types/lib/context-ledger/contextLedger.d.ts.map +1 -1
  127. package/dist/types/lib/time-travel/epochs.d.ts +211 -0
  128. package/dist/types/lib/time-travel/epochs.d.ts.map +1 -0
  129. package/dist/types/lib/time-travel/index.d.ts +10 -4
  130. package/dist/types/lib/time-travel/index.d.ts.map +1 -1
  131. package/dist/types/lib/time-travel/keyedFold.d.ts +113 -0
  132. package/dist/types/lib/time-travel/keyedFold.d.ts.map +1 -0
  133. package/dist/types/lib/time-travel/milestoneStops.d.ts +57 -36
  134. package/dist/types/lib/time-travel/milestoneStops.d.ts.map +1 -1
  135. package/dist/types/lib/time-travel/receipt.d.ts +497 -0
  136. package/dist/types/lib/time-travel/receipt.d.ts.map +1 -0
  137. package/dist/types/lib/time-travel/servedView.d.ts +526 -0
  138. package/dist/types/lib/time-travel/servedView.d.ts.map +1 -0
  139. package/dist/types/lib/time-travel/sha256.d.ts +25 -0
  140. package/dist/types/lib/time-travel/sha256.d.ts.map +1 -0
  141. package/dist/types/recorders/observability/BoundaryRecorder.d.ts +21 -0
  142. package/dist/types/recorders/observability/BoundaryRecorder.d.ts.map +1 -1
  143. package/package.json +3 -3
package/CHANGELOG.md CHANGED
@@ -7,6 +7,952 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [9.89.0] - 2026-09-09
11
+
12
+ **The third digest half, and one owner of the axis.** Two follow-ups to 9.88.0,
13
+ both additive: a 9.88.0 consumer compiles and behaves identically.
14
+
15
+ ### Added
16
+
17
+ - **`toolDigestInput(tool)` — a consumer can verify the schema rows of a
18
+ receipt.** 9.88.0 exported `receiptHash` and `messageDigestInput`, so a reader
19
+ holding `servedAt(k)` and `receiptAt(k)` could prove the system text, every
20
+ piece and every message against the receipt from outside this package — and
21
+ could prove everything the model was served EXCEPT the tools' schemas. The
22
+ receipt hashed each schema through a serializer the root barrel did not
23
+ export, so a consumer's schema rows could never read Verified; its only
24
+ options were to copy the serializer (a second owner of the rule, which drifts
25
+ the day the digest gains a field — the message digest gained two in 9.88.0)
26
+ or to leave the rows unchecked. The law now holds for the third row on the
27
+ object a consumer already holds:
28
+
29
+ ```ts
30
+ receiptHash(runId, toolDigestInput(servedAt(k).tools.schemas[i])) === receiptAt(k).tools.schemaHashes[name]
31
+ ```
32
+
33
+ It takes an `LLMToolSchema` — the tool as handed to the provider port, which
34
+ is what `servedAt(k).tools.schemas` reads back — never a `Tool` definition,
35
+ which carries `execute` and other fields the model never saw. It is the ONLY
36
+ spelling of the schema rule: `buildReceipt` calls it too. A schema JSON cannot
37
+ express (a `BigInt`; a cycle) digests to the `UNSERIALIZABLE` mark on both
38
+ sides and never throws — the `BigInt` is the worked example, because a
39
+ cyclic schema is refused by footprintjs's `deepEqual` in the subflow
40
+ outputMapper before any receipt is minted under `dynamic-grouped` (a
41
+ substrate limit, not a hole in the rule). `stableJson` stays off the root
42
+ barrel on purpose —
43
+ `hash(stableJson(tool))` would be the rule written a second time. Pinned on
44
+ real runs in both chart shapes, every tool of every epoch
45
+ (`test/lib/time-travel/receipt-conformance.test.ts`), on the unserializable
46
+ cases and on the barrel by identity
47
+ (`test/lib/time-travel/tool-digest-input.test.ts`).
48
+
49
+ ### Changed
50
+
51
+ - **`milestoneStops` is a filter over footprintjs's own stop grammar.**
52
+ footprintjs 9.18.0 shipped `filterStops(stops, keep)` — the bookend guard, the
53
+ re-partition, `Stop.meta` for a strategy's own vocabulary and `Stop.prologue`
54
+ on a start that absorbed stages — because two consumers had each re-derived
55
+ all of it by hand against 9.17. This was one of them. The hand-rolled guard,
56
+ the re-partition loop and `milestoneOf`'s re-derivation are gone;
57
+ `milestoneStops` is now one expression, `filterStops(commitStops(log, tree),
58
+ keep)`, and the one owner of the `[start, …stages, end]` contract is the
59
+ library that returns it. Public names and signatures are unchanged
60
+ (`milestoneOf`, `milestoneStops`, `milestoneStopsStrategy`); the stops are
61
+ now typed `Stop<Milestone>`, so `cursor.at()?.meta?.kind` is typed, and
62
+ `milestoneOf(stop)` reads that `meta` when a stop carries one and falls back
63
+ to classifying the `runtimeStageId` for a stop from another strategy (a meta
64
+ of some other vocabulary is not mistaken for a milestone). **Behaviour is
65
+ identical and proven, not asserted:**
66
+ `test/lib/time-travel/milestone-stops-equivalence.test.ts` carries the 9.88.0
67
+ implementation verbatim and drives it beside the new one over every recorded
68
+ fixture the milestone tests use — both chart shapes, a skill graph, a
69
+ dynamic-grouped run with its drilled inner histories, a plumbing-only log, an
70
+ empty log, and a paused-then-resumed run including the 9.18 chained axis —
71
+ requiring agreement on every stop's step, id, kind, label and commit range and
72
+ on the `stateAt` fold at every stop. The only differences are the two things
73
+ 9.18 added, and both are asserted present and right: `meta` is the milestone
74
+ on every milestone stop and absent on the bookends; `prologue: true` is on
75
+ the start exactly when it absorbed a stage. A renderer that means "before
76
+ anything ran" can now check `kind === 'start' && !prologue` instead of
77
+ assuming it from the kind. `footprintjs` peer and dev ranges move to
78
+ `^9.18.0`. `milestone-stops-contract.test.ts` still mocks a broken
79
+ `commitStops` and requires the refusal to reach the consumer — it is now the
80
+ port's refusal (`filterStops: expected a bookended axis …`), not ours.
81
+ `test/type-regressions/MilestoneStops.assignability.test.ts` pins that the
82
+ bare 9.88.0 shapes (`TimeTravelStrategy`, `Stop[]`, `milestoneOf(Stop)`)
83
+ still compile.
84
+
85
+ ## [9.88.0] - 2026-09-07
86
+
87
+ **The receipt at the stop.** Stand on an `llm-turn` stop, ask what the model
88
+ read, and until now the honest answer was "most of it". The request a provider
89
+ receives is assembled from committed pieces and is itself never committed — the
90
+ `call-llm` bundle holds the response, not the ask. Two things now stand at every
91
+ turn, and one law binds them:
92
+
93
+ ```
94
+ hash(servedAt(k)) === receiptAt(k).hash
95
+ ```
96
+
97
+ `servedAt(snapshot, epoch)` rebuilds the request from the committed pieces;
98
+ `receiptAt(snapshot, epoch)` reads the hashes-and-references record the call
99
+ itself left behind. Agreement means the record is complete. Disagreement means
100
+ something reached the model that the run never wrote down — a defect in the
101
+ record, not in the check.
102
+
103
+ Writing the conformance test found **five** places where the committed pieces
104
+ and the sent request had drifted apart. A four-lens review then found eleven
105
+ more, and two independent verifiers found five more after that — two of them
106
+ NEW instances of the very laws the previous pass was enforcing. Every one is
107
+ reproduced and closed below, each with a test that fails without the fix. None
108
+ was closed by loosening an assertion.
109
+
110
+ And then a fourth pass found two more, in the same place, for the same reason:
111
+ the gap catalogue was checked by a person reading it against the two shapes, and
112
+ three careful readings came up short three times. That is not a run of bad luck.
113
+ It is the defect this library diagnosed in 9.86 — **a hand-counted list is short
114
+ the day after** — so the catalogue is no longer counted. It is WALKED:
115
+ `test/lib/time-travel/gap-catalogue-walk.test.ts` derives every field of both
116
+ shapes and requires each one to be named by a gap or excused in writing, then
117
+ damages a real recording the way each gap describes and requires every field
118
+ that moves to be named by that gap.
119
+
120
+ Then a **fourth review round read the walk instead of trusting it**. The walk
121
+ was green and bit on five attack
122
+ probes — and three shipped sentences a renderer prints were still FALSE, which
123
+ is the half no walk can check. The one that matters most said only `Agent` has a
124
+ run id to salt the hashes with, in a paragraph explaining why `LLMCall` mints no
125
+ receipt; `LLMCall.ts` · `createExecutor` mints `runId: makeRunId()` exactly as
126
+ `Agent` does. The other two were the same defect in the account itself: a gap
127
+ naming a field whose absence its own mechanism does not cause. And the walk's
128
+ DIVERGENCE half turned out to be vacuous for two of its four rows — the damages
129
+ moved nothing, so both rows passed while proving nothing, and one gap's field
130
+ list could be emptied outright with the file still green.
131
+
132
+ And then a SEVENTH round drove a real view for every sentence in the catalogue
133
+ instead of reading them, which is how this release actually ends. It found four
134
+ more printed sentences that mislead a reader — one of them written by the round
135
+ before it, and MEASURED false: `no-run-log` said its fields "could not be fully
136
+ recovered here" on a view where the damaged rebuild is byte-identical to the
137
+ intact one. It also overturned that round's own conclusion, that a reduced
138
+ sentence cannot go false. Ten of the eleven still can. What ends the class is
139
+ not a rule about wording but an ASSERTION per claim, driven on a real run, and
140
+ that file now exists. All of it is closed below, at the root, additively.
141
+
142
+ ### Added
143
+
144
+ - **`servedAt(source, epoch)` / `servedViews(source)`** — the request an epoch
145
+ was served, rebuilt from the log: the joined system text and its pieces, the
146
+ conversation as sent, the request-only lines, the tool names and schemas, the
147
+ forced tool and the wrap-up withholding. Works on a live snapshot and on a
148
+ recording read back from JSON, in both chart shapes.
149
+ - **`receiptAt(source, epoch)`** — the receipt, or `undefined` on a recording
150
+ made before this release. A missing receipt never makes an epoch unreadable:
151
+ `servedAt` rebuilds it either way, so an old recording stays readable. (It
152
+ does return `undefined` for an epoch the run does not have, which is
153
+ `epochAt`'s answer rather than a fact about receipts — the docstring says so
154
+ now and used to say the opposite.)
155
+ - **`ServedView.gaps`** — what the log honestly cannot rebuild, each entry
156
+ naming the receipt field it explains. `SERVED_GAPS` is the catalogue those
157
+ sentences come from, so a renderer prints the library's own wording. A rebuild
158
+ that quietly omits a piece looks exactly like one that proved the piece
159
+ absent; this is what keeps them apart.
160
+ - **`epochAt` / `epochLocations`** — the ONE owner of where an iteration's
161
+ pieces live: the run's own log under `reactMode: 'dynamic'`, the turn's inner
162
+ `sf-llm-call` history under `'dynamic-grouped'`. `contextLedger` and
163
+ `context-bisect`'s trajectory assembler each carried a private copy of that
164
+ fork; both now ask this one and their own copies are gone. An `EpochLocation`
165
+ carries the FOLD SOURCE its log belongs to — the log plus the base it was
166
+ recorded against — and that fold's `basis` verdict.
167
+ - **`keyedFold(source)`** — the value of one state key at one commit, folded
168
+ from the run's own base. Built because a RESUMED run replays a fiction
169
+ without it: a resume is a fresh executor seeded from `checkpoint.sharedState`,
170
+ so the whole pre-pause world is the resumed run's fold BASE and not its log,
171
+ and `commitValueAt` says in its own docstring that it cannot see a base.
172
+ Measured on a paused-and-resumed agent under `reactMode: 'dynamic'`: the old
173
+ read returned an EMPTY system prompt and a one-message window for a call that
174
+ really went out with 27 characters of prompt and three messages, and declared
175
+ no gap. It now rebuilds both and matches the wire, in both chart shapes.
176
+ `keyed-fold-equivalence.test.ts` pins every answer against footprintjs's own
177
+ `stateAt`, which is used here for the base and the `basis` verdict.
178
+ - **`receipt.params`** — the sampling knobs the call went out with:
179
+ `temperature`, `maxTokens`, `thinkingBudget`, `stop`, `toolChoice`, read off
180
+ the request the provider PORT was handed. The same context at
181
+ `temperature: 0` and at `1.2` is a different call, and "why did this turn
182
+ ramble?" is unanswerable from a record that kept the prompt and dropped the
183
+ dial. Scalars and short strings; no bytes, no privacy change.
184
+ - **`receipt.cache.markersApplied`** — which `cache_control` breakpoints the
185
+ strategy actually applied, three scalars each (`field`, `boundaryIndex`,
186
+ `ttl`). `transformHash` is a digest over the whole prepared request: inside a
187
+ run it says only "something changed", and across epochs it is not comparable
188
+ at all — so it could not answer *did the breakpoints move between call 3 and
189
+ call 4?*, which is the question that decides an Anthropic bill. Two receipts'
190
+ `markersApplied` answer it by inspection.
191
+ - **`receipt.cache.transform`** — `'unchanged' | 'rewritten' | 'unknown'`.
192
+ Branch on this, never on `transformHash === null`.
193
+ - **`RECEIPT_BOUNDARY`** — the one sentence every receipt field is true at, so
194
+ a renderer prints the library's own wording: *a receipt describes the request
195
+ as this library last saw it.* Where that is — the provider port — is in the
196
+ comment beside the constant, because the sentence is printed and the sixth
197
+ round's rule is that a printed sentence names no mechanism.
198
+ - **`ServedView.basis`** — `{ model, provider, runId }`, read off the receipt.
199
+ A served view could not previously say WHICH model saw this, only that
200
+ something did. Absent when no receipt was committed, and deliberately not
201
+ part of the law: there is no committed counterpart to check it against.
202
+ - **`AgentOptions.recordReceipt`** — the receipt's OFF SWITCH. Default ON.
203
+ There is no privacy reason to decline it (it carries no bytes) but there is a
204
+ cost reason: one commit-log value per iteration plus a SHA-256 per system
205
+ piece, per message and per tool schema. An offline eval loop scoring ten
206
+ thousand turns nobody will scrub is entitled to skip all of it. `servedAt`
207
+ still rebuilds every epoch; `receiptAt` returns `undefined`, exactly as on a
208
+ pre-9.88 recording.
209
+ - **Four more `ServedGapKind`s**, because a view must be able to say what it
210
+ cannot prove: `no-fold-base` (the recording travelled without
211
+ `initialState` — most of a resumed run is then unreadable),
212
+ `no-conversation-on-record` (neither `history` nor `messagesInjections` was
213
+ committed: the turns are UNKNOWN, not empty), `no-run-log` (a subtree handed
214
+ in on its own loses every run constant) and `provider-defaults` (the sampling
215
+ dials are the PORT's values; a vendor may resolve its own). Seven kinds ship,
216
+ with `no-receipt-on-chart` below.
217
+ - **`UNGAPPED_FIELDS`** — the other half of the account. `SERVED_GAPS` says what
218
+ a rebuild cannot prove; this says which fields need no gap and, in one
219
+ sentence each, why (`omittedForAttention`, `callRuntimeStageId`, `gaps`).
220
+ Between them they cover every field of a `Receipt` and a `ServedView`, and a
221
+ walk is what keeps that true rather than a person's reading. TWO reasons
222
+ qualify a field for this list and they are not the same reason: no fold can
223
+ fail to produce it (`callRuntimeStageId`), or its absence is universal and has
224
+ nothing to do with this recording (`omittedForAttention` — no chart supplies
225
+ it, on any run). A field a gap DOES name never belongs here, whatever else is
226
+ true of it, which is why `epoch` left.
227
+ - **`ServedGap` kind `no-receipt-on-chart`** — an epoch that minted no receipt
228
+ now says so. `LLMCall` and the two message-API charts run a `call-llm` stage
229
+ and rebuild perfectly, but mint nothing, so `ServedView.basis` was dropped and
230
+ NOTHING explained it — a Lens denying rather than omitting, on a source
231
+ `servedAt`'s own `@param` names as supported. The gap names the fields only a
232
+ receipt carries (`basis.*` — all four, `params`, `cache.*`) and says what
233
+ follows for them. WHY there is no receipt is carried as data, not prose — see
234
+ `ServedGap.cause` below.
235
+ - **`ServedGap.cause` and `ServedGapCause`** — the discriminating fact, computed
236
+ where it is known instead of guessed at in a sentence.
237
+ `'no-receipt-committed'` when nothing was written under the receipt key;
238
+ `'receipt-shape-rejected'` when something WAS and it carries no basis, so the
239
+ read refused it — the value that means the recording is damaged rather than
240
+ that the run never minted. A renderer prints the gap's structural sentence
241
+ and, if it wants, the cause. Absent on every other gap: a gap carries one when
242
+ the site that raised it read something that told it.
243
+ - **`receiptHash` / `messageDigestInput`** — the digest halves of the law, so a
244
+ consumer checks the rebuild with the library's own rule instead of a fourth
245
+ copy of it.
246
+ - **`BoundaryRecorder`** carries `systemPromptText` and the tool catalog through
247
+ from `stream.llm_start`. Both were dropped there, which meant the one opt-in
248
+ that puts the prompt on the record (`recordSystemPrompt`) reached every sink
249
+ except the ordered boundary stream a replay reads.
250
+
251
+ ### Fixed
252
+
253
+ - **The system-prompt join is one function.** `joinSystemPrompt` — pieces with
254
+ content, `'\n\n'` between them — was written inline five times (`callLLM`,
255
+ `LLMCall`, both message-API charts, the tool-calls self-call frame). The
256
+ joined string is never committed, so a rebuild rests entirely on the join
257
+ being one rule; a sixth copy in the reader is exactly the defect this closes.
258
+ - **`seed` records two build-time facts** it never had to before, both
259
+ value-conditional so every other agent's committed key set is byte-identical:
260
+ `forcedOutputToolName` (so a rebuild can NAME the forced tool without reading
261
+ the receipt it is checking) and `toolWantsByName` (the last input to the
262
+ staged-refs nudge that was build-time-only — with it, the one model-facing
263
+ line written to no history is recomposed from committed state instead of
264
+ being declared a gap).
265
+ - **`stepOutputText` skips the receipt.** A run-salted digest is semantically
266
+ empty by construction; left in a step's output text it is hundreds of
267
+ characters of noise inside a character budget that then has less room for the
268
+ assistant's own words. Measured: it alone reordered `localizeContextBug`'s
269
+ suspects and demoted a planted fact below a tool. A text corpus scored by an
270
+ embedder must contain only text somebody wrote.
271
+ - **A resumed run no longer replays a fiction.** `readAtCall` /
272
+ `readRunConstant` folded with `commitValueAt`, which cannot see a run's
273
+ initial state. Every key not re-`set` after a resume folded to absent, so
274
+ `servedAt` returned an empty system prompt and a truncated conversation while
275
+ declaring NO gap. Both now fold through `keyedFold`, from the base the
276
+ recording carries; when that base did not travel, the view raises
277
+ `no-fold-base` instead of a confident empty one.
278
+ - **`servedAt` no longer asserts an empty conversation on an `LLMCall` run** —
279
+ a source its own JSDoc named. Those charts have no `history`: the messages
280
+ slot IS the conversation, and `?? []` swallowed the difference. The
281
+ messages-slot join is now one exported function (`messagesFromInjections`),
282
+ called by `LLMCall` on the way out and by the rebuild on the way back — the
283
+ same argument that made the system-prompt join one function. A chart that
284
+ committed neither source raises `no-conversation-on-record`.
285
+ - **`SERVED_GAPS['cache-transform']` no longer claims more than a receipt can
286
+ know.** It said a null `transformHash` "is the proof that this particular
287
+ call was not rewritten". It is not: the receipt is minted at the provider
288
+ PORT, and a decorated provider, a vendor adapter or a consumer's own
289
+ `complete()` rewrites downstream of it. Reproduced with an injected system
290
+ suffix and an unregistered tool, both invisible to a record reading
291
+ `'unchanged'`. The sentence is now scoped to the CACHE STRATEGY and carries
292
+ `RECEIPT_BOUNDARY`.
293
+ - **`messageDigestInput` covers the join key — BOTH of them.** `toolCallId` —
294
+ `tool_use_id` on Anthropic's wire, `tool_call_id` on OpenAI's — pairs a tool
295
+ result to the call that asked for it, and was excluded. Two parallel calls
296
+ whose results happen to be byte-identical hashed the SAME, so filing one
297
+ call's answer under another was invisible to the law. `toolName` was excluded
298
+ too, and on two shipped providers it is the join key: `GeminiProvider` ·
299
+ `toGeminiContents` pairs a `functionResponse` to its call BY NAME (and drops
300
+ a non-real id), and `OllamaProvider` · `toOllamaMessages` puts `tool_name` on
301
+ the wire. There, two `role:'tool'` messages with identical text and SWAPPED
302
+ names still fingerprinted identically — the exact mis-pairing the id was
303
+ added to catch, invisible on the providers that need it most. It rides as its
304
+ own field beside the id, so neither can absorb the other's bytes. It also now covers `thinkingBlocks` and
305
+ `toolCalls[].providerMeta` as `stableJson` fingerprints: `adapters/types.ts`
306
+ is explicit that a signed thinking block must be echoed byte-exact or the API
307
+ rejects the turn, so two requests that differ only there are not the same
308
+ request. Signatures are opaque tokens, not content — no bytes are added.
309
+ - **`stableJson` no longer collapses the unreadable to the empty string.** It
310
+ returns `undefined`, and callers substitute a mark (`UNSERIALIZABLE`) or
311
+ branch. Before, two DIFFERENT requests that could not be serialized compared
312
+ equal, and the receipt wrote `transformHash: null` — "the cache strategy
313
+ changed nothing" — about a pair it had never read. That case is now
314
+ `cache.transform: 'unknown'`.
315
+ - **`readRunConstant` on a subtree says so.** Handed a recording with no run
316
+ log, it read every run constant as absent; the view now raises `no-run-log`.
317
+ - **The per-epoch scrub is a constant factor over the batch form.**
318
+ `epochLocations` re-located every epoch on every `servedAt` call, and the
319
+ iteration number was resolved by rescanning the log per bundle. Measured
320
+ against that shape: the scrub cost 2.4x the batch form at 13 epochs, 3.7x at
321
+ 49 and 5.0x at 97 — the factor grew with the run, which is what turned a
322
+ 600-turn scrub into 20.8 s. Epochs are now located once per recording
323
+ (a module-level `WeakMap`), the iteration is read from the same one-pass index
324
+ every other key uses, and the fold resumes forward rather than replaying from
325
+ its anchor. The overhead is 1.0-1.1x at every size, and the absolute scrub of
326
+ a 96-turn run (1,651 commits, 97 epochs) fell from 50.2 ms to 17.8 ms. The
327
+ batch form pays for the correctness fix: 10.0 ms to 16.2 ms on the same run,
328
+ because every read now folds from the base.
329
+ - **Control characters in `receipt.ts` are written as escapes.** The digest
330
+ separators were literal `U+001C`-`U+001F` bytes in the source — invisible in a
331
+ terminal, in a diff and in review, and one `sed` away from being eaten. Same
332
+ bytes, same hashes; they can now be read.
333
+ - **`test/lib/time-travel/receipt-conformance.test.ts` · `describe('a redacted
334
+ run')` was a NO-OP, and two READMEs documented what it pretended to prove.**
335
+ It passed `redact: [...]` to `Agent.create`, which has no such option;
336
+ `tsconfig.json` excludes `test/`, so the unknown key was never typechecked and
337
+ was silently dropped. The run was not redacted, and the READMEs' "honest edge"
338
+ told a reader a recording was safe to pass on. The case is replaced by what is
339
+ true — an agent recording carries the plaintext, the receipt carries
340
+ unredacted run-salted hashes of it, and redaction here is EXECUTOR-level
341
+ (`flowchartAsTool({ redact })` scrubs an inner run's commit log; note that the
342
+ same snapshot's live `sharedState` is not scrubbed). Both READMEs now say so.
343
+ `test/type-regressions/AgentOptionsRedaction.assignability.test.ts` pins it at
344
+ the compiler: `redact` is not an `AgentOptions` key and an excess property on
345
+ the literal is refused. The false sentence itself is swept out of the two
346
+ places the previous pass missed — `receipt.ts`'s THIRD LAW, which said the
347
+ salt "is the reason a recording is safe to pass on", and the PRINTED takeaway
348
+ of `examples/observability/24-receipt-at-the-stop.ts`, which a reader copies.
349
+ Both now say what a recording actually contains: the salt protects the
350
+ fingerprints and only the fingerprints.
351
+
352
+ #### The five the verifiers found — two of them new instances of these laws
353
+
354
+ - **`servedAt` raises `no-fold-base` off BOTH folds, not one.** An epoch has
355
+ two: the log holding its call, and the RUN log holding its build-time
356
+ constants (the forced tool's name, the `wants` the staged-refs nudge is
357
+ composed from). Under `reactMode: 'dynamic-grouped'` those are different logs
358
+ with different bases, and `EpochLocation.runBasis` — added by the previous
359
+ pass for exactly this — was computed, exported on a public type, and read by
360
+ nobody. So a grouped recording that travelled without its RUN base read every
361
+ run constant as absent and declared NO gap: the Lens denying rather than
362
+ omitting, which is the law the two blocking fixes before it were about. The
363
+ gap now also names what a missing run base costs — `tools.names`,
364
+ `tools.forced`, `messages.requestOnly`.
365
+ - **A fold's answers are DETACHED.** `keyedFold` memoizes: the same object comes
366
+ back for every read of the same question, and the forward cursor seeds every
367
+ later epoch's replay from that very object. `servedAt` then aliased those
368
+ objects straight into `ServedView` (`tools.schemas`, and `messages.asSent` in
369
+ the common case), so a consumer that edited what it was handed silently
370
+ rewrote what LATER epochs reported was served. This was new in 9.88.0 — the
371
+ reader it replaced, footprintjs's `commitValueAt`, clones per call, so the
372
+ same edit was harmless before; the memo introduced it, and the memo is where
373
+ it is closed. Every answer is now deep-frozen before it is cached, which is
374
+ what footprintjs's own `stateAt` already does, and `servedAt` copies the two
375
+ containers it would otherwise alias so a `ServedView` is a value in its own
376
+ right. `receiptAt` and `epochLocations` (array and locations) are frozen for
377
+ the same reason. Cost, measured at 601 epochs / 10,219 commits: see below.
378
+ - **`receipt.params` describes the request the PORT got.** It was read from
379
+ `baseRequest` — the request handed TO the cache strategy — so a strategy that
380
+ rewrote `maxTokens` or `temperature` left five receipt fields describing
381
+ something the port was never handed, no gap named them, and
382
+ `SERVED_GAPS['provider-defaults']` asserted the falsehood in words ("the
383
+ sampling knobs on the receipt are the values the PORT was handed"). It now
384
+ reads `preparedRequest`, which is what `ReceiptParams` and `RECEIPT_BOUNDARY`
385
+ already promised, and the `provider-defaults` sentence is true.
386
+ - **`SERVED_GAPS['cache-transform']` names everything a rewrite could have
387
+ changed.** It listed the three `cache.*` fields — the report — and excused
388
+ nothing it reports on. A strategy is handed the whole composed request, and
389
+ both the receipt and the rebuild describe the version it was GIVEN, so the
390
+ gap now also covers `system.*`, `messages.*` and `tools.*`. `params` is
391
+ deliberately not among them: that one is read past the strategy.
392
+ - **`FlowchartAsToolOptions.redact` no longer claims a kept record is safe to
393
+ serve back to a model.** The commit-log half of the claim is true; the
394
+ `sharedState` of a kept recording is the live view and holds the plaintext.
395
+ The behaviour is a different subsystem's decision and is recorded, with its
396
+ reproduction and what a fix would cost, as entry 6 of
397
+ `docs/design/2026-09-recorded-not-built.md`.
398
+ - **`ServedGap.fields` says which spelling it uses.** The catalogue is rendered
399
+ beside a `ServedView` but names `Receipt` paths, and three of them differ
400
+ (`system.hash`/`chars` is the view's `system.text`,
401
+ `messages.entries`/`count` is `messages.asSent`, `tools.schemaHashes` is
402
+ `tools.schemas`). The mapping is now on the field's own docstring instead of
403
+ in a renderer's head.
404
+ - **`SERVED_GAPS['no-fold-base']` names the COUNTS, and the epoch number.** It
405
+ named `system.hash` and `messages.entries` and not `system.chars` or
406
+ `messages.count` — the counts of the very things it named. The two counts
407
+ were not unnamed: `cache-transform` names them, for an unrelated reason, and
408
+ its docstring said of exactly those fields that the rebuild "produces them
409
+ and they DO agree with the receipt". So a base-less rebuild reported a
410
+ shorter prompt over fewer turns while the only entry covering the counts told
411
+ a reader they agreed. Measured on a resumed run whose base was stripped: a real system
412
+ prompt and a real window become a shorter prompt and a shorter window, and
413
+ both counts move with them. `tools.withheld` joins them, because it is folded
414
+ from `wrapUpAsked` like any other key, and the VIEW's own `epoch` too, because
415
+ a fold that cannot read `iteration` numbers the turn by its POSITION instead —
416
+ so the view and the receipt can disagree about which turn this is. (That last
417
+ entry shipped as `basis.epoch`, the RECEIPT's number, which a missing base
418
+ cannot touch. Corrected in the round below.)
419
+ - **`cache-transform`'s docstring no longer asserts agreement it cannot have.**
420
+ It said of its composition fields that "the rebuild produces them and they
421
+ agree with the receipt", which is false on every view that also raises
422
+ `no-fold-base`. Both the docstring and the sentence a renderer prints are now
423
+ scoped: this entry says the rebuild stops AT the cache strategy, never that
424
+ the rebuild got that far, and where another gap on the same view names the
425
+ same field that one is the stronger claim.
426
+ - **`no-conversation-on-record` names `messages.requestOnly`.** The staged-refs
427
+ nudge is recomposed FROM the conversation, so a rebuild with no conversation
428
+ finds no refs and reports no nudge — indistinguishable from a call that had
429
+ none.
430
+ - **A `ServedView` is a value, all of it.** Two of its six containers were
431
+ frozen (`messages.asSent`, `tools.schemas`, the two that would otherwise alias
432
+ the fold's memo); `system.pieces`, `messages.requestOnly`, `tools.names`,
433
+ `gaps` and the view object itself were plain, while the type said `readonly`
434
+ throughout. No leak — the other four are built per call — but a promise that
435
+ holds for two containers out of six is one a reader cannot use. The whole view
436
+ is frozen now, down to the pieces and gaps, and the docstring says which two
437
+ are also copies and why.
438
+
439
+ #### The fourth round — three false sentences and one vacuous half
440
+
441
+ The walk was green and bit on five attack probes. What it cannot see is whether
442
+ a sentence is TRUE, and three of them were not.
443
+
444
+ - **A shipped sentence said `LLMCall` has no run id to salt hashes with, and it
445
+ does.** `servedView.ts`'s module comment, and the `no-receipt-on-chart` `why` a
446
+ renderer prints verbatim, both explained the refusal to mint a receipt on the
447
+ three non-agent charts with THE SALT: "only `Agent` has a run id to give". But
448
+ `LLMCall.ts` · `createExecutor` mints `runId: makeRunId()` exactly as `Agent`
449
+ does and owns its own executor, so the salt is there for the taking. The
450
+ DECISION is unchanged — declare, do not mint — because the other reason holds
451
+ for all three charts: a receipt carries a cache verdict about a strategy none
452
+ of them runs, and `cache.transform` has no value meaning "no strategy ran".
453
+ The salt clause is now SCOPED to the two message-API charts, where it is true
454
+ and is the reason wiring cannot fix them: `buildMessageApiChart` and
455
+ `buildAgentMessageApiChart` are exported chart BUILDERS whose deps carry no run
456
+ id and no way to ask for one, run on a consumer's own executor. The printed
457
+ sentence gives the cache verdict and nothing else, because that is the half
458
+ that holds everywhere the gap fires.
459
+ - **The epoch account was INVERTED.** `no-fold-base` named `basis.epoch` — the
460
+ RECEIPT's number, minted live and carried in the call's own bundle, which no
461
+ missing base can move — while the number that CAN be fabricated, the view's
462
+ own `epoch`, was excused in `UNGAPPED_FIELDS` on the ground that "a caller
463
+ passes it to `servedAt` and gets it back". True of `servedAt`; untrue of
464
+ `servedViews()`, which returns whatever the fold produced, and
465
+ `EpochLocation.epoch` falls back to POSITION when it cannot read `iteration`.
466
+ Measured: a resumed run whose base and `iteration` writes had both gone
467
+ rebuilt its second turn as epoch 1 while that turn's own receipt still said 2.
468
+ Now `no-fold-base` names `epoch` and `no-receipt-on-chart` names `basis.epoch`
469
+ (a receipt-only field like the other three on `basis`), the `UNGAPPED_FIELDS`
470
+ key is gone, and `ServedGap.fields` names the ONE place the two shapes hold
471
+ two records of one fact rather than two spellings of it. Pinned by a new
472
+ conformance case that measures the disagreement.
473
+ - **`omittedForAttention` was blamed on the missing receipt.** It was in
474
+ `no-receipt-on-chart.fields`, but its absence has nothing to do with a
475
+ receipt: no chart in this library supplies it, on any recording, so it is absent on views that
476
+ HAVE a receipt too — where nothing explained it at all. It is a key of
477
+ `UNGAPPED_FIELDS` now with the true reason: a slot writes its budget drops to
478
+ `slotCompositions` inside its own subflow and no boundary bubbles them out, so
479
+ `buildReceipt` is never handed one. Recorded as entry 9 of
480
+ `docs/design/2026-09-recorded-not-built.md`.
481
+ - **The walk's divergence half was VACUOUS for two of four rows.** It asks "is
482
+ every field that MOVED named?", which a damage that moves nothing satisfies
483
+ for free. `no-run-log`'s damage moved nothing at all and
484
+ `no-conversation-on-record`'s never reached `messages.requestOnly`: measured,
485
+ emptying `no-run-log.fields` to `[]` and deleting `messages.requestOnly` from
486
+ `no-conversation-on-record` left the file green. The previous round wrote a
487
+ guard for exactly this hazard, for ONE row. It is general now: the loop
488
+ collects the moved paths per damage and FAILS on an empty set, and both rows
489
+ are driven on a run that composes a staged-refs nudge, which is the one shape
490
+ where losing the run log or the conversation really costs a request-only line.
491
+ "A damage row that damages nothing" joins the header's own blind-spot list.
492
+ - **`no-fold-base`'s "mechanical rule" named a reader it does not apply to.**
493
+ The comment said a field belongs on the list when the rebuild derives it from
494
+ `readAtCall`, `readAfterCall` or `readRunConstant`. `readAfterCall` reads the
495
+ receipt, which the call's own bundle commits — no missing base can cost it,
496
+ and naming it is what let the receipt's own `basis.epoch` onto a list it does
497
+ not belong on. The rule is now the two readers that fold over values the log
498
+ may never have written, with the third named as deliberately excluded.
499
+ - **`cache-transform` says it is unconditional.** It is raised on every view,
500
+ including the three charts that can run no cache strategy at all — where the
501
+ same view's `no-receipt-on-chart` says exactly that. The `why` now OPENS with
502
+ the condition: raised unconditionally, vacuous where no strategy ran, a
503
+ boundary rather than a claim that anything was rewritten. Raising it
504
+ conditionally instead would mean inferring "no strategy ran" from a recording,
505
+ which is the absence-of-evidence reading this whole feature refuses; recorded
506
+ as entry 8 of `docs/design/2026-09-recorded-not-built.md`.
507
+ #### The fifth and sixth rounds — a printed gap sentence stops describing code
508
+
509
+ Five review rounds, and each one found NEW false prose in the sentences the
510
+ round before had just written, at a roughly constant rate. That is not a run of
511
+ careless writing. It is the law this library named in 9.84–9.86 — **a sentence
512
+ composed once and read many times is a PREDICTION** — one surface over: composed
513
+ once, and read against every later version of the code it describes.
514
+
515
+ The reproduction is one shipped string. `SERVED_GAPS['no-receipt-on-chart'].why`
516
+ said *"THREE causes and none of them is a hole in this view: …"*. A fourth path
517
+ was then added — a value under the receipt key refused because it carries no
518
+ basis — and BOTH halves went false at once: four causes, and that one IS a hole.
519
+ Nobody edited the string. Nobody had to.
520
+
521
+ **THE RULE, AND THEN THE RULE THAT REPLACED IT.** The fifth round allowed a
522
+ gap sentence three things: which fields it covers, what MECHANICALLY could not
523
+ be established, and what therefore follows. The sixth round deleted the middle
524
+ one, and the reason is the whole story of this release.
525
+
526
+ Five rounds tried to write TRUE mechanism sentences and the rate of new
527
+ falsehoods held constant. So the sixth put one question to all ten printed
528
+ sentences — *could this become false without anyone editing it?* — and NINE
529
+ could, two of them being false the day they shipped. Exactly one could not:
530
+
531
+ > `UNGAPPED_FIELDS.gaps` — *"The account itself rather than a fact about the
532
+ > request: a gap naming this list would be the account excusing its own
533
+ > absence."*
534
+
535
+ It survives because it makes **no claim about code**. It says what the field
536
+ means inside the account, and nothing outside the sentence can falsify it. Every
537
+ other sentence described a MECHANISM — "the fold could not read", "only its
538
+ inputs are on the record", "the request-only lines are recomposed from the
539
+ conversation" — and a mechanism is code, and code moves. The conclusion is not
540
+ to write them better. It is to STOP WRITING THEM.
541
+
542
+ **A printed gap sentence may now say only three things: WHICH FIELDS it covers,
543
+ WHAT THEY MEAN ON THIS VIEW for the person reading, and WHAT TO DO
544
+ DIFFERENTLY.** It may not name a module, a function, a key, a version, a chart,
545
+ a strategy, an option, or any mechanism at all — not `initialState`, not "the
546
+ fold", not "the cache strategy", not "recomposed from". A sentence that needs
547
+ one of those words to be understood is explaining WHY the gap exists, which is
548
+ not the printed sentence's job. NINE of the ten got shorter — the tenth is the
549
+ one that already had the shape, and it is unchanged to the byte.
550
+
551
+ **None of it is lost.** The mechanism moved into the code comment above each
552
+ catalogue entry, phrased for a maintainer and carrying the `file · symbol`
553
+ pointers that are correct there and banned in printed prose. The CAUSE is
554
+ already data (`ServedGap.cause`). The docs still explain the mechanism at
555
+ length, because a doc is versioned with the code and its reader can open the
556
+ file — so `src/lib/time-travel/README.md` and the docs-site page are now
557
+ deliberately LONGER than what a renderer prints, and both say so.
558
+
559
+ **WHAT IT RULES OUT — AND THE CLAIM A SEVENTH ROUND OVERTURNED.** The sixth
560
+ round shipped this paragraph saying the reduction had made the prose
561
+ UNROTTABLE: a sentence with no code claim in it cannot go false when the code
562
+ changes, so the class is closed outright rather than merely thinned. A verifier
563
+ then read all eleven printed sentences one at a time, and that is false. **TEN
564
+ of them still make a claim a code edit falsifies.** Exactly one does not —
565
+ `UNGAPPED_FIELDS.gaps` — and it does not because it is SELF-REFERENTIAL: it says
566
+ what its field is inside the account, not anything about the request. The other
567
+ ten cannot copy that shape, because a sentence that tells a reader something
568
+ USEFUL — *may be SHORT*, *absent means unknown*, *the tool list is complete and
569
+ the schemas are one short* — is a claim about how the rebuild behaves, and the
570
+ rebuild is code. **The reduction changed the VOCABULARY of the claims, not their
571
+ CLASS.**
572
+
573
+ What the reduction really buys is smaller and still worth the rows: the
574
+ sentences are short and readable, and the enumerations that went false in five
575
+ rounds have nowhere to come back through. It also makes the rule enforceable.
576
+ The fifth round's checker was PHRASING-shaped and near-synonyms walked through
577
+ it; the sixth round's printed surface admits no code-shaped token — dotted path,
578
+ `.ts` file, camelCase, PascalCase, `SCREAMING_SNAKE`, a call with parens, a
579
+ quoted option name, a version number — and no mechanism verb from a closed list
580
+ of ten. That is close to a whitelist, and a whitelist has no synonyms.
581
+
582
+ **WHAT ACTUALLY CLOSES THE CLASS IS A RUN, and the evidence is in the same
583
+ report that overturned the claim.** Driving one real view per gap, the verifier
584
+ recorded seven sentences HOLDING and four MISLEADING — and caught a BRAND-NEW
585
+ false sentence in the very round written to end false sentences. No rule caught
586
+ it. Measurement caught it. So a gap sentence MAY make a code claim, because a
587
+ sentence that makes none cannot inform, and **every claim it makes is now
588
+ ASSERTED against a real view in a test that sits beside it**:
589
+ `test/lib/time-travel/gap-sentences.test.ts` drives one run per catalogue entry,
590
+ decomposes each sentence into quoted clauses, pairs every clause with its own
591
+ assertion, and requires the clauses to PARTITION the sentence so no printed word
592
+ sits outside a checked claim. The prose rule stays and is no longer sold as the
593
+ thing that makes the sentences true.
594
+
595
+ **THE HONEST LIMIT**, in the new file's header and in the checker's: a claim
596
+ nobody wrote an assertion for. The partition guarantees each clause has a test;
597
+ it cannot guarantee the test is as strong as the clause. That is a smaller blind
598
+ spot than six rounds of rewriting produced, and it is the whole of it.
599
+
600
+ - **The rule is a checker, not a habit.** `test/helpers/gapProseClaims.ts` is
601
+ the reader-facing sibling of `modelFacingClaims.ts`: **six** banned shapes now
602
+ — the two REDUCTION rows the sixth round added (code shape, mechanism verb),
603
+ then the four PHRASING rows the fifth round wrote (cardinality, benignity,
604
+ discrimination, cross-module), kept as the second line of defence and
605
+ redundant on the printed surface by design. Each carries the reason an edit
606
+ elsewhere falsifies it, and a structurally-required exemption argument (the
607
+ same discriminated union, proven the same way in
608
+ `test/type-regressions/GapProseClaims.assignability.test.ts`). The three
609
+ strong rows stand down on `'prose-doc'`, because naming the mechanism is what
610
+ a doc is FOR; the other three do not, because a cause count goes stale in a
611
+ doc exactly as it does in a constant.
612
+ - **Every printed gap sentence is one or two sentences and names nothing.**
613
+ `no-receipt-on-chart` now reads in full: *"Nothing on this view has been
614
+ checked against what went out. Every field below is missing as a whole, and an
615
+ absence among them says nothing about the call — not even that a dial was left
616
+ unset."* `forced-tool-schema` is 26 words. `callRuntimeStageId`'s excuse is 12.
617
+ - **TWO SENTENCES WERE FALSE THE DAY THEY SHIPPED, and both were mechanism
618
+ claims, so the rule deletes the category rather than the instances.**
619
+ `cache-transform` said *"only its INPUTS are on the record"* while three of
620
+ the fields it covers are OUTPUTS that are on the record — `cache.transform`
621
+ (the verdict of comparing what the strategy was given against what it handed
622
+ back), `cache.transformHash` (the digest of the result, when they differed)
623
+ and `cache.markersApplied` (the breakpoints actually applied, as against the
624
+ candidates in `scope.cacheMarkers`, which are the inputs). And
625
+ `no-receipt-on-chart` opened *"No receipt was found for this epoch"* and
626
+ closed *"absent here means unrecorded"* — both false under
627
+ `'receipt-shape-rejected'`, where a receipt WAS written and the read refused
628
+ it, which is to say the sentence asserted which cause applied and was wrong
629
+ for one of two. Neither can be written under the new rule. Both are corrected
630
+ in the two doc tables and in the comment beside each entry, where the
631
+ mechanism now lives, rather than merely dropped.
632
+ - **`RECEIPT_BOUNDARY` was the one printed sentence exempted from the rule, and
633
+ the exemption is gone.** It named `LLMProvider.complete` and `complete()` — a
634
+ module and a call, printed to a reader who cannot open either. It now reads
635
+ *"A receipt describes the request as this library last saw it. Whatever
636
+ handled it after that could have changed it, and nothing on the receipt would
637
+ show that."* The port, the decorated provider, the vendor adapter and the
638
+ vendor's own defaults are in the comment above the constant. One string, one
639
+ rule set: the walk no longer strips it before judging, and asserts it on its
640
+ own as well as inside the two entries that quote it.
641
+ - **THE CAUSE IS A VALUE.** The enumeration was prose doing DATA'S job: a frozen
642
+ constant cannot know which cause applied at the site it is printed beside, so
643
+ it listed them all and hoped. `ServedGap.cause` carries the answer now,
644
+ computed in the one function that has it — the receipt read reports whether
645
+ the key held nothing (`'no-receipt-committed'`) or held something it refused
646
+ (`'receipt-shape-rejected'`). Same "one fact, one owner" move that fixed the
647
+ `read_skill` refusals in 9.86. One test per cause: the first on a real
648
+ `LLMCall` run, the second on a crafted recording whose receipt key holds a
649
+ value with no basis, since no run produces one. The set is closed AT THE SITE
650
+ and deliberately no wider — a pre-9.88 recording, `recordReceipt: false` and a
651
+ chart that mints none all leave the same record, and claiming to separate them
652
+ would be this field repeating the defect it was added to fix.
653
+ - **`ServedView.basis`'s docstring claimed the gap discriminates.** It said the
654
+ gap "says which of the three it was", which it never could. It points at
655
+ `cause` now.
656
+ - **`receiptAt`'s docstring merged two causes, omitted a third, and denied a
657
+ fourth.** It said `undefined` has three causes, gave `recordReceipt: false` as
658
+ a gloss on "this chart mints none" (two different causes), left out the
659
+ malformed-receipt refusal, and asserted that none of them is "the epoch is
660
+ missing" — which is false, because `epochAt` returning nothing is exactly one
661
+ of the ways it returns `undefined`. It now gives the same account
662
+ `ServedGapCause` gives, so the two exported accounts cannot disagree, and says
663
+ plainly that a missing epoch is a separate answer.
664
+ - **`UNGAPPED_FIELDS.omittedForAttention` said "no chart supplies it".** True of
665
+ this library and not of the world: `buildReceipt` is a pure exported mint, so
666
+ a consumer can hand it the fact. Narrowed to **no chart in this library** —
667
+ and then, in the sixth round, moved out of the printed sentence altogether,
668
+ because a dated measurement over a set of charts is a claim about code. The
669
+ measurement is unchanged and still re-taken by the walk on every run; it reads
670
+ the claim from the COMMENT beside the entry now, so a chart that starts
671
+ supplying one still fails the suite instead of aging the sentence. What a
672
+ reader is shown is what the field means: *"Absent means nobody recorded a
673
+ drop, never that nothing was dropped."*
674
+ - **`examples/observability/24-receipt-at-the-stop.ts` clipped a printed reason
675
+ mid-version-number.** It printed `reason.split('.')[0]`, so "measured on
676
+ 9.88.0" reached a reader as "measured on 9." — a fragment that reads as a
677
+ complete sentence. It wraps on word boundaries now, and prints the gap's
678
+ `cause` beside its kind.
679
+ - **The walk's divergence half now STATES ITS MEASUREMENT instead of implying
680
+ coverage.** The header claimed the vacuous-row guard was closed "by driving
681
+ each row on a run that reaches its own fields". Measured, that clause is
682
+ false: `no-fold-base` moves 7 of the 11 fields it names, `no-run-log` 1 of 4,
683
+ `no-receipt-on-chart` 3 of 8, and only `no-conversation-on-record` reaches all
684
+ 3. The reasons are structural — a receipt-only field never appears on a view,
685
+ so removing the receipt cannot MOVE it — so the header names the numbers per
686
+ row, a test pins them so the table cannot go stale, and it says plainly that a
687
+ field claim divergence does not reach is carried by the coverage half and by
688
+ `receipt-conformance.test.ts`. No new machinery was built for this; the honest
689
+ fix was to say what was measured.
690
+ - **The gap tables in `src/lib/time-travel/README.md` and
691
+ `docs-next/content/docs/debug/time-travel.mdx` were short AND over-broad on the
692
+ day they shipped** — `no-run-log` missing `tools.schemaHashes`,
693
+ `cache-transform` claiming all of `tools.*` when it names two of the four
694
+ fields under `tools`. That is this release's own defect one layer out, where
695
+ the walk was not looking. Both tables now carry the LITERAL field lists, and
696
+ the walk parses them: every row must match `Object.keys(SERVED_GAPS)`, every
697
+ field cell must match that entry's `fields` exactly, and no cell may name a
698
+ field the catalogue has moved to `UNGAPPED_FIELDS`. A doc that restates a
699
+ frozen exported constant is checked against it, not retyped.
700
+
701
+ #### The seventh round — every sentence is asserted against a real run
702
+
703
+ The rounds above all ended by READING the sentences. This one drove a view for
704
+ each of them, which is how the library closes everything else, and it found a
705
+ different class of defect: not prose that names a mechanism, but prose that is
706
+ plain, short, rule-abiding and UNTRUE OF THE VIEW IT IS PRINTED BESIDE.
707
+
708
+ - **`no-run-log` claimed a loss that a run says did not happen.** It read *"The
709
+ fields below could not be fully recovered here"* — an assertion that recovery
710
+ DID fail. Measured on the ordinary view that raises it, a
711
+ `'dynamic-grouped'` agent with one plain tool and its `commitLog` emptied:
712
+ `tools.names`, `tools.schemas`, `tools.forced` and `messages.requestOnly` all
713
+ come back BYTE-IDENTICAL to the intact view. The gap fires and costs nothing,
714
+ because that run has no forced tool name and no `wants` to lose. It now reads
715
+ *"The fields below may be SHORT: a name can be missing from the tool list, and
716
+ a line that went out with the request can be missing too. An absence below is
717
+ not evidence that there was nothing there — read the whole recording rather
718
+ than a piece of it."* **The repair is the sentence and not the condition**,
719
+ and the reason is that the condition cannot be narrowed by anything the read
720
+ can see: whether the run had a constant to lose is recorded in the log whose
721
+ absence raises the gap. Both directions are asserted — emptying the run log
722
+ takes a forced-output run's tool list from one name to none and a staged-refs
723
+ run's request-only line to nothing, and takes nothing at all from the plain
724
+ run. Its second clause was loose as well: it said a line could go missing
725
+ *"from the conversation"*, and a request-only line is by construction in no
726
+ conversation — asserted now against the wire, which carries it as the last
727
+ message of the request while the rebuilt `messages.asSent` does not contain it.
728
+ - **Three sentences were true where they were composed and misleading where they
729
+ were PRINTED.** One sentence, several contexts: the library's own Honest
730
+ Sentence law says it has to hold in all of them.
731
+ - `cache-transform` ended by quoting `RECEIPT_BOUNDARY` — *"A receipt
732
+ describes the request as this library last saw it"* — and it is raised on
733
+ EVERY view, including a receipt-less one. Measured: an `LLMCall` view
734
+ carries exactly `no-receipt-on-chart` and `cache-transform`, so the reader
735
+ was told what a receipt describes beside a view that has none. The quote is
736
+ gone from it and the claim survives in the entry's own words, in the
737
+ vocabulary of a view: *"…and nothing on this view would show it."*
738
+ `provider-defaults` keeps the quote and is the only entry that may have it —
739
+ it is pushed inside `if (receipt !== undefined)`, so a view carrying it
740
+ always has a receipt for the sentence to be about. Asserted across every run
741
+ in the new file: a gap whose `why` includes the boundary appears only on a
742
+ view whose `basis` is defined.
743
+ - `no-fold-base` said the view's number *"may differ from the one the receipt
744
+ for this turn carries"* — printed on views that carry no receipt (a
745
+ base-less `LLMCall` recording raises both gaps at once). It now says what
746
+ the NUMBER means: *"The turn number below may be this turn's place in run
747
+ order rather than the number the run itself gave it."* Asserted on the
748
+ resumed run whose base and `iteration` writes are gone: the view calls the
749
+ second turn 1 while the run's own count for it was 2.
750
+ - `no-receipt-on-chart` closed *"their absence here is a gap in the record,
751
+ never a call made without them"*. True of each field AS A WHOLE and false
752
+ one level down, which is the level a reader reads at: a receipt always
753
+ carries `params` and always carries a `cache.transform` verdict, and an
754
+ absence INSIDE `params` — measured `{}` on an agent that set no dials — IS a
755
+ call made without one. It now claims nothing about what is inside a field it
756
+ cannot see: *"…an absence among them says nothing about the call — not even
757
+ that a dial was left unset."*
758
+
759
+ - **`test/lib/time-travel/gap-sentences.test.ts` — the assertion, beside the
760
+ sentence.** One real run per catalogue entry and per `UNGAPPED_FIELDS` key,
761
+ and an assertion for each claim the sentence makes — not that the gap fired,
762
+ but that what it says about the view HOLDS. Each sentence is decomposed into
763
+ clauses quoted verbatim from the constant, each clause carries its own
764
+ assertion, and three contract tests hold the binding shut: every catalogue key
765
+ is an entry, every quote is verbatim and in order, and **the clauses PARTITION
766
+ the sentence** — strike them out and only punctuation is left, so no printed
767
+ word sits outside a checked claim. Rewrite a sentence and the partition fails,
768
+ which sends the author back to write the assertion for what it now claims. The
769
+ measurements the round took by hand are transcribed into it rather than
770
+ re-derived, and the rest were driven to fill the gaps between them.
771
+ - **`UNGAPPED_FIELDS.gaps` is labelled as the one clause class that is not a
772
+ claim about the request.** It is the only sentence no code edit can falsify,
773
+ and it is that way because it is self-referential — a statement about what its
774
+ field is inside the account. The new file carries a flag naming that category
775
+ rather than an assertion pretending to cover it, and a contract test requires
776
+ exactly that one entry to be flagged.
777
+
778
+ ### Changed
779
+
780
+ ### The receipt's three laws
781
+
782
+ 1. **Hashes and references, never bytes.** No message text, no prompt text, no
783
+ schema bodies. Those bytes are already governed — `recordSystemPrompt` is
784
+ opt-in for exactly this reason, redaction scrubs the committed mirror, a
785
+ window strategy decides what survives — and a receipt carrying content would
786
+ quietly reopen all three.
787
+ 2. **Run-salted digests.** `sha256(runId + '\u001f' + content)`, first 16 hex
788
+ characters. Hashes are NOT redacted, and the salt is why that is safe: an
789
+ unsalted hash of a one-line prompt or a two-word user turn is a dictionary
790
+ lookup away from being read back, and receipts travel inside recordings.
791
+ 3. **No authority omissions.** A receipt never names — and never counts — what a
792
+ caller's role was not allowed to see. Committed state is readable by the
793
+ trace toolpack's debugging tools, so a receipt carrying `hiddenSkillIds`, or
794
+ even "3 skills withheld", would restate a permission decision one layer down
795
+ where nobody is checking.
796
+
797
+ ### Measured
798
+
799
+ - `test/lib/time-travel/receipt-conformance.test.ts` — 51 tests over real runs:
800
+ both chart shapes, a pause and a resume in both, a skill-graph hop, a stepped
801
+ skill, a parked map, a wrap-up call, a forced output tool, a staged-refs
802
+ nudge, a cache strategy that rewrites the composition and one that rewrites a
803
+ sampling dial, a marker-applying one, a provider decorated past the port, an
804
+ `LLMCall` chart, a JSON round-trip, a pre-9.88 recording, a recording with no
805
+ fold base on either of its two folds, a recording whose base AND whose
806
+ `iteration` writes are gone (where the view numbers the turn by position and
807
+ its own receipt still says otherwise), a subtree with no run log, a reader
808
+ that tries to edit what it was handed, and the off switch. The vendored
809
+ SHA-256 is checked against `node:crypto` on every shape it hashes. Two
810
+ mutation tests drop a committed piece from the replay and require the law to
811
+ go red naming the epoch and the field.
812
+ - `test/lib/time-travel/gap-catalogue-walk.test.ts` — 44 tests, and the reason
813
+ this release has one more file than it planned. `SERVED_GAPS` was hand-checked
814
+ against the two shapes three times and came up short three times, which is not
815
+ a run of bad luck: A HAND-COUNTED LIST IS SHORT THE DAY AFTER. So the
816
+ correspondence is WALKED, the way `userTurnProducers.test.ts` walks every
817
+ `role:'user'` producer and `toolDivergenceWalk.test.ts` crosses every
818
+ claimant. It derives the field list twice — from the declarations, with the
819
+ TypeScript parser, and from five real runs — and requires every field to be
820
+ named by a gap or to be a key of `UNGAPPED_FIELDS` with a written reason;
821
+ requires every `fields` entry and every `UNGAPPED_FIELDS` key to resolve to a
822
+ field that exists, so a rename cannot leave a gap pointing at nothing; and
823
+ requires the kinds `viewOf` can push and the kinds in the catalogue to be the
824
+ same set. Then it DAMAGES a real recording the way each gap describes,
825
+ rebuilds, and requires every field that MOVES to be named by that gap — which
826
+ is the half that catches a field named by the wrong gap for the wrong reason,
827
+ and the half the first three checks could not. Verified by reverting each fix
828
+ and watching the right row go red.
829
+
830
+ **The two halves, and what each cannot prove.** The ACCOUNT half (coverage,
831
+ resolution, reachability, and the two doc tables) proves every field of both
832
+ shapes is named by a gap or excused in writing, that every pointer lands on a
833
+ field that exists, and that the prose copies say what the constant says. It
834
+ cannot prove any of it is TRUE: a gap can name a field for a mechanism that
835
+ does not cause its absence, and this release shipped two of those. The
836
+ DIVERGENCE half proves that for the four damages it can apply, no field moves
837
+ without the responsible gap naming it — and, since this round, that each
838
+ damage moves SOMETHING, so a row cannot pass by damaging nothing. It cannot
839
+ prove a row moved everything the gap is about, it has no damage at all for the
840
+ three gaps that are conditions of the RUN rather than of the recording
841
+ (`cache-transform`, `provider-defaults`, `forced-tool-schema` —
842
+ `receipt-conformance.test.ts` drives those), and the damage table is
843
+ hand-listed, so a gap whose damage nobody wrote down still gets only the
844
+ account half. Neither half reads a `why`. That is a person's job, and it is
845
+ where this release's last three defects came from.
846
+ - `test/lib/time-travel/keyed-fold-equivalence.test.ts` — 9 tests: every key at
847
+ every commit of a real run, checked against `stateAt` itself, including a
848
+ merge with no `set` anchor (which cannot be folded at all without the base),
849
+ a resumed agent in both chart shapes, and a replayed value that comes back
850
+ frozen.
851
+ - **The detachment freeze costs 23%, and the alternative costs more.** Measured
852
+ on a 601-epoch run (10,219 commits): the whole per-epoch scrub is 1,141 ms
853
+ with the freeze and 930 ms without, against 20.8 s two fixes ago; the batch
854
+ form moves 919 ms to 1,116 ms. Copying instead is dearer, not cheaper — on
855
+ the same 1,200-message structure `structuredClone` costs 0.94 ms against
856
+ `freezeDeep`'s 0.26 ms, and a copy would run once per READ where the freeze
857
+ runs once per memoized answer.
858
+ - `test/lib/time-travel/served-view-complexity.test.ts` — 2 tests: epochs are
859
+ located once per recording (checked by identity, no clock), and the per-epoch
860
+ scrub stays within 2x the batch form at both 13 and 49 epochs without
861
+ drifting. Against the shape this release replaced those ratios are 2.4x and
862
+ 3.7x, climbing to 5.0x at 97 epochs.
863
+ - Full suite: 10,348 passing, 20 skipped, 624 files.
864
+ - `test/lib/time-travel/served-view-complexity.test.ts` is a RATIO guard, and a
865
+ ratio is immune to the machine but not to contention: it failed once inside a
866
+ fully parallel `vitest run` and passes alone and in a clean full run. The
867
+ freeze does not touch its subject — the scrub and the batch form do the same
868
+ folding in the same order, so both pay it identically (measured: scrub/batch
869
+ is 1.02 with the freeze and 1.01 without). Recorded rather than widened.
870
+ - Runnable: `examples/observability/24-receipt-at-the-stop.ts` — three turns, the
871
+ law checked against the rebuild AND against the request the provider really
872
+ received, with the staged-refs nudge printed back from the record, the
873
+ sampling dials, and the port boundary printed where a reader meets it.
874
+
875
+ ### A stated limit
876
+
877
+ The `@wire` clauses of the conformance test are not an INDEPENDENT witness.
878
+ They compare the receipt against the request the provider stub really received,
879
+ which catches a rebuild that drifts from the request and a receipt that
880
+ describes something the provider never got. It does not catch a defect in the
881
+ shared assembly: the receipt and the request are minted from the same locals
882
+ inside `callLLM`, a few lines apart, so a change that alters both symmetrically
883
+ leaves every `@wire` clause green. A genuinely independent witness would have to
884
+ come from outside the process that composed the request — a recorded HTTP body
885
+ from a real adapter, or a second implementation written against the vendor's own
886
+ schema. Neither exists; the limit is stated in the test's own header rather than
887
+ left to be discovered.
888
+
889
+ Typechecking the whole of `test/` would have closed the redaction hole at its
890
+ root. It was tried and surfaces 1,054 pre-existing errors across the suite and
891
+ the examples it pulls in — a repair of its own, not a line item in this release.
892
+ The hole is closed instead in `test/type-regressions/`, which already compiles
893
+ under `npm run test:types`.
894
+
895
+ `ServedGap.fields` carries TWO relations on one list. Most entries mean *the
896
+ rebuild cannot produce this field*; `cache-transform`'s composition fields mean
897
+ *it can, and both sides agree, but only up to the cache strategy*. A checker
898
+ that granted the second as an excuse would stop checking fields the record
899
+ proves perfectly well — which is exactly what happened when the composition
900
+ fields were added, and it silently disabled one clause of the law. The clause
901
+ now asks its narrower question against its own list of gaps, and the field's
902
+ docstring names both relations. A second array on the public type is the
903
+ cleaner shape; adding a public field in a fix pass is not, so it is written
904
+ down rather than shipped.
905
+
906
+ **A green `gap-catalogue-walk` proves the fields are ACCOUNTED FOR, not that the
907
+ account is TRUE.** A gap can name a field for the wrong reason and the walk will
908
+ call it covered; a `why` can be a fluent sentence about the wrong mechanism; an
909
+ `UNGAPPED_FIELDS` reason can be wishful. Only a person reading the `why` catches
910
+ that — which is how the defect the walk was built for was found in the first
911
+ place — three times in this release, the last of them after the walk was
912
+ already green. Five narrower blind spots are named in the file's own header
913
+ rather than left to be discovered: the reachability half reads `gapOf('…')`
914
+ literals out of the source (and so fails on any `gapOf` call whose argument is
915
+ not a literal, which is the only shape that could hide one); the static half
916
+ follows type references by name and does not expand an alias, a mapped type or
917
+ an intersection; the runtime half only produces what its five scenarios reach;
918
+ the DAMAGES table is hand-listed, so a gap whose damage nobody wrote down gets
919
+ the coverage check and not the divergence one; and A DAMAGE ROW THAT DAMAGES
920
+ NOTHING passed as a green row for one release, which is why the loop now
921
+ requires each damage to move something and still says that "something" is not
922
+ "everything". Three gaps have no damage at all,
923
+ because they are conditions of the RUN rather than of the recording —
924
+ `cache-transform`, `provider-defaults`, `forced-tool-schema` — and
925
+ `receipt-conformance.test.ts` drives each of those on a real run instead.
926
+
927
+ **A recording stops being JSON-serializable at about 600 epochs**, and it is the
928
+ same reader at the same size that the epoch memo was built for. Measured on the
929
+ looping run the complexity guard uses: 301 epochs serialize to 145,238,645
930
+ characters; 601 epochs throw `RangeError: Invalid string length` — V8's maximum
931
+ string length, not a library limit. Nothing here fails before then, and none of
932
+ these readers needs `JSON.stringify` to work: `servedAt`, `receiptAt` and
933
+ `epochLocations` all read the live object. But a recording that cannot be
934
+ written to a file cannot be handed to anybody, so at that size the answer is to
935
+ persist per-epoch views rather than the whole snapshot. Stated rather than
936
+ worked around, beside the memo it shares a size with.
937
+
938
+ `flowchartAsTool({ redact })`'s kept inner recording carries an UNREDACTED
939
+ `sharedState` while the commit log is scrubbed. It is a different subsystem and
940
+ every available fix changes behaviour for runs that work today, so this release
941
+ corrects the option's own claim and records the defect — reproduction, cause
942
+ and what each fix would cost — as entry 6 of
943
+ `docs/design/2026-09-recorded-not-built.md`.
944
+
945
+ **Three more are RECORDED rather than built**, as entries 7-9 of the same file,
946
+ because the last review round's job was to make every printed sentence true
947
+ rather than to close every hole: `cache-transform` does not name `tools.forced`
948
+ or `tools.withheld` although a strategy that rewrote `toolChoice` could make
949
+ both stale (the receipt builds them from assembly's decision, not from the
950
+ prepared request); `cache-transform` stays unconditional, because raising it
951
+ conditionally would mean inferring "no strategy ran" from a recording; and a
952
+ slot's attention drops never reach `Receipt.omittedForAttention`, because no
953
+ boundary bubbles `slotCompositions` out of the slot subflow. Each entry carries
954
+ its reproduction, its cause, and what a fix would cost.
955
+
10
956
  ## [9.87.1] - 2026-09-06
11
957
 
12
958
  9.87.0 never reached npm. Its publish job failed the docs site's byte budget —