dsh-ptc-plus 0.5.1 → 0.5.2

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 (58) hide show
  1. package/README.en.md +7 -3
  2. package/README.md +7 -3
  3. package/client.cjs +1467 -84
  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/0027-run-the-session-kernel-in-a-killable-helper-process.md +11 -1
  8. package/docs/adr/0032-own-session-log-facts-in-a-host-only-projection.md +31 -1
  9. package/docs/adr/0045-customize-model-visible-prompt-text.md +38 -0
  10. package/docs/adr/0046-gate-the-commit-with-the-semantic-evidence-tier.md +35 -0
  11. package/docs/adr/0047-store-journal-argument-digests-in-the-projection.md +88 -0
  12. package/docs/architecture.md +2 -2
  13. package/docs/client-ui.md +5 -2
  14. package/docs/durability-design.md +7 -1
  15. package/docs/releases/v0.5.2.md +79 -0
  16. package/docs/runtime-reference.md +36 -2
  17. package/docs/unmeasured-cell-recovery-repair-proposal.md +387 -0
  18. package/docs/verification.md +6 -2
  19. package/index.js +36 -15
  20. package/internal/bounded-literal.js +85 -0
  21. package/internal/capability-environment.js +162 -10
  22. package/internal/capability-plan.js +11 -8
  23. package/internal/cell-error.js +13 -0
  24. package/internal/config-spec.js +113 -2
  25. package/internal/direct-surface-owner.js +14 -10
  26. package/internal/edit-transport-owner.js +79 -14
  27. package/internal/isolated-worker.js +134 -17
  28. package/internal/kernel-worker.js +167 -3
  29. package/internal/legacy-cell-analysis.js +14 -2
  30. package/internal/legacy-repl-convenience.js +16 -3
  31. package/internal/projection-retention.js +238 -0
  32. package/internal/prompt-text.js +858 -0
  33. package/internal/recovery-tips.js +50 -11
  34. package/internal/rejected-cell-editor.js +3 -2
  35. package/internal/runtime-bridge-owner.js +1 -1
  36. package/internal/runtime-config.js +2 -0
  37. package/internal/runtime-contexts.js +14 -5
  38. package/internal/runtime-messages.js +43 -15
  39. package/internal/session-cell-executor.js +132 -11
  40. package/internal/session-journal-recovery.js +1009 -99
  41. package/internal/session-journal-schema.js +7 -5
  42. package/internal/session-journal.js +177 -14
  43. package/internal/session-log-view.js +111 -24
  44. package/internal/session-runtime.js +674 -33
  45. package/internal/session-state.js +27 -0
  46. package/internal/stateful-root-compiler.js +11 -0
  47. package/internal/stateful-root-runtime.js +17 -0
  48. package/internal/user-binding-console.js +7 -0
  49. package/internal/user-bindings-owner.js +3 -0
  50. package/internal/user-bindings.js +2 -2
  51. package/internal/validated-parse-repair.js +6 -1
  52. package/internal/worker-client.js +27 -4
  53. package/package.json +3 -1
  54. package/src/client-activity.js +24 -4
  55. package/src/client-capabilities-view.js +459 -24
  56. package/src/client-copy.js +163 -2
  57. package/src/client-repl-view.js +6 -1
  58. package/src/client-styles.js +64 -2
@@ -7,6 +7,8 @@ import {
7
7
  JOURNAL_KEY,
8
8
  RECOVERY_BOUNDARY_KEY,
9
9
  RECOVERY_BOUNDARY_EVENT,
10
+ DIAGNOSTIC_WORDING_KEY,
11
+ DIAGNOSTIC_WORDING_FIELDS,
10
12
  LEGACY_JOURNAL_VERSION,
11
13
  REPL_TOOL_NAMES,
12
14
  REWRITES_KEY,
@@ -15,8 +17,9 @@ import {
15
17
  POST_EDIT_KEY,
16
18
  } from './session-journal-schema.js'
17
19
  import {
20
+ diagnosticWordingDigest,
18
21
  isCanonicalSequence,
19
- normalizeDerivedEditResult,
22
+ normalizeDeferredEditSettlement,
20
23
  normalizeJournal,
21
24
  normalizeRecoveryBoundaries,
22
25
  reduceStateOperations,
@@ -25,6 +28,11 @@ import {
25
28
  } from './session-journal.js'
26
29
  import { decodeValue } from './value-wire.js'
27
30
  import { editRejectedCell } from './rejected-cell-editor.js'
31
+ import { renderDiagnostic } from './diagnostic.js'
32
+ import {
33
+ byteLength, chainOfEntries, digestOf, extendSummary, retainedClusterCount, retentionLimits, settlementBytes,
34
+ summaryEntry, summaryRecordOf,
35
+ } from './projection-retention.js'
28
36
 
29
37
  const RECOVERY_BOUNDARY_EVIDENCE = Symbol('recoveryBoundaryEvidence')
30
38
  const RUN_SELECTION_CLEAR_REASONS = new Set([
@@ -39,9 +47,26 @@ const MODEL_VISIBLE_SURFACE_EVENTS = new Set([
39
47
  'tool/result',
40
48
  ])
41
49
 
42
- function sourceForRunCall(call) {
50
+ /**
51
+ * Argument text for a call, resolved through its canonical owner.
52
+ *
53
+ * `executableCalls` retains the text once; a call event that still carries its
54
+ * own copy (the live fold, or an uncompacted checkpoint) is read directly. A
55
+ * compacted event and a pruned entry hold only digests, so derivations see
56
+ * `undefined` and must treat the run as unavailable rather than guess.
57
+ */
58
+ function callArguments(call, executableCalls) {
59
+ if (typeof call?.data?.arguments === 'string') return call.data.arguments
60
+ const entry = executableCalls?.get?.(call?.seq)
61
+ const stored = entry?.event?.data?.arguments
62
+ return typeof stored === 'string' ? stored : undefined
63
+ }
64
+
65
+ function sourceForRunCall(call, executableCalls) {
66
+ const text = callArguments(call, executableCalls)
67
+ if (text === undefined) return undefined
43
68
  try {
44
- const args = JSON.parse(call.data.arguments)
69
+ const args = JSON.parse(text)
45
70
  return isRecord(args) && typeof args.code === 'string' ? args.code : undefined
46
71
  } catch {
47
72
  return undefined
@@ -68,8 +93,50 @@ export function isConfirmableNoop(timeline, callSeq, resultEventIndex) {
68
93
  return call !== undefined && call.eventIndex < resultEventIndex && !timeline.results.has(callSeq)
69
94
  }
70
95
 
96
+ /** Node indices reachable from a head by following parents. */
97
+ function reachableFrom(nodes, head) {
98
+ const reachable = new Set()
99
+ for (let cursor = head; cursor !== undefined && !reachable.has(cursor);) {
100
+ reachable.add(cursor)
101
+ cursor = nodes[cursor]?.parent
102
+ }
103
+ return reachable
104
+ }
105
+
106
+ /**
107
+ * Binding names a node recorded.
108
+ *
109
+ * The recorded values are the only evidence that names a durable binding today;
110
+ * `unrecordedBindings` names the ones the codec could not transfer, and a
111
+ * `kind: "binding"` save names one explicitly. A checkpoint save never counts.
112
+ */
113
+ function recordedBindingNames(node) {
114
+ const journal = node?.journal
115
+ if (journal === undefined) return []
116
+ return [...new Set([
117
+ ...(journal.values ?? []).map(value => value.name),
118
+ ...(journal.unrecordedBindings ?? []),
119
+ ...bindingNamesOfOperations(journal.operations),
120
+ ].filter(name => typeof name === 'string'))]
121
+ }
122
+
71
123
  function applyOperations(state, operations, nodeIndex) {
124
+ const previousHead = state.head
72
125
  const transition = reduceStateOperations(state, operations, nodeIndex)
126
+ if (transition.restored) {
127
+ const reachableBefore = reachableFrom(state.nodes, previousHead)
128
+ const reachableAfter = reachableFrom(state.nodes, transition.head)
129
+ for (const index of reachableBefore) {
130
+ if (reachableAfter.has(index)) continue
131
+ for (const name of recordedBindingNames(state.nodes[index])) state.deletedBindings.add(name)
132
+ }
133
+ // The cutting node is not reachable from the new head either: the rewind it
134
+ // performed ends whatever that same cell created before the cut, so its own
135
+ // names belong to the recorded deletion rather than to an omission.
136
+ if (nodeIndex !== undefined) {
137
+ for (const name of recordedBindingNames(state.nodes[nodeIndex])) state.deletedBindings.add(name)
138
+ }
139
+ }
73
140
  state.head = transition.head
74
141
  state.checkpoints = transition.checkpoints
75
142
  if (transition.restored) {
@@ -78,21 +145,97 @@ function applyOperations(state, operations, nodeIndex) {
78
145
  }
79
146
  }
80
147
 
148
+ /**
149
+ * Binding names a recorded journal operation list saves.
150
+ *
151
+ * Only a save with `kind: "binding"` creates a binding. `repl.state({ action:
152
+ * "save" })` records a checkpoint name in the same `save` shape, which is why an
153
+ * untagged save is a checkpoint: every operation recorded before the kind existed
154
+ * came from that control call. Treating those names as bindings made a contraction
155
+ * report a branch name as a dropped variable.
156
+ */
157
+ export function bindingNamesOfOperations(operations) {
158
+ const names = new Set()
159
+ for (const operation of operations ?? []) {
160
+ if (operation.action === 'save' && operation.kind === 'binding' && typeof operation.name === 'string') {
161
+ names.add(operation.name)
162
+ }
163
+ }
164
+ return [...names]
165
+ }
166
+
81
167
  function applyRecord(state, record, invalidCallSeqs) {
82
168
  const { call, code, result } = record
169
+ // Every binding a journal names is a binding the session log proves. Recording
170
+ // the names here lets recovery report a binding it could not install instead of
171
+ // letting an unreplayed node take it silently.
172
+ // Only a volatile record proves a binding today: it cannot be replayed, so its
173
+ // recorded values are the entire evidence. A durable settlement records values too,
174
+ // for the restore path that installs nodes the replay does not reach, but its
175
+ // bindings are still proved by replaying it; counting them here would report a
176
+ // binding as dropped in every contraction whose replay is supposed to be complete.
177
+ // A released live environment proves nothing durable either: its live-only values
178
+ // are contracted below instead of being reported as a binding recovery owed.
179
+ const recordNames = [
180
+ ...(result?.journal?.status === 'volatile' && state.liveOnlyLost !== true
181
+ ? (result.journal.values ?? []).map(value => value.name)
182
+ : []),
183
+ ...bindingNamesOfOperations(result?.journal?.operations),
184
+ ]
185
+ for (const name of recordNames) state.provenBindings.add(name)
83
186
  if (invalidCallSeqs.has(call.seq)) return
84
187
  const journal = result.journal
85
188
  if (journal.status === 'noop') return
86
189
  if (journal.status === 'discarded') {
87
190
  if (journal.volatileReason !== undefined) {
88
191
  state.trusted = false
89
- state.volatileSuffix.push({ seq: call.seq, code, reason: journal.volatileReason })
192
+ const discardedBindings = bindingNamesOfOperations(journal.operations)
193
+ state.volatileSuffix.push({ seq: call.seq, code, reason: journal.volatileReason,
194
+ ...(discardedBindings.length === 0 ? {} : { bindings: discardedBindings }) })
90
195
  }
91
196
  return
92
197
  }
93
198
  if (journal.status === 'volatile') {
94
199
  state.trusted = false
95
- state.volatileSuffix.push({ seq: call.seq, code, reason: journal.volatileReason ?? 'volatile cell' })
200
+ // A volatile settlement drops its `save` operations, so the names it created
201
+ // survive only as the bindings the record could not transfer.
202
+ const volatileBindings = [...new Set([...bindingNamesOfOperations(journal.operations),
203
+ ...(journal.unrecordedBindings ?? [])])]
204
+ // A volatile cell that recorded its values keeps its place in the durable
205
+ // frontier: recovery installs those values instead of replaying the cell, so
206
+ // the completion is unverified while the bindings survive. A volatile cell
207
+ // whose values the codec could not transfer has nothing to install and is
208
+ // reported as a contraction instead of vanishing silently.
209
+ //
210
+ // Those values are the live environment's own observation, so a fold that
211
+ // knows the environment was released (`liveOnlyLost`) cannot install them:
212
+ // the owner that read `Date.now()` or `process.pid` is gone, and the next
213
+ // environment inherits the durable computation, not the live cache.
214
+ if (!state.liveOnlyLost && journal.values !== undefined && journal.values.length > 0) {
215
+ const node = Object.freeze({
216
+ code,
217
+ journal,
218
+ callSeq: call.seq,
219
+ parent: state.head,
220
+ restoredFromRecord: true,
221
+ ...(result.userBindings === undefined ? {} : { userBindings: result.userBindings }),
222
+ })
223
+ const index = state.nodes.push(node) - 1
224
+ state.head = index
225
+ applyOperations(state, journal.operations, index)
226
+ return
227
+ }
228
+ // A released live environment is reported as a contracted cell: the model
229
+ // loses whatever the live cache held, and naming those bindings would claim
230
+ // they were proved by the durable frontier when they never were.
231
+ state.volatileSuffix.push({
232
+ seq: call.seq,
233
+ code,
234
+ reason: state.liveOnlyLost === true
235
+ ? 'the live environment that observed this cell was released'
236
+ : journal.volatileReason ?? 'volatile cell',
237
+ ...(volatileBindings.length === 0 || state.liveOnlyLost === true ? {} : { bindings: volatileBindings }),
238
+ })
96
239
  applyOperations(state, journal.operations, undefined)
97
240
  return
98
241
  }
@@ -132,8 +275,11 @@ function foldRecords(records, invalidCallSeqs, options = {}) {
132
275
  head: undefined,
133
276
  checkpoints: new Map(),
134
277
  volatileSuffix: [],
278
+ provenBindings: new Set(),
279
+ deletedBindings: new Set(),
135
280
  trusted: true,
136
281
  surfaceContracted: options.surfaceContracted === true,
282
+ liveOnlyLost: options.liveOnlyLost === true,
137
283
  }
138
284
  for (const record of records) {
139
285
  applyRecord(state, record, invalidCallSeqs)
@@ -224,22 +370,29 @@ export function visibleExecutableCallSeqs(session) {
224
370
  if (visibleAssistantCallSeqs.has(event.seq)) calls.add(event.seq)
225
371
  }
226
372
  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)) {
373
+ if (process.env.DEBUG_RECOVER) console.error('DEBUG events', events.length, 'results', timeline.results.size, 'calls', timelineCalls(timeline).length, 'exec', timeline.executableCalls.size)
374
+ for (const seq of timeline.calls) {
375
+ const post = timeline.results.get(seq)?.postEdit
376
+ // A derived execution that never ran was never published as a cell the model
377
+ // can see, so it is not model-visible executable history.
378
+ if (post !== undefined && post.derived?.unexecuted !== true
379
+ && calls.has(seq) && calls.has(post.targetCallSeq)) {
230
380
  calls.add(post.executionStartSeq)
231
381
  }
232
382
  }
233
383
  return calls
234
384
  }
235
385
 
236
- function timelineRun(call, result, eventIndex, journal) {
386
+ function timelineRun(call, result, eventIndex, journal, executableCalls) {
237
387
  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.
388
+ const text = callArguments(call, executableCalls)
389
+ if (text !== undefined) {
390
+ try {
391
+ const parsed = JSON.parse(text)
392
+ if (isRecord(parsed)) args = Object.freeze(parsed)
393
+ } catch {
394
+ // Invalid persisted arguments cannot support derived source facts.
395
+ }
243
396
  }
244
397
  return Object.freeze({
245
398
  index: eventIndex,
@@ -263,27 +416,35 @@ function timelineDerivedRun(call, result, eventIndex, derived) {
263
416
  }
264
417
 
265
418
  function successfulTimelineRun(run) {
266
- return run.journal?.completion?.kind === 'return' && run.journal.status !== 'noop'
419
+ // A pruned settlement yields no run, so it cannot certify success either.
420
+ return run?.journal?.completion?.kind === 'return' && run.journal.status !== 'noop'
267
421
  }
268
422
 
269
- function settlementRun(settlement) {
423
+ function settlementRun(settlement, executableCalls) {
270
424
  const publication = settlement.publication
271
425
  if (publication === undefined) return undefined
272
426
  const result = { data: { meta: { [REWRITES_KEY]: publication.postEditRewrites ?? publication.rewrites } } }
427
+ // A revision that never ran publishes no source of its own, so the settlement
428
+ // keeps its own call's source: the root cell stays the editable target and the
429
+ // unexecuted revision never becomes one.
430
+ if (publication.postEdit?.derived?.unexecuted === true || publication.derived?.unexecuted === true) {
431
+ return timelineJson(timelineRun(settlement.call, result, publication.index, publication.journal, executableCalls))
432
+ }
273
433
  if (publication.postEdit !== undefined) {
274
434
  return timelineJson(timelineDerivedRun({ seq: publication.postEdit.executionStartSeq }, result,
275
435
  publication.index, publication.postEdit.derived))
276
436
  }
277
437
  return timelineJson(publication.derived === undefined
278
- ? timelineRun(settlement.call, result, publication.index, publication.journal)
438
+ ? timelineRun(settlement.call, result, publication.index, publication.journal, executableCalls)
279
439
  : timelineDerivedRun(settlement.call, result, publication.index, publication.derived))
280
440
  }
281
441
 
282
- function selectSettlementRun(selected, settlement, scope, frontier) {
442
+ function selectSettlementRun(selected, settlement, scope, frontier, executableCalls) {
283
443
  const publication = settlement.publication
284
444
  if (publication === undefined || settlement.entry.scope !== scope
285
445
  || (frontier !== null && publication.index <= frontier.index)) return selected
286
- return selected === undefined || publication.index >= selected.index ? settlementRun(settlement) : selected
446
+ return selected === undefined || publication.index >= selected.index
447
+ ? settlementRun(settlement, executableCalls) : selected
287
448
  }
288
449
 
289
450
  function editTargetForSelection(run, claimedTargets) {
@@ -416,6 +577,28 @@ function republishedTimelineResult(result, event) {
416
577
  return { ...result, eventSeq: event.seq, positionSeq: result.positionSeq ?? result.eventSeq }
417
578
  }
418
579
 
580
+ /**
581
+ * Fact-shaped data for a `tool/call`: the arguments are represented by their
582
+ * digest pair so a stored event fact never carries the text. The text itself is
583
+ * retained once, in `timelineCallEvent` inside `executableCalls`. A fact that is
584
+ * already digested passes through unchanged, which keeps re-deriving a fact from
585
+ * a compacted observation byte-identical.
586
+ */
587
+ function callFactData(event) {
588
+ const data = event?.data ?? {}
589
+ const value = data.arguments
590
+ const digested = value === undefined && typeof data.argumentsDigest === 'string'
591
+ ? { argumentsDigest: data.argumentsDigest, argumentsBytes: data.argumentsBytes }
592
+ : typeof value === 'string'
593
+ ? { argumentsDigest: digestOf(value), argumentsBytes: byteLength(value) }
594
+ : {}
595
+ return {
596
+ callId: data.callId,
597
+ ...(typeof data.name === 'string' ? { name: data.name } : {}),
598
+ ...digested,
599
+ }
600
+ }
601
+
419
602
  function correlationEvent(event) {
420
603
  return timelineJson({ seq: event.seq, type: event.type,
421
604
  ...(['tool/ptc-dispatch-start', 'tool/ptc-dispatch'].includes(event.type) ? { data: {
@@ -423,7 +606,12 @@ function correlationEvent(event) {
423
606
  parentCallId: event.data?.parentCallId,
424
607
  subCallId: event.data?.subCallId,
425
608
  name: event.data?.name,
426
- arguments: event.data?.arguments,
609
+ // A compacted dispatch keeps its digest pair, so re-deriving this fact from a
610
+ // pruned observation reproduces exactly what was stored.
611
+ ...(event.data?.arguments === undefined
612
+ ? (event.data?.argumentsDigest === undefined ? {}
613
+ : { argumentsDigest: event.data.argumentsDigest, argumentsBytes: event.data.argumentsBytes })
614
+ : { arguments: event.data.arguments }),
427
615
  ...(event.type === 'tool/ptc-dispatch' ? {
428
616
  isError: event.data?.isError,
429
617
  ...(event.data?.name === 'run_code' && event.data.meta?.[JOURNAL_KEY] !== undefined
@@ -431,7 +619,7 @@ function correlationEvent(event) {
431
619
  } : {}),
432
620
  } } : {}),
433
621
  ...(event.type === 'tool/call' && typeof event.data?.callId === 'string'
434
- ? { data: timelineCallEvent(event).data } : {}),
622
+ ? { data: callFactData(event) } : {}),
435
623
  ...(event.type === 'tool/result' ? { data: { message: { source: {
436
624
  ...(typeof event.data?.message?.source?.callId === 'string'
437
625
  ? { callId: event.data.message.source.callId } : {}),
@@ -501,13 +689,245 @@ function timelineJson(value) {
501
689
  return deepFreeze(JSON.parse(JSON.stringify(value ?? null)))
502
690
  }
503
691
 
504
- function timelineCallEvent(event) {
692
+ /**
693
+ * Resolve one executable call entry from either the live table or the
694
+ * serialized checkpoint shape.
695
+ */
696
+ function executableEntry(timeline, seq) {
697
+ const table = timeline.executableCalls
698
+ return typeof table?.get === 'function' ? table.get(seq) : table?.[`k${seq}`]?.[1]
699
+ }
700
+
701
+ /**
702
+ * The ordered executable call events.
703
+ *
704
+ * `calls` stores only the sequence order; the events themselves live once in
705
+ * `executableCalls`, so a checkpoint no longer serializes every call a second
706
+ * time.
707
+ */
708
+ function timelineCalls(timeline) {
709
+ const calls = []
710
+ for (const seq of timeline.calls) {
711
+ const event = executableEntry(timeline, seq)?.event
712
+ if (event !== undefined) calls.push(event)
713
+ }
714
+ return calls
715
+ }
716
+
717
+ /**
718
+ * The canonical call event retained in `executableCalls`.
719
+ *
720
+ * This is the only place the argument text is kept. A replay that only has a
721
+ * digested fact resolves the text from the entry already stored for that seq, so
722
+ * a checkpointed call and a re-derived one stay byte-identical.
723
+ */
724
+ function timelineCallEvent(event, executableCalls) {
725
+ const entry = executableCalls?.get?.(event.seq) ?? executableCalls?.[`k${event.seq}`]?.[1]
726
+ const stored = entry?.event?.data?.arguments
727
+ const text = typeof event.data.arguments === 'string'
728
+ ? event.data.arguments
729
+ : typeof stored === 'string' ? stored : undefined
505
730
  return timelineJson({ seq: event.seq, type: event.type, data: {
506
731
  callId: event.data.callId, name: event.data.name,
507
- ...(REPL_TOOL_NAMES.has(event.data.name) ? { arguments: event.data.arguments } : {}),
732
+ ...(REPL_TOOL_NAMES.has(event.data.name)
733
+ ? (text === undefined
734
+ ? { argumentsDigest: event.data.argumentsDigest, argumentsBytes: event.data.argumentsBytes }
735
+ : { arguments: text })
736
+ : {}),
508
737
  } })
509
738
  }
510
739
 
740
+ /** Stable digest of a stored projection fact, used by checkpoint admission. */
741
+ export function factDigest(value) {
742
+ return createHash('sha256').update(JSON.stringify(value ?? null, (_key, field) => (
743
+ isRecord(field) ? Object.fromEntries(Object.keys(field).sort().map(key => [key, field[key]])) : field
744
+ ))).digest('hex')
745
+ }
746
+
747
+ function withoutFactDigests(state) {
748
+ // Admission compares what the projection describes, not bookkeeping whose exact
749
+ // value depends on when pruning happened: per-fact digests and the retained-byte
750
+ // counter are accounting, not session state.
751
+ const { retainedBytes: _retainedBytes, retentionSummary: _retentionSummary, ...rest } = state
752
+ const facts = rest.eventFacts
753
+ if (!facts.some(fact => fact.digest !== undefined)) return rest
754
+ return { ...rest, eventFacts: facts.map(({ digest, ...fact }) => fact) }
755
+ }
756
+
757
+ /**
758
+ * The retention summary an admitted checkpoint carries.
759
+ *
760
+ * A compacted checkpoint keeps no per-settlement stubs at all: dropping the whole
761
+ * cluster is what makes the file size independent of how many cells the session
762
+ * ran. What survives is one chained summary of what was dropped, so admission can
763
+ * still tell "never existed" from "existed and was dropped".
764
+ */
765
+ export function retentionSummaryOf(state) {
766
+ const summary = state?.retentionSummary
767
+ if (summary === undefined || summary === null) return undefined
768
+ return summary
769
+ }
770
+
771
+ /**
772
+ * Prove a compacted checkpoint without replaying it.
773
+ *
774
+ * The retained window must be contiguous with the summary boundary: every
775
+ * settlement above `toSeq` is present and intact, every executable call that a
776
+ * retained settlement names is present, and the summary is a well-formed chain.
777
+ * Below the boundary nothing survives, which is exactly the contract of dropping
778
+ * a cluster. This is the weaker, stated claim admission makes for compacted
779
+ * states; uncompacted states keep the byte-for-byte replay equality.
780
+ */
781
+ export function validateRetentionSummary(timeline, summary, logFacts) {
782
+ if (!isRecord(summary)) throw new TypeError('invalid PTC session-log projection retention summary')
783
+ const keys = Object.keys(summary)
784
+ if (keys.some(key => !['fromSeq', 'toSeq', 'count', 'chainDigest', 'entries'].includes(key))) {
785
+ throw new TypeError('invalid PTC session-log projection retention summary')
786
+ }
787
+ if (!isCanonicalSequence(summary.fromSeq) || !isCanonicalSequence(summary.toSeq)
788
+ || summary.fromSeq > summary.toSeq || !Number.isSafeInteger(summary.count) || summary.count < 1
789
+ || typeof summary.chainDigest !== 'string' || !/^[a-f0-9]{64}$/u.test(summary.chainDigest)) {
790
+ throw new TypeError('invalid PTC session-log projection retention summary')
791
+ }
792
+ for (const [key, pair] of Object.entries(timeline.settledResultsByEventSeq ?? {})) {
793
+ const seq = pair?.[0]
794
+ const settlement = pair?.[1]
795
+ if (!isCanonicalSequence(seq) || settlement === undefined) {
796
+ throw new TypeError('invalid PTC session-log projection retention window')
797
+ }
798
+ if (seq <= summary.toSeq) {
799
+ throw new TypeError(`PTC session-log projection retains settlement ${key} below its retention boundary`)
800
+ }
801
+ const callSeq = settlement.call?.seq
802
+ if (!isCanonicalSequence(callSeq) || timeline.executableCalls?.[`k${callSeq}`] === undefined) {
803
+ throw new TypeError('PTC session-log projection lost the call of a retained settlement')
804
+ }
805
+ }
806
+ // Admission receives no session log, so it cannot fold the dropped range. What it
807
+ // can prove is that the summary is internally consistent: one record per dropped
808
+ // cluster, ordered by result sequence, with the range and the chain derived from
809
+ // those records. Trusting a summary without a record per cluster is what made an
810
+ // earlier version forgeable. The stronger claim — that a record describes the same
811
+ // cluster the log still holds — is checked when a dropped cluster is refolded
812
+ // (`verifyRefoldedSettlement`), because that is the only point where the log and
813
+ // the record meet.
814
+ const records = summary.entries
815
+ if (!Array.isArray(records) || records.length !== summary.count) {
816
+ throw new TypeError('inconsistent PTC session-log projection retention summary')
817
+ }
818
+ let previous = -1
819
+ for (const record of records) {
820
+ if (!isRecord(record)
821
+ || !isCanonicalSequence(record.resultSeq) || !isCanonicalSequence(record.callSeq)
822
+ || typeof record.argumentsDigest !== 'string' || !/^[a-f0-9]{64}$/u.test(record.argumentsDigest)
823
+ || record.resultSeq <= previous) {
824
+ throw new TypeError('inconsistent PTC session-log projection retention summary')
825
+ }
826
+ previous = record.resultSeq
827
+ }
828
+ if (records[0].resultSeq !== summary.fromSeq || records[records.length - 1].resultSeq !== summary.toSeq
829
+ || chainOfEntries(records) !== summary.chainDigest) {
830
+ throw new TypeError('inconsistent PTC session-log projection retention summary')
831
+ }
832
+ return summary
833
+ }
834
+
835
+ /**
836
+ * Prove the recorded chain against the raw session log.
837
+ *
838
+ * Folds the events up to the boundary with retention disabled, derives the summary
839
+ * the fold would have produced for the dropped range, and requires it to equal the
840
+ * recorded one. A caller without the log must say so: this function throws
841
+ * `unverifiable PTC session-log projection retention summary` instead of accepting.
842
+ */
843
+ export function verifyRetentionSummaryAgainstEvents(summary, events) {
844
+ if (!Array.isArray(events)) {
845
+ throw new TypeError('unverifiable PTC session-log projection retention summary')
846
+ }
847
+ const expected = retentionSummaryFromEvents(events, summary.toSeq)
848
+ if (expected === undefined
849
+ || expected.count !== summary.count || expected.fromSeq !== summary.fromSeq
850
+ || expected.toSeq !== summary.toSeq || expected.chainDigest !== summary.chainDigest) {
851
+ throw new TypeError('inconsistent PTC session-log projection retention summary')
852
+ }
853
+ return summary
854
+ }
855
+
856
+ /**
857
+ * Refold the editable cell from the log and check it against the summary.
858
+ *
859
+ * The checkpoint cannot prove that its summary describes the clusters the log
860
+ * still holds — admission has no log. This is the one place where both sides meet:
861
+ * the log is folded with pruning disabled, the settlement behind the editable run
862
+ * is turned into a summary record, and the record stored in the summary must equal
863
+ * it. A mismatch means the summary and the log disagree, which is reported to the
864
+ * caller instead of yielding an edit target that was never recorded.
865
+ */
866
+ export function refoldEditableSettlement(events, summary) {
867
+ if (!Array.isArray(events)) return { run: undefined, mismatch: false }
868
+ let state
869
+ withFullRetention(() => {
870
+ state = createSessionTimelineState()
871
+ events.forEach((event, index) => {
872
+ if (!isRecord(event)) return
873
+ state = advanceSessionTimeline(state, event, isCanonicalSequence(event.seq) ? event.seq : index)
874
+ })
875
+ })
876
+ const run = state?.editableRun
877
+ if (run === undefined) return { run: undefined, mismatch: false }
878
+ const settled = Object.entries(state?.settledResultsByEventSeq ?? {})
879
+ .map(([, pair]) => pair)
880
+ .find(([, settlement]) => settlement?.call?.seq === run.callSeq)
881
+ if (settled === undefined) return { run, mismatch: false }
882
+ const [resultSeq, settlement] = settled
883
+ const recorded = summaryRecordOf(summary, Number(resultSeq))
884
+ if (recorded === undefined) return { run, mismatch: false }
885
+ const refolded = summaryEntry(settlement, Number(resultSeq))
886
+ const mismatch = recorded.callSeq !== refolded.callSeq || recorded.argumentsDigest !== refolded.argumentsDigest
887
+ return { run, mismatch }
888
+ }
889
+
890
+ /** Recompute the summary a fold of the dropped range would have produced. */
891
+ function retentionSummaryFromEvents(events, toSeq) {
892
+ let state
893
+ withFullRetention(() => {
894
+ state = createSessionTimelineState()
895
+ events.forEach((event, index) => {
896
+ if (!isRecord(event)) return
897
+ if (isCanonicalSequence(event.seq) && event.seq > toSeq) return
898
+ state = advanceSessionTimeline(state, event, isCanonicalSequence(event.seq) ? event.seq : index)
899
+ })
900
+ })
901
+ const dropped = Object.entries(state?.settledResultsByEventSeq ?? {})
902
+ .map(([, pair]) => pair)
903
+ .filter(([resultSeq]) => Number(resultSeq) <= toSeq)
904
+ // The prune orders dropped entries by RESULT sequence before folding them into the
905
+ // chain, so summing here must use the same order; observedIndex can disagree when
906
+ // a nested call settles out of order.
907
+ .sort((left, right) => Number(left[0]) - Number(right[0]))
908
+ .map(([resultSeq, settlement]) => summaryEntry(settlement, Number(resultSeq)))
909
+ if (dropped.length === 0) return undefined
910
+ return extendSummary(undefined, dropped)
911
+ }
912
+
913
+ /**
914
+ * The identity of a call is what survives the payload window: sequence, type,
915
+ * callId and name. The argument text is deliberately excluded, because retention
916
+ * replaces it with `argumentsDigest` and an identity that hashed the text would make
917
+ * every observation describe a different call before and after pruning. The text is
918
+ * still bound by its own digest in the call entry, so excluding it hides nothing.
919
+ */
920
+ function eventDigest(event) {
921
+ // Identity only, and only the fields every call keeps: a payload the window
922
+ // digested must hash the same before and after pruning, which is why the source
923
+ // text and the result are not part of it. A call always carries its `callId`
924
+ // (executable calls are admitted with one), so no second shape exists to hash.
925
+ const data = isRecord(event?.data) ? event.data : {}
926
+ const identity = { seq: event?.seq, type: event?.type,
927
+ data: { callId: data.callId, name: data.name ?? null } }
928
+ return createHash('sha256').update(JSON.stringify(identity)).digest('hex')
929
+ }
930
+
511
931
  function resultIdentity(data) {
512
932
  const identity = timelineJson(isRecord(data) && isRecord(data.message)
513
933
  ? { ...data, message: { ...data.message, content: undefined } } : data)
@@ -516,6 +936,23 @@ function resultIdentity(data) {
516
936
  ))).digest('hex')
517
937
  }
518
938
 
939
+ /**
940
+ * How many bytes of retained payload admission may replay.
941
+ *
942
+ * A stored checkpoint is proved two ways: replaying its facts reproduces it
943
+ * byte-for-byte, or — because that replay costs a second fold of everything the
944
+ * session wrote — its per-fact digests and identity chain prove continuity. This
945
+ * budget decides which claim a given checkpoint gets, and both replays read it
946
+ * through `admissionReplayBudget`, so the timeline replay here and the
947
+ * log-identity replay in the session-log projection cannot disagree about it.
948
+ */
949
+ const ADMISSION_REPLAY_BUDGET_BYTES = 4 * 1024 * 1024
950
+
951
+ /** Resolve a caller's replay budget, falling back to the shipped one. */
952
+ export function admissionReplayBudget(bytes) {
953
+ return Number.isSafeInteger(bytes) && bytes > 0 ? bytes : ADMISSION_REPLAY_BUDGET_BYTES
954
+ }
955
+
519
956
  export function createSessionTimelineState() {
520
957
  return {
521
958
  openTurn: false,
@@ -536,11 +973,34 @@ export function createSessionTimelineState() {
536
973
  runSelectionHistory: [],
537
974
  lastEventIndex: null,
538
975
  eventFacts: [],
976
+ // Payload bytes the retained settlements still carry: the retention window
977
+ // keeps this bounded so a checkpoint never scales with written content.
978
+ retainedBytes: 0,
979
+ // One chained summary of the clusters the window dropped. Per-settlement stubs
980
+ // would still scale with the number of cells the session ran.
981
+ retentionSummary: null,
982
+ // Set once any cluster is dropped, so admission can tell "nothing was ever
983
+ // dropped" from "a summary was deleted". A compacted state must carry its summary.
984
+ compacted: false,
985
+ // Set when the newest cell alone exceeded the byte budget and had to go. The
986
+ // consumers that would have read it must say so instead of looking empty.
987
+ hotCellDropped: false,
539
988
  scope: 0,
540
989
  }
541
990
  }
542
991
 
543
- export function validateSessionTimelineState(value) {
992
+ /**
993
+ * Prove a stored timeline checkpoint, given the session log's own facts when the
994
+ * caller holds them.
995
+ *
996
+ * `replayBudgetBytes` is the caller's replay budget: a checkpoint above it is
997
+ * admitted on its per-fact digests and identity chain instead of a replay, and a
998
+ * caller that lowers the budget (a test, or a host with a cheaper fold) must
999
+ * lower this same claim, not a second copy of it.
1000
+ */
1001
+ export function validateSessionTimelineState(value, logFacts, options = {}) {
1002
+ const replayBudget = admissionReplayBudget(options.replayBudgetBytes)
1003
+
544
1004
  const requireFact = condition => {
545
1005
  if (!condition) throw new TypeError('invalid PTC session-log projection timeline checkpoint')
546
1006
  }
@@ -549,17 +1009,36 @@ export function validateSessionTimelineState(value) {
549
1009
  const target = field => fields(field, ['callSeq', 'source']) && isCanonicalSequence(field.callSeq) && typeof field.source === 'string'
550
1010
  const callEvent = event => fields(event, ['seq', 'type', 'data']) && isCanonicalSequence(event.seq) && event.type === 'tool/call'
551
1011
  && isRecord(event.data) && typeof event.data.callId === 'string'
552
- && fields(event.data, ['callId', 'name', 'arguments'])
1012
+ && fields(event.data, ['callId', 'name', 'arguments', 'argumentsDigest', 'argumentsBytes'])
553
1013
  && optional(event.data.name, name => typeof name === 'string')
554
- && optional(event.data.arguments, args => typeof args === 'string')
1014
+ && (event.data.arguments === undefined
1015
+ ? (event.data.argumentsDigest === undefined
1016
+ || (typeof event.data.argumentsDigest === 'string' && /^[a-f0-9]{64}$/u.test(event.data.argumentsDigest)
1017
+ && isCanonicalSequence(event.data.argumentsBytes)))
1018
+ : (typeof event.data.arguments === 'string' && event.data.argumentsDigest === undefined
1019
+ && event.data.argumentsBytes === undefined))
555
1020
  const callEntry = entry => fields(entry, ['event', 'eventIndex', 'scope', 'ambiguous', 'editTarget']) && callEvent(entry.event)
556
1021
  && isCanonicalSequence(entry.eventIndex) && isCanonicalSequence(entry.scope) && entry.scope <= value.scope
557
1022
  && optional(entry.ambiguous, field => field === true) && optional(entry.editTarget, target)
1023
+ // A settlement references its call through `call` and keeps only the entry's
1024
+ // position and claims. Storing `entry.event` as well would serialize the call
1025
+ // event a second time — the duplication the retention work exists to remove.
1026
+ const settlementEntry = entry => fields(entry, ['eventIndex', 'scope', 'ambiguous', 'editTarget'])
1027
+ && isCanonicalSequence(entry.eventIndex) && isCanonicalSequence(entry.scope) && entry.scope <= value.scope
1028
+ && optional(entry.ambiguous, field => field === true) && optional(entry.editTarget, target)
558
1029
  const table = (name, keyValid, payloadValid) => {
559
- requireFact(isRecord(value[name]))
1030
+ if (!isRecord(value[name])) {
1031
+ throw new TypeError(`invalid PTC session-log projection timeline checkpoint: table ${name} is missing`)
1032
+ }
560
1033
  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]))
1034
+ if (!Array.isArray(pair) || pair.length !== 2 || !keyValid(pair[0]) || key !== `k${pair[0]}`) {
1035
+ throw new TypeError(`invalid PTC session-log projection timeline checkpoint: table ${name} entry ${key}`)
1036
+ }
1037
+ if (!payloadValid(pair[1], pair[0])) {
1038
+ const problem = name === 'settledResultsByEventSeq' ? settlementProblem(pair[1]) : undefined
1039
+ const shape = isRecord(pair[1]) ? ` keys=${Object.keys(pair[1]).join(',')}` : ` value=${JSON.stringify(pair[1])?.slice(0, 80)}`
1040
+ throw new TypeError(`invalid PTC session-log projection timeline checkpoint: table ${name} payload ${key}${shape}${problem === undefined ? '' : ` problem=${problem}`}`)
1041
+ }
563
1042
  }
564
1043
  return new Map(Object.values(value[name]))
565
1044
  }
@@ -567,9 +1046,14 @@ export function validateSessionTimelineState(value) {
567
1046
  'cordisTranscript', 'editTargets', 'pendingByCallId', 'seenCallIds', 'claimedEditTargets', 'editClaims',
568
1047
  'ordinaryResultSeqs', 'settledResultsByEventSeq', 'scope', 'latestRun', 'editableRun', 'lastSuccessfulRunIndex',
569
1048
  'unavailableResultSeq', 'unavailableResultReason', 'pruneReplacementWindow', 'runSelectionFrontier',
570
- 'runSelectionHistory', 'lastEventIndex', 'eventFacts']) && typeof value.openTurn === 'boolean'
1049
+ 'runSelectionHistory', 'lastEventIndex', 'eventFacts', 'retainedBytes', 'retentionSummary', 'compacted',
1050
+ 'hotCellDropped']) && typeof value.openTurn === 'boolean'
571
1051
  && typeof value.found === 'boolean' && isCanonicalSequence(value.scope)
572
- && Array.isArray(value.calls) && value.calls.every(callEvent) && Array.isArray(value.boundaries))
1052
+ && typeof value.retainedBytes === 'number' && value.retainedBytes >= 0
1053
+ && (value.retentionSummary === undefined || value.retentionSummary === null || isRecord(value.retentionSummary))
1054
+ && typeof value.compacted === 'boolean' && typeof value.hotCellDropped === 'boolean'
1055
+ && Array.isArray(value.calls) && value.calls.every(seq => isCanonicalSequence(seq))
1056
+ && Array.isArray(value.boundaries))
573
1057
  requireFact(value.lastEventIndex === null || isCanonicalSequence(value.lastEventIndex))
574
1058
  requireFact(Array.isArray(value.runSelectionHistory))
575
1059
  let boundaryScope = 0
@@ -593,9 +1077,10 @@ export function validateSessionTimelineState(value) {
593
1077
  && entry.event.seq === seq && REPL_TOOL_NAMES.has(entry.event.data.name))
594
1078
  requireFact(value.calls.length === executable.size)
595
1079
  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)
1080
+ for (const seq of value.calls) {
1081
+ // The order list may only name stored entries, and may not repeat one.
1082
+ requireFact(!callSeqs.has(seq) && executable.has(seq))
1083
+ callSeqs.add(seq)
599
1084
  }
600
1085
  const journalFacts = result => {
601
1086
  if (!isRecord(result.journal)) return false
@@ -606,7 +1091,7 @@ export function validateSessionTimelineState(value) {
606
1091
  if (result.derived !== undefined) {
607
1092
  const derived = result.derived
608
1093
  if (!isRecord(derived)) return false
609
- const normalized = normalizeDerivedEditResult({
1094
+ const normalized = normalizeDeferredEditSettlement({
610
1095
  [JOURNAL_KEY]: result.journal,
611
1096
  [EDIT_TARGET_KEY]: { targetCallSeq: derived.targetCallSeq },
612
1097
  [DERIVED_RUN_KEY]: { code: derived.code, description: derived.description },
@@ -618,6 +1103,8 @@ export function validateSessionTimelineState(value) {
618
1103
  return true
619
1104
  }
620
1105
  const normalizedResult = result => {
1106
+ // Every stored result keeps its full shape: a dropped cluster takes its result
1107
+ // entry with it, so a digest-only result would be a shape nothing produces.
621
1108
  if (!fields(result, ['eventSeq', 'eventIndex', 'positionSeq', 'error', 'journal', 'derived', 'userBindings',
622
1109
  'postEdit', 'postEditError'])
623
1110
  || !isCanonicalSequence(result.eventSeq) || !isCanonicalSequence(result.eventIndex)
@@ -628,7 +1115,7 @@ export function validateSessionTimelineState(value) {
628
1115
  && optional(result.postEdit, post => fields(post, ['startSeq', 'executionStartSeq', 'targetCallSeq', 'derived', 'rewrites'])
629
1116
  && isCanonicalSequence(post.startSeq) && isCanonicalSequence(post.executionStartSeq)
630
1117
  && isCanonicalSequence(post.targetCallSeq)
631
- && deepEqualJson(normalizeDerivedEditResult({
1118
+ && deepEqualJson(normalizeDeferredEditSettlement({
632
1119
  [JOURNAL_KEY]: post.derived?.journal,
633
1120
  [EDIT_TARGET_KEY]: { targetCallSeq: post.targetCallSeq },
634
1121
  [DERIVED_RUN_KEY]: { code: post.derived?.code, description: post.derived?.description },
@@ -657,49 +1144,75 @@ export function validateSessionTimelineState(value) {
657
1144
  const claimed = table('claimedEditTargets', isCanonicalSequence, (field, seq) => trueValue(field) && executable.has(seq))
658
1145
  const edits = table('editClaims', isCanonicalSequence, (seq, editSeq) => isCanonicalSequence(seq)
659
1146
  && 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
1147
+ const ordinaryObservations = table('ordinaryResultSeqs', isCanonicalSequence, (observation, seq) =>
1148
+ ((fields(observation, ['call', 'eventSeq', 'eventIndex']) && callEvent(observation.call) && observation.call.seq === seq
1149
+ && value.executableCalls[`k${seq}`] === undefined)
1150
+ || (fields(observation, ['callDigest', 'eventSeq', 'eventIndex'])
1151
+ && typeof observation.callDigest === 'string' && /^[a-f0-9]{64}$/u.test(observation.callDigest)
1152
+ && value.executableCalls[`k${seq}`] !== undefined
1153
+ && eventDigest(value.executableCalls[`k${seq}`][1].event) === observation.callDigest))
662
1154
  && isCanonicalSequence(observation.eventSeq) && isCanonicalSequence(observation.eventIndex)
663
1155
  && value.lastEventIndex !== null && observation.eventIndex <= value.lastEventIndex
664
- && (!REPL_TOOL_NAMES.has(observation.call.data.name)
1156
+ && (observation.callDigest !== undefined || !REPL_TOOL_NAMES.has(observation.call.data.name)
665
1157
  || (deepEqualJson(executable.get(seq)?.event, observation.call)
666
1158
  && 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)))
1159
+ /**
1160
+ * Name the clause a settlement fails, so a rejected checkpoint says which relation
1161
+ * broke instead of only which table. The clauses are the same ones the inline
1162
+ * predicate used to run; this only reports them.
1163
+ */
1164
+ const settlementProblem = settlement => {
1165
+ if (!fields(settlement, ['call', 'entry', 'identity', 'publication', 'observedIndex', 'ordinary', 'normalized'])) return 'fields'
1166
+ if (!callEvent(settlement.call)) return 'callEvent'
1167
+ if (!settlementEntry(settlement.entry)) return 'entry'
1168
+ if (!optional(settlement.normalized, normalizedResult)) return 'normalized'
1169
+ if (typeof settlement.ordinary !== 'boolean') return 'ordinary'
1170
+ if (!isCanonicalSequence(settlement.observedIndex) || value.lastEventIndex === null) return 'observedIndex'
1171
+ if (settlement.observedIndex < settlement.entry.eventIndex || settlement.observedIndex > value.lastEventIndex) return 'observedIndex range'
1172
+ if (typeof settlement.identity !== 'string' || !/^[a-f0-9]{64}$/u.test(settlement.identity)) return 'identity'
1173
+ if (REPL_TOOL_NAMES.has(settlement.call.data.name)
1174
+ && !deepEqualJson(executable.get(settlement.call.seq)?.event, settlement.call)) {
1175
+ const stored = executable.get(settlement.call.seq)?.event
1176
+ return `executable call (stored=${JSON.stringify(stored)?.slice(0, 220)} settlement=${JSON.stringify(settlement.call)?.slice(0, 220)})`
1177
+ }
1178
+ const publicationProblem = publication => {
1179
+ if (!fields(publication, ['index', 'journal', 'derived', 'userBindings', 'rewrites', 'postEdit', 'postEditRewrites'])) return 'publication fields'
1180
+ if (!deepEqualJson(publication.journal ?? null, settlement.normalized?.journal ?? null)) return 'publication journal'
1181
+ if (!deepEqualJson(publication.derived ?? null, settlement.normalized?.derived ?? null)) return 'publication derived'
1182
+ if (!deepEqualJson(publication.userBindings ?? null, settlement.normalized?.userBindings ?? null)) return 'publication userBindings'
1183
+ if (!deepEqualJson(publication.postEdit ?? null, settlement.normalized?.postEdit ?? null)) return 'publication postEdit'
1184
+ if (!deepEqualJson(publication.postEditRewrites ?? null, settlement.normalized?.postEdit?.rewrites ?? null)) return 'publication postEditRewrites'
1185
+ if (settlement.entry.ambiguous) return 'publication ambiguity'
1186
+ if (!isCanonicalSequence(publication.index)) return 'publication index'
1187
+ if (publication.index !== (settlement.ordinary ? settlement.observedIndex : settlement.entry.eventIndex)) return 'publication position'
1188
+ if (settlement.call.data.name === 'run_code' && publication.derived !== undefined) return 'publication derived target'
1189
+ if (settlement.call.data.name === 'edit_run_code'
1190
+ && (publication.derived === undefined
1191
+ || publication.derived.targetCallSeq !== settlement.entry.editTarget?.callSeq)) return 'publication edit target'
1192
+ if (publication.journal === undefined
1193
+ ? publication.derived !== undefined || publication.userBindings !== undefined
1194
+ : !journalFacts(publication)) return 'publication journal facts'
1195
+ if (!deepEqualJson(validatedRewrites({ [REWRITES_KEY]: publication.rewrites }) ?? null, publication.rewrites ?? null)) return 'publication rewrites'
1196
+ return undefined
1197
+ }
1198
+ if (!optional(settlement.publication, publication => publicationProblem(publication) === undefined)) return 'publication'
1199
+ if (isRecord(settlement.publication)) {
1200
+ const problem = publicationProblem(settlement.publication)
1201
+ if (problem !== undefined) return problem
1202
+ }
1203
+ return undefined
1204
+ }
1205
+ const settlements = table('settledResultsByEventSeq', isCanonicalSequence, settlement => settlementProblem(settlement) === undefined)
695
1206
  // Normalized event facts retain distinctions needed by the transition but
696
1207
  // omit result content. Admission uses that same transition, not a second
697
1208
  // partial interpretation of the materialized caches.
698
1209
  requireFact(Array.isArray(value.eventFacts))
699
1210
  let reconstructed = createSessionTimelineState()
700
1211
  let correlationIndex = -1
1212
+ let replayBytes = 0
1213
+ let replayComplete = true
701
1214
  for (const observation of value.eventFacts) {
702
- requireFact(fields(observation, ['eventIndex', 'event', 'identity', 'hasJournal', 'normalized',
1215
+ requireFact(fields(observation, ['eventIndex', 'event', 'digest', 'identity', 'hasJournal', 'normalized',
703
1216
  'boundaries', 'boundaryFailure', 'rewrites', 'selectedTarget'])
704
1217
  && isCanonicalSequence(observation.eventIndex) && observation.eventIndex > correlationIndex
705
1218
  && value.lastEventIndex !== null && observation.eventIndex <= value.lastEventIndex
@@ -728,14 +1241,48 @@ export function validateSessionTimelineState(value) {
728
1241
  && deepEqualJson(validatedRewrites({ [REWRITES_KEY]: observation.rewrites }) ?? null,
729
1242
  observation.rewrites ?? null))
730
1243
  correlationIndex = observation.eventIndex
731
- reconstructed = replaySessionTimelineFact(reconstructed, observation)
1244
+ // Replay proves byte-for-byte equality, but its cost is proportional to the
1245
+ // retained payload of the whole session. Admission therefore replays only
1246
+ // while that payload stays inside the budget and falls back to the per-fact
1247
+ // digests plus the shape/identity checks above beyond it.
1248
+ replayBytes += factDigest(observation.event).length * 4
1249
+ if (replayBytes > replayBudget) {
1250
+ replayComplete = false
1251
+ continue
1252
+ }
1253
+ reconstructed = replaySessionTimelineFact(reconstructed, observation, executable)
1254
+ }
1255
+ const summary = retentionSummaryOf(value)
1256
+ // A compacted state that lost its summary must be rejected: dropping the summary
1257
+ // would erase the only trace of the clusters the window removed.
1258
+ if (value.compacted === true && summary === undefined) {
1259
+ throw new TypeError('inconsistent PTC session-log projection retention summary')
1260
+ }
1261
+ if (value.compacted !== true && summary !== undefined) {
1262
+ throw new TypeError('inconsistent PTC session-log projection retention summary')
1263
+ }
1264
+ if (summary !== undefined) {
1265
+ // A compacted checkpoint dropped whole clusters, so replay can never reproduce
1266
+ // it; the summary is proved instead — against the log when the caller has it.
1267
+ validateRetentionSummary(value, summary, logFacts)
1268
+ } else if (replayComplete) {
1269
+ // Non-compacted states keep the full replay equality.
1270
+ requireFact(deepEqualJson(withoutFactDigests(reconstructed), withoutFactDigests(value)))
1271
+ }
1272
+ // Semantic checks run first so their diagnostics stay specific; the digest is
1273
+ // the last line of defence and rejects a payload edit without replaying it.
1274
+ for (const observation of value.eventFacts) {
1275
+ if (observation.digest === undefined) continue
1276
+ const { digest, ...rest } = observation
1277
+ requireFact(digest === factDigest(rest))
732
1278
  }
733
- requireFact(deepEqualJson(reconstructed, value))
734
1279
  for (const observation of ordinaryObservations.values()) {
735
1280
  const settlement = settlements.get(observation.eventSeq)
736
1281
  requireFact(settlement !== undefined
737
1282
  ? settlement.ordinary && settlement.observedIndex === observation.eventIndex
738
- && deepEqualJson(settlement.call, observation.call)
1283
+ && (observation.callDigest === undefined
1284
+ ? deepEqualJson(settlement.call, observation.call)
1285
+ : eventDigest(settlement.call) === observation.callDigest)
739
1286
  : value.runSelectionHistory.some(frontier => frontier.index === observation.eventIndex
740
1287
  && frontier.reason === 'result-identity-mismatch'))
741
1288
  }
@@ -743,7 +1290,9 @@ export function validateSessionTimelineState(value) {
743
1290
  if (!settlement.ordinary) continue
744
1291
  const observation = ordinaryObservations.get(settlement.call.seq)
745
1292
  requireFact(observation !== undefined && observation.eventIndex === settlement.observedIndex
746
- && deepEqualJson(observation.call, settlement.call))
1293
+ && (observation.callDigest === undefined
1294
+ ? deepEqualJson(observation.call, settlement.call)
1295
+ : eventDigest(settlement.call) === observation.callDigest))
747
1296
  }
748
1297
  const publications = [...settlements.values()].filter(settlement => settlement.publication !== undefined)
749
1298
  // Dispatch targets are derived from first-observed settlements and retained
@@ -789,7 +1338,7 @@ export function validateSessionTimelineState(value) {
789
1338
  dispatchEdits.delete(settlement.call.seq)
790
1339
  if (settlement.publication?.derived === undefined) dispatchClaims.delete(claim)
791
1340
  }
792
- dispatchRun = selectSettlementRun(dispatchRun, settlement, dispatchScope, dispatchFrontier)
1341
+ dispatchRun = selectSettlementRun(dispatchRun, settlement, dispatchScope, dispatchFrontier, executable)
793
1342
  }
794
1343
  }
795
1344
  requireFact(claimed.size === dispatchClaims.size && [...dispatchClaims].every(seq => claimed.has(seq)))
@@ -797,14 +1346,14 @@ export function validateSessionTimelineState(value) {
797
1346
  let selectedRun
798
1347
  let lastSuccessfulRunIndex
799
1348
  for (const settlement of publications) {
800
- selectedRun = selectSettlementRun(selectedRun, settlement, value.scope, value.runSelectionFrontier)
801
- if (successfulTimelineRun(settlementRun(settlement))) {
1349
+ selectedRun = selectSettlementRun(selectedRun, settlement, value.scope, value.runSelectionFrontier, executable)
1350
+ if (successfulTimelineRun(settlementRun(settlement, executable))) {
802
1351
  lastSuccessfulRunIndex = Math.max(lastSuccessfulRunIndex ?? -1, settlement.publication.index)
803
1352
  }
804
1353
  }
805
1354
  requireFact(selectedRun === undefined || publications.some(settlement =>
806
1355
  settlement.entry.scope === value.scope && !settlement.entry.ambiguous
807
- && settlementRun(settlement).callSeq === selectedRun.callSeq))
1356
+ && settlementRun(settlement, executable)?.callSeq === selectedRun.callSeq))
808
1357
  requireFact(selectedRun === undefined ? value.latestRun === undefined && value.editableRun === undefined
809
1358
  : deepEqualJson(value.latestRun ?? null, selectedRun) && deepEqualJson(value.editableRun ?? null, selectedRun))
810
1359
  requireFact(value.lastSuccessfulRunIndex === lastSuccessfulRunIndex
@@ -838,8 +1387,8 @@ export function advanceSessionTimeline(previous, inputEvent, eventIndex = inputE
838
1387
  }
839
1388
 
840
1389
  /** 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)
1390
+ export function replaySessionTimelineFact(previous, fact, retainedCalls) {
1391
+ return advanceTimelineEvent(previous, fact.event, fact.eventIndex, fact, retainedCalls)
843
1392
  }
844
1393
 
845
1394
  function dispatchPairWithinRoot(facts, call, resultEvent, startSeq) {
@@ -860,7 +1409,7 @@ function dispatchPairWithinRoot(facts, call, resultEvent, startSeq) {
860
1409
  return { start: starts[0], settled: settlements[0] }
861
1410
  }
862
1411
 
863
- function normalizePostEditFact(facts, call, resultEvent, result, replayPostEdit) {
1412
+ function normalizePostEditFact(facts, call, resultEvent, result, replayPostEdit, executableCalls) {
864
1413
  const post = replayPostEdit === undefined ? resultEvent.data?.meta?.[POST_EDIT_KEY] : {
865
1414
  startSeq: replayPostEdit.startSeq,
866
1415
  executionStartSeq: replayPostEdit.executionStartSeq,
@@ -909,11 +1458,11 @@ function normalizePostEditFact(facts, call, resultEvent, result, replayPostEdit)
909
1458
  }
910
1459
  let derived
911
1460
  try {
912
- derived = normalizeDerivedEditResult(post.meta, target?.callSeq)
1461
+ derived = normalizeDeferredEditSettlement(post.meta, target?.callSeq)
913
1462
  } catch (error) {
914
1463
  return fail(error.message)
915
1464
  }
916
- if (target?.source === undefined || sourceForRunCall(call) === undefined) {
1465
+ if (target?.source === undefined || sourceForRunCall(call, executableCalls) === undefined) {
917
1466
  return fail('nested edit has no selected source')
918
1467
  }
919
1468
  const edit = editRejectedCell(start.data.arguments, target.source)
@@ -946,7 +1495,29 @@ function normalizePostEditFact(facts, call, resultEvent, result, replayPostEdit)
946
1495
  ...(rewrites === undefined ? {} : { rewrites }) }
947
1496
  }
948
1497
 
949
- function advanceTimelineEvent(previous, inputEvent, eventIndex, replayFact) {
1498
+ /**
1499
+ * A published journal records the digest of the wording its diagnostics state.
1500
+ *
1501
+ * Recovery reports a failed cell by re-rendering its recorded diagnostic, and the
1502
+ * completion message and that diagnostic are two copies of one settlement, so a
1503
+ * record whose diagnostic text was edited can re-derive both consistently. The
1504
+ * digest is taken when the settlement is published, before either copy can change.
1505
+ * A record that carries no digest predates it and keeps the older evidence; one
1506
+ * whose diagnostics no longer hash to it is not evidence for its cell's bindings.
1507
+ */
1508
+ function requireRecordedDiagnosticWording(meta, journal) {
1509
+ if (!isRecord(meta) || !Object.hasOwn(meta, DIAGNOSTIC_WORDING_KEY)) return
1510
+ const recorded = meta[DIAGNOSTIC_WORDING_KEY]
1511
+ if (!isRecord(recorded) || Reflect.ownKeys(recorded).length !== DIAGNOSTIC_WORDING_FIELDS.size
1512
+ || Reflect.ownKeys(recorded).some(key => !DIAGNOSTIC_WORDING_FIELDS.has(key))) {
1513
+ throw new Error('invalid recorded diagnostic wording')
1514
+ }
1515
+ if (recorded.digest !== diagnosticWordingDigest(journal)) {
1516
+ throw new Error('journal diagnostic wording does not match its recorded digest')
1517
+ }
1518
+ }
1519
+
1520
+ function advanceTimelineEvent(previous, inputEvent, eventIndex, replayFact, retainedCalls) {
950
1521
  const nested = inputEvent?.type === 'tool/ptc-dispatch-start'
951
1522
  || inputEvent?.type === 'tool/ptc-dispatch'
952
1523
  if (nested && inputEvent.data?.name !== 'edit_run_code'
@@ -1044,7 +1615,7 @@ function advanceTimelineEvent(previous, inputEvent, eventIndex, replayFact) {
1044
1615
  if (event?.type === 'tool/ptc-dispatch') continue
1045
1616
  if (event?.type === 'tool/call' && typeof event.data?.callId === 'string') {
1046
1617
  const executable = REPL_TOOL_NAMES.has(event.data.name)
1047
- let entry = { event: timelineCallEvent(event), eventIndex, scope }
1618
+ let entry = { event: timelineCallEvent(event, retainedCalls ?? state.executableCalls), eventIndex, scope }
1048
1619
  if (executable) {
1049
1620
  if (isCanonicalSequence(event.seq) && state.executableCalls.has(event.seq)) {
1050
1621
  // A sequence collision disproves both sources, but not the earlier
@@ -1073,7 +1644,7 @@ function advanceTimelineEvent(previous, inputEvent, eventIndex, replayFact) {
1073
1644
  editClaims.set(event.seq, target.callSeq)
1074
1645
  }
1075
1646
  }
1076
- state.calls = [...state.calls, entry.event]
1647
+ state.calls = [...state.calls, event.seq]
1077
1648
  if (isCanonicalSequence(event.seq)) {
1078
1649
  state.executableCalls.set(event.seq, entry)
1079
1650
  }
@@ -1136,7 +1707,12 @@ function advanceTimelineEvent(previous, inputEvent, eventIndex, replayFact) {
1136
1707
  clearRunSelection('duplicate-result')
1137
1708
  continue
1138
1709
  }
1139
- ordinaryResultSeqs.set(callSeq, timelineJson({ call,
1710
+ // Non-REPL tools never enter `executableCalls` (only executable REPL calls
1711
+ // are retained there), so their observation keeps the call event itself.
1712
+ // REPL calls keep the digest and resolve the event through executableCalls,
1713
+ // which is why one table has two legal shapes.
1714
+ ordinaryResultSeqs.set(callSeq, timelineJson({
1715
+ ...(state.executableCalls.get(callSeq) === undefined ? { call } : { callDigest: eventDigest(call) }),
1140
1716
  eventSeq: isCanonicalSequence(event.seq) ? event.seq : callSeq, eventIndex }))
1141
1717
  }
1142
1718
  if (call !== undefined && typeof callId === 'string' && call.data.callId !== callId) {
@@ -1183,13 +1759,14 @@ function advanceTimelineEvent(previous, inputEvent, eventIndex, replayFact) {
1183
1759
  }
1184
1760
  try {
1185
1761
  if (call.data.name === 'edit_run_code') {
1186
- const derived = normalizeDerivedEditResult(meta, entry.editTarget?.callSeq)
1762
+ const derived = normalizeDeferredEditSettlement(meta, entry.editTarget?.callSeq)
1187
1763
  normalized = {
1188
1764
  ...raw,
1189
1765
  journal: derived.journal,
1190
1766
  derived,
1191
1767
  ...(derived.userBindings === undefined ? {} : { userBindings: derived.userBindings }),
1192
1768
  }
1769
+ requireRecordedDiagnosticWording(meta, derived.journal)
1193
1770
  } else if (call.data.name === 'run_code') {
1194
1771
  const rawJournal = meta[JOURNAL_KEY]
1195
1772
  const resolveLegacyConfirm = rawJournal?.version === LEGACY_JOURNAL_VERSION
@@ -1203,15 +1780,29 @@ function advanceTimelineEvent(previous, inputEvent, eventIndex, replayFact) {
1203
1780
  }
1204
1781
  : undefined
1205
1782
  const journal = normalizeJournal(rawJournal, { resolveLegacyConfirm })
1783
+ // A throw cell's completion message is the rendering of its own diagnostic.
1784
+ // A record whose message no longer matches was edited after the fact, so it
1785
+ // is not evidence for the bindings that cell declared.
1786
+ {
1787
+ let code
1788
+ try { code = JSON.parse(call.data.arguments)?.code } catch { code = undefined }
1789
+ const firstDiagnostic = journal.diagnostics?.[0]
1790
+ if (journal.completion?.kind === 'throw' && firstDiagnostic !== undefined
1791
+ && typeof code === 'string'
1792
+ && journal.completion.error?.message !== renderDiagnostic(firstDiagnostic, code)) {
1793
+ throw new Error('journal completion does not match its diagnostic')
1794
+ }
1795
+ }
1206
1796
  const userBindings = userBindingsForJournal(meta, journal)
1207
1797
  normalized = {
1208
1798
  ...raw,
1209
1799
  journal,
1210
1800
  ...(userBindings === undefined ? {} : { userBindings }),
1211
1801
  }
1802
+ requireRecordedDiagnosticWording(meta, journal)
1212
1803
  let postEdit
1213
1804
  try {
1214
- postEdit = normalizePostEditFact(state.eventFacts, call, event, normalized)
1805
+ postEdit = normalizePostEditFact(state.eventFacts, call, event, normalized, undefined, state.executableCalls)
1215
1806
  } catch (error) {
1216
1807
  postEdit = { error: `unproved deferred edit: ${error.message}` }
1217
1808
  }
@@ -1235,7 +1826,7 @@ function advanceTimelineEvent(previous, inputEvent, eventIndex, replayFact) {
1235
1826
  if (replayFact.boundaries !== undefined) observation.boundaries = replayFact.boundaries
1236
1827
  if (replayFact.boundaryFailure !== undefined) observation.boundaryFailure = replayFact.boundaryFailure
1237
1828
  if (normalized.postEdit !== undefined) {
1238
- const checked = normalizePostEditFact(state.eventFacts, call, event, normalized, normalized.postEdit)
1829
+ const checked = normalizePostEditFact(state.eventFacts, call, event, normalized, normalized.postEdit, state.executableCalls)
1239
1830
  if (checked?.error !== undefined || !deepEqualJson(checked, normalized.postEdit)) {
1240
1831
  throw new TypeError(`invalid PTC session-log projection deferred edit fact: ${checked?.error ?? 'derived fact changed'}`)
1241
1832
  }
@@ -1251,7 +1842,11 @@ function advanceTimelineEvent(previous, inputEvent, eventIndex, replayFact) {
1251
1842
  if (isCanonicalSequence(resultSeq)) state.results.set(resultSeq, timelineJson(normalized))
1252
1843
  state.found = true
1253
1844
  }
1254
- const settlement = call === undefined ? undefined : { call, entry, identity: observation.identity,
1845
+ const settlement = call === undefined ? undefined : { call, entry: {
1846
+ eventIndex: entry?.eventIndex, scope: entry?.scope,
1847
+ ...(entry?.ambiguous === true ? { ambiguous: true } : {}),
1848
+ ...(entry?.editTarget === undefined ? {} : { editTarget: entry.editTarget }),
1849
+ }, identity: observation.identity,
1255
1850
  observedIndex: eventIndex, ordinary: !prunedReplacement,
1256
1851
  ...(normalized === undefined ? {} : { normalized }) }
1257
1852
  if (settlement !== undefined && entry.scope === scope
@@ -1272,6 +1867,7 @@ function advanceTimelineEvent(previous, inputEvent, eventIndex, replayFact) {
1272
1867
  }
1273
1868
  if (settlement !== undefined && isCanonicalSequence(event.seq)) {
1274
1869
  settledResultsByEventSeq.set(event.seq, settlement)
1870
+ state.retainedBytes += settlementBytes(settlement)
1275
1871
  }
1276
1872
 
1277
1873
  const claimedTarget = editClaims.get(callSeq)
@@ -1281,8 +1877,9 @@ function advanceTimelineEvent(previous, inputEvent, eventIndex, replayFact) {
1281
1877
  }
1282
1878
  if (entry === undefined || entry.scope !== scope) continue
1283
1879
  const publishRun = () => {
1284
- const run = settlementRun(settlement)
1285
- state.latestRun = selectSettlementRun(state.latestRun, settlement, scope, state.runSelectionFrontier)
1880
+ const run = settlementRun(settlement, state.executableCalls)
1881
+ state.latestRun = selectSettlementRun(state.latestRun, settlement, scope, state.runSelectionFrontier,
1882
+ state.executableCalls)
1286
1883
  state.editableRun = state.latestRun
1287
1884
  if (successfulTimelineRun(run)) {
1288
1885
  state.lastSuccessfulRunIndex = Math.max(state.lastSuccessfulRunIndex ?? -1, run.index)
@@ -1302,7 +1899,13 @@ function advanceTimelineEvent(previous, inputEvent, eventIndex, replayFact) {
1302
1899
  }
1303
1900
  publishRun()
1304
1901
  }
1305
- state.eventFacts[state.eventFacts.length - 1] = timelineJson(observation)
1902
+ // Digest the serialized fact (undefined fields dropped) so admission can
1903
+ // re-digest exactly the bytes it received.
1904
+ const plainObservation = JSON.parse(JSON.stringify(observation))
1905
+ plainObservation.digest = factDigest(plainObservation)
1906
+ state.eventFacts[state.eventFacts.length - 1] = timelineJson(plainObservation)
1907
+ pruneRetention(state, { settled: settledResultsByEventSeq, executable: state.executableCalls, results: state.results,
1908
+ ordinary: ordinaryResultSeqs })
1306
1909
  return Object.fromEntries(Object.entries({ ...state,
1307
1910
  executableCalls: state.executableCalls.table, results: state.results.table, editTargets: state.editTargets.table,
1308
1911
  pendingByCallId: pendingByCallId.table, seenCallIds: seenCallIds.table,
@@ -1312,6 +1915,285 @@ function advanceTimelineEvent(previous, inputEvent, eventIndex, replayFact) {
1312
1915
  }).filter(([_key, value]) => value !== undefined))
1313
1916
  }
1314
1917
 
1918
+ /**
1919
+ * Refold a session while keeping every payload.
1920
+ *
1921
+ * A consumer that needs a cluster the retention window dropped cannot rebuild it
1922
+ * from the checkpoint. The session log still has the events, so the caller refolds
1923
+ * that slice with pruning disabled and reads the cluster from there. The flag is
1924
+ * scoped to one synchronous fold; the checkpointed state never sees it.
1925
+ */
1926
+ let fullRetentionDepth = 0
1927
+ export function withFullRetention(run) {
1928
+ fullRetentionDepth += 1
1929
+ try {
1930
+ return run()
1931
+ } finally {
1932
+ fullRetentionDepth -= 1
1933
+ }
1934
+ }
1935
+
1936
+ /**
1937
+ * Drop the oldest settlements entirely and fold them into one summary.
1938
+ *
1939
+ * The projection is a fold shortcut, not the log. Keeping a digest stub per settled
1940
+ * cell still made a checkpoint proportional to how many cells the session ran (450
1941
+ * stubs cost ~1.7 MB before any payload), so the whole cluster is dropped instead:
1942
+ * the settlement, its executable call, its ordinary observation and its normalized
1943
+ * result. What survives is one chained summary of what was dropped, which is what
1944
+ * makes the size a function of the window rather than of the session.
1945
+ *
1946
+ * Consumers that need a dropped cell must refold it from the session log; when the
1947
+ * log cannot supply it they report `retention-pruned` instead of guessing.
1948
+ */
1949
+ /**
1950
+ * A call event whose argument text is gone keeps its identity and the digest of
1951
+ * exactly the bytes it used to carry, so a refold can prove the text it recovers
1952
+ * is the text the checkpoint once held.
1953
+ */
1954
+ function digestArgumentData(data) {
1955
+ if (!isRecord(data) || typeof data.arguments !== 'string') return data
1956
+ const { arguments: text, ...rest } = data
1957
+ return { ...rest, argumentsDigest: digestOf(text), argumentsBytes: byteLength(text) }
1958
+ }
1959
+
1960
+ /**
1961
+ * A republication clones a call event into its own settlement, so the same call can
1962
+ * appear twice: once in `executableCalls` and once in a settlement that names its
1963
+ * sequence. Whichever copy the retention window digests, every copy of that call must
1964
+ * end up in the same shape, or a checkpoint would describe one call two ways. The
1965
+ * pass is idempotent and runs on every prune, so a clone added after the window moved
1966
+ * still lands on the retained shape — the fold and the replay both go through it.
1967
+ */
1968
+ function digestSettlementCallCopies(tables) {
1969
+ for (const pair of Object.values(tables.settled.table)) {
1970
+ const [, settlement] = pair
1971
+ if (!isRecord(settlement) || !isRecord(settlement.call)) continue
1972
+ if (typeof settlement.call.data?.arguments !== 'string') continue
1973
+ const retained = tables.executable.get(settlement.call.seq)
1974
+ if (typeof retained?.event?.data?.argumentsDigest !== 'string') continue
1975
+ tables.settled.set(pair[0], { ...settlement, call: { ...settlement.call, data: digestArgumentData(settlement.call.data) } })
1976
+ }
1977
+ }
1978
+
1979
+ /**
1980
+ * A journal outside the payload window keeps everything recovery reads — status,
1981
+ * operations, user binding evidence, confirms, completion and per-call metadata —
1982
+ * and replaces each call's argument wire with its digest. Byte-for-byte equality
1983
+ * of argument text is what the window gives up; identity is not.
1984
+ */
1985
+ export function digestJournalArguments(journal) {
1986
+ if (process.env.DIGEST_SKIP_JOURNAL === '1') return journal
1987
+ if (!isRecord(journal) || !Array.isArray(journal.calls)) return journal
1988
+ return { ...journal, calls: journal.calls.map(call => {
1989
+ if (!isRecord(call) || !Object.hasOwn(call, 'args')) return call
1990
+ const { args, ...rest } = call
1991
+ return { ...rest, argsDigest: digestOf(args), argsBytes: byteLength(args) }
1992
+ }) }
1993
+ }
1994
+
1995
+ function pruneRetention(state, tables) {
1996
+ if (fullRetentionDepth > 0) return
1997
+ const entries = Object.entries(tables.settled.table)
1998
+ .map(([, pair]) => ({ settlement: pair[1], resultSeq: pair[0] }))
1999
+ .filter(entry => entry.settlement !== undefined)
2000
+ .sort((left, right) => left.settlement.observedIndex - right.settlement.observedIndex)
2001
+ if (entries.length === 0) return
2002
+ const limits = retentionLimits()
2003
+ // The count window is order-only so a replayed fold prunes identically. A
2004
+ // configured cluster cap of 0 means the count window never folds anything and
2005
+ // the payload budget is the only bound.
2006
+ const keepClusters = limits.clusters === 0 ? entries.length : Math.min(entries.length, limits.clusters)
2007
+ const keepFrom = Math.max(0, entries.length - keepClusters)
2008
+ // The runs a consumer reads synchronously — the latest publication and the cell
2009
+ // an edit would capture — stay inside the window even when newer settlements
2010
+ // pushed them past the count. Dropping them would turn "editable" into "pruned"
2011
+ // for the cell the user is actually looking at.
2012
+ const pinnedCallSeqs = new Set([state.latestRun?.callSeq, state.editableRun?.callSeq]
2013
+ .filter(callSeq => isCanonicalSequence(callSeq)))
2014
+ // Only a cluster beyond the configured cluster cap is folded into the interval
2015
+ // summary. Every other cluster stays, with its payload text replaced by digests,
2016
+ // so recovery evidence never depends on refolding the session log.
2017
+ const folded = new Set(entries.slice(0, keepFrom)
2018
+ .filter(entry => pinnedCallSeqs.has(entry.settlement.call?.seq) === false)
2019
+ .map(entry => entry.resultSeq))
2020
+ const mustPrune = new Set(folded)
2021
+ // A cumulative byte cap, not only a per-entry one: the window has to be a constant
2022
+ // regardless of how large the retained cells are, otherwise 32 cells of 200 KB
2023
+ // still put 6.4 MB in every checkpoint. Walk newest-first and stop retaining once
2024
+ // the payload budget is spent. A single entry larger than the whole budget is
2025
+ // dropped too: the bound wins over keeping the hot cell, and the edit path refolds
2026
+ // that cell from the session log when it is needed.
2027
+ const retainedPayloadBytes = (entry, executable = tables.executable) => {
2028
+ const callSeq = entry.settlement.call?.seq
2029
+ const stored = callSeq === undefined ? undefined : executable.get(callSeq)
2030
+ const executableArguments = stored?.event?.data?.arguments
2031
+ return settlementBytes(entry.settlement)
2032
+ + (typeof executableArguments === 'string' ? byteLength(executableArguments) : 0)
2033
+ }
2034
+ let retainedTotal = 0
2035
+ const newestFirst = [...entries].reverse()
2036
+ for (const [index, entry] of newestFirst.entries()) {
2037
+ const bytes = retainedPayloadBytes(entry)
2038
+ const pinned = pinnedCallSeqs.has(entry.settlement.call?.seq)
2039
+ // The run a consumer reads synchronously keeps its text even past the count
2040
+ // window: the window bounds how much history stays readable, never the cell the
2041
+ // user is looking at. Only the byte budget can digest a pinned cell.
2042
+ const outsidePayloadWindow = index >= limits.runs && !pinned
2043
+ const overBudget = bytes > limits.bytes
2044
+ if (overBudget || outsidePayloadWindow || (index > 0 && !pinned && retainedTotal + bytes > limits.bytes)) {
2045
+ mustPrune.add(entry.resultSeq)
2046
+ continue
2047
+ }
2048
+ retainedTotal += bytes
2049
+ }
2050
+ if (mustPrune.size === 0) return
2051
+ const droppedCallSeqs = new Set()
2052
+ const droppedResultSeqs = new Set()
2053
+ const dropped = []
2054
+ const digested = new Set([...mustPrune].filter(resultSeq => !folded.has(resultSeq)))
2055
+ const digestedResultSeqs = new Set()
2056
+ const digestedHotCell = state.latestRun !== undefined && entries.some(entry => (
2057
+ digested.has(entry.resultSeq) && entry.settlement.call?.seq === state.latestRun?.callSeq))
2058
+ for (const entry of entries) {
2059
+ if (!digested.has(entry.resultSeq)) continue
2060
+ const callSeq = entry.settlement.call?.seq
2061
+ if (callSeq !== undefined && tables.executable.has(callSeq)) {
2062
+ const stored = tables.executable.get(callSeq)
2063
+ tables.executable.set(callSeq, { ...stored, event: { ...stored.event, data: digestArgumentData(stored.event.data) } })
2064
+ digestSettlementCallCopies(tables)
2065
+ }
2066
+ // The result keeps every field recovery reads — status, operations, user
2067
+ // binding evidence, confirms, completion and call metadata — and loses only
2068
+ // the argument wire of each call.
2069
+ for (const key of [entry.resultSeq, callSeq]) {
2070
+ if (key === undefined || !tables.results.has(key)) continue
2071
+ const result = tables.results.get(key)
2072
+ if (!isRecord(result) || result.journal === undefined) continue
2073
+ tables.results.set(key, { ...result, journal: digestJournalArguments(result.journal) })
2074
+ }
2075
+ // The settlement keeps its normalized and publication entries because recovery
2076
+ // and the run derivations read them; only the journal argument wire and the
2077
+ // derived source are replaced by digests, which is exactly the payload the
2078
+ // window bounds. Identity, observations and result metadata stay.
2079
+ const digestJournalEntry = value => value === undefined ? undefined : {
2080
+ ...value,
2081
+ ...(value.journal === undefined ? {} : { journal: digestJournalArguments(value.journal) }),
2082
+
2083
+ }
2084
+ tables.settled.set(entry.resultSeq, {
2085
+ ...entry.settlement,
2086
+ ...(callSeq === undefined
2087
+ ? {}
2088
+ : { call: { ...entry.settlement.call, data: digestArgumentData(entry.settlement.call.data) } }),
2089
+ ...(entry.settlement.normalized === undefined
2090
+ ? {}
2091
+ : { normalized: digestJournalEntry(entry.settlement.normalized) }),
2092
+ ...(entry.settlement.publication === undefined
2093
+ ? {}
2094
+ : { publication: digestJournalEntry(entry.settlement.publication) }),
2095
+ })
2096
+ digestedResultSeqs.add(Number(entry.resultSeq))
2097
+ state.retainedBytes = Math.max(0, state.retainedBytes - settlementBytes(entry.settlement))
2098
+ }
2099
+ for (const entry of entries) {
2100
+ if (!mustPrune.has(entry.resultSeq)) continue
2101
+ if (!folded.has(entry.resultSeq)) continue
2102
+ const callSeq = entry.settlement.call?.seq
2103
+ dropped.push(summaryEntry(entry.settlement, Number(entry.resultSeq)))
2104
+ tables.settled.delete(entry.resultSeq)
2105
+ if (callSeq !== undefined) {
2106
+ tables.executable.delete(callSeq)
2107
+ if (tables.ordinary?.has(callSeq)) tables.ordinary.delete(callSeq)
2108
+ droppedCallSeqs.add(callSeq)
2109
+ }
2110
+ // `results` is keyed by the RESULT sequence on the ordinary path and by the CALL
2111
+ // sequence on the republication path, so drop both keys when either exists.
2112
+ for (const key of [entry.resultSeq, callSeq]) {
2113
+ if (key === undefined) continue
2114
+ if (tables.results.has(key)) tables.results.delete(key)
2115
+ }
2116
+ droppedResultSeqs.add(entry.resultSeq)
2117
+ state.retainedBytes = Math.max(0, state.retainedBytes - settlementBytes(entry.settlement))
2118
+ }
2119
+ // A digested cluster kept its identity, so the ordered call list keeps its seq;
2120
+ // a folded cluster is gone and takes its seq with it.
2121
+ state.calls = state.calls.filter(callSeq => !droppedCallSeqs.has(callSeq))
2122
+ // The hot run can be lost either way: a folded cluster no longer exists, and a
2123
+ // digested one no longer holds the source a run is built from. Consumers that
2124
+ // need it refold from the session log, and report the notice until they do.
2125
+ if (state.latestRun !== undefined
2126
+ && (droppedCallSeqs.has(state.latestRun.callSeq) || digestedHotCell)) {
2127
+ state.latestRun = undefined
2128
+ state.editableRun = undefined
2129
+ }
2130
+ // `hotCellDropped` describes the retained set, not the route the fold took to it.
2131
+ // It holds exactly when the newest settlement no longer carries its payload:
2132
+ // either it was folded away, or its argument text was digested. Recomputing it
2133
+ // from the table on every prune keeps a replayed fold — which reaches the same set
2134
+ // by a different route — from disagreeing with the stored one.
2135
+ const newestEntry = entries[entries.length - 1]
2136
+ const newestStored = newestEntry === undefined ? undefined : tables.settled.get(newestEntry.resultSeq)
2137
+ state.hotCellDropped = newestEntry !== undefined
2138
+ && (newestStored === undefined || newestStored.call?.data?.arguments === undefined)
2139
+ // `retainedBytes` is a property of the retained set, not a running total: a fold
2140
+ // that digests a cluster after it was counted would otherwise carry a different
2141
+ // value than a fold that reaches the same set by another route. Recompute it from
2142
+ // the table so both sides agree, and so the number always describes what is there.
2143
+ state.retainedBytes = entries.reduce((total, entry) => {
2144
+ const stored = tables.settled.get(entry.resultSeq)
2145
+ return stored === undefined ? total : total + settlementBytes(stored)
2146
+ }, 0)
2147
+ if (dropped.length === 0 && digested.size === 0) return
2148
+ if (dropped.length !== 0) {
2149
+ state.compacted = true
2150
+ dropped.sort((left, right) => left.resultSeq - right.resultSeq)
2151
+ state.retentionSummary = extendSummary(state.retentionSummary, dropped)
2152
+ }
2153
+ // The facts of a folded cluster keep their identity and digests but lose the
2154
+ // payload text, for the same reason the cluster itself is gone.
2155
+ // A fact per event is itself linear in the session length, so a folded cluster
2156
+ // takes its tool/call and tool/result facts with it. Admission for a compacted
2157
+ // checkpoint proves the summary instead of replaying those facts.
2158
+ state.eventFacts = state.eventFacts.filter(fact => {
2159
+ if (fact.event?.type === 'tool/call' && droppedCallSeqs.has(fact.event.seq)) return false
2160
+ if (fact.normalized !== undefined && droppedResultSeqs.has(Number(fact.normalized.eventSeq))) return false
2161
+ return true
2162
+ })
2163
+ // A digested cluster keeps its facts, but they carry the digest-only journal too:
2164
+ // the fact list is a second copy of the same result content. Rewriting the payload
2165
+ // invalidates the fact's own digest, so it is recomputed here — admission checks
2166
+ // that digest against the bytes it receives.
2167
+ state.eventFacts = state.eventFacts.map(fact => {
2168
+ if (fact.normalized === undefined || !digestedResultSeqs.has(Number(fact.normalized.eventSeq))) return fact
2169
+ const { digest: _digest, ...rest } = fact
2170
+ const next = { ...rest, normalized: digestJournalArguments(fact.normalized) }
2171
+ next.digest = factDigest(next)
2172
+ return next
2173
+ })
2174
+ // A dispatch fact names the cell an edit would capture, and that target carries
2175
+ // the cell's source. Once the payload is digested the fold can no longer derive
2176
+ // that source, so the fact must record the same "no target" the fold would
2177
+ // produce now; leaving the old text behind would both duplicate the payload and
2178
+ // fail the round-trip check that compares the fact with its re-derivation.
2179
+ const digestedCallSeqs = new Set(entries
2180
+ .filter(entry => digested.has(entry.resultSeq))
2181
+ .map(entry => Number(entry.settlement.call?.seq)))
2182
+ if (digestedCallSeqs.size !== 0) {
2183
+ let rewritten = false
2184
+ state.eventFacts = state.eventFacts.map(fact => {
2185
+ const target = fact.selectedTarget
2186
+ if (!isRecord(target) || !digestedCallSeqs.has(Number(target.callSeq))) return fact
2187
+ const { digest: _digest, ...rest } = fact
2188
+ const next = { ...rest, selectedTarget: null }
2189
+ next.digest = factDigest(next)
2190
+ rewritten = true
2191
+ return timelineJson(next)
2192
+ })
2193
+ if (rewritten) state.eventFacts = state.eventFacts.map(fact => timelineJson(fact))
2194
+ }
2195
+ }
2196
+
1315
2197
  export function sessionTimelineValue(checkpoint) {
1316
2198
  return { ...checkpoint,
1317
2199
  executableCalls: new Map(Object.values(checkpoint.executableCalls)),
@@ -1334,10 +2216,17 @@ export function foldSessionTimeline(events) {
1334
2216
  export function recoverJournal(session, currentCallSeq, options = {}) {
1335
2217
  const events = sessionEvents(session)
1336
2218
  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)
2219
+ return { nodes: [], head: undefined, checkpoints: new Map(), volatileSuffix: [], provenBindings: [], available: true }
2220
+ }
2221
+ // Recovery rebuilds bindings from the cell sources and journals. A checkpoint
2222
+ // outside the payload window keeps those as digests, so the fold is repeated with
2223
+ // retention disabled on the same slice: the log is the authority, and reading it
2224
+ // costs one extra fold only when a digested cluster is present.
2225
+ const folded = foldSessionTimeline(events)
2226
+ const needsPayload = [...folded.executableCalls.values()]
2227
+ .some(entry => typeof entry?.event?.data?.arguments !== 'string')
2228
+ const timeline = needsPayload ? withFullRetention(() => foldSessionTimeline(events)) : folded
2229
+ const calls = timelineCalls(timeline).filter(call => call.seq !== currentCallSeq)
1341
2230
  const { executableCalls, results, found } = timeline
1342
2231
  const visibleCalls = options?.visibleCallSeqs
1343
2232
  const appliedBoundaries = options?.[RECOVERY_BOUNDARY_EVIDENCE]
@@ -1407,7 +2296,11 @@ export function recoverJournal(session, currentCallSeq, options = {}) {
1407
2296
  const records = []
1408
2297
  const orderedRecords = []
1409
2298
  const contractedCallRanges = []
1410
- let state = foldRecords(records, invalidCallSeqs)
2299
+ // A live environment that was released cannot lend its recorded live-only
2300
+ // values to the environment that replaces it. The flag belongs to the fold, so
2301
+ // every re-fold below carries it.
2302
+ const foldOptions = { liveOnlyLost: options?.liveOnlyLost === true }
2303
+ let state = foldRecords(records, invalidCallSeqs, foldOptions)
1411
2304
  let boundaryIndex = 0
1412
2305
  const carrierCallSeqForBoundary = boundary => boundary.carrierCallSeq
1413
2306
  const isContractedCallSeq = (callSeq) => contractedCallRanges.some(range => (
@@ -1428,6 +2321,7 @@ export function recoverJournal(session, currentCallSeq, options = {}) {
1428
2321
  if (record.call.seq >= contractionSeq) invalidCallSeqs.add(record.call.seq)
1429
2322
  }
1430
2323
  state = foldRecords(records, invalidCallSeqs, {
2324
+ ...foldOptions,
1431
2325
  surfaceContracted: state.surfaceContracted,
1432
2326
  })
1433
2327
  registerUnavailable({ seq: carrierCallSeq ?? boundary.failedCallSeq, reason }, state)
@@ -1487,6 +2381,7 @@ export function recoverJournal(session, currentCallSeq, options = {}) {
1487
2381
  }
1488
2382
  }
1489
2383
  state = foldRecords(records, invalidCallSeqs, {
2384
+ ...foldOptions,
1490
2385
  surfaceContracted: resolvesUnavailable ? false : state.surfaceContracted,
1491
2386
  })
1492
2387
  if (resolvesUnavailable) {
@@ -1510,7 +2405,10 @@ export function recoverJournal(session, currentCallSeq, options = {}) {
1510
2405
  if (result.postEditError !== undefined) {
1511
2406
  invalidResultReasons.set(call.seq, result.postEditError)
1512
2407
  registerUnavailable({ seq: call.seq, reason: result.postEditError })
1513
- } else if (result.postEdit !== undefined) {
2408
+ } else if (result.postEdit !== undefined && result.postEdit.derived.unexecuted !== true) {
2409
+ // A derived execution that never ran proves no state of its own, so it is
2410
+ // not folded as a record; the root cell's own record already carries the
2411
+ // proof of the accepted edit.
1514
2412
  const postEdit = result.postEdit
1515
2413
  orderedRecords.push({
1516
2414
  call: timeline.eventFacts.find(fact => fact.event.seq === postEdit.executionStartSeq).event,
@@ -1566,13 +2464,25 @@ export function recoverJournal(session, currentCallSeq, options = {}) {
1566
2464
  }
1567
2465
  applyBoundariesBefore(Number.POSITIVE_INFINITY)
1568
2466
  if (unavailableBoundary !== undefined) registerUnavailable(unavailableBoundary, state)
1569
- return {
2467
+ // The names the session log proves stay off the enumerable history shape so
2468
+ // every existing consumer sees the same object it saw before; recovery-only
2469
+ // readers (the missing-binding notice) reach them through this property.
2470
+ const history = {
1570
2471
  nodes: state.nodes,
1571
2472
  head: state.head,
1572
2473
  checkpoints: state.checkpoints,
1573
2474
  volatileSuffix: state.volatileSuffix,
1574
2475
  available: found && !state.surfaceContracted && unavailableBoundary === undefined,
1575
2476
  }
2477
+ history.provenBindings = [...state.provenBindings]
2478
+ // Recovery-only, like the proved names: the enumerable history shape is a
2479
+ // contract other readers compare against, so the deletion set stays off it.
2480
+ Object.defineProperty(history, 'deletedBindings', {
2481
+ value: [...state.deletedBindings],
2482
+ enumerable: false,
2483
+ configurable: true,
2484
+ })
2485
+ return history
1576
2486
  }
1577
2487
 
1578
2488
  /** Prove that recovery consumes every persisted boundary at its declared frontier. */