@quolu/lattice 0.52.3 → 0.53.0

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