@quolu/lattice 0.52.2 → 0.52.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (134) hide show
  1. package/package.json +1 -1
  2. package/src/artifact-contracts-v2.mjs +325 -325
  3. package/src/artifact-contracts.mjs +895 -895
  4. package/src/boundary-compiler.mjs +712 -712
  5. package/src/boundary-observation-compiler-v2.mjs +344 -344
  6. package/src/bounded-seam.mjs +230 -230
  7. package/src/bridge-address.mjs +107 -107
  8. package/src/bridge-cli.mjs +297 -297
  9. package/src/bridge-config.mjs +346 -346
  10. package/src/bridge-daemon.mjs +378 -378
  11. package/src/bridge-hub-protocol.mjs +198 -0
  12. package/src/bridge-launch-agent.mjs +323 -323
  13. package/src/bridge-registrar.mjs +102 -102
  14. package/src/bridge-server.mjs +376 -376
  15. package/src/cli-help.mjs +308 -308
  16. package/src/cli-stdio.mjs +40 -40
  17. package/src/control-compiler.mjs +532 -532
  18. package/src/dag-chain.mjs +261 -261
  19. package/src/factory-diagnostics.mjs +188 -188
  20. package/src/git-process.mjs +72 -0
  21. package/src/hash-chain.mjs +76 -76
  22. package/src/hooks-cli.mjs +1057 -1057
  23. package/src/isolation-runner.mjs +409 -409
  24. package/src/node-version-guard.mjs +44 -44
  25. package/src/project-cli.mjs +699 -697
  26. package/src/project-identity.mjs +130 -130
  27. package/src/rc1-black-box-oracle.mjs +906 -906
  28. package/src/rc1-comparison.mjs +154 -154
  29. package/src/rc1-evidence-bundle.mjs +456 -456
  30. package/src/rc1-v4-campaign.mjs +708 -708
  31. package/src/rc1-v4-transform.mjs +467 -467
  32. package/src/rc1-v5-artifact-set.mjs +855 -855
  33. package/src/rc1-v5-behavior-evidence.mjs +594 -594
  34. package/src/rc1-v5-campaign.mjs +797 -797
  35. package/src/rc1-v5-transform.mjs +421 -421
  36. package/src/rc1-v6-artifact-set.mjs +807 -807
  37. package/src/rc1-v6-behavior-evidence.mjs +273 -273
  38. package/src/rc1-v6-campaign.mjs +623 -623
  39. package/src/rc1-v6-causal-binding.mjs +473 -473
  40. package/src/rc1-v6-measurement.mjs +313 -313
  41. package/src/rc2-artifact-set.mjs +1584 -1584
  42. package/src/rc2-campaign.mjs +1506 -1506
  43. package/src/rc2-delivery-policy-front-end.mjs +1079 -1079
  44. package/src/rc2-delivery-policy-oracle.mjs +134 -134
  45. package/src/rc2-delivery-policy-transform.mjs +1127 -1127
  46. package/src/rc2-rc1-transfer-front-end.mjs +511 -511
  47. package/src/rc3-actual-dogfood.mjs +652 -652
  48. package/src/rc3-dogfood-scaffold.mjs +315 -315
  49. package/src/rc3-scripted-campaign.mjs +1383 -1383
  50. package/src/rc4-stage1-dogfood.mjs +673 -673
  51. package/src/runtime-adapter-registry.mjs +524 -524
  52. package/src/runtime-cli.mjs +4588 -4588
  53. package/src/runtime-contracts.mjs +824 -824
  54. package/src/runtime-control-store.mjs +604 -604
  55. package/src/runtime-controller-protocol.mjs +587 -587
  56. package/src/runtime-decision-verifier.mjs +701 -701
  57. package/src/runtime-diff-observer.mjs +361 -361
  58. package/src/runtime-direct-os-observer.mjs +301 -301
  59. package/src/runtime-driver-state.mjs +166 -166
  60. package/src/runtime-engine.mjs +779 -779
  61. package/src/runtime-errors.mjs +356 -356
  62. package/src/runtime-event-store.mjs +189 -189
  63. package/src/runtime-front-end.mjs +925 -925
  64. package/src/runtime-gate-store.mjs +481 -481
  65. package/src/runtime-hold-recompile.mjs +916 -916
  66. package/src/runtime-io-sentinel.mjs +391 -391
  67. package/src/runtime-lifecycle-lock.mjs +294 -294
  68. package/src/runtime-managed-supervisor.mjs +1490 -1490
  69. package/src/runtime-multi-epoch-store.mjs +838 -838
  70. package/src/runtime-projection.mjs +269 -269
  71. package/src/runtime-pull-intake.mjs +1192 -1192
  72. package/src/runtime-scripted-adapter-controller.mjs +1160 -1160
  73. package/src/runtime-scripted-executor.mjs +163 -163
  74. package/src/runtime-scripted-worktree.mjs +104 -104
  75. package/src/runtime-seam-resolve.mjs +428 -428
  76. package/src/runtime-seam-treatment.mjs +173 -173
  77. package/src/runtime-socket-owner.mjs +125 -125
  78. package/src/runtime-work-order-contracts.mjs +91 -91
  79. package/src/runtime-work-order-controller.mjs +1171 -1171
  80. package/src/runtime-worktree-executor.mjs +199 -199
  81. package/src/schedulability-compiler-v2.mjs +303 -303
  82. package/src/schedulability-verifier-v2.mjs +317 -317
  83. package/src/seam-apply.mjs +549 -549
  84. package/src/seam-commit-shared.mjs +22 -22
  85. package/src/seam-commit-transform.mjs +81 -81
  86. package/src/seam-commit.mjs +18 -18
  87. package/src/seam-cost.mjs +322 -322
  88. package/src/seam-derivation.mjs +188 -188
  89. package/src/seam-gate.mjs +146 -146
  90. package/src/seam-proposal-contracts.mjs +446 -446
  91. package/src/seam-proposal-queries.mjs +521 -521
  92. package/src/seam-proposal.mjs +2011 -2011
  93. package/src/seam-ref.mjs +33 -33
  94. package/src/seam-rewrite.mjs +286 -286
  95. package/src/seam-transform.mjs +554 -554
  96. package/src/seam-verification.mjs +260 -260
  97. package/src/sensor-adapter.mjs +432 -432
  98. package/src/sensor-cli.mjs +139 -139
  99. package/src/sensor-diff.mjs +661 -661
  100. package/src/sensor-node-runtime.mjs +53 -53
  101. package/src/sensor-runtime.mjs +52 -52
  102. package/src/timestamp-contract.mjs +8 -8
  103. package/src/todo-audit-pending.mjs +91 -91
  104. package/src/todo-chain.mjs +178 -178
  105. package/src/todo-cli.mjs +3143 -3141
  106. package/src/todo-contracts.mjs +728 -728
  107. package/src/todo-dashboard-registry.mjs +573 -573
  108. package/src/todo-dispatch-shape.mjs +190 -190
  109. package/src/todo-gantt-html-independence.mjs +239 -239
  110. package/src/todo-gantt-html-shared.mjs +226 -226
  111. package/src/todo-gantt-html-style.mjs +131 -131
  112. package/src/todo-gantt-html.mjs +248 -248
  113. package/src/todo-gantt-layout.mjs +974 -974
  114. package/src/todo-gantt-live.mjs +361 -361
  115. package/src/todo-gantt-nested.mjs +263 -263
  116. package/src/todo-gantt-presentation.mjs +217 -217
  117. package/src/todo-gantt-scope.mjs +123 -123
  118. package/src/todo-gantt-svg.mjs +353 -353
  119. package/src/todo-independence-contracts.mjs +595 -595
  120. package/src/todo-independence-guidance.mjs +322 -322
  121. package/src/todo-independence.mjs +640 -640
  122. package/src/todo-markdown-renderer.mjs +260 -260
  123. package/src/todo-migration.mjs +448 -448
  124. package/src/todo-narrative-anchor.mjs +130 -130
  125. package/src/todo-note-store.mjs +629 -629
  126. package/src/todo-parallel-candidates.mjs +114 -114
  127. package/src/todo-revision.mjs +995 -995
  128. package/src/todo-split.mjs +472 -472
  129. package/src/todo-status.mjs +690 -690
  130. package/src/todo-store-git-transaction.mjs +418 -418
  131. package/src/todo-store.mjs +4360 -4322
  132. package/src/treatment-compiler.mjs +728 -728
  133. package/src/treatment-runner.mjs +656 -656
  134. package/src/witness-scaffold.mjs +180 -180
@@ -1,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
+ }