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