@quolu/lattice 0.52.0 → 0.52.2

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 (182) hide show
  1. package/LICENSE +147 -147
  2. package/README.ja.md +355 -355
  3. package/README.md +258 -258
  4. package/bin/lattice-mcp.mjs +0 -0
  5. package/bin/lattice-scripted-adapter.mjs +0 -0
  6. package/bin/lattice-scripted-worker.mjs +0 -0
  7. package/bin/lattice-work-order-adapter.mjs +0 -0
  8. package/bin/lattice.mjs +0 -0
  9. package/docs/bridge-setup.md +132 -132
  10. package/docs/schemas/lattice.executor_packet.v1.schema.json +57 -57
  11. package/docs/schemas/lattice.executor_receipt.v1.schema.json +66 -66
  12. package/docs/schemas/lattice.phase_todo_revision.v3.schema.json +360 -360
  13. package/docs/schemas/lattice.plan_create_input.v1.schema.json +56 -56
  14. package/docs/schemas/lattice.plan_create_input.v2.schema.json +72 -72
  15. package/docs/schemas/lattice.plan_create_input.v3.schema.json +81 -81
  16. package/docs/schemas/lattice.plan_create_input.v4.schema.json +85 -85
  17. package/docs/schemas/lattice.run_request.v1.schema.json +238 -238
  18. package/docs/schemas/lattice.runtime_adapter_capabilities.v2.schema.json +55 -55
  19. package/docs/schemas/lattice.runtime_adapter_registration_input.v1.schema.json +78 -78
  20. package/docs/schemas/lattice.runtime_adapter_registration_input.v2.schema.json +86 -86
  21. package/docs/schemas/lattice.todo_extraction.v2.schema.json +298 -298
  22. package/docs/schemas/lattice.todo_extraction.v3.schema.json +146 -146
  23. package/docs/schemas/lattice.todo_revision.v2.schema.json +260 -260
  24. package/docs/schemas/lattice.todo_revision_set.v3.schema.json +363 -363
  25. package/package.json +103 -103
  26. package/sensor/LICENSE +21 -21
  27. package/sensor/NOTICE +19 -19
  28. package/sensor/dist/bin/lattice-sensor.js +9 -9
  29. package/sensor/dist/db/index.js +24 -24
  30. package/sensor/dist/db/migrations.js +41 -41
  31. package/sensor/dist/db/queries.js +164 -164
  32. package/sensor/dist/db/schema.sql +205 -205
  33. package/sensor/dist/directory.js +5 -5
  34. package/sensor/dist/extraction/wasm/tree-sitter-c_sharp.wasm +0 -0
  35. package/sensor/dist/extraction/wasm/tree-sitter-cfml.wasm +0 -0
  36. package/sensor/dist/extraction/wasm/tree-sitter-cfquery.wasm +0 -0
  37. package/sensor/dist/extraction/wasm/tree-sitter-cfscript.wasm +0 -0
  38. package/sensor/dist/extraction/wasm/tree-sitter-cobol.wasm +0 -0
  39. package/sensor/dist/extraction/wasm/tree-sitter-erlang.wasm +0 -0
  40. package/sensor/dist/extraction/wasm/tree-sitter-go.wasm +0 -0
  41. package/sensor/dist/extraction/wasm/tree-sitter-java.wasm +0 -0
  42. package/sensor/dist/extraction/wasm/tree-sitter-javascript.wasm +0 -0
  43. package/sensor/dist/extraction/wasm/tree-sitter-nix.wasm +0 -0
  44. package/sensor/dist/extraction/wasm/tree-sitter-pascal.wasm +0 -0
  45. package/sensor/dist/extraction/wasm/tree-sitter-python.wasm +0 -0
  46. package/sensor/dist/extraction/wasm/tree-sitter-tsx.wasm +0 -0
  47. package/sensor/dist/extraction/wasm/tree-sitter-typescript.wasm +0 -0
  48. package/sensor/dist/extraction/wasm/tree-sitter-vbnet.wasm +0 -0
  49. package/sensor/dist/mcp/liveness-watchdog.js +53 -53
  50. package/sensor/dist/mcp/server-instructions.js +95 -95
  51. package/sensor/package.json +56 -56
  52. package/src/artifact-contracts-v2.mjs +325 -325
  53. package/src/artifact-contracts.mjs +895 -895
  54. package/src/boundary-compiler.mjs +712 -712
  55. package/src/boundary-observation-compiler-v2.mjs +344 -344
  56. package/src/bounded-seam.mjs +230 -230
  57. package/src/bridge-address.mjs +107 -107
  58. package/src/bridge-cli.mjs +297 -297
  59. package/src/bridge-config.mjs +346 -346
  60. package/src/bridge-daemon.mjs +378 -378
  61. package/src/bridge-launch-agent.mjs +323 -323
  62. package/src/bridge-registrar.mjs +102 -102
  63. package/src/bridge-server.mjs +376 -376
  64. package/src/cli-help.mjs +308 -308
  65. package/src/cli-stdio.mjs +40 -40
  66. package/src/control-compiler.mjs +532 -532
  67. package/src/dag-chain.mjs +261 -261
  68. package/src/factory-diagnostics.mjs +188 -188
  69. package/src/hash-chain.mjs +76 -76
  70. package/src/hooks-cli.mjs +1057 -1053
  71. package/src/isolation-runner.mjs +409 -409
  72. package/src/node-version-guard.mjs +44 -44
  73. package/src/project-cli.mjs +697 -697
  74. package/src/project-identity.mjs +130 -130
  75. package/src/rc1-black-box-oracle.mjs +906 -906
  76. package/src/rc1-comparison.mjs +154 -154
  77. package/src/rc1-evidence-bundle.mjs +456 -456
  78. package/src/rc1-v4-campaign.mjs +708 -708
  79. package/src/rc1-v4-transform.mjs +467 -467
  80. package/src/rc1-v5-artifact-set.mjs +855 -855
  81. package/src/rc1-v5-behavior-evidence.mjs +594 -594
  82. package/src/rc1-v5-campaign.mjs +797 -797
  83. package/src/rc1-v5-transform.mjs +421 -421
  84. package/src/rc1-v6-artifact-set.mjs +807 -807
  85. package/src/rc1-v6-behavior-evidence.mjs +273 -273
  86. package/src/rc1-v6-campaign.mjs +623 -623
  87. package/src/rc1-v6-causal-binding.mjs +473 -473
  88. package/src/rc1-v6-measurement.mjs +313 -313
  89. package/src/rc2-artifact-set.mjs +1584 -1584
  90. package/src/rc2-campaign.mjs +1506 -1502
  91. package/src/rc2-delivery-policy-front-end.mjs +1079 -1079
  92. package/src/rc2-delivery-policy-oracle.mjs +134 -134
  93. package/src/rc2-delivery-policy-transform.mjs +1127 -1127
  94. package/src/rc2-rc1-transfer-front-end.mjs +511 -511
  95. package/src/rc3-actual-dogfood.mjs +652 -652
  96. package/src/rc3-dogfood-scaffold.mjs +315 -315
  97. package/src/rc3-scripted-campaign.mjs +1383 -1383
  98. package/src/rc4-stage1-dogfood.mjs +673 -673
  99. package/src/runtime-adapter-registry.mjs +524 -520
  100. package/src/runtime-cli.mjs +4588 -4584
  101. package/src/runtime-contracts.mjs +824 -824
  102. package/src/runtime-control-store.mjs +604 -600
  103. package/src/runtime-controller-protocol.mjs +587 -587
  104. package/src/runtime-decision-verifier.mjs +701 -701
  105. package/src/runtime-diff-observer.mjs +361 -361
  106. package/src/runtime-direct-os-observer.mjs +301 -301
  107. package/src/runtime-driver-state.mjs +166 -162
  108. package/src/runtime-engine.mjs +779 -779
  109. package/src/runtime-errors.mjs +356 -356
  110. package/src/runtime-event-store.mjs +189 -189
  111. package/src/runtime-front-end.mjs +925 -925
  112. package/src/runtime-gate-store.mjs +481 -477
  113. package/src/runtime-hold-recompile.mjs +916 -916
  114. package/src/runtime-io-sentinel.mjs +391 -391
  115. package/src/runtime-lifecycle-lock.mjs +294 -290
  116. package/src/runtime-managed-supervisor.mjs +1490 -1490
  117. package/src/runtime-multi-epoch-store.mjs +838 -834
  118. package/src/runtime-projection.mjs +269 -269
  119. package/src/runtime-pull-intake.mjs +1192 -1188
  120. package/src/runtime-scripted-adapter-controller.mjs +1160 -1156
  121. package/src/runtime-scripted-executor.mjs +163 -163
  122. package/src/runtime-scripted-worktree.mjs +104 -104
  123. package/src/runtime-seam-resolve.mjs +428 -428
  124. package/src/runtime-seam-treatment.mjs +173 -173
  125. package/src/runtime-socket-owner.mjs +125 -125
  126. package/src/runtime-work-order-contracts.mjs +91 -91
  127. package/src/runtime-work-order-controller.mjs +1171 -1167
  128. package/src/runtime-worktree-executor.mjs +199 -199
  129. package/src/schedulability-compiler-v2.mjs +303 -303
  130. package/src/schedulability-verifier-v2.mjs +317 -317
  131. package/src/seam-apply.mjs +549 -549
  132. package/src/seam-commit-shared.mjs +22 -22
  133. package/src/seam-commit-transform.mjs +81 -81
  134. package/src/seam-commit.mjs +18 -18
  135. package/src/seam-cost.mjs +322 -322
  136. package/src/seam-derivation.mjs +188 -188
  137. package/src/seam-gate.mjs +146 -146
  138. package/src/seam-proposal-contracts.mjs +446 -446
  139. package/src/seam-proposal-queries.mjs +521 -521
  140. package/src/seam-proposal.mjs +2011 -2011
  141. package/src/seam-ref.mjs +33 -33
  142. package/src/seam-rewrite.mjs +286 -286
  143. package/src/seam-transform.mjs +554 -554
  144. package/src/seam-verification.mjs +260 -260
  145. package/src/sensor-adapter.mjs +432 -432
  146. package/src/sensor-cli.mjs +139 -139
  147. package/src/sensor-diff.mjs +661 -661
  148. package/src/sensor-node-runtime.mjs +53 -53
  149. package/src/sensor-runtime.mjs +52 -52
  150. package/src/timestamp-contract.mjs +8 -8
  151. package/src/todo-audit-pending.mjs +91 -91
  152. package/src/todo-chain.mjs +178 -178
  153. package/src/todo-cli.mjs +3141 -3126
  154. package/src/todo-contracts.mjs +728 -728
  155. package/src/todo-dashboard-registry.mjs +573 -573
  156. package/src/todo-dispatch-shape.mjs +190 -190
  157. package/src/todo-gantt-html-independence.mjs +239 -239
  158. package/src/todo-gantt-html-shared.mjs +226 -226
  159. package/src/todo-gantt-html-style.mjs +131 -131
  160. package/src/todo-gantt-html.mjs +248 -248
  161. package/src/todo-gantt-layout.mjs +974 -974
  162. package/src/todo-gantt-live.mjs +361 -361
  163. package/src/todo-gantt-nested.mjs +263 -263
  164. package/src/todo-gantt-presentation.mjs +217 -217
  165. package/src/todo-gantt-scope.mjs +123 -123
  166. package/src/todo-gantt-svg.mjs +353 -353
  167. package/src/todo-independence-contracts.mjs +595 -595
  168. package/src/todo-independence-guidance.mjs +322 -322
  169. package/src/todo-independence.mjs +640 -640
  170. package/src/todo-markdown-renderer.mjs +260 -260
  171. package/src/todo-migration.mjs +448 -448
  172. package/src/todo-narrative-anchor.mjs +130 -130
  173. package/src/todo-note-store.mjs +629 -625
  174. package/src/todo-parallel-candidates.mjs +114 -114
  175. package/src/todo-revision.mjs +995 -995
  176. package/src/todo-split.mjs +472 -472
  177. package/src/todo-status.mjs +690 -690
  178. package/src/todo-store-git-transaction.mjs +418 -418
  179. package/src/todo-store.mjs +4322 -4314
  180. package/src/treatment-compiler.mjs +728 -728
  181. package/src/treatment-runner.mjs +656 -656
  182. 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
+ }