fix(v1-gate): rows 1-2 stop at FORM OK — what files cannot show, a person has to say

The PM checkpoint of 18.09 paid M-6's price three times and row 2 still read 3 of 3 GREEN:
a whole artefact family written by hand and made internally consistent (verdict_id minted
with the product's own rule, about sixty lines of script), four REAL runs' artefacts under
a handwritten feedback file, and `<n>/outbox` symlinked out of the round to a real run
elsewhere. `RUN_ATTESTATION` stood in the output the whole time, and the row was green
anyway — which is the finding: a row that PRINTS its limit and reports GREEN has not
stated a limit, it has decorated one.

One of the three is a hole and is closed; two are not holes at all.

1. The outbox must lie in the round ITSELF (`outbox_escape`). Derived is only derived if
   the filesystem cannot redirect it: `<n>/outbox` as a symlink, or any symlinked artefact
   inside it, is refused by name.

2. Rows 1 and 2 can no longer be green from files. What the computation gives is
   `FORM_OK` ("FORM OK, IKKE BEVIST"): every check the gate knows how to make passed, the
   round counts 0 against the criterion, and the exit code still fails — it is un-green in
   every way RED is. What separates the two is what the reader must do next, not what the
   gate concludes.

3. Green is `<n>/attestering.txt`, per round: the operator's own statement, naming the
   round, the run and the date. Plain text with three keyed lines, deliberately not JSON —
   it is the one artefact in this contract a PERSON types, and a missing brace must never
   be able to read as "not confirmed". Nothing in the product writes one, and a test in
   `src/` says so: a product that can produce a witness to its own run has produced
   exactly the thing these rows cannot.

   A MISSING attestation is FORM OK: nobody has confirmed anything yet, and the gate is
   not entitled to call that a lie. A PRESENT one that names another round or run, carries
   no date, an unparsable date, or a date before the run, is RED: it is a statement about
   something this is not. Row 2 needs round 0 attested as well, since round 1 is measured
   against it — an unattested baseline is an unattested comparison.

The attestation is read raw off `outcome.json` (`_declared_run`), not through
`read_outcome`, so row 1 — which reads no run — does not inherit row 2's artefact checks
through the back door.

Nothing moved on the criterion: 0/3 · 0/3 · 3/8 · no report · 3/8 · IKKE MÅLT · 1/20,
exit 1. What changed is that rows 1-2 now name what they are waiting for, and it is not
another file.

Suite after `git add` on a clean tree: `uv run pytest -q` = 2014 passed, 5 skipped,
5 xfailed (508 s). The attack of b769537/ab00016 is the red half of this commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-09-18 16:38:42 +02:00
commit 1b48124a01
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
2 changed files with 204 additions and 21 deletions

View file

@ -521,12 +521,13 @@ def _forged_rounds(root: Path) -> Path:
def test_m6_a_handwritten_outbox_is_not_a_run(tmp_path: Path) -> None:
"""Row 2 must not be satisfiable by files a forger wrote. Row 1 stays GREEN on the same tree —
the feedback there IS well formed, and that is what makes this an attack on row 2 rather than
a broken fixture. The control that the row can still go green is ``_green_rounds``, which
carries a whole run family (``test_row2_a_traced_change_counts``)."""
"""Row 2 must not be satisfiable by files a forger wrote. Row 1 reads FORM OK on the same
tree the feedback there IS well formed, and that is what makes this an attack on row 2
rather than a broken fixture; since M-7 that form is as far as row 1 can get without the
operator's attestation. The control that the row can still go green is ``_green_rounds``,
which carries a whole run family AND an attestation (``test_row2_a_traced_change_counts``)."""
root = _forged_rounds(tmp_path)
assert gate.score_rounds(root, 3, _AI).status == gate.GREEN
assert gate.score_rounds(root, 3, _AI).status == gate.FORM_OK
row = gate.score_changes(root, 3, _AI)
assert (row.k, row.status) == (0, gate.RED), row.exceptions