@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,595 +1,595 @@
1
- import {
2
- exactRecord,
3
- isNonNegativeSafeInteger,
4
- isStrictTodoTimestamp,
5
- isTodoDigest,
6
- isTodoIdentifier,
7
- todoSelfDigest,
8
- } from './todo-contracts.mjs';
9
- import {
10
- RUN_REQUEST_CLAIM_MODE,
11
- RUN_REQUEST_DECLARATIVE_SCHEMA,
12
- RUN_REQUEST_PREDICTION_SCHEMA,
13
- RUN_REQUEST_SCHEMA,
14
- explainRunRequest,
15
- selfDigest as runtimeSelfDigest,
16
- } from './runtime-contracts.mjs';
17
- import { TODO_INDEPENDENCE_GUIDANCE_CODES } from './todo-independence-guidance.mjs';
18
-
19
- export const TODO_WITNESS_SET_SCHEMA = 'lattice.todo_witness_set.v5';
20
- /**
21
- * まだ受理する旧witness set契約。v3以前の厳密なcompile意味を変えず、
22
- * 既存宣言を書き換えさせないために読み口を残す。
23
- */
24
- export const TODO_WITNESS_SET_LEGACY_SCHEMAS = Object.freeze([
25
- 'lattice.todo_witness_set.v4',
26
- 'lattice.todo_witness_set.v3',
27
- 'lattice.todo_witness_set.v2',
28
- 'lattice.todo_witness_set.v1',
29
- ]);
30
- export const TODO_WITNESS_SET_SCHEMAS = Object.freeze([
31
- TODO_WITNESS_SET_SCHEMA,
32
- ...TODO_WITNESS_SET_LEGACY_SCHEMAS,
33
- ]);
34
- /** 宣言できる欄はversionごとに違う。どの版から使えるかを1箇所で持つ。 */
35
- const CONCERN_ANCHOR_SCHEMAS = Object.freeze([
36
- TODO_WITNESS_SET_SCHEMA,
37
- 'lattice.todo_witness_set.v4',
38
- 'lattice.todo_witness_set.v3',
39
- 'lattice.todo_witness_set.v2',
40
- ]);
41
- const CREATES_SCHEMAS = Object.freeze([
42
- TODO_WITNESS_SET_SCHEMA, 'lattice.todo_witness_set.v4', 'lattice.todo_witness_set.v3',
43
- ]);
44
-
45
- /** 1 taskが宣言できるconcern anchorの資源数と、資源あたりのsymbol数の上限。 */
46
- export const TODO_CONCERN_ANCHOR_LIMIT = 256;
47
- export const TODO_INDEPENDENCE_SCHEMA = 'lattice.todo_independence.v5';
48
- export const TODO_INDEPENDENCE_PROJECTION_SCHEMA = 'lattice.todo_independence_projection.v2';
49
- export const TODO_INDEPENDENCE_LEGACY_MARKER_SCHEMA = 'lattice.todo_independence_legacy_marker.v1';
50
- export const TODO_INDEPENDENCE_LEGACY_SCHEMAS = Object.freeze([
51
- 'lattice.todo_independence.v1',
52
- 'lattice.todo_independence.v2',
53
- 'lattice.todo_independence.v3',
54
- // v4は`scope_expanded`を持つが`first_seen_path_count`/`growth_events`が整数固定だった。
55
- // v5でnull(=subset gapで累計が分からない)を足したので、**同じ版名で形を変えず**版を上げる。
56
- // 同名変更は「新readerが旧artifactを読める」後方互換しか満たさず、**旧readerが新artifactを
57
- // 読めない前方互換破壊**になる(2026-08-09のP0と同型・suzune [1183] / kanade [1200])。
58
- 'lattice.todo_independence.v4',
59
- ]);
60
-
61
- /** boundary compileが一度に扱えるToDo数(runtime front-endのMAX_COLLECTIONと同じ閉じ方)。 */
62
- export const TODO_INDEPENDENCE_TASK_LIMIT = 256;
63
- export const TODO_INDEPENDENCE_LIST_LIMIT = 4_096;
64
- export const TODO_INDEPENDENCE_COVERAGE = Object.freeze([
65
- 'verified', 'stale', 'superseded', 'missing',
66
- ]);
67
-
68
- /** conflictを生んだresourceの種別。切断可能性の導出はこの種別だけを根拠にする。 */
69
- export const TODO_INDEPENDENCE_CONFLICT_KINDS = Object.freeze([
70
- 'symbol', 'path', 'state', 'effect', 'line',
71
- ]);
72
-
73
- /** 切断可能性。code seamで切れるのはsymbol/path起因のconflictだけ(ADR 0128 Decision 2)。 */
74
- export const TODO_INDEPENDENCE_SEVERABILITY = Object.freeze(['code_seam', 'serial']);
75
-
76
- /**
77
- * conflict kindから切断可能性を導く。
78
- *
79
- * 共有state/effectと意味的なlineはcode seamでは切断できない(RC1 boundary compilerの分類規則と同一)。
80
- * read×write交差から実体化される`rw-*`はkind=stateなのでserialへ倒れる。seam候補を
81
- * 見逃す方向にしか外れない保守的な誤りであり、既知の限界として受け入れる。
82
- */
83
- export function severabilityOfConflictKind(kind) {
84
- return ['symbol', 'path'].includes(kind) ? 'code_seam' : 'serial';
85
- }
86
-
87
- const GIT_SHA = /^[0-9a-f]{40}$/u;
88
- const PROBE_BASE_SHA = '0'.repeat(40);
89
-
90
- export const isGitSha = (value) => typeof value === 'string' && GIT_SHA.test(value);
91
-
92
- function plain(value) {
93
- return value !== null && typeof value === 'object' && !Array.isArray(value)
94
- && Object.getPrototypeOf(value) === Object.prototype;
95
- }
96
-
97
- /**
98
- * 既知の旧independence artifactを、単なるschema文字列だけでなく版をまたいで不変な
99
- * identity fieldの型までで識別する。本体構造や自己digestは旧契約validatorの再実装に
100
- * なるため、ここでは検査しない。
101
- */
102
- export function isTodoIndependenceLegacyArtifactIdentity(value) {
103
- return plain(value) && TODO_INDEPENDENCE_LEGACY_SCHEMAS.includes(value.schema)
104
- && isTodoIdentifier(value.project_id) && isTodoIdentifier(value.plan_key)
105
- && isTodoIdentifier(value.plan_version) && isTodoDigest(value.topology_digest)
106
- && isTodoDigest(value.witness_set_digest) && isTodoDigest(value.result_digest)
107
- && isGitSha(value.base_sha);
108
- }
109
-
110
- /**
111
- * task別の宣言膨張の記録(v4から)。**判定を持たない——事実だけを置く。**
112
- *
113
- * 何が膨張の原因か(上流の契約確定に追従したのか、自分の変更の後始末か、元から在った面の
114
- * 見落としか、思いつきで盛ったのか)は**機械には区別できない**。区別するのはAIであり、
115
- * 装置が言えるのは「増えた」「何が増減した」「何回目か」「合流点か」までである。
116
- * ここへ閾値も勧告も置かない(勧告はadvisory側の仕事)。
117
- *
118
- * `removed_paths`を持つのは、増加だけを見ると「盛った」と「宣言をやり直した」が同じ顔に
119
- * なるからである。増減を両方見ないと読み手が誤る。
120
- *
121
- * `first_seen_path_count`と`growth_events`が**null**なのは「**このtaskの履歴が分からない**」で、
122
- * 0とは違う。witnessはwave単位のsubsetで書き出せるので、比較相手のartifactに当該taskが
123
- * 居ないことがある——その時に今回を初回と置くと、本当の初回と膨張回数が消える。
124
- * 初回(比較相手が無い)は`compared_witness_digest=null`+数値、
125
- * subset入替(比較相手はあるがtaskが居ない)は`compared_witness_digest=digest`+nullで区別する。
126
- */
127
- function scopeExpansionEntry(value) {
128
- return plain(value)
129
- && exactRecord(value, [
130
- 'task_id', 'compared_witness_digest', 'first_seen_path_count', 'path_count',
131
- 'added_paths', 'removed_paths', 'growth_events', 'gate_shape',
132
- ])
133
- && isTodoIdentifier(value.task_id)
134
- && (value.compared_witness_digest === null || isTodoDigest(value.compared_witness_digest))
135
- && (value.first_seen_path_count === null
136
- || (Number.isSafeInteger(value.first_seen_path_count) && value.first_seen_path_count >= 0))
137
- && Number.isSafeInteger(value.path_count) && value.path_count >= 0
138
- && boundedList(value.added_paths, (entry) => typeof entry === 'string' && entry.length > 0)
139
- && strictlySorted(value.added_paths)
140
- && boundedList(value.removed_paths, (entry) => typeof entry === 'string' && entry.length > 0)
141
- && strictlySorted(value.removed_paths)
142
- && (value.growth_events === null
143
- || (Number.isSafeInteger(value.growth_events) && value.growth_events >= 0))
144
- && typeof value.gate_shape === 'boolean';
145
- }
146
-
147
- function boundedList(value, validator, limit = TODO_INDEPENDENCE_LIST_LIMIT) {
148
- return Array.isArray(value) && value.length <= limit && value.every(validator);
149
- }
150
-
151
- const compareText = (left, right) => left < right ? -1 : left > right ? 1 : 0;
152
-
153
- function strictlySorted(values, key = (value) => value) {
154
- return values.every((value, index) => index === 0 || compareText(key(values[index - 1]), key(value)) < 0);
155
- }
156
-
157
- function sorted(values, key = (value) => value) {
158
- return values.every((value, index) => index === 0 || compareText(key(values[index - 1]), key(value)) <= 0);
159
- }
160
-
161
- function boundedText(value, maximumBytes = 4_096) {
162
- return typeof value === 'string' && value.length > 0
163
- && Buffer.byteLength(value) <= maximumBytes
164
- && !/[\u0000-\u001f\u007f]/u.test(value);
165
- }
166
-
167
- /**
168
- * witness setを、同じ宣言を持つ`lattice.run_request.v1`へ写す。
169
- *
170
- * manual witnessとsensor query setの判定正本は`explainRunRequest`だけが持つ(ADR 0123)。
171
- * ここで同じ規則を書き直すと契約が二箇所へ分裂するため、検証もcompileもこの合成を通す。
172
- * `requestId`はplan identityとwitness digestから導出され、run lifecycleへは登録されない。
173
- *
174
- * `concern_anchors`はここで落とす。並列可否の判定はこの合成requestだけを入力にするので、
175
- * 落としておけばconcern宣言が判定へ影響しないことが構造で保証される(testの主張ではない)。
176
- * 宣言はseam束縛の入力であり、witness setから直接読む。
177
- */
178
- export function synthesizeWitnessRunRequest(witnessSet, { baseSha, requestId }) {
179
- const taskIds = Object.keys(witnessSet.manual_witness).sort(compareText);
180
- const request = {
181
- schema: witnessSet.schema === TODO_WITNESS_SET_SCHEMA
182
- ? RUN_REQUEST_SCHEMA
183
- : witnessSet.schema === 'lattice.todo_witness_set.v4'
184
- ? RUN_REQUEST_PREDICTION_SCHEMA
185
- : RUN_REQUEST_DECLARATIVE_SCHEMA,
186
- request_id: requestId,
187
- repo: { base_sha: baseSha, root_kind: 'git' },
188
- capacity: witnessSet.capacity,
189
- todos: taskIds.map((taskId) => ({ todo_id: taskId })),
190
- manual_witness: Object.fromEntries(taskIds.map((taskId) => {
191
- const { concern_anchors: _concernAnchors, ...boundary } = witnessSet.manual_witness[taskId];
192
- return [taskId, boundary];
193
- })),
194
- sensor_query_set: witnessSet.sensor_query_set,
195
- executor_capability: { adapters: ['todo-independence'] },
196
- claim_mode: RUN_REQUEST_CLAIM_MODE,
197
- request_digest: '',
198
- };
199
- request.request_digest = runtimeSelfDigest(request, 'request_digest');
200
- return request;
201
- }
202
-
203
- /**
204
- * 1 taskのconcern anchor宣言を検査する。
205
- *
206
- * `within`はそのtask自身が`owns`で主張している資源に限る。所有していない資源の内側に
207
- * 担当を主張させない。symbol名の実在・資源内包含・task間排他はsensorとcompile側が見る。
208
- */
209
- function explainConcernAnchors(anchors, owns, at) {
210
- const reject = (reason, path) => ({ valid: false, reason, path });
211
- if (!Array.isArray(anchors) || anchors.length > TODO_CONCERN_ANCHOR_LIMIT) {
212
- return reject('bounded_collection_violation', at);
213
- }
214
- const ownedKeys = new Set(owns.map((own) => `${own.kind}\0${own.target}`));
215
- for (const [index, entry] of anchors.entries()) {
216
- const entryAt = `${at}/${index}`;
217
- if (!exactRecord(entry, ['within', 'symbols'])) {
218
- return reject('unexpected_or_missing_keys', entryAt);
219
- }
220
- if (!exactRecord(entry.within, ['kind', 'target'])
221
- || !['symbol', 'path'].includes(entry.within.kind)) {
222
- return reject('invalid_concern_anchor_resource', `${entryAt}/within`);
223
- }
224
- const targetValid = entry.within.kind === 'path'
225
- ? repoRelativeResourceTarget(entry.within.target)
226
- : boundedText(entry.within.target, 1_024);
227
- if (!targetValid) return reject('invalid_concern_anchor_resource', `${entryAt}/within`);
228
- if (!ownedKeys.has(`${entry.within.kind}\0${entry.within.target}`)) {
229
- return reject('concern_anchor_resource_not_owned', `${entryAt}/within`);
230
- }
231
- if (!Array.isArray(entry.symbols) || entry.symbols.length < 1
232
- || entry.symbols.length > TODO_CONCERN_ANCHOR_LIMIT) {
233
- return reject('bounded_collection_violation', `${entryAt}/symbols`);
234
- }
235
- if (!entry.symbols.every((symbol) => boundedText(symbol, 1_024))) {
236
- return reject('invalid_concern_anchor_symbol', `${entryAt}/symbols`);
237
- }
238
- if (!strictlySorted(entry.symbols)) {
239
- return reject('unsorted_or_duplicate_collection', `${entryAt}/symbols`);
240
- }
241
- }
242
- if (!strictlySorted(anchors, (entry) => `${entry.within.kind}\0${entry.within.target}`)) {
243
- return reject('unsorted_or_duplicate_collection', at);
244
- }
245
- return { valid: true };
246
- }
247
-
248
- /**
249
- * `lattice.todo_witness_set.v2`(およびconcern anchorを持たない旧v1)を検証し、
250
- * 拒否理由とpathを返す。
251
- *
252
- * 自分のshapeだけをここで見て、witness本体はprobe requestへ合成して
253
- * `explainRunRequest`へ委譲する。probeのbase_shaは検証専用の定数であり永続化しない。
254
- * `concern_anchors`はprobeから落ちるので、ここだけが判定正本になる。
255
- */
256
- export function explainTodoWitnessSet(value) {
257
- const reject = (reason, at = '') => ({ valid: false, reason, path: at });
258
- try {
259
- if (!exactRecord(value, [
260
- 'schema', 'project_id', 'plan_key', 'capacity', 'sensor_query_set',
261
- 'manual_witness', 'witness_set_digest',
262
- ])) return reject('unexpected_or_missing_top_level_keys');
263
- if (!TODO_WITNESS_SET_SCHEMAS.includes(value.schema)) return reject('schema_mismatch', '/schema');
264
- if (!isTodoIdentifier(value.project_id)) return reject('invalid_identifier', '/project_id');
265
- if (!isTodoIdentifier(value.plan_key)) return reject('invalid_identifier', '/plan_key');
266
- if (!plain(value.manual_witness)) return reject('not_an_object', '/manual_witness');
267
- const taskIds = Object.keys(value.manual_witness);
268
- if (taskIds.length < 1 || taskIds.length > TODO_INDEPENDENCE_TASK_LIMIT) {
269
- return reject('bounded_collection_violation', '/manual_witness');
270
- }
271
- if (!taskIds.every(isTodoIdentifier)) return reject('invalid_identifier', '/manual_witness');
272
- if (value.witness_set_digest !== todoSelfDigest(value, 'witness_set_digest')) {
273
- return reject('witness_set_digest_mismatch', '/witness_set_digest');
274
- }
275
- for (const taskId of taskIds) {
276
- const witness = value.manual_witness[taskId];
277
- if (plain(witness) && Object.hasOwn(witness, 'lines')
278
- && value.schema !== TODO_WITNESS_SET_SCHEMA) {
279
- return reject('lines_require_witness_set_v5', `/manual_witness/${taskId}/lines`);
280
- }
281
- }
282
- const probe = synthesizeWitnessRunRequest(value, {
283
- baseSha: PROBE_BASE_SHA, requestId: 'witness-set-probe',
284
- });
285
- const explained = explainRunRequest(probe);
286
- if (!explained.valid) return reject(explained.reason, explained.path);
287
- // 版ごとの欄。probeはRUN_REQUEST_SCHEMAで合成するので創作宣言はそこを通ってしまう。
288
- // 旧版の宣言に新しい欄を書けないことは、ここだけが見る。
289
- for (const taskId of taskIds) {
290
- const witness = value.manual_witness[taskId];
291
- if (!CREATES_SCHEMAS.includes(value.schema)
292
- && witness.owns.some((own) => Object.hasOwn(own, 'creates'))) {
293
- return reject('creates_require_witness_set_v3', `/manual_witness/${taskId}/owns`);
294
- }
295
- const at = `/manual_witness/${taskId}/concern_anchors`;
296
- if (!Object.hasOwn(witness, 'concern_anchors')) continue;
297
- if (!CONCERN_ANCHOR_SCHEMAS.includes(value.schema)) {
298
- return reject('concern_anchors_require_witness_set_v2', at);
299
- }
300
- const anchors = explainConcernAnchors(witness.concern_anchors, witness.owns, at);
301
- if (!anchors.valid) return reject(anchors.reason, anchors.path);
302
- }
303
- return { valid: true };
304
- } catch {
305
- return reject('non_canonical_witness_set_bytes');
306
- }
307
- }
308
-
309
- export function validateTodoWitnessSet(value) {
310
- return explainTodoWitnessSet(value).valid;
311
- }
312
-
313
- function conflictEntry(value) {
314
- return exactRecord(value, ['task_ids', 'resource_id'])
315
- && Array.isArray(value.task_ids) && value.task_ids.length === 2
316
- && value.task_ids.every(isTodoIdentifier)
317
- && compareText(value.task_ids[0], value.task_ids[1]) < 0
318
- && boundedText(value.resource_id);
319
- }
320
-
321
- function repoRelativeResourceTarget(value) {
322
- if (!boundedText(value) || value.startsWith('/') || value.includes('\\')
323
- || /^[A-Za-z]:/u.test(value)) return false;
324
- const body = value.endsWith('/') ? value.slice(0, -1) : value;
325
- return body.length > 0 && body.split('/')
326
- .every((segment) => segment !== '' && segment !== '.' && segment !== '..');
327
- }
328
-
329
- function conflictResourceEntry(value) {
330
- if (!exactRecord(value, ['resource_id', 'kind', 'target'])
331
- || !boundedText(value.resource_id)
332
- || !TODO_INDEPENDENCE_CONFLICT_KINDS.includes(value.kind)) return false;
333
- return value.kind === 'path'
334
- ? repoRelativeResourceTarget(value.target)
335
- : boundedText(value.target);
336
- }
337
-
338
- export function isTodoIndependenceLegacyMarker(value) {
339
- return exactRecord(value, [
340
- 'schema', 'legacy_schema', 'project_id', 'plan_key', 'plan_version',
341
- 'topology_digest', 'base_sha',
342
- ])
343
- && value.schema === TODO_INDEPENDENCE_LEGACY_MARKER_SCHEMA
344
- && TODO_INDEPENDENCE_LEGACY_SCHEMAS.includes(value.legacy_schema)
345
- && isTodoIdentifier(value.project_id) && isTodoIdentifier(value.plan_key)
346
- && value.plan_version === null && value.topology_digest === null && value.base_sha === null;
347
- }
348
-
349
- /**
350
- * task別の宣言境界。鮮度判定をartifactとgit diffだけで閉じるために持つ(ADR 0128 Decision 4)。
351
- * witness setを読み直さないので、参照コストは定数のまま保たれる。
352
- */
353
- function taskBoundaryEntry(value) {
354
- return exactRecord(value, ['task_id', 'paths'])
355
- && isTodoIdentifier(value.task_id)
356
- && Array.isArray(value.paths) && value.paths.length <= TODO_INDEPENDENCE_LIST_LIMIT
357
- && value.paths.every((path) => boundedText(path)) && strictlySorted(value.paths);
358
- }
359
-
360
- function precedenceEntry(value) {
361
- return exactRecord(value, ['from_task_id', 'to_task_id', 'reason'])
362
- && isTodoIdentifier(value.from_task_id) && isTodoIdentifier(value.to_task_id)
363
- && value.from_task_id !== value.to_task_id && boundedText(value.reason);
364
- }
365
-
366
- function unknownEntry(value) {
367
- return exactRecord(value, ['task_id', 'kind', 'ref'])
368
- && isTodoIdentifier(value.task_id) && isTodoIdentifier(value.kind) && boundedText(value.ref);
369
- }
370
-
371
- function waveEntry(value) {
372
- return exactRecord(value, ['task_ids'])
373
- && Array.isArray(value.task_ids) && value.task_ids.length >= 1
374
- && value.task_ids.length <= TODO_INDEPENDENCE_TASK_LIMIT
375
- && value.task_ids.every(isTodoIdentifier) && strictlySorted(value.task_ids);
376
- }
377
-
378
- function wavePlan(value, taskIds) {
379
- if (value === null) return true;
380
- if (!exactRecord(value, ['waves', 'minimum_feasible_waves'])
381
- || !boundedList(value.waves, waveEntry, TODO_INDEPENDENCE_TASK_LIMIT)
382
- || !isNonNegativeSafeInteger(value.minimum_feasible_waves)
383
- || value.waves.length !== value.minimum_feasible_waves) return false;
384
- const scheduled = value.waves.flatMap((wave) => wave.task_ids).sort(compareText);
385
- return scheduled.length === taskIds.length
386
- && scheduled.every((taskId, index) => taskId === taskIds[index]);
387
- }
388
-
389
- /**
390
- * `lattice.todo_independence.v3`。
391
- *
392
- * conflict/precedence/unknownはnormalized boundary graphから採る(ADR 0127 Decision 4)。
393
- * conflictは`conflict_resources`のresource idを参照し、kindとnormalized targetを一度だけ保持する。
394
- * `task_boundaries`はtask別の宣言境界で、鮮度のdiff交差判定をartifactだけで閉じるために持つ。
395
- * `wave_plan`はschedulability compileが`compiled`を返した時だけ持ち、unknownが残る間はnullになる。
396
- * verdictに現れないペアをverified独立と読めるのは、両taskにunknownが無いときだけである。
397
- */
398
- export function validateTodoIndependence(value) {
399
- try {
400
- if (!exactRecord(value, [
401
- 'schema', 'project_id', 'plan_key', 'plan_version', 'topology_digest', 'base_sha',
402
- 'witness_set_digest', 'compiled_at', 'task_ids', 'task_boundaries', 'conflict_resources', 'conflicts',
403
- 'precedences', 'unknowns', 'wave_plan', 'scope_expanded', 'outcome', 'result_digest',
404
- ])) return false;
405
- if (value.schema !== TODO_INDEPENDENCE_SCHEMA) return false;
406
- if (!isTodoIdentifier(value.project_id) || !isTodoIdentifier(value.plan_key)
407
- || !isTodoIdentifier(value.plan_version)) return false;
408
- if (!isTodoDigest(value.topology_digest) || !isGitSha(value.base_sha)
409
- || !isTodoDigest(value.witness_set_digest)
410
- || !isStrictTodoTimestamp(value.compiled_at)) return false;
411
- if (!Array.isArray(value.task_ids) || value.task_ids.length < 1
412
- || value.task_ids.length > TODO_INDEPENDENCE_TASK_LIMIT
413
- || !value.task_ids.every(isTodoIdentifier) || !strictlySorted(value.task_ids)) return false;
414
- const known = new Set(value.task_ids);
415
- // 宣言境界はcompile対象taskとちょうど一対一で対応する。欠けたtaskがあると、
416
- // そのtaskだけ交差判定ができないのに全体はverifiedを名乗れてしまう。
417
- if (!boundedList(value.task_boundaries, taskBoundaryEntry, TODO_INDEPENDENCE_TASK_LIMIT)
418
- || value.task_boundaries.length !== value.task_ids.length
419
- || !strictlySorted(value.task_boundaries, (entry) => entry.task_id)
420
- || !value.task_boundaries.every((entry, index) => entry.task_id === value.task_ids[index])) {
421
- return false;
422
- }
423
- // scope_expandedはtask_idsとちょうど一対一。欠けたtaskがあると「膨張していない」と
424
- // 「まだ見ていない」が同じ顔になる。
425
- if (!boundedList(value.scope_expanded, scopeExpansionEntry, TODO_INDEPENDENCE_TASK_LIMIT)
426
- || value.scope_expanded.length !== value.task_ids.length
427
- || !strictlySorted(value.scope_expanded, (entry) => entry.task_id)
428
- || !value.scope_expanded.every((entry, index) => entry.task_id === value.task_ids[index])) {
429
- return false;
430
- }
431
- if (!boundedList(value.conflict_resources, conflictResourceEntry)
432
- || !strictlySorted(value.conflict_resources, (entry) => entry.resource_id)) return false;
433
- const conflictResourceIds = new Set(value.conflict_resources.map(({ resource_id: id }) => id));
434
- if (!boundedList(value.conflicts, conflictEntry)
435
- || !value.conflicts.every((entry) => entry.task_ids.every((taskId) => known.has(taskId)))
436
- || !value.conflicts.every((entry) => conflictResourceIds.has(entry.resource_id))
437
- || !strictlySorted(value.conflicts, (entry) => (
438
- `${entry.task_ids[0]}\0${entry.task_ids[1]}\0${entry.resource_id}`))) return false;
439
- const referencedResourceIds = new Set(value.conflicts.map(({ resource_id: id }) => id));
440
- if (referencedResourceIds.size !== value.conflict_resources.length) return false;
441
- if (!boundedList(value.precedences, precedenceEntry)
442
- || !value.precedences.every((entry) => known.has(entry.from_task_id) && known.has(entry.to_task_id))
443
- || !strictlySorted(value.precedences, (entry) => (
444
- `${entry.from_task_id}\0${entry.to_task_id}\0${entry.reason}`))) return false;
445
- if (!boundedList(value.unknowns, unknownEntry)
446
- || !value.unknowns.every((entry) => known.has(entry.task_id))
447
- || !sorted(value.unknowns, (entry) => (
448
- `${entry.task_id}\0${entry.kind}\0${entry.ref}`))) return false;
449
- if (!['compiled', 'unknown'].includes(value.outcome)) return false;
450
- // unknownが残る限りwave planは主張しない。compiledならunknownは空でなければならない。
451
- if (value.outcome === 'compiled' && value.unknowns.length > 0) return false;
452
- if (value.outcome === 'unknown' && value.wave_plan !== null) return false;
453
- if (!wavePlan(value.wave_plan, value.task_ids)) return false;
454
- return isTodoDigest(value.result_digest)
455
- && value.result_digest === todoSelfDigest(value, 'result_digest');
456
- } catch {
457
- return false;
458
- }
459
- }
460
-
461
- function groupEntry(value) {
462
- return exactRecord(value, ['task_ids'])
463
- && Array.isArray(value.task_ids) && value.task_ids.length >= 1
464
- && value.task_ids.length <= TODO_INDEPENDENCE_TASK_LIMIT
465
- && value.task_ids.every(isTodoIdentifier) && strictlySorted(value.task_ids);
466
- }
467
-
468
- function pairKindAndSeverability(value) {
469
- // conflictはkindを持ちseverabilityがそこから導かれる。precedenceは順序制約であって
470
- // resource起因ではないためkindを持たず、常にserialになる。
471
- if (value.type === 'conflict') {
472
- return TODO_INDEPENDENCE_CONFLICT_KINDS.includes(value.kind)
473
- && value.severability === severabilityOfConflictKind(value.kind);
474
- }
475
- return value.kind === null && value.severability === 'serial';
476
- }
477
-
478
- function serializeEntry(value) {
479
- return exactRecord(value, ['task_ids', 'type', 'detail', 'kind', 'severability'])
480
- && Array.isArray(value.task_ids) && value.task_ids.length === 2
481
- && value.task_ids.every(isTodoIdentifier)
482
- && compareText(value.task_ids[0], value.task_ids[1]) < 0
483
- && ['conflict', 'precedence'].includes(value.type) && boundedText(value.detail)
484
- && pairKindAndSeverability(value);
485
- }
486
-
487
- /**
488
- * 着手候補と進行中ToDoの競合。
489
- *
490
- * v1は両端がready集合のペアだけを採っており、片端がactiveのペアを黙って捨てていた。
491
- * 着手する瞬間に最も危ないのはこの組み合わせなので、独立した面として持つ(ADR 0128 Decision 3)。
492
- */
493
- function activeConflictEntry(value) {
494
- return exactRecord(value, [
495
- 'ready_task_id', 'active_task_id', 'type', 'detail', 'kind', 'severability',
496
- ]) && isTodoIdentifier(value.ready_task_id) && isTodoIdentifier(value.active_task_id)
497
- && value.ready_task_id !== value.active_task_id
498
- && ['conflict', 'precedence'].includes(value.type) && boundedText(value.detail)
499
- && pairKindAndSeverability(value);
500
- }
501
-
502
- /**
503
- * 案内。単一正本のcatalogが返した形をそのまま載せる(ADR 0130 Decision 1・2)。
504
- * 面ごとに文言を組み立て直さないため、ここではshapeだけを検査する。
505
- */
506
- function guidanceEntry(value) {
507
- return exactRecord(value, ['code', 'message', 'next_action'])
508
- && TODO_INDEPENDENCE_GUIDANCE_CODES.includes(value.code)
509
- && boundedText(value.message) && isTodoIdentifier(value.next_action);
510
- }
511
-
512
- /**
513
- * 鮮度の内訳。`coverage`がsha水準の事実を述べるのに対し、こちらは
514
- * 「そのdiffが宣言境界に触れたか」というtask単位の事実を述べる(ADR 0128 Decision 4)。
515
- */
516
- function driftEntry(value) {
517
- if (value === null) return true;
518
- return exactRecord(value, ['base_reachable', 'changed_path_count', 'intersecting_task_ids'])
519
- && typeof value.base_reachable === 'boolean'
520
- && isNonNegativeSafeInteger(value.changed_path_count)
521
- && Array.isArray(value.intersecting_task_ids)
522
- && value.intersecting_task_ids.length <= TODO_INDEPENDENCE_TASK_LIMIT
523
- && value.intersecting_task_ids.every(isTodoIdentifier)
524
- && strictlySorted(value.intersecting_task_ids);
525
- }
526
-
527
- function unknownTaskEntry(value) {
528
- return exactRecord(value, ['task_id', 'unknowns'])
529
- && isTodoIdentifier(value.task_id)
530
- && Array.isArray(value.unknowns) && value.unknowns.length >= 1
531
- && value.unknowns.length <= TODO_INDEPENDENCE_LIST_LIMIT
532
- && value.unknowns.every((entry) => exactRecord(entry, ['kind', 'ref'])
533
- && isTodoIdentifier(entry.kind) && boundedText(entry.ref));
534
- }
535
-
536
- /**
537
- * `lattice.todo_independence_projection.v2`。
538
- *
539
- * ready frontierを「検証済み並列グループ」「直列化すべき組」「未検査」へ分けた読み出し面。
540
- * v2は進行中ToDoとの競合(`conflicts_with_active`)と鮮度の内訳(`drift`)を加える。
541
- * `todo_status_result`と`dispatch_frontier`は変更せず、加算の別面として持つ(ADR 0124の規律)。
542
- */
543
- export function validateTodoIndependenceProjection(value) {
544
- try {
545
- if (!exactRecord(value, [
546
- 'schema', 'project_id', 'plan_key', 'coverage', 'compiled_base_sha', 'current_base_sha',
547
- 'plan_version', 'topology_digest', 'active_task_ids', 'uncovered_active_task_ids',
548
- 'drift', 'guidance', 'frontier', 'result_digest',
549
- ])) return false;
550
- if (value.schema !== TODO_INDEPENDENCE_PROJECTION_SCHEMA) return false;
551
- if (!isTodoIdentifier(value.project_id)) return false;
552
- if (value.plan_key !== null && !isTodoIdentifier(value.plan_key)) return false;
553
- if (!TODO_INDEPENDENCE_COVERAGE.includes(value.coverage)) return false;
554
- if (value.compiled_base_sha !== null && !isGitSha(value.compiled_base_sha)) return false;
555
- if (!isGitSha(value.current_base_sha)) return false;
556
- if (value.plan_version !== null && !isTodoIdentifier(value.plan_version)) return false;
557
- if (value.topology_digest !== null && !isTodoDigest(value.topology_digest)) return false;
558
- // 記録が無い状態でcompile済みidentityを名乗らない。
559
- if (value.coverage === 'missing'
560
- && (value.compiled_base_sha !== null || value.topology_digest !== null)) return false;
561
- if (['verified', 'stale'].includes(value.coverage) && value.compiled_base_sha === null) return false;
562
- if (value.coverage === 'superseded' && value.compiled_base_sha === null
563
- && (value.plan_version !== null || value.topology_digest !== null)) return false;
564
- for (const key of ['active_task_ids', 'uncovered_active_task_ids']) {
565
- if (!Array.isArray(value[key]) || value[key].length > TODO_INDEPENDENCE_TASK_LIMIT
566
- || !value[key].every(isTodoIdentifier) || !strictlySorted(value[key])) return false;
567
- }
568
- // 宣言のないactiveは考慮済みactiveの部分集合でなければならない。
569
- const activeSet = new Set(value.active_task_ids);
570
- if (!value.uncovered_active_task_ids.every((taskId) => activeSet.has(taskId))) return false;
571
- if (!driftEntry(value.drift)) return false;
572
- if (!guidanceEntry(value.guidance)) return false;
573
- // driftはstale時の内訳。それ以外で語ると鮮度の事実を二重に主張することになる。
574
- if (value.coverage !== 'stale' && value.drift !== null) return false;
575
- if (!exactRecord(value.frontier, [
576
- 'parallel_groups', 'serialize_pairs', 'conflicts_with_active', 'unknown',
577
- ])) return false;
578
- if (!boundedList(value.frontier.parallel_groups, groupEntry, TODO_INDEPENDENCE_TASK_LIMIT)
579
- || !strictlySorted(value.frontier.parallel_groups, (entry) => entry.task_ids[0])) return false;
580
- if (!boundedList(value.frontier.serialize_pairs, serializeEntry)
581
- || !strictlySorted(value.frontier.serialize_pairs, (entry) => (
582
- `${entry.task_ids[0]}\0${entry.task_ids[1]}\0${entry.type}\0${entry.detail}`))) return false;
583
- if (!boundedList(value.frontier.conflicts_with_active, activeConflictEntry)
584
- || !strictlySorted(value.frontier.conflicts_with_active, (entry) => (
585
- `${entry.ready_task_id}\0${entry.active_task_id}\0${entry.type}\0${entry.detail}`))) {
586
- return false;
587
- }
588
- if (!boundedList(value.frontier.unknown, unknownTaskEntry, TODO_INDEPENDENCE_TASK_LIMIT)
589
- || !strictlySorted(value.frontier.unknown, (entry) => entry.task_id)) return false;
590
- return isTodoDigest(value.result_digest)
591
- && value.result_digest === todoSelfDigest(value, 'result_digest');
592
- } catch {
593
- return false;
594
- }
595
- }
1
+ import {
2
+ exactRecord,
3
+ isNonNegativeSafeInteger,
4
+ isStrictTodoTimestamp,
5
+ isTodoDigest,
6
+ isTodoIdentifier,
7
+ todoSelfDigest,
8
+ } from './todo-contracts.mjs';
9
+ import {
10
+ RUN_REQUEST_CLAIM_MODE,
11
+ RUN_REQUEST_DECLARATIVE_SCHEMA,
12
+ RUN_REQUEST_PREDICTION_SCHEMA,
13
+ RUN_REQUEST_SCHEMA,
14
+ explainRunRequest,
15
+ selfDigest as runtimeSelfDigest,
16
+ } from './runtime-contracts.mjs';
17
+ import { TODO_INDEPENDENCE_GUIDANCE_CODES } from './todo-independence-guidance.mjs';
18
+
19
+ export const TODO_WITNESS_SET_SCHEMA = 'lattice.todo_witness_set.v5';
20
+ /**
21
+ * まだ受理する旧witness set契約。v3以前の厳密なcompile意味を変えず、
22
+ * 既存宣言を書き換えさせないために読み口を残す。
23
+ */
24
+ export const TODO_WITNESS_SET_LEGACY_SCHEMAS = Object.freeze([
25
+ 'lattice.todo_witness_set.v4',
26
+ 'lattice.todo_witness_set.v3',
27
+ 'lattice.todo_witness_set.v2',
28
+ 'lattice.todo_witness_set.v1',
29
+ ]);
30
+ export const TODO_WITNESS_SET_SCHEMAS = Object.freeze([
31
+ TODO_WITNESS_SET_SCHEMA,
32
+ ...TODO_WITNESS_SET_LEGACY_SCHEMAS,
33
+ ]);
34
+ /** 宣言できる欄はversionごとに違う。どの版から使えるかを1箇所で持つ。 */
35
+ const CONCERN_ANCHOR_SCHEMAS = Object.freeze([
36
+ TODO_WITNESS_SET_SCHEMA,
37
+ 'lattice.todo_witness_set.v4',
38
+ 'lattice.todo_witness_set.v3',
39
+ 'lattice.todo_witness_set.v2',
40
+ ]);
41
+ const CREATES_SCHEMAS = Object.freeze([
42
+ TODO_WITNESS_SET_SCHEMA, 'lattice.todo_witness_set.v4', 'lattice.todo_witness_set.v3',
43
+ ]);
44
+
45
+ /** 1 taskが宣言できるconcern anchorの資源数と、資源あたりのsymbol数の上限。 */
46
+ export const TODO_CONCERN_ANCHOR_LIMIT = 256;
47
+ export const TODO_INDEPENDENCE_SCHEMA = 'lattice.todo_independence.v5';
48
+ export const TODO_INDEPENDENCE_PROJECTION_SCHEMA = 'lattice.todo_independence_projection.v2';
49
+ export const TODO_INDEPENDENCE_LEGACY_MARKER_SCHEMA = 'lattice.todo_independence_legacy_marker.v1';
50
+ export const TODO_INDEPENDENCE_LEGACY_SCHEMAS = Object.freeze([
51
+ 'lattice.todo_independence.v1',
52
+ 'lattice.todo_independence.v2',
53
+ 'lattice.todo_independence.v3',
54
+ // v4は`scope_expanded`を持つが`first_seen_path_count`/`growth_events`が整数固定だった。
55
+ // v5でnull(=subset gapで累計が分からない)を足したので、**同じ版名で形を変えず**版を上げる。
56
+ // 同名変更は「新readerが旧artifactを読める」後方互換しか満たさず、**旧readerが新artifactを
57
+ // 読めない前方互換破壊**になる(2026-08-09のP0と同型・suzune [1183] / kanade [1200])。
58
+ 'lattice.todo_independence.v4',
59
+ ]);
60
+
61
+ /** boundary compileが一度に扱えるToDo数(runtime front-endのMAX_COLLECTIONと同じ閉じ方)。 */
62
+ export const TODO_INDEPENDENCE_TASK_LIMIT = 256;
63
+ export const TODO_INDEPENDENCE_LIST_LIMIT = 4_096;
64
+ export const TODO_INDEPENDENCE_COVERAGE = Object.freeze([
65
+ 'verified', 'stale', 'superseded', 'missing',
66
+ ]);
67
+
68
+ /** conflictを生んだresourceの種別。切断可能性の導出はこの種別だけを根拠にする。 */
69
+ export const TODO_INDEPENDENCE_CONFLICT_KINDS = Object.freeze([
70
+ 'symbol', 'path', 'state', 'effect', 'line',
71
+ ]);
72
+
73
+ /** 切断可能性。code seamで切れるのはsymbol/path起因のconflictだけ(ADR 0128 Decision 2)。 */
74
+ export const TODO_INDEPENDENCE_SEVERABILITY = Object.freeze(['code_seam', 'serial']);
75
+
76
+ /**
77
+ * conflict kindから切断可能性を導く。
78
+ *
79
+ * 共有state/effectと意味的なlineはcode seamでは切断できない(RC1 boundary compilerの分類規則と同一)。
80
+ * read×write交差から実体化される`rw-*`はkind=stateなのでserialへ倒れる。seam候補を
81
+ * 見逃す方向にしか外れない保守的な誤りであり、既知の限界として受け入れる。
82
+ */
83
+ export function severabilityOfConflictKind(kind) {
84
+ return ['symbol', 'path'].includes(kind) ? 'code_seam' : 'serial';
85
+ }
86
+
87
+ const GIT_SHA = /^[0-9a-f]{40}$/u;
88
+ const PROBE_BASE_SHA = '0'.repeat(40);
89
+
90
+ export const isGitSha = (value) => typeof value === 'string' && GIT_SHA.test(value);
91
+
92
+ function plain(value) {
93
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
94
+ && Object.getPrototypeOf(value) === Object.prototype;
95
+ }
96
+
97
+ /**
98
+ * 既知の旧independence artifactを、単なるschema文字列だけでなく版をまたいで不変な
99
+ * identity fieldの型までで識別する。本体構造や自己digestは旧契約validatorの再実装に
100
+ * なるため、ここでは検査しない。
101
+ */
102
+ export function isTodoIndependenceLegacyArtifactIdentity(value) {
103
+ return plain(value) && TODO_INDEPENDENCE_LEGACY_SCHEMAS.includes(value.schema)
104
+ && isTodoIdentifier(value.project_id) && isTodoIdentifier(value.plan_key)
105
+ && isTodoIdentifier(value.plan_version) && isTodoDigest(value.topology_digest)
106
+ && isTodoDigest(value.witness_set_digest) && isTodoDigest(value.result_digest)
107
+ && isGitSha(value.base_sha);
108
+ }
109
+
110
+ /**
111
+ * task別の宣言膨張の記録(v4から)。**判定を持たない——事実だけを置く。**
112
+ *
113
+ * 何が膨張の原因か(上流の契約確定に追従したのか、自分の変更の後始末か、元から在った面の
114
+ * 見落としか、思いつきで盛ったのか)は**機械には区別できない**。区別するのはAIであり、
115
+ * 装置が言えるのは「増えた」「何が増減した」「何回目か」「合流点か」までである。
116
+ * ここへ閾値も勧告も置かない(勧告はadvisory側の仕事)。
117
+ *
118
+ * `removed_paths`を持つのは、増加だけを見ると「盛った」と「宣言をやり直した」が同じ顔に
119
+ * なるからである。増減を両方見ないと読み手が誤る。
120
+ *
121
+ * `first_seen_path_count`と`growth_events`が**null**なのは「**このtaskの履歴が分からない**」で、
122
+ * 0とは違う。witnessはwave単位のsubsetで書き出せるので、比較相手のartifactに当該taskが
123
+ * 居ないことがある——その時に今回を初回と置くと、本当の初回と膨張回数が消える。
124
+ * 初回(比較相手が無い)は`compared_witness_digest=null`+数値、
125
+ * subset入替(比較相手はあるがtaskが居ない)は`compared_witness_digest=digest`+nullで区別する。
126
+ */
127
+ function scopeExpansionEntry(value) {
128
+ return plain(value)
129
+ && exactRecord(value, [
130
+ 'task_id', 'compared_witness_digest', 'first_seen_path_count', 'path_count',
131
+ 'added_paths', 'removed_paths', 'growth_events', 'gate_shape',
132
+ ])
133
+ && isTodoIdentifier(value.task_id)
134
+ && (value.compared_witness_digest === null || isTodoDigest(value.compared_witness_digest))
135
+ && (value.first_seen_path_count === null
136
+ || (Number.isSafeInteger(value.first_seen_path_count) && value.first_seen_path_count >= 0))
137
+ && Number.isSafeInteger(value.path_count) && value.path_count >= 0
138
+ && boundedList(value.added_paths, (entry) => typeof entry === 'string' && entry.length > 0)
139
+ && strictlySorted(value.added_paths)
140
+ && boundedList(value.removed_paths, (entry) => typeof entry === 'string' && entry.length > 0)
141
+ && strictlySorted(value.removed_paths)
142
+ && (value.growth_events === null
143
+ || (Number.isSafeInteger(value.growth_events) && value.growth_events >= 0))
144
+ && typeof value.gate_shape === 'boolean';
145
+ }
146
+
147
+ function boundedList(value, validator, limit = TODO_INDEPENDENCE_LIST_LIMIT) {
148
+ return Array.isArray(value) && value.length <= limit && value.every(validator);
149
+ }
150
+
151
+ const compareText = (left, right) => left < right ? -1 : left > right ? 1 : 0;
152
+
153
+ function strictlySorted(values, key = (value) => value) {
154
+ return values.every((value, index) => index === 0 || compareText(key(values[index - 1]), key(value)) < 0);
155
+ }
156
+
157
+ function sorted(values, key = (value) => value) {
158
+ return values.every((value, index) => index === 0 || compareText(key(values[index - 1]), key(value)) <= 0);
159
+ }
160
+
161
+ function boundedText(value, maximumBytes = 4_096) {
162
+ return typeof value === 'string' && value.length > 0
163
+ && Buffer.byteLength(value) <= maximumBytes
164
+ && !/[\u0000-\u001f\u007f]/u.test(value);
165
+ }
166
+
167
+ /**
168
+ * witness setを、同じ宣言を持つ`lattice.run_request.v1`へ写す。
169
+ *
170
+ * manual witnessとsensor query setの判定正本は`explainRunRequest`だけが持つ(ADR 0123)。
171
+ * ここで同じ規則を書き直すと契約が二箇所へ分裂するため、検証もcompileもこの合成を通す。
172
+ * `requestId`はplan identityとwitness digestから導出され、run lifecycleへは登録されない。
173
+ *
174
+ * `concern_anchors`はここで落とす。並列可否の判定はこの合成requestだけを入力にするので、
175
+ * 落としておけばconcern宣言が判定へ影響しないことが構造で保証される(testの主張ではない)。
176
+ * 宣言はseam束縛の入力であり、witness setから直接読む。
177
+ */
178
+ export function synthesizeWitnessRunRequest(witnessSet, { baseSha, requestId }) {
179
+ const taskIds = Object.keys(witnessSet.manual_witness).sort(compareText);
180
+ const request = {
181
+ schema: witnessSet.schema === TODO_WITNESS_SET_SCHEMA
182
+ ? RUN_REQUEST_SCHEMA
183
+ : witnessSet.schema === 'lattice.todo_witness_set.v4'
184
+ ? RUN_REQUEST_PREDICTION_SCHEMA
185
+ : RUN_REQUEST_DECLARATIVE_SCHEMA,
186
+ request_id: requestId,
187
+ repo: { base_sha: baseSha, root_kind: 'git' },
188
+ capacity: witnessSet.capacity,
189
+ todos: taskIds.map((taskId) => ({ todo_id: taskId })),
190
+ manual_witness: Object.fromEntries(taskIds.map((taskId) => {
191
+ const { concern_anchors: _concernAnchors, ...boundary } = witnessSet.manual_witness[taskId];
192
+ return [taskId, boundary];
193
+ })),
194
+ sensor_query_set: witnessSet.sensor_query_set,
195
+ executor_capability: { adapters: ['todo-independence'] },
196
+ claim_mode: RUN_REQUEST_CLAIM_MODE,
197
+ request_digest: '',
198
+ };
199
+ request.request_digest = runtimeSelfDigest(request, 'request_digest');
200
+ return request;
201
+ }
202
+
203
+ /**
204
+ * 1 taskのconcern anchor宣言を検査する。
205
+ *
206
+ * `within`はそのtask自身が`owns`で主張している資源に限る。所有していない資源の内側に
207
+ * 担当を主張させない。symbol名の実在・資源内包含・task間排他はsensorとcompile側が見る。
208
+ */
209
+ function explainConcernAnchors(anchors, owns, at) {
210
+ const reject = (reason, path) => ({ valid: false, reason, path });
211
+ if (!Array.isArray(anchors) || anchors.length > TODO_CONCERN_ANCHOR_LIMIT) {
212
+ return reject('bounded_collection_violation', at);
213
+ }
214
+ const ownedKeys = new Set(owns.map((own) => `${own.kind}\0${own.target}`));
215
+ for (const [index, entry] of anchors.entries()) {
216
+ const entryAt = `${at}/${index}`;
217
+ if (!exactRecord(entry, ['within', 'symbols'])) {
218
+ return reject('unexpected_or_missing_keys', entryAt);
219
+ }
220
+ if (!exactRecord(entry.within, ['kind', 'target'])
221
+ || !['symbol', 'path'].includes(entry.within.kind)) {
222
+ return reject('invalid_concern_anchor_resource', `${entryAt}/within`);
223
+ }
224
+ const targetValid = entry.within.kind === 'path'
225
+ ? repoRelativeResourceTarget(entry.within.target)
226
+ : boundedText(entry.within.target, 1_024);
227
+ if (!targetValid) return reject('invalid_concern_anchor_resource', `${entryAt}/within`);
228
+ if (!ownedKeys.has(`${entry.within.kind}\0${entry.within.target}`)) {
229
+ return reject('concern_anchor_resource_not_owned', `${entryAt}/within`);
230
+ }
231
+ if (!Array.isArray(entry.symbols) || entry.symbols.length < 1
232
+ || entry.symbols.length > TODO_CONCERN_ANCHOR_LIMIT) {
233
+ return reject('bounded_collection_violation', `${entryAt}/symbols`);
234
+ }
235
+ if (!entry.symbols.every((symbol) => boundedText(symbol, 1_024))) {
236
+ return reject('invalid_concern_anchor_symbol', `${entryAt}/symbols`);
237
+ }
238
+ if (!strictlySorted(entry.symbols)) {
239
+ return reject('unsorted_or_duplicate_collection', `${entryAt}/symbols`);
240
+ }
241
+ }
242
+ if (!strictlySorted(anchors, (entry) => `${entry.within.kind}\0${entry.within.target}`)) {
243
+ return reject('unsorted_or_duplicate_collection', at);
244
+ }
245
+ return { valid: true };
246
+ }
247
+
248
+ /**
249
+ * `lattice.todo_witness_set.v2`(およびconcern anchorを持たない旧v1)を検証し、
250
+ * 拒否理由とpathを返す。
251
+ *
252
+ * 自分のshapeだけをここで見て、witness本体はprobe requestへ合成して
253
+ * `explainRunRequest`へ委譲する。probeのbase_shaは検証専用の定数であり永続化しない。
254
+ * `concern_anchors`はprobeから落ちるので、ここだけが判定正本になる。
255
+ */
256
+ export function explainTodoWitnessSet(value) {
257
+ const reject = (reason, at = '') => ({ valid: false, reason, path: at });
258
+ try {
259
+ if (!exactRecord(value, [
260
+ 'schema', 'project_id', 'plan_key', 'capacity', 'sensor_query_set',
261
+ 'manual_witness', 'witness_set_digest',
262
+ ])) return reject('unexpected_or_missing_top_level_keys');
263
+ if (!TODO_WITNESS_SET_SCHEMAS.includes(value.schema)) return reject('schema_mismatch', '/schema');
264
+ if (!isTodoIdentifier(value.project_id)) return reject('invalid_identifier', '/project_id');
265
+ if (!isTodoIdentifier(value.plan_key)) return reject('invalid_identifier', '/plan_key');
266
+ if (!plain(value.manual_witness)) return reject('not_an_object', '/manual_witness');
267
+ const taskIds = Object.keys(value.manual_witness);
268
+ if (taskIds.length < 1 || taskIds.length > TODO_INDEPENDENCE_TASK_LIMIT) {
269
+ return reject('bounded_collection_violation', '/manual_witness');
270
+ }
271
+ if (!taskIds.every(isTodoIdentifier)) return reject('invalid_identifier', '/manual_witness');
272
+ if (value.witness_set_digest !== todoSelfDigest(value, 'witness_set_digest')) {
273
+ return reject('witness_set_digest_mismatch', '/witness_set_digest');
274
+ }
275
+ for (const taskId of taskIds) {
276
+ const witness = value.manual_witness[taskId];
277
+ if (plain(witness) && Object.hasOwn(witness, 'lines')
278
+ && value.schema !== TODO_WITNESS_SET_SCHEMA) {
279
+ return reject('lines_require_witness_set_v5', `/manual_witness/${taskId}/lines`);
280
+ }
281
+ }
282
+ const probe = synthesizeWitnessRunRequest(value, {
283
+ baseSha: PROBE_BASE_SHA, requestId: 'witness-set-probe',
284
+ });
285
+ const explained = explainRunRequest(probe);
286
+ if (!explained.valid) return reject(explained.reason, explained.path);
287
+ // 版ごとの欄。probeはRUN_REQUEST_SCHEMAで合成するので創作宣言はそこを通ってしまう。
288
+ // 旧版の宣言に新しい欄を書けないことは、ここだけが見る。
289
+ for (const taskId of taskIds) {
290
+ const witness = value.manual_witness[taskId];
291
+ if (!CREATES_SCHEMAS.includes(value.schema)
292
+ && witness.owns.some((own) => Object.hasOwn(own, 'creates'))) {
293
+ return reject('creates_require_witness_set_v3', `/manual_witness/${taskId}/owns`);
294
+ }
295
+ const at = `/manual_witness/${taskId}/concern_anchors`;
296
+ if (!Object.hasOwn(witness, 'concern_anchors')) continue;
297
+ if (!CONCERN_ANCHOR_SCHEMAS.includes(value.schema)) {
298
+ return reject('concern_anchors_require_witness_set_v2', at);
299
+ }
300
+ const anchors = explainConcernAnchors(witness.concern_anchors, witness.owns, at);
301
+ if (!anchors.valid) return reject(anchors.reason, anchors.path);
302
+ }
303
+ return { valid: true };
304
+ } catch {
305
+ return reject('non_canonical_witness_set_bytes');
306
+ }
307
+ }
308
+
309
+ export function validateTodoWitnessSet(value) {
310
+ return explainTodoWitnessSet(value).valid;
311
+ }
312
+
313
+ function conflictEntry(value) {
314
+ return exactRecord(value, ['task_ids', 'resource_id'])
315
+ && Array.isArray(value.task_ids) && value.task_ids.length === 2
316
+ && value.task_ids.every(isTodoIdentifier)
317
+ && compareText(value.task_ids[0], value.task_ids[1]) < 0
318
+ && boundedText(value.resource_id);
319
+ }
320
+
321
+ function repoRelativeResourceTarget(value) {
322
+ if (!boundedText(value) || value.startsWith('/') || value.includes('\\')
323
+ || /^[A-Za-z]:/u.test(value)) return false;
324
+ const body = value.endsWith('/') ? value.slice(0, -1) : value;
325
+ return body.length > 0 && body.split('/')
326
+ .every((segment) => segment !== '' && segment !== '.' && segment !== '..');
327
+ }
328
+
329
+ function conflictResourceEntry(value) {
330
+ if (!exactRecord(value, ['resource_id', 'kind', 'target'])
331
+ || !boundedText(value.resource_id)
332
+ || !TODO_INDEPENDENCE_CONFLICT_KINDS.includes(value.kind)) return false;
333
+ return value.kind === 'path'
334
+ ? repoRelativeResourceTarget(value.target)
335
+ : boundedText(value.target);
336
+ }
337
+
338
+ export function isTodoIndependenceLegacyMarker(value) {
339
+ return exactRecord(value, [
340
+ 'schema', 'legacy_schema', 'project_id', 'plan_key', 'plan_version',
341
+ 'topology_digest', 'base_sha',
342
+ ])
343
+ && value.schema === TODO_INDEPENDENCE_LEGACY_MARKER_SCHEMA
344
+ && TODO_INDEPENDENCE_LEGACY_SCHEMAS.includes(value.legacy_schema)
345
+ && isTodoIdentifier(value.project_id) && isTodoIdentifier(value.plan_key)
346
+ && value.plan_version === null && value.topology_digest === null && value.base_sha === null;
347
+ }
348
+
349
+ /**
350
+ * task別の宣言境界。鮮度判定をartifactとgit diffだけで閉じるために持つ(ADR 0128 Decision 4)。
351
+ * witness setを読み直さないので、参照コストは定数のまま保たれる。
352
+ */
353
+ function taskBoundaryEntry(value) {
354
+ return exactRecord(value, ['task_id', 'paths'])
355
+ && isTodoIdentifier(value.task_id)
356
+ && Array.isArray(value.paths) && value.paths.length <= TODO_INDEPENDENCE_LIST_LIMIT
357
+ && value.paths.every((path) => boundedText(path)) && strictlySorted(value.paths);
358
+ }
359
+
360
+ function precedenceEntry(value) {
361
+ return exactRecord(value, ['from_task_id', 'to_task_id', 'reason'])
362
+ && isTodoIdentifier(value.from_task_id) && isTodoIdentifier(value.to_task_id)
363
+ && value.from_task_id !== value.to_task_id && boundedText(value.reason);
364
+ }
365
+
366
+ function unknownEntry(value) {
367
+ return exactRecord(value, ['task_id', 'kind', 'ref'])
368
+ && isTodoIdentifier(value.task_id) && isTodoIdentifier(value.kind) && boundedText(value.ref);
369
+ }
370
+
371
+ function waveEntry(value) {
372
+ return exactRecord(value, ['task_ids'])
373
+ && Array.isArray(value.task_ids) && value.task_ids.length >= 1
374
+ && value.task_ids.length <= TODO_INDEPENDENCE_TASK_LIMIT
375
+ && value.task_ids.every(isTodoIdentifier) && strictlySorted(value.task_ids);
376
+ }
377
+
378
+ function wavePlan(value, taskIds) {
379
+ if (value === null) return true;
380
+ if (!exactRecord(value, ['waves', 'minimum_feasible_waves'])
381
+ || !boundedList(value.waves, waveEntry, TODO_INDEPENDENCE_TASK_LIMIT)
382
+ || !isNonNegativeSafeInteger(value.minimum_feasible_waves)
383
+ || value.waves.length !== value.minimum_feasible_waves) return false;
384
+ const scheduled = value.waves.flatMap((wave) => wave.task_ids).sort(compareText);
385
+ return scheduled.length === taskIds.length
386
+ && scheduled.every((taskId, index) => taskId === taskIds[index]);
387
+ }
388
+
389
+ /**
390
+ * `lattice.todo_independence.v3`。
391
+ *
392
+ * conflict/precedence/unknownはnormalized boundary graphから採る(ADR 0127 Decision 4)。
393
+ * conflictは`conflict_resources`のresource idを参照し、kindとnormalized targetを一度だけ保持する。
394
+ * `task_boundaries`はtask別の宣言境界で、鮮度のdiff交差判定をartifactだけで閉じるために持つ。
395
+ * `wave_plan`はschedulability compileが`compiled`を返した時だけ持ち、unknownが残る間はnullになる。
396
+ * verdictに現れないペアをverified独立と読めるのは、両taskにunknownが無いときだけである。
397
+ */
398
+ export function validateTodoIndependence(value) {
399
+ try {
400
+ if (!exactRecord(value, [
401
+ 'schema', 'project_id', 'plan_key', 'plan_version', 'topology_digest', 'base_sha',
402
+ 'witness_set_digest', 'compiled_at', 'task_ids', 'task_boundaries', 'conflict_resources', 'conflicts',
403
+ 'precedences', 'unknowns', 'wave_plan', 'scope_expanded', 'outcome', 'result_digest',
404
+ ])) return false;
405
+ if (value.schema !== TODO_INDEPENDENCE_SCHEMA) return false;
406
+ if (!isTodoIdentifier(value.project_id) || !isTodoIdentifier(value.plan_key)
407
+ || !isTodoIdentifier(value.plan_version)) return false;
408
+ if (!isTodoDigest(value.topology_digest) || !isGitSha(value.base_sha)
409
+ || !isTodoDigest(value.witness_set_digest)
410
+ || !isStrictTodoTimestamp(value.compiled_at)) return false;
411
+ if (!Array.isArray(value.task_ids) || value.task_ids.length < 1
412
+ || value.task_ids.length > TODO_INDEPENDENCE_TASK_LIMIT
413
+ || !value.task_ids.every(isTodoIdentifier) || !strictlySorted(value.task_ids)) return false;
414
+ const known = new Set(value.task_ids);
415
+ // 宣言境界はcompile対象taskとちょうど一対一で対応する。欠けたtaskがあると、
416
+ // そのtaskだけ交差判定ができないのに全体はverifiedを名乗れてしまう。
417
+ if (!boundedList(value.task_boundaries, taskBoundaryEntry, TODO_INDEPENDENCE_TASK_LIMIT)
418
+ || value.task_boundaries.length !== value.task_ids.length
419
+ || !strictlySorted(value.task_boundaries, (entry) => entry.task_id)
420
+ || !value.task_boundaries.every((entry, index) => entry.task_id === value.task_ids[index])) {
421
+ return false;
422
+ }
423
+ // scope_expandedはtask_idsとちょうど一対一。欠けたtaskがあると「膨張していない」と
424
+ // 「まだ見ていない」が同じ顔になる。
425
+ if (!boundedList(value.scope_expanded, scopeExpansionEntry, TODO_INDEPENDENCE_TASK_LIMIT)
426
+ || value.scope_expanded.length !== value.task_ids.length
427
+ || !strictlySorted(value.scope_expanded, (entry) => entry.task_id)
428
+ || !value.scope_expanded.every((entry, index) => entry.task_id === value.task_ids[index])) {
429
+ return false;
430
+ }
431
+ if (!boundedList(value.conflict_resources, conflictResourceEntry)
432
+ || !strictlySorted(value.conflict_resources, (entry) => entry.resource_id)) return false;
433
+ const conflictResourceIds = new Set(value.conflict_resources.map(({ resource_id: id }) => id));
434
+ if (!boundedList(value.conflicts, conflictEntry)
435
+ || !value.conflicts.every((entry) => entry.task_ids.every((taskId) => known.has(taskId)))
436
+ || !value.conflicts.every((entry) => conflictResourceIds.has(entry.resource_id))
437
+ || !strictlySorted(value.conflicts, (entry) => (
438
+ `${entry.task_ids[0]}\0${entry.task_ids[1]}\0${entry.resource_id}`))) return false;
439
+ const referencedResourceIds = new Set(value.conflicts.map(({ resource_id: id }) => id));
440
+ if (referencedResourceIds.size !== value.conflict_resources.length) return false;
441
+ if (!boundedList(value.precedences, precedenceEntry)
442
+ || !value.precedences.every((entry) => known.has(entry.from_task_id) && known.has(entry.to_task_id))
443
+ || !strictlySorted(value.precedences, (entry) => (
444
+ `${entry.from_task_id}\0${entry.to_task_id}\0${entry.reason}`))) return false;
445
+ if (!boundedList(value.unknowns, unknownEntry)
446
+ || !value.unknowns.every((entry) => known.has(entry.task_id))
447
+ || !sorted(value.unknowns, (entry) => (
448
+ `${entry.task_id}\0${entry.kind}\0${entry.ref}`))) return false;
449
+ if (!['compiled', 'unknown'].includes(value.outcome)) return false;
450
+ // unknownが残る限りwave planは主張しない。compiledならunknownは空でなければならない。
451
+ if (value.outcome === 'compiled' && value.unknowns.length > 0) return false;
452
+ if (value.outcome === 'unknown' && value.wave_plan !== null) return false;
453
+ if (!wavePlan(value.wave_plan, value.task_ids)) return false;
454
+ return isTodoDigest(value.result_digest)
455
+ && value.result_digest === todoSelfDigest(value, 'result_digest');
456
+ } catch {
457
+ return false;
458
+ }
459
+ }
460
+
461
+ function groupEntry(value) {
462
+ return exactRecord(value, ['task_ids'])
463
+ && Array.isArray(value.task_ids) && value.task_ids.length >= 1
464
+ && value.task_ids.length <= TODO_INDEPENDENCE_TASK_LIMIT
465
+ && value.task_ids.every(isTodoIdentifier) && strictlySorted(value.task_ids);
466
+ }
467
+
468
+ function pairKindAndSeverability(value) {
469
+ // conflictはkindを持ちseverabilityがそこから導かれる。precedenceは順序制約であって
470
+ // resource起因ではないためkindを持たず、常にserialになる。
471
+ if (value.type === 'conflict') {
472
+ return TODO_INDEPENDENCE_CONFLICT_KINDS.includes(value.kind)
473
+ && value.severability === severabilityOfConflictKind(value.kind);
474
+ }
475
+ return value.kind === null && value.severability === 'serial';
476
+ }
477
+
478
+ function serializeEntry(value) {
479
+ return exactRecord(value, ['task_ids', 'type', 'detail', 'kind', 'severability'])
480
+ && Array.isArray(value.task_ids) && value.task_ids.length === 2
481
+ && value.task_ids.every(isTodoIdentifier)
482
+ && compareText(value.task_ids[0], value.task_ids[1]) < 0
483
+ && ['conflict', 'precedence'].includes(value.type) && boundedText(value.detail)
484
+ && pairKindAndSeverability(value);
485
+ }
486
+
487
+ /**
488
+ * 着手候補と進行中ToDoの競合。
489
+ *
490
+ * v1は両端がready集合のペアだけを採っており、片端がactiveのペアを黙って捨てていた。
491
+ * 着手する瞬間に最も危ないのはこの組み合わせなので、独立した面として持つ(ADR 0128 Decision 3)。
492
+ */
493
+ function activeConflictEntry(value) {
494
+ return exactRecord(value, [
495
+ 'ready_task_id', 'active_task_id', 'type', 'detail', 'kind', 'severability',
496
+ ]) && isTodoIdentifier(value.ready_task_id) && isTodoIdentifier(value.active_task_id)
497
+ && value.ready_task_id !== value.active_task_id
498
+ && ['conflict', 'precedence'].includes(value.type) && boundedText(value.detail)
499
+ && pairKindAndSeverability(value);
500
+ }
501
+
502
+ /**
503
+ * 案内。単一正本のcatalogが返した形をそのまま載せる(ADR 0130 Decision 1・2)。
504
+ * 面ごとに文言を組み立て直さないため、ここではshapeだけを検査する。
505
+ */
506
+ function guidanceEntry(value) {
507
+ return exactRecord(value, ['code', 'message', 'next_action'])
508
+ && TODO_INDEPENDENCE_GUIDANCE_CODES.includes(value.code)
509
+ && boundedText(value.message) && isTodoIdentifier(value.next_action);
510
+ }
511
+
512
+ /**
513
+ * 鮮度の内訳。`coverage`がsha水準の事実を述べるのに対し、こちらは
514
+ * 「そのdiffが宣言境界に触れたか」というtask単位の事実を述べる(ADR 0128 Decision 4)。
515
+ */
516
+ function driftEntry(value) {
517
+ if (value === null) return true;
518
+ return exactRecord(value, ['base_reachable', 'changed_path_count', 'intersecting_task_ids'])
519
+ && typeof value.base_reachable === 'boolean'
520
+ && isNonNegativeSafeInteger(value.changed_path_count)
521
+ && Array.isArray(value.intersecting_task_ids)
522
+ && value.intersecting_task_ids.length <= TODO_INDEPENDENCE_TASK_LIMIT
523
+ && value.intersecting_task_ids.every(isTodoIdentifier)
524
+ && strictlySorted(value.intersecting_task_ids);
525
+ }
526
+
527
+ function unknownTaskEntry(value) {
528
+ return exactRecord(value, ['task_id', 'unknowns'])
529
+ && isTodoIdentifier(value.task_id)
530
+ && Array.isArray(value.unknowns) && value.unknowns.length >= 1
531
+ && value.unknowns.length <= TODO_INDEPENDENCE_LIST_LIMIT
532
+ && value.unknowns.every((entry) => exactRecord(entry, ['kind', 'ref'])
533
+ && isTodoIdentifier(entry.kind) && boundedText(entry.ref));
534
+ }
535
+
536
+ /**
537
+ * `lattice.todo_independence_projection.v2`。
538
+ *
539
+ * ready frontierを「検証済み並列グループ」「直列化すべき組」「未検査」へ分けた読み出し面。
540
+ * v2は進行中ToDoとの競合(`conflicts_with_active`)と鮮度の内訳(`drift`)を加える。
541
+ * `todo_status_result`と`dispatch_frontier`は変更せず、加算の別面として持つ(ADR 0124の規律)。
542
+ */
543
+ export function validateTodoIndependenceProjection(value) {
544
+ try {
545
+ if (!exactRecord(value, [
546
+ 'schema', 'project_id', 'plan_key', 'coverage', 'compiled_base_sha', 'current_base_sha',
547
+ 'plan_version', 'topology_digest', 'active_task_ids', 'uncovered_active_task_ids',
548
+ 'drift', 'guidance', 'frontier', 'result_digest',
549
+ ])) return false;
550
+ if (value.schema !== TODO_INDEPENDENCE_PROJECTION_SCHEMA) return false;
551
+ if (!isTodoIdentifier(value.project_id)) return false;
552
+ if (value.plan_key !== null && !isTodoIdentifier(value.plan_key)) return false;
553
+ if (!TODO_INDEPENDENCE_COVERAGE.includes(value.coverage)) return false;
554
+ if (value.compiled_base_sha !== null && !isGitSha(value.compiled_base_sha)) return false;
555
+ if (!isGitSha(value.current_base_sha)) return false;
556
+ if (value.plan_version !== null && !isTodoIdentifier(value.plan_version)) return false;
557
+ if (value.topology_digest !== null && !isTodoDigest(value.topology_digest)) return false;
558
+ // 記録が無い状態でcompile済みidentityを名乗らない。
559
+ if (value.coverage === 'missing'
560
+ && (value.compiled_base_sha !== null || value.topology_digest !== null)) return false;
561
+ if (['verified', 'stale'].includes(value.coverage) && value.compiled_base_sha === null) return false;
562
+ if (value.coverage === 'superseded' && value.compiled_base_sha === null
563
+ && (value.plan_version !== null || value.topology_digest !== null)) return false;
564
+ for (const key of ['active_task_ids', 'uncovered_active_task_ids']) {
565
+ if (!Array.isArray(value[key]) || value[key].length > TODO_INDEPENDENCE_TASK_LIMIT
566
+ || !value[key].every(isTodoIdentifier) || !strictlySorted(value[key])) return false;
567
+ }
568
+ // 宣言のないactiveは考慮済みactiveの部分集合でなければならない。
569
+ const activeSet = new Set(value.active_task_ids);
570
+ if (!value.uncovered_active_task_ids.every((taskId) => activeSet.has(taskId))) return false;
571
+ if (!driftEntry(value.drift)) return false;
572
+ if (!guidanceEntry(value.guidance)) return false;
573
+ // driftはstale時の内訳。それ以外で語ると鮮度の事実を二重に主張することになる。
574
+ if (value.coverage !== 'stale' && value.drift !== null) return false;
575
+ if (!exactRecord(value.frontier, [
576
+ 'parallel_groups', 'serialize_pairs', 'conflicts_with_active', 'unknown',
577
+ ])) return false;
578
+ if (!boundedList(value.frontier.parallel_groups, groupEntry, TODO_INDEPENDENCE_TASK_LIMIT)
579
+ || !strictlySorted(value.frontier.parallel_groups, (entry) => entry.task_ids[0])) return false;
580
+ if (!boundedList(value.frontier.serialize_pairs, serializeEntry)
581
+ || !strictlySorted(value.frontier.serialize_pairs, (entry) => (
582
+ `${entry.task_ids[0]}\0${entry.task_ids[1]}\0${entry.type}\0${entry.detail}`))) return false;
583
+ if (!boundedList(value.frontier.conflicts_with_active, activeConflictEntry)
584
+ || !strictlySorted(value.frontier.conflicts_with_active, (entry) => (
585
+ `${entry.ready_task_id}\0${entry.active_task_id}\0${entry.type}\0${entry.detail}`))) {
586
+ return false;
587
+ }
588
+ if (!boundedList(value.frontier.unknown, unknownTaskEntry, TODO_INDEPENDENCE_TASK_LIMIT)
589
+ || !strictlySorted(value.frontier.unknown, (entry) => entry.task_id)) return false;
590
+ return isTodoDigest(value.result_digest)
591
+ && value.result_digest === todoSelfDigest(value, 'result_digest');
592
+ } catch {
593
+ return false;
594
+ }
595
+ }