dsh-ptc-plus 0.5.1 → 0.5.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/README.en.md +10 -4
  2. package/README.md +10 -4
  3. package/client.cjs +1623 -104
  4. package/compiler-core.cjs +132 -132
  5. package/compiler-core.cjs.map +3 -3
  6. package/docs/adr/0005-temporary-rejected-cell-edit-transport.md +2 -0
  7. package/docs/adr/0017-track-the-latest-dsh-public-surface.md +4 -2
  8. package/docs/adr/0019-plugin-settings-and-kill-switch.md +2 -2
  9. package/docs/adr/0023-global-user-bindings.md +12 -0
  10. package/docs/adr/0027-run-the-session-kernel-in-a-killable-helper-process.md +11 -1
  11. package/docs/adr/0028-attach-to-the-host-ptc-execution-seam.md +1 -1
  12. package/docs/adr/0030-serve-both-typert-codec-generations-from-one-codec.md +2 -2
  13. package/docs/adr/0032-own-session-log-facts-in-a-host-only-projection.md +31 -1
  14. package/docs/adr/0044-coexist-with-the-official-persistent-repl.md +4 -4
  15. package/docs/adr/0045-customize-model-visible-prompt-text.md +38 -0
  16. package/docs/adr/0046-gate-the-commit-with-the-semantic-evidence-tier.md +35 -0
  17. package/docs/adr/0047-store-journal-argument-digests-in-the-projection.md +88 -0
  18. package/docs/adr/0048-supersede-a-rewritten-cell.md +48 -0
  19. package/docs/adr/0049-prove-a-settlement-against-its-published-record.md +53 -0
  20. package/docs/adr/0050-read-host-surfaces-through-their-published-contract.md +48 -0
  21. package/docs/adr/README.md +7 -1
  22. package/docs/architecture.md +3 -3
  23. package/docs/client-ui.md +5 -2
  24. package/docs/durability-design.md +15 -2
  25. package/docs/installation.md +9 -9
  26. package/docs/releases/v0.5.2.md +91 -0
  27. package/docs/releases/v0.5.3.md +77 -0
  28. package/docs/runtime-reference.md +43 -3
  29. package/docs/unmeasured-cell-recovery-repair-proposal.md +387 -0
  30. package/docs/user-bindings.en.md +15 -0
  31. package/docs/user-bindings.md +15 -0
  32. package/docs/v0.5.3-goals.md +117 -0
  33. package/docs/verification.md +7 -3
  34. package/index.js +100 -31
  35. package/internal/binding-adoption.js +136 -0
  36. package/internal/bounded-literal.js +85 -0
  37. package/internal/capability-command-receipt.js +18 -4
  38. package/internal/capability-delivery-proof.js +70 -0
  39. package/internal/capability-environment.js +200 -11
  40. package/internal/capability-plan.js +11 -8
  41. package/internal/cell-error.js +13 -0
  42. package/internal/config-spec.js +126 -6
  43. package/internal/cordis-tools-owner.js +2 -1
  44. package/internal/direct-surface-owner.js +39 -21
  45. package/internal/edit-transport-owner.js +81 -15
  46. package/internal/execution-provider-owner.js +6 -0
  47. package/internal/execution-seam-compat.js +25 -0
  48. package/internal/host-tool-sdk.js +308 -0
  49. package/internal/isolated-worker.js +134 -17
  50. package/internal/kernel-worker.js +167 -3
  51. package/internal/legacy-cell-analysis.js +14 -2
  52. package/internal/legacy-repl-convenience.js +16 -3
  53. package/internal/official-repl-priority.js +31 -10
  54. package/internal/program-bindings.js +12 -1
  55. package/internal/projection-retention.js +238 -0
  56. package/internal/prompt-text.js +858 -0
  57. package/internal/recovery-tips.js +50 -11
  58. package/internal/rejected-cell-editor.js +3 -2
  59. package/internal/repl-memory-projection.js +21 -2
  60. package/internal/runtime-bridge-owner.js +114 -9
  61. package/internal/runtime-config.js +2 -0
  62. package/internal/runtime-contexts.js +14 -5
  63. package/internal/runtime-messages.js +48 -15
  64. package/internal/session-cell-executor.js +132 -11
  65. package/internal/session-journal-recovery.js +1204 -116
  66. package/internal/session-journal-schema.js +10 -5
  67. package/internal/session-journal.js +214 -14
  68. package/internal/session-log-view.js +133 -26
  69. package/internal/session-runtime.js +746 -38
  70. package/internal/session-state.js +27 -0
  71. package/internal/stateful-root-compiler.js +11 -0
  72. package/internal/stateful-root-runtime.js +17 -0
  73. package/internal/user-binding-console.js +7 -0
  74. package/internal/user-bindings-owner.js +112 -6
  75. package/internal/user-bindings.js +2 -2
  76. package/internal/validated-parse-repair.js +6 -1
  77. package/internal/worker-client.js +27 -4
  78. package/package.json +16 -6
  79. package/src/client-activity.js +24 -4
  80. package/src/client-capabilities-view.js +504 -27
  81. package/src/client-copy.js +187 -2
  82. package/src/client-indicator-view.js +2 -1
  83. package/src/client-repl-view.js +35 -11
  84. package/src/client-styles.js +64 -2
  85. package/src/client.js +3 -1
@@ -11,6 +11,8 @@ export const DERIVED_RUN_KEY = 'dshPtcPlusDerivedRun'
11
11
  export const POST_EDIT_KEY = 'dshPtcPlusPostEdit'
12
12
  export const REWRITES_KEY = 'dshPtcPlusRewrites'
13
13
  export const RECOVERY_BOUNDARY_KEY = 'dshPtcPlusRecoveryBoundaries'
14
+ export const DIAGNOSTIC_WORDING_KEY = 'dshPtcPlusDiagnosticWording'
15
+ export const SETTLEMENT_PROOF_KEY = 'dshPtcPlusSettlementProof'
14
16
  export const IMPORT_BOUNDARY_JOURNAL_VERSION = 7
15
17
  export const PER_NAME_USER_BINDINGS_JOURNAL_VERSION = 8
16
18
  export const LANGUAGE_SEMANTICS_JOURNAL_VERSION = 9
@@ -35,7 +37,7 @@ export const WHOLE_ENTRY_JOURNAL_FIELDS = new Set(['version', 'bindingPolicy', '
35
37
  export const PER_NAME_JOURNAL_FIELDS = new Set([...WHOLE_ENTRY_JOURNAL_FIELDS, 'userBindingsShadowPolicy', 'userBindingNames'])
36
38
  export const LANGUAGE_SEMANTICS_JOURNAL_FIELDS = new Set([...PER_NAME_JOURNAL_FIELDS, 'languageSemantics'])
37
39
  export const MODULE_TRANSFORM_JOURNAL_FIELDS = new Set([...LANGUAGE_SEMANTICS_JOURNAL_FIELDS, 'moduleTransform'])
38
- export const JOURNAL_FIELDS = new Set([...MODULE_TRANSFORM_JOURNAL_FIELDS, 'executionCwd'])
40
+ export const JOURNAL_FIELDS = new Set([...MODULE_TRANSFORM_JOURNAL_FIELDS, 'executionCwd', 'values', 'unrecordedBindings'])
39
41
  export const FINGERPRINT_REUSE_JOURNAL_FIELDS = new Set([...WHOLE_ENTRY_JOURNAL_FIELDS].filter(field => field !== 'userBindingsReusePolicy'))
40
42
  export const RELATIONLESS_JOURNAL_FIELDS = new Set([...FINGERPRINT_REUSE_JOURNAL_FIELDS].filter(field => field !== 'userBindingsFingerprint'))
41
43
  export const PREDECESSOR_JOURNAL_FIELDS = new Set(['version', 'bindingMode', 'rewritePolicy', 'status', 'calls', 'operations', 'confirms', 'diagnostics', 'completion', 'volatileReason'])
@@ -52,14 +54,17 @@ export const DEFAULT_EXPORT_BINDINGS = new Set([
52
54
  LEGACY_DEFAULT_EXPORT_BINDING,
53
55
  LIVE_DEFAULT_EXPORT_BINDING,
54
56
  ])
55
- export const CALL_SUCCESS_FIELDS = new Set(['global', 'member', 'args', 'ok', 'value', 'settle'])
56
- export const CALL_ERROR_FIELDS = new Set(['global', 'member', 'args', 'ok', 'error', 'settle'])
57
- export const OPERATION_FIELDS = new Set(['action', 'name'])
57
+ export const CALL_SUCCESS_FIELDS = new Set(['global', 'member', 'args', 'argsDigest', 'argsBytes', 'ok', 'value', 'settle'])
58
+ export const CALL_ERROR_FIELDS = new Set(['global', 'member', 'args', 'argsDigest', 'argsBytes', 'ok', 'error', 'settle'])
59
+ export const OPERATION_FIELDS = new Set(['action', 'name', 'kind'])
58
60
  export const RETURN_FIELDS = new Set(['kind', 'hasValue', 'value'])
59
61
  export const THROW_FIELDS = new Set(['kind', 'error'])
60
62
  export const ERROR_FIELDS = new Set(['kind', 'message'])
61
63
  export const EDIT_TARGET_FIELDS = new Set(['targetCallSeq'])
62
- export const DERIVED_RUN_FIELDS = new Set(['code', 'description'])
64
+ export const DERIVED_RUN_FIELDS = new Set(['code', 'codeDigest', 'codeBytes', 'description'])
65
+ export const DIAGNOSTIC_WORDING_FIELDS = new Set(['digest'])
66
+ export const SETTLEMENT_PROOF_VERSION = 1
67
+ export const SETTLEMENT_PROOF_FIELDS = new Set(['version', 'recordedMessage'])
63
68
 
64
69
  export const JOURNAL_VERSIONS = new Set([LEGACY_JOURNAL_VERSION, INTERMEDIATE_JOURNAL_VERSION, PREVIOUS_JOURNAL_VERSION, USER_BINDING_RELATIONLESS_JOURNAL_VERSION, FINGERPRINT_REUSE_JOURNAL_VERSION, VERSIONED_BINDING_REUSE_JOURNAL_VERSION, IMPORT_BOUNDARY_JOURNAL_VERSION, PER_NAME_USER_BINDINGS_JOURNAL_VERSION, LANGUAGE_SEMANTICS_JOURNAL_VERSION, MODULE_TRANSFORM_JOURNAL_VERSION, JOURNAL_VERSION])
65
70
  export const USER_BINDINGS_REUSE_POLICIES = new Set([LEGACY_USER_BINDINGS_REUSE_POLICY, LIVE_USER_BINDINGS_REUSE_POLICY])
@@ -13,6 +13,7 @@ import {
13
13
  LIVE_MODULE_SEMANTICS,
14
14
  } from './repl-rewrite-contract.js'
15
15
  import { userBindingsSnapshotFromMeta } from './user-bindings.js'
16
+ import { digestOf } from './projection-retention.js'
16
17
  import {
17
18
  JOURNAL_KEY,
18
19
  EDIT_TARGET_KEY,
@@ -62,6 +63,10 @@ import {
62
63
  JOURNAL_VERSIONS,
63
64
  USER_BINDINGS_REUSE_POLICIES,
64
65
  RECOVERY_BOUNDARY_FIELDS,
66
+ DIAGNOSTIC_WORDING_KEY,
67
+ SETTLEMENT_PROOF_KEY,
68
+ SETTLEMENT_PROOF_FIELDS,
69
+ SETTLEMENT_PROOF_VERSION,
65
70
  REPL_TOOL_NAMES,
66
71
  REWRITE_FIELDS,
67
72
  REWRITE_KINDS,
@@ -74,11 +79,104 @@ export {
74
79
  DERIVED_RUN_KEY,
75
80
  REWRITES_KEY,
76
81
  RECOVERY_BOUNDARY_KEY,
82
+ DIAGNOSTIC_WORDING_KEY,
83
+ DIAGNOSTIC_WORDING_FIELDS,
84
+ SETTLEMENT_PROOF_KEY,
85
+ SETTLEMENT_PROOF_FIELDS,
86
+ SETTLEMENT_PROOF_VERSION,
77
87
  JOURNAL_VERSION,
78
88
  LIVE_USER_BINDINGS_REUSE_POLICY,
79
89
  RECOVERY_BOUNDARY_EVENT,
80
90
  } from './session-journal-schema.js'
81
91
 
92
+ /**
93
+ * The facts one recorded diagnostic states about what happened, without the
94
+ * presentation it is rendered through.
95
+ *
96
+ * `help` and `source` are presentation: a historical record may carry an older
97
+ * generation's help wording, and a caret position is re-derived from the cell
98
+ * when the diagnostic is rendered. The facts below are the wording the model was
99
+ * told, and they must be the facts that were recorded.
100
+ */
101
+ function diagnosticWordingFacts(diagnostic) {
102
+ return [
103
+ diagnostic.code,
104
+ diagnostic.severity,
105
+ diagnostic.phase,
106
+ diagnostic.message,
107
+ diagnostic.stateEffect,
108
+ diagnostic.dispatchState ?? null,
109
+ diagnosticWordingCause(diagnostic.cause),
110
+ diagnostic.collisions ?? null,
111
+ ]
112
+ }
113
+
114
+ function diagnosticWordingCause(cause) {
115
+ if (cause === undefined) return null
116
+ return cause.severity === undefined
117
+ ? [cause.code ?? null, cause.message]
118
+ : diagnosticWordingFacts(cause)
119
+ }
120
+
121
+ /**
122
+ * Digest of the diagnostic wording a journal records, or `undefined` when it
123
+ * records none.
124
+ *
125
+ * Recovery re-renders a recorded diagnostic to report what the cell did, and it
126
+ * cannot tell an edited diagnostic from the recorded one when the record is the
127
+ * only evidence: the completion message and the diagnostic that produced it are
128
+ * two copies of one settlement. The digest of the recorded wording is taken when
129
+ * the settlement is published, so a record whose wording was changed afterwards
130
+ * is no longer evidence for the bindings its cell declared.
131
+ */
132
+ export function diagnosticWordingDigest(journal) {
133
+ const diagnostics = journal?.diagnostics
134
+ if (!Array.isArray(diagnostics) || diagnostics.length === 0) return undefined
135
+ return digestOf(diagnostics.map(diagnosticWordingFacts))
136
+ }
137
+
138
+ /**
139
+ * Digest of the one settlement a journal states: the status of its cell, its
140
+ * completion, and the wording of its diagnostics.
141
+ *
142
+ * A published record is the only account of its own cell, so a record edited
143
+ * afterwards stays self-consistent: the completion is checkable against a
144
+ * diagnostic, and the diagnostic against the wording digest, but all three are
145
+ * the same settlement. The proof is taken at publication, from the facts the
146
+ * settlement was published with, and every fact is represented here exactly once,
147
+ * so a record whose status, completion or diagnostic wording changed no longer
148
+ * reproduces it.
149
+ */
150
+ export function settlementProofDigest(journal) {
151
+ const completion = journal?.completion
152
+ const completionFacts = completion === undefined ? null
153
+ : completion.kind === 'return'
154
+ ? [completion.kind, completion.hasValue,
155
+ completion.hasValue ? digestOf(completion.value) : null]
156
+ : [completion.kind, completion.error.kind, completion.error.message]
157
+ return digestOf([
158
+ journal?.status ?? null,
159
+ completionFacts,
160
+ diagnosticWordingDigest(journal) ?? null,
161
+ ])
162
+ }
163
+
164
+ /**
165
+ * Attach the published settlement's proof beside a journal.
166
+ *
167
+ * Every settlement states a status, so every publication carries one; a record
168
+ * that carries none predates this proof and keeps the older evidence.
169
+ */
170
+ export function withSettlementProof(meta, journal) {
171
+ return {
172
+ ...meta,
173
+ [SETTLEMENT_PROOF_KEY]: {
174
+ version: SETTLEMENT_PROOF_VERSION,
175
+ recordedMessage: settlementProofDigest(journal),
176
+ },
177
+ }
178
+ }
179
+
82
180
  function cloneJson(value) {
83
181
  if (value === undefined) return undefined
84
182
  return decodeValue(encodeValue(value))
@@ -96,7 +194,8 @@ function normalizeCalls(value) {
96
194
  if (!Array.isArray(value)) throw new Error('invalid dsh-ptc-plus journal calls')
97
195
  const calls = value.map((call, index) => {
98
196
  if (!isRecord(call) || typeof call.global !== 'string' || typeof call.member !== 'string'
99
- || !Object.hasOwn(call, 'args') || (call.ok !== true && call.ok !== false)
197
+ || Object.hasOwn(call, 'args') === Object.hasOwn(call, 'argsDigest')
198
+ || (call.ok !== true && call.ok !== false)
100
199
  || !isCanonicalSequence(call.settle)) {
101
200
  throw new Error(`invalid dsh-ptc-plus journal call at index ${index}`)
102
201
  }
@@ -107,10 +206,17 @@ function normalizeCalls(value) {
107
206
  if (call.ok === false && typeof call.error !== 'string') {
108
207
  throw new Error(`journal call at index ${index} is missing its error`)
109
208
  }
209
+ const digestedArgs = Object.hasOwn(call, 'argsDigest')
210
+ if (digestedArgs && (typeof call.argsDigest !== 'string' || !/^[a-f0-9]{64}$/u.test(call.argsDigest)
211
+ || !isCanonicalSequence(call.argsBytes))) {
212
+ throw new Error(`invalid digested dsh-ptc-plus journal call args at index ${index}`)
213
+ }
110
214
  return {
111
215
  global: call.global,
112
216
  member: call.member,
113
- args: normalizeValueWire(call.args),
217
+ ...(digestedArgs
218
+ ? { argsDigest: call.argsDigest, argsBytes: call.argsBytes }
219
+ : { args: normalizeValueWire(call.args) }),
114
220
  ok: call.ok,
115
221
  settle: call.settle,
116
222
  ...(call.ok ? { value: normalizeValueWire(call.value) } : { error: call.error }),
@@ -131,7 +237,12 @@ function normalizeOperations(value) {
131
237
  throw new Error(`invalid dsh-ptc-plus journal operation at index ${index}`)
132
238
  }
133
239
  assertOwnFields(operation, OPERATION_FIELDS, `journal operation at index ${index}`)
134
- return { action: operation.action, ...(operation.name === undefined ? {} : { name: operation.name }) }
240
+ if (operation.kind !== undefined
241
+ && (operation.action !== 'save' || !['binding', 'checkpoint'].includes(operation.kind))) {
242
+ throw new Error(`invalid dsh-ptc-plus journal operation kind at index ${index}`)
243
+ }
244
+ return { action: operation.action, ...(operation.name === undefined ? {} : { name: operation.name }),
245
+ ...(operation.kind === undefined ? {} : { kind: operation.kind }) }
135
246
  })
136
247
  }
137
248
 
@@ -263,7 +374,21 @@ function normalizeUserBindingsFingerprint(value) {
263
374
  return value
264
375
  }
265
376
 
377
+ // Fields a later journal version introduced. A record is interpreted by its own
378
+ // version, so a field that version could not have carried is not evidence: drop it
379
+ // rather than rejecting the whole journal. A field that carries its own historical
380
+ // contract, such as executionCwd, stays strict; only the value-carrying fields added by the recording work are trimmed, so
381
+ // a record written by a newer build and relabelled by a migration fixture still folds
382
+ // with its historical meaning.
383
+ const NEWER_JOURNAL_FIELDS = Object.freeze(['values', 'unrecordedBindings'])
384
+ function withoutNewerJournalFields(value) {
385
+ const trimmed = { ...value }
386
+ for (const field of NEWER_JOURNAL_FIELDS) delete trimmed[field]
387
+ return trimmed
388
+ }
389
+
266
390
  function migrateJournal(value, resolveLegacyConfirm) {
391
+ value = value.version === JOURNAL_VERSION ? value : withoutNewerJournalFields(value)
267
392
  if (value.version === JOURNAL_VERSION) return value
268
393
  if (value.version === MODULE_TRANSFORM_JOURNAL_VERSION) {
269
394
  assertOwnFields(value, MODULE_TRANSFORM_JOURNAL_FIELDS, 'dsh-ptc-plus journal')
@@ -358,6 +483,33 @@ function normalizeExecutionCwd(value) {
358
483
  }
359
484
 
360
485
  /** Validate and detach one journal emitted by the runtime. */
486
+ /**
487
+ * Values a volatile settlement recorded for the bindings it created.
488
+ *
489
+ * A volatile cell cannot be replayed, so the record carries the values it
490
+ * produced and recovery installs them without running the cell again. Names the
491
+ * codec could not transfer are kept apart so a contraction can name them
492
+ * instead of pretending they were never created.
493
+ */
494
+ function normalizeJournalValues(values) {
495
+ if (values === undefined) return undefined
496
+ if (!Array.isArray(values)) throw new Error('invalid dsh-ptc-plus journal values')
497
+ return Object.freeze(values.map(entry => {
498
+ if (!isRecord(entry) || typeof entry.name !== 'string' || entry.name === '') {
499
+ throw new Error('invalid dsh-ptc-plus journal value entry')
500
+ }
501
+ return Object.freeze({ name: entry.name, wire: normalizeValueWire(entry.wire) })
502
+ }))
503
+ }
504
+
505
+ function normalizeUnrecordedBindings(names) {
506
+ if (names === undefined) return undefined
507
+ if (!Array.isArray(names) || names.some(name => typeof name !== 'string' || name === '')) {
508
+ throw new Error('invalid dsh-ptc-plus journal unrecorded bindings')
509
+ }
510
+ return Object.freeze([...new Set(names)])
511
+ }
512
+
361
513
  export function normalizeJournal(value, options = {}) {
362
514
  if (!isRecord(value)) throw new Error('invalid dsh-ptc-plus journal')
363
515
  if (!JOURNAL_VERSIONS.has(value.version)
@@ -387,6 +539,8 @@ export function normalizeJournal(value, options = {}) {
387
539
  migrated.completion,
388
540
  migrated.status === 'durable' || migrated.status === 'volatile',
389
541
  )
542
+ const recordedValues = normalizeJournalValues(migrated.values)
543
+ const unrecordedBindings = normalizeUnrecordedBindings(migrated.unrecordedBindings)
390
544
  if ((migrated.status === 'discarded' || migrated.status === 'noop')
391
545
  && (calls.length !== 0 || operations.length !== 0)) {
392
546
  throw new Error(`${migrated.status} dsh-ptc-plus journal must not contain calls or operations`)
@@ -416,11 +570,19 @@ export function normalizeJournal(value, options = {}) {
416
570
  diagnostics: Object.freeze(diagnostics),
417
571
  ...(completion === undefined ? {} : { completion }),
418
572
  ...(migrated.volatileReason === undefined ? {} : { volatileReason: migrated.volatileReason }),
573
+ ...(recordedValues === undefined ? {} : { values: recordedValues }),
574
+ ...(unrecordedBindings === undefined ? {} : { unrecordedBindings }),
419
575
  })
420
576
  }
421
577
 
422
- /** Validate the required persisted relation that makes one edit result executable history. */
423
- export function normalizeDerivedEditResult(meta, expectedTargetCallSeq) {
578
+ /**
579
+ * Completion error kind of a cell that never reached the runtime because it could
580
+ * not be measured. Such a settlement records that nothing ran, so it can prove an
581
+ * accepted edit's dispatch relation without proving any executed state.
582
+ */
583
+ export const UNMEASURED_CELL_ERROR_KIND = 'budget-unavailable'
584
+
585
+ function normalizeDerivedRelation(meta, expectedTargetCallSeq, { allowUnexecuted }) {
424
586
  if (!isCanonicalSequence(expectedTargetCallSeq)) {
425
587
  throw new Error('derived edit does not identify an eligible target call')
426
588
  }
@@ -433,24 +595,61 @@ export function normalizeDerivedEditResult(meta, expectedTargetCallSeq) {
433
595
  || target.targetCallSeq !== expectedTargetCallSeq) {
434
596
  throw new Error('derived edit target does not match the eligible target call')
435
597
  }
436
- if (!isRecord(derived) || typeof derived.code !== 'string' || typeof derived.description !== 'string') {
598
+ // A derived run outside the payload window keeps the digest of its source instead
599
+ // of the text. Every field except the source is kept, so the deferred-edit
600
+ // relation stays checkable in both shapes; the text is refolded from the log when
601
+ // a consumer needs it.
602
+ const digestedCode = isRecord(derived) && !Object.hasOwn(derived, 'code')
603
+ if (!isRecord(derived) || typeof derived.description !== 'string'
604
+ || (digestedCode
605
+ ? typeof derived.codeDigest !== 'string' || !/^[a-f0-9]{64}$/u.test(derived.codeDigest)
606
+ || !isCanonicalSequence(derived.codeBytes)
607
+ : typeof derived.code !== 'string')) {
437
608
  throw new Error('invalid dsh-ptc-plus derived run metadata')
438
609
  }
439
610
  assertOwnFields(derived, DERIVED_RUN_FIELDS, 'derived run metadata')
440
611
  const journal = normalizeJournal(meta[JOURNAL_KEY])
441
- if (journal.status === 'noop') throw new Error('derived edit journal must not be noop')
612
+ // Only the current deferred edit relation may prove itself with a settlement
613
+ // that never ran, and only for the unmeasured route: a rejected source, an
614
+ // aborted cell or a lost helper are different facts and keep the historical
615
+ // rule that a derived edit journal must record an execution.
616
+ const unexecuted = journal.status === 'noop'
617
+ && journal.completion?.error?.kind === UNMEASURED_CELL_ERROR_KIND
618
+ if (journal.status === 'noop' && !(allowUnexecuted && unexecuted)) {
619
+ throw new Error('derived edit journal must not be noop')
620
+ }
442
621
  const recoveryBoundaries = meta[RECOVERY_BOUNDARY_KEY] === undefined
443
622
  ? undefined
444
623
  : normalizeRecoveryBoundaries(meta[RECOVERY_BOUNDARY_KEY])
445
624
  const userBindings = userBindingsForJournal(meta, journal)
446
- return Object.freeze({
625
+ return {
447
626
  targetCallSeq: target.targetCallSeq,
448
- code: derived.code,
627
+ ...(digestedCode
628
+ ? { codeDigest: derived.codeDigest, codeBytes: derived.codeBytes }
629
+ : { code: derived.code }),
449
630
  description: derived.description,
450
631
  journal,
632
+ ...(unexecuted ? { unexecuted: true } : {}),
451
633
  ...(recoveryBoundaries === undefined ? {} : { recoveryBoundaries }),
452
634
  ...(userBindings === undefined ? {} : { userBindings }),
453
- })
635
+ }
636
+ }
637
+
638
+ /** Validate the required persisted relation that makes one edit result executable history. */
639
+ export function normalizeDerivedEditResult(meta, expectedTargetCallSeq) {
640
+ return Object.freeze(normalizeDerivedRelation(meta, expectedTargetCallSeq, { allowUnexecuted: false }))
641
+ }
642
+
643
+ /**
644
+ * Validate the relation one accepted deferred edit records for itself.
645
+ *
646
+ * The current deferred relation additionally admits a settlement that proves the
647
+ * revision never ran, because the root cell's own execution stays provable then.
648
+ * The historical top-level editor relation does not: it keeps requiring an
649
+ * executed derived run.
650
+ */
651
+ export function normalizeDeferredEditSettlement(meta, expectedTargetCallSeq) {
652
+ return Object.freeze(normalizeDerivedRelation(meta, expectedTargetCallSeq, { allowUnexecuted: true }))
454
653
  }
455
654
 
456
655
  export function userBindingsForJournal(meta, journal) {
@@ -492,8 +691,8 @@ function validateUserBindingNameProviders(journal, snapshot) {
492
691
  /** Compare only the required persisted relation for one derived edit. */
493
692
  export function derivedEditResultsEqual(leftMeta, rightMeta, expectedTargetCallSeq) {
494
693
  try {
495
- const left = encodeValue(normalizeDerivedEditResult(leftMeta, expectedTargetCallSeq))
496
- const right = encodeValue(normalizeDerivedEditResult(rightMeta, expectedTargetCallSeq))
694
+ const left = encodeValue(normalizeDeferredEditSettlement(leftMeta, expectedTargetCallSeq))
695
+ const right = encodeValue(normalizeDeferredEditSettlement(rightMeta, expectedTargetCallSeq))
497
696
  return JSON.stringify(left) === JSON.stringify(right)
498
697
  } catch {
499
698
  return false
@@ -1108,8 +1307,9 @@ export function reduceStateOperations({ nodes, head, checkpoints }, operations,
1108
1307
  /** Merge the journal into the tool result's private metadata. */
1109
1308
  export function withJournal(meta, journal) {
1110
1309
  const base = isRecord(meta) ? { ...meta } : meta === undefined ? {} : { value: cloneJson(meta) }
1111
- base[JOURNAL_KEY] = normalizeJournal(journal)
1112
- return base
1310
+ const normalized = normalizeJournal(journal)
1311
+ base[JOURNAL_KEY] = normalized
1312
+ return withSettlementProof(base, normalized)
1113
1313
  }
1114
1314
 
1115
1315
  /** Validate and detach one rewrite record emitted by the runtime. */
@@ -1,5 +1,6 @@
1
1
  import {
2
- advanceSessionTimeline, createSessionTimelineState, replaySessionTimelineFact, validateSessionTimelineState,
2
+ advanceSessionTimeline, createSessionTimelineState, factDigest, replaySessionTimelineFact, withFullRetention,
3
+ retentionSummaryOf, validateRetentionSummary, validateSessionTimelineState, admissionReplayBudget,
3
4
  } from './session-journal-recovery.js'
4
5
  import { isCanonicalSequence } from './session-journal.js'
5
6
  import { advanceSessionSurface, readSessionLog, sessionEvents } from './session-events.js'
@@ -38,7 +39,7 @@ function isContextStep(event) {
38
39
  || (event?.type === 'user/message' && event.data?.source?.kind === 'user')
39
40
  }
40
41
 
41
- export const SESSION_LOG_PROJECTION_KEY = 'ptcPlusSessionLog'
42
+ const SESSION_LOG_PROJECTION_KEY = 'ptcPlusSessionLog'
42
43
 
43
44
  function emptyLogState() {
44
45
  return { timeline: createSessionTimelineState(), contextStep: 0,
@@ -46,6 +47,19 @@ function emptyLogState() {
46
47
  systemPromptSnapshots: [], ptcMessages: [], runtimeMessagesBySeq: {}, visibleRuntimeMessages: [], surfaceNodes: [], logFacts: [] }
47
48
  }
48
49
 
50
+ /**
51
+ * Digest fields are validated per fact against their own bytes, so the replay
52
+ * comparison must ignore them: a v10 checkpoint has no digests and must still be
53
+ * admissible (the host refolds on the version mismatch), while a v11 one has been
54
+ * digest-checked already.
55
+ */
56
+ function withoutFactDigests(state) {
57
+ const facts = state.timeline.eventFacts
58
+ if (!facts.some(fact => fact.digest !== undefined)) return state
59
+ return { ...state, timeline: { ...state.timeline,
60
+ eventFacts: facts.map(({ digest, ...rest }) => rest) } }
61
+ }
62
+
49
63
  export function runtimeMessageFacts(records, initial = {}) {
50
64
  const facts = { ...initial }
51
65
  for (const record of records) {
@@ -115,10 +129,11 @@ function applyLogEvent(state, event, index = event?.seq,
115
129
  return next
116
130
  }
117
131
 
118
- export function createSessionLogProjection() {
132
+ export function createSessionLogProjection(options = {}) {
133
+ const replayBudget = admissionReplayBudget(options.replayBudgetBytes)
119
134
  return Object.freeze({
120
135
  key: SESSION_LOG_PROJECTION_KEY,
121
- stateVersion: 9,
136
+ stateVersion: 13,
122
137
  stateSchema: Object.freeze({ parse(value) {
123
138
  if (!isJsonValue(value) || !isRecord(value) || !isRecord(value.timeline)
124
139
  || typeof value.timeline.openTurn !== 'boolean' || typeof value.timeline.found !== 'boolean'
@@ -134,12 +149,17 @@ export function createSessionLogProjection() {
134
149
  || !isRecord(value.runtimeMessagesBySeq) || !isRecord(value.timeline.cordisTranscript)) {
135
150
  throw new TypeError('invalid PTC session-log projection checkpoint')
136
151
  }
137
- validateSessionTimelineState(value.timeline)
138
152
  if (!Array.isArray(value.logFacts)) throw new TypeError('invalid PTC session-log projection checkpoint facts')
139
- const timelineFacts = new Map(value.timeline.eventFacts.map(fact => [fact.eventIndex, fact]))
140
- let reconstructed = emptyLogState()
153
+ validateSessionTimelineState(value.timeline, value.logFacts, { replayBudgetBytes: replayBudget })
154
+ // Admission proves digest-bound continuity and frontier agreement, not a
155
+ // byte-for-byte replay equality of argument text. Replaying the whole log
156
+ // here would cost the host a second fold of every event (and the retained
157
+ // payloads make that fold proportional to everything the session wrote), so
158
+ // this validates the identity chain the projection itself maintains:
159
+ // ordered log facts whose indices agree with the timeline facts, each
160
+ // timeline fact carrying a digest that matches the log fact it points at,
161
+ // and a frontier that names the processed cut.
141
162
  let lastIndex = -1
142
- let timelineCount = 0
143
163
  for (const fact of value.logFacts) {
144
164
  if (!isRecord(fact) || Object.keys(fact).some(key => !['index', 'event'].includes(key))
145
165
  || !isCanonicalSequence(fact.index) || fact.index <= lastIndex
@@ -148,23 +168,79 @@ export function createSessionLogProjection() {
148
168
  throw new TypeError('invalid PTC session-log projection checkpoint facts')
149
169
  }
150
170
  lastIndex = fact.index
171
+ }
172
+ // Strictly increasing indices are what makes this index one-to-one, so a
173
+ // duplicate cannot reach the identity loop below. The per-fact digest is
174
+ // already required by `validateSessionTimelineState` above, which reads the
175
+ // same equality from the same facts.
176
+ const factsByIndex = new Map(value.logFacts.map(fact => [fact.index, fact]))
177
+ for (const timelineFact of value.timeline.eventFacts) {
178
+ const logFact = factsByIndex.get(timelineFact.eventIndex)
179
+ if (logFact === undefined) {
180
+ throw new TypeError('inconsistent PTC session-log projection checkpoint event identity')
181
+ }
182
+ if (logFact.event.seq !== timelineFact.event.seq || logFact.event.type !== timelineFact.event.type) {
183
+ throw new TypeError('inconsistent PTC session-log projection checkpoint event identity')
184
+ }
185
+ }
186
+ // Replay is the strongest admission check, but replaying every fact costs a
187
+ // second fold whose payload is proportional to everything the session wrote.
188
+ // Admission therefore replays while the retained payload stays inside the
189
+ // budget and falls back to the identity chain above (ordered facts, per-fact
190
+ // digests, frontier agreement) beyond it. Small checkpoints keep the full
191
+ // byte-for-byte proof; large ones prove digest-bound continuity instead.
192
+ // Admission proves digest-bound continuity and frontier agreement, not a
193
+ // byte-for-byte replay equality of argument text: replay runs only while the
194
+ // retained payload stays inside a fixed budget, and every retained fact
195
+ // carries a digest over its own bytes, so beyond that budget a payload edit
196
+ // is still rejected without folding the whole session a second time.
197
+ const summaryPresent = retentionSummaryOf(value.timeline) !== undefined
198
+ // A compacted checkpoint dropped whole clusters, so replaying the log can no
199
+ // longer reach the stored state. Admission proves the summary instead (below)
200
+ // and skips the replay entirely: it is both impossible to satisfy and the
201
+ // most expensive thing this file does.
202
+ const timelineFacts = new Map(value.timeline.eventFacts.map(fact => [fact.eventIndex, fact]))
203
+ let replayBytes = 0
204
+ let replayComplete = true
205
+ let reconstructed = emptyLogState()
206
+ for (const fact of summaryPresent ? [] : value.logFacts) {
151
207
  const timelineFact = timelineFacts.get(fact.index)
152
- if (timelineFact !== undefined) {
153
- if (!deepEqualJson(presentationEvent(timelineFact.event), fact.event)) {
154
- throw new TypeError('inconsistent PTC session-log projection checkpoint event identity')
208
+ if (timelineFact === undefined) {
209
+ if (advanceSessionTimeline(reconstructed.timeline, fact.event, fact.index) !== reconstructed.timeline) {
210
+ throw new TypeError('inconsistent PTC session-log projection checkpoint missing timeline facts')
211
+ }
212
+ } else {
213
+ replayBytes += factDigest(timelineFact.event).length * 4
214
+ if (replayBytes > replayBudget) {
215
+ replayComplete = false
216
+ continue
155
217
  }
156
- timelineCount += 1
157
- }
158
- if (timelineFact === undefined
159
- && advanceSessionTimeline(reconstructed.timeline, fact.event, fact.index) !== reconstructed.timeline) {
160
- throw new TypeError('inconsistent PTC session-log projection checkpoint missing timeline facts')
161
218
  }
162
219
  const timeline = timelineFact === undefined ? reconstructed.timeline
163
- : replaySessionTimelineFact(reconstructed.timeline, timelineFact)
220
+ : replaySessionTimelineFact(reconstructed.timeline, timelineFact, value.timeline.executableCalls)
164
221
  reconstructed = applyLogEvent(reconstructed, fact.event, fact.index, timeline)
165
222
  }
166
- if (timelineCount !== timelineFacts.size || !deepEqualJson(reconstructed, value)) {
167
- throw new TypeError('inconsistent PTC session-log projection checkpoint facts')
223
+ // A compacted checkpoint cannot be compared with the fold that produced it:
224
+ // the payload it dropped is gone from its own side. Admission proves the cut
225
+ // instead (below it stubs, at or above it text), which is the weaker, stated
226
+ // claim for compacted states. Non-compacted states keep the full comparison.
227
+ const summary = retentionSummaryOf(value.timeline)
228
+ if (summary === undefined) {
229
+ if (replayComplete && !deepEqualJson(withoutFactDigests(reconstructed), withoutFactDigests(value))) {
230
+ throw new TypeError('inconsistent PTC session-log projection checkpoint facts')
231
+ }
232
+ } else {
233
+ // A compacted checkpoint dropped its old clusters, so replay can never
234
+ // reproduce it. Admission proves the summary instead: the retained window is
235
+ // contiguous with the boundary, nothing below it survives, and the chain is
236
+ // recomputed from the logged dropped range so the summary cannot certify
237
+ // itself.
238
+ // A compacted checkpoint cannot prove its chain from its own state: the fold
239
+ // that dropped the clusters also dropped the observations, and `logFacts`
240
+ // hold presentation facts only. Admission claims the boundary and range here;
241
+ // callers that hold the raw log verify the chain with
242
+ // `verifyRetentionSummaryAgainstEvents`.
243
+ validateRetentionSummary(value.timeline, summary)
168
244
  }
169
245
  return deepFreeze(value)
170
246
  } }),
@@ -173,6 +249,24 @@ export function createSessionLogProjection() {
173
249
  })
174
250
  }
175
251
 
252
+ /**
253
+ * The disposer a session projection registration handed back.
254
+ *
255
+ * Two registration contracts are published: an earlier host returns a disposer and a
256
+ * newer one returns a `ProjectionHandle` (`stateOf`/`readerFor`/`dispose`, possibly
257
+ * async). Every projection this plugin registers goes through this reader, because a
258
+ * registration that only understands the earlier shape drops the projection — and
259
+ * with it the fact that the host accepted it — on the newer contract.
260
+ * @param {unknown} registration - what `sessionProjections.register` returned.
261
+ * @returns {(() => unknown)|undefined} the release call, or undefined when the answer
262
+ * names neither contract.
263
+ */
264
+ export function projectionRegistrationDisposer(registration) {
265
+ if (typeof registration === 'function') return () => registration()
266
+ if (typeof registration?.dispose === 'function') return () => registration.dispose()
267
+ return undefined
268
+ }
269
+
176
270
  export function createSessionLogOwner(ctx) {
177
271
  const projection = createSessionLogProjection()
178
272
  let registeredService
@@ -180,8 +274,10 @@ export function createSessionLogOwner(ctx) {
180
274
  try {
181
275
  scope.effect(() => {
182
276
  const registry = scope.sessionProjections
183
- const unregister = registry.register(projection)
184
- if (typeof unregister !== 'function') throw new Error('ptc-plus: sessionProjections.register did not return a disposer')
277
+ const unregister = projectionRegistrationDisposer(registry.register(projection))
278
+ if (unregister === undefined) {
279
+ throw new Error('ptc-plus: sessionProjections.register returned neither a disposer nor a handle')
280
+ }
185
281
  registeredService = registry
186
282
  return async () => {
187
283
  await unregister()
@@ -207,7 +303,7 @@ export function createSessionLogOwner(ctx) {
207
303
  }
208
304
 
209
305
  /** Project session events into immutable facts consumed by prompt presentation. */
210
- export function projectSessionLog(agent, requestedEdit, projectedState) {
306
+ export function projectSessionLog(agent, requestedEdit, projectedState, options = {}) {
211
307
  const events = projectedState === undefined ? sessionEvents(agent?.session) : undefined
212
308
  if (projectedState === undefined && !Array.isArray(events)) {
213
309
  return Object.freeze({
@@ -226,7 +322,13 @@ export function projectSessionLog(agent, requestedEdit, projectedState) {
226
322
  }
227
323
  let state = projectedState ?? emptyLogState()
228
324
  if (projectedState === undefined) {
229
- for (let index = 0; index < events.length; index += 1) state = applyLogEvent(state, events[index], index)
325
+ const fold = () => {
326
+ for (let index = 0; index < events.length; index += 1) state = applyLogEvent(state, events[index], index)
327
+ }
328
+ // A consumer that needs a dropped cluster refolds the same slice with pruning
329
+ // disabled; the checkpointed state never carries the flag.
330
+ if (options.retainAll === true) withFullRetention(fold)
331
+ else fold()
230
332
  }
231
333
  const { timeline, contextStep, systemPromptSnapshots, ptcMessages, runtimeMessagesBySeq } = state
232
334
  const requestedEntry = requestedEdit === undefined
@@ -243,9 +345,10 @@ export function projectSessionLog(agent, requestedEdit, projectedState) {
243
345
  let visibleRuntimeMessages = projectedState === undefined ? undefined : state.visibleRuntimeMessages
244
346
  if (projectedState === undefined) try {
245
347
  const nodes = agent?.session?.surface?.nodes
246
- const knownSeqs = events === undefined ? undefined : new Set(events.map(event => event?.seq))
247
- if (Array.isArray(nodes) && nodes.every(seq => isCanonicalSequence(seq)
248
- && (knownSeqs === undefined ? seq < agent.session.seq : knownSeqs.has(seq)))) {
348
+ // `events` is an array here: the caller either passed a projected state or
349
+ // sessionEvents produced one, so the undefined arm could never run.
350
+ const knownSeqs = new Set(events.map(event => event?.seq))
351
+ if (Array.isArray(nodes) && nodes.every(seq => isCanonicalSequence(seq) && knownSeqs.has(seq))) {
249
352
  visibleRuntimeMessages = Object.freeze(nodes.flatMap(seq => {
250
353
  const record = runtimeMessagesBySeq[`k${seq}`]
251
354
  return record === undefined ? [] : [record]
@@ -266,6 +369,10 @@ export function projectSessionLog(agent, requestedEdit, projectedState) {
266
369
  editableRun,
267
370
  repairSource: editableRun?.source,
268
371
  cordisTranscript,
372
+ retentionSummary: timeline.retentionSummary ?? undefined,
373
+ // Consumers must be able to tell "the newest cell was dropped for budget" from
374
+ // "there never was a cell"; both would otherwise look like an undefined run.
375
+ hotCellDropped: timeline.hotCellDropped === true,
269
376
  ...(requestedEdit === undefined ? {} : { requestedEditTarget }),
270
377
  })
271
378
  }