@quolu/lattice 0.19.0 → 0.20.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quolu/lattice",
3
- "version": "0.19.0",
3
+ "version": "0.20.0",
4
4
  "description": "Lattice — phase-aware TODO graph compiler and conflict-aware orchestration runtime",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -15,6 +15,11 @@ const PROVENANCE_SOURCES = new Set([
15
15
  const SENSOR_STATUSES = new Set([
16
16
  'ready',
17
17
  'symbol_absent',
18
+ // 不存在path関連。宣言の有無で3つに分かれる(ADR 0136)。宣言が無いabsentはpath_absent、
19
+ // 宣言があるのに実在するのはcreates_path_present、fs観測が記録に無いのはcreates_unverified。
20
+ 'path_absent',
21
+ 'creates_path_present',
22
+ 'creates_unverified',
18
23
  'empty',
19
24
  'unresolved',
20
25
  'command_failure',
@@ -140,10 +140,23 @@ const STATE_EFFECT_KINDS = Object.freeze([
140
140
  'external_effect',
141
141
  ]);
142
142
 
143
- function ownEntry(value) {
144
- return plainObject(value)
145
- && exactRecord(value, ['kind', 'target'])
143
+ /**
144
+ * 所有宣言。`creates: true`は「このpathはまだ存在せず、このTODOが作る」という創作の意思である
145
+ * (ADR 0135 Decision 3・ADR 0136)。
146
+ *
147
+ * `kind`は`path`のまま据え置く。存在の有無は資源の種類ではなく資源の状態であり、kindを分けると
148
+ * 既存のpath判定(write交差の免除、conflict resourceのkind)が全部この宣言を取りこぼす。
149
+ *
150
+ * 値は`true`だけを受理する。`false`は「存在するpath」と同義で、同じ事実に2つの書き方を
151
+ * 与えることになる。省略が既定である。
152
+ */
153
+ function ownEntry(value, { allowCreates = false } = {}) {
154
+ if (!plainObject(value)) return false;
155
+ const creates = Object.hasOwn(value, 'creates');
156
+ if (creates && !allowCreates) return false;
157
+ return exactRecord(value, creates ? ['kind', 'target', 'creates'] : ['kind', 'target'])
146
158
  && OWN_KINDS.includes(value.kind)
159
+ && (!creates || (value.creates === true && value.kind === 'path'))
147
160
  && typeof value.target === 'string'
148
161
  && value.target.length > 0
149
162
  && Buffer.byteLength(value.target, 'utf8') <= MAX_PATH_BYTES;
@@ -221,6 +234,17 @@ const WITNESS_PROVENANCE = Object.freeze([
221
234
  'manual_state_effect',
222
235
  ]);
223
236
 
237
+ /**
238
+ * 現行のrun request契約。v2は`owns[].creates`だけがv1との差であり、境界宣言としては同値である。
239
+ * 既存requestの書き換えを要求しないため、v1は読み口として残す。
240
+ */
241
+ export const RUN_REQUEST_SCHEMA = 'lattice.run_request.v2';
242
+ export const RUN_REQUEST_LEGACY_SCHEMAS = Object.freeze(['lattice.run_request.v1']);
243
+ export const RUN_REQUEST_SCHEMAS = Object.freeze([
244
+ RUN_REQUEST_SCHEMA,
245
+ ...RUN_REQUEST_LEGACY_SCHEMAS,
246
+ ]);
247
+
224
248
  export const RUN_REQUEST_FIELDS = Object.freeze([
225
249
  'schema',
226
250
  'request_id',
@@ -276,7 +300,9 @@ export function explainRunRequest(value) {
276
300
  return reject('non_canonical_request_bytes', '');
277
301
  }
278
302
  if (!exactRecord(value, RUN_REQUEST_FIELDS)) return reject('unexpected_or_missing_top_level_keys', '');
279
- if (value.schema !== 'lattice.run_request.v1') return reject('schema_mismatch', '/schema');
303
+ if (!RUN_REQUEST_SCHEMAS.includes(value.schema)) return reject('schema_mismatch', '/schema');
304
+ // 創作宣言はv2から。v1のclosed shapeは余分fieldを拒否するので加算互換が成立しない。
305
+ const allowCreates = value.schema === RUN_REQUEST_SCHEMA;
280
306
  if (!identifier(value.request_id)) return reject('invalid_identifier', '/request_id');
281
307
  if (!exactRecord(value.repo, ['base_sha', 'root_kind'])) return reject('unexpected_or_missing_keys', '/repo');
282
308
  if (!gitSha(value.repo.base_sha)) return reject('invalid_git_sha', '/repo/base_sha');
@@ -301,7 +327,9 @@ export function explainRunRequest(value) {
301
327
  const at = `/manual_witness/${todoId}`;
302
328
  if (!plainObject(witness)) return reject('not_an_object', at);
303
329
  if (!exactRecord(witness, MANUAL_WITNESS_FIELDS)) return reject('unexpected_or_missing_keys', at);
304
- if (!boundedArray(witness.owns, ownEntry)) return reject('invalid_own_entries', `${at}/owns`);
330
+ if (!boundedArray(witness.owns, (own) => ownEntry(own, { allowCreates }))) {
331
+ return reject('invalid_own_entries', `${at}/owns`);
332
+ }
305
333
  if (!repoPathArray(witness.reads)) return reject('invalid_repo_relative_paths', `${at}/reads`);
306
334
  if (!repoPathArray(witness.writes, { allowPrefix: true })) return reject('invalid_repo_relative_paths', `${at}/writes`);
307
335
  if (!boundedArray(witness.resources, identifier)) return reject('invalid_identifier', `${at}/resources`);
@@ -371,7 +399,18 @@ export function verifyRuntimePlanBinding(options = {}) {
371
399
  return plan.nodes.every((node) => requestTodoIds.has(node.todo_id));
372
400
  }
373
401
 
374
- /** `lattice.boundary_manifest.v2`。witness_provenanceはresourceごとに区別する。 */
402
+ /**
403
+ * `lattice.boundary_manifest.v3`。witness_provenanceはresourceごとに区別する。
404
+ *
405
+ * v3は`owns[].creates`だけがv2との差である。宣言が持っていた「このpathはまだ無い」を
406
+ * 記録側でも保つ——落とすと、manifestだけを読む消費者が既存fileと同じ扱いをする。
407
+ * 旧v2 manifestはrun storeに残るので読み口として受理する。
408
+ */
409
+ export const BOUNDARY_MANIFEST_SCHEMA = 'lattice.boundary_manifest.v3';
410
+ export const BOUNDARY_MANIFEST_SCHEMAS = Object.freeze([
411
+ BOUNDARY_MANIFEST_SCHEMA,
412
+ 'lattice.boundary_manifest.v2',
413
+ ]);
375
414
  export function validateRuntimeBoundaryManifest(value) {
376
415
  return validateSafely(value, (manifest) => (
377
416
  exactRecord(manifest, [
@@ -388,9 +427,11 @@ export function validateRuntimeBoundaryManifest(value) {
388
427
  'witness_provenance',
389
428
  'manifest_digest',
390
429
  ])
391
- && manifest.schema === 'lattice.boundary_manifest.v2'
430
+ && BOUNDARY_MANIFEST_SCHEMAS.includes(manifest.schema)
392
431
  && identifier(manifest.todo_id)
393
- && boundedArray(manifest.owns, ownEntry)
432
+ && boundedArray(manifest.owns, (own) => ownEntry(own, {
433
+ allowCreates: manifest.schema === BOUNDARY_MANIFEST_SCHEMA,
434
+ }))
394
435
  && repoPathArray(manifest.reads)
395
436
  && repoPathArray(manifest.writes, { allowPrefix: true })
396
437
  && boundedArray(manifest.resources, identifier)
@@ -6,6 +6,7 @@ import { portableSensorOutcome } from './sensor-adapter.mjs';
6
6
  import { compileSchedulabilityGraphV2 } from './schedulability-compiler-v2.mjs';
7
7
  import { verifySchedulabilityPlanV2 } from './schedulability-verifier-v2.mjs';
8
8
  import {
9
+ BOUNDARY_MANIFEST_SCHEMA,
9
10
  SENSOR_EXPECT_KINDS,
10
11
  SENSOR_QUERY_OPERATIONS,
11
12
  selfDigest,
@@ -265,11 +266,44 @@ function entryNode(entry) {
265
266
  return null;
266
267
  }
267
268
 
269
+ /**
270
+ * 創作宣言されたpathの裏付けを決める(ADR 0136)。
271
+ *
272
+ * 宣言と観測が一致する時だけ`ready`にする。fresh absentは推測ではなくfsのlstat結果なので、
273
+ * 「観測できなかった」ではなく「観測して、無かった」である。存在しないfileに依存するものは
274
+ * 構造的に存在しえないため、affected testが空であることまで確かめれば裏付けは閉じる。
275
+ *
276
+ * 既に存在するpathへ創作を宣言していたら`creates_path_present`で止める。実害は無いが、
277
+ * 宣言が実態からずれているのを黙って通すと、宣言と観測が一致しているという前提が崩れる。
278
+ * 宣言していないabsent pathは従来どおり`path_absent`のままにする——綴り違いを
279
+ * 「黙って通る創作境界」にしないための線である。
280
+ */
281
+ function creationBoundaryStatus(outcome, expectPath) {
282
+ // sensorが答えられていない状況は、この関数の管轄でない。通常のstatus判定へ返す。
283
+ if (!['ready', 'empty'].includes(outcome.status)) return null;
284
+ const target = affectedTarget(outcome.raw, expectPath);
285
+ // fs観測が記録に無いなら、不存在は確かめられていない。宣言だけで裏付けにしない。
286
+ if (target === null || target === undefined) return 'creates_unverified';
287
+ if (target.path_state !== 'absent') return 'creates_path_present';
288
+ const payload = affectedPayload(outcome.raw, expectPath);
289
+ if (payload === null
290
+ || !Array.isArray(payload.changedFiles)
291
+ || payload.changedFiles.length !== 1
292
+ || payload.changedFiles[0] !== expectPath
293
+ || !Array.isArray(payload.affectedTests)
294
+ || payload.affectedTests.length !== 0) return 'creates_unverified';
295
+ return 'ready';
296
+ }
297
+
268
298
  /**
269
299
  * 束縛queryのoutcomeへexpectをexact照合し、sensor statusを決める。
270
300
  * fuzzy解決・空結果は依存なしへ丸めず、unknown系statusへ落とす(AGENTS.md)。
271
301
  */
272
302
  function resolveBindingStatus(binding, outcome) {
303
+ if (binding.expect.kind === 'affected' && binding.creates === true) {
304
+ const declared = creationBoundaryStatus(outcome, binding.expect.path);
305
+ if (declared !== null) return declared;
306
+ }
273
307
  if (outcome.status !== 'ready') return outcome.status;
274
308
  const { expect } = binding;
275
309
  if (expect.kind === 'affected') {
@@ -401,7 +435,13 @@ export function compileRuntimePlanV1(options = {}) {
401
435
  const driftDetails = [];
402
436
  for (const todoId of todoIds) {
403
437
  const witness = request.manual_witness[todoId];
404
- const bindings = normalizeProvenanceQueries(witness, todoId);
438
+ // 創作宣言はowns側にある。裏付けを決めるのはbindingなので、覆っているbindingへ写す。
439
+ const creating = new Set(witness.owns
440
+ .filter((own) => own.creates === true).map((own) => own.target));
441
+ const bindings = normalizeProvenanceQueries(witness, todoId)
442
+ .map((binding) => (creating.size > 0
443
+ && [...creating].some((target) => bindingCoversOwn(binding, { kind: 'path', target }))
444
+ ? { ...binding, creates: true } : binding));
405
445
  for (const binding of bindings) {
406
446
  const query = queryById.get(binding.query_id);
407
447
  if (query === undefined) {
@@ -697,7 +737,7 @@ export function compileRuntimePlanV1(options = {}) {
697
737
  guidance: hasFreshAbsentPath
698
738
  ? {
699
739
  code: 'BOOTSTRAP_OWNERSHIP_SEAM',
700
- message: 'fresh path観測で不存在の新規pathは親が空の専用seamをbase commitへ先行追加し、sensor sync後に同じrequestを再compileする',
740
+ message: '不存在と観測されたpathは、そのTODOが作るならowns entryへcreates: trueを宣言して再compileする。宣言せずに裏付けを得るなら、親が空の専用seamをbase commitへ先行追加し、sensor sync後に同じrequestを再compileする',
701
741
  }
702
742
  : {
703
743
  code: 'ACQUIRE_OWNERSHIP_EVIDENCE',
@@ -729,7 +769,7 @@ export function compileRuntimePlanV1(options = {}) {
729
769
  : 'manual_state_effect';
730
770
  }
731
771
  const manifest = {
732
- schema: 'lattice.boundary_manifest.v2',
772
+ schema: BOUNDARY_MANIFEST_SCHEMA,
733
773
  todo_id: todoId,
734
774
  owns: witness.owns,
735
775
  reads: witness.reads,
@@ -751,7 +791,7 @@ export function compileRuntimePlanV1(options = {}) {
751
791
  };
752
792
  manifest.manifest_digest = selfDigest(manifest, 'manifest_digest');
753
793
  if (!validateRuntimeBoundaryManifest(manifest)) {
754
- fail(`生成manifestがboundary_manifest.v2 contractを満たさない: ${todoId}`);
794
+ fail(`生成manifestがboundary_manifest contractを満たさない: ${todoId}`);
755
795
  }
756
796
  manifests[todoId] = manifest;
757
797
  }
@@ -8,21 +8,31 @@ import {
8
8
  } from './todo-contracts.mjs';
9
9
  import {
10
10
  RUN_REQUEST_CLAIM_MODE,
11
+ RUN_REQUEST_SCHEMA,
11
12
  explainRunRequest,
12
13
  selfDigest as runtimeSelfDigest,
13
14
  } from './runtime-contracts.mjs';
14
15
  import { TODO_INDEPENDENCE_GUIDANCE_CODES } from './todo-independence-guidance.mjs';
15
16
 
16
- export const TODO_WITNESS_SET_SCHEMA = 'lattice.todo_witness_set.v2';
17
+ export const TODO_WITNESS_SET_SCHEMA = 'lattice.todo_witness_set.v3';
17
18
  /**
18
- * まだ受理する旧witness set契約。v1はconcern anchorを持てないだけで、境界宣言としては
19
- * v2と同値である。既存宣言を書き換えさせないために読み口を残す。
19
+ * まだ受理する旧witness set契約。v1はconcern anchorを、v2は創作宣言を持てないだけで、
20
+ * 境界宣言としてはv3と同値である。既存宣言を書き換えさせないために読み口を残す。
20
21
  */
21
- export const TODO_WITNESS_SET_LEGACY_SCHEMAS = Object.freeze(['lattice.todo_witness_set.v1']);
22
+ export const TODO_WITNESS_SET_LEGACY_SCHEMAS = Object.freeze([
23
+ 'lattice.todo_witness_set.v2',
24
+ 'lattice.todo_witness_set.v1',
25
+ ]);
22
26
  export const TODO_WITNESS_SET_SCHEMAS = Object.freeze([
23
27
  TODO_WITNESS_SET_SCHEMA,
24
28
  ...TODO_WITNESS_SET_LEGACY_SCHEMAS,
25
29
  ]);
30
+ /** 宣言できる欄はversionごとに違う。どの版から使えるかを1箇所で持つ。 */
31
+ const CONCERN_ANCHOR_SCHEMAS = Object.freeze([
32
+ TODO_WITNESS_SET_SCHEMA,
33
+ 'lattice.todo_witness_set.v2',
34
+ ]);
35
+ const CREATES_SCHEMAS = Object.freeze([TODO_WITNESS_SET_SCHEMA]);
26
36
 
27
37
  /** 1 taskが宣言できるconcern anchorの資源数と、資源あたりのsymbol数の上限。 */
28
38
  export const TODO_CONCERN_ANCHOR_LIMIT = 256;
@@ -117,7 +127,7 @@ function boundedText(value, maximumBytes = 4_096) {
117
127
  export function synthesizeWitnessRunRequest(witnessSet, { baseSha, requestId }) {
118
128
  const taskIds = Object.keys(witnessSet.manual_witness).sort(compareText);
119
129
  const request = {
120
- schema: 'lattice.run_request.v1',
130
+ schema: RUN_REQUEST_SCHEMA,
121
131
  request_id: requestId,
122
132
  repo: { base_sha: baseSha, root_kind: 'git' },
123
133
  capacity: witnessSet.capacity,
@@ -212,13 +222,19 @@ export function explainTodoWitnessSet(value) {
212
222
  });
213
223
  const explained = explainRunRequest(probe);
214
224
  if (!explained.valid) return reject(explained.reason, explained.path);
215
- // concern anchorprobeへ写していないので、ここが唯一の判定正本になる。
216
- const legacy = TODO_WITNESS_SET_LEGACY_SCHEMAS.includes(value.schema);
225
+ // 版ごとの欄。probeRUN_REQUEST_SCHEMAで合成するので創作宣言はそこを通ってしまう。
226
+ // 旧版の宣言に新しい欄を書けないことは、ここだけが見る。
217
227
  for (const taskId of taskIds) {
218
228
  const witness = value.manual_witness[taskId];
229
+ if (!CREATES_SCHEMAS.includes(value.schema)
230
+ && witness.owns.some((own) => Object.hasOwn(own, 'creates'))) {
231
+ return reject('creates_require_witness_set_v3', `/manual_witness/${taskId}/owns`);
232
+ }
219
233
  const at = `/manual_witness/${taskId}/concern_anchors`;
220
234
  if (!Object.hasOwn(witness, 'concern_anchors')) continue;
221
- if (legacy) return reject('concern_anchors_require_witness_set_v2', at);
235
+ if (!CONCERN_ANCHOR_SCHEMAS.includes(value.schema)) {
236
+ return reject('concern_anchors_require_witness_set_v2', at);
237
+ }
222
238
  const anchors = explainConcernAnchors(witness.concern_anchors, witness.owns, at);
223
239
  if (!anchors.valid) return reject(anchors.reason, anchors.path);
224
240
  }