@quolu/lattice 0.78.1 → 0.79.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/src/todo-cli.mjs CHANGED
@@ -1,5 +1,6 @@
1
1
  import { cliCommandKind } from './cli-command-kind.mjs';
2
2
  import { cliFailureClass } from './cli-failure-class.mjs';
3
+ import { readTaskBackchainContext } from './plan-backchain.mjs';
3
4
  import { gitSync } from './git-process.mjs';
4
5
  import { createHash } from 'node:crypto';
5
6
  import {
@@ -636,7 +637,8 @@ function terminalAuditDoneAdvisory(plan, phases) {
636
637
 
637
638
  async function mutate({
638
639
  repoRoot, env, planKey, taskId, kind, payload, evidenceRef, evidenceMessage = null,
639
- advisory = null, noteContext = null, structureContext = null, testResultRef = null,
640
+ advisory = null, noteContext = null, structureContext = null, featureContext = null,
641
+ testResultRef = null,
640
642
  }) {
641
643
  const actor = mutationActor(env);
642
644
  const evidence = evidenceRef === null && evidenceMessage === null
@@ -683,7 +685,9 @@ async function mutate({
683
685
  throw new TypeError('start mutation requires note context');
684
686
  }
685
687
  const result = {
686
- schema: includesNoteContext ? 'lattice.todo_mutation_result.v5' : 'lattice.todo_mutation_result.v2',
688
+ schema: includesNoteContext
689
+ ? featureContext === null ? 'lattice.todo_mutation_result.v5' : 'lattice.todo_mutation_result.v6'
690
+ : 'lattice.todo_mutation_result.v2',
687
691
  project_id: event.project_id,
688
692
  plan_key: event.plan_key,
689
693
  plan_version: event.plan_version,
@@ -699,6 +703,7 @@ async function mutate({
699
703
  design_memo: designMemoProjection(authoredTask),
700
704
  note_context: noteContext,
701
705
  structure_context: structureContext,
706
+ ...(featureContext === null ? {} : { feature_context: featureContext }),
702
707
  } : {}),
703
708
  result_digest: '',
704
709
  };
@@ -906,9 +911,12 @@ async function startTask({
906
911
  const structureContext = await startStructureContext({
907
912
  repoRoot, store, planKey, taskId: resolvedTaskId,
908
913
  });
914
+ const featureContext = await readTaskBackchainContext({
915
+ repoRoot, plan: member.plan, taskId: resolvedTaskId,
916
+ });
909
917
  return mutate({ repoRoot, env, planKey, taskId: resolvedTaskId, kind: 'start',
910
918
  payload: { override_reason: overrideReason }, evidenceRef: null, advisory, noteContext,
911
- structureContext });
919
+ structureContext, featureContext });
912
920
  }
913
921
 
914
922
  // plan改訂でcarryされたin-progress ToDoの再束縛入口(ADR 0191)。task状態・開始時刻は
@@ -916,7 +924,7 @@ async function startTask({
916
924
  // literal startだけを束縛に使いcarryを推定しないため、改訂を跨いだ進行中工程の
917
925
  // 再開はこの入口だけが正規経路になる。advisory・independence gateは初回startで
918
926
  // 済んでいるため再適用しない。
919
- async function rebindStart({ repoRoot, env, planKey, taskId, reason }) {
927
+ export async function rebindStart({ repoRoot, env, planKey, taskId, reason }) {
920
928
  const store = await readTodoStore({ repoRoot });
921
929
  const member = store.members.find(({ descriptor }) => descriptor.plan_key === planKey);
922
930
  if (member === undefined) throw new TodoStoreError('STORE_INCONSISTENT', 'plan_not_active');
@@ -1816,8 +1824,11 @@ async function todoDetail({ repoRoot, planKey, taskId }) {
1816
1824
  const { context } = await readTodoNoteContext({
1817
1825
  repoRoot, store, planKey, taskId: task.task_id,
1818
1826
  });
1827
+ const featureContext = await readTaskBackchainContext({
1828
+ repoRoot, plan: member.plan, taskId: task.task_id,
1829
+ });
1819
1830
  const result = {
1820
- schema: 'lattice.todo_detail_result.v3',
1831
+ schema: featureContext === null ? 'lattice.todo_detail_result.v3' : 'lattice.todo_detail_result.v4',
1821
1832
  project_id: store.project_id,
1822
1833
  plan_key: planKey,
1823
1834
  plan_version: member.plan.plan_version,
@@ -1825,6 +1836,7 @@ async function todoDetail({ repoRoot, planKey, taskId }) {
1825
1836
  design_memo: designMemoProjection(task),
1826
1837
  state,
1827
1838
  note_context: context,
1839
+ ...(featureContext === null ? {} : { feature_context: featureContext }),
1828
1840
  result_digest: '',
1829
1841
  };
1830
1842
  result.result_digest = todoSelfDigest(result, 'result_digest');
@@ -3658,6 +3670,7 @@ async function verify({ repoRoot, requestedPlanKey }) {
3658
3670
  case 'lattice.phase_todo_revision.v1':
3659
3671
  case 'lattice.phase_todo_revision.v2':
3660
3672
  case 'lattice.phase_todo_revision.v3':
3673
+ case 'lattice.phase_todo_revision.v4':
3661
3674
  verifiedSourceInventories.set(member.descriptor.plan_key,
3662
3675
  await verifyEffectivePhaseTodoRevisionSources({ repoRoot, member }));
3663
3676
  break;
@@ -3670,7 +3683,7 @@ async function verify({ repoRoot, requestedPlanKey }) {
3670
3683
  const reconciled = member.revision !== null;
3671
3684
  const sourceInventory = verifiedSourceInventories.get(member.descriptor.plan_key) ?? null;
3672
3685
  const phaseRevision = ['lattice.phase_todo_revision.v1', 'lattice.phase_todo_revision.v2',
3673
- 'lattice.phase_todo_revision.v3']
3686
+ 'lattice.phase_todo_revision.v3', 'lattice.phase_todo_revision.v4']
3674
3687
  .includes(member.revision?.schema);
3675
3688
  return {
3676
3689
  plan_key: member.descriptor.plan_key,
@@ -23,6 +23,7 @@ const PHASE_REVISION_V3_KEYS = [
23
23
  'runtime_task_migration', 'task_migration', 'phase_migration', 'source_inventory',
24
24
  'reconciliation', 'source_cutover_batch', 'revision_digest',
25
25
  ];
26
+ const PHASE_REVISION_V4_KEYS = [...PHASE_REVISION_KEYS, 'backchain'];
26
27
 
27
28
  const compareText = (left, right) => left < right ? -1 : left > right ? 1 : 0;
28
29
  const boundedText = (value) => typeof value === 'string' && value.length > 0
@@ -64,9 +65,11 @@ export function todoRevisionPlanVersion({
64
65
 
65
66
  export function phaseTodoRevisionPlanVersion({
66
67
  projectId, planKey, predecessor, desiredPlan, taskMigration, phaseMigration,
68
+ featureGraphDigest = null,
67
69
  }) {
68
70
  const versionDigest = digestTodoArtifact({
69
- schema: ['lattice.todo_plan.v5', 'lattice.todo_plan.v7'].includes(desiredPlan.schema)
71
+ schema: featureGraphDigest !== null ? 'lattice.phase_todo_revision_version.v3'
72
+ : ['lattice.todo_plan.v5', 'lattice.todo_plan.v7'].includes(desiredPlan.schema)
70
73
  ? 'lattice.phase_todo_revision_version.v2' : 'lattice.phase_todo_revision_version.v1', project_id: projectId,
71
74
  plan_key: planKey, predecessor, desired_topology: {
72
75
  schema: desiredPlan.schema, project_id: desiredPlan.project_id,
@@ -76,6 +79,7 @@ export function phaseTodoRevisionPlanVersion({
76
79
  ...(['lattice.todo_plan.v5', 'lattice.todo_plan.v7'].includes(desiredPlan.schema)
77
80
  ? { phase_accept_dependencies: desiredPlan.phase_accept_dependencies } : {}),
78
81
  }, task_migration: taskMigration, phase_migration: phaseMigration,
82
+ ...(featureGraphDigest === null ? {} : { feature_graph_digest: featureGraphDigest }),
79
83
  });
80
84
  return `rev-${versionDigest.slice(0, 24)}`;
81
85
  }
@@ -291,22 +295,51 @@ export function validatePhaseTodoRevision(value) {
291
295
  const revisionV1 = value?.schema === 'lattice.phase_todo_revision.v1';
292
296
  const revisionV2 = value?.schema === 'lattice.phase_todo_revision.v2';
293
297
  const revisionV3 = value?.schema === 'lattice.phase_todo_revision.v3';
294
- const keys = revisionV3 ? PHASE_REVISION_V3_KEYS : PHASE_REVISION_KEYS;
295
- if (!exactRecord(value, keys) || (!revisionV1 && !revisionV2 && !revisionV3)) return false;
298
+ const revisionV4 = value?.schema === 'lattice.phase_todo_revision.v4';
299
+ const keys = revisionV3 ? PHASE_REVISION_V3_KEYS
300
+ : revisionV4 ? PHASE_REVISION_V4_KEYS : PHASE_REVISION_KEYS;
301
+ if (!exactRecord(value, keys) || (!revisionV1 && !revisionV2 && !revisionV3 && !revisionV4)) return false;
296
302
  if (!isTodoIdentifier(value.project_id) || !isTodoIdentifier(value.plan_key)) return false;
297
303
  if (!validPredecessor(value.predecessor) || !validateTodoPlan(value.desired_plan)
298
304
  || !(revisionV3
299
305
  ? ['lattice.todo_plan.v5', 'lattice.todo_plan.v7'].includes(value.desired_plan.schema)
300
- : value.desired_plan.schema === (revisionV2 ? 'lattice.todo_plan.v5' : 'lattice.todo_plan.v4'))
306
+ : value.desired_plan.schema === (revisionV2 ? 'lattice.todo_plan.v5'
307
+ : revisionV4 ? 'lattice.todo_plan.v7' : 'lattice.todo_plan.v4'))
301
308
  || value.desired_plan.project_id !== value.project_id
302
309
  || value.desired_plan.plan_key !== value.plan_key
303
310
  || value.desired_plan.predecessor_plan_digest !== value.predecessor.plan_digest
304
311
  || !validTaskMigration(value.task_migration)
305
312
  || !validPhaseMigration(value.phase_migration, value.desired_plan)) return false;
313
+ const backchainKeys = [
314
+ 'schema', 'authoring_ref', 'project_id', 'plan_key', 'plan_version',
315
+ 'goal', 'features', 'tasks', 'chain_digest',
316
+ ];
317
+ if (revisionV4 && (!(exactRecord(value.backchain, backchainKeys)
318
+ || exactRecord(value.backchain, [...backchainKeys, 'continuity']))
319
+ || value.backchain.schema !== 'lattice.plan_backchain.v1'
320
+ || value.backchain.project_id !== value.project_id
321
+ || value.backchain.plan_key !== value.plan_key
322
+ || value.backchain.plan_version !== value.desired_plan.plan_version
323
+ || (value.backchain.continuity !== undefined
324
+ && (!Array.isArray(value.backchain.continuity)
325
+ || value.backchain.continuity.length > 512
326
+ || !value.backchain.continuity.every((entry) => exactRecord(entry,
327
+ ['task_id', 'policy', 'reason']) && isTodoIdentifier(entry.task_id)
328
+ && ['carry', 'reset', 'retire'].includes(entry.policy)
329
+ && typeof entry.reason === 'string' && entry.reason.trim().length > 0)
330
+ || new Set(value.backchain.continuity.map(({ task_id: id }) => id)).size
331
+ !== value.backchain.continuity.length))
332
+ || value.backchain.chain_digest !== todoSelfDigest(value.backchain, 'chain_digest')
333
+ || canonicalizeForCompare(value.backchain.tasks.map(({ task_id: id }) => id))
334
+ !== canonicalizeForCompare(value.desired_plan.tasks.map(({ task_id: id }) => id)))) return false;
306
335
  if (value.desired_plan.plan_version !== phaseTodoRevisionPlanVersion({
307
336
  projectId: value.project_id, planKey: value.plan_key, predecessor: value.predecessor,
308
337
  desiredPlan: value.desired_plan, taskMigration: value.task_migration,
309
338
  phaseMigration: value.phase_migration,
339
+ ...(revisionV4 ? { featureGraphDigest: digestTodoArtifact({
340
+ goal: value.backchain.goal, features: value.backchain.features,
341
+ tasks: value.backchain.tasks,
342
+ }) } : {}),
310
343
  })) return false;
311
344
  if (revisionV3) {
312
345
  if (!validRuntimeTaskMigration(value.runtime_task_migration)
@@ -810,8 +843,10 @@ export function explainPhaseTodoRevision(value) {
810
843
  const revisionV1 = value.schema === 'lattice.phase_todo_revision.v1';
811
844
  const revisionV2 = value.schema === 'lattice.phase_todo_revision.v2';
812
845
  const revisionV3 = value.schema === 'lattice.phase_todo_revision.v3';
813
- if (!revisionV1 && !revisionV2 && !revisionV3) return reject('schema_mismatch', '/schema');
814
- const keyCheck = explainKeys(value, revisionV3 ? PHASE_REVISION_V3_KEYS : PHASE_REVISION_KEYS, '');
846
+ const revisionV4 = value.schema === 'lattice.phase_todo_revision.v4';
847
+ if (!revisionV1 && !revisionV2 && !revisionV3 && !revisionV4) return reject('schema_mismatch', '/schema');
848
+ const keyCheck = explainKeys(value, revisionV3 ? PHASE_REVISION_V3_KEYS
849
+ : revisionV4 ? PHASE_REVISION_V4_KEYS : PHASE_REVISION_KEYS, '');
815
850
  if (!keyCheck.valid) return keyCheck;
816
851
  if (!isTodoIdentifier(value.project_id)) return reject('invalid_identifier', '/project_id');
817
852
  if (!isTodoIdentifier(value.plan_key)) return reject('invalid_identifier', '/plan_key');
@@ -819,7 +854,8 @@ export function explainPhaseTodoRevision(value) {
819
854
  if (!predecessorCheck.valid) return predecessorCheck;
820
855
  if (!validateTodoPlan(value.desired_plan)) return reject('desired_plan_invalid', '/desired_plan');
821
856
  const expectedPlanSchemas = revisionV3 ? ['lattice.todo_plan.v5', 'lattice.todo_plan.v7']
822
- : [revisionV2 ? 'lattice.todo_plan.v5' : 'lattice.todo_plan.v4'];
857
+ : [revisionV2 ? 'lattice.todo_plan.v5'
858
+ : revisionV4 ? 'lattice.todo_plan.v7' : 'lattice.todo_plan.v4'];
823
859
  if (!expectedPlanSchemas.includes(value.desired_plan.schema)) {
824
860
  return reject('desired_plan_schema_mismatch', '/desired_plan/schema');
825
861
  }
@@ -840,6 +876,10 @@ export function explainPhaseTodoRevision(value) {
840
876
  projectId: value.project_id, planKey: value.plan_key, predecessor: value.predecessor,
841
877
  desiredPlan: value.desired_plan, taskMigration: value.task_migration,
842
878
  phaseMigration: value.phase_migration,
879
+ ...(revisionV4 ? { featureGraphDigest: digestTodoArtifact({
880
+ goal: value.backchain.goal, features: value.backchain.features,
881
+ tasks: value.backchain.tasks,
882
+ }) } : {}),
843
883
  });
844
884
  if (value.desired_plan.plan_version !== expectedPlanVersion) {
845
885
  return reject('plan_version_mismatch', '/desired_plan/plan_version');
@@ -69,7 +69,7 @@ const TODO_REVISION_SCHEMAS = Object.freeze([
69
69
  ]);
70
70
  const PHASE_TODO_REVISION_SCHEMAS = Object.freeze([
71
71
  'lattice.phase_todo_revision.v1', 'lattice.phase_todo_revision.v2',
72
- 'lattice.phase_todo_revision.v3',
72
+ 'lattice.phase_todo_revision.v3', 'lattice.phase_todo_revision.v4',
73
73
  ]);
74
74
 
75
75
  export class TodoStoreError extends Error {
@@ -2207,6 +2207,26 @@ export function buildTodoPlan(input) {
2207
2207
  return plan;
2208
2208
  }
2209
2209
 
2210
+ function boundPlanBackchain(plan, chain) {
2211
+ if (chain === null || chain === undefined) return null;
2212
+ if (chain.schema !== 'lattice.plan_backchain.v1'
2213
+ || chain.project_id !== plan.project_id || chain.plan_key !== plan.plan_key
2214
+ || chain.plan_version !== plan.plan_version
2215
+ || !isTodoDigest(chain.chain_digest)
2216
+ || chain.chain_digest !== todoSelfDigest(chain, 'chain_digest')) {
2217
+ fail('STORE_INCONSISTENT', 'backchain_plan_binding_invalid');
2218
+ }
2219
+ const binding = {
2220
+ schema: 'lattice.plan_backchain_binding.v1',
2221
+ plan_digest: plan.plan_digest, chain, binding_digest: '',
2222
+ };
2223
+ binding.binding_digest = todoSelfDigest(binding, 'binding_digest');
2224
+ if (Buffer.byteLength(JSON.stringify(binding), 'utf8') > TODO_LIMITS.snapshotBytes) {
2225
+ fail('STORE_WRITE_REFUSED', 'backchain_binding_too_large');
2226
+ }
2227
+ return binding;
2228
+ }
2229
+
2210
2230
  export function buildPlanGenesis(plan, input) {
2211
2231
  const event = {
2212
2232
  schema: isPhaseTodoPlanSchema(plan.schema)
@@ -2347,6 +2367,7 @@ async function bootstrapImportedPlan(repoRoot, options) {
2347
2367
  }
2348
2368
  const prepared = prepareImportedArtifacts(repoRoot, options, initialization.projectId, []);
2349
2369
  const { plan, genesis, events, snapshot } = prepared;
2370
+ const backchain = boundPlanBackchain(plan, options.featureChain);
2350
2371
  const base = `${STORE_ROOT_REF}/plans/${plan.plan_key}/${plan.plan_version}`;
2351
2372
  const planRef = `${base}/plan.json`;
2352
2373
  const journalRef = `${base}/journal/active.jsonl`;
@@ -2370,6 +2391,7 @@ async function bootstrapImportedPlan(repoRoot, options) {
2370
2391
  await atomicWrite(path.join(stagedBase, 'plan.json'), canonicalLine(plan));
2371
2392
  await atomicWrite(path.join(stagedBase, 'journal', 'active.jsonl'), Buffer.concat(events.map(canonicalLine)));
2372
2393
  await atomicWrite(path.join(stagedBase, 'snapshot.json'), canonicalLine(snapshot));
2394
+ if (backchain !== null) await atomicWrite(path.join(stagedBase, 'backchain.json'), canonicalLine(backchain));
2373
2395
  await atomicWrite(path.join(stage, 'manifest.json'), canonicalLine(manifest));
2374
2396
  await protocolStage(options, 'bootstrap_staged');
2375
2397
 
@@ -2753,6 +2775,7 @@ export async function appendImportedPlan(options = {}) {
2753
2775
  repoRoot, options, store.project_id, store.members.map(({ plan: memberPlan }) => ({ plan: memberPlan })),
2754
2776
  );
2755
2777
  const { plan, genesis, events, snapshot, tasks } = prepared;
2778
+ const backchain = boundPlanBackchain(plan, options.featureChain);
2756
2779
 
2757
2780
  const base = `${STORE_ROOT_REF}/plans/${plan.plan_key}/${plan.plan_version}`;
2758
2781
  const planRef = `${base}/plan.json`;
@@ -2780,6 +2803,7 @@ export async function appendImportedPlan(options = {}) {
2780
2803
  await atomicWrite(stagedPlan, canonicalLine(plan));
2781
2804
  await atomicWrite(stagedJournal, Buffer.concat(events.map(canonicalLine)));
2782
2805
  await atomicWrite(stagedSnapshot, canonicalLine(snapshot));
2806
+ if (backchain !== null) await atomicWrite(path.join(stagedBase, 'backchain.json'), canonicalLine(backchain));
2783
2807
  for (const [index, artifact] of importedCrossPlan.artifacts.entries()) {
2784
2808
  artifact.stagedAbsolute = path.join(transactionAbsolute, 'plan-scoped', `${index}.jsonl`);
2785
2809
  await atomicWrite(artifact.stagedAbsolute, artifact.finalBytes);
@@ -2824,6 +2848,9 @@ export async function appendImportedPlan(options = {}) {
2824
2848
  await rename(stagedPlan, path.resolve(repoRoot, planRef));
2825
2849
  await rename(stagedJournal, path.resolve(repoRoot, journalRef));
2826
2850
  await rename(stagedSnapshot, path.resolve(repoRoot, snapshotRef));
2851
+ if (backchain !== null) {
2852
+ await rename(path.join(stagedBase, 'backchain.json'), path.join(finalBaseAbsolute, 'backchain.json'));
2853
+ }
2827
2854
  await fsyncDirectory(path.dirname(path.resolve(repoRoot, planRef)));
2828
2855
  await fsyncDirectory(path.dirname(path.resolve(repoRoot, journalRef)));
2829
2856
  }
@@ -2911,14 +2938,16 @@ export async function initializeAuthoredTodoStore(options = {}) {
2911
2938
  const optionKeys = [
2912
2939
  'repoRoot', 'writer', 'projectId', 'repositories', 'plan', 'genesis',
2913
2940
  ];
2914
- if (!(exactRecord(options, optionKeys)
2915
- || (exactRecord(options, [...optionKeys, 'onProtocolStage'])
2941
+ const allowedOptions = options.featureChain === undefined ? optionKeys : [...optionKeys, 'featureChain'];
2942
+ if (!(exactRecord(options, allowedOptions)
2943
+ || (exactRecord(options, [...allowedOptions, 'onProtocolStage'])
2916
2944
  && typeof options.onProtocolStage === 'function'))
2917
2945
  || options.projectId !== options.plan?.project_id
2918
2946
  || options.plan?.predecessor_plan_digest !== null) {
2919
2947
  throw new TypeError('authored store initialization input invalid');
2920
2948
  }
2921
2949
  const plan = buildTodoPlan(options.plan);
2950
+ const backchain = boundPlanBackchain(plan, options.featureChain);
2922
2951
  verifyPlanNarrativeAnchors(repoRoot, plan);
2923
2952
  const genesis = buildPlanGenesis(plan, options.genesis);
2924
2953
  const tasks = replay(plan, [genesis]);
@@ -2962,6 +2991,7 @@ export async function initializeAuthoredTodoStore(options = {}) {
2962
2991
  await atomicWrite(path.join(stage, base, 'plan.json'), canonicalLine(plan));
2963
2992
  await atomicWrite(path.join(stage, base, 'journal', 'active.jsonl'), canonicalLine(genesis));
2964
2993
  await atomicWrite(path.join(stage, base, 'snapshot.json'), canonicalLine(snapshot));
2994
+ if (backchain !== null) await atomicWrite(path.join(stage, base, 'backchain.json'), canonicalLine(backchain));
2965
2995
  await atomicWrite(path.join(stage, 'manifest.json'), canonicalLine(manifest));
2966
2996
 
2967
2997
  try {
@@ -3245,6 +3275,83 @@ function validatePhaseV3Carry(previous, revision, migration, idMap, state) {
3245
3275
  }
3246
3276
  }
3247
3277
 
3278
+ function validateBackchainCarry(previous, revision, migration, idMap) {
3279
+ const decision = revision.backchain.continuity?.find(({ task_id: id }) =>
3280
+ id === migration.from_task_id);
3281
+ if (decision?.policy === 'carry') return;
3282
+ const reconciliationMetadata = migration.state_policy === 'carry_reconciled_metadata';
3283
+ const predecessorTask = previous.plan.tasks.find(({ task_id }) => task_id === migration.from_task_id);
3284
+ const phaseIdMap = new Map(revision.phase_migration
3285
+ .filter(({ from_phase_id, to_phase_id }) => from_phase_id !== null && to_phase_id !== 'removed')
3286
+ .map(({ from_phase_id, to_phase_id }) => [from_phase_id, to_phase_id]));
3287
+ const before = phaseV3CarrySemantics(previous.plan, migration.from_task_id, idMap, phaseIdMap, {
3288
+ reconciliationMetadata, includeDesignMemo: !reconciliationMetadata
3289
+ && typeof predecessorTask?.design_memo === 'string',
3290
+ removedTaskIds: removedTaskIdsFor(revision),
3291
+ });
3292
+ const after = phaseV3CarrySemantics(revision.desired_plan, migration.to_task_id,
3293
+ new Map(), new Map(), {
3294
+ reconciliationMetadata, includeDesignMemo: !reconciliationMetadata
3295
+ && typeof predecessorTask?.design_memo === 'string',
3296
+ });
3297
+ // 利用先の増減は提供済み機能の意味を変えない。入力・提供機能の同一性は
3298
+ // backchain契約で別に照合し、工程の入力辺と本体の意味だけをここで検査する。
3299
+ if (canonicalizeTodoArtifact(before.task) !== canonicalizeTodoArtifact(after.task)
3300
+ || canonicalizeTodoArtifact(before.incoming) !== canonicalizeTodoArtifact(after.incoming)) {
3301
+ fail('REVISION_INVALID', 'carry_semantics_changed', { from_task_id: migration.from_task_id });
3302
+ }
3303
+ }
3304
+
3305
+ function backchainTaskSignature(chain, taskId) {
3306
+ const task = chain?.tasks?.find(({ task_id: id }) => id === taskId);
3307
+ if (task === undefined) return null;
3308
+ const featureIds = [...new Set([...task.requires_feature_ids, ...task.provides_feature_ids])].sort();
3309
+ const byId = new Map(chain.features.map((feature) => [feature.feature_id, feature]));
3310
+ return canonicalizeTodoArtifact({
3311
+ requires_feature_ids: task.requires_feature_ids,
3312
+ provides_feature_ids: task.provides_feature_ids,
3313
+ features: featureIds.map((id) => byId.get(id)),
3314
+ });
3315
+ }
3316
+
3317
+ async function assertBackchainCarryContracts(repoRoot, previous, revision) {
3318
+ const decisions = new Map((revision.backchain.continuity ?? [])
3319
+ .map((entry) => [entry.task_id, entry]));
3320
+ for (const decision of decisions.values()) {
3321
+ const migration = revision.task_migration.find(({ from_task_id: id }) => id === decision.task_id);
3322
+ if (migration === undefined || (decision.policy === 'carry'
3323
+ && !['carry', 'carry_reconciled_metadata'].includes(migration.state_policy))
3324
+ || (decision.policy === 'reset' && migration.state_policy !== 'reset_pending')
3325
+ || (decision.policy === 'retire' && migration.state_policy !== 'removed')) {
3326
+ fail('REVISION_INVALID', 'backchain_continuity_mismatch', { task_id: decision.task_id });
3327
+ }
3328
+ }
3329
+ const carried = revision.task_migration.filter(({ state_policy }) =>
3330
+ ['carry', 'carry_reconciled_metadata', 'acquire_phase'].includes(state_policy));
3331
+ if (carried.length === 0) return;
3332
+ const oldRef = path.resolve(repoRoot, STORE_ROOT_REF, 'plans', previous.plan.plan_key,
3333
+ previous.plan.plan_version, 'backchain.json');
3334
+ const bytes = await exactFileOrNull(oldRef);
3335
+ if (bytes === null) fail('REVISION_INVALID', 'backchain_predecessor_missing');
3336
+ let binding;
3337
+ try { binding = JSON.parse(decodeUtf8(bytes, 'REVISION_INVALID', 'backchain_predecessor_invalid')); }
3338
+ catch { fail('REVISION_INVALID', 'backchain_predecessor_invalid'); }
3339
+ if (binding?.schema !== 'lattice.plan_backchain_binding.v1'
3340
+ || binding.plan_digest !== previous.plan.plan_digest
3341
+ || binding.binding_digest !== todoSelfDigest(binding, 'binding_digest')) {
3342
+ fail('REVISION_INVALID', 'backchain_predecessor_invalid');
3343
+ }
3344
+ for (const migration of carried) {
3345
+ if (decisions.get(migration.from_task_id)?.policy === 'carry') continue;
3346
+ if (backchainTaskSignature(binding.chain, migration.from_task_id)
3347
+ !== backchainTaskSignature(revision.backchain, migration.to_task_id)) {
3348
+ fail('REVISION_INVALID', 'backchain_carry_feature_changed', {
3349
+ from_task_id: migration.from_task_id, to_task_id: migration.to_task_id,
3350
+ });
3351
+ }
3352
+ }
3353
+ }
3354
+
3248
3355
  /**
3249
3356
  * acquire_phase専用のcarry検証(ADR 0147裁定4)。既存carry/carry_reconciled_metadataの
3250
3357
  * 分岐(validatePhaseV3Carry)には一切手を入れず、「Phase割当ての獲得だけ」を許す別分岐として
@@ -3330,6 +3437,8 @@ function stateMigrationFor(previous, revision) {
3330
3437
  }
3331
3438
  } else if (revision.schema === 'lattice.phase_todo_revision.v3') {
3332
3439
  validatePhaseV3Carry(previous, revision, migration, idMap, state);
3440
+ } else if (revision.schema === 'lattice.phase_todo_revision.v4') {
3441
+ validateBackchainCarry(previous, revision, migration, idMap);
3333
3442
  } else {
3334
3443
  const predecessorTask = previous.plan.tasks
3335
3444
  .find(({ task_id }) => task_id === migration.from_task_id);
@@ -4373,6 +4482,9 @@ export async function applyPhaseTodoRevision(options = {}) {
4373
4482
  fail('STORE_WRITE_CONFLICT', 'stale_predecessor');
4374
4483
  }
4375
4484
  verifyPlanNarrativeAnchors(repoRoot, revision.desired_plan, previous.plan);
4485
+ if (revision.schema === 'lattice.phase_todo_revision.v4') {
4486
+ await assertBackchainCarryContracts(repoRoot, previous, revision);
4487
+ }
4376
4488
  const stateMigration = stateMigrationFor(previous, revision);
4377
4489
  const phaseStateMigration = phaseStateMigrationFor(previous, revision);
4378
4490
  const prospective = store.members.map((member) => member === previous
@@ -4417,6 +4529,9 @@ export async function applyPhaseTodoRevision(options = {}) {
4417
4529
  ['phase_revision_plan_durable', planRef, canonicalLine(revision.desired_plan)],
4418
4530
  ['phase_revision_genesis_durable', journalRef, canonicalLine(genesis)],
4419
4531
  ['phase_revision_snapshot_durable', snapshotRef, canonicalLine(snapshot)],
4532
+ ...(revision.schema === 'lattice.phase_todo_revision.v4'
4533
+ ? [['phase_revision_backchain_durable', `${base}/backchain.json`,
4534
+ canonicalLine(boundPlanBackchain(revision.desired_plan, revision.backchain))]] : []),
4420
4535
  ];
4421
4536
  for (const [stage, ref, bytes] of artifacts) {
4422
4537
  const absolute = path.resolve(repoRoot, ref);