dsh-ptc-plus 0.4.1 → 0.4.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 (103) hide show
  1. package/README.en.md +164 -78
  2. package/README.md +165 -79
  3. package/assets/dsh-ptc-plus-banner-en.webp +0 -0
  4. package/assets/dsh-ptc-plus-banner-zh.webp +0 -0
  5. package/assets/ptc-plus-icon.svg +5 -0
  6. package/client.js +854 -336
  7. package/compiler-core.cjs +161 -157
  8. package/compiler-core.cjs.map +3 -3
  9. package/docs/README.md +4 -1
  10. package/docs/adr/0005-temporary-rejected-cell-edit-transport.md +8 -7
  11. package/docs/adr/0008-define-agent-operating-contract.md +2 -2
  12. package/docs/adr/0010-session-log-derived-recovery-tips.md +7 -0
  13. package/docs/adr/0016-archive-resolved-review-findings.md +3 -3
  14. package/docs/adr/0019-plugin-settings-and-kill-switch.md +3 -3
  15. package/docs/adr/0020-optional-cordis-tools-in-ptc-mode.md +2 -0
  16. package/docs/adr/0021-separate-host-bootstrap-from-session-runtime.md +14 -3
  17. package/docs/adr/0023-global-user-bindings.md +7 -7
  18. package/docs/adr/0025-use-versioned-logical-binding-identities.md +3 -3
  19. package/docs/adr/0026-persist-the-delivery-verdict-against-the-verified-candidate.md +23 -7
  20. package/docs/adr/0027-run-the-session-kernel-in-a-killable-helper-process.md +9 -5
  21. package/docs/adr/0028-attach-to-the-host-ptc-execution-seam.md +26 -6
  22. package/docs/adr/0031-close-semantic-obligations-by-owner.md +29 -0
  23. package/docs/adr/README.md +4 -4
  24. package/docs/architecture.md +15 -10
  25. package/docs/binding-review-placement-design.md +1 -1
  26. package/docs/client-ui.md +9 -5
  27. package/docs/durability-design.md +12 -9
  28. package/docs/installation.md +27 -9
  29. package/docs/runtime-reference.md +23 -12
  30. package/docs/semantic-validation.md +7 -3
  31. package/docs/stateful-binding-updates-proposal.md +8 -4
  32. package/docs/user-bindings.en.md +10 -7
  33. package/docs/user-bindings.md +9 -6
  34. package/docs/value-wire.md +1 -1
  35. package/docs/verification-optimization-work.md +41 -0
  36. package/docs/verification.md +53 -2
  37. package/index.js +39 -34
  38. package/internal/async-local-storage-intrinsics.js +43 -0
  39. package/internal/binding-declaration-contract.js +8 -0
  40. package/internal/binding-source-analysis.js +381 -64
  41. package/internal/binding-update-policy.js +7 -1
  42. package/internal/callable-source-facts.js +37 -1
  43. package/internal/cell-analysis.js +85 -17
  44. package/internal/compiler-intrinsics.js +9 -43
  45. package/internal/compiler-module-links.js +7 -4
  46. package/internal/config-spec.js +1 -1
  47. package/internal/cordis-tools-owner.js +20 -10
  48. package/internal/dynamic-environment-integration.js +8 -4
  49. package/internal/dynamic-environment-runtime.js +24 -3
  50. package/internal/dynamic-native-calls.js +4 -1
  51. package/internal/dynamic-native-runtime.js +15 -1
  52. package/internal/edit-transport-owner.js +80 -20
  53. package/internal/execution-seam-compat.js +192 -30
  54. package/internal/failure-reporting.js +38 -30
  55. package/internal/isolated-worker.js +17 -10
  56. package/internal/kernel-worker.js +645 -393
  57. package/internal/message-sources.js +14 -0
  58. package/internal/module-compilation.js +7 -5
  59. package/internal/module-transform-contract.js +32 -2
  60. package/internal/record-utils.js +24 -15
  61. package/internal/repl-memory-projection.js +10 -0
  62. package/internal/repl-scope-normalizer.js +72 -35
  63. package/internal/repl-value-observer.js +145 -46
  64. package/internal/runtime-config.js +62 -2
  65. package/internal/runtime-intrinsics.js +104 -0
  66. package/internal/runtime-messages.js +6 -4
  67. package/internal/session-cell-executor.js +26 -2
  68. package/internal/session-journal-recovery.js +267 -67
  69. package/internal/session-journal-schema.js +30 -13
  70. package/internal/session-journal.js +485 -44
  71. package/internal/session-log-view.js +5 -4
  72. package/internal/session-runtime.js +46 -21
  73. package/internal/session-state.js +7 -29
  74. package/internal/settings-compat.js +66 -1
  75. package/internal/source-position-map.js +38 -0
  76. package/internal/stateful-root-compiler.js +3 -1
  77. package/internal/typescript-transform.js +4 -2
  78. package/internal/user-binding-console-worker.js +115 -41
  79. package/internal/user-binding-draft-projection.js +3 -2
  80. package/internal/user-bindings-owner.js +2 -1
  81. package/internal/user-bindings.js +16 -3
  82. package/internal/validated-parse-repair.js +6 -2
  83. package/internal/value-wire.js +189 -94
  84. package/internal/worker-client.js +99 -8
  85. package/internal/worker-cwd-virtualization.js +82 -54
  86. package/internal/worker-environment.js +20 -0
  87. package/internal/worker-output-capture.js +187 -0
  88. package/internal/worker-output-fence.js +26 -0
  89. package/internal/worker-realm-surfaces.js +197 -0
  90. package/internal/worker-repl-realm.js +140 -0
  91. package/package.json +37 -4
  92. package/src/client-activity.js +12 -2
  93. package/src/client-authoring-view.js +121 -62
  94. package/src/client-console.js +4 -2
  95. package/src/client-copy.js +4 -2
  96. package/src/client-feature-gates.js +7 -9
  97. package/src/client-host-compat.js +104 -27
  98. package/src/client-repl-view.js +3 -1
  99. package/src/client-styles.js +12 -1
  100. package/src/client-tool-view.js +3 -1
  101. package/src/client-transport.js +2 -2
  102. package/src/client-workbench.js +4 -3
  103. package/src/client.js +56 -17
@@ -1,3 +1,4 @@
1
+ import { isDeepStrictEqual } from 'node:util'
1
2
  import { isRecord } from './record-utils.js'
2
3
  import { sessionEvents } from './session-events.js'
3
4
  import {
@@ -8,6 +9,7 @@ import {
8
9
  REPL_TOOL_NAMES,
9
10
  } from './session-journal-schema.js'
10
11
  import {
12
+ isCanonicalSequence,
11
13
  normalizeDerivedEditResult,
12
14
  normalizeJournal,
13
15
  normalizeRecoveryBoundaries,
@@ -16,6 +18,15 @@ import {
16
18
  validatedRewrites,
17
19
  } from './session-journal.js'
18
20
 
21
+ const RECOVERY_BOUNDARY_EVIDENCE = Symbol('recoveryBoundaryEvidence')
22
+ const MODEL_VISIBLE_SURFACE_EVENTS = new Set([
23
+ 'system/message',
24
+ 'developer/message',
25
+ 'user/message',
26
+ 'assistant/message',
27
+ 'tool/result',
28
+ ])
29
+
19
30
  function sourceForRunCall(call) {
20
31
  try {
21
32
  const args = JSON.parse(call.data.arguments)
@@ -96,7 +107,11 @@ function applyRecord(state, record, invalidCallSeqs) {
96
107
  }
97
108
 
98
109
  function recordEventSeq(record) {
99
- return record.result?.eventSeq ?? record.call.seq
110
+ // A settlement that the Host re-published as a pruned clone carries the
111
+ // clone's row sequence, but it is still the same settlement: ordering and
112
+ // boundary application use its settlement position so a recorded boundary
113
+ // finds the call it names already folded.
114
+ return record.result?.positionSeq ?? record.result?.eventSeq ?? record.call.seq
100
115
  }
101
116
 
102
117
  function foldRecords(records, invalidCallSeqs, options = {}) {
@@ -123,24 +138,32 @@ function dependsOn(nodes, index, ancestor) {
123
138
 
124
139
  /** Return executable call sequences whose provenance remains model-visible. */
125
140
  export function visibleExecutableCallSeqs(session) {
126
- const events = sessionEvents(session)
141
+ let events
127
142
  let nodes
128
143
  try {
144
+ events = sessionEvents(session)
129
145
  nodes = session?.surface?.nodes
130
146
  } catch {
131
- return undefined
147
+ return new Set()
132
148
  }
133
- if (!Array.isArray(events) || !Array.isArray(nodes)) return undefined
149
+ if (!Array.isArray(events) || !Array.isArray(nodes)) return new Set()
134
150
  const eventBySeq = new Map()
135
- for (const event of events) eventBySeq.set(event?.seq, event)
151
+ const duplicateEventSeqs = new Set()
152
+ for (const event of events) {
153
+ if (!isCanonicalSequence(event?.seq)) return new Set()
154
+ if (eventBySeq.has(event.seq)) duplicateEventSeqs.add(event.seq)
155
+ else eventBySeq.set(event.seq, event)
156
+ }
157
+ const visible = new Set()
136
158
  for (const seq of nodes) {
137
- if (!Number.isSafeInteger(seq) || seq < 0 || eventBySeq.get(seq) === undefined) return undefined
159
+ const event = eventBySeq.get(seq)
160
+ if (!isCanonicalSequence(seq) || event === undefined
161
+ || duplicateEventSeqs.has(seq) || !MODEL_VISIBLE_SURFACE_EVENTS.has(event.type)
162
+ || visible.has(seq)) return new Set()
163
+ visible.add(seq)
138
164
  }
139
- const visible = new Set(nodes)
140
- const resultSources = new Set()
141
165
  const visibleAssistantCallSeqs = new Set()
142
166
  const pendingAssistantCalls = new Map()
143
- let assistantCallEvidence = false
144
167
  for (const event of events) {
145
168
  if (event?.type === 'turn/start' || event?.type === 'turn/end') {
146
169
  pendingAssistantCalls.clear()
@@ -153,41 +176,34 @@ export function visibleExecutableCallSeqs(session) {
153
176
  for (const part of content) {
154
177
  if (part?.type === 'tool-call' && typeof part.id === 'string') {
155
178
  const isVisible = visible.has(event.seq)
156
- if (isVisible) assistantCallEvidence = true
157
- let occurrences = pendingAssistantCalls.get(part.id)
158
- if (occurrences === undefined) {
159
- occurrences = []
160
- pendingAssistantCalls.set(part.id, occurrences)
179
+ const candidate = {
180
+ isVisible,
181
+ name: typeof part.name === 'string' ? part.name : undefined,
182
+ arguments: typeof part.arguments === 'string' ? part.arguments : undefined,
161
183
  }
162
- occurrences.push(isVisible)
184
+ pendingAssistantCalls.set(part.id,
185
+ pendingAssistantCalls.has(part.id) ? null : candidate)
163
186
  }
164
187
  }
165
188
  }
166
189
  continue
167
190
  }
168
191
  if (event?.type === 'tool/call' && typeof event.data?.callId === 'string') {
169
- const occurrences = pendingAssistantCalls.get(event.data.callId)
170
- if (occurrences !== undefined && occurrences.length > 0) {
171
- const isVisible = occurrences.shift()
172
- if (occurrences.length === 0) pendingAssistantCalls.delete(event.data.callId)
173
- if (isVisible && REPL_TOOL_NAMES.has(event.data.name)) {
192
+ const candidate = pendingAssistantCalls.get(event.data.callId)
193
+ pendingAssistantCalls.delete(event.data.callId)
194
+ if (candidate !== undefined && candidate !== null) {
195
+ if (candidate.isVisible && REPL_TOOL_NAMES.has(event.data.name)
196
+ && candidate.name === event.data.name
197
+ && candidate.arguments === event.data.arguments) {
174
198
  visibleAssistantCallSeqs.add(event.seq)
175
199
  }
176
200
  }
177
201
  }
178
- if (visible.has(event?.seq)
179
- && event?.type === 'tool/result' && Array.isArray(event.sourceEventSeqs)) {
180
- for (const sourceSeq of event.sourceEventSeqs) {
181
- if (Number.isSafeInteger(sourceSeq) && sourceSeq >= 0) resultSources.add(sourceSeq)
182
- }
183
- }
184
202
  }
185
203
  const calls = new Set()
186
204
  for (const event of events) {
187
205
  if (event?.type !== 'tool/call' || !REPL_TOOL_NAMES.has(event.data?.name)) continue
188
- if (assistantCallEvidence
189
- ? visibleAssistantCallSeqs.has(event.seq)
190
- : (visible.has(event.seq) || resultSources.has(event.seq))) calls.add(event.seq)
206
+ if (visibleAssistantCallSeqs.has(event.seq)) calls.add(event.seq)
191
207
  }
192
208
  return calls
193
209
  }
@@ -202,7 +218,7 @@ function timelineRun(call, result, eventIndex, journal) {
202
218
  }
203
219
  return Object.freeze({
204
220
  index: eventIndex,
205
- callSeq: Number.isSafeInteger(call.seq) ? call.seq : undefined,
221
+ callSeq: isCanonicalSequence(call.seq) ? call.seq : undefined,
206
222
  args,
207
223
  source: typeof args?.code === 'string' ? args.code : undefined,
208
224
  journal,
@@ -213,7 +229,7 @@ function timelineRun(call, result, eventIndex, journal) {
213
229
  function timelineDerivedRun(call, result, eventIndex, derived) {
214
230
  return Object.freeze({
215
231
  index: eventIndex,
216
- callSeq: Number.isSafeInteger(call.seq) ? call.seq : undefined,
232
+ callSeq: isCanonicalSequence(call.seq) ? call.seq : undefined,
217
233
  args: Object.freeze({ description: derived.description }),
218
234
  source: derived.code,
219
235
  journal: derived.journal,
@@ -235,7 +251,7 @@ function pruneWindowForEvent(event, eventIndex) {
235
251
  && shadowedRange.end === shadowedSeqs[shadowedSeqs.length - 1])
236
252
  const valid = shadowedSeqs.length > 0
237
253
  && seqs.size === shadowedSeqs.length
238
- && shadowedSeqs.every(seq => Number.isSafeInteger(seq) && seq >= 0)
254
+ && shadowedSeqs.every(isCanonicalSequence)
239
255
  && shadowedSeqs.every((seq, index) => index === 0 || seq > shadowedSeqs[index - 1])
240
256
  && rangeValid
241
257
  return { seqs: valid ? seqs : new Set(), lastEventIndex: eventIndex }
@@ -247,7 +263,7 @@ function continuesPruneWindow(window, event, eventIndex) {
247
263
 
248
264
  function identifiesPrunedResult(window, event, eventIndex) {
249
265
  const sourceSeq = event.sourceEventSeqs?.[0]
250
- return Number.isSafeInteger(sourceSeq)
266
+ return isCanonicalSequence(sourceSeq)
251
267
  && window?.seqs.has(sourceSeq)
252
268
  && eventIndex === window.lastEventIndex + 1
253
269
  && Array.isArray(event.sourceEventSeqs) && event.sourceEventSeqs.length === 1
@@ -260,16 +276,43 @@ function replacesPendingCall(candidate, event, pruneSeq) {
260
276
  const sourceSeq = event.sourceEventSeqs[0]
261
277
  const surfaceOp = event.surfaceOp
262
278
  const orderedIdentity = REPL_TOOL_NAMES.has(candidate.event.data.name)
263
- && Number.isSafeInteger(candidateSeq) && candidateSeq >= 0
279
+ && isCanonicalSequence(candidateSeq)
264
280
  && sourceSeq > candidateSeq
265
- && Number.isSafeInteger(pruneSeq) && pruneSeq >= 0
266
- && Number.isSafeInteger(event.seq) && event.seq - pruneSeq === 1
281
+ && isCanonicalSequence(pruneSeq)
282
+ && isCanonicalSequence(event.seq) && event.seq - pruneSeq === 1
267
283
  && sourceSeq < pruneSeq
268
- const exactReplacement = surfaceOp === undefined
269
- || (surfaceOp?.op === 'replace' && surfaceOp.start === sourceSeq && surfaceOp.end === sourceSeq)
284
+ const legacyReplacement = isRecord(surfaceOp)
285
+ && Reflect.ownKeys(surfaceOp).length === 3
286
+ && surfaceOp.op === 'replace'
287
+ && surfaceOp.start === sourceSeq
288
+ && surfaceOp.end === sourceSeq
289
+ const currentReplacement = isRecord(surfaceOp)
290
+ && Reflect.ownKeys(surfaceOp).length === 3
291
+ && surfaceOp.op === 'replace'
292
+ && surfaceOp.startSeq === sourceSeq
293
+ && surfaceOp.endSeq === sourceSeq
294
+ const exactReplacement = surfaceOp === undefined || legacyReplacement || currentReplacement
270
295
  return orderedIdentity && exactReplacement
271
296
  }
272
297
 
298
+ /** The exact single-event surface replacement the Host pruner appends. */
299
+ function exactResultReplacement(event, sourceSeq) {
300
+ const surfaceOp = event.surfaceOp
301
+ if (!isRecord(surfaceOp) || Reflect.ownKeys(surfaceOp).length !== 3 || surfaceOp.op !== 'replace') {
302
+ return false
303
+ }
304
+ return (surfaceOp.start === sourceSeq && surfaceOp.end === sourceSeq)
305
+ || (surfaceOp.startSeq === sourceSeq && surfaceOp.endSeq === sourceSeq)
306
+ }
307
+
308
+ /** A replacement representation may differ from its shadowed result only in message content. */
309
+ function sameResultExceptContent(previous, next) {
310
+ const withoutContent = data => isRecord(data) && isRecord(data.message)
311
+ ? { ...data, message: { ...data.message, content: undefined } }
312
+ : data
313
+ return isDeepStrictEqual(withoutContent(previous), withoutContent(next))
314
+ }
315
+
273
316
  /**
274
317
  * Fold persisted tool events once into the call/result and edit-target timeline
275
318
  * shared by prompt projection and cold journal recovery.
@@ -283,6 +326,7 @@ export function foldSessionTimeline(events) {
283
326
  boundaries: [],
284
327
  found: false,
285
328
  unavailableResultSeq: undefined,
329
+ unavailableResultReason: undefined,
286
330
  lastSuccessfulRunIndex: undefined,
287
331
  latestRun: undefined,
288
332
  editableRun: undefined,
@@ -295,7 +339,8 @@ export function foldSessionTimeline(events) {
295
339
  const seenCallIds = new Set()
296
340
  const claimedEditTargets = new Set()
297
341
  const editClaims = new Map()
298
- const journalResultSeqs = new Set()
342
+ const ordinaryResultSeqs = new Set()
343
+ const settledResultsByEventSeq = new Map()
299
344
  let pruneReplacementWindow
300
345
  let scope = 0
301
346
 
@@ -324,7 +369,7 @@ export function foldSessionTimeline(events) {
324
369
  continue
325
370
  }
326
371
  if (event?.type === RECOVERY_BOUNDARY_EVENT) {
327
- state.unavailableResultSeq ??= Number.isSafeInteger(event.seq) ? event.seq : eventIndex
372
+ state.unavailableResultSeq ??= isCanonicalSequence(event.seq) ? event.seq : eventIndex
328
373
  continue
329
374
  }
330
375
  if (event?.type === 'compaction/prune' && Array.isArray(event.data?.shadowedSeqs)) {
@@ -335,12 +380,19 @@ export function foldSessionTimeline(events) {
335
380
  const executable = REPL_TOOL_NAMES.has(event.data.name)
336
381
  let entry = { event, eventIndex, scope }
337
382
  if (executable) {
338
- if (Number.isSafeInteger(event.seq) && event.seq >= 0 && state.executableCalls.has(event.seq)) {
383
+ if (isCanonicalSequence(event.seq) && state.executableCalls.has(event.seq)) {
339
384
  // A sequence collision disproves both sources, but not the earlier
340
385
  // verified frontier. Keep its position for persistent contraction.
341
386
  const previous = state.executableCalls.get(event.seq)
342
387
  previous.ambiguous = true
343
388
  state.unavailableResultSeq = Math.min(state.unavailableResultSeq ?? event.seq, event.seq)
389
+ if (state.unavailableResultReason === undefined
390
+ || event.seq < state.unavailableResultReason.seq) {
391
+ state.unavailableResultReason = {
392
+ seq: event.seq,
393
+ reason: `duplicate executable tool call sequence ${event.seq}`,
394
+ }
395
+ }
344
396
  state.found = true
345
397
  state.latestRun = undefined
346
398
  state.editableRun = undefined
@@ -363,7 +415,7 @@ export function foldSessionTimeline(events) {
363
415
  }
364
416
  }
365
417
  state.calls.push(event)
366
- if (Number.isSafeInteger(event.seq) && event.seq >= 0) {
418
+ if (isCanonicalSequence(event.seq)) {
367
419
  state.executableCalls.set(event.seq, entry)
368
420
  }
369
421
  }
@@ -376,9 +428,14 @@ export function foldSessionTimeline(events) {
376
428
  }
377
429
  if (event?.type !== 'tool/result') continue
378
430
 
379
- const sourceSeq = event.sourceEventSeqs?.[0]
431
+ const hasSourceRelation = Object.hasOwn(event, 'sourceEventSeqs')
432
+ const sourceRelation = event.sourceEventSeqs
433
+ const sourceSeq = sourceRelation?.[0]
434
+ const canonicalSourceRelation = Array.isArray(sourceRelation)
435
+ && sourceRelation.length === 1
436
+ && isCanonicalSequence(sourceSeq)
380
437
  const callId = event.data?.message?.source?.callId
381
- let entry = Number.isSafeInteger(sourceSeq) ? state.executableCalls.get(sourceSeq) : undefined
438
+ let entry = canonicalSourceRelation ? state.executableCalls.get(sourceSeq) : undefined
382
439
  let prunedReplacement = false
383
440
  if (entry === undefined && identifiesPrunedResult(pruneReplacementWindow, event, eventIndex)) {
384
441
  const candidate = pendingByCallId.get(callId)
@@ -389,9 +446,48 @@ export function foldSessionTimeline(events) {
389
446
  pruneReplacementWindow.lastEventIndex = eventIndex
390
447
  }
391
448
  }
392
- if (entry === undefined && !Number.isSafeInteger(sourceSeq) && typeof callId === 'string') {
449
+ if (entry === undefined && !hasSourceRelation && typeof callId === 'string') {
393
450
  entry = pendingByCallId.get(callId)
394
451
  }
452
+ if (entry === undefined && canonicalSourceRelation) {
453
+ // The Host pruner may replace an already-settled result with a
454
+ // content-only clone that cites the shadowed result event. It is the same
455
+ // call's settlement representation, not a new unknown boundary.
456
+ const settled = settledResultsByEventSeq.get(sourceSeq)
457
+ if (settled !== undefined && typeof callId === 'string'
458
+ && settled.call?.data?.callId === callId
459
+ && exactResultReplacement(event, sourceSeq)
460
+ && sameResultExceptContent(settled.data, event.data)) {
461
+ entry = settled.entry
462
+ prunedReplacement = true
463
+ }
464
+ }
465
+ if (hasSourceRelation && !canonicalSourceRelation) {
466
+ const identityEntry = typeof callId === 'string' ? pendingByCallId.get(callId) : undefined
467
+ const callSeqs = new Set(Array.isArray(sourceRelation)
468
+ ? sourceRelation
469
+ .filter(isCanonicalSequence)
470
+ .map(candidateSeq => state.executableCalls.get(candidateSeq)?.event?.seq)
471
+ .filter(isCanonicalSequence)
472
+ : [])
473
+ if (isCanonicalSequence(identityEntry?.event?.seq)) {
474
+ callSeqs.add(identityEntry.event.seq)
475
+ }
476
+ if (callSeqs.size > 0) {
477
+ const callSeq = Math.min(...callSeqs)
478
+ state.unavailableResultSeq = Math.min(state.unavailableResultSeq ?? callSeq, callSeq)
479
+ state.results.set(callSeq, {
480
+ eventSeq: isCanonicalSequence(event.seq) ? event.seq : callSeq,
481
+ eventIndex,
482
+ error: `tool result has an invalid source relation for call seq ${callSeq}`,
483
+ })
484
+ state.found = true
485
+ }
486
+ if (typeof callId === 'string') pendingByCallId.delete(callId)
487
+ state.latestRun = undefined
488
+ state.editableRun = undefined
489
+ continue
490
+ }
395
491
  if (typeof callId === 'string') pendingByCallId.delete(callId)
396
492
  if (entry === null) {
397
493
  state.latestRun = undefined
@@ -407,38 +503,65 @@ export function foldSessionTimeline(events) {
407
503
  }
408
504
  const callSeq = call?.seq
409
505
  const meta = event.data?.meta
410
- let normalized
411
- if (isRecord(meta) && Object.hasOwn(meta, JOURNAL_KEY)) {
412
- if (Number.isSafeInteger(sourceSeq) && journalResultSeqs.has(sourceSeq)) {
413
- state.unavailableResultSeq ??= sourceSeq
414
- state.results.set(sourceSeq, {
415
- eventSeq: Number.isSafeInteger(event.seq) && event.seq >= 0 ? event.seq : sourceSeq,
506
+ if (!prunedReplacement && isCanonicalSequence(callSeq)) {
507
+ if (ordinaryResultSeqs.has(callSeq)) {
508
+ state.unavailableResultSeq ??= callSeq
509
+ state.results.set(callSeq, {
510
+ eventSeq: isCanonicalSequence(event.seq) ? event.seq : callSeq,
416
511
  eventIndex,
417
- error: `session log contains duplicate PTC journal results for call seq ${sourceSeq}`,
512
+ error: `session log contains duplicate ordinary tool results for call seq ${callSeq}`,
418
513
  })
419
514
  state.found = true
515
+ state.latestRun = undefined
516
+ state.editableRun = undefined
420
517
  continue
421
518
  }
422
- if (Number.isSafeInteger(sourceSeq)) journalResultSeqs.add(sourceSeq)
519
+ ordinaryResultSeqs.add(callSeq)
520
+ }
521
+ if (call !== undefined && typeof callId === 'string' && call.data.callId !== callId) {
522
+ state.latestRun = undefined
523
+ state.editableRun = undefined
524
+ if (isCanonicalSequence(callSeq)) {
525
+ state.unavailableResultSeq ??= callSeq
526
+ state.results.set(callSeq, {
527
+ eventSeq: isCanonicalSequence(event.seq) ? event.seq : callSeq,
528
+ eventIndex,
529
+ error: `tool result identities disagree for call seq ${callSeq}`,
530
+ })
531
+ state.found = true
532
+ }
533
+ continue
534
+ }
535
+ let normalized
536
+ if (isRecord(meta) && Object.hasOwn(meta, JOURNAL_KEY)) {
423
537
  if (entry === undefined) {
424
- if (Number.isSafeInteger(sourceSeq)) {
538
+ if (isCanonicalSequence(sourceSeq)) {
425
539
  state.unavailableResultSeq ??= sourceSeq
426
540
  }
427
541
  continue
428
542
  }
429
543
  const resultSeq = prunedReplacement
430
544
  ? callSeq
431
- : Number.isSafeInteger(sourceSeq) ? sourceSeq : callSeq
545
+ : isCanonicalSequence(sourceSeq) ? sourceSeq : callSeq
546
+ // A pruned clone re-publishes an existing settlement, so it keeps that
547
+ // settlement's position instead of the clone's later row sequence. A
548
+ // recorded recovery boundary names the call it follows, and ordering the
549
+ // clone by its own row would apply the boundary before that call exists
550
+ // in the verified frontier.
551
+ const settlementPosition = prunedReplacement
552
+ ? state.results.get(resultSeq)?.eventSeq ?? resultSeq
553
+ : undefined
432
554
  const raw = {
433
555
  meta,
434
- eventSeq: Number.isSafeInteger(event.seq) && event.seq >= 0 ? event.seq : resultSeq,
556
+ eventSeq: isCanonicalSequence(event.seq) ? event.seq : resultSeq,
557
+ ...(settlementPosition === undefined ? {} : { positionSeq: settlementPosition }),
435
558
  eventIndex,
436
559
  }
437
560
  if (Object.hasOwn(meta, RECOVERY_BOUNDARY_KEY)) {
438
561
  try {
439
562
  state.boundaries.push(...normalizeRecoveryBoundaries(
440
563
  meta[RECOVERY_BOUNDARY_KEY], raw.eventSeq,
441
- ))
564
+ ).map(boundary => ({ ...boundary, carrierCallSeq: resultSeq })))
442
565
  } catch {
443
566
  state.unavailableResultSeq ??= callSeq
444
567
  }
@@ -475,7 +598,15 @@ export function foldSessionTimeline(events) {
475
598
  } catch (error) {
476
599
  normalized = { ...raw, error: error.message }
477
600
  }
478
- if (Number.isSafeInteger(resultSeq)) state.results.set(resultSeq, normalized)
601
+ if (normalized === undefined) {
602
+ // PTC metadata on a settlement whose call is not a REPL tool is
603
+ // unproved evidence: it must contract the frontier, not escape the fold.
604
+ normalized = { ...raw, error: 'PTC journal metadata appeared on a non-REPL tool result' }
605
+ }
606
+ if (isCanonicalSequence(resultSeq)) state.results.set(resultSeq, normalized)
607
+ if (!prunedReplacement && isCanonicalSequence(event.seq)) {
608
+ settledResultsByEventSeq.set(event.seq, { call, entry, data: event.data })
609
+ }
479
610
  state.found = true
480
611
  }
481
612
 
@@ -518,6 +649,7 @@ export function recoverJournal(session, currentCallSeq, options = {}) {
518
649
  const calls = timeline.calls.filter(call => call.seq !== currentCallSeq)
519
650
  const { executableCalls, results, found } = timeline
520
651
  const visibleCalls = options?.visibleCallSeqs
652
+ const appliedBoundaries = options?.[RECOVERY_BOUNDARY_EVIDENCE]
521
653
  const invalidCallSeqs = new Set()
522
654
  let unavailableBoundary
523
655
  const registerUnavailable = ({ seq, reason, code }, recoveryState) => {
@@ -528,17 +660,22 @@ export function recoverJournal(session, currentCallSeq, options = {}) {
528
660
  invalidCallSeqs.add(seq)
529
661
  recoveryState.trusted = false
530
662
  if (!recoveryState.volatileSuffix.some(item => item.seq === seq)) {
531
- recoveryState.volatileSuffix.push({ seq, code, reason })
663
+ const item = { seq, code, reason }
664
+ const index = recoveryState.volatileSuffix.findIndex(existing => existing.seq > seq)
665
+ if (index < 0) recoveryState.volatileSuffix.push(item)
666
+ else recoveryState.volatileSuffix.splice(index, 0, item)
532
667
  }
533
668
  }
534
669
  if (timeline.unavailableResultSeq !== undefined) {
535
670
  registerUnavailable({
536
671
  seq: timeline.unavailableResultSeq,
537
- reason: 'unavailable or malformed dsh-ptc-plus journal result',
672
+ reason: timeline.unavailableResultReason?.seq === timeline.unavailableResultSeq
673
+ ? timeline.unavailableResultReason.reason
674
+ : 'unavailable or malformed dsh-ptc-plus journal result',
538
675
  })
539
676
  }
540
677
  for (const [resultSeq, result] of results.entries()) {
541
- if (result?.error !== undefined && Number.isSafeInteger(resultSeq)) {
678
+ if (result?.error !== undefined && isCanonicalSequence(resultSeq)) {
542
679
  registerUnavailable({ seq: resultSeq, reason: result.error })
543
680
  }
544
681
  }
@@ -578,27 +715,71 @@ export function recoverJournal(session, currentCallSeq, options = {}) {
578
715
  }
579
716
  const records = []
580
717
  const orderedRecords = []
718
+ const contractedCallRanges = []
581
719
  let state = foldRecords(records, invalidCallSeqs)
582
720
  let boundaryIndex = 0
721
+ const carrierCallSeqForBoundary = boundary => boundary.carrierCallSeq
722
+ const isContractedCallSeq = (callSeq) => contractedCallRanges.some(range => (
723
+ callSeq >= range.start && callSeq < range.end
724
+ ))
725
+ const rejectBoundary = (boundary, reason) => {
726
+ const carrierCallSeq = carrierCallSeqForBoundary(boundary)
727
+ const contractionSeq = carrierCallSeq === undefined
728
+ ? boundary.failedCallSeq
729
+ : Math.min(boundary.failedCallSeq, carrierCallSeq)
730
+ // A rejected boundary still establishes a conservative contraction point:
731
+ // the same window an accepted boundary would contract stays unproved, so
732
+ // settlements folded after this rejection cannot re-enter the frontier.
733
+ if (carrierCallSeq !== undefined) {
734
+ contractedCallRanges.push({ start: contractionSeq, end: carrierCallSeq })
735
+ }
736
+ for (const record of records) {
737
+ if (record.call.seq >= contractionSeq) invalidCallSeqs.add(record.call.seq)
738
+ }
739
+ state = foldRecords(records, invalidCallSeqs, {
740
+ surfaceContracted: state.surfaceContracted,
741
+ })
742
+ registerUnavailable({ seq: carrierCallSeq ?? boundary.failedCallSeq, reason }, state)
743
+ }
583
744
  const applyBoundariesBefore = (seq) => {
584
745
  while (boundaryIndex < boundaries.length && boundaries[boundaryIndex].eventSeq <= seq) {
585
746
  const boundary = boundaries[boundaryIndex++]
747
+ const carrierCallSeq = carrierCallSeqForBoundary(boundary)
586
748
  const failedCall = executableCalls.get(boundary.failedCallSeq)
587
- if (failedCall === undefined || failedCall.event.seq >= boundary.eventSeq) continue
749
+ if (failedCall === undefined || failedCall.event.seq >= boundary.eventSeq) {
750
+ rejectBoundary(boundary, 'recovery boundary does not identify an earlier executable call')
751
+ continue
752
+ }
588
753
  const failedIndex = state.nodes.findIndex(node => node.callSeq === boundary.failedCallSeq)
589
754
  const expectedFrontier = failedIndex < 0 ? state.head : state.nodes[failedIndex].parent
590
755
  const frontierIndex = boundary.frontierCallSeq === null
591
756
  ? undefined
592
757
  : state.nodes.findIndex(node => node.callSeq === boundary.frontierCallSeq)
593
- if (frontierIndex !== expectedFrontier) continue
758
+ if (frontierIndex !== expectedFrontier) {
759
+ rejectBoundary(boundary, 'recovery boundary frontier does not match the failed call parent')
760
+ continue
761
+ }
594
762
  const resolvesUnavailable = unavailableBoundary?.seq === boundary.failedCallSeq
595
- if (failedIndex < 0 && !resolvesUnavailable) continue
763
+ const resolvesConfirmedNoop = confirmedNoops.has(boundary.failedCallSeq)
764
+ if (failedIndex < 0 && !resolvesUnavailable && !resolvesConfirmedNoop) {
765
+ rejectBoundary(boundary, 'recovery boundary failed call is outside the verified frontier')
766
+ continue
767
+ }
768
+ if (carrierCallSeq !== undefined && carrierCallSeq <= boundary.failedCallSeq) {
769
+ rejectBoundary(boundary, 'recovery boundary carrier does not follow the failed call')
770
+ continue
771
+ }
772
+ if (Array.isArray(appliedBoundaries)) appliedBoundaries.push(boundary)
773
+ if (carrierCallSeq !== undefined) {
774
+ contractedCallRanges.push({ start: boundary.failedCallSeq, end: carrierCallSeq })
775
+ }
596
776
  for (let index = 0; index < state.nodes.length; index += 1) {
597
777
  if (dependsOn(state.nodes, index, failedIndex)) invalidCallSeqs.add(state.nodes[index].callSeq)
598
778
  }
599
- if (failedIndex < 0) {
600
- for (const record of records) {
601
- if (record.call.seq >= boundary.failedCallSeq) invalidCallSeqs.add(record.call.seq)
779
+ for (const record of records) {
780
+ if ((failedIndex < 0 && record.call.seq >= boundary.failedCallSeq)
781
+ || isContractedCallSeq(record.call.seq)) {
782
+ invalidCallSeqs.add(record.call.seq)
602
783
  }
603
784
  }
604
785
  state = foldRecords(records, invalidCallSeqs, {
@@ -628,6 +809,10 @@ export function recoverJournal(session, currentCallSeq, options = {}) {
628
809
  for (const record of orderedRecords) {
629
810
  applyBoundariesBefore(recordEventSeq(record))
630
811
  records.push(record)
812
+ if (isContractedCallSeq(record.call.seq)) {
813
+ invalidCallSeqs.add(record.call.seq)
814
+ continue
815
+ }
631
816
  if (record.hidden) {
632
817
  state.surfaceContracted = true
633
818
  registerUnavailable({
@@ -670,6 +855,21 @@ export function recoverJournal(session, currentCallSeq, options = {}) {
670
855
  }
671
856
  }
672
857
 
858
+ /** Prove that recovery consumes every persisted boundary at its declared frontier. */
859
+ export function validateRecoveryBoundaryApplication(events) {
860
+ const applied = []
861
+ const recovered = recoverJournal(
862
+ { events },
863
+ undefined,
864
+ { [RECOVERY_BOUNDARY_EVIDENCE]: applied },
865
+ )
866
+ const declared = foldSessionTimeline(events).boundaries.length
867
+ if (!recovered.available || applied.length !== declared) {
868
+ throw new Error('migrated PTC recovery boundary does not prove its declared frontier')
869
+ }
870
+ return recovered
871
+ }
872
+
673
873
  /** Return source nodes from the empty state to a selected durable head. */
674
874
  export function pathToHead(state) {
675
875
  const path = []
@@ -13,7 +13,8 @@ export const RECOVERY_BOUNDARY_KEY = 'dshPtcPlusRecoveryBoundaries'
13
13
  export const IMPORT_BOUNDARY_JOURNAL_VERSION = 7
14
14
  export const PER_NAME_USER_BINDINGS_JOURNAL_VERSION = 8
15
15
  export const LANGUAGE_SEMANTICS_JOURNAL_VERSION = 9
16
- export const JOURNAL_VERSION = LANGUAGE_SEMANTICS_JOURNAL_VERSION
16
+ export const MODULE_TRANSFORM_JOURNAL_VERSION = 10
17
+ export const JOURNAL_VERSION = MODULE_TRANSFORM_JOURNAL_VERSION
17
18
  export const LIVE_USER_BINDINGS_SHADOW_POLICY = 'per-name'
18
19
  export const LEGACY_USER_BINDINGS_SHADOW_POLICY = 'whole-entry'
19
20
  export const LIVE_USER_BINDINGS_REUSE_POLICY = 'implementation-v1'
@@ -30,7 +31,8 @@ export const STATUSES = new Set(['durable', 'volatile', 'discarded', 'noop'])
30
31
  export const BINDING_MODES = new Set(['loose', 'strict'])
31
32
  export const WHOLE_ENTRY_JOURNAL_FIELDS = new Set(['version', 'bindingPolicy', 'rewritePolicy', 'moduleSemantics', 'userBindingsFingerprint', 'userBindingsReusePolicy', 'status', 'calls', 'operations', 'confirms', 'diagnostics', 'completion', 'volatileReason'])
32
33
  export const PER_NAME_JOURNAL_FIELDS = new Set([...WHOLE_ENTRY_JOURNAL_FIELDS, 'userBindingsShadowPolicy', 'userBindingNames'])
33
- export const JOURNAL_FIELDS = new Set([...PER_NAME_JOURNAL_FIELDS, 'languageSemantics'])
34
+ export const LANGUAGE_SEMANTICS_JOURNAL_FIELDS = new Set([...PER_NAME_JOURNAL_FIELDS, 'languageSemantics'])
35
+ export const JOURNAL_FIELDS = new Set([...LANGUAGE_SEMANTICS_JOURNAL_FIELDS, 'moduleTransform'])
34
36
  export const FINGERPRINT_REUSE_JOURNAL_FIELDS = new Set([...WHOLE_ENTRY_JOURNAL_FIELDS].filter(field => field !== 'userBindingsReusePolicy'))
35
37
  export const RELATIONLESS_JOURNAL_FIELDS = new Set([...FINGERPRINT_REUSE_JOURNAL_FIELDS].filter(field => field !== 'userBindingsFingerprint'))
36
38
  export const PREDECESSOR_JOURNAL_FIELDS = new Set(['version', 'bindingMode', 'rewritePolicy', 'status', 'calls', 'operations', 'confirms', 'diagnostics', 'completion', 'volatileReason'])
@@ -56,32 +58,47 @@ export const ERROR_FIELDS = new Set(['kind', 'message'])
56
58
  export const EDIT_TARGET_FIELDS = new Set(['targetCallSeq'])
57
59
  export const DERIVED_RUN_FIELDS = new Set(['code', 'description'])
58
60
 
59
- 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, JOURNAL_VERSION])
61
+ 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, JOURNAL_VERSION])
60
62
  export const USER_BINDINGS_REUSE_POLICIES = new Set([LEGACY_USER_BINDINGS_REUSE_POLICY, LIVE_USER_BINDINGS_REUSE_POLICY])
61
63
  export const USER_BINDINGS_SHADOW_POLICIES = new Set([LEGACY_USER_BINDINGS_SHADOW_POLICY, LIVE_USER_BINDINGS_SHADOW_POLICY])
62
64
  const USER_BINDING_NAME_STATES = new Set(['provider', 'local', 'absent', 'unknown'])
65
+ const isArray = Array.isArray
66
+ const freeze = Object.freeze
67
+ const ownKeys = Reflect.ownKeys
68
+ const defineProperty = Object.defineProperty
69
+ const uncurry = Function.prototype.bind.bind(Function.prototype.call)
70
+ const setHas = uncurry(Set.prototype.has)
71
+ const setAdd = uncurry(Set.prototype.add)
72
+ const sort = uncurry(Array.prototype.sort)
73
+ const enumerable = uncurry(Object.prototype.propertyIsEnumerable)
63
74
 
64
75
  /** Closed, value-free evidence shared by worker settlement, persistence and Client. */
65
76
  export function normalizeUserBindingNames(value) {
66
- if (!Array.isArray(value)) throw new TypeError('invalid user binding name evidence')
77
+ if (!isArray(value)) throw new TypeError('invalid user binding name evidence')
67
78
  const names = new Set()
68
- return Object.freeze(value.map(fact => {
79
+ const normalized = []
80
+ for (let index = 0; index < value.length; index++) {
81
+ const fact = value[index]
69
82
  if (fact === null || typeof fact !== 'object'
70
- || !USER_BINDING_NAME_STATES.has(fact.state)
83
+ || !setHas(USER_BINDING_NAME_STATES, fact.state)
71
84
  || typeof fact.name !== 'string' || fact.name.length > 128
72
85
  || !/^[$_\p{ID_Start}][$\u200c\u200d\p{ID_Continue}]*$/u.test(fact.name)
73
- || names.has(fact.name)) throw new TypeError('invalid user binding name evidence')
86
+ || setHas(names, fact.name)) throw new TypeError('invalid user binding name evidence')
74
87
  const fields = fact.state === 'provider' ? ['name', 'state', 'entryId'] : ['name', 'state']
75
- if (Reflect.ownKeys(fact).length !== fields.length
76
- || !fields.every(key => Object.prototype.propertyIsEnumerable.call(fact, key))
88
+ let validFields = ownKeys(fact).length === fields.length
89
+ for (let field = 0; validFields && field < fields.length; field++) validFields = enumerable(fact, fields[field])
90
+ if (!validFields
77
91
  || (fact.state === 'provider' && (typeof fact.entryId !== 'string'
78
92
  || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(fact.entryId)))) {
79
93
  throw new TypeError('invalid user binding name evidence')
80
94
  }
81
- names.add(fact.name)
82
- return Object.freeze({ name: fact.name, state: fact.state,
83
- ...(fact.state === 'provider' ? { entryId: fact.entryId } : {}) })
84
- }).sort((left, right) => left.name < right.name ? -1 : left.name > right.name ? 1 : 0))
95
+ setAdd(names, fact.name)
96
+ defineProperty(normalized, normalized.length, { configurable: true, enumerable: true, writable: true,
97
+ value: freeze({ name: fact.name, state: fact.state,
98
+ ...(fact.state === 'provider' ? { entryId: fact.entryId } : {}) }) })
99
+ }
100
+ sort(normalized, (left, right) => left.name < right.name ? -1 : left.name > right.name ? 1 : 0)
101
+ return freeze(normalized)
85
102
  }
86
103
 
87
104
  export function normalizeJournalUserBindingNames(journal) {