@quolu/lattice 0.52.2 → 0.52.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 (134) hide show
  1. package/package.json +1 -1
  2. package/src/artifact-contracts-v2.mjs +325 -325
  3. package/src/artifact-contracts.mjs +895 -895
  4. package/src/boundary-compiler.mjs +712 -712
  5. package/src/boundary-observation-compiler-v2.mjs +344 -344
  6. package/src/bounded-seam.mjs +230 -230
  7. package/src/bridge-address.mjs +107 -107
  8. package/src/bridge-cli.mjs +297 -297
  9. package/src/bridge-config.mjs +346 -346
  10. package/src/bridge-daemon.mjs +378 -378
  11. package/src/bridge-hub-protocol.mjs +198 -0
  12. package/src/bridge-launch-agent.mjs +323 -323
  13. package/src/bridge-registrar.mjs +102 -102
  14. package/src/bridge-server.mjs +376 -376
  15. package/src/cli-help.mjs +308 -308
  16. package/src/cli-stdio.mjs +40 -40
  17. package/src/control-compiler.mjs +532 -532
  18. package/src/dag-chain.mjs +261 -261
  19. package/src/factory-diagnostics.mjs +188 -188
  20. package/src/git-process.mjs +72 -0
  21. package/src/hash-chain.mjs +76 -76
  22. package/src/hooks-cli.mjs +1057 -1057
  23. package/src/isolation-runner.mjs +409 -409
  24. package/src/node-version-guard.mjs +44 -44
  25. package/src/project-cli.mjs +699 -697
  26. package/src/project-identity.mjs +130 -130
  27. package/src/rc1-black-box-oracle.mjs +906 -906
  28. package/src/rc1-comparison.mjs +154 -154
  29. package/src/rc1-evidence-bundle.mjs +456 -456
  30. package/src/rc1-v4-campaign.mjs +708 -708
  31. package/src/rc1-v4-transform.mjs +467 -467
  32. package/src/rc1-v5-artifact-set.mjs +855 -855
  33. package/src/rc1-v5-behavior-evidence.mjs +594 -594
  34. package/src/rc1-v5-campaign.mjs +797 -797
  35. package/src/rc1-v5-transform.mjs +421 -421
  36. package/src/rc1-v6-artifact-set.mjs +807 -807
  37. package/src/rc1-v6-behavior-evidence.mjs +273 -273
  38. package/src/rc1-v6-campaign.mjs +623 -623
  39. package/src/rc1-v6-causal-binding.mjs +473 -473
  40. package/src/rc1-v6-measurement.mjs +313 -313
  41. package/src/rc2-artifact-set.mjs +1584 -1584
  42. package/src/rc2-campaign.mjs +1506 -1506
  43. package/src/rc2-delivery-policy-front-end.mjs +1079 -1079
  44. package/src/rc2-delivery-policy-oracle.mjs +134 -134
  45. package/src/rc2-delivery-policy-transform.mjs +1127 -1127
  46. package/src/rc2-rc1-transfer-front-end.mjs +511 -511
  47. package/src/rc3-actual-dogfood.mjs +652 -652
  48. package/src/rc3-dogfood-scaffold.mjs +315 -315
  49. package/src/rc3-scripted-campaign.mjs +1383 -1383
  50. package/src/rc4-stage1-dogfood.mjs +673 -673
  51. package/src/runtime-adapter-registry.mjs +524 -524
  52. package/src/runtime-cli.mjs +4588 -4588
  53. package/src/runtime-contracts.mjs +824 -824
  54. package/src/runtime-control-store.mjs +604 -604
  55. package/src/runtime-controller-protocol.mjs +587 -587
  56. package/src/runtime-decision-verifier.mjs +701 -701
  57. package/src/runtime-diff-observer.mjs +361 -361
  58. package/src/runtime-direct-os-observer.mjs +301 -301
  59. package/src/runtime-driver-state.mjs +166 -166
  60. package/src/runtime-engine.mjs +779 -779
  61. package/src/runtime-errors.mjs +356 -356
  62. package/src/runtime-event-store.mjs +189 -189
  63. package/src/runtime-front-end.mjs +925 -925
  64. package/src/runtime-gate-store.mjs +481 -481
  65. package/src/runtime-hold-recompile.mjs +916 -916
  66. package/src/runtime-io-sentinel.mjs +391 -391
  67. package/src/runtime-lifecycle-lock.mjs +294 -294
  68. package/src/runtime-managed-supervisor.mjs +1490 -1490
  69. package/src/runtime-multi-epoch-store.mjs +838 -838
  70. package/src/runtime-projection.mjs +269 -269
  71. package/src/runtime-pull-intake.mjs +1192 -1192
  72. package/src/runtime-scripted-adapter-controller.mjs +1160 -1160
  73. package/src/runtime-scripted-executor.mjs +163 -163
  74. package/src/runtime-scripted-worktree.mjs +104 -104
  75. package/src/runtime-seam-resolve.mjs +428 -428
  76. package/src/runtime-seam-treatment.mjs +173 -173
  77. package/src/runtime-socket-owner.mjs +125 -125
  78. package/src/runtime-work-order-contracts.mjs +91 -91
  79. package/src/runtime-work-order-controller.mjs +1171 -1171
  80. package/src/runtime-worktree-executor.mjs +199 -199
  81. package/src/schedulability-compiler-v2.mjs +303 -303
  82. package/src/schedulability-verifier-v2.mjs +317 -317
  83. package/src/seam-apply.mjs +549 -549
  84. package/src/seam-commit-shared.mjs +22 -22
  85. package/src/seam-commit-transform.mjs +81 -81
  86. package/src/seam-commit.mjs +18 -18
  87. package/src/seam-cost.mjs +322 -322
  88. package/src/seam-derivation.mjs +188 -188
  89. package/src/seam-gate.mjs +146 -146
  90. package/src/seam-proposal-contracts.mjs +446 -446
  91. package/src/seam-proposal-queries.mjs +521 -521
  92. package/src/seam-proposal.mjs +2011 -2011
  93. package/src/seam-ref.mjs +33 -33
  94. package/src/seam-rewrite.mjs +286 -286
  95. package/src/seam-transform.mjs +554 -554
  96. package/src/seam-verification.mjs +260 -260
  97. package/src/sensor-adapter.mjs +432 -432
  98. package/src/sensor-cli.mjs +139 -139
  99. package/src/sensor-diff.mjs +661 -661
  100. package/src/sensor-node-runtime.mjs +53 -53
  101. package/src/sensor-runtime.mjs +52 -52
  102. package/src/timestamp-contract.mjs +8 -8
  103. package/src/todo-audit-pending.mjs +91 -91
  104. package/src/todo-chain.mjs +178 -178
  105. package/src/todo-cli.mjs +3143 -3141
  106. package/src/todo-contracts.mjs +728 -728
  107. package/src/todo-dashboard-registry.mjs +573 -573
  108. package/src/todo-dispatch-shape.mjs +190 -190
  109. package/src/todo-gantt-html-independence.mjs +239 -239
  110. package/src/todo-gantt-html-shared.mjs +226 -226
  111. package/src/todo-gantt-html-style.mjs +131 -131
  112. package/src/todo-gantt-html.mjs +248 -248
  113. package/src/todo-gantt-layout.mjs +974 -974
  114. package/src/todo-gantt-live.mjs +361 -361
  115. package/src/todo-gantt-nested.mjs +263 -263
  116. package/src/todo-gantt-presentation.mjs +217 -217
  117. package/src/todo-gantt-scope.mjs +123 -123
  118. package/src/todo-gantt-svg.mjs +353 -353
  119. package/src/todo-independence-contracts.mjs +595 -595
  120. package/src/todo-independence-guidance.mjs +322 -322
  121. package/src/todo-independence.mjs +640 -640
  122. package/src/todo-markdown-renderer.mjs +260 -260
  123. package/src/todo-migration.mjs +448 -448
  124. package/src/todo-narrative-anchor.mjs +130 -130
  125. package/src/todo-note-store.mjs +629 -629
  126. package/src/todo-parallel-candidates.mjs +114 -114
  127. package/src/todo-revision.mjs +995 -995
  128. package/src/todo-split.mjs +472 -472
  129. package/src/todo-status.mjs +690 -690
  130. package/src/todo-store-git-transaction.mjs +418 -418
  131. package/src/todo-store.mjs +4360 -4322
  132. package/src/treatment-compiler.mjs +728 -728
  133. package/src/treatment-runner.mjs +656 -656
  134. package/src/witness-scaffold.mjs +180 -180
@@ -1,701 +1,701 @@
1
- import path from 'node:path';
2
-
3
- import { digestArtifact } from './artifact-contracts.mjs';
4
- import { validateCarryOverWitness } from './runtime-contracts.mjs';
5
- import { projectRuntimeState } from './runtime-projection.mjs';
6
-
7
- // RC3 runtime decision verifier(ADR 0044 Decision 4〜7)。
8
- // dispatch/hold/continue/invalidate裁定を、保存planとevent prefixだけから
9
- // producer非依存に再計算する。schema envelope/digestの完全検証は
10
- // runtime-contracts.mjsのvalidatorが担い、本moduleはruntime意味論を再計算する。
11
- // 判定はすべてevent sequenceで行い、生成時刻・到着時刻を根拠にしない(Decision 7.4)。
12
-
13
- function invalidVerification(reason) {
14
- throw new TypeError(`runtime decision verifier契約違反: ${reason}`);
15
- }
16
-
17
- function requirePlan(plan) {
18
- if (plan === null || typeof plan !== 'object' || Array.isArray(plan)
19
- || !Array.isArray(plan.nodes)
20
- || plan.nodes.length === 0
21
- || !Array.isArray(plan.precedence)
22
- || !Array.isArray(plan.conflicts)
23
- || plan.capacity === null
24
- || typeof plan.capacity !== 'object'
25
- || !Number.isSafeInteger(plan.capacity.executors)
26
- || plan.capacity.executors < 1
27
- || !Number.isSafeInteger(plan.plan_epoch)) {
28
- invalidVerification('runtime planの意味論的shapeが不正');
29
- }
30
- const nodeIds = plan.nodes.map((node) => node.todo_id);
31
- if (nodeIds.some((todoId) => typeof todoId !== 'string')
32
- || new Set(nodeIds).size !== nodeIds.length) {
33
- invalidVerification('plan nodeが不正');
34
- }
35
- return nodeIds;
36
- }
37
-
38
- function sorted(values) {
39
- return [...values].sort();
40
- }
41
-
42
- /**
43
- * ready frontierを再計算する(ADR 0044 Decision 4)。
44
- * minimum waveを同期barrierとして扱わず、hard predecessor充足、running集合との
45
- * conflict不在、実capacityだけをdispatch可否の根拠にする。intake freeze中は
46
- * fail closedでdispatch 0件とする。dispatchableは辞書順で空きcapacity分に切る。
47
- */
48
- export function computeReadyFrontier(options = {}) {
49
- const { plan, events } = options;
50
- const nodeIds = requirePlan(plan);
51
- const state = projectRuntimeState({ events });
52
-
53
- if (state.freeze !== null || state.closed) {
54
- return { dispatchable: [] };
55
- }
56
-
57
- const acceptedSet = new Set(state.accepted);
58
- const runningSet = new Set(state.running);
59
- // dispatch済み・terminal済み・held済みの除外は現plan epochへscopeする。
60
- // 旧epochのdispatch/hold記録は、recompile時のcontext失効とredispatch契約
61
- //(RC3-G)によって新epochでは再配置可能になる。accepted/runningは
62
- // epoch非依存(物理状態)のまま除外する。
63
- const heldSet = new Set();
64
- const terminalSet = new Set();
65
- const dispatchedSet = new Set();
66
- for (const event of events) {
67
- if (event.plan_epoch !== plan.plan_epoch) continue;
68
- if (event.kind === 'hold_decided') {
69
- for (const todoId of event.payload?.hold_set ?? []) heldSet.add(todoId);
70
- }
71
- if (event.subject?.kind !== 'todo') continue;
72
- if (event.kind === 'executor_dispatched') dispatchedSet.add(event.subject.ref);
73
- if (event.kind === 'executor_terminal') terminalSet.add(event.subject.ref);
74
- }
75
- const slots = plan.capacity.executors - runningSet.size;
76
- if (slots < 1) {
77
- return { dispatchable: [] };
78
- }
79
-
80
- const eligible = nodeIds.filter((todoId) => {
81
- if (acceptedSet.has(todoId) || runningSet.has(todoId) || heldSet.has(todoId)
82
- || terminalSet.has(todoId) || dispatchedSet.has(todoId)) return false;
83
- return plan.precedence.every((edge) => (
84
- edge.to_todo_id !== todoId || acceptedSet.has(edge.from_todo_id)
85
- ));
86
- });
87
-
88
- // running集合だけでなく、同じfrontierで既に選んだnodeとのconflictも検査する。
89
- // conflict pairを同一dispatch decisionで同時実行しないための貪欲選択(辞書順)。
90
- const dispatchable = [];
91
- for (const todoId of sorted(eligible)) {
92
- if (dispatchable.length >= slots) break;
93
- const conflictFree = plan.conflicts.every((conflict) => {
94
- if (!conflict.todo_ids.includes(todoId)) return true;
95
- return conflict.todo_ids.every((member) => (
96
- member === todoId || (!runningSet.has(member) && !dispatchable.includes(member))
97
- ));
98
- });
99
- if (conflictFree) dispatchable.push(todoId);
100
- }
101
-
102
- return { dispatchable };
103
- }
104
-
105
- function declaredWriteCovers(declaredWrites, observedPath) {
106
- return declaredWrites.some((declared) => (
107
- declared === observedPath
108
- || (declared.endsWith('/') && observedPath.startsWith(declared))
109
- ));
110
- }
111
-
112
- function witnessSet(manifest, kinds) {
113
- const resources = new Set(manifest?.resources ?? []);
114
- for (const effect of manifest?.state_effects ?? []) {
115
- if (kinds.includes(effect.kind) && typeof effect.resource_id === 'string') {
116
- resources.add(effect.resource_id);
117
- }
118
- }
119
- return resources;
120
- }
121
-
122
- const LINE_ID = /^[0-9A-Za-z](?:[0-9A-Za-z._-]{0,127})$/;
123
- const LINE_PATH_CONTROL = /[\u0000-\u001f\u007f]/;
124
-
125
- function runtimeLinePath(value) {
126
- return typeof value === 'string' && value.length > 0
127
- && Buffer.byteLength(value, 'utf8') <= 1_024
128
- && !LINE_PATH_CONTROL.test(value) && !value.includes('\\')
129
- && !path.posix.isAbsolute(value) && value === path.posix.normalize(value)
130
- && !value.split('/').includes('..');
131
- }
132
-
133
- function runtimeLineGroups(manifests, todoIds) {
134
- const groups = new Map();
135
- for (const todoId of sorted(new Set(todoIds))) {
136
- const manifest = manifests[todoId];
137
- if (manifest === null || typeof manifest !== 'object' || Array.isArray(manifest)
138
- || !Array.isArray(manifest.lines ?? [])) {
139
- invalidVerification(`boundary manifestのlinesが不正: ${todoId}`);
140
- }
141
- const seen = new Set();
142
- for (const line of manifest.lines ?? []) {
143
- if (line === null || typeof line !== 'object' || Array.isArray(line)
144
- || Object.keys(line).sort().join(',') !== 'anchors,line_id,role'
145
- || !LINE_ID.test(line.line_id ?? '') || !['reads', 'writes'].includes(line.role)
146
- || !Array.isArray(line.anchors) || line.anchors.length === 0
147
- || seen.has(line.line_id)) {
148
- invalidVerification(`line宣言が不正: ${todoId}`);
149
- }
150
- seen.add(line.line_id);
151
- const group = groups.get(line.line_id) ?? {
152
- readers: new Set(), writers: new Set(), anchorPaths: new Set(),
153
- };
154
- group[line.role === 'reads' ? 'readers' : 'writers'].add(todoId);
155
- for (const anchor of line.anchors) {
156
- const keys = anchor !== null && typeof anchor === 'object' && !Array.isArray(anchor)
157
- ? Object.keys(anchor).sort().join(',') : '';
158
- const valid = anchor?.kind === 'path'
159
- ? keys === 'kind,path' && runtimeLinePath(anchor.path)
160
- : anchor?.kind === 'symbol' && keys === 'kind,name,path'
161
- && typeof anchor.name === 'string' && anchor.name.length > 0
162
- && runtimeLinePath(anchor.path);
163
- if (!valid) invalidVerification(`line anchorが不正: ${todoId}/${line.line_id}`);
164
- // producerと共有せず、観測pathだけから独立に近似する。
165
- group.anchorPaths.add(anchor.path);
166
- }
167
- groups.set(line.line_id, group);
168
- }
169
- }
170
- return groups;
171
- }
172
-
173
- /**
174
- * 観測diffをdeclared path/resource witnessへcross-bindし、closed conflict分類の
175
- * findingを返す(ADR 0044 Decision 5)。宣言外writeと運転中overlapは別findingで
176
- * 保存し、silent mergeしない。path非交差でも共有witnessがあれば安全と推測せず
177
- * unknown findingにする。
178
- */
179
- export function classifyObservedDiff(options = {}) {
180
- const { plan, manifests, observations, relevantTodoIds = null } = options;
181
- requirePlan(plan);
182
- if (manifests === null || typeof manifests !== 'object' || Array.isArray(manifests)
183
- || !Array.isArray(observations)
184
- || !(relevantTodoIds === null || Array.isArray(relevantTodoIds))) {
185
- invalidVerification('manifests/observationsが不正');
186
- }
187
- const relevant = new Set(relevantTodoIds ?? Object.keys(manifests));
188
-
189
- const findings = [];
190
- const observedByTodo = new Map();
191
- for (const observation of observations) {
192
- if (observation === null || typeof observation !== 'object'
193
- || typeof observation.todo_id !== 'string'
194
- || !Array.isArray(observation.paths)) {
195
- invalidVerification('observationが不正');
196
- }
197
- const manifest = manifests[observation.todo_id];
198
- if (manifest === undefined) {
199
- invalidVerification(`boundary manifestがないTODOの観測: ${observation.todo_id}`);
200
- }
201
- observedByTodo.set(observation.todo_id, [...new Set(observation.paths)].sort());
202
- }
203
-
204
- for (const [todoId, paths] of observedByTodo) {
205
- const declaredWrites = manifests[todoId].writes ?? [];
206
- for (const path of paths) {
207
- if (!declaredWriteCovers(declaredWrites, path)) {
208
- findings.push({ kind: 'undeclared_write', todo_ids: [todoId], path });
209
- }
210
- }
211
- }
212
-
213
- const lineScope = new Set([...relevant, ...observedByTodo.keys()]);
214
- const lineGroups = runtimeLineGroups(manifests, lineScope);
215
- for (const [todoId, paths] of observedByTodo) {
216
- const observedPaths = new Set(paths);
217
- for (const [lineId, group] of lineGroups) {
218
- if (group.writers.has(todoId)) continue;
219
- const readers = [...group.readers].filter((readerId) => readerId !== todoId).sort();
220
- if (readers.length === 0
221
- || ![...group.anchorPaths].some((anchorPath) => observedPaths.has(anchorPath))) continue;
222
- findings.push({
223
- kind: 'observed_line_change',
224
- todo_ids: [todoId, ...readers].sort(),
225
- resource_id: lineId,
226
- });
227
- }
228
- }
229
-
230
- const writersByPath = new Map();
231
- for (const [todoId, paths] of observedByTodo) {
232
- for (const path of paths) {
233
- const writers = writersByPath.get(path) ?? [];
234
- writers.push(todoId);
235
- writersByPath.set(path, writers);
236
- }
237
- }
238
- const overlapPairs = new Set();
239
- const emittedOverlaps = new Set();
240
- for (const [path, writers] of [...writersByPath.entries()].sort()) {
241
- if (writers.length < 2) continue;
242
- const sortedWriters = sorted(writers);
243
- for (let left = 0; left < sortedWriters.length; left += 1) {
244
- for (let right = left + 1; right < sortedWriters.length; right += 1) {
245
- findings.push({
246
- kind: 'observed_write_conflict',
247
- todo_ids: [sortedWriters[left], sortedWriters[right]],
248
- path,
249
- });
250
- emittedOverlaps.add(`${sortedWriters[left]} ${sortedWriters[right]} ${path}`);
251
- overlapPairs.add(`${sortedWriters[left]} ${sortedWriters[right]}`);
252
- }
253
- }
254
- }
255
-
256
- // 観測pathが、まだ観測を持たない別TODOのdeclared write(prefix宣言含む)へ
257
- // 到達する場合も後発write conflictとして保存する(片側観測だけの
258
- // late conflictを安全と推測しない。RC3-F採用のverifier意味論補完)。
259
- for (const [todoId, paths] of observedByTodo) {
260
- for (const [otherId, manifest] of Object.entries(manifests)) {
261
- if (otherId === todoId) continue;
262
- if (!relevant.has(otherId)) continue;
263
- const otherDeclared = [...(manifest.reads ?? []), ...(manifest.writes ?? [])];
264
- for (const path of paths) {
265
- if (!declaredWriteCovers(otherDeclared, path)) continue;
266
- const pair = sorted([todoId, otherId]);
267
- const key = `${pair[0]} ${pair[1]} ${path}`;
268
- if (emittedOverlaps.has(key)) continue;
269
- emittedOverlaps.add(key);
270
- findings.push({
271
- kind: 'observed_write_conflict',
272
- todo_ids: pair,
273
- path,
274
- });
275
- overlapPairs.add(`${pair[0]} ${pair[1]}`);
276
- }
277
- }
278
- }
279
-
280
- const todoIds = sorted(observedByTodo.keys());
281
- for (let left = 0; left < todoIds.length; left += 1) {
282
- for (let right = left + 1; right < todoIds.length; right += 1) {
283
- const pairKey = `${todoIds[left]} ${todoIds[right]}`;
284
- if (overlapPairs.has(pairKey)) continue;
285
- // dynamic/semantic/effect unknownを持つTODOは、path非交差でも独立性を
286
- // 主張できない(ADR 0044 Decision 1.5・5.1)。unknown由来のpairをevidence
287
- // acquisition対象のsemantic conflict unknownとして保存する。
288
- const declaredUnknowns = [
289
- ...(manifests[todoIds[left]].unknowns ?? []),
290
- ...(manifests[todoIds[right]].unknowns ?? []),
291
- ];
292
- if (declaredUnknowns.length > 0) {
293
- findings.push({
294
- kind: 'semantic_conflict_unknown',
295
- todo_ids: [todoIds[left], todoIds[right]],
296
- resource_id: null,
297
- unknowns: structuredClone(declaredUnknowns),
298
- });
299
- }
300
- for (const [kind, witnessKinds] of [
301
- ['semantic_conflict_unknown', ['state', 'schema', 'invariant']],
302
- ['effect_conflict_unknown', ['effect', 'external_effect']],
303
- ]) {
304
- const leftWitness = kind === 'semantic_conflict_unknown'
305
- ? witnessSet(manifests[todoIds[left]], witnessKinds)
306
- : witnessSet({ state_effects: manifests[todoIds[left]].state_effects }, witnessKinds);
307
- const rightWitness = kind === 'semantic_conflict_unknown'
308
- ? witnessSet(manifests[todoIds[right]], witnessKinds)
309
- : witnessSet({ state_effects: manifests[todoIds[right]].state_effects }, witnessKinds);
310
- for (const resourceId of sorted(leftWitness)) {
311
- if (rightWitness.has(resourceId)) {
312
- findings.push({
313
- kind,
314
- todo_ids: [todoIds[left], todoIds[right]],
315
- resource_id: resourceId,
316
- });
317
- }
318
- }
319
- }
320
- }
321
- }
322
-
323
- return { findings };
324
- }
325
-
326
- function affectedClosure(plan, seedTodoIds, manifests) {
327
- const closure = new Set(seedTodoIds);
328
- // manifestsが与えられた場合、同一resource witnessへ到達するTODOをclosureへ
329
- // 含める(ADR 0044 Decision 6.3の第三要素)。resource→TODOのindexを先に作る。
330
- const byResource = new Map();
331
- if (manifests !== undefined) {
332
- for (const [todoId, manifest] of Object.entries(manifests)) {
333
- for (const resourceId of witnessSet(manifest, [
334
- 'state', 'schema', 'invariant', 'effect', 'external_effect',
335
- ])) {
336
- const members = byResource.get(resourceId) ?? new Set();
337
- members.add(todoId);
338
- byResource.set(resourceId, members);
339
- }
340
- }
341
- }
342
- let changed = true;
343
- while (changed) {
344
- changed = false;
345
- for (const conflict of plan.conflicts) {
346
- const [left, right] = conflict.todo_ids;
347
- if (closure.has(left) !== closure.has(right)) {
348
- closure.add(left);
349
- closure.add(right);
350
- changed = true;
351
- }
352
- }
353
- for (const edge of plan.precedence) {
354
- if (closure.has(edge.from_todo_id) && !closure.has(edge.to_todo_id)) {
355
- closure.add(edge.to_todo_id);
356
- changed = true;
357
- }
358
- }
359
- for (const members of byResource.values()) {
360
- if ([...members].some((todoId) => closure.has(todoId))) {
361
- for (const todoId of members) {
362
- if (!closure.has(todoId)) {
363
- closure.add(todoId);
364
- changed = true;
365
- }
366
- }
367
- }
368
- }
369
- }
370
- return closure;
371
- }
372
-
373
- /**
374
- * carry_over_witnessed eventが埋め込むwitness documentをschema・自己digest・
375
- * 帰属で検証する。event payloadは`{ witness_digest, witness }`を持ち、witnessは
376
- * `lattice.carry_over_witness.v1`の完全documentでなければならない。
377
- * invariant digestと提供bytesの再照合はverifyCarryOverWitnessが行う(RC3-Gで
378
- * 保存artifactと配線する)。ここでは存在・shape・digest整合を実証できない
379
- * witnessをすべて不成立として扱う(fail closed)。
380
- */
381
- function witnessedForContinue(witnessRecord, todoId) {
382
- if (witnessRecord === undefined) return false;
383
- const payload = witnessRecord.payload;
384
- const witness = payload?.witness;
385
- if (witness === null || witness === undefined) return false;
386
- if (!validateCarryOverWitness(witness)) return false;
387
- if (witness.todo_id !== todoId) return false;
388
- if (typeof payload.witness_digest !== 'string'
389
- || payload.witness_digest !== witness.witness_digest) {
390
- return false;
391
- }
392
- return true;
393
- }
394
-
395
- /**
396
- * frozen prefixからaffected closureとhold/continue集合を再計算する
397
- * (ADR 0044 Decision 6・7.2)。closure外のrunning TODOでも、schema・自己digest
398
- * 整合まで実証できたcarry-over witnessが保存bytesに存在しなければcontinueできない
399
- * (fail closed)。manifestsを渡すと同一resource witness到達もclosureへ含める。
400
- */
401
- export function recomputeHoldDecision(options = {}) {
402
- const { plan, events, manifests } = options;
403
- requirePlan(plan);
404
- const state = projectRuntimeState({ events });
405
- if (state.freeze === null) {
406
- invalidVerification('intake_frozen eventのないprefixからhold decisionを再計算できない');
407
- }
408
- const freezeSequence = state.freeze.sequence;
409
-
410
- // seedは現plan epochのconflictだけ(過去epochの処理済みconflictを再seedしない)。
411
- const currentEpochConflictSequences = new Set(events
412
- .filter((event) => event.kind === 'conflict_found' && event.plan_epoch === plan.plan_epoch)
413
- .map((event) => event.sequence));
414
- const frozenConflicts = state.conflicts.filter((conflict) => (
415
- conflict.sequence <= freezeSequence && currentEpochConflictSequences.has(conflict.sequence)
416
- ));
417
- if (frozenConflicts.length === 0) {
418
- invalidVerification('frozen prefix内にconflict findingがない');
419
- }
420
- const finding = frozenConflicts.at(-1);
421
- const seed = frozenConflicts.flatMap((conflict) => conflict.todo_ids ?? []);
422
- const closure = affectedClosure(plan, seed, manifests);
423
-
424
- const frozenRunning = new Set();
425
- for (const event of events) {
426
- if (event.sequence > freezeSequence) continue;
427
- if (event.kind === 'executor_dispatched' && event.subject?.kind === 'todo') {
428
- frozenRunning.add(event.subject.ref);
429
- }
430
- if ((event.kind === 'receipt_accepted' || event.kind === 'executor_terminal')
431
- && event.subject?.kind === 'todo') {
432
- frozenRunning.delete(event.subject.ref);
433
- }
434
- }
435
-
436
- const holdSet = new Set();
437
- const continueSet = new Set();
438
- const reasons = {};
439
- for (const todoId of frozenRunning) {
440
- if (closure.has(todoId)) {
441
- holdSet.add(todoId);
442
- reasons[todoId] = 'affected_closure';
443
- } else if (witnessedForContinue(state.witnesses[todoId], todoId)) {
444
- continueSet.add(todoId);
445
- } else {
446
- holdSet.add(todoId);
447
- reasons[todoId] = 'carry_over_unprovable';
448
- }
449
- }
450
- for (const todoId of closure) {
451
- if (!holdSet.has(todoId) && !continueSet.has(todoId)) {
452
- holdSet.add(todoId);
453
- reasons[todoId] = 'affected_closure';
454
- }
455
- }
456
-
457
- return {
458
- finding: structuredClone(finding),
459
- frozen_prefix_sequence: freezeSequence,
460
- hold_set: sorted(holdSet),
461
- continue_set: sorted(continueSet),
462
- reasons,
463
- };
464
- }
465
-
466
- /**
467
- * receipt受理裁定をevent prefixから再計算する(ADR 0044 Decision 7.4)。
468
- * 「rebind前」「rebind後」は生成時刻でなくevent sequenceで判定する。
469
- * freeze後のvN epoch receiptはwitness bindingの有無によらずstale_contextで
470
- * rejectし、frozen prefix内のpending receiptはwitness bindingがある場合だけ
471
- * 受理する。証明できない受理はすべてrejectへ倒す(fail closed)。
472
- */
473
- const RECEIPT_BINDING_FIELDS = Object.freeze([
474
- 'executor_handle',
475
- 'worktree_id',
476
- 'base_sha',
477
- 'packet_digest',
478
- 'checkpoint_digest',
479
- ]);
480
-
481
- export function recomputeReceiptDecisions(options = {}) {
482
- const { plan, events } = options;
483
- requirePlan(plan);
484
- const state = projectRuntimeState({ events });
485
- // freezeはresumeで消えない永続境界として扱う。resume後もvN epoch receiptの
486
- // stale判定は最初のfreeze境界に対して行う(受理へ反転させない)。
487
- // 境界は検証対象planのepochで発生したfreezeへscopeする: vN+1 epochの
488
- // receiptをvNのfreezeで塞がない(rebind後のvN+1 receipt受理、RC3-G)。
489
- const freezes = events
490
- .filter((event) => event.kind === 'intake_frozen' && event.plan_epoch === plan.plan_epoch)
491
- .map((event) => event.sequence)
492
- .sort((left, right) => left - right);
493
- const freezeBoundary = freezes.length === 0 ? null : freezes[0];
494
-
495
- const seenReceiptIds = new Set();
496
- const decisions = state.receipts.map((receipt) => {
497
- const base = {
498
- receipt_id: receipt.receipt_id,
499
- todo_id: receipt.todo_id,
500
- sequence: receipt.sequence,
501
- };
502
- const reject = (detail) => (
503
- { ...base, decision: 'rejected', reason: 'stale_context', detail }
504
- );
505
-
506
- // receipt_idの再利用は帰属の一意性を壊すためreject(先着だけを裁定対象にする)。
507
- if (seenReceiptIds.has(receipt.receipt_id)) {
508
- return reject('duplicate_receipt_id');
509
- }
510
- seenReceiptIds.add(receipt.receipt_id);
511
-
512
- // 帰属照合の基準はdispatch記録であり、executor自己申告ではない(Decision 7.4)。
513
- const payload = receipt.payload ?? {};
514
- if (RECEIPT_BINDING_FIELDS.some((field) => typeof payload[field] !== 'string')) {
515
- return reject('binding_missing');
516
- }
517
- // dispatch記録は「receipt記録時点までの最後のdispatch」を使う(裁定時点の
518
- // prefix意味論。全投影の最後勝ちにすると、後続redispatchが過去receiptの
519
- // 帰属先を書き換えてproducer裁定とdivergeする。RC3-J P1採用)。
520
- const dispatchEventForReceipt = events.findLast((event) => (
521
- event.kind === 'executor_dispatched'
522
- && event.subject?.kind === 'todo'
523
- && event.subject.ref === receipt.todo_id
524
- && event.sequence < receipt.sequence
525
- ));
526
- const dispatch = dispatchEventForReceipt === undefined
527
- ? undefined
528
- : { sequence: dispatchEventForReceipt.sequence, payload: dispatchEventForReceipt.payload };
529
- const originBindingRetained = (() => {
530
- if (!Number.isSafeInteger(receipt.plan_epoch) || receipt.plan_epoch >= plan.plan_epoch) return false;
531
- for (let epoch = receipt.plan_epoch; epoch < plan.plan_epoch; epoch += 1) {
532
- const witnessed = events.some((event) => event.sequence < receipt.sequence
533
- && event.kind === 'carry_over_witnessed' && event.plan_epoch === epoch
534
- && event.subject?.kind === 'todo' && event.subject.ref === receipt.todo_id);
535
- const continued = events.some((event) => event.sequence < receipt.sequence
536
- && event.kind === 'hold_decided' && event.plan_epoch === epoch
537
- && event.payload?.continue_set?.includes(receipt.todo_id));
538
- const recompiled = events.some((event) => event.sequence < receipt.sequence
539
- && event.kind === 'plan_recompiled' && event.plan_epoch === epoch + 1);
540
- const invalidated = events.some((event) => event.sequence < receipt.sequence
541
- && event.kind === 'context_invalidated' && event.plan_epoch === epoch + 1
542
- && event.subject?.kind === 'todo' && event.subject.ref === receipt.todo_id);
543
- if (!witnessed || !continued || !recompiled || invalidated) return false;
544
- }
545
- return true;
546
- })();
547
- if (dispatch === undefined) {
548
- return reject('not_dispatched');
549
- }
550
- const dispatchPayload = dispatch.payload ?? {};
551
- if (payload.executor_handle !== dispatchPayload.executor_handle
552
- || payload.worktree_id !== dispatchPayload.worktree_id
553
- || payload.packet_digest !== dispatchPayload.packet_digest) {
554
- return reject('binding_mismatch');
555
- }
556
- if (typeof plan.base_sha === 'string' && payload.base_sha !== plan.base_sha) {
557
- return reject('base_mismatch');
558
- }
559
- if (receipt.plan_epoch !== plan.plan_epoch && !originBindingRetained) {
560
- return reject('epoch_mismatch');
561
- }
562
- // dispatchが旧epochのTODOが現epoch receiptを名乗る場合はepoch_rebound必須
563
- // (rebindなしのepoch自称を受理しない。Decision 7.3/7.4)。
564
- if (dispatchEventForReceipt.plan_epoch !== plan.plan_epoch && !originBindingRetained) {
565
- const rebound = state.rebinds[receipt.todo_id];
566
- if (rebound === undefined
567
- || rebound.payload?.new_plan_epoch !== plan.plan_epoch
568
- || rebound.sequence >= receipt.sequence) {
569
- return reject('unrebound_epoch');
570
- }
571
- }
572
- // checkpoint観測が存在するTODOは、receipt以前の最後の観測checkpointとの
573
- // digest一致とobserved_diff一致を要求(executor自己申告をbinding証拠に
574
- // しない。RC3-F)。bindingは同一dispatch attemptへscope=最後のdispatch
575
- // 以降のcheckpointだけが対象(redispatch後のreceiptを旧attemptの
576
- // checkpointで塞がない。RC3-G)。
577
- const observedCheckpoints = state.checkpoints.filter((entry) => (
578
- entry.todo_id === receipt.todo_id
579
- && entry.sequence > dispatch.sequence
580
- && entry.sequence < receipt.sequence
581
- // supervisorがI/O警報を確かめるために撮ったcheckpointはexecutorの申告境界では
582
- // ないので、bindingの基準にしない(ADR 0143。engine側と同一規則)。
583
- && entry.payload?.observed_by !== 'supervisor_probe'
584
- ));
585
- if (observedCheckpoints.length > 0) {
586
- const last = observedCheckpoints[observedCheckpoints.length - 1].payload;
587
- if (typeof last?.checkpoint_digest === 'string'
588
- && payload.checkpoint_digest !== last.checkpoint_digest) {
589
- return reject('checkpoint_mismatch');
590
- }
591
- if (last?.diff?.entries !== undefined && Array.isArray(last.diff.entries)) {
592
- const expected = JSON.stringify(last.diff.entries.map(({ path, change }) => ({ path, change })));
593
- const reported = JSON.stringify((payload.observed_diff ?? []).map(({ path, change }) => ({ path, change })));
594
- if (expected !== reported) {
595
- return reject('checkpoint_mismatch');
596
- }
597
- }
598
- }
599
- if (receipt.rejected_sequence !== null) {
600
- return reject('recorded_rejection');
601
- }
602
- if (freezeBoundary === null) {
603
- return { ...base, decision: 'accepted', reason: null, detail: null };
604
- }
605
- if (receipt.accepted_sequence !== null && receipt.accepted_sequence <= freezeBoundary) {
606
- return { ...base, decision: 'accepted', reason: null, detail: null };
607
- }
608
- if (receipt.sequence > freezeBoundary) {
609
- return reject('post_freeze');
610
- }
611
- // frozen prefix内のpending receiptは、実証済みwitness documentの
612
- // receipt_bindingsが当該receiptをbindする場合だけ有効になる(Decision 7.5)。
613
- const witnessRecord = state.witnesses[receipt.todo_id];
614
- if (!witnessedForContinue(witnessRecord, receipt.todo_id)) {
615
- return reject('witness_unproven');
616
- }
617
- const bound = witnessRecord.payload.witness.receipt_bindings.some((binding) => (
618
- binding.receipt_id === receipt.receipt_id
619
- && binding.recorded_sequence === receipt.sequence
620
- && binding.within_frozen_prefix === true
621
- ));
622
- if (bound) {
623
- return { ...base, decision: 'accepted', reason: null, detail: null };
624
- }
625
- return reject('unbound_receipt');
626
- });
627
-
628
- return { decisions };
629
- }
630
-
631
- const INVARIANT_DIGEST_FIELDS = Object.freeze([
632
- 'todo_input',
633
- 'boundary_manifest',
634
- 'validator',
635
- 'context_content',
636
- ]);
637
-
638
- /**
639
- * carry-over witnessのinvariant digestを提供bytesから再計算する
640
- * (ADR 0044 Decision 7.2・7.5)。1 fieldでも証明不能ならvalid falseとし、
641
- * reasonsへ具体的なfailureと`carry_over_unprovable`を返す。
642
- */
643
- export function verifyCarryOverWitness(options = {}) {
644
- const { witness, sources } = options;
645
- const reasons = [];
646
- if (witness === null || typeof witness !== 'object' || Array.isArray(witness)
647
- || witness.invariant_digests === null
648
- || typeof witness.invariant_digests !== 'object') {
649
- return { valid: false, reasons: ['witness_shape', 'carry_over_unprovable'] };
650
- }
651
- if (sources === null || typeof sources !== 'object' || Array.isArray(sources)) {
652
- return { valid: false, reasons: ['sources_shape', 'carry_over_unprovable'] };
653
- }
654
-
655
- if (!Number.isSafeInteger(witness.predecessor_epoch)
656
- || !Number.isSafeInteger(witness.successor_epoch)
657
- || witness.successor_epoch <= witness.predecessor_epoch) {
658
- reasons.push('epoch_ordering');
659
- }
660
-
661
- for (const field of INVARIANT_DIGEST_FIELDS) {
662
- if (sources[field] === undefined) {
663
- reasons.push(`missing_source:${field}`);
664
- continue;
665
- }
666
- let recomputed = null;
667
- try {
668
- recomputed = digestArtifact(sources[field]);
669
- } catch {
670
- reasons.push(`source_not_canonical:${field}`);
671
- continue;
672
- }
673
- if (witness.invariant_digests[field] !== recomputed) {
674
- reasons.push(`invariant_digest_mismatch:${field}`);
675
- }
676
- }
677
-
678
- if (typeof witness.witness_digest === 'string') {
679
- const projection = {};
680
- for (const key of Object.keys(witness)) {
681
- if (key !== 'witness_digest') projection[key] = witness[key];
682
- }
683
- let selfDigest = null;
684
- try {
685
- selfDigest = digestArtifact(projection);
686
- } catch {
687
- selfDigest = null;
688
- }
689
- if (witness.witness_digest !== selfDigest) {
690
- reasons.push('witness_digest_mismatch');
691
- }
692
- } else {
693
- reasons.push('witness_digest_mismatch');
694
- }
695
-
696
- if (reasons.length > 0) {
697
- reasons.push('carry_over_unprovable');
698
- return { valid: false, reasons };
699
- }
700
- return { valid: true, reasons: [] };
701
- }
1
+ import path from 'node:path';
2
+
3
+ import { digestArtifact } from './artifact-contracts.mjs';
4
+ import { validateCarryOverWitness } from './runtime-contracts.mjs';
5
+ import { projectRuntimeState } from './runtime-projection.mjs';
6
+
7
+ // RC3 runtime decision verifier(ADR 0044 Decision 4〜7)。
8
+ // dispatch/hold/continue/invalidate裁定を、保存planとevent prefixだけから
9
+ // producer非依存に再計算する。schema envelope/digestの完全検証は
10
+ // runtime-contracts.mjsのvalidatorが担い、本moduleはruntime意味論を再計算する。
11
+ // 判定はすべてevent sequenceで行い、生成時刻・到着時刻を根拠にしない(Decision 7.4)。
12
+
13
+ function invalidVerification(reason) {
14
+ throw new TypeError(`runtime decision verifier契約違反: ${reason}`);
15
+ }
16
+
17
+ function requirePlan(plan) {
18
+ if (plan === null || typeof plan !== 'object' || Array.isArray(plan)
19
+ || !Array.isArray(plan.nodes)
20
+ || plan.nodes.length === 0
21
+ || !Array.isArray(plan.precedence)
22
+ || !Array.isArray(plan.conflicts)
23
+ || plan.capacity === null
24
+ || typeof plan.capacity !== 'object'
25
+ || !Number.isSafeInteger(plan.capacity.executors)
26
+ || plan.capacity.executors < 1
27
+ || !Number.isSafeInteger(plan.plan_epoch)) {
28
+ invalidVerification('runtime planの意味論的shapeが不正');
29
+ }
30
+ const nodeIds = plan.nodes.map((node) => node.todo_id);
31
+ if (nodeIds.some((todoId) => typeof todoId !== 'string')
32
+ || new Set(nodeIds).size !== nodeIds.length) {
33
+ invalidVerification('plan nodeが不正');
34
+ }
35
+ return nodeIds;
36
+ }
37
+
38
+ function sorted(values) {
39
+ return [...values].sort();
40
+ }
41
+
42
+ /**
43
+ * ready frontierを再計算する(ADR 0044 Decision 4)。
44
+ * minimum waveを同期barrierとして扱わず、hard predecessor充足、running集合との
45
+ * conflict不在、実capacityだけをdispatch可否の根拠にする。intake freeze中は
46
+ * fail closedでdispatch 0件とする。dispatchableは辞書順で空きcapacity分に切る。
47
+ */
48
+ export function computeReadyFrontier(options = {}) {
49
+ const { plan, events } = options;
50
+ const nodeIds = requirePlan(plan);
51
+ const state = projectRuntimeState({ events });
52
+
53
+ if (state.freeze !== null || state.closed) {
54
+ return { dispatchable: [] };
55
+ }
56
+
57
+ const acceptedSet = new Set(state.accepted);
58
+ const runningSet = new Set(state.running);
59
+ // dispatch済み・terminal済み・held済みの除外は現plan epochへscopeする。
60
+ // 旧epochのdispatch/hold記録は、recompile時のcontext失効とredispatch契約
61
+ //(RC3-G)によって新epochでは再配置可能になる。accepted/runningは
62
+ // epoch非依存(物理状態)のまま除外する。
63
+ const heldSet = new Set();
64
+ const terminalSet = new Set();
65
+ const dispatchedSet = new Set();
66
+ for (const event of events) {
67
+ if (event.plan_epoch !== plan.plan_epoch) continue;
68
+ if (event.kind === 'hold_decided') {
69
+ for (const todoId of event.payload?.hold_set ?? []) heldSet.add(todoId);
70
+ }
71
+ if (event.subject?.kind !== 'todo') continue;
72
+ if (event.kind === 'executor_dispatched') dispatchedSet.add(event.subject.ref);
73
+ if (event.kind === 'executor_terminal') terminalSet.add(event.subject.ref);
74
+ }
75
+ const slots = plan.capacity.executors - runningSet.size;
76
+ if (slots < 1) {
77
+ return { dispatchable: [] };
78
+ }
79
+
80
+ const eligible = nodeIds.filter((todoId) => {
81
+ if (acceptedSet.has(todoId) || runningSet.has(todoId) || heldSet.has(todoId)
82
+ || terminalSet.has(todoId) || dispatchedSet.has(todoId)) return false;
83
+ return plan.precedence.every((edge) => (
84
+ edge.to_todo_id !== todoId || acceptedSet.has(edge.from_todo_id)
85
+ ));
86
+ });
87
+
88
+ // running集合だけでなく、同じfrontierで既に選んだnodeとのconflictも検査する。
89
+ // conflict pairを同一dispatch decisionで同時実行しないための貪欲選択(辞書順)。
90
+ const dispatchable = [];
91
+ for (const todoId of sorted(eligible)) {
92
+ if (dispatchable.length >= slots) break;
93
+ const conflictFree = plan.conflicts.every((conflict) => {
94
+ if (!conflict.todo_ids.includes(todoId)) return true;
95
+ return conflict.todo_ids.every((member) => (
96
+ member === todoId || (!runningSet.has(member) && !dispatchable.includes(member))
97
+ ));
98
+ });
99
+ if (conflictFree) dispatchable.push(todoId);
100
+ }
101
+
102
+ return { dispatchable };
103
+ }
104
+
105
+ function declaredWriteCovers(declaredWrites, observedPath) {
106
+ return declaredWrites.some((declared) => (
107
+ declared === observedPath
108
+ || (declared.endsWith('/') && observedPath.startsWith(declared))
109
+ ));
110
+ }
111
+
112
+ function witnessSet(manifest, kinds) {
113
+ const resources = new Set(manifest?.resources ?? []);
114
+ for (const effect of manifest?.state_effects ?? []) {
115
+ if (kinds.includes(effect.kind) && typeof effect.resource_id === 'string') {
116
+ resources.add(effect.resource_id);
117
+ }
118
+ }
119
+ return resources;
120
+ }
121
+
122
+ const LINE_ID = /^[0-9A-Za-z](?:[0-9A-Za-z._-]{0,127})$/;
123
+ const LINE_PATH_CONTROL = /[\u0000-\u001f\u007f]/;
124
+
125
+ function runtimeLinePath(value) {
126
+ return typeof value === 'string' && value.length > 0
127
+ && Buffer.byteLength(value, 'utf8') <= 1_024
128
+ && !LINE_PATH_CONTROL.test(value) && !value.includes('\\')
129
+ && !path.posix.isAbsolute(value) && value === path.posix.normalize(value)
130
+ && !value.split('/').includes('..');
131
+ }
132
+
133
+ function runtimeLineGroups(manifests, todoIds) {
134
+ const groups = new Map();
135
+ for (const todoId of sorted(new Set(todoIds))) {
136
+ const manifest = manifests[todoId];
137
+ if (manifest === null || typeof manifest !== 'object' || Array.isArray(manifest)
138
+ || !Array.isArray(manifest.lines ?? [])) {
139
+ invalidVerification(`boundary manifestのlinesが不正: ${todoId}`);
140
+ }
141
+ const seen = new Set();
142
+ for (const line of manifest.lines ?? []) {
143
+ if (line === null || typeof line !== 'object' || Array.isArray(line)
144
+ || Object.keys(line).sort().join(',') !== 'anchors,line_id,role'
145
+ || !LINE_ID.test(line.line_id ?? '') || !['reads', 'writes'].includes(line.role)
146
+ || !Array.isArray(line.anchors) || line.anchors.length === 0
147
+ || seen.has(line.line_id)) {
148
+ invalidVerification(`line宣言が不正: ${todoId}`);
149
+ }
150
+ seen.add(line.line_id);
151
+ const group = groups.get(line.line_id) ?? {
152
+ readers: new Set(), writers: new Set(), anchorPaths: new Set(),
153
+ };
154
+ group[line.role === 'reads' ? 'readers' : 'writers'].add(todoId);
155
+ for (const anchor of line.anchors) {
156
+ const keys = anchor !== null && typeof anchor === 'object' && !Array.isArray(anchor)
157
+ ? Object.keys(anchor).sort().join(',') : '';
158
+ const valid = anchor?.kind === 'path'
159
+ ? keys === 'kind,path' && runtimeLinePath(anchor.path)
160
+ : anchor?.kind === 'symbol' && keys === 'kind,name,path'
161
+ && typeof anchor.name === 'string' && anchor.name.length > 0
162
+ && runtimeLinePath(anchor.path);
163
+ if (!valid) invalidVerification(`line anchorが不正: ${todoId}/${line.line_id}`);
164
+ // producerと共有せず、観測pathだけから独立に近似する。
165
+ group.anchorPaths.add(anchor.path);
166
+ }
167
+ groups.set(line.line_id, group);
168
+ }
169
+ }
170
+ return groups;
171
+ }
172
+
173
+ /**
174
+ * 観測diffをdeclared path/resource witnessへcross-bindし、closed conflict分類の
175
+ * findingを返す(ADR 0044 Decision 5)。宣言外writeと運転中overlapは別findingで
176
+ * 保存し、silent mergeしない。path非交差でも共有witnessがあれば安全と推測せず
177
+ * unknown findingにする。
178
+ */
179
+ export function classifyObservedDiff(options = {}) {
180
+ const { plan, manifests, observations, relevantTodoIds = null } = options;
181
+ requirePlan(plan);
182
+ if (manifests === null || typeof manifests !== 'object' || Array.isArray(manifests)
183
+ || !Array.isArray(observations)
184
+ || !(relevantTodoIds === null || Array.isArray(relevantTodoIds))) {
185
+ invalidVerification('manifests/observationsが不正');
186
+ }
187
+ const relevant = new Set(relevantTodoIds ?? Object.keys(manifests));
188
+
189
+ const findings = [];
190
+ const observedByTodo = new Map();
191
+ for (const observation of observations) {
192
+ if (observation === null || typeof observation !== 'object'
193
+ || typeof observation.todo_id !== 'string'
194
+ || !Array.isArray(observation.paths)) {
195
+ invalidVerification('observationが不正');
196
+ }
197
+ const manifest = manifests[observation.todo_id];
198
+ if (manifest === undefined) {
199
+ invalidVerification(`boundary manifestがないTODOの観測: ${observation.todo_id}`);
200
+ }
201
+ observedByTodo.set(observation.todo_id, [...new Set(observation.paths)].sort());
202
+ }
203
+
204
+ for (const [todoId, paths] of observedByTodo) {
205
+ const declaredWrites = manifests[todoId].writes ?? [];
206
+ for (const path of paths) {
207
+ if (!declaredWriteCovers(declaredWrites, path)) {
208
+ findings.push({ kind: 'undeclared_write', todo_ids: [todoId], path });
209
+ }
210
+ }
211
+ }
212
+
213
+ const lineScope = new Set([...relevant, ...observedByTodo.keys()]);
214
+ const lineGroups = runtimeLineGroups(manifests, lineScope);
215
+ for (const [todoId, paths] of observedByTodo) {
216
+ const observedPaths = new Set(paths);
217
+ for (const [lineId, group] of lineGroups) {
218
+ if (group.writers.has(todoId)) continue;
219
+ const readers = [...group.readers].filter((readerId) => readerId !== todoId).sort();
220
+ if (readers.length === 0
221
+ || ![...group.anchorPaths].some((anchorPath) => observedPaths.has(anchorPath))) continue;
222
+ findings.push({
223
+ kind: 'observed_line_change',
224
+ todo_ids: [todoId, ...readers].sort(),
225
+ resource_id: lineId,
226
+ });
227
+ }
228
+ }
229
+
230
+ const writersByPath = new Map();
231
+ for (const [todoId, paths] of observedByTodo) {
232
+ for (const path of paths) {
233
+ const writers = writersByPath.get(path) ?? [];
234
+ writers.push(todoId);
235
+ writersByPath.set(path, writers);
236
+ }
237
+ }
238
+ const overlapPairs = new Set();
239
+ const emittedOverlaps = new Set();
240
+ for (const [path, writers] of [...writersByPath.entries()].sort()) {
241
+ if (writers.length < 2) continue;
242
+ const sortedWriters = sorted(writers);
243
+ for (let left = 0; left < sortedWriters.length; left += 1) {
244
+ for (let right = left + 1; right < sortedWriters.length; right += 1) {
245
+ findings.push({
246
+ kind: 'observed_write_conflict',
247
+ todo_ids: [sortedWriters[left], sortedWriters[right]],
248
+ path,
249
+ });
250
+ emittedOverlaps.add(`${sortedWriters[left]} ${sortedWriters[right]} ${path}`);
251
+ overlapPairs.add(`${sortedWriters[left]} ${sortedWriters[right]}`);
252
+ }
253
+ }
254
+ }
255
+
256
+ // 観測pathが、まだ観測を持たない別TODOのdeclared write(prefix宣言含む)へ
257
+ // 到達する場合も後発write conflictとして保存する(片側観測だけの
258
+ // late conflictを安全と推測しない。RC3-F採用のverifier意味論補完)。
259
+ for (const [todoId, paths] of observedByTodo) {
260
+ for (const [otherId, manifest] of Object.entries(manifests)) {
261
+ if (otherId === todoId) continue;
262
+ if (!relevant.has(otherId)) continue;
263
+ const otherDeclared = [...(manifest.reads ?? []), ...(manifest.writes ?? [])];
264
+ for (const path of paths) {
265
+ if (!declaredWriteCovers(otherDeclared, path)) continue;
266
+ const pair = sorted([todoId, otherId]);
267
+ const key = `${pair[0]} ${pair[1]} ${path}`;
268
+ if (emittedOverlaps.has(key)) continue;
269
+ emittedOverlaps.add(key);
270
+ findings.push({
271
+ kind: 'observed_write_conflict',
272
+ todo_ids: pair,
273
+ path,
274
+ });
275
+ overlapPairs.add(`${pair[0]} ${pair[1]}`);
276
+ }
277
+ }
278
+ }
279
+
280
+ const todoIds = sorted(observedByTodo.keys());
281
+ for (let left = 0; left < todoIds.length; left += 1) {
282
+ for (let right = left + 1; right < todoIds.length; right += 1) {
283
+ const pairKey = `${todoIds[left]} ${todoIds[right]}`;
284
+ if (overlapPairs.has(pairKey)) continue;
285
+ // dynamic/semantic/effect unknownを持つTODOは、path非交差でも独立性を
286
+ // 主張できない(ADR 0044 Decision 1.5・5.1)。unknown由来のpairをevidence
287
+ // acquisition対象のsemantic conflict unknownとして保存する。
288
+ const declaredUnknowns = [
289
+ ...(manifests[todoIds[left]].unknowns ?? []),
290
+ ...(manifests[todoIds[right]].unknowns ?? []),
291
+ ];
292
+ if (declaredUnknowns.length > 0) {
293
+ findings.push({
294
+ kind: 'semantic_conflict_unknown',
295
+ todo_ids: [todoIds[left], todoIds[right]],
296
+ resource_id: null,
297
+ unknowns: structuredClone(declaredUnknowns),
298
+ });
299
+ }
300
+ for (const [kind, witnessKinds] of [
301
+ ['semantic_conflict_unknown', ['state', 'schema', 'invariant']],
302
+ ['effect_conflict_unknown', ['effect', 'external_effect']],
303
+ ]) {
304
+ const leftWitness = kind === 'semantic_conflict_unknown'
305
+ ? witnessSet(manifests[todoIds[left]], witnessKinds)
306
+ : witnessSet({ state_effects: manifests[todoIds[left]].state_effects }, witnessKinds);
307
+ const rightWitness = kind === 'semantic_conflict_unknown'
308
+ ? witnessSet(manifests[todoIds[right]], witnessKinds)
309
+ : witnessSet({ state_effects: manifests[todoIds[right]].state_effects }, witnessKinds);
310
+ for (const resourceId of sorted(leftWitness)) {
311
+ if (rightWitness.has(resourceId)) {
312
+ findings.push({
313
+ kind,
314
+ todo_ids: [todoIds[left], todoIds[right]],
315
+ resource_id: resourceId,
316
+ });
317
+ }
318
+ }
319
+ }
320
+ }
321
+ }
322
+
323
+ return { findings };
324
+ }
325
+
326
+ function affectedClosure(plan, seedTodoIds, manifests) {
327
+ const closure = new Set(seedTodoIds);
328
+ // manifestsが与えられた場合、同一resource witnessへ到達するTODOをclosureへ
329
+ // 含める(ADR 0044 Decision 6.3の第三要素)。resource→TODOのindexを先に作る。
330
+ const byResource = new Map();
331
+ if (manifests !== undefined) {
332
+ for (const [todoId, manifest] of Object.entries(manifests)) {
333
+ for (const resourceId of witnessSet(manifest, [
334
+ 'state', 'schema', 'invariant', 'effect', 'external_effect',
335
+ ])) {
336
+ const members = byResource.get(resourceId) ?? new Set();
337
+ members.add(todoId);
338
+ byResource.set(resourceId, members);
339
+ }
340
+ }
341
+ }
342
+ let changed = true;
343
+ while (changed) {
344
+ changed = false;
345
+ for (const conflict of plan.conflicts) {
346
+ const [left, right] = conflict.todo_ids;
347
+ if (closure.has(left) !== closure.has(right)) {
348
+ closure.add(left);
349
+ closure.add(right);
350
+ changed = true;
351
+ }
352
+ }
353
+ for (const edge of plan.precedence) {
354
+ if (closure.has(edge.from_todo_id) && !closure.has(edge.to_todo_id)) {
355
+ closure.add(edge.to_todo_id);
356
+ changed = true;
357
+ }
358
+ }
359
+ for (const members of byResource.values()) {
360
+ if ([...members].some((todoId) => closure.has(todoId))) {
361
+ for (const todoId of members) {
362
+ if (!closure.has(todoId)) {
363
+ closure.add(todoId);
364
+ changed = true;
365
+ }
366
+ }
367
+ }
368
+ }
369
+ }
370
+ return closure;
371
+ }
372
+
373
+ /**
374
+ * carry_over_witnessed eventが埋め込むwitness documentをschema・自己digest・
375
+ * 帰属で検証する。event payloadは`{ witness_digest, witness }`を持ち、witnessは
376
+ * `lattice.carry_over_witness.v1`の完全documentでなければならない。
377
+ * invariant digestと提供bytesの再照合はverifyCarryOverWitnessが行う(RC3-Gで
378
+ * 保存artifactと配線する)。ここでは存在・shape・digest整合を実証できない
379
+ * witnessをすべて不成立として扱う(fail closed)。
380
+ */
381
+ function witnessedForContinue(witnessRecord, todoId) {
382
+ if (witnessRecord === undefined) return false;
383
+ const payload = witnessRecord.payload;
384
+ const witness = payload?.witness;
385
+ if (witness === null || witness === undefined) return false;
386
+ if (!validateCarryOverWitness(witness)) return false;
387
+ if (witness.todo_id !== todoId) return false;
388
+ if (typeof payload.witness_digest !== 'string'
389
+ || payload.witness_digest !== witness.witness_digest) {
390
+ return false;
391
+ }
392
+ return true;
393
+ }
394
+
395
+ /**
396
+ * frozen prefixからaffected closureとhold/continue集合を再計算する
397
+ * (ADR 0044 Decision 6・7.2)。closure外のrunning TODOでも、schema・自己digest
398
+ * 整合まで実証できたcarry-over witnessが保存bytesに存在しなければcontinueできない
399
+ * (fail closed)。manifestsを渡すと同一resource witness到達もclosureへ含める。
400
+ */
401
+ export function recomputeHoldDecision(options = {}) {
402
+ const { plan, events, manifests } = options;
403
+ requirePlan(plan);
404
+ const state = projectRuntimeState({ events });
405
+ if (state.freeze === null) {
406
+ invalidVerification('intake_frozen eventのないprefixからhold decisionを再計算できない');
407
+ }
408
+ const freezeSequence = state.freeze.sequence;
409
+
410
+ // seedは現plan epochのconflictだけ(過去epochの処理済みconflictを再seedしない)。
411
+ const currentEpochConflictSequences = new Set(events
412
+ .filter((event) => event.kind === 'conflict_found' && event.plan_epoch === plan.plan_epoch)
413
+ .map((event) => event.sequence));
414
+ const frozenConflicts = state.conflicts.filter((conflict) => (
415
+ conflict.sequence <= freezeSequence && currentEpochConflictSequences.has(conflict.sequence)
416
+ ));
417
+ if (frozenConflicts.length === 0) {
418
+ invalidVerification('frozen prefix内にconflict findingがない');
419
+ }
420
+ const finding = frozenConflicts.at(-1);
421
+ const seed = frozenConflicts.flatMap((conflict) => conflict.todo_ids ?? []);
422
+ const closure = affectedClosure(plan, seed, manifests);
423
+
424
+ const frozenRunning = new Set();
425
+ for (const event of events) {
426
+ if (event.sequence > freezeSequence) continue;
427
+ if (event.kind === 'executor_dispatched' && event.subject?.kind === 'todo') {
428
+ frozenRunning.add(event.subject.ref);
429
+ }
430
+ if ((event.kind === 'receipt_accepted' || event.kind === 'executor_terminal')
431
+ && event.subject?.kind === 'todo') {
432
+ frozenRunning.delete(event.subject.ref);
433
+ }
434
+ }
435
+
436
+ const holdSet = new Set();
437
+ const continueSet = new Set();
438
+ const reasons = {};
439
+ for (const todoId of frozenRunning) {
440
+ if (closure.has(todoId)) {
441
+ holdSet.add(todoId);
442
+ reasons[todoId] = 'affected_closure';
443
+ } else if (witnessedForContinue(state.witnesses[todoId], todoId)) {
444
+ continueSet.add(todoId);
445
+ } else {
446
+ holdSet.add(todoId);
447
+ reasons[todoId] = 'carry_over_unprovable';
448
+ }
449
+ }
450
+ for (const todoId of closure) {
451
+ if (!holdSet.has(todoId) && !continueSet.has(todoId)) {
452
+ holdSet.add(todoId);
453
+ reasons[todoId] = 'affected_closure';
454
+ }
455
+ }
456
+
457
+ return {
458
+ finding: structuredClone(finding),
459
+ frozen_prefix_sequence: freezeSequence,
460
+ hold_set: sorted(holdSet),
461
+ continue_set: sorted(continueSet),
462
+ reasons,
463
+ };
464
+ }
465
+
466
+ /**
467
+ * receipt受理裁定をevent prefixから再計算する(ADR 0044 Decision 7.4)。
468
+ * 「rebind前」「rebind後」は生成時刻でなくevent sequenceで判定する。
469
+ * freeze後のvN epoch receiptはwitness bindingの有無によらずstale_contextで
470
+ * rejectし、frozen prefix内のpending receiptはwitness bindingがある場合だけ
471
+ * 受理する。証明できない受理はすべてrejectへ倒す(fail closed)。
472
+ */
473
+ const RECEIPT_BINDING_FIELDS = Object.freeze([
474
+ 'executor_handle',
475
+ 'worktree_id',
476
+ 'base_sha',
477
+ 'packet_digest',
478
+ 'checkpoint_digest',
479
+ ]);
480
+
481
+ export function recomputeReceiptDecisions(options = {}) {
482
+ const { plan, events } = options;
483
+ requirePlan(plan);
484
+ const state = projectRuntimeState({ events });
485
+ // freezeはresumeで消えない永続境界として扱う。resume後もvN epoch receiptの
486
+ // stale判定は最初のfreeze境界に対して行う(受理へ反転させない)。
487
+ // 境界は検証対象planのepochで発生したfreezeへscopeする: vN+1 epochの
488
+ // receiptをvNのfreezeで塞がない(rebind後のvN+1 receipt受理、RC3-G)。
489
+ const freezes = events
490
+ .filter((event) => event.kind === 'intake_frozen' && event.plan_epoch === plan.plan_epoch)
491
+ .map((event) => event.sequence)
492
+ .sort((left, right) => left - right);
493
+ const freezeBoundary = freezes.length === 0 ? null : freezes[0];
494
+
495
+ const seenReceiptIds = new Set();
496
+ const decisions = state.receipts.map((receipt) => {
497
+ const base = {
498
+ receipt_id: receipt.receipt_id,
499
+ todo_id: receipt.todo_id,
500
+ sequence: receipt.sequence,
501
+ };
502
+ const reject = (detail) => (
503
+ { ...base, decision: 'rejected', reason: 'stale_context', detail }
504
+ );
505
+
506
+ // receipt_idの再利用は帰属の一意性を壊すためreject(先着だけを裁定対象にする)。
507
+ if (seenReceiptIds.has(receipt.receipt_id)) {
508
+ return reject('duplicate_receipt_id');
509
+ }
510
+ seenReceiptIds.add(receipt.receipt_id);
511
+
512
+ // 帰属照合の基準はdispatch記録であり、executor自己申告ではない(Decision 7.4)。
513
+ const payload = receipt.payload ?? {};
514
+ if (RECEIPT_BINDING_FIELDS.some((field) => typeof payload[field] !== 'string')) {
515
+ return reject('binding_missing');
516
+ }
517
+ // dispatch記録は「receipt記録時点までの最後のdispatch」を使う(裁定時点の
518
+ // prefix意味論。全投影の最後勝ちにすると、後続redispatchが過去receiptの
519
+ // 帰属先を書き換えてproducer裁定とdivergeする。RC3-J P1採用)。
520
+ const dispatchEventForReceipt = events.findLast((event) => (
521
+ event.kind === 'executor_dispatched'
522
+ && event.subject?.kind === 'todo'
523
+ && event.subject.ref === receipt.todo_id
524
+ && event.sequence < receipt.sequence
525
+ ));
526
+ const dispatch = dispatchEventForReceipt === undefined
527
+ ? undefined
528
+ : { sequence: dispatchEventForReceipt.sequence, payload: dispatchEventForReceipt.payload };
529
+ const originBindingRetained = (() => {
530
+ if (!Number.isSafeInteger(receipt.plan_epoch) || receipt.plan_epoch >= plan.plan_epoch) return false;
531
+ for (let epoch = receipt.plan_epoch; epoch < plan.plan_epoch; epoch += 1) {
532
+ const witnessed = events.some((event) => event.sequence < receipt.sequence
533
+ && event.kind === 'carry_over_witnessed' && event.plan_epoch === epoch
534
+ && event.subject?.kind === 'todo' && event.subject.ref === receipt.todo_id);
535
+ const continued = events.some((event) => event.sequence < receipt.sequence
536
+ && event.kind === 'hold_decided' && event.plan_epoch === epoch
537
+ && event.payload?.continue_set?.includes(receipt.todo_id));
538
+ const recompiled = events.some((event) => event.sequence < receipt.sequence
539
+ && event.kind === 'plan_recompiled' && event.plan_epoch === epoch + 1);
540
+ const invalidated = events.some((event) => event.sequence < receipt.sequence
541
+ && event.kind === 'context_invalidated' && event.plan_epoch === epoch + 1
542
+ && event.subject?.kind === 'todo' && event.subject.ref === receipt.todo_id);
543
+ if (!witnessed || !continued || !recompiled || invalidated) return false;
544
+ }
545
+ return true;
546
+ })();
547
+ if (dispatch === undefined) {
548
+ return reject('not_dispatched');
549
+ }
550
+ const dispatchPayload = dispatch.payload ?? {};
551
+ if (payload.executor_handle !== dispatchPayload.executor_handle
552
+ || payload.worktree_id !== dispatchPayload.worktree_id
553
+ || payload.packet_digest !== dispatchPayload.packet_digest) {
554
+ return reject('binding_mismatch');
555
+ }
556
+ if (typeof plan.base_sha === 'string' && payload.base_sha !== plan.base_sha) {
557
+ return reject('base_mismatch');
558
+ }
559
+ if (receipt.plan_epoch !== plan.plan_epoch && !originBindingRetained) {
560
+ return reject('epoch_mismatch');
561
+ }
562
+ // dispatchが旧epochのTODOが現epoch receiptを名乗る場合はepoch_rebound必須
563
+ // (rebindなしのepoch自称を受理しない。Decision 7.3/7.4)。
564
+ if (dispatchEventForReceipt.plan_epoch !== plan.plan_epoch && !originBindingRetained) {
565
+ const rebound = state.rebinds[receipt.todo_id];
566
+ if (rebound === undefined
567
+ || rebound.payload?.new_plan_epoch !== plan.plan_epoch
568
+ || rebound.sequence >= receipt.sequence) {
569
+ return reject('unrebound_epoch');
570
+ }
571
+ }
572
+ // checkpoint観測が存在するTODOは、receipt以前の最後の観測checkpointとの
573
+ // digest一致とobserved_diff一致を要求(executor自己申告をbinding証拠に
574
+ // しない。RC3-F)。bindingは同一dispatch attemptへscope=最後のdispatch
575
+ // 以降のcheckpointだけが対象(redispatch後のreceiptを旧attemptの
576
+ // checkpointで塞がない。RC3-G)。
577
+ const observedCheckpoints = state.checkpoints.filter((entry) => (
578
+ entry.todo_id === receipt.todo_id
579
+ && entry.sequence > dispatch.sequence
580
+ && entry.sequence < receipt.sequence
581
+ // supervisorがI/O警報を確かめるために撮ったcheckpointはexecutorの申告境界では
582
+ // ないので、bindingの基準にしない(ADR 0143。engine側と同一規則)。
583
+ && entry.payload?.observed_by !== 'supervisor_probe'
584
+ ));
585
+ if (observedCheckpoints.length > 0) {
586
+ const last = observedCheckpoints[observedCheckpoints.length - 1].payload;
587
+ if (typeof last?.checkpoint_digest === 'string'
588
+ && payload.checkpoint_digest !== last.checkpoint_digest) {
589
+ return reject('checkpoint_mismatch');
590
+ }
591
+ if (last?.diff?.entries !== undefined && Array.isArray(last.diff.entries)) {
592
+ const expected = JSON.stringify(last.diff.entries.map(({ path, change }) => ({ path, change })));
593
+ const reported = JSON.stringify((payload.observed_diff ?? []).map(({ path, change }) => ({ path, change })));
594
+ if (expected !== reported) {
595
+ return reject('checkpoint_mismatch');
596
+ }
597
+ }
598
+ }
599
+ if (receipt.rejected_sequence !== null) {
600
+ return reject('recorded_rejection');
601
+ }
602
+ if (freezeBoundary === null) {
603
+ return { ...base, decision: 'accepted', reason: null, detail: null };
604
+ }
605
+ if (receipt.accepted_sequence !== null && receipt.accepted_sequence <= freezeBoundary) {
606
+ return { ...base, decision: 'accepted', reason: null, detail: null };
607
+ }
608
+ if (receipt.sequence > freezeBoundary) {
609
+ return reject('post_freeze');
610
+ }
611
+ // frozen prefix内のpending receiptは、実証済みwitness documentの
612
+ // receipt_bindingsが当該receiptをbindする場合だけ有効になる(Decision 7.5)。
613
+ const witnessRecord = state.witnesses[receipt.todo_id];
614
+ if (!witnessedForContinue(witnessRecord, receipt.todo_id)) {
615
+ return reject('witness_unproven');
616
+ }
617
+ const bound = witnessRecord.payload.witness.receipt_bindings.some((binding) => (
618
+ binding.receipt_id === receipt.receipt_id
619
+ && binding.recorded_sequence === receipt.sequence
620
+ && binding.within_frozen_prefix === true
621
+ ));
622
+ if (bound) {
623
+ return { ...base, decision: 'accepted', reason: null, detail: null };
624
+ }
625
+ return reject('unbound_receipt');
626
+ });
627
+
628
+ return { decisions };
629
+ }
630
+
631
+ const INVARIANT_DIGEST_FIELDS = Object.freeze([
632
+ 'todo_input',
633
+ 'boundary_manifest',
634
+ 'validator',
635
+ 'context_content',
636
+ ]);
637
+
638
+ /**
639
+ * carry-over witnessのinvariant digestを提供bytesから再計算する
640
+ * (ADR 0044 Decision 7.2・7.5)。1 fieldでも証明不能ならvalid falseとし、
641
+ * reasonsへ具体的なfailureと`carry_over_unprovable`を返す。
642
+ */
643
+ export function verifyCarryOverWitness(options = {}) {
644
+ const { witness, sources } = options;
645
+ const reasons = [];
646
+ if (witness === null || typeof witness !== 'object' || Array.isArray(witness)
647
+ || witness.invariant_digests === null
648
+ || typeof witness.invariant_digests !== 'object') {
649
+ return { valid: false, reasons: ['witness_shape', 'carry_over_unprovable'] };
650
+ }
651
+ if (sources === null || typeof sources !== 'object' || Array.isArray(sources)) {
652
+ return { valid: false, reasons: ['sources_shape', 'carry_over_unprovable'] };
653
+ }
654
+
655
+ if (!Number.isSafeInteger(witness.predecessor_epoch)
656
+ || !Number.isSafeInteger(witness.successor_epoch)
657
+ || witness.successor_epoch <= witness.predecessor_epoch) {
658
+ reasons.push('epoch_ordering');
659
+ }
660
+
661
+ for (const field of INVARIANT_DIGEST_FIELDS) {
662
+ if (sources[field] === undefined) {
663
+ reasons.push(`missing_source:${field}`);
664
+ continue;
665
+ }
666
+ let recomputed = null;
667
+ try {
668
+ recomputed = digestArtifact(sources[field]);
669
+ } catch {
670
+ reasons.push(`source_not_canonical:${field}`);
671
+ continue;
672
+ }
673
+ if (witness.invariant_digests[field] !== recomputed) {
674
+ reasons.push(`invariant_digest_mismatch:${field}`);
675
+ }
676
+ }
677
+
678
+ if (typeof witness.witness_digest === 'string') {
679
+ const projection = {};
680
+ for (const key of Object.keys(witness)) {
681
+ if (key !== 'witness_digest') projection[key] = witness[key];
682
+ }
683
+ let selfDigest = null;
684
+ try {
685
+ selfDigest = digestArtifact(projection);
686
+ } catch {
687
+ selfDigest = null;
688
+ }
689
+ if (witness.witness_digest !== selfDigest) {
690
+ reasons.push('witness_digest_mismatch');
691
+ }
692
+ } else {
693
+ reasons.push('witness_digest_mismatch');
694
+ }
695
+
696
+ if (reasons.length > 0) {
697
+ reasons.push('carry_over_unprovable');
698
+ return { valid: false, reasons };
699
+ }
700
+ return { valid: true, reasons: [] };
701
+ }