@patronage/software-factory 0.30.0 → 1.0.0-alpha.1

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
@@ -14,10 +14,10 @@ import { setImmediate } from "node:timers";
14
14
  import { setImmediate as setImmediate$1, setTimeout as setTimeout$1 } from "node:timers/promises";
15
15
  import { Worker } from "node:worker_threads";
16
16
  import picomatch from "picomatch";
17
- import { promisify } from "node:util";
18
17
  import { parse } from "yaml";
18
+ import { promisify } from "node:util";
19
19
  //#region package.json
20
- var version = "0.30.0";
20
+ var version = "1.0.0-alpha.1";
21
21
  //#endregion
22
22
  //#region src/review-rungs.ts
23
23
  const EVIDENCE_REVIEW_RUNGS$1 = [
@@ -894,8 +894,15 @@ function isValidGlob(pattern) {
894
894
  return false;
895
895
  }
896
896
  }
897
+ function isRepoRelativePath(value) {
898
+ const segments = value.split("/");
899
+ return value.length > 0 && !value.startsWith("/") && !/^[A-Za-z]:\//u.test(value) && !value.includes("\\") && !segments.some((segment) => segment === "" || segment === "." || segment === "..");
900
+ }
901
+ function isValidRepoRelativeGlob(pattern) {
902
+ return isRepoRelativePath(pattern) && !(pattern.startsWith("!") && !pattern.startsWith("!(")) && isValidGlob(pattern);
903
+ }
897
904
  function matchesAnyGlob(patterns, file) {
898
- return patterns.length > 0 && picomatch.isMatch(file, patterns);
905
+ return patterns.length > 0 && picomatch.isMatch(file, [...patterns]);
899
906
  }
900
907
  //#endregion
901
908
  //#region src/diff-classification.ts
@@ -939,11 +946,13 @@ const LEGACY_PROFILE_SCHEMA_VERSIONS = [
939
946
  3,
940
947
  4
941
948
  ];
942
- const PROFILE_JSON_SCHEMA_DESCRIPTION = "Editor validation covers the profile input shape and straightforward cross-field constraints. Zod remains the load-time enforcement authority: verification command names must be distinct and requiredChecks entries must have distinct names; these invariants are enforced at load time, not by this schema.";
949
+ const PROFILE_JSON_SCHEMA_DESCRIPTION = "Editor validation covers the profile input shape and straightforward cross-field constraints. Zod remains the load-time enforcement authority: verification command names must be distinct and requiredChecks entries must have distinct names; these invariants are enforced at load time, not by this schema. Impact target path patterns encode repo-relative safety in JSON Schema, while complete picomatch syntax validity remains a runtime-only refinement.";
943
950
  const globSchema = z.string().min(1).refine(isValidGlob, "must be a valid glob");
951
+ const repoRelativeGlobSchema = z.string().min(1).regex(/^(?!(?:\/[\s\S]*|[A-Za-z]:\/[\s\S]*|!(?!\()[\s\S]*|[\s\S]*\\[\s\S]*|[\s\S]*\/\/[\s\S]*|[\s\S]*\/$|\.{1,2}(?:\/|$)[\s\S]*|[\s\S]*\/\.{1,2}(?:\/|$)[\s\S]*)).+$/u, "must be a repo-relative positive glob").refine(isValidRepoRelativeGlob, "must be a valid repo-relative glob");
944
952
  const commandSchema = z.object({
945
953
  command: z.string().min(1),
946
954
  description: z.string().min(1),
955
+ impactTarget: z.string().min(1).optional(),
947
956
  name: z.string().min(1),
948
957
  requiredCheck: z.string().min(1).optional(),
949
958
  scope: z.enum([
@@ -969,10 +978,23 @@ const requiredCheckSchema = z.object({
969
978
  name: z.string().min(1),
970
979
  scope: requiredCheckScopeSchema$1.optional()
971
980
  }).strict();
981
+ const impactTargetSchema = z.object({
982
+ importers: z.array(z.string().min(1)).min(1),
983
+ name: z.string().min(1),
984
+ paths: z.array(repoRelativeGlobSchema).min(1).optional()
985
+ }).strict();
986
+ const impactConfigSchema = z.object({ targets: z.array(impactTargetSchema).min(1).superRefine((targets, context) => {
987
+ const names = targets.map((target) => target.name);
988
+ if (new Set(names).size !== names.length) context.addIssue({
989
+ code: "custom",
990
+ message: "impact.targets entries must have distinct names."
991
+ });
992
+ }) }).strict();
972
993
  const factoryProjectProfileSchema = z.object({
973
994
  $schema: z.string().min(1).optional(),
974
995
  extensions: z.record(z.string(), z.unknown()).optional(),
975
996
  hq: hqIngestConfigSchema.optional(),
997
+ impact: impactConfigSchema.optional(),
976
998
  proof: z.object({ classificationPolicy: z.object({
977
999
  docsOnly: z.array(globSchema).default([]),
978
1000
  trivial: z.array(globSchema).default([])
@@ -1006,6 +1028,30 @@ const factoryProjectProfileSchema = z.object({
1006
1028
  });
1007
1029
  commandNames.add(command.name);
1008
1030
  }
1031
+ const impactTargetNames = new Set((profile.impact?.targets ?? []).map((target) => target.name));
1032
+ for (const [commandIndex, command] of profile.verification.commands.entries()) {
1033
+ if (command.impactTarget === void 0) continue;
1034
+ if (!impactTargetNames.has(command.impactTarget)) context.addIssue({
1035
+ code: "custom",
1036
+ message: `Verification command impactTarget "${command.impactTarget}" does not name a declared impact.targets entry.`,
1037
+ path: [
1038
+ "verification",
1039
+ "commands",
1040
+ commandIndex,
1041
+ "impactTarget"
1042
+ ]
1043
+ });
1044
+ if (command.requiredCheck !== void 0 && command.requiredCheck !== command.impactTarget) context.addIssue({
1045
+ code: "custom",
1046
+ message: `Verification command "${command.name}" maps requiredCheck "${command.requiredCheck}" but scopes on impactTarget "${command.impactTarget}"; a scoped command's evidence demand and impact target must be the same name.`,
1047
+ path: [
1048
+ "verification",
1049
+ "commands",
1050
+ commandIndex,
1051
+ "impactTarget"
1052
+ ]
1053
+ });
1054
+ }
1009
1055
  const requiredChecks = new Map((profile.requiredChecks ?? []).map((check) => [check.name, check]));
1010
1056
  const mappedChecks = /* @__PURE__ */ new Set();
1011
1057
  for (const [commandIndex, command] of profile.verification.commands.entries()) {
@@ -1036,7 +1082,7 @@ const factoryProjectProfileSchema = z.object({
1036
1082
  mappedChecks.add(command.requiredCheck);
1037
1083
  }
1038
1084
  }).meta({ description: PROFILE_JSON_SCHEMA_DESCRIPTION });
1039
- const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
1085
+ const isRecord$1 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
1040
1086
  const stringList = (value) => Array.isArray(value) ? value.filter((entry) => typeof entry === "string") : [];
1041
1087
  const quoteList = (values) => JSON.stringify(values);
1042
1088
  const namesExistingFile = (root, entry) => {
@@ -1085,13 +1131,13 @@ const migratedPolicyLines = (policy, root) => {
1085
1131
  */
1086
1132
  function v2MigrationLines(input, root) {
1087
1133
  const lines = [];
1088
- const proof = isRecord(input.proof) ? input.proof : void 0;
1089
- if (proof && isRecord(proof.classificationPolicy)) lines.push(...migratedPolicyLines(proof.classificationPolicy, root));
1090
- const commands = isRecord(input.verification) ? input.verification.commands : void 0;
1134
+ const proof = isRecord$1(input.proof) ? input.proof : void 0;
1135
+ if (proof && isRecord$1(proof.classificationPolicy)) lines.push(...migratedPolicyLines(proof.classificationPolicy, root));
1136
+ const commands = isRecord$1(input.verification) ? input.verification.commands : void 0;
1091
1137
  if (Array.isArray(commands)) {
1092
- for (const [index, command] of commands.entries()) if (isRecord(command) && command.scope === "always") lines.push(`verification.commands[${index}].scope "always" -> "docs-only"`);
1138
+ for (const [index, command] of commands.entries()) if (isRecord$1(command) && command.scope === "always") lines.push(`verification.commands[${index}].scope "always" -> "docs-only"`);
1093
1139
  }
1094
- if (isRecord(input.hq) && typeof input.hq.endpoint === "string") try {
1140
+ if (isRecord$1(input.hq) && typeof input.hq.endpoint === "string") try {
1095
1141
  const url = new URL(input.hq.endpoint);
1096
1142
  if (url.href !== `${url.origin}/` && url.href !== url.origin) lines.push(`hq.endpoint ${JSON.stringify(input.hq.endpoint)} -> ${JSON.stringify(url.origin)}`);
1097
1143
  } catch {}
@@ -1103,25 +1149,25 @@ function hasCorrectnessOnlyReview(review) {
1103
1149
  }
1104
1150
  const v4MigrationLines = (input) => {
1105
1151
  const lines = [];
1106
- if (isRecord(input.project) && "key" in input.project) lines.push("project.key -> removed; current proof writers derive projectKey from repository.name");
1107
- if (isRecord(input.repository) && "defaultBranch" in input.repository) lines.push("repository.defaultBranch -> removed; commands resolve the live base from Git/GitHub or an explicit --base");
1108
- if (isRecord(input.env) && "required" in input.env) lines.push("env.required -> removed; the command that needs an environment value owns its fail-closed diagnostic");
1109
- if (isRecord(input.review)) {
1152
+ if (isRecord$1(input.project) && "key" in input.project) lines.push("project.key -> removed; current proof writers derive projectKey from repository.name");
1153
+ if (isRecord$1(input.repository) && "defaultBranch" in input.repository) lines.push("repository.defaultBranch -> removed; commands resolve the live base from Git/GitHub or an explicit --base");
1154
+ if (isRecord$1(input.env) && "required" in input.env) lines.push("env.required -> removed; the command that needs an environment value owns its fail-closed diagnostic");
1155
+ if (isRecord$1(input.review)) {
1110
1156
  const isCorrectnessOnlyReview = hasCorrectnessOnlyReview(input.review);
1111
1157
  if ("defaultMaxCycles" in input.review) lines.push("review.defaultMaxCycles -> removed; the factory review cap is 5");
1112
1158
  if ("modes" in input.review) lines.push("review.modes -> removed; correctness is always required");
1113
1159
  if (isCorrectnessOnlyReview) lines.push("review -> removed; a correctness-only review has no v5 configuration, so delete the object");
1114
1160
  if (Array.isArray(input.review.conditional)) {
1115
- for (const [index, conditional] of input.review.conditional.entries()) if (isRecord(conditional) && "modes" in conditional) lines.push(`review.conditional[${index}].modes -> removed; each retained conditional path list demands security review`);
1161
+ for (const [index, conditional] of input.review.conditional.entries()) if (isRecord$1(conditional) && "modes" in conditional) lines.push(`review.conditional[${index}].modes -> removed; each retained conditional path list demands security review`);
1116
1162
  }
1117
1163
  }
1118
1164
  if (Array.isArray(input.requiredChecks)) {
1119
- for (const [index, check] of input.requiredChecks.entries()) if (isRecord(check) && "checkType" in check) lines.push(`requiredChecks[${index}].checkType -> removed; external required checks are verify-type`);
1165
+ for (const [index, check] of input.requiredChecks.entries()) if (isRecord$1(check) && "checkType" in check) lines.push(`requiredChecks[${index}].checkType -> removed; external required checks are verify-type`);
1120
1166
  }
1121
1167
  return lines;
1122
1168
  };
1123
1169
  function legacyProfileMigration(input, root) {
1124
- if (!(isRecord(input) && typeof input.schemaVersion === "number" && LEGACY_PROFILE_SCHEMA_VERSIONS.includes(input.schemaVersion))) return;
1170
+ if (!(isRecord$1(input) && typeof input.schemaVersion === "number" && LEGACY_PROFILE_SCHEMA_VERSIONS.includes(input.schemaVersion))) return;
1125
1171
  const foundVersion = input.schemaVersion;
1126
1172
  const lines = [`schemaVersion ${foundVersion} -> 5`];
1127
1173
  if (foundVersion === 2) lines.push(...v2MigrationLines(input, root));
@@ -3792,6 +3838,675 @@ const authorizeDemandWaiver = ({ authenticatedLogin, session }) => {
3792
3838
  ...normalizedSession && normalizedSession !== "unknown" ? { session: normalizedSession } : {}
3793
3839
  } };
3794
3840
  };
3841
+ //#endregion
3842
+ //#region src/impact-stamp.ts
3843
+ const LOCKFILE_PATH = "pnpm-lock.yaml";
3844
+ const SUPPORTED_LOCKFILE_VERSION = "9.0";
3845
+ const impactStampTargetSchema = z.object({
3846
+ basis: z.string().min(1),
3847
+ impact: z.enum(["affected", "not-affected"]),
3848
+ name: z.string().min(1)
3849
+ });
3850
+ /**
3851
+ * The recorded stamp. `targets` satisfies the completeness invariant: every
3852
+ * target the profile declares is present, each with its exact basis — never
3853
+ * silently absent. `basis: "conservative"` means an unmodelled or unprovable
3854
+ * input widened every target to full impact.
3855
+ */
3856
+ const impactStampSchema = z.object({
3857
+ basis: z.enum(["target-scoped", "conservative"]),
3858
+ reasons: z.array(z.string()),
3859
+ stampVersion: z.literal(2),
3860
+ targets: z.array(impactStampTargetSchema).superRefine((targets, context) => {
3861
+ const names = targets.map((target) => target.name);
3862
+ if (new Set(names).size !== names.length) context.addIssue({
3863
+ code: "custom",
3864
+ message: "impact stamp targets must have distinct names."
3865
+ });
3866
+ })
3867
+ });
3868
+ /**
3869
+ * A configuration contradiction between the profile's impact declarations and
3870
+ * the repository's actual lockfile — e.g. a declared importer root that
3871
+ * matches no importer on either side of the delta. Controls fail loudly (ADR
3872
+ * 0024): this error must ABORT the run, never degrade to a stamp, because a
3873
+ * profile typo would otherwise silently mis-scope every future candidate.
3874
+ * Data/runtime failures (unreadable, unparseable, malformed lockfiles, reader
3875
+ * throws) are the opposite case and stay on the conservative-stamp path.
3876
+ */
3877
+ var ImpactStampConfigError = class extends Error {
3878
+ name = "ImpactStampConfigError";
3879
+ };
3880
+ const CONSERVATIVE_TARGET_BASIS = "conservative: impact not provable; full impact assumed";
3881
+ /**
3882
+ * The surfaces that consume the stamp. One classification, consumed
3883
+ * everywhere (#430): every surface routes through
3884
+ * {@link impactStampScopeDecision}, so no surface can be more trusting than
3885
+ * another, and no surface re-derives path rules of its own. The value only
3886
+ * selects the wording of the conservative floor a refusal falls back to.
3887
+ */
3888
+ const IMPACT_SCOPE_SURFACES = {
3889
+ demand: "full demand floor",
3890
+ "preview-lifecycle": "full preview-lifecycle floor",
3891
+ "verification-battery": "full verification-battery floor"
3892
+ };
3893
+ /**
3894
+ * Whether a trusted stamp RELEASES one target-scoped unit of work — an
3895
+ * external proof demand (#542 wave 2), a verification-battery command, or a
3896
+ * preview (Alchemy) lifecycle stack (#430 wave 3).
3897
+ *
3898
+ * The only release path is a `target-scoped` stamp of exactly this build's
3899
+ * stamp version whose target entry for exactly this name is provably
3900
+ * `not-affected`. Every other input — no trusted stamp, an unknown stamp
3901
+ * version, a conservative stamp, a name the stamp does not classify, or an
3902
+ * `affected` verdict — keeps today's full-work floor. Work only ever gets
3903
+ * released, never added or satisfied: this function can withdraw a demand, it
3904
+ * can never certify one.
3905
+ *
3906
+ * Identity binding is the CALLER's precondition: pass only a stamp computed
3907
+ * for, or read from a pr:verify proof bound to, the current candidate (see
3908
+ * `trustedImpactStamp` in `pr-readiness/verification-proof.ts`); pass
3909
+ * `undefined` otherwise.
3910
+ */
3911
+ const impactStampScopeDecision = ({ stamp, surface, targetName }) => {
3912
+ const floor = IMPACT_SCOPE_SURFACES[surface];
3913
+ if (stamp === void 0) return {
3914
+ reason: `no trusted identity-bound impact stamp covers this candidate; ${floor}`,
3915
+ scoped: false
3916
+ };
3917
+ if (stamp.stampVersion !== 2) return {
3918
+ reason: `impact stamp version ${String(stamp.stampVersion)} is not this build's version 2; ${floor}`,
3919
+ scoped: false
3920
+ };
3921
+ if (stamp.basis !== "target-scoped") return {
3922
+ reason: `impact stamp is conservative (full impact assumed); ${floor}`,
3923
+ scoped: false
3924
+ };
3925
+ const matches = stamp.targets.filter((candidate) => candidate.name === targetName);
3926
+ if (matches.length > 1) return {
3927
+ reason: `impact stamp is self-contradictory: ${matches.length} rows classify a target named "${targetName}"; ${floor}`,
3928
+ scoped: false
3929
+ };
3930
+ const [target] = matches;
3931
+ if (target === void 0) return {
3932
+ reason: `impact stamp does not classify a target named "${targetName}"; ${floor}`,
3933
+ scoped: false
3934
+ };
3935
+ if (target.impact !== "not-affected") return {
3936
+ reason: `impact stamp records target "${targetName}" as affected (${target.basis})`,
3937
+ scoped: false
3938
+ };
3939
+ return {
3940
+ reason: `impact stamp released: target "${targetName}" is provably not affected by this candidate's delta (${target.basis})`,
3941
+ scoped: true
3942
+ };
3943
+ };
3944
+ const impactStampDemandRelease = ({ checkName, stamp }) => {
3945
+ const decision = impactStampScopeDecision({
3946
+ stamp,
3947
+ surface: "demand",
3948
+ targetName: checkName
3949
+ });
3950
+ return {
3951
+ reason: decision.reason,
3952
+ released: decision.scoped
3953
+ };
3954
+ };
3955
+ /**
3956
+ * The full-impact stamp: every declared target `affected`. This is the floor
3957
+ * every doubt path lands on, and the stamp a producer must fall back to if
3958
+ * stamp computation itself fails for any reason.
3959
+ */
3960
+ const conservativeImpactStamp = (targets, reasons) => ({
3961
+ basis: "conservative",
3962
+ reasons,
3963
+ stampVersion: 2,
3964
+ targets: targets.map((target) => ({
3965
+ basis: CONSERVATIVE_TARGET_BASIS,
3966
+ impact: "affected",
3967
+ name: target.name
3968
+ }))
3969
+ });
3970
+ const conservativeStamp = conservativeImpactStamp;
3971
+ const DEPENDENCY_FIELDS = [
3972
+ "dependencies",
3973
+ "devDependencies",
3974
+ "optionalDependencies"
3975
+ ];
3976
+ const importerDependencySchema = z.looseObject({
3977
+ specifier: z.string().optional(),
3978
+ version: z.string()
3979
+ });
3980
+ const importerSectionSchema = z.looseObject({
3981
+ dependencies: z.record(z.string(), importerDependencySchema).optional(),
3982
+ devDependencies: z.record(z.string(), importerDependencySchema).optional(),
3983
+ optionalDependencies: z.record(z.string(), importerDependencySchema).optional()
3984
+ });
3985
+ const snapshotSectionSchema = z.looseObject({
3986
+ dependencies: z.record(z.string(), z.string()).optional(),
3987
+ devDependencies: z.record(z.string(), z.string()).optional(),
3988
+ optionalDependencies: z.record(z.string(), z.string()).optional()
3989
+ });
3990
+ const pnpmLockfileSchema = z.looseObject({
3991
+ importers: z.record(z.string(), importerSectionSchema),
3992
+ lockfileVersion: z.unknown().optional(),
3993
+ packages: z.record(z.string(), z.unknown()).optional(),
3994
+ snapshots: z.record(z.string(), snapshotSectionSchema).optional()
3995
+ });
3996
+ const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
3997
+ const canonical = (value) => {
3998
+ if (Array.isArray(value)) return value.map(canonical);
3999
+ if (isRecord(value)) return Object.fromEntries(Object.keys(value).toSorted().map((key) => [key, canonical(value[key])]));
4000
+ return value;
4001
+ };
4002
+ const stableStringify$1 = (value) => JSON.stringify(canonical(value));
4003
+ const parseLockfile = (content) => {
4004
+ try {
4005
+ const parsed = parse(content);
4006
+ if (!isRecord(parsed)) return;
4007
+ const validated = pnpmLockfileSchema.safeParse(parsed);
4008
+ return validated.success ? validated.data : void 0;
4009
+ } catch {
4010
+ return;
4011
+ }
4012
+ };
4013
+ const nonGraphSections = ({ importers: _importers, packages: _packages, snapshots: _snapshots, ...rest }) => rest;
4014
+ const changedSectionKeys = (before, after) => {
4015
+ return [...new Set([...Object.keys(before ?? {}), ...Object.keys(after ?? {})])].filter((key) => stableStringify$1(before?.[key]) !== stableStringify$1(after?.[key]));
4016
+ };
4017
+ const linkTarget = (importerPath, version) => {
4018
+ if (!version.startsWith("link:")) return;
4019
+ const raw = version.slice(5);
4020
+ const joined = importerPath === "." ? raw : `${importerPath}/${raw}`;
4021
+ const resolved = [];
4022
+ for (const segment of joined.split("/")) {
4023
+ if (segment === "" || segment === ".") continue;
4024
+ if (segment === "..") {
4025
+ resolved.pop();
4026
+ continue;
4027
+ }
4028
+ resolved.push(segment);
4029
+ }
4030
+ return resolved.length === 0 ? "." : resolved.join("/");
4031
+ };
4032
+ const importerMatchesTarget = (importerPath, importerRoots) => importerRoots.some((root) => importerPath === root || importerPath.startsWith(`${root}/`));
4033
+ const workspaceClosure = (lockfile, importerRoots) => {
4034
+ const closure = new Set(["."]);
4035
+ const queue = Object.keys(lockfile.importers ?? {}).filter((importerPath) => importerMatchesTarget(importerPath, importerRoots));
4036
+ while (queue.length > 0) {
4037
+ const importerPath = queue.pop();
4038
+ if (importerPath === void 0 || closure.has(importerPath)) continue;
4039
+ closure.add(importerPath);
4040
+ const section = lockfile.importers?.[importerPath];
4041
+ for (const field of DEPENDENCY_FIELDS) for (const dependency of Object.values(section?.[field] ?? {})) {
4042
+ const target = dependency.version === void 0 ? void 0 : linkTarget(importerPath, dependency.version);
4043
+ if (target !== void 0 && lockfile.importers?.[target]) queue.push(target);
4044
+ }
4045
+ }
4046
+ return closure;
4047
+ };
4048
+ const pushReference = (lockfile, queue, name, value) => {
4049
+ if (value.startsWith("link:")) return;
4050
+ queue.push(`${name}@${value}`);
4051
+ if (lockfile.snapshots?.[value]) queue.push(value);
4052
+ };
4053
+ const snapshotClosure = (lockfile, importerPaths) => {
4054
+ const keys = /* @__PURE__ */ new Set();
4055
+ const queue = [];
4056
+ for (const importerPath of importerPaths) {
4057
+ const section = lockfile.importers?.[importerPath];
4058
+ for (const field of DEPENDENCY_FIELDS) for (const [name, dependency] of Object.entries(section?.[field] ?? {})) if (dependency.version !== void 0) pushReference(lockfile, queue, name, dependency.version);
4059
+ }
4060
+ while (queue.length > 0) {
4061
+ const key = queue.pop();
4062
+ if (key === void 0 || keys.has(key)) continue;
4063
+ keys.add(key);
4064
+ const snapshot = lockfile.snapshots?.[key];
4065
+ for (const field of DEPENDENCY_FIELDS) for (const [name, version] of Object.entries(snapshot?.[field] ?? {})) pushReference(lockfile, queue, name, version);
4066
+ }
4067
+ return keys;
4068
+ };
4069
+ const withoutPeerSuffix = (key) => key.split("(")[0] ?? key;
4070
+ const supportedLockfileVersion = (lockfile) => String(lockfile.lockfileVersion) === SUPPORTED_LOCKFILE_VERSION;
4071
+ const targetImpactForLockfileDelta = (target, delta) => {
4072
+ const closures = delta.sides.map((lockfile) => ({
4073
+ importers: workspaceClosure(lockfile, target.importers),
4074
+ lockfile
4075
+ }));
4076
+ const changedImporter = delta.changedImporters.find((importerPath) => closures.some((side) => side.importers.has(importerPath)));
4077
+ if (changedImporter !== void 0) return {
4078
+ basis: `changed importer "${changedImporter}" is in this target's workspace closure`,
4079
+ impact: "affected",
4080
+ name: target.name
4081
+ };
4082
+ const resolved = new Set(closures.flatMap((side) => [...snapshotClosure(side.lockfile, side.importers)]));
4083
+ const resolvedBare = new Set([...resolved].map(withoutPeerSuffix));
4084
+ const changedKey = delta.changedSnapshots.find((key) => resolved.has(key)) ?? delta.changedPackages.find((key) => resolved.has(key) || resolvedBare.has(key));
4085
+ if (changedKey !== void 0) return {
4086
+ basis: `changed graph key "${changedKey}" is resolved by this target's importer closure`,
4087
+ impact: "affected",
4088
+ name: target.name
4089
+ };
4090
+ return {
4091
+ basis: `no changed importer or resolved graph key reaches importers [${target.importers.join(", ")}]`,
4092
+ impact: "not-affected",
4093
+ name: target.name
4094
+ };
4095
+ };
4096
+ /**
4097
+ * Compute the impact stamp for one candidate: every declared target recorded
4098
+ * as affected/not-affected with its exact basis. `not-affected` is only ever
4099
+ * returned only when every changed input is owned by a declared source glob or
4100
+ * is an analyzable pnpm-lock.yaml (v9) delta. Source and lockfile reachability
4101
+ * are unioned per target. Any invalid, unmatched, unreadable, unsupported, or
4102
+ * otherwise doubtful input widens the whole stamp to full impact.
4103
+ *
4104
+ * Total by contract: this function never throws. Any unexpected failure in
4105
+ * reading, parsing, or traversal is itself a doubt path and returns the
4106
+ * conservative stamp, so a producer can never be aborted by stamp
4107
+ * computation.
4108
+ */
4109
+ const computeImpactStamp = (input) => {
4110
+ const targets = input.profile.impact?.targets ?? [];
4111
+ try {
4112
+ return computeImpactStampOrThrow(input, targets);
4113
+ } catch (error) {
4114
+ if (error instanceof ImpactStampConfigError) throw error;
4115
+ return conservativeStamp(targets, [`impact stamp computation failed (${error instanceof Error ? error.message : String(error)}); fail closed to full impact`]);
4116
+ }
4117
+ };
4118
+ const readLockfileSides = (readLockfile) => {
4119
+ let base;
4120
+ let head;
4121
+ try {
4122
+ base = readLockfile("base");
4123
+ head = readLockfile("head");
4124
+ } catch (error) {
4125
+ return {
4126
+ error,
4127
+ kind: "threw"
4128
+ };
4129
+ }
4130
+ if (base === void 0 || head === void 0) return {
4131
+ kind: "unreadable",
4132
+ side: base === void 0 ? "base" : "head"
4133
+ };
4134
+ const baseLock = parseLockfile(base);
4135
+ const headLock = parseLockfile(head);
4136
+ if (!(baseLock && headLock)) return {
4137
+ kind: "unparseable",
4138
+ side: baseLock ? "head" : "base"
4139
+ };
4140
+ if (![baseLock, headLock].every(supportedLockfileVersion)) return { kind: "unsupported-version" };
4141
+ return {
4142
+ kind: "ok",
4143
+ parsed: [baseLock, headLock]
4144
+ };
4145
+ };
4146
+ /**
4147
+ * Every declared importer root must exist somewhere in the delta: a root
4148
+ * matching no importer on EITHER side is a profile typo, and scoping from it
4149
+ * would leave only the implicit root importer in that target's closure —
4150
+ * silently classifying real changes `not-affected` forever. That is a
4151
+ * configuration contradiction, so it fails loudly instead of degrading. A root
4152
+ * present on ONE side only is legitimate (this very delta adds or removes the
4153
+ * importer) and scopes normally.
4154
+ *
4155
+ * Checked on EVERY computation whose lockfile sides are readable, not only on
4156
+ * the lockfile-only deltas that can actually scope. The check answers a
4157
+ * question about the PROFILE, which does not depend on what this candidate
4158
+ * changed; running it only where scoping happens would leave a typo latent
4159
+ * through every code PR and first surface it on the cheap lockfile-only PR
4160
+ * this machinery exists to speed up.
4161
+ */
4162
+ const assertDeclaredImporterRoots = (targets, sides) => {
4163
+ for (const target of targets) for (const root of target.importers) if (!sides.some((lockfile) => Object.keys(lockfile.importers).some((importerPath) => importerMatchesTarget(importerPath, [root])))) throw new ImpactStampConfigError(`impact target "${target.name}" declares importer root "${root}", but no importer under that root exists in pnpm-lock.yaml on either side of the delta; fix the profile's impact.targets declaration`);
4164
+ };
4165
+ const assertDeclaredPathGlobs = (targets) => {
4166
+ for (const target of targets) for (const pattern of target.paths ?? []) if (!isValidRepoRelativeGlob(pattern)) throw new ImpactStampConfigError(`impact target "${target.name}" declares invalid repo-relative path glob "${pattern}"; fix the profile's impact.targets declaration`);
4167
+ };
4168
+ const analyzeSourcePaths = (sourceFiles, targets, profilePath) => {
4169
+ for (const file of sourceFiles) {
4170
+ if (!isRepoRelativePath(file)) return {
4171
+ kind: "conservative",
4172
+ reason: `changed source path "${file}" is not a valid repo-relative path; fail closed to full impact`
4173
+ };
4174
+ if (file === profilePath) return {
4175
+ kind: "conservative",
4176
+ reason: `changed source path "${file}" is the impact profile being evaluated; fail closed to full impact`
4177
+ };
4178
+ if (!targets.some((target) => matchesAnyGlob(target.paths ?? [], file))) return {
4179
+ kind: "conservative",
4180
+ reason: `changed source path "${file}" has no declared impact target owner; fail closed to full impact`
4181
+ };
4182
+ }
4183
+ return {
4184
+ kind: "ok",
4185
+ targets: targets.map((target) => {
4186
+ const matched = sourceFiles.find((file) => matchesAnyGlob(target.paths ?? [], file));
4187
+ return matched === void 0 ? {
4188
+ basis: "no changed source path matches this target's declared paths",
4189
+ impact: "not-affected",
4190
+ name: target.name
4191
+ } : {
4192
+ basis: `changed source path "${matched}" matches this target's declared paths`,
4193
+ impact: "affected",
4194
+ name: target.name
4195
+ };
4196
+ })
4197
+ };
4198
+ };
4199
+ const unionTargetImpact = (source, lockfile) => {
4200
+ if (source.impact === "affected") return source;
4201
+ if (lockfile.impact === "affected") return lockfile;
4202
+ return {
4203
+ basis: `${source.basis}; ${lockfile.basis}`,
4204
+ impact: "not-affected",
4205
+ name: source.name
4206
+ };
4207
+ };
4208
+ const computeImpactStampOrThrow = ({ changedFiles, profilePath = "software-factory.profile.json", readLockfile }, targets) => {
4209
+ if (targets.length === 0) return {
4210
+ basis: "conservative",
4211
+ reasons: ["no impact targets declared in the profile; every surface keeps full demand"],
4212
+ stampVersion: 2,
4213
+ targets: []
4214
+ };
4215
+ assertDeclaredPathGlobs(targets);
4216
+ const sides = readLockfileSides(readLockfile);
4217
+ if (sides.kind === "ok") assertDeclaredImporterRoots(targets, sides.parsed);
4218
+ if (changedFiles.length === 0) return conservativeStamp(targets, ["no changed files detected; full impact assumed"]);
4219
+ const sourceFiles = changedFiles.filter((file) => file !== LOCKFILE_PATH);
4220
+ const source = analyzeSourcePaths(sourceFiles, targets, profilePath);
4221
+ if (source.kind === "conservative") return conservativeStamp(targets, [source.reason]);
4222
+ if (sides.kind === "threw") throw sides.error;
4223
+ if (sides.kind === "unreadable") return conservativeStamp(targets, [`pnpm-lock.yaml is unreadable at the ${sides.side} side; fail closed to full impact`]);
4224
+ if (sides.kind === "unparseable") return conservativeStamp(targets, [`pnpm-lock.yaml is unparseable or structurally malformed at the ${sides.side} side; fail closed to full impact`]);
4225
+ if (sides.kind === "unsupported-version") return conservativeStamp(targets, [`pnpm-lock.yaml is not lockfileVersion ${SUPPORTED_LOCKFILE_VERSION}; only pnpm v9 lockfiles are modelled; fail closed to full impact`]);
4226
+ if (!changedFiles.includes("pnpm-lock.yaml")) return {
4227
+ basis: "target-scoped",
4228
+ reasons: ["all changed source paths have declared target ownership; impact scoped by repo-relative path globs"],
4229
+ stampVersion: 2,
4230
+ targets: source.targets
4231
+ };
4232
+ const [baseLock, headLock] = sides.parsed;
4233
+ if (stableStringify$1(nonGraphSections(baseLock)) !== stableStringify$1(nonGraphSections(headLock))) return conservativeStamp(targets, ["pnpm-lock.yaml delta touches sections outside importers/packages/snapshots (e.g. settings, overrides, patchedDependencies); fail closed to full impact"]);
4234
+ const delta = {
4235
+ changedImporters: changedSectionKeys(baseLock.importers, headLock.importers),
4236
+ changedPackages: changedSectionKeys(baseLock.packages, headLock.packages),
4237
+ changedSnapshots: changedSectionKeys(baseLock.snapshots, headLock.snapshots),
4238
+ sides: [baseLock, headLock]
4239
+ };
4240
+ const lockfileTargets = targets.map((target) => targetImpactForLockfileDelta(target, delta));
4241
+ const mixed = sourceFiles.length > 0;
4242
+ return {
4243
+ basis: "target-scoped",
4244
+ reasons: mixed ? ["all changed source paths have declared target ownership and pnpm-lock.yaml is analyzable; impact is the union of path ownership and importer/snapshot graph analysis"] : ["pnpm-lock.yaml is the only changed file; impact scoped by importer/snapshot graph analysis"],
4245
+ stampVersion: 2,
4246
+ targets: mixed ? source.targets.map((sourceTarget, index) => unionTargetImpact(sourceTarget, lockfileTargets[index])) : lockfileTargets
4247
+ };
4248
+ };
4249
+ //#endregion
4250
+ //#region src/verification-battery.ts
4251
+ const UNCONDITIONAL_BASIS = "unconditional: the profile declares no impactTarget for this command";
4252
+ /**
4253
+ * The `vetoedTargets` contract, enforced at RUNTIME as well as at the type
4254
+ * level. TypeScript makes the parameter required for callers it compiles;
4255
+ * `new Set(undefined)` is a perfectly good empty set, so an untyped JavaScript
4256
+ * caller that omits it would otherwise scope exactly as if it had inspected
4257
+ * the envelopes and found nothing — the silent state requiring the parameter
4258
+ * exists to eliminate.
4259
+ *
4260
+ * A malformed CALL is API misuse, not data doubt: it fails loudly (ADR 0024)
4261
+ * rather than degrading to the conservative floor, because a producer that
4262
+ * never resolved the veto set has a bug its author must see.
4263
+ */
4264
+ const assertVetoedTargets = (vetoedTargets, caller) => {
4265
+ if (!Array.isArray(vetoedTargets)) throw new TypeError(`${caller} requires vetoedTargets: the target names with a current, candidate-bound FAILING envelope. Resolve it with boundFailingCheckNames and pass the result; pass [] to mean "inspected the envelopes and found no bound failure". It has no default because omitting it would silently scope work whose standing demand nothing on this head could meet.`);
4266
+ };
4267
+ /**
4268
+ * Plan one verification battery against the candidate's impact stamp.
4269
+ *
4270
+ * Pure and total: it makes a decision, it never reads a proof, a profile file,
4271
+ * or the filesystem, and no INPUT can make it throw — every doubt path is a
4272
+ * decision, not an exception. A malformed CALL is the one exception to that,
4273
+ * and deliberately so: omitting `vetoedTargets` is API misuse rather than data
4274
+ * doubt, and it throws (see {@link assertVetoedTargets}).
4275
+ *
4276
+ * `stamp` must already be trusted by
4277
+ * the caller — inside `pr:verify` that is the stamp just computed for this
4278
+ * candidate's own identity triple; anywhere else it is `trustedImpactStamp`'s
4279
+ * output or `undefined`.
4280
+ */
4281
+ const planVerificationBattery = ({ commands, stamp, vetoedTargets }) => {
4282
+ assertVetoedTargets(vetoedTargets, "planVerificationBattery");
4283
+ const vetoed = new Set(vetoedTargets);
4284
+ const dispositions = [];
4285
+ const execute = [];
4286
+ const notRequired = [];
4287
+ for (const command of commands) {
4288
+ const { impactTarget, name } = command;
4289
+ if (impactTarget === void 0) {
4290
+ dispositions.push({
4291
+ basis: UNCONDITIONAL_BASIS,
4292
+ disposition: "executed",
4293
+ name
4294
+ });
4295
+ execute.push(command);
4296
+ continue;
4297
+ }
4298
+ const decision = impactStampScopeDecision({
4299
+ stamp,
4300
+ surface: "verification-battery",
4301
+ targetName: impactTarget
4302
+ });
4303
+ const vetoedHere = decision.scoped && vetoed.has(impactTarget);
4304
+ const basis = vetoedHere ? `veto: a current envelope bound to this candidate records target "${impactTarget}" as FAILING, so the stamp's release is withheld (${decision.reason})` : decision.reason;
4305
+ const scoped = decision.scoped && !vetoedHere;
4306
+ dispositions.push({
4307
+ basis,
4308
+ disposition: scoped ? "not-required" : "executed",
4309
+ impactTarget,
4310
+ name
4311
+ });
4312
+ if (scoped) notRequired.push({
4313
+ basis,
4314
+ impactTarget,
4315
+ name
4316
+ });
4317
+ else execute.push(command);
4318
+ }
4319
+ return {
4320
+ dispositions,
4321
+ execute,
4322
+ notRequired
4323
+ };
4324
+ };
4325
+ /**
4326
+ * The completeness invariant as an enforceable control (ADR 0024: controls
4327
+ * fail loudly), shared by both pr:verify proof schemas so they cannot drift.
4328
+ *
4329
+ * Four rules:
4330
+ * 1. A withheld command names one the resolved mode actually selected.
4331
+ * 2. A command is never recorded as both executed and not-required.
4332
+ * 3. Withheld commands are distinct — a duplicated disposition would let one
4333
+ * name carry two different bases.
4334
+ * 4. On a PASSED proof, every selected command has a disposition: silence is
4335
+ * not a disposition, and "absent from both lists" is exactly the silent
4336
+ * skip this epic forbids.
4337
+ *
4338
+ * Rule 4 is scoped to `outcome: "passed"` because an ABORTED proof legitimately
4339
+ * records partial execution — the run stopped at the failing command. It is
4340
+ * also scoped to schemaVersion >= 4: `executedCommands` postdates v1–v3, so a
4341
+ * legacy proof that never recorded it is not making a false completeness claim.
4342
+ *
4343
+ * The check is one-directional on purpose. Every SELECTED command must be
4344
+ * accounted for, but `executedCommands` may legitimately carry MORE than the
4345
+ * profile selected — full mode appends the `workspace:install-resolves` probe,
4346
+ * which is real work no profile declares.
4347
+ */
4348
+ const assertBatteryCompleteness = (proof, context) => {
4349
+ const selected = proof.verificationCommands.map((command) => command.name);
4350
+ const selectedSet = new Set(selected);
4351
+ const executed = new Set((proof.executedCommands ?? []).map((command) => command.name));
4352
+ const notRequired = proof.notRequiredCommands ?? [];
4353
+ const seen = /* @__PURE__ */ new Set();
4354
+ for (const [index, entry] of notRequired.entries()) {
4355
+ if (!selectedSet.has(entry.name)) context.addIssue({
4356
+ code: "custom",
4357
+ message: `notRequiredCommands entry "${entry.name}" is not one of this proof's verificationCommands.`,
4358
+ path: [
4359
+ "notRequiredCommands",
4360
+ index,
4361
+ "name"
4362
+ ]
4363
+ });
4364
+ if (executed.has(entry.name)) context.addIssue({
4365
+ code: "custom",
4366
+ message: `Command "${entry.name}" is recorded both as executed and as not-required.`,
4367
+ path: [
4368
+ "notRequiredCommands",
4369
+ index,
4370
+ "name"
4371
+ ]
4372
+ });
4373
+ if (seen.has(entry.name)) context.addIssue({
4374
+ code: "custom",
4375
+ message: `notRequiredCommands records "${entry.name}" more than once.`,
4376
+ path: [
4377
+ "notRequiredCommands",
4378
+ index,
4379
+ "name"
4380
+ ]
4381
+ });
4382
+ seen.add(entry.name);
4383
+ }
4384
+ if (proof.outcome !== "passed" || proof.schemaVersion < 4) return;
4385
+ const disposed = new Set([...executed, ...seen]);
4386
+ const undisposed = selected.filter((name) => !disposed.has(name));
4387
+ if (undisposed.length > 0) context.addIssue({
4388
+ code: "custom",
4389
+ message: `A passed pr:verify proof must give every selected verification command a disposition; [${undisposed.join(", ")}] appear in verificationCommands but in neither executedCommands nor notRequiredCommands.`,
4390
+ path: ["executedCommands"]
4391
+ });
4392
+ };
4393
+ /**
4394
+ * The stamp-authorization control: a passed v4 proof may only claim a command
4395
+ * was NOT REQUIRED if its own recorded stamp says so.
4396
+ *
4397
+ * `assertBatteryCompleteness` proves the two lists partition the selected
4398
+ * commands — that no command is silently absent. It does not prove the
4399
+ * withholding was EARNED. Without this rule a proof could name every expensive
4400
+ * command in `notRequiredCommands`, carry no stamp at all (or a conservative
4401
+ * one), and still read as full verification: the omission would be recorded,
4402
+ * accounted for, and completely unauthorized.
4403
+ *
4404
+ * Two things have to hold, and the first is what keeps the second honest:
4405
+ *
4406
+ * 1. COHERENCE. The entry's `impactTarget` must equal the `impactTarget` the
4407
+ * proof records for that same command in `verificationCommands`. The entry
4408
+ * does not get to nominate its own target; the proof's command→target
4409
+ * mapping (written by `pr:verify` from the loaded profile) does. Without
4410
+ * this, authorization validates a self-reported field and a crafted proof
4411
+ * can withhold an affected command while pointing at an unrelated released
4412
+ * target.
4413
+ * 2. AUTHORIZATION. That recorded target must be one the proof's own stamp
4414
+ * provably released.
4415
+ *
4416
+ * The proof is one artifact and a determined forger controls all of it; this
4417
+ * is internal-coherence belt-and-braces in the same trust domain as the
4418
+ * version guards, and write-time truth stays `pr:verify`'s job.
4419
+ *
4420
+ * The authorizing evidence is the proof's OWN `impactStamp` — the same stamp
4421
+ * `pr:verify` computed for this candidate's identity triple and recorded here,
4422
+ * so authorization is bound to the same identities the proof binds. The
4423
+ * predicate is the shared {@link impactStampScopeDecision}, not a second
4424
+ * reading of the stamp: an entry is authorized exactly when the stamp would
4425
+ * have released that target's command in the first place (target-scoped
4426
+ * basis, this build's stamp version, exactly one row for the target,
4427
+ * `not-affected`). A conservative stamp, an unknown version, a
4428
+ * self-contradictory stamp, an unclassified name, or an `affected` verdict all
4429
+ * fail the same way scoping itself would.
4430
+ *
4431
+ * Scoped to `outcome: "passed"` and schemaVersion >= 4 for the same reasons as
4432
+ * the completeness rule: an aborted run records partial execution, and a
4433
+ * legacy proof predates both fields, so neither is making a false completeness
4434
+ * claim.
4435
+ */
4436
+ const assertNotRequiredStampAuthorization = (proof, context) => {
4437
+ const notRequired = proof.notRequiredCommands ?? [];
4438
+ if (proof.outcome !== "passed" || proof.schemaVersion < 4 || notRequired.length === 0) return;
4439
+ const { impactStamp } = proof;
4440
+ if (impactStamp === void 0) {
4441
+ context.addIssue({
4442
+ code: "custom",
4443
+ message: `A passed pr:verify proof that withholds verification commands must record the impact stamp that authorized the withholding; [${notRequired.map((entry) => entry.name).join(", ")}] are recorded not-required by a proof carrying no impactStamp.`,
4444
+ path: ["impactStamp"]
4445
+ });
4446
+ return;
4447
+ }
4448
+ const recordedTargets = new Map(proof.verificationCommands.map((command) => [command.name, command.impactTarget]));
4449
+ for (const [index, entry] of notRequired.entries()) {
4450
+ const recorded = recordedTargets.get(entry.name);
4451
+ if (recorded !== entry.impactTarget) {
4452
+ context.addIssue({
4453
+ code: "custom",
4454
+ message: recorded === void 0 ? `notRequiredCommands entry "${entry.name}" claims impactTarget "${entry.impactTarget}", but this proof records no impactTarget for that command; only a command the profile scopes can be withheld.` : `notRequiredCommands entry "${entry.name}" claims impactTarget "${entry.impactTarget}", but this proof records impactTarget "${recorded}" for that command.`,
4455
+ path: [
4456
+ "notRequiredCommands",
4457
+ index,
4458
+ "impactTarget"
4459
+ ]
4460
+ });
4461
+ continue;
4462
+ }
4463
+ const decision = impactStampScopeDecision({
4464
+ stamp: impactStamp,
4465
+ surface: "verification-battery",
4466
+ targetName: entry.impactTarget
4467
+ });
4468
+ if (!decision.scoped) context.addIssue({
4469
+ code: "custom",
4470
+ message: `notRequiredCommands entry "${entry.name}" is not authorized by this proof's impactStamp: ${decision.reason}.`,
4471
+ path: [
4472
+ "notRequiredCommands",
4473
+ index,
4474
+ "impactTarget"
4475
+ ]
4476
+ });
4477
+ }
4478
+ };
4479
+ /** Console lines naming every withheld command and why. Never silent. */
4480
+ const batteryScopeSummaryLines = (plan) => {
4481
+ if (plan.notRequired.length === 0) return [];
4482
+ return ["Verification battery scoped by the impact stamp (not-required):", ...plan.notRequired.map((entry) => `- ${entry.name} (${entry.impactTarget}): ${entry.basis}`)];
4483
+ };
4484
+ //#endregion
4485
+ //#region src/pr-verify-proof-rules.ts
4486
+ const assertPrVerifyProofRules = (proof, context) => {
4487
+ if (proof.schemaVersion >= 4 && proof.authoringSession === void 0) context.addIssue({
4488
+ code: "custom",
4489
+ message: "schemaVersion 4 pr:verify proof requires authoringSession (the recorded authoring identity, or the `unknown` sentinel).",
4490
+ path: ["authoringSession"]
4491
+ });
4492
+ if (proof.schemaVersion < 4 && proof.authoringSession !== void 0) context.addIssue({
4493
+ code: "custom",
4494
+ message: "authoringSession is a schemaVersion>=4 field; a v1–v3 pr:verify proof must not carry it.",
4495
+ path: ["authoringSession"]
4496
+ });
4497
+ if (proof.schemaVersion < 4 && proof.impactStamp !== void 0) context.addIssue({
4498
+ code: "custom",
4499
+ message: "impactStamp is a schemaVersion>=4 field; a v1–v3 pr:verify proof must not carry it.",
4500
+ path: ["impactStamp"]
4501
+ });
4502
+ if (proof.schemaVersion < 4 && proof.notRequiredCommands !== void 0) context.addIssue({
4503
+ code: "custom",
4504
+ message: "notRequiredCommands is a schemaVersion>=4 field; a v1–v3 pr:verify proof must not carry it.",
4505
+ path: ["notRequiredCommands"]
4506
+ });
4507
+ assertBatteryCompleteness(proof, context);
4508
+ assertNotRequiredStampAuthorization(proof, context);
4509
+ };
3795
4510
  const LEGACY_CLOSEOUT_SCHEMA_VERSION = 3;
3796
4511
  const MAX_CLOSEOUT_ROWS = 1e3;
3797
4512
  const IdentifierSchema = z.string().min(1).max(500);
@@ -4297,6 +5012,11 @@ const executedCommandSchema$1 = z.object({
4297
5012
  "full"
4298
5013
  ])
4299
5014
  });
5015
+ const notRequiredCommandSchema$1 = z.object({
5016
+ basis: z.string().min(1),
5017
+ impactTarget: z.string().min(1),
5018
+ name: z.string().min(1)
5019
+ });
4300
5020
  const prVerifySchemaVersionSchema$1 = z.number().refine((value) => SUPPORTED_PR_VERIFY_SCHEMA_VERSIONS$1.includes(value), { message: `schemaVersion must be one of: ${SUPPORTED_PR_VERIFY_SCHEMA_VERSIONS$1.join(", ")}` });
4301
5021
  z.object({
4302
5022
  authoringSession: z.string().trim().min(1).optional(),
@@ -4317,12 +5037,14 @@ z.object({
4317
5037
  endedAt: z.iso.datetime(),
4318
5038
  executedCommands: z.array(executedCommandSchema$1).optional(),
4319
5039
  headSha: z.string().regex(/^[0-9a-f]{40}$/u),
5040
+ impactStamp: impactStampSchema.optional(),
4320
5041
  mergeBaseSha: z.string().regex(/^[0-9a-f]{40}$/u).optional(),
4321
5042
  mode: z.enum([
4322
5043
  "docs-only",
4323
5044
  "trivial",
4324
5045
  "full"
4325
5046
  ]),
5047
+ notRequiredCommands: z.array(notRequiredCommandSchema$1).min(1).optional(),
4326
5048
  outcome: z.enum(["aborted", "passed"]).optional(),
4327
5049
  patchId: z.string().regex(/^[0-9a-f]{40}$/u).optional(),
4328
5050
  profilePath: z.string().min(1),
@@ -4333,6 +5055,7 @@ z.object({
4333
5055
  verificationCommands: z.array(z.object({
4334
5056
  command: z.string().min(1),
4335
5057
  description: z.string().min(1),
5058
+ impactTarget: z.string().min(1).optional(),
4336
5059
  name: z.string().min(1),
4337
5060
  scope: z.enum([
4338
5061
  "always",
@@ -4341,18 +5064,7 @@ z.object({
4341
5064
  "full"
4342
5065
  ])
4343
5066
  }))
4344
- }).superRefine((proof, context) => {
4345
- if (proof.schemaVersion >= 4 && proof.authoringSession === void 0) context.addIssue({
4346
- code: "custom",
4347
- message: "schemaVersion 4 pr:verify proof requires authoringSession (the recorded authoring identity, or the `unknown` sentinel).",
4348
- path: ["authoringSession"]
4349
- });
4350
- if (proof.schemaVersion < 4 && proof.authoringSession !== void 0) context.addIssue({
4351
- code: "custom",
4352
- message: "authoringSession is a schemaVersion>=4 field; a v1–v3 pr:verify proof must not carry it.",
4353
- path: ["authoringSession"]
4354
- });
4355
- });
5067
+ }).superRefine(assertPrVerifyProofRules);
4356
5068
  const PR_REVIEW_FINDING_PROVENANCE_VERSION$1 = 1;
4357
5069
  const nonBlankString$3 = z.string().refine((value) => value.trim().length > 0, { message: "must not be blank" });
4358
5070
  const parseableDateString$1 = z.string().refine((value) => Number.isFinite(Date.parse(value)), { message: "must be a parseable date string" });
@@ -9403,6 +10115,7 @@ var external_evidence_exports = /* @__PURE__ */ __exportAll({
9403
10115
  EVIDENCE_DIR: () => EVIDENCE_DIR,
9404
10116
  EVIDENCE_ENVELOPE_SCHEMA_VERSION: () => 1,
9405
10117
  EVIDENCE_REVIEW_RUNGS: () => EVIDENCE_REVIEW_RUNGS$1,
10118
+ boundFailingCheckNames: () => boundFailingCheckNames,
9406
10119
  evaluateRequiredChecks: () => evaluateRequiredChecks,
9407
10120
  evidenceBindingFresh: () => evidenceBindingFresh,
9408
10121
  evidenceEnvelopeSchema: () => evidenceEnvelopeSchema,
@@ -9491,6 +10204,41 @@ const evidenceReviewIndependence = ({ authoringSessionIds, envelope }) => {
9491
10204
  };
9492
10205
  return { independent: true };
9493
10206
  };
10207
+ /**
10208
+ * The VETO predicate: does a current envelope, bound to exactly this
10209
+ * candidate, record a FAILING outcome for this check?
10210
+ *
10211
+ * An inferred graph verdict never silences a direct observation (ADR 0024:
10212
+ * controls fail loudly). Wave 2 uses this to withhold a demand release; wave 3
10213
+ * (#430) uses the same predicate — via {@link boundFailingCheckNames} — to
10214
+ * withhold battery and preview-lifecycle scoping. It is ONE matcher on
10215
+ * purpose: two vetoes that matched differently could withhold a demand while
10216
+ * releasing the work that would satisfy it, leaving a demand nothing on this
10217
+ * head can ever meet.
10218
+ */
10219
+ const envelopeRecordsBoundFailure = ({ candidate, check, envelope }) => envelope.check === check.name && envelope.checkType === check.checkType && envelope.outcome === "fail" && evidenceBindingFresh({
10220
+ candidate,
10221
+ envelope
10222
+ });
10223
+ /**
10224
+ * Every check name with a current, candidate-bound FAILING envelope on disk.
10225
+ *
10226
+ * The wave-3 scoping veto input. Callers pass the resolved set to
10227
+ * `planVerificationBattery` / `planPreviewLifecycle`, which stay pure — the
10228
+ * planners decide, they never read the filesystem.
10229
+ *
10230
+ * `checkType` is the caller's, not a guess: schema-v5 requiredChecks are
10231
+ * verify-only (`resolveProfileRequiredChecks`), so a scoping caller passes
10232
+ * `"verify"` and matches exactly the pair wave 2 evaluates.
10233
+ */
10234
+ const boundFailingCheckNames = ({ candidate, checkType, envelopes }) => [...new Set(envelopes.flatMap((loaded) => loaded.ok && envelopeRecordsBoundFailure({
10235
+ candidate,
10236
+ check: {
10237
+ checkType,
10238
+ name: loaded.envelope.check
10239
+ },
10240
+ envelope: loaded.envelope
10241
+ }) ? [loaded.envelope.check] : []))];
9494
10242
  const bindingRefusalReason = (name) => `Required check "${name}" has no passing, current envelope bound to the candidate delta (patchId).`;
9495
10243
  /**
9496
10244
  * The `evidence:emit` invocation that would satisfy a specific demanded check.
@@ -9618,6 +10366,23 @@ const evaluateRequiredChecks = ({ authoringSessionIds, candidate, envelopes, req
9618
10366
  scopeReason: scopeDecision.reason,
9619
10367
  status: "out-of-scope"
9620
10368
  };
10369
+ const release = impactStampDemandRelease({
10370
+ checkName: check.name,
10371
+ stamp: scopeContext.impactStamp
10372
+ });
10373
+ const boundFailureObserved = parsed.some((loaded) => envelopeRecordsBoundFailure({
10374
+ candidate,
10375
+ check,
10376
+ envelope: loaded.envelope
10377
+ }));
10378
+ if (release.released && !boundFailureObserved) return {
10379
+ checkType: check.checkType,
10380
+ inScope: false,
10381
+ name: check.name,
10382
+ ...check.scope ? { scope: check.scope } : {},
10383
+ scopeReason: release.reason,
10384
+ status: "out-of-scope"
10385
+ };
9621
10386
  return {
9622
10387
  ...evaluateInScopeCheck({
9623
10388
  authoringSessionIds,
@@ -9790,6 +10555,7 @@ var verification_proof_exports = /* @__PURE__ */ __exportAll({
9790
10555
  prVerifyProofHeadShas: () => prVerifyProofHeadShas,
9791
10556
  readPrVerifyProof: () => readPrVerifyProof,
9792
10557
  resolveVerifyProofApplicability: () => resolveVerifyProofApplicability,
10558
+ trustedImpactStamp: () => trustedImpactStamp,
9793
10559
  validatePrVerifyProof: () => validatePrVerifyProof,
9794
10560
  verificationProofApplicabilityFor: () => verificationProofApplicabilityFor,
9795
10561
  verificationProofBlockingReason: () => verificationProofBlockingReason,
@@ -9820,6 +10586,11 @@ const executedCommandSchema = z.object({
9820
10586
  "full"
9821
10587
  ])
9822
10588
  });
10589
+ const notRequiredCommandSchema = z.object({
10590
+ basis: z.string().min(1),
10591
+ impactTarget: z.string().min(1),
10592
+ name: z.string().min(1)
10593
+ });
9823
10594
  const prVerifySchemaVersionSchema = z.number().refine((value) => SUPPORTED_PR_VERIFY_SCHEMA_VERSIONS.includes(value), { message: `schemaVersion must be one of: ${SUPPORTED_PR_VERIFY_SCHEMA_VERSIONS.join(", ")}` });
9824
10595
  const prVerifyProofSchema = z.object({
9825
10596
  authoringSession: z.string().trim().min(1).optional(),
@@ -9844,12 +10615,14 @@ const prVerifyProofSchema = z.object({
9844
10615
  endedAt: z.iso.datetime(),
9845
10616
  executedCommands: z.array(executedCommandSchema).optional(),
9846
10617
  headSha: z.string().regex(/^[0-9a-f]{40}$/u),
10618
+ impactStamp: impactStampSchema.optional(),
9847
10619
  mergeBaseSha: z.string().regex(/^[0-9a-f]{40}$/u).optional(),
9848
10620
  mode: z.enum([
9849
10621
  "docs-only",
9850
10622
  "trivial",
9851
10623
  "full"
9852
10624
  ]),
10625
+ notRequiredCommands: z.array(notRequiredCommandSchema).min(1).optional(),
9853
10626
  outcome: z.enum(["aborted", "passed"]).optional(),
9854
10627
  patchId: z.string().regex(/^[0-9a-f]{40}$/u).optional(),
9855
10628
  profilePath: z.string().min(1),
@@ -9860,6 +10633,7 @@ const prVerifyProofSchema = z.object({
9860
10633
  verificationCommands: z.array(z.object({
9861
10634
  command: z.string().min(1),
9862
10635
  description: z.string().min(1),
10636
+ impactTarget: z.string().min(1).optional(),
9863
10637
  name: z.string().min(1),
9864
10638
  scope: z.enum([
9865
10639
  "always",
@@ -9868,18 +10642,7 @@ const prVerifyProofSchema = z.object({
9868
10642
  "full"
9869
10643
  ])
9870
10644
  }))
9871
- }).superRefine((proof, context) => {
9872
- if (proof.schemaVersion >= 4 && proof.authoringSession === void 0) context.addIssue({
9873
- code: "custom",
9874
- message: "schemaVersion 4 pr:verify proof requires authoringSession (the recorded authoring identity, or the `unknown` sentinel).",
9875
- path: ["authoringSession"]
9876
- });
9877
- if (proof.schemaVersion < 4 && proof.authoringSession !== void 0) context.addIssue({
9878
- code: "custom",
9879
- message: "authoringSession is a schemaVersion>=4 field; a v1–v3 pr:verify proof must not carry it.",
9880
- path: ["authoringSession"]
9881
- });
9882
- });
10645
+ }).superRefine(assertPrVerifyProofRules);
9883
10646
  function validatePrVerifyProof(value) {
9884
10647
  return prVerifyProofSchema.parse(value);
9885
10648
  }
@@ -9920,6 +10683,29 @@ const verifyProofIdentityMatches = ({ currentIdentity, proof }) => Boolean(proof
9920
10683
  recorded: { patchId: proof.patchId },
9921
10684
  rule: "patch-id"
9922
10685
  }));
10686
+ /**
10687
+ * Wave-2 consumption (#542): the recorded impact stamp, released for demand
10688
+ * resolution ONLY when it is identity-bound to the current candidate — the
10689
+ * proof passed, and its recorded headSha AND patchId both name exactly this
10690
+ * candidate (the same identities the proof already binds). Everything else —
10691
+ * no proof, an aborted run, a stale head, a moved patch id, a pre-stamp proof
10692
+ * — returns `undefined`, and the consumer keeps today's full-demand floor.
10693
+ *
10694
+ * Deliberately stricter than verify-once applicability (which tolerates a
10695
+ * head-only or patch-id-only match): a stamp narrows external demands, so it
10696
+ * is trusted only on an exact double binding, fail closed.
10697
+ */
10698
+ const trustedImpactStamp = ({ candidate, proof }) => {
10699
+ if (!proof || !verifyProofPassed(proof)) return;
10700
+ if (proof.schemaVersion < 4) return;
10701
+ const headBound = sameHeadSha(candidate.headSha, proof.headSha);
10702
+ const patchBound = evidenceFresh({
10703
+ candidate: { patchId: candidate.patchId },
10704
+ recorded: { patchId: proof.patchId },
10705
+ rule: "patch-id"
10706
+ });
10707
+ return headBound && patchBound ? proof.impactStamp : void 0;
10708
+ };
9923
10709
  const prVerifyFullHeadShasForDiff = ({ docsOnlyDeltaAcceptedSince, files, proof }) => {
9924
10710
  if (!verifyProofPassed(proof)) return [];
9925
10711
  if (proof.mode === "full") return sameFileList(proof.changedFiles, files) ? [proof.headSha] : [];
@@ -10287,6 +11073,7 @@ function defaultPrVerifyGit() {
10287
11073
  exitCode: result.exitCode
10288
11074
  };
10289
11075
  },
11076
+ showFileAtRef,
10290
11077
  stablePatchId: runStablePatchId,
10291
11078
  statusPorcelain
10292
11079
  };
@@ -10316,7 +11103,7 @@ function baselineFullProofsForDocsOnly({ previousProof, profilePath, projectKey,
10316
11103
  seedFilter: matchesProfile
10317
11104
  });
10318
11105
  }
10319
- function buildPrVerifyProof({ authoringSession, base, baselineFullProofs, classification, commands, endedAt, executedCommands, files, headSha, mode, mergeBaseSha: proofMergeBaseSha, outcome, patchId, profilePath, projectKey, repository, startedAt }) {
11106
+ function buildPrVerifyProof({ authoringSession, base, baselineFullProofs, classification, commands, endedAt, executedCommands, files, headSha, impactStamp, mode, mergeBaseSha: proofMergeBaseSha, notRequiredCommands, outcome, patchId, profilePath, projectKey, repository, startedAt }) {
10320
11107
  return {
10321
11108
  authoringSession,
10322
11109
  base,
@@ -10329,8 +11116,10 @@ function buildPrVerifyProof({ authoringSession, base, baselineFullProofs, classi
10329
11116
  endedAt: endedAt.toISOString(),
10330
11117
  executedCommands,
10331
11118
  headSha,
11119
+ impactStamp,
10332
11120
  mergeBaseSha: proofMergeBaseSha,
10333
11121
  mode,
11122
+ ...notRequiredCommands.length > 0 ? { notRequiredCommands } : {},
10334
11123
  outcome,
10335
11124
  patchId,
10336
11125
  profilePath,
@@ -10375,9 +11164,10 @@ function resolveMode({ classification, mode }) {
10375
11164
  return mode;
10376
11165
  }
10377
11166
  function commandsForMode(profile, mode) {
10378
- return profile.verification.commands.filter((command) => commandAppliesToMode(command, mode)).map(({ command, description, name, scope }) => ({
11167
+ return profile.verification.commands.filter((command) => commandAppliesToMode(command, mode)).map(({ command, description, impactTarget, name, scope }) => ({
10379
11168
  command,
10380
11169
  description,
11170
+ ...impactTarget === void 0 ? {} : { impactTarget },
10381
11171
  name,
10382
11172
  scope
10383
11173
  }));
@@ -10432,6 +11222,36 @@ function runPrVerify(args, dependencies = {}) {
10432
11222
  cwd,
10433
11223
  git
10434
11224
  });
11225
+ const readLockfileAtRef = git.showFileAtRef ?? showFileAtRef;
11226
+ let impactStamp;
11227
+ try {
11228
+ impactStamp = computeImpactStamp({
11229
+ changedFiles: files,
11230
+ profile,
11231
+ profilePath: relativeProfile,
11232
+ readLockfile: (side) => readLockfileAtRef(cwd, side === "base" ? proofMergeBaseSha : headSha, path.resolve(cwd, LOCKFILE_PATH))
11233
+ });
11234
+ } catch (error) {
11235
+ if (error instanceof ImpactStampConfigError) throw error;
11236
+ impactStamp = conservativeImpactStamp(profile.impact?.targets ?? [], [`impact stamp computation failed (${error instanceof Error ? error.message : String(error)}); fail closed to full impact`]);
11237
+ }
11238
+ const vetoedTargets = boundFailingCheckNames({
11239
+ candidate: {
11240
+ headSha,
11241
+ mergeBaseSha: proofMergeBaseSha,
11242
+ patchId
11243
+ },
11244
+ checkType: "verify",
11245
+ envelopes: loadEvidenceEnvelopes(cwd)
11246
+ });
11247
+ const battery = planVerificationBattery({
11248
+ commands: selectedProfileCommands,
11249
+ stamp: impactStamp,
11250
+ vetoedTargets
11251
+ });
11252
+ const notRequiredCommands = battery.notRequired;
11253
+ const scopedOutNames = new Set(notRequiredCommands.map(({ name }) => name));
11254
+ for (const line of batteryScopeSummaryLines(battery)) console.log(line);
10435
11255
  const repository = `${profile.repository.owner}/${profile.repository.name}`;
10436
11256
  let gateState = "failure";
10437
11257
  const { env: verificationEnv, cleanup } = context.buildEnv();
@@ -10455,8 +11275,10 @@ function runPrVerify(args, dependencies = {}) {
10455
11275
  commands,
10456
11276
  files,
10457
11277
  headSha,
11278
+ impactStamp,
10458
11279
  mergeBaseSha: proofMergeBaseSha,
10459
11280
  mode: resolvedMode,
11281
+ notRequiredCommands,
10460
11282
  patchId,
10461
11283
  profilePath,
10462
11284
  projectKey: profile.repository.name,
@@ -10488,6 +11310,7 @@ function runPrVerify(args, dependencies = {}) {
10488
11310
  };
10489
11311
  try {
10490
11312
  for (const command of commands) {
11313
+ if (scopedOutNames.has(command.name)) continue;
10491
11314
  console.log(`\n> ${command.name}: ${command.command}`);
10492
11315
  const result = git.runVerificationCommand(command.command, cwd, verificationEnv);
10493
11316
  const executedCommand = {
@@ -10554,6 +11377,7 @@ function runPrVerify(args, dependencies = {}) {
10554
11377
  });
10555
11378
  for (const command of selectedProfileCommands) {
10556
11379
  if (!command.requiredCheck) continue;
11380
+ if (scopedOutNames.has(command.name)) continue;
10557
11381
  runEvidenceEmit({
10558
11382
  base: args.base,
10559
11383
  check: command.requiredCheck,
@@ -11235,6 +12059,13 @@ const requiredCheckChecks = ({ candidate, candidateError, cwd, profile, verifyPr
11235
12059
  name: "admission:required-checks",
11236
12060
  status: "warning"
11237
12061
  }];
12062
+ const impactStamp = trustedImpactStamp({
12063
+ candidate: {
12064
+ headSha: candidate.headSha,
12065
+ patchId: candidate.patchId
12066
+ },
12067
+ proof: verifyProof
12068
+ });
11238
12069
  const { outcomes } = evaluateRequiredChecks({
11239
12070
  authoringSessionIds: resolveAuthoringSessionIds({ recorded: verifyProof?.authoringSession }),
11240
12071
  candidate: {
@@ -11244,7 +12075,10 @@ const requiredCheckChecks = ({ candidate, candidateError, cwd, profile, verifyPr
11244
12075
  },
11245
12076
  envelopes: loadEvidenceEnvelopes(cwd),
11246
12077
  requiredChecks,
11247
- scopeContext: { classification: candidate.classification }
12078
+ scopeContext: {
12079
+ classification: candidate.classification,
12080
+ ...impactStamp ? { impactStamp } : {}
12081
+ }
11248
12082
  });
11249
12083
  return outcomes.map((outcome) => ({
11250
12084
  message: requiredCheckMessage(outcome),
@@ -12638,8 +13472,8 @@ const inactiveMergeFreezeStateSchema = z.object({
12638
13472
  });
12639
13473
  const mergeFreezeStateSchema = z.discriminatedUnion("active", [activeMergeFreezeStateSchema, inactiveMergeFreezeStateSchema]);
12640
13474
  /**
12641
- * The write side of this contract lives in the generated main-push verify
12642
- * workflow (#356, ADR 0016 as amended): it is the ONLY producer of
13475
+ * The write side of this contract lives in the generated merge-target push
13476
+ * Verify workflow (#356, ADR 0016 as amended; #429): it is the ONLY producer of
12643
13477
  * `patronage-factory/merge-freeze` generations. Its emitted `output.text`
12644
13478
  * payload must parse under this exact reader schema, which is what the
12645
13479
  * workflow's own tests assert through this export.
@@ -12732,7 +13566,7 @@ function selectListedMergeFreezeGeneration(listed, expectedHeadSha) {
12732
13566
  function unconfiguredMergeFreeze(input, detail) {
12733
13567
  return {
12734
13568
  kind: "unreadable",
12735
- reason: `The ${MERGE_FREEZE_CHECK_NAME} writer is unconfigured or its settle window cannot be confirmed for base ${input.headSha}: ${detail} Generate the main-push verify workflow, configure FACTORY_GITHUB_APP_PRIVATE_KEY, and rerun pr:ready after the first App-owned freeze generation appears.`
13569
+ reason: `The ${MERGE_FREEZE_CHECK_NAME} writer is unconfigured or its settle window cannot be confirmed for base ${input.headSha}: ${detail} Generate the merge-target push Verify workflow, configure FACTORY_GITHUB_APP_PRIVATE_KEY, and rerun pr:ready after the first App-owned freeze generation appears on this base branch.`
12736
13570
  };
12737
13571
  }
12738
13572
  function selectMissingMergeFreezeGenerationUnchecked(api, input) {
@@ -12839,8 +13673,38 @@ function createGitHubCheckRunMergeFreezeApi(dependencies = {}) {
12839
13673
  };
12840
13674
  }
12841
13675
  const githubMergeFreezeStore = createGitHubCheckRunMergeFreezeStore(createGitHubCheckRunMergeFreezeApi());
12842
- /** The one refusal sentence, naming both exits, wherever the freeze refuses. */
12843
- const activeMergeFreezeReason = (reason) => `Merge freeze is active (${reason}). It clears automatically when the main-push verify workflow completes green after a fix-forward or revert; an operator may waive it for one candidate with patronage-factory demand:waive --pr <pr> --demand merge-freeze --rationale <why>.`;
13676
+ /**
13677
+ * The one refusal sentence, naming both exits, wherever the freeze refuses.
13678
+ *
13679
+ * Deliberately base-tip-scoped rather than main-scoped (#429). Since the writer
13680
+ * completes a generation on every factory merge target, the freeze this reader
13681
+ * found lives on the tip it was asked about — `main` for a main-targeting
13682
+ * candidate, the epic branch for an epic-targeting one — and only a later green
13683
+ * Verify push to THAT branch writes a newer inactive generation. The writer's
13684
+ * own recovery text, quoted verbatim in `reason`, names the branch; this
13685
+ * wrapper must not contradict it by naming a different one.
13686
+ */
13687
+ const activeMergeFreezeReason = (reason) => `Merge freeze is active (${reason}). The generation is the one on this candidate's own base tip, so it clears automatically when a later Verify push to that base branch completes green after a fix-forward or revert; an operator may waive it for one candidate with patronage-factory demand:waive --pr <pr> --demand merge-freeze --rationale <why>.`;
13688
+ /**
13689
+ * The refusal for unreadable authority, which is NOT the active-freeze
13690
+ * sentence (#429 review cycles 2 and 3).
13691
+ *
13692
+ * Both refusals are equally fail-closed, but their recovery is not the same
13693
+ * act, and — the cycle-3 correction — "unreadable" is not one situation either.
13694
+ * It covers a repository with no writer at all, an unreachable Checks API, and
13695
+ * a defective *selected* generation: ambiguous newest runs, a malformed
13696
+ * payload, state inconsistent with its own conclusion. The first two are exited
13697
+ * by making the authority readable; the last is exited by a newer valid
13698
+ * App-owned generation winning the `started_at` selection, which a later Verify
13699
+ * push to the base branch normally produces.
13700
+ *
13701
+ * So this message states both paths without asserting which one applies. The
13702
+ * detail string it quotes is what distinguishes them, and the operator can read
13703
+ * it. Two categorical claims have already been wrong here — "clears when the
13704
+ * main-push verify completes green" (cycle 1) and "no Verify push clears this"
13705
+ * (cycle 2) — both from a refusal that spoke for states it could not see.
13706
+ */
13707
+ 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>.`;
12844
13708
  /**
12845
13709
  * The freeze read during readiness, before any authorized arming (#477,
12846
13710
  * ADR 0016 as amended 2026-07-31/2026-08-01).
@@ -12852,17 +13716,17 @@ const activeMergeFreezeReason = (reason) => `Merge freeze is active (${reason}).
12852
13716
  * notice only when the writer is observable: either its generation is running,
12853
13717
  * or the prior tip has a settled generation while this tip's source-pinned
12854
13718
  * hosted verify is running (#521). Epic #473 decision 5 deliberately trades
12855
- * the settle-window wait against the measured rarity of a red main. A
13719
+ * the settle-window wait against the measured rarity of a red merge target. A
12856
13720
  * writerless or unprovable repository fails closed.
12857
13721
  */
12858
13722
  function armingMergeFreezeOutcome(input, authority) {
12859
13723
  const generation = authority.readGeneration(input);
12860
13724
  if (generation.kind === "settling") return {
12861
13725
  blockingReasons: [],
12862
- notices: [`Merge freeze generation for base ${input.headSha} is still settling (${generation.reason}); readiness proceeds (ADR 0016, 2026-07-31/2026-08-01 amendments). If this wave authorizes machine merge, arming follows the admission decision. The main-push verify for that tip still refuses every later readiness decision if it goes red.`]
13726
+ notices: [`Merge freeze generation for base ${input.headSha} is still settling (${generation.reason}); 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.`]
12863
13727
  };
12864
13728
  if (generation.kind === "unreadable") return {
12865
- blockingReasons: [activeMergeFreezeReason(`Authoritative GitHub merge freeze check-run state is unavailable or invalid: ${generation.reason}`)],
13729
+ blockingReasons: [unreadableMergeFreezeReason(input.headSha, `Authoritative GitHub merge freeze check-run state is unavailable or invalid: ${generation.reason}`)],
12866
13730
  notices: []
12867
13731
  };
12868
13732
  return {
@@ -13354,12 +14218,23 @@ const requiredChecksEvaluation = (input) => {
13354
14218
  mergeBaseSha: input.mergeBaseSha ?? input.baseSha,
13355
14219
  patchId: input.patchId
13356
14220
  };
14221
+ const { verificationProof } = input;
14222
+ const impactStamp = trustedImpactStamp({
14223
+ candidate: {
14224
+ headSha: input.headSha,
14225
+ patchId: input.patchId
14226
+ },
14227
+ proof: verificationProof && "proof" in verificationProof ? verificationProof.proof : void 0
14228
+ });
13357
14229
  const { blockingReasons, outcomes } = evaluateRequiredChecks({
13358
14230
  authoringSessionIds: input.authoringSessionIds ?? [],
13359
14231
  candidate,
13360
14232
  envelopes: input.evidenceEnvelopes ?? [],
13361
14233
  requiredChecks,
13362
- scopeContext: { classification: input.classification }
14234
+ scopeContext: {
14235
+ classification: input.classification,
14236
+ ...impactStamp ? { impactStamp } : {}
14237
+ }
13363
14238
  });
13364
14239
  const blockers = outcomes.flatMap((outcome) => outcome.reason ? [{
13365
14240
  demand: requiredCheckDemand(outcome.name),
@@ -15490,6 +16365,60 @@ const assembleReviewPrompt = ({ base, changedFiles, headSha, issueFocus, kind, p
15490
16365
  };
15491
16366
  };
15492
16367
  //#endregion
16368
+ //#region src/preview-lifecycle.ts
16369
+ /**
16370
+ * The declared preview stacks: impact targets that are ALSO declared required
16371
+ * checks — i.e. targets whose proof is produced externally, which is exactly
16372
+ * what a preview producer is.
16373
+ *
16374
+ * Deriving the list from the two existing declarations rather than adding a
16375
+ * third is what makes lifecycle and demand unable to disagree: the stacks this
16376
+ * plan can withhold are precisely the demands wave 2 can release, so a stack
16377
+ * can never be skipped while its proof stays demanded.
16378
+ */
16379
+ const previewLifecycleTargets = (profile) => {
16380
+ const demanded = new Set((profile.requiredChecks ?? []).map(({ name }) => name));
16381
+ return (profile.impact?.targets ?? []).map(({ name }) => name).filter((name) => demanded.has(name));
16382
+ };
16383
+ /**
16384
+ * Plan the preview lifecycle for one candidate.
16385
+ *
16386
+ * Trust is resolved here through the SAME identity gate demand release uses
16387
+ * (`trustedImpactStamp`): the proof must have passed and must bind both the
16388
+ * candidate's headSha and its patchId. A stale, aborted, absent, or
16389
+ * differently-bound proof yields no stamp, and every declared stack is planned
16390
+ * for deploy. Total over its INPUTS — no proof, stamp, or profile can make it
16391
+ * throw. A malformed CALL is the deliberate exception: omitting
16392
+ * `vetoedTargets` is API misuse, not data doubt, and throws.
16393
+ */
16394
+ const planPreviewLifecycle = ({ candidate, profile, proof, vetoedTargets }) => {
16395
+ assertVetoedTargets(vetoedTargets, "planPreviewLifecycle");
16396
+ const stamp = trustedImpactStamp({
16397
+ candidate,
16398
+ proof
16399
+ });
16400
+ const vetoed = new Set(vetoedTargets);
16401
+ const targets = previewLifecycleTargets(profile).map((name) => {
16402
+ const decision = impactStampScopeDecision({
16403
+ stamp,
16404
+ surface: "preview-lifecycle",
16405
+ targetName: name
16406
+ });
16407
+ const vetoedHere = decision.scoped && vetoed.has(name);
16408
+ return {
16409
+ basis: vetoedHere ? `veto: a current envelope bound to this candidate records stack "${name}" as FAILING, so the stamp's release is withheld (${decision.reason})` : decision.reason,
16410
+ disposition: decision.scoped && !vetoedHere ? "not-required" : "deploy",
16411
+ name
16412
+ };
16413
+ });
16414
+ return {
16415
+ deploy: targets.filter((target) => target.disposition === "deploy").map((target) => target.name),
16416
+ notRequired: targets.filter((target) => target.disposition === "not-required"),
16417
+ stampTrusted: stamp !== void 0,
16418
+ targets
16419
+ };
16420
+ };
16421
+ //#endregion
15493
16422
  //#region src/index.ts
15494
16423
  function createProgram(options = {}) {
15495
16424
  const factoryCliInvocation = options.factoryCliInvocation ? FactoryCliInvocationSchema.parse(options.factoryCliInvocation) : defaultFactoryCliInvocation(import.meta.filename);
@@ -15537,4 +16466,4 @@ if (isDirectCliExecution()) try {
15537
16466
  process.exit(1);
15538
16467
  }
15539
16468
  //#endregion
15540
- export { DEFAULT_DEMAND_WAIVER_PATH, DEFAULT_FACTORY_REPOSITORY, DemandWaiveRefusalError, EPIC_STRUCTURE_NODE_STATUSES, EPIC_STRUCTURE_SCHEMA_VERSION, EpicStructureValidationError, FactoryCliInvocationSchema, FollowUpActionSchema, MERGE_FREEZE_APP_SLUG, MERGE_FREEZE_CHECK_NAME, PrPublishFollowUpError, RETRO_ENVELOPE_SCHEMA_VERSION, RETRO_ENVELOPE_VALIDATORS, REVIEW_FOCUS_SECTION, SUPPORTED_RETRO_ENVELOPE_SCHEMA_VERSIONS, WorkerCheckoutGuardError, appendReviewLadderStageTraceEvent, appendWithTraceSinks, applyDemandWaiver, assembleReviewPrompt, assertWorkerCheckoutAllowed, authorizeDemandWaiver, blockingLadderFindings, boundary_manifest_exports as boundaryManifest, boundary_review_proof_exports as boundaryReviewProof, buildEpicStructureEvent, buildEpicStructurePayload, buildEvidenceEnvelope, buildRetroEnvelope, buildReviewGateNotRequiredTraceEvent, buildReviewLadderStageTraceEvent, comment_provenance_exports as commentProvenance, createLocalJsonlTraceSink, createProgram, doctorProjectProfile, epicStructureEventId, evaluateReviewLadder, evidenceEnvelopeFilename, findingKey, followUpFromArgv, inferFixedInThreadDispositions, isProductionHqUrl, isRetroEnvelopeWireComplete, loadProjectProfile, normalizeIssueComments, openLadderFindings, parseRetroEnvelope, planPublishFollowUp, readiness_evaluation_exports as prReadinessEvaluation, external_evidence_exports as prReadinessExternalEvidence, pr_body_renderer_exports as prReadinessPrBodyRenderer, proof_identity_exports as prReadinessProofIdentity, review_proof_exports as prReadinessReviewProof, status_check_rollup_exports as prReadinessStatusChecks, verification_proof_exports as prReadinessVerificationProof, publishEpicStructure, readDemandWaivers, readPrReadyProof, readPrReviewProof, resolveFactoryRepository, resolveFindingBlocking, resolveReviewFindingCategory, resolveReviewFindingSeverity, resolveReviewLadderPolicy, resolveTraceWriteSinks, retroEnvelopeSchemaVersionOf, retroEnvelopeV1Schema, retroEpicReference, reviewCycleStateFor, reviewFocusFromIssueBody, reviewPromptSectionSchema, reviewPromptSectionsSchema, run, runBoundaryCheck, runDemandWaive, runEvidenceEmit, runPrPublish, runPrReady, runPrReview, runPrVerify, scanFactoryTraceDiagnostics, scanFactoryTraceEvents, selectWaiversForCandidate, staleRepeatLadderFindings, toFactoryTraceEnvelope, tryAppendReviewGateNotRequiredTraceEvent, tryAppendReviewLadderStageTraceEvent, validateBoundaryCheckProof, validateDagDocument, validateDemandWaiverStore, validateFactoryTraceEvent, validateMergeFreezeState, validatePrReadyProof, validatePrReviewProof, validatePrVerifyProof, waivedDemandNotice, waivedDemandSchema, worktree_scratch_files_exports as worktreeScratchFiles };
16469
+ export { DEFAULT_DEMAND_WAIVER_PATH, DEFAULT_FACTORY_REPOSITORY, DemandWaiveRefusalError, EPIC_STRUCTURE_NODE_STATUSES, EPIC_STRUCTURE_SCHEMA_VERSION, EpicStructureValidationError, FactoryCliInvocationSchema, FollowUpActionSchema, MERGE_FREEZE_APP_SLUG, MERGE_FREEZE_CHECK_NAME, PrPublishFollowUpError, RETRO_ENVELOPE_SCHEMA_VERSION, RETRO_ENVELOPE_VALIDATORS, REVIEW_FOCUS_SECTION, SUPPORTED_RETRO_ENVELOPE_SCHEMA_VERSIONS, WorkerCheckoutGuardError, appendReviewLadderStageTraceEvent, appendWithTraceSinks, applyDemandWaiver, assembleReviewPrompt, assertWorkerCheckoutAllowed, authorizeDemandWaiver, batteryScopeSummaryLines, blockingLadderFindings, boundary_manifest_exports as boundaryManifest, boundary_review_proof_exports as boundaryReviewProof, buildEpicStructureEvent, buildEpicStructurePayload, buildEvidenceEnvelope, buildRetroEnvelope, buildReviewGateNotRequiredTraceEvent, buildReviewLadderStageTraceEvent, comment_provenance_exports as commentProvenance, createLocalJsonlTraceSink, createProgram, doctorProjectProfile, epicStructureEventId, evaluateReviewLadder, evidenceEnvelopeFilename, findingKey, followUpFromArgv, impactStampScopeDecision, inferFixedInThreadDispositions, isProductionHqUrl, isRetroEnvelopeWireComplete, loadProjectProfile, normalizeIssueComments, openLadderFindings, parseRetroEnvelope, planPreviewLifecycle, planPublishFollowUp, planVerificationBattery, readiness_evaluation_exports as prReadinessEvaluation, external_evidence_exports as prReadinessExternalEvidence, pr_body_renderer_exports as prReadinessPrBodyRenderer, proof_identity_exports as prReadinessProofIdentity, review_proof_exports as prReadinessReviewProof, status_check_rollup_exports as prReadinessStatusChecks, verification_proof_exports as prReadinessVerificationProof, previewLifecycleTargets, publishEpicStructure, readDemandWaivers, readPrReadyProof, readPrReviewProof, resolveFactoryRepository, resolveFindingBlocking, resolveReviewFindingCategory, resolveReviewFindingSeverity, resolveReviewLadderPolicy, resolveTraceWriteSinks, retroEnvelopeSchemaVersionOf, retroEnvelopeV1Schema, retroEpicReference, reviewCycleStateFor, reviewFocusFromIssueBody, reviewPromptSectionSchema, reviewPromptSectionsSchema, run, runBoundaryCheck, runDemandWaive, runEvidenceEmit, runPrPublish, runPrReady, runPrReview, runPrVerify, scanFactoryTraceDiagnostics, scanFactoryTraceEvents, selectWaiversForCandidate, staleRepeatLadderFindings, toFactoryTraceEnvelope, tryAppendReviewGateNotRequiredTraceEvent, tryAppendReviewLadderStageTraceEvent, validateBoundaryCheckProof, validateDagDocument, validateDemandWaiverStore, validateFactoryTraceEvent, validateMergeFreezeState, validatePrReadyProof, validatePrReviewProof, validatePrVerifyProof, waivedDemandNotice, waivedDemandSchema, worktree_scratch_files_exports as worktreeScratchFiles };