@patronage/software-factory 1.0.0-alpha.30 → 1.0.0-alpha.31

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/dist/index.js CHANGED
@@ -18,7 +18,8 @@ import { FACTORY_APP_BROKER_ORIGIN, FACTORY_APP_BROKER_PATH, FACTORY_CURSOR_OIDC
18
18
  import http from "node:http";
19
19
  import { pathToFileURL } from "node:url";
20
20
  //#region package.json
21
- var version = "1.0.0-alpha.30";
21
+ var name = "@patronage/software-factory";
22
+ var version = "1.0.0-alpha.31";
22
23
  //#endregion
23
24
  //#region src/cli-entry.ts
24
25
  /**
@@ -2277,7 +2278,7 @@ const migratedPolicyLines = (policy, root) => {
2277
2278
  * still on `schemaVersion` 2 — or `undefined` when it is not.
2278
2279
  *
2279
2280
  * v3 is a hard break: there is no in-memory translation and no compatibility
2280
- * path (ADR 0023, #318). What replaces it is a refusal that names each
2281
+ * path (ADR 0021, #318). What replaces it is a refusal that names each
2281
2282
  * offending field and prints its replacement, so migrating is a transcription
2282
2283
  * rather than a reading exercise. The message is a courtesy, never a control:
2283
2284
  * every rule it describes is enforced by the schema below it.
@@ -2402,7 +2403,7 @@ function hqLaneRefBaseUrlFromProfile(profile) {
2402
2403
  return hqLaneRefBaseUrl(profile.hq.endpoint);
2403
2404
  }
2404
2405
  function formatLegacyProfileError(profilePath, foundVersion, migration) {
2405
- return [`Profile is invalid: ${profilePath}: schemaVersion ${foundVersion} is not supported; migrate to schemaVersion 6 (ADR 0023).`, ...migration.map((line) => ` ${line}`)].join("\n");
2406
+ return [`Profile is invalid: ${profilePath}: schemaVersion ${foundVersion} is not supported; migrate to schemaVersion 6 (ADR 0021).`, ...migration.map((line) => ` ${line}`)].join("\n");
2406
2407
  }
2407
2408
  function formatProfileError(profilePath, error) {
2408
2409
  return `Profile is invalid: ${profilePath}: ${error.issues.map((issue) => {
@@ -5580,18 +5581,18 @@ const evidenceEnvelopeSchema$1 = z.object({
5580
5581
  if (envelope.checkType === "review") {
5581
5582
  if (envelope.rung === void 0) context.addIssue({
5582
5583
  code: "custom",
5583
- message: "review-type evidence envelope requires a rung (ADR 0014 §3).",
5584
+ message: "review-type evidence envelope requires a rung (ADR 0014).",
5584
5585
  path: ["rung"]
5585
5586
  });
5586
5587
  } else {
5587
5588
  if (envelope.rung !== void 0) context.addIssue({
5588
5589
  code: "custom",
5589
- message: "verify-type evidence envelope must not carry rung (review-type-only field, ADR 0014 §3).",
5590
+ message: "verify-type evidence envelope must not carry rung (review-type-only field, ADR 0014).",
5590
5591
  path: ["rung"]
5591
5592
  });
5592
5593
  if (envelope.model !== void 0) context.addIssue({
5593
5594
  code: "custom",
5594
- message: "verify-type evidence envelope must not carry model (review-type-only field, ADR 0014 §3).",
5595
+ message: "verify-type evidence envelope must not carry model (review-type-only field, ADR 0014).",
5595
5596
  path: ["model"]
5596
5597
  });
5597
5598
  }
@@ -5700,7 +5701,7 @@ const evidenceEmitInvocation = (check) => check.checkType === "review" ? "review
5700
5701
  * the reader has a thread to pull rather than a demand to interpret. It stays a
5701
5702
  * courtesy: the schema and the envelope remain the control.
5702
5703
  */
5703
- const absentEnvelopeReason = (check) => `Required check "${check.name}" has no evidence envelope; absence fails closed (ADR 0014 §2). This demand resolves ${check.checkType} envelope files under ${EVIDENCE_DIR}/ in this checkout — not GitHub check runs, so a green hosted job satisfies nothing on its own. Satisfy it with a passing ${check.checkType} envelope named "${check.name}", bound to this candidate's headSha, patchId, and mergeBaseSha: ${evidenceEmitInvocation(check)}`;
5704
+ const absentEnvelopeReason = (check) => `Required check "${check.name}" has no evidence envelope; absence fails closed (ADR 0014). This demand resolves ${check.checkType} envelope files under ${EVIDENCE_DIR}/ in this checkout — not GitHub check runs, so a green hosted job satisfies nothing on its own. Satisfy it with a passing ${check.checkType} envelope named "${check.name}", bound to this candidate's headSha, patchId, and mergeBaseSha: ${evidenceEmitInvocation(check)}`;
5704
5705
  const freshnessRefusalReason = (name) => `Required check "${name}" evidence is stale: the envelope's patchId no longer matches the candidate delta.`;
5705
5706
  const evaluateInScopeCheck = ({ authoringSessionIds, candidate, check, envelopes, parsed }) => {
5706
5707
  const named = parsed.filter((loaded) => loaded.envelope.check === check.name);
@@ -5709,7 +5710,7 @@ const evaluateInScopeCheck = ({ authoringSessionIds, candidate, check, envelopes
5709
5710
  if (malformedForCheck) return {
5710
5711
  checkType: check.checkType,
5711
5712
  name: check.name,
5712
- reason: `Required check "${check.name}" envelope fails the three-way binding requirement (headSha+patchId+mergeBaseSha always required, ADR 0014 §3): ${malformedForCheck.error}.`,
5713
+ reason: `Required check "${check.name}" envelope fails the three-way binding requirement (headSha+patchId+mergeBaseSha always required, ADR 0014): ${malformedForCheck.error}.`,
5713
5714
  status: "unmet"
5714
5715
  };
5715
5716
  return {
@@ -5871,10 +5872,10 @@ const loadEvidenceEnvelopes = (cwd) => {
5871
5872
  };
5872
5873
  function independenceRefusalReason(name, independence) {
5873
5874
  switch (independence.reason) {
5874
- case "no-session": return `Required review check "${name}" carries no usable sessionId (absent or the reserved "unknown" sentinel), so different-session independence is unconfirmable; fail closed (ADR 0014 §5).`;
5875
- case "unknown-authoring-identity": return `Required review check "${name}" independence is unconfirmable: the candidate authoring identity is unknown, so a different-session cross-check cannot run; fail closed (ADR 0014 §5, resolution 4).`;
5876
- case "same-session": return `Required review check "${name}" ran in the same session as the authoring identity; same-session self-review never meets the rung (ADR 0014 §5).`;
5877
- default: return `Required review check "${name}" independence could not be confirmed; fail closed (ADR 0014 §5).`;
5875
+ case "no-session": return `Required review check "${name}" carries no usable sessionId (absent or the reserved "unknown" sentinel), so different-session independence is unconfirmable; fail closed (ADR 0014).`;
5876
+ case "unknown-authoring-identity": return `Required review check "${name}" independence is unconfirmable: the candidate authoring identity is unknown, so a different-session cross-check cannot run; fail closed (ADR 0014).`;
5877
+ case "same-session": return `Required review check "${name}" ran in the same session as the authoring identity; same-session self-review never meets the rung (ADR 0014).`;
5878
+ default: return `Required review check "${name}" independence could not be confirmed; fail closed (ADR 0014).`;
5878
5879
  }
5879
5880
  }
5880
5881
  //#endregion
@@ -7157,7 +7158,7 @@ const catchUpRecognitionSchema = z.object({
7157
7158
  }).strict();
7158
7159
  //#endregion
7159
7160
  //#region src/policy-resolution.ts
7160
- const COMMIT_SHA_PATTERN$2 = /^[0-9a-f]{40}$/u;
7161
+ const COMMIT_SHA_PATTERN$3 = /^[0-9a-f]{40}$/u;
7161
7162
  const digestSchema = z.string().regex(/^[0-9a-f]{64}$/u);
7162
7163
  /**
7163
7164
  * The authority resolved: the live tip of the PR's own base ref served a
@@ -7181,7 +7182,7 @@ const authoritativePolicyResolutionSchema = z.object({
7181
7182
  /** sha256 of the merged (base ∪ candidate) protected-path set. */
7182
7183
  effectiveDigest: digestSchema,
7183
7184
  /** The base-ref tip the base policy was read at. */
7184
- policyBaseSha: z.string().regex(COMMIT_SHA_PATTERN$2),
7185
+ policyBaseSha: z.string().regex(COMMIT_SHA_PATTERN$3),
7185
7186
  /**
7186
7187
  * Whether the candidate's `review` subtree differs from the base's. The
7187
7188
  * digests prove which policies were read; this says whether they agreed,
@@ -8873,7 +8874,6 @@ const foldLedger = (cycles, refs) => {
8873
8874
  };
8874
8875
  };
8875
8876
  const openLadderFindings = (ledger) => ledger.filter((entry) => entry.disposition === "open");
8876
- const nonOpenLadderFindings = (ledger) => ledger.filter((entry) => entry.disposition !== "open");
8877
8877
  const blockingLadderFindings = (ledger) => openLadderFindings(ledger).filter((entry) => resolveFindingBlocking(entry, entry.disposition));
8878
8878
  const staleRepeatLadderFindings = (ledger) => ledger.filter((entry) => entry.disposition === "stale-repeat");
8879
8879
  const inferFixedInThreadDispositions = (ledger, findings, options = {}) => {
@@ -9137,8 +9137,27 @@ const reviewAcceptedAfterCycleCap = (proof, review) => {
9137
9137
  });
9138
9138
  return state.windowExhausted && state.autoBlockingFindings === 0;
9139
9139
  };
9140
+ const ladderRefoldDisagreement = (epoch) => {
9141
+ if (epoch.kind !== "ladder") return {
9142
+ autoBlockingFindings: 0,
9143
+ unmatchedDispositions: 0
9144
+ };
9145
+ if (epoch.snapshot) return {
9146
+ autoBlockingFindings: epoch.snapshot.cycleState.autoBlockingFindings,
9147
+ unmatchedDispositions: epoch.snapshot.evaluation.unmatchedDispositions.length
9148
+ };
9149
+ const { ledger, unmatchedDispositions } = foldLedger(epoch.ladder.cycles, ladderCycleRefsFor(epoch.ladder.cycles));
9150
+ return {
9151
+ autoBlockingFindings: blockingLadderFindings([...ledger.values()]).length,
9152
+ unmatchedDispositions: unmatchedDispositions.length
9153
+ };
9154
+ };
9140
9155
  const resolveReviewAcceptanceForEpoch = (proof, review, epoch) => {
9141
- if (review.outcome === "passed") return "accepted-clean";
9156
+ if (review.outcome === "passed") {
9157
+ const disagreement = ladderRefoldDisagreement(epoch);
9158
+ if (disagreement.autoBlockingFindings > 0 || disagreement.unmatchedDispositions > 0) return "blocked";
9159
+ return "accepted-clean";
9160
+ }
9142
9161
  if (review.outcome !== "failed") return "blocked";
9143
9162
  if (epoch.kind === "ladder" && epoch.snapshot && reviewAcceptedThroughSnapshot(epoch.snapshot)) return "accepted-through-ladder";
9144
9163
  if (epoch.kind === "declared-window" && reviewAcceptedAfterCycleCap(proof, review)) return "accepted-after-cap";
@@ -9149,7 +9168,10 @@ const resolveReviewTerminalStateForEpoch = (proof, review, epoch, adjudicatedLed
9149
9168
  const acceptance = resolveReviewAcceptanceForEpoch(proof, review, epoch);
9150
9169
  if (acceptance === "accepted-clean") return "clean";
9151
9170
  const { autoBlockingFindings, openFindings } = reviewCycleStateForEpoch(proof, epoch, adjudicatedLedger);
9152
- if (acceptance === "blocked") return openFindings === 0 && autoBlockingFindings === 0 ? "clean" : "blocked";
9171
+ if (acceptance === "blocked") {
9172
+ if (ladderRefoldDisagreement(epoch).unmatchedDispositions > 0) return "blocked";
9173
+ return openFindings === 0 && autoBlockingFindings === 0 ? "clean" : "blocked";
9174
+ }
9153
9175
  return openFindings === 0 ? "clean" : "accepted-with-findings";
9154
9176
  };
9155
9177
  const resolveReviewTerminalState = (proof, review, policy) => resolveReviewTerminalStateForEpoch(proof, review, reviewEpochModeFor(proof, policy));
@@ -9475,18 +9497,18 @@ z.object({
9475
9497
  if (envelope.checkType === "review") {
9476
9498
  if (envelope.rung === void 0) context.addIssue({
9477
9499
  code: "custom",
9478
- message: "review-type evidence envelope requires a rung (ADR 0014 §3).",
9500
+ message: "review-type evidence envelope requires a rung (ADR 0014).",
9479
9501
  path: ["rung"]
9480
9502
  });
9481
9503
  } else {
9482
9504
  if (envelope.rung !== void 0) context.addIssue({
9483
9505
  code: "custom",
9484
- message: "verify-type evidence envelope must not carry rung (review-type-only field, ADR 0014 §3).",
9506
+ message: "verify-type evidence envelope must not carry rung (review-type-only field, ADR 0014).",
9485
9507
  path: ["rung"]
9486
9508
  });
9487
9509
  if (envelope.model !== void 0) context.addIssue({
9488
9510
  code: "custom",
9489
- message: "verify-type evidence envelope must not carry model (review-type-only field, ADR 0014 §3).",
9511
+ message: "verify-type evidence envelope must not carry model (review-type-only field, ADR 0014).",
9490
9512
  path: ["model"]
9491
9513
  });
9492
9514
  }
@@ -10708,7 +10730,7 @@ function createBoundaryCheckCommand(output, action) {
10708
10730
  //#endregion
10709
10731
  //#region src/candidate-impact.ts
10710
10732
  const CANDIDATE_IMPACT_DECISION_SCHEMA_VERSION = 1;
10711
- const COMMIT_SHA_PATTERN$1 = /^[0-9a-f]{40}$/iu;
10733
+ const COMMIT_SHA_PATTERN$2 = /^[0-9a-f]{40}$/iu;
10712
10734
  const ZERO_SHA_PATTERN$1 = /^0{40}$/u;
10713
10735
  const demandedTargets$1 = (profile, reason) => (profile?.impact?.targets ?? []).map(({ name }) => ({
10714
10736
  basis: reason,
@@ -10747,10 +10769,10 @@ const canonicalJson = (content) => {
10747
10769
  }
10748
10770
  };
10749
10771
  const assertCommitIdentity$1 = (value, name) => {
10750
- if (value === void 0 || !COMMIT_SHA_PATTERN$1.test(value) || ZERO_SHA_PATTERN$1.test(value)) throw new Error(`${name} must be a non-zero 40-character commit SHA`);
10772
+ if (value === void 0 || !COMMIT_SHA_PATTERN$2.test(value) || ZERO_SHA_PATTERN$1.test(value)) throw new Error(`${name} must be a non-zero 40-character commit SHA`);
10751
10773
  return value.toLowerCase();
10752
10774
  };
10753
- const assertReachableCommit$1 = (cwd, sha, name) => {
10775
+ const assertReachableCommit = (cwd, sha, name) => {
10754
10776
  try {
10755
10777
  runCapture("git", [
10756
10778
  "cat-file",
@@ -10806,8 +10828,8 @@ const classifyCandidateImpact = (args) => {
10806
10828
  });
10807
10829
  const base = assertCommitIdentity$1(args.base, "base");
10808
10830
  const head = assertCommitIdentity$1(args.head, "head");
10809
- assertReachableCommit$1(cwd, base, "base");
10810
- assertReachableCommit$1(cwd, head, "head");
10831
+ assertReachableCommit(cwd, base, "base");
10832
+ assertReachableCommit(cwd, head, "head");
10811
10833
  const checkedOutHead = runCapture("git", ["rev-parse", "HEAD"], cwd).stdout.trim().toLowerCase();
10812
10834
  if (checkedOutHead !== head) throw new Error(`head commit ${head} is not the checked-out HEAD ${checkedOutHead}; refusing to mix commit identities with declarations from another tree`);
10813
10835
  const relativeProfilePath = relativeProfile;
@@ -10819,7 +10841,7 @@ const classifyCandidateImpact = (args) => {
10819
10841
  base,
10820
10842
  head
10821
10843
  ], cwd).stdout.trim().toLowerCase();
10822
- if (!COMMIT_SHA_PATTERN$1.test(mergeBase)) throw new Error(`no merge base exists between base ${base} and head ${head}`);
10844
+ if (!COMMIT_SHA_PATTERN$2.test(mergeBase)) throw new Error(`no merge base exists between base ${base} and head ${head}`);
10823
10845
  const changedFiles = [...new Set(filesFromNameStatus(runCapture("git", [
10824
10846
  "diff",
10825
10847
  "--find-renames",
@@ -11445,9 +11467,9 @@ const renderCiAnalyzeReport = (report) => {
11445
11467
  * `gh` CLI, which resolves its own token (GH_TOKEN / GITHUB_TOKEN / login) —
11446
11468
  * no credential is stored or minted here.
11447
11469
  */
11448
- const REPOSITORY_PATTERN = /^[\w.-]+\/[\w.-]+$/u;
11470
+ const REPOSITORY_PATTERN$1 = /^[\w.-]+\/[\w.-]+$/u;
11449
11471
  const parseRepository = (value) => {
11450
- if (!REPOSITORY_PATTERN.test(value)) throw new Error(`--repo must be owner/name, got "${value}"`);
11472
+ if (!REPOSITORY_PATTERN$1.test(value)) throw new Error(`--repo must be owner/name, got "${value}"`);
11451
11473
  return value;
11452
11474
  };
11453
11475
  const parseRunId = (value) => {
@@ -13900,7 +13922,7 @@ function formatHumanReport(report) {
13900
13922
  const EPIC_STRUCTURE_SCHEMA_VERSION = 1;
13901
13923
  /**
13902
13924
  * DAG-node PLANNING statuses — the planning-plane vocabulary a planner authors
13903
- * on a `dag.yml` node. Carried forward by ADR 0018 §4 and defined by the epic
13925
+ * on a `dag.yml` node. Carried forward by ADR 0018 and defined by the epic
13904
13926
  * skill (`reference/epic-artifacts.md`) and both `CONTEXT.md` glossaries.
13905
13927
  *
13906
13928
  * A DAG node is a unit of *plan*; an HQ lane is a unit of *runtime execution*.
@@ -14713,7 +14735,7 @@ const isFactoryReadyCheck = (check) => check.context?.startsWith("patronage-fact
14713
14735
  * The hosted verification gate's context name — the branch ruleset's other
14714
14736
  * source-pinned required check, alongside `patronage-factory/pr-ready`. One
14715
14737
  * name across the fleet because one generator emits the workflow that posts
14716
- * it (ADR 0016, 2026-07-31 amendment).
14738
+ * it (ADR 0016).
14717
14739
  */
14718
14740
  const HOSTED_VERIFY_CHECK_NAME$1 = "verify";
14719
14741
  /**
@@ -14963,7 +14985,7 @@ function armAutoMerge(input, dependencies = {}) {
14963
14985
  const MERGE_FREEZE_CHECK_NAME = "patronage-factory/merge-freeze";
14964
14986
  const MERGE_FREEZE_APP_SLUG = "patronage-factory";
14965
14987
  const HOSTED_VERIFY_CHECK_NAME = "verify";
14966
- const GITHUB_ACTIONS_APP_ID = 15368;
14988
+ const GITHUB_ACTIONS_APP_ID$1 = 15368;
14967
14989
  const GITHUB_ACTIONS_APP_SLUG = "github-actions";
14968
14990
  const MERGE_FREEZE_SCHEMA_VERSION = 1;
14969
14991
  const CHECK_RUNS_PER_PAGE$1 = 100;
@@ -14991,7 +15013,7 @@ const inactiveMergeFreezeStateSchema = z.object({
14991
15013
  const mergeFreezeStateSchema = z.discriminatedUnion("active", [activeMergeFreezeStateSchema, inactiveMergeFreezeStateSchema]);
14992
15014
  /**
14993
15015
  * The write side of this contract lives in the generated merge-target push
14994
- * Verify workflow (#356, ADR 0016 as amended; #429): it is the ONLY producer of
15016
+ * Verify workflow (#356, ADR 0016; #429): it is the ONLY producer of
14995
15017
  * `patronage-factory/merge-freeze` generations. Its emitted `output.text`
14996
15018
  * payload must parse under this exact reader schema, which is what the
14997
15019
  * workflow's own tests assert through this export.
@@ -15028,7 +15050,7 @@ const completedMergeFreezeCheckRunSchema = mergeFreezeCheckRunListItemSchema.ext
15028
15050
  });
15029
15051
  const hostedVerifyCheckRunSchema = z.object({
15030
15052
  app: z.object({
15031
- id: z.literal(GITHUB_ACTIONS_APP_ID),
15053
+ id: z.literal(GITHUB_ACTIONS_APP_ID$1),
15032
15054
  slug: z.literal(GITHUB_ACTIONS_APP_SLUG)
15033
15055
  }),
15034
15056
  head_sha: shaSchema$2,
@@ -15224,7 +15246,7 @@ const activeMergeFreezeReason = (reason) => `Merge freeze is active (${reason}).
15224
15246
  */
15225
15247
  const unreadableMergeFreezeReason = (headSha, detail) => `Merge freeze refuses this candidate: the authoritative ${MERGE_FREEZE_CHECK_NAME} state for its base tip ${headSha} could not be read (${detail}). Which recovery applies is what that detail says: restore readability where the authority is missing or unreachable — the merge-target push Verify workflow generated, its writer App credentials configured, GitHub's Checks API reachable with read access — and/or let a fresh valid App-owned generation supersede a defective one, which a later Verify push to this base branch normally produces. Then rerun pr:ready. An operator may waive it for one candidate with patronage-factory demand:waive --pr <pr> --demand merge-freeze --rationale <why>.`;
15226
15248
  /** What every settling notice says after it has named its own witness. */
15227
- const SETTLING_MERGE_FREEZE_TAIL = "readiness proceeds (ADR 0016, 2026-07-31/2026-08-01 amendments). If this wave authorizes machine merge, arming follows the admission decision. The Verify run for that tip still refuses every later readiness decision against this base branch if it goes red.";
15249
+ const SETTLING_MERGE_FREEZE_TAIL = "readiness proceeds (ADR 0016). If this wave authorizes machine merge, arming follows the admission decision. The Verify run for that tip still refuses every later readiness decision against this base branch if it goes red.";
15228
15250
  /**
15229
15251
  * The notice each settling phase earns, from the witness that phase carries.
15230
15252
  *
@@ -15239,7 +15261,7 @@ const settlingMergeFreezeNotice = (headSha, generation) => {
15239
15261
  };
15240
15262
  /**
15241
15263
  * The freeze read during readiness, before any authorized arming (#477,
15242
- * ADR 0016 as amended 2026-07-31/2026-08-01).
15264
+ * ADR 0016).
15243
15265
  *
15244
15266
  * An **active** generation refuses readiness and names both exits. **Unreadable**
15245
15267
  * authority refuses too — the identity and ordering predicates are unchanged,
@@ -17568,11 +17590,12 @@ const priorLadderStateFor = ({ base, cwd, headSha, isAncestor, output }) => {
17568
17590
  }, isAncestor) ? prior.ladder : void 0;
17569
17591
  };
17570
17592
  const normalizedDispositionKey = ({ file, title }) => `(title=${JSON.stringify(normalizeTitle(title))}, file=${file === void 0 ? "<unspecified>" : JSON.stringify(normalizeFile(file))})`;
17571
- const assertDeclaredDispositionsMatch = (priorLedger, declarations) => {
17572
- const unmatched = declarations.filter((declaration) => !priorLedger.some((entry) => declarationMatchesEntry(entry, declaration.finding)));
17593
+ const assertDeclaredDispositionsMatch = (priorLedger, declarations, currentFindings = []) => {
17594
+ const unmatched = declarations.filter((declaration) => !priorLedger.some((entry) => declarationMatchesEntry(entry, declaration.finding)) && !currentFindings.some((finding) => declarationMatchesEntry(finding, declaration.finding)));
17573
17595
  if (unmatched.length > 0) {
17574
17596
  const unmatchedKeys = unmatched.map((declaration) => normalizedDispositionKey(declaration.finding)).join("; ");
17575
- const availableKeys = priorLedger.length === 0 ? "prior ledger is empty" : priorLedger.map((entry) => normalizedDispositionKey(entry)).join("; ");
17597
+ const availableTargets = [...priorLedger.map((entry) => normalizedDispositionKey(entry)), ...currentFindings.map((finding) => normalizedDispositionKey(finding))];
17598
+ const availableKeys = availableTargets.length === 0 ? "prior ledger is empty" : availableTargets.join("; ");
17576
17599
  throw new Error(`--dispositions entries match no ledger finding. Unmatched normalized declaration keys: ${unmatchedKeys}. Available normalized ledger keys: ${availableKeys}. Declarations are keyed by normalized title plus file; check both against the persisted ladder ledger.`);
17577
17600
  }
17578
17601
  };
@@ -17594,10 +17617,19 @@ function ladderStateForRun({ declaredDispositions, cycle, historical, identity,
17594
17617
  ...sessionAttribution?.sessionId ? { sessionId: sessionAttribution.sessionId } : {},
17595
17618
  ...sessionAttribution?.runtime ? { runtime: sessionAttribution.runtime } : {}
17596
17619
  };
17620
+ const sameCycleDeclared = historical === true ? declaredDispositions.flatMap((declaration) => {
17621
+ const finding = findings.find((candidate) => declarationMatchesEntry(candidate, declaration.finding));
17622
+ return finding === void 0 ? [] : [{
17623
+ ...declaration,
17624
+ findingReport: finding
17625
+ }];
17626
+ }) : [];
17627
+ const priorDeclared = historical === true ? declaredDispositions.filter((declaration) => !findings.some((candidate) => declarationMatchesEntry(candidate, declaration.finding))) : declaredDispositions;
17597
17628
  const currentCycle = {
17598
17629
  dispositions: [
17599
17630
  ...inferenceSafe ? inferFixedInThreadDispositions(priorLedger, findings, { sameHeadAsPriorCycle }) : [],
17600
- ...declaredDispositions,
17631
+ ...priorDeclared,
17632
+ ...sameCycleDeclared,
17601
17633
  ...priorCycles.length >= 1 || (cycle ?? 1) >= 2 ? findings.filter((finding) => {
17602
17634
  const severity = resolveReviewFindingSeverity(finding.priority);
17603
17635
  return severity === "medium" || severity === "low";
@@ -17675,7 +17707,7 @@ ${waveRung === void 0 ? "" : ` "producer": "<the engine and model that ran this
17675
17707
  }
17676
17708
  ${authorityRule(waveRung)}
17677
17709
  ${SESSION_ID_RULE}
17678
- Use category "correctness", "safety", "coordination", or "maintainability"; set blockingAfterCap true for correctness, safety, or coordination findings. citedSpan must be the exact code text the finding is about, quoted verbatim from the diff (identity is derived from this, not from the title, so quote precisely). prescribedAction must be a concise, standalone statement of the required fix (identity is derived from this text too). If this finding is a restatement of a prior finding shown in the prior review ledger below, set supersedes to {"title": "<exact prior title>", "file": "<its file>"} instead of minting a new title for the same theme.`;
17710
+ Use category "correctness", "safety", "coordination", or "maintainability"; set blockingAfterCap true for correctness, safety, or coordination findings. citedSpan must be the exact code text the finding is about, quoted verbatim from the diff (identity is derived from this, not from the title, so quote precisely). prescribedAction must be a concise, standalone statement of the required fix (identity is derived from this text too).`;
17679
17711
  };
17680
17712
  //#endregion
17681
17713
  //#region src/review-prompt.ts
@@ -17756,7 +17788,7 @@ ${focus.text}`
17756
17788
  const priorLedgerEntryLine = (entry) => {
17757
17789
  const location = entry.file ? ` (${entry.file})` : "";
17758
17790
  const reference = entry.reference ? ` — ${entry.reference}` : "";
17759
- return `- [${entry.disposition}] "${entry.title}"${location}${reference} (flagged ${entry.flagCount}x)`;
17791
+ return `- [${entry.disposition === "open" ? "open, unadjudicated" : entry.disposition}] "${entry.title}"${location}${reference} (flagged ${entry.flagCount}x)`;
17760
17792
  };
17761
17793
  const priorLedgerSegment = (entries) => ({
17762
17794
  section: {
@@ -17764,13 +17796,13 @@ const priorLedgerSegment = (entries) => ({
17764
17796
  source: "persisted pr:review disposition ledger (prior cycles, #233)",
17765
17797
  text: entries.map(priorLedgerEntryLine).join("\n")
17766
17798
  },
17767
- text: `Prior review ledger (source: persisted pr:review disposition ledger from earlier cycles on this PR; already adjudicated — do not re-litigate):
17799
+ text: `Prior review ledger (source: persisted pr:review disposition ledger from earlier cycles on this PR):
17768
17800
  ${entries.map(priorLedgerEntryLine).join("\n")}
17769
17801
 
17770
- Do not re-raise any of the findings above under a new title: they are already adjudicated (waived, filed as a follow-up, or fixed in this thread). If new evidence genuinely supersedes one of them, say so explicitly and name the exact prior finding (its title and file) that it supersedes, rather than minting an unrelated title for the same underlying theme.`
17802
+ Open entries above are unadjudicated: they still need a disposition or a supersedes citation. Do not re-raise any already-adjudicated finding (waived, filed as a follow-up, or fixed in this thread) under a new title. If this finding is a restatement of a prior finding shown in this ledger, set supersedes to {"title": "<exact prior title>", "file": "<its file>"} instead of minting a new title for the same theme.`
17771
17803
  });
17772
17804
  const assembleReviewPrompt = ({ base, changedFiles, cwd = process.cwd(), headSha, issueFocus, kind, patchId, priorLedgerEntries, profile, waveRung }, dependencies = {}) => {
17773
- const priorLedger = priorLedgerEntries ? nonOpenLadderFindings(priorLedgerEntries) : [];
17805
+ const priorLedger = priorLedgerEntries ?? [];
17774
17806
  const optionalSegments = {
17775
17807
  "coding-standards": codingStandardsSegment({
17776
17808
  contents: kind === "correctness" ? readCodingStandardsAtHead({
@@ -17991,7 +18023,9 @@ const reviewResults = (findingsFile, kinds, startedAt, endedAt) => {
17991
18023
  const assertReviewOutcomes = (proof, policy, cycle, maxCycles) => {
17992
18024
  if (!proof.reviews.some((review) => resolveReviewAcceptance(proof, review, policy) === "blocked")) return;
17993
18025
  const state = reviewCycleStateFor(proof, policy);
17994
- console.error(cycle >= maxCycles ? `Review cycle limit reached (${cycle}/${maxCycles}). Stop and request human direction before rerunning.` : `Fix ${state.openFindings} actionable finding(s) before rerunning pr:review as cycle ${cycle + 1}/${maxCycles}.`);
18026
+ if (proof.reviews.some((review) => review.outcome === "passed")) console.error(`Stored review outcome is passed, but a re-fold of persisted cycles yields ${state.autoBlockingFindings} auto-blocking finding(s) and ${state.openFindings} open finding(s). The stored pass and the ladder disagree.`);
18027
+ else if (cycle >= maxCycles) console.error(`Review cycle limit reached (${cycle}/${maxCycles}). Stop and request human direction before rerunning.`);
18028
+ else console.error(`Fix ${state.openFindings} actionable finding(s) before rerunning pr:review as cycle ${cycle + 1}/${maxCycles}.`);
17995
18029
  throw new Error("pr:review found actionable review findings.");
17996
18030
  };
17997
18031
  const loadIssueFocus = (issue, profile, fetchIssueBody) => {
@@ -18124,7 +18158,7 @@ const resolveFindingsBinding = ({ args, cwd, findingsPatchId, git, headSha, patc
18124
18158
  });
18125
18159
  if (args.historical !== true) {
18126
18160
  if (atCandidate) return { kind: "candidate" };
18127
- throw new Error(`Review findings patchId ${findingsPatchId} does not match the candidate stable patch-id ${patchId}. Every fix moves the patch-id, so a cycle must be recorded before the fixes that answer it. If these findings read an earlier commit on this branch, record them with pr:review --historical: the cycle joins the ladder as prior history and renders no verdict of its own, so it never admits. Declare each finding the fixes answered with --dispositions.`);
18161
+ throw new Error(`Review findings patchId ${findingsPatchId} does not match the candidate stable patch-id ${patchId}. Every fix moves the patch-id, so a cycle must be recorded before the fixes that answer it. If these findings read an earlier commit on this branch, record them first with pr:review --historical --findings <path>: the cycle joins the ladder as prior history and renders no verdict of its own, so it never admits. That historical recording may carry --dispositions for the findings it itself flags. Otherwise declare each finding the fixes answered with --dispositions on the next cycle, once the findings are persisted ledger entries.`);
18128
18162
  }
18129
18163
  if (atCandidate) throw new Error(`--historical findings already match the candidate stable patch-id ${patchId}. Record them as the current cycle, without --historical.`);
18130
18164
  const ancestors = resolveAncestorCandidatePatchIds({
@@ -18141,6 +18175,7 @@ const resolveFindingsBinding = ({ args, cwd, findingsPatchId, git, headSha, patc
18141
18175
  ref: matched.ref
18142
18176
  };
18143
18177
  };
18178
+ const currentCycleDispositionTargets = (historical, findingsFile) => historical ? findingsFile.reviews.flatMap((review) => review.findings) : [];
18144
18179
  /** The flag combinations that describe no single run. */
18145
18180
  const assertReviewFlagsCombine = (args) => {
18146
18181
  if (args.assemble === true && args.findings !== void 0) throw new Error("--assemble and --findings cannot be combined; they are sequential steps.");
@@ -18363,8 +18398,8 @@ async function runPrReview(args, dependencies = {}) {
18363
18398
  });
18364
18399
  const priorLedger = priorLedgerFor(priorLadder);
18365
18400
  const declaredDispositions = declaredDispositionsFor(args.dispositions, cwd);
18366
- assertDeclaredDispositionsMatch(priorLedger, declaredDispositions);
18367
18401
  const historical = binding.kind === "historical";
18402
+ assertDeclaredDispositionsMatch(priorLedger, declaredDispositions, currentCycleDispositionTargets(historical, findingsFile));
18368
18403
  const { identity, reviews } = cycleRecordFor({
18369
18404
  binding,
18370
18405
  cwd,
@@ -18463,7 +18498,7 @@ function editPullRequestBody({ body, owner, repo, pr }) {
18463
18498
  repo
18464
18499
  }), process.cwd());
18465
18500
  }
18466
- const defaultGhRunner = (args, cwd) => runCapture("gh", args, cwd).stdout;
18501
+ const defaultGhRunner$1 = (args, cwd) => runCapture("gh", args, cwd).stdout;
18467
18502
  const resolveFollowUpArgv = (followUp, factoryCliInvocation) => {
18468
18503
  const [command, ...args] = followUp.argv;
18469
18504
  if (!command) throw new Error("pr:ready returned an empty follow-up argv.");
@@ -18961,7 +18996,7 @@ async function runPrPublish(args, dependencies = {}) {
18961
18996
  args,
18962
18997
  branch: args.pr === void 0 ? (dependencies.currentBranch ?? currentBranchName)(cwd) : void 0,
18963
18998
  cwd,
18964
- gh: dependencies.gh ?? defaultGhRunner
18999
+ gh: dependencies.gh ?? defaultGhRunner$1
18965
19000
  });
18966
19001
  const readyArgs = {
18967
19002
  ...args,
@@ -19165,7 +19200,7 @@ function createPrPublishCommand(_output, action = runPrPublish) {
19165
19200
  //#endregion
19166
19201
  //#region src/commands/pr-ready.ts
19167
19202
  function createPrReadyCommand(output, action) {
19168
- return markCwdOptionDefault(new Command("pr:ready").description("Evaluate typed proof and GitHub state for final readiness").requiredOption("--pr <number>", "pull request number", positiveInteger$1("--pr")).option("--base <ref>", "explicit base branch or ref override").option("--authoring-session <id...>", "recorded authoring session id(s) for review-type requiredCheck independence (ADR 0014 §5)").option("--cwd <path>", "working directory to evaluate", collectCwdOption).option("--epic <number>", "issue carrying the factory-boundary manifest, which may be the work issue itself; readiness then evaluates the wave-demanded review rung (#351, #789)", positiveInteger$1("--epic")).option("--json", "print the readiness proof as JSON").option("--output <path>", "write proof JSON to a file").option("--profile <path>", "path to the project profile JSON file").option("--review-proof <path>", "pr:review proof JSON path").option("--verify-proof <path>", "pr:verify proof JSON path").action(withGateTiming({
19203
+ return markCwdOptionDefault(new Command("pr:ready").description("Evaluate typed proof and GitHub state for final readiness").requiredOption("--pr <number>", "pull request number", positiveInteger$1("--pr")).option("--base <ref>", "explicit base branch or ref override").option("--authoring-session <id...>", "recorded authoring session id(s) for review-type requiredCheck independence (ADR 0014)").option("--cwd <path>", "working directory to evaluate", collectCwdOption).option("--epic <number>", "issue carrying the factory-boundary manifest, which may be the work issue itself; readiness then evaluates the wave-demanded review rung (#351, #789)", positiveInteger$1("--epic")).option("--json", "print the readiness proof as JSON").option("--output <path>", "write proof JSON to a file").option("--profile <path>", "path to the project profile JSON file").option("--review-proof <path>", "pr:review proof JSON path").option("--verify-proof <path>", "pr:verify proof JSON path").action(withGateTiming({
19169
19204
  gate: "pr:ready",
19170
19205
  resolveLedgerRoot: (options) => resolveCwdOption(options.cwd),
19171
19206
  stderr: output.stderr
@@ -19212,7 +19247,7 @@ function assertForegroundGate({ gate, env = process.env }) {
19212
19247
  //#endregion
19213
19248
  //#region src/commands/pr-review.ts
19214
19249
  function createPrReviewCommand(output, action) {
19215
- return markCwdOptionDefault(new Command("pr:review").description("Assemble a factory-owned review prompt, or validate clean-session findings and write typed review proof").option("--base <ref>", "base branch or ref", "origin/main").option("--cwd <path>", "working directory to review", collectCwdOption).option("--cycle <number>", "review cycle number", positiveInteger$1("--cycle"), 1).option("--mode <mode>", "correctness, security, or all", "all").option("--assemble", "write the factory-owned review prompt for a clean session; do not validate findings").option("--epic <number>", "issue carrying the factory-boundary manifest, which may be the work issue itself; --assemble then asks the reviewer for the findings schema the wave-demanded review rung accepts (#1001)", positiveInteger$1("--epic")).option("--findings <path>", "typed clean-session findings JSON file").option("--historical", "record findings that read an earlier commit on this branch: the cycle joins the ladder as prior history and renders no verdict of its own, so it never admits. It leaves the candidate's recorded verdict untouched. Use --dispositions for the findings its fixes answered").option("--dispositions <path>", "JSON file of ladder disposition declarations (waived / follow-up-filed / fixed-in-thread) for previously flagged findings").option("--issue <number>", "issue number recorded on the review ladder trace; --assemble also loads Review focus from this issue", positiveInteger$1("--issue")).option("--output <path>", "write proof JSON to a file").option("--profile <path>", "path to the project profile JSON file").option("--verify-proof <path>", "pr:verify proof JSON path").action(withGateTiming({
19250
+ return markCwdOptionDefault(new Command("pr:review").description("Assemble a factory-owned review prompt, or validate clean-session findings and write typed review proof").option("--base <ref>", "base branch or ref", "origin/main").option("--cwd <path>", "working directory to review", collectCwdOption).option("--cycle <number>", "review cycle number", positiveInteger$1("--cycle"), 1).option("--mode <mode>", "correctness, security, or all", "all").option("--assemble", "write the factory-owned review prompt for a clean session; do not validate findings").option("--epic <number>", "issue carrying the factory-boundary manifest, which may be the work issue itself; --assemble then asks the reviewer for the findings schema the wave-demanded review rung accepts (#1001)", positiveInteger$1("--epic")).option("--findings <path>", "typed clean-session findings JSON file").option("--historical", "record findings that read an earlier commit on this branch: the cycle joins the ladder as prior history and renders no verdict of its own, so it never admits. It leaves the candidate's recorded verdict untouched. This recording may carry --dispositions for the findings it flags; otherwise declare them on the next cycle once they are persisted ledger entries").option("--dispositions <path>", "JSON file of ladder disposition declarations (waived / follow-up-filed / fixed-in-thread) for previously flagged findings, or for the findings a --historical recording itself flags").option("--issue <number>", "issue number recorded on the review ladder trace; --assemble also loads Review focus from this issue", positiveInteger$1("--issue")).option("--output <path>", "write proof JSON to a file").option("--profile <path>", "path to the project profile JSON file").option("--verify-proof <path>", "pr:verify proof JSON path").action(withGateTiming({
19216
19251
  gate: "pr:review",
19217
19252
  resolveCycle: (options) => options.cycle,
19218
19253
  resolveLedgerRoot: (options) => resolveCwdOption(options.cwd),
@@ -19270,56 +19305,544 @@ function modeFor(options) {
19270
19305
  if (options.full) return "full";
19271
19306
  return "auto";
19272
19307
  }
19308
+ /** The GitHub deployment `task` that names receipts this tool wrote. */
19309
+ const DEPLOYMENT_RECEIPT_TASK = "patronage-factory/deployment";
19310
+ /** The `performed_via_github_app.id` of GitHub Actions itself. */
19311
+ const GITHUB_ACTIONS_APP_ID = 15368;
19312
+ const RECEIPT_PAGE_SIZE = 100;
19313
+ var DeploymentReceiptTransportError = class extends Error {
19314
+ code;
19315
+ constructor(code) {
19316
+ super(`deployment receipt transport failed: ${code}`);
19317
+ this.name = "DeploymentReceiptTransportError";
19318
+ this.code = code;
19319
+ }
19320
+ };
19321
+ /** The code a failure is reported as; anything not classified is `transport`. */
19322
+ const transportCode = (error) => error instanceof DeploymentReceiptTransportError ? error.code : "transport";
19323
+ const COMMIT_SHA_PATTERN$1 = /^[0-9a-f]{40}$/u;
19324
+ const REPOSITORY_PATTERN = /^[^/\s]+\/[^/\s]+$/u;
19325
+ const RUN_ID_PATTERN = /^[1-9]\d*$/u;
19326
+ const WORKFLOW_REF_PATTERN = /^(?<repository>[^/\s]+\/[^/\s]+)\/(?<path>\.github\/workflows\/[^@\s]+)@(?<ref>refs\/\S+)$/u;
19327
+ /**
19328
+ * Read the run identity from GitHub Actions environment variables. Returns
19329
+ * the missing or malformed variable names when the environment is not a
19330
+ * workflow run; the caller then refuses to read or write receipts.
19331
+ */
19332
+ const workflowRunIdentityFromEnv = (env) => {
19333
+ const invalid = [];
19334
+ const workflowRef = WORKFLOW_REF_PATTERN.exec(env.GITHUB_WORKFLOW_REF ?? "");
19335
+ const repository = env.GITHUB_REPOSITORY ?? "";
19336
+ const runId = env.GITHUB_RUN_ID ?? "";
19337
+ const runAttempt = env.GITHUB_RUN_ATTEMPT ?? "";
19338
+ const serverUrl = env.GITHUB_SERVER_URL ?? "";
19339
+ const ref = env.GITHUB_REF ?? "";
19340
+ if (!REPOSITORY_PATTERN.test(repository)) invalid.push("GITHUB_REPOSITORY");
19341
+ if (!workflowRef?.groups || REPOSITORY_PATTERN.test(repository) && workflowRef.groups.repository !== repository) invalid.push("GITHUB_WORKFLOW_REF");
19342
+ if (!RUN_ID_PATTERN.test(runId)) invalid.push("GITHUB_RUN_ID");
19343
+ if (!RUN_ID_PATTERN.test(runAttempt)) invalid.push("GITHUB_RUN_ATTEMPT");
19344
+ if (!/^https:\/\/[^/\s]+$/u.test(serverUrl)) invalid.push("GITHUB_SERVER_URL");
19345
+ if (!ref.startsWith("refs/")) invalid.push("GITHUB_REF");
19346
+ if (invalid.length > 0 || !workflowRef?.groups?.path) return { invalid };
19347
+ return { identity: {
19348
+ ref,
19349
+ repository,
19350
+ runAttempt: Number(runAttempt),
19351
+ runId,
19352
+ serverUrl,
19353
+ workflowPath: workflowRef.groups.path
19354
+ } };
19355
+ };
19356
+ /** The payload this tool writes into every deployment it creates. */
19357
+ const receiptPayloadSchema = z.object({
19358
+ repository: z.string().regex(REPOSITORY_PATTERN),
19359
+ schemaVersion: z.literal(1),
19360
+ sourceSha: z.string().regex(COMMIT_SHA_PATTERN$1),
19361
+ stage: z.string().min(1).optional(),
19362
+ target: z.string().min(1),
19363
+ tool: z.object({
19364
+ name: z.literal(name),
19365
+ version: z.string().min(1)
19366
+ }),
19367
+ workflow: z.object({
19368
+ path: z.string().min(1),
19369
+ ref: z.string().min(1),
19370
+ runAttempt: z.number().int().positive(),
19371
+ runId: z.string().regex(RUN_ID_PATTERN)
19372
+ })
19373
+ });
19374
+ const deploymentRecordSchema = z.object({
19375
+ created_at: z.string(),
19376
+ environment: z.string(),
19377
+ id: z.number().int(),
19378
+ /** Fixed at creation. `environment` can be rewritten by a later status write. */
19379
+ original_environment: z.string(),
19380
+ payload: z.unknown(),
19381
+ performed_via_github_app: z.object({ id: z.number().int() }).nullable().optional(),
19382
+ sha: z.string(),
19383
+ task: z.string()
19384
+ });
19385
+ const workflowRunJobSchema = z.object({
19386
+ conclusion: z.string().nullable(),
19387
+ head_sha: z.string(),
19388
+ id: z.number().int(),
19389
+ name: z.string(),
19390
+ steps: z.array(z.object({
19391
+ conclusion: z.string().nullable(),
19392
+ name: z.string()
19393
+ })).optional()
19394
+ });
19395
+ const workflowRunRecordSchema = z.object({
19396
+ head_branch: z.string().nullable(),
19397
+ head_sha: z.string(),
19398
+ id: z.number().int(),
19399
+ path: z.string(),
19400
+ repository: z.object({ full_name: z.string() })
19401
+ });
19402
+ /** The deployment environment one target's receipts live in. */
19403
+ const deploymentReceiptEnvironment = (target) => target;
19404
+ const runUrl = (identity, runId) => `${identity.serverUrl}/${identity.repository}/actions/runs/${runId}`;
19405
+ /** The `$GITHUB_OUTPUT` key a success publication writes the new deployment id under. */
19406
+ const RECEIPT_DEPLOYMENT_ID_OUTPUT = "deployment_id";
19407
+ /**
19408
+ * The name of the generated step that binds one deployment record to the run
19409
+ * that created it. The identity is the record's server-assigned id alone,
19410
+ * so no two records render the same name and no target or stage text takes
19411
+ * part. `factoryProductionImpactWorkflow().receiptSteps()` emits this text
19412
+ * with the id as a step-output expression; the production-impact integration
19413
+ * test holds the two together. The reader looks the rendered name up in
19414
+ * GitHub's job record, where only the run itself can have put it.
19415
+ */
19416
+ const boundReceiptStepName = (deploymentId) => `Bind deployment receipt ${deploymentId}`;
19417
+ /**
19418
+ * Bind the deployment record and its payload to this reader: performed by
19419
+ * GitHub Actions, written by this tool for this repository, target, stage,
19420
+ * source, workflow, and ref.
19421
+ */
19422
+ const bindReceiptPayload = (args, deployment) => {
19423
+ const label = `deployment ${deployment.id}`;
19424
+ if (deployment.performed_via_github_app?.id !== GITHUB_ACTIONS_APP_ID) return { reason: `${label} was not performed by GitHub Actions` };
19425
+ if (deployment.task !== "patronage-factory/deployment") return { reason: `${label} has a foreign task, not a factory receipt` };
19426
+ const payload = receiptPayloadSchema.safeParse(deployment.payload);
19427
+ if (!payload.success) return { reason: `${label} payload is not a schema-version-1 factory receipt` };
19428
+ const receipt = payload.data;
19429
+ if (receipt.repository !== args.identity.repository) return { reason: `${label} names another repository` };
19430
+ if (receipt.target !== args.target || receipt.stage !== args.stage) return { reason: `${label} names another target or stage` };
19431
+ if (receipt.sourceSha !== deployment.sha.toLowerCase()) return { reason: `${label} payload source ${receipt.sourceSha} differs from its deployment sha` };
19432
+ if (receipt.workflow.path !== args.identity.workflowPath) return { reason: `${label} was written by another workflow file, not ${args.identity.workflowPath}` };
19433
+ if (receipt.workflow.ref !== args.identity.ref) return { reason: `${label} was written for another ref, not ${args.identity.ref}` };
19434
+ return { receipt };
19435
+ };
19436
+ /**
19437
+ * Judge one deployment record. The payload bindings come first; then the run
19438
+ * GitHub records for the payload's run id must agree with the receipt and
19439
+ * must have bound this record's id. The first failure names the receipt
19440
+ * untrusted. Statuses are not read: they are caller-written.
19441
+ */
19442
+ const trustReceipt = (args, deployment) => {
19443
+ const label = `deployment ${deployment.id}`;
19444
+ const bound = bindReceiptPayload(args, deployment);
19445
+ if ("reason" in bound) return bound;
19446
+ const { receipt } = bound;
19447
+ let runRecord;
19448
+ let jobRecords;
19449
+ try {
19450
+ runRecord = args.transport.getWorkflowRun(receipt.workflow.runId);
19451
+ jobRecords = args.transport.listWorkflowRunJobs(receipt.workflow.runId);
19452
+ } catch (error) {
19453
+ return { reason: `${label} names run ${receipt.workflow.runId}, which could not be read (transport error code: ${transportCode(error)})` };
19454
+ }
19455
+ const run = workflowRunRecordSchema.safeParse(runRecord);
19456
+ if (!run.success) return { reason: `${label} names run ${receipt.workflow.runId}, which GitHub does not record` };
19457
+ const expectedBranch = args.identity.ref.replace(/^refs\/heads\//u, "");
19458
+ if (run.data.path !== args.identity.workflowPath || run.data.repository.full_name !== args.identity.repository || run.data.head_sha.toLowerCase() !== receipt.sourceSha || run.data.head_branch !== expectedBranch) return { reason: `${label} names run ${receipt.workflow.runId}, whose recorded workflow, repository, head, or branch disagrees with the receipt` };
19459
+ const jobs = z.object({ jobs: z.array(workflowRunJobSchema) }).safeParse(jobRecords);
19460
+ if (!jobs.success) return { reason: `${label} names run ${receipt.workflow.runId}, whose job records are malformed` };
19461
+ const stepName = boundReceiptStepName(deployment.id);
19462
+ if (jobs.data.jobs.find((job) => job.conclusion === "success" && job.head_sha.toLowerCase() === receipt.sourceSha && job.steps?.some((step) => step.name === stepName && step.conclusion === "success")) === void 0) return { reason: `${label} names run ${receipt.workflow.runId}, but no succeeded job of that run at ${receipt.sourceSha} bound deployment ${deployment.id}` };
19463
+ return {
19464
+ baseline: {
19465
+ deploymentId: deployment.id,
19466
+ runId: receipt.workflow.runId,
19467
+ sha: receipt.sourceSha
19468
+ },
19469
+ trusted: true
19470
+ };
19471
+ };
19472
+ /**
19473
+ * Resolve the trusted baseline for one target from GitHub deployment
19474
+ * records. The repository's newest page of records is read once and the
19475
+ * target's records are those whose `original_environment` is the target;
19476
+ * a target whose newest record is older than that page has no baseline. The
19477
+ * newest such record decides: bound by
19478
+ * its run's job record, it is the baseline; otherwise the baseline is
19479
+ * absent, and an older trusted receipt does not rescue it. Failed, partial,
19480
+ * foreign, and copied records are all unbound, so every one of them demands
19481
+ * reconciliation. Do not declare `environment: <target>` on a job; the
19482
+ * record GitHub creates for it is unbound too.
19483
+ */
19484
+ const resolveDeploymentBaseline = (args) => {
19485
+ const environment = deploymentReceiptEnvironment(args.target);
19486
+ let deployments;
19487
+ try {
19488
+ const parsed = z.array(deploymentRecordSchema).safeParse(args.transport.listDeployments());
19489
+ if (!parsed.success) return {
19490
+ kind: "absent",
19491
+ reasons: [`deployment records for ${environment} are malformed`]
19492
+ };
19493
+ deployments = parsed.data.filter((deployment) => deployment.original_environment === environment).toSorted((left, right) => left.created_at === right.created_at ? right.id - left.id : right.created_at.localeCompare(left.created_at));
19494
+ } catch (error) {
19495
+ return {
19496
+ kind: "absent",
19497
+ reasons: [`deployment records for ${environment} could not be read (transport error code: ${transportCode(error)})`]
19498
+ };
19499
+ }
19500
+ const [newest] = deployments;
19501
+ if (newest === void 0) return {
19502
+ kind: "absent",
19503
+ reasons: [`no deployment receipt for ${environment}${args.stage ? ` stage ${args.stage}` : ""}`]
19504
+ };
19505
+ const verdict = trustReceipt(args, newest);
19506
+ if ("trusted" in verdict) return {
19507
+ baseline: verdict.baseline,
19508
+ kind: "trusted"
19509
+ };
19510
+ return {
19511
+ kind: "absent",
19512
+ reasons: [`newest receipt is not a bound success: ${verdict.reason}`]
19513
+ };
19514
+ };
19515
+ const createdDeploymentSchema = z.object({ id: z.number().int() });
19516
+ /**
19517
+ * Write one receipt: a deployment bound to this run, then its terminal
19518
+ * status. The payload carries only the fixed identity fields above; no
19519
+ * command output and no secret can enter it. A `success` receipt marks
19520
+ * earlier successful deployments of the environment inactive, so the newest
19521
+ * success is always what production runs.
19522
+ */
19523
+ const publishDeploymentReceipt = (args) => {
19524
+ const environment = deploymentReceiptEnvironment(args.target);
19525
+ const payload = {
19526
+ repository: args.identity.repository,
19527
+ schemaVersion: 1,
19528
+ sourceSha: args.sourceSha,
19529
+ ...args.stage === void 0 ? {} : { stage: args.stage },
19530
+ target: args.target,
19531
+ tool: {
19532
+ name,
19533
+ version
19534
+ },
19535
+ workflow: {
19536
+ path: args.identity.workflowPath,
19537
+ ref: args.identity.ref,
19538
+ runAttempt: args.identity.runAttempt,
19539
+ runId: args.identity.runId
19540
+ }
19541
+ };
19542
+ const created = createdDeploymentSchema.parse(args.transport.createDeployment({
19543
+ auto_merge: false,
19544
+ description: `${DEPLOYMENT_RECEIPT_TASK} ${args.target} at ${args.sourceSha}`,
19545
+ environment,
19546
+ payload,
19547
+ production_environment: true,
19548
+ ref: args.sourceSha,
19549
+ required_contexts: [],
19550
+ task: DEPLOYMENT_RECEIPT_TASK
19551
+ }));
19552
+ args.transport.createDeploymentStatus(created.id, {
19553
+ auto_inactive: args.state === "success",
19554
+ description: `${args.state}: ${args.target} at ${args.sourceSha} by run ${args.identity.runId} attempt ${args.identity.runAttempt}`,
19555
+ log_url: `${runUrl(args.identity, args.identity.runId)}/attempts/${args.identity.runAttempt}`,
19556
+ state: args.state
19557
+ });
19558
+ return {
19559
+ deploymentId: created.id,
19560
+ environment,
19561
+ payload,
19562
+ state: args.state
19563
+ };
19564
+ };
19565
+ const defaultGhRunner = (args, cwd) => runCapture("gh", args, cwd).stdout;
19566
+ const HTTP_STATUS_PATTERN = /\bHTTP (?<status>\d{3})\b/u;
19567
+ /**
19568
+ * Classify a failed `gh` call from the HTTP status gh reports. The error text
19569
+ * is read here and dropped; only the code leaves this function.
19570
+ */
19571
+ const classifyGhFailure = (error) => {
19572
+ const text = error instanceof Error ? error.message : "";
19573
+ const status = Number(HTTP_STATUS_PATTERN.exec(text)?.groups?.status ?? 0);
19574
+ if (status === 401 || status === 403) return new DeploymentReceiptTransportError("unauthorized");
19575
+ if (status === 404) return new DeploymentReceiptTransportError("not-found");
19576
+ if (status === 429) return new DeploymentReceiptTransportError("rate-limited");
19577
+ if (status >= 500) return new DeploymentReceiptTransportError("unavailable");
19578
+ return new DeploymentReceiptTransportError("transport");
19579
+ };
19580
+ const ghJson = (run, args, cwd) => {
19581
+ let stdout;
19582
+ try {
19583
+ stdout = run(args, cwd);
19584
+ } catch (error) {
19585
+ throw classifyGhFailure(error);
19586
+ }
19587
+ try {
19588
+ return JSON.parse(stdout);
19589
+ } catch {
19590
+ throw new DeploymentReceiptTransportError("malformed-response");
19591
+ }
19592
+ };
19593
+ /**
19594
+ * The `gh`-backed transport. Reads take the first page of 100, which is
19595
+ * newest first; a target with more than 100 deployments still resolves from
19596
+ * the newest. The environment is not filtered by task, so a foreign
19597
+ * deployment of the same environment is judged, not hidden. Writes hand the JSON body to `gh api --input` through a file,
19598
+ * because `runCapture` does not open stdin.
19599
+ */
19600
+ const ghDeploymentReceiptTransport = (cwd, repository, run = defaultGhRunner) => {
19601
+ const api = (route) => ghJson(run, ["api", route], cwd);
19602
+ const post = (route, body) => {
19603
+ const directory = mkdtempSync(path.join(tmpdir(), "factory-receipt-"));
19604
+ try {
19605
+ const input = path.join(directory, "body.json");
19606
+ writeFileSync(input, JSON.stringify(body), "utf-8");
19607
+ return ghJson(run, [
19608
+ "api",
19609
+ "-X",
19610
+ "POST",
19611
+ route,
19612
+ "--input",
19613
+ input
19614
+ ], cwd);
19615
+ } finally {
19616
+ rmSync(directory, {
19617
+ force: true,
19618
+ recursive: true
19619
+ });
19620
+ }
19621
+ };
19622
+ const base = `/repos/${repository}`;
19623
+ return {
19624
+ createDeployment: (body) => post(`${base}/deployments`, body),
19625
+ createDeploymentStatus: (deploymentId, body) => post(`${base}/deployments/${deploymentId}/statuses`, body),
19626
+ getWorkflowRun: (runId) => api(`${base}/actions/runs/${runId}`),
19627
+ listDeploymentStatuses: (deploymentId) => api(`${base}/deployments/${deploymentId}/statuses?per_page=${RECEIPT_PAGE_SIZE}`),
19628
+ listDeployments: () => api(`${base}/deployments?per_page=${RECEIPT_PAGE_SIZE}`),
19629
+ listWorkflowRunJobs: (runId) => api(`${base}/actions/runs/${runId}/jobs?per_page=${RECEIPT_PAGE_SIZE}`)
19630
+ };
19631
+ };
19273
19632
  //#endregion
19274
19633
  //#region src/production-impact.ts
19275
- const PRODUCTION_IMPACT_DECISION_SCHEMA_VERSION = 1;
19634
+ /**
19635
+ * Production deployment demand (#1075, epic #1063).
19636
+ *
19637
+ * Two questions live here and they stay separate:
19638
+ *
19639
+ * 1. Per-change impact: does a delta touch a target? `computeImpactStamp`
19640
+ * answers it for any two commits, and `candidate:impact` uses it for pull
19641
+ * requests.
19642
+ * 2. Permission to withdraw a production deploy: may this candidate skip a
19643
+ * target? Only the delta from the target's last trusted successful
19644
+ * deployment receipt to the candidate can answer it. A push's before/after
19645
+ * commits cannot, because a failed or cancelled push leaves its changes
19646
+ * undeployed and the next push's diff does not contain them.
19647
+ *
19648
+ * Every doubt path keeps the target demanded. Only a trusted baseline that
19649
+ * is an ancestor of the candidate and whose delta provably misses the target
19650
+ * withdraws it, and only a trusted baseline that the candidate is an
19651
+ * ancestor of supersedes it.
19652
+ */
19653
+ const PRODUCTION_IMPACT_DECISION_SCHEMA_VERSION = 2;
19276
19654
  const COMMIT_SHA_PATTERN = /^[0-9a-f]{40}$/iu;
19277
19655
  const ZERO_SHA_PATTERN = /^0{40}$/u;
19656
+ /**
19657
+ * A refusal this module states in its own fixed words. Only its message may
19658
+ * enter evidence; every other throw is reported by class as a code.
19659
+ */
19660
+ var RefusalError = class extends Error {
19661
+ constructor(message) {
19662
+ super(message);
19663
+ this.name = "RefusalError";
19664
+ }
19665
+ };
19666
+ /**
19667
+ * Run git for this decision. A failure carries git's stderr, which is
19668
+ * unclassified text, so it is rethrown as the `git` transport code.
19669
+ */
19670
+ const git = (cwd, args) => {
19671
+ try {
19672
+ return runCapture("git", args, cwd).stdout;
19673
+ } catch {
19674
+ throw new DeploymentReceiptTransportError("git");
19675
+ }
19676
+ };
19677
+ /** The reason a refusal records: fixed words, a code, or a named class. */
19678
+ const refusalReason = (error) => {
19679
+ if (error instanceof RefusalError) return error.message;
19680
+ if (error instanceof DeploymentReceiptTransportError) return `transport error code: ${error.code}`;
19681
+ if (error instanceof ImpactStampConfigError) return "impact declarations contradict the lockfile; run candidate:impact for the detail";
19682
+ return "unclassified error";
19683
+ };
19278
19684
  const demandedTargets = (profile, reason) => (profile?.impact?.targets ?? []).map(({ name }) => ({
19279
19685
  basis: reason,
19280
19686
  name,
19281
19687
  state: "demanded"
19282
19688
  }));
19283
- const refusal = ({ after, before, profile, reason }) => ({
19284
- ...after ? { afterSha: after } : {},
19689
+ const refusal = ({ candidate, profile, reason, stage }) => ({
19285
19690
  basis: {
19286
- changedFiles: [],
19287
19691
  classification: "refused",
19288
19692
  reasons: [reason]
19289
19693
  },
19290
- ...before ? { beforeSha: before } : {},
19694
+ ...candidate !== void 0 && COMMIT_SHA_PATTERN.test(candidate) && !ZERO_SHA_PATTERN.test(candidate) ? { candidateSha: candidate.toLowerCase() } : {},
19291
19695
  decision: "refused",
19292
19696
  schemaVersion: PRODUCTION_IMPACT_DECISION_SCHEMA_VERSION,
19697
+ ...stage === void 0 ? {} : { stage },
19293
19698
  targets: demandedTargets(profile, reason),
19294
19699
  unsubscribedPaths: []
19295
19700
  });
19296
19701
  const assertCommitIdentity = (value, name) => {
19297
- if (value === void 0 || !COMMIT_SHA_PATTERN.test(value) || ZERO_SHA_PATTERN.test(value)) throw new Error(`${name} must be a non-zero 40-character commit SHA`);
19702
+ if (value === void 0 || !COMMIT_SHA_PATTERN.test(value) || ZERO_SHA_PATTERN.test(value)) throw new RefusalError(`${name} must be a non-zero 40-character commit SHA`);
19298
19703
  return value.toLowerCase();
19299
19704
  };
19300
- const assertReachableCommit = (cwd, sha, name) => {
19705
+ const isReachableCommit = (cwd, sha) => {
19301
19706
  try {
19302
19707
  runCapture("git", [
19303
19708
  "cat-file",
19304
19709
  "-e",
19305
19710
  `${sha}^{commit}`
19306
19711
  ], cwd);
19712
+ return true;
19307
19713
  } catch {
19308
- throw new Error(`${name} commit ${sha} is not reachable in this checkout`);
19714
+ return false;
19309
19715
  }
19310
19716
  };
19717
+ const assertCheckedOutCandidate = (cwd, candidate) => {
19718
+ if (!isReachableCommit(cwd, candidate)) throw new RefusalError(`candidate commit ${candidate} is not reachable in this checkout`);
19719
+ if (git(cwd, ["rev-parse", "HEAD"]).trim().toLowerCase() !== candidate) throw new RefusalError(`candidate commit ${candidate} is not the checked-out HEAD; refusing to mix commit identities with declarations from another tree`);
19720
+ };
19721
+ const identityFromEnv = () => {
19722
+ const fromEnv = workflowRunIdentityFromEnv(process.env);
19723
+ if ("invalid" in fromEnv) throw new RefusalError(`deployment receipts need a GitHub Actions run identity; missing or malformed: ${fromEnv.invalid.join(", ")}`);
19724
+ return fromEnv.identity;
19725
+ };
19726
+ /**
19727
+ * The run identity and transport the decision reads receipts with. Both come
19728
+ * from the GitHub Actions environment and `gh` unless a test injects them.
19729
+ */
19730
+ const resolveReceiptSource = (cwd, receipts) => {
19731
+ const identity = receipts?.identity ?? identityFromEnv();
19732
+ return {
19733
+ identity,
19734
+ transport: receipts?.transport ?? ghDeploymentReceiptTransport(cwd, identity.repository)
19735
+ };
19736
+ };
19737
+ const outputState = (state) => state === "superseded" ? "withdrawn" : state;
19311
19738
  const writeGithubOutputs = (outputPath, decision) => {
19312
19739
  appendFileSync(outputPath, `${[
19313
19740
  `decision=${decision.decision}`,
19314
19741
  `basis=${JSON.stringify(decision.basis)}`,
19315
19742
  `unsubscribed_paths=${JSON.stringify(decision.unsubscribedPaths)}`,
19316
- ...decision.targets.map((target) => `${productionImpactTargetOutput(target.name)}=${target.state}`)
19743
+ ...decision.targets.map((target) => `${productionImpactTargetOutput(target.name)}=${outputState(target.state)}`)
19317
19744
  ].join("\n")}\n`, "utf-8");
19318
19745
  };
19746
+ /** Classify the delta from one baseline to the candidate, once per distinct baseline. */
19747
+ const classifyDelta = ({ baseline, candidate, cwd, profile, profilePath }) => {
19748
+ const changedFiles = [...new Set(filesFromNameStatus(git(cwd, [
19749
+ "diff",
19750
+ "--find-renames",
19751
+ "--name-status",
19752
+ baseline,
19753
+ candidate
19754
+ ])))].toSorted();
19755
+ const stamp = computeImpactStamp({
19756
+ changedFiles,
19757
+ profile,
19758
+ profilePath: repositoryRelativeProfilePath({
19759
+ cwd,
19760
+ profilePath
19761
+ }),
19762
+ readLockfile: (side) => showFileAtRef(cwd, side === "base" ? baseline : candidate, LOCKFILE_PATH)
19763
+ });
19764
+ return {
19765
+ changedFiles,
19766
+ targets: new Map(stamp.targets.map((target) => [target.name, target.impact])),
19767
+ unsubscribedPaths: stamp.unsubscribedPaths,
19768
+ usable: stamp.basis === "target-scoped"
19769
+ };
19770
+ };
19771
+ const resolveTargets = ({ candidate, cwd, identity, profile, profilePath, stage, transport }) => {
19772
+ const deltas = /* @__PURE__ */ new Map();
19773
+ const unsubscribedPaths = /* @__PURE__ */ new Set();
19774
+ return {
19775
+ targets: (profile.impact?.targets ?? []).map(({ name }) => {
19776
+ const resolution = resolveDeploymentBaseline({
19777
+ identity,
19778
+ stage,
19779
+ target: name,
19780
+ transport
19781
+ });
19782
+ if (resolution.kind === "absent") return {
19783
+ basis: `reconciliation demanded: ${resolution.reasons.join("; ")}`,
19784
+ name,
19785
+ state: "demanded"
19786
+ };
19787
+ const { baseline } = resolution;
19788
+ const bound = {
19789
+ baseline,
19790
+ name
19791
+ };
19792
+ if (baseline.sha === candidate) return {
19793
+ ...bound,
19794
+ basis: `trusted baseline ${baseline.sha} is the candidate; already deployed by run ${baseline.runId}`,
19795
+ state: "withdrawn"
19796
+ };
19797
+ if (!isReachableCommit(cwd, baseline.sha)) return {
19798
+ ...bound,
19799
+ basis: `reconciliation demanded: trusted baseline ${baseline.sha} is not reachable in this checkout`,
19800
+ state: "demanded"
19801
+ };
19802
+ if (isAncestor(cwd, candidate, baseline.sha)) return {
19803
+ ...bound,
19804
+ basis: `candidate ${candidate} is an ancestor of trusted baseline ${baseline.sha}; deploying would regress run ${baseline.runId}`,
19805
+ state: "superseded"
19806
+ };
19807
+ if (!isAncestor(cwd, baseline.sha, candidate)) return {
19808
+ ...bound,
19809
+ basis: `reconciliation demanded: trusted baseline ${baseline.sha} is not an ancestor of candidate ${candidate}`,
19810
+ state: "demanded"
19811
+ };
19812
+ let delta = deltas.get(baseline.sha);
19813
+ if (delta === void 0) {
19814
+ delta = classifyDelta({
19815
+ baseline: baseline.sha,
19816
+ candidate,
19817
+ cwd,
19818
+ profile,
19819
+ profilePath
19820
+ });
19821
+ deltas.set(baseline.sha, delta);
19822
+ }
19823
+ for (const unsubscribed of delta.unsubscribedPaths) unsubscribedPaths.add(unsubscribed);
19824
+ const impact = delta.targets.get(name);
19825
+ if (!delta.usable || impact === void 0) return {
19826
+ ...bound,
19827
+ basis: `reconciliation demanded: delta from ${baseline.sha} is not target-scoped (conservative impact stamp)`,
19828
+ changedFiles: delta.changedFiles,
19829
+ state: "demanded"
19830
+ };
19831
+ return {
19832
+ ...bound,
19833
+ basis: `delta from trusted baseline ${baseline.sha}: target-scoped impact stamp says ${impact}`,
19834
+ changedFiles: delta.changedFiles,
19835
+ state: impact === "not-affected" ? "withdrawn" : "demanded"
19836
+ };
19837
+ }),
19838
+ unsubscribedPaths: [...unsubscribedPaths].toSorted()
19839
+ };
19840
+ };
19319
19841
  /**
19320
- * Recompute impact from one merge push's exact before/after commits. The
19321
- * result may withdraw consumer work only; every doubt path returns a refused
19322
- * decision whose known targets remain demanded.
19842
+ * Resolve production deployment demand for every declared target from its
19843
+ * trusted deployment baseline to the checked-out candidate. The result may
19844
+ * withdraw or supersede consumer work only through a usable decision; every
19845
+ * refusal keeps every known target demanded.
19323
19846
  */
19324
19847
  const runProductionImpact = (args) => {
19325
19848
  const cwd = path.resolve(args.cwd ?? process.cwd());
@@ -19327,108 +19850,152 @@ const runProductionImpact = (args) => {
19327
19850
  let profilePath;
19328
19851
  let decision;
19329
19852
  try {
19330
- const { path: loadedPath, profile: loadedProfile } = loadProjectProfile({
19331
- cwd,
19332
- profilePath: args.profilePath
19333
- });
19334
- profile = loadedProfile;
19335
- profilePath = loadedPath;
19336
- const before = assertCommitIdentity(args.before, "before");
19337
- const after = assertCommitIdentity(args.after, "after");
19338
- assertReachableCommit(cwd, before, "before");
19339
- assertReachableCommit(cwd, after, "after");
19340
- const checkedOutHead = runCapture("git", ["rev-parse", "HEAD"], cwd).stdout.trim().toLowerCase();
19341
- if (checkedOutHead !== after) throw new Error(`after commit ${after} is not the checked-out HEAD ${checkedOutHead}; refusing to mix commit identities with declarations from another tree`);
19342
19853
  try {
19343
- runCapture("git", [
19344
- "merge-base",
19345
- "--is-ancestor",
19346
- before,
19347
- after
19348
- ], cwd);
19854
+ const { path: loadedPath, profile: loadedProfile } = loadProjectProfile({
19855
+ cwd,
19856
+ profilePath: args.profilePath
19857
+ });
19858
+ profile = loadedProfile;
19859
+ profilePath = loadedPath;
19349
19860
  } catch {
19350
- throw new Error(`before commit ${before} is not an ancestor of after commit ${after}`);
19861
+ throw new RefusalError("project profile could not be loaded or validated; run psf doctor");
19351
19862
  }
19352
- const changedFiles = [...new Set(filesFromNameStatus(runCapture("git", [
19353
- "diff",
19354
- "--find-renames",
19355
- "--name-status",
19356
- before,
19357
- after
19358
- ], cwd).stdout))].toSorted();
19359
- const stamp = computeImpactStamp({
19360
- changedFiles,
19863
+ const candidate = assertCommitIdentity(args.candidate, "candidate");
19864
+ assertCheckedOutCandidate(cwd, candidate);
19865
+ const { identity, transport } = resolveReceiptSource(cwd, args.receipts);
19866
+ const resolved = resolveTargets({
19867
+ candidate,
19868
+ cwd,
19869
+ identity,
19361
19870
  profile,
19362
- profilePath: repositoryRelativeProfilePath({
19363
- cwd,
19364
- profilePath
19365
- }),
19366
- readLockfile: (side) => showFileAtRef(cwd, side === "base" ? before : after, LOCKFILE_PATH)
19871
+ profilePath,
19872
+ stage: args.stage,
19873
+ transport
19367
19874
  });
19368
- const core = {
19369
- afterSha: after,
19370
- beforeSha: before,
19371
- schemaVersion: PRODUCTION_IMPACT_DECISION_SCHEMA_VERSION,
19372
- unsubscribedPaths: stamp.unsubscribedPaths
19373
- };
19374
- decision = stamp.basis === "target-scoped" ? {
19375
- ...core,
19875
+ decision = {
19376
19876
  basis: {
19377
- changedFiles,
19378
- classification: stamp.basis,
19379
- reasons: stamp.reasons
19877
+ classification: "receipt-baseline",
19878
+ reasons: [`resolved from trusted ${identity.workflowPath} deployment receipts in ${identity.repository}`]
19380
19879
  },
19880
+ candidateSha: candidate,
19381
19881
  decision: "usable",
19382
- targets: stamp.targets.map((target) => ({
19383
- basis: target.basis,
19384
- name: target.name,
19385
- state: target.impact === "not-affected" ? "withdrawn" : "demanded"
19386
- }))
19387
- } : {
19388
- ...core,
19389
- basis: {
19390
- changedFiles,
19391
- classification: stamp.basis,
19392
- reasons: stamp.reasons
19393
- },
19394
- decision: "refused",
19395
- targets: stamp.targets.map((target) => ({
19396
- basis: target.basis,
19397
- name: target.name,
19398
- state: "demanded"
19399
- }))
19882
+ schemaVersion: PRODUCTION_IMPACT_DECISION_SCHEMA_VERSION,
19883
+ ...args.stage === void 0 ? {} : { stage: args.stage },
19884
+ targets: resolved.targets,
19885
+ unsubscribedPaths: resolved.unsubscribedPaths
19400
19886
  };
19401
19887
  } catch (error) {
19402
19888
  decision = refusal({
19403
- after: args.after,
19404
- before: args.before,
19889
+ candidate: args.candidate,
19405
19890
  profile,
19406
- reason: `production impact classification refused: ${error instanceof Error ? error.message : String(error)}`
19891
+ reason: `production impact classification refused: ${refusalReason(error)}`,
19892
+ stage: args.stage
19407
19893
  });
19408
19894
  }
19409
19895
  if (args.githubOutput) writeGithubOutputs(path.resolve(cwd, args.githubOutput), decision);
19410
19896
  if (args.githubSummary) appendFileSync(path.resolve(cwd, args.githubSummary), `### Production impact\n\n${productionImpactSummaryLines(decision).join("\n")}\n`, "utf-8");
19411
19897
  return decision;
19412
19898
  };
19413
- const productionImpactSummaryLines = (decision) => decision.basis.classification === "refused" ? [
19899
+ const productionImpactSummaryLines = (decision) => decision.decision === "refused" ? [
19414
19900
  "Production impact: refused; all consumer work remains demanded.",
19415
19901
  ...decision.basis.reasons,
19416
19902
  unsubscribedPathsLine(void 0, "classification was refused")
19417
- ] : [`Production impact: ${decision.basis.classification}; ${decision.targets.filter(({ state }) => state === "demanded").length}/${decision.targets.length} target(s) demanded.`, unsubscribedPathsLine(decision.unsubscribedPaths)];
19903
+ ] : [
19904
+ `Production impact: ${decision.basis.classification}; ${decision.targets.filter(({ state }) => state === "demanded").length}/${decision.targets.length} target(s) demanded.`,
19905
+ ...decision.targets.map((target) => `- ${target.name}: ${target.state} — ${target.basis}`),
19906
+ unsubscribedPathsLine(decision.unsubscribedPaths)
19907
+ ];
19908
+ /**
19909
+ * The trusted baseline a `success` receipt for this candidate would
19910
+ * overwrite with an older revision, when one stands.
19911
+ */
19912
+ const overwrittenBaseline = ({ candidate, cwd, identity, stage, target, transport }) => {
19913
+ const standing = resolveDeploymentBaseline({
19914
+ identity,
19915
+ stage,
19916
+ target,
19917
+ transport
19918
+ });
19919
+ if (standing.kind === "trusted" && standing.baseline.sha !== candidate && isReachableCommit(cwd, standing.baseline.sha) && isAncestor(cwd, candidate, standing.baseline.sha)) return standing.baseline;
19920
+ };
19921
+ /**
19922
+ * Publish one target's deployment receipt for the checked-out candidate.
19923
+ * Consumers call it only after deployment and their health checks conclude;
19924
+ * a `success` receipt advances the target's baseline, a `failure` receipt
19925
+ * records the attempt and advances nothing.
19926
+ */
19927
+ const runProductionReceipt = (args) => {
19928
+ const cwd = path.resolve(args.cwd ?? process.cwd());
19929
+ if (args.state !== "success" && args.state !== "failure") throw new RefusalError("--publish-receipt must be success or failure");
19930
+ if (args.target === void 0 || args.target.length === 0) throw new RefusalError("--publish-receipt requires --target <name>");
19931
+ const candidate = assertCommitIdentity(args.candidate, "candidate");
19932
+ assertCheckedOutCandidate(cwd, candidate);
19933
+ const { identity, transport } = resolveReceiptSource(cwd, args.receipts);
19934
+ const { stage } = args;
19935
+ const staleCandidate = args.state === "success" ? overwrittenBaseline({
19936
+ candidate,
19937
+ cwd,
19938
+ identity,
19939
+ stage,
19940
+ target: args.target,
19941
+ transport
19942
+ }) : void 0;
19943
+ const receipt = publishDeploymentReceipt({
19944
+ identity,
19945
+ sourceSha: candidate,
19946
+ stage,
19947
+ state: args.state,
19948
+ target: args.target,
19949
+ transport
19950
+ });
19951
+ if (args.githubOutput) appendFileSync(path.resolve(cwd, args.githubOutput), `${RECEIPT_DEPLOYMENT_ID_OUTPUT}=${receipt.deploymentId}\n`, "utf-8");
19952
+ if (args.githubSummary) appendFileSync(path.resolve(cwd, args.githubSummary), `### Deployment receipt\n\n${productionReceiptSummaryLines({
19953
+ receipt,
19954
+ ...staleCandidate ? { staleCandidate } : {}
19955
+ }).join("\n")}\n`, "utf-8");
19956
+ return {
19957
+ receipt,
19958
+ ...staleCandidate ? { staleCandidate } : {}
19959
+ };
19960
+ };
19961
+ const productionReceiptSummaryLines = (result) => [`Deployment receipt: ${result.receipt.state} for ${result.receipt.payload.target}${result.receipt.payload.stage ? ` stage ${result.receipt.payload.stage}` : ""} at ${result.receipt.payload.sourceSha} (deployment ${result.receipt.deploymentId}, run ${result.receipt.payload.workflow.runId}).`, ...result.staleCandidate ? [`Stale candidate: ${result.receipt.payload.sourceSha} is an ancestor of the previously trusted baseline ${result.staleCandidate.sha} (run ${result.staleCandidate.runId}); production now runs the older revision and needs reconciliation.`] : []];
19418
19962
  //#endregion
19419
19963
  //#region src/commands/production-impact.ts
19420
- function createProductionImpactCommand(output) {
19421
- return markCwdOptionDefault(new Command("production:impact").description("Classify production targets from an exact merge-push before/after diff").option("--before <sha>", "merge-push before commit SHA").option("--after <sha>", "merge-push after commit SHA").option("--cwd <path>", "repository working directory", collectCwdOption).option("--profile <path>", "path to the project profile JSON file (default: software-factory.profile.json in --cwd)").option("--github-output <path>", "append stable decision outputs to a GitHub Actions output file").option("--github-summary <path>", "append a human-readable decision to a GitHub Actions summary file").option("--json", "print the complete decision as JSON").action((options) => {
19964
+ /**
19965
+ * `production:impact` has two modes on one command. Without
19966
+ * `--publish-receipt` it resolves deployment demand from receipts; with it,
19967
+ * it writes one target's receipt after the consumer deployed and checked
19968
+ * health. `receipts` is the test seam for the transport and run identity.
19969
+ */
19970
+ function createProductionImpactCommand(output, receipts) {
19971
+ return markCwdOptionDefault(new Command("production:impact").description("Resolve production deployment demand from trusted deployment receipts, or publish one").option("--candidate <sha>", "checked-out candidate commit SHA").option("--cwd <path>", "repository working directory", collectCwdOption).option("--profile <path>", "path to the project profile JSON file (default: software-factory.profile.json in --cwd)").option("--stage <name>", "deployment stage the receipts are bound to (optional)").option("--publish-receipt <state>", "publish a success or failure receipt for --target instead of deciding").option("--target <name>", "declared impact target the receipt names").option("--github-output <path>", "append stable decision outputs (or the published deployment_id) to a GitHub Actions output file").option("--github-summary <path>", "append a human-readable decision to a GitHub Actions summary file").option("--json", "print the complete decision as JSON").action((options) => {
19972
+ const cwd = resolveCwdOption(options.cwd);
19973
+ if (options.publishReceipt !== void 0) {
19974
+ const result = runProductionReceipt({
19975
+ candidate: options.candidate,
19976
+ cwd,
19977
+ githubOutput: options.githubOutput,
19978
+ githubSummary: options.githubSummary,
19979
+ receipts,
19980
+ stage: options.stage,
19981
+ state: options.publishReceipt,
19982
+ target: options.target
19983
+ });
19984
+ output.stdout.write(options.json ? `${JSON.stringify(result, null, 2)}\n` : `${productionReceiptSummaryLines(result).join("\n")}\n`);
19985
+ if (result.staleCandidate) throw new Error(`stale candidate ${result.receipt.payload.sourceSha} overwrote trusted baseline ${result.staleCandidate.sha}; reconcile the target`);
19986
+ return;
19987
+ }
19988
+ if (options.target !== void 0) throw new Error("--target applies only with --publish-receipt");
19422
19989
  const decision = runProductionImpact({
19423
- after: options.after,
19424
- before: options.before,
19425
- cwd: resolveCwdOption(options.cwd),
19990
+ candidate: options.candidate,
19991
+ cwd,
19426
19992
  githubOutput: options.githubOutput,
19427
19993
  githubSummary: options.githubSummary,
19428
- profilePath: options.profile
19994
+ profilePath: options.profile,
19995
+ receipts,
19996
+ stage: options.stage
19429
19997
  });
19430
- if (options.json) output.stdout.write(`${JSON.stringify(decision, null, 2)}\n`);
19431
- else output.stdout.write(`${productionImpactSummaryLines(decision).join("\n")}\n`);
19998
+ output.stdout.write(options.json ? `${JSON.stringify(decision, null, 2)}\n` : `${productionImpactSummaryLines(decision).join("\n")}\n`);
19432
19999
  }));
19433
20000
  }
19434
20001
  //#endregion