dsh-ptc-plus 0.5.1 → 0.5.3

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 (85) hide show
  1. package/README.en.md +10 -4
  2. package/README.md +10 -4
  3. package/client.cjs +1623 -104
  4. package/compiler-core.cjs +132 -132
  5. package/compiler-core.cjs.map +3 -3
  6. package/docs/adr/0005-temporary-rejected-cell-edit-transport.md +2 -0
  7. package/docs/adr/0017-track-the-latest-dsh-public-surface.md +4 -2
  8. package/docs/adr/0019-plugin-settings-and-kill-switch.md +2 -2
  9. package/docs/adr/0023-global-user-bindings.md +12 -0
  10. package/docs/adr/0027-run-the-session-kernel-in-a-killable-helper-process.md +11 -1
  11. package/docs/adr/0028-attach-to-the-host-ptc-execution-seam.md +1 -1
  12. package/docs/adr/0030-serve-both-typert-codec-generations-from-one-codec.md +2 -2
  13. package/docs/adr/0032-own-session-log-facts-in-a-host-only-projection.md +31 -1
  14. package/docs/adr/0044-coexist-with-the-official-persistent-repl.md +4 -4
  15. package/docs/adr/0045-customize-model-visible-prompt-text.md +38 -0
  16. package/docs/adr/0046-gate-the-commit-with-the-semantic-evidence-tier.md +35 -0
  17. package/docs/adr/0047-store-journal-argument-digests-in-the-projection.md +88 -0
  18. package/docs/adr/0048-supersede-a-rewritten-cell.md +48 -0
  19. package/docs/adr/0049-prove-a-settlement-against-its-published-record.md +53 -0
  20. package/docs/adr/0050-read-host-surfaces-through-their-published-contract.md +48 -0
  21. package/docs/adr/README.md +7 -1
  22. package/docs/architecture.md +3 -3
  23. package/docs/client-ui.md +5 -2
  24. package/docs/durability-design.md +15 -2
  25. package/docs/installation.md +9 -9
  26. package/docs/releases/v0.5.2.md +91 -0
  27. package/docs/releases/v0.5.3.md +77 -0
  28. package/docs/runtime-reference.md +43 -3
  29. package/docs/unmeasured-cell-recovery-repair-proposal.md +387 -0
  30. package/docs/user-bindings.en.md +15 -0
  31. package/docs/user-bindings.md +15 -0
  32. package/docs/v0.5.3-goals.md +117 -0
  33. package/docs/verification.md +7 -3
  34. package/index.js +100 -31
  35. package/internal/binding-adoption.js +136 -0
  36. package/internal/bounded-literal.js +85 -0
  37. package/internal/capability-command-receipt.js +18 -4
  38. package/internal/capability-delivery-proof.js +70 -0
  39. package/internal/capability-environment.js +200 -11
  40. package/internal/capability-plan.js +11 -8
  41. package/internal/cell-error.js +13 -0
  42. package/internal/config-spec.js +126 -6
  43. package/internal/cordis-tools-owner.js +2 -1
  44. package/internal/direct-surface-owner.js +39 -21
  45. package/internal/edit-transport-owner.js +81 -15
  46. package/internal/execution-provider-owner.js +6 -0
  47. package/internal/execution-seam-compat.js +25 -0
  48. package/internal/host-tool-sdk.js +308 -0
  49. package/internal/isolated-worker.js +134 -17
  50. package/internal/kernel-worker.js +167 -3
  51. package/internal/legacy-cell-analysis.js +14 -2
  52. package/internal/legacy-repl-convenience.js +16 -3
  53. package/internal/official-repl-priority.js +31 -10
  54. package/internal/program-bindings.js +12 -1
  55. package/internal/projection-retention.js +238 -0
  56. package/internal/prompt-text.js +858 -0
  57. package/internal/recovery-tips.js +50 -11
  58. package/internal/rejected-cell-editor.js +3 -2
  59. package/internal/repl-memory-projection.js +21 -2
  60. package/internal/runtime-bridge-owner.js +114 -9
  61. package/internal/runtime-config.js +2 -0
  62. package/internal/runtime-contexts.js +14 -5
  63. package/internal/runtime-messages.js +48 -15
  64. package/internal/session-cell-executor.js +132 -11
  65. package/internal/session-journal-recovery.js +1204 -116
  66. package/internal/session-journal-schema.js +10 -5
  67. package/internal/session-journal.js +214 -14
  68. package/internal/session-log-view.js +133 -26
  69. package/internal/session-runtime.js +746 -38
  70. package/internal/session-state.js +27 -0
  71. package/internal/stateful-root-compiler.js +11 -0
  72. package/internal/stateful-root-runtime.js +17 -0
  73. package/internal/user-binding-console.js +7 -0
  74. package/internal/user-bindings-owner.js +112 -6
  75. package/internal/user-bindings.js +2 -2
  76. package/internal/validated-parse-repair.js +6 -1
  77. package/internal/worker-client.js +27 -4
  78. package/package.json +16 -6
  79. package/src/client-activity.js +24 -4
  80. package/src/client-capabilities-view.js +504 -27
  81. package/src/client-copy.js +187 -2
  82. package/src/client-indicator-view.js +2 -1
  83. package/src/client-repl-view.js +35 -11
  84. package/src/client-styles.js +64 -2
  85. package/src/client.js +3 -1
@@ -7,6 +7,11 @@ import {
7
7
  JOURNAL_KEY,
8
8
  RECOVERY_BOUNDARY_KEY,
9
9
  RECOVERY_BOUNDARY_EVENT,
10
+ DIAGNOSTIC_WORDING_KEY,
11
+ DIAGNOSTIC_WORDING_FIELDS,
12
+ SETTLEMENT_PROOF_KEY,
13
+ SETTLEMENT_PROOF_FIELDS,
14
+ SETTLEMENT_PROOF_VERSION,
10
15
  LEGACY_JOURNAL_VERSION,
11
16
  REPL_TOOL_NAMES,
12
17
  REWRITES_KEY,
@@ -15,16 +20,23 @@ import {
15
20
  POST_EDIT_KEY,
16
21
  } from './session-journal-schema.js'
17
22
  import {
23
+ diagnosticWordingDigest,
18
24
  isCanonicalSequence,
19
- normalizeDerivedEditResult,
25
+ normalizeDeferredEditSettlement,
20
26
  normalizeJournal,
21
27
  normalizeRecoveryBoundaries,
22
28
  reduceStateOperations,
29
+ settlementProofDigest,
23
30
  userBindingsForJournal,
24
31
  validatedRewrites,
25
32
  } from './session-journal.js'
26
33
  import { decodeValue } from './value-wire.js'
27
34
  import { editRejectedCell } from './rejected-cell-editor.js'
35
+ import { renderDiagnostic } from './diagnostic.js'
36
+ import {
37
+ byteLength, chainOfEntries, digestOf, extendSummary, retainedClusterCount, retentionLimits, settlementBytes,
38
+ summaryEntry, summaryRecordOf,
39
+ } from './projection-retention.js'
28
40
 
29
41
  const RECOVERY_BOUNDARY_EVIDENCE = Symbol('recoveryBoundaryEvidence')
30
42
  const RUN_SELECTION_CLEAR_REASONS = new Set([
@@ -39,9 +51,26 @@ const MODEL_VISIBLE_SURFACE_EVENTS = new Set([
39
51
  'tool/result',
40
52
  ])
41
53
 
42
- function sourceForRunCall(call) {
54
+ /**
55
+ * Argument text for a call, resolved through its canonical owner.
56
+ *
57
+ * `executableCalls` retains the text once; a call event that still carries its
58
+ * own copy (the live fold, or an uncompacted checkpoint) is read directly. A
59
+ * compacted event and a pruned entry hold only digests, so derivations see
60
+ * `undefined` and must treat the run as unavailable rather than guess.
61
+ */
62
+ function callArguments(call, executableCalls) {
63
+ if (typeof call?.data?.arguments === 'string') return call.data.arguments
64
+ const entry = executableCalls?.get?.(call?.seq)
65
+ const stored = entry?.event?.data?.arguments
66
+ return typeof stored === 'string' ? stored : undefined
67
+ }
68
+
69
+ function sourceForRunCall(call, executableCalls) {
70
+ const text = callArguments(call, executableCalls)
71
+ if (text === undefined) return undefined
43
72
  try {
44
- const args = JSON.parse(call.data.arguments)
73
+ const args = JSON.parse(text)
45
74
  return isRecord(args) && typeof args.code === 'string' ? args.code : undefined
46
75
  } catch {
47
76
  return undefined
@@ -68,8 +97,54 @@ export function isConfirmableNoop(timeline, callSeq, resultEventIndex) {
68
97
  return call !== undefined && call.eventIndex < resultEventIndex && !timeline.results.has(callSeq)
69
98
  }
70
99
 
100
+ /** Node indices reachable from a head by following parents. */
101
+ function reachableFrom(nodes, head) {
102
+ const reachable = new Set()
103
+ for (let cursor = head; cursor !== undefined && !reachable.has(cursor);) {
104
+ reachable.add(cursor)
105
+ cursor = nodes[cursor]?.parent
106
+ }
107
+ return reachable
108
+ }
109
+
110
+ /**
111
+ * Binding names a settlement journal recorded.
112
+ *
113
+ * The recorded values are the only evidence that names a durable binding today;
114
+ * `unrecordedBindings` names the ones the codec could not transfer, and a
115
+ * `kind: "binding"` save names one explicitly. A checkpoint save never counts.
116
+ */
117
+ function journalBindingNames(journal) {
118
+ if (journal === undefined) return []
119
+ return [...new Set([
120
+ ...(journal.values ?? []).map(value => value.name),
121
+ ...(journal.unrecordedBindings ?? []),
122
+ ...bindingNamesOfOperations(journal.operations),
123
+ ].filter(name => typeof name === 'string'))]
124
+ }
125
+
126
+ /** Binding names an installed node recorded. */
127
+ function recordedBindingNames(node) {
128
+ return journalBindingNames(node?.journal)
129
+ }
130
+
71
131
  function applyOperations(state, operations, nodeIndex) {
132
+ const previousHead = state.head
72
133
  const transition = reduceStateOperations(state, operations, nodeIndex)
134
+ if (transition.restored) {
135
+ const reachableBefore = reachableFrom(state.nodes, previousHead)
136
+ const reachableAfter = reachableFrom(state.nodes, transition.head)
137
+ for (const index of reachableBefore) {
138
+ if (reachableAfter.has(index)) continue
139
+ for (const name of recordedBindingNames(state.nodes[index])) state.deletedBindings.add(name)
140
+ }
141
+ // The cutting node is not reachable from the new head either: the rewind it
142
+ // performed ends whatever that same cell created before the cut, so its own
143
+ // names belong to the recorded deletion rather than to an omission.
144
+ if (nodeIndex !== undefined) {
145
+ for (const name of recordedBindingNames(state.nodes[nodeIndex])) state.deletedBindings.add(name)
146
+ }
147
+ }
73
148
  state.head = transition.head
74
149
  state.checkpoints = transition.checkpoints
75
150
  if (transition.restored) {
@@ -78,44 +153,217 @@ function applyOperations(state, operations, nodeIndex) {
78
153
  }
79
154
  }
80
155
 
156
+ /**
157
+ * Adopt the relation a record states about the cell it revised.
158
+ *
159
+ * The relation belongs to the fold, because only the fold knows the path that
160
+ * builds the environment. An accepted edit claims the revision *replaces* the cell
161
+ * it edited, so the revision takes that cell's place in the recorded order: it
162
+ * continues from the target's parent and every cell that followed the target
163
+ * continues from the revision. Appending the revision after those cells instead
164
+ * leaves them replaying an environment the edit already rewrote, with the
165
+ * declarations they read gone and the revision not yet in place.
166
+ *
167
+ * `installed` says whether this settlement puts a cell on the path. A contracted
168
+ * revision installs nothing, so the cells that followed the target bridge to the
169
+ * target's parent, and only a head that was the target itself has to move.
170
+ *
171
+ * The returned record of what changed is what lets a rejected settlement put the
172
+ * path back: nothing this adoption did survives a settlement the fold refuses.
173
+ */
174
+ function adoptSupersession(state, record, { installed }) {
175
+ const superseded = state.editRewritesHistory && record.supersedes !== undefined
176
+ ? state.nodes.findIndex(node => node.callSeq === record.supersedes) : -1
177
+ const adoption = {
178
+ superseded,
179
+ previousHead: state.head,
180
+ supersededBindings: new Set(state.supersededBindings),
181
+ supersessions: state.supersessions.length,
182
+ children: [],
183
+ parent: state.head,
184
+ head: undefined,
185
+ }
186
+ if (superseded < 0) {
187
+ // No relation: the settlement appends to the head, as it always has.
188
+ adoption.head = installed ? undefined : state.head
189
+ return adoption
190
+ }
191
+ const parent = state.nodes[superseded].parent
192
+ adoption.parent = parent
193
+ for (let child = 0; child < state.nodes.length; child += 1) {
194
+ if (state.nodes[child].parent === superseded) adoption.children.push(child)
195
+ }
196
+ // The head is the last recorded cell. It keeps its place when the target is on
197
+ // its path behind other cells, because those cells now reach the same head
198
+ // through the revision; otherwise the revision is the newest cell and the head.
199
+ const targetOnPath = reachableFrom(state.nodes, state.head).has(superseded)
200
+ if (installed) adoption.head = targetOnPath && state.head !== superseded ? state.head : undefined
201
+ else adoption.head = state.head === superseded ? parent : state.head
202
+ return adoption
203
+ }
204
+
205
+ /**
206
+ * Record the relation as the fact the model is owed.
207
+ *
208
+ * Only names the record proves count. A durable declaration that the replay
209
+ * re-establishes is not evidence about the superseded cell, and claiming it
210
+ * would report a binding this fold cannot see. What the revision supplies is the
211
+ * caller's answer, because a settlement the fold refused to install supplies
212
+ * nothing; a cell the fold installs supplies the names its journal recorded.
213
+ */
214
+ function recordSupersession(state, record, adoption, suppliedNames) {
215
+ if (adoption.superseded < 0) return
216
+ const supplied = new Set(suppliedNames)
217
+ for (const name of recordedBindingNames(state.nodes[adoption.superseded])) {
218
+ if (!supplied.has(name)) state.supersededBindings.add(name)
219
+ }
220
+ state.supersessions.push(Object.freeze({
221
+ supersededCallSeq: record.supersedes,
222
+ supersedingCallSeq: record.call.seq,
223
+ }))
224
+ }
225
+
226
+ /**
227
+ * Fold one accepted record as a settlement of the recorded path.
228
+ *
229
+ * `installed` is false for a settlement the fold contracts: it puts no cell on the
230
+ * path and supplies no name, while the relation it states is still the log's fact.
231
+ * Either way the relation is adopted first and the state operations are applied
232
+ * last, so a settlement that throws leaves the path, the children it re-parented
233
+ * and the relation it recorded exactly as it found them.
234
+ */
235
+ function applyAdoptedRecord(state, record, { journal, installed = true, restoredFromRecord = false }) {
236
+ const adoption = adoptSupersession(state, record, { installed })
237
+ const index = installed
238
+ ? state.nodes.push(Object.freeze({
239
+ code: record.code,
240
+ journal,
241
+ callSeq: record.call.seq,
242
+ parent: adoption.parent,
243
+ ...(restoredFromRecord ? { restoredFromRecord: true } : {}),
244
+ ...(record.result.userBindings === undefined ? {} : { userBindings: record.result.userBindings }),
245
+ })) - 1
246
+ : undefined
247
+ const inherited = installed ? index : adoption.parent
248
+ for (const child of adoption.children) {
249
+ state.nodes[child] = Object.freeze({ ...state.nodes[child], parent: inherited })
250
+ }
251
+ state.head = adoption.head === undefined ? index : adoption.head
252
+ try {
253
+ recordSupersession(state, record, adoption, installed ? journalBindingNames(journal) : [])
254
+ applyOperations(state, journal.operations, index)
255
+ } catch (error) {
256
+ if (index !== undefined) state.nodes.pop()
257
+ state.head = adoption.previousHead
258
+ for (const child of adoption.children) {
259
+ state.nodes[child] = Object.freeze({ ...state.nodes[child], parent: adoption.superseded })
260
+ }
261
+ state.supersededBindings = adoption.supersededBindings
262
+ state.supersessions.length = adoption.supersessions
263
+ throw error
264
+ }
265
+ }
266
+
267
+ /**
268
+ * Binding names a recorded journal operation list saves.
269
+ *
270
+ * Only a save with `kind: "binding"` creates a binding. `repl.state({ action:
271
+ * "save" })` records a checkpoint name in the same `save` shape, which is why an
272
+ * untagged save is a checkpoint: every operation recorded before the kind existed
273
+ * came from that control call. Treating those names as bindings made a contraction
274
+ * report a branch name as a dropped variable.
275
+ */
276
+ export function bindingNamesOfOperations(operations) {
277
+ const names = new Set()
278
+ for (const operation of operations ?? []) {
279
+ if (operation.action === 'save' && operation.kind === 'binding' && typeof operation.name === 'string') {
280
+ names.add(operation.name)
281
+ }
282
+ }
283
+ return [...names]
284
+ }
285
+
81
286
  function applyRecord(state, record, invalidCallSeqs) {
82
287
  const { call, code, result } = record
288
+ // Every binding a journal names is a binding the session log proves. Recording
289
+ // the names here lets recovery report a binding it could not install instead of
290
+ // letting an unreplayed node take it silently.
291
+ // Only a volatile record proves a binding today: it cannot be replayed, so its
292
+ // recorded values are the entire evidence. A durable settlement records values too,
293
+ // for the restore path that installs nodes the replay does not reach, but its
294
+ // bindings are still proved by replaying it; counting them here would report a
295
+ // binding as dropped in every contraction whose replay is supposed to be complete.
296
+ // A released live environment proves nothing durable either: its live-only values
297
+ // are contracted below instead of being reported as a binding recovery owed.
298
+ const recordNames = [
299
+ ...(result?.journal?.status === 'volatile' && state.liveOnlyLost !== true
300
+ ? (result.journal.values ?? []).map(value => value.name)
301
+ : []),
302
+ ...bindingNamesOfOperations(result?.journal?.operations),
303
+ ]
304
+ for (const name of recordNames) state.provenBindings.add(name)
83
305
  if (invalidCallSeqs.has(call.seq)) return
84
306
  const journal = result.journal
85
307
  if (journal.status === 'noop') return
86
308
  if (journal.status === 'discarded') {
87
309
  if (journal.volatileReason !== undefined) {
88
310
  state.trusted = false
89
- state.volatileSuffix.push({ seq: call.seq, code, reason: journal.volatileReason })
311
+ const discardedBindings = bindingNamesOfOperations(journal.operations)
312
+ state.volatileSuffix.push({ seq: call.seq, code, reason: journal.volatileReason,
313
+ ...(discardedBindings.length === 0 ? {} : { bindings: discardedBindings }) })
90
314
  }
91
315
  return
92
316
  }
93
317
  if (journal.status === 'volatile') {
94
318
  state.trusted = false
95
- state.volatileSuffix.push({ seq: call.seq, code, reason: journal.volatileReason ?? 'volatile cell' })
96
- applyOperations(state, journal.operations, undefined)
319
+ // A volatile settlement drops its `save` operations, so the names it created
320
+ // survive only as the bindings the record could not transfer.
321
+ const volatileBindings = [...new Set([...bindingNamesOfOperations(journal.operations),
322
+ ...(journal.unrecordedBindings ?? [])])]
323
+ // A volatile cell that recorded its values keeps its place in the durable
324
+ // frontier: recovery installs those values instead of replaying the cell, so
325
+ // the completion is unverified while the bindings survive. A volatile cell
326
+ // whose values the codec could not transfer has nothing to install and is
327
+ // reported as a contraction instead of vanishing silently.
328
+ //
329
+ // Those values are the live environment's own observation, so a fold that
330
+ // knows the environment was released (`liveOnlyLost`) cannot install them:
331
+ // the owner that read `Date.now()` or `process.pid` is gone, and the next
332
+ // environment inherits the durable computation, not the live cache.
333
+ if (!state.liveOnlyLost && journal.values !== undefined && journal.values.length > 0) {
334
+ // The relation is adopted for this settlement too: an accepted revision that
335
+ // settled volatile is still the revision the record names, so it replaces its
336
+ // target on the path and installs its own recorded values.
337
+ applyAdoptedRecord(state, record, { journal, restoredFromRecord: true })
338
+ return
339
+ }
340
+ // A released live environment is reported as a contracted cell: the model
341
+ // loses whatever the live cache held, and naming those bindings would claim
342
+ // they were proved by the durable frontier when they never were.
343
+ state.volatileSuffix.push({
344
+ seq: call.seq,
345
+ code,
346
+ reason: state.liveOnlyLost === true
347
+ ? 'the live environment that observed this cell was released'
348
+ : journal.volatileReason ?? 'volatile cell',
349
+ ...(volatileBindings.length === 0 || state.liveOnlyLost === true ? {} : { bindings: volatileBindings }),
350
+ })
351
+ // A contracted revision still replaces the cell it edited. Leaving the target
352
+ // on the path would keep building an environment the accepted edit already
353
+ // ended, and the relation is what tells the model those names are withdrawn
354
+ // rather than lost. The settlement installs nothing, so it supplies no name:
355
+ // every name the target recorded is withdrawn by this record.
356
+ applyAdoptedRecord(state, record, { journal, installed: false })
97
357
  return
98
358
  }
99
359
  if (!state.trusted && !state.surfaceContracted) {
100
360
  state.trusted = true
101
361
  state.volatileSuffix.length = 0
102
362
  }
103
- const node = Object.freeze({
104
- code,
105
- journal,
106
- callSeq: call.seq,
107
- parent: state.head,
108
- ...(result.userBindings === undefined ? {} : { userBindings: result.userBindings }),
109
- })
110
- const index = state.nodes.push(node) - 1
111
- state.head = index
112
- try {
113
- applyOperations(state, journal.operations, index)
114
- } catch (error) {
115
- state.nodes.pop()
116
- state.head = node.parent
117
- throw error
118
- }
363
+ // A record may declare that it is the revision of an earlier cell. Adoption of
364
+ // that relation, its re-parented children and the node this record puts on the
365
+ // path are one settlement: a record the fold refuses leaves none of them behind.
366
+ applyAdoptedRecord(state, record, { journal })
119
367
  }
120
368
 
121
369
  function recordEventSeq(record) {
@@ -132,8 +380,17 @@ function foldRecords(records, invalidCallSeqs, options = {}) {
132
380
  head: undefined,
133
381
  checkpoints: new Map(),
134
382
  volatileSuffix: [],
383
+ provenBindings: new Set(),
384
+ deletedBindings: new Set(),
385
+ // One recorded revision replaced the cell it edited. The relation, not a
386
+ // deletion, is what the log proves: the superseded cell stays in the record
387
+ // and off the path to the head.
388
+ supersessions: [],
389
+ supersededBindings: new Set(),
135
390
  trusted: true,
136
391
  surfaceContracted: options.surfaceContracted === true,
392
+ liveOnlyLost: options.liveOnlyLost === true,
393
+ editRewritesHistory: options.editRewritesHistory === true,
137
394
  }
138
395
  for (const record of records) {
139
396
  applyRecord(state, record, invalidCallSeqs)
@@ -224,22 +481,29 @@ export function visibleExecutableCallSeqs(session) {
224
481
  if (visibleAssistantCallSeqs.has(event.seq)) calls.add(event.seq)
225
482
  }
226
483
  const timeline = foldSessionTimeline(events)
227
- for (const root of timeline.calls) {
228
- const post = timeline.results.get(root.seq)?.postEdit
229
- if (post !== undefined && calls.has(root.seq) && calls.has(post.targetCallSeq)) {
484
+ if (process.env.DEBUG_RECOVER) console.error('DEBUG events', events.length, 'results', timeline.results.size, 'calls', timelineCalls(timeline).length, 'exec', timeline.executableCalls.size)
485
+ for (const seq of timeline.calls) {
486
+ const post = timeline.results.get(seq)?.postEdit
487
+ // A derived execution that never ran was never published as a cell the model
488
+ // can see, so it is not model-visible executable history.
489
+ if (post !== undefined && post.derived?.unexecuted !== true
490
+ && calls.has(seq) && calls.has(post.targetCallSeq)) {
230
491
  calls.add(post.executionStartSeq)
231
492
  }
232
493
  }
233
494
  return calls
234
495
  }
235
496
 
236
- function timelineRun(call, result, eventIndex, journal) {
497
+ function timelineRun(call, result, eventIndex, journal, executableCalls) {
237
498
  let args
238
- try {
239
- const parsed = JSON.parse(call.data.arguments)
240
- if (isRecord(parsed)) args = Object.freeze(parsed)
241
- } catch {
242
- // Invalid persisted arguments cannot support derived source facts.
499
+ const text = callArguments(call, executableCalls)
500
+ if (text !== undefined) {
501
+ try {
502
+ const parsed = JSON.parse(text)
503
+ if (isRecord(parsed)) args = Object.freeze(parsed)
504
+ } catch {
505
+ // Invalid persisted arguments cannot support derived source facts.
506
+ }
243
507
  }
244
508
  return Object.freeze({
245
509
  index: eventIndex,
@@ -263,27 +527,35 @@ function timelineDerivedRun(call, result, eventIndex, derived) {
263
527
  }
264
528
 
265
529
  function successfulTimelineRun(run) {
266
- return run.journal?.completion?.kind === 'return' && run.journal.status !== 'noop'
530
+ // A pruned settlement yields no run, so it cannot certify success either.
531
+ return run?.journal?.completion?.kind === 'return' && run.journal.status !== 'noop'
267
532
  }
268
533
 
269
- function settlementRun(settlement) {
534
+ function settlementRun(settlement, executableCalls) {
270
535
  const publication = settlement.publication
271
536
  if (publication === undefined) return undefined
272
537
  const result = { data: { meta: { [REWRITES_KEY]: publication.postEditRewrites ?? publication.rewrites } } }
538
+ // A revision that never ran publishes no source of its own, so the settlement
539
+ // keeps its own call's source: the root cell stays the editable target and the
540
+ // unexecuted revision never becomes one.
541
+ if (publication.postEdit?.derived?.unexecuted === true || publication.derived?.unexecuted === true) {
542
+ return timelineJson(timelineRun(settlement.call, result, publication.index, publication.journal, executableCalls))
543
+ }
273
544
  if (publication.postEdit !== undefined) {
274
545
  return timelineJson(timelineDerivedRun({ seq: publication.postEdit.executionStartSeq }, result,
275
546
  publication.index, publication.postEdit.derived))
276
547
  }
277
548
  return timelineJson(publication.derived === undefined
278
- ? timelineRun(settlement.call, result, publication.index, publication.journal)
549
+ ? timelineRun(settlement.call, result, publication.index, publication.journal, executableCalls)
279
550
  : timelineDerivedRun(settlement.call, result, publication.index, publication.derived))
280
551
  }
281
552
 
282
- function selectSettlementRun(selected, settlement, scope, frontier) {
553
+ function selectSettlementRun(selected, settlement, scope, frontier, executableCalls) {
283
554
  const publication = settlement.publication
284
555
  if (publication === undefined || settlement.entry.scope !== scope
285
556
  || (frontier !== null && publication.index <= frontier.index)) return selected
286
- return selected === undefined || publication.index >= selected.index ? settlementRun(settlement) : selected
557
+ return selected === undefined || publication.index >= selected.index
558
+ ? settlementRun(settlement, executableCalls) : selected
287
559
  }
288
560
 
289
561
  function editTargetForSelection(run, claimedTargets) {
@@ -416,6 +688,28 @@ function republishedTimelineResult(result, event) {
416
688
  return { ...result, eventSeq: event.seq, positionSeq: result.positionSeq ?? result.eventSeq }
417
689
  }
418
690
 
691
+ /**
692
+ * Fact-shaped data for a `tool/call`: the arguments are represented by their
693
+ * digest pair so a stored event fact never carries the text. The text itself is
694
+ * retained once, in `timelineCallEvent` inside `executableCalls`. A fact that is
695
+ * already digested passes through unchanged, which keeps re-deriving a fact from
696
+ * a compacted observation byte-identical.
697
+ */
698
+ function callFactData(event) {
699
+ const data = event?.data ?? {}
700
+ const value = data.arguments
701
+ const digested = value === undefined && typeof data.argumentsDigest === 'string'
702
+ ? { argumentsDigest: data.argumentsDigest, argumentsBytes: data.argumentsBytes }
703
+ : typeof value === 'string'
704
+ ? { argumentsDigest: digestOf(value), argumentsBytes: byteLength(value) }
705
+ : {}
706
+ return {
707
+ callId: data.callId,
708
+ ...(typeof data.name === 'string' ? { name: data.name } : {}),
709
+ ...digested,
710
+ }
711
+ }
712
+
419
713
  function correlationEvent(event) {
420
714
  return timelineJson({ seq: event.seq, type: event.type,
421
715
  ...(['tool/ptc-dispatch-start', 'tool/ptc-dispatch'].includes(event.type) ? { data: {
@@ -423,7 +717,12 @@ function correlationEvent(event) {
423
717
  parentCallId: event.data?.parentCallId,
424
718
  subCallId: event.data?.subCallId,
425
719
  name: event.data?.name,
426
- arguments: event.data?.arguments,
720
+ // A compacted dispatch keeps its digest pair, so re-deriving this fact from a
721
+ // pruned observation reproduces exactly what was stored.
722
+ ...(event.data?.arguments === undefined
723
+ ? (event.data?.argumentsDigest === undefined ? {}
724
+ : { argumentsDigest: event.data.argumentsDigest, argumentsBytes: event.data.argumentsBytes })
725
+ : { arguments: event.data.arguments }),
427
726
  ...(event.type === 'tool/ptc-dispatch' ? {
428
727
  isError: event.data?.isError,
429
728
  ...(event.data?.name === 'run_code' && event.data.meta?.[JOURNAL_KEY] !== undefined
@@ -431,7 +730,7 @@ function correlationEvent(event) {
431
730
  } : {}),
432
731
  } } : {}),
433
732
  ...(event.type === 'tool/call' && typeof event.data?.callId === 'string'
434
- ? { data: timelineCallEvent(event).data } : {}),
733
+ ? { data: callFactData(event) } : {}),
435
734
  ...(event.type === 'tool/result' ? { data: { message: { source: {
436
735
  ...(typeof event.data?.message?.source?.callId === 'string'
437
736
  ? { callId: event.data.message.source.callId } : {}),
@@ -501,13 +800,245 @@ function timelineJson(value) {
501
800
  return deepFreeze(JSON.parse(JSON.stringify(value ?? null)))
502
801
  }
503
802
 
504
- function timelineCallEvent(event) {
803
+ /**
804
+ * Resolve one executable call entry from either the live table or the
805
+ * serialized checkpoint shape.
806
+ */
807
+ function executableEntry(timeline, seq) {
808
+ const table = timeline.executableCalls
809
+ return typeof table?.get === 'function' ? table.get(seq) : table?.[`k${seq}`]?.[1]
810
+ }
811
+
812
+ /**
813
+ * The ordered executable call events.
814
+ *
815
+ * `calls` stores only the sequence order; the events themselves live once in
816
+ * `executableCalls`, so a checkpoint no longer serializes every call a second
817
+ * time.
818
+ */
819
+ function timelineCalls(timeline) {
820
+ const calls = []
821
+ for (const seq of timeline.calls) {
822
+ const event = executableEntry(timeline, seq)?.event
823
+ if (event !== undefined) calls.push(event)
824
+ }
825
+ return calls
826
+ }
827
+
828
+ /**
829
+ * The canonical call event retained in `executableCalls`.
830
+ *
831
+ * This is the only place the argument text is kept. A replay that only has a
832
+ * digested fact resolves the text from the entry already stored for that seq, so
833
+ * a checkpointed call and a re-derived one stay byte-identical.
834
+ */
835
+ function timelineCallEvent(event, executableCalls) {
836
+ const entry = executableCalls?.get?.(event.seq) ?? executableCalls?.[`k${event.seq}`]?.[1]
837
+ const stored = entry?.event?.data?.arguments
838
+ const text = typeof event.data.arguments === 'string'
839
+ ? event.data.arguments
840
+ : typeof stored === 'string' ? stored : undefined
505
841
  return timelineJson({ seq: event.seq, type: event.type, data: {
506
842
  callId: event.data.callId, name: event.data.name,
507
- ...(REPL_TOOL_NAMES.has(event.data.name) ? { arguments: event.data.arguments } : {}),
843
+ ...(REPL_TOOL_NAMES.has(event.data.name)
844
+ ? (text === undefined
845
+ ? { argumentsDigest: event.data.argumentsDigest, argumentsBytes: event.data.argumentsBytes }
846
+ : { arguments: text })
847
+ : {}),
508
848
  } })
509
849
  }
510
850
 
851
+ /** Stable digest of a stored projection fact, used by checkpoint admission. */
852
+ export function factDigest(value) {
853
+ return createHash('sha256').update(JSON.stringify(value ?? null, (_key, field) => (
854
+ isRecord(field) ? Object.fromEntries(Object.keys(field).sort().map(key => [key, field[key]])) : field
855
+ ))).digest('hex')
856
+ }
857
+
858
+ function withoutFactDigests(state) {
859
+ // Admission compares what the projection describes, not bookkeeping whose exact
860
+ // value depends on when pruning happened: per-fact digests and the retained-byte
861
+ // counter are accounting, not session state.
862
+ const { retainedBytes: _retainedBytes, retentionSummary: _retentionSummary, ...rest } = state
863
+ const facts = rest.eventFacts
864
+ if (!facts.some(fact => fact.digest !== undefined)) return rest
865
+ return { ...rest, eventFacts: facts.map(({ digest, ...fact }) => fact) }
866
+ }
867
+
868
+ /**
869
+ * The retention summary an admitted checkpoint carries.
870
+ *
871
+ * A compacted checkpoint keeps no per-settlement stubs at all: dropping the whole
872
+ * cluster is what makes the file size independent of how many cells the session
873
+ * ran. What survives is one chained summary of what was dropped, so admission can
874
+ * still tell "never existed" from "existed and was dropped".
875
+ */
876
+ export function retentionSummaryOf(state) {
877
+ const summary = state?.retentionSummary
878
+ if (summary === undefined || summary === null) return undefined
879
+ return summary
880
+ }
881
+
882
+ /**
883
+ * Prove a compacted checkpoint without replaying it.
884
+ *
885
+ * The retained window must be contiguous with the summary boundary: every
886
+ * settlement above `toSeq` is present and intact, every executable call that a
887
+ * retained settlement names is present, and the summary is a well-formed chain.
888
+ * Below the boundary nothing survives, which is exactly the contract of dropping
889
+ * a cluster. This is the weaker, stated claim admission makes for compacted
890
+ * states; uncompacted states keep the byte-for-byte replay equality.
891
+ */
892
+ export function validateRetentionSummary(timeline, summary, logFacts) {
893
+ if (!isRecord(summary)) throw new TypeError('invalid PTC session-log projection retention summary')
894
+ const keys = Object.keys(summary)
895
+ if (keys.some(key => !['fromSeq', 'toSeq', 'count', 'chainDigest', 'entries'].includes(key))) {
896
+ throw new TypeError('invalid PTC session-log projection retention summary')
897
+ }
898
+ if (!isCanonicalSequence(summary.fromSeq) || !isCanonicalSequence(summary.toSeq)
899
+ || summary.fromSeq > summary.toSeq || !Number.isSafeInteger(summary.count) || summary.count < 1
900
+ || typeof summary.chainDigest !== 'string' || !/^[a-f0-9]{64}$/u.test(summary.chainDigest)) {
901
+ throw new TypeError('invalid PTC session-log projection retention summary')
902
+ }
903
+ for (const [key, pair] of Object.entries(timeline.settledResultsByEventSeq ?? {})) {
904
+ const seq = pair?.[0]
905
+ const settlement = pair?.[1]
906
+ if (!isCanonicalSequence(seq) || settlement === undefined) {
907
+ throw new TypeError('invalid PTC session-log projection retention window')
908
+ }
909
+ if (seq <= summary.toSeq) {
910
+ throw new TypeError(`PTC session-log projection retains settlement ${key} below its retention boundary`)
911
+ }
912
+ const callSeq = settlement.call?.seq
913
+ if (!isCanonicalSequence(callSeq) || timeline.executableCalls?.[`k${callSeq}`] === undefined) {
914
+ throw new TypeError('PTC session-log projection lost the call of a retained settlement')
915
+ }
916
+ }
917
+ // Admission receives no session log, so it cannot fold the dropped range. What it
918
+ // can prove is that the summary is internally consistent: one record per dropped
919
+ // cluster, ordered by result sequence, with the range and the chain derived from
920
+ // those records. Trusting a summary without a record per cluster is what made an
921
+ // earlier version forgeable. The stronger claim — that a record describes the same
922
+ // cluster the log still holds — is checked when a dropped cluster is refolded
923
+ // (`verifyRefoldedSettlement`), because that is the only point where the log and
924
+ // the record meet.
925
+ const records = summary.entries
926
+ if (!Array.isArray(records) || records.length !== summary.count) {
927
+ throw new TypeError('inconsistent PTC session-log projection retention summary')
928
+ }
929
+ let previous = -1
930
+ for (const record of records) {
931
+ if (!isRecord(record)
932
+ || !isCanonicalSequence(record.resultSeq) || !isCanonicalSequence(record.callSeq)
933
+ || typeof record.argumentsDigest !== 'string' || !/^[a-f0-9]{64}$/u.test(record.argumentsDigest)
934
+ || record.resultSeq <= previous) {
935
+ throw new TypeError('inconsistent PTC session-log projection retention summary')
936
+ }
937
+ previous = record.resultSeq
938
+ }
939
+ if (records[0].resultSeq !== summary.fromSeq || records[records.length - 1].resultSeq !== summary.toSeq
940
+ || chainOfEntries(records) !== summary.chainDigest) {
941
+ throw new TypeError('inconsistent PTC session-log projection retention summary')
942
+ }
943
+ return summary
944
+ }
945
+
946
+ /**
947
+ * Prove the recorded chain against the raw session log.
948
+ *
949
+ * Folds the events up to the boundary with retention disabled, derives the summary
950
+ * the fold would have produced for the dropped range, and requires it to equal the
951
+ * recorded one. A caller without the log must say so: this function throws
952
+ * `unverifiable PTC session-log projection retention summary` instead of accepting.
953
+ */
954
+ export function verifyRetentionSummaryAgainstEvents(summary, events) {
955
+ if (!Array.isArray(events)) {
956
+ throw new TypeError('unverifiable PTC session-log projection retention summary')
957
+ }
958
+ const expected = retentionSummaryFromEvents(events, summary.toSeq)
959
+ if (expected === undefined
960
+ || expected.count !== summary.count || expected.fromSeq !== summary.fromSeq
961
+ || expected.toSeq !== summary.toSeq || expected.chainDigest !== summary.chainDigest) {
962
+ throw new TypeError('inconsistent PTC session-log projection retention summary')
963
+ }
964
+ return summary
965
+ }
966
+
967
+ /**
968
+ * Refold the editable cell from the log and check it against the summary.
969
+ *
970
+ * The checkpoint cannot prove that its summary describes the clusters the log
971
+ * still holds — admission has no log. This is the one place where both sides meet:
972
+ * the log is folded with pruning disabled, the settlement behind the editable run
973
+ * is turned into a summary record, and the record stored in the summary must equal
974
+ * it. A mismatch means the summary and the log disagree, which is reported to the
975
+ * caller instead of yielding an edit target that was never recorded.
976
+ */
977
+ export function refoldEditableSettlement(events, summary) {
978
+ if (!Array.isArray(events)) return { run: undefined, mismatch: false }
979
+ let state
980
+ withFullRetention(() => {
981
+ state = createSessionTimelineState()
982
+ events.forEach((event, index) => {
983
+ if (!isRecord(event)) return
984
+ state = advanceSessionTimeline(state, event, isCanonicalSequence(event.seq) ? event.seq : index)
985
+ })
986
+ })
987
+ const run = state?.editableRun
988
+ if (run === undefined) return { run: undefined, mismatch: false }
989
+ const settled = Object.entries(state?.settledResultsByEventSeq ?? {})
990
+ .map(([, pair]) => pair)
991
+ .find(([, settlement]) => settlement?.call?.seq === run.callSeq)
992
+ if (settled === undefined) return { run, mismatch: false }
993
+ const [resultSeq, settlement] = settled
994
+ const recorded = summaryRecordOf(summary, Number(resultSeq))
995
+ if (recorded === undefined) return { run, mismatch: false }
996
+ const refolded = summaryEntry(settlement, Number(resultSeq))
997
+ const mismatch = recorded.callSeq !== refolded.callSeq || recorded.argumentsDigest !== refolded.argumentsDigest
998
+ return { run, mismatch }
999
+ }
1000
+
1001
+ /** Recompute the summary a fold of the dropped range would have produced. */
1002
+ function retentionSummaryFromEvents(events, toSeq) {
1003
+ let state
1004
+ withFullRetention(() => {
1005
+ state = createSessionTimelineState()
1006
+ events.forEach((event, index) => {
1007
+ if (!isRecord(event)) return
1008
+ if (isCanonicalSequence(event.seq) && event.seq > toSeq) return
1009
+ state = advanceSessionTimeline(state, event, isCanonicalSequence(event.seq) ? event.seq : index)
1010
+ })
1011
+ })
1012
+ const dropped = Object.entries(state?.settledResultsByEventSeq ?? {})
1013
+ .map(([, pair]) => pair)
1014
+ .filter(([resultSeq]) => Number(resultSeq) <= toSeq)
1015
+ // The prune orders dropped entries by RESULT sequence before folding them into the
1016
+ // chain, so summing here must use the same order; observedIndex can disagree when
1017
+ // a nested call settles out of order.
1018
+ .sort((left, right) => Number(left[0]) - Number(right[0]))
1019
+ .map(([resultSeq, settlement]) => summaryEntry(settlement, Number(resultSeq)))
1020
+ if (dropped.length === 0) return undefined
1021
+ return extendSummary(undefined, dropped)
1022
+ }
1023
+
1024
+ /**
1025
+ * The identity of a call is what survives the payload window: sequence, type,
1026
+ * callId and name. The argument text is deliberately excluded, because retention
1027
+ * replaces it with `argumentsDigest` and an identity that hashed the text would make
1028
+ * every observation describe a different call before and after pruning. The text is
1029
+ * still bound by its own digest in the call entry, so excluding it hides nothing.
1030
+ */
1031
+ function eventDigest(event) {
1032
+ // Identity only, and only the fields every call keeps: a payload the window
1033
+ // digested must hash the same before and after pruning, which is why the source
1034
+ // text and the result are not part of it. A call always carries its `callId`
1035
+ // (executable calls are admitted with one), so no second shape exists to hash.
1036
+ const data = isRecord(event?.data) ? event.data : {}
1037
+ const identity = { seq: event?.seq, type: event?.type,
1038
+ data: { callId: data.callId, name: data.name ?? null } }
1039
+ return createHash('sha256').update(JSON.stringify(identity)).digest('hex')
1040
+ }
1041
+
511
1042
  function resultIdentity(data) {
512
1043
  const identity = timelineJson(isRecord(data) && isRecord(data.message)
513
1044
  ? { ...data, message: { ...data.message, content: undefined } } : data)
@@ -516,6 +1047,23 @@ function resultIdentity(data) {
516
1047
  ))).digest('hex')
517
1048
  }
518
1049
 
1050
+ /**
1051
+ * How many bytes of retained payload admission may replay.
1052
+ *
1053
+ * A stored checkpoint is proved two ways: replaying its facts reproduces it
1054
+ * byte-for-byte, or — because that replay costs a second fold of everything the
1055
+ * session wrote — its per-fact digests and identity chain prove continuity. This
1056
+ * budget decides which claim a given checkpoint gets, and both replays read it
1057
+ * through `admissionReplayBudget`, so the timeline replay here and the
1058
+ * log-identity replay in the session-log projection cannot disagree about it.
1059
+ */
1060
+ const ADMISSION_REPLAY_BUDGET_BYTES = 4 * 1024 * 1024
1061
+
1062
+ /** Resolve a caller's replay budget, falling back to the shipped one. */
1063
+ export function admissionReplayBudget(bytes) {
1064
+ return Number.isSafeInteger(bytes) && bytes > 0 ? bytes : ADMISSION_REPLAY_BUDGET_BYTES
1065
+ }
1066
+
519
1067
  export function createSessionTimelineState() {
520
1068
  return {
521
1069
  openTurn: false,
@@ -536,11 +1084,34 @@ export function createSessionTimelineState() {
536
1084
  runSelectionHistory: [],
537
1085
  lastEventIndex: null,
538
1086
  eventFacts: [],
1087
+ // Payload bytes the retained settlements still carry: the retention window
1088
+ // keeps this bounded so a checkpoint never scales with written content.
1089
+ retainedBytes: 0,
1090
+ // One chained summary of the clusters the window dropped. Per-settlement stubs
1091
+ // would still scale with the number of cells the session ran.
1092
+ retentionSummary: null,
1093
+ // Set once any cluster is dropped, so admission can tell "nothing was ever
1094
+ // dropped" from "a summary was deleted". A compacted state must carry its summary.
1095
+ compacted: false,
1096
+ // Set when the newest cell alone exceeded the byte budget and had to go. The
1097
+ // consumers that would have read it must say so instead of looking empty.
1098
+ hotCellDropped: false,
539
1099
  scope: 0,
540
1100
  }
541
1101
  }
542
1102
 
543
- export function validateSessionTimelineState(value) {
1103
+ /**
1104
+ * Prove a stored timeline checkpoint, given the session log's own facts when the
1105
+ * caller holds them.
1106
+ *
1107
+ * `replayBudgetBytes` is the caller's replay budget: a checkpoint above it is
1108
+ * admitted on its per-fact digests and identity chain instead of a replay, and a
1109
+ * caller that lowers the budget (a test, or a host with a cheaper fold) must
1110
+ * lower this same claim, not a second copy of it.
1111
+ */
1112
+ export function validateSessionTimelineState(value, logFacts, options = {}) {
1113
+ const replayBudget = admissionReplayBudget(options.replayBudgetBytes)
1114
+
544
1115
  const requireFact = condition => {
545
1116
  if (!condition) throw new TypeError('invalid PTC session-log projection timeline checkpoint')
546
1117
  }
@@ -549,17 +1120,36 @@ export function validateSessionTimelineState(value) {
549
1120
  const target = field => fields(field, ['callSeq', 'source']) && isCanonicalSequence(field.callSeq) && typeof field.source === 'string'
550
1121
  const callEvent = event => fields(event, ['seq', 'type', 'data']) && isCanonicalSequence(event.seq) && event.type === 'tool/call'
551
1122
  && isRecord(event.data) && typeof event.data.callId === 'string'
552
- && fields(event.data, ['callId', 'name', 'arguments'])
1123
+ && fields(event.data, ['callId', 'name', 'arguments', 'argumentsDigest', 'argumentsBytes'])
553
1124
  && optional(event.data.name, name => typeof name === 'string')
554
- && optional(event.data.arguments, args => typeof args === 'string')
1125
+ && (event.data.arguments === undefined
1126
+ ? (event.data.argumentsDigest === undefined
1127
+ || (typeof event.data.argumentsDigest === 'string' && /^[a-f0-9]{64}$/u.test(event.data.argumentsDigest)
1128
+ && isCanonicalSequence(event.data.argumentsBytes)))
1129
+ : (typeof event.data.arguments === 'string' && event.data.argumentsDigest === undefined
1130
+ && event.data.argumentsBytes === undefined))
555
1131
  const callEntry = entry => fields(entry, ['event', 'eventIndex', 'scope', 'ambiguous', 'editTarget']) && callEvent(entry.event)
556
1132
  && isCanonicalSequence(entry.eventIndex) && isCanonicalSequence(entry.scope) && entry.scope <= value.scope
557
1133
  && optional(entry.ambiguous, field => field === true) && optional(entry.editTarget, target)
1134
+ // A settlement references its call through `call` and keeps only the entry's
1135
+ // position and claims. Storing `entry.event` as well would serialize the call
1136
+ // event a second time — the duplication the retention work exists to remove.
1137
+ const settlementEntry = entry => fields(entry, ['eventIndex', 'scope', 'ambiguous', 'editTarget'])
1138
+ && isCanonicalSequence(entry.eventIndex) && isCanonicalSequence(entry.scope) && entry.scope <= value.scope
1139
+ && optional(entry.ambiguous, field => field === true) && optional(entry.editTarget, target)
558
1140
  const table = (name, keyValid, payloadValid) => {
559
- requireFact(isRecord(value[name]))
1141
+ if (!isRecord(value[name])) {
1142
+ throw new TypeError(`invalid PTC session-log projection timeline checkpoint: table ${name} is missing`)
1143
+ }
560
1144
  for (const [key, pair] of Object.entries(value[name])) {
561
- requireFact(Array.isArray(pair) && pair.length === 2 && keyValid(pair[0]) && key === `k${pair[0]}`)
562
- requireFact(payloadValid(pair[1], pair[0]))
1145
+ if (!Array.isArray(pair) || pair.length !== 2 || !keyValid(pair[0]) || key !== `k${pair[0]}`) {
1146
+ throw new TypeError(`invalid PTC session-log projection timeline checkpoint: table ${name} entry ${key}`)
1147
+ }
1148
+ if (!payloadValid(pair[1], pair[0])) {
1149
+ const problem = name === 'settledResultsByEventSeq' ? settlementProblem(pair[1]) : undefined
1150
+ const shape = isRecord(pair[1]) ? ` keys=${Object.keys(pair[1]).join(',')}` : ` value=${JSON.stringify(pair[1])?.slice(0, 80)}`
1151
+ throw new TypeError(`invalid PTC session-log projection timeline checkpoint: table ${name} payload ${key}${shape}${problem === undefined ? '' : ` problem=${problem}`}`)
1152
+ }
563
1153
  }
564
1154
  return new Map(Object.values(value[name]))
565
1155
  }
@@ -567,9 +1157,14 @@ export function validateSessionTimelineState(value) {
567
1157
  'cordisTranscript', 'editTargets', 'pendingByCallId', 'seenCallIds', 'claimedEditTargets', 'editClaims',
568
1158
  'ordinaryResultSeqs', 'settledResultsByEventSeq', 'scope', 'latestRun', 'editableRun', 'lastSuccessfulRunIndex',
569
1159
  'unavailableResultSeq', 'unavailableResultReason', 'pruneReplacementWindow', 'runSelectionFrontier',
570
- 'runSelectionHistory', 'lastEventIndex', 'eventFacts']) && typeof value.openTurn === 'boolean'
1160
+ 'runSelectionHistory', 'lastEventIndex', 'eventFacts', 'retainedBytes', 'retentionSummary', 'compacted',
1161
+ 'hotCellDropped']) && typeof value.openTurn === 'boolean'
571
1162
  && typeof value.found === 'boolean' && isCanonicalSequence(value.scope)
572
- && Array.isArray(value.calls) && value.calls.every(callEvent) && Array.isArray(value.boundaries))
1163
+ && typeof value.retainedBytes === 'number' && value.retainedBytes >= 0
1164
+ && (value.retentionSummary === undefined || value.retentionSummary === null || isRecord(value.retentionSummary))
1165
+ && typeof value.compacted === 'boolean' && typeof value.hotCellDropped === 'boolean'
1166
+ && Array.isArray(value.calls) && value.calls.every(seq => isCanonicalSequence(seq))
1167
+ && Array.isArray(value.boundaries))
573
1168
  requireFact(value.lastEventIndex === null || isCanonicalSequence(value.lastEventIndex))
574
1169
  requireFact(Array.isArray(value.runSelectionHistory))
575
1170
  let boundaryScope = 0
@@ -593,9 +1188,10 @@ export function validateSessionTimelineState(value) {
593
1188
  && entry.event.seq === seq && REPL_TOOL_NAMES.has(entry.event.data.name))
594
1189
  requireFact(value.calls.length === executable.size)
595
1190
  const callSeqs = new Set()
596
- for (const call of value.calls) {
597
- requireFact(!callSeqs.has(call.seq) && deepEqualJson(executable.get(call.seq)?.event, call))
598
- callSeqs.add(call.seq)
1191
+ for (const seq of value.calls) {
1192
+ // The order list may only name stored entries, and may not repeat one.
1193
+ requireFact(!callSeqs.has(seq) && executable.has(seq))
1194
+ callSeqs.add(seq)
599
1195
  }
600
1196
  const journalFacts = result => {
601
1197
  if (!isRecord(result.journal)) return false
@@ -606,7 +1202,7 @@ export function validateSessionTimelineState(value) {
606
1202
  if (result.derived !== undefined) {
607
1203
  const derived = result.derived
608
1204
  if (!isRecord(derived)) return false
609
- const normalized = normalizeDerivedEditResult({
1205
+ const normalized = normalizeDeferredEditSettlement({
610
1206
  [JOURNAL_KEY]: result.journal,
611
1207
  [EDIT_TARGET_KEY]: { targetCallSeq: derived.targetCallSeq },
612
1208
  [DERIVED_RUN_KEY]: { code: derived.code, description: derived.description },
@@ -618,6 +1214,8 @@ export function validateSessionTimelineState(value) {
618
1214
  return true
619
1215
  }
620
1216
  const normalizedResult = result => {
1217
+ // Every stored result keeps its full shape: a dropped cluster takes its result
1218
+ // entry with it, so a digest-only result would be a shape nothing produces.
621
1219
  if (!fields(result, ['eventSeq', 'eventIndex', 'positionSeq', 'error', 'journal', 'derived', 'userBindings',
622
1220
  'postEdit', 'postEditError'])
623
1221
  || !isCanonicalSequence(result.eventSeq) || !isCanonicalSequence(result.eventIndex)
@@ -628,7 +1226,7 @@ export function validateSessionTimelineState(value) {
628
1226
  && optional(result.postEdit, post => fields(post, ['startSeq', 'executionStartSeq', 'targetCallSeq', 'derived', 'rewrites'])
629
1227
  && isCanonicalSequence(post.startSeq) && isCanonicalSequence(post.executionStartSeq)
630
1228
  && isCanonicalSequence(post.targetCallSeq)
631
- && deepEqualJson(normalizeDerivedEditResult({
1229
+ && deepEqualJson(normalizeDeferredEditSettlement({
632
1230
  [JOURNAL_KEY]: post.derived?.journal,
633
1231
  [EDIT_TARGET_KEY]: { targetCallSeq: post.targetCallSeq },
634
1232
  [DERIVED_RUN_KEY]: { code: post.derived?.code, description: post.derived?.description },
@@ -657,49 +1255,75 @@ export function validateSessionTimelineState(value) {
657
1255
  const claimed = table('claimedEditTargets', isCanonicalSequence, (field, seq) => trueValue(field) && executable.has(seq))
658
1256
  const edits = table('editClaims', isCanonicalSequence, (seq, editSeq) => isCanonicalSequence(seq)
659
1257
  && claimed.has(seq) && targets.get(editSeq)?.callSeq === seq)
660
- const ordinaryObservations = table('ordinaryResultSeqs', isCanonicalSequence, (observation, seq) => fields(observation,
661
- ['call', 'eventSeq', 'eventIndex']) && callEvent(observation.call) && observation.call.seq === seq
1258
+ const ordinaryObservations = table('ordinaryResultSeqs', isCanonicalSequence, (observation, seq) =>
1259
+ ((fields(observation, ['call', 'eventSeq', 'eventIndex']) && callEvent(observation.call) && observation.call.seq === seq
1260
+ && value.executableCalls[`k${seq}`] === undefined)
1261
+ || (fields(observation, ['callDigest', 'eventSeq', 'eventIndex'])
1262
+ && typeof observation.callDigest === 'string' && /^[a-f0-9]{64}$/u.test(observation.callDigest)
1263
+ && value.executableCalls[`k${seq}`] !== undefined
1264
+ && eventDigest(value.executableCalls[`k${seq}`][1].event) === observation.callDigest))
662
1265
  && isCanonicalSequence(observation.eventSeq) && isCanonicalSequence(observation.eventIndex)
663
1266
  && value.lastEventIndex !== null && observation.eventIndex <= value.lastEventIndex
664
- && (!REPL_TOOL_NAMES.has(observation.call.data.name)
1267
+ && (observation.callDigest !== undefined || !REPL_TOOL_NAMES.has(observation.call.data.name)
665
1268
  || (deepEqualJson(executable.get(seq)?.event, observation.call)
666
1269
  && observation.eventIndex >= executable.get(seq).eventIndex)))
667
- const settlements = table('settledResultsByEventSeq', isCanonicalSequence, settlement => fields(settlement,
668
- ['call', 'entry', 'identity', 'publication', 'observedIndex', 'ordinary', 'normalized'])
669
- && callEvent(settlement.call) && callEntry(settlement.entry)
670
- && optional(settlement.normalized, normalizedResult)
671
- && typeof settlement.ordinary === 'boolean'
672
- && isCanonicalSequence(settlement.observedIndex) && value.lastEventIndex !== null
673
- && settlement.observedIndex >= settlement.entry.eventIndex && settlement.observedIndex <= value.lastEventIndex
674
- && deepEqualJson(settlement.call, settlement.entry.event)
675
- && typeof settlement.identity === 'string' && /^[a-f0-9]{64}$/u.test(settlement.identity)
676
- && (!REPL_TOOL_NAMES.has(settlement.call.data.name)
677
- || deepEqualJson(executable.get(settlement.call.seq)?.event, settlement.call))
678
- && optional(settlement.publication, publication => fields(publication,
679
- ['index', 'journal', 'derived', 'userBindings', 'rewrites', 'postEdit', 'postEditRewrites'])
680
- && deepEqualJson(publication.journal ?? null, settlement.normalized?.journal ?? null)
681
- && deepEqualJson(publication.derived ?? null, settlement.normalized?.derived ?? null)
682
- && deepEqualJson(publication.userBindings ?? null, settlement.normalized?.userBindings ?? null)
683
- && deepEqualJson(publication.postEdit ?? null, settlement.normalized?.postEdit ?? null)
684
- && deepEqualJson(publication.postEditRewrites ?? null, settlement.normalized?.postEdit?.rewrites ?? null)
685
- && !settlement.entry.ambiguous && isCanonicalSequence(publication.index)
686
- && publication.index === (settlement.ordinary ? settlement.observedIndex : settlement.entry.eventIndex)
687
- && ((settlement.call.data.name === 'run_code' && publication.derived === undefined)
688
- || (settlement.call.data.name === 'edit_run_code'
689
- && publication.derived?.targetCallSeq === settlement.entry.editTarget?.callSeq
690
- && publication.derived !== undefined))
691
- && (publication.journal === undefined
692
- ? publication.derived === undefined && publication.userBindings === undefined
693
- : journalFacts(publication))
694
- && deepEqualJson(validatedRewrites({ [REWRITES_KEY]: publication.rewrites }) ?? null, publication.rewrites ?? null)))
1270
+ /**
1271
+ * Name the clause a settlement fails, so a rejected checkpoint says which relation
1272
+ * broke instead of only which table. The clauses are the same ones the inline
1273
+ * predicate used to run; this only reports them.
1274
+ */
1275
+ const settlementProblem = settlement => {
1276
+ if (!fields(settlement, ['call', 'entry', 'identity', 'publication', 'observedIndex', 'ordinary', 'normalized'])) return 'fields'
1277
+ if (!callEvent(settlement.call)) return 'callEvent'
1278
+ if (!settlementEntry(settlement.entry)) return 'entry'
1279
+ if (!optional(settlement.normalized, normalizedResult)) return 'normalized'
1280
+ if (typeof settlement.ordinary !== 'boolean') return 'ordinary'
1281
+ if (!isCanonicalSequence(settlement.observedIndex) || value.lastEventIndex === null) return 'observedIndex'
1282
+ if (settlement.observedIndex < settlement.entry.eventIndex || settlement.observedIndex > value.lastEventIndex) return 'observedIndex range'
1283
+ if (typeof settlement.identity !== 'string' || !/^[a-f0-9]{64}$/u.test(settlement.identity)) return 'identity'
1284
+ if (REPL_TOOL_NAMES.has(settlement.call.data.name)
1285
+ && !deepEqualJson(executable.get(settlement.call.seq)?.event, settlement.call)) {
1286
+ const stored = executable.get(settlement.call.seq)?.event
1287
+ return `executable call (stored=${JSON.stringify(stored)?.slice(0, 220)} settlement=${JSON.stringify(settlement.call)?.slice(0, 220)})`
1288
+ }
1289
+ const publicationProblem = publication => {
1290
+ if (!fields(publication, ['index', 'journal', 'derived', 'userBindings', 'rewrites', 'postEdit', 'postEditRewrites'])) return 'publication fields'
1291
+ if (!deepEqualJson(publication.journal ?? null, settlement.normalized?.journal ?? null)) return 'publication journal'
1292
+ if (!deepEqualJson(publication.derived ?? null, settlement.normalized?.derived ?? null)) return 'publication derived'
1293
+ if (!deepEqualJson(publication.userBindings ?? null, settlement.normalized?.userBindings ?? null)) return 'publication userBindings'
1294
+ if (!deepEqualJson(publication.postEdit ?? null, settlement.normalized?.postEdit ?? null)) return 'publication postEdit'
1295
+ if (!deepEqualJson(publication.postEditRewrites ?? null, settlement.normalized?.postEdit?.rewrites ?? null)) return 'publication postEditRewrites'
1296
+ if (settlement.entry.ambiguous) return 'publication ambiguity'
1297
+ if (!isCanonicalSequence(publication.index)) return 'publication index'
1298
+ if (publication.index !== (settlement.ordinary ? settlement.observedIndex : settlement.entry.eventIndex)) return 'publication position'
1299
+ if (settlement.call.data.name === 'run_code' && publication.derived !== undefined) return 'publication derived target'
1300
+ if (settlement.call.data.name === 'edit_run_code'
1301
+ && (publication.derived === undefined
1302
+ || publication.derived.targetCallSeq !== settlement.entry.editTarget?.callSeq)) return 'publication edit target'
1303
+ if (publication.journal === undefined
1304
+ ? publication.derived !== undefined || publication.userBindings !== undefined
1305
+ : !journalFacts(publication)) return 'publication journal facts'
1306
+ if (!deepEqualJson(validatedRewrites({ [REWRITES_KEY]: publication.rewrites }) ?? null, publication.rewrites ?? null)) return 'publication rewrites'
1307
+ return undefined
1308
+ }
1309
+ if (!optional(settlement.publication, publication => publicationProblem(publication) === undefined)) return 'publication'
1310
+ if (isRecord(settlement.publication)) {
1311
+ const problem = publicationProblem(settlement.publication)
1312
+ if (problem !== undefined) return problem
1313
+ }
1314
+ return undefined
1315
+ }
1316
+ const settlements = table('settledResultsByEventSeq', isCanonicalSequence, settlement => settlementProblem(settlement) === undefined)
695
1317
  // Normalized event facts retain distinctions needed by the transition but
696
1318
  // omit result content. Admission uses that same transition, not a second
697
1319
  // partial interpretation of the materialized caches.
698
1320
  requireFact(Array.isArray(value.eventFacts))
699
1321
  let reconstructed = createSessionTimelineState()
700
1322
  let correlationIndex = -1
1323
+ let replayBytes = 0
1324
+ let replayComplete = true
701
1325
  for (const observation of value.eventFacts) {
702
- requireFact(fields(observation, ['eventIndex', 'event', 'identity', 'hasJournal', 'normalized',
1326
+ requireFact(fields(observation, ['eventIndex', 'event', 'digest', 'identity', 'hasJournal', 'normalized',
703
1327
  'boundaries', 'boundaryFailure', 'rewrites', 'selectedTarget'])
704
1328
  && isCanonicalSequence(observation.eventIndex) && observation.eventIndex > correlationIndex
705
1329
  && value.lastEventIndex !== null && observation.eventIndex <= value.lastEventIndex
@@ -728,14 +1352,48 @@ export function validateSessionTimelineState(value) {
728
1352
  && deepEqualJson(validatedRewrites({ [REWRITES_KEY]: observation.rewrites }) ?? null,
729
1353
  observation.rewrites ?? null))
730
1354
  correlationIndex = observation.eventIndex
731
- reconstructed = replaySessionTimelineFact(reconstructed, observation)
1355
+ // Replay proves byte-for-byte equality, but its cost is proportional to the
1356
+ // retained payload of the whole session. Admission therefore replays only
1357
+ // while that payload stays inside the budget and falls back to the per-fact
1358
+ // digests plus the shape/identity checks above beyond it.
1359
+ replayBytes += factDigest(observation.event).length * 4
1360
+ if (replayBytes > replayBudget) {
1361
+ replayComplete = false
1362
+ continue
1363
+ }
1364
+ reconstructed = replaySessionTimelineFact(reconstructed, observation, executable)
1365
+ }
1366
+ const summary = retentionSummaryOf(value)
1367
+ // A compacted state that lost its summary must be rejected: dropping the summary
1368
+ // would erase the only trace of the clusters the window removed.
1369
+ if (value.compacted === true && summary === undefined) {
1370
+ throw new TypeError('inconsistent PTC session-log projection retention summary')
1371
+ }
1372
+ if (value.compacted !== true && summary !== undefined) {
1373
+ throw new TypeError('inconsistent PTC session-log projection retention summary')
1374
+ }
1375
+ if (summary !== undefined) {
1376
+ // A compacted checkpoint dropped whole clusters, so replay can never reproduce
1377
+ // it; the summary is proved instead — against the log when the caller has it.
1378
+ validateRetentionSummary(value, summary, logFacts)
1379
+ } else if (replayComplete) {
1380
+ // Non-compacted states keep the full replay equality.
1381
+ requireFact(deepEqualJson(withoutFactDigests(reconstructed), withoutFactDigests(value)))
1382
+ }
1383
+ // Semantic checks run first so their diagnostics stay specific; the digest is
1384
+ // the last line of defence and rejects a payload edit without replaying it.
1385
+ for (const observation of value.eventFacts) {
1386
+ if (observation.digest === undefined) continue
1387
+ const { digest, ...rest } = observation
1388
+ requireFact(digest === factDigest(rest))
732
1389
  }
733
- requireFact(deepEqualJson(reconstructed, value))
734
1390
  for (const observation of ordinaryObservations.values()) {
735
1391
  const settlement = settlements.get(observation.eventSeq)
736
1392
  requireFact(settlement !== undefined
737
1393
  ? settlement.ordinary && settlement.observedIndex === observation.eventIndex
738
- && deepEqualJson(settlement.call, observation.call)
1394
+ && (observation.callDigest === undefined
1395
+ ? deepEqualJson(settlement.call, observation.call)
1396
+ : eventDigest(settlement.call) === observation.callDigest)
739
1397
  : value.runSelectionHistory.some(frontier => frontier.index === observation.eventIndex
740
1398
  && frontier.reason === 'result-identity-mismatch'))
741
1399
  }
@@ -743,7 +1401,9 @@ export function validateSessionTimelineState(value) {
743
1401
  if (!settlement.ordinary) continue
744
1402
  const observation = ordinaryObservations.get(settlement.call.seq)
745
1403
  requireFact(observation !== undefined && observation.eventIndex === settlement.observedIndex
746
- && deepEqualJson(observation.call, settlement.call))
1404
+ && (observation.callDigest === undefined
1405
+ ? deepEqualJson(observation.call, settlement.call)
1406
+ : eventDigest(settlement.call) === observation.callDigest))
747
1407
  }
748
1408
  const publications = [...settlements.values()].filter(settlement => settlement.publication !== undefined)
749
1409
  // Dispatch targets are derived from first-observed settlements and retained
@@ -789,7 +1449,7 @@ export function validateSessionTimelineState(value) {
789
1449
  dispatchEdits.delete(settlement.call.seq)
790
1450
  if (settlement.publication?.derived === undefined) dispatchClaims.delete(claim)
791
1451
  }
792
- dispatchRun = selectSettlementRun(dispatchRun, settlement, dispatchScope, dispatchFrontier)
1452
+ dispatchRun = selectSettlementRun(dispatchRun, settlement, dispatchScope, dispatchFrontier, executable)
793
1453
  }
794
1454
  }
795
1455
  requireFact(claimed.size === dispatchClaims.size && [...dispatchClaims].every(seq => claimed.has(seq)))
@@ -797,14 +1457,14 @@ export function validateSessionTimelineState(value) {
797
1457
  let selectedRun
798
1458
  let lastSuccessfulRunIndex
799
1459
  for (const settlement of publications) {
800
- selectedRun = selectSettlementRun(selectedRun, settlement, value.scope, value.runSelectionFrontier)
801
- if (successfulTimelineRun(settlementRun(settlement))) {
1460
+ selectedRun = selectSettlementRun(selectedRun, settlement, value.scope, value.runSelectionFrontier, executable)
1461
+ if (successfulTimelineRun(settlementRun(settlement, executable))) {
802
1462
  lastSuccessfulRunIndex = Math.max(lastSuccessfulRunIndex ?? -1, settlement.publication.index)
803
1463
  }
804
1464
  }
805
1465
  requireFact(selectedRun === undefined || publications.some(settlement =>
806
1466
  settlement.entry.scope === value.scope && !settlement.entry.ambiguous
807
- && settlementRun(settlement).callSeq === selectedRun.callSeq))
1467
+ && settlementRun(settlement, executable)?.callSeq === selectedRun.callSeq))
808
1468
  requireFact(selectedRun === undefined ? value.latestRun === undefined && value.editableRun === undefined
809
1469
  : deepEqualJson(value.latestRun ?? null, selectedRun) && deepEqualJson(value.editableRun ?? null, selectedRun))
810
1470
  requireFact(value.lastSuccessfulRunIndex === lastSuccessfulRunIndex
@@ -838,8 +1498,8 @@ export function advanceSessionTimeline(previous, inputEvent, eventIndex = inputE
838
1498
  }
839
1499
 
840
1500
  /** Reconstruct an already validated normalized fact at its recorded cut. */
841
- export function replaySessionTimelineFact(previous, fact) {
842
- return advanceTimelineEvent(previous, fact.event, fact.eventIndex, fact)
1501
+ export function replaySessionTimelineFact(previous, fact, retainedCalls) {
1502
+ return advanceTimelineEvent(previous, fact.event, fact.eventIndex, fact, retainedCalls)
843
1503
  }
844
1504
 
845
1505
  function dispatchPairWithinRoot(facts, call, resultEvent, startSeq) {
@@ -860,7 +1520,7 @@ function dispatchPairWithinRoot(facts, call, resultEvent, startSeq) {
860
1520
  return { start: starts[0], settled: settlements[0] }
861
1521
  }
862
1522
 
863
- function normalizePostEditFact(facts, call, resultEvent, result, replayPostEdit) {
1523
+ function normalizePostEditFact(facts, call, resultEvent, result, replayPostEdit, executableCalls) {
864
1524
  const post = replayPostEdit === undefined ? resultEvent.data?.meta?.[POST_EDIT_KEY] : {
865
1525
  startSeq: replayPostEdit.startSeq,
866
1526
  executionStartSeq: replayPostEdit.executionStartSeq,
@@ -909,11 +1569,17 @@ function normalizePostEditFact(facts, call, resultEvent, result, replayPostEdit)
909
1569
  }
910
1570
  let derived
911
1571
  try {
912
- derived = normalizeDerivedEditResult(post.meta, target?.callSeq)
1572
+ derived = normalizeDeferredEditSettlement(post.meta, target?.callSeq)
1573
+ // The derived metadata is a published settlement of its own, so it carries the
1574
+ // same proof the root journal does. Comparing the two recorded journal copies
1575
+ // proves only that they agree with each other; an edit that changed both and
1576
+ // left this proof behind no longer reproduces the settlement it published, and
1577
+ // its journal is then not evidence for the bindings the revision declares.
1578
+ requireRecordedSettlementProof(post.meta, derived.journal)
913
1579
  } catch (error) {
914
1580
  return fail(error.message)
915
1581
  }
916
- if (target?.source === undefined || sourceForRunCall(call) === undefined) {
1582
+ if (target?.source === undefined || sourceForRunCall(call, executableCalls) === undefined) {
917
1583
  return fail('nested edit has no selected source')
918
1584
  }
919
1585
  const edit = editRejectedCell(start.data.arguments, target.source)
@@ -946,7 +1612,63 @@ function normalizePostEditFact(facts, call, resultEvent, result, replayPostEdit)
946
1612
  ...(rewrites === undefined ? {} : { rewrites }) }
947
1613
  }
948
1614
 
949
- function advanceTimelineEvent(previous, inputEvent, eventIndex, replayFact) {
1615
+ /**
1616
+ * A published journal records the digest of the wording its diagnostics state.
1617
+ *
1618
+ * Recovery reports a failed cell by re-rendering its recorded diagnostic, and the
1619
+ * completion message and that diagnostic are two copies of one settlement, so a
1620
+ * record whose diagnostic text was edited can re-derive both consistently. The
1621
+ * digest is taken when the settlement is published, before either copy can change.
1622
+ * A record that carries no digest predates it and keeps the older evidence; one
1623
+ * whose diagnostics no longer hash to it is not evidence for its cell's bindings.
1624
+ */
1625
+ function requireRecordedDiagnosticWording(meta, journal) {
1626
+ if (!isRecord(meta) || !Object.hasOwn(meta, DIAGNOSTIC_WORDING_KEY)) return
1627
+ const recorded = meta[DIAGNOSTIC_WORDING_KEY]
1628
+ if (!isRecord(recorded) || Reflect.ownKeys(recorded).length !== DIAGNOSTIC_WORDING_FIELDS.size
1629
+ || Reflect.ownKeys(recorded).some(key => !DIAGNOSTIC_WORDING_FIELDS.has(key))) {
1630
+ throw new Error('invalid recorded diagnostic wording')
1631
+ }
1632
+ if (recorded.digest !== diagnosticWordingDigest(journal)) {
1633
+ throw new Error('journal diagnostic wording does not match its recorded digest')
1634
+ }
1635
+ }
1636
+
1637
+ /**
1638
+ * A published settlement proves its own facts, and the Host's copy of its outcome
1639
+ * must still agree with them.
1640
+ *
1641
+ * Recovery reads a record as evidence for the bindings its cell declared, and the
1642
+ * record is the only account of its cell: editing the status, the completion or a
1643
+ * diagnostic leaves the record self-consistent, because the completion is rendered
1644
+ * from the same diagnostic that was edited. The proof recorded at publication is
1645
+ * one digest over exactly those facts, so an edited record no longer reproduces it.
1646
+ * A record that carries no proof predates it and keeps the older evidence.
1647
+ *
1648
+ * The retained `isError` is the Host's copy of the same outcome and not a fact this
1649
+ * plugin can recompute, so an edit that also rewrites the proof still has to agree
1650
+ * with it. That relation is the one a deferred edit already proves before it is
1651
+ * published.
1652
+ */
1653
+ function requireRecordedSettlementProof(meta, journal, isError = undefined) {
1654
+ if (isError !== undefined && isError !== (journal.status === 'discarded'
1655
+ || journal.completion?.kind === 'throw')) {
1656
+ throw new Error('journal settlement does not agree with the tool result it settled')
1657
+ }
1658
+ if (!isRecord(meta) || !Object.hasOwn(meta, SETTLEMENT_PROOF_KEY)) return
1659
+ const recorded = meta[SETTLEMENT_PROOF_KEY]
1660
+ if (!isRecord(recorded) || Reflect.ownKeys(recorded).length !== SETTLEMENT_PROOF_FIELDS.size
1661
+ || Reflect.ownKeys(recorded).some(key => !SETTLEMENT_PROOF_FIELDS.has(key))
1662
+ || recorded.version !== SETTLEMENT_PROOF_VERSION
1663
+ || typeof recorded.recordedMessage !== 'string') {
1664
+ throw new Error('invalid recorded settlement proof')
1665
+ }
1666
+ if (recorded.recordedMessage !== settlementProofDigest(journal)) {
1667
+ throw new Error('journal facts do not match their recorded settlement proof')
1668
+ }
1669
+ }
1670
+
1671
+ function advanceTimelineEvent(previous, inputEvent, eventIndex, replayFact, retainedCalls) {
950
1672
  const nested = inputEvent?.type === 'tool/ptc-dispatch-start'
951
1673
  || inputEvent?.type === 'tool/ptc-dispatch'
952
1674
  if (nested && inputEvent.data?.name !== 'edit_run_code'
@@ -1044,7 +1766,7 @@ function advanceTimelineEvent(previous, inputEvent, eventIndex, replayFact) {
1044
1766
  if (event?.type === 'tool/ptc-dispatch') continue
1045
1767
  if (event?.type === 'tool/call' && typeof event.data?.callId === 'string') {
1046
1768
  const executable = REPL_TOOL_NAMES.has(event.data.name)
1047
- let entry = { event: timelineCallEvent(event), eventIndex, scope }
1769
+ let entry = { event: timelineCallEvent(event, retainedCalls ?? state.executableCalls), eventIndex, scope }
1048
1770
  if (executable) {
1049
1771
  if (isCanonicalSequence(event.seq) && state.executableCalls.has(event.seq)) {
1050
1772
  // A sequence collision disproves both sources, but not the earlier
@@ -1073,7 +1795,7 @@ function advanceTimelineEvent(previous, inputEvent, eventIndex, replayFact) {
1073
1795
  editClaims.set(event.seq, target.callSeq)
1074
1796
  }
1075
1797
  }
1076
- state.calls = [...state.calls, entry.event]
1798
+ state.calls = [...state.calls, event.seq]
1077
1799
  if (isCanonicalSequence(event.seq)) {
1078
1800
  state.executableCalls.set(event.seq, entry)
1079
1801
  }
@@ -1136,7 +1858,12 @@ function advanceTimelineEvent(previous, inputEvent, eventIndex, replayFact) {
1136
1858
  clearRunSelection('duplicate-result')
1137
1859
  continue
1138
1860
  }
1139
- ordinaryResultSeqs.set(callSeq, timelineJson({ call,
1861
+ // Non-REPL tools never enter `executableCalls` (only executable REPL calls
1862
+ // are retained there), so their observation keeps the call event itself.
1863
+ // REPL calls keep the digest and resolve the event through executableCalls,
1864
+ // which is why one table has two legal shapes.
1865
+ ordinaryResultSeqs.set(callSeq, timelineJson({
1866
+ ...(state.executableCalls.get(callSeq) === undefined ? { call } : { callDigest: eventDigest(call) }),
1140
1867
  eventSeq: isCanonicalSequence(event.seq) ? event.seq : callSeq, eventIndex }))
1141
1868
  }
1142
1869
  if (call !== undefined && typeof callId === 'string' && call.data.callId !== callId) {
@@ -1183,13 +1910,15 @@ function advanceTimelineEvent(previous, inputEvent, eventIndex, replayFact) {
1183
1910
  }
1184
1911
  try {
1185
1912
  if (call.data.name === 'edit_run_code') {
1186
- const derived = normalizeDerivedEditResult(meta, entry.editTarget?.callSeq)
1913
+ const derived = normalizeDeferredEditSettlement(meta, entry.editTarget?.callSeq)
1187
1914
  normalized = {
1188
1915
  ...raw,
1189
1916
  journal: derived.journal,
1190
1917
  derived,
1191
1918
  ...(derived.userBindings === undefined ? {} : { userBindings: derived.userBindings }),
1192
1919
  }
1920
+ requireRecordedDiagnosticWording(meta, derived.journal)
1921
+ requireRecordedSettlementProof(meta, derived.journal)
1193
1922
  } else if (call.data.name === 'run_code') {
1194
1923
  const rawJournal = meta[JOURNAL_KEY]
1195
1924
  const resolveLegacyConfirm = rawJournal?.version === LEGACY_JOURNAL_VERSION
@@ -1203,15 +1932,38 @@ function advanceTimelineEvent(previous, inputEvent, eventIndex, replayFact) {
1203
1932
  }
1204
1933
  : undefined
1205
1934
  const journal = normalizeJournal(rawJournal, { resolveLegacyConfirm })
1935
+ // A throw cell's completion message is the rendering of its own diagnostic.
1936
+ // A record whose message no longer matches was edited after the fact, so it
1937
+ // is not evidence for the bindings that cell declared.
1938
+ {
1939
+ let code
1940
+ try { code = JSON.parse(call.data.arguments)?.code } catch { code = undefined }
1941
+ const firstDiagnostic = journal.diagnostics?.[0]
1942
+ if (journal.completion?.kind === 'throw' && firstDiagnostic !== undefined
1943
+ && typeof code === 'string'
1944
+ && journal.completion.error?.message !== renderDiagnostic(firstDiagnostic, code)) {
1945
+ throw new Error('journal completion does not match its diagnostic')
1946
+ }
1947
+ }
1206
1948
  const userBindings = userBindingsForJournal(meta, journal)
1207
1949
  normalized = {
1208
1950
  ...raw,
1209
1951
  journal,
1210
1952
  ...(userBindings === undefined ? {} : { userBindings }),
1211
1953
  }
1954
+ requireRecordedDiagnosticWording(meta, journal)
1955
+ // The Host states the outcome of the cell it settled; a record whose
1956
+ // completion or status contradicts it was edited after publication. The
1957
+ // Host records that outcome on the result message it appends, so the
1958
+ // message is read first; a log that carries it on the event instead is
1959
+ // read the same way.
1960
+ const hostIsError = typeof event.data?.message?.isError === 'boolean'
1961
+ ? event.data.message.isError
1962
+ : typeof event.data?.isError === 'boolean' ? event.data.isError : undefined
1963
+ requireRecordedSettlementProof(meta, journal, hostIsError)
1212
1964
  let postEdit
1213
1965
  try {
1214
- postEdit = normalizePostEditFact(state.eventFacts, call, event, normalized)
1966
+ postEdit = normalizePostEditFact(state.eventFacts, call, event, normalized, undefined, state.executableCalls)
1215
1967
  } catch (error) {
1216
1968
  postEdit = { error: `unproved deferred edit: ${error.message}` }
1217
1969
  }
@@ -1235,7 +1987,7 @@ function advanceTimelineEvent(previous, inputEvent, eventIndex, replayFact) {
1235
1987
  if (replayFact.boundaries !== undefined) observation.boundaries = replayFact.boundaries
1236
1988
  if (replayFact.boundaryFailure !== undefined) observation.boundaryFailure = replayFact.boundaryFailure
1237
1989
  if (normalized.postEdit !== undefined) {
1238
- const checked = normalizePostEditFact(state.eventFacts, call, event, normalized, normalized.postEdit)
1990
+ const checked = normalizePostEditFact(state.eventFacts, call, event, normalized, normalized.postEdit, state.executableCalls)
1239
1991
  if (checked?.error !== undefined || !deepEqualJson(checked, normalized.postEdit)) {
1240
1992
  throw new TypeError(`invalid PTC session-log projection deferred edit fact: ${checked?.error ?? 'derived fact changed'}`)
1241
1993
  }
@@ -1251,7 +2003,11 @@ function advanceTimelineEvent(previous, inputEvent, eventIndex, replayFact) {
1251
2003
  if (isCanonicalSequence(resultSeq)) state.results.set(resultSeq, timelineJson(normalized))
1252
2004
  state.found = true
1253
2005
  }
1254
- const settlement = call === undefined ? undefined : { call, entry, identity: observation.identity,
2006
+ const settlement = call === undefined ? undefined : { call, entry: {
2007
+ eventIndex: entry?.eventIndex, scope: entry?.scope,
2008
+ ...(entry?.ambiguous === true ? { ambiguous: true } : {}),
2009
+ ...(entry?.editTarget === undefined ? {} : { editTarget: entry.editTarget }),
2010
+ }, identity: observation.identity,
1255
2011
  observedIndex: eventIndex, ordinary: !prunedReplacement,
1256
2012
  ...(normalized === undefined ? {} : { normalized }) }
1257
2013
  if (settlement !== undefined && entry.scope === scope
@@ -1272,6 +2028,7 @@ function advanceTimelineEvent(previous, inputEvent, eventIndex, replayFact) {
1272
2028
  }
1273
2029
  if (settlement !== undefined && isCanonicalSequence(event.seq)) {
1274
2030
  settledResultsByEventSeq.set(event.seq, settlement)
2031
+ state.retainedBytes += settlementBytes(settlement)
1275
2032
  }
1276
2033
 
1277
2034
  const claimedTarget = editClaims.get(callSeq)
@@ -1281,8 +2038,9 @@ function advanceTimelineEvent(previous, inputEvent, eventIndex, replayFact) {
1281
2038
  }
1282
2039
  if (entry === undefined || entry.scope !== scope) continue
1283
2040
  const publishRun = () => {
1284
- const run = settlementRun(settlement)
1285
- state.latestRun = selectSettlementRun(state.latestRun, settlement, scope, state.runSelectionFrontier)
2041
+ const run = settlementRun(settlement, state.executableCalls)
2042
+ state.latestRun = selectSettlementRun(state.latestRun, settlement, scope, state.runSelectionFrontier,
2043
+ state.executableCalls)
1286
2044
  state.editableRun = state.latestRun
1287
2045
  if (successfulTimelineRun(run)) {
1288
2046
  state.lastSuccessfulRunIndex = Math.max(state.lastSuccessfulRunIndex ?? -1, run.index)
@@ -1302,7 +2060,13 @@ function advanceTimelineEvent(previous, inputEvent, eventIndex, replayFact) {
1302
2060
  }
1303
2061
  publishRun()
1304
2062
  }
1305
- state.eventFacts[state.eventFacts.length - 1] = timelineJson(observation)
2063
+ // Digest the serialized fact (undefined fields dropped) so admission can
2064
+ // re-digest exactly the bytes it received.
2065
+ const plainObservation = JSON.parse(JSON.stringify(observation))
2066
+ plainObservation.digest = factDigest(plainObservation)
2067
+ state.eventFacts[state.eventFacts.length - 1] = timelineJson(plainObservation)
2068
+ pruneRetention(state, { settled: settledResultsByEventSeq, executable: state.executableCalls, results: state.results,
2069
+ ordinary: ordinaryResultSeqs })
1306
2070
  return Object.fromEntries(Object.entries({ ...state,
1307
2071
  executableCalls: state.executableCalls.table, results: state.results.table, editTargets: state.editTargets.table,
1308
2072
  pendingByCallId: pendingByCallId.table, seenCallIds: seenCallIds.table,
@@ -1312,6 +2076,285 @@ function advanceTimelineEvent(previous, inputEvent, eventIndex, replayFact) {
1312
2076
  }).filter(([_key, value]) => value !== undefined))
1313
2077
  }
1314
2078
 
2079
+ /**
2080
+ * Refold a session while keeping every payload.
2081
+ *
2082
+ * A consumer that needs a cluster the retention window dropped cannot rebuild it
2083
+ * from the checkpoint. The session log still has the events, so the caller refolds
2084
+ * that slice with pruning disabled and reads the cluster from there. The flag is
2085
+ * scoped to one synchronous fold; the checkpointed state never sees it.
2086
+ */
2087
+ let fullRetentionDepth = 0
2088
+ export function withFullRetention(run) {
2089
+ fullRetentionDepth += 1
2090
+ try {
2091
+ return run()
2092
+ } finally {
2093
+ fullRetentionDepth -= 1
2094
+ }
2095
+ }
2096
+
2097
+ /**
2098
+ * Drop the oldest settlements entirely and fold them into one summary.
2099
+ *
2100
+ * The projection is a fold shortcut, not the log. Keeping a digest stub per settled
2101
+ * cell still made a checkpoint proportional to how many cells the session ran (450
2102
+ * stubs cost ~1.7 MB before any payload), so the whole cluster is dropped instead:
2103
+ * the settlement, its executable call, its ordinary observation and its normalized
2104
+ * result. What survives is one chained summary of what was dropped, which is what
2105
+ * makes the size a function of the window rather than of the session.
2106
+ *
2107
+ * Consumers that need a dropped cell must refold it from the session log; when the
2108
+ * log cannot supply it they report `retention-pruned` instead of guessing.
2109
+ */
2110
+ /**
2111
+ * A call event whose argument text is gone keeps its identity and the digest of
2112
+ * exactly the bytes it used to carry, so a refold can prove the text it recovers
2113
+ * is the text the checkpoint once held.
2114
+ */
2115
+ function digestArgumentData(data) {
2116
+ if (!isRecord(data) || typeof data.arguments !== 'string') return data
2117
+ const { arguments: text, ...rest } = data
2118
+ return { ...rest, argumentsDigest: digestOf(text), argumentsBytes: byteLength(text) }
2119
+ }
2120
+
2121
+ /**
2122
+ * A republication clones a call event into its own settlement, so the same call can
2123
+ * appear twice: once in `executableCalls` and once in a settlement that names its
2124
+ * sequence. Whichever copy the retention window digests, every copy of that call must
2125
+ * end up in the same shape, or a checkpoint would describe one call two ways. The
2126
+ * pass is idempotent and runs on every prune, so a clone added after the window moved
2127
+ * still lands on the retained shape — the fold and the replay both go through it.
2128
+ */
2129
+ function digestSettlementCallCopies(tables) {
2130
+ for (const pair of Object.values(tables.settled.table)) {
2131
+ const [, settlement] = pair
2132
+ if (!isRecord(settlement) || !isRecord(settlement.call)) continue
2133
+ if (typeof settlement.call.data?.arguments !== 'string') continue
2134
+ const retained = tables.executable.get(settlement.call.seq)
2135
+ if (typeof retained?.event?.data?.argumentsDigest !== 'string') continue
2136
+ tables.settled.set(pair[0], { ...settlement, call: { ...settlement.call, data: digestArgumentData(settlement.call.data) } })
2137
+ }
2138
+ }
2139
+
2140
+ /**
2141
+ * A journal outside the payload window keeps everything recovery reads — status,
2142
+ * operations, user binding evidence, confirms, completion and per-call metadata —
2143
+ * and replaces each call's argument wire with its digest. Byte-for-byte equality
2144
+ * of argument text is what the window gives up; identity is not.
2145
+ */
2146
+ export function digestJournalArguments(journal) {
2147
+ if (process.env.DIGEST_SKIP_JOURNAL === '1') return journal
2148
+ if (!isRecord(journal) || !Array.isArray(journal.calls)) return journal
2149
+ return { ...journal, calls: journal.calls.map(call => {
2150
+ if (!isRecord(call) || !Object.hasOwn(call, 'args')) return call
2151
+ const { args, ...rest } = call
2152
+ return { ...rest, argsDigest: digestOf(args), argsBytes: byteLength(args) }
2153
+ }) }
2154
+ }
2155
+
2156
+ function pruneRetention(state, tables) {
2157
+ if (fullRetentionDepth > 0) return
2158
+ const entries = Object.entries(tables.settled.table)
2159
+ .map(([, pair]) => ({ settlement: pair[1], resultSeq: pair[0] }))
2160
+ .filter(entry => entry.settlement !== undefined)
2161
+ .sort((left, right) => left.settlement.observedIndex - right.settlement.observedIndex)
2162
+ if (entries.length === 0) return
2163
+ const limits = retentionLimits()
2164
+ // The count window is order-only so a replayed fold prunes identically. A
2165
+ // configured cluster cap of 0 means the count window never folds anything and
2166
+ // the payload budget is the only bound.
2167
+ const keepClusters = limits.clusters === 0 ? entries.length : Math.min(entries.length, limits.clusters)
2168
+ const keepFrom = Math.max(0, entries.length - keepClusters)
2169
+ // The runs a consumer reads synchronously — the latest publication and the cell
2170
+ // an edit would capture — stay inside the window even when newer settlements
2171
+ // pushed them past the count. Dropping them would turn "editable" into "pruned"
2172
+ // for the cell the user is actually looking at.
2173
+ const pinnedCallSeqs = new Set([state.latestRun?.callSeq, state.editableRun?.callSeq]
2174
+ .filter(callSeq => isCanonicalSequence(callSeq)))
2175
+ // Only a cluster beyond the configured cluster cap is folded into the interval
2176
+ // summary. Every other cluster stays, with its payload text replaced by digests,
2177
+ // so recovery evidence never depends on refolding the session log.
2178
+ const folded = new Set(entries.slice(0, keepFrom)
2179
+ .filter(entry => pinnedCallSeqs.has(entry.settlement.call?.seq) === false)
2180
+ .map(entry => entry.resultSeq))
2181
+ const mustPrune = new Set(folded)
2182
+ // A cumulative byte cap, not only a per-entry one: the window has to be a constant
2183
+ // regardless of how large the retained cells are, otherwise 32 cells of 200 KB
2184
+ // still put 6.4 MB in every checkpoint. Walk newest-first and stop retaining once
2185
+ // the payload budget is spent. A single entry larger than the whole budget is
2186
+ // dropped too: the bound wins over keeping the hot cell, and the edit path refolds
2187
+ // that cell from the session log when it is needed.
2188
+ const retainedPayloadBytes = (entry, executable = tables.executable) => {
2189
+ const callSeq = entry.settlement.call?.seq
2190
+ const stored = callSeq === undefined ? undefined : executable.get(callSeq)
2191
+ const executableArguments = stored?.event?.data?.arguments
2192
+ return settlementBytes(entry.settlement)
2193
+ + (typeof executableArguments === 'string' ? byteLength(executableArguments) : 0)
2194
+ }
2195
+ let retainedTotal = 0
2196
+ const newestFirst = [...entries].reverse()
2197
+ for (const [index, entry] of newestFirst.entries()) {
2198
+ const bytes = retainedPayloadBytes(entry)
2199
+ const pinned = pinnedCallSeqs.has(entry.settlement.call?.seq)
2200
+ // The run a consumer reads synchronously keeps its text even past the count
2201
+ // window: the window bounds how much history stays readable, never the cell the
2202
+ // user is looking at. Only the byte budget can digest a pinned cell.
2203
+ const outsidePayloadWindow = index >= limits.runs && !pinned
2204
+ const overBudget = bytes > limits.bytes
2205
+ if (overBudget || outsidePayloadWindow || (index > 0 && !pinned && retainedTotal + bytes > limits.bytes)) {
2206
+ mustPrune.add(entry.resultSeq)
2207
+ continue
2208
+ }
2209
+ retainedTotal += bytes
2210
+ }
2211
+ if (mustPrune.size === 0) return
2212
+ const droppedCallSeqs = new Set()
2213
+ const droppedResultSeqs = new Set()
2214
+ const dropped = []
2215
+ const digested = new Set([...mustPrune].filter(resultSeq => !folded.has(resultSeq)))
2216
+ const digestedResultSeqs = new Set()
2217
+ const digestedHotCell = state.latestRun !== undefined && entries.some(entry => (
2218
+ digested.has(entry.resultSeq) && entry.settlement.call?.seq === state.latestRun?.callSeq))
2219
+ for (const entry of entries) {
2220
+ if (!digested.has(entry.resultSeq)) continue
2221
+ const callSeq = entry.settlement.call?.seq
2222
+ if (callSeq !== undefined && tables.executable.has(callSeq)) {
2223
+ const stored = tables.executable.get(callSeq)
2224
+ tables.executable.set(callSeq, { ...stored, event: { ...stored.event, data: digestArgumentData(stored.event.data) } })
2225
+ digestSettlementCallCopies(tables)
2226
+ }
2227
+ // The result keeps every field recovery reads — status, operations, user
2228
+ // binding evidence, confirms, completion and call metadata — and loses only
2229
+ // the argument wire of each call.
2230
+ for (const key of [entry.resultSeq, callSeq]) {
2231
+ if (key === undefined || !tables.results.has(key)) continue
2232
+ const result = tables.results.get(key)
2233
+ if (!isRecord(result) || result.journal === undefined) continue
2234
+ tables.results.set(key, { ...result, journal: digestJournalArguments(result.journal) })
2235
+ }
2236
+ // The settlement keeps its normalized and publication entries because recovery
2237
+ // and the run derivations read them; only the journal argument wire and the
2238
+ // derived source are replaced by digests, which is exactly the payload the
2239
+ // window bounds. Identity, observations and result metadata stay.
2240
+ const digestJournalEntry = value => value === undefined ? undefined : {
2241
+ ...value,
2242
+ ...(value.journal === undefined ? {} : { journal: digestJournalArguments(value.journal) }),
2243
+
2244
+ }
2245
+ tables.settled.set(entry.resultSeq, {
2246
+ ...entry.settlement,
2247
+ ...(callSeq === undefined
2248
+ ? {}
2249
+ : { call: { ...entry.settlement.call, data: digestArgumentData(entry.settlement.call.data) } }),
2250
+ ...(entry.settlement.normalized === undefined
2251
+ ? {}
2252
+ : { normalized: digestJournalEntry(entry.settlement.normalized) }),
2253
+ ...(entry.settlement.publication === undefined
2254
+ ? {}
2255
+ : { publication: digestJournalEntry(entry.settlement.publication) }),
2256
+ })
2257
+ digestedResultSeqs.add(Number(entry.resultSeq))
2258
+ state.retainedBytes = Math.max(0, state.retainedBytes - settlementBytes(entry.settlement))
2259
+ }
2260
+ for (const entry of entries) {
2261
+ if (!mustPrune.has(entry.resultSeq)) continue
2262
+ if (!folded.has(entry.resultSeq)) continue
2263
+ const callSeq = entry.settlement.call?.seq
2264
+ dropped.push(summaryEntry(entry.settlement, Number(entry.resultSeq)))
2265
+ tables.settled.delete(entry.resultSeq)
2266
+ if (callSeq !== undefined) {
2267
+ tables.executable.delete(callSeq)
2268
+ if (tables.ordinary?.has(callSeq)) tables.ordinary.delete(callSeq)
2269
+ droppedCallSeqs.add(callSeq)
2270
+ }
2271
+ // `results` is keyed by the RESULT sequence on the ordinary path and by the CALL
2272
+ // sequence on the republication path, so drop both keys when either exists.
2273
+ for (const key of [entry.resultSeq, callSeq]) {
2274
+ if (key === undefined) continue
2275
+ if (tables.results.has(key)) tables.results.delete(key)
2276
+ }
2277
+ droppedResultSeqs.add(entry.resultSeq)
2278
+ state.retainedBytes = Math.max(0, state.retainedBytes - settlementBytes(entry.settlement))
2279
+ }
2280
+ // A digested cluster kept its identity, so the ordered call list keeps its seq;
2281
+ // a folded cluster is gone and takes its seq with it.
2282
+ state.calls = state.calls.filter(callSeq => !droppedCallSeqs.has(callSeq))
2283
+ // The hot run can be lost either way: a folded cluster no longer exists, and a
2284
+ // digested one no longer holds the source a run is built from. Consumers that
2285
+ // need it refold from the session log, and report the notice until they do.
2286
+ if (state.latestRun !== undefined
2287
+ && (droppedCallSeqs.has(state.latestRun.callSeq) || digestedHotCell)) {
2288
+ state.latestRun = undefined
2289
+ state.editableRun = undefined
2290
+ }
2291
+ // `hotCellDropped` describes the retained set, not the route the fold took to it.
2292
+ // It holds exactly when the newest settlement no longer carries its payload:
2293
+ // either it was folded away, or its argument text was digested. Recomputing it
2294
+ // from the table on every prune keeps a replayed fold — which reaches the same set
2295
+ // by a different route — from disagreeing with the stored one.
2296
+ const newestEntry = entries[entries.length - 1]
2297
+ const newestStored = newestEntry === undefined ? undefined : tables.settled.get(newestEntry.resultSeq)
2298
+ state.hotCellDropped = newestEntry !== undefined
2299
+ && (newestStored === undefined || newestStored.call?.data?.arguments === undefined)
2300
+ // `retainedBytes` is a property of the retained set, not a running total: a fold
2301
+ // that digests a cluster after it was counted would otherwise carry a different
2302
+ // value than a fold that reaches the same set by another route. Recompute it from
2303
+ // the table so both sides agree, and so the number always describes what is there.
2304
+ state.retainedBytes = entries.reduce((total, entry) => {
2305
+ const stored = tables.settled.get(entry.resultSeq)
2306
+ return stored === undefined ? total : total + settlementBytes(stored)
2307
+ }, 0)
2308
+ if (dropped.length === 0 && digested.size === 0) return
2309
+ if (dropped.length !== 0) {
2310
+ state.compacted = true
2311
+ dropped.sort((left, right) => left.resultSeq - right.resultSeq)
2312
+ state.retentionSummary = extendSummary(state.retentionSummary, dropped)
2313
+ }
2314
+ // The facts of a folded cluster keep their identity and digests but lose the
2315
+ // payload text, for the same reason the cluster itself is gone.
2316
+ // A fact per event is itself linear in the session length, so a folded cluster
2317
+ // takes its tool/call and tool/result facts with it. Admission for a compacted
2318
+ // checkpoint proves the summary instead of replaying those facts.
2319
+ state.eventFacts = state.eventFacts.filter(fact => {
2320
+ if (fact.event?.type === 'tool/call' && droppedCallSeqs.has(fact.event.seq)) return false
2321
+ if (fact.normalized !== undefined && droppedResultSeqs.has(Number(fact.normalized.eventSeq))) return false
2322
+ return true
2323
+ })
2324
+ // A digested cluster keeps its facts, but they carry the digest-only journal too:
2325
+ // the fact list is a second copy of the same result content. Rewriting the payload
2326
+ // invalidates the fact's own digest, so it is recomputed here — admission checks
2327
+ // that digest against the bytes it receives.
2328
+ state.eventFacts = state.eventFacts.map(fact => {
2329
+ if (fact.normalized === undefined || !digestedResultSeqs.has(Number(fact.normalized.eventSeq))) return fact
2330
+ const { digest: _digest, ...rest } = fact
2331
+ const next = { ...rest, normalized: digestJournalArguments(fact.normalized) }
2332
+ next.digest = factDigest(next)
2333
+ return next
2334
+ })
2335
+ // A dispatch fact names the cell an edit would capture, and that target carries
2336
+ // the cell's source. Once the payload is digested the fold can no longer derive
2337
+ // that source, so the fact must record the same "no target" the fold would
2338
+ // produce now; leaving the old text behind would both duplicate the payload and
2339
+ // fail the round-trip check that compares the fact with its re-derivation.
2340
+ const digestedCallSeqs = new Set(entries
2341
+ .filter(entry => digested.has(entry.resultSeq))
2342
+ .map(entry => Number(entry.settlement.call?.seq)))
2343
+ if (digestedCallSeqs.size !== 0) {
2344
+ let rewritten = false
2345
+ state.eventFacts = state.eventFacts.map(fact => {
2346
+ const target = fact.selectedTarget
2347
+ if (!isRecord(target) || !digestedCallSeqs.has(Number(target.callSeq))) return fact
2348
+ const { digest: _digest, ...rest } = fact
2349
+ const next = { ...rest, selectedTarget: null }
2350
+ next.digest = factDigest(next)
2351
+ rewritten = true
2352
+ return timelineJson(next)
2353
+ })
2354
+ if (rewritten) state.eventFacts = state.eventFacts.map(fact => timelineJson(fact))
2355
+ }
2356
+ }
2357
+
1315
2358
  export function sessionTimelineValue(checkpoint) {
1316
2359
  return { ...checkpoint,
1317
2360
  executableCalls: new Map(Object.values(checkpoint.executableCalls)),
@@ -1334,10 +2377,17 @@ export function foldSessionTimeline(events) {
1334
2377
  export function recoverJournal(session, currentCallSeq, options = {}) {
1335
2378
  const events = sessionEvents(session)
1336
2379
  if (!Array.isArray(events)) {
1337
- return { nodes: [], head: undefined, checkpoints: new Map(), volatileSuffix: [], available: true }
1338
- }
1339
- const timeline = foldSessionTimeline(events)
1340
- const calls = timeline.calls.filter(call => call.seq !== currentCallSeq)
2380
+ return { nodes: [], head: undefined, checkpoints: new Map(), volatileSuffix: [], provenBindings: [], available: true }
2381
+ }
2382
+ // Recovery rebuilds bindings from the cell sources and journals. A checkpoint
2383
+ // outside the payload window keeps those as digests, so the fold is repeated with
2384
+ // retention disabled on the same slice: the log is the authority, and reading it
2385
+ // costs one extra fold only when a digested cluster is present.
2386
+ const folded = foldSessionTimeline(events)
2387
+ const needsPayload = [...folded.executableCalls.values()]
2388
+ .some(entry => typeof entry?.event?.data?.arguments !== 'string')
2389
+ const timeline = needsPayload ? withFullRetention(() => foldSessionTimeline(events)) : folded
2390
+ const calls = timelineCalls(timeline).filter(call => call.seq !== currentCallSeq)
1341
2391
  const { executableCalls, results, found } = timeline
1342
2392
  const visibleCalls = options?.visibleCallSeqs
1343
2393
  const appliedBoundaries = options?.[RECOVERY_BOUNDARY_EVIDENCE]
@@ -1407,7 +2457,12 @@ export function recoverJournal(session, currentCallSeq, options = {}) {
1407
2457
  const records = []
1408
2458
  const orderedRecords = []
1409
2459
  const contractedCallRanges = []
1410
- let state = foldRecords(records, invalidCallSeqs)
2460
+ // A live environment that was released cannot lend its recorded live-only
2461
+ // values to the environment that replaces it. The flag belongs to the fold, so
2462
+ // every re-fold below carries it.
2463
+ const foldOptions = { liveOnlyLost: options?.liveOnlyLost === true,
2464
+ editRewritesHistory: options?.editRewritesHistory === true }
2465
+ let state = foldRecords(records, invalidCallSeqs, foldOptions)
1411
2466
  let boundaryIndex = 0
1412
2467
  const carrierCallSeqForBoundary = boundary => boundary.carrierCallSeq
1413
2468
  const isContractedCallSeq = (callSeq) => contractedCallRanges.some(range => (
@@ -1428,6 +2483,7 @@ export function recoverJournal(session, currentCallSeq, options = {}) {
1428
2483
  if (record.call.seq >= contractionSeq) invalidCallSeqs.add(record.call.seq)
1429
2484
  }
1430
2485
  state = foldRecords(records, invalidCallSeqs, {
2486
+ ...foldOptions,
1431
2487
  surfaceContracted: state.surfaceContracted,
1432
2488
  })
1433
2489
  registerUnavailable({ seq: carrierCallSeq ?? boundary.failedCallSeq, reason }, state)
@@ -1487,6 +2543,7 @@ export function recoverJournal(session, currentCallSeq, options = {}) {
1487
2543
  }
1488
2544
  }
1489
2545
  state = foldRecords(records, invalidCallSeqs, {
2546
+ ...foldOptions,
1490
2547
  surfaceContracted: resolvesUnavailable ? false : state.surfaceContracted,
1491
2548
  })
1492
2549
  if (resolvesUnavailable) {
@@ -1510,13 +2567,19 @@ export function recoverJournal(session, currentCallSeq, options = {}) {
1510
2567
  if (result.postEditError !== undefined) {
1511
2568
  invalidResultReasons.set(call.seq, result.postEditError)
1512
2569
  registerUnavailable({ seq: call.seq, reason: result.postEditError })
1513
- } else if (result.postEdit !== undefined) {
2570
+ } else if (result.postEdit !== undefined && result.postEdit.derived.unexecuted !== true) {
2571
+ // A derived execution that never ran proves no state of its own, so it is
2572
+ // not folded as a record; the root cell's own record already carries the
2573
+ // proof of the accepted edit.
1514
2574
  const postEdit = result.postEdit
1515
2575
  orderedRecords.push({
1516
2576
  call: timeline.eventFacts.find(fact => fact.event.seq === postEdit.executionStartSeq).event,
1517
2577
  code: postEdit.derived.code,
1518
2578
  result: { eventSeq: result.eventSeq, journal: postEdit.derived.journal,
1519
2579
  derived: postEdit.derived, userBindings: postEdit.derived.userBindings },
2580
+ // The revision names the cell it edits. Whether that relation replaces
2581
+ // the target or appends to it is the fold's decision, not the log's.
2582
+ supersedes: postEdit.targetCallSeq,
1520
2583
  hidden: visibleCalls instanceof Set && (!visibleCalls.has(call.seq)
1521
2584
  || !visibleCalls.has(postEdit.targetCallSeq)),
1522
2585
  })
@@ -1566,13 +2629,38 @@ export function recoverJournal(session, currentCallSeq, options = {}) {
1566
2629
  }
1567
2630
  applyBoundariesBefore(Number.POSITIVE_INFINITY)
1568
2631
  if (unavailableBoundary !== undefined) registerUnavailable(unavailableBoundary, state)
1569
- return {
2632
+ // The names the session log proves stay off the enumerable history shape so
2633
+ // every existing consumer sees the same object it saw before; recovery-only
2634
+ // readers (the missing-binding notice) reach them through this property.
2635
+ const history = {
1570
2636
  nodes: state.nodes,
1571
2637
  head: state.head,
1572
2638
  checkpoints: state.checkpoints,
1573
2639
  volatileSuffix: state.volatileSuffix,
1574
2640
  available: found && !state.surfaceContracted && unavailableBoundary === undefined,
1575
2641
  }
2642
+ history.provenBindings = [...state.provenBindings]
2643
+ // Recovery-only, like the proved names: the enumerable history shape is a
2644
+ // contract other readers compare against, so the deletion set stays off it.
2645
+ Object.defineProperty(history, 'deletedBindings', {
2646
+ value: [...state.deletedBindings],
2647
+ enumerable: false,
2648
+ configurable: true,
2649
+ })
2650
+ // The supersession relation is recovery evidence of the same kind: which cell
2651
+ // each revision replaced, and which proved names the path no longer supplies
2652
+ // because of it. Both stay off the enumerable shape for the same reason.
2653
+ Object.defineProperty(history, 'supersessions', {
2654
+ value: [...state.supersessions],
2655
+ enumerable: false,
2656
+ configurable: true,
2657
+ })
2658
+ Object.defineProperty(history, 'supersededBindings', {
2659
+ value: [...state.supersededBindings],
2660
+ enumerable: false,
2661
+ configurable: true,
2662
+ })
2663
+ return history
1576
2664
  }
1577
2665
 
1578
2666
  /** Prove that recovery consumes every persisted boundary at its declared frontier. */