fix(review): fail-closed verdicts - an unsubstantiated finding can no longer yield ALLOW
computeVerdict counted only the findings handed to it (reasoned.kept), so a
finding removed by Pass 2 or Pass 3, and a reviewer whose payload was thrown
away or never arrived, were arithmetically identical to a finding that never
existed. All three pushed the verdict toward ALLOW.
Measured before the fix (probes, 2026-09-01):
- a BLOCKER with a 101-character title -> ALLOW (Pass 2 succinctness)
- a payload with one ad-hoc rule_key is skipped WHOLE at ingest, taking a
valid BLOCKER sibling with it -> ALLOW
- a reviewer that never reported -> ALLOW
Pass 3's own no-citation / unknown-rule_key branches turned out unreachable
through runContract (validateFindings rejects those payloads first), so the
reachable exposure was Pass 2 plus the skipped/absent reviewer.
THE OPEN DESIGN DECISION, and why it went against the order's default.
The order proposed: indeterminate file-existence YES, plain succinctness NO
("a too-long finding is not an uncertain finding"). I kept the first and
overrode the second, on one principle:
A removal is `dropped` only when the test REFUTED the finding as a claim
about this codebase. Every other removal is `unverified`.
Succinctness and actionability read a `.length`. They never examine the claim,
so they cannot establish the finding is unreal - and dropping a BLOCKER for a
101-character title is precisely the fail-open shape being fixed. Three things
settled it:
1. Under the order's default the fix would have been almost inert. Pass 3's
drop branches are unreachable via runContract, so leaving Pass 2 out would
have left the only reachable finding-level exposure open.
2. Cost asymmetry, priced rather than asserted: the verdict is not a gate.
Handover 6 feeds `findings` filtered to BLOCKER+MAJOR into /trekplan
(commands/trekplan.md:218); `verdict` is optional metadata
(docs/HANDOVER-CONTRACTS.md:353). Nothing loops or re-plans on WARN. So a
false `unverified` costs WARN plus a printed reason; a false drop costs a
silent ALLOW over a live BLOCKER.
3. unknown-rule_key joins them for the same reason: an ad-hoc key is a real
defect wearing the wrong label, and v5.1.1 high-effort mode already KEEPS
those, normalised to PLAN_EXECUTE_DRIFT. Refuting them at normal effort
while keeping them at high effort would be incoherent.
no-citation stays a drop: a finding whose file is empty or whose line is
negative names no location, so it makes no checkable claim at all - the one
deterministic refutation, and what the Pass 3 prose already said it was.
Iron Law: tests/lib/coordinator-contract.test.mjs first, red (missing export +
the three measured ALLOWs), then production code. Two existing assertions were
updated AFTER implementation as contract changes, not to make the red pass.
A known-positive control pins that ALLOW is still reachable - without it,
"no ALLOW" is not a fail-closed contract, only a broken one.
lib/review/coordinator-contract.mjs
+ classifySuppression / REFUTING_REASONS / UNVERIFIED_REASONS - one
vocabulary owned by the lib, including the tokens only the LLM
coordinator emits (accuracy:refuted, file-existence:refuted/indeterminate),
so prose and lib cannot drift. Unclassified reasons default to unverified:
the default fails closed.
~ judgeFilter / reasonablenessFilter return {kept, dropped, unverified}
~ computeVerdict(findings, {unverified, missingReviewers}) -> + allow_blocked_by.
Never raises a verdict, only withholds ALLOW. Unverified findings are NOT
counted into a severity tier: their severity was never substantiated, and
counting it would be invention.
~ runContract(payloads, {expectedReviewers}) -> + unverified,
missing_reviewers, allow_blocked_by. `suppressed` stays the union of
dropped + unverified, so existing consumers (gold-eval) keep their meaning.
agents/review-coordinator.md - Pass 2/3 tables gain a fate column, new
"Suppression is two-valued" section, Pass 4 threshold table gains the two
fail-closed rows, Executive Summary must state a withheld ALLOW, Suppressed
Findings tags each line [dropped]/[unverified]. Pass 3's unknown-rule_key
bullet explicitly says high-effort does not reach that branch, so the same
input never has two documented fates.
commands/trekreview.md - Phase 5 "Reviewer accounting": the expected set is
written down before the spawn, a silent reviewer gets one re-ask and then
STOP. That extends the pattern already in the file (schema failure -> 2
bounded re-asks -> "do not feed unvalidated findings to the coordinator") to
the other two ways a reviewer goes missing, rather than softening it to WARN.
The lib's missing_reviewers stays as belt-and-braces for direct callers.
docs/agent-return-channel-defect.md - the "inferred, not observed" caveat on
the unnamed arm above 66 lines is struck: akashic-intelligence S27
(f168630) measured 2/2 unnamed agents returning against a 4370-line plan,
30449 B and 10989 B, both valid JSON. Recorded with akashic's own two
caveats intact - the measurer owns the finding, and byte-identity between
the returned string and the file on disk was not proven. The separate S25
named-arm figures are left standing; these are two measurements, not a
correction of one by the other.
No release, no version bump, no tag, no catalogue ref, no Workflow port.
Suite 1025 (1023/0/2) -> 1034 (1032/0/2), 0 failures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
d650ff3bac
commit
e2aec019ac
6 changed files with 469 additions and 50 deletions
|
|
@ -6,6 +6,9 @@
|
|||
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
severityRank,
|
||||
ingest,
|
||||
|
|
@ -13,9 +16,14 @@ import {
|
|||
judgeFilter,
|
||||
reasonablenessFilter,
|
||||
computeVerdict,
|
||||
classifySuppression,
|
||||
REFUTING_REASONS,
|
||||
UNVERIFIED_REASONS,
|
||||
runContract,
|
||||
} from '../../lib/review/coordinator-contract.mjs';
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
||||
|
||||
// ---- Pass 1 — dedup --------------------------------------------------------
|
||||
|
||||
test('dedupByTriplet — genuine cross-reviewer collapse (identical triplet) → 1, raised_by both', () => {
|
||||
|
|
@ -98,29 +106,41 @@ test('computeVerdict — counts each severity tier', () => {
|
|||
|
||||
// ---- Pass 3 — reasonableness -----------------------------------------------
|
||||
|
||||
test('reasonablenessFilter — drops unknown rule_key + citation-less, corrects severity mismatch', () => {
|
||||
test('reasonablenessFilter — citation-less is REFUTED, unknown rule_key is UNVERIFIED, severity mismatch corrected', () => {
|
||||
// Contract change (fail-closed): only `no-citation` refutes. An ad-hoc
|
||||
// rule_key is a real defect wearing the wrong label — v5.1.1 high-effort mode
|
||||
// already keeps those, normalised to PLAN_EXECUTE_DRIFT.
|
||||
const r = reasonablenessFilter([
|
||||
{ file: 'x.mjs', line: 1, rule_key: 'NOPE_KEY', severity: 'BLOCKER' }, // unknown → drop
|
||||
{ file: 'x.mjs', line: 1, rule_key: 'NOPE_KEY', severity: 'BLOCKER' }, // unknown → unverified
|
||||
{ file: '', line: 1, rule_key: 'MISSING_TEST', severity: 'MAJOR' }, // no file → drop
|
||||
{ file: 'x.mjs', line: -1, rule_key: 'MISSING_TEST', severity: 'MAJOR' }, // line < 0 → drop
|
||||
{ file: 'x.mjs', line: 1, rule_key: 'MISSING_TEST', severity: 'MINOR' }, // catalogue is MAJOR → correct, keep
|
||||
]);
|
||||
assert.equal(r.kept.length, 1);
|
||||
assert.equal(r.dropped.length, 3);
|
||||
assert.equal(r.dropped.length, 2);
|
||||
assert.deepEqual(r.dropped.map((f) => f.suppressed_reason), ['no-citation', 'no-citation']);
|
||||
assert.equal(r.unverified.length, 1);
|
||||
assert.equal(r.unverified[0].suppressed_reason, 'unknown-rule_key');
|
||||
assert.equal(r.kept[0].severity, 'MAJOR');
|
||||
assert.equal(r.kept[0].original_severity, 'MINOR');
|
||||
});
|
||||
|
||||
// ---- Pass 2 — judge --------------------------------------------------------
|
||||
|
||||
test('judgeFilter — drops over-long title and empty recommended_action', () => {
|
||||
test('judgeFilter — over-long title and empty recommended_action are UNVERIFIED, not dropped', () => {
|
||||
// Contract change (fail-closed): both implemented Pass 2 tests read a
|
||||
// `.length` and never examine the claim, so neither refutes the finding.
|
||||
// `dropped` is empty here on purpose — the refuting Pass 2 filter (Accuracy)
|
||||
// is the one this deterministic subset excludes.
|
||||
const j = judgeFilter([
|
||||
{ file: 'x.mjs', line: 1, rule_key: 'MISSING_TEST', severity: 'MAJOR', title: 'x'.repeat(101) }, // too long → drop
|
||||
{ file: 'x.mjs', line: 2, rule_key: 'MISSING_TEST', severity: 'MAJOR', title: 'ok', recommended_action: ' ' }, // empty action → drop
|
||||
{ file: 'x.mjs', line: 1, rule_key: 'MISSING_TEST', severity: 'MAJOR', title: 'x'.repeat(101) }, // too long → unverified
|
||||
{ file: 'x.mjs', line: 2, rule_key: 'MISSING_TEST', severity: 'MAJOR', title: 'ok', recommended_action: ' ' }, // empty action → unverified
|
||||
{ file: 'x.mjs', line: 3, rule_key: 'MISSING_TEST', severity: 'MAJOR', title: 'ok' }, // keep (no action field is fine)
|
||||
]);
|
||||
assert.equal(j.kept.length, 1);
|
||||
assert.equal(j.dropped.length, 2);
|
||||
assert.equal(j.dropped.length, 0);
|
||||
assert.equal(j.unverified.length, 2);
|
||||
assert.deepEqual(j.unverified.map((f) => f.suppressed_reason), ['succinctness:title', 'actionability:empty']);
|
||||
});
|
||||
|
||||
// ---- ingest ----------------------------------------------------------------
|
||||
|
|
@ -161,3 +181,130 @@ test('runContract — deterministic: identical input yields identical output', (
|
|||
];
|
||||
assert.deepEqual(runContract(input), runContract(input));
|
||||
});
|
||||
|
||||
// ---- Fail-closed: the `unverified` bucket (ORDRE 834432937) -----------------
|
||||
//
|
||||
// The defect: a finding REMOVED by Pass 2/Pass 3, and a reviewer whose payload
|
||||
// was thrown away or never arrived, are all arithmetically identical to a
|
||||
// finding that never existed -- they push the verdict toward ALLOW. Measured
|
||||
// before the fix (probe, 2026-09-01): an over-long-title BLOCKER -> ALLOW; a
|
||||
// payload with one ad-hoc rule_key -> the whole payload skipped, its valid
|
||||
// BLOCKER sibling gone -> ALLOW.
|
||||
//
|
||||
// The rule under test: a removal is `dropped` ONLY when the test refutes the
|
||||
// finding as a claim about this codebase. Every other removal is `unverified`,
|
||||
// and a non-empty `unverified` -- or a reviewer that did not report -- forbids
|
||||
// ALLOW.
|
||||
|
||||
test('classifySuppression — only no-citation refutes; form and taxonomy failures are unverified', () => {
|
||||
assert.equal(classifySuppression('no-citation'), 'refuted',
|
||||
'a finding that names no location makes no checkable claim');
|
||||
assert.equal(classifySuppression('succinctness:title'), 'unverified');
|
||||
assert.equal(classifySuppression('succinctness:detail'), 'unverified');
|
||||
assert.equal(classifySuppression('actionability:empty'), 'unverified');
|
||||
assert.equal(classifySuppression('unknown-rule_key'), 'unverified');
|
||||
assert.equal(classifySuppression('file-existence:indeterminate'), 'unverified');
|
||||
assert.equal(classifySuppression('something-nobody-declared'), 'unverified',
|
||||
'an unclassified reason must fail CLOSED, not open');
|
||||
});
|
||||
|
||||
test('computeVerdict — non-empty unverified forbids ALLOW but never downgrades BLOCK or WARN', () => {
|
||||
const u = [{ file: 'x.mjs', line: 1, rule_key: 'MISSING_TEST', severity: 'BLOCKER' }];
|
||||
const withUnverified = computeVerdict([], { unverified: u });
|
||||
assert.equal(withUnverified.verdict, 'WARN', 'ALLOW is forbidden while anything is unverified');
|
||||
assert.deepEqual(withUnverified.counts, { BLOCKER: 0, MAJOR: 0, MINOR: 0, SUGGESTION: 0 },
|
||||
'the unverified finding is NOT counted into a severity tier');
|
||||
assert.ok(withUnverified.allow_blocked_by.length > 0);
|
||||
|
||||
assert.equal(computeVerdict([{ severity: 'BLOCKER' }], { unverified: u }).verdict, 'BLOCK',
|
||||
'BLOCK stands regardless of the unverified bucket');
|
||||
assert.equal(computeVerdict([{ severity: 'MAJOR' }], { unverified: u }).verdict, 'WARN');
|
||||
assert.equal(computeVerdict([], { unverified: [] }).verdict, 'ALLOW',
|
||||
'known-positive control: an empty unverified bucket still allows ALLOW');
|
||||
});
|
||||
|
||||
test('computeVerdict — a reviewer that did not report forbids ALLOW', () => {
|
||||
const r = computeVerdict([], { missingReviewers: ['brief-conformance-reviewer'] });
|
||||
assert.equal(r.verdict, 'WARN');
|
||||
assert.ok(r.allow_blocked_by.some((x) => x.includes('brief-conformance-reviewer')));
|
||||
});
|
||||
|
||||
test('runContract — a BLOCKER dropped for an over-long title cannot yield ALLOW', () => {
|
||||
// Pass 2 succinctness reads `.length`. It never examines the claim, so it
|
||||
// cannot establish the finding is unreal -- it is unverified, not refuted.
|
||||
const result = runContract([
|
||||
{ reviewer: 'code-correctness-reviewer', findings: [
|
||||
{ file: 'lib/auth/jwt.mjs', line: 19, rule_key: 'SECURITY_INJECTION', severity: 'BLOCKER', title: 'x'.repeat(101), detail: 'algo taken from the JWT header' },
|
||||
] },
|
||||
]);
|
||||
assert.notEqual(result.verdict, 'ALLOW', 'an unsubstantiated BLOCKER must never clear the review');
|
||||
assert.equal(result.findings.length, 0, 'it is still not a kept finding');
|
||||
assert.equal(result.unverified.length, 1);
|
||||
assert.equal(result.unverified[0].suppressed_reason, 'succinctness:title');
|
||||
assert.equal(result.suppressed.length, 1, 'suppressed stays the union of dropped + unverified');
|
||||
});
|
||||
|
||||
test('runContract — a schema-invalid payload cannot yield ALLOW (an unread reviewer is an absent one)', () => {
|
||||
// Measured: one ad-hoc rule_key invalidates the WHOLE payload at ingest, so a
|
||||
// valid BLOCKER sibling disappears with it. That must not read as "clean".
|
||||
const result = runContract([
|
||||
{ reviewer: 'code-correctness-reviewer', findings: [
|
||||
{ file: 'lib/auth/jwt.mjs', line: 19, rule_key: 'SECURITY_INJECTION', severity: 'BLOCKER', title: 'real' },
|
||||
{ file: 'x.mjs', line: 1, rule_key: 'NOPE_KEY', severity: 'MINOR', title: 'ad-hoc key' },
|
||||
] },
|
||||
]);
|
||||
assert.equal(result.skipped.length, 1);
|
||||
assert.notEqual(result.verdict, 'ALLOW');
|
||||
assert.ok(result.allow_blocked_by.some((x) => x.includes('code-correctness-reviewer')));
|
||||
});
|
||||
|
||||
test('runContract — a reviewer named in expectedReviewers that never reported cannot yield ALLOW', () => {
|
||||
const result = runContract(
|
||||
[{ reviewer: 'code-correctness-reviewer', findings: [] }],
|
||||
{ expectedReviewers: ['code-correctness-reviewer', 'brief-conformance-reviewer'] },
|
||||
);
|
||||
assert.deepEqual(result.missing_reviewers, ['brief-conformance-reviewer']);
|
||||
assert.notEqual(result.verdict, 'ALLOW');
|
||||
});
|
||||
|
||||
test('runContract — known-positive control: every reviewer reported, nothing suppressed → ALLOW', () => {
|
||||
// Proves ALLOW is still REACHABLE. Without this, "no ALLOW" is not a
|
||||
// fail-closed contract, only a broken one.
|
||||
const result = runContract(
|
||||
[
|
||||
{ reviewer: 'code-correctness-reviewer', findings: [
|
||||
{ file: 'a.mjs', line: 1, rule_key: 'MISSING_ERROR_HANDLING', severity: 'MINOR', title: 'unguarded await', recommended_action: 'Wrap the await in a try/catch.' },
|
||||
] },
|
||||
{ reviewer: 'brief-conformance-reviewer', findings: [] },
|
||||
],
|
||||
{ expectedReviewers: ['code-correctness-reviewer', 'brief-conformance-reviewer'] },
|
||||
);
|
||||
assert.equal(result.verdict, 'ALLOW');
|
||||
assert.equal(result.unverified.length, 0);
|
||||
assert.deepEqual(result.missing_reviewers, []);
|
||||
assert.deepEqual(result.allow_blocked_by, []);
|
||||
});
|
||||
|
||||
test('classifySuppression — the refuting reasons the LLM coordinator emits are declared here too', () => {
|
||||
// agents/review-coordinator.md Pass 2 "Accuracy" and Pass 3 "Non-existent
|
||||
// file" DO refute (a citation outside the repo root, a file absent from both
|
||||
// tree and diff). Both are fs/judgement branches this deterministic subset
|
||||
// excludes, but the vocabulary is owned here so prose and lib cannot drift.
|
||||
assert.equal(classifySuppression('accuracy:refuted'), 'refuted');
|
||||
assert.equal(classifySuppression('file-existence:refuted'), 'refuted');
|
||||
assert.equal(classifySuppression('file-existence:indeterminate'), 'unverified',
|
||||
'unresolvable must never collapse into refuted');
|
||||
});
|
||||
|
||||
test('suppression vocabulary — the two sets are disjoint and every reason is documented in the prose', () => {
|
||||
const refuting = [...REFUTING_REASONS];
|
||||
const overlap = refuting.filter((r) => UNVERIFIED_REASONS.includes(r));
|
||||
assert.deepEqual(overlap, [], 'a reason cannot be both refuting and unverified');
|
||||
|
||||
const prose = readFileSync(join(ROOT, 'agents/review-coordinator.md'), 'utf-8');
|
||||
assert.ok(prose.includes('review-coordinator'), 'known-positive control: the prose file loaded');
|
||||
for (const reason of [...refuting, ...UNVERIFIED_REASONS]) {
|
||||
assert.ok(prose.includes(reason),
|
||||
`reason "${reason}" is declared in the lib but never documented in agents/review-coordinator.md`);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue