@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,824 +1,824 @@
1
- import { canonicalizeArtifact, digestArtifact } from './artifact-contracts.mjs';
2
- import { isCanonicalUtcTimestamp } from './timestamp-contract.mjs';
3
-
4
- // RC3 runtime契約(ADR 0044 Decision 2・3・5・7)。
5
- // RC2公開済みschemaを変更しない加算moduleであり、全schemaはexact key、
6
- // bounded collection、canonical serialization、自己digest規則をfail closedで検査する。
7
- // field追加・意味変更はschema versionを上げ、新しいADRで裁定する。
8
-
9
- const SHA256 = /^[0-9a-f]{64}$/;
10
- const SHA1 = /^[0-9a-f]{40}$/;
11
- const IDENTIFIER = /^[0-9A-Za-z](?:[0-9A-Za-z._-]{0,127})$/;
12
- const MAX_COLLECTION = 256;
13
- const MAX_NODES_PER_PLAN = 8;
14
-
15
- // 人間向けaudit reasonは表示を偽装できる制御文字を拒否しつつ、通常のUnicode説明を保持する。
16
- const AUDIT_BIDI_CONTROLS = /[\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/u;
17
-
18
- export function validRuntimeAbandonReason(value) {
19
- return typeof value === 'string'
20
- && value === value.trim()
21
- && [...value].length >= 1
22
- && [...value].length <= 256
23
- && !/\p{Cc}/u.test(value)
24
- && !/[\u2028\u2029]/u.test(value)
25
- && !AUDIT_BIDI_CONTROLS.test(value);
26
- }
27
-
28
- // ADR 0044 Decision 3.2のclosed event kind set。拡張はrun_event.v2+新ADRでだけ行う。
29
- export const RUN_EVENT_KINDS = Object.freeze([
30
- 'run_initialized',
31
- 'plan_compiled',
32
- 'plan_verified',
33
- 'dispatch_decided',
34
- 'executor_dispatched',
35
- 'checkpoint_observed',
36
- 'receipt_recorded',
37
- 'conflict_found',
38
- 'intake_frozen',
39
- 'hold_decided',
40
- 'carry_over_witnessed',
41
- 'epoch_rebound',
42
- 'context_invalidated',
43
- 'plan_recompiled',
44
- 'intake_resumed',
45
- 'receipt_accepted',
46
- 'receipt_rejected',
47
- 'executor_terminal',
48
- 'run_closed',
49
- ]);
50
-
51
- // ADR 0044 Decision 5のclosed conflict分類と、Decision 7.5のhold理由kind。
52
- export const RUNTIME_CONFLICT_KINDS = Object.freeze([
53
- 'observed_write_conflict',
54
- 'observed_line_change',
55
- 'semantic_conflict_unknown',
56
- 'effect_conflict_unknown',
57
- 'undeclared_write',
58
- 'stale_context',
59
- ]);
60
- export const HOLD_REASON_KINDS = Object.freeze([
61
- ...RUNTIME_CONFLICT_KINDS,
62
- 'carry_over_unprovable',
63
- ]);
64
-
65
- function exactRecord(value, keys) {
66
- if (value === null || typeof value !== 'object' || Array.isArray(value)
67
- || Object.getPrototypeOf(value) !== Object.prototype) {
68
- return false;
69
- }
70
- const actual = Object.keys(value).sort();
71
- const expected = [...keys].sort();
72
- return actual.length === expected.length
73
- && actual.every((key, index) => key === expected[index]);
74
- }
75
-
76
- function plainObject(value) {
77
- return value !== null && typeof value === 'object' && !Array.isArray(value)
78
- && Object.getPrototypeOf(value) === Object.prototype;
79
- }
80
-
81
- function digest(value) {
82
- return typeof value === 'string' && SHA256.test(value);
83
- }
84
-
85
- function gitSha(value) {
86
- return typeof value === 'string' && SHA1.test(value);
87
- }
88
-
89
- function identifier(value) {
90
- return typeof value === 'string' && IDENTIFIER.test(value);
91
- }
92
-
93
- function timestamp(value) {
94
- return isCanonicalUtcTimestamp(value);
95
- }
96
-
97
- function nonNegativeInteger(value) {
98
- return Number.isSafeInteger(value) && value >= 0;
99
- }
100
-
101
- function positiveInteger(value) {
102
- return Number.isSafeInteger(value) && value >= 1;
103
- }
104
-
105
- const CONTROL_CHARACTER = /[\u0000-\u001f\u007f]/;
106
- const MAX_PATH_BYTES = 1024;
107
-
108
- /**
109
- * repo相対pathだけを受理する(不正pathのfail closed、ADR 0044 Decision 2)。
110
- * 絶対path、`..`遡上、空segment、制御文字、`\`区切りを拒否する。
111
- * 末尾`/`はdeclared write prefix(directory宣言)としてのみ許す。
112
- */
113
- function repoRelativePath(value, { allowPrefix = false } = {}) {
114
- if (typeof value !== 'string'
115
- || value.length === 0
116
- || Buffer.byteLength(value, 'utf8') > MAX_PATH_BYTES
117
- || CONTROL_CHARACTER.test(value)
118
- || value.includes('\\')
119
- || value.startsWith('/')) {
120
- return false;
121
- }
122
- const isPrefix = value.endsWith('/');
123
- if (isPrefix && !allowPrefix) return false;
124
- const body = isPrefix ? value.slice(0, -1) : value;
125
- const segments = body.split('/');
126
- return segments.every((segment) => (
127
- segment.length > 0 && segment !== '.' && segment !== '..'
128
- ));
129
- }
130
-
131
- function repoPathArray(value, options) {
132
- return boundedArray(value, (entry) => repoRelativePath(entry, options));
133
- }
134
-
135
- const OWN_KINDS = Object.freeze(['symbol', 'path']);
136
- const STATE_EFFECT_KINDS = Object.freeze([
137
- 'state',
138
- 'schema',
139
- 'invariant',
140
- 'effect',
141
- 'external_effect',
142
- ]);
143
-
144
- /**
145
- * 所有宣言。`creates: true`は「このpathはまだ存在せず、このTODOが作る」という創作の意思である
146
- * (ADR 0135 Decision 3・ADR 0136)。
147
- *
148
- * `kind`は`path`のまま据え置く。存在の有無は資源の種類ではなく資源の状態であり、kindを分けると
149
- * 既存のpath判定(write交差の免除、conflict resourceのkind)が全部この宣言を取りこぼす。
150
- *
151
- * 値は`true`だけを受理する。`false`は「存在するpath」と同義で、同じ事実に2つの書き方を
152
- * 与えることになる。省略が既定である。
153
- */
154
- function ownEntry(value, { allowCreates = false } = {}) {
155
- if (!plainObject(value)) return false;
156
- const creates = Object.hasOwn(value, 'creates');
157
- if (creates && !allowCreates) return false;
158
- return exactRecord(value, creates ? ['kind', 'target', 'creates'] : ['kind', 'target'])
159
- && OWN_KINDS.includes(value.kind)
160
- && (!creates || (value.creates === true && value.kind === 'path'))
161
- && typeof value.target === 'string'
162
- && value.target.length > 0
163
- && Buffer.byteLength(value.target, 'utf8') <= MAX_PATH_BYTES;
164
- }
165
-
166
- function stateEffectEntry(value) {
167
- return plainObject(value)
168
- && exactRecord(value, ['resource_id', 'kind'])
169
- && identifier(value.resource_id)
170
- && STATE_EFFECT_KINDS.includes(value.kind);
171
- }
172
-
173
- function unknownEntry(value) {
174
- return plainObject(value)
175
- && exactRecord(value, ['kind', 'ref'])
176
- && identifier(value.kind)
177
- && typeof value.ref === 'string'
178
- && value.ref.length > 0;
179
- }
180
-
181
- export const MANUAL_WITNESS_FIELDS = Object.freeze([
182
- 'owns',
183
- 'reads',
184
- 'writes',
185
- 'resources',
186
- 'state_effects',
187
- 'sensor_provenance',
188
- 'affected_tests',
189
- 'unknowns',
190
- ]);
191
- export const MANUAL_WITNESS_OPTIONAL_FIELDS = Object.freeze(['lines']);
192
- export const LINE_ROLES = Object.freeze(['reads', 'writes']);
193
-
194
- function lineAnchor(value) {
195
- if (!plainObject(value)) return false;
196
- if (value.kind === 'path') {
197
- return exactRecord(value, ['kind', 'path']) && repoRelativePath(value.path);
198
- }
199
- return value.kind === 'symbol'
200
- && exactRecord(value, ['kind', 'name', 'path'])
201
- && typeof value.name === 'string' && value.name.length > 0
202
- && Buffer.byteLength(value.name, 'utf8') <= MAX_PATH_BYTES
203
- && repoRelativePath(value.path);
204
- }
205
-
206
- function lineEntry(value) {
207
- return plainObject(value)
208
- && exactRecord(value, ['line_id', 'role', 'anchors'])
209
- && identifier(value.line_id)
210
- && LINE_ROLES.includes(value.role)
211
- && boundedArray(value.anchors, lineAnchor, { min: 1 });
212
- }
213
-
214
- function lineEntries(value) {
215
- return boundedArray(value, lineEntry)
216
- && new Set(value.map(({ line_id: lineId }) => lineId)).size === value.length;
217
- }
218
-
219
- // manual witness entryの判定は`explainRunRequest`だけが所有する。
220
- // 同じ規則をbooleanと診断の二箇所へ持たないための単一正本化(ADR 0123)。
221
-
222
- function boundedArray(value, predicate, { min = 0, max = MAX_COLLECTION } = {}) {
223
- return Array.isArray(value)
224
- && value.length >= min
225
- && value.length <= max
226
- && value.every((entry) => predicate(entry));
227
- }
228
-
229
- function uniqueIdentifierArray(value, options) {
230
- return boundedArray(value, identifier, options)
231
- && new Set(value).size === value.length;
232
- }
233
-
234
- function validateSafely(value, validator) {
235
- try {
236
- canonicalizeArtifact(value);
237
- return validator(value) === true;
238
- } catch {
239
- return false;
240
- }
241
- }
242
-
243
- /**
244
- * 自己digest規則: `digestField`自身を除いた残り全fieldのcanonical JSON SHA-256。
245
- * `run_request.v1`の`request_digest`(RC2 `admission_digest`と同型)を全schemaへ適用する。
246
- */
247
- export function selfDigest(value, digestField) {
248
- const projection = {};
249
- for (const key of Object.keys(value)) {
250
- if (key !== digestField) projection[key] = value[key];
251
- }
252
- return digestArtifact(projection);
253
- }
254
-
255
- function selfDigestValid(value, digestField) {
256
- return digest(value[digestField]) && value[digestField] === selfDigest(value, digestField);
257
- }
258
-
259
- const WITNESS_PROVENANCE = Object.freeze([
260
- 'sensor',
261
- 'manual_candidate_spec',
262
- 'manual_state_effect',
263
- ]);
264
-
265
- /**
266
- * 現行のrun request契約。v5はpathを跨ぐ意味的な線を任意宣言できる。v4は同じ予測契約の
267
- * lines無し版として残し、v3以前の厳密なcompile契約も既存requestの意味を変えずに読む。
268
- *
269
- * v2はこの系列ではない。ADR 0064のepoch後継request(`predecessor_request_digest`と
270
- * `task_migration_digest`を持つ別shape)が既に使っている番号なので、飛ばして採番する。
271
- */
272
- export const RUN_REQUEST_SCHEMA = 'lattice.run_request.v5';
273
- export const RUN_REQUEST_PREDICTION_SCHEMA = 'lattice.run_request.v4';
274
- export const RUN_REQUEST_DECLARATIVE_SCHEMA = 'lattice.run_request.v3';
275
- export const RUN_REQUEST_LEGACY_SCHEMAS = Object.freeze([
276
- RUN_REQUEST_PREDICTION_SCHEMA,
277
- RUN_REQUEST_DECLARATIVE_SCHEMA,
278
- 'lattice.run_request.v1',
279
- ]);
280
- export const RUN_REQUEST_SCHEMAS = Object.freeze([
281
- RUN_REQUEST_SCHEMA,
282
- ...RUN_REQUEST_LEGACY_SCHEMAS,
283
- ]);
284
-
285
- export const RUN_REQUEST_FIELDS = Object.freeze([
286
- 'schema',
287
- 'request_id',
288
- 'repo',
289
- 'capacity',
290
- 'todos',
291
- 'manual_witness',
292
- 'sensor_query_set',
293
- 'executor_capability',
294
- 'claim_mode',
295
- 'request_digest',
296
- ]);
297
-
298
- export const RUN_REQUEST_CLAIM_MODE = 'exact_minimum';
299
-
300
- /**
301
- * `sensor_query_set.queries[].operation`のclosed set。
302
- * runtime front-endはこの定数を輸入して使う(同じ閉集合を二箇所へ持たない)。
303
- */
304
- export const SENSOR_QUERY_OPERATIONS = Object.freeze([
305
- 'status',
306
- 'query',
307
- 'callers',
308
- 'callees',
309
- 'impact',
310
- 'affected',
311
- ]);
312
-
313
- /** `manual_witness[].sensor_provenance.queries[].expect.kind`のclosed set。 */
314
- export const SENSOR_EXPECT_KINDS = Object.freeze(['symbol', 'path', 'affected']);
315
-
316
- /** `sensor_provenance` entryのexpectを検査する(front-end adapter契約と同一規則)。 */
317
- function sensorExpect(value) {
318
- if (!plainObject(value) || !SENSOR_EXPECT_KINDS.includes(value.kind)) return false;
319
- if (value.kind === 'symbol') {
320
- return exactRecord(value, ['kind', 'name', 'path'])
321
- && typeof value.name === 'string' && value.name.length > 0
322
- && repoRelativePath(value.path);
323
- }
324
- return exactRecord(value, ['kind', 'path']) && repoRelativePath(value.path);
325
- }
326
-
327
- /**
328
- * `lattice.run_request.v1`の唯一の判定正本(ADR 0123)。
329
- * 受理は`{ valid: true }`、拒否は最初の違反の`reason`と`path`を返す。
330
- * `validateRunRequest`は本関数へ委譲するため、boolean判定と診断が乖離しない。
331
- */
332
- export function explainRunRequest(value) {
333
- const reject = (reason, at) => ({ valid: false, reason, path: at });
334
- try {
335
- canonicalizeArtifact(value);
336
- } catch {
337
- return reject('non_canonical_request_bytes', '');
338
- }
339
- if (!exactRecord(value, RUN_REQUEST_FIELDS)) return reject('unexpected_or_missing_top_level_keys', '');
340
- if (!RUN_REQUEST_SCHEMAS.includes(value.schema)) return reject('schema_mismatch', '/schema');
341
- // 創作宣言はv2から。v1のclosed shapeは余分fieldを拒否するので加算互換が成立しない。
342
- const allowCreates = [
343
- RUN_REQUEST_SCHEMA, RUN_REQUEST_PREDICTION_SCHEMA, RUN_REQUEST_DECLARATIVE_SCHEMA,
344
- ].includes(value.schema);
345
- const allowLines = value.schema === RUN_REQUEST_SCHEMA;
346
- if (!identifier(value.request_id)) return reject('invalid_identifier', '/request_id');
347
- if (!exactRecord(value.repo, ['base_sha', 'root_kind'])) return reject('unexpected_or_missing_keys', '/repo');
348
- if (!gitSha(value.repo.base_sha)) return reject('invalid_git_sha', '/repo/base_sha');
349
- if (!identifier(value.repo.root_kind)) return reject('invalid_identifier', '/repo/root_kind');
350
- if (!exactRecord(value.capacity, ['executors'])) return reject('unexpected_or_missing_keys', '/capacity');
351
- if (!positiveInteger(value.capacity.executors)) return reject('not_a_positive_integer', '/capacity/executors');
352
- if (!Array.isArray(value.todos) || value.todos.length < 1 || value.todos.length > MAX_COLLECTION) {
353
- return reject('bounded_collection_violation', '/todos');
354
- }
355
- for (const [index, todo] of value.todos.entries()) {
356
- if (!exactRecord(todo, ['todo_id'])) return reject('unexpected_or_missing_keys', `/todos/${index}`);
357
- if (!identifier(todo.todo_id)) return reject('invalid_identifier', `/todos/${index}/todo_id`);
358
- }
359
- const todoIds = value.todos.map((todo) => todo.todo_id);
360
- if (new Set(todoIds).size !== todoIds.length) return reject('duplicate_todo_id', '/todos');
361
- if (!plainObject(value.manual_witness)) return reject('not_an_object', '/manual_witness');
362
- if (!exactRecord(value.manual_witness, todoIds)) {
363
- return reject('manual_witness_keys_must_equal_todo_ids', '/manual_witness');
364
- }
365
- for (const todoId of todoIds) {
366
- const witness = value.manual_witness[todoId];
367
- const at = `/manual_witness/${todoId}`;
368
- if (!plainObject(witness)) return reject('not_an_object', at);
369
- const hasLines = Object.hasOwn(witness, 'lines');
370
- const witnessFields = hasLines
371
- ? [...MANUAL_WITNESS_FIELDS, ...MANUAL_WITNESS_OPTIONAL_FIELDS]
372
- : MANUAL_WITNESS_FIELDS;
373
- if (!exactRecord(witness, witnessFields)) return reject('unexpected_or_missing_keys', at);
374
- if (hasLines && !allowLines) return reject('lines_require_run_request_v5', `${at}/lines`);
375
- if (hasLines && !lineEntries(witness.lines)) return reject('invalid_line_entries', `${at}/lines`);
376
- if (!boundedArray(witness.owns, (own) => ownEntry(own, { allowCreates }))) {
377
- return reject('invalid_own_entries', `${at}/owns`);
378
- }
379
- if (!repoPathArray(witness.reads)) return reject('invalid_repo_relative_paths', `${at}/reads`);
380
- if (!repoPathArray(witness.writes, { allowPrefix: true })) return reject('invalid_repo_relative_paths', `${at}/writes`);
381
- if (!boundedArray(witness.resources, identifier)) return reject('invalid_identifier', `${at}/resources`);
382
- if (!boundedArray(witness.state_effects, stateEffectEntry)) return reject('invalid_state_effect_entries', `${at}/state_effects`);
383
- // sensor_provenanceもfront-end adapter契約のshapeまで検査する(ADR 0123)。
384
- const provenanceAt = `${at}/sensor_provenance`;
385
- if (!exactRecord(witness.sensor_provenance, ['queries'])) return reject('unexpected_or_missing_keys', provenanceAt);
386
- if (!Array.isArray(witness.sensor_provenance.queries)
387
- || witness.sensor_provenance.queries.length > MAX_COLLECTION) {
388
- return reject('bounded_collection_violation', `${provenanceAt}/queries`);
389
- }
390
- for (const [index, entry] of witness.sensor_provenance.queries.entries()) {
391
- const entryAt = `${provenanceAt}/queries/${index}`;
392
- if (!exactRecord(entry, ['query_id', 'expect'])) return reject('unexpected_or_missing_keys', entryAt);
393
- if (!identifier(entry.query_id)) return reject('invalid_identifier', `${entryAt}/query_id`);
394
- if (!sensorExpect(entry.expect)) return reject('invalid_sensor_expect', `${entryAt}/expect`);
395
- }
396
- if (!repoPathArray(witness.affected_tests)) return reject('invalid_repo_relative_paths', `${at}/affected_tests`);
397
- if (!boundedArray(witness.unknowns, unknownEntry)) return reject('invalid_unknown_entries', `${at}/unknowns`);
398
- }
399
- // sensor_query_set/executor_capabilityは、runtime front-endとrun startが実際に要求する
400
- // shapeまで検査する。schemaで通ってから後段で落ちる契約分裂を残さない(ADR 0123)。
401
- if (!exactRecord(value.sensor_query_set, ['queries'])) return reject('unexpected_or_missing_keys', '/sensor_query_set');
402
- if (!Array.isArray(value.sensor_query_set.queries) || value.sensor_query_set.queries.length > MAX_COLLECTION) {
403
- return reject('bounded_collection_violation', '/sensor_query_set/queries');
404
- }
405
- const queryIds = new Set();
406
- for (const [index, query] of value.sensor_query_set.queries.entries()) {
407
- const at = `/sensor_query_set/queries/${index}`;
408
- const keys = plainObject(query) && Object.hasOwn(query, 'target')
409
- ? ['id', 'operation', 'target'] : ['id', 'operation'];
410
- if (!exactRecord(query, keys)) return reject('unexpected_or_missing_keys', at);
411
- if (!identifier(query.id)) return reject('invalid_identifier', `${at}/id`);
412
- if (!SENSOR_QUERY_OPERATIONS.includes(query.operation)) return reject('unknown_sensor_query_operation', `${at}/operation`);
413
- if (keys.includes('target') && (typeof query.target !== 'string' || query.target.length === 0)) {
414
- return reject('empty_query_target', `${at}/target`);
415
- }
416
- if (queryIds.has(query.id)) return reject('duplicate_query_id', `${at}/id`);
417
- queryIds.add(query.id);
418
- }
419
- if (!exactRecord(value.executor_capability, ['adapters'])) return reject('unexpected_or_missing_keys', '/executor_capability');
420
- if (!uniqueIdentifierArray(value.executor_capability.adapters, { min: 1 })) {
421
- return reject('invalid_adapter_identifiers', '/executor_capability/adapters');
422
- }
423
- if (value.claim_mode !== RUN_REQUEST_CLAIM_MODE) return reject('claim_mode_must_be_exact_minimum', '/claim_mode');
424
- if (!selfDigestValid(value, 'request_digest')) return reject('request_digest_mismatch', '/request_digest');
425
- return { valid: true };
426
- }
427
-
428
- /** `lattice.run_request.v1`。manual witnessはTODOごとに完備でなければならない。 */
429
- export function validateRunRequest(value) {
430
- return explainRunRequest(value).valid;
431
- }
432
-
433
- /**
434
- * runtime planをrun requestへcross-bindする(ADR 0044 Decision 2)。
435
- * 同一TODO集合を別base/別requestへ再包装したplanをtyped rejectするための検証で、
436
- * schema単体のvalidatorとは別に、request bytesとの一致を要求する。
437
- */
438
- export function verifyRuntimePlanBinding(options = {}) {
439
- if (options === null || typeof options !== 'object' || Array.isArray(options)) return false;
440
- const { plan, request } = options;
441
- if (!validateRuntimePlan(plan) || !validateRunRequest(request)) return false;
442
- if (plan.request_digest !== request.request_digest) return false;
443
- if (plan.base_sha !== request.repo.base_sha) return false;
444
- const requestTodoIds = new Set(request.todos.map((todo) => todo.todo_id));
445
- return plan.nodes.every((node) => requestTodoIds.has(node.todo_id));
446
- }
447
-
448
- /**
449
- * `lattice.boundary_manifest.v4`。v4はpathを跨ぐ意味的な線を任意記録できる。
450
- *
451
- * v3は`owns[].creates`だけがv2との差である。宣言が持っていた「このpathはまだ無い」を
452
- * 記録側でも保つ——落とすと、manifestだけを読む消費者が既存fileと同じ扱いをする。
453
- * 旧v2/v3 manifestはrun storeに残るので読み口として受理し、linesを要求しない。
454
- */
455
- export const BOUNDARY_MANIFEST_SCHEMA = 'lattice.boundary_manifest.v4';
456
- export const BOUNDARY_MANIFEST_SCHEMAS = Object.freeze([
457
- BOUNDARY_MANIFEST_SCHEMA,
458
- 'lattice.boundary_manifest.v3',
459
- 'lattice.boundary_manifest.v2',
460
- ]);
461
- export function validateRuntimeBoundaryManifest(value) {
462
- return validateSafely(value, (manifest) => {
463
- const hasLines = Object.hasOwn(manifest, 'lines');
464
- return (
465
- exactRecord(manifest, [
466
- 'schema',
467
- 'todo_id',
468
- 'owns',
469
- 'reads',
470
- 'writes',
471
- 'resources',
472
- 'state_effects',
473
- 'unknowns',
474
- 'affected_tests',
475
- 'graph_evidence',
476
- 'witness_provenance',
477
- ...(hasLines ? ['lines'] : []),
478
- 'manifest_digest',
479
- ])
480
- && BOUNDARY_MANIFEST_SCHEMAS.includes(manifest.schema)
481
- && (!hasLines || manifest.schema === BOUNDARY_MANIFEST_SCHEMA)
482
- && identifier(manifest.todo_id)
483
- && boundedArray(manifest.owns, (own) => ownEntry(own, {
484
- allowCreates: [BOUNDARY_MANIFEST_SCHEMA, 'lattice.boundary_manifest.v3'].includes(manifest.schema),
485
- }))
486
- && repoPathArray(manifest.reads)
487
- && repoPathArray(manifest.writes, { allowPrefix: true })
488
- && boundedArray(manifest.resources, identifier)
489
- && boundedArray(manifest.state_effects, stateEffectEntry)
490
- && boundedArray(manifest.unknowns, unknownEntry)
491
- && repoPathArray(manifest.affected_tests)
492
- && boundedArray(manifest.graph_evidence, plainObject)
493
- && plainObject(manifest.witness_provenance)
494
- && Object.values(manifest.witness_provenance).every((entry) => (
495
- WITNESS_PROVENANCE.includes(entry)
496
- ))
497
- && (!hasLines || lineEntries(manifest.lines))
498
- && selfDigestValid(manifest, 'manifest_digest')
499
- );
500
- });
501
- }
502
-
503
- /** `lattice.runtime_plan.v1`。exact_minimum claimは1〜8 nodeだけを受理する(Decision 1)。 */
504
- export function validateRuntimePlan(value) {
505
- return validateSafely(value, (plan) => {
506
- if (!exactRecord(plan, [
507
- 'schema',
508
- 'plan_ref',
509
- 'plan_epoch',
510
- 'request_digest',
511
- 'base_sha',
512
- 'nodes',
513
- 'precedence',
514
- 'conflicts',
515
- 'capacity',
516
- 'manifest_digests',
517
- 'claim',
518
- 'predecessor_refs',
519
- 'plan_digest',
520
- ])
521
- || plan.schema !== 'lattice.runtime_plan.v1'
522
- || !identifier(plan.plan_ref)
523
- || !nonNegativeInteger(plan.plan_epoch)
524
- || !digest(plan.request_digest)
525
- || !gitSha(plan.base_sha)
526
- || !boundedArray(plan.nodes, (node) => (
527
- exactRecord(node, ['todo_id']) && identifier(node.todo_id)
528
- ), { min: 1, max: MAX_NODES_PER_PLAN })
529
- || !exactRecord(plan.claim, ['mode'])
530
- || plan.claim.mode !== 'exact_minimum'
531
- || !exactRecord(plan.capacity, ['executors'])
532
- || !positiveInteger(plan.capacity.executors)
533
- || !boundedArray(plan.predecessor_refs, (entry) => typeof entry === 'string')) {
534
- return false;
535
- }
536
- const nodeIds = plan.nodes.map((node) => node.todo_id);
537
- const nodeSet = new Set(nodeIds);
538
- if (nodeSet.size !== nodeIds.length) return false;
539
- if (!boundedArray(plan.precedence, (edge) => (
540
- exactRecord(edge, ['from_todo_id', 'to_todo_id'])
541
- && nodeSet.has(edge.from_todo_id)
542
- && nodeSet.has(edge.to_todo_id)
543
- && edge.from_todo_id !== edge.to_todo_id
544
- ))) {
545
- return false;
546
- }
547
- if (!boundedArray(plan.conflicts, (conflict) => (
548
- exactRecord(conflict, ['todo_ids', 'resource_id'])
549
- && Array.isArray(conflict.todo_ids)
550
- && conflict.todo_ids.length === 2
551
- && conflict.todo_ids.every((todoId) => nodeSet.has(todoId))
552
- && conflict.todo_ids[0] !== conflict.todo_ids[1]
553
- && identifier(conflict.resource_id)
554
- ))) {
555
- return false;
556
- }
557
- return plainObject(plan.manifest_digests)
558
- && exactRecord(plan.manifest_digests, nodeIds)
559
- && Object.values(plan.manifest_digests).every(digest)
560
- && selfDigestValid(plan, 'plan_digest');
561
- });
562
- }
563
-
564
- /** `lattice.run_event.v1`のevent単体shape。chain規則はruntime-event-store.mjsが検査する。 */
565
- export function validateRunEvent(value) {
566
- return validateSafely(value, (event) => (
567
- exactRecord(event, [
568
- 'schema',
569
- 'run_id',
570
- 'sequence',
571
- 'previous_digest',
572
- 'kind',
573
- 'actor',
574
- 'plan_epoch',
575
- 'subject',
576
- 'payload',
577
- 'recorded_at',
578
- 'event_digest',
579
- ])
580
- && event.schema === 'lattice.run_event.v1'
581
- && identifier(event.run_id)
582
- && nonNegativeInteger(event.sequence)
583
- && (event.previous_digest === null || digest(event.previous_digest))
584
- && (event.sequence === 0) === (event.previous_digest === null)
585
- && RUN_EVENT_KINDS.includes(event.kind)
586
- && identifier(event.actor)
587
- && nonNegativeInteger(event.plan_epoch)
588
- && exactRecord(event.subject, ['kind', 'ref'])
589
- && identifier(event.subject.kind)
590
- && typeof event.subject.ref === 'string'
591
- && plainObject(event.payload)
592
- && timestamp(event.recorded_at)
593
- && selfDigestValid(event, 'event_digest')
594
- ));
595
- }
596
-
597
- const CONTEXT_CONTENT_FIELDS = Object.freeze([
598
- 'todo_id',
599
- 'task_ref',
600
- 'scope',
601
- 'base_sha',
602
- 'verifier_refs',
603
- 'forbidden_operations',
604
- ]);
605
-
606
- /**
607
- * ADR 0044 Decision 7.1のcontent projection digest。plan帰属field
608
- * (packet_id・plan_ref・plan_epoch・packet_digest)を除いた6 fieldだけを対象にし、
609
- * epoch rebindが「content不変・epochだけ更新」であることを機械検査可能にする。
610
- */
611
- export function computeContextContentDigest(packet) {
612
- const projection = {};
613
- for (const field of CONTEXT_CONTENT_FIELDS) {
614
- projection[field] = packet[field];
615
- }
616
- return digestArtifact(projection);
617
- }
618
-
619
- /** `lattice.executor_packet.v1`。dispatch時に発行する唯一のcontext packet。 */
620
- export function validateExecutorPacket(value) {
621
- return validateSafely(value, (packet) => (
622
- exactRecord(packet, [
623
- 'schema',
624
- 'packet_id',
625
- 'todo_id',
626
- 'task_ref',
627
- 'scope',
628
- 'base_sha',
629
- 'plan_ref',
630
- 'plan_epoch',
631
- 'verifier_refs',
632
- 'forbidden_operations',
633
- 'context_content_digest',
634
- 'packet_digest',
635
- ])
636
- && packet.schema === 'lattice.executor_packet.v1'
637
- && identifier(packet.packet_id)
638
- && identifier(packet.todo_id)
639
- && identifier(packet.task_ref)
640
- && plainObject(packet.scope)
641
- && gitSha(packet.base_sha)
642
- && identifier(packet.plan_ref)
643
- && nonNegativeInteger(packet.plan_epoch)
644
- && boundedArray(packet.verifier_refs, (entry) => typeof entry === 'string')
645
- && boundedArray(packet.forbidden_operations, (entry) => typeof entry === 'string', { min: 1 })
646
- && packet.context_content_digest === computeContextContentDigest(packet)
647
- && selfDigestValid(packet, 'packet_digest')
648
- ));
649
- }
650
-
651
- /** `lattice.executor_receipt.v1`。binding欠落はtyped rejectの対象(Decision 7.4)。 */
652
- export function validateExecutorReceipt(value) {
653
- return validateSafely(value, (receipt) => (
654
- exactRecord(receipt, [
655
- 'schema',
656
- 'receipt_id',
657
- 'executor_handle',
658
- 'worktree_id',
659
- 'base_sha',
660
- 'plan_epoch',
661
- 'packet_digest',
662
- 'todo_id',
663
- 'checkpoint_digest',
664
- 'observed_diff',
665
- 'receipt_digest',
666
- ])
667
- && receipt.schema === 'lattice.executor_receipt.v1'
668
- && identifier(receipt.receipt_id)
669
- && identifier(receipt.executor_handle)
670
- && identifier(receipt.worktree_id)
671
- && gitSha(receipt.base_sha)
672
- && nonNegativeInteger(receipt.plan_epoch)
673
- && digest(receipt.packet_digest)
674
- && identifier(receipt.todo_id)
675
- && digest(receipt.checkpoint_digest)
676
- && boundedArray(receipt.observed_diff, (entry) => (
677
- exactRecord(entry, ['path', 'change'])
678
- && repoRelativePath(entry.path)
679
- && ['added', 'modified', 'deleted'].includes(entry.change)
680
- ))
681
- && selfDigestValid(receipt, 'receipt_digest')
682
- ));
683
- }
684
-
685
- /** `lattice.hold_decision.v1`。hold/continueは交差しない。 */
686
- export function validateHoldDecision(value) {
687
- return validateSafely(value, (decision) => {
688
- if (!exactRecord(decision, [
689
- 'schema',
690
- 'decision_id',
691
- 'finding',
692
- 'frozen_prefix_digest',
693
- 'affected_closure',
694
- 'hold_set',
695
- 'continue_set',
696
- 'evidence_digests',
697
- 'decision_digest',
698
- ])
699
- || decision.schema !== 'lattice.hold_decision.v1'
700
- || !identifier(decision.decision_id)
701
- || !plainObject(decision.finding)
702
- || !HOLD_REASON_KINDS.includes(decision.finding.kind)
703
- || !digest(decision.frozen_prefix_digest)
704
- || !uniqueIdentifierArray(decision.affected_closure)
705
- || !uniqueIdentifierArray(decision.hold_set)
706
- || !uniqueIdentifierArray(decision.continue_set)
707
- || !boundedArray(decision.evidence_digests, digest)) {
708
- return false;
709
- }
710
- const holdSet = new Set(decision.hold_set);
711
- return decision.continue_set.every((todoId) => !holdSet.has(todoId))
712
- && selfDigestValid(decision, 'decision_digest');
713
- });
714
- }
715
-
716
- const INVARIANT_DIGEST_FIELDS = Object.freeze([
717
- 'todo_input',
718
- 'boundary_manifest',
719
- 'validator',
720
- 'context_content',
721
- ]);
722
-
723
- /** `lattice.carry_over_witness.v1`。invariant digestは保存bytesから再計算できる。 */
724
- export function validateCarryOverWitness(value) {
725
- return validateSafely(value, (witness) => (
726
- exactRecord(witness, [
727
- 'schema',
728
- 'witness_id',
729
- 'todo_id',
730
- 'predecessor_epoch',
731
- 'successor_epoch',
732
- 'invariant_digests',
733
- 'non_overlap_evidence',
734
- 'receipt_bindings',
735
- 'witness_digest',
736
- ])
737
- && witness.schema === 'lattice.carry_over_witness.v1'
738
- && identifier(witness.witness_id)
739
- && identifier(witness.todo_id)
740
- && nonNegativeInteger(witness.predecessor_epoch)
741
- && nonNegativeInteger(witness.successor_epoch)
742
- && witness.successor_epoch > witness.predecessor_epoch
743
- && exactRecord(witness.invariant_digests, INVARIANT_DIGEST_FIELDS)
744
- && INVARIANT_DIGEST_FIELDS.every((field) => digest(witness.invariant_digests[field]))
745
- && boundedArray(witness.non_overlap_evidence, (entry) => typeof entry === 'string')
746
- && boundedArray(witness.receipt_bindings, (binding) => (
747
- exactRecord(binding, [
748
- 'receipt_id',
749
- 'checkpoint_digest',
750
- 'recorded_sequence',
751
- 'within_frozen_prefix',
752
- ])
753
- && identifier(binding.receipt_id)
754
- && digest(binding.checkpoint_digest)
755
- && nonNegativeInteger(binding.recorded_sequence)
756
- && typeof binding.within_frozen_prefix === 'boolean'
757
- ))
758
- && selfDigestValid(witness, 'witness_digest')
759
- ));
760
- }
761
-
762
- /** `lattice.epoch_rebind_packet.v1`。content不変・plan帰属だけを更新する(Decision 7.3)。 */
763
- export function validateEpochRebindPacket(value) {
764
- return validateSafely(value, (packet) => (
765
- exactRecord(packet, [
766
- 'schema',
767
- 'packet_id',
768
- 'todo_id',
769
- 'executor_handle',
770
- 'worktree_id',
771
- 'witness_digest',
772
- 'context_content_digest',
773
- 'authorized_checkpoint_digest',
774
- 'old_plan_ref',
775
- 'new_plan_ref',
776
- 'new_plan_epoch',
777
- 'packet_digest',
778
- ])
779
- && packet.schema === 'lattice.epoch_rebind_packet.v1'
780
- && identifier(packet.packet_id)
781
- && identifier(packet.todo_id)
782
- && identifier(packet.executor_handle)
783
- && identifier(packet.worktree_id)
784
- && digest(packet.witness_digest)
785
- && digest(packet.context_content_digest)
786
- && digest(packet.authorized_checkpoint_digest)
787
- && identifier(packet.old_plan_ref)
788
- && identifier(packet.new_plan_ref)
789
- && packet.old_plan_ref !== packet.new_plan_ref
790
- && positiveInteger(packet.new_plan_epoch)
791
- && selfDigestValid(packet, 'packet_digest')
792
- ));
793
- }
794
-
795
- /** `lattice.runtime_plan_diff.v1`。carry-overとredispatchは交差しない。 */
796
- export function validateRuntimePlanDiff(value) {
797
- return validateSafely(value, (diff) => {
798
- if (!exactRecord(diff, [
799
- 'schema',
800
- 'old_plan_ref',
801
- 'new_plan_ref',
802
- 'accepted_checkpoints',
803
- 'invalidated_contexts',
804
- 'carried_over',
805
- 'redispatched',
806
- 'node_edge_diff',
807
- 'diff_digest',
808
- ])
809
- || diff.schema !== 'lattice.runtime_plan_diff.v1'
810
- || !identifier(diff.old_plan_ref)
811
- || !identifier(diff.new_plan_ref)
812
- || diff.old_plan_ref === diff.new_plan_ref
813
- || !boundedArray(diff.accepted_checkpoints, digest)
814
- || !uniqueIdentifierArray(diff.invalidated_contexts)
815
- || !uniqueIdentifierArray(diff.carried_over)
816
- || !uniqueIdentifierArray(diff.redispatched)
817
- || !plainObject(diff.node_edge_diff)) {
818
- return false;
819
- }
820
- const carried = new Set(diff.carried_over);
821
- return diff.redispatched.every((todoId) => !carried.has(todoId))
822
- && selfDigestValid(diff, 'diff_digest');
823
- });
824
- }
1
+ import { canonicalizeArtifact, digestArtifact } from './artifact-contracts.mjs';
2
+ import { isCanonicalUtcTimestamp } from './timestamp-contract.mjs';
3
+
4
+ // RC3 runtime契約(ADR 0044 Decision 2・3・5・7)。
5
+ // RC2公開済みschemaを変更しない加算moduleであり、全schemaはexact key、
6
+ // bounded collection、canonical serialization、自己digest規則をfail closedで検査する。
7
+ // field追加・意味変更はschema versionを上げ、新しいADRで裁定する。
8
+
9
+ const SHA256 = /^[0-9a-f]{64}$/;
10
+ const SHA1 = /^[0-9a-f]{40}$/;
11
+ const IDENTIFIER = /^[0-9A-Za-z](?:[0-9A-Za-z._-]{0,127})$/;
12
+ const MAX_COLLECTION = 256;
13
+ const MAX_NODES_PER_PLAN = 8;
14
+
15
+ // 人間向けaudit reasonは表示を偽装できる制御文字を拒否しつつ、通常のUnicode説明を保持する。
16
+ const AUDIT_BIDI_CONTROLS = /[\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/u;
17
+
18
+ export function validRuntimeAbandonReason(value) {
19
+ return typeof value === 'string'
20
+ && value === value.trim()
21
+ && [...value].length >= 1
22
+ && [...value].length <= 256
23
+ && !/\p{Cc}/u.test(value)
24
+ && !/[\u2028\u2029]/u.test(value)
25
+ && !AUDIT_BIDI_CONTROLS.test(value);
26
+ }
27
+
28
+ // ADR 0044 Decision 3.2のclosed event kind set。拡張はrun_event.v2+新ADRでだけ行う。
29
+ export const RUN_EVENT_KINDS = Object.freeze([
30
+ 'run_initialized',
31
+ 'plan_compiled',
32
+ 'plan_verified',
33
+ 'dispatch_decided',
34
+ 'executor_dispatched',
35
+ 'checkpoint_observed',
36
+ 'receipt_recorded',
37
+ 'conflict_found',
38
+ 'intake_frozen',
39
+ 'hold_decided',
40
+ 'carry_over_witnessed',
41
+ 'epoch_rebound',
42
+ 'context_invalidated',
43
+ 'plan_recompiled',
44
+ 'intake_resumed',
45
+ 'receipt_accepted',
46
+ 'receipt_rejected',
47
+ 'executor_terminal',
48
+ 'run_closed',
49
+ ]);
50
+
51
+ // ADR 0044 Decision 5のclosed conflict分類と、Decision 7.5のhold理由kind。
52
+ export const RUNTIME_CONFLICT_KINDS = Object.freeze([
53
+ 'observed_write_conflict',
54
+ 'observed_line_change',
55
+ 'semantic_conflict_unknown',
56
+ 'effect_conflict_unknown',
57
+ 'undeclared_write',
58
+ 'stale_context',
59
+ ]);
60
+ export const HOLD_REASON_KINDS = Object.freeze([
61
+ ...RUNTIME_CONFLICT_KINDS,
62
+ 'carry_over_unprovable',
63
+ ]);
64
+
65
+ function exactRecord(value, keys) {
66
+ if (value === null || typeof value !== 'object' || Array.isArray(value)
67
+ || Object.getPrototypeOf(value) !== Object.prototype) {
68
+ return false;
69
+ }
70
+ const actual = Object.keys(value).sort();
71
+ const expected = [...keys].sort();
72
+ return actual.length === expected.length
73
+ && actual.every((key, index) => key === expected[index]);
74
+ }
75
+
76
+ function plainObject(value) {
77
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
78
+ && Object.getPrototypeOf(value) === Object.prototype;
79
+ }
80
+
81
+ function digest(value) {
82
+ return typeof value === 'string' && SHA256.test(value);
83
+ }
84
+
85
+ function gitSha(value) {
86
+ return typeof value === 'string' && SHA1.test(value);
87
+ }
88
+
89
+ function identifier(value) {
90
+ return typeof value === 'string' && IDENTIFIER.test(value);
91
+ }
92
+
93
+ function timestamp(value) {
94
+ return isCanonicalUtcTimestamp(value);
95
+ }
96
+
97
+ function nonNegativeInteger(value) {
98
+ return Number.isSafeInteger(value) && value >= 0;
99
+ }
100
+
101
+ function positiveInteger(value) {
102
+ return Number.isSafeInteger(value) && value >= 1;
103
+ }
104
+
105
+ const CONTROL_CHARACTER = /[\u0000-\u001f\u007f]/;
106
+ const MAX_PATH_BYTES = 1024;
107
+
108
+ /**
109
+ * repo相対pathだけを受理する(不正pathのfail closed、ADR 0044 Decision 2)。
110
+ * 絶対path、`..`遡上、空segment、制御文字、`\`区切りを拒否する。
111
+ * 末尾`/`はdeclared write prefix(directory宣言)としてのみ許す。
112
+ */
113
+ function repoRelativePath(value, { allowPrefix = false } = {}) {
114
+ if (typeof value !== 'string'
115
+ || value.length === 0
116
+ || Buffer.byteLength(value, 'utf8') > MAX_PATH_BYTES
117
+ || CONTROL_CHARACTER.test(value)
118
+ || value.includes('\\')
119
+ || value.startsWith('/')) {
120
+ return false;
121
+ }
122
+ const isPrefix = value.endsWith('/');
123
+ if (isPrefix && !allowPrefix) return false;
124
+ const body = isPrefix ? value.slice(0, -1) : value;
125
+ const segments = body.split('/');
126
+ return segments.every((segment) => (
127
+ segment.length > 0 && segment !== '.' && segment !== '..'
128
+ ));
129
+ }
130
+
131
+ function repoPathArray(value, options) {
132
+ return boundedArray(value, (entry) => repoRelativePath(entry, options));
133
+ }
134
+
135
+ const OWN_KINDS = Object.freeze(['symbol', 'path']);
136
+ const STATE_EFFECT_KINDS = Object.freeze([
137
+ 'state',
138
+ 'schema',
139
+ 'invariant',
140
+ 'effect',
141
+ 'external_effect',
142
+ ]);
143
+
144
+ /**
145
+ * 所有宣言。`creates: true`は「このpathはまだ存在せず、このTODOが作る」という創作の意思である
146
+ * (ADR 0135 Decision 3・ADR 0136)。
147
+ *
148
+ * `kind`は`path`のまま据え置く。存在の有無は資源の種類ではなく資源の状態であり、kindを分けると
149
+ * 既存のpath判定(write交差の免除、conflict resourceのkind)が全部この宣言を取りこぼす。
150
+ *
151
+ * 値は`true`だけを受理する。`false`は「存在するpath」と同義で、同じ事実に2つの書き方を
152
+ * 与えることになる。省略が既定である。
153
+ */
154
+ function ownEntry(value, { allowCreates = false } = {}) {
155
+ if (!plainObject(value)) return false;
156
+ const creates = Object.hasOwn(value, 'creates');
157
+ if (creates && !allowCreates) return false;
158
+ return exactRecord(value, creates ? ['kind', 'target', 'creates'] : ['kind', 'target'])
159
+ && OWN_KINDS.includes(value.kind)
160
+ && (!creates || (value.creates === true && value.kind === 'path'))
161
+ && typeof value.target === 'string'
162
+ && value.target.length > 0
163
+ && Buffer.byteLength(value.target, 'utf8') <= MAX_PATH_BYTES;
164
+ }
165
+
166
+ function stateEffectEntry(value) {
167
+ return plainObject(value)
168
+ && exactRecord(value, ['resource_id', 'kind'])
169
+ && identifier(value.resource_id)
170
+ && STATE_EFFECT_KINDS.includes(value.kind);
171
+ }
172
+
173
+ function unknownEntry(value) {
174
+ return plainObject(value)
175
+ && exactRecord(value, ['kind', 'ref'])
176
+ && identifier(value.kind)
177
+ && typeof value.ref === 'string'
178
+ && value.ref.length > 0;
179
+ }
180
+
181
+ export const MANUAL_WITNESS_FIELDS = Object.freeze([
182
+ 'owns',
183
+ 'reads',
184
+ 'writes',
185
+ 'resources',
186
+ 'state_effects',
187
+ 'sensor_provenance',
188
+ 'affected_tests',
189
+ 'unknowns',
190
+ ]);
191
+ export const MANUAL_WITNESS_OPTIONAL_FIELDS = Object.freeze(['lines']);
192
+ export const LINE_ROLES = Object.freeze(['reads', 'writes']);
193
+
194
+ function lineAnchor(value) {
195
+ if (!plainObject(value)) return false;
196
+ if (value.kind === 'path') {
197
+ return exactRecord(value, ['kind', 'path']) && repoRelativePath(value.path);
198
+ }
199
+ return value.kind === 'symbol'
200
+ && exactRecord(value, ['kind', 'name', 'path'])
201
+ && typeof value.name === 'string' && value.name.length > 0
202
+ && Buffer.byteLength(value.name, 'utf8') <= MAX_PATH_BYTES
203
+ && repoRelativePath(value.path);
204
+ }
205
+
206
+ function lineEntry(value) {
207
+ return plainObject(value)
208
+ && exactRecord(value, ['line_id', 'role', 'anchors'])
209
+ && identifier(value.line_id)
210
+ && LINE_ROLES.includes(value.role)
211
+ && boundedArray(value.anchors, lineAnchor, { min: 1 });
212
+ }
213
+
214
+ function lineEntries(value) {
215
+ return boundedArray(value, lineEntry)
216
+ && new Set(value.map(({ line_id: lineId }) => lineId)).size === value.length;
217
+ }
218
+
219
+ // manual witness entryの判定は`explainRunRequest`だけが所有する。
220
+ // 同じ規則をbooleanと診断の二箇所へ持たないための単一正本化(ADR 0123)。
221
+
222
+ function boundedArray(value, predicate, { min = 0, max = MAX_COLLECTION } = {}) {
223
+ return Array.isArray(value)
224
+ && value.length >= min
225
+ && value.length <= max
226
+ && value.every((entry) => predicate(entry));
227
+ }
228
+
229
+ function uniqueIdentifierArray(value, options) {
230
+ return boundedArray(value, identifier, options)
231
+ && new Set(value).size === value.length;
232
+ }
233
+
234
+ function validateSafely(value, validator) {
235
+ try {
236
+ canonicalizeArtifact(value);
237
+ return validator(value) === true;
238
+ } catch {
239
+ return false;
240
+ }
241
+ }
242
+
243
+ /**
244
+ * 自己digest規則: `digestField`自身を除いた残り全fieldのcanonical JSON SHA-256。
245
+ * `run_request.v1`の`request_digest`(RC2 `admission_digest`と同型)を全schemaへ適用する。
246
+ */
247
+ export function selfDigest(value, digestField) {
248
+ const projection = {};
249
+ for (const key of Object.keys(value)) {
250
+ if (key !== digestField) projection[key] = value[key];
251
+ }
252
+ return digestArtifact(projection);
253
+ }
254
+
255
+ function selfDigestValid(value, digestField) {
256
+ return digest(value[digestField]) && value[digestField] === selfDigest(value, digestField);
257
+ }
258
+
259
+ const WITNESS_PROVENANCE = Object.freeze([
260
+ 'sensor',
261
+ 'manual_candidate_spec',
262
+ 'manual_state_effect',
263
+ ]);
264
+
265
+ /**
266
+ * 現行のrun request契約。v5はpathを跨ぐ意味的な線を任意宣言できる。v4は同じ予測契約の
267
+ * lines無し版として残し、v3以前の厳密なcompile契約も既存requestの意味を変えずに読む。
268
+ *
269
+ * v2はこの系列ではない。ADR 0064のepoch後継request(`predecessor_request_digest`と
270
+ * `task_migration_digest`を持つ別shape)が既に使っている番号なので、飛ばして採番する。
271
+ */
272
+ export const RUN_REQUEST_SCHEMA = 'lattice.run_request.v5';
273
+ export const RUN_REQUEST_PREDICTION_SCHEMA = 'lattice.run_request.v4';
274
+ export const RUN_REQUEST_DECLARATIVE_SCHEMA = 'lattice.run_request.v3';
275
+ export const RUN_REQUEST_LEGACY_SCHEMAS = Object.freeze([
276
+ RUN_REQUEST_PREDICTION_SCHEMA,
277
+ RUN_REQUEST_DECLARATIVE_SCHEMA,
278
+ 'lattice.run_request.v1',
279
+ ]);
280
+ export const RUN_REQUEST_SCHEMAS = Object.freeze([
281
+ RUN_REQUEST_SCHEMA,
282
+ ...RUN_REQUEST_LEGACY_SCHEMAS,
283
+ ]);
284
+
285
+ export const RUN_REQUEST_FIELDS = Object.freeze([
286
+ 'schema',
287
+ 'request_id',
288
+ 'repo',
289
+ 'capacity',
290
+ 'todos',
291
+ 'manual_witness',
292
+ 'sensor_query_set',
293
+ 'executor_capability',
294
+ 'claim_mode',
295
+ 'request_digest',
296
+ ]);
297
+
298
+ export const RUN_REQUEST_CLAIM_MODE = 'exact_minimum';
299
+
300
+ /**
301
+ * `sensor_query_set.queries[].operation`のclosed set。
302
+ * runtime front-endはこの定数を輸入して使う(同じ閉集合を二箇所へ持たない)。
303
+ */
304
+ export const SENSOR_QUERY_OPERATIONS = Object.freeze([
305
+ 'status',
306
+ 'query',
307
+ 'callers',
308
+ 'callees',
309
+ 'impact',
310
+ 'affected',
311
+ ]);
312
+
313
+ /** `manual_witness[].sensor_provenance.queries[].expect.kind`のclosed set。 */
314
+ export const SENSOR_EXPECT_KINDS = Object.freeze(['symbol', 'path', 'affected']);
315
+
316
+ /** `sensor_provenance` entryのexpectを検査する(front-end adapter契約と同一規則)。 */
317
+ function sensorExpect(value) {
318
+ if (!plainObject(value) || !SENSOR_EXPECT_KINDS.includes(value.kind)) return false;
319
+ if (value.kind === 'symbol') {
320
+ return exactRecord(value, ['kind', 'name', 'path'])
321
+ && typeof value.name === 'string' && value.name.length > 0
322
+ && repoRelativePath(value.path);
323
+ }
324
+ return exactRecord(value, ['kind', 'path']) && repoRelativePath(value.path);
325
+ }
326
+
327
+ /**
328
+ * `lattice.run_request.v1`の唯一の判定正本(ADR 0123)。
329
+ * 受理は`{ valid: true }`、拒否は最初の違反の`reason`と`path`を返す。
330
+ * `validateRunRequest`は本関数へ委譲するため、boolean判定と診断が乖離しない。
331
+ */
332
+ export function explainRunRequest(value) {
333
+ const reject = (reason, at) => ({ valid: false, reason, path: at });
334
+ try {
335
+ canonicalizeArtifact(value);
336
+ } catch {
337
+ return reject('non_canonical_request_bytes', '');
338
+ }
339
+ if (!exactRecord(value, RUN_REQUEST_FIELDS)) return reject('unexpected_or_missing_top_level_keys', '');
340
+ if (!RUN_REQUEST_SCHEMAS.includes(value.schema)) return reject('schema_mismatch', '/schema');
341
+ // 創作宣言はv2から。v1のclosed shapeは余分fieldを拒否するので加算互換が成立しない。
342
+ const allowCreates = [
343
+ RUN_REQUEST_SCHEMA, RUN_REQUEST_PREDICTION_SCHEMA, RUN_REQUEST_DECLARATIVE_SCHEMA,
344
+ ].includes(value.schema);
345
+ const allowLines = value.schema === RUN_REQUEST_SCHEMA;
346
+ if (!identifier(value.request_id)) return reject('invalid_identifier', '/request_id');
347
+ if (!exactRecord(value.repo, ['base_sha', 'root_kind'])) return reject('unexpected_or_missing_keys', '/repo');
348
+ if (!gitSha(value.repo.base_sha)) return reject('invalid_git_sha', '/repo/base_sha');
349
+ if (!identifier(value.repo.root_kind)) return reject('invalid_identifier', '/repo/root_kind');
350
+ if (!exactRecord(value.capacity, ['executors'])) return reject('unexpected_or_missing_keys', '/capacity');
351
+ if (!positiveInteger(value.capacity.executors)) return reject('not_a_positive_integer', '/capacity/executors');
352
+ if (!Array.isArray(value.todos) || value.todos.length < 1 || value.todos.length > MAX_COLLECTION) {
353
+ return reject('bounded_collection_violation', '/todos');
354
+ }
355
+ for (const [index, todo] of value.todos.entries()) {
356
+ if (!exactRecord(todo, ['todo_id'])) return reject('unexpected_or_missing_keys', `/todos/${index}`);
357
+ if (!identifier(todo.todo_id)) return reject('invalid_identifier', `/todos/${index}/todo_id`);
358
+ }
359
+ const todoIds = value.todos.map((todo) => todo.todo_id);
360
+ if (new Set(todoIds).size !== todoIds.length) return reject('duplicate_todo_id', '/todos');
361
+ if (!plainObject(value.manual_witness)) return reject('not_an_object', '/manual_witness');
362
+ if (!exactRecord(value.manual_witness, todoIds)) {
363
+ return reject('manual_witness_keys_must_equal_todo_ids', '/manual_witness');
364
+ }
365
+ for (const todoId of todoIds) {
366
+ const witness = value.manual_witness[todoId];
367
+ const at = `/manual_witness/${todoId}`;
368
+ if (!plainObject(witness)) return reject('not_an_object', at);
369
+ const hasLines = Object.hasOwn(witness, 'lines');
370
+ const witnessFields = hasLines
371
+ ? [...MANUAL_WITNESS_FIELDS, ...MANUAL_WITNESS_OPTIONAL_FIELDS]
372
+ : MANUAL_WITNESS_FIELDS;
373
+ if (!exactRecord(witness, witnessFields)) return reject('unexpected_or_missing_keys', at);
374
+ if (hasLines && !allowLines) return reject('lines_require_run_request_v5', `${at}/lines`);
375
+ if (hasLines && !lineEntries(witness.lines)) return reject('invalid_line_entries', `${at}/lines`);
376
+ if (!boundedArray(witness.owns, (own) => ownEntry(own, { allowCreates }))) {
377
+ return reject('invalid_own_entries', `${at}/owns`);
378
+ }
379
+ if (!repoPathArray(witness.reads)) return reject('invalid_repo_relative_paths', `${at}/reads`);
380
+ if (!repoPathArray(witness.writes, { allowPrefix: true })) return reject('invalid_repo_relative_paths', `${at}/writes`);
381
+ if (!boundedArray(witness.resources, identifier)) return reject('invalid_identifier', `${at}/resources`);
382
+ if (!boundedArray(witness.state_effects, stateEffectEntry)) return reject('invalid_state_effect_entries', `${at}/state_effects`);
383
+ // sensor_provenanceもfront-end adapter契約のshapeまで検査する(ADR 0123)。
384
+ const provenanceAt = `${at}/sensor_provenance`;
385
+ if (!exactRecord(witness.sensor_provenance, ['queries'])) return reject('unexpected_or_missing_keys', provenanceAt);
386
+ if (!Array.isArray(witness.sensor_provenance.queries)
387
+ || witness.sensor_provenance.queries.length > MAX_COLLECTION) {
388
+ return reject('bounded_collection_violation', `${provenanceAt}/queries`);
389
+ }
390
+ for (const [index, entry] of witness.sensor_provenance.queries.entries()) {
391
+ const entryAt = `${provenanceAt}/queries/${index}`;
392
+ if (!exactRecord(entry, ['query_id', 'expect'])) return reject('unexpected_or_missing_keys', entryAt);
393
+ if (!identifier(entry.query_id)) return reject('invalid_identifier', `${entryAt}/query_id`);
394
+ if (!sensorExpect(entry.expect)) return reject('invalid_sensor_expect', `${entryAt}/expect`);
395
+ }
396
+ if (!repoPathArray(witness.affected_tests)) return reject('invalid_repo_relative_paths', `${at}/affected_tests`);
397
+ if (!boundedArray(witness.unknowns, unknownEntry)) return reject('invalid_unknown_entries', `${at}/unknowns`);
398
+ }
399
+ // sensor_query_set/executor_capabilityは、runtime front-endとrun startが実際に要求する
400
+ // shapeまで検査する。schemaで通ってから後段で落ちる契約分裂を残さない(ADR 0123)。
401
+ if (!exactRecord(value.sensor_query_set, ['queries'])) return reject('unexpected_or_missing_keys', '/sensor_query_set');
402
+ if (!Array.isArray(value.sensor_query_set.queries) || value.sensor_query_set.queries.length > MAX_COLLECTION) {
403
+ return reject('bounded_collection_violation', '/sensor_query_set/queries');
404
+ }
405
+ const queryIds = new Set();
406
+ for (const [index, query] of value.sensor_query_set.queries.entries()) {
407
+ const at = `/sensor_query_set/queries/${index}`;
408
+ const keys = plainObject(query) && Object.hasOwn(query, 'target')
409
+ ? ['id', 'operation', 'target'] : ['id', 'operation'];
410
+ if (!exactRecord(query, keys)) return reject('unexpected_or_missing_keys', at);
411
+ if (!identifier(query.id)) return reject('invalid_identifier', `${at}/id`);
412
+ if (!SENSOR_QUERY_OPERATIONS.includes(query.operation)) return reject('unknown_sensor_query_operation', `${at}/operation`);
413
+ if (keys.includes('target') && (typeof query.target !== 'string' || query.target.length === 0)) {
414
+ return reject('empty_query_target', `${at}/target`);
415
+ }
416
+ if (queryIds.has(query.id)) return reject('duplicate_query_id', `${at}/id`);
417
+ queryIds.add(query.id);
418
+ }
419
+ if (!exactRecord(value.executor_capability, ['adapters'])) return reject('unexpected_or_missing_keys', '/executor_capability');
420
+ if (!uniqueIdentifierArray(value.executor_capability.adapters, { min: 1 })) {
421
+ return reject('invalid_adapter_identifiers', '/executor_capability/adapters');
422
+ }
423
+ if (value.claim_mode !== RUN_REQUEST_CLAIM_MODE) return reject('claim_mode_must_be_exact_minimum', '/claim_mode');
424
+ if (!selfDigestValid(value, 'request_digest')) return reject('request_digest_mismatch', '/request_digest');
425
+ return { valid: true };
426
+ }
427
+
428
+ /** `lattice.run_request.v1`。manual witnessはTODOごとに完備でなければならない。 */
429
+ export function validateRunRequest(value) {
430
+ return explainRunRequest(value).valid;
431
+ }
432
+
433
+ /**
434
+ * runtime planをrun requestへcross-bindする(ADR 0044 Decision 2)。
435
+ * 同一TODO集合を別base/別requestへ再包装したplanをtyped rejectするための検証で、
436
+ * schema単体のvalidatorとは別に、request bytesとの一致を要求する。
437
+ */
438
+ export function verifyRuntimePlanBinding(options = {}) {
439
+ if (options === null || typeof options !== 'object' || Array.isArray(options)) return false;
440
+ const { plan, request } = options;
441
+ if (!validateRuntimePlan(plan) || !validateRunRequest(request)) return false;
442
+ if (plan.request_digest !== request.request_digest) return false;
443
+ if (plan.base_sha !== request.repo.base_sha) return false;
444
+ const requestTodoIds = new Set(request.todos.map((todo) => todo.todo_id));
445
+ return plan.nodes.every((node) => requestTodoIds.has(node.todo_id));
446
+ }
447
+
448
+ /**
449
+ * `lattice.boundary_manifest.v4`。v4はpathを跨ぐ意味的な線を任意記録できる。
450
+ *
451
+ * v3は`owns[].creates`だけがv2との差である。宣言が持っていた「このpathはまだ無い」を
452
+ * 記録側でも保つ——落とすと、manifestだけを読む消費者が既存fileと同じ扱いをする。
453
+ * 旧v2/v3 manifestはrun storeに残るので読み口として受理し、linesを要求しない。
454
+ */
455
+ export const BOUNDARY_MANIFEST_SCHEMA = 'lattice.boundary_manifest.v4';
456
+ export const BOUNDARY_MANIFEST_SCHEMAS = Object.freeze([
457
+ BOUNDARY_MANIFEST_SCHEMA,
458
+ 'lattice.boundary_manifest.v3',
459
+ 'lattice.boundary_manifest.v2',
460
+ ]);
461
+ export function validateRuntimeBoundaryManifest(value) {
462
+ return validateSafely(value, (manifest) => {
463
+ const hasLines = Object.hasOwn(manifest, 'lines');
464
+ return (
465
+ exactRecord(manifest, [
466
+ 'schema',
467
+ 'todo_id',
468
+ 'owns',
469
+ 'reads',
470
+ 'writes',
471
+ 'resources',
472
+ 'state_effects',
473
+ 'unknowns',
474
+ 'affected_tests',
475
+ 'graph_evidence',
476
+ 'witness_provenance',
477
+ ...(hasLines ? ['lines'] : []),
478
+ 'manifest_digest',
479
+ ])
480
+ && BOUNDARY_MANIFEST_SCHEMAS.includes(manifest.schema)
481
+ && (!hasLines || manifest.schema === BOUNDARY_MANIFEST_SCHEMA)
482
+ && identifier(manifest.todo_id)
483
+ && boundedArray(manifest.owns, (own) => ownEntry(own, {
484
+ allowCreates: [BOUNDARY_MANIFEST_SCHEMA, 'lattice.boundary_manifest.v3'].includes(manifest.schema),
485
+ }))
486
+ && repoPathArray(manifest.reads)
487
+ && repoPathArray(manifest.writes, { allowPrefix: true })
488
+ && boundedArray(manifest.resources, identifier)
489
+ && boundedArray(manifest.state_effects, stateEffectEntry)
490
+ && boundedArray(manifest.unknowns, unknownEntry)
491
+ && repoPathArray(manifest.affected_tests)
492
+ && boundedArray(manifest.graph_evidence, plainObject)
493
+ && plainObject(manifest.witness_provenance)
494
+ && Object.values(manifest.witness_provenance).every((entry) => (
495
+ WITNESS_PROVENANCE.includes(entry)
496
+ ))
497
+ && (!hasLines || lineEntries(manifest.lines))
498
+ && selfDigestValid(manifest, 'manifest_digest')
499
+ );
500
+ });
501
+ }
502
+
503
+ /** `lattice.runtime_plan.v1`。exact_minimum claimは1〜8 nodeだけを受理する(Decision 1)。 */
504
+ export function validateRuntimePlan(value) {
505
+ return validateSafely(value, (plan) => {
506
+ if (!exactRecord(plan, [
507
+ 'schema',
508
+ 'plan_ref',
509
+ 'plan_epoch',
510
+ 'request_digest',
511
+ 'base_sha',
512
+ 'nodes',
513
+ 'precedence',
514
+ 'conflicts',
515
+ 'capacity',
516
+ 'manifest_digests',
517
+ 'claim',
518
+ 'predecessor_refs',
519
+ 'plan_digest',
520
+ ])
521
+ || plan.schema !== 'lattice.runtime_plan.v1'
522
+ || !identifier(plan.plan_ref)
523
+ || !nonNegativeInteger(plan.plan_epoch)
524
+ || !digest(plan.request_digest)
525
+ || !gitSha(plan.base_sha)
526
+ || !boundedArray(plan.nodes, (node) => (
527
+ exactRecord(node, ['todo_id']) && identifier(node.todo_id)
528
+ ), { min: 1, max: MAX_NODES_PER_PLAN })
529
+ || !exactRecord(plan.claim, ['mode'])
530
+ || plan.claim.mode !== 'exact_minimum'
531
+ || !exactRecord(plan.capacity, ['executors'])
532
+ || !positiveInteger(plan.capacity.executors)
533
+ || !boundedArray(plan.predecessor_refs, (entry) => typeof entry === 'string')) {
534
+ return false;
535
+ }
536
+ const nodeIds = plan.nodes.map((node) => node.todo_id);
537
+ const nodeSet = new Set(nodeIds);
538
+ if (nodeSet.size !== nodeIds.length) return false;
539
+ if (!boundedArray(plan.precedence, (edge) => (
540
+ exactRecord(edge, ['from_todo_id', 'to_todo_id'])
541
+ && nodeSet.has(edge.from_todo_id)
542
+ && nodeSet.has(edge.to_todo_id)
543
+ && edge.from_todo_id !== edge.to_todo_id
544
+ ))) {
545
+ return false;
546
+ }
547
+ if (!boundedArray(plan.conflicts, (conflict) => (
548
+ exactRecord(conflict, ['todo_ids', 'resource_id'])
549
+ && Array.isArray(conflict.todo_ids)
550
+ && conflict.todo_ids.length === 2
551
+ && conflict.todo_ids.every((todoId) => nodeSet.has(todoId))
552
+ && conflict.todo_ids[0] !== conflict.todo_ids[1]
553
+ && identifier(conflict.resource_id)
554
+ ))) {
555
+ return false;
556
+ }
557
+ return plainObject(plan.manifest_digests)
558
+ && exactRecord(plan.manifest_digests, nodeIds)
559
+ && Object.values(plan.manifest_digests).every(digest)
560
+ && selfDigestValid(plan, 'plan_digest');
561
+ });
562
+ }
563
+
564
+ /** `lattice.run_event.v1`のevent単体shape。chain規則はruntime-event-store.mjsが検査する。 */
565
+ export function validateRunEvent(value) {
566
+ return validateSafely(value, (event) => (
567
+ exactRecord(event, [
568
+ 'schema',
569
+ 'run_id',
570
+ 'sequence',
571
+ 'previous_digest',
572
+ 'kind',
573
+ 'actor',
574
+ 'plan_epoch',
575
+ 'subject',
576
+ 'payload',
577
+ 'recorded_at',
578
+ 'event_digest',
579
+ ])
580
+ && event.schema === 'lattice.run_event.v1'
581
+ && identifier(event.run_id)
582
+ && nonNegativeInteger(event.sequence)
583
+ && (event.previous_digest === null || digest(event.previous_digest))
584
+ && (event.sequence === 0) === (event.previous_digest === null)
585
+ && RUN_EVENT_KINDS.includes(event.kind)
586
+ && identifier(event.actor)
587
+ && nonNegativeInteger(event.plan_epoch)
588
+ && exactRecord(event.subject, ['kind', 'ref'])
589
+ && identifier(event.subject.kind)
590
+ && typeof event.subject.ref === 'string'
591
+ && plainObject(event.payload)
592
+ && timestamp(event.recorded_at)
593
+ && selfDigestValid(event, 'event_digest')
594
+ ));
595
+ }
596
+
597
+ const CONTEXT_CONTENT_FIELDS = Object.freeze([
598
+ 'todo_id',
599
+ 'task_ref',
600
+ 'scope',
601
+ 'base_sha',
602
+ 'verifier_refs',
603
+ 'forbidden_operations',
604
+ ]);
605
+
606
+ /**
607
+ * ADR 0044 Decision 7.1のcontent projection digest。plan帰属field
608
+ * (packet_id・plan_ref・plan_epoch・packet_digest)を除いた6 fieldだけを対象にし、
609
+ * epoch rebindが「content不変・epochだけ更新」であることを機械検査可能にする。
610
+ */
611
+ export function computeContextContentDigest(packet) {
612
+ const projection = {};
613
+ for (const field of CONTEXT_CONTENT_FIELDS) {
614
+ projection[field] = packet[field];
615
+ }
616
+ return digestArtifact(projection);
617
+ }
618
+
619
+ /** `lattice.executor_packet.v1`。dispatch時に発行する唯一のcontext packet。 */
620
+ export function validateExecutorPacket(value) {
621
+ return validateSafely(value, (packet) => (
622
+ exactRecord(packet, [
623
+ 'schema',
624
+ 'packet_id',
625
+ 'todo_id',
626
+ 'task_ref',
627
+ 'scope',
628
+ 'base_sha',
629
+ 'plan_ref',
630
+ 'plan_epoch',
631
+ 'verifier_refs',
632
+ 'forbidden_operations',
633
+ 'context_content_digest',
634
+ 'packet_digest',
635
+ ])
636
+ && packet.schema === 'lattice.executor_packet.v1'
637
+ && identifier(packet.packet_id)
638
+ && identifier(packet.todo_id)
639
+ && identifier(packet.task_ref)
640
+ && plainObject(packet.scope)
641
+ && gitSha(packet.base_sha)
642
+ && identifier(packet.plan_ref)
643
+ && nonNegativeInteger(packet.plan_epoch)
644
+ && boundedArray(packet.verifier_refs, (entry) => typeof entry === 'string')
645
+ && boundedArray(packet.forbidden_operations, (entry) => typeof entry === 'string', { min: 1 })
646
+ && packet.context_content_digest === computeContextContentDigest(packet)
647
+ && selfDigestValid(packet, 'packet_digest')
648
+ ));
649
+ }
650
+
651
+ /** `lattice.executor_receipt.v1`。binding欠落はtyped rejectの対象(Decision 7.4)。 */
652
+ export function validateExecutorReceipt(value) {
653
+ return validateSafely(value, (receipt) => (
654
+ exactRecord(receipt, [
655
+ 'schema',
656
+ 'receipt_id',
657
+ 'executor_handle',
658
+ 'worktree_id',
659
+ 'base_sha',
660
+ 'plan_epoch',
661
+ 'packet_digest',
662
+ 'todo_id',
663
+ 'checkpoint_digest',
664
+ 'observed_diff',
665
+ 'receipt_digest',
666
+ ])
667
+ && receipt.schema === 'lattice.executor_receipt.v1'
668
+ && identifier(receipt.receipt_id)
669
+ && identifier(receipt.executor_handle)
670
+ && identifier(receipt.worktree_id)
671
+ && gitSha(receipt.base_sha)
672
+ && nonNegativeInteger(receipt.plan_epoch)
673
+ && digest(receipt.packet_digest)
674
+ && identifier(receipt.todo_id)
675
+ && digest(receipt.checkpoint_digest)
676
+ && boundedArray(receipt.observed_diff, (entry) => (
677
+ exactRecord(entry, ['path', 'change'])
678
+ && repoRelativePath(entry.path)
679
+ && ['added', 'modified', 'deleted'].includes(entry.change)
680
+ ))
681
+ && selfDigestValid(receipt, 'receipt_digest')
682
+ ));
683
+ }
684
+
685
+ /** `lattice.hold_decision.v1`。hold/continueは交差しない。 */
686
+ export function validateHoldDecision(value) {
687
+ return validateSafely(value, (decision) => {
688
+ if (!exactRecord(decision, [
689
+ 'schema',
690
+ 'decision_id',
691
+ 'finding',
692
+ 'frozen_prefix_digest',
693
+ 'affected_closure',
694
+ 'hold_set',
695
+ 'continue_set',
696
+ 'evidence_digests',
697
+ 'decision_digest',
698
+ ])
699
+ || decision.schema !== 'lattice.hold_decision.v1'
700
+ || !identifier(decision.decision_id)
701
+ || !plainObject(decision.finding)
702
+ || !HOLD_REASON_KINDS.includes(decision.finding.kind)
703
+ || !digest(decision.frozen_prefix_digest)
704
+ || !uniqueIdentifierArray(decision.affected_closure)
705
+ || !uniqueIdentifierArray(decision.hold_set)
706
+ || !uniqueIdentifierArray(decision.continue_set)
707
+ || !boundedArray(decision.evidence_digests, digest)) {
708
+ return false;
709
+ }
710
+ const holdSet = new Set(decision.hold_set);
711
+ return decision.continue_set.every((todoId) => !holdSet.has(todoId))
712
+ && selfDigestValid(decision, 'decision_digest');
713
+ });
714
+ }
715
+
716
+ const INVARIANT_DIGEST_FIELDS = Object.freeze([
717
+ 'todo_input',
718
+ 'boundary_manifest',
719
+ 'validator',
720
+ 'context_content',
721
+ ]);
722
+
723
+ /** `lattice.carry_over_witness.v1`。invariant digestは保存bytesから再計算できる。 */
724
+ export function validateCarryOverWitness(value) {
725
+ return validateSafely(value, (witness) => (
726
+ exactRecord(witness, [
727
+ 'schema',
728
+ 'witness_id',
729
+ 'todo_id',
730
+ 'predecessor_epoch',
731
+ 'successor_epoch',
732
+ 'invariant_digests',
733
+ 'non_overlap_evidence',
734
+ 'receipt_bindings',
735
+ 'witness_digest',
736
+ ])
737
+ && witness.schema === 'lattice.carry_over_witness.v1'
738
+ && identifier(witness.witness_id)
739
+ && identifier(witness.todo_id)
740
+ && nonNegativeInteger(witness.predecessor_epoch)
741
+ && nonNegativeInteger(witness.successor_epoch)
742
+ && witness.successor_epoch > witness.predecessor_epoch
743
+ && exactRecord(witness.invariant_digests, INVARIANT_DIGEST_FIELDS)
744
+ && INVARIANT_DIGEST_FIELDS.every((field) => digest(witness.invariant_digests[field]))
745
+ && boundedArray(witness.non_overlap_evidence, (entry) => typeof entry === 'string')
746
+ && boundedArray(witness.receipt_bindings, (binding) => (
747
+ exactRecord(binding, [
748
+ 'receipt_id',
749
+ 'checkpoint_digest',
750
+ 'recorded_sequence',
751
+ 'within_frozen_prefix',
752
+ ])
753
+ && identifier(binding.receipt_id)
754
+ && digest(binding.checkpoint_digest)
755
+ && nonNegativeInteger(binding.recorded_sequence)
756
+ && typeof binding.within_frozen_prefix === 'boolean'
757
+ ))
758
+ && selfDigestValid(witness, 'witness_digest')
759
+ ));
760
+ }
761
+
762
+ /** `lattice.epoch_rebind_packet.v1`。content不変・plan帰属だけを更新する(Decision 7.3)。 */
763
+ export function validateEpochRebindPacket(value) {
764
+ return validateSafely(value, (packet) => (
765
+ exactRecord(packet, [
766
+ 'schema',
767
+ 'packet_id',
768
+ 'todo_id',
769
+ 'executor_handle',
770
+ 'worktree_id',
771
+ 'witness_digest',
772
+ 'context_content_digest',
773
+ 'authorized_checkpoint_digest',
774
+ 'old_plan_ref',
775
+ 'new_plan_ref',
776
+ 'new_plan_epoch',
777
+ 'packet_digest',
778
+ ])
779
+ && packet.schema === 'lattice.epoch_rebind_packet.v1'
780
+ && identifier(packet.packet_id)
781
+ && identifier(packet.todo_id)
782
+ && identifier(packet.executor_handle)
783
+ && identifier(packet.worktree_id)
784
+ && digest(packet.witness_digest)
785
+ && digest(packet.context_content_digest)
786
+ && digest(packet.authorized_checkpoint_digest)
787
+ && identifier(packet.old_plan_ref)
788
+ && identifier(packet.new_plan_ref)
789
+ && packet.old_plan_ref !== packet.new_plan_ref
790
+ && positiveInteger(packet.new_plan_epoch)
791
+ && selfDigestValid(packet, 'packet_digest')
792
+ ));
793
+ }
794
+
795
+ /** `lattice.runtime_plan_diff.v1`。carry-overとredispatchは交差しない。 */
796
+ export function validateRuntimePlanDiff(value) {
797
+ return validateSafely(value, (diff) => {
798
+ if (!exactRecord(diff, [
799
+ 'schema',
800
+ 'old_plan_ref',
801
+ 'new_plan_ref',
802
+ 'accepted_checkpoints',
803
+ 'invalidated_contexts',
804
+ 'carried_over',
805
+ 'redispatched',
806
+ 'node_edge_diff',
807
+ 'diff_digest',
808
+ ])
809
+ || diff.schema !== 'lattice.runtime_plan_diff.v1'
810
+ || !identifier(diff.old_plan_ref)
811
+ || !identifier(diff.new_plan_ref)
812
+ || diff.old_plan_ref === diff.new_plan_ref
813
+ || !boundedArray(diff.accepted_checkpoints, digest)
814
+ || !uniqueIdentifierArray(diff.invalidated_contexts)
815
+ || !uniqueIdentifierArray(diff.carried_over)
816
+ || !uniqueIdentifierArray(diff.redispatched)
817
+ || !plainObject(diff.node_edge_diff)) {
818
+ return false;
819
+ }
820
+ const carried = new Set(diff.carried_over);
821
+ return diff.redispatched.every((todoId) => !carried.has(todoId))
822
+ && selfDigestValid(diff, 'diff_digest');
823
+ });
824
+ }