fix(v1-gate): an attestation cannot be dated in the future or before the run by a timezone, and a doubled key, a link or a directory is red

Chosen: a key written twice is RED (PM recommendation: two answers to one question are not an answer). A time without an offset is RED as ambiguous. A hard link is NOT refused but declared a limit in the output (content binds round, run and date either way). A BOM is tolerated. The clock is a parameter (now=), read by ONE check.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-09-18 18:15:19 +02:00
commit c067d0e85b
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q

View file

@ -27,7 +27,7 @@ import tempfile
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
from collections.abc import Callable, Iterable, Mapping, Sequence from collections.abc import Callable, Iterable, Mapping, Sequence
from dataclasses import asdict, dataclass, field from dataclasses import asdict, dataclass, field
from datetime import date, datetime from datetime import date, datetime, timezone
import difflib import difflib
import re import re
from pathlib import Path from pathlib import Path
@ -69,7 +69,12 @@ ATTEST_KEYS = ("runde", "kjøring", "dato")
ATTEST_RULE = ( ATTEST_RULE = (
"grønt på rad 1-2 regner gaten ikke ut: filene kan stemme innbyrdes og likevel beskrive en " "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 " "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" f"attestering i <runde>/{ATTEST_FILE} (runde, kjøring, dato), som gaten ALDRI skriver selv. "
"Datoen kan ikke ligge i framtiden (den ENE sjekken som leser klokka) eller før kjøringen; "
"en dato med klokkeslett må ha tidssone og sammenlignes som tidspunkt, en ren dato kan "
"ikke skilles fra kjøringens egen dag (samme dag passerer). En nøkkel som står to ganger, "
"en katalog eller en symlenke i stedet for fila, og ikke-UTF-8 er RØDT; en hardlenke "
"avvises IKKE (innholdet binder runde, kjøring og dato uansett) — det er en kjent grense"
) )
GREEN = "GRØNN" GREEN = "GRØNN"
@ -211,11 +216,23 @@ def _parse_time(value: Any) -> datetime | None:
return stamp if stamp.tzinfo is not None else None return stamp if stamp.tzinfo is not None else None
def _parse_date(value: str) -> date | None: def _parse_given(value: str) -> tuple[date | datetime | None, str]:
"""An attestation's ``dato``: a plain date, or an instant, or ``(None, why)``.
A plain date carries no time and stays a ``date`` same day cannot be told apart, and the
contract says so. A date WITH a time must say which zone, and is then an instant: comparing
only its wall-clock date let ``+14:00`` put an attestation ten hours before the run."""
try: try:
return datetime.fromisoformat(value).date() return date.fromisoformat(value), ""
except ValueError: except ValueError:
return None pass
try:
stamp = datetime.fromisoformat(value)
except ValueError:
return None, f"dato {value!r} er ikke en ISO-dato"
if stamp.tzinfo is None:
return None, f"dato {value!r} har klokkeslett uten tidssone (tvetydig)"
return stamp, ""
@dataclass(frozen=True) @dataclass(frozen=True)
@ -242,7 +259,7 @@ def _declared_run(round_dir: Path) -> tuple[str, datetime | None]:
return str(data.get("run_id", "")).strip(), _parse_time(data.get("ran_at")) return str(data.get("run_id", "")).strip(), _parse_time(data.get("ran_at"))
def read_attestation(round_dir: Path) -> Attestation: def read_attestation(round_dir: Path, now: datetime | None = None) -> Attestation:
"""The operator's confirmation for ONE round: that it is there, and that it is about THIS """The operator's confirmation for ONE round: that it is there, and that it is about THIS
round and the run this round declares. round and the run this round declares.
@ -251,23 +268,34 @@ def read_attestation(round_dir: Path) -> Attestation:
pairs, ``verdict_id`` minted with the product's own rule — took row 2 to 3 of 3 GREEN, as did 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`` 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 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.""" computation stops at ``FORM_OK`` and green comes from a person saying so, per round.
``now`` is the clock, injected so a test is deterministic; the default is the real one. It is
read for ONE check only an attestation cannot be dated in the future."""
path = round_dir / ATTEST_FILE path = round_dir / ATTEST_FILE
if not path.is_file(): if path.is_symlink():
return Attestation(True, False, f"{ATTEST_FILE} er en lenke, ikke en vanlig fil")
if not path.exists():
return Attestation( return Attestation(
False, False,
False, False,
f"{ATTEST_FILE} mangler — operatøren har ikke bekreftet at runden ble holdt", f"{ATTEST_FILE} mangler — operatøren har ikke bekreftet at runden ble holdt",
) )
if not path.is_file():
return Attestation(True, False, f"{ATTEST_FILE} er ikke en vanlig fil")
try: try:
text = path.read_text(encoding="utf-8") # utf-8-sig: an ordinary editor may put a BOM first, and that is not the person's error.
text = path.read_text(encoding="utf-8-sig")
except (OSError, ValueError) as exc: except (OSError, ValueError) as exc:
return Attestation(True, False, f"{ATTEST_FILE} uleselig ({exc!r})") return Attestation(True, False, f"{ATTEST_FILE} uleselig ({exc!r})")
fields: dict[str, str] = {} fields: dict[str, str] = {}
for line in text.splitlines(): for line in text.splitlines():
key, sep, value = line.partition(":") key, sep, value = line.partition(":")
if sep and key.strip().casefold() in ATTEST_KEYS: name = key.strip().casefold()
fields.setdefault(key.strip().casefold(), value.strip()) if sep and name in ATTEST_KEYS:
if name in fields:
return Attestation(True, False, f"{ATTEST_FILE}: nøkkelen {name!r} står to ganger")
fields[name] = value.strip()
missing = [k for k in ATTEST_KEYS if not fields.get(k)] missing = [k for k in ATTEST_KEYS if not fields.get(k)]
if missing: if missing:
return Attestation(True, False, f"{ATTEST_FILE} mangler {', '.join(missing)}") return Attestation(True, False, f"{ATTEST_FILE} mangler {', '.join(missing)}")
@ -290,17 +318,30 @@ def read_attestation(round_dir: Path) -> Attestation:
False, False,
f"{ATTEST_FILE} navngir kjøring {fields['kjøring']!r}, runden er kjøring {run_id!r}", f"{ATTEST_FILE} navngir kjøring {fields['kjøring']!r}, runden er kjøring {run_id!r}",
) )
given = _parse_date(fields["dato"]) given, why = _parse_given(fields["dato"])
if given is None: if given is None:
return Attestation( return Attestation(True, False, f"{ATTEST_FILE}: {why}")
True, False, f"{ATTEST_FILE}: dato {fields['dato']!r} er ikke en ISO-dato" clock = now if now is not None else datetime.now(tz=timezone.utc)
) if isinstance(given, datetime):
if ran_at is not None and given < ran_at.date(): before = ran_at is not None and given < ran_at
future = given > clock
ran = ran_at.isoformat() if ran_at is not None else ""
else:
before = ran_at is not None and given < ran_at.date()
future = given > clock.astimezone(timezone.utc).date()
ran = ran_at.date().isoformat() if ran_at is not None else ""
if before:
return Attestation( return Attestation(
True, True,
False, False,
f"{ATTEST_FILE}: attestert {given.isoformat()}, før kjøringen {run_id} ble gjort " f"{ATTEST_FILE}: attestert {given.isoformat()}, før kjøringen {run_id} ble gjort ({ran})",
f"({ran_at.date().isoformat()})", )
if future:
return Attestation(
True,
False,
f"{ATTEST_FILE}: attestert {given.isoformat()}, i framtiden (nå: "
f"{clock.isoformat(timespec='seconds')})",
) )
return Attestation(True, True, "") return Attestation(True, True, "")
@ -413,7 +454,9 @@ def read_feedback(round_dir: Path, ai: tuple[str, str] | None) -> tuple[set[str]
return (set(feedback.ids), "") if feedback is not None else (set(), why) return (set(feedback.ids), "") if feedback is not None else (set(), why)
def score_rounds(rounds_dir: Path, required: int, ai: tuple[str, str] | None) -> Row: def score_rounds(
rounds_dir: Path, required: int, ai: tuple[str, str] | None, now: datetime | None = None
) -> Row:
exceptions: list[str] = [] exceptions: list[str] = []
pending: list[str] = [] pending: list[str] = []
k = 0 k = 0
@ -424,7 +467,7 @@ def score_rounds(rounds_dir: Path, required: int, ai: tuple[str, str] | None) ->
if feedback is None: if feedback is None:
exceptions.append(why) exceptions.append(why)
continue continue
attested = read_attestation(rounds_dir / str(n)) attested = read_attestation(rounds_dir / str(n), now)
if attested.ok: if attested.ok:
k += 1 k += 1
elif attested.present: elif attested.present:
@ -717,7 +760,9 @@ def outcomes_changed(prev: Outcome, cur: Outcome, feedback_ids: set[str]) -> tup
return True, f"{len(changed)} rad(er) endret, sporet: {', '.join(traced)}" return True, f"{len(changed)} rad(er) endret, sporet: {', '.join(traced)}"
def score_changes(rounds_dir: Path, required: int, ai: tuple[str, str] | None) -> Row: def score_changes(
rounds_dir: Path, required: int, ai: tuple[str, str] | None, now: datetime | None = None
) -> Row:
exceptions: list[str] = [] exceptions: list[str] = []
pending: list[str] = [] pending: list[str] = []
k = 0 k = 0
@ -727,7 +772,7 @@ def score_changes(rounds_dir: Path, required: int, ai: tuple[str, str] | None) -
base += f" (kjøring {base_outcome.run_id})" if base_outcome else f"{base_why}" 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 # 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. # comparison and no round can count. Said ONCE here rather than repeated on every round.
base_attested = read_attestation(rounds_dir / "0") base_attested = read_attestation(rounds_dir / "0", now)
if not base_attested.ok: if not base_attested.ok:
target = exceptions if base_attested.present else pending target = exceptions if base_attested.present else pending
target.append(f"runde 0 (grunnkjøringen rad 2 måler mot): {base_attested.why}") target.append(f"runde 0 (grunnkjøringen rad 2 måler mot): {base_attested.why}")
@ -751,7 +796,7 @@ def score_changes(rounds_dir: Path, required: int, ai: tuple[str, str] | None) -
if not ok: if not ok:
exceptions.append(f"runde {n}: {detail}") exceptions.append(f"runde {n}: {detail}")
continue continue
attested = read_attestation(rounds_dir / str(n)) attested = read_attestation(rounds_dir / str(n), now)
if not attested.ok: if not attested.ok:
(exceptions if attested.present else pending).append(f"runde {n}: {attested.why}") (exceptions if attested.present else pending).append(f"runde {n}: {attested.why}")
continue continue
@ -1226,6 +1271,14 @@ NAMED_WARNING = (
) )
#: Printed on rows 6-7: the stress round is read from a directory the repository does not track.
STRESS_DEPENDENCY = (
"rad 6-7 leser stressrundens artefakter fra scratchpad/, som IKKE er sporet av git: i en "
"klone uten den mappen er begge IKKE MÅLT, så «1 av 20» er en påstand om denne sjekkouten, "
"ikke om produktet"
)
def score_named(m: StressMeasure, label: str) -> Row: def score_named(m: StressMeasure, label: str) -> Row:
title = "7 named (diagnose, ingen terskel)" title = "7 named (diagnose, ingen terskel)"
if m.missing: if m.missing:
@ -1267,6 +1320,7 @@ def evaluate(
bundle_root: Path | None = None, bundle_root: Path | None = None,
probe_runner: ProbeRunner | None = None, probe_runner: ProbeRunner | None = None,
stress_measure: StressMeasure | None = None, stress_measure: StressMeasure | None = None,
now: datetime | None = None,
) -> list[Row]: ) -> list[Row]:
ai = ai_authored_lines(repo_root, config["ai_authored"]) ai = ai_authored_lines(repo_root, config["ai_authored"])
required = int(config["rounds_required"]) required = int(config["rounds_required"])
@ -1283,8 +1337,8 @@ def evaluate(
bundle_root, bundle_root,
) )
return [ return [
score_rounds(rounds_dir, required, ai), score_rounds(rounds_dir, required, ai, now),
score_changes(rounds_dir, required, ai), score_changes(rounds_dir, required, ai, now),
score_types(types, outcomes), score_types(types, outcomes),
score_kept(rounds_dir, float(config["keep_threshold"]), ai), score_kept(rounds_dir, float(config["keep_threshold"]), ai),
score_maf(config["maf_points"], green_types(types, outcomes), src), score_maf(config["maf_points"], green_types(types, outcomes), src),
@ -1305,6 +1359,7 @@ def render(rows: Sequence[Row]) -> str:
for r in rows: for r in rows:
for a in r.attests: for a in r.attests:
out.append(f" [{r.title.split()[0]}] {a}.") out.append(f" [{r.title.split()[0]}] {a}.")
out.append(f"Stressrunde: {STRESS_DEPENDENCY}.")
out.append("") out.append("")
out.append("Unntak fra 100 %:") out.append("Unntak fra 100 %:")
for r in rows: for r in rows: