fix(#4663): gate the canonicalization on the uat-passed predicate (#4809)

* test(#4663): add failing-first contract coverage for the blocked-uat canonicalization gate

verify-work.md's complete_session step flips VERIFICATION.md to passed on
'zero issues' alone, so a session whose every UAT row is blocked (a session
that observed nothing) canonicalizes the report. Pins the deployed contract
the fix must satisfy: the flip runs the unflagged phase uat-passed predicate
inside the human_needed branch, frontmatter.set sits inside a passed==true
guard, a refusal message carries the blocker count and keeps
human_needed, and an indeterminate pre-check fails closed. All four new
assertions are RED until the workflow grows the guard.

* fix(#4663): gate the canonicalization on the uat-passed predicate

complete_session flipped VERIFICATION.md to passed whenever the session
recorded zero issues and the status was human_needed — but blocked rows are
not issues by this workflow's own rule, so a 0-passed / 0-issues / N-blocked
session (one that observed nothing) rewrote the canonical report to passed.
Every later reader (transition.md's preliminary check, resume paths,
validate-phase, verification.status) then inherited the unearned pass while
the phase-close predicate correctly refused it.

The flip now runs the phase-close predicate in a new --uat-only form before
canonicalizing: UAT rows evaluated (at least one pass, no
pending/blocked/failed/unexplained-skip row), VERIFICATION-status blockers
skipped — they must be, because the report still reads human_needed at
pre-check time and that status is itself a blocking verification entry, so
the full predicate could never pass there and the flip would deadlock
(found by isolated review, probed). The --require-verification call stays
the transition gate; the refusal branch reports the blocker count and keeps
human_needed; an indeterminate pre-check fails closed.

Emitted-Drift-Ack-Growth: verify-work.md — canonicalize block gains the uat-only pre-check and refusal branch (#4663)

* test(#4663): align the canonicalize pre-check needles with the shipped line

The workflow line carries a 2>/dev/null redirect the needles did not
include, so both pre-check assertions fail against the committed fix
(fixed-string grep verified). Reviewer-found; needle and message aligned.

* fix(#4663): reword the canonicalize prose and refresh its size baseline

The rationale paragraph mentioned the flagged transition-gate call by its
flag, putting a --require-verification literal before the first
phase uat-passed occurrence and breaking the existing ordering pin; the
prose now describes it without the literal. verify-work.md's growth also
drifted the committed compact-content baseline; regenerated via
benchmark-compact-content.cjs --write (derived artifact, report-not-gate
contract).

Emitted-Drift-Ack-Growth: verify-work.md — canonicalize block gains the uat-only pre-check and refusal branch (#4663)

* docs(#4663): backfill changeset PR number

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-09-16 17:26:24 -04:00
committed by GitHub
parent c72fb34e9a
commit 85545a77a5
8 changed files with 161 additions and 15 deletions

View File

@@ -45,7 +45,7 @@ interface PhaseHandlers {
) => void;
cmdPhaseRemove: (cwd: string, phaseNum: string, opts: { force: boolean }, raw: boolean) => void;
cmdPhaseComplete: (cwd: string, phaseNum: string | undefined, raw: boolean) => void;
cmdPhaseUatPassed: (cwd: string, phaseNum: string | undefined, raw: boolean, opts?: { policy?: { requireVerification?: boolean } }) => void;
cmdPhaseUatPassed: (cwd: string, phaseNum: string | undefined, raw: boolean, opts?: { policy?: { requireVerification?: boolean; uatOnly?: boolean } }) => void;
cmdPhaseListPlans: (cwd: string, phaseNum: string | undefined, raw: boolean) => void;
}
@@ -222,10 +222,14 @@ function routePhaseCommand({ phase, args, cwd, raw, error }: RoutePhaseCommandOp
},
'uat-passed': (_ctx: Record<string, unknown>): { ok: true; data: null } => {
let requireVerification = false;
let uatOnly = false;
const positional: string[] = [];
for (const token of args.slice(2)) {
if (token === '--require-verification') {
requireVerification = true;
} else if (token === '--uat-only') {
// #4663: evaluate UAT rows only (verification-status blockers skipped).
uatOnly = true;
} else if (token === '--raw') {
// --raw is handled by the outer CLI layer; accepted here silently
} else if (token.startsWith('--')) {
@@ -234,7 +238,13 @@ function routePhaseCommand({ phase, args, cwd, raw, error }: RoutePhaseCommandOp
positional.push(token);
}
}
phase.cmdPhaseUatPassed(cwd, positional[0], raw, { policy: { requireVerification } });
if (requireVerification && uatOnly) {
return makeInvalidArgs(
'--uat-only',
'--uat-only and --require-verification are mutually exclusive',
) as never;
}
phase.cmdPhaseUatPassed(cwd, positional[0], raw, { policy: { requireVerification, uatOnly } });
return { ok: true as const, data: null };
},
// #1437 — list plan files for a phase

View File

@@ -4693,7 +4693,7 @@ function cmdPhaseUatPassed(
cwd: string,
phaseNum: string | undefined,
raw: boolean,
opts: { policy?: { requireVerification?: boolean } } = {},
opts: { policy?: { requireVerification?: boolean; uatOnly?: boolean } } = {},
): void {
if (!phaseNum) {
error('phase number required for phase uat-passed');

View File

@@ -55,6 +55,14 @@ interface UatPassedReport {
no_uat_artifacts: boolean;
policy: {
require_verification: boolean;
/**
* #4663: true when the report was produced in uat-only mode — UAT rows
* evaluated, VERIFICATION-file blockers (and only those) skipped. The
* verify-work canonicalize pre-check uses this form; the flip it gates is
* what removes the human_needed verification status, so the full
* predicate could never pass at pre-check time.
*/
uat_only: boolean;
};
/**
* #3057 B3: true when the `requireVerification` policy check's own
@@ -283,9 +291,15 @@ function parseUatResultItems(cleanContent: string): Array<{ test: number; name:
*/
function evaluateUatPassed(
phaseFullDir: string,
opts?: { policy?: { requireVerification?: boolean } },
opts?: { policy?: { requireVerification?: boolean; uatOnly?: boolean } },
): UatPassedReport {
const requireVerification = opts?.policy?.requireVerification === true;
// uatOnly (#4663) takes precedence: it evaluates the UAT rows ONLY, skipping
// the VERIFICATION-file blockers entirely. The verify-work canonicalize
// pre-check needs exactly that — it runs while the report still reads
// `human_needed`, which is itself a blocking verification status, so the
// full predicate could never pass there and the flip would deadlock.
const uatOnly = opts?.policy?.uatOnly === true;
const requireVerification = !uatOnly && opts?.policy?.requireVerification === true;
const blockers: string[] = [];
const checks: UatCheckItem[] = [];
@@ -309,7 +323,7 @@ function evaluateUatPassed(
checks: [],
blockers,
no_uat_artifacts,
policy: { require_verification: requireVerification },
policy: { require_verification: requireVerification, uat_only: uatOnly },
// readVerificationStatus was never reached on this early-return path.
verification_stale_check_indeterminate: false,
};
@@ -392,7 +406,7 @@ function evaluateUatPassed(
// ── Process VERIFICATION files ─────────────────────────────────────────────
let hasPassingVerification = false;
for (const file of verFileNames) {
for (const file of uatOnly ? [] : verFileNames) {
verificationFiles.push(file);
const verificationFilePath = path.join(phaseFullDir, file);
let raw = '';
@@ -451,6 +465,7 @@ function evaluateUatPassed(
no_uat_artifacts,
policy: {
require_verification: requireVerification,
uat_only: uatOnly,
},
verification_stale_check_indeterminate: verificationStaleCheckIndeterminate,
};