dsh-ptc-plus 0.5.2 → 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 (53) hide show
  1. package/README.en.md +3 -1
  2. package/README.md +3 -1
  3. package/client.cjs +165 -29
  4. package/docs/adr/0017-track-the-latest-dsh-public-surface.md +4 -2
  5. package/docs/adr/0019-plugin-settings-and-kill-switch.md +2 -2
  6. package/docs/adr/0023-global-user-bindings.md +12 -0
  7. package/docs/adr/0028-attach-to-the-host-ptc-execution-seam.md +1 -1
  8. package/docs/adr/0030-serve-both-typert-codec-generations-from-one-codec.md +2 -2
  9. package/docs/adr/0044-coexist-with-the-official-persistent-repl.md +4 -4
  10. package/docs/adr/0046-gate-the-commit-with-the-semantic-evidence-tier.md +1 -1
  11. package/docs/adr/0048-supersede-a-rewritten-cell.md +48 -0
  12. package/docs/adr/0049-prove-a-settlement-against-its-published-record.md +53 -0
  13. package/docs/adr/0050-read-host-surfaces-through-their-published-contract.md +48 -0
  14. package/docs/adr/README.md +7 -1
  15. package/docs/architecture.md +1 -1
  16. package/docs/durability-design.md +9 -2
  17. package/docs/installation.md +9 -9
  18. package/docs/releases/v0.5.2.md +26 -14
  19. package/docs/releases/v0.5.3.md +77 -0
  20. package/docs/runtime-reference.md +7 -1
  21. package/docs/user-bindings.en.md +15 -0
  22. package/docs/user-bindings.md +15 -0
  23. package/docs/v0.5.3-goals.md +117 -0
  24. package/docs/verification.md +4 -4
  25. package/index.js +67 -19
  26. package/internal/binding-adoption.js +136 -0
  27. package/internal/capability-command-receipt.js +18 -4
  28. package/internal/capability-delivery-proof.js +70 -0
  29. package/internal/capability-environment.js +38 -1
  30. package/internal/config-spec.js +14 -5
  31. package/internal/cordis-tools-owner.js +2 -1
  32. package/internal/direct-surface-owner.js +27 -13
  33. package/internal/edit-transport-owner.js +6 -5
  34. package/internal/execution-provider-owner.js +6 -0
  35. package/internal/execution-seam-compat.js +25 -0
  36. package/internal/host-tool-sdk.js +308 -0
  37. package/internal/official-repl-priority.js +31 -10
  38. package/internal/program-bindings.js +12 -1
  39. package/internal/repl-memory-projection.js +21 -2
  40. package/internal/runtime-bridge-owner.js +113 -8
  41. package/internal/runtime-messages.js +5 -0
  42. package/internal/session-journal-recovery.js +210 -32
  43. package/internal/session-journal-schema.js +3 -0
  44. package/internal/session-journal.js +46 -9
  45. package/internal/session-log-view.js +22 -2
  46. package/internal/session-runtime.js +106 -39
  47. package/internal/user-bindings-owner.js +109 -6
  48. package/package.json +15 -7
  49. package/src/client-capabilities-view.js +45 -3
  50. package/src/client-copy.js +24 -0
  51. package/src/client-indicator-view.js +2 -1
  52. package/src/client-repl-view.js +35 -16
  53. package/src/client.js +3 -1
@@ -9,6 +9,9 @@ import {
9
9
  RECOVERY_BOUNDARY_EVENT,
10
10
  DIAGNOSTIC_WORDING_KEY,
11
11
  DIAGNOSTIC_WORDING_FIELDS,
12
+ SETTLEMENT_PROOF_KEY,
13
+ SETTLEMENT_PROOF_FIELDS,
14
+ SETTLEMENT_PROOF_VERSION,
12
15
  LEGACY_JOURNAL_VERSION,
13
16
  REPL_TOOL_NAMES,
14
17
  REWRITES_KEY,
@@ -23,6 +26,7 @@ import {
23
26
  normalizeJournal,
24
27
  normalizeRecoveryBoundaries,
25
28
  reduceStateOperations,
29
+ settlementProofDigest,
26
30
  userBindingsForJournal,
27
31
  validatedRewrites,
28
32
  } from './session-journal.js'
@@ -104,14 +108,13 @@ function reachableFrom(nodes, head) {
104
108
  }
105
109
 
106
110
  /**
107
- * Binding names a node recorded.
111
+ * Binding names a settlement journal recorded.
108
112
  *
109
113
  * The recorded values are the only evidence that names a durable binding today;
110
114
  * `unrecordedBindings` names the ones the codec could not transfer, and a
111
115
  * `kind: "binding"` save names one explicitly. A checkpoint save never counts.
112
116
  */
113
- function recordedBindingNames(node) {
114
- const journal = node?.journal
117
+ function journalBindingNames(journal) {
115
118
  if (journal === undefined) return []
116
119
  return [...new Set([
117
120
  ...(journal.values ?? []).map(value => value.name),
@@ -120,6 +123,11 @@ function recordedBindingNames(node) {
120
123
  ].filter(name => typeof name === 'string'))]
121
124
  }
122
125
 
126
+ /** Binding names an installed node recorded. */
127
+ function recordedBindingNames(node) {
128
+ return journalBindingNames(node?.journal)
129
+ }
130
+
123
131
  function applyOperations(state, operations, nodeIndex) {
124
132
  const previousHead = state.head
125
133
  const transition = reduceStateOperations(state, operations, nodeIndex)
@@ -145,6 +153,117 @@ function applyOperations(state, operations, nodeIndex) {
145
153
  }
146
154
  }
147
155
 
156
+ /**
157
+ * Adopt the relation a record states about the cell it revised.
158
+ *
159
+ * The relation belongs to the fold, because only the fold knows the path that
160
+ * builds the environment. An accepted edit claims the revision *replaces* the cell
161
+ * it edited, so the revision takes that cell's place in the recorded order: it
162
+ * continues from the target's parent and every cell that followed the target
163
+ * continues from the revision. Appending the revision after those cells instead
164
+ * leaves them replaying an environment the edit already rewrote, with the
165
+ * declarations they read gone and the revision not yet in place.
166
+ *
167
+ * `installed` says whether this settlement puts a cell on the path. A contracted
168
+ * revision installs nothing, so the cells that followed the target bridge to the
169
+ * target's parent, and only a head that was the target itself has to move.
170
+ *
171
+ * The returned record of what changed is what lets a rejected settlement put the
172
+ * path back: nothing this adoption did survives a settlement the fold refuses.
173
+ */
174
+ function adoptSupersession(state, record, { installed }) {
175
+ const superseded = state.editRewritesHistory && record.supersedes !== undefined
176
+ ? state.nodes.findIndex(node => node.callSeq === record.supersedes) : -1
177
+ const adoption = {
178
+ superseded,
179
+ previousHead: state.head,
180
+ supersededBindings: new Set(state.supersededBindings),
181
+ supersessions: state.supersessions.length,
182
+ children: [],
183
+ parent: state.head,
184
+ head: undefined,
185
+ }
186
+ if (superseded < 0) {
187
+ // No relation: the settlement appends to the head, as it always has.
188
+ adoption.head = installed ? undefined : state.head
189
+ return adoption
190
+ }
191
+ const parent = state.nodes[superseded].parent
192
+ adoption.parent = parent
193
+ for (let child = 0; child < state.nodes.length; child += 1) {
194
+ if (state.nodes[child].parent === superseded) adoption.children.push(child)
195
+ }
196
+ // The head is the last recorded cell. It keeps its place when the target is on
197
+ // its path behind other cells, because those cells now reach the same head
198
+ // through the revision; otherwise the revision is the newest cell and the head.
199
+ const targetOnPath = reachableFrom(state.nodes, state.head).has(superseded)
200
+ if (installed) adoption.head = targetOnPath && state.head !== superseded ? state.head : undefined
201
+ else adoption.head = state.head === superseded ? parent : state.head
202
+ return adoption
203
+ }
204
+
205
+ /**
206
+ * Record the relation as the fact the model is owed.
207
+ *
208
+ * Only names the record proves count. A durable declaration that the replay
209
+ * re-establishes is not evidence about the superseded cell, and claiming it
210
+ * would report a binding this fold cannot see. What the revision supplies is the
211
+ * caller's answer, because a settlement the fold refused to install supplies
212
+ * nothing; a cell the fold installs supplies the names its journal recorded.
213
+ */
214
+ function recordSupersession(state, record, adoption, suppliedNames) {
215
+ if (adoption.superseded < 0) return
216
+ const supplied = new Set(suppliedNames)
217
+ for (const name of recordedBindingNames(state.nodes[adoption.superseded])) {
218
+ if (!supplied.has(name)) state.supersededBindings.add(name)
219
+ }
220
+ state.supersessions.push(Object.freeze({
221
+ supersededCallSeq: record.supersedes,
222
+ supersedingCallSeq: record.call.seq,
223
+ }))
224
+ }
225
+
226
+ /**
227
+ * Fold one accepted record as a settlement of the recorded path.
228
+ *
229
+ * `installed` is false for a settlement the fold contracts: it puts no cell on the
230
+ * path and supplies no name, while the relation it states is still the log's fact.
231
+ * Either way the relation is adopted first and the state operations are applied
232
+ * last, so a settlement that throws leaves the path, the children it re-parented
233
+ * and the relation it recorded exactly as it found them.
234
+ */
235
+ function applyAdoptedRecord(state, record, { journal, installed = true, restoredFromRecord = false }) {
236
+ const adoption = adoptSupersession(state, record, { installed })
237
+ const index = installed
238
+ ? state.nodes.push(Object.freeze({
239
+ code: record.code,
240
+ journal,
241
+ callSeq: record.call.seq,
242
+ parent: adoption.parent,
243
+ ...(restoredFromRecord ? { restoredFromRecord: true } : {}),
244
+ ...(record.result.userBindings === undefined ? {} : { userBindings: record.result.userBindings }),
245
+ })) - 1
246
+ : undefined
247
+ const inherited = installed ? index : adoption.parent
248
+ for (const child of adoption.children) {
249
+ state.nodes[child] = Object.freeze({ ...state.nodes[child], parent: inherited })
250
+ }
251
+ state.head = adoption.head === undefined ? index : adoption.head
252
+ try {
253
+ recordSupersession(state, record, adoption, installed ? journalBindingNames(journal) : [])
254
+ applyOperations(state, journal.operations, index)
255
+ } catch (error) {
256
+ if (index !== undefined) state.nodes.pop()
257
+ state.head = adoption.previousHead
258
+ for (const child of adoption.children) {
259
+ state.nodes[child] = Object.freeze({ ...state.nodes[child], parent: adoption.superseded })
260
+ }
261
+ state.supersededBindings = adoption.supersededBindings
262
+ state.supersessions.length = adoption.supersessions
263
+ throw error
264
+ }
265
+ }
266
+
148
267
  /**
149
268
  * Binding names a recorded journal operation list saves.
150
269
  *
@@ -212,17 +331,10 @@ function applyRecord(state, record, invalidCallSeqs) {
212
331
  // the owner that read `Date.now()` or `process.pid` is gone, and the next
213
332
  // environment inherits the durable computation, not the live cache.
214
333
  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)
334
+ // The relation is adopted for this settlement too: an accepted revision that
335
+ // settled volatile is still the revision the record names, so it replaces its
336
+ // target on the path and installs its own recorded values.
337
+ applyAdoptedRecord(state, record, { journal, restoredFromRecord: true })
226
338
  return
227
339
  }
228
340
  // A released live environment is reported as a contracted cell: the model
@@ -236,29 +348,22 @@ function applyRecord(state, record, invalidCallSeqs) {
236
348
  : journal.volatileReason ?? 'volatile cell',
237
349
  ...(volatileBindings.length === 0 || state.liveOnlyLost === true ? {} : { bindings: volatileBindings }),
238
350
  })
239
- applyOperations(state, journal.operations, undefined)
351
+ // A contracted revision still replaces the cell it edited. Leaving the target
352
+ // on the path would keep building an environment the accepted edit already
353
+ // ended, and the relation is what tells the model those names are withdrawn
354
+ // rather than lost. The settlement installs nothing, so it supplies no name:
355
+ // every name the target recorded is withdrawn by this record.
356
+ applyAdoptedRecord(state, record, { journal, installed: false })
240
357
  return
241
358
  }
242
359
  if (!state.trusted && !state.surfaceContracted) {
243
360
  state.trusted = true
244
361
  state.volatileSuffix.length = 0
245
362
  }
246
- const node = Object.freeze({
247
- code,
248
- journal,
249
- callSeq: call.seq,
250
- parent: state.head,
251
- ...(result.userBindings === undefined ? {} : { userBindings: result.userBindings }),
252
- })
253
- const index = state.nodes.push(node) - 1
254
- state.head = index
255
- try {
256
- applyOperations(state, journal.operations, index)
257
- } catch (error) {
258
- state.nodes.pop()
259
- state.head = node.parent
260
- throw error
261
- }
363
+ // A record may declare that it is the revision of an earlier cell. Adoption of
364
+ // that relation, its re-parented children and the node this record puts on the
365
+ // path are one settlement: a record the fold refuses leaves none of them behind.
366
+ applyAdoptedRecord(state, record, { journal })
262
367
  }
263
368
 
264
369
  function recordEventSeq(record) {
@@ -277,9 +382,15 @@ function foldRecords(records, invalidCallSeqs, options = {}) {
277
382
  volatileSuffix: [],
278
383
  provenBindings: new Set(),
279
384
  deletedBindings: new Set(),
385
+ // One recorded revision replaced the cell it edited. The relation, not a
386
+ // deletion, is what the log proves: the superseded cell stays in the record
387
+ // and off the path to the head.
388
+ supersessions: [],
389
+ supersededBindings: new Set(),
280
390
  trusted: true,
281
391
  surfaceContracted: options.surfaceContracted === true,
282
392
  liveOnlyLost: options.liveOnlyLost === true,
393
+ editRewritesHistory: options.editRewritesHistory === true,
283
394
  }
284
395
  for (const record of records) {
285
396
  applyRecord(state, record, invalidCallSeqs)
@@ -1459,6 +1570,12 @@ function normalizePostEditFact(facts, call, resultEvent, result, replayPostEdit,
1459
1570
  let derived
1460
1571
  try {
1461
1572
  derived = normalizeDeferredEditSettlement(post.meta, target?.callSeq)
1573
+ // The derived metadata is a published settlement of its own, so it carries the
1574
+ // same proof the root journal does. Comparing the two recorded journal copies
1575
+ // proves only that they agree with each other; an edit that changed both and
1576
+ // left this proof behind no longer reproduces the settlement it published, and
1577
+ // its journal is then not evidence for the bindings the revision declares.
1578
+ requireRecordedSettlementProof(post.meta, derived.journal)
1462
1579
  } catch (error) {
1463
1580
  return fail(error.message)
1464
1581
  }
@@ -1517,6 +1634,40 @@ function requireRecordedDiagnosticWording(meta, journal) {
1517
1634
  }
1518
1635
  }
1519
1636
 
1637
+ /**
1638
+ * A published settlement proves its own facts, and the Host's copy of its outcome
1639
+ * must still agree with them.
1640
+ *
1641
+ * Recovery reads a record as evidence for the bindings its cell declared, and the
1642
+ * record is the only account of its cell: editing the status, the completion or a
1643
+ * diagnostic leaves the record self-consistent, because the completion is rendered
1644
+ * from the same diagnostic that was edited. The proof recorded at publication is
1645
+ * one digest over exactly those facts, so an edited record no longer reproduces it.
1646
+ * A record that carries no proof predates it and keeps the older evidence.
1647
+ *
1648
+ * The retained `isError` is the Host's copy of the same outcome and not a fact this
1649
+ * plugin can recompute, so an edit that also rewrites the proof still has to agree
1650
+ * with it. That relation is the one a deferred edit already proves before it is
1651
+ * published.
1652
+ */
1653
+ function requireRecordedSettlementProof(meta, journal, isError = undefined) {
1654
+ if (isError !== undefined && isError !== (journal.status === 'discarded'
1655
+ || journal.completion?.kind === 'throw')) {
1656
+ throw new Error('journal settlement does not agree with the tool result it settled')
1657
+ }
1658
+ if (!isRecord(meta) || !Object.hasOwn(meta, SETTLEMENT_PROOF_KEY)) return
1659
+ const recorded = meta[SETTLEMENT_PROOF_KEY]
1660
+ if (!isRecord(recorded) || Reflect.ownKeys(recorded).length !== SETTLEMENT_PROOF_FIELDS.size
1661
+ || Reflect.ownKeys(recorded).some(key => !SETTLEMENT_PROOF_FIELDS.has(key))
1662
+ || recorded.version !== SETTLEMENT_PROOF_VERSION
1663
+ || typeof recorded.recordedMessage !== 'string') {
1664
+ throw new Error('invalid recorded settlement proof')
1665
+ }
1666
+ if (recorded.recordedMessage !== settlementProofDigest(journal)) {
1667
+ throw new Error('journal facts do not match their recorded settlement proof')
1668
+ }
1669
+ }
1670
+
1520
1671
  function advanceTimelineEvent(previous, inputEvent, eventIndex, replayFact, retainedCalls) {
1521
1672
  const nested = inputEvent?.type === 'tool/ptc-dispatch-start'
1522
1673
  || inputEvent?.type === 'tool/ptc-dispatch'
@@ -1767,6 +1918,7 @@ function advanceTimelineEvent(previous, inputEvent, eventIndex, replayFact, reta
1767
1918
  ...(derived.userBindings === undefined ? {} : { userBindings: derived.userBindings }),
1768
1919
  }
1769
1920
  requireRecordedDiagnosticWording(meta, derived.journal)
1921
+ requireRecordedSettlementProof(meta, derived.journal)
1770
1922
  } else if (call.data.name === 'run_code') {
1771
1923
  const rawJournal = meta[JOURNAL_KEY]
1772
1924
  const resolveLegacyConfirm = rawJournal?.version === LEGACY_JOURNAL_VERSION
@@ -1800,6 +1952,15 @@ function advanceTimelineEvent(previous, inputEvent, eventIndex, replayFact, reta
1800
1952
  ...(userBindings === undefined ? {} : { userBindings }),
1801
1953
  }
1802
1954
  requireRecordedDiagnosticWording(meta, journal)
1955
+ // The Host states the outcome of the cell it settled; a record whose
1956
+ // completion or status contradicts it was edited after publication. The
1957
+ // Host records that outcome on the result message it appends, so the
1958
+ // message is read first; a log that carries it on the event instead is
1959
+ // read the same way.
1960
+ const hostIsError = typeof event.data?.message?.isError === 'boolean'
1961
+ ? event.data.message.isError
1962
+ : typeof event.data?.isError === 'boolean' ? event.data.isError : undefined
1963
+ requireRecordedSettlementProof(meta, journal, hostIsError)
1803
1964
  let postEdit
1804
1965
  try {
1805
1966
  postEdit = normalizePostEditFact(state.eventFacts, call, event, normalized, undefined, state.executableCalls)
@@ -2299,7 +2460,8 @@ export function recoverJournal(session, currentCallSeq, options = {}) {
2299
2460
  // A live environment that was released cannot lend its recorded live-only
2300
2461
  // values to the environment that replaces it. The flag belongs to the fold, so
2301
2462
  // every re-fold below carries it.
2302
- const foldOptions = { liveOnlyLost: options?.liveOnlyLost === true }
2463
+ const foldOptions = { liveOnlyLost: options?.liveOnlyLost === true,
2464
+ editRewritesHistory: options?.editRewritesHistory === true }
2303
2465
  let state = foldRecords(records, invalidCallSeqs, foldOptions)
2304
2466
  let boundaryIndex = 0
2305
2467
  const carrierCallSeqForBoundary = boundary => boundary.carrierCallSeq
@@ -2415,6 +2577,9 @@ export function recoverJournal(session, currentCallSeq, options = {}) {
2415
2577
  code: postEdit.derived.code,
2416
2578
  result: { eventSeq: result.eventSeq, journal: postEdit.derived.journal,
2417
2579
  derived: postEdit.derived, userBindings: postEdit.derived.userBindings },
2580
+ // The revision names the cell it edits. Whether that relation replaces
2581
+ // the target or appends to it is the fold's decision, not the log's.
2582
+ supersedes: postEdit.targetCallSeq,
2418
2583
  hidden: visibleCalls instanceof Set && (!visibleCalls.has(call.seq)
2419
2584
  || !visibleCalls.has(postEdit.targetCallSeq)),
2420
2585
  })
@@ -2482,6 +2647,19 @@ export function recoverJournal(session, currentCallSeq, options = {}) {
2482
2647
  enumerable: false,
2483
2648
  configurable: true,
2484
2649
  })
2650
+ // The supersession relation is recovery evidence of the same kind: which cell
2651
+ // each revision replaced, and which proved names the path no longer supplies
2652
+ // because of it. Both stay off the enumerable shape for the same reason.
2653
+ Object.defineProperty(history, 'supersessions', {
2654
+ value: [...state.supersessions],
2655
+ enumerable: false,
2656
+ configurable: true,
2657
+ })
2658
+ Object.defineProperty(history, 'supersededBindings', {
2659
+ value: [...state.supersededBindings],
2660
+ enumerable: false,
2661
+ configurable: true,
2662
+ })
2485
2663
  return history
2486
2664
  }
2487
2665
 
@@ -12,6 +12,7 @@ export const POST_EDIT_KEY = 'dshPtcPlusPostEdit'
12
12
  export const REWRITES_KEY = 'dshPtcPlusRewrites'
13
13
  export const RECOVERY_BOUNDARY_KEY = 'dshPtcPlusRecoveryBoundaries'
14
14
  export const DIAGNOSTIC_WORDING_KEY = 'dshPtcPlusDiagnosticWording'
15
+ export const SETTLEMENT_PROOF_KEY = 'dshPtcPlusSettlementProof'
15
16
  export const IMPORT_BOUNDARY_JOURNAL_VERSION = 7
16
17
  export const PER_NAME_USER_BINDINGS_JOURNAL_VERSION = 8
17
18
  export const LANGUAGE_SEMANTICS_JOURNAL_VERSION = 9
@@ -62,6 +63,8 @@ export const ERROR_FIELDS = new Set(['kind', 'message'])
62
63
  export const EDIT_TARGET_FIELDS = new Set(['targetCallSeq'])
63
64
  export const DERIVED_RUN_FIELDS = new Set(['code', 'codeDigest', 'codeBytes', 'description'])
64
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'])
65
68
 
66
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])
67
70
  export const USER_BINDINGS_REUSE_POLICIES = new Set([LEGACY_USER_BINDINGS_REUSE_POLICY, LIVE_USER_BINDINGS_REUSE_POLICY])
@@ -64,6 +64,9 @@ import {
64
64
  USER_BINDINGS_REUSE_POLICIES,
65
65
  RECOVERY_BOUNDARY_FIELDS,
66
66
  DIAGNOSTIC_WORDING_KEY,
67
+ SETTLEMENT_PROOF_KEY,
68
+ SETTLEMENT_PROOF_FIELDS,
69
+ SETTLEMENT_PROOF_VERSION,
67
70
  REPL_TOOL_NAMES,
68
71
  REWRITE_FIELDS,
69
72
  REWRITE_KINDS,
@@ -78,6 +81,9 @@ export {
78
81
  RECOVERY_BOUNDARY_KEY,
79
82
  DIAGNOSTIC_WORDING_KEY,
80
83
  DIAGNOSTIC_WORDING_FIELDS,
84
+ SETTLEMENT_PROOF_KEY,
85
+ SETTLEMENT_PROOF_FIELDS,
86
+ SETTLEMENT_PROOF_VERSION,
81
87
  JOURNAL_VERSION,
82
88
  LIVE_USER_BINDINGS_REUSE_POLICY,
83
89
  RECOVERY_BOUNDARY_EVENT,
@@ -129,15 +135,46 @@ export function diagnosticWordingDigest(journal) {
129
135
  return digestOf(diagnostics.map(diagnosticWordingFacts))
130
136
  }
131
137
 
132
- /** Attach the recorded wording digest beside a journal, or drop a stale one. */
133
- export function withDiagnosticWording(meta, journal) {
134
- const digest = diagnosticWordingDigest(journal)
135
- if (digest === undefined) {
136
- if (!Object.hasOwn(meta, DIAGNOSTIC_WORDING_KEY)) return meta
137
- const { [DIAGNOSTIC_WORDING_KEY]: _omitted, ...rest } = meta
138
- return rest
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
+ },
139
177
  }
140
- return { ...meta, [DIAGNOSTIC_WORDING_KEY]: { digest } }
141
178
  }
142
179
 
143
180
  function cloneJson(value) {
@@ -1272,7 +1309,7 @@ export function withJournal(meta, journal) {
1272
1309
  const base = isRecord(meta) ? { ...meta } : meta === undefined ? {} : { value: cloneJson(meta) }
1273
1310
  const normalized = normalizeJournal(journal)
1274
1311
  base[JOURNAL_KEY] = normalized
1275
- return withDiagnosticWording(base, normalized)
1312
+ return withSettlementProof(base, normalized)
1276
1313
  }
1277
1314
 
1278
1315
  /** Validate and detach one rewrite record emitted by the runtime. */
@@ -249,6 +249,24 @@ export function createSessionLogProjection(options = {}) {
249
249
  })
250
250
  }
251
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
+
252
270
  export function createSessionLogOwner(ctx) {
253
271
  const projection = createSessionLogProjection()
254
272
  let registeredService
@@ -256,8 +274,10 @@ export function createSessionLogOwner(ctx) {
256
274
  try {
257
275
  scope.effect(() => {
258
276
  const registry = scope.sessionProjections
259
- const unregister = registry.register(projection)
260
- 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
+ }
261
281
  registeredService = registry
262
282
  return async () => {
263
283
  await unregister()