ADR-0014 — Arène frontier-models : recorder + replay validator, jamais runtime

Status: Proposed (2026-05-01, design review interne).

Related ADRs: ADR-0002 (byte-deterministic CLI), ADR-0003 (sealed-score-replay), ADR-0006 (canonical move enumeration order), ADR-0008 (deterministic board iteration), ADR-0009 (parity CI gate), ADR-0010 (corpus public — code MIT), ADR-0012 (identité utilisateur — request_nonce/device_session/handle_secret).

Upstream-feedback dépendance : dist-play/wrangler.toml lignes 24–27 (binding MORPION_USED_CANCEL_TOKENS dormant — cf. §Migration / threat-model).

Suivis non engageants : multi-modal arena track, Elo / pairwise ranking, tournament mode, upstream-feedback signal sur MORPION_USED_CANCEL_TOKENS.

Context

Le projet morpion possède déjà un zoo d'algorithmes : greedy, MCTS, NRPA, AlphaZero — tous joueurs déterministes pilotés par crates/morpion-arena/match_runner.rs, validés par crates/morpion-arena/replay.rs (sealed-score, ADR-0003) et indexés par le BotStats schema (crates/morpion-arena/leaderboard.rs). Mais le zoo est fermé : il n'accueille que des joueurs Rust dont l'opérateur contrôle le code.

L'enjeu de cet ADR est d'ouvrir le zoo aux frontier-models (GPT-5, Claude, Gemini, Llama, …) sans :

Une revue de design interne a couvert six questions (transport, board representation, API surface, public page, attack surface, prompt publication policy) et a produit 9 convergences C1–C9, 4 divergences résolues D1–D5, et 5 surprising insights. Cet ADR encode ces résolutions comme invariants morphologiques.

L'invariant cardinal — celui dont tout le reste dérive — est C1 : le serveur est un recorder + replay validator, jamais un runtime, jamais un proxy LLM. Le submitter exécute la boucle LLM sur sa machine, paie les tokens, tient la clef API, encaisse la latence ; le serveur reçoit une trace complète, la rejoue à travers replay.rs (microseconde-budget, déterministe), et l'inscrit au leaderboard si replay_check_passed: true. Cette unique décision élimine structurellement 4 attaques sur 5 — l'architecture désamorce avant qu'on ne défende. Sans cet ADR, les implémentations enfant tourneraient contre une spec instable et reproduiraient une à une les confusions du client_id (R1/R2/R3) corrigées dans ADR-0012.

Decision

1. Invariants de l'arène (C1–C9)

Les neuf convergences de la revue deviennent des invariants morphologiques du projet. Aucune implémentation post-ADR ne peut les violer ; tout changement exige un nouvel ADR.

#InvariantConséquence concrète
C1Serveur = recorder + replay validator. Jamais runtime, jamais proxy LLM.Trois Functions, toutes pures. Aucune n'invoque un fournisseur LLM. Le payload submission contient la trace complète.
C2Submitter paie le LLM. Serveur ne détient aucune clef API.Aucun secret OPENAI_API_KEY / ANTHROPIC_API_KEY / GEMINI_API_KEY dans wrangler.toml arena ; aucune env var. Élimine attaque iv (DoS économique).
C3Séparation dure d'avec dist-play/functions/.Pages project distinct (nom de travail morpion-arena.pages.dev) ; wrangler.toml séparé ; aucun binding partagé ; KV namespaces neufs (MORPION_BOTS, MORPION_ARENA_USED_NONCES) ; R2 préfixe arena/. La Function arena n'importe pas dist-play/functions/_shared.js tel quel — fork ou override de json() pour ne pas hériter du Access-Control-Allow-Origin: * (ligne 29).
C4Enveloppe HMAC challenge-signed, single-use, TTL'd.Réutilise le pattern issueCancelToken/verifyCancelToken de dist-play/functions/_shared.js. Mapping ADR-0012 : flux bot = cas dégénéré de request_nonce. Pas de device_session, pas de handle_secret, pas de quatrième primitive.
C5Validator = pur Rust. Aucun LLM dans la boucle de validation, jamais.crates/morpion-arena/src/replay.rs (déjà existant, ADR-0003) ou morpion_core::ReplayOracle. Garde-fou normatif : interdit le futur "judge model". Toute proposition d'un LLM-as-judge devra commencer par révoquer cet invariant via un nouvel ADR.
C6Humains et bots juxtaposés, jamais classement unifié.Même page, deux colonnes côte à côte, deux échelles distinctes. Visualisation hero = un seul graphe de distribution (violin/box) : Anh-Hào \(\cdot\) joueur·B \(\cdot\) joueur·C \(\cdot\) joueur·D \(\cdot\) greedy \(\cdot\) MCTS \(\cdot\) NRPA \(\cdot\) AlphaZero \(\cdot\) GPT-5 \(\cdot\) Claude \(\cdot\) Gemini \(\cdot\) Llama, sur un même axe final_score. La juxtaposition est le message.
C7replay_check_passed: true obligatoire sur chaque entrée du leaderboard.Recompute à chaque build du leaderboard (déterministe par ADR-0008). Aucune entrée non-vérifiée n'apparaît.
C8Reporting multi-stat, min-n gate avant headline.Schéma figé : (max_score, mean_score, median_score, p_ge_25, p_31, n_episodes)BotStats en crates/morpion-arena/src/leaderboard.rs:20. max_score n'est pas publié comme rang headline tant que n_episodes < n_min (défense contre l'overfit single-shot).
C9Format wire serveur = ObsView JSON (crates/morpion-arena/src/wire.rs:23).Une seule schéma canonique. Sorted keys (ADR-0002), canonical move order (ADR-0006), iteration déterministe (ADR-0008). Pas de ?format= knob. Le rendu côté submitter (ASCII, image, autre) est un client concern hors-contrat serveur.

C6.1 — Symétrie tri-track : chaque track a son endpoint live + son seed offline, jamais croisement

Amendement 2026-05-02 (régression image #63 post-livraison). C6 décrit l'invariant : humains, algos et frontier-models juxtaposés, jamais fusionnés. C6.1 fixe l'architecture symétrique qui matérialise l'invariant — sans symétrie, la juxtaposition se brise dès qu'un track migre.

TrackEndpoint liveKV namespaceSeed offlineProducteur
humansGET /humans/leaderboardMORPION_HUMANS_AGGREGATEdist-arena/seeds/humans-seed.jsondist-play-overlay/functions/save.js (best-effort RMW per handle)
algosGET /algos/leaderboardMORPION_ALGOS_AGGREGATEdist-arena/seeds/algos-seed.jsontools/run-algo-bench.sh \(\to\) POST /algos/submit (admin token-gated)
frontierGET /arena/leaderboardMORPION_BOTSdist-arena/seeds/frontier-seed.json (vide par doctrine)POST /arena/submit (HMAC-signed nonces, ADR-0014 §C1)

Trois règles d'invariance :

  1. Trois endpoints, trois KV, trois seeds — jamais un track qui dépend d'un autre. La régression 5732 (humans migré vers /humans/leaderboard \(\to\) algos disparu parce qu'il vivait dans humans.json.algorithms) est exactement ce que C6.1 interdit.
  2. Promise.all côté clientdist-arena/leaderboard.js:boot() fetch les 3 endpoints en parallèle. Latence = max(track), pas somme. Une panne dans un track ne starve jamais les autres (soft-fail encapsulé par track).
  3. Soft-fail per-track — chaque loader (loadHumans / loadAlgos / loadBots) tente le live, puis dégrade vers son seed propre. Un seed peut être vide par doctrine (le frontier-seed l'est : on n'invente jamais de score ; cf. C1 + ADR-0018 §3.5) ou référence chargée (humans-seed porte le record humain de référence ; algos-seed porte le batch v1).

L'authoring d'un nouveau track (ex: multi-modal, D3 en suivi non engageant) duplique cette structure — endpoint dédié, KV dédié, seed dédié, producteur dédié — au lieu de mêler ses entrées dans un track existant. C'est le coût de l'absence-de-fusion (C6) rendu structurellement explicite.

Conséquence opérationnelle : dist-arena/humans.json est promu vers dist-arena/seeds/humans-seed.json + dist-arena/seeds/algos-seed.json (les deux datasets distincts qui y cohabitaient illégitimement sont séparés à l'exact endroit où l'invariant le réclame).

C10 — Leaderboard-first ordering : la promesse produit est la juxtaposition, pas l'objet plateau

Amendement 2026-05-03 (signal terrain — le produit ne racontait pas sa promesse en premier coup d'œil ; comparaison de deux maquettes a tranché vers : « plutôt que voir le board, on devrait voir le leaderboard en premier ; le plateau devrait être plus un logo, moins en avant »).

C6 fixe l'invariant humains/bots/frontier juxtaposés sur la même page. C10 ordonne ce que le visiteur voit en premier : pas le plateau de départ (objet d'observation), mais la juxtaposition elle-même (la promesse — humains \(\times\) frontier-models côte-à-côte).

AvantAprès C10
Hero plein écran : H1 + sous-titre + plateau \(540\times540\) cards + CTA. Le leaderboard arrive après scroll.Hero-band compact : H1 court + mini-logo \(~100\times100\) px (identité visuelle, pas observation). Le leaderboard suit immédiatement.
Plateau \(540\times540\) = objet d'observation (figure + caption, registre cartel de musée).Plateau \(100\times100\) = logo (identité, pas figure). Pas de caption.
Caption « Plateau de départ — 16 dots. Premier coup : à toi. » sous le hero-board.Caption déplacée en CTA-strip (#cta-play) collée au bouton Joue toi-même ↗.

Trois règles d'invariance :

  1. Le hero-band ne dépasse pas ~120 px de haut (mini-logo \(\leq\) 100 px + padding 20 px). Tout padding: 48px ou figure width: var(--board-width-target) dans .hero-* est une régression.
  2. Le mini-logo est immuable et identitaire — toujours la croix grecque réduite (16 dots canonicalCrossDots()), jamais un snapshot du run #1. Un snapshot serait un objet d'observation, donc volerait l'attention que C10 réserve au leaderboard.
  3. Le leaderboard est le premier <section> de contenu après le .hero-band (et après .site-header). Aucune section figure / cartel / gallery ne peut s'intercaler — l'ordre du DOM matérialise C10.

Tension résolue : doctrine cartel-de-musée (revue §S5, hero-board comme cadre cream, registre disjoint de la PWA play) vs hiérarchie produit (« qu'est-ce qui doit disparaître pour que le leaderboard apparaisse en premier ? »). Le cartel-de-musée n'est pas révoqué ; il est redimensionné : ce qui était figure devient logo, ce qui était caption devient appel-à-action. Soustraction + changement d'échelle — pas de révocation d'invariant, juste un changement de registre.

2. Divergences résolues (D1–D5)

D1 — Trois endpoints, modèle recorder

Décision : GET /arena/challenge \(\to\) POST /arena/submit \(\to\) GET /arena/leaderboard.

EndpointRôleCoût
GET /arena/challengeÉmet une enveloppe HMAC-signée ({ nonce, expires_at, signature }). Stateful uniquement à l'expiration via MORPION_ARENA_USED_NONCES.Une signature HMAC par appel ; pas de stockage avant submission.
POST /arena/submitReçoit la trace complète + l'enveloppe + le payload submission (cf. §3). Vérifie la signature, marque le nonce used, rejoue via replay.rs, écrit dans R2 préfixe arena/ et indexe en MORPION_BOTS.Une vérif HMAC + un replay() (microseconde-budget) + un PUT R2 + un PUT KV.
GET /arena/leaderboardRecompute le Leaderboard depuis les entrées MORPION_BOTS + R2. Déterministe, cacheable.Lecture KV/R2 ; pas d'état mutable.

Rejets :

Le challenge endpoint coûte essentiellement rien (une signature HMAC) et clôt structurellement l'attaque xii. C'est la plus petite défense correcte.

D2 — Politique de publication des prompts

Décision : prompt obligatoire dans le payload de submission, hash toujours public, corps complet requis pour le headline ranking, opt-out unranked tier disponible.

TierVisibilité du promptPlace sur le leaderboard
Headline (ranked)Corps complet du prompt publié ; prompt_template_sha256 calculé et exposé.Apparaît dans la table principale, triable par max_score / mean_score / p_31.
Unranked (opt-out)Seul prompt_template_sha256 exposé ; corps gardé en R2 mais non publié.Apparaît grisé, sortable sans figurer dans les rangs headline.

Le prompt est licencié MIT (cohérent avec ADR-0010 §3 : « Code de la PWA, du workflow, des outils — MIT, adoption maximale aval »). Le serveur stocke le prompt comme bytes opaques ; il ne le ré-injecte jamais dans un LLM (invariant C5).

Tension résolue : doctrine du commun (« opacity is contest of prompt engineering disguised as reasoning », doctrine privé \(\to\) commun, jamais l'inverse déjà fixée par ADR-0010 §3) vs friction d'entrée (« lower friction for early competitors » + injection vector). L'argument injection est désarmé structurellement par C5 : pas de judge model = pas de surface d'injection. L'argument doctrinal gagne. Mais on garde une voie unranked pour les vendors qui ne peuvent pas publier — ils existent sur la page, sans figurer dans le headline. C'est une version mince de « no prompt \(\to\) no entry » : « no prompt \(\to\) no rank ».

Cette division est plus fine que celle d'ADR-0010 sur les traces (CC0 monolithique) et plus fine que la doctrine pseudonyme d'ADR-0012 (handle libre, client_id jamais exposé) — mais elle dérive du même principe : le commun est le défaut, l'opt-out est explicite et tracé.

D3 — Multi-modal différé

Décision : v1 est text-only. Le track multi-modal (PNG payload, leaderboard parallèle) part en suivi non engageant.

Une perspective proposait deux tracks (text + multi-modal). Quatre perspectives sur cinq différaient. La complexité ajoutée (rendu d'image côté submitter, leaderboard séparé, méthodologie séparée) ne se justifie pas sans signal de demande. Le single distribution chart hero (C6) reste dans le scope v1 — c'est la visualisation ; le track multi-modal n'y est pas.

D4 — Pas d'énumération des coups légaux dans le prompt canonique

Décision : ne pas inclure legal_moves énuméré dans le prompt. Inclure uniquement legal_move_count: int comme signal de difficulté.

Le constat : « le puzzle est trouver le coup, pas ranker l'index ». Inclure legal_moves ferait collapse vers une tâche de classification triviale. legal_move_count reste utile post-hoc pour analyser la difficulté d'une position, sans être gameable.

L'inquiétude sur la format-multiplication attack surface est désarmée par C9 : il y a un format wire canonique. Le rendu côté submitter (ASCII, JSON brut, prose) est libre, mais le prompt_template_sha256 est pinné dans la submission — donc le submitter ne peut pas dériver son rendu librement sans tomber hors du headline rank (cf. D2).

D5 — Reference client : nouveau binaire morpion-arena-llm, fin wrapper

Décision : créer le binaire morpion-arena-llm comme thin wrapper de crates/morpion-arena/match_runner.rs, ajoutant un trait LLM-backend analogue au Bot trait (crates/morpion-arena/src/bot.rs).

Le binaire Rust est la référence reproductible canonique. Le script Python de référence — livré séparément — est un artefact pédagogique plus petit, destiné aux chercheurs qui ne veulent pas builder Rust. morpion-cli (ADR-0002) reste la référence du format wire, sans changement.

Le travail est léger : le Bot trait existant couvre l'essentiel du game-loop ; le delta est une trait LlmBackend avec une méthode propose_move(&ObsView, &Prompt) -> Result<MoveProposal>.

3. Schéma de submission (canonical payload)

Champs alphabétiquement ordonnés (ADR-0002), figés dès v1 :

{
  "bot_run_token": "<base64url HMAC envelope from /arena/challenge>",
  "episodes": [
    {
      "final_score": 24,
      "history": [/* MoveWire[] — canonical move order, ADR-0006 */],
      "model_id": "anthropic/claude-opus-4-7",
      "prompt_template_sha256": "<hex sha256 of prompt body>",
      "seed": 17,
      "started_at": "<RFC3339 day-granularity>"
    }
  ],
  "prompt_body": "<MIT-licensed prompt template, opaque bytes server-side>",
  "publish_prompt": true,
  "submitter_label": "<vendor>:<model>:<rev>"
}

4. Identity & namespace contract

Reprend l'invariant ADR-0012 §1 (trois primitives normatives) et l'étend à l'arène. Aucun namespace ne contient les clefs d'un autre.

NamespaceOwnerSchéma de clefDoit ne jamais contenir
MORPION_HANDLEShumains (PWA familiale)handle:<name>un model_id de bot
MORPION_BOTSbots (arena)bot:<vendor>:<model>:<rev>un handle humain
MORPION_ARENA_USED_NONCESbots (arena)nonce:<sha256>quoi que ce soit hors du TTL nonce

R2 : un seul bucket physique, deux préfixes lisibles :

PréfixeOwnerÉcrit par
traces/humainsdist-play/functions/save.js (ADR-0010)
arena/botsarena/functions/submit.js (livraison sibling B)

Les écritures arena ne touchent jamais traces/. Le _shared.js:json() de dist-play/functions/ ne peut pas être importé tel quel par l'arène : il poserait Access-Control-Allow-Origin: * (ligne 29) et créerait le CORS bleed (attaque xi). L'arène fork ou override un json_arena() avec un allowlist explicite (au minimum : son propre origin, optionnellement les origins documentés des reference clients).

Architecture

                                   submitter machine (LLM lives here)
                                  ┌─────────────────────────────────┐
                                  │  morpion-arena-llm binary       │
                                  │  ─ match_runner.rs game loop    │
                                  │  ─ LlmBackend trait calls       │
                                  │  ─ holds vendor API key         │
                                  └────────────────┬────────────────┘
                                                   │
                                  ① GET /arena/challenge
                                  ◀──── { nonce, expires_at, sig }
                                                   │
                                                   │ run N episodes locally,
                                                   │ pay LLM tokens
                                                   │
                                  ② POST /arena/submit
                                  ────▶  { bot_run_token, episodes,
                                            prompt_body, publish_prompt,
                                            submitter_label }
                                                   │
                                                   ▼
              ┌─────────────────────────────────────────────────────────┐
              │  morpion-arena.pages.dev (separate Pages project)        │
              │                                                          │
              │  GET /arena/challenge   →  HMAC envelope, single-use,    │
              │                            TTL'd in MORPION_ARENA_…     │
              │                                                          │
              │  POST /arena/submit     →  verify HMAC ▸ burn nonce ▸    │
              │                            replay() in pure Rust ▸       │
              │                            R2 PUT arena/<entry>.ndjson ▸ │
              │                            KV PUT MORPION_BOTS:<key>     │
              │                                                          │
              │  GET /arena/leaderboard →  recompute Leaderboard         │
              │                            (BotStats schema) on each     │
              │                            build, deterministic order    │
              └────────────────┬────────────────────────────────────────┘
                               │
                               │ HTML page consumes JSON
                               ▼
                    ┌─────────────────────────────────┐
                    │  arena public page                │
                    │  ─ hero distribution chart       │
                    │  ─ humans + bots juxtaposed      │
                    │  ─ replay viewer (wow)           │
                    │  ─ methodology + prompt links    │
                    └─────────────────────────────────┘

Out-of-scope

Cet ADR refuse explicitement, ou diffère, les options suivantes. Chaque ligne différée est un suivi non engageant.

ItemStatut
Multi-modal arena track (PNG payload, leaderboard parallèle)Suivi non engageant post-ship
Elo / pairwise ranking entre modèlesSuivi non engageant
Tournament mode (épisodes head-to-head)Suivi non engageant
Server-side LLM proxyRefusé (C1, C2). Économiquement insurvivable, vecteur d'attaque iv.
Judge model in validation loopRefusé (C5). Garde-fou normatif.
Per-move HMAC chainRefusé (qualifié de « semver bear-trap » par la revue).
Stateful referee endpoints (start/move/submit)Refusé (D1, race conditions, KV bloat).
Format ?format= knob (alternate wire formats)Refusé (C9, attack-surface multiplication).
legal_moves enumerated in canonical promptRefusé (D4).

Migration / threat-model

Reprend la table de 12 attaques issue de l'analyse adversariale, avec verdict post-ADR :

#AttaqueSévéritéVerdict ADR
iServer-side rule access (bot demande au serveur les coups)NON-ATTAQUELe puzzle est raisonner, pas cacher la règle. ADR ne prend rien.
iiOverfitting via \(N\to\infty\) submissionsMEDC8 (min-n gate) + C4 (challenge nonce single-use, rate-limit) atténuent. Résiduel accepté.
iiiMechanical-Turk smuggling (humain joue sous étiquette bot)RÉSIDUEL ACCEPTÉIndistinguible côté serveur d'un modèle lent. La doctrine pseudonyme d'ADR-0012 ne se contre pas par identification — par invariant.
ivEconomic DoS (bot DDoS la clef API serveur)RÉSOLU PAR C2Pas de clef API serveur — surface inexistante.
vTrace forgery (faux trace gagnant)RÉSOLU PAR C5+C7replay() pur Rust déterministe ; replay_check_passed: true obligatoire. Les traces forgées échouent au replay.
viPrompt-injection of validator (LLM judge prompt-jacké)RÉSOLU PAR C5Pas de judge model — pas de surface d'injection. Garde-fou normatif.
viiOff-by-one segment validation (forgery via used_segments mal validé)RÉSOLU PAR C5+ADR-0007replay() valide les used_segments canonicalement (ADR-0007).
viiiKV pollution into MORPION_HANDLESRÉSOLU PAR C3Namespace dédié MORPION_BOTS. Aucune écriture arena ne peut atteindre MORPION_HANDLES.
ixR2 cost amplification (storage flood)Suivi non engageantCap dur sur episodes.length par submission, cap sur prompt_body size, rate-limit Cloudflare-edge. À détailler dans la livraison Functions.
xSubmission size DoS (multi-MB payloads)Suivi non engageantCap explicite Content-Length \(\leq\) 1 MB par submission. À détailler dans la livraison Functions.
xiCORS bleed via shared _shared.js:json() wildcard ACAORÉSOLU PAR C3L'arena fork/override json() avec allowlist explicite. Ne pas importer dist-play/functions/_shared.js tel quel.
xiiHash-replay d'un trace gagnant antérieurRÉSOLU PAR C4 + upstream-feedback fixLe challenge nonce est single-use, marqué dans MORPION_ARENA_USED_NONCES après submission. MAIS : nécessite que le pattern MORPION_USED_CANCEL_TOKENS soit câblé d'abord — voir upstream-feedback signal ci-dessous.

Upstream-feedback signal — MORPION_USED_CANCEL_TOKENS est commenté

dist-play/wrangler.toml lignes 24–27 laissent le binding MORPION_USED_CANCEL_TOKENS commenté, dormant. Le pattern issueCancelToken/verifyCancelToken que C4 réutilise (et qui sous-tend la défense contre l'attaque xii) dépend de ce KV pour la marque single-use. Tant qu'il n'est pas câblé :

Conséquence opérationnelle :

Open questions

Cinq questions restent ouvertes et seront tranchées par les livraisons enfant ou par un follow-up ADR. Aucune ne bloque l'invariant.

  1. Min-n gate exact (C8). Hypothèse de travail : n_min = 30 épisodes pour publier max_score comme rang headline. À calibrer sur les données simulées.
  2. Cap episodes.length par submission (attaque ix/x). Hypothèse de travail : 100 épisodes max par POST.
  3. Forme du submitter_label (<vendor>:<model>:<rev> proposé). Le format exact (séparateurs, casse, longueur max) est à fixer.
  4. TTL du nonce (expires_at dans l'enveloppe HMAC). Hypothèse de travail : 1 heure. À calibrer en fonction de la durée typique de N épisodes côté submitter.
  5. Allowlist CORS arena — origins explicitement autorisés en cross-origin. Hypothèse de travail : https://morpion-arena.pages.dev only ; les reference clients (Rust, Python) appellent en server-to-server, pas de CORS.

Invariant

Le serveur de l'arène frontier-models est un recorder + replay validator, jamais un runtime, jamais un proxy LLM. Aucune clef API LLM n'est détenue serveur-side ; aucun judge model n'intervient dans la boucle de validation. Le validateur est crates/morpion-arena/src/replay.rs en Rust pur. Les humains et les bots sont juxtaposés mais jamais classés ensemble — MORPION_HANDLES n'accueille aucun model_id, MORPION_BOTS n'accueille aucun handle humain. Le format wire serveur est ObsView JSON (crates/morpion-arena/src/wire.rs), unique et sans variantes. Tout changement de cette répartition est un breaking change qui exige un nouvel ADR.


Derivation — Issue d'une revue de design interne convergeant sur cinq angles : (1) architecture recorder + replay validator avec séparation dure d'avec la PWA familiale dist-play, hard split humains/bots, best-of-N + distribution ; (2) format wire canonique JSON+ASCII, refus d'énumérer legal_moves, distribution chart en hero, track multi-modal différé en suivi non engageant ; (3) modèle 3 endpoints avec enveloppe HMAC smallest-correct, mapping sur ADR-0012 sans nouvelle primitive, namespace dédié MORPION_BOTS, ObsView canonique ; (4) doctrine one-product/one-URL, replay viewer comme effet wow, prompts MIT obligatoires (cohérent avec la doctrine privé \(\to\) commun, jamais l'inverse déjà fixée par ADR-0010) ; (5) table de 12 attaques avec 8 ship-blockers + 4 résiduels, attaques vi/vii/viii/ix/xi/xii cataloguées, et le signal upstream-feedback sur MORPION_USED_CANCEL_TOKENS.