@quolu/lattice 0.52.3 → 0.52.4

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