dsh-ptc-plus 0.5.2 → 0.5.4

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 (72) hide show
  1. package/README.en.md +4 -2
  2. package/README.md +4 -2
  3. package/client.cjs +3392 -2811
  4. package/compiler-core.cjs +127 -127
  5. package/compiler-core.cjs.map +2 -2
  6. package/docs/README.md +1 -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/0028-attach-to-the-host-ptc-execution-seam.md +1 -1
  11. package/docs/adr/0030-serve-both-typert-codec-generations-from-one-codec.md +2 -2
  12. package/docs/adr/0044-coexist-with-the-official-persistent-repl.md +4 -4
  13. package/docs/adr/0046-gate-the-commit-with-the-semantic-evidence-tier.md +1 -1
  14. package/docs/adr/0048-supersede-a-rewritten-cell.md +48 -0
  15. package/docs/adr/0049-prove-a-settlement-against-its-published-record.md +53 -0
  16. package/docs/adr/0050-read-host-surfaces-through-their-published-contract.md +48 -0
  17. package/docs/adr/README.md +7 -1
  18. package/docs/architecture.md +1 -1
  19. package/docs/capability-planning-components.md +6 -6
  20. package/docs/capability-projection.md +1 -1
  21. package/docs/capability-surface.md +133 -0
  22. package/docs/client-ui.md +17 -15
  23. package/docs/client-visual-contract.md +7 -7
  24. package/docs/durability-design.md +9 -2
  25. package/docs/installation.md +17 -10
  26. package/docs/panel-concept.md +171 -0
  27. package/docs/releases/v0.5.2.md +26 -14
  28. package/docs/releases/v0.5.3.md +77 -0
  29. package/docs/releases/v0.5.4.md +75 -0
  30. package/docs/runtime-reference.md +21 -1
  31. package/docs/user-bindings.en.md +15 -0
  32. package/docs/user-bindings.md +15 -0
  33. package/docs/v0.5.3-goals.md +117 -0
  34. package/docs/verification.md +4 -4
  35. package/index.js +176 -41
  36. package/internal/binding-adoption.js +228 -0
  37. package/internal/capability-command-receipt.js +106 -8
  38. package/internal/capability-environment.js +1109 -312
  39. package/internal/capability-plan.js +18 -2
  40. package/internal/capability-policy.js +13 -0
  41. package/internal/capability-selection.js +330 -0
  42. package/internal/config-spec.js +14 -5
  43. package/internal/cordis-tools-owner.js +2 -1
  44. package/internal/direct-surface-owner.js +44 -17
  45. package/internal/edit-transport-owner.js +6 -5
  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/message-sources.js +21 -0
  50. package/internal/official-repl-priority.js +31 -10
  51. package/internal/program-bindings.js +25 -5
  52. package/internal/repl-memory-projection.js +21 -2
  53. package/internal/runtime-bridge-owner.js +118 -9
  54. package/internal/runtime-config.js +10 -0
  55. package/internal/runtime-messages.js +5 -0
  56. package/internal/session-journal-recovery.js +210 -32
  57. package/internal/session-journal-schema.js +3 -0
  58. package/internal/session-journal.js +46 -9
  59. package/internal/session-log-view.js +69 -7
  60. package/internal/session-runtime.js +106 -39
  61. package/internal/settings-compat.js +6 -0
  62. package/internal/user-bindings-owner.js +126 -6
  63. package/package.json +59 -37
  64. package/src/client-authoring-view.js +7 -8
  65. package/src/client-capabilities-view.js +874 -909
  66. package/src/client-capability-sort.js +22 -9
  67. package/src/client-copy.js +118 -272
  68. package/src/client-indicator-view.js +2 -1
  69. package/src/client-repl-view.js +35 -16
  70. package/src/client-settings-view.js +90 -33
  71. package/src/client-styles.js +67 -19
  72. package/src/client.js +49 -22
@@ -24,6 +24,8 @@ import {
24
24
  toolCapabilityMetadata,
25
25
  } from './program-bindings.js'
26
26
  import { deepFreeze } from './record-utils.js'
27
+ import { adaptRunCodeSchema } from './direct-surface-owner.js'
28
+ import { hostTransportDefinition, isBatchSubmission } from './host-tool-sdk.js'
27
29
  import { REPL_MEMORY_META_KEY, withReplMemorySnapshot } from './repl-memory-projection.js'
28
30
  import { withUserBindingDraftCapability } from './user-binding-draft-projection.js'
29
31
  import { POST_EDIT_KEY } from './session-journal-schema.js'
@@ -160,6 +162,7 @@ export function createRuntimeBridgeOwner({
160
162
  userBindingDraftForAgent,
161
163
  bindingSubmissionForAgent,
162
164
  capabilityEnvironment,
165
+ readPromptText = () => undefined,
163
166
  }) {
164
167
  const scope = new AsyncLocalStorage()
165
168
  const childScope = new AsyncLocalStorage()
@@ -185,6 +188,8 @@ export function createRuntimeBridgeOwner({
185
188
  })
186
189
  let currentConfig = sessions.config
187
190
  const patchedDefinitions = new Map()
191
+ /** The host descriptors this attachment replaced on each definition's slots. */
192
+ const slotPatches = new Map()
188
193
  const pending = new WeakMap()
189
194
  const nativeRunCodeHandoffs = new Set()
190
195
  let active = true
@@ -342,7 +347,11 @@ export function createRuntimeBridgeOwner({
342
347
  )))
343
348
  const metadata = Object.freeze([
344
349
  ...(capabilityEnvironment?.toolMetadata(executionToken?.agent, toolCapabilityMetadata(schemas, annotations), schemas) ?? toolCapabilityMetadata(schemas, annotations)),
345
- ...(capabilityEnvironment?.metadata(executionToken?.agent) ?? []),
350
+ // The same set, as the third argument's own evidence: a derived row (`skills.*`,
351
+ // `mcp.*`) states the invocation path it still has, and the operations it can name
352
+ // are the members this program was handed — not the ones the composition froze when
353
+ // the session was last composed, which a step that delivers less does not rewrite.
354
+ ...(capabilityEnvironment?.metadata(executionToken?.agent, schemas) ?? []),
346
355
  ...PROGRAM_CAPABILITY_METADATA.map(entry => entry.namespace === 'code'
347
356
  && cellConfig.userBindingsEnabled === true
348
357
  ? { ...entry, members: [...entry.members, BINDING_SUBMISSION] }
@@ -393,14 +402,94 @@ export function createRuntimeBridgeOwner({
393
402
  return { request: { ...request, bindings: projected }, release }
394
403
  }
395
404
 
405
+ /**
406
+ * Write one editable run_code slot onto the host's definition.
407
+ *
408
+ * A host may publish the slot as writable data or as a getter-only accessor over
409
+ * its own text, and strict-mode assignment throws on the latter. An owned property
410
+ * definition reaches both shapes, so the plugin never fails silently to deliver the
411
+ * text it was asked to deliver.
412
+ * @param {object} definition - the host's live run_code definition.
413
+ * @param {string} key - the definition field being replaced.
414
+ * @param {unknown} value - the text the plugin owns.
415
+ * @returns {boolean} whether the field now holds that value.
416
+ */
417
+ function writeSlot(definition, key, value) {
418
+ const own = Object.getOwnPropertyDescriptor(definition, key)
419
+ try {
420
+ Object.defineProperty(definition, key, {
421
+ configurable: own?.configurable ?? true,
422
+ enumerable: own?.enumerable ?? true,
423
+ writable: true,
424
+ value,
425
+ })
426
+ return true
427
+ } catch {
428
+ return false
429
+ }
430
+ }
431
+
432
+ /** Put one slot back to the descriptor the host published before this plugin. */
433
+ function restoreSlot(definition, key, descriptor) {
434
+ try {
435
+ if (descriptor === undefined) delete definition[key]
436
+ else Object.defineProperty(definition, key, descriptor)
437
+ } catch {
438
+ // A host that made the slot non-configurable after the plugin replaced it keeps
439
+ // the last written text; the restore cannot be forced, and the caller that
440
+ // rewrote it owns the consequence.
441
+ }
442
+ }
443
+
444
+ /**
445
+ * The editable run_code slots have to reach whichever definition the host lets this
446
+ * plugin own. Two writes, one decision: if the host's parameter texts cannot be
447
+ * replaced, the definition keeps all of its own text rather than half of ours. The
448
+ * descriptors the write replaced are recorded once, so re-applying the current
449
+ * prompt document on every request can never mistake the plugin's own text for the
450
+ * host's, and disposal can restore what the host still publishes.
451
+ */
452
+ function applyEditableSlots(definition, promptText, slotPatches) {
453
+ if (promptText === undefined) return
454
+ let adapted
455
+ try {
456
+ adapted = adaptRunCodeSchema(definition, promptText)
457
+ } catch {
458
+ return
459
+ }
460
+ const recorded = slotPatches.get(definition)
461
+ if (recorded !== undefined) {
462
+ // Re-applied on every request: the descriptors recorded by the first write are
463
+ // the ones disposal restores, so a later application can never mistake the
464
+ // plugin's own text for the host's. A slot the host has since made unreplaceable
465
+ // keeps the text this attachment already delivered.
466
+ writeSlot(definition, 'parameters', adapted.parameters)
467
+ writeSlot(definition, 'description', adapted.description)
468
+ return
469
+ }
470
+ const ownParameters = Object.getOwnPropertyDescriptor(definition, 'parameters')
471
+ const ownDescription = Object.getOwnPropertyDescriptor(definition, 'description')
472
+ if (!writeSlot(definition, 'parameters', adapted.parameters)) return
473
+ if (!writeSlot(definition, 'description', adapted.description)) {
474
+ restoreSlot(definition, 'parameters', ownParameters)
475
+ return
476
+ }
477
+ slotPatches.set(definition, new Map([['parameters', ownParameters], ['description', ownDescription]]))
478
+ }
479
+
396
480
  const patchRunCodeDefinition = (agent) => {
397
- const definition = ctx.tools.get(RUN_CODE, agent)
481
+ const definition = hostTransportDefinition(ctx.tools, agent, RUN_CODE)
398
482
  if (definition === undefined) {
399
483
  throw new Error('ptc-plus: run_code definition is unavailable for the owning session')
400
484
  }
401
485
  if (definition.output === undefined) {
402
486
  throw new Error('ptc-plus: run_code definition has no output projection')
403
487
  }
488
+ // A host that declares its own model-facing tools never hands this plugin a
489
+ // tools array to adapt, so the editable prompt slots reach the model only on
490
+ // the definition the executor projects. Re-applied on every request, because
491
+ // the prompt document can change while the definition stays the same object.
492
+ applyEditableSlots(definition, readPromptText(), slotPatches)
404
493
  if (patchedDefinitions.has(definition)) return
405
494
  const output = definition.output
406
495
  const ownPresentationMeta = Object.getOwnPropertyDescriptor(output, 'presentationMeta')
@@ -463,10 +552,10 @@ export function createRuntimeBridgeOwner({
463
552
  // nested success. Capture its canonical value before policy; the private
464
553
  // relation is admitted only if policy retains that same frozen value.
465
554
  if (current?.deferredSettlement?.current === current && derived !== undefined
466
- && current.exec === exec && exec.token !== undefined && exec.parent !== undefined
555
+ && current.exec === exec && exec.token !== undefined
467
556
  && current.id === sessionId(exec.agent) && derived.agent === exec.agent
468
- && derived.callId === exec.callId && derived.rootCallId === exec.rootCallId
469
- && derived.parent === exec.parent && deepEqualJson(derived.arguments, exec.arguments)
557
+ && isDerivedExecution(derived, exec)
558
+ && deepEqualJson(derived.arguments, exec.arguments)
470
559
  && current.presentationMetaCalls === undefined && result.isError === false
471
560
  && result.value !== null && typeof result.value === 'object'
472
561
  && Object.isFrozen(result.value) && result.meta?.[JOURNAL_KEY] === undefined
@@ -610,13 +699,30 @@ export function createRuntimeBridgeOwner({
610
699
 
611
700
  const releaseSeam = seam.installExecute(patchedRun)
612
701
 
702
+ /**
703
+ * Whether this execution is the derived execution the edit transport submitted.
704
+ *
705
+ * A registry host runs the very object the plugin built, so the call id, the root
706
+ * call and the parent token it carries all prove the relation. The batch contract
707
+ * assigns its own identity instead: the parent token is the host's own and the
708
+ * root call is whichever call that host treats as direct. What survives there is
709
+ * the call id this plugin chose, which the host matched the batch result back to,
710
+ * together with the dispatch record; the derived source is compared beside it.
711
+ */
712
+ const isDerivedExecution = (derived, exec) => {
713
+ if (derived === null || typeof derived !== 'object' || exec === null || typeof exec !== 'object') return false
714
+ if (derived.callId !== exec.callId) return false
715
+ if (isBatchSubmission(derived)) return true
716
+ return exec.parent !== undefined
717
+ && derived.rootCallId === exec.rootCallId && derived.parent === exec.parent
718
+ }
719
+
613
720
  const ownedDerivedScope = (exec, executionArguments = exec.arguments) => {
614
721
  const inherited = scope.getStore()
615
722
  const derived = inherited?.derivedExecution
616
723
  if (inherited?.deferredSettlement === undefined
617
724
  || inherited.id !== sessionId(exec.agent) || derived?.agent !== exec.agent
618
- || derived.callId !== exec.callId || derived.rootCallId !== exec.rootCallId
619
- || derived.parent !== exec.parent || exec.name !== RUN_CODE
725
+ || !isDerivedExecution(derived, exec) || exec.name !== RUN_CODE
620
726
  || !deepEqualJson(derived.arguments, executionArguments)) return undefined
621
727
  return inherited
622
728
  }
@@ -800,8 +906,7 @@ export function createRuntimeBridgeOwner({
800
906
  const derived = current.derivedExecution
801
907
  const privateMetadata = projection !== undefined && projection.exec === exec
802
908
  && deferred.current === current && current.exec === exec && current.id === id
803
- && derived?.agent === exec.agent && derived.callId === exec.callId
804
- && derived.rootCallId === exec.rootCallId && derived.parent === exec.parent
909
+ && derived?.agent === exec.agent && isDerivedExecution(derived, exec)
805
910
  && deepEqualJson(derived.arguments, exec.arguments)
806
911
  && projection.token === exec.token && projection.value === result?.value
807
912
  && result.isError === false && deepEqualJson(projection.meta, result.meta)
@@ -868,6 +973,10 @@ export function createRuntimeBridgeOwner({
868
973
  }
869
974
  }
870
975
  patchedDefinitions.clear()
976
+ for (const [definition, recorded] of slotPatches) {
977
+ for (const [key, descriptor] of recorded) restoreSlot(definition, key, descriptor)
978
+ }
979
+ slotPatches.clear()
871
980
  try {
872
981
  await sessions.dispose()
873
982
  } catch (error) {
@@ -130,6 +130,16 @@ export function resolveConfig(config = {}) {
130
130
  }
131
131
  }
132
132
  resolved.capabilityPolicy = JSON.stringify(capabilityPolicyForConfig(provided.has('capabilityPolicy') ? resolved : { ...resolved, capabilityPolicy: undefined }))
133
+ // Whether the record holds this policy is a fact about the input document, not about this
134
+ // resolution's output: a configuration this function already resolved carries the answer,
135
+ // and resolving that answer's own product again must not re-infer it from the policy this
136
+ // function just wrote. Callers that reconcile by resolving a source that may itself be
137
+ // resolved (the plugin resolves its raw document and then re-resolves the result) would
138
+ // otherwise report every policy as saved, which is what makes a synthesized bottom layer
139
+ // decide the saved column. The marker only means something beside a policy the document
140
+ // itself holds, so a document that writes the marker without one states no provenance.
141
+ resolved.capabilityPolicySaved = provided.has('capabilityPolicy')
142
+ && (typeof config.capabilityPolicySaved === 'boolean' ? config.capabilityPolicySaved : true)
133
143
  if (!provided.has('capabilityPolicy') && resolved.capabilityManagementEnabled) {
134
144
  if (!provided.has('capabilityRecoveryEnabled')) resolved.capabilityRecoveryEnabled = true
135
145
  if (!provided.has('modelPreviewEnabled')) resolved.modelPreviewEnabled = true
@@ -5,6 +5,7 @@ import {
5
5
  } from './prompt-text.js'
6
6
 
7
7
  import { isPtcMessageSource, PTC_MESSAGE_SOURCE_KIND } from './message-sources.js'
8
+ import { BINDING_ADOPTION_FORM, readBindingAdoption } from './binding-adoption.js'
8
9
 
9
10
  export const PTC_DELIVERY_CONTEXT = 'tools:ptc-plus-message-delivery'
10
11
  export const PTC_BINDING_CATALOG = 'tools:ptc-plus-user-binding-defaults'
@@ -116,6 +117,10 @@ export function readRuntimeMessage(message) {
116
117
  const text = message.content[0].text
117
118
  const availability = readRuntimeAvailability(message)
118
119
  if (availability !== undefined) return { form: 'runtime-availability', state: availability }
120
+ // A session's binding adoption is recorded as one of its own messages, so the
121
+ // log is its only owner and replay reconstructs it without process memory.
122
+ const adoption = readBindingAdoption(message)
123
+ if (adoption !== undefined) return { form: BINDING_ADOPTION_FORM, declined: [...adoption.declined] }
119
124
  if (source.form === 'snapshot') {
120
125
  const sections = stateSections(source.sections)
121
126
  if (sections !== undefined && [STATE_PREFIX, PREVIOUS_STATE_PREFIX, LEGACY_STATE_PREFIX]
@@ -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])