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

@ -27,7 +27,7 @@ import tempfile
import xml.etree.ElementTree as ET
from collections.abc import Callable, Iterable, Mapping, Sequence
from dataclasses import asdict, dataclass, field
from datetime import datetime
from datetime import date, datetime
import difflib
import re
from pathlib import Path
@ -58,8 +58,26 @@ RUN_ATTESTATION = (
"operatøren"
)
#: Rows 1-2: the file the gate can neither derive nor write, and the one thing that turns their
#: computation into GREEN. Plain text with three keyed lines, deliberately not JSON: it is the one
#: artefact in the contract a PERSON types by hand, and a missing brace must never be able to read
#: as "not confirmed".
ATTEST_FILE = "attestering.txt"
#: What an attestation has to name: which round, which run, and when it was given.
ATTEST_KEYS = ("runde", "kjøring", "dato")
#: Printed on rows 1-2 on every run: what GREEN on those rows means, and whose statement it is.
ATTEST_RULE = (
"grønt på rad 1-2 regner gaten ikke ut: filene kan stemme innbyrdes og likevel beskrive en "
"runde ingen har holdt. Målingen gir FORM OK; skrittet derfra til grønt er operatørens egen "
f"attestering i <runde>/{ATTEST_FILE} (runde, kjøring, dato), som gaten ALDRI skriver selv"
)
GREEN = "GRØNN"
RED = "RØD"
#: Rows 1-2 only: every computed check passed, and the round is still waiting for the operator's
#: attestation. Never green, so it fails the exit code exactly as RED does — what separates it
#: from RED is what the reader must do next, not what the gate concludes.
FORM_OK = "FORM OK, IKKE BEVIST"
DIAGNOSIS = "DIAGNOSE"
#: A row whose evidence could not be read. Never green: on a failing row it fails the exit code.
NOT_MEASURED = "IKKE MÅLT"
@ -75,6 +93,7 @@ Rundekatalogen (--rounds-dir) har fast form. Runde n = tilbakemelding på rappor
<rounds-dir>/<n>/outbox/ kjøring n sin EGEN utboks, kopiert hit urørt
<rounds-dir>/<n>/report.md rapporten bygget fra kjøring n
<rounds-dir>/3/report.kept.md runde 3-rapporten slik fagpersonen BEHOLDT den
<rounds-dir>/<n>/attestering.txt operatørens bekreftelse runden (n = 0, 1, 2, 3)
feedback.json:
{"author": "<fagpersonen>", "given_at": "<ISO-8601 med tidssone>",
@ -101,6 +120,17 @@ kjøring må stå inne for seg selv: hver EVALUERT tilnærming har <run_id>-<id>
hvis verdict_id gaten selv minter NYTT fra forslagets egen IR (verdicts.verdict_key). En
not_evaluated-rad har ingen av delene, og en artefakt for en tilnærming coverage ikke nevner hører
til en annen kjøring. Dette beviser ikke at kjøringen skjedde se attesteringen for rad 2.
attestering.txt (ren tekst, skrevet for hånd av operatøren gaten skriver den ALDRI):
runde: <n>
kjøring: <run_id slik rundens outcome.json oppgir den>
dato: <ISO-dato, ikke før kjøringen ble gjort>
<fritekst under, om ønskelig>
Rad 1 og 2 kan ALDRI bli grønne av filer alene. Uten attesteringen er taket FORM OK, IKKE BEVIST:
runden teller 0 mot ferdig-kriteriet og gaten feller fortsatt. En attestering som FINNES, men
navngir en annen runde eller kjøring, mangler dato eller er datert før kjøringen, er RØD - den er
et utsagn om noe annet. Rad 2 krever i tillegg at grunnkjøringen (runde 0) er attestert: det er
den runde 1 måles mot. Utboksen dessuten ligge i runden SELV - en lenke ut av runden avvises.
Rad 4 teller innholdslinjer (ikke blanke, skillelinjer eller tabellrammer) som står uendret og i
samme rekkefølge; fagpersonens tillegg vises som eget tall. --rounds-dir inne i repoet være
gitignored.
@ -181,6 +211,111 @@ def _parse_time(value: Any) -> datetime | None:
return stamp if stamp.tzinfo is not None else None
def _parse_date(value: str) -> date | None:
try:
return datetime.fromisoformat(value).date()
except ValueError:
return None
@dataclass(frozen=True)
class Attestation:
"""One round's attestation, read back.
``present`` separates the two ways it can fail, because they ask different things of the
reader: no file at all means nobody has confirmed the round YET (the row waits at
``FORM_OK``), while a file that contradicts the round is a statement about some other round
(``RED``). Both are un-green, and neither is a measurement."""
present: bool
ok: bool
why: str
def _declared_run(round_dir: Path) -> tuple[str, datetime | None]:
"""The run id and run time the round's own ``outcome.json`` DECLARES — read raw, without the
verification ``read_outcome`` does, so row 1 (which reads no run) does not inherit row 2's
artefact checks through the attestation."""
data, _ = _read_json(round_dir / "outcome.json")
if not isinstance(data, Mapping):
return "", None
return str(data.get("run_id", "")).strip(), _parse_time(data.get("ran_at"))
def read_attestation(round_dir: Path) -> Attestation:
"""The operator's confirmation for ONE round: that it is there, and that it is about THIS
round and the run this round declares.
This is the gate's one input it cannot compute, and that is the point. Measured 18.09: a
whole artefact family written by hand and made internally consistent coverage, artefact
pairs, ``verdict_id`` minted with the product's own rule — took row 2 to 3 of 3 GREEN, as did
four real runs' artefacts under a handwritten feedback file. Neither is a hole ``verify_run``
can close: files agreeing with each other is not a witness that anything happened. So the
computation stops at ``FORM_OK`` and green comes from a person saying so, per round."""
path = round_dir / ATTEST_FILE
if not path.is_file():
return Attestation(
False,
False,
f"{ATTEST_FILE} mangler — operatøren har ikke bekreftet at runden ble holdt",
)
try:
text = path.read_text(encoding="utf-8")
except (OSError, ValueError) as exc:
return Attestation(True, False, f"{ATTEST_FILE} uleselig ({exc!r})")
fields: dict[str, str] = {}
for line in text.splitlines():
key, sep, value = line.partition(":")
if sep and key.strip().casefold() in ATTEST_KEYS:
fields.setdefault(key.strip().casefold(), value.strip())
missing = [k for k in ATTEST_KEYS if not fields.get(k)]
if missing:
return Attestation(True, False, f"{ATTEST_FILE} mangler {', '.join(missing)}")
if fields["runde"] != round_dir.name:
return Attestation(
True,
False,
f"{ATTEST_FILE} attesterer runde {fields['runde']!r}, ikke {round_dir.name!r}",
)
run_id, ran_at = _declared_run(round_dir)
if not run_id:
return Attestation(
True,
False,
f"{ATTEST_FILE} navngir kjøring {fields['kjøring']!r}, men runden oppgir ingen",
)
if fields["kjøring"] != run_id:
return Attestation(
True,
False,
f"{ATTEST_FILE} navngir kjøring {fields['kjøring']!r}, runden er kjøring {run_id!r}",
)
given = _parse_date(fields["dato"])
if given is None:
return Attestation(
True, False, f"{ATTEST_FILE}: dato {fields['dato']!r} er ikke en ISO-dato"
)
if ran_at is not None and given < ran_at.date():
return Attestation(
True,
False,
f"{ATTEST_FILE}: attestert {given.isoformat()}, før kjøringen {run_id} ble gjort "
f"({ran_at.date().isoformat()})",
)
return Attestation(True, True, "")
def _cap(k: int, required: int, exceptions: Sequence[str], pending: Sequence[str]) -> str:
"""Rows 1-2 stop where the files stop. Green is not something these rows can COMPUTE: a
tree can pass every check the gate knows how to make and still describe a round nobody held.
So a round that holds in form but has no attestation leaves the row at ``FORM_OK`` un-green,
still failing the exit code, and counted as 0 against the criterion while something the
gate CAN refuse (bad form, or an attestation about another round) is ``RED``."""
if k >= required:
return GREEN
return RED if exceptions else FORM_OK
@dataclass(frozen=True)
class Feedback:
ids: frozenset[str]
@ -280,20 +415,27 @@ def read_feedback(round_dir: Path, ai: tuple[str, str] | None) -> tuple[set[str]
def score_rounds(rounds_dir: Path, required: int, ai: tuple[str, str] | None) -> Row:
exceptions: list[str] = []
pending: list[str] = []
k = 0
if not rounds_dir.is_dir():
exceptions.append(f"{rounds_dir} finnes ikke")
else:
for _, (feedback, why) in sorted(read_rounds(rounds_dir, required, ai).items()):
if feedback is not None:
k += 1
else:
for n, (feedback, why) in sorted(read_rounds(rounds_dir, required, ai).items()):
if feedback is None:
exceptions.append(why)
status = GREEN if k == required else RED
continue
attested = read_attestation(rounds_dir / str(n))
if attested.ok:
k += 1
elif attested.present:
exceptions.append(f"runde {n}: {attested.why}")
else:
pending.append(f"runde {n}: {attested.why}")
status = _cap(k, required, exceptions, pending)
reason = (
"form verifisert i alle runder (forfatterskap: se attestering)"
if k == required
else exceptions[0]
"form verifisert i alle runder, og hver runde attestert av operatøren"
if status == GREEN
else (exceptions + pending)[0]
)
return Row(
"rounds",
@ -302,7 +444,8 @@ def score_rounds(rounds_dir: Path, required: int, ai: tuple[str, str] | None) ->
required,
status,
reason,
exceptions=tuple(exceptions),
exceptions=tuple(exceptions + pending),
attests=(ATTEST_RULE,),
)
@ -440,6 +583,31 @@ def verify_run(outbox: Path, run_id: str, coverage: Sequence[Mapping[str, Any]])
return ""
def outbox_escape(round_dir: Path) -> str:
"""``""`` when the run the round presents lies IN the round, else why not.
The outbox is DERIVED (``RUN_OUTBOX``) rather than declared but a derived path is only
derived if the filesystem cannot redirect it. Measured 18.09: ``<n>/outbox`` made a symlink
to a real run's directory elsewhere let a round present another run's artefacts as its own,
and row 2 read 3 of 3 GREEN. Every artefact the round stands on must therefore be a real file
under the round itself, symlink-free in both directions."""
outbox = round_dir / RUN_OUTBOX
if outbox.is_symlink():
return (
f"{RUN_OUTBOX}/ er en lenke ut av runden ({os.readlink(outbox)}) — kjøringen må "
f"ligge i {round_dir.name}/{RUN_OUTBOX}/ selv"
)
if not outbox.is_dir():
return ""
for entry in sorted(outbox.rglob("*")):
if entry.is_symlink():
return (
f"{RUN_OUTBOX}/{entry.relative_to(outbox)} er en lenke "
f"({os.readlink(entry)}) — kjøringens artefakter må ligge i runden selv"
)
return ""
def read_outcome(round_dir: Path) -> tuple[Outcome | None, str]:
"""A round's outcome file, VERIFIED against the run it names — and the run is the one in the
round's OWN ``outbox/``, never a path the file points at.
@ -473,6 +641,9 @@ def read_outcome(round_dir: Path) -> tuple[Outcome | None, str]:
)
if ran_at is None:
return None, f"{path}: ran_at er ikke et ISO-tidsstempel med tidssone"
escape = outbox_escape(round_dir)
if escape:
return None, f"{path}: {escape}"
outbox = round_dir / RUN_OUTBOX
coverage_path = outbox / f"{run_id}-coverage.json"
payload, why = _read_json(coverage_path)
@ -548,11 +719,18 @@ def outcomes_changed(prev: Outcome, cur: Outcome, feedback_ids: set[str]) -> tup
def score_changes(rounds_dir: Path, required: int, ai: tuple[str, str] | None) -> Row:
exceptions: list[str] = []
pending: list[str] = []
k = 0
base_path = rounds_dir / "0" / "outcome.json"
base_outcome, base_why = read_outcome(rounds_dir / "0")
base = f"runde 0 = {base_path}"
base += f" (kjøring {base_outcome.run_id})" if base_outcome else f"{base_why}"
# Round 1 is measured AGAINST round 0's run, so an unattested baseline is an unattested
# comparison and no round can count. Said ONCE here rather than repeated on every round.
base_attested = read_attestation(rounds_dir / "0")
if not base_attested.ok:
target = exceptions if base_attested.present else pending
target.append(f"runde 0 (grunnkjøringen rad 2 måler mot): {base_attested.why}")
feedback_by_round = read_rounds(rounds_dir, required, ai) if rounds_dir.is_dir() else {}
for n in range(1, required + 1):
feedback, why = feedback_by_round.get(n, (None, f"runde {n}: {rounds_dir} finnes ikke"))
@ -570,20 +748,24 @@ def score_changes(rounds_dir: Path, required: int, ai: tuple[str, str] | None) -
)
continue
ok, detail = outcomes_changed(prev, cur, set(feedback.ids))
if ok:
k += 1
else:
if not ok:
exceptions.append(f"runde {n}: {detail}")
status = GREEN if k == required else RED
continue
attested = read_attestation(rounds_dir / str(n))
if not attested.ok:
(exceptions if attested.present else pending).append(f"runde {n}: {attested.why}")
continue
if base_attested.ok:
k += 1
return Row(
"changes",
"2 runder med målbar endring",
k,
required,
status,
_cap(k, required, exceptions, pending),
base,
exceptions=tuple(exceptions),
attests=(RUN_ATTESTATION,),
exceptions=tuple(exceptions + pending),
attests=(RUN_ATTESTATION, ATTEST_RULE),
)

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