# ERRATA — TRANSCRIPTION-001

Journal des corrections publiées. Une entrée par erratum, jamais réécrite:
une correction ultérieure ajoute une entrée, elle ne modifie pas les
précédentes. Les octets d'avant correction restent dans l'historique git aux
commits cités.

Procédure: `docs/decisions/L0-002-procedure-erratum.md` (adoptée le 2026-09-03,
TORNEO-004 C17), sept étapes. Cet erratum en est le premier cas d'application.

---

## ERR-001 — LIM-2 nommait le mauvais vendeur (2026-09-03)

| | |
|---|---|
| **Date (UTC)** | 2026-09-03T12:00:00Z |
| **Champ fautif** | `limits[1]` (LIM-2) de `runs/TRANSCRIPTION-001/result.json`, et sa source `contest.yaml` → `limits` |
| **Constaté par** | le Conducteur (mission « erratum », plan v3 étape 0) |
| **Décision de référence** | `docs/decisions/TORNEO-004-challenge-opus-plan-v3.md` C17; procédure `docs/decisions/L0-002-procedure-erratum.md` |
| **Corrigé par** | lane L2, PR https://github.com/devlollc/gladiator/pull/15 |

**Sévérité: bloquante.** Une limite publiée était fausse sur un fait
vérifiable, et elle était servie aux agents et aux humains sur toutes les
surfaces (bundle, `results/transcription/latest.json`, rapport, site, MCP).

### Ce qui était publié

`limits[1]` (LIM-2) du résultat servi:

> « 4 solutions d'un seul vendeur (OpenAI); autres providers
> BLOCKED_NO_CREDENTIAL, pas battus. »

### Ce qui est vrai

Les quatre participants du run sont des endpoints **Google**
(`gemini-3-5-transcribe`, `gemini-2-5-flash`, `gemini-2-5-pro`,
`gemini-3-5-flash`, `vendor: "Google"` dans `contest.yaml` et dans chaque
entrée `participants[]` du résultat). OpenAI n'a produit **aucune**
observation: son organisation était sans crédits API (HTTP 429
`insufficient_quota`, sondé le 2026-09-02, `PROVIDERS.json` statut
`BLOCKED_NO_CREDITS`). La phrase publiée attribuait donc le run entier au
mauvais vendeur, et écrasait en même temps la distinction entre les trois
classes de blocage (`BLOCKED_NO_CREDITS`, `BLOCKED_NO_CREDENTIAL`,
`BLOCKED_TOS`).

### Origine

Texte de limite **codé en dur** dans le scorer de catégorie
(`categories/transcription/tools/score.py`), écrit quand les participants
prévus étaient les quatre endpoints OpenAI, et jamais mis à jour lors du
changement de participants du 2026-09-02 (addendum de
`docs/decisions/L2-001-corpus-et-metriques.md`). La propagation:

`score.py` (dur) → `RUN_REPORT.json` → `contest.yaml` (recopié par
`convert_bundle.py` depuis `RUN_REPORT.json`) → `result.json` →
`results/transcription/latest.json` → `reports/TRANSCRIPTION-001.*` → site, MCP.

Le plan d'analyse gelé (`ANALYSIS_PLAN.md`, sous `PROTOCOL.lock`, donc
antérieur au premier appel) portait déjà la formulation correcte
(« 4 solutions d'un seul vendeur (Google) »). **La correction rétablit
l'accord du résultat publié avec le protocole gelé; elle ne change aucune
substance du protocole, aucune observation, aucune estimation.**

### Correction appliquée

Texte de LIM-2 après correction:

> « 4 solutions d'un seul vendeur (Google): la couverture du marché est
> incomplète; les autres fournisseurs candidats n'ont pas couru (crédits API
> épuisés, absence de credential, ou clause anti-benchmark dans leurs
> conditions) — bloqués, pas battus; détail fournisseur par fournisseur dans
> categories/transcription/PROVIDERS.json et legal/. »

Le vendeur mesuré est nommé; les fournisseurs qui n'ont pas couru sont décrits
par la **classe** de blocage, et le détail nominatif reste dans
`PROVIDERS.json` et `legal/`, qui portent déjà la preuve par fournisseur. Ce
choix de formulation est aussi ce qui satisfait le test littéral de
`lanes/S4-conducteur/DONE_WHEN.md` D1 (`rg -c "OpenAI"
runs/TRANSCRIPTION-001/result.json` = 0): une phrase qui nommerait OpenAI,
même pour dire vrai (« sans crédits »), ferait échouer ce gate. Qui reformulera
LIM-2 plus tard doit garder cette contrainte en tête.

Corrigés à la source, dans cet ordre:

1. `categories/transcription/tools/score.py` — le producteur. Sans cela, tout
   rescore réintroduirait la faute (règle « réparer l'artefact ET le
   producteur »).
2. `runs/TRANSCRIPTION-001/RUN_REPORT.json` — régénéré par ce scorer.
3. `runs/TRANSCRIPTION-001/contest.yaml` — source canonique des `limits` du
   résultat. Ce fichier est **gelé dans `PROTOCOL.lock`**: sa correction est
   donc traitée en **amendement A2**, décrit dans `PROTOCOL_AMENDMENTS.md`
   (réécriture du lock à `locked_at` inchangé + re-chaînage du journal, dont
   la genèse est le hash du lock).

   **Antériorité: le nouveau lock n'en prouve aucune pour le texte corrigé**
   (L0-002 étape 2, « le dire »). Le texte de LIM-2 publié aujourd'hui a été
   écrit le 2026-09-03, après le run: aucun lock ne peut prétendre le
   contraire, et `locked_at` reste à l'instant du gel d'origine parce que
   c'est un fait, pas une propriété du texte corrigé. Ce qui est antérieur et
   vérifiable, c'est la **substance**: `ANALYSIS_PLAN.md`, gelé avant le
   premier appel, porte déjà « 4 solutions d'un seul vendeur (Google) », et
   son empreinte est **inchangée** par l'erratum
   (`b6e1ab3ca12feae4545765925cfb172b72a75ba5bdf3a0e4f6dc8cc2ed228ed0` avant
   et après, visible dans les deux versions du lock). La correction ne fait
   donc qu'aligner un texte dérivé sur un document gelé antérieur.
4. `runs/TRANSCRIPTION-001/result.json` — rescoré par `gladiator`.
5. `results/transcription/latest.json`, `reports/TRANSCRIPTION-001.md` et
   `reports/TRANSCRIPTION-001.report.json` — régénérés.

### Ce qui ne change pas

Aucune observation, aucun appel API (zéro dépense), aucune estimation, aucun
intervalle, aucun rang, aucun statut. `perfect_transcription_rate` par
participant, les IC95 Wilson, les rangs `[1,4]` (aucune paire séparée) et le
WER micro de `RUN_REPORT.json` sont identiques avant et après. Les payloads
des 360 observations sont inchangés octet pour octet; seul leur champ `prev`
(chaînage) est recalculé, la genèse de la chaîne étant le hash du lock.

### Empreintes

| Artefact | Avant (servi) | Après |
|---|---|---|
| `result.json` (`result_sha256`) | `ac21d55664875bc1d89e3948cd84ca7a9d0a713b3708caeb2de19daf642e8256` | `1658e2913b8407b5fe3321a79d6a088a599b22345f69105453555c8d59639e23` |
| `PROTOCOL.lock` (`protocol_lock_sha256`) | `5b0c0993b4e2228cf99cba4416952bf4563a14b5e4ec95adec61b60546619d03` | `68db3486e113994b89545be040700b8b0d18bda08eb807d7d6050f062fa32cce` |
| `contest.yaml` | `f2825e8a04e09b64dd4a7c34c42b3eee28715b5f42699dd787bc7ad6bca36bd4` | `4327eea10ecdec7a18780e3b95685b69a339384b1f090573dab0cc9480b80cc1` |
| `events.ndjson` (`raw_data_sha256`) | `77fdeaab842c8f29e82e4efbd4bdfa223c1340a1e2a73132c0502a2ec8ec6b67` | `d0b848d061322ff93d65c2ce5436012672eba3bb5067092c0781920f60a04a91` |
| `RUN_REPORT.json` (WER) | `2a9aa1e0b8255f8a3005c74c6ff03826986489627f8b12e1981f1eb8a3c64862` | `3b97308ed151bdf68b5c055d8ff1078b266f60a51fc0074234ab285f6b5b4d20` |

Diff du résultat, champ par champ (`limits` mis à part, ce sont les trois
empreintes dérivées, rien d'autre): `limits`, `protocol_lock_sha256`,
`raw_data_sha256`, `result_sha256`. Estimations, intervalles, rangs, statuts,
n, taux d'erreur et coûts par participant: **identiques**, vérifié par
comparaison champ à champ avec la version servie.

Octets d'avant correction: commit `f5d018d` (branche `lane/L2-transcription`).
Le SHA servi et son successeur sont journalisés dans `SERVED_SHA_LOG.ndjson` à
la racine du dépôt. Correction publiée par la PR
https://github.com/devlollc/gladiator/pull/15.

### État des sept étapes de L0-002

| # | Étape | État |
|---|---|---|
| 1 | Constat dans `ERRATA.md` | fait (ce document) |
| 2 | Correction à la source + amendement | fait (A2; antériorité impossible, dite plus haut) |
| 3 | Rescore: `reproduce` ×2, `validate`, `verify` | fait, PASS |
| 4 | Ligne dans `SERVED_SHA_LOG.ndjson` | fait, au schéma de l'étape 4 |
| 5 | Projection `results/` + `reports/` | fait; **redéploiement + `SURFACE_PARITY`: lane S2, après merge** |
| 6 | Visibilité (page du run, `llms.txt`) | **bloqué hors de cette lane**, voir ci-dessous |
| 7 | Registre + producteur + fixture négative | fait (`LANE-REGISTRY.json` `errata`, `score.py`, `tests/test_limits_vendor_claim.py`) |

### Étape 6: ce qui manque, et pourquoi ce n'est pas dans cette PR

`site/scripts/prebuild.mjs` ne lit qu'un seul fichier par run
(`runs/<RUN>/result.json`, ligne « Source canonique unique »); `ERRATA.md` n'est
pas ingéré, et `schemas/result.v1.json` est `additionalProperties: false`, donc
aucun champ `errata` ne peut être ajouté au résultat sans passer par L1. La
page du run ne peut donc pas afficher l'erratum tant que l'une de ces deux
routes n'est pas ouverte:

- **route site (S2)**: `prebuild.mjs` lit aussi `runs/<RUN>/ERRATA.md` et la
  page du run le rend au-dessus des limites; ou
- **route schéma (L1)**: `result.v2` porte `errata[]` et `superseded_by`
  (déjà prévu par L0-002 étape 4), le site le rend depuis le bundle.

`llms.txt` est généré depuis `result.json`: il portera la limite corrigée
automatiquement au prochain build, sans travail supplémentaire.

### Suites

- Redéploiement du site: lane S2, après merge. SHA avant/après dans
  `lanes/L2-transcription/HANDOFF.md`.
- `SURFACE_PARITY` se mesure au SHA du commit déployé (règle 3 de
  `docs/plan/10-FINALITES-EXECUTABLES.md`); elle n'est donc pas rejouable dans
  cette PR, elle appartient au redéploiement S2.
- Tant que le redéploiement n'a pas eu lieu, le site sert `ac21d556…`,
  c'est-à-dire la limite fausse.

---

## ERR-002 — la catégorie servait « aucun conflit » sur un vendeur que devlo paie (2026-09-04)

| | |
|---|---|
| **Date (UTC)** | 2026-09-04T15:30:00Z |
| **Champ fautif** | `conflicts` de `runs/TRANSCRIPTION-001/result.json` (servi `[]`), et sa source `contest.yaml` → `conflicts` |
| **Constaté par** | lane OPS-003, en écrivant `runs/<id>/FUNDING.json` (ligne OPS-051): la garde `tools/check_funding.py` a refusé un run qui déclare une dépense chez un éditeur absent de `CONFLICT_REGISTER.json` |
| **Décision de référence** | `INDEPENDENCE_CHARTER.md` §1; procédure `docs/decisions/L0-002-procedure-erratum.md`; ligne OPS-136 |
| **Corrigé par** | lane OPS-003, amendement A3 |

**Sévérité: bloquante.** Le résultat servait `conflicts: []` sur toutes les
surfaces (bundle, `results/transcription/latest.json`, MCP `explain_limits`,
page, rapport) alors que les **quatre bras mesurés** sont des points de
terminaison d'un seul vendeur, Google, chez qui devlo est client payant à
l'usage, et que ce run précis lui a été facturé 0,60 USD (`SPEND_LEDGER.json`,
entrée du 2026-09-02, lane L2-transcription; `runs/TRANSCRIPTION-001/FUNDING.json`).
Dire « aucun conflit » à un lecteur qui choisit un outil sur cette mesure est
exactement ce que la charte interdit.

### Pourquoi personne ne l'avait vu

La garde `tools/check_conflicts_projection.py`, écrite pour l'erratum ERR-001
d'EMAILFIND-001 (OPS-109), n'appariait que le **nom** du participant contre
l'entité du registre. Pour emailfind cela suffit: le participant *est* l'éditeur
(`lusha`, `dropcontact`). Pour transcription les participants sont
`gemini-2-5-flash` et consorts, et l'entité est leur `vendor`. La garde passait
donc au vert sur un bundle vide de conflits. Le défaut était dans la garde
autant que dans le producteur.

### Ce qui était publié

```json
"conflicts": []
```

### Ce qui est publié après correction

Quatre lignes, une par bras, projetées de `CONFLICT_REGISTER.json` (entrée
`Google`, statut `USER_OF_TOOLS_PAY_PER_USE`), la première étant:

```
gemini-3-5-transcribe: devlo is a paying customer of Google, the vendor of this arm, billed per call; devlo is a user of the tool, not a vendor; same conditions and same protocol as any customer, journaled; what this run cost is in FUNDING.json (CONFLICT_REGISTER.json: USER_OF_TOOLS_PAY_PER_USE)
```

### Corrections apportées

1. `CONFLICT_REGISTER.json` déclare `Google` (relation, portée, source), ce qui
   manquait: le registre ne connaissait aucune entrée pour le vendeur des quatre
   bras publiés.
2. La projection devient la source unique `src/gladiator/conflicts.py`, apparie
   **le nom ou le vendeur**, et distingue le paiement à l'usage du crédit
   prépayé: les deux phrases servies ne disent pas la même chose parce que ce
   n'est pas le même fait. `categories/emailfind/conflicts.py` devient un renvoi;
   une seconde implémentation serait le défaut que la règle « une seule source
   canonique » interdit. Les lignes servies d'EMAILFIND-001 sont inchangées,
   octet pour octet.
3. `tools/check_conflicts_projection.py` apparie aussi par vendeur; rejouée
   contre le bundle d'avant, elle nomme les quatre bras.
4. `categories/transcription/tools/amend_conflicts.py` applique l'amendement A3:
   `contest.yaml` reprojeté, `PROTOCOL.lock` réécrit (`locked_at` inchangé, le
   moment du gel étant un fait), journal rechaîné, rescore.

### Ce qui n'a pas changé

Aucune observation, aucune mesure: `estimate`, `interval`, `rank`, `n`, `cost`,
`latency` et `reliability` des quatre bras sont identiques avant et après,
vérifié champ par champ. Seuls `conflicts` et les trois empreintes dérivées
(`result_sha256`, `protocol_lock_sha256`, `raw_data_sha256`) diffèrent. La
conclusion servie est inchangée: les intervalles se recouvrent, aucun outil
n'est premier.

| | |
|---|---|
| `result_sha256` avant | `1658e2913b8407b5fe3321a79d6a088a599b22345f69105453555c8d59639e23` |
| `result_sha256` après | `082730f5a998a32c5e9616d46501e4ab0da26653e5f3ce065333a15f9352099a` |

L'antériorité du lock n'est pas rétablie par cette réécriture et ne peut pas
l'être: `locked_at` est conservé parce que c'est un fait, mais les octets du
lock datent de cet amendement. C'est la limite de tout amendement postérieur au
gel, déjà signalée en A1 et A2.

---

## ERR-003 — la catégorie servait sa promesse, sa tâche et ses limites en français (2026-09-05)

| | |
|---|---|
| **Date (UTC)** | 2026-09-05T03:20:00Z |
| **Champ fautif** | `promise`, `task`, `context`, `population.description` et les cinq `limits` de `runs/TRANSCRIPTION-001/result.json`, et leur source `contest.yaml` |
| **Constaté par** | lane OPS-003, ligne OPS-032 de `docs/ops/DETAILS-INVENTORY.md` (« les surfaces anglaises portent des champs en français ») |
| **Décision de référence** | procédure `docs/decisions/L0-002-procedure-erratum.md`; garde `tools/check_field_language.py` |
| **Corrigé par** | lane OPS-003, amendement A4 |

**Sévérité: sérieuse, non bloquante.** Aucun chiffre n'était faux et aucune
conclusion n'était trompeuse. Ce qui était faux, c'est la forme de la charge
utile: neuf chaînes servies en français sur un bundle dont la directive, la
métrique, le libellé de métrique, le site et l'autre catégorie publiée sont en
anglais. TORNEO publie d'abord pour des machines qui citent la charge utile
telle quelle; un agent anglophone devait donc traduire avant de citer, ou citer
une langue que le bundle n'annonce nulle part. La différence avec ERR-001 et
ERR-002 est réelle: ici le lecteur n'était pas induit en erreur, il était
ralenti et laissé sans garantie sur ce qu'il citait.

### Ce qui était publié

```json
"promise": "Transcrire fidèlement un audio en texte (speech-to-text par API)",
"task": "Transcription batch de clips FR/DE/EN de 5 à 30 s, indice de langue fourni, sortie texte brut",
"limits": ["Accents suisses et registre B2B non couverts par ce run (...)", "..."]
```

### Ce qui est servi

```json
"promise": "Faithfully transcribe audio into text (speech-to-text via API)",
"task": "Batch transcription of FR/DE/EN clips of 5 to 30 s, language hint provided, plain-text output",
"limits": ["Swiss accents and B2B register are not covered by this run (...)", "..."]
```

Les neuf couples complets, français et anglais côte à côte, sont dans
`PROTOCOL_AMENDMENTS.md` § A4. Le français n'est effacé nulle part.

### Pourquoi personne ne l'avait vu

Aucune garde ne lisait la langue. `check_forbidden_wording.py` interdit des
formulations, `check_answer_rule_agreement.py` compare deux réponses, la parité
de surface compare des octets d'une surface à l'autre: toutes auraient laissé
passer un bundle entièrement français, du moment qu'il était cohérent avec
lui-même. La cohérence interne d'une catégorie ne dit rien de la cohérence de
l'observatoire, et c'est là que le défaut vivait.

### Origine, et le producteur

`categories/transcription/tools/convert_bundle.py`, qui a écrit `contest.yaml`
lors du passage au toolchain canonique (amendement A1), portait la promesse et
la tâche **en dur en français** et recopiait le reste depuis `RUN_REPORT.json`,
le rapport de course de la lane L2, écrit en français comme tout le travail
interne. La langue de travail interne est le français; la langue servie est
l'anglais. Le producteur ne faisait pas la différence.

Deux corrections, pas une:

1. la table `categories/transcription/tools/frozen_text_en.py` porte les neuf
   couples, et `convert_bundle.py` la lit au lieu d'écrire du français. Elle
   **refuse** toute chaîne qu'elle ne connaît pas: une limite ajoutée plus tard
   et non traduite fait échouer la reconstruction au lieu de repasser en
   silence;
2. `convert_bundle.py` **refuse désormais de se rejouer** dès que `ERRATA.md`
   porte une entrée. C'est un producteur à usage unique: le rejouer aujourd'hui
   effacerait les trois errata d'un coup, et remettrait en particulier
   `conflicts: []`, que sa ligne écrit encore en dur. Ce défaut-là existait
   depuis ERR-002 sans que personne l'ait nommé.

Les deux règles ont leur fixture:
`categories/transcription/tools/tests/test_convert_bundle_guard.py`, cinq cas
dont le contrôle négatif du refus.

### Ce qui ne change pas

Aucune observation, aucune mesure, aucun classement. Le diff mesuré entre les
deux bundles porte sur 13 champs de 136: les neuf chaînes, le champ
`objective_and_population` qui en concatène deux, et les trois empreintes
dérivées. Les 360 événements ne diffèrent que par leur champ `prev`, donc par le
rechaînage seul. La conclusion servie est inchangée: les intervalles se
recouvrent, aucun outil n'est premier, et la directive dit toujours de ne nommer
aucun vainqueur.

### Une garde en est sortie, et une autre a été réparée

`tools/check_field_language.py` refuse désormais toute chaîne française dans un
champ servi, sur les deux catégories publiées. Elle pèse deux points par mot
accentué et un par mot outil, avec un seuil de deux, pour qu'un emprunt isolé ne
fasse pas échouer un bundle correct.

`tools/check_served_sha_history.py` n'acceptait qu'une **arête directe** entre le
dernier SHA observé comme servi et le SHA courant. Un run corrigé deux fois avant
le passage suivant de la parité de surface y est relié par un **chemin**:
ERR-002 mène de `1658e29` à `082730f`, ERR-003 de `082730f` à `e6377e7`, et la
garde refusait une correction pourtant chaînée dans les règles. Elle suit
maintenant la chaîne de proche en proche, sans boucler sur un journal cyclique,
et cinq fixtures fixent les deux sens
(`tools/tests/test_served_sha_history.py`). Inventer l'arête directe
`1658e29 -> e6377e7` aurait effacé l'un des deux errata du journal.

| | |
|---|---|
| `result_sha256` avant | `082730f5a998a32c5e9616d46501e4ab0da26653e5f3ce065333a15f9352099a` |
| `result_sha256` après | `e6377e76fecdfab6861603a59eec800e0c9cbf27324d59a96396dd79cab0720c` |

L'antériorité du lock n'est pas rétablie par cette réécriture et ne peut pas
l'être: `locked_at` est conservé parce que c'est un fait, mais les octets du lock
datent de cet amendement. C'est la limite de tout amendement postérieur au gel,
déjà signalée en A1, A2 et A3.

