@quolu/lattice 0.34.2 → 0.36.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.
@@ -216,11 +216,19 @@ async function readJournal(repoRoot, journalRef) {
216
216
  const phaseRevisionSchema = events[0].schema === 'lattice.todo_event.v4';
217
217
  const phaseTail = (event) => event.schema === 'lattice.todo_event.v3';
218
218
  const legacyTail = (event) => event.schema === 'lattice.todo_event.v1';
219
+ // ADR 0147: genesisがv1/v2(phase無しplan)でも、暗黙のterminal-audit Phaseへの
220
+ // phase_review/phase_accept/phase_reject/phase_reopenだけはv3 tail eventとして
221
+ // 混在を許す。task側のevent(start/done/block/unblock/reopen)は従来どおりv1のまま
222
+ // ——既存planの既存event bytesは1つも変わらない。新しく増えるのは、これまで
223
+ // phase無しplanには存在し得なかったphase_*event kindの受け皿だけである。
224
+ const implicitTerminalAuditTail = (event) => phaseTail(event)
225
+ && ['phase_review', 'phase_accept', 'phase_reject', 'phase_reopen'].includes(event.kind);
226
+ const legacyOrImplicitPhaseTail = (event) => legacyTail(event) || implicitTerminalAuditTail(event);
219
227
  if ((phaseSchema && events.some(({ schema }, index) => index === 0
220
228
  ? !['lattice.todo_event.v3', 'lattice.todo_event.v4'].includes(schema) : !phaseTail(events[index])))
221
- || (!phaseSchema && events.slice(1).some((event) => !legacyTail(event)))
229
+ || (!phaseSchema && events.slice(1).some((event) => !legacyOrImplicitPhaseTail(event)))
222
230
  || (!phaseSchema && !successorSchema && events.some((event, index) => index === 0
223
- ? event.schema !== 'lattice.todo_event.v1' : !legacyTail(event)))) {
231
+ ? event.schema !== 'lattice.todo_event.v1' : !legacyOrImplicitPhaseTail(event)))) {
224
232
  fail('STORE_CORRUPT', 'journal_schema_sequence_invalid');
225
233
  }
226
234
  return { segments, events, activeBytes };
@@ -236,18 +244,46 @@ function emptyPhaseState(phaseId) {
236
244
  decision_event_digest: null, decision_evidence: null };
237
245
  }
238
246
 
247
+ // phase無しplan(todo_plan.v1/v2/v3)の終端に暗黙で挿入する予約Phase(ADR 0147)。全taskが
248
+ // doneになっても、この暗黙Phaseのaccept(review→evidence束縛accept)が記録されるまでplanを
249
+ // 「閉じた」ことにさせない——既存のPhase gate機構(review/accept/evidence slot/journal event)を
250
+ // そのまま再利用し、新しい状態機械を増やさない(ADR 0147裁定2)。phase_idはtask/plan由来の識別子
251
+ // と衝突しない予約名として固定する。
252
+ export const TERMINAL_AUDIT_PHASE_ID = 'terminal-audit';
253
+
254
+ // CLI側(migrate/plan create/最後のdone)が「終端重監査が要る」ことを通知するために
255
+ // 同じ判定を再利用する。判定基準を二重管理しないよう、ここから公開する。
256
+ export function isPhaselessTodoPlanSchema(schema) {
257
+ return !['lattice.todo_plan.v4', 'lattice.todo_plan.v5'].includes(schema);
258
+ }
259
+
260
+ function terminalAuditPhase() {
261
+ return { phase_id: TERMINAL_AUDIT_PHASE_ID, title: '終端重監査', gate_policy: 'terminal-audit',
262
+ predecessor_phase_ids: [], required_evidence_slots: ['terminal-audit'] };
263
+ }
264
+
265
+ // v4/v5はplan.phasesをそのまま使う。phase無しplan(v1/v2/v3)は暗黙のterminal-audit Phase
266
+ // 1つだけを持つものとして扱う(所属taskは常にそのplanの全task)。derivedPhaseStatus・
267
+ // projectPhaseStates・replayのphase event検証が同じ一覧を見るよう、この関数だけに集約する。
268
+ function phasesOf(plan) {
269
+ return isPhaselessTodoPlanSchema(plan.schema) ? [terminalAuditPhase()] : plan.phases;
270
+ }
271
+
239
272
  function derivedPhaseStatus(plan, taskStates, phaseStates, phaseId) {
240
273
  const state = phaseStates.get(phaseId);
241
274
  if (['reviewing', 'accepted', 'rejected'].includes(state.status)) return state.status;
242
- const phase = plan.phases.find((entry) => entry.phase_id === phaseId);
275
+ const phase = phasesOf(plan).find((entry) => entry.phase_id === phaseId);
243
276
  if (!phase.predecessor_phase_ids.every((id) => phaseStates.get(id)?.status === 'accepted')) return 'locked';
244
- const tasks = plan.tasks.filter((entry) => entry.phase_id === phaseId);
277
+ // 暗黙のterminal-audit Phaseはtask側にphase_idフィールドが無い(v1/v2/v3にはそもそも
278
+ // 存在しない)ため、所属taskをフィルタで絞らずplan全taskとして扱う。
279
+ const tasks = phaseId === TERMINAL_AUDIT_PHASE_ID && isPhaselessTodoPlanSchema(plan.schema)
280
+ ? plan.tasks : plan.tasks.filter((entry) => entry.phase_id === phaseId);
245
281
  return tasks.every((entry) => taskStates.get(entry.task_id)?.status === 'done') ? 'gate_ready' : 'active';
246
282
  }
247
283
 
248
284
  function projectPhaseStates(plan, events, taskStates) {
249
- if (!['lattice.todo_plan.v4', 'lattice.todo_plan.v5'].includes(plan.schema)) return [];
250
- const states = new Map(plan.phases.map(({ phase_id }) => [phase_id, emptyPhaseState(phase_id)]));
285
+ const phases = phasesOf(plan);
286
+ const states = new Map(phases.map(({ phase_id }) => [phase_id, emptyPhaseState(phase_id)]));
251
287
  for (const event of events) {
252
288
  if (event.schema === 'lattice.todo_event.v4') {
253
289
  for (const migration of event.phase_state_migration) {
@@ -271,7 +307,7 @@ function projectPhaseStates(plan, events, taskStates) {
271
307
  Object.assign(states.get(event.phase_id), emptyPhaseState(event.phase_id));
272
308
  }
273
309
  }
274
- for (const phase of plan.phases) {
310
+ for (const phase of phases) {
275
311
  const state = states.get(phase.phase_id);
276
312
  state.status = derivedPhaseStatus(plan, taskStates, states, phase.phase_id);
277
313
  }
@@ -298,7 +334,7 @@ function localSuccessors(plan, taskId) {
298
334
 
299
335
  function replay(plan, events, { now = new Date(), verifyEvidence, verifyImportSource } = {}) {
300
336
  const states = new Map(plan.tasks.map(({ task_id }) => [task_id, taskState(task_id)]));
301
- const phaseStates = new Map((plan.phases ?? []).map(({ phase_id }) => [phase_id, emptyPhaseState(phase_id)]));
337
+ const phaseStates = new Map(phasesOf(plan).map(({ phase_id }) => [phase_id, emptyPhaseState(phase_id)]));
302
338
  const doneDigest = new Map();
303
339
  const completion = new Map();
304
340
  const importedGenesis = events[0]?.payload.historical_import === true;
@@ -331,7 +367,9 @@ function replay(plan, events, { now = new Date(), verifyEvidence, verifyImportSo
331
367
  fail('STORE_INCONSISTENT', 'genesis_migration_target_invalid');
332
368
  }
333
369
  for (const migration of event.state_migration) {
334
- if (!['carry', 'carry_reconciled_metadata'].includes(migration.state_policy)) continue;
370
+ // acquire_phaseもcarry(状態を完全に持ち越す)。ADR 0147裁定4のPhase獲得はdoneを
371
+ // 保つことが目的であり、ここで除外するとreplayが状態を復元しない。
372
+ if (!['carry', 'carry_reconciled_metadata', 'acquire_phase'].includes(migration.state_policy)) continue;
335
373
  const state = states.get(migration.to_task_id);
336
374
  Object.assign(state, structuredClone(migration.state), { evidence_unverified: false });
337
375
  if (state.evidence !== null) {
@@ -357,7 +395,11 @@ function replay(plan, events, { now = new Date(), verifyEvidence, verifyImportSo
357
395
  continue;
358
396
  }
359
397
  if (event.kind.startsWith('phase_')) {
360
- if (!['lattice.todo_plan.v4', 'lattice.todo_plan.v5'].includes(plan.schema) || !phaseStates.has(event.phase_id)) {
398
+ // phaseStatesはphasesOf(plan)から作られる(v4/v5なら実Phase、それ以外なら暗黙の
399
+ // terminal-audit Phaseだけ)。`has`判定だけで両方の場合を賄えるので、schemaでの
400
+ // 事前分岐は不要——phase無しplanは予約phase_id以外を宣言していないため、任意の
401
+ // phase_idを名乗るevent_phase_missingでの拒否は従来どおり効く。
402
+ if (!phaseStates.has(event.phase_id)) {
361
403
  fail('STORE_INCONSISTENT', 'event_phase_missing');
362
404
  }
363
405
  const state = phaseStates.get(event.phase_id);
@@ -367,7 +409,7 @@ function replay(plan, events, { now = new Date(), verifyEvidence, verifyImportSo
367
409
  state.status = 'reviewing'; state.review_event_digest = event.event_digest;
368
410
  state.decision_event_digest = null; state.decision_evidence = null;
369
411
  } else if (event.kind === 'phase_accept') {
370
- const phase = plan.phases.find(({ phase_id }) => phase_id === event.phase_id);
412
+ const phase = phasesOf(plan).find(({ phase_id }) => phase_id === event.phase_id);
371
413
  const slots = event.payload.evidence_slots.map(({ slot_id }) => slot_id);
372
414
  if (currentStatus !== 'reviewing' || state.review_event_digest !== event.payload.review_event_digest
373
415
  || canonicalizeTodoArtifact(slots) !== canonicalizeTodoArtifact(phase.required_evidence_slots)) {
@@ -391,17 +433,21 @@ function replay(plan, events, { now = new Date(), verifyEvidence, verifyImportSo
391
433
  || state.decision_event_digest !== event.payload.target_decision_digest) {
392
434
  fail('STORE_INCONSISTENT', 'phase_reopen_binding_invalid');
393
435
  }
394
- const successorStarted = currentStatus === 'accepted' && (plan.schema === 'lattice.todo_plan.v4'
395
- ? plan.phases.some((phase) => phase.predecessor_phase_ids.includes(event.phase_id)
396
- && (phaseStates.get(phase.phase_id).status !== 'locked'
397
- || plan.tasks.some((task) => task.phase_id === phase.phase_id
398
- && states.get(task.task_id).status !== 'pending')))
399
- : plan.phases.some((phase) => phase.predecessor_phase_ids.includes(event.phase_id)
400
- && phaseStates.get(phase.phase_id).status !== 'locked')
401
- || plan.phase_accept_dependencies.some((edge) => edge.from.project_id === plan.project_id
402
- && edge.from.plan_key === plan.plan_key && edge.from.phase_id === event.phase_id
403
- && edge.to.project_id === plan.project_id && edge.to.plan_key === plan.plan_key
404
- && states.get(edge.to.task_id)?.status !== 'pending'));
436
+ // phase無しplanの暗黙Phaseは唯一のPhaseであり、他Phaseの前提にも
437
+ // phase_accept_dependenciesにもなり得ない(v1/v2/v3にはそのフィールド自体が無い)ため、
438
+ // successorStartedは常にfalseでよい。v4/v5の既存判定には一切手を入れない。
439
+ const successorStarted = currentStatus === 'accepted' && !isPhaselessTodoPlanSchema(plan.schema)
440
+ && (plan.schema === 'lattice.todo_plan.v4'
441
+ ? plan.phases.some((phase) => phase.predecessor_phase_ids.includes(event.phase_id)
442
+ && (phaseStates.get(phase.phase_id).status !== 'locked'
443
+ || plan.tasks.some((task) => task.phase_id === phase.phase_id
444
+ && states.get(task.task_id).status !== 'pending')))
445
+ : plan.phases.some((phase) => phase.predecessor_phase_ids.includes(event.phase_id)
446
+ && phaseStates.get(phase.phase_id).status !== 'locked')
447
+ || plan.phase_accept_dependencies.some((edge) => edge.from.project_id === plan.project_id
448
+ && edge.from.plan_key === plan.plan_key && edge.from.phase_id === event.phase_id
449
+ && edge.to.project_id === plan.project_id && edge.to.plan_key === plan.plan_key
450
+ && states.get(edge.to.task_id)?.status !== 'pending'));
405
451
  if (successorStarted && event.payload.override_reason === null) {
406
452
  fail('STORE_INCONSISTENT', 'phase_reopen_has_started_successor');
407
453
  }
@@ -469,8 +515,14 @@ function replay(plan, events, { now = new Date(), verifyEvidence, verifyImportSo
469
515
  if (state.status !== 'done' || doneDigest.get(event.task_id) !== event.payload.target_done_digest) {
470
516
  fail('STORE_INCONSISTENT', 'invalid_reopen_binding');
471
517
  }
472
- if (['lattice.todo_plan.v4', 'lattice.todo_plan.v5'].includes(plan.schema)) {
473
- const phaseId = plan.tasks.find(({ task_id }) => task_id === event.task_id).phase_id;
518
+ {
519
+ // 暗黙のterminal-audit Phaseがacceptedの後にtaskだけを無警告でreopenできると、
520
+ // 監査済みのまま(gantt上も畳まれたまま)裏で作業が再開する抜け道になり、ADR 0147の
521
+ // 「監査の記録なしに閉じたことにさせない」を潜脱する。v4/v5の既存Phaseと同じ規律を
522
+ // 暗黙Phaseにも及ぼし、phase_reopenを先に通させる。
523
+ const phaseId = isPhaselessTodoPlanSchema(plan.schema)
524
+ ? TERMINAL_AUDIT_PHASE_ID
525
+ : plan.tasks.find(({ task_id }) => task_id === event.task_id).phase_id;
474
526
  if (derivedPhaseStatus(plan, states, phaseStates, phaseId) === 'accepted') {
475
527
  fail('STORE_INCONSISTENT', 'task_reopen_requires_phase_reopen');
476
528
  }
@@ -486,6 +538,11 @@ function replay(plan, events, { now = new Date(), verifyEvidence, verifyImportSo
486
538
 
487
539
  function snapshotFor(plan, events, tasks) {
488
540
  const head = events.at(-1);
541
+ // snapshot artifactの形式は変えない(store canonical形式の非目標)。既存on-disk snapshot
542
+ // (phase無しplanはv1・`phases`キー無し)との canonical比較がここで外れると、全既存storeが
543
+ // snapshot_staleになりforWrite(start/done/revise等)が丸ごとSTORE_WRITE_REFUSEDへ落ちる
544
+ // ——ADR 0147以降の暗黙terminal-audit Phaseの状態は、この関数の外(readTodoStore/
545
+ // appendTodoEventが返す`phases`という導出ビュー)で供給する。
489
546
  const phasePlan = ['lattice.todo_plan.v4', 'lattice.todo_plan.v5'].includes(plan.schema);
490
547
  const snapshot = {
491
548
  schema: phasePlan ? 'lattice.todo_snapshot.v2' : 'lattice.todo_snapshot.v1',
@@ -1003,8 +1060,12 @@ export async function readTodoStore(options = {}) {
1003
1060
  else throw error;
1004
1061
  }
1005
1062
  if (options.forWrite === true && snapshotStale) fail('STORE_WRITE_REFUSED', 'snapshot_stale');
1063
+ // 導出済みphase状態(ADR 0147)。snapshot artifactの形式(v1/v2)には縛られず、v4/v5・
1064
+ // phase無しplanのどちらでも同じ形(暗黙のterminal-audit Phase込み)で常に埋める。
1065
+ // 消費者はここを読み、snapshot.phases(v1には存在しない)を直接読まない。
1066
+ const phases = projectPhaseStates(plan, journal.events, new Map(tasks.map((task) => [task.task_id, task])));
1006
1067
  loaded.push({ descriptor, plan, revision, journal, snapshot: snapshotStale ? expectedSnapshot : snapshot,
1007
- tasks, snapshot_stale: snapshotStale });
1068
+ tasks, phases, snapshot_stale: snapshotStale });
1008
1069
  }
1009
1070
  validateMergedGraph(loaded);
1010
1071
  return {
@@ -1112,14 +1173,20 @@ function nextEvent(input, storeMember) {
1112
1173
  const payload = input.kind === 'done' && exactRecord(input.payload, ['evidence'])
1113
1174
  ? { done_mode: 'authored', imported: false, evidence: input.payload.evidence }
1114
1175
  : input.payload;
1176
+ const phaseCapablePlan = ['lattice.todo_plan.v4', 'lattice.todo_plan.v5'].includes(storeMember.plan.schema);
1177
+ const phaseKind = ['phase_review', 'phase_accept', 'phase_reject', 'phase_reopen'].includes(input.kind);
1178
+ // ADR 0147: phase無しplanでも、暗黙のterminal-audit Phaseへのphase_*eventだけは
1179
+ // v3 tail eventとして書く(readJournalのlegacyOrImplicitPhaseTailが同じ規則で受理する)。
1180
+ // task側のevent(start/done等)はphase無しplanのままv1で書き続ける——既存の
1181
+ // canonical形式を変えるのはphase_*eventの新設分だけに限る。
1182
+ const usesPhaseEventShape = phaseCapablePlan || phaseKind;
1115
1183
  const event = {
1116
- schema: ['lattice.todo_plan.v4', 'lattice.todo_plan.v5'].includes(storeMember.plan.schema)
1117
- ? 'lattice.todo_event.v3' : 'lattice.todo_event.v1', project_id: storeMember.plan.project_id,
1184
+ schema: usesPhaseEventShape ? 'lattice.todo_event.v3' : 'lattice.todo_event.v1',
1185
+ project_id: storeMember.plan.project_id,
1118
1186
  plan_key: storeMember.plan.plan_key, plan_version: storeMember.plan.plan_version,
1119
1187
  sequence: previous.sequence + 1, previous_digest: previous.event_digest,
1120
1188
  kind: input.kind, task_id: input.task_id ?? null,
1121
- ...(['lattice.todo_plan.v4', 'lattice.todo_plan.v5'].includes(storeMember.plan.schema)
1122
- ? { phase_id: input.phase_id ?? null } : {}),
1189
+ ...(usesPhaseEventShape ? { phase_id: input.phase_id ?? null } : {}),
1123
1190
  actor: input.actor, recorded_at: input.recorded_at,
1124
1191
  provenance: input.provenance ?? null, payload, event_digest: '',
1125
1192
  };
@@ -1148,7 +1215,7 @@ function resolveDoneBindingDigest(storeMember, taskId) {
1148
1215
  const genesis = events[0];
1149
1216
  if (genesis?.kind !== 'plan_genesis' || !Array.isArray(genesis.state_migration)) return null;
1150
1217
  const carried = genesis.state_migration.some((migration) => (
1151
- ['carry', 'carry_reconciled_metadata'].includes(migration.state_policy)
1218
+ ['carry', 'carry_reconciled_metadata', 'acquire_phase'].includes(migration.state_policy)
1152
1219
  && migration.to_task_id === taskId && migration.state?.status === 'done'
1153
1220
  ));
1154
1221
  return carried ? genesis.event_digest : null;
@@ -1252,7 +1319,12 @@ export async function appendTodoEvent(options = {}) {
1252
1319
  member.descriptor.journal_head_digest = event.event_digest;
1253
1320
  store.manifest.manifest_digest = todoSelfDigest(store.manifest, 'manifest_digest');
1254
1321
  await atomicWrite(path.resolve(repoRoot, MANIFEST_REF), canonicalLine(store.manifest));
1255
- return { event, snapshot };
1322
+ // snapshot artifactの形式(v1/v2)はplanのschemaで決まったまま変えない。呼び出し側
1323
+ // (todo-cli.mjsのphaseMutation・done advisory)がPhase状態(暗黙のterminal-audit含む)を
1324
+ // 見るための導出ビューは、snapshotと分けてここで別途返す。
1325
+ const phases = projectPhaseStates(member.plan, [...member.journal.events, event],
1326
+ new Map(tasks.map((task) => [task.task_id, task])));
1327
+ return { event, snapshot, plan: member.plan, phases };
1256
1328
  });
1257
1329
  }
1258
1330
 
@@ -1894,6 +1966,42 @@ function validatePhaseV3Carry(previous, revision, migration, idMap, state) {
1894
1966
  }
1895
1967
  }
1896
1968
 
1969
+ /**
1970
+ * acquire_phase専用のcarry検証(ADR 0147裁定4)。既存carry/carry_reconciled_metadataの
1971
+ * 分岐(validatePhaseV3Carry)には一切手を入れず、「Phase割当ての獲得だけ」を許す別分岐として
1972
+ * 独立に置く——同じcarry比較へphase_idだけ除外する特例を混ぜると、以後の意味論比較すべてに
1973
+ * 例外条件が混入する経路を開いてしまう(裁定4が名指しで禁じる形)。
1974
+ *
1975
+ * 獲得の定義: predecessor側がphase無し(phase_idがnullに正規化される)→successor側がphase有り、
1976
+ * という向きだけを許す。既にphaseを持つtaskの付け替えは意味論の変更であり、acquire_phaseでは
1977
+ * 許さない(carryへ回して従来どおりcarry_semantics_changedで拒否させる)。phase_id以外の
1978
+ * 属性(title/lane/narrative_ref/narrative_anchor/compile_binding/parent_task_id)と
1979
+ * 依存辺(incoming)は、phaseV3CarrySemanticsのtaskからphase_idだけを取り除いた上で
1980
+ * carryと同じ完全一致を要求する。outgoingの扱い(successor側が上位集合であること)もcarryと同じ。
1981
+ */
1982
+ function validateAcquirePhaseCarry(previous, revision, migration, idMap) {
1983
+ const phaseIdMap = new Map(revision.phase_migration
1984
+ .filter(({ from_phase_id, to_phase_id }) => from_phase_id !== null && to_phase_id !== 'removed')
1985
+ .map(({ from_phase_id, to_phase_id }) => [from_phase_id, to_phase_id]));
1986
+ const before = phaseV3CarrySemantics(previous.plan, migration.from_task_id, idMap, phaseIdMap);
1987
+ const after = phaseV3CarrySemantics(revision.desired_plan, migration.to_task_id, new Map(), new Map());
1988
+ if (before.task.phase_id !== null) {
1989
+ fail('REVISION_INVALID', 'acquire_phase_requires_unassigned_predecessor', { from_task_id: migration.from_task_id });
1990
+ }
1991
+ if (after.task.phase_id === null) {
1992
+ fail('REVISION_INVALID', 'acquire_phase_requires_assigned_successor', { from_task_id: migration.from_task_id });
1993
+ }
1994
+ const stripPhase = ({ phase_id, ...rest }) => rest;
1995
+ if (canonicalizeTodoArtifact(stripPhase(before.task)) !== canonicalizeTodoArtifact(stripPhase(after.task))
1996
+ || canonicalizeTodoArtifact(before.incoming) !== canonicalizeTodoArtifact(after.incoming)) {
1997
+ fail('REVISION_INVALID', 'carry_semantics_changed', { from_task_id: migration.from_task_id });
1998
+ }
1999
+ const successorOutgoing = new Set(after.outgoing);
2000
+ if (!before.outgoing.every((edge) => successorOutgoing.has(edge))) {
2001
+ fail('REVISION_INVALID', 'carry_outgoing_edge_removed', { from_task_id: migration.from_task_id });
2002
+ }
2003
+ }
2004
+
1897
2005
  function stateMigrationFor(previous, revision) {
1898
2006
  const oldIds = previous.plan.tasks.map(({ task_id }) => task_id);
1899
2007
  const migrationIds = revision.task_migration.map(({ from_task_id }) => from_task_id);
@@ -1905,12 +2013,26 @@ function stateMigrationFor(previous, revision) {
1905
2013
  .map(({ from_task_id, to_task_id }) => [from_task_id, to_task_id]));
1906
2014
  const states = new Map(previous.tasks.map((state) => [state.task_id, state]));
1907
2015
  return revision.task_migration.map((migration) => {
1908
- const carriesState = ['carry', 'carry_reconciled_metadata'].includes(migration.state_policy);
2016
+ const carriesState = ['carry', 'carry_reconciled_metadata', 'acquire_phase'].includes(migration.state_policy);
1909
2017
  if (!carriesState) return { ...migration, state: null };
1910
2018
  const reconciliationMetadata = migration.state_policy === 'carry_reconciled_metadata';
1911
2019
  const state = states.get(migration.from_task_id);
1912
2020
  if (!state) fail('STORE_INCONSISTENT', 'predecessor_task_state_missing');
1913
- if (revision.schema === 'lattice.phase_todo_revision.v3') {
2021
+ if (migration.state_policy === 'acquire_phase') {
2022
+ // acquire_phaseはv3 phase revision専用の獲得判定を持つ。v3以外(v1/v2 phase revisionや
2023
+ // 平文todo_revision)ではtaskSemantics自体がphase_idを比較対象へ含めないため、carryと
2024
+ // 同じ比較で意味論は保たれる——ここを別ロジックにする理由が無い(既知のv1/v2の緩さは
2025
+ // ADR 0147の対象外であり、緩めも締めもしない)。
2026
+ if (revision.schema === 'lattice.phase_todo_revision.v3') {
2027
+ validateAcquirePhaseCarry(previous, revision, migration, idMap);
2028
+ } else {
2029
+ const before = taskSemantics(previous.plan, migration.from_task_id, idMap, {});
2030
+ const after = taskSemantics(revision.desired_plan, migration.to_task_id, new Map(), {});
2031
+ if (canonicalizeTodoArtifact(before) !== canonicalizeTodoArtifact(after)) {
2032
+ fail('REVISION_INVALID', 'carry_semantics_changed', { from_task_id: migration.from_task_id });
2033
+ }
2034
+ }
2035
+ } else if (revision.schema === 'lattice.phase_todo_revision.v3') {
1914
2036
  validatePhaseV3Carry(previous, revision, migration, idMap, state);
1915
2037
  } else {
1916
2038
  const before = taskSemantics(previous.plan, migration.from_task_id, idMap,