From 38ffee22071f88367f025c01ec3b2319809d1777 Mon Sep 17 00:00:00 2001 From: jsboige Date: Sat, 19 Sep 2026 21:35:25 +0200 Subject: [PATCH 1/3] harness(gate,#16906): la prevalidation Phase 4 accepte une lane TIERCE qualifiante MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le gate n'acceptait un dossier que de `ADJOINT_LANE` code en dur. Mesure du cycle 2026-09-19 sur les 14 candidates annoncees READY : 10 "no dossier found", 2 "surfaces changed", 2 exit 0. Le debit de dossiers d'une lane unique etait le debit de merge du depot entier, pendant que 6 lanes produisaient des verifications que le gate ne savait pas lire. Ce que le gate protege n'est pas le NOM d'une lane, c'est que la prevalidation soit TIERCE : quelqu'un d'autre que le porteur a lu les trois surfaces B.0 a head exact et l'a atteste dans un contrat machine-lisible. - `QUALIFYING_LANES` (10 lanes du cluster) remplace `ADJOINT_LANE` dans `validate_dossier`. Une lane inconnue ou malformee echoue toujours ferme. - Refus de l'auto-prevalidation : `carrying_lane()` lit le tag `Grain: ... lane ` du body ; si elle egale la lane du dossier, le gate refuse. Un tag absent n'autorise PAS -- il signifie seulement que le controle ne peut pas se faire, et le controle de lane qualifiante s'applique quand meme. - `render_template(snapshot, lane)` + option `--lane` : une lane rend son PROPRE nom. Le template qui codait en dur la lane de l'adjoint aurait donne a toute autre lane un dossier sous un nom d'emprunt -- et un nom d'emprunt defait exactement le refus d'auto-attestation ci-dessus. - SKILL.md coordinate mis en coherence (le texte disait l'inverse du code). Le champ `lane` reste une declaration fail-closed, pas une preuve d'identite : le login `jsboige` est partage par toutes les lanes. Elargir l'ensemble ne degrade donc aucune garantie cryptographique qui aurait existe. Tests : 27 passed (5 nouveaux sur les lanes, 2 sur le rendu du template). `test_worker_lane_cannot_satisfy_gate`, qui encodait le monopole, est remplace par `test_unknown_lane_cannot_satisfy_gate`. Gate non regresse sur PRs live (#16218, #16802 : rc=1 sur motifs de fond). Changement normatif substantiel du harnais (CLAUDE.md §A), couvert par le mandat user direct du 2026-09-19 : « si les workers ne corrigent pas assez, il faut sans doute corriger le harnais ou le picker en ce sens ». See #16906 Co-Authored-By: Claude Opus 5 (1M context) --- .claude/skills/coordinate/SKILL.md | 6 +- scripts/check_adjoint_prevalidation.py | 72 ++++++++++++++-- .../tests/test_check_adjoint_prevalidation.py | 85 ++++++++++++++++++- 3 files changed, 152 insertions(+), 11 deletions(-) diff --git a/.claude/skills/coordinate/SKILL.md b/.claude/skills/coordinate/SKILL.md index fd4daf0437..ea4790ee6e 100644 --- a/.claude/skills/coordinate/SKILL.md +++ b/.claude/skills/coordinate/SKILL.md @@ -71,7 +71,7 @@ Les phases ci-dessous s'executent sous le budget defini par la section `## Budge |---|---|---| | **0** | dossier integre + `verdict: READY` | ouvrir body, commentaires, reviews, threads, diff ; lire B.0 ; merger si les gates du point 5 passent | | **3** | dossier integre + `verdict: BLOCKED` | **n'ouvrir AUCUNE surface** — dispatcher a la lane auteur depuis le motif atteste par le dossier, et passer a la candidate suivante | - | **1** | pas de dossier digne de confiance (absent, malforme, perime, mauvaise lane/auteur, empreinte cassee) | router la candidate a l'adjoint `myia-po-2025:CoursIA-2`, l'exclure jusqu'a un dossier exact-head, candidate suivante | + | **1** | pas de dossier digne de confiance (absent, malforme, perime, mauvaise lane/auteur, empreinte cassee) | router la candidate vers une lane **TIERCE qualifiante** -- l'adjoint `myia-po-2025:CoursIA-2` en premier, mais toute autre lane du cluster qui **ne porte pas** la PR convient (#16906) --, l'exclure jusqu'a un dossier exact-head, candidate suivante | | **2** | organe injoignable | refus fail-closed, identique a 1 | **Exit 3 n'est PAS un gate plus mou** : un dossier BLOCKED doit satisfaire toutes les exigences structurelles, `surfaces-sha256` comprise. Ce qui tombe, ce sont les controles qui **refutent une claim READY** (checks verts, B.0 clear, zero thread non resolu, non-draft) — ce sont des raisons pour lesquelles une PR est bloquee, pas des raisons de se mefier du dossier qui le dit. @@ -80,9 +80,9 @@ Les phases ci-dessous s'executent sous le budget defini par la section `## Budge **Une seule identite est neutre** : `myia-ai-01`, et uniquement pour les surfaces qu'elle ecrit **apres** le dossier. Sans ca, le geste que le gate autorise — lire la PR, puis lever sa propre reserve — perime le dossier que le gate exige, et une PR bloquee par la seule reserve du coordinateur ne peut jamais se merger sans un aller-retour complet. Une surface de **tout autre auteur**, ou une surface `myia-ai-01` **anterieure** au dossier, perime toujours. - Le bloc `[ADJOINT PREFLIGHT]` est genere par `python scripts/check_adjoint_prevalidation.py --template`, puis complete par l'adjoint ; le compte des commentaires exclut le commentaire-dossier lui-meme. **Interdit de contourner le gate par un sous-agent, une lecture API directe ou un ancien dossier d'inbox.** + Le bloc `[ADJOINT PREFLIGHT]` est genere par `python scripts/check_adjoint_prevalidation.py --template [--lane ]`, puis complete par la lane emettrice -- **qui rend son PROPRE nom** : un dossier sous un nom d'emprunt defait le refus d'auto-attestation. Le compte des commentaires exclut le commentaire-dossier lui-meme. **Interdit de contourner le gate par un sous-agent, une lecture API directe ou un ancien dossier d'inbox.** 2. **Exploiter les verdicts deja poses** : `[Hermes] COMMENT_WITH_CONCERNS` (prefixe de `reviews[].body`), `EXEC_PROVED` / `STRUCTURAL_ONLY` / `SUSPECT_REGRESSION` (body). Tout finding NanoClaw suit [audit-reassessment.md](../../rules/audit-reassessment.md) avant fix (~60 % de FP). -3. **L'adjoint fabrique, ai-01 ne re-fabrique pas** : les PRs sans dossier valide partent en lots explicites issus du sweep vers l'adjoint, pas vers des sous-agents ai-01. Dossier attendu : contrat machine-lisible exact-head, trois surfaces B.0, checks latest-wins, scope, domaine et verdict ; un changement de head ou de surface le perime. L'adjoint vise **>=20 READY oldest-first par fenetre de 4 h quand >=20 candidates sont eligibles**, et remonte chaque READY immediatement : le lot n'est pas une barriere. Un dossier insuffisant repart avec UNE question precise. Le login GitHub `jsboige` etant partage, le champ `lane` est une declaration fail-closed, pas une preuve cryptographique d'identite ; aucune autre lane declaree n'est acceptee. +3. **L'adjoint fabrique, ai-01 ne re-fabrique pas** : les PRs sans dossier valide partent en lots explicites issus du sweep vers l'adjoint, pas vers des sous-agents ai-01. Dossier attendu : contrat machine-lisible exact-head, trois surfaces B.0, checks latest-wins, scope, domaine et verdict ; un changement de head ou de surface le perime. L'adjoint vise **>=20 READY oldest-first par fenetre de 4 h quand >=20 candidates sont eligibles**, et remonte chaque READY immediatement : le lot n'est pas une barriere. Un dossier insuffisant repart avec UNE question precise. Le login GitHub `jsboige` etant partage, le champ `lane` est une declaration fail-closed, pas une preuve cryptographique d'identite. Ce que le gate exige est que la prevalidation soit **tierce**, pas qu'elle vienne d'une lane nommee : toute lane du cluster (`QUALIFYING_LANES`) peut emettre un dossier pour une PR **qu'elle ne porte pas**, et le gate refuse l'auto-attestation en comparant la lane du dossier au tag `Grain:` du body (#16906). Une lane hors de l'ensemble, ou malformee, echoue toujours ferme. 4. **Lecture B.0 personnelle minimale avant chaque merge -- non delegable, seulement APRES gate vert** : body + comments + reviews + diff ("Read Body Before Any Action") ; etat A L'INSTANT-T via `gh pr view N --json state,mergedAt,mergeStateStatus,reviews` (jamais depuis le dashboard ni le cycle N-1) ; organe `python scripts/check_unaddressed_nits.py ` (exit 1 = ne pas merger ; son vert ne dispense pas de la lecture). Verifier seulement le dossier, le delta et la preuve decisive ; ne pas rejouer l'audit complet. Une levee porte un auteur et une heure. 5. **Gates de merge** : un preflight READY n'autorise jamais le merge. Appliquer encore B.0, latest-wins CI, H.4 (notebooks : checkout + Papermill local OU log dans le body), catalogue byte-identique a main (`gh pr view N --json files`), scope reel = titre, ordre de stack, variation et relecture de la queue de commentaires. 6. **Merge** : sous `myia-ai-01` (droit `MergePullRequest` verifie firsthand 2026-08-08), avec `gh pr merge --repo jsboige/CoursIA --squash --match-head-commit ` (`--merge` preserve-SHA pour la base d'un stack), **JAMAIS `--delete-branch`**. diff --git a/scripts/check_adjoint_prevalidation.py b/scripts/check_adjoint_prevalidation.py index 46cd0c5e77..57269eaf44 100644 --- a/scripts/check_adjoint_prevalidation.py +++ b/scripts/check_adjoint_prevalidation.py @@ -2,9 +2,15 @@ """Fail-closed gate for the coordinator's adjoint prevalidation dossier. The gate answers one narrow question: does this pull request have a complete, -exact-head, machine-readable READY dossier from the canonical adjoint lane? +exact-head, machine-readable READY dossier from a qualifying THIRD-PARTY lane? It does not approve the pull request, replace B.0, or authorize a merge. +The binding constraint is third-party review, not the name of one lane. A +dossier written by the lane that carries the pull request is self-attestation +and is refused; a dossier written by any other qualifying lane carries the same +evidential weight as the adjoint's. Restricting emission to a single named lane +made that lane's throughput the merge throughput of the whole repository. + Canonical comment body (the marker must be the first line): [ADJOINT PREFLIGHT] @@ -78,7 +84,27 @@ from typing import Any REPO = "jsboige/CoursIA" +# The adjoint remains the canonical emitter: `--template` renders its lane, and +# it is the lane the coordinator nudges first. It is no longer the only one. ADJOINT_LANE = "myia-po-2025:CoursIA-2" +# A dossier is an act of third-party verification. Any cluster lane may emit one +# for a pull request it does not carry. The set is explicit so that an unknown or +# malformed lane string fails closed rather than passing as "some lane". +QUALIFYING_LANES = frozenset({ + "myia-ai-01:CoursIA", + "myia-po-2023:CoursIA", + "myia-po-2024:CoursIA", + "myia-po-2024:CoursIA-2", + "myia-po-2025:CoursIA", + "myia-po-2025:CoursIA-2", + "myia-po-2026:CoursIA", + "myia-po-2026:CoursIA-2", + "myia-po-2027:CoursIA", + "myia-po-2027:CoursIA-2", +}) +# `Grain: -- lane ` in the pull request body names the +# lane that carries the work. Same grammar as scripts/check_lane_claim.py. +GRAIN_LANE_RE = re.compile(r"Grain:[^\n]*?\blane\s+([A-Za-z0-9_.-]+:[A-Za-z0-9_.-]+)") SHARED_GITHUB_LOGIN = "jsboige" # The gate's only consumer. Every worker lane signs SHARED_GITHUB_LOGIN, so this # login is the one surface author the coordinator can recognise as itself. @@ -291,6 +317,17 @@ def surfaces_fingerprint( return hashlib.sha256(encoded).hexdigest() +def carrying_lane(snapshot: dict[str, Any]) -> str | None: + """Return the lane that carries this pull request, from its `Grain:` tag. + + Returns None when the body carries no readable tag. An absent tag is not an + authorization: it only means the self-attestation check cannot be made from + the body, and the qualifying-lane check still applies. + """ + match = GRAIN_LANE_RE.search(snapshot.get("body") or "") + return match.group(1) if match else None + + def validate_dossier(dossier: Dossier, snapshot: dict[str, Any]) -> list[str]: """Validate a parsed dossier against one live PR snapshot.""" f = dossier.fields @@ -303,7 +340,6 @@ def validate_dossier(dossier: Dossier, snapshot: dict[str, Any]) -> list[str]: # from an absent one (#16800). expected = { "schema": "1", - "lane": ADJOINT_LANE, "complete": "true", "body": "read", } @@ -326,6 +362,18 @@ def validate_dossier(dossier: Dossier, snapshot: dict[str, Any]) -> list[str]: errors.append(f"{key} must be {value!r} when verdict is READY") if f.get("domain") not in {"pass", "not-applicable"}: errors.append("domain must be 'pass' or 'not-applicable' when verdict is READY") + dossier_lane = f.get("lane", "") + if dossier_lane not in QUALIFYING_LANES: + errors.append( + f"lane must be one of the qualifying cluster lanes, got {dossier_lane!r}" + ) + else: + carrier = carrying_lane(snapshot) + if carrier is not None and carrier == dossier_lane: + errors.append( + "self-prevalidation refused: the dossier lane " + f"{dossier_lane!r} is the lane that carries this pull request" + ) if dossier.author != SHARED_GITHUB_LOGIN: errors.append(f"comment author must be {SHARED_GITHUB_LOGIN!r}") if not SHA_RE.fullmatch(f.get("head", "")): @@ -536,11 +584,17 @@ def load_snapshot(pr: int) -> dict[str, Any]: return snapshot -def render_template(snapshot: dict[str, Any]) -> str: - """Render the mechanical fields; the adjoint sets the four verdict fields.""" +def render_template(snapshot: dict[str, Any], lane: str = ADJOINT_LANE) -> str: + """Render the mechanical fields; the emitting lane sets the verdict fields. + + `lane` defaults to the adjoint because it emits most dossiers, but a template + that hardcoded one lane would hand every other lane a dossier declaring a + name that is not its own -- and a borrowed name defeats the self-attestation + refusal in `validate_dossier`. A lane renders its OWN name here. + """ fields = ( ("schema", "1"), - ("lane", ADJOINT_LANE), + ("lane", lane), ("pr", str(snapshot["number"])), ("head", snapshot["headRefOid"]), ("complete", "REPLACE_WITH_true"), @@ -572,6 +626,12 @@ def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("pr", type=int, help="pull request number") parser.add_argument("--json", action="store_true", help="emit machine-readable output") + parser.add_argument( + "--lane", + default=ADJOINT_LANE, + choices=sorted(QUALIFYING_LANES), + help="lane emitting the dossier, for --template (default: the adjoint)", + ) parser.add_argument( "--fingerprint", action="store_true", @@ -586,7 +646,7 @@ def main() -> int: try: snapshot = load_snapshot(args.pr) if args.template: - print(render_template(snapshot)) + print(render_template(snapshot, args.lane)) return 0 if args.fingerprint: print(surfaces_fingerprint(snapshot)) diff --git a/scripts/tests/test_check_adjoint_prevalidation.py b/scripts/tests/test_check_adjoint_prevalidation.py index 171d16d4ef..92b9fc60e6 100644 --- a/scripts/tests/test_check_adjoint_prevalidation.py +++ b/scripts/tests/test_check_adjoint_prevalidation.py @@ -71,6 +71,21 @@ def _snapshot(dossier: str | None = None) -> dict: return snapshot +def _snapshot_with_body(pr_body: str, **dossier_changes: str) -> dict: + """Snapshot whose PR body is `pr_body`, attested by a dossier over THAT body. + + `surfaces-sha256` covers the body (see `surfaces_fingerprint`), so the + fingerprint is taken AFTER the body is set. Mutating the body afterwards + would perime the dossier and mask the check under test. + """ + snapshot = _base_snapshot() + snapshot["body"] = pr_body + fields = dict(dossier_changes) + fields["surfaces-sha256"] = mod.surfaces_fingerprint(snapshot) + snapshot["comments"].append(_comment(_body(**fields))) + return snapshot + + def _errors(snapshot: dict) -> list[str]: """Assert the snapshot yields NO trustworthy dossier, and return why.""" verdict, errors = mod.evaluate(snapshot) @@ -139,8 +154,57 @@ def test_checks_and_b0_require_canonical_complete_verdicts(): assert any(error.startswith(f"{field} must") for error in errors), field -def test_worker_lane_cannot_satisfy_gate(): - errors = _errors(_snapshot(_body(lane="myia-po-2027:CoursIA"))) +def test_unknown_lane_cannot_satisfy_gate(): + """A lane outside the cluster set fails closed, as does a malformed string.""" + for lane in ("not-a-lane", "myia-po-9999:CoursIA", ""): + errors = _errors(_snapshot(_body(lane=lane))) + assert any(error.startswith("lane must") for error in errors), lane + + +def test_qualifying_third_party_lane_satisfies_gate(): + """Any cluster lane may attest a pull request it does not carry (#16904). + + The binding constraint is third-party review, not one named lane. Before + this, `ADJOINT_LANE` made a single lane's throughput the merge throughput of + the whole repository. + """ + for lane in ("myia-po-2027:CoursIA", "myia-po-2023:CoursIA", "myia-ai-01:CoursIA"): + ready, errors = mod.evaluate(_snapshot(_body(lane=lane))) + assert ready, (lane, errors) + assert errors == [], lane + + +def test_lane_carrying_the_pr_cannot_prevalidate_itself(): + """Self-attestation is refused: the `Grain:` tag names the carrying lane.""" + carrier = "myia-po-2027:CoursIA" + snapshot = _snapshot_with_body( + "Grain: DEEP/notebook-python -- lane %s -- prev: MED" % carrier, lane=carrier + ) + errors = _errors(snapshot) + assert any(error.startswith("self-prevalidation refused") for error in errors) + # Le refus est le SEUL motif : sans cette assertion, une empreinte perimee + # ferait passer le test pour la mauvaise raison. + assert not any(error.startswith("discussion surfaces") for error in errors), errors + + +def test_third_party_lane_passes_when_carrier_is_declared(): + """A declared carrier does not block a dossier from a different lane.""" + snapshot = _snapshot_with_body( + "Grain: DEEP/lean -- lane myia-po-2027:CoursIA -- prev: MED", + lane="myia-po-2025:CoursIA-2", + ) + ready, errors = mod.evaluate(snapshot) + assert ready, errors + assert errors == [] + + +def test_absent_grain_tag_is_not_an_authorization(): + """No readable tag means the self-check cannot run, not that it passed. + + The qualifying-lane check still applies, so an unknown lane still fails. + """ + snapshot = _snapshot_with_body("pas de tag Grain ici", lane="not-a-lane") + errors = _errors(snapshot) assert any(error.startswith("lane must") for error in errors) @@ -423,3 +487,20 @@ def test_coordinator_review_BEFORE_the_dossier_must_still_be_attested(): "submittedAt": "2026-09-18T00:00:00Z", "body": "reserve posee AVANT le dossier"} ) assert _errors(snapshot) + + +def test_template_renders_the_emitting_lane_not_a_borrowed_name(): + """A lane renders its OWN name, or the self-attestation refusal is defeated. + + A template hardcoding one lane hands every other lane a dossier declaring a + name that is not its own. The carrying lane could then prevalidate itself + under a borrowed name and `validate_dossier` would see two different lanes. + """ + for lane in ("myia-po-2027:CoursIA", "myia-po-2023:CoursIA"): + template = mod.render_template(_base_snapshot(), lane) + assert f"lane: {lane}" in template, lane + + +def test_template_lane_defaults_to_the_adjoint(): + """The adjoint stays the canonical emitter: the default is unchanged.""" + assert f"lane: {mod.ADJOINT_LANE}" in mod.render_template(_base_snapshot()) From 937240db82f6eaad1efc0f46eeb68c08d3cff7f4 Mon Sep 17 00:00:00 2001 From: jsboige Date: Sun, 20 Sep 2026 00:57:14 +0200 Subject: [PATCH 2/3] harness(gate,#16906,#16928): la prevalidation Phase 4 accepte une lane tierce, et un dossier suivi de sa prose Deux defauts d'ENVELOPPE du meme parser, mesures dans le meme cycle : le gate refusait des attestations tierces completes pour des motifs qui ne portent sur aucune de leurs proprietes de fond. 1. Lane unique (#16906). `ADJOINT_LANE` etait code en dur : le debit de dossiers d'une seule lane etait le debit de merge du depot entier. `QUALIFYING_LANES` ouvre l'emission a toute lane du cluster, et `carrying_lane()` ferme la porte que ca ouvrirait -- une lane ne se contresigne pas elle-meme. 2. Prose apres le marqueur (#16928). `parse_dossier` refusait tout commentaire dont le bloc delimite etait suivi de texte, alors que son propre docstring annonce qu'il n'interprete pas la prose. Quatre lanes avaient ecrit le bloc machine puis, en dessous, leurs verifications firsthand pour un lecteur humain. Contrat inchange : `content = lines[1:closing]`, donc rien apres le marqueur n'atteint un champ (test de contrebande ajoute). Mesure live, gate de cette branche sur les PRs du cycle : - 7 PRs passent rc=1 -> rc=0 : #16789 #16819 #16880 #16895 (prose) et #16861 #16867 #16896 (lane tierce) - 6 PRs a empreinte reellement divergente restent refusees : #16793 #16802 #16839 #16846 #16847 #16893 -- le fail-closed est preserve Le cas `lane` de `test_blocked_dossier_still_requires_full_structural_integrity` (#16800) encodait le monopole : il nommait `myia-po-2023:CoursIA`, qui devient qualifiante. Re-pointe sur une lane hors `QUALIFYING_LANES`, intention preservee. See #16906. See #16928. Co-Authored-By: Claude Opus 5 (1M context) --- scripts/check_adjoint_prevalidation.py | 16 +++++-- .../tests/test_check_adjoint_prevalidation.py | 43 ++++++++++++++++--- 2 files changed, 51 insertions(+), 8 deletions(-) diff --git a/scripts/check_adjoint_prevalidation.py b/scripts/check_adjoint_prevalidation.py index 57269eaf44..d802bcf99b 100644 --- a/scripts/check_adjoint_prevalidation.py +++ b/scripts/check_adjoint_prevalidation.py @@ -178,7 +178,19 @@ def parse_dossier( author: str, created_at: str = "", ) -> tuple[Dossier | None, list[str]]: - """Parse one strictly delimited dossier comment without interpreting prose.""" + """Parse one strictly delimited dossier comment without interpreting prose. + + Prose FOLLOWING the closing marker is ignored, not refused. The contract is + the delimited block: `content` stops at `closing`, so trailing text can never + reach a field. Refusing it discarded dossiers whose machine-readable block + was complete and whose firsthand evidence was written below it for a human -- + measured on four pull requests in one cycle (#16928). + + Nothing is hidden by this. `check_unaddressed_nits.py` strips the dossier by + its two delimiters, so a reserve written after the closing marker still + reaches B.0 classification; only a reserve written INSIDE the block is + absorbed, which is the intended semantics of #16442/#16443. + """ lines = body.strip().splitlines() if not lines or lines[0].strip() != START: return None, [] @@ -192,8 +204,6 @@ def parse_dossier( content = lines[1:] else: content = lines[1:closing] - if closing != len(lines) - 1: - errors.append("content after closing marker") fields: dict[str, str] = {} for raw in content: diff --git a/scripts/tests/test_check_adjoint_prevalidation.py b/scripts/tests/test_check_adjoint_prevalidation.py index 92b9fc60e6..f69c528b69 100644 --- a/scripts/tests/test_check_adjoint_prevalidation.py +++ b/scripts/tests/test_check_adjoint_prevalidation.py @@ -231,11 +231,18 @@ def test_blocked_preflight_is_a_valid_dossier_but_never_ready(): def test_blocked_dossier_still_requires_full_structural_integrity(): - """exit 3 is NOT a softer gate: the fingerprint stays mandatory.""" + """exit 3 is NOT a softer gate: the fingerprint stays mandatory. + + The bad `lane` must be one that is genuinely DISQUALIFYING. This case read + `myia-po-2023:CoursIA` while a single lane could attest; #16906 makes that a + qualifying lane, so it would silently stop testing anything. The assertion + survives by naming a lane outside `QUALIFYING_LANES`, not by narrowing the + set back. + """ for field, value in ( ("surfaces-sha256", "0" * 64), ("head", "f" * 40), - ("lane", "myia-po-2023:CoursIA"), + ("lane", "myia-po-9999:CoursIA"), ("complete", "false"), ("schema", "2"), ): @@ -333,12 +340,38 @@ def test_unknown_and_duplicate_fields_fail_closed(): assert any("duplicate field" in error for error in _errors(_snapshot(duplicate))) -def test_truncated_or_trailed_dossier_is_reported_as_malformed(): +def test_truncated_dossier_is_reported_as_malformed(): + """An unclosed block stays refused: its extent is undefined.""" truncated = _body().replace("\n" + mod.END, "") assert "missing closing marker" in _errors(_snapshot(truncated)) - trailed = _body() + "\ntext after the contract" - assert "content after closing marker" in _errors(_snapshot(trailed)) + +def test_prose_after_the_closing_marker_is_ignored_not_refused(): + """The contract is the delimited block; what follows is for a human. + + Refusing it discarded four dossiers in a single cycle whose machine-readable + block was complete and whose firsthand evidence -- `check_unaddressed_nits` + rc, comment counts, exact head -- was written below the marker so a reader + could see it (#16928). + """ + trailed = _body() + "\n\n### Verifications firsthand\n- B.0 : rc=0, 6 commentaires lus." + verdict, errors = mod.evaluate(_snapshot(trailed)) + assert verdict == mod.VERDICT_READY, errors + assert errors == [] + + +def test_trailing_prose_cannot_smuggle_a_contract_field(): + """What the old refusal actually had to protect -- and still does. + + `content` stops at the closing marker, so a field written after it is never + parsed. Tolerating prose is therefore not tolerating a second, contradicting + contract: the BLOCKED verdict inside the block wins over the READY written + below it. + """ + smuggled = _body(verdict="BLOCKED") + "\nverdict: READY\nb0: clear\nchecks: latest-wins-green" + verdict, _ = mod.evaluate(_snapshot(smuggled)) + assert verdict == mod.VERDICT_BLOCKED + def test_noncanonical_integer_and_pr_mismatch_fail_closed(): From ce4c991ec70b083f4aec92369edd7b83aa22f018 Mon Sep 17 00:00:00 2001 From: jsboige Date: Sun, 20 Sep 2026 02:52:02 +0200 Subject: [PATCH 3/3] harness(gate,#16906): un tag Grain illisible n'autorise pas l'auto-prevalidation Reserve de l'adjoint (BLOCKED-WITH-SUBSTANCE, head 937240db82), juste : quand le body ne porte aucun `Grain: ... lane ...` lisible, `carrier is None` et aucune erreur n'etait ajoutee. Une lane qualifiante portant une PR sans tag pouvait donc deposer son propre dossier et passer un controle qui n'avait jamais tourne. `carrier is None` devient un refus explicite. Un controle qui ne PEUT pas se faire n'est pas un controle qui passe. Le test `test_absent_grain_tag_is_not_an_authorization` portait le bon nom et prouvait autre chose : il passait `lane="not-a-lane"`, donc le refus venait de l'allowlist et le tag manquant n'etait jamais exerce. Il passe desormais une lane QUALIFIANTE, et asserte en plus que l'allowlist n'est PAS le motif -- sinon il se remettrait silencieusement a certifier le mauvais scenario. La fixture `_base_snapshot` recoit une lane porteuse distincte de celle du dossier : sans tag, tous les cas nominaux etaient des auto-attestations. Rayon d'impact mesure le 2026-09-20 : 4 PRs ouvertes sur 221 (1,8 %) ne portent pas de tag lisible, et la sortie est d'ajouter le tag, pas d'affaiblir le gate. 44 tests passent. See #16906. See #16928. Co-Authored-By: Claude Opus 5 (1M context) --- scripts/check_adjoint_prevalidation.py | 18 ++++++++--- .../tests/test_check_adjoint_prevalidation.py | 30 +++++++++++++++---- 2 files changed, 38 insertions(+), 10 deletions(-) diff --git a/scripts/check_adjoint_prevalidation.py b/scripts/check_adjoint_prevalidation.py index d802bcf99b..b978854e38 100644 --- a/scripts/check_adjoint_prevalidation.py +++ b/scripts/check_adjoint_prevalidation.py @@ -330,9 +330,13 @@ def surfaces_fingerprint( def carrying_lane(snapshot: dict[str, Any]) -> str | None: """Return the lane that carries this pull request, from its `Grain:` tag. - Returns None when the body carries no readable tag. An absent tag is not an - authorization: it only means the self-attestation check cannot be made from - the body, and the qualifying-lane check still applies. + Returns None when the body carries no readable tag. `validate_dossier` turns + that None into a refusal: an absent tag means the self-attestation check + CANNOT be made, and a check that cannot be made has not passed. Without that + refusal, a qualifying lane carrying an untagged PR files its own dossier and + clears a control that never ran -- the exact hole the third-party rule exists + to close. Blast radius measured 2026-09-20: 4 of 221 open PRs carry no + readable tag, and the escape is to add the tag, not to weaken the gate. """ match = GRAIN_LANE_RE.search(snapshot.get("body") or "") return match.group(1) if match else None @@ -379,7 +383,13 @@ def validate_dossier(dossier: Dossier, snapshot: dict[str, Any]) -> list[str]: ) else: carrier = carrying_lane(snapshot) - if carrier is not None and carrier == dossier_lane: + if carrier is None: + errors.append( + "carrying lane cannot be established: the body carries no readable " + "'Grain: ... lane ' tag, so third-party " + "prevalidation cannot be verified" + ) + elif carrier == dossier_lane: errors.append( "self-prevalidation refused: the dossier lane " f"{dossier_lane!r} is the lane that carries this pull request" diff --git a/scripts/tests/test_check_adjoint_prevalidation.py b/scripts/tests/test_check_adjoint_prevalidation.py index f69c528b69..a70a7d2cae 100644 --- a/scripts/tests/test_check_adjoint_prevalidation.py +++ b/scripts/tests/test_check_adjoint_prevalidation.py @@ -18,10 +18,17 @@ def _comment(body: str, login: str = "jsboige") -> dict: return {"author": {"login": login}, "body": body} +# The carrying lane of the default fixture. It must differ from the `lane` of +# `_body()` (the adjoint), or every nominal case would be a self-attestation. +# It is a real tag because an absent one is now a refusal: a dossier can only be +# trusted when the gate can see WHO carries the pull request (#16928). +CARRIER = "myia-po-2026:CoursIA" + + def _base_snapshot() -> dict: return { "number": 123, - "body": "PR body", + "body": f"Grain: MED/harnais -- lane {CARRIER} -- prev: MED\n\nPR body", "headRefOid": HEAD, "state": "OPEN", "title": "PR title", @@ -199,13 +206,24 @@ def test_third_party_lane_passes_when_carrier_is_declared(): def test_absent_grain_tag_is_not_an_authorization(): - """No readable tag means the self-check cannot run, not that it passed. - - The qualifying-lane check still applies, so an unknown lane still fails. + """No readable tag means the self-check CANNOT run -- so the dossier fails. + + The first version of this test passed `lane="not-a-lane"`, so the refusal + came from the allowlist and the missing tag was never exercised at all: the + test carried the right name and proved something else, while the code let a + qualifying lane carrying an untagged PR file its own dossier. A QUALIFYING + lane is what makes the absent tag the only thing left to refuse on -- hence + the second assertion, which fails if the allowlist starts doing the work + again. """ - snapshot = _snapshot_with_body("pas de tag Grain ici", lane="not-a-lane") + snapshot = _snapshot_with_body( + "pas de tag Grain ici", lane="myia-po-2023:CoursIA" + ) errors = _errors(snapshot) - assert any(error.startswith("lane must") for error in errors) + assert any( + error.startswith("carrying lane cannot be established") for error in errors + ) + assert not any(error.startswith("lane must") for error in errors) def test_non_shared_github_author_cannot_satisfy_gate():