@quolu/lattice 0.14.0 → 0.16.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.
@@ -13,9 +13,26 @@ import {
13
13
  } from './runtime-contracts.mjs';
14
14
  import { TODO_INDEPENDENCE_GUIDANCE_CODES } from './todo-independence-guidance.mjs';
15
15
 
16
- export const TODO_WITNESS_SET_SCHEMA = 'lattice.todo_witness_set.v1';
17
- export const TODO_INDEPENDENCE_SCHEMA = 'lattice.todo_independence.v2';
16
+ export const TODO_WITNESS_SET_SCHEMA = 'lattice.todo_witness_set.v2';
17
+ /**
18
+ * まだ受理する旧witness set契約。v1はconcern anchorを持てないだけで、境界宣言としては
19
+ * v2と同値である。既存宣言を書き換えさせないために読み口を残す。
20
+ */
21
+ export const TODO_WITNESS_SET_LEGACY_SCHEMAS = Object.freeze(['lattice.todo_witness_set.v1']);
22
+ export const TODO_WITNESS_SET_SCHEMAS = Object.freeze([
23
+ TODO_WITNESS_SET_SCHEMA,
24
+ ...TODO_WITNESS_SET_LEGACY_SCHEMAS,
25
+ ]);
26
+
27
+ /** 1 taskが宣言できるconcern anchorの資源数と、資源あたりのsymbol数の上限。 */
28
+ export const TODO_CONCERN_ANCHOR_LIMIT = 256;
29
+ export const TODO_INDEPENDENCE_SCHEMA = 'lattice.todo_independence.v3';
18
30
  export const TODO_INDEPENDENCE_PROJECTION_SCHEMA = 'lattice.todo_independence_projection.v2';
31
+ export const TODO_INDEPENDENCE_LEGACY_MARKER_SCHEMA = 'lattice.todo_independence_legacy_marker.v1';
32
+ export const TODO_INDEPENDENCE_LEGACY_SCHEMAS = Object.freeze([
33
+ 'lattice.todo_independence.v1',
34
+ 'lattice.todo_independence.v2',
35
+ ]);
19
36
 
20
37
  /** boundary compileが一度に扱えるToDo数(runtime front-endのMAX_COLLECTIONと同じ閉じ方)。 */
21
38
  export const TODO_INDEPENDENCE_TASK_LIMIT = 256;
@@ -53,6 +70,19 @@ function plain(value) {
53
70
  && Object.getPrototypeOf(value) === Object.prototype;
54
71
  }
55
72
 
73
+ /**
74
+ * 既知の旧independence artifactを、単なるschema文字列だけでなく版をまたいで不変な
75
+ * identity fieldの型までで識別する。本体構造や自己digestは旧契約validatorの再実装に
76
+ * なるため、ここでは検査しない。
77
+ */
78
+ export function isTodoIndependenceLegacyArtifactIdentity(value) {
79
+ return plain(value) && TODO_INDEPENDENCE_LEGACY_SCHEMAS.includes(value.schema)
80
+ && isTodoIdentifier(value.project_id) && isTodoIdentifier(value.plan_key)
81
+ && isTodoIdentifier(value.plan_version) && isTodoDigest(value.topology_digest)
82
+ && isTodoDigest(value.witness_set_digest) && isTodoDigest(value.result_digest)
83
+ && isGitSha(value.base_sha);
84
+ }
85
+
56
86
  function boundedList(value, validator, limit = TODO_INDEPENDENCE_LIST_LIMIT) {
57
87
  return Array.isArray(value) && value.length <= limit && value.every(validator);
58
88
  }
@@ -79,6 +109,10 @@ function boundedText(value, maximumBytes = 4_096) {
79
109
  * manual witnessとsensor query setの判定正本は`explainRunRequest`だけが持つ(ADR 0123)。
80
110
  * ここで同じ規則を書き直すと契約が二箇所へ分裂するため、検証もcompileもこの合成を通す。
81
111
  * `requestId`はplan identityとwitness digestから導出され、run lifecycleへは登録されない。
112
+ *
113
+ * `concern_anchors`はここで落とす。並列可否の判定はこの合成requestだけを入力にするので、
114
+ * 落としておけばconcern宣言が判定へ影響しないことが構造で保証される(testの主張ではない)。
115
+ * 宣言はseam束縛の入力であり、witness setから直接読む。
82
116
  */
83
117
  export function synthesizeWitnessRunRequest(witnessSet, { baseSha, requestId }) {
84
118
  const taskIds = Object.keys(witnessSet.manual_witness).sort(compareText);
@@ -88,7 +122,10 @@ export function synthesizeWitnessRunRequest(witnessSet, { baseSha, requestId })
88
122
  repo: { base_sha: baseSha, root_kind: 'git' },
89
123
  capacity: witnessSet.capacity,
90
124
  todos: taskIds.map((taskId) => ({ todo_id: taskId })),
91
- manual_witness: witnessSet.manual_witness,
125
+ manual_witness: Object.fromEntries(taskIds.map((taskId) => {
126
+ const { concern_anchors: _concernAnchors, ...boundary } = witnessSet.manual_witness[taskId];
127
+ return [taskId, boundary];
128
+ })),
92
129
  sensor_query_set: witnessSet.sensor_query_set,
93
130
  executor_capability: { adapters: ['todo-independence'] },
94
131
  claim_mode: RUN_REQUEST_CLAIM_MODE,
@@ -99,10 +136,57 @@ export function synthesizeWitnessRunRequest(witnessSet, { baseSha, requestId })
99
136
  }
100
137
 
101
138
  /**
102
- * `lattice.todo_witness_set.v1`を検証し、拒否理由とpathを返す。
139
+ * 1 taskのconcern anchor宣言を検査する。
140
+ *
141
+ * `within`はそのtask自身が`owns`で主張している資源に限る。所有していない資源の内側に
142
+ * 担当を主張させない。symbol名の実在・資源内包含・task間排他はsensorとcompile側が見る。
143
+ */
144
+ function explainConcernAnchors(anchors, owns, at) {
145
+ const reject = (reason, path) => ({ valid: false, reason, path });
146
+ if (!Array.isArray(anchors) || anchors.length > TODO_CONCERN_ANCHOR_LIMIT) {
147
+ return reject('bounded_collection_violation', at);
148
+ }
149
+ const ownedKeys = new Set(owns.map((own) => `${own.kind}\0${own.target}`));
150
+ for (const [index, entry] of anchors.entries()) {
151
+ const entryAt = `${at}/${index}`;
152
+ if (!exactRecord(entry, ['within', 'symbols'])) {
153
+ return reject('unexpected_or_missing_keys', entryAt);
154
+ }
155
+ if (!exactRecord(entry.within, ['kind', 'target'])
156
+ || !['symbol', 'path'].includes(entry.within.kind)) {
157
+ return reject('invalid_concern_anchor_resource', `${entryAt}/within`);
158
+ }
159
+ const targetValid = entry.within.kind === 'path'
160
+ ? repoRelativeResourceTarget(entry.within.target)
161
+ : boundedText(entry.within.target, 1_024);
162
+ if (!targetValid) return reject('invalid_concern_anchor_resource', `${entryAt}/within`);
163
+ if (!ownedKeys.has(`${entry.within.kind}\0${entry.within.target}`)) {
164
+ return reject('concern_anchor_resource_not_owned', `${entryAt}/within`);
165
+ }
166
+ if (!Array.isArray(entry.symbols) || entry.symbols.length < 1
167
+ || entry.symbols.length > TODO_CONCERN_ANCHOR_LIMIT) {
168
+ return reject('bounded_collection_violation', `${entryAt}/symbols`);
169
+ }
170
+ if (!entry.symbols.every((symbol) => boundedText(symbol, 1_024))) {
171
+ return reject('invalid_concern_anchor_symbol', `${entryAt}/symbols`);
172
+ }
173
+ if (!strictlySorted(entry.symbols)) {
174
+ return reject('unsorted_or_duplicate_collection', `${entryAt}/symbols`);
175
+ }
176
+ }
177
+ if (!strictlySorted(anchors, (entry) => `${entry.within.kind}\0${entry.within.target}`)) {
178
+ return reject('unsorted_or_duplicate_collection', at);
179
+ }
180
+ return { valid: true };
181
+ }
182
+
183
+ /**
184
+ * `lattice.todo_witness_set.v2`(およびconcern anchorを持たない旧v1)を検証し、
185
+ * 拒否理由とpathを返す。
103
186
  *
104
187
  * 自分のshapeだけをここで見て、witness本体はprobe requestへ合成して
105
188
  * `explainRunRequest`へ委譲する。probeのbase_shaは検証専用の定数であり永続化しない。
189
+ * `concern_anchors`はprobeから落ちるので、ここだけが判定正本になる。
106
190
  */
107
191
  export function explainTodoWitnessSet(value) {
108
192
  const reject = (reason, at = '') => ({ valid: false, reason, path: at });
@@ -111,7 +195,7 @@ export function explainTodoWitnessSet(value) {
111
195
  'schema', 'project_id', 'plan_key', 'capacity', 'sensor_query_set',
112
196
  'manual_witness', 'witness_set_digest',
113
197
  ])) return reject('unexpected_or_missing_top_level_keys');
114
- if (value.schema !== TODO_WITNESS_SET_SCHEMA) return reject('schema_mismatch', '/schema');
198
+ if (!TODO_WITNESS_SET_SCHEMAS.includes(value.schema)) return reject('schema_mismatch', '/schema');
115
199
  if (!isTodoIdentifier(value.project_id)) return reject('invalid_identifier', '/project_id');
116
200
  if (!isTodoIdentifier(value.plan_key)) return reject('invalid_identifier', '/plan_key');
117
201
  if (!plain(value.manual_witness)) return reject('not_an_object', '/manual_witness');
@@ -128,6 +212,16 @@ export function explainTodoWitnessSet(value) {
128
212
  });
129
213
  const explained = explainRunRequest(probe);
130
214
  if (!explained.valid) return reject(explained.reason, explained.path);
215
+ // concern anchorはprobeへ写していないので、ここが唯一の判定正本になる。
216
+ const legacy = TODO_WITNESS_SET_LEGACY_SCHEMAS.includes(value.schema);
217
+ for (const taskId of taskIds) {
218
+ const witness = value.manual_witness[taskId];
219
+ const at = `/manual_witness/${taskId}/concern_anchors`;
220
+ if (!Object.hasOwn(witness, 'concern_anchors')) continue;
221
+ if (legacy) return reject('concern_anchors_require_witness_set_v2', at);
222
+ const anchors = explainConcernAnchors(witness.concern_anchors, witness.owns, at);
223
+ if (!anchors.valid) return reject(anchors.reason, anchors.path);
224
+ }
131
225
  return { valid: true };
132
226
  } catch {
133
227
  return reject('non_canonical_witness_set_bytes');
@@ -139,12 +233,39 @@ export function validateTodoWitnessSet(value) {
139
233
  }
140
234
 
141
235
  function conflictEntry(value) {
142
- return exactRecord(value, ['task_ids', 'resource_id', 'kind'])
236
+ return exactRecord(value, ['task_ids', 'resource_id'])
143
237
  && Array.isArray(value.task_ids) && value.task_ids.length === 2
144
238
  && value.task_ids.every(isTodoIdentifier)
145
239
  && compareText(value.task_ids[0], value.task_ids[1]) < 0
146
- && boundedText(value.resource_id)
147
- && TODO_INDEPENDENCE_CONFLICT_KINDS.includes(value.kind);
240
+ && boundedText(value.resource_id);
241
+ }
242
+
243
+ function repoRelativeResourceTarget(value) {
244
+ if (!boundedText(value) || value.startsWith('/') || value.includes('\\')
245
+ || /^[A-Za-z]:/u.test(value)) return false;
246
+ const body = value.endsWith('/') ? value.slice(0, -1) : value;
247
+ return body.length > 0 && body.split('/')
248
+ .every((segment) => segment !== '' && segment !== '.' && segment !== '..');
249
+ }
250
+
251
+ function conflictResourceEntry(value) {
252
+ if (!exactRecord(value, ['resource_id', 'kind', 'target'])
253
+ || !boundedText(value.resource_id)
254
+ || !TODO_INDEPENDENCE_CONFLICT_KINDS.includes(value.kind)) return false;
255
+ return value.kind === 'path'
256
+ ? repoRelativeResourceTarget(value.target)
257
+ : boundedText(value.target);
258
+ }
259
+
260
+ export function isTodoIndependenceLegacyMarker(value) {
261
+ return exactRecord(value, [
262
+ 'schema', 'legacy_schema', 'project_id', 'plan_key', 'plan_version',
263
+ 'topology_digest', 'base_sha',
264
+ ])
265
+ && value.schema === TODO_INDEPENDENCE_LEGACY_MARKER_SCHEMA
266
+ && TODO_INDEPENDENCE_LEGACY_SCHEMAS.includes(value.legacy_schema)
267
+ && isTodoIdentifier(value.project_id) && isTodoIdentifier(value.plan_key)
268
+ && value.plan_version === null && value.topology_digest === null && value.base_sha === null;
148
269
  }
149
270
 
150
271
  /**
@@ -188,10 +309,10 @@ function wavePlan(value, taskIds) {
188
309
  }
189
310
 
190
311
  /**
191
- * `lattice.todo_independence.v2`。
312
+ * `lattice.todo_independence.v3`。
192
313
  *
193
314
  * conflict/precedence/unknownはnormalized boundary graphから採る(ADR 0127 Decision 4)。
194
- * conflictresource kindを併せて持ち、切断可能性の導出を投影側に許す(ADR 0128 Decision 1)。
315
+ * conflictは`conflict_resources`のresource idを参照し、kindとnormalized targetを一度だけ保持する。
195
316
  * `task_boundaries`はtask別の宣言境界で、鮮度のdiff交差判定をartifactだけで閉じるために持つ。
196
317
  * `wave_plan`はschedulability compileが`compiled`を返した時だけ持ち、unknownが残る間はnullになる。
197
318
  * verdictに現れないペアをverified独立と読めるのは、両taskにunknownが無いときだけである。
@@ -200,7 +321,7 @@ export function validateTodoIndependence(value) {
200
321
  try {
201
322
  if (!exactRecord(value, [
202
323
  'schema', 'project_id', 'plan_key', 'plan_version', 'topology_digest', 'base_sha',
203
- 'witness_set_digest', 'compiled_at', 'task_ids', 'task_boundaries', 'conflicts',
324
+ 'witness_set_digest', 'compiled_at', 'task_ids', 'task_boundaries', 'conflict_resources', 'conflicts',
204
325
  'precedences', 'unknowns', 'wave_plan', 'outcome', 'result_digest',
205
326
  ])) return false;
206
327
  if (value.schema !== TODO_INDEPENDENCE_SCHEMA) return false;
@@ -221,10 +342,16 @@ export function validateTodoIndependence(value) {
221
342
  || !value.task_boundaries.every((entry, index) => entry.task_id === value.task_ids[index])) {
222
343
  return false;
223
344
  }
345
+ if (!boundedList(value.conflict_resources, conflictResourceEntry)
346
+ || !strictlySorted(value.conflict_resources, (entry) => entry.resource_id)) return false;
347
+ const conflictResourceIds = new Set(value.conflict_resources.map(({ resource_id: id }) => id));
224
348
  if (!boundedList(value.conflicts, conflictEntry)
225
349
  || !value.conflicts.every((entry) => entry.task_ids.every((taskId) => known.has(taskId)))
350
+ || !value.conflicts.every((entry) => conflictResourceIds.has(entry.resource_id))
226
351
  || !strictlySorted(value.conflicts, (entry) => (
227
352
  `${entry.task_ids[0]}\0${entry.task_ids[1]}\0${entry.resource_id}`))) return false;
353
+ const referencedResourceIds = new Set(value.conflicts.map(({ resource_id: id }) => id));
354
+ if (referencedResourceIds.size !== value.conflict_resources.length) return false;
228
355
  if (!boundedList(value.precedences, precedenceEntry)
229
356
  || !value.precedences.every((entry) => known.has(entry.from_task_id) && known.has(entry.to_task_id))
230
357
  || !strictlySorted(value.precedences, (entry) => (
@@ -345,7 +472,9 @@ export function validateTodoIndependenceProjection(value) {
345
472
  // 記録が無い状態でcompile済みidentityを名乗らない。
346
473
  if (value.coverage === 'missing'
347
474
  && (value.compiled_base_sha !== null || value.topology_digest !== null)) return false;
348
- if (value.coverage !== 'missing' && value.compiled_base_sha === null) return false;
475
+ if (['verified', 'stale'].includes(value.coverage) && value.compiled_base_sha === null) return false;
476
+ if (value.coverage === 'superseded' && value.compiled_base_sha === null
477
+ && (value.plan_version !== null || value.topology_digest !== null)) return false;
349
478
  for (const key of ['active_task_ids', 'uncovered_active_task_ids']) {
350
479
  if (!Array.isArray(value[key]) || value[key].length > TODO_INDEPENDENCE_TASK_LIMIT
351
480
  || !value[key].every(isTodoIdentifier) || !strictlySorted(value[key])) return false;
@@ -12,6 +12,7 @@ export const TODO_INDEPENDENCE_GUIDANCE_CODES = Object.freeze([
12
12
  'independence_no_ready_frontier',
13
13
  'independence_unrecorded',
14
14
  'independence_task_undeclared',
15
+ 'independence_contract_superseded',
15
16
  'independence_superseded',
16
17
  'independence_stale_for_task',
17
18
  'independence_conflict_with_active',
@@ -19,6 +20,13 @@ export const TODO_INDEPENDENCE_GUIDANCE_CODES = Object.freeze([
19
20
  'independence_verified',
20
21
  ]);
21
22
 
23
+ export const SEAM_PROPOSAL_GUIDANCE_CODES = Object.freeze([
24
+ 'seam_proposal_unrecorded',
25
+ 'seam_proposal_superseded',
26
+ 'seam_proposal_stale',
27
+ 'seam_proposal_verified',
28
+ ]);
29
+
22
30
  const CATALOG = Object.freeze({
23
31
  independence_no_ready_frontier: Object.freeze({
24
32
  message: '着手候補が無いため、並列可否を述べる対象が無い。',
@@ -32,6 +40,10 @@ const CATALOG = Object.freeze({
32
40
  message: 'この工程はwitness setで宣言されていないため、記録には含まれていない。',
33
41
  next_action: 'add_task_to_witness_set_then_compile',
34
42
  }),
43
+ independence_contract_superseded: Object.freeze({
44
+ message: '記録は旧契約versionで書かれており、現在の並列可否の判定としては読めない。現在の契約での再compileが次の一歩になる。',
45
+ next_action: 'recompile_independence',
46
+ }),
35
47
  independence_superseded: Object.freeze({
36
48
  message: 'planが改訂され、記録は別のtopologyについての判定になっている。',
37
49
  next_action: 'migrate_witness_set_then_compile',
@@ -52,6 +64,22 @@ const CATALOG = Object.freeze({
52
64
  message: '記録時点の宣言境界では、他のready工程と干渉しない。',
53
65
  next_action: 'none',
54
66
  }),
67
+ seam_proposal_unrecorded: Object.freeze({
68
+ message: 'このplanのseam提案はまだ生成していない。提案対象が無いのではなく、記録が存在しない。',
69
+ next_action: 'compile_seam_proposal',
70
+ }),
71
+ seam_proposal_superseded: Object.freeze({
72
+ message: '参照元のplanまたは並列可否記録が更新され、このseam提案は現在の競合についての記録ではない。',
73
+ next_action: 'compile_seam_proposal',
74
+ }),
75
+ seam_proposal_stale: Object.freeze({
76
+ message: 'seam提案の生成後にHEADが進み、記録時点の構造証拠は現在のcodeを指していない。',
77
+ next_action: 'compile_seam_proposal',
78
+ }),
79
+ seam_proposal_verified: Object.freeze({
80
+ message: 'seam提案の記録は現在のplan、並列可否記録、HEADと一致している。',
81
+ next_action: 'none',
82
+ }),
55
83
  });
56
84
 
57
85
  /** 切断可能性の言い換え。conflictの案内へ添える。 */
@@ -82,7 +110,7 @@ export function todoIndependenceGuidance(code, { severability = null } = {}) {
82
110
  */
83
111
  export function selectIndependenceGuidance({
84
112
  coverage, taskDeclared, taskStale, conflictWithActive = null, conflictBetweenReady = null,
85
- readyCount = null,
113
+ contractSuperseded = false, readyCount = null,
86
114
  }) {
87
115
  // 着手候補が無いなら述べる対象が無い。ここを通さないと、readyが空のとき
88
116
  // 「未検査taskが1件も無い」が空虚に真になり、記録が古くても検証済みへ倒れる。
@@ -97,6 +125,9 @@ export function selectIndependenceGuidance({
97
125
  severability: conflictBetweenReady,
98
126
  });
99
127
  }
128
+ if (contractSuperseded) {
129
+ return todoIndependenceGuidance('independence_contract_superseded');
130
+ }
100
131
  if (coverage === 'missing') return todoIndependenceGuidance('independence_unrecorded');
101
132
  if (coverage === 'superseded') return todoIndependenceGuidance('independence_superseded');
102
133
  if (!taskDeclared) return todoIndependenceGuidance('independence_task_undeclared');
@@ -104,6 +135,15 @@ export function selectIndependenceGuidance({
104
135
  return todoIndependenceGuidance('independence_verified');
105
136
  }
106
137
 
138
+ export function selectSeamProposalGuidance({ coverage }) {
139
+ const code = coverage === 'missing' ? 'seam_proposal_unrecorded'
140
+ : coverage === 'superseded' ? 'seam_proposal_superseded'
141
+ : coverage === 'stale' ? 'seam_proposal_stale'
142
+ : coverage === 'verified' ? 'seam_proposal_verified' : null;
143
+ if (code === null) throw new TypeError(`unknown seam proposal coverage: ${coverage}`);
144
+ return todoIndependenceGuidance(code);
145
+ }
146
+
107
147
  /**
108
148
  * 宣言からcompileを経て読むまでの順序。helpとMCP instructionsが同じ手順を語るための正本。
109
149
  * 面ごとに手順を書き直すと、片方だけが古くなる。
@@ -2,6 +2,7 @@ import { digestTodoArtifact, isTodoIdentifier, todoSelfDigest } from './todo-con
2
2
  import {
3
3
  TODO_INDEPENDENCE_SCHEMA,
4
4
  isGitSha,
5
+ isTodoIndependenceLegacyMarker,
5
6
  severabilityOfConflictKind,
6
7
  synthesizeWitnessRunRequest,
7
8
  validateTodoIndependence,
@@ -50,34 +51,65 @@ export async function collectWitnessSensorEvidence({ cwd, witnessSet, execute =
50
51
  }
51
52
 
52
53
  /**
53
- * conflictresource kindを載せる(ADR 0128 Decision 1)。
54
+ * conflictから参照されるnormalized resourceを正規化してartifactへ載せる。
54
55
  *
55
56
  * `graph.conflicts`はresource_idしか持たず、宣言由来のstate resource idは任意文字列なので
56
57
  * prefixからkindを復元できない。normalized resourceを引けない場合は、切断可能性を
57
58
  * 不明のまま記録するのでなくtyped failで止める。
58
59
  */
59
60
  function conflictsFrom(verdicts, resources) {
60
- const kindByResourceId = new Map((Array.isArray(resources) ? resources : [])
61
- .map((resource) => [resource?.resource_id, resource?.kind]));
62
- return verdicts
61
+ const resourceById = new Map();
62
+ for (const resource of Array.isArray(resources) ? resources : []) {
63
+ const resourceId = resource?.resource_id;
64
+ if (resourceById.has(resourceId)) {
65
+ const previous = resourceById.get(resourceId);
66
+ if (previous.kind !== resource?.kind) {
67
+ fail('INDEPENDENCE_RESOURCE_KIND_MISMATCH', 'conflict_resource_kind_mismatch', {
68
+ resource_id: resourceId ?? null,
69
+ observed_kinds: [previous.kind ?? null, resource?.kind ?? null],
70
+ });
71
+ }
72
+ if (previous.target !== resource?.target) {
73
+ fail('INDEPENDENCE_RESOURCE_TARGET_MISMATCH', 'conflict_resource_target_mismatch', {
74
+ resource_id: resourceId ?? null,
75
+ observed_targets: [previous.target ?? null, resource?.target ?? null],
76
+ });
77
+ }
78
+ fail('INDEPENDENCE_RESOURCE_DUPLICATE', 'conflict_resource_duplicate', {
79
+ resource_id: resourceId ?? null,
80
+ });
81
+ }
82
+ resourceById.set(resourceId, { kind: resource?.kind, target: resource?.target });
83
+ }
84
+ const conflicts = verdicts
63
85
  .filter((verdict) => verdict.type === 'conflict')
64
86
  .map((verdict) => {
65
- const kind = kindByResourceId.get(verdict.resource_id);
87
+ const resource = resourceById.get(verdict.resource_id);
88
+ const kind = resource?.kind;
66
89
  if (!['symbol', 'path', 'state', 'effect'].includes(kind)) {
67
90
  fail('INDEPENDENCE_RESOURCE_KIND_UNRESOLVED', 'conflict_resource_kind_unresolved', {
68
91
  resource_id: verdict.resource_id, observed_kind: kind ?? null,
69
92
  });
70
93
  }
94
+ if (typeof resource.target !== 'string' || resource.target.length === 0) {
95
+ fail('INDEPENDENCE_RESOURCE_TARGET_UNRESOLVED', 'conflict_resource_target_unresolved', {
96
+ resource_id: verdict.resource_id, observed_target: resource.target ?? null,
97
+ });
98
+ }
71
99
  return {
72
100
  task_ids: [...verdict.todo_ids].sort(compareText),
73
101
  resource_id: verdict.resource_id,
74
- kind,
75
102
  };
76
103
  })
77
104
  .sort((left, right) => compareText(
78
105
  `${left.task_ids[0]}\0${left.task_ids[1]}\0${left.resource_id}`,
79
106
  `${right.task_ids[0]}\0${right.task_ids[1]}\0${right.resource_id}`,
80
107
  ));
108
+ const used = new Set(conflicts.map(({ resource_id: resourceId }) => resourceId));
109
+ const conflictResources = [...used]
110
+ .map((resourceId) => ({ resource_id: resourceId, ...resourceById.get(resourceId) }))
111
+ .sort((left, right) => compareText(left.resource_id, right.resource_id));
112
+ return { conflicts, conflictResources };
81
113
  }
82
114
 
83
115
  /**
@@ -141,7 +173,7 @@ function unknownsFrom(detail) {
141
173
  }
142
174
 
143
175
  /**
144
- * witness setとsensor evidenceから`lattice.todo_independence.v1`を作る。
176
+ * witness setとsensor evidenceから`lattice.todo_independence.v3`を作る。
145
177
  *
146
178
  * compileは宣言済みtaskの部分集合へ閉じる(ADR 0127 Decision 4)。未宣言taskを混ぜると、
147
179
  * unknownが1件でも出た時点で宣言済みtask同士の判定まで失われるためである。
@@ -187,6 +219,9 @@ export function compileTodoIndependence(options = {}) {
187
219
  }
188
220
 
189
221
  const dispatchable = compiled.outcome === 'dispatchable';
222
+ const conflictProjection = dispatchable
223
+ ? conflictsFrom(compiled.pairwise_verdicts, compiled.resources)
224
+ : { conflicts: [], conflictResources: [] };
190
225
  const artifact = {
191
226
  schema: TODO_INDEPENDENCE_SCHEMA,
192
227
  project_id: plan.project_id,
@@ -201,8 +236,8 @@ export function compileTodoIndependence(options = {}) {
201
236
  task_id: taskId,
202
237
  paths: boundaryPathsOf(witnessSet.manual_witness[taskId]),
203
238
  })),
204
- conflicts: dispatchable
205
- ? conflictsFrom(compiled.pairwise_verdicts, compiled.resources) : [],
239
+ conflict_resources: conflictProjection.conflictResources,
240
+ conflicts: conflictProjection.conflicts,
206
241
  precedences: dispatchable ? precedencesFrom(compiled.pairwise_verdicts) : [],
207
242
  unknowns: dispatchable ? [] : unknownsFrom(compiled.detail),
208
243
  wave_plan: dispatchable
@@ -290,6 +325,24 @@ export function projectIndependenceFrontier({
290
325
  };
291
326
  }
292
327
 
328
+ if (isTodoIndependenceLegacyMarker(artifact)) {
329
+ return {
330
+ coverage: 'superseded',
331
+ drift: null,
332
+ active_task_ids: active,
333
+ uncovered_active_task_ids: active,
334
+ frontier: {
335
+ parallel_groups: [],
336
+ serialize_pairs: [],
337
+ conflicts_with_active: [],
338
+ unknown: ready.map((taskId) => ({
339
+ task_id: taskId,
340
+ unknowns: [{ kind: 'record_superseded', ref: artifact.legacy_schema }],
341
+ })),
342
+ },
343
+ };
344
+ }
345
+
293
346
  const superseded = artifact.plan_version !== plan.plan_version
294
347
  || artifact.topology_digest !== plan.topology_digest;
295
348
  const coverage = superseded ? 'superseded'
@@ -382,9 +435,17 @@ export function projectIndependenceFrontier({
382
435
  });
383
436
  };
384
437
 
438
+ const conflictResourceById = new Map(artifact.conflict_resources
439
+ .map((resource) => [resource.resource_id, resource]));
385
440
  for (const conflict of artifact.conflicts) {
441
+ const resource = conflictResourceById.get(conflict.resource_id);
442
+ if (resource === undefined) {
443
+ fail('INDEPENDENCE_RESOURCE_KIND_UNRESOLVED', 'conflict_resource_kind_unresolved', {
444
+ resource_id: conflict.resource_id, observed_kind: null,
445
+ });
446
+ }
386
447
  notePair(conflict.task_ids[0], conflict.task_ids[1], 'conflict', conflict.resource_id,
387
- conflict.kind);
448
+ resource.kind);
388
449
  }
389
450
  for (const precedence of artifact.precedences) {
390
451
  notePair(precedence.from_task_id, precedence.to_task_id, 'precedence', precedence.reason, null);
@@ -21,9 +21,12 @@ import {
21
21
  validateTodoSnapshot,
22
22
  } from './todo-contracts.mjs';
23
23
  import {
24
+ TODO_INDEPENDENCE_LEGACY_MARKER_SCHEMA,
25
+ isTodoIndependenceLegacyArtifactIdentity,
24
26
  validateTodoIndependence,
25
27
  validateTodoWitnessSet,
26
28
  } from './todo-independence-contracts.mjs';
29
+ import { validateSeamProposal } from './seam-proposal-contracts.mjs';
27
30
  import { sha256Bytes, verifyLinearHashChain } from './hash-chain.mjs';
28
31
  import {
29
32
  parseTodoSourceRef,
@@ -3609,12 +3612,25 @@ export async function readTodoIndependenceArtifact(options = {}) {
3609
3612
  const member = activeMember(store, options.planKey);
3610
3613
  const ref = todoIndependenceRef(member.plan.plan_key, member.plan.plan_version);
3611
3614
  try {
3612
- return await readArtifact(repoRoot, ref, {
3615
+ const artifact = await readArtifact(repoRoot, ref, {
3613
3616
  code: 'INDEPENDENCE_ARTIFACT_INVALID',
3614
3617
  maxBytes: INDEPENDENCE_ARTIFACT_BYTES,
3615
- validate: validateTodoIndependence,
3618
+ // 旧契約は本体を信用せず、canonical JSONと版をまたいで不変なidentityの型だけを見る。
3619
+ // schema名だけの壊れた記録や現行v3をこの分岐へ逃がさず、旧版集合も明示したものだけに閉じる。
3620
+ validate: (value) => validateTodoIndependence(value)
3621
+ || isTodoIndependenceLegacyArtifactIdentity(value),
3616
3622
  missing: true,
3617
3623
  });
3624
+ if (artifact === null || validateTodoIndependence(artifact)) return artifact;
3625
+ return {
3626
+ schema: TODO_INDEPENDENCE_LEGACY_MARKER_SCHEMA,
3627
+ legacy_schema: artifact.schema,
3628
+ project_id: member.plan.project_id,
3629
+ plan_key: member.plan.plan_key,
3630
+ plan_version: null,
3631
+ topology_digest: null,
3632
+ base_sha: null,
3633
+ };
3618
3634
  } catch (error) {
3619
3635
  // 読めない記録は握りつぶさない。ただしどのplanをどう直すかまで言わないと、
3620
3636
  // 消費者は「壊れている」以上のことができない。
@@ -3630,6 +3646,91 @@ export async function readTodoIndependenceArtifact(options = {}) {
3630
3646
  }
3631
3647
  }
3632
3648
 
3649
+ const SEAM_PROPOSAL_ARTIFACT_NAME = 'seam-proposal.json';
3650
+ const SEAM_PROPOSAL_ARTIFACT_BYTES = 4_194_304;
3651
+
3652
+ /** independence.jsonと同じplan versionディレクトリへ並置し、manifestへ登録しない。 */
3653
+ export function todoSeamProposalRef(planKey, planVersion) {
3654
+ return `${STORE_ROOT_REF}/plans/${planKey}/${planVersion}/${SEAM_PROPOSAL_ARTIFACT_NAME}`;
3655
+ }
3656
+
3657
+ /**
3658
+ * seam proposal artifactをactive planと現在のindependence artifactへbindして書く。
3659
+ * planの正本とmanifestには触れない。
3660
+ */
3661
+ export async function writeTodoSeamProposalArtifact(options = {}) {
3662
+ const repoRoot = path.resolve(options.repoRoot ?? process.cwd());
3663
+ const { artifact } = options;
3664
+ if (!validateSeamProposal(artifact)) {
3665
+ fail('SEAM_PROPOSAL_ARTIFACT_INVALID', 'seam_proposal_artifact_invalid');
3666
+ }
3667
+ return withLock(repoRoot, async () => {
3668
+ const store = await readTodoStore({ repoRoot, forWrite: true, now: options.now });
3669
+ if (store.project_id !== artifact.project_id) {
3670
+ fail('SEAM_PROPOSAL_BINDING_MISMATCH', 'project_id_mismatch', {
3671
+ expected: store.project_id, actual: artifact.project_id,
3672
+ });
3673
+ }
3674
+ const member = activeMember(store, artifact.plan_key);
3675
+ const binding = artifact.source_binding;
3676
+ if (member.plan.plan_version !== binding.plan_version) {
3677
+ fail('SEAM_PROPOSAL_BINDING_MISMATCH', 'plan_version_mismatch', {
3678
+ expected: member.plan.plan_version, actual: binding.plan_version,
3679
+ });
3680
+ }
3681
+ if (member.plan.topology_digest !== binding.topology_digest) {
3682
+ fail('SEAM_PROPOSAL_BINDING_MISMATCH', 'topology_digest_mismatch', {
3683
+ expected: member.plan.topology_digest, actual: binding.topology_digest,
3684
+ });
3685
+ }
3686
+ const independenceArtifact = await readTodoIndependenceArtifact({
3687
+ repoRoot, store, planKey: artifact.plan_key,
3688
+ });
3689
+ if (independenceArtifact === null
3690
+ || !validateTodoIndependence(independenceArtifact)
3691
+ || independenceArtifact.schema !== binding.independence_schema
3692
+ || independenceArtifact.result_digest !== binding.independence_result_digest
3693
+ || independenceArtifact.witness_set_digest !== binding.witness_set_digest
3694
+ || independenceArtifact.plan_version !== binding.plan_version
3695
+ || independenceArtifact.topology_digest !== binding.topology_digest
3696
+ || independenceArtifact.base_sha !== binding.base_sha) {
3697
+ fail('SEAM_PROPOSAL_BINDING_MISMATCH', 'independence_binding_mismatch');
3698
+ }
3699
+ const ref = todoSeamProposalRef(artifact.plan_key, binding.plan_version);
3700
+ await atomicWrite(path.resolve(repoRoot, ref), canonicalLine(artifact));
3701
+ return { ref, artifact };
3702
+ });
3703
+ }
3704
+
3705
+ /**
3706
+ * active planに並置されたseam proposal artifactを読む。
3707
+ * 無ければnull、壊れた記録はtyped failureとし、missingへ丸めない。
3708
+ */
3709
+ export async function readTodoSeamProposalArtifact(options = {}) {
3710
+ const repoRoot = path.resolve(options.repoRoot ?? process.cwd());
3711
+ const store = options.store ?? await readTodoStore({ repoRoot, now: options.now });
3712
+ const member = activeMember(store, options.planKey);
3713
+ const ref = todoSeamProposalRef(member.plan.plan_key, member.plan.plan_version);
3714
+ try {
3715
+ return await readArtifact(repoRoot, ref, {
3716
+ code: 'SEAM_PROPOSAL_ARTIFACT_INVALID',
3717
+ maxBytes: SEAM_PROPOSAL_ARTIFACT_BYTES,
3718
+ validate: validateSeamProposal,
3719
+ missing: true,
3720
+ });
3721
+ } catch (error) {
3722
+ if (error instanceof TodoStoreError && error.code === 'SEAM_PROPOSAL_ARTIFACT_INVALID') {
3723
+ throw new TodoStoreError(error.code, error.detail.reason, undefined, {
3724
+ ...error.detail,
3725
+ plan_key: member.plan.plan_key,
3726
+ artifact_ref: ref,
3727
+ next_action: 'recompile_seam_proposal_or_remove_stale_record',
3728
+ });
3729
+ }
3730
+ throw error;
3731
+ }
3732
+ }
3733
+
3633
3734
  const WITNESS_SET_BYTES = 4_194_304;
3634
3735
 
3635
3736
  /**