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..b978854e38 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. @@ -152,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, [] @@ -166,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: @@ -291,6 +327,21 @@ 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. `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 + + 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 +354,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 +376,24 @@ 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 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" + ) 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 +604,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 +646,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 +666,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..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", @@ -71,6 +78,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,9 +161,69 @@ 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"))) - assert any(error.startswith("lane must") for error in errors) +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 -- 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="myia-po-2023:CoursIA" + ) + errors = _errors(snapshot) + 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(): @@ -167,11 +249,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"), ): @@ -269,12 +358,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(): @@ -423,3 +538,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())