@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,995 +1,995 @@
1
- import {
2
- canonicalizeTodoArtifact,
3
- digestTodoArtifact,
4
- exactRecord,
5
- isTodoDigest,
6
- isTodoIdentifier,
7
- isTodoRef,
8
- todoSelfDigest,
9
- validateTodoPlan,
10
- } from './todo-contracts.mjs';
11
-
12
- const REVISION_V1_KEYS = [
13
- 'schema', 'project_id', 'plan_key', 'predecessor', 'desired_plan', 'task_migration',
14
- 'source_inventory', 'reconciliation', 'revision_digest',
15
- ];
16
- const REVISION_V2_KEYS = [...REVISION_V1_KEYS, 'source_cutover_batch'];
17
- const PHASE_REVISION_KEYS = [
18
- 'schema', 'project_id', 'plan_key', 'predecessor', 'desired_plan', 'task_migration',
19
- 'phase_migration', 'revision_digest',
20
- ];
21
- const PHASE_REVISION_V3_KEYS = [
22
- 'schema', 'project_id', 'plan_key', 'predecessor', 'desired_plan',
23
- 'runtime_task_migration', 'task_migration', 'phase_migration', 'source_inventory',
24
- 'reconciliation', 'source_cutover_batch', 'revision_digest',
25
- ];
26
-
27
- const compareText = (left, right) => left < right ? -1 : left > right ? 1 : 0;
28
- const boundedText = (value) => typeof value === 'string' && value.length > 0
29
- && Buffer.byteLength(value) <= 16_384 && !/[\u0000-\u001f\u007f]/u.test(value);
30
-
31
- export function parseTodoSourceRef(value) {
32
- if (typeof value !== 'string') return null;
33
- const match = /^(.*)#L([1-9]\d*)$/u.exec(value);
34
- if (match === null || !isTodoRef(match[1])) return null;
35
- const line = Number(match[2]);
36
- return Number.isSafeInteger(line) ? { path: match[1], line } : null;
37
- }
38
-
39
- export function todoRevisionPlanVersion({
40
- projectId, planKey, predecessor, desiredPlan, taskMigration, sourceInventory,
41
- sourceCutoverBatch = undefined,
42
- }) {
43
- const versionDigest = digestTodoArtifact({
44
- schema: sourceCutoverBatch === undefined
45
- ? 'lattice.todo_revision_version.v1' : 'lattice.todo_revision_version.v2',
46
- project_id: projectId,
47
- plan_key: planKey,
48
- predecessor,
49
- desired_topology: {
50
- schema: desiredPlan.schema,
51
- project_id: desiredPlan.project_id,
52
- plan_key: desiredPlan.plan_key,
53
- predecessor_plan_digest: desiredPlan.predecessor_plan_digest,
54
- tasks: desiredPlan.tasks,
55
- hard_dependencies: desiredPlan.hard_dependencies,
56
- joins: desiredPlan.joins,
57
- },
58
- task_migration: taskMigration,
59
- source_inventory: sourceInventory,
60
- ...(sourceCutoverBatch === undefined ? {} : { source_cutover_batch: sourceCutoverBatch }),
61
- });
62
- return `rev-${versionDigest.slice(0, 24)}`;
63
- }
64
-
65
- export function phaseTodoRevisionPlanVersion({
66
- projectId, planKey, predecessor, desiredPlan, taskMigration, phaseMigration,
67
- }) {
68
- const versionDigest = digestTodoArtifact({
69
- schema: ['lattice.todo_plan.v5', 'lattice.todo_plan.v7'].includes(desiredPlan.schema)
70
- ? 'lattice.phase_todo_revision_version.v2' : 'lattice.phase_todo_revision_version.v1', project_id: projectId,
71
- plan_key: planKey, predecessor, desired_topology: {
72
- schema: desiredPlan.schema, project_id: desiredPlan.project_id,
73
- plan_key: desiredPlan.plan_key, predecessor_plan_digest: desiredPlan.predecessor_plan_digest,
74
- tasks: desiredPlan.tasks, phases: desiredPlan.phases,
75
- hard_dependencies: desiredPlan.hard_dependencies, joins: desiredPlan.joins,
76
- ...(['lattice.todo_plan.v5', 'lattice.todo_plan.v7'].includes(desiredPlan.schema)
77
- ? { phase_accept_dependencies: desiredPlan.phase_accept_dependencies } : {}),
78
- }, task_migration: taskMigration, phase_migration: phaseMigration,
79
- });
80
- return `rev-${versionDigest.slice(0, 24)}`;
81
- }
82
-
83
- export function todoLegacyReconciliationDigest({ planDigest, journalHeadDigest }) {
84
- if (!isTodoDigest(planDigest) || !isTodoDigest(journalHeadDigest)) {
85
- throw new TypeError('legacy reconciliation anchor digest required');
86
- }
87
- return digestTodoArtifact({
88
- schema: 'lattice.todo_reconciliation_anchor.v1',
89
- state: 'registered_unreconciled',
90
- plan_digest: planDigest,
91
- journal_head_digest: journalHeadDigest,
92
- });
93
- }
94
-
95
- export function todoSourceInventoryDigest(sourceInventory) {
96
- return digestTodoArtifact(sourceInventory);
97
- }
98
-
99
- export function todoReconciliationDigest({
100
- predecessorReconciliationDigest,
101
- sourceInventoryDigest,
102
- predecessor,
103
- desiredPlanDigest,
104
- taskMigration,
105
- sourceCutoverBatch = undefined,
106
- }) {
107
- if (!isTodoDigest(predecessorReconciliationDigest) || !isTodoDigest(sourceInventoryDigest)
108
- || !isTodoDigest(desiredPlanDigest)) throw new TypeError('reconciliation digest input invalid');
109
- return digestTodoArtifact({
110
- schema: sourceCutoverBatch === undefined
111
- ? 'lattice.todo_reconciliation_binding.v1' : 'lattice.todo_reconciliation_binding.v2',
112
- predecessor_reconciliation_digest: predecessorReconciliationDigest,
113
- source_inventory_digest: sourceInventoryDigest,
114
- predecessor,
115
- desired_plan_digest: desiredPlanDigest,
116
- task_migration_digest: digestTodoArtifact(taskMigration),
117
- ...(sourceCutoverBatch === undefined ? {} : {
118
- source_cutover_batch_digest: sourceCutoverBatch.batch_digest,
119
- }),
120
- });
121
- }
122
-
123
- export function todoCutoverArchiveSourceRef(batch, index) {
124
- if (!Number.isSafeInteger(index) || index < 0) throw new TypeError('cutover operation index invalid');
125
- return `${batch.archive_ref}#L${index + 6}`;
126
- }
127
-
128
- function validLiveReplacement(value) {
129
- return typeof value === 'string' && value.length > 0 && Buffer.byteLength(value) <= 4_096
130
- && !/[\r\n\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/u.test(value)
131
- && !/^[\t ]*(?:[-+*]|\d+[A-Za-z]?\.|\d+\))[\t ]+\[[ xX]\](?:[\t ]+.*)?$/u.test(value);
132
- }
133
-
134
- function validSourceCutoverBatch(value, revision) {
135
- if (!exactRecord(value, ['batch_id', 'archive_ref', 'operations', 'batch_digest'])
136
- || !isTodoIdentifier(value.batch_id) || !isTodoRef(value.archive_ref)
137
- || !value.archive_ref.endsWith('.md') || parseTodoSourceRef(value.archive_ref) !== null
138
- || !Array.isArray(value.operations) || value.operations.length < 1 || value.operations.length > 512
139
- || !isTodoDigest(value.batch_digest)
140
- || value.batch_digest !== todoSelfDigest(value, 'batch_digest')) return false;
141
- const sourceRefs = new Set();
142
- const activeTaskIds = new Set();
143
- for (const [index, operation] of value.operations.entries()) {
144
- if (!exactRecord(operation, [
145
- 'task_id', 'disposition', 'source_ref', 'source_digest', 'live_replacement',
146
- ]) || !['active', 'excluded'].includes(operation.disposition)
147
- || (operation.disposition === 'active' ? !isTodoIdentifier(operation.task_id) : operation.task_id !== null)
148
- || parseTodoSourceRef(operation.source_ref) === null || !isTodoDigest(operation.source_digest)
149
- || !validLiveReplacement(operation.live_replacement)
150
- || sourceRefs.has(operation.source_ref)
151
- || (index > 0 && compareText(value.operations[index - 1].source_ref, operation.source_ref) >= 0)) return false;
152
- sourceRefs.add(operation.source_ref);
153
- const archivedSourceRef = todoCutoverArchiveSourceRef(value, index);
154
- if (operation.disposition === 'active') {
155
- if (activeTaskIds.has(operation.task_id)) return false;
156
- activeTaskIds.add(operation.task_id);
157
- const inventory = revision.source_inventory.active.find(({ task_id }) => task_id === operation.task_id);
158
- const task = revision.desired_plan.tasks.find(({ task_id }) => task_id === operation.task_id);
159
- if (inventory?.source_ref !== archivedSourceRef
160
- || inventory.source_digest !== operation.source_digest
161
- || task?.narrative_ref !== archivedSourceRef) return false;
162
- } else {
163
- const tombstone = revision.source_inventory.excluded_tombstones.find((entry) => (
164
- entry.source_ref === archivedSourceRef && entry.source_digest === operation.source_digest
165
- ));
166
- if (tombstone === undefined) return false;
167
- }
168
- }
169
- return true;
170
- }
171
-
172
- function validPredecessor(value) {
173
- return exactRecord(value, ['plan_digest', 'journal_head_digest', 'plan_version'])
174
- && isTodoDigest(value.plan_digest) && isTodoDigest(value.journal_head_digest)
175
- && isTodoIdentifier(value.plan_version);
176
- }
177
-
178
- function validTaskMigration(value) {
179
- const activeTargets = Array.isArray(value)
180
- ? value.filter(({ to_task_id }) => to_task_id !== 'removed').map(({ to_task_id }) => to_task_id)
181
- : [];
182
- return Array.isArray(value) && value.length > 0 && value.length <= 512
183
- && value.every((entry) => exactRecord(entry, [
184
- 'from_task_id', 'to_task_id', 'state_policy',
185
- ]) && isTodoIdentifier(entry.from_task_id)
186
- && (entry.to_task_id === 'removed' || isTodoIdentifier(entry.to_task_id))
187
- && ((entry.state_policy === 'removed' && entry.to_task_id === 'removed')
188
- || (['carry', 'carry_reconciled_metadata', 'reset_pending', 'acquire_phase'].includes(entry.state_policy)
189
- && entry.to_task_id !== 'removed')))
190
- && new Set(value.map(({ from_task_id }) => from_task_id)).size === value.length
191
- && new Set(activeTargets).size === activeTargets.length
192
- && value.every((entry, index) => index === 0
193
- || compareText(value[index - 1].from_task_id, entry.from_task_id) < 0);
194
- }
195
-
196
- function validPhaseMigration(value, desiredPlan) {
197
- if (!Array.isArray(value) || value.length === 0 || value.length > 512
198
- || !value.every((entry) => exactRecord(entry, [
199
- 'from_phase_id', 'to_phase_id', 'state_policy',
200
- ]) && (entry.from_phase_id === null || isTodoIdentifier(entry.from_phase_id))
201
- && (entry.to_phase_id === 'removed' || isTodoIdentifier(entry.to_phase_id))
202
- && ['carry', 'reset', 'removed'].includes(entry.state_policy)
203
- && (entry.state_policy !== 'carry' || (entry.from_phase_id !== null && entry.to_phase_id !== 'removed'))
204
- && (entry.state_policy !== 'removed' || (entry.from_phase_id !== null && entry.to_phase_id === 'removed'))
205
- && (entry.state_policy !== 'reset' || entry.to_phase_id !== 'removed'))) return false;
206
- const sources = value.filter(({ from_phase_id }) => from_phase_id !== null).map(({ from_phase_id }) => from_phase_id);
207
- const targets = value.filter(({ to_phase_id }) => to_phase_id !== 'removed').map(({ to_phase_id }) => to_phase_id);
208
- return new Set(sources).size === sources.length && new Set(targets).size === targets.length
209
- && canonicalizeForCompare(targets) === canonicalizeForCompare(desiredPlan.phases.map(({ phase_id }) => phase_id));
210
- }
211
-
212
- function canonicalizeForCompare(value) {
213
- return canonicalizeTodoArtifact([...value].sort((left, right) => compareText(
214
- canonicalizeTodoArtifact(left), canonicalizeTodoArtifact(right),
215
- )));
216
- }
217
-
218
- export function todoTaskMigrationDigest(taskMigration) {
219
- return todoSelfDigest({ task_migration: taskMigration, task_migration_digest: '' },
220
- 'task_migration_digest');
221
- }
222
-
223
- function validRuntimeTaskMigration(value) {
224
- if (!exactRecord(value, ['schema', 'entries', 'migration_digest'])
225
- || value.schema !== 'lattice.runtime_task_migration.v1'
226
- || !Array.isArray(value.entries) || value.entries.length < 1 || value.entries.length > 512
227
- || !isTodoDigest(value.migration_digest)
228
- || value.migration_digest !== todoSelfDigest(value, 'migration_digest')) return false;
229
- const targets = [];
230
- for (const [index, entry] of value.entries.entries()) {
231
- const successorTail = Array.isArray(entry.successor_task_ids)
232
- ? entry.successor_task_ids.slice(1) : [];
233
- if (!exactRecord(entry, [
234
- 'predecessor_task_id', 'disposition', 'successor_task_ids', 'reason', 'evidence_digests',
235
- ]) || !isTodoIdentifier(entry.predecessor_task_id)
236
- || !['carry', 'stay', 'replace', 'split', 'retire'].includes(entry.disposition)
237
- || !Array.isArray(entry.successor_task_ids) || entry.successor_task_ids.length > 512
238
- || !entry.successor_task_ids.every(isTodoIdentifier)
239
- || new Set(entry.successor_task_ids).size !== entry.successor_task_ids.length
240
- || successorTail.some((id, targetIndex) => targetIndex > 0
241
- && compareText(successorTail[targetIndex - 1], id) >= 0)
242
- || !boundedText(entry.reason) || !Array.isArray(entry.evidence_digests)
243
- || entry.evidence_digests.length < 1 || entry.evidence_digests.length > 512
244
- || !entry.evidence_digests.every(isTodoDigest)
245
- || new Set(entry.evidence_digests).size !== entry.evidence_digests.length
246
- || entry.evidence_digests.some((digest, digestIndex) => digestIndex > 0
247
- && compareText(entry.evidence_digests[digestIndex - 1], digest) >= 0)
248
- || (index > 0 && compareText(value.entries[index - 1].predecessor_task_id,
249
- entry.predecessor_task_id) >= 0)) return false;
250
- if (['carry', 'stay'].includes(entry.disposition)
251
- && (entry.successor_task_ids.length !== 1
252
- || entry.successor_task_ids[0] !== entry.predecessor_task_id)) return false;
253
- if (entry.disposition === 'retire' && entry.successor_task_ids.length !== 0) return false;
254
- if (['replace', 'split'].includes(entry.disposition) && entry.successor_task_ids.length === 0) return false;
255
- targets.push(...entry.successor_task_ids);
256
- }
257
- return new Set(targets).size === targets.length;
258
- }
259
-
260
- function projectRuntimeTaskMigration(value) {
261
- return value.entries.map((entry) => {
262
- if (['carry', 'stay'].includes(entry.disposition)) return {
263
- from_task_id: entry.predecessor_task_id,
264
- to_task_id: entry.predecessor_task_id,
265
- state_policy: 'carry',
266
- };
267
- if (entry.disposition === 'retire') return {
268
- from_task_id: entry.predecessor_task_id, to_task_id: 'removed', state_policy: 'removed',
269
- };
270
- return { from_task_id: entry.predecessor_task_id,
271
- to_task_id: entry.successor_task_ids[0], state_policy: 'reset_pending' };
272
- });
273
- }
274
-
275
- function validRuntimeTodoProjection(runtimeMigration, taskMigration) {
276
- const projected = projectRuntimeTaskMigration(runtimeMigration);
277
- return projected.length === taskMigration.length && projected.every((expected, index) => {
278
- const actual = taskMigration[index];
279
- return expected.from_task_id === actual.from_task_id && expected.to_task_id === actual.to_task_id
280
- // runtime disposition carry/stayは実行状態の持ち越しだけを申告する。task_migration側は
281
- // carry_reconciled_metadataに加え、Phase獲得だけを許すacquire_phase(ADR 0147裁定4)も
282
- // 「状態を持ち越すcarry系」の投影として受理する。
283
- && (expected.state_policy === 'carry'
284
- ? ['carry', 'carry_reconciled_metadata', 'acquire_phase'].includes(actual.state_policy)
285
- : expected.state_policy === actual.state_policy);
286
- });
287
- }
288
-
289
- export function validatePhaseTodoRevision(value) {
290
- try {
291
- const revisionV1 = value?.schema === 'lattice.phase_todo_revision.v1';
292
- const revisionV2 = value?.schema === 'lattice.phase_todo_revision.v2';
293
- const revisionV3 = value?.schema === 'lattice.phase_todo_revision.v3';
294
- const keys = revisionV3 ? PHASE_REVISION_V3_KEYS : PHASE_REVISION_KEYS;
295
- if (!exactRecord(value, keys) || (!revisionV1 && !revisionV2 && !revisionV3)) return false;
296
- if (!isTodoIdentifier(value.project_id) || !isTodoIdentifier(value.plan_key)) return false;
297
- if (!validPredecessor(value.predecessor) || !validateTodoPlan(value.desired_plan)
298
- || !(revisionV3
299
- ? ['lattice.todo_plan.v5', 'lattice.todo_plan.v7'].includes(value.desired_plan.schema)
300
- : value.desired_plan.schema === (revisionV2 ? 'lattice.todo_plan.v5' : 'lattice.todo_plan.v4'))
301
- || value.desired_plan.project_id !== value.project_id
302
- || value.desired_plan.plan_key !== value.plan_key
303
- || value.desired_plan.predecessor_plan_digest !== value.predecessor.plan_digest
304
- || !validTaskMigration(value.task_migration)
305
- || !validPhaseMigration(value.phase_migration, value.desired_plan)) return false;
306
- if (value.desired_plan.plan_version !== phaseTodoRevisionPlanVersion({
307
- projectId: value.project_id, planKey: value.plan_key, predecessor: value.predecessor,
308
- desiredPlan: value.desired_plan, taskMigration: value.task_migration,
309
- phaseMigration: value.phase_migration,
310
- })) return false;
311
- if (revisionV3) {
312
- if (!validRuntimeTaskMigration(value.runtime_task_migration)
313
- || !validRuntimeTodoProjection(value.runtime_task_migration, value.task_migration)
314
- || canonicalizeForCompare(value.runtime_task_migration.entries
315
- .flatMap(({ successor_task_ids: ids }) => ids))
316
- !== canonicalizeForCompare(value.desired_plan.tasks.map(({ task_id: id }) => id))
317
- || !validSourceInventory(value.source_inventory, value.desired_plan,
318
- { requireNarrativeRef: true })
319
- || !validSourceCutoverBatch(value.source_cutover_batch, value)
320
- || !exactRecord(value.reconciliation, [
321
- 'predecessor_reconciliation_digest', 'source_inventory_digest', 'desired_plan_digest',
322
- 'runtime_task_migration_digest', 'task_migration_digest', 'phase_migration_digest',
323
- 'source_cutover_batch_digest', 'reconciliation_digest',
324
- ]) || !isTodoDigest(value.reconciliation.predecessor_reconciliation_digest)
325
- || value.reconciliation.source_inventory_digest !== todoSourceInventoryDigest(value.source_inventory)
326
- || value.reconciliation.desired_plan_digest !== value.desired_plan.plan_digest
327
- || value.reconciliation.runtime_task_migration_digest !== value.runtime_task_migration.migration_digest
328
- || value.reconciliation.task_migration_digest !== todoTaskMigrationDigest(value.task_migration)
329
- || value.reconciliation.phase_migration_digest !== digestTodoArtifact(value.phase_migration)
330
- || value.reconciliation.source_cutover_batch_digest !== value.source_cutover_batch.batch_digest
331
- || value.reconciliation.reconciliation_digest
332
- !== todoSelfDigest(value.reconciliation, 'reconciliation_digest')) return false;
333
- }
334
- return isTodoDigest(value.revision_digest)
335
- && value.revision_digest === todoSelfDigest(value, 'revision_digest');
336
- } catch { return false; }
337
- }
338
-
339
- function validSourceInventory(value, desiredPlan, { requireNarrativeRef = false } = {}) {
340
- if (!exactRecord(value, ['active', 'excluded_tombstones'])
341
- || !Array.isArray(value.active) || !Array.isArray(value.excluded_tombstones)
342
- || value.active.length > 512 || value.excluded_tombstones.length > 2_048
343
- || !value.active.every((entry) => exactRecord(entry, ['task_id', 'source_ref', 'source_digest'])
344
- && isTodoIdentifier(entry.task_id) && parseTodoSourceRef(entry.source_ref) !== null
345
- && isTodoDigest(entry.source_digest))
346
- || !value.excluded_tombstones.every((entry) => exactRecord(entry, [
347
- 'source_ref', 'source_digest', 'exclusion_reason',
348
- ]) && parseTodoSourceRef(entry.source_ref) !== null && isTodoDigest(entry.source_digest)
349
- && boundedText(entry.exclusion_reason))) return false;
350
- const taskIds = desiredPlan.tasks.map(({ task_id }) => task_id);
351
- const activeIds = value.active.map(({ task_id }) => task_id);
352
- const activeRefs = value.active.map(({ source_ref }) => source_ref);
353
- const tombstoneRefs = value.excluded_tombstones.map(({ source_ref }) => source_ref);
354
- const activeRefSet = new Set(activeRefs);
355
- return taskIds.length === activeIds.length && taskIds.every((id, index) => id === activeIds[index])
356
- && (!requireNarrativeRef || value.active.every((entry) => desiredPlan.tasks
357
- .find(({ task_id }) => task_id === entry.task_id)?.narrative_ref === entry.source_ref))
358
- && activeRefSet.size === activeRefs.length
359
- && new Set(tombstoneRefs).size === tombstoneRefs.length
360
- && tombstoneRefs.every((ref) => !activeRefSet.has(ref))
361
- && value.excluded_tombstones.every((entry, index) => index === 0
362
- || compareText(value.excluded_tombstones[index - 1].source_ref, entry.source_ref) < 0);
363
- }
364
-
365
- export function validateTodoRevision(value) {
366
- try {
367
- const revisionV1 = value?.schema === 'lattice.todo_revision.v1';
368
- const revisionV2 = value?.schema === 'lattice.todo_revision.v2';
369
- const sourceCutoverBatch = revisionV2 ? value.source_cutover_batch : undefined;
370
- if ((!revisionV1 && !revisionV2)
371
- || !exactRecord(value, revisionV1 ? REVISION_V1_KEYS : REVISION_V2_KEYS)
372
- || !isTodoIdentifier(value.project_id) || !isTodoIdentifier(value.plan_key)
373
- || !validPredecessor(value.predecessor) || !validateTodoPlan(value.desired_plan)
374
- || !['lattice.todo_plan.v3', 'lattice.todo_plan.v6'].includes(value.desired_plan.schema)
375
- || value.desired_plan.project_id !== value.project_id
376
- || value.desired_plan.plan_key !== value.plan_key
377
- || value.desired_plan.predecessor_plan_digest !== value.predecessor.plan_digest
378
- || !validTaskMigration(value.task_migration)
379
- || !validSourceInventory(value.source_inventory, value.desired_plan)
380
- || value.desired_plan.plan_version !== todoRevisionPlanVersion({
381
- projectId: value.project_id, planKey: value.plan_key, predecessor: value.predecessor,
382
- desiredPlan: value.desired_plan, taskMigration: value.task_migration,
383
- sourceInventory: value.source_inventory, sourceCutoverBatch,
384
- }) || !exactRecord(value.reconciliation, [
385
- 'predecessor_reconciliation_digest', 'source_inventory_digest', 'reconciliation_digest',
386
- ]) || !isTodoDigest(value.reconciliation.predecessor_reconciliation_digest)
387
- || value.reconciliation.source_inventory_digest !== todoSourceInventoryDigest(value.source_inventory)
388
- || value.reconciliation.reconciliation_digest !== todoReconciliationDigest({
389
- predecessorReconciliationDigest: value.reconciliation.predecessor_reconciliation_digest,
390
- sourceInventoryDigest: value.reconciliation.source_inventory_digest,
391
- predecessor: value.predecessor,
392
- desiredPlanDigest: value.desired_plan.plan_digest,
393
- taskMigration: value.task_migration, sourceCutoverBatch,
394
- })) return false;
395
- if (revisionV2 && !validSourceCutoverBatch(value.source_cutover_batch, value)) return false;
396
- const targets = new Set(value.desired_plan.tasks.map(({ task_id }) => task_id));
397
- if (value.task_migration.some(({ to_task_id }) => to_task_id !== 'removed' && !targets.has(to_task_id))) return false;
398
- return isTodoDigest(value.revision_digest)
399
- && value.revision_digest === todoSelfDigest(value, 'revision_digest');
400
- } catch { return false; }
401
- }
402
-
403
- const reject = (reason, path = '') => ({ valid: false, reason, path });
404
-
405
- function plainObject(value) {
406
- return value !== null && typeof value === 'object' && !Array.isArray(value);
407
- }
408
-
409
- /**
410
- * 期待keyとの過不足を1件ずつ言い当てる。missing/unexpectedのどちらが先に見つかっても
411
- * そこで止め、複数の欠落を一度に説明しない——`exactRecord`のbooleanと違い、
412
- * 最初に見つかった違反fieldをpathへ刻む(ADR 0130の案内規律をrevision入口へ拡張)。
413
- */
414
- function explainKeys(value, requiredKeys, at) {
415
- if (!plainObject(value) || Object.getPrototypeOf(value) !== Object.prototype) {
416
- return reject('not_an_object', at);
417
- }
418
- const actualKeys = new Set(Object.keys(value));
419
- for (const key of requiredKeys) {
420
- if (!actualKeys.has(key)) return reject('missing_required_key', `${at}/${key}`);
421
- }
422
- const requiredSet = new Set(requiredKeys);
423
- for (const key of actualKeys) {
424
- if (!requiredSet.has(key)) return reject('unexpected_key', `${at}/${key}`);
425
- }
426
- return { valid: true };
427
- }
428
-
429
- function explainPredecessor(value, at = '/predecessor') {
430
- const keyCheck = explainKeys(value, ['plan_digest', 'journal_head_digest', 'plan_version'], at);
431
- if (!keyCheck.valid) return keyCheck;
432
- if (!isTodoDigest(value.plan_digest)) return reject('invalid_digest', `${at}/plan_digest`);
433
- if (!isTodoDigest(value.journal_head_digest)) return reject('invalid_digest', `${at}/journal_head_digest`);
434
- if (!isTodoIdentifier(value.plan_version)) return reject('invalid_identifier', `${at}/plan_version`);
435
- return { valid: true };
436
- }
437
-
438
- /**
439
- * 実運用で最も時間を溶かした違反——配列のソート漏れ——を、壊れているindexを名指しして返す。
440
- * `validTaskMigration`と同じ規則(from_task_idの厳密昇順、from/toの重複禁止)を、
441
- * 可否を変えずに1件ずつ言い当てる。
442
- */
443
- function explainTaskMigration(value, at = '/task_migration') {
444
- if (!Array.isArray(value) || value.length === 0 || value.length > 512) {
445
- return reject('bounded_collection_violation', at);
446
- }
447
- for (const [index, entry] of value.entries()) {
448
- const entryAt = `${at}/${index}`;
449
- const keyCheck = explainKeys(entry, ['from_task_id', 'to_task_id', 'state_policy'], entryAt);
450
- if (!keyCheck.valid) return keyCheck;
451
- if (!isTodoIdentifier(entry.from_task_id)) return reject('invalid_identifier', `${entryAt}/from_task_id`);
452
- if (entry.to_task_id !== 'removed' && !isTodoIdentifier(entry.to_task_id)) {
453
- return reject('invalid_identifier', `${entryAt}/to_task_id`);
454
- }
455
- const validPolicy = (entry.state_policy === 'removed' && entry.to_task_id === 'removed')
456
- || (['carry', 'carry_reconciled_metadata', 'reset_pending', 'acquire_phase'].includes(entry.state_policy)
457
- && entry.to_task_id !== 'removed');
458
- if (!validPolicy) return reject('state_policy_disposition_mismatch', `${entryAt}/state_policy`);
459
- }
460
- if (new Set(value.map(({ from_task_id: fromTaskId }) => fromTaskId)).size !== value.length) {
461
- return reject('duplicate_from_task_id', at);
462
- }
463
- const activeTargets = value.filter(({ to_task_id: toTaskId }) => toTaskId !== 'removed')
464
- .map(({ to_task_id: toTaskId }) => toTaskId);
465
- if (new Set(activeTargets).size !== activeTargets.length) {
466
- return reject('duplicate_to_task_id', at);
467
- }
468
- for (let index = 1; index < value.length; index += 1) {
469
- if (compareText(value[index - 1].from_task_id, value[index].from_task_id) >= 0) {
470
- return reject('unsorted_or_duplicate_collection', `${at}/${index}/from_task_id`);
471
- }
472
- }
473
- return { valid: true };
474
- }
475
-
476
- function explainPhaseMigration(value, desiredPlan, at = '/phase_migration') {
477
- if (!Array.isArray(value) || value.length === 0 || value.length > 512) {
478
- return reject('bounded_collection_violation', at);
479
- }
480
- for (const [index, entry] of value.entries()) {
481
- const entryAt = `${at}/${index}`;
482
- const keyCheck = explainKeys(entry, ['from_phase_id', 'to_phase_id', 'state_policy'], entryAt);
483
- if (!keyCheck.valid) return keyCheck;
484
- if (entry.from_phase_id !== null && !isTodoIdentifier(entry.from_phase_id)) {
485
- return reject('invalid_identifier', `${entryAt}/from_phase_id`);
486
- }
487
- if (entry.to_phase_id !== 'removed' && !isTodoIdentifier(entry.to_phase_id)) {
488
- return reject('invalid_identifier', `${entryAt}/to_phase_id`);
489
- }
490
- if (!['carry', 'reset', 'removed'].includes(entry.state_policy)) {
491
- return reject('invalid_state_policy', `${entryAt}/state_policy`);
492
- }
493
- if (entry.state_policy === 'carry'
494
- && (entry.from_phase_id === null || entry.to_phase_id === 'removed')) {
495
- return reject('state_policy_disposition_mismatch', entryAt);
496
- }
497
- if (entry.state_policy === 'removed'
498
- && (entry.from_phase_id === null || entry.to_phase_id !== 'removed')) {
499
- return reject('state_policy_disposition_mismatch', entryAt);
500
- }
501
- if (entry.state_policy === 'reset' && entry.to_phase_id === 'removed') {
502
- return reject('state_policy_disposition_mismatch', entryAt);
503
- }
504
- }
505
- const sources = value.filter(({ from_phase_id: fromPhaseId }) => fromPhaseId !== null)
506
- .map(({ from_phase_id: fromPhaseId }) => fromPhaseId);
507
- if (new Set(sources).size !== sources.length) return reject('duplicate_from_phase_id', at);
508
- const targets = value.filter(({ to_phase_id: toPhaseId }) => toPhaseId !== 'removed')
509
- .map(({ to_phase_id: toPhaseId }) => toPhaseId);
510
- if (new Set(targets).size !== targets.length) return reject('duplicate_to_phase_id', at);
511
- if (canonicalizeForCompare(targets)
512
- !== canonicalizeForCompare(desiredPlan.phases.map(({ phase_id: phaseId }) => phaseId))) {
513
- return reject('phase_migration_target_set_mismatch', at);
514
- }
515
- return { valid: true };
516
- }
517
-
518
- function explainRuntimeTaskMigration(value, taskMigration, desiredPlan, at = '/runtime_task_migration') {
519
- const keyCheck = explainKeys(value, ['schema', 'entries', 'migration_digest'], at);
520
- if (!keyCheck.valid) return keyCheck;
521
- if (value.schema !== 'lattice.runtime_task_migration.v1') return reject('schema_mismatch', `${at}/schema`);
522
- if (!Array.isArray(value.entries) || value.entries.length === 0 || value.entries.length > 512) {
523
- return reject('bounded_collection_violation', `${at}/entries`);
524
- }
525
- const targets = [];
526
- for (const [index, entry] of value.entries.entries()) {
527
- const entryAt = `${at}/entries/${index}`;
528
- const keys = ['predecessor_task_id', 'disposition', 'successor_task_ids', 'reason', 'evidence_digests'];
529
- const entryKeyCheck = explainKeys(entry, keys, entryAt);
530
- if (!entryKeyCheck.valid) return entryKeyCheck;
531
- if (!isTodoIdentifier(entry.predecessor_task_id)) {
532
- return reject('invalid_identifier', `${entryAt}/predecessor_task_id`);
533
- }
534
- if (!['carry', 'stay', 'replace', 'split', 'retire'].includes(entry.disposition)) {
535
- return reject('invalid_disposition', `${entryAt}/disposition`);
536
- }
537
- if (!Array.isArray(entry.successor_task_ids) || entry.successor_task_ids.length > 512
538
- || !entry.successor_task_ids.every(isTodoIdentifier)) {
539
- return reject('invalid_successor_task_ids', `${entryAt}/successor_task_ids`);
540
- }
541
- if (new Set(entry.successor_task_ids).size !== entry.successor_task_ids.length) {
542
- return reject('duplicate_successor_task_id', `${entryAt}/successor_task_ids`);
543
- }
544
- const successorTail = entry.successor_task_ids.slice(1);
545
- for (let tailIndex = 1; tailIndex < successorTail.length; tailIndex += 1) {
546
- if (compareText(successorTail[tailIndex - 1], successorTail[tailIndex]) >= 0) {
547
- return reject('unsorted_or_duplicate_collection', `${entryAt}/successor_task_ids/${tailIndex + 1}`);
548
- }
549
- }
550
- if (!boundedText(entry.reason)) return reject('invalid_text', `${entryAt}/reason`);
551
- if (!Array.isArray(entry.evidence_digests) || entry.evidence_digests.length === 0
552
- || entry.evidence_digests.length > 512 || !entry.evidence_digests.every(isTodoDigest)) {
553
- return reject('invalid_evidence_digests', `${entryAt}/evidence_digests`);
554
- }
555
- if (new Set(entry.evidence_digests).size !== entry.evidence_digests.length) {
556
- return reject('duplicate_evidence_digest', `${entryAt}/evidence_digests`);
557
- }
558
- for (let digestIndex = 1; digestIndex < entry.evidence_digests.length; digestIndex += 1) {
559
- if (compareText(entry.evidence_digests[digestIndex - 1], entry.evidence_digests[digestIndex]) >= 0) {
560
- return reject('unsorted_or_duplicate_collection', `${entryAt}/evidence_digests/${digestIndex}`);
561
- }
562
- }
563
- if (['carry', 'stay'].includes(entry.disposition)
564
- && (entry.successor_task_ids.length !== 1
565
- || entry.successor_task_ids[0] !== entry.predecessor_task_id)) {
566
- return reject('disposition_successor_mismatch', entryAt);
567
- }
568
- if (entry.disposition === 'retire' && entry.successor_task_ids.length !== 0) {
569
- return reject('disposition_successor_mismatch', entryAt);
570
- }
571
- if (['replace', 'split'].includes(entry.disposition) && entry.successor_task_ids.length === 0) {
572
- return reject('disposition_successor_mismatch', entryAt);
573
- }
574
- if (index > 0 && compareText(value.entries[index - 1].predecessor_task_id,
575
- entry.predecessor_task_id) >= 0) {
576
- return reject('unsorted_or_duplicate_collection', `${at}/entries/${index}/predecessor_task_id`);
577
- }
578
- targets.push(...entry.successor_task_ids);
579
- }
580
- if (new Set(targets).size !== targets.length) return reject('duplicate_successor_task_id_across_entries', `${at}/entries`);
581
- if (!isTodoDigest(value.migration_digest)) return reject('invalid_digest', `${at}/migration_digest`);
582
- const expectedMigrationDigest = todoSelfDigest(value, 'migration_digest');
583
- if (value.migration_digest !== expectedMigrationDigest) {
584
- return reject('migration_digest_mismatch', `${at}/migration_digest`);
585
- }
586
- if (!validRuntimeTodoProjection(value, taskMigration)) {
587
- return reject('runtime_task_migration_projection_mismatch', at);
588
- }
589
- if (canonicalizeForCompare(value.entries.flatMap(({ successor_task_ids: ids }) => ids))
590
- !== canonicalizeForCompare(desiredPlan.tasks.map(({ task_id: taskId }) => taskId))) {
591
- return reject('runtime_task_migration_target_set_mismatch', at);
592
- }
593
- return { valid: true };
594
- }
595
-
596
- function explainSourceInventory(value, desiredPlan, { requireNarrativeRef = false } = {}, at = '/source_inventory') {
597
- const keyCheck = explainKeys(value, ['active', 'excluded_tombstones'], at);
598
- if (!keyCheck.valid) return keyCheck;
599
- if (!Array.isArray(value.active) || value.active.length > 512) {
600
- return reject('bounded_collection_violation', `${at}/active`);
601
- }
602
- if (!Array.isArray(value.excluded_tombstones) || value.excluded_tombstones.length > 2_048) {
603
- return reject('bounded_collection_violation', `${at}/excluded_tombstones`);
604
- }
605
- for (const [index, entry] of value.active.entries()) {
606
- const entryAt = `${at}/active/${index}`;
607
- const entryKeyCheck = explainKeys(entry, ['task_id', 'source_ref', 'source_digest'], entryAt);
608
- if (!entryKeyCheck.valid) return entryKeyCheck;
609
- if (!isTodoIdentifier(entry.task_id)) return reject('invalid_identifier', `${entryAt}/task_id`);
610
- if (parseTodoSourceRef(entry.source_ref) === null) return reject('invalid_source_ref', `${entryAt}/source_ref`);
611
- if (!isTodoDigest(entry.source_digest)) return reject('invalid_digest', `${entryAt}/source_digest`);
612
- }
613
- for (const [index, entry] of value.excluded_tombstones.entries()) {
614
- const entryAt = `${at}/excluded_tombstones/${index}`;
615
- const entryKeyCheck = explainKeys(entry, ['source_ref', 'source_digest', 'exclusion_reason'], entryAt);
616
- if (!entryKeyCheck.valid) return entryKeyCheck;
617
- if (parseTodoSourceRef(entry.source_ref) === null) return reject('invalid_source_ref', `${entryAt}/source_ref`);
618
- if (!isTodoDigest(entry.source_digest)) return reject('invalid_digest', `${entryAt}/source_digest`);
619
- if (!boundedText(entry.exclusion_reason)) return reject('invalid_text', `${entryAt}/exclusion_reason`);
620
- }
621
- const taskIds = desiredPlan.tasks.map(({ task_id: taskId }) => taskId);
622
- const activeIds = value.active.map(({ task_id: taskId }) => taskId);
623
- if (taskIds.length !== activeIds.length || !taskIds.every((id, index) => id === activeIds[index])) {
624
- return reject('source_inventory_task_set_mismatch', `${at}/active`);
625
- }
626
- if (requireNarrativeRef) {
627
- for (const [index, entry] of value.active.entries()) {
628
- const task = desiredPlan.tasks.find(({ task_id: taskId }) => taskId === entry.task_id);
629
- if (task?.narrative_ref !== entry.source_ref) {
630
- return reject('source_inventory_narrative_ref_mismatch', `${at}/active/${index}/source_ref`);
631
- }
632
- }
633
- }
634
- const activeRefs = value.active.map(({ source_ref: sourceRef }) => sourceRef);
635
- if (new Set(activeRefs).size !== activeRefs.length) return reject('duplicate_source_ref', `${at}/active`);
636
- const tombstoneRefs = value.excluded_tombstones.map(({ source_ref: sourceRef }) => sourceRef);
637
- if (new Set(tombstoneRefs).size !== tombstoneRefs.length) {
638
- return reject('duplicate_source_ref', `${at}/excluded_tombstones`);
639
- }
640
- const activeRefSet = new Set(activeRefs);
641
- if (tombstoneRefs.some((ref) => activeRefSet.has(ref))) {
642
- return reject('tombstone_ref_still_active', `${at}/excluded_tombstones`);
643
- }
644
- for (let index = 1; index < value.excluded_tombstones.length; index += 1) {
645
- if (compareText(value.excluded_tombstones[index - 1].source_ref,
646
- value.excluded_tombstones[index].source_ref) >= 0) {
647
- return reject('unsorted_or_duplicate_collection', `${at}/excluded_tombstones/${index}/source_ref`);
648
- }
649
- }
650
- return { valid: true };
651
- }
652
-
653
- function explainSourceCutoverBatch(value, revision, at = '/source_cutover_batch') {
654
- const keyCheck = explainKeys(value, ['batch_id', 'archive_ref', 'operations', 'batch_digest'], at);
655
- if (!keyCheck.valid) return keyCheck;
656
- if (!isTodoIdentifier(value.batch_id)) return reject('invalid_identifier', `${at}/batch_id`);
657
- if (!isTodoRef(value.archive_ref) || !value.archive_ref.endsWith('.md')
658
- || parseTodoSourceRef(value.archive_ref) !== null) {
659
- return reject('invalid_archive_ref', `${at}/archive_ref`);
660
- }
661
- if (!Array.isArray(value.operations) || value.operations.length === 0 || value.operations.length > 512) {
662
- return reject('bounded_collection_violation', `${at}/operations`);
663
- }
664
- const sourceRefs = new Set();
665
- const activeTaskIds = new Set();
666
- for (const [index, operation] of value.operations.entries()) {
667
- const opAt = `${at}/operations/${index}`;
668
- const keys = ['task_id', 'disposition', 'source_ref', 'source_digest', 'live_replacement'];
669
- const opKeyCheck = explainKeys(operation, keys, opAt);
670
- if (!opKeyCheck.valid) return opKeyCheck;
671
- if (!['active', 'excluded'].includes(operation.disposition)) {
672
- return reject('invalid_disposition', `${opAt}/disposition`);
673
- }
674
- if (operation.disposition === 'active' ? !isTodoIdentifier(operation.task_id)
675
- : operation.task_id !== null) {
676
- return reject('task_id_disposition_mismatch', `${opAt}/task_id`);
677
- }
678
- if (parseTodoSourceRef(operation.source_ref) === null) return reject('invalid_source_ref', `${opAt}/source_ref`);
679
- if (!isTodoDigest(operation.source_digest)) return reject('invalid_digest', `${opAt}/source_digest`);
680
- if (!validLiveReplacement(operation.live_replacement)) {
681
- return reject('invalid_live_replacement', `${opAt}/live_replacement`);
682
- }
683
- if (sourceRefs.has(operation.source_ref)) return reject('duplicate_source_ref', `${opAt}/source_ref`);
684
- sourceRefs.add(operation.source_ref);
685
- if (index > 0 && compareText(value.operations[index - 1].source_ref, operation.source_ref) >= 0) {
686
- return reject('unsorted_or_duplicate_collection', `${opAt}/source_ref`);
687
- }
688
- const archivedSourceRef = todoCutoverArchiveSourceRef(value, index);
689
- if (operation.disposition === 'active') {
690
- if (activeTaskIds.has(operation.task_id)) return reject('duplicate_task_id', `${opAt}/task_id`);
691
- activeTaskIds.add(operation.task_id);
692
- const inventory = revision.source_inventory.active
693
- .find(({ task_id: taskId }) => taskId === operation.task_id);
694
- const task = revision.desired_plan.tasks.find(({ task_id: taskId }) => taskId === operation.task_id);
695
- if (inventory?.source_ref !== archivedSourceRef || inventory.source_digest !== operation.source_digest
696
- || task?.narrative_ref !== archivedSourceRef) {
697
- return reject('cutover_operation_not_bound_to_inventory', opAt);
698
- }
699
- } else {
700
- const tombstone = revision.source_inventory.excluded_tombstones.find((entry) => (
701
- entry.source_ref === archivedSourceRef && entry.source_digest === operation.source_digest
702
- ));
703
- if (tombstone === undefined) return reject('cutover_operation_not_bound_to_tombstone', opAt);
704
- }
705
- }
706
- if (!isTodoDigest(value.batch_digest)) return reject('invalid_digest', `${at}/batch_digest`);
707
- const expectedBatchDigest = todoSelfDigest(value, 'batch_digest');
708
- if (value.batch_digest !== expectedBatchDigest) return reject('batch_digest_mismatch', `${at}/batch_digest`);
709
- return { valid: true };
710
- }
711
-
712
- /**
713
- * `lattice todo revise`が受理する`lattice.todo_revision.v1/v2`を診断する。
714
- *
715
- * `validateTodoRevision`の可否は変えない。desired_plan自体のgraph整合
716
- * (task/phase閉包・topology digest)は`validateTodoPlan`任せの単一reasonへ丸め、
717
- * ここでは実運用で詰まった箇所——必須key・task_migrationのソート・各digestの不一致——
718
- * を優先して名指しする。
719
- */
720
- export function explainTodoRevision(value) {
721
- try {
722
- if (!plainObject(value)) return reject('not_an_object', '');
723
- const revisionV1 = value.schema === 'lattice.todo_revision.v1';
724
- const revisionV2 = value.schema === 'lattice.todo_revision.v2';
725
- if (!revisionV1 && !revisionV2) return reject('schema_mismatch', '/schema');
726
- const keyCheck = explainKeys(value, revisionV1 ? REVISION_V1_KEYS : REVISION_V2_KEYS, '');
727
- if (!keyCheck.valid) return keyCheck;
728
- if (!isTodoIdentifier(value.project_id)) return reject('invalid_identifier', '/project_id');
729
- if (!isTodoIdentifier(value.plan_key)) return reject('invalid_identifier', '/plan_key');
730
- const predecessorCheck = explainPredecessor(value.predecessor);
731
- if (!predecessorCheck.valid) return predecessorCheck;
732
- if (!validateTodoPlan(value.desired_plan)) return reject('desired_plan_invalid', '/desired_plan');
733
- if (!['lattice.todo_plan.v3', 'lattice.todo_plan.v6'].includes(value.desired_plan.schema)) {
734
- return reject('desired_plan_schema_mismatch', '/desired_plan/schema');
735
- }
736
- if (value.desired_plan.project_id !== value.project_id) {
737
- return reject('desired_plan_project_id_mismatch', '/desired_plan/project_id');
738
- }
739
- if (value.desired_plan.plan_key !== value.plan_key) {
740
- return reject('desired_plan_plan_key_mismatch', '/desired_plan/plan_key');
741
- }
742
- if (value.desired_plan.predecessor_plan_digest !== value.predecessor.plan_digest) {
743
- return reject('desired_plan_predecessor_mismatch', '/desired_plan/predecessor_plan_digest');
744
- }
745
- const taskMigrationCheck = explainTaskMigration(value.task_migration);
746
- if (!taskMigrationCheck.valid) return taskMigrationCheck;
747
- const sourceCutoverBatch = revisionV2 ? value.source_cutover_batch : undefined;
748
- const sourceInventoryCheck = explainSourceInventory(value.source_inventory, value.desired_plan);
749
- if (!sourceInventoryCheck.valid) return sourceInventoryCheck;
750
- const expectedPlanVersion = todoRevisionPlanVersion({
751
- projectId: value.project_id, planKey: value.plan_key, predecessor: value.predecessor,
752
- desiredPlan: value.desired_plan, taskMigration: value.task_migration,
753
- sourceInventory: value.source_inventory, sourceCutoverBatch,
754
- });
755
- if (value.desired_plan.plan_version !== expectedPlanVersion) {
756
- return reject('plan_version_mismatch', '/desired_plan/plan_version');
757
- }
758
- const reconciliationCheck = explainKeys(value.reconciliation, [
759
- 'predecessor_reconciliation_digest', 'source_inventory_digest', 'reconciliation_digest',
760
- ], '/reconciliation');
761
- if (!reconciliationCheck.valid) return reconciliationCheck;
762
- if (!isTodoDigest(value.reconciliation.predecessor_reconciliation_digest)) {
763
- return reject('invalid_digest', '/reconciliation/predecessor_reconciliation_digest');
764
- }
765
- const expectedSourceInventoryDigest = todoSourceInventoryDigest(value.source_inventory);
766
- if (value.reconciliation.source_inventory_digest !== expectedSourceInventoryDigest) {
767
- return reject('source_inventory_digest_mismatch', '/reconciliation/source_inventory_digest');
768
- }
769
- const expectedReconciliationDigest = todoReconciliationDigest({
770
- predecessorReconciliationDigest: value.reconciliation.predecessor_reconciliation_digest,
771
- sourceInventoryDigest: value.reconciliation.source_inventory_digest,
772
- predecessor: value.predecessor, desiredPlanDigest: value.desired_plan.plan_digest,
773
- taskMigration: value.task_migration, sourceCutoverBatch,
774
- });
775
- if (value.reconciliation.reconciliation_digest !== expectedReconciliationDigest) {
776
- return reject('reconciliation_digest_mismatch', '/reconciliation/reconciliation_digest');
777
- }
778
- if (revisionV2) {
779
- const cutoverCheck = explainSourceCutoverBatch(value.source_cutover_batch, value);
780
- if (!cutoverCheck.valid) return cutoverCheck;
781
- }
782
- const targets = new Set(value.desired_plan.tasks.map(({ task_id: taskId }) => taskId));
783
- const badTargetIndex = value.task_migration
784
- .findIndex(({ to_task_id: toTaskId }) => toTaskId !== 'removed' && !targets.has(toTaskId));
785
- if (badTargetIndex !== -1) {
786
- return reject('task_migration_target_unresolved', `/task_migration/${badTargetIndex}/to_task_id`);
787
- }
788
- if (!isTodoDigest(value.revision_digest)) return reject('invalid_digest', '/revision_digest');
789
- const expectedRevisionDigest = todoSelfDigest(value, 'revision_digest');
790
- if (value.revision_digest !== expectedRevisionDigest) {
791
- return reject('revision_digest_mismatch', '/revision_digest');
792
- }
793
- // ここまでの個別検査を全て通過したのに`validateTodoRevision`がfalseを返す状況は、
794
- // このexplainがまだ言い当てられない違反があるということ。捏造せず未特定と申告する。
795
- return { valid: true };
796
- } catch {
797
- return reject('diagnosis_failed', '');
798
- }
799
- }
800
-
801
- /**
802
- * `lattice todo revise-phase`が受理する`lattice.phase_todo_revision.v1/v2/v3`を診断する。
803
- * 方針は`explainTodoRevision`と同じ——desired_planのgraph整合は単一reasonへ丸め、
804
- * v3で増える`runtime_task_migration`・`source_cutover_batch`・8-key reconciliationの
805
- * 各digestを個別に名指しする。
806
- */
807
- export function explainPhaseTodoRevision(value) {
808
- try {
809
- if (!plainObject(value)) return reject('not_an_object', '');
810
- const revisionV1 = value.schema === 'lattice.phase_todo_revision.v1';
811
- const revisionV2 = value.schema === 'lattice.phase_todo_revision.v2';
812
- const revisionV3 = value.schema === 'lattice.phase_todo_revision.v3';
813
- if (!revisionV1 && !revisionV2 && !revisionV3) return reject('schema_mismatch', '/schema');
814
- const keyCheck = explainKeys(value, revisionV3 ? PHASE_REVISION_V3_KEYS : PHASE_REVISION_KEYS, '');
815
- if (!keyCheck.valid) return keyCheck;
816
- if (!isTodoIdentifier(value.project_id)) return reject('invalid_identifier', '/project_id');
817
- if (!isTodoIdentifier(value.plan_key)) return reject('invalid_identifier', '/plan_key');
818
- const predecessorCheck = explainPredecessor(value.predecessor);
819
- if (!predecessorCheck.valid) return predecessorCheck;
820
- if (!validateTodoPlan(value.desired_plan)) return reject('desired_plan_invalid', '/desired_plan');
821
- const expectedPlanSchemas = revisionV3 ? ['lattice.todo_plan.v5', 'lattice.todo_plan.v7']
822
- : [revisionV2 ? 'lattice.todo_plan.v5' : 'lattice.todo_plan.v4'];
823
- if (!expectedPlanSchemas.includes(value.desired_plan.schema)) {
824
- return reject('desired_plan_schema_mismatch', '/desired_plan/schema');
825
- }
826
- if (value.desired_plan.project_id !== value.project_id) {
827
- return reject('desired_plan_project_id_mismatch', '/desired_plan/project_id');
828
- }
829
- if (value.desired_plan.plan_key !== value.plan_key) {
830
- return reject('desired_plan_plan_key_mismatch', '/desired_plan/plan_key');
831
- }
832
- if (value.desired_plan.predecessor_plan_digest !== value.predecessor.plan_digest) {
833
- return reject('desired_plan_predecessor_mismatch', '/desired_plan/predecessor_plan_digest');
834
- }
835
- const taskMigrationCheck = explainTaskMigration(value.task_migration);
836
- if (!taskMigrationCheck.valid) return taskMigrationCheck;
837
- const phaseMigrationCheck = explainPhaseMigration(value.phase_migration, value.desired_plan);
838
- if (!phaseMigrationCheck.valid) return phaseMigrationCheck;
839
- const expectedPlanVersion = phaseTodoRevisionPlanVersion({
840
- projectId: value.project_id, planKey: value.plan_key, predecessor: value.predecessor,
841
- desiredPlan: value.desired_plan, taskMigration: value.task_migration,
842
- phaseMigration: value.phase_migration,
843
- });
844
- if (value.desired_plan.plan_version !== expectedPlanVersion) {
845
- return reject('plan_version_mismatch', '/desired_plan/plan_version');
846
- }
847
- if (revisionV3) {
848
- const runtimeCheck = explainRuntimeTaskMigration(
849
- value.runtime_task_migration, value.task_migration, value.desired_plan,
850
- );
851
- if (!runtimeCheck.valid) return runtimeCheck;
852
- const sourceInventoryCheck = explainSourceInventory(value.source_inventory, value.desired_plan, {
853
- requireNarrativeRef: true,
854
- });
855
- if (!sourceInventoryCheck.valid) return sourceInventoryCheck;
856
- const cutoverCheck = explainSourceCutoverBatch(value.source_cutover_batch, value);
857
- if (!cutoverCheck.valid) return cutoverCheck;
858
- const reconciliationKeys = [
859
- 'predecessor_reconciliation_digest', 'source_inventory_digest', 'desired_plan_digest',
860
- 'runtime_task_migration_digest', 'task_migration_digest', 'phase_migration_digest',
861
- 'source_cutover_batch_digest', 'reconciliation_digest',
862
- ];
863
- const reconciliationCheck = explainKeys(value.reconciliation, reconciliationKeys, '/reconciliation');
864
- if (!reconciliationCheck.valid) return reconciliationCheck;
865
- if (!isTodoDigest(value.reconciliation.predecessor_reconciliation_digest)) {
866
- return reject('invalid_digest', '/reconciliation/predecessor_reconciliation_digest');
867
- }
868
- if (value.reconciliation.source_inventory_digest
869
- !== todoSourceInventoryDigest(value.source_inventory)) {
870
- return reject('source_inventory_digest_mismatch', '/reconciliation/source_inventory_digest');
871
- }
872
- if (value.reconciliation.desired_plan_digest !== value.desired_plan.plan_digest) {
873
- return reject('desired_plan_digest_mismatch', '/reconciliation/desired_plan_digest');
874
- }
875
- if (value.reconciliation.runtime_task_migration_digest !== value.runtime_task_migration.migration_digest) {
876
- return reject('runtime_task_migration_digest_mismatch', '/reconciliation/runtime_task_migration_digest');
877
- }
878
- if (value.reconciliation.task_migration_digest !== todoTaskMigrationDigest(value.task_migration)) {
879
- return reject('task_migration_digest_mismatch', '/reconciliation/task_migration_digest');
880
- }
881
- if (value.reconciliation.phase_migration_digest !== digestTodoArtifact(value.phase_migration)) {
882
- return reject('phase_migration_digest_mismatch', '/reconciliation/phase_migration_digest');
883
- }
884
- if (value.reconciliation.source_cutover_batch_digest !== value.source_cutover_batch.batch_digest) {
885
- return reject('source_cutover_batch_digest_mismatch', '/reconciliation/source_cutover_batch_digest');
886
- }
887
- const expectedReconciliationDigest = todoSelfDigest(value.reconciliation, 'reconciliation_digest');
888
- if (value.reconciliation.reconciliation_digest !== expectedReconciliationDigest) {
889
- return reject('reconciliation_digest_mismatch', '/reconciliation/reconciliation_digest');
890
- }
891
- }
892
- if (!isTodoDigest(value.revision_digest)) return reject('invalid_digest', '/revision_digest');
893
- const expectedRevisionDigest = todoSelfDigest(value, 'revision_digest');
894
- if (value.revision_digest !== expectedRevisionDigest) {
895
- return reject('revision_digest_mismatch', '/revision_digest');
896
- }
897
- return { valid: true };
898
- } catch {
899
- return reject('diagnosis_failed', '');
900
- }
901
- }
902
-
903
- /**
904
- * `lattice todo revise-set`が受理する`lattice.todo_revision_set.v1/v2/v3`を診断する。
905
- * 個々のrevision memberの中身は`explainTodoRevision`/`explainPhaseTodoRevision`へ委譲し、
906
- * setとして必要な件数・plan_key順序・自己digestだけをここで見る。
907
- */
908
- export function explainTodoRevisionSet(value) {
909
- try {
910
- if (!plainObject(value)) return reject('not_an_object', '');
911
- const setV1 = value.schema === 'lattice.todo_revision_set.v1';
912
- const setV2 = value.schema === 'lattice.todo_revision_set.v2';
913
- const setV3 = value.schema === 'lattice.todo_revision_set.v3';
914
- if (!setV1 && !setV2 && !setV3) return reject('schema_mismatch', '/schema');
915
- const keyCheck = explainKeys(value, ['schema', 'project_id', 'revisions', 'revision_set_digest'], '');
916
- if (!keyCheck.valid) return keyCheck;
917
- if (!isTodoIdentifier(value.project_id)) return reject('invalid_identifier', '/project_id');
918
- if (!Array.isArray(value.revisions) || value.revisions.length < 2 || value.revisions.length > 64) {
919
- return reject('bounded_collection_violation', '/revisions');
920
- }
921
- for (const [index, revision] of value.revisions.entries()) {
922
- const entryAt = `/revisions/${index}`;
923
- const isPhaseMember = setV3 && ['lattice.phase_todo_revision.v1', 'lattice.phase_todo_revision.v2']
924
- .includes(revision?.schema);
925
- if (isPhaseMember) {
926
- const memberCheck = explainPhaseTodoRevision(revision);
927
- if (!memberCheck.valid) {
928
- return reject(memberCheck.reason, `${entryAt}${memberCheck.path}`);
929
- }
930
- } else {
931
- if (!setV2 && !setV3 && revision?.schema !== 'lattice.todo_revision.v1') {
932
- return reject('revision_set_v1_requires_todo_revision_v1', `${entryAt}/schema`);
933
- }
934
- const memberCheck = explainTodoRevision(revision);
935
- if (!memberCheck.valid) {
936
- return reject(memberCheck.reason, `${entryAt}${memberCheck.path}`);
937
- }
938
- }
939
- if (revision.project_id !== value.project_id) {
940
- return reject('revision_project_id_mismatch', `${entryAt}/project_id`);
941
- }
942
- }
943
- if (setV2 && !value.revisions.some((revision) => revision.schema === 'lattice.todo_revision.v2')) {
944
- return reject('revision_set_v2_requires_todo_revision_v2_member', '/revisions');
945
- }
946
- if (setV3 && !value.revisions.some((revision) => [
947
- 'lattice.phase_todo_revision.v1', 'lattice.phase_todo_revision.v2',
948
- ].includes(revision.schema))) {
949
- return reject('revision_set_v3_requires_phase_todo_revision_member', '/revisions');
950
- }
951
- for (let index = 1; index < value.revisions.length; index += 1) {
952
- if (compareText(value.revisions[index - 1].plan_key, value.revisions[index].plan_key) >= 0) {
953
- return reject('unsorted_or_duplicate_collection', `/revisions/${index}/plan_key`);
954
- }
955
- }
956
- if (!isTodoDigest(value.revision_set_digest)) return reject('invalid_digest', '/revision_set_digest');
957
- const expectedDigest = todoSelfDigest(value, 'revision_set_digest');
958
- if (value.revision_set_digest !== expectedDigest) {
959
- return reject('revision_set_digest_mismatch', '/revision_set_digest');
960
- }
961
- return { valid: true };
962
- } catch {
963
- return reject('diagnosis_failed', '');
964
- }
965
- }
966
-
967
- export function validateTodoRevisionSet(value) {
968
- try {
969
- const setV1 = value?.schema === 'lattice.todo_revision_set.v1';
970
- const setV2 = value?.schema === 'lattice.todo_revision_set.v2';
971
- const setV3 = value?.schema === 'lattice.todo_revision_set.v3';
972
- return exactRecord(value, [
973
- 'schema', 'project_id', 'revisions', 'revision_set_digest',
974
- ])
975
- && (setV1 || setV2 || setV3)
976
- && isTodoIdentifier(value.project_id)
977
- && Array.isArray(value.revisions)
978
- && value.revisions.length >= 2
979
- && value.revisions.length <= 64
980
- && value.revisions.every((revision) => (
981
- (validateTodoRevision(revision)
982
- && (setV2 || setV3 || revision.schema === 'lattice.todo_revision.v1'))
983
- || (setV3 && ['lattice.phase_todo_revision.v1', 'lattice.phase_todo_revision.v2']
984
- .includes(revision.schema) && validatePhaseTodoRevision(revision))
985
- ) && revision.project_id === value.project_id)
986
- && (!setV2 || value.revisions.some((revision) => revision.schema === 'lattice.todo_revision.v2'))
987
- && (!setV3 || value.revisions.some((revision) => [
988
- 'lattice.phase_todo_revision.v1', 'lattice.phase_todo_revision.v2',
989
- ].includes(revision.schema)))
990
- && value.revisions.every((revision, index) => index === 0
991
- || compareText(value.revisions[index - 1].plan_key, revision.plan_key) < 0)
992
- && isTodoDigest(value.revision_set_digest)
993
- && value.revision_set_digest === todoSelfDigest(value, 'revision_set_digest');
994
- } catch { return false; }
995
- }
1
+ import {
2
+ canonicalizeTodoArtifact,
3
+ digestTodoArtifact,
4
+ exactRecord,
5
+ isTodoDigest,
6
+ isTodoIdentifier,
7
+ isTodoRef,
8
+ todoSelfDigest,
9
+ validateTodoPlan,
10
+ } from './todo-contracts.mjs';
11
+
12
+ const REVISION_V1_KEYS = [
13
+ 'schema', 'project_id', 'plan_key', 'predecessor', 'desired_plan', 'task_migration',
14
+ 'source_inventory', 'reconciliation', 'revision_digest',
15
+ ];
16
+ const REVISION_V2_KEYS = [...REVISION_V1_KEYS, 'source_cutover_batch'];
17
+ const PHASE_REVISION_KEYS = [
18
+ 'schema', 'project_id', 'plan_key', 'predecessor', 'desired_plan', 'task_migration',
19
+ 'phase_migration', 'revision_digest',
20
+ ];
21
+ const PHASE_REVISION_V3_KEYS = [
22
+ 'schema', 'project_id', 'plan_key', 'predecessor', 'desired_plan',
23
+ 'runtime_task_migration', 'task_migration', 'phase_migration', 'source_inventory',
24
+ 'reconciliation', 'source_cutover_batch', 'revision_digest',
25
+ ];
26
+
27
+ const compareText = (left, right) => left < right ? -1 : left > right ? 1 : 0;
28
+ const boundedText = (value) => typeof value === 'string' && value.length > 0
29
+ && Buffer.byteLength(value) <= 16_384 && !/[\u0000-\u001f\u007f]/u.test(value);
30
+
31
+ export function parseTodoSourceRef(value) {
32
+ if (typeof value !== 'string') return null;
33
+ const match = /^(.*)#L([1-9]\d*)$/u.exec(value);
34
+ if (match === null || !isTodoRef(match[1])) return null;
35
+ const line = Number(match[2]);
36
+ return Number.isSafeInteger(line) ? { path: match[1], line } : null;
37
+ }
38
+
39
+ export function todoRevisionPlanVersion({
40
+ projectId, planKey, predecessor, desiredPlan, taskMigration, sourceInventory,
41
+ sourceCutoverBatch = undefined,
42
+ }) {
43
+ const versionDigest = digestTodoArtifact({
44
+ schema: sourceCutoverBatch === undefined
45
+ ? 'lattice.todo_revision_version.v1' : 'lattice.todo_revision_version.v2',
46
+ project_id: projectId,
47
+ plan_key: planKey,
48
+ predecessor,
49
+ desired_topology: {
50
+ schema: desiredPlan.schema,
51
+ project_id: desiredPlan.project_id,
52
+ plan_key: desiredPlan.plan_key,
53
+ predecessor_plan_digest: desiredPlan.predecessor_plan_digest,
54
+ tasks: desiredPlan.tasks,
55
+ hard_dependencies: desiredPlan.hard_dependencies,
56
+ joins: desiredPlan.joins,
57
+ },
58
+ task_migration: taskMigration,
59
+ source_inventory: sourceInventory,
60
+ ...(sourceCutoverBatch === undefined ? {} : { source_cutover_batch: sourceCutoverBatch }),
61
+ });
62
+ return `rev-${versionDigest.slice(0, 24)}`;
63
+ }
64
+
65
+ export function phaseTodoRevisionPlanVersion({
66
+ projectId, planKey, predecessor, desiredPlan, taskMigration, phaseMigration,
67
+ }) {
68
+ const versionDigest = digestTodoArtifact({
69
+ schema: ['lattice.todo_plan.v5', 'lattice.todo_plan.v7'].includes(desiredPlan.schema)
70
+ ? 'lattice.phase_todo_revision_version.v2' : 'lattice.phase_todo_revision_version.v1', project_id: projectId,
71
+ plan_key: planKey, predecessor, desired_topology: {
72
+ schema: desiredPlan.schema, project_id: desiredPlan.project_id,
73
+ plan_key: desiredPlan.plan_key, predecessor_plan_digest: desiredPlan.predecessor_plan_digest,
74
+ tasks: desiredPlan.tasks, phases: desiredPlan.phases,
75
+ hard_dependencies: desiredPlan.hard_dependencies, joins: desiredPlan.joins,
76
+ ...(['lattice.todo_plan.v5', 'lattice.todo_plan.v7'].includes(desiredPlan.schema)
77
+ ? { phase_accept_dependencies: desiredPlan.phase_accept_dependencies } : {}),
78
+ }, task_migration: taskMigration, phase_migration: phaseMigration,
79
+ });
80
+ return `rev-${versionDigest.slice(0, 24)}`;
81
+ }
82
+
83
+ export function todoLegacyReconciliationDigest({ planDigest, journalHeadDigest }) {
84
+ if (!isTodoDigest(planDigest) || !isTodoDigest(journalHeadDigest)) {
85
+ throw new TypeError('legacy reconciliation anchor digest required');
86
+ }
87
+ return digestTodoArtifact({
88
+ schema: 'lattice.todo_reconciliation_anchor.v1',
89
+ state: 'registered_unreconciled',
90
+ plan_digest: planDigest,
91
+ journal_head_digest: journalHeadDigest,
92
+ });
93
+ }
94
+
95
+ export function todoSourceInventoryDigest(sourceInventory) {
96
+ return digestTodoArtifact(sourceInventory);
97
+ }
98
+
99
+ export function todoReconciliationDigest({
100
+ predecessorReconciliationDigest,
101
+ sourceInventoryDigest,
102
+ predecessor,
103
+ desiredPlanDigest,
104
+ taskMigration,
105
+ sourceCutoverBatch = undefined,
106
+ }) {
107
+ if (!isTodoDigest(predecessorReconciliationDigest) || !isTodoDigest(sourceInventoryDigest)
108
+ || !isTodoDigest(desiredPlanDigest)) throw new TypeError('reconciliation digest input invalid');
109
+ return digestTodoArtifact({
110
+ schema: sourceCutoverBatch === undefined
111
+ ? 'lattice.todo_reconciliation_binding.v1' : 'lattice.todo_reconciliation_binding.v2',
112
+ predecessor_reconciliation_digest: predecessorReconciliationDigest,
113
+ source_inventory_digest: sourceInventoryDigest,
114
+ predecessor,
115
+ desired_plan_digest: desiredPlanDigest,
116
+ task_migration_digest: digestTodoArtifact(taskMigration),
117
+ ...(sourceCutoverBatch === undefined ? {} : {
118
+ source_cutover_batch_digest: sourceCutoverBatch.batch_digest,
119
+ }),
120
+ });
121
+ }
122
+
123
+ export function todoCutoverArchiveSourceRef(batch, index) {
124
+ if (!Number.isSafeInteger(index) || index < 0) throw new TypeError('cutover operation index invalid');
125
+ return `${batch.archive_ref}#L${index + 6}`;
126
+ }
127
+
128
+ function validLiveReplacement(value) {
129
+ return typeof value === 'string' && value.length > 0 && Buffer.byteLength(value) <= 4_096
130
+ && !/[\r\n\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/u.test(value)
131
+ && !/^[\t ]*(?:[-+*]|\d+[A-Za-z]?\.|\d+\))[\t ]+\[[ xX]\](?:[\t ]+.*)?$/u.test(value);
132
+ }
133
+
134
+ function validSourceCutoverBatch(value, revision) {
135
+ if (!exactRecord(value, ['batch_id', 'archive_ref', 'operations', 'batch_digest'])
136
+ || !isTodoIdentifier(value.batch_id) || !isTodoRef(value.archive_ref)
137
+ || !value.archive_ref.endsWith('.md') || parseTodoSourceRef(value.archive_ref) !== null
138
+ || !Array.isArray(value.operations) || value.operations.length < 1 || value.operations.length > 512
139
+ || !isTodoDigest(value.batch_digest)
140
+ || value.batch_digest !== todoSelfDigest(value, 'batch_digest')) return false;
141
+ const sourceRefs = new Set();
142
+ const activeTaskIds = new Set();
143
+ for (const [index, operation] of value.operations.entries()) {
144
+ if (!exactRecord(operation, [
145
+ 'task_id', 'disposition', 'source_ref', 'source_digest', 'live_replacement',
146
+ ]) || !['active', 'excluded'].includes(operation.disposition)
147
+ || (operation.disposition === 'active' ? !isTodoIdentifier(operation.task_id) : operation.task_id !== null)
148
+ || parseTodoSourceRef(operation.source_ref) === null || !isTodoDigest(operation.source_digest)
149
+ || !validLiveReplacement(operation.live_replacement)
150
+ || sourceRefs.has(operation.source_ref)
151
+ || (index > 0 && compareText(value.operations[index - 1].source_ref, operation.source_ref) >= 0)) return false;
152
+ sourceRefs.add(operation.source_ref);
153
+ const archivedSourceRef = todoCutoverArchiveSourceRef(value, index);
154
+ if (operation.disposition === 'active') {
155
+ if (activeTaskIds.has(operation.task_id)) return false;
156
+ activeTaskIds.add(operation.task_id);
157
+ const inventory = revision.source_inventory.active.find(({ task_id }) => task_id === operation.task_id);
158
+ const task = revision.desired_plan.tasks.find(({ task_id }) => task_id === operation.task_id);
159
+ if (inventory?.source_ref !== archivedSourceRef
160
+ || inventory.source_digest !== operation.source_digest
161
+ || task?.narrative_ref !== archivedSourceRef) return false;
162
+ } else {
163
+ const tombstone = revision.source_inventory.excluded_tombstones.find((entry) => (
164
+ entry.source_ref === archivedSourceRef && entry.source_digest === operation.source_digest
165
+ ));
166
+ if (tombstone === undefined) return false;
167
+ }
168
+ }
169
+ return true;
170
+ }
171
+
172
+ function validPredecessor(value) {
173
+ return exactRecord(value, ['plan_digest', 'journal_head_digest', 'plan_version'])
174
+ && isTodoDigest(value.plan_digest) && isTodoDigest(value.journal_head_digest)
175
+ && isTodoIdentifier(value.plan_version);
176
+ }
177
+
178
+ function validTaskMigration(value) {
179
+ const activeTargets = Array.isArray(value)
180
+ ? value.filter(({ to_task_id }) => to_task_id !== 'removed').map(({ to_task_id }) => to_task_id)
181
+ : [];
182
+ return Array.isArray(value) && value.length > 0 && value.length <= 512
183
+ && value.every((entry) => exactRecord(entry, [
184
+ 'from_task_id', 'to_task_id', 'state_policy',
185
+ ]) && isTodoIdentifier(entry.from_task_id)
186
+ && (entry.to_task_id === 'removed' || isTodoIdentifier(entry.to_task_id))
187
+ && ((entry.state_policy === 'removed' && entry.to_task_id === 'removed')
188
+ || (['carry', 'carry_reconciled_metadata', 'reset_pending', 'acquire_phase'].includes(entry.state_policy)
189
+ && entry.to_task_id !== 'removed')))
190
+ && new Set(value.map(({ from_task_id }) => from_task_id)).size === value.length
191
+ && new Set(activeTargets).size === activeTargets.length
192
+ && value.every((entry, index) => index === 0
193
+ || compareText(value[index - 1].from_task_id, entry.from_task_id) < 0);
194
+ }
195
+
196
+ function validPhaseMigration(value, desiredPlan) {
197
+ if (!Array.isArray(value) || value.length === 0 || value.length > 512
198
+ || !value.every((entry) => exactRecord(entry, [
199
+ 'from_phase_id', 'to_phase_id', 'state_policy',
200
+ ]) && (entry.from_phase_id === null || isTodoIdentifier(entry.from_phase_id))
201
+ && (entry.to_phase_id === 'removed' || isTodoIdentifier(entry.to_phase_id))
202
+ && ['carry', 'reset', 'removed'].includes(entry.state_policy)
203
+ && (entry.state_policy !== 'carry' || (entry.from_phase_id !== null && entry.to_phase_id !== 'removed'))
204
+ && (entry.state_policy !== 'removed' || (entry.from_phase_id !== null && entry.to_phase_id === 'removed'))
205
+ && (entry.state_policy !== 'reset' || entry.to_phase_id !== 'removed'))) return false;
206
+ const sources = value.filter(({ from_phase_id }) => from_phase_id !== null).map(({ from_phase_id }) => from_phase_id);
207
+ const targets = value.filter(({ to_phase_id }) => to_phase_id !== 'removed').map(({ to_phase_id }) => to_phase_id);
208
+ return new Set(sources).size === sources.length && new Set(targets).size === targets.length
209
+ && canonicalizeForCompare(targets) === canonicalizeForCompare(desiredPlan.phases.map(({ phase_id }) => phase_id));
210
+ }
211
+
212
+ function canonicalizeForCompare(value) {
213
+ return canonicalizeTodoArtifact([...value].sort((left, right) => compareText(
214
+ canonicalizeTodoArtifact(left), canonicalizeTodoArtifact(right),
215
+ )));
216
+ }
217
+
218
+ export function todoTaskMigrationDigest(taskMigration) {
219
+ return todoSelfDigest({ task_migration: taskMigration, task_migration_digest: '' },
220
+ 'task_migration_digest');
221
+ }
222
+
223
+ function validRuntimeTaskMigration(value) {
224
+ if (!exactRecord(value, ['schema', 'entries', 'migration_digest'])
225
+ || value.schema !== 'lattice.runtime_task_migration.v1'
226
+ || !Array.isArray(value.entries) || value.entries.length < 1 || value.entries.length > 512
227
+ || !isTodoDigest(value.migration_digest)
228
+ || value.migration_digest !== todoSelfDigest(value, 'migration_digest')) return false;
229
+ const targets = [];
230
+ for (const [index, entry] of value.entries.entries()) {
231
+ const successorTail = Array.isArray(entry.successor_task_ids)
232
+ ? entry.successor_task_ids.slice(1) : [];
233
+ if (!exactRecord(entry, [
234
+ 'predecessor_task_id', 'disposition', 'successor_task_ids', 'reason', 'evidence_digests',
235
+ ]) || !isTodoIdentifier(entry.predecessor_task_id)
236
+ || !['carry', 'stay', 'replace', 'split', 'retire'].includes(entry.disposition)
237
+ || !Array.isArray(entry.successor_task_ids) || entry.successor_task_ids.length > 512
238
+ || !entry.successor_task_ids.every(isTodoIdentifier)
239
+ || new Set(entry.successor_task_ids).size !== entry.successor_task_ids.length
240
+ || successorTail.some((id, targetIndex) => targetIndex > 0
241
+ && compareText(successorTail[targetIndex - 1], id) >= 0)
242
+ || !boundedText(entry.reason) || !Array.isArray(entry.evidence_digests)
243
+ || entry.evidence_digests.length < 1 || entry.evidence_digests.length > 512
244
+ || !entry.evidence_digests.every(isTodoDigest)
245
+ || new Set(entry.evidence_digests).size !== entry.evidence_digests.length
246
+ || entry.evidence_digests.some((digest, digestIndex) => digestIndex > 0
247
+ && compareText(entry.evidence_digests[digestIndex - 1], digest) >= 0)
248
+ || (index > 0 && compareText(value.entries[index - 1].predecessor_task_id,
249
+ entry.predecessor_task_id) >= 0)) return false;
250
+ if (['carry', 'stay'].includes(entry.disposition)
251
+ && (entry.successor_task_ids.length !== 1
252
+ || entry.successor_task_ids[0] !== entry.predecessor_task_id)) return false;
253
+ if (entry.disposition === 'retire' && entry.successor_task_ids.length !== 0) return false;
254
+ if (['replace', 'split'].includes(entry.disposition) && entry.successor_task_ids.length === 0) return false;
255
+ targets.push(...entry.successor_task_ids);
256
+ }
257
+ return new Set(targets).size === targets.length;
258
+ }
259
+
260
+ function projectRuntimeTaskMigration(value) {
261
+ return value.entries.map((entry) => {
262
+ if (['carry', 'stay'].includes(entry.disposition)) return {
263
+ from_task_id: entry.predecessor_task_id,
264
+ to_task_id: entry.predecessor_task_id,
265
+ state_policy: 'carry',
266
+ };
267
+ if (entry.disposition === 'retire') return {
268
+ from_task_id: entry.predecessor_task_id, to_task_id: 'removed', state_policy: 'removed',
269
+ };
270
+ return { from_task_id: entry.predecessor_task_id,
271
+ to_task_id: entry.successor_task_ids[0], state_policy: 'reset_pending' };
272
+ });
273
+ }
274
+
275
+ function validRuntimeTodoProjection(runtimeMigration, taskMigration) {
276
+ const projected = projectRuntimeTaskMigration(runtimeMigration);
277
+ return projected.length === taskMigration.length && projected.every((expected, index) => {
278
+ const actual = taskMigration[index];
279
+ return expected.from_task_id === actual.from_task_id && expected.to_task_id === actual.to_task_id
280
+ // runtime disposition carry/stayは実行状態の持ち越しだけを申告する。task_migration側は
281
+ // carry_reconciled_metadataに加え、Phase獲得だけを許すacquire_phase(ADR 0147裁定4)も
282
+ // 「状態を持ち越すcarry系」の投影として受理する。
283
+ && (expected.state_policy === 'carry'
284
+ ? ['carry', 'carry_reconciled_metadata', 'acquire_phase'].includes(actual.state_policy)
285
+ : expected.state_policy === actual.state_policy);
286
+ });
287
+ }
288
+
289
+ export function validatePhaseTodoRevision(value) {
290
+ try {
291
+ const revisionV1 = value?.schema === 'lattice.phase_todo_revision.v1';
292
+ const revisionV2 = value?.schema === 'lattice.phase_todo_revision.v2';
293
+ const revisionV3 = value?.schema === 'lattice.phase_todo_revision.v3';
294
+ const keys = revisionV3 ? PHASE_REVISION_V3_KEYS : PHASE_REVISION_KEYS;
295
+ if (!exactRecord(value, keys) || (!revisionV1 && !revisionV2 && !revisionV3)) return false;
296
+ if (!isTodoIdentifier(value.project_id) || !isTodoIdentifier(value.plan_key)) return false;
297
+ if (!validPredecessor(value.predecessor) || !validateTodoPlan(value.desired_plan)
298
+ || !(revisionV3
299
+ ? ['lattice.todo_plan.v5', 'lattice.todo_plan.v7'].includes(value.desired_plan.schema)
300
+ : value.desired_plan.schema === (revisionV2 ? 'lattice.todo_plan.v5' : 'lattice.todo_plan.v4'))
301
+ || value.desired_plan.project_id !== value.project_id
302
+ || value.desired_plan.plan_key !== value.plan_key
303
+ || value.desired_plan.predecessor_plan_digest !== value.predecessor.plan_digest
304
+ || !validTaskMigration(value.task_migration)
305
+ || !validPhaseMigration(value.phase_migration, value.desired_plan)) return false;
306
+ if (value.desired_plan.plan_version !== phaseTodoRevisionPlanVersion({
307
+ projectId: value.project_id, planKey: value.plan_key, predecessor: value.predecessor,
308
+ desiredPlan: value.desired_plan, taskMigration: value.task_migration,
309
+ phaseMigration: value.phase_migration,
310
+ })) return false;
311
+ if (revisionV3) {
312
+ if (!validRuntimeTaskMigration(value.runtime_task_migration)
313
+ || !validRuntimeTodoProjection(value.runtime_task_migration, value.task_migration)
314
+ || canonicalizeForCompare(value.runtime_task_migration.entries
315
+ .flatMap(({ successor_task_ids: ids }) => ids))
316
+ !== canonicalizeForCompare(value.desired_plan.tasks.map(({ task_id: id }) => id))
317
+ || !validSourceInventory(value.source_inventory, value.desired_plan,
318
+ { requireNarrativeRef: true })
319
+ || !validSourceCutoverBatch(value.source_cutover_batch, value)
320
+ || !exactRecord(value.reconciliation, [
321
+ 'predecessor_reconciliation_digest', 'source_inventory_digest', 'desired_plan_digest',
322
+ 'runtime_task_migration_digest', 'task_migration_digest', 'phase_migration_digest',
323
+ 'source_cutover_batch_digest', 'reconciliation_digest',
324
+ ]) || !isTodoDigest(value.reconciliation.predecessor_reconciliation_digest)
325
+ || value.reconciliation.source_inventory_digest !== todoSourceInventoryDigest(value.source_inventory)
326
+ || value.reconciliation.desired_plan_digest !== value.desired_plan.plan_digest
327
+ || value.reconciliation.runtime_task_migration_digest !== value.runtime_task_migration.migration_digest
328
+ || value.reconciliation.task_migration_digest !== todoTaskMigrationDigest(value.task_migration)
329
+ || value.reconciliation.phase_migration_digest !== digestTodoArtifact(value.phase_migration)
330
+ || value.reconciliation.source_cutover_batch_digest !== value.source_cutover_batch.batch_digest
331
+ || value.reconciliation.reconciliation_digest
332
+ !== todoSelfDigest(value.reconciliation, 'reconciliation_digest')) return false;
333
+ }
334
+ return isTodoDigest(value.revision_digest)
335
+ && value.revision_digest === todoSelfDigest(value, 'revision_digest');
336
+ } catch { return false; }
337
+ }
338
+
339
+ function validSourceInventory(value, desiredPlan, { requireNarrativeRef = false } = {}) {
340
+ if (!exactRecord(value, ['active', 'excluded_tombstones'])
341
+ || !Array.isArray(value.active) || !Array.isArray(value.excluded_tombstones)
342
+ || value.active.length > 512 || value.excluded_tombstones.length > 2_048
343
+ || !value.active.every((entry) => exactRecord(entry, ['task_id', 'source_ref', 'source_digest'])
344
+ && isTodoIdentifier(entry.task_id) && parseTodoSourceRef(entry.source_ref) !== null
345
+ && isTodoDigest(entry.source_digest))
346
+ || !value.excluded_tombstones.every((entry) => exactRecord(entry, [
347
+ 'source_ref', 'source_digest', 'exclusion_reason',
348
+ ]) && parseTodoSourceRef(entry.source_ref) !== null && isTodoDigest(entry.source_digest)
349
+ && boundedText(entry.exclusion_reason))) return false;
350
+ const taskIds = desiredPlan.tasks.map(({ task_id }) => task_id);
351
+ const activeIds = value.active.map(({ task_id }) => task_id);
352
+ const activeRefs = value.active.map(({ source_ref }) => source_ref);
353
+ const tombstoneRefs = value.excluded_tombstones.map(({ source_ref }) => source_ref);
354
+ const activeRefSet = new Set(activeRefs);
355
+ return taskIds.length === activeIds.length && taskIds.every((id, index) => id === activeIds[index])
356
+ && (!requireNarrativeRef || value.active.every((entry) => desiredPlan.tasks
357
+ .find(({ task_id }) => task_id === entry.task_id)?.narrative_ref === entry.source_ref))
358
+ && activeRefSet.size === activeRefs.length
359
+ && new Set(tombstoneRefs).size === tombstoneRefs.length
360
+ && tombstoneRefs.every((ref) => !activeRefSet.has(ref))
361
+ && value.excluded_tombstones.every((entry, index) => index === 0
362
+ || compareText(value.excluded_tombstones[index - 1].source_ref, entry.source_ref) < 0);
363
+ }
364
+
365
+ export function validateTodoRevision(value) {
366
+ try {
367
+ const revisionV1 = value?.schema === 'lattice.todo_revision.v1';
368
+ const revisionV2 = value?.schema === 'lattice.todo_revision.v2';
369
+ const sourceCutoverBatch = revisionV2 ? value.source_cutover_batch : undefined;
370
+ if ((!revisionV1 && !revisionV2)
371
+ || !exactRecord(value, revisionV1 ? REVISION_V1_KEYS : REVISION_V2_KEYS)
372
+ || !isTodoIdentifier(value.project_id) || !isTodoIdentifier(value.plan_key)
373
+ || !validPredecessor(value.predecessor) || !validateTodoPlan(value.desired_plan)
374
+ || !['lattice.todo_plan.v3', 'lattice.todo_plan.v6'].includes(value.desired_plan.schema)
375
+ || value.desired_plan.project_id !== value.project_id
376
+ || value.desired_plan.plan_key !== value.plan_key
377
+ || value.desired_plan.predecessor_plan_digest !== value.predecessor.plan_digest
378
+ || !validTaskMigration(value.task_migration)
379
+ || !validSourceInventory(value.source_inventory, value.desired_plan)
380
+ || value.desired_plan.plan_version !== todoRevisionPlanVersion({
381
+ projectId: value.project_id, planKey: value.plan_key, predecessor: value.predecessor,
382
+ desiredPlan: value.desired_plan, taskMigration: value.task_migration,
383
+ sourceInventory: value.source_inventory, sourceCutoverBatch,
384
+ }) || !exactRecord(value.reconciliation, [
385
+ 'predecessor_reconciliation_digest', 'source_inventory_digest', 'reconciliation_digest',
386
+ ]) || !isTodoDigest(value.reconciliation.predecessor_reconciliation_digest)
387
+ || value.reconciliation.source_inventory_digest !== todoSourceInventoryDigest(value.source_inventory)
388
+ || value.reconciliation.reconciliation_digest !== todoReconciliationDigest({
389
+ predecessorReconciliationDigest: value.reconciliation.predecessor_reconciliation_digest,
390
+ sourceInventoryDigest: value.reconciliation.source_inventory_digest,
391
+ predecessor: value.predecessor,
392
+ desiredPlanDigest: value.desired_plan.plan_digest,
393
+ taskMigration: value.task_migration, sourceCutoverBatch,
394
+ })) return false;
395
+ if (revisionV2 && !validSourceCutoverBatch(value.source_cutover_batch, value)) return false;
396
+ const targets = new Set(value.desired_plan.tasks.map(({ task_id }) => task_id));
397
+ if (value.task_migration.some(({ to_task_id }) => to_task_id !== 'removed' && !targets.has(to_task_id))) return false;
398
+ return isTodoDigest(value.revision_digest)
399
+ && value.revision_digest === todoSelfDigest(value, 'revision_digest');
400
+ } catch { return false; }
401
+ }
402
+
403
+ const reject = (reason, path = '') => ({ valid: false, reason, path });
404
+
405
+ function plainObject(value) {
406
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
407
+ }
408
+
409
+ /**
410
+ * 期待keyとの過不足を1件ずつ言い当てる。missing/unexpectedのどちらが先に見つかっても
411
+ * そこで止め、複数の欠落を一度に説明しない——`exactRecord`のbooleanと違い、
412
+ * 最初に見つかった違反fieldをpathへ刻む(ADR 0130の案内規律をrevision入口へ拡張)。
413
+ */
414
+ function explainKeys(value, requiredKeys, at) {
415
+ if (!plainObject(value) || Object.getPrototypeOf(value) !== Object.prototype) {
416
+ return reject('not_an_object', at);
417
+ }
418
+ const actualKeys = new Set(Object.keys(value));
419
+ for (const key of requiredKeys) {
420
+ if (!actualKeys.has(key)) return reject('missing_required_key', `${at}/${key}`);
421
+ }
422
+ const requiredSet = new Set(requiredKeys);
423
+ for (const key of actualKeys) {
424
+ if (!requiredSet.has(key)) return reject('unexpected_key', `${at}/${key}`);
425
+ }
426
+ return { valid: true };
427
+ }
428
+
429
+ function explainPredecessor(value, at = '/predecessor') {
430
+ const keyCheck = explainKeys(value, ['plan_digest', 'journal_head_digest', 'plan_version'], at);
431
+ if (!keyCheck.valid) return keyCheck;
432
+ if (!isTodoDigest(value.plan_digest)) return reject('invalid_digest', `${at}/plan_digest`);
433
+ if (!isTodoDigest(value.journal_head_digest)) return reject('invalid_digest', `${at}/journal_head_digest`);
434
+ if (!isTodoIdentifier(value.plan_version)) return reject('invalid_identifier', `${at}/plan_version`);
435
+ return { valid: true };
436
+ }
437
+
438
+ /**
439
+ * 実運用で最も時間を溶かした違反——配列のソート漏れ——を、壊れているindexを名指しして返す。
440
+ * `validTaskMigration`と同じ規則(from_task_idの厳密昇順、from/toの重複禁止)を、
441
+ * 可否を変えずに1件ずつ言い当てる。
442
+ */
443
+ function explainTaskMigration(value, at = '/task_migration') {
444
+ if (!Array.isArray(value) || value.length === 0 || value.length > 512) {
445
+ return reject('bounded_collection_violation', at);
446
+ }
447
+ for (const [index, entry] of value.entries()) {
448
+ const entryAt = `${at}/${index}`;
449
+ const keyCheck = explainKeys(entry, ['from_task_id', 'to_task_id', 'state_policy'], entryAt);
450
+ if (!keyCheck.valid) return keyCheck;
451
+ if (!isTodoIdentifier(entry.from_task_id)) return reject('invalid_identifier', `${entryAt}/from_task_id`);
452
+ if (entry.to_task_id !== 'removed' && !isTodoIdentifier(entry.to_task_id)) {
453
+ return reject('invalid_identifier', `${entryAt}/to_task_id`);
454
+ }
455
+ const validPolicy = (entry.state_policy === 'removed' && entry.to_task_id === 'removed')
456
+ || (['carry', 'carry_reconciled_metadata', 'reset_pending', 'acquire_phase'].includes(entry.state_policy)
457
+ && entry.to_task_id !== 'removed');
458
+ if (!validPolicy) return reject('state_policy_disposition_mismatch', `${entryAt}/state_policy`);
459
+ }
460
+ if (new Set(value.map(({ from_task_id: fromTaskId }) => fromTaskId)).size !== value.length) {
461
+ return reject('duplicate_from_task_id', at);
462
+ }
463
+ const activeTargets = value.filter(({ to_task_id: toTaskId }) => toTaskId !== 'removed')
464
+ .map(({ to_task_id: toTaskId }) => toTaskId);
465
+ if (new Set(activeTargets).size !== activeTargets.length) {
466
+ return reject('duplicate_to_task_id', at);
467
+ }
468
+ for (let index = 1; index < value.length; index += 1) {
469
+ if (compareText(value[index - 1].from_task_id, value[index].from_task_id) >= 0) {
470
+ return reject('unsorted_or_duplicate_collection', `${at}/${index}/from_task_id`);
471
+ }
472
+ }
473
+ return { valid: true };
474
+ }
475
+
476
+ function explainPhaseMigration(value, desiredPlan, at = '/phase_migration') {
477
+ if (!Array.isArray(value) || value.length === 0 || value.length > 512) {
478
+ return reject('bounded_collection_violation', at);
479
+ }
480
+ for (const [index, entry] of value.entries()) {
481
+ const entryAt = `${at}/${index}`;
482
+ const keyCheck = explainKeys(entry, ['from_phase_id', 'to_phase_id', 'state_policy'], entryAt);
483
+ if (!keyCheck.valid) return keyCheck;
484
+ if (entry.from_phase_id !== null && !isTodoIdentifier(entry.from_phase_id)) {
485
+ return reject('invalid_identifier', `${entryAt}/from_phase_id`);
486
+ }
487
+ if (entry.to_phase_id !== 'removed' && !isTodoIdentifier(entry.to_phase_id)) {
488
+ return reject('invalid_identifier', `${entryAt}/to_phase_id`);
489
+ }
490
+ if (!['carry', 'reset', 'removed'].includes(entry.state_policy)) {
491
+ return reject('invalid_state_policy', `${entryAt}/state_policy`);
492
+ }
493
+ if (entry.state_policy === 'carry'
494
+ && (entry.from_phase_id === null || entry.to_phase_id === 'removed')) {
495
+ return reject('state_policy_disposition_mismatch', entryAt);
496
+ }
497
+ if (entry.state_policy === 'removed'
498
+ && (entry.from_phase_id === null || entry.to_phase_id !== 'removed')) {
499
+ return reject('state_policy_disposition_mismatch', entryAt);
500
+ }
501
+ if (entry.state_policy === 'reset' && entry.to_phase_id === 'removed') {
502
+ return reject('state_policy_disposition_mismatch', entryAt);
503
+ }
504
+ }
505
+ const sources = value.filter(({ from_phase_id: fromPhaseId }) => fromPhaseId !== null)
506
+ .map(({ from_phase_id: fromPhaseId }) => fromPhaseId);
507
+ if (new Set(sources).size !== sources.length) return reject('duplicate_from_phase_id', at);
508
+ const targets = value.filter(({ to_phase_id: toPhaseId }) => toPhaseId !== 'removed')
509
+ .map(({ to_phase_id: toPhaseId }) => toPhaseId);
510
+ if (new Set(targets).size !== targets.length) return reject('duplicate_to_phase_id', at);
511
+ if (canonicalizeForCompare(targets)
512
+ !== canonicalizeForCompare(desiredPlan.phases.map(({ phase_id: phaseId }) => phaseId))) {
513
+ return reject('phase_migration_target_set_mismatch', at);
514
+ }
515
+ return { valid: true };
516
+ }
517
+
518
+ function explainRuntimeTaskMigration(value, taskMigration, desiredPlan, at = '/runtime_task_migration') {
519
+ const keyCheck = explainKeys(value, ['schema', 'entries', 'migration_digest'], at);
520
+ if (!keyCheck.valid) return keyCheck;
521
+ if (value.schema !== 'lattice.runtime_task_migration.v1') return reject('schema_mismatch', `${at}/schema`);
522
+ if (!Array.isArray(value.entries) || value.entries.length === 0 || value.entries.length > 512) {
523
+ return reject('bounded_collection_violation', `${at}/entries`);
524
+ }
525
+ const targets = [];
526
+ for (const [index, entry] of value.entries.entries()) {
527
+ const entryAt = `${at}/entries/${index}`;
528
+ const keys = ['predecessor_task_id', 'disposition', 'successor_task_ids', 'reason', 'evidence_digests'];
529
+ const entryKeyCheck = explainKeys(entry, keys, entryAt);
530
+ if (!entryKeyCheck.valid) return entryKeyCheck;
531
+ if (!isTodoIdentifier(entry.predecessor_task_id)) {
532
+ return reject('invalid_identifier', `${entryAt}/predecessor_task_id`);
533
+ }
534
+ if (!['carry', 'stay', 'replace', 'split', 'retire'].includes(entry.disposition)) {
535
+ return reject('invalid_disposition', `${entryAt}/disposition`);
536
+ }
537
+ if (!Array.isArray(entry.successor_task_ids) || entry.successor_task_ids.length > 512
538
+ || !entry.successor_task_ids.every(isTodoIdentifier)) {
539
+ return reject('invalid_successor_task_ids', `${entryAt}/successor_task_ids`);
540
+ }
541
+ if (new Set(entry.successor_task_ids).size !== entry.successor_task_ids.length) {
542
+ return reject('duplicate_successor_task_id', `${entryAt}/successor_task_ids`);
543
+ }
544
+ const successorTail = entry.successor_task_ids.slice(1);
545
+ for (let tailIndex = 1; tailIndex < successorTail.length; tailIndex += 1) {
546
+ if (compareText(successorTail[tailIndex - 1], successorTail[tailIndex]) >= 0) {
547
+ return reject('unsorted_or_duplicate_collection', `${entryAt}/successor_task_ids/${tailIndex + 1}`);
548
+ }
549
+ }
550
+ if (!boundedText(entry.reason)) return reject('invalid_text', `${entryAt}/reason`);
551
+ if (!Array.isArray(entry.evidence_digests) || entry.evidence_digests.length === 0
552
+ || entry.evidence_digests.length > 512 || !entry.evidence_digests.every(isTodoDigest)) {
553
+ return reject('invalid_evidence_digests', `${entryAt}/evidence_digests`);
554
+ }
555
+ if (new Set(entry.evidence_digests).size !== entry.evidence_digests.length) {
556
+ return reject('duplicate_evidence_digest', `${entryAt}/evidence_digests`);
557
+ }
558
+ for (let digestIndex = 1; digestIndex < entry.evidence_digests.length; digestIndex += 1) {
559
+ if (compareText(entry.evidence_digests[digestIndex - 1], entry.evidence_digests[digestIndex]) >= 0) {
560
+ return reject('unsorted_or_duplicate_collection', `${entryAt}/evidence_digests/${digestIndex}`);
561
+ }
562
+ }
563
+ if (['carry', 'stay'].includes(entry.disposition)
564
+ && (entry.successor_task_ids.length !== 1
565
+ || entry.successor_task_ids[0] !== entry.predecessor_task_id)) {
566
+ return reject('disposition_successor_mismatch', entryAt);
567
+ }
568
+ if (entry.disposition === 'retire' && entry.successor_task_ids.length !== 0) {
569
+ return reject('disposition_successor_mismatch', entryAt);
570
+ }
571
+ if (['replace', 'split'].includes(entry.disposition) && entry.successor_task_ids.length === 0) {
572
+ return reject('disposition_successor_mismatch', entryAt);
573
+ }
574
+ if (index > 0 && compareText(value.entries[index - 1].predecessor_task_id,
575
+ entry.predecessor_task_id) >= 0) {
576
+ return reject('unsorted_or_duplicate_collection', `${at}/entries/${index}/predecessor_task_id`);
577
+ }
578
+ targets.push(...entry.successor_task_ids);
579
+ }
580
+ if (new Set(targets).size !== targets.length) return reject('duplicate_successor_task_id_across_entries', `${at}/entries`);
581
+ if (!isTodoDigest(value.migration_digest)) return reject('invalid_digest', `${at}/migration_digest`);
582
+ const expectedMigrationDigest = todoSelfDigest(value, 'migration_digest');
583
+ if (value.migration_digest !== expectedMigrationDigest) {
584
+ return reject('migration_digest_mismatch', `${at}/migration_digest`);
585
+ }
586
+ if (!validRuntimeTodoProjection(value, taskMigration)) {
587
+ return reject('runtime_task_migration_projection_mismatch', at);
588
+ }
589
+ if (canonicalizeForCompare(value.entries.flatMap(({ successor_task_ids: ids }) => ids))
590
+ !== canonicalizeForCompare(desiredPlan.tasks.map(({ task_id: taskId }) => taskId))) {
591
+ return reject('runtime_task_migration_target_set_mismatch', at);
592
+ }
593
+ return { valid: true };
594
+ }
595
+
596
+ function explainSourceInventory(value, desiredPlan, { requireNarrativeRef = false } = {}, at = '/source_inventory') {
597
+ const keyCheck = explainKeys(value, ['active', 'excluded_tombstones'], at);
598
+ if (!keyCheck.valid) return keyCheck;
599
+ if (!Array.isArray(value.active) || value.active.length > 512) {
600
+ return reject('bounded_collection_violation', `${at}/active`);
601
+ }
602
+ if (!Array.isArray(value.excluded_tombstones) || value.excluded_tombstones.length > 2_048) {
603
+ return reject('bounded_collection_violation', `${at}/excluded_tombstones`);
604
+ }
605
+ for (const [index, entry] of value.active.entries()) {
606
+ const entryAt = `${at}/active/${index}`;
607
+ const entryKeyCheck = explainKeys(entry, ['task_id', 'source_ref', 'source_digest'], entryAt);
608
+ if (!entryKeyCheck.valid) return entryKeyCheck;
609
+ if (!isTodoIdentifier(entry.task_id)) return reject('invalid_identifier', `${entryAt}/task_id`);
610
+ if (parseTodoSourceRef(entry.source_ref) === null) return reject('invalid_source_ref', `${entryAt}/source_ref`);
611
+ if (!isTodoDigest(entry.source_digest)) return reject('invalid_digest', `${entryAt}/source_digest`);
612
+ }
613
+ for (const [index, entry] of value.excluded_tombstones.entries()) {
614
+ const entryAt = `${at}/excluded_tombstones/${index}`;
615
+ const entryKeyCheck = explainKeys(entry, ['source_ref', 'source_digest', 'exclusion_reason'], entryAt);
616
+ if (!entryKeyCheck.valid) return entryKeyCheck;
617
+ if (parseTodoSourceRef(entry.source_ref) === null) return reject('invalid_source_ref', `${entryAt}/source_ref`);
618
+ if (!isTodoDigest(entry.source_digest)) return reject('invalid_digest', `${entryAt}/source_digest`);
619
+ if (!boundedText(entry.exclusion_reason)) return reject('invalid_text', `${entryAt}/exclusion_reason`);
620
+ }
621
+ const taskIds = desiredPlan.tasks.map(({ task_id: taskId }) => taskId);
622
+ const activeIds = value.active.map(({ task_id: taskId }) => taskId);
623
+ if (taskIds.length !== activeIds.length || !taskIds.every((id, index) => id === activeIds[index])) {
624
+ return reject('source_inventory_task_set_mismatch', `${at}/active`);
625
+ }
626
+ if (requireNarrativeRef) {
627
+ for (const [index, entry] of value.active.entries()) {
628
+ const task = desiredPlan.tasks.find(({ task_id: taskId }) => taskId === entry.task_id);
629
+ if (task?.narrative_ref !== entry.source_ref) {
630
+ return reject('source_inventory_narrative_ref_mismatch', `${at}/active/${index}/source_ref`);
631
+ }
632
+ }
633
+ }
634
+ const activeRefs = value.active.map(({ source_ref: sourceRef }) => sourceRef);
635
+ if (new Set(activeRefs).size !== activeRefs.length) return reject('duplicate_source_ref', `${at}/active`);
636
+ const tombstoneRefs = value.excluded_tombstones.map(({ source_ref: sourceRef }) => sourceRef);
637
+ if (new Set(tombstoneRefs).size !== tombstoneRefs.length) {
638
+ return reject('duplicate_source_ref', `${at}/excluded_tombstones`);
639
+ }
640
+ const activeRefSet = new Set(activeRefs);
641
+ if (tombstoneRefs.some((ref) => activeRefSet.has(ref))) {
642
+ return reject('tombstone_ref_still_active', `${at}/excluded_tombstones`);
643
+ }
644
+ for (let index = 1; index < value.excluded_tombstones.length; index += 1) {
645
+ if (compareText(value.excluded_tombstones[index - 1].source_ref,
646
+ value.excluded_tombstones[index].source_ref) >= 0) {
647
+ return reject('unsorted_or_duplicate_collection', `${at}/excluded_tombstones/${index}/source_ref`);
648
+ }
649
+ }
650
+ return { valid: true };
651
+ }
652
+
653
+ function explainSourceCutoverBatch(value, revision, at = '/source_cutover_batch') {
654
+ const keyCheck = explainKeys(value, ['batch_id', 'archive_ref', 'operations', 'batch_digest'], at);
655
+ if (!keyCheck.valid) return keyCheck;
656
+ if (!isTodoIdentifier(value.batch_id)) return reject('invalid_identifier', `${at}/batch_id`);
657
+ if (!isTodoRef(value.archive_ref) || !value.archive_ref.endsWith('.md')
658
+ || parseTodoSourceRef(value.archive_ref) !== null) {
659
+ return reject('invalid_archive_ref', `${at}/archive_ref`);
660
+ }
661
+ if (!Array.isArray(value.operations) || value.operations.length === 0 || value.operations.length > 512) {
662
+ return reject('bounded_collection_violation', `${at}/operations`);
663
+ }
664
+ const sourceRefs = new Set();
665
+ const activeTaskIds = new Set();
666
+ for (const [index, operation] of value.operations.entries()) {
667
+ const opAt = `${at}/operations/${index}`;
668
+ const keys = ['task_id', 'disposition', 'source_ref', 'source_digest', 'live_replacement'];
669
+ const opKeyCheck = explainKeys(operation, keys, opAt);
670
+ if (!opKeyCheck.valid) return opKeyCheck;
671
+ if (!['active', 'excluded'].includes(operation.disposition)) {
672
+ return reject('invalid_disposition', `${opAt}/disposition`);
673
+ }
674
+ if (operation.disposition === 'active' ? !isTodoIdentifier(operation.task_id)
675
+ : operation.task_id !== null) {
676
+ return reject('task_id_disposition_mismatch', `${opAt}/task_id`);
677
+ }
678
+ if (parseTodoSourceRef(operation.source_ref) === null) return reject('invalid_source_ref', `${opAt}/source_ref`);
679
+ if (!isTodoDigest(operation.source_digest)) return reject('invalid_digest', `${opAt}/source_digest`);
680
+ if (!validLiveReplacement(operation.live_replacement)) {
681
+ return reject('invalid_live_replacement', `${opAt}/live_replacement`);
682
+ }
683
+ if (sourceRefs.has(operation.source_ref)) return reject('duplicate_source_ref', `${opAt}/source_ref`);
684
+ sourceRefs.add(operation.source_ref);
685
+ if (index > 0 && compareText(value.operations[index - 1].source_ref, operation.source_ref) >= 0) {
686
+ return reject('unsorted_or_duplicate_collection', `${opAt}/source_ref`);
687
+ }
688
+ const archivedSourceRef = todoCutoverArchiveSourceRef(value, index);
689
+ if (operation.disposition === 'active') {
690
+ if (activeTaskIds.has(operation.task_id)) return reject('duplicate_task_id', `${opAt}/task_id`);
691
+ activeTaskIds.add(operation.task_id);
692
+ const inventory = revision.source_inventory.active
693
+ .find(({ task_id: taskId }) => taskId === operation.task_id);
694
+ const task = revision.desired_plan.tasks.find(({ task_id: taskId }) => taskId === operation.task_id);
695
+ if (inventory?.source_ref !== archivedSourceRef || inventory.source_digest !== operation.source_digest
696
+ || task?.narrative_ref !== archivedSourceRef) {
697
+ return reject('cutover_operation_not_bound_to_inventory', opAt);
698
+ }
699
+ } else {
700
+ const tombstone = revision.source_inventory.excluded_tombstones.find((entry) => (
701
+ entry.source_ref === archivedSourceRef && entry.source_digest === operation.source_digest
702
+ ));
703
+ if (tombstone === undefined) return reject('cutover_operation_not_bound_to_tombstone', opAt);
704
+ }
705
+ }
706
+ if (!isTodoDigest(value.batch_digest)) return reject('invalid_digest', `${at}/batch_digest`);
707
+ const expectedBatchDigest = todoSelfDigest(value, 'batch_digest');
708
+ if (value.batch_digest !== expectedBatchDigest) return reject('batch_digest_mismatch', `${at}/batch_digest`);
709
+ return { valid: true };
710
+ }
711
+
712
+ /**
713
+ * `lattice todo revise`が受理する`lattice.todo_revision.v1/v2`を診断する。
714
+ *
715
+ * `validateTodoRevision`の可否は変えない。desired_plan自体のgraph整合
716
+ * (task/phase閉包・topology digest)は`validateTodoPlan`任せの単一reasonへ丸め、
717
+ * ここでは実運用で詰まった箇所——必須key・task_migrationのソート・各digestの不一致——
718
+ * を優先して名指しする。
719
+ */
720
+ export function explainTodoRevision(value) {
721
+ try {
722
+ if (!plainObject(value)) return reject('not_an_object', '');
723
+ const revisionV1 = value.schema === 'lattice.todo_revision.v1';
724
+ const revisionV2 = value.schema === 'lattice.todo_revision.v2';
725
+ if (!revisionV1 && !revisionV2) return reject('schema_mismatch', '/schema');
726
+ const keyCheck = explainKeys(value, revisionV1 ? REVISION_V1_KEYS : REVISION_V2_KEYS, '');
727
+ if (!keyCheck.valid) return keyCheck;
728
+ if (!isTodoIdentifier(value.project_id)) return reject('invalid_identifier', '/project_id');
729
+ if (!isTodoIdentifier(value.plan_key)) return reject('invalid_identifier', '/plan_key');
730
+ const predecessorCheck = explainPredecessor(value.predecessor);
731
+ if (!predecessorCheck.valid) return predecessorCheck;
732
+ if (!validateTodoPlan(value.desired_plan)) return reject('desired_plan_invalid', '/desired_plan');
733
+ if (!['lattice.todo_plan.v3', 'lattice.todo_plan.v6'].includes(value.desired_plan.schema)) {
734
+ return reject('desired_plan_schema_mismatch', '/desired_plan/schema');
735
+ }
736
+ if (value.desired_plan.project_id !== value.project_id) {
737
+ return reject('desired_plan_project_id_mismatch', '/desired_plan/project_id');
738
+ }
739
+ if (value.desired_plan.plan_key !== value.plan_key) {
740
+ return reject('desired_plan_plan_key_mismatch', '/desired_plan/plan_key');
741
+ }
742
+ if (value.desired_plan.predecessor_plan_digest !== value.predecessor.plan_digest) {
743
+ return reject('desired_plan_predecessor_mismatch', '/desired_plan/predecessor_plan_digest');
744
+ }
745
+ const taskMigrationCheck = explainTaskMigration(value.task_migration);
746
+ if (!taskMigrationCheck.valid) return taskMigrationCheck;
747
+ const sourceCutoverBatch = revisionV2 ? value.source_cutover_batch : undefined;
748
+ const sourceInventoryCheck = explainSourceInventory(value.source_inventory, value.desired_plan);
749
+ if (!sourceInventoryCheck.valid) return sourceInventoryCheck;
750
+ const expectedPlanVersion = todoRevisionPlanVersion({
751
+ projectId: value.project_id, planKey: value.plan_key, predecessor: value.predecessor,
752
+ desiredPlan: value.desired_plan, taskMigration: value.task_migration,
753
+ sourceInventory: value.source_inventory, sourceCutoverBatch,
754
+ });
755
+ if (value.desired_plan.plan_version !== expectedPlanVersion) {
756
+ return reject('plan_version_mismatch', '/desired_plan/plan_version');
757
+ }
758
+ const reconciliationCheck = explainKeys(value.reconciliation, [
759
+ 'predecessor_reconciliation_digest', 'source_inventory_digest', 'reconciliation_digest',
760
+ ], '/reconciliation');
761
+ if (!reconciliationCheck.valid) return reconciliationCheck;
762
+ if (!isTodoDigest(value.reconciliation.predecessor_reconciliation_digest)) {
763
+ return reject('invalid_digest', '/reconciliation/predecessor_reconciliation_digest');
764
+ }
765
+ const expectedSourceInventoryDigest = todoSourceInventoryDigest(value.source_inventory);
766
+ if (value.reconciliation.source_inventory_digest !== expectedSourceInventoryDigest) {
767
+ return reject('source_inventory_digest_mismatch', '/reconciliation/source_inventory_digest');
768
+ }
769
+ const expectedReconciliationDigest = todoReconciliationDigest({
770
+ predecessorReconciliationDigest: value.reconciliation.predecessor_reconciliation_digest,
771
+ sourceInventoryDigest: value.reconciliation.source_inventory_digest,
772
+ predecessor: value.predecessor, desiredPlanDigest: value.desired_plan.plan_digest,
773
+ taskMigration: value.task_migration, sourceCutoverBatch,
774
+ });
775
+ if (value.reconciliation.reconciliation_digest !== expectedReconciliationDigest) {
776
+ return reject('reconciliation_digest_mismatch', '/reconciliation/reconciliation_digest');
777
+ }
778
+ if (revisionV2) {
779
+ const cutoverCheck = explainSourceCutoverBatch(value.source_cutover_batch, value);
780
+ if (!cutoverCheck.valid) return cutoverCheck;
781
+ }
782
+ const targets = new Set(value.desired_plan.tasks.map(({ task_id: taskId }) => taskId));
783
+ const badTargetIndex = value.task_migration
784
+ .findIndex(({ to_task_id: toTaskId }) => toTaskId !== 'removed' && !targets.has(toTaskId));
785
+ if (badTargetIndex !== -1) {
786
+ return reject('task_migration_target_unresolved', `/task_migration/${badTargetIndex}/to_task_id`);
787
+ }
788
+ if (!isTodoDigest(value.revision_digest)) return reject('invalid_digest', '/revision_digest');
789
+ const expectedRevisionDigest = todoSelfDigest(value, 'revision_digest');
790
+ if (value.revision_digest !== expectedRevisionDigest) {
791
+ return reject('revision_digest_mismatch', '/revision_digest');
792
+ }
793
+ // ここまでの個別検査を全て通過したのに`validateTodoRevision`がfalseを返す状況は、
794
+ // このexplainがまだ言い当てられない違反があるということ。捏造せず未特定と申告する。
795
+ return { valid: true };
796
+ } catch {
797
+ return reject('diagnosis_failed', '');
798
+ }
799
+ }
800
+
801
+ /**
802
+ * `lattice todo revise-phase`が受理する`lattice.phase_todo_revision.v1/v2/v3`を診断する。
803
+ * 方針は`explainTodoRevision`と同じ——desired_planのgraph整合は単一reasonへ丸め、
804
+ * v3で増える`runtime_task_migration`・`source_cutover_batch`・8-key reconciliationの
805
+ * 各digestを個別に名指しする。
806
+ */
807
+ export function explainPhaseTodoRevision(value) {
808
+ try {
809
+ if (!plainObject(value)) return reject('not_an_object', '');
810
+ const revisionV1 = value.schema === 'lattice.phase_todo_revision.v1';
811
+ const revisionV2 = value.schema === 'lattice.phase_todo_revision.v2';
812
+ const revisionV3 = value.schema === 'lattice.phase_todo_revision.v3';
813
+ if (!revisionV1 && !revisionV2 && !revisionV3) return reject('schema_mismatch', '/schema');
814
+ const keyCheck = explainKeys(value, revisionV3 ? PHASE_REVISION_V3_KEYS : PHASE_REVISION_KEYS, '');
815
+ if (!keyCheck.valid) return keyCheck;
816
+ if (!isTodoIdentifier(value.project_id)) return reject('invalid_identifier', '/project_id');
817
+ if (!isTodoIdentifier(value.plan_key)) return reject('invalid_identifier', '/plan_key');
818
+ const predecessorCheck = explainPredecessor(value.predecessor);
819
+ if (!predecessorCheck.valid) return predecessorCheck;
820
+ if (!validateTodoPlan(value.desired_plan)) return reject('desired_plan_invalid', '/desired_plan');
821
+ const expectedPlanSchemas = revisionV3 ? ['lattice.todo_plan.v5', 'lattice.todo_plan.v7']
822
+ : [revisionV2 ? 'lattice.todo_plan.v5' : 'lattice.todo_plan.v4'];
823
+ if (!expectedPlanSchemas.includes(value.desired_plan.schema)) {
824
+ return reject('desired_plan_schema_mismatch', '/desired_plan/schema');
825
+ }
826
+ if (value.desired_plan.project_id !== value.project_id) {
827
+ return reject('desired_plan_project_id_mismatch', '/desired_plan/project_id');
828
+ }
829
+ if (value.desired_plan.plan_key !== value.plan_key) {
830
+ return reject('desired_plan_plan_key_mismatch', '/desired_plan/plan_key');
831
+ }
832
+ if (value.desired_plan.predecessor_plan_digest !== value.predecessor.plan_digest) {
833
+ return reject('desired_plan_predecessor_mismatch', '/desired_plan/predecessor_plan_digest');
834
+ }
835
+ const taskMigrationCheck = explainTaskMigration(value.task_migration);
836
+ if (!taskMigrationCheck.valid) return taskMigrationCheck;
837
+ const phaseMigrationCheck = explainPhaseMigration(value.phase_migration, value.desired_plan);
838
+ if (!phaseMigrationCheck.valid) return phaseMigrationCheck;
839
+ const expectedPlanVersion = phaseTodoRevisionPlanVersion({
840
+ projectId: value.project_id, planKey: value.plan_key, predecessor: value.predecessor,
841
+ desiredPlan: value.desired_plan, taskMigration: value.task_migration,
842
+ phaseMigration: value.phase_migration,
843
+ });
844
+ if (value.desired_plan.plan_version !== expectedPlanVersion) {
845
+ return reject('plan_version_mismatch', '/desired_plan/plan_version');
846
+ }
847
+ if (revisionV3) {
848
+ const runtimeCheck = explainRuntimeTaskMigration(
849
+ value.runtime_task_migration, value.task_migration, value.desired_plan,
850
+ );
851
+ if (!runtimeCheck.valid) return runtimeCheck;
852
+ const sourceInventoryCheck = explainSourceInventory(value.source_inventory, value.desired_plan, {
853
+ requireNarrativeRef: true,
854
+ });
855
+ if (!sourceInventoryCheck.valid) return sourceInventoryCheck;
856
+ const cutoverCheck = explainSourceCutoverBatch(value.source_cutover_batch, value);
857
+ if (!cutoverCheck.valid) return cutoverCheck;
858
+ const reconciliationKeys = [
859
+ 'predecessor_reconciliation_digest', 'source_inventory_digest', 'desired_plan_digest',
860
+ 'runtime_task_migration_digest', 'task_migration_digest', 'phase_migration_digest',
861
+ 'source_cutover_batch_digest', 'reconciliation_digest',
862
+ ];
863
+ const reconciliationCheck = explainKeys(value.reconciliation, reconciliationKeys, '/reconciliation');
864
+ if (!reconciliationCheck.valid) return reconciliationCheck;
865
+ if (!isTodoDigest(value.reconciliation.predecessor_reconciliation_digest)) {
866
+ return reject('invalid_digest', '/reconciliation/predecessor_reconciliation_digest');
867
+ }
868
+ if (value.reconciliation.source_inventory_digest
869
+ !== todoSourceInventoryDigest(value.source_inventory)) {
870
+ return reject('source_inventory_digest_mismatch', '/reconciliation/source_inventory_digest');
871
+ }
872
+ if (value.reconciliation.desired_plan_digest !== value.desired_plan.plan_digest) {
873
+ return reject('desired_plan_digest_mismatch', '/reconciliation/desired_plan_digest');
874
+ }
875
+ if (value.reconciliation.runtime_task_migration_digest !== value.runtime_task_migration.migration_digest) {
876
+ return reject('runtime_task_migration_digest_mismatch', '/reconciliation/runtime_task_migration_digest');
877
+ }
878
+ if (value.reconciliation.task_migration_digest !== todoTaskMigrationDigest(value.task_migration)) {
879
+ return reject('task_migration_digest_mismatch', '/reconciliation/task_migration_digest');
880
+ }
881
+ if (value.reconciliation.phase_migration_digest !== digestTodoArtifact(value.phase_migration)) {
882
+ return reject('phase_migration_digest_mismatch', '/reconciliation/phase_migration_digest');
883
+ }
884
+ if (value.reconciliation.source_cutover_batch_digest !== value.source_cutover_batch.batch_digest) {
885
+ return reject('source_cutover_batch_digest_mismatch', '/reconciliation/source_cutover_batch_digest');
886
+ }
887
+ const expectedReconciliationDigest = todoSelfDigest(value.reconciliation, 'reconciliation_digest');
888
+ if (value.reconciliation.reconciliation_digest !== expectedReconciliationDigest) {
889
+ return reject('reconciliation_digest_mismatch', '/reconciliation/reconciliation_digest');
890
+ }
891
+ }
892
+ if (!isTodoDigest(value.revision_digest)) return reject('invalid_digest', '/revision_digest');
893
+ const expectedRevisionDigest = todoSelfDigest(value, 'revision_digest');
894
+ if (value.revision_digest !== expectedRevisionDigest) {
895
+ return reject('revision_digest_mismatch', '/revision_digest');
896
+ }
897
+ return { valid: true };
898
+ } catch {
899
+ return reject('diagnosis_failed', '');
900
+ }
901
+ }
902
+
903
+ /**
904
+ * `lattice todo revise-set`が受理する`lattice.todo_revision_set.v1/v2/v3`を診断する。
905
+ * 個々のrevision memberの中身は`explainTodoRevision`/`explainPhaseTodoRevision`へ委譲し、
906
+ * setとして必要な件数・plan_key順序・自己digestだけをここで見る。
907
+ */
908
+ export function explainTodoRevisionSet(value) {
909
+ try {
910
+ if (!plainObject(value)) return reject('not_an_object', '');
911
+ const setV1 = value.schema === 'lattice.todo_revision_set.v1';
912
+ const setV2 = value.schema === 'lattice.todo_revision_set.v2';
913
+ const setV3 = value.schema === 'lattice.todo_revision_set.v3';
914
+ if (!setV1 && !setV2 && !setV3) return reject('schema_mismatch', '/schema');
915
+ const keyCheck = explainKeys(value, ['schema', 'project_id', 'revisions', 'revision_set_digest'], '');
916
+ if (!keyCheck.valid) return keyCheck;
917
+ if (!isTodoIdentifier(value.project_id)) return reject('invalid_identifier', '/project_id');
918
+ if (!Array.isArray(value.revisions) || value.revisions.length < 2 || value.revisions.length > 64) {
919
+ return reject('bounded_collection_violation', '/revisions');
920
+ }
921
+ for (const [index, revision] of value.revisions.entries()) {
922
+ const entryAt = `/revisions/${index}`;
923
+ const isPhaseMember = setV3 && ['lattice.phase_todo_revision.v1', 'lattice.phase_todo_revision.v2']
924
+ .includes(revision?.schema);
925
+ if (isPhaseMember) {
926
+ const memberCheck = explainPhaseTodoRevision(revision);
927
+ if (!memberCheck.valid) {
928
+ return reject(memberCheck.reason, `${entryAt}${memberCheck.path}`);
929
+ }
930
+ } else {
931
+ if (!setV2 && !setV3 && revision?.schema !== 'lattice.todo_revision.v1') {
932
+ return reject('revision_set_v1_requires_todo_revision_v1', `${entryAt}/schema`);
933
+ }
934
+ const memberCheck = explainTodoRevision(revision);
935
+ if (!memberCheck.valid) {
936
+ return reject(memberCheck.reason, `${entryAt}${memberCheck.path}`);
937
+ }
938
+ }
939
+ if (revision.project_id !== value.project_id) {
940
+ return reject('revision_project_id_mismatch', `${entryAt}/project_id`);
941
+ }
942
+ }
943
+ if (setV2 && !value.revisions.some((revision) => revision.schema === 'lattice.todo_revision.v2')) {
944
+ return reject('revision_set_v2_requires_todo_revision_v2_member', '/revisions');
945
+ }
946
+ if (setV3 && !value.revisions.some((revision) => [
947
+ 'lattice.phase_todo_revision.v1', 'lattice.phase_todo_revision.v2',
948
+ ].includes(revision.schema))) {
949
+ return reject('revision_set_v3_requires_phase_todo_revision_member', '/revisions');
950
+ }
951
+ for (let index = 1; index < value.revisions.length; index += 1) {
952
+ if (compareText(value.revisions[index - 1].plan_key, value.revisions[index].plan_key) >= 0) {
953
+ return reject('unsorted_or_duplicate_collection', `/revisions/${index}/plan_key`);
954
+ }
955
+ }
956
+ if (!isTodoDigest(value.revision_set_digest)) return reject('invalid_digest', '/revision_set_digest');
957
+ const expectedDigest = todoSelfDigest(value, 'revision_set_digest');
958
+ if (value.revision_set_digest !== expectedDigest) {
959
+ return reject('revision_set_digest_mismatch', '/revision_set_digest');
960
+ }
961
+ return { valid: true };
962
+ } catch {
963
+ return reject('diagnosis_failed', '');
964
+ }
965
+ }
966
+
967
+ export function validateTodoRevisionSet(value) {
968
+ try {
969
+ const setV1 = value?.schema === 'lattice.todo_revision_set.v1';
970
+ const setV2 = value?.schema === 'lattice.todo_revision_set.v2';
971
+ const setV3 = value?.schema === 'lattice.todo_revision_set.v3';
972
+ return exactRecord(value, [
973
+ 'schema', 'project_id', 'revisions', 'revision_set_digest',
974
+ ])
975
+ && (setV1 || setV2 || setV3)
976
+ && isTodoIdentifier(value.project_id)
977
+ && Array.isArray(value.revisions)
978
+ && value.revisions.length >= 2
979
+ && value.revisions.length <= 64
980
+ && value.revisions.every((revision) => (
981
+ (validateTodoRevision(revision)
982
+ && (setV2 || setV3 || revision.schema === 'lattice.todo_revision.v1'))
983
+ || (setV3 && ['lattice.phase_todo_revision.v1', 'lattice.phase_todo_revision.v2']
984
+ .includes(revision.schema) && validatePhaseTodoRevision(revision))
985
+ ) && revision.project_id === value.project_id)
986
+ && (!setV2 || value.revisions.some((revision) => revision.schema === 'lattice.todo_revision.v2'))
987
+ && (!setV3 || value.revisions.some((revision) => [
988
+ 'lattice.phase_todo_revision.v1', 'lattice.phase_todo_revision.v2',
989
+ ].includes(revision.schema)))
990
+ && value.revisions.every((revision, index) => index === 0
991
+ || compareText(value.revisions[index - 1].plan_key, revision.plan_key) < 0)
992
+ && isTodoDigest(value.revision_set_digest)
993
+ && value.revision_set_digest === todoSelfDigest(value, 'revision_set_digest');
994
+ } catch { return false; }
995
+ }