fix(gate): the threat model applies to new findings only (decision 221)

Undone: item 2 of the Part 2 fix round (1be961a). recordVerdict no longer
drops a copy rule A carried as counted when a finding with the same id is
backed this round, and no longer recomputes the counts, `lowered` and the
stop-rule summary for it; the isLowered helper and the SEVERITIES import it
needed are gone. Why: the id is (file, line, rule_key), so a DIFFERENT
backed security finding at the same line and key knocked a counted security
finding inside the threat model out of the count (round 2 PASS, its text
gone), against decisions 218 and 220.

The rule now, as decided: the threat model applies only to this round's
new findings (the deduped findings, before countRound); a finding counted
in an earlier round stays counted, through rule A's carry, until a reviewer
marks it resolved. The gate never counts less. Items 1 and 3-8 of the fix
round are untouched.

Tests: the fix-round case for item 2 now asserts the decided rule (the
carried copy stays counted, BLOCK, the stop rule fires not-fewer, the gate
stops); a new case pins that a different backed finding at the same line
and key leaves the counted one, with its own text, in the count.

The plan-gate header and the known_outside comment, and Handover 4's
known_outside sentence, state the rule. CHANGELOG Unreleased, Known issues:
a finding counted earlier stays counted when an Outside: line approved
later puts it outside, or when it moves to another plan line; the way on is
the operator's choice at the stop rule.

Part 2 of the E plan, decision 221.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-11-08 12:17:57 +01:00
commit ff46f57a89
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
4 changed files with 45 additions and 29 deletions

View file

@ -381,23 +381,46 @@ test('fix 1: a string entry in a reviewer\'s findings still counts as a blocker
assert.equal(r.verdict.verdict, 'BLOCK');
});
test('fix 2: a finding counted in round 1 and backed in round 2 (same id, marked resolved: false) is listed once and counts nowhere', () => {
// Decision 221: the threat model applies to this round's new findings only. A
// finding counted in an earlier round stays counted (rule A's carry) until a
// reviewer marks it resolved; the gate never counts less.
function countedThenBacked(text2) {
const t1 = buildPlan();
const r1 = rec(t1, dd({ pc: [f('blocker', at(t1, R1_CHANGES), S1, 'SECURITY_RISK')] }));
const id = r1.verdict.findings[0].id;
const t2 = editA(r1.text);
const r2 = rec(t2, dd({
pc: [f('blocker', at(t2, R1_CHANGES), S1, 'SECURITY_RISK', { threat_model_ref: OUTSIDE })],
pc: [f('blocker', at(t2, R1_CHANGES), text2, 'SECURITY_RISK', { threat_model_ref: OUTSIDE })],
pcPrev: [{ id, resolved: false }], sgPrev: [{ id, resolved: false }],
}), { base: baseOf(r1.text), threatModel: TM, briefPath: 'brief.md' });
return { id, r2 };
}
test('fix 2 (decision 221): a finding counted in round 1 and backed in round 2 stays counted until marked resolved', () => {
const { id, r2 } = countedThenBacked(S1);
const v = r2.verdict;
assert.deepEqual(v.findings.map(x => x.id), [], 'the carried copy left the counted set');
assert.deepEqual(v.counts, { blocker: 0, major: 0, minor: 0 });
assert.equal(v.verdict, 'PASS');
assert.deepEqual((v.known_outside ?? []).map(e => e.id), [id]);
assert.equal(v.rounds.at(-1).blocking, 0, 'it left the stop-rule summary');
assert.equal(v.stop_rule.fired, false, JSON.stringify(v.stop_rule));
assert.equal(gatePlan(r2.text, { threatModel: TM }).code, 0);
assert.deepEqual(v.findings.map(x => [x.id, x.carried_from]), [[id, 1]], 'the carried copy stays counted');
assert.deepEqual(v.counts, { blocker: 1, major: 0, minor: 0 });
assert.equal(v.verdict, 'BLOCK');
assert.deepEqual((v.known_outside ?? []).map(e => e.id), [id], 'the backed re-report may stay listed');
assert.equal(v.rounds.at(-1).blocking, 1);
assert.equal(v.stop_rule.fired, true, 'the stop rule fires: the way on is the operator\'s choice');
assert.equal(v.stop_rule.reason, 'not-fewer');
assert.equal(gatePlan(r2.text, { threatModel: TM }).code, 1);
});
test('decision 221: a DIFFERENT backed finding at the same line and key never knocks out a counted one', () => {
const B = 'Step 1 writes the downloaded token into a world-readable cache file';
const { id, r2 } = countedThenBacked(B);
const v = r2.verdict;
assert.equal(v.verdict, 'BLOCK');
assert.deepEqual(v.counts, { blocker: 1, major: 0, minor: 0 });
const a = v.findings.find(x => x.id === id);
assert.ok(a, `A is still counted: ${JSON.stringify(v.findings)}`);
assert.equal(a.carried_from, 1);
assert.equal(a.text, S1, 'A keeps its own text');
const g = gatePlan(r2.text, { threatModel: TM });
assert.equal(g.code, 1, JSON.stringify(g.reasons));
});
test('fix 3: a merged finding whose security key is only in rule_keys is backed at record and in the bare gate alike', () => {