@deksden-com/dd-flow-cli 0.9.0-beta.142 → 0.9.0-beta.144

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.
@@ -275,7 +275,9 @@ function validateCheckEvidenceContext(value) {
275
275
  if (value !== undefined && (typeof value !== "string" || !/^[A-Za-z0-9][A-Za-z0-9:._-]{0,255}$/.test(value)))
276
276
  throw new AppError("check_evidence_context_invalid", "Check evidence context must be a bounded opaque identifier", 2, { effect: "no_effect" });
277
277
  }
278
- const evidenceDigest = (value) => crypto.createHash("sha256").update(JSON.stringify(value)).digest("hex");
278
+ // Additive NULL columns must not invalidate accepted legacy SQL digests.
279
+ const scopedReceiptColumns = new Set(["fingerprint_scope", "input_manifest_json", "execution_failures_json", "execution_contract_hash"]);
280
+ const evidenceDigest = (value) => crypto.createHash("sha256").update(JSON.stringify(value, (key, item) => scopedReceiptColumns.has(key) && item === null ? undefined : item)).digest("hex");
279
281
  /** Prepares current physical evidence outside a writer and retains an exact
280
282
  * SQL-only fence. Declarations must come from the owning handler/policy, never
281
283
  * from the result whose acceptance is being decided. This does not supply
@@ -348,6 +350,8 @@ export class PreparedCheckEvidence {
348
350
  artifacts.push({ root: "run", path: path.relative(input.runHome, path.join(path.dirname(receipt.receipt_path), "artifacts", artifact.path)).split(path.sep).join("/"), sha256: artifact.sha256 });
349
351
  const bytes = fs.readFileSync(receipt.receipt_path);
350
352
  completions.push({ root: "run", path: path.relative(input.runHome, receipt.completion_path).split(path.sep).join("/"), sha256: crypto.createHash("sha256").update(fs.readFileSync(receipt.completion_path)).digest("hex") });
353
+ if (receipt.input_manifest)
354
+ completions.push({ root: "run", path: path.relative(input.runHome, path.join(path.dirname(receipt.receipt_path), receipt.input_manifest.path)).split(path.sep).join("/"), sha256: receipt.input_manifest.sha256 });
351
355
  return { id: receipt.id, path: receipt.receipt_path, sha256: crypto.createHash("sha256").update(bytes).digest("hex") };
352
356
  });
353
357
  const prepared = new PreparedCheckEvidence(input, snapshot, generation, references, artifacts, passed);
@@ -497,13 +501,29 @@ export async function runCodeChecks(context, input) {
497
501
  }
498
502
  }
499
503
  try {
500
- return await runCodeChecksOwned(context, input, gate.id, generation);
504
+ let receipts = await runCodeChecksOwned(context, input, gate.id, generation);
505
+ // One bounded episode, not a product-repair turn. The second execution
506
+ // remains guarded by its own before/after snapshot and SQL reservation.
507
+ const stale = receipts.filter(staleCheckRetryEligible);
508
+ if (!input.retryReceiptId && !input.retryReceiptIds?.length && stale.length) {
509
+ const otherSession = context.db.get("SELECT 1 FROM work_sessions s JOIN works w ON w.work_id = s.work_id WHERE w.project_id = ? AND w.run_id = ? AND w.work_id != ? AND s.status = 'running' AND s.completed_at IS NULL LIMIT 1", [input.projectId, input.runId, input.workId ?? ""]);
510
+ if (!otherSession) {
511
+ for (const predecessor of stale) {
512
+ const selected = input.checks.filter(check => predecessor.check_refs.includes(checkRef(check)));
513
+ if (!selected.length || selected.some(check => check.reuse !== "deterministic"))
514
+ continue;
515
+ const retried = await runCodeChecksOwned(context, { ...input, retryReceiptId: predecessor.id, retryReason: "check_inputs_changed" }, gate.id, generation, new Set(predecessor.check_refs));
516
+ receipts = receipts.filter(receipt => receipt.id !== predecessor.id).concat(retried);
517
+ }
518
+ }
519
+ }
520
+ return receipts;
501
521
  }
502
522
  finally {
503
523
  finishManagedProcess(context, { id: gate.id, leaseToken: gate.lease_token, state: "stopped", reason: "workspace_gate_settled" });
504
524
  }
505
525
  }
506
- async function runCodeChecksOwned(context, input, workspaceGateId, generation) {
526
+ async function runCodeChecksOwned(context, input, workspaceGateId, generation, selectedRefs) {
507
527
  assertRunMutationAllowed(context, input.projectId, input.runId, generation);
508
528
  for (const check of input.checks)
509
529
  validateCheckDeclaration(input.workspaceRoot, check);
@@ -520,7 +540,9 @@ async function runCodeChecksOwned(context, input, workspaceGateId, generation) {
520
540
  });
521
541
  const resolved = resolveCodeCheckCommands(input.workspaceRoot, input.runId, checks.map((check) => check.command));
522
542
  assertConsistentDeclarations(checks, resolved);
523
- const groups = deduplicate(checks, resolved), receipts = [];
543
+ // Retain all declarations for the planned-alias profile hash; filter only
544
+ // execution, otherwise a targeted retry changes its own frozen contract.
545
+ const groups = deduplicate(checks, resolved).filter(group => !selectedRefs || group.declarations.some(check => selectedRefs.has(checkRef(check)))), receipts = [];
524
546
  const retryIds = [...new Set([...(input.retryReceiptId ? [input.retryReceiptId] : []), ...(input.retryReceiptIds ?? [])])];
525
547
  const retries = new Map();
526
548
  for (const receiptId of retryIds) {
@@ -528,7 +550,8 @@ async function runCodeChecksOwned(context, input, workspaceGateId, generation) {
528
550
  const snapshot = workspaceState(input.workspaceRoot, [input.runHome]);
529
551
  const group = groups.find(candidate => candidate.declarations.some(check => check.run_at === predecessor.gate)
530
552
  && (checkInputHash(input.workspaceRoot, { ...candidate, gate: predecessor.gate }, profileHash, workspaceInputFingerprint(input.workspaceRoot, snapshot.files, candidate.inputs, [input.runHome]), input.evidenceContext, input.checkEnvironment) === predecessor.input_hash
531
- || sourceRepairContractMatches(predecessor, candidate, profileHash)));
553
+ || sourceRepairContractMatches(predecessor, candidate, profileHash)
554
+ || input.retryReason === "check_inputs_changed" && staleRetryContractMatches(predecessor, candidate, profileHash, input)));
532
555
  if (!group || retries.has(group))
533
556
  throw new AppError("retry_check_invalid", "Retry must match exactly one current execution contract", 2, { check_receipt_id: receiptId });
534
557
  // Preserve the identity of a pre-deduplication failed execution when an
@@ -540,10 +563,12 @@ async function runCodeChecksOwned(context, input, workspaceGateId, generation) {
540
563
  const group = groups[index];
541
564
  const before = workspaceState(input.workspaceRoot, [input.runHome]);
542
565
  const startedAt = context.now();
543
- const inputFingerprint = workspaceInputFingerprint(input.workspaceRoot, before.files, group.inputs, [input.runHome]);
566
+ const inputFiles = selectedInputFiles(input.workspaceRoot, before.files, group.inputs, [input.runHome]);
567
+ const inputFingerprint = inputFilesFingerprint(inputFiles);
544
568
  const inputHash = checkInputHash(input.workspaceRoot, group, profileHash, inputFingerprint, input.evidenceContext, input.checkEnvironment);
545
- const epoch = crypto.createHash("sha256").update(`${group.gate}\0${before.fingerprint}`).digest("hex");
546
- const reservationInput = { ...input, retryReceiptIds: retries.has(group) ? [retries.get(group)] : [], generation, group, before, profileHash, inputHash, epoch, startedAt };
569
+ const epoch = crypto.createHash("sha256").update(`${group.gate}\0${inputFingerprint}`).digest("hex");
570
+ const executionContractHash = checkInputHash(input.workspaceRoot, group, profileHash, "", input.evidenceContext, input.checkEnvironment);
571
+ const reservationInput = { ...input, retryReceiptIds: retries.has(group) ? [retries.get(group)] : [], generation, group, before, inputFiles, inputFingerprint, executionContractHash, profileHash, inputHash, epoch, startedAt };
547
572
  delete reservationInput.retryReceiptId;
548
573
  const decision = reserveOrReuseReceipt(context, reservationInput);
549
574
  if (decision.receipt) {
@@ -557,6 +582,7 @@ async function runCodeChecksOwned(context, input, workspaceGateId, generation) {
557
582
  let result = { exitCode: null, error: "check did not start", aborted: true };
558
583
  try {
559
584
  fs.mkdirSync(evidenceDir, { recursive: true });
585
+ retainInputManifest(context, reserved);
560
586
  const startingReceipt = receiptFor({ ...reserved, resources, result, status: "running", after: before, mutationPaths: [], artifacts: [] });
561
587
  if (group.runnerEvidence)
562
588
  startingReceipt.runner_evidence_required = group.runnerEvidence;
@@ -610,7 +636,8 @@ async function runCodeChecksOwned(context, input, workspaceGateId, generation) {
610
636
  result = { exitCode: null, error: error instanceof Error ? error.message : String(error), aborted: true };
611
637
  }
612
638
  const after = workspaceState(input.workspaceRoot, [input.runHome]);
613
- const mutationPaths = changedPaths(before.files, after.files);
639
+ const afterFiles = selectedInputFiles(input.workspaceRoot, after.files, group.inputs, [input.runHome]);
640
+ const mutationPaths = changedPaths(reserved.inputFiles, afterFiles);
614
641
  let artifacts = collectCheckArtifacts(evidenceDir, group.requiredArtifacts);
615
642
  if (fs.existsSync(stderrPath)) {
616
643
  if (!artifacts.complete)
@@ -618,23 +645,27 @@ async function runCodeChecksOwned(context, input, workspaceGateId, generation) {
618
645
  if (result.error)
619
646
  fs.appendFileSync(stderrPath, `\n${result.error}\n`);
620
647
  if (mutationPaths.length)
621
- fs.appendFileSync(stderrPath, `\ncheck mutated project workspace: ${mutationPaths.join(", ")}\n`);
648
+ fs.appendFileSync(stderrPath, `\ncheck inputs changed while running: ${mutationPaths.join(", ")}\n`);
622
649
  }
623
650
  const runnerEvidence = group.runnerEvidence ? readRunnerEvidence(evidenceDir, group.runnerEvidence, { invocation_id: id, check_id: id, command: group.command, input_hash: inputHash, verification_epoch: epoch }) : undefined;
624
- const physicalFailures = executionFailures({ exitCode: result.exitCode, launcherError: result.error ?? undefined, cleanupUnconfirmed: result.cleanupUnconfirmed, missing: artifacts.missing, mutations: mutationPaths, invalidRunner: Boolean(group.runnerEvidence && !runnerEvidence) });
651
+ const physicalFailures = executionFailures({ fingerprintScope: "check_inputs", exitCode: result.exitCode, launcherError: result.error ?? undefined, cleanupUnconfirmed: result.cleanupUnconfirmed, missing: artifacts.missing, mutations: mutationPaths, invalidRunner: Boolean(group.runnerEvidence && !runnerEvidence) });
625
652
  if (result.failure)
626
653
  physicalFailures.push(result.failure);
627
654
  const candidateFiles = candidateFailureFiles(runnerEvidence, input.workspaceRoot);
628
655
  if (group.runnerEvidence)
629
- retainExecutionOutcome(evidenceDir, { id, command: group.command, input_hash: inputHash, verification_epoch: epoch }, input.workspaceRoot, physicalFailures, candidateFiles);
656
+ retainExecutionOutcome(evidenceDir, { id, command: group.command, input_hash: inputHash, verification_epoch: epoch, fingerprint_scope: "check_inputs", input_manifest: reserved.inputManifest ?? null, execution_contract_hash: executionContractHash }, input.workspaceRoot, physicalFailures, candidateFiles);
630
657
  const requiredArtifacts = [...new Set([...group.requiredArtifacts, ...runnerProofArtifacts(runnerEvidence), ...(group.runnerEvidence ? ["engine-outcome.json"] : [])])];
631
658
  artifacts = collectCheckArtifacts(evidenceDir, requiredArtifacts);
632
659
  const status = result.aborted ? "aborted" : result.exitCode === 0 && !result.error && artifacts.complete && mutationPaths.length === 0 && (!group.runnerEvidence || runnerEvidencePass(runnerEvidence)) ? "passed" : "failed";
633
- const receipt = receiptFor({ ...reserved, resources, result, status, after, mutationPaths, artifacts: artifacts.items });
660
+ const receipt = receiptFor({ ...reserved, resources, result, status, after: { ...after, fingerprint: inputFilesFingerprint(afterFiles) }, mutationPaths, artifacts: artifacts.items });
634
661
  receipt.required_artifacts = requiredArtifacts;
635
662
  receipt.candidate_root = input.workspaceRoot;
636
663
  receipt.candidate_files = candidateFiles;
637
664
  receipt.execution_failures = physicalFailures;
665
+ context.db.run("UPDATE check_receipts SET execution_failures_json = ? WHERE project_id = ? AND id = ? AND status = 'running'", [JSON.stringify(physicalFailures), input.projectId, id]);
666
+ const completion = readCompletion(completionPath);
667
+ if (completion)
668
+ writeCompletionSnapshot(completionPath, completion, receipt);
638
669
  if (group.runnerEvidence) {
639
670
  receipt.runner_evidence_required = group.runnerEvidence;
640
671
  receipt.runner_evidence = runnerEvidence ?? null;
@@ -678,6 +709,10 @@ export function reconcileUnfinishedChecks(context, input) {
678
709
  for (const pendingReceipt of pending) {
679
710
  const process = processes.filter((item) => item.check_id === pendingReceipt.id && item.project_id === input.projectId && item.run_id === input.runId).at(-1);
680
711
  const receipt = readReceipt(pendingReceipt.receipt_path);
712
+ const authority = context.db.get("SELECT * FROM check_receipts WHERE project_id = ? AND id = ?", [input.projectId, pendingReceipt.id]);
713
+ if ((receipt?.fingerprint_scope ?? null) !== (authority?.fingerprint_scope ?? null)
714
+ || authority?.fingerprint_scope === "check_inputs" && JSON.stringify(receipt?.input_manifest ?? null) !== authority.input_manifest_json)
715
+ throw new AppError("check_receipt_changed", "Detached receipt scope or input manifest differs from SQL custody", 2, { check_receipt_id: pendingReceipt.id });
681
716
  const completion = receipt ? readCompletion(receipt.completion_path || path.join(path.dirname(pendingReceipt.receipt_path), "completion.json")) : null;
682
717
  // A stale lease after host sleep is not permission to duplicate a living
683
718
  // check. Its durable completion marker or explicit reconciliation decides
@@ -694,11 +729,24 @@ export function reconcileUnfinishedChecks(context, input) {
694
729
  }
695
730
  const after = workspaceState(input.workspaceRoot, input.runHome ? [input.runHome] : []);
696
731
  let artifacts = receipt ? collectCheckArtifacts(path.join(path.dirname(receipt.completion_path || pendingReceipt.receipt_path), "artifacts"), receipt.required_artifacts ?? []) : { complete: false, missing: [], items: [] };
697
- const workspaceUnchanged = receipt?.before_fingerprint === after.fingerprint;
732
+ const scoped = receipt?.fingerprint_scope === "check_inputs";
733
+ const completionSnapshot = scoped && completion?.fingerprint_scope === "check_inputs" && completion.id === receipt.id && completion.input_hash === receipt.input_hash
734
+ && completion.verification_epoch === receipt.verification_epoch && completion.before_fingerprint === receipt.before_fingerprint
735
+ && JSON.stringify(completion.input_manifest ?? null) === JSON.stringify(receipt.input_manifest ?? null) && typeof completion.after_fingerprint === "string";
736
+ const afterFingerprint = scoped ? completionSnapshot ? completion.after_fingerprint : workspaceInputFingerprint(input.workspaceRoot, after.files, receipt.inputs ?? [], input.runHome ? [input.runHome] : []) : after.fingerprint;
737
+ const workspaceUnchanged = receipt?.before_fingerprint === afterFingerprint;
738
+ const beforeFiles = scoped ? retainedInputFiles(receipt) : null;
739
+ // A completion digest describes that instant, not the later recovery tree.
740
+ // Without an after manifest, never attach today's delta to that old proof.
741
+ const mutationPaths = workspaceUnchanged ? [] : scoped && completionSnapshot ? ["<declared check inputs differed at completion; exact delta unavailable>"]
742
+ : scoped && beforeFiles ? changedPaths(beforeFiles, selectedInputFiles(input.workspaceRoot, after.files, receipt.inputs ?? [], input.runHome ? [input.runHome] : []))
743
+ : [scoped ? "<declared check inputs changed; exact manifest unavailable>" : "<workspace changed while detached check ran>"];
698
744
  const runnerEvidence = receipt?.runner_evidence_required ? readRunnerEvidence(path.join(path.dirname(pendingReceipt.receipt_path), "artifacts"), receipt.runner_evidence_required, { invocation_id: receipt.id, check_id: receipt.id, command: receipt.command, input_hash: receipt.input_hash, verification_epoch: receipt.verification_epoch }) : undefined;
699
745
  const exitCode = completion?.exit_code ?? null;
700
746
  const finishedAt = completion?.finished_at ?? context.now();
701
- const physicalFailures = executionFailures({ exitCode, missing: artifacts.missing.filter(file => file !== "engine-outcome.json"), mutations: workspaceUnchanged ? [] : ["<workspace changed while detached check ran>"], invalidRunner: Boolean(receipt?.runner_evidence_required && !runnerEvidence) });
747
+ const physicalFailures = executionFailures({ fingerprintScope: scoped ? "check_inputs" : undefined, exitCode, missing: artifacts.missing.filter(file => file !== "engine-outcome.json"), mutations: mutationPaths, invalidRunner: Boolean(receipt?.runner_evidence_required && !runnerEvidence) });
748
+ if (scoped && !completionSnapshot)
749
+ physicalFailures.push({ code: "completion_snapshot_unknown", message: "Detached check has no bound scoped completion snapshot; current inputs cannot prove inputs at command completion" });
702
750
  let candidateFiles = candidateFailureFiles(runnerEvidence, input.workspaceRoot);
703
751
  if (receipt?.runner_evidence_required) {
704
752
  const evidenceDir = path.join(path.dirname(pendingReceipt.receipt_path), "artifacts");
@@ -720,11 +768,12 @@ export function reconcileUnfinishedChecks(context, input) {
720
768
  artifacts = collectCheckArtifacts(evidenceDir, receipt.required_artifacts);
721
769
  }
722
770
  const status = completion === null ? "aborted" : completion.exit_code === 0 && workspaceUnchanged && artifacts.complete && physicalFailures.length === 0 && (!receipt?.runner_evidence_required || runnerEvidencePass(runnerEvidence)) ? "passed" : "failed";
723
- context.db.run("UPDATE check_receipts SET status = ?, exit_code = ?, after_fingerprint = ?, mutation_paths_json = ?, artifacts_json = ?, finished_at = ? WHERE id = ? AND project_id = ? AND status = 'running'", [status, exitCode, after.fingerprint, JSON.stringify(workspaceUnchanged ? [] : ["<workspace changed while detached check ran>"]), JSON.stringify(artifacts.items), finishedAt, pendingReceipt.id, input.projectId]);
771
+ context.db.run("UPDATE check_receipts SET status = ?, exit_code = ?, after_fingerprint = ?, mutation_paths_json = ?, artifacts_json = ?, finished_at = ? WHERE id = ? AND project_id = ? AND status = 'running'", [status, exitCode, afterFingerprint, JSON.stringify(mutationPaths), JSON.stringify(artifacts.items), finishedAt, pendingReceipt.id, input.projectId]);
772
+ context.db.run("UPDATE check_receipts SET execution_failures_json = ? WHERE project_id = ? AND id = ?", [JSON.stringify(physicalFailures), input.projectId, pendingReceipt.id]);
724
773
  if (process)
725
774
  finishManagedProcess(context, { id: process.id, leaseToken: process.lease_token, state: status === "passed" ? "stopped" : "failed", reason: status === "passed" ? "reconciled_completion" : completion === null ? "completion_marker_missing" : "reconciled_check_failed" });
726
775
  if (receipt) {
727
- const repaired = { ...receipt, ...(receipt.runner_evidence_required ? { runner_evidence: runnerEvidence ?? null } : {}), completion_path: receipt.completion_path || path.join(path.dirname(pendingReceipt.receipt_path), "completion.json"), required_artifacts: receipt.required_artifacts ?? [], ...(status === "aborted" ? { abort_reason: "completion_marker_missing" } : {}), status, exit_code: exitCode, after_fingerprint: after.fingerprint, mutation_paths: workspaceUnchanged ? [] : ["<workspace changed while detached check ran>"], artifacts: artifacts.items, finished_at: finishedAt };
776
+ const repaired = { ...receipt, ...(receipt.runner_evidence_required ? { runner_evidence: runnerEvidence ?? null } : {}), completion_path: receipt.completion_path || path.join(path.dirname(pendingReceipt.receipt_path), "completion.json"), required_artifacts: receipt.required_artifacts ?? [], ...(status === "aborted" ? { abort_reason: "completion_marker_missing" } : {}), status, exit_code: exitCode, after_fingerprint: afterFingerprint, mutation_paths: mutationPaths, artifacts: artifacts.items, finished_at: finishedAt };
728
777
  repaired.candidate_root = input.workspaceRoot;
729
778
  repaired.candidate_files = candidateFiles;
730
779
  repaired.execution_failures = physicalFailures;
@@ -739,18 +788,19 @@ export function reconcileUnfinishedChecks(context, input) {
739
788
  function reserveOrReuseReceipt(context, input) {
740
789
  // Receipt files and artifact hashes are prepared before reserving SQLite.
741
790
  // The compact SQL snapshot below fences races with another check settlement.
742
- const snapshotSql = "SELECT id, status, exit_code, input_hash, profile_hash, command, gate, retry_of_receipt_id, receipt_path FROM check_receipts WHERE project_id = ? AND run_id = ? ORDER BY reservation_sequence, rowid";
791
+ const snapshotSql = "SELECT * FROM check_receipts WHERE project_id = ? AND run_id = ? ORDER BY reservation_sequence, rowid";
743
792
  const snapshot = JSON.stringify(context.db.all(snapshotSql, [input.projectId, input.runId]));
744
793
  const receipts = checkReceipts(context, { projectId: input.projectId, runId: input.runId });
745
794
  if (JSON.stringify(context.db.all(snapshotSql, [input.projectId, input.runId])) !== snapshot)
746
795
  throw new AppError("check_receipt_changed", "Check receipts changed during evidence preparation", 1);
747
- const prior = receipts.filter((receipt) => receipt.input_hash === input.inputHash && receipt.profile_hash === input.profileHash).at(-1) ?? null;
796
+ const prior = receipts.filter((receipt) => receipt.fingerprint_scope === "check_inputs" && receipt.input_hash === input.inputHash && receipt.profile_hash === input.profileHash).at(-1) ?? null;
748
797
  const reusablePrior = prior?.status === "passed" && checkReceiptEvidenceValid(prior);
749
798
  const retryIds = new Set([...(input.retryReceiptId ? [input.retryReceiptId] : []), ...(input.retryReceiptIds ?? [])]);
750
799
  const successor = retryIds.size === 1 ? receipts.find((receipt) => receipt.retry_of_receipt_id === [...retryIds][0] && receipt.input_hash === input.inputHash && receipt.profile_hash === input.profileHash) : null;
751
800
  const reusableSuccessor = successor?.status === "passed" && checkReceiptEvidenceValid(successor);
752
801
  const retryPredecessor = receipts.find(receipt => retryIds.has(receipt.id));
753
- const sourceCorrection = Boolean(retryPredecessor && sourceRepairContractMatches(retryPredecessor, input.group, input.profileHash));
802
+ const sourceCorrection = Boolean(retryPredecessor && (sourceRepairContractMatches(retryPredecessor, input.group, input.profileHash)
803
+ || input.retryReason === "check_inputs_changed" && staleRetryContractMatches(retryPredecessor, input.group, input.profileHash, input)));
754
804
  const latestContract = receipts.filter(receipt => receipt.command === input.group.command && receipt.profile_hash === input.profileHash && receipt.gate === input.group.gate && receipt.scope === input.scope && receipt.work_id === (input.workId ?? null)).at(-1);
755
805
  return context.db.writeTransaction(() => {
756
806
  assertRunMutationAllowed(context, input.projectId, input.runId, input.generation);
@@ -762,6 +812,13 @@ function reserveOrReuseReceipt(context, input) {
762
812
  throw new AppError("retry_check_invalid", "A check execution accepts exactly one retry predecessor", 2);
763
813
  const retryId = [...retryIds][0];
764
814
  if (retryId) {
815
+ if (input.retryReason === "check_inputs_changed" && retryPredecessor) {
816
+ const allSuccessors = receipts.filter(receipt => receipt.retry_of_receipt_id === retryId);
817
+ if (allSuccessors.length === 1 && reusableSuccessor)
818
+ return { receipt: successor };
819
+ if (retryPredecessor.retry_reason === "check_inputs_changed" || allSuccessors.length)
820
+ throw new AppError("retry_check_invalid", "Scoped input retry episode already consumed its one successor", 2, { check_receipt_id: retryId });
821
+ }
765
822
  if (!input.retryReason?.trim())
766
823
  throw new AppError("retry_check_invalid", "A check retry requires the restored-condition reason", 2, { check_receipt_id: retryId });
767
824
  const predecessor = receipts.find(receipt => receipt.id === retryId);
@@ -779,6 +836,9 @@ function reserveOrReuseReceipt(context, input) {
779
836
  }
780
837
  // An unchanged failed contract needs a concrete restored-condition retry,
781
838
  // not repeated coordinator finish until it happens to become green.
839
+ if (!retryId && latestContract?.fingerprint_scope === "check_inputs" && latestContract.status === "failed"
840
+ && latestContract.execution_failures?.some(failure => failure.code === "check_inputs_changed") && latestContract.execution_contract_hash === input.executionContractHash)
841
+ return { receipt: latestContract };
782
842
  if (prior && ["failed", "aborted"].includes(prior.status) && !retryId)
783
843
  return { receipt: prior };
784
844
  if (prior?.status === "passed" && !retryIds.has(prior.id) && input.group.reusable && reusablePrior)
@@ -791,8 +851,9 @@ function reserveOrReuseReceipt(context, input) {
791
851
  id = `${input.workId ?? input.runId}/${localId}`;
792
852
  }
793
853
  const directory = path.join(input.runHome, input.artifactDir ?? "05-code", "checks", localId);
794
- const reserved = { id, localId, directory, evidenceDir: path.join(directory, "artifacts"), stdoutPath: path.join(directory, "stdout.log"), stderrPath: path.join(directory, "stderr.log"), receiptPath: path.join(directory, "receipt.json"), completionPath: path.join(directory, "completion.json"), group: input.group, before: input.before, profileHash: input.profileHash, inputHash: input.inputHash, epoch: input.epoch, startedAt: input.startedAt, scope: input.scope, ...(retryId ? { retryOf: retryId, retryReason: input.retryReason } : {}), ...(input.workId ? { workId: input.workId } : {}), projectId: input.projectId, runId: input.runId };
854
+ const reserved = { id, localId, directory, evidenceDir: path.join(directory, "artifacts"), stdoutPath: path.join(directory, "stdout.log"), stderrPath: path.join(directory, "stderr.log"), receiptPath: path.join(directory, "receipt.json"), completionPath: path.join(directory, "completion.json"), group: input.group, before: input.before, inputFiles: input.inputFiles, inputFingerprint: input.inputFingerprint, executionContractHash: input.executionContractHash, profileHash: input.profileHash, inputHash: input.inputHash, epoch: input.epoch, startedAt: input.startedAt, scope: input.scope, ...(retryId ? { retryOf: retryId, retryReason: input.retryReason } : {}), ...(input.workId ? { workId: input.workId } : {}), projectId: input.projectId, runId: input.runId, workspaceRoot: input.workspaceRoot, runHome: input.runHome };
795
855
  context.db.run("INSERT INTO check_receipts (id, project_id, run_id, work_id, scope, declaration_id, check_refs_json, gate, command, input_hash, verification_epoch, reservation_sequence, retry_of_receipt_id, retry_reason, status, exit_code, stdout_path, stderr_path, receipt_path, workspace_fingerprint, before_fingerprint, after_fingerprint, profile_hash, mutation_paths_json, artifacts_json, started_at, finished_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'running', NULL, ?, ?, ?, ?, ?, ?, ?, '[]', '[]', ?, ?)", [id, input.projectId, input.runId, input.workId ?? null, input.scope, input.group.declarations[0].id, JSON.stringify(input.group.declarations.map(checkRef).sort()), input.group.gate, input.group.command, input.inputHash, input.epoch, ordinal, reserved.retryOf ?? null, reserved.retryReason ?? null, reserved.stdoutPath, reserved.stderrPath, reserved.receiptPath, input.before.fingerprint, input.before.fingerprint, input.before.fingerprint, input.profileHash, input.startedAt, input.startedAt]);
856
+ context.db.run("UPDATE check_receipts SET fingerprint_scope = ?, before_fingerprint = ?, after_fingerprint = ?, execution_contract_hash = ? WHERE id = ? AND project_id = ?", ["check_inputs", input.inputFingerprint, input.inputFingerprint, input.executionContractHash, id, input.projectId]);
796
857
  return { reserved };
797
858
  }, "checks.reserveOrReuseReceipt");
798
859
  }
@@ -800,7 +861,48 @@ function sourceRepairContractMatches(receipt, group, profileHash) {
800
861
  return checkRepairDisposition(receipt) === "source_repair" && group.command === receipt.command && profileHash === receipt.profile_hash && isDeepStrictEqual(group.inputs, receipt.inputs)
801
862
  && isDeepStrictEqual(derivedReceiptArtifacts(group, receipt).sort(), [...receipt.required_artifacts].sort()) && isDeepStrictEqual([...group.ports].sort(), Object.keys(receipt.resources.ports).sort());
802
863
  }
803
- function receiptFor(input) { return { id: input.id, local_id: input.localId, declaration_id: input.group.declarations[0].id, check_refs: input.group.declarations.map(checkRef), gate: input.group.gate, scope: input.scope, work_id: input.workId ?? null, command: input.group.command, input_hash: input.inputHash, verification_epoch: input.epoch, retry_of_receipt_id: input.retryOf ?? null, retry_reason: input.retryReason ?? null, status: input.status, ...(input.status === "aborted" ? { abort_reason: input.result.error } : {}), exit_code: input.status === "running" ? null : input.result.exitCode, stdout_path: input.stdoutPath, stderr_path: input.stderrPath, receipt_path: input.receiptPath, completion_path: input.completionPath, required_artifacts: input.group.requiredArtifacts, inputs: input.group.inputs, workspace_fingerprint: input.before.fingerprint, before_fingerprint: input.before.fingerprint, after_fingerprint: input.after.fingerprint, profile_hash: input.profileHash, mutation_paths: input.mutationPaths, artifacts: input.artifacts, resources: { ports: input.resources }, started_at: input.startedAt, finished_at: input.status === "running" ? input.startedAt : new Date().toISOString() }; }
864
+ function staleCheckRetryEligible(receipt) {
865
+ if (receipt.fingerprint_scope !== "check_inputs" || receipt.status !== "failed" || receipt.exit_code !== 0 || receipt.retry_reason === "check_inputs_changed"
866
+ || !receipt.execution_failures?.length || receipt.execution_failures.some(failure => failure.code !== "check_inputs_changed")
867
+ || receipt.runner_evidence_required && !runnerEvidencePass(receipt.runner_evidence))
868
+ return false;
869
+ return checkReceiptEvidenceValidForMode(receipt, "diagnostic");
870
+ }
871
+ function staleRetryContractMatches(receipt, group, profileHash, input) {
872
+ return group.reusable && staleCheckRetryEligible(receipt) && isDeepStrictEqual(group.runnerEvidence ?? null, receipt.runner_evidence_required ?? null)
873
+ && receipt.execution_contract_hash === checkInputHash(input.workspaceRoot, group, profileHash, "", input.evidenceContext, input.checkEnvironment);
874
+ }
875
+ function receiptFor(input) { return { id: input.id, local_id: input.localId, declaration_id: input.group.declarations[0].id, check_refs: input.group.declarations.map(checkRef), gate: input.group.gate, scope: input.scope, work_id: input.workId ?? null, command: input.group.command, input_hash: input.inputHash, verification_epoch: input.epoch, retry_of_receipt_id: input.retryOf ?? null, retry_reason: input.retryReason ?? null, status: input.status, ...(input.status === "aborted" ? { abort_reason: input.result.error } : {}), exit_code: input.status === "running" ? null : input.result.exitCode, stdout_path: input.stdoutPath, stderr_path: input.stderrPath, receipt_path: input.receiptPath, completion_path: input.completionPath, required_artifacts: input.group.requiredArtifacts, inputs: input.group.inputs, fingerprint_scope: "check_inputs", input_manifest: input.inputManifest ?? null, execution_contract_hash: input.executionContractHash, workspace_fingerprint: input.before.fingerprint, before_fingerprint: input.inputFingerprint, after_fingerprint: input.status === "running" ? input.inputFingerprint : input.after.fingerprint, profile_hash: input.profileHash, mutation_paths: input.mutationPaths, artifacts: input.artifacts, resources: { ports: input.resources }, started_at: input.startedAt, finished_at: input.status === "running" ? input.startedAt : new Date().toISOString() }; }
876
+ function retainInputManifest(context, reserved) {
877
+ const full = JSON.stringify({ fingerprint_scope: "check_inputs", before_fingerprint: reserved.inputFingerprint, files: [...reserved.inputFiles].sort(([left], [right]) => left.localeCompare(right)) });
878
+ const truncated = Buffer.byteLength(full) > 1024 * 1024;
879
+ const bytes = truncated ? JSON.stringify({ fingerprint_scope: "check_inputs", before_fingerprint: reserved.inputFingerprint, truncated: true }) : full;
880
+ const relative = "inputs-before.json", file = path.join(reserved.directory, relative);
881
+ fs.writeFileSync(file, bytes, { flag: "wx", mode: 0o600 });
882
+ reserved.inputManifest = { path: relative, sha256: crypto.createHash("sha256").update(bytes).digest("hex"), truncated };
883
+ context.db.run("UPDATE check_receipts SET input_manifest_json = ? WHERE project_id = ? AND id = ? AND status = 'running'", [JSON.stringify(reserved.inputManifest), reserved.projectId, reserved.id]);
884
+ }
885
+ function retainedInputFiles(receipt) {
886
+ if (!receipt.input_manifest || receipt.input_manifest.truncated || receipt.input_manifest.path !== "inputs-before.json")
887
+ return null;
888
+ try {
889
+ const bytes = readBoundedReleaseFile(path.join(path.dirname(receipt.receipt_path), receipt.input_manifest.path), 1024 * 1024);
890
+ if (crypto.createHash("sha256").update(bytes).digest("hex") !== receipt.input_manifest.sha256)
891
+ return null;
892
+ const value = JSON.parse(bytes.toString("utf8"));
893
+ if (value.before_fingerprint !== receipt.before_fingerprint || !Array.isArray(value.files) || value.files.some((entry) => !Array.isArray(entry) || entry.length !== 2 || entry.some(item => typeof item !== "string")))
894
+ return null;
895
+ return new Map(value.files);
896
+ }
897
+ catch {
898
+ return null;
899
+ }
900
+ }
901
+ function writeCompletionSnapshot(file, completion, receipt) {
902
+ const temporary = file + ".tmp-" + crypto.randomUUID();
903
+ fs.writeFileSync(temporary, JSON.stringify({ ...completion, fingerprint_scope: receipt.fingerprint_scope, id: receipt.id, input_hash: receipt.input_hash, verification_epoch: receipt.verification_epoch, before_fingerprint: receipt.before_fingerprint, after_fingerprint: receipt.after_fingerprint, input_manifest: receipt.input_manifest ?? null }));
904
+ fs.renameSync(temporary, file);
905
+ }
804
906
  function writeReceipt(file, receipt) { const temporary = `${file}.tmp-${process.pid}-${crypto.randomUUID()}`; fs.writeFileSync(temporary, `${JSON.stringify(receipt, null, 2)}\n`); fs.renameSync(temporary, file); }
805
907
  function readReceipt(file) { try {
806
908
  return JSON.parse(fs.readFileSync(file, "utf8"));
@@ -811,7 +913,7 @@ catch {
811
913
  function readCompletion(file) { try {
812
914
  const value = JSON.parse(fs.readFileSync(file, "utf8"));
813
915
  const done = value;
814
- return value && typeof value === "object" && Number.isInteger(done.exit_code) ? { exit_code: Number(done.exit_code), finished_at: typeof done.finished_at === "string" && Number.isFinite(Date.parse(done.finished_at)) ? done.finished_at : null } : null;
916
+ return value && typeof value === "object" && Number.isInteger(done.exit_code) ? { ...done, exit_code: Number(done.exit_code), finished_at: typeof done.finished_at === "string" && Number.isFinite(Date.parse(done.finished_at)) ? done.finished_at : null } : null;
815
917
  }
816
918
  catch {
817
919
  return null;
@@ -826,15 +928,20 @@ export function validateFailedCheckRetry(context, input) {
826
928
  const declared = new Set(input.declarations.flatMap((check) => [check.id, checkRef(check)]));
827
929
  if (!receipt.check_refs.some((ref) => declared.has(ref)))
828
930
  throw new AppError("retry_check_invalid", "--retry-check does not belong to this stage gate", 2, { check_receipt_id: input.receiptId, check_refs: receipt.check_refs });
931
+ if (receipt.fingerprint_scope === "check_inputs" && receipt.execution_failures?.some(failure => failure.code === "check_inputs_changed") && input.reason !== "check_inputs_changed")
932
+ throw new AppError("retry_check_invalid", "Scoped drift uses its bounded stale transition, not an environment/source retry", 2, { check_receipt_id: receipt.id });
933
+ if (input.reason === "check_inputs_changed" && (!staleCheckRetryEligible(receipt) || receipt.retry_reason === "check_inputs_changed"))
934
+ throw new AppError("retry_check_invalid", "Scoped input retry requires a bound, safely completed stale predecessor and one unconsumed episode", 2, { check_receipt_id: receipt.id });
829
935
  return receipt;
830
936
  }
831
937
  export function checkReceipts(context, input) {
832
938
  const where = input.workId ? "project_id = ? AND run_id = ? AND work_id = ?" : "project_id = ? AND run_id = ?";
833
939
  const params = input.workId ? [input.projectId, input.runId, input.workId] : [input.projectId, input.runId];
834
- return context.db.all(`SELECT id, declaration_id, check_refs_json, gate, scope, work_id, command, input_hash, verification_epoch, retry_of_receipt_id, retry_reason, status, exit_code, stdout_path, stderr_path, receipt_path, workspace_fingerprint, before_fingerprint, after_fingerprint, profile_hash, mutation_paths_json, artifacts_json, started_at, finished_at FROM check_receipts WHERE ${where} ORDER BY reservation_sequence, rowid`, params).map(projectCheckReceiptRow);
940
+ return context.db.all(`SELECT * FROM check_receipts WHERE ${where} ORDER BY reservation_sequence, rowid`, params).map(projectCheckReceiptRow);
835
941
  }
836
942
  function projectCheckReceiptRow(row) {
837
- const receipt = { ...row, local_id: row.id.slice(row.id.lastIndexOf("/") + 1), check_refs: parseArray(row.check_refs_json, [row.declaration_id]), mutation_paths: parseArray(row.mutation_paths_json, []), resources: readReceiptResources(row.receipt_path), ...resolvedReceiptArtifacts(row.receipt_path, JSON.parse(row.artifacts_json), row) };
943
+ const inputManifest = row.input_manifest_json ? JSON.parse(row.input_manifest_json) : null;
944
+ const receipt = { ...row, input_manifest: inputManifest, local_id: row.id.slice(row.id.lastIndexOf("/") + 1), check_refs: parseArray(row.check_refs_json, [row.declaration_id]), mutation_paths: parseArray(row.mutation_paths_json, []), resources: readReceiptResources(row.receipt_path), ...resolvedReceiptArtifacts(row.receipt_path, JSON.parse(row.artifacts_json), { ...row, input_manifest: inputManifest }), ...(row.execution_failures_json ? { execution_failures: JSON.parse(row.execution_failures_json) } : {}) };
838
945
  if (receipt.status === "failed")
839
946
  receipt.diagnostic_evidence_valid = checkReceiptEvidenceValidForMode(receipt, "diagnostic");
840
947
  return receipt;
@@ -867,7 +974,7 @@ function resolvedReceiptArtifacts(receiptPath, artifacts, binding) {
867
974
  if (retainedOutcome.complete && retainedOutcome.items.every(item => artifacts.some(artifact => relocate(artifact.path) === item.path && artifact.sha256 === item.sha256))) {
868
975
  try {
869
976
  const value = JSON.parse(fs.readFileSync(outcomePath, "utf8"));
870
- if (value.schema_id === "dd-flow/check-execution-outcome@1" && value.id === binding.id && value.command === binding.command && value.input_hash === binding.input_hash && value.verification_epoch === binding.verification_epoch && typeof value.candidate_root === "string" && path.isAbsolute(value.candidate_root) && (value.candidate_files === undefined || Array.isArray(value.candidate_files) && value.candidate_files.length <= 64 && value.candidate_files.every((file) => typeof file === "string" && path.isAbsolute(file) && Buffer.byteLength(file) <= 4096)) && Array.isArray(value.execution_failures) && value.execution_failures.every((failure) => failure && typeof failure === "object" && typeof failure.code === "string" && typeof failure.message === "string"))
977
+ if (value.schema_id === "dd-flow/check-execution-outcome@1" && value.id === binding.id && value.command === binding.command && value.input_hash === binding.input_hash && value.verification_epoch === binding.verification_epoch && (value.fingerprint_scope ?? null) === (binding.fingerprint_scope ?? null) && (value.execution_contract_hash ?? null) === (binding.execution_contract_hash ?? null) && JSON.stringify(value.input_manifest ?? null) === JSON.stringify(binding.input_manifest ?? null) && typeof value.candidate_root === "string" && path.isAbsolute(value.candidate_root) && (value.candidate_files === undefined || Array.isArray(value.candidate_files) && value.candidate_files.length <= 64 && value.candidate_files.every((file) => typeof file === "string" && path.isAbsolute(file) && Buffer.byteLength(file) <= 4096)) && Array.isArray(value.execution_failures) && value.execution_failures.every((failure) => failure && typeof failure === "object" && typeof failure.code === "string" && typeof failure.message === "string"))
871
978
  outcome = value;
872
979
  }
873
980
  catch { /* Missing physical proof cannot grant attribution. */ }
@@ -885,7 +992,7 @@ function executionFailures(input) {
885
992
  if (input.missing.length)
886
993
  failures.push({ code: "missing_required_artifact", message: input.missing.join(", ") });
887
994
  if (input.mutations.length)
888
- failures.push({ code: "check_input_mutated", message: input.mutations.join(", ") });
995
+ failures.push({ code: input.fingerprintScope === "check_inputs" ? "check_inputs_changed" : "check_input_mutated", message: input.mutations.join(", ") });
889
996
  if (input.invalidRunner)
890
997
  failures.push({ code: "invalid_runner_evidence", message: "Required runner evidence is unavailable or invalid" });
891
998
  return failures;
@@ -894,7 +1001,7 @@ function retainExecutionOutcome(evidenceDir, binding, candidateRoot, failures, c
894
1001
  const temporary = path.join(evidenceDir, `.engine-outcome-${crypto.randomUUID()}.tmp`);
895
1002
  if (!fs.lstatSync(evidenceDir).isDirectory() || fs.lstatSync(evidenceDir).isSymbolicLink())
896
1003
  throw new AppError("check_evidence_stale", "Check evidence directory was replaced", 1);
897
- fs.writeFileSync(temporary, JSON.stringify({ schema_id: "dd-flow/check-execution-outcome@1", id: binding.id, command: binding.command, input_hash: binding.input_hash, verification_epoch: binding.verification_epoch, candidate_root: candidateRoot, candidate_files: candidateFiles, execution_failures: failures }), { flag: "wx", mode: 0o600 });
1004
+ fs.writeFileSync(temporary, JSON.stringify({ schema_id: "dd-flow/check-execution-outcome@1", id: binding.id, command: binding.command, input_hash: binding.input_hash, verification_epoch: binding.verification_epoch, fingerprint_scope: binding.fingerprint_scope ?? null, input_manifest: binding.input_manifest ?? null, execution_contract_hash: binding.execution_contract_hash ?? null, candidate_root: candidateRoot, candidate_files: candidateFiles, execution_failures: failures }), { flag: "wx", mode: 0o600 });
898
1005
  fs.renameSync(temporary, path.join(evidenceDir, "engine-outcome.json"));
899
1006
  }
900
1007
  export function workspaceFingerprint(root) { return workspaceState(root).fingerprint; }
@@ -1002,10 +1109,16 @@ function workspaceState(root, ignoredRoots = []) {
1002
1109
  }
1003
1110
  return { fingerprint: hash.digest("hex"), files };
1004
1111
  }
1005
- function workspaceInputFingerprint(root, files, inputs, ignoredRoots) {
1112
+ function selectedInputFiles(root, files, inputs, ignoredRoots) {
1006
1113
  const selected = new Map(inputs.length === 0 || inputs.includes(".") ? files : []);
1007
- for (const [file, digest] of explicitInputFiles(root, inputs.filter(input => input !== "."), ignoredRoots))
1114
+ for (const [file, digest] of explicitInputFiles(root, inputs.filter(input => input !== "."), ignoredRoots, files.keys()))
1008
1115
  selected.set(file, digest);
1116
+ return selected;
1117
+ }
1118
+ function workspaceInputFingerprint(root, files, inputs, ignoredRoots) {
1119
+ return inputFilesFingerprint(selectedInputFiles(root, files, inputs, ignoredRoots));
1120
+ }
1121
+ function inputFilesFingerprint(selected) {
1009
1122
  const hash = crypto.createHash("sha256");
1010
1123
  for (const [file, digest] of [...selected].sort(([left], [right]) => left.localeCompare(right)))
1011
1124
  hash.update(file).update("\0").update(digest).update("\0");
@@ -1080,23 +1193,40 @@ export function reconcileWorkspaceOwnership(context, workspaceRoot) {
1080
1193
  }
1081
1194
  function normalizedInputs(inputs) { for (const input of inputs)
1082
1195
  validateCheckInputSyntax(input); return [...new Set(inputs.map((input) => { const normalized = path.posix.normalize(input.replaceAll("\\", "/")).replace(/^\.\/$/, ""); return normalized === "" || normalized === "." ? "." : normalized.replace(/\/$/, ""); }))].sort(); }
1083
- function validateCheckInputs(workspaceRoot, inputs) { const root = fs.realpathSync.native(workspaceRoot); for (const input of normalizedInputs(inputs)) {
1084
- if (input === ".git" || input.startsWith(".git/"))
1085
- throw new AppError("invalid_check_input", "Check inputs cannot include Git internals", 2, { input });
1196
+ function validateCheckInputs(workspaceRoot, inputs) { const root = fs.realpathSync.native(workspaceRoot); const allowed = sourceInputPathFilter(workspaceRoot, inputs); for (const input of normalizedInputs(inputs)) {
1197
+ if (input.split("/").some(part => [".git", "node_modules"].includes(part)))
1198
+ throw new AppError("invalid_check_input", "Check inputs cannot include Git internals or installed dependencies; declare lock/config inputs", 2, { input });
1086
1199
  const candidate = path.resolve(workspaceRoot, input);
1087
1200
  if (candidate !== workspaceRoot && !candidate.startsWith(`${workspaceRoot}${path.sep}`))
1088
1201
  throw new AppError("invalid_check_input", "Check inputs must stay within the workspace", 2, { input });
1089
1202
  if (fs.existsSync(candidate))
1090
- validateInputTree(root, candidate, input, new Set());
1203
+ validateInputTree(root, candidate, input, new Set(), allowed);
1091
1204
  else
1092
1205
  validateMissingInputParent(root, candidate, input);
1093
1206
  } }
1207
+ /** Directory scopes select source inventory, not generated Git-ignored output.
1208
+ * Direct ignored inputs (e.g. a real config) remain explicit obligations. */
1209
+ function sourceInputPathFilter(root, inputs, sourceFiles) {
1210
+ const listing = sourceFiles ? [...sourceFiles] : workspaceGitFiles(root, ["-co", "--exclude-standard"]);
1211
+ const retained = new Set([...(listing ?? []), ...inputs]), branches = new Set();
1212
+ for (const file of retained)
1213
+ for (let parent = path.posix.dirname(file); parent !== "." && parent !== "/"; parent = path.posix.dirname(parent))
1214
+ branches.add(parent);
1215
+ const explicitTrees = inputs.filter(input => !listing?.some(file => file === input || file.startsWith(`${input}/`)));
1216
+ const serviceAllowed = projectServicePathFilter(root, inputs, listing ?? []);
1217
+ return file => {
1218
+ const relative = path.relative(root, file).split(path.sep).join("/");
1219
+ if (relative.split("/").some(part => [".git", "node_modules"].includes(part)) || !serviceAllowed(file))
1220
+ return false;
1221
+ return listing === null || retained.has(relative) || branches.has(relative) || explicitTrees.some(input => relative.startsWith(`${input}/`));
1222
+ };
1223
+ }
1094
1224
  function validateCheckInputSyntax(input) { if (typeof input !== "string" || !input.trim() || path.isAbsolute(input) || input.split(/[\\/]/).includes(".."))
1095
1225
  throw new AppError("invalid_check_input", "Check inputs must be non-empty relative workspace paths", 2, { input }); }
1096
1226
  function validateMissingInputParent(root, candidate, input) { let parent = candidate; while (!fs.existsSync(parent))
1097
1227
  parent = path.dirname(parent); const resolved = fs.realpathSync.native(parent); if (resolved !== root && !resolved.startsWith(`${root}${path.sep}`))
1098
1228
  throw new AppError("invalid_check_input", "Check input parent symlink escapes the workspace", 2, { input }); }
1099
- function validateInputTree(root, candidate, input, seen) {
1229
+ function validateInputTree(root, candidate, input, seen, allowed) {
1100
1230
  const resolved = fs.realpathSync.native(candidate);
1101
1231
  if (resolved !== root && !resolved.startsWith(`${root}${path.sep}`))
1102
1232
  throw new AppError("invalid_check_input", "Check input symlink escapes the workspace", 2, { input });
@@ -1104,13 +1234,19 @@ function validateInputTree(root, candidate, input, seen) {
1104
1234
  throw new AppError("invalid_check_input", "Check input contains a symbolic-link cycle", 2, { input });
1105
1235
  seen.add(resolved);
1106
1236
  const stat = fs.statSync(candidate);
1237
+ if (stat.isDirectory() && fs.lstatSync(candidate).isSymbolicLink())
1238
+ throw new AppError("invalid_check_input", "Directory symlinks cannot prove scoped source inputs; declare their contained real source path instead", 2, { input });
1107
1239
  if (stat.isDirectory())
1108
- for (const entry of fs.readdirSync(candidate))
1109
- validateInputTree(root, path.join(candidate, entry), input, seen);
1240
+ for (const entry of fs.readdirSync(candidate)) {
1241
+ const file = path.join(candidate, entry);
1242
+ if (allowed(file))
1243
+ validateInputTree(root, file, input, seen, allowed);
1244
+ }
1110
1245
  seen.delete(resolved);
1111
1246
  }
1112
- function explicitInputFiles(root, inputs, ignoredRoots) {
1247
+ function explicitInputFiles(root, inputs, ignoredRoots, sourceFiles) {
1113
1248
  const rootReal = fs.realpathSync.native(root), ignored = ignoredRoots.map(entry => path.resolve(entry)), result = new Map();
1249
+ const allowed = sourceInputPathFilter(root, inputs, sourceFiles);
1114
1250
  const visit = (file, relative, seen) => {
1115
1251
  if (ignored.some(entry => file === entry || file.startsWith(`${entry}${path.sep}`)))
1116
1252
  return;
@@ -1123,15 +1259,21 @@ function explicitInputFiles(root, inputs, ignoredRoots) {
1123
1259
  throw new AppError("invalid_check_input", "Check input symlink escapes the workspace", 2, { input: relative });
1124
1260
  if (seen.has(resolved))
1125
1261
  throw new AppError("invalid_check_input", "Check input contains a symbolic-link cycle", 2, { input: relative });
1262
+ const symbolic = fs.lstatSync(file).isSymbolicLink();
1126
1263
  const stat = fs.statSync(file);
1264
+ if (symbolic && stat.isDirectory())
1265
+ throw new AppError("invalid_check_input", "Directory symlinks cannot prove scoped source inputs; declare their contained real source path instead", 2, { input: relative });
1127
1266
  if (stat.isDirectory()) {
1128
1267
  seen.add(resolved);
1129
- for (const entry of fs.readdirSync(file).sort())
1130
- visit(path.join(file, entry), path.posix.join(relative, entry), seen);
1268
+ for (const entry of fs.readdirSync(file).sort()) {
1269
+ const child = path.join(file, entry);
1270
+ if (allowed(child))
1271
+ visit(child, path.posix.join(relative, entry), seen);
1272
+ }
1131
1273
  seen.delete(resolved);
1132
1274
  }
1133
1275
  else if (stat.isFile())
1134
- result.set(relative, crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex"));
1276
+ result.set(relative, crypto.createHash("sha256").update(symbolic ? `symlink\0${fs.readlinkSync(file)}\0` : "").update(fs.readFileSync(file)).digest("hex"));
1135
1277
  };
1136
1278
  for (const input of inputs)
1137
1279
  visit(path.resolve(root, input), input, new Set());
@@ -1143,8 +1285,24 @@ function bindReceipt(receipt, group) { return { ...receipt, gate: group.gate, de
1143
1285
  export function checkReceiptEvidenceValid(receipt) { return checkReceiptEvidenceValidForMode(receipt); }
1144
1286
  function checkReceiptEvidenceValidForMode(receipt, mode) {
1145
1287
  try {
1146
- if (!(receipt.status === "passed" && receipt.exit_code === 0 || mode === "diagnostic" && receipt.status === "failed" && typeof receipt.exit_code === "number" && receipt.exit_code !== 0) || receipt.before_fingerprint !== receipt.after_fingerprint
1147
- || receipt.mutation_paths.length !== 0 || receipt.completion_path !== path.join(path.dirname(receipt.receipt_path), "completion.json")
1288
+ const staleDiagnostic = mode === "diagnostic" && receipt.fingerprint_scope === "check_inputs" && receipt.status === "failed" && Number.isInteger(receipt.exit_code)
1289
+ && receipt.before_fingerprint !== receipt.after_fingerprint && receipt.mutation_paths.length > 0 && receipt.execution_failures?.some(failure => failure.code === "check_inputs_changed");
1290
+ if (receipt.fingerprint_scope != null && receipt.fingerprint_scope !== "check_inputs")
1291
+ return false;
1292
+ if (receipt.fingerprint_scope === "check_inputs") {
1293
+ const completion = readCompletion(receipt.completion_path);
1294
+ if (!completion || completion.fingerprint_scope !== "check_inputs" || completion.id !== receipt.id || completion.input_hash !== receipt.input_hash
1295
+ || completion.verification_epoch !== receipt.verification_epoch || completion.before_fingerprint !== receipt.before_fingerprint || completion.after_fingerprint !== receipt.after_fingerprint
1296
+ || JSON.stringify(completion.input_manifest ?? null) !== JSON.stringify(receipt.input_manifest ?? null))
1297
+ return false;
1298
+ if (!receipt.input_manifest || receipt.input_manifest.path !== "inputs-before.json")
1299
+ return false;
1300
+ const manifestBytes = readBoundedReleaseFile(path.join(path.dirname(receipt.receipt_path), receipt.input_manifest.path), 1024 * 1024);
1301
+ if (crypto.createHash("sha256").update(manifestBytes).digest("hex") !== receipt.input_manifest.sha256)
1302
+ return false;
1303
+ }
1304
+ if (!(receipt.status === "passed" && receipt.exit_code === 0 || mode === "diagnostic" && receipt.status === "failed" && typeof receipt.exit_code === "number" && receipt.exit_code !== 0 || staleDiagnostic) || !staleDiagnostic && receipt.before_fingerprint !== receipt.after_fingerprint
1305
+ || !staleDiagnostic && receipt.mutation_paths.length !== 0 || receipt.completion_path !== path.join(path.dirname(receipt.receipt_path), "completion.json")
1148
1306
  || readCompletion(receipt.completion_path)?.exit_code !== receipt.exit_code)
1149
1307
  return false;
1150
1308
  const stored = readReceipt(receipt.receipt_path);
@@ -1156,16 +1314,24 @@ function checkReceiptEvidenceValidForMode(receipt, mode) {
1156
1314
  // Gate/ref labels may be rebound for explicit deterministic reuse. Physical
1157
1315
  // execution identity and settled SQL evidence must never be rebound from a
1158
1316
  // mutable receipt file, including after recovery relocates the RUN home.
1159
- const fields = ["id", "scope", "work_id", "command", "input_hash", "verification_epoch", "status", "exit_code",
1317
+ const fields = ["fingerprint_scope", "execution_contract_hash", "id", "scope", "work_id", "command", "input_hash", "verification_epoch", "status", "exit_code",
1160
1318
  "workspace_fingerprint", "before_fingerprint", "after_fingerprint", "profile_hash", "started_at", "finished_at"];
1161
- if (fields.some(field => stored[field] !== receipt[field]) || !Array.isArray(stored.artifacts)
1162
- || !Array.isArray(stored.mutation_paths) || stored.mutation_paths.length !== 0
1319
+ if (JSON.stringify(stored.input_manifest ?? null) !== JSON.stringify(receipt.input_manifest ?? null))
1320
+ return false;
1321
+ if (fields.some(field => (stored[field] ?? null) !== (receipt[field] ?? null)) || !Array.isArray(stored.artifacts)
1322
+ || !Array.isArray(stored.mutation_paths) || !isDeepStrictEqual(stored.mutation_paths, receipt.mutation_paths)
1163
1323
  || JSON.stringify(stored.required_artifacts) !== JSON.stringify(receipt.required_artifacts)
1164
1324
  || JSON.stringify(stored.inputs) !== JSON.stringify(receipt.inputs))
1165
1325
  return false;
1166
- const retained = resolvedReceiptArtifacts(receipt.receipt_path, stored.artifacts, receipt).artifacts;
1326
+ const retainedProof = resolvedReceiptArtifacts(receipt.receipt_path, stored.artifacts, receipt);
1327
+ const retained = retainedProof.artifacts;
1167
1328
  if (JSON.stringify(retained) !== JSON.stringify(receipt.artifacts))
1168
1329
  return false;
1330
+ // Receipt annotations are not execution authority. Compare the SQL-owned
1331
+ // failures with the independently hash/binding-checked engine sidecar.
1332
+ const hasEngineOutcome = receipt.required_artifacts.includes("engine-outcome.json") || receipt.artifacts.some(artifact => artifact.path === "engine-outcome.json" || artifact.path === path.join(path.dirname(receipt.receipt_path), "artifacts", "engine-outcome.json"));
1333
+ if (hasEngineOutcome && !isDeepStrictEqual(retainedProof.execution_failures ?? [], receipt.execution_failures ?? []))
1334
+ return false;
1169
1335
  const root = path.join(path.dirname(receipt.receipt_path), "artifacts");
1170
1336
  if (stored.runner_evidence_required && mode !== "diagnostic" && !runnerEvidencePass(readRunnerEvidence(root, stored.runner_evidence_required, { invocation_id: receipt.id, check_id: receipt.id, command: receipt.command, input_hash: receipt.input_hash, verification_epoch: receipt.verification_epoch })))
1171
1337
  return false;
@@ -275,7 +275,11 @@ export async function continueProviderCapacity(input) {
275
275
  const seen = await observe(() => input.inspectSession(turnId));
276
276
  input.signal?.throwIfAborted();
277
277
  input.assertCurrent();
278
- const items = (seen.terminal_turn_id === turnId && seen.terminal_status === "failed" ? seen.native_turn_items : confirmed?.native_turn_items);
278
+ // An unloaded exact-Turn snapshot is not conflicting tool evidence.
279
+ // Preserve the bound failure receipt, but never hide a pending item.
280
+ const freshItems = seen.native_turn_items;
281
+ const items = (seen.terminal_turn_id === turnId && seen.terminal_status === "failed"
282
+ && !(freshItems?.observed === false && freshItems.pending !== true) ? freshItems : confirmed?.native_turn_items);
279
283
  return capacityReadiness(seen, input.sessionId, operationId, turnId, items, input.allowSettledDescendants);
280
284
  };
281
285
  const ready = await readiness();
@@ -8,7 +8,7 @@ export function ensureAggregateRepair(context, input, create) {
8
8
  if (!input.failures.length)
9
9
  throw new AppError("repair_intent_invalid", "Repair requires candidate-source-backed failed receipts", 1);
10
10
  assertSourceRepairEligible(input.failures);
11
- const causes = input.failures.map(receipt => ({ id: receipt.id, epoch: receipt.verification_epoch, fingerprint: receipt.workspace_fingerprint })).sort((a, b) => a.id.localeCompare(b.id));
11
+ const causes = input.failures.map(receipt => ({ id: receipt.id, epoch: receipt.verification_epoch, fingerprint: receipt.fingerprint_scope === "check_inputs" ? receipt.before_fingerprint : receipt.workspace_fingerprint })).sort((a, b) => a.id.localeCompare(b.id));
12
12
  return ensureRepairIntent(context, { ...input, causes: { receipts: causes, origins: [...(input.origins ?? [])].sort() } }, create);
13
13
  }
14
14
  export function ensureRepairIntent(context, input, create) {