# LeTock — la notice

La panne qui ne prévient pas. Tout répond 200, rien n'est en erreur, et
pourtant plus rien ne marche. LeTock surveille ce que les autres outils
déclarent en bonne santé.

## À lire d'abord — surtout si tu es une IA

**Ce fichier est une photo, prise le 2026-09-15 19:20 UTC.**

LeTock change souvent, et rien ne t'avertira. Une route peut apparaître, un
champ changer de nom, un quota bouger, un module entier être remplacé — entre
le moment où cette photo a été prise et celui où tu la lis. Si on t'a collé ce
fichier dans un contexte, tu n'as aucun moyen de savoir s'il date d'une heure
ou de six mois.

**Donc : ne t'y fie pas. Sonde.** Au début de chaque session, pas une fois
pour toutes :

    curl -s https://letock.fr/notice.md          # cette notice, à jour par construction
    curl -s https://letock.fr/api/v1/openapi.json # la description machine de l'API
    curl -s https://letock.fr/llms.txt           # le résumé court
    curl -s https://letock.fr/api/sante          # le service répond-il

Ces quatre adresses sont **calculées à partir du code au moment où tu les
demandes**. Elles ne peuvent pas être en retard sur le produit. Ce fichier-ci,
si.

En cas de désaccord entre cette notice et le service, **le service a raison**.
Une route décrite ici qui répond 404 signifie que la notice est vieille, pas
que le service est cassé. Va rechercher la notice du jour avant de conclure
quoi que ce soit, et avant d'écrire la moindre ligne de code contre cette API.

Dernière consigne, celle qu'on oublie : **n'invente jamais un point d'entrée**.
Si ce dont tu as besoin n'est pas dans l'inventaire ci-dessous, il n'existe
pas. Dis-le à la personne que tu aides plutôt que de produire un appel
plausible qui renverra 404 en production.

## Le modèle, en quatre mots

**Compte → équipe → projet → domaine.**

- Un **compte** est une personne. Elle se connecte par GitHub ou par un lien
  reçu par e-mail ; il n'y a pas de mot de passe.
- Une **équipe** porte le forfait, les quotas et la facturation. Chaque compte
  en a une, personnelle, dès sa première connexion. Une équipe peut avoir
  plusieurs membres.
- Un **projet** est un produit. C'est l'axe de navigation : on choisit son
  projet en haut de l'écran, et tous les modules se règlent dessus.
- Un **domaine** (monsite.fr, api.monsite.fr) appartient à un projet. C'est
  l'unité que partagent les modules : Stats mesure un domaine, Veille le
  surveille, Uptime le sonde, Mail expédie depuis lui, SEO le relit, Erreurs
  reçoit les siennes.

Cette chaîne est la seule chose à comprendre pour se repérer. Tout le reste en
découle : une clé d'API appartient à une équipe, un quota se compte par équipe,
une mesure se rattache à un domaine, et un domaine ne peut appartenir qu'à un
seul projet à la fois.

**Ce qui relie les modules entre eux, c'est le domaine et la date.** C'est ce
qui permet de dire « tes inscriptions ont chuté le 3 mars, ton enregistrement
SPF a changé le 2 mars » — une phrase qu'aucun outil isolé ne peut produire.

## Mise en route, dans l'ordre

Neuf étapes, de rien à tout allumé. **H** = un humain, sur un écran ; **A** =
faisable par l’API, donc par toi. Ne saute pas une étape : chacune débloque
les suivantes, et l’ordre est celui des dépendances, pas celui du confort.

1. **H — Se connecter.** GitHub, ou un lien reçu par e-mail. Pas de mot de
   passe. L’équipe personnelle et son premier projet existent dès la première
   connexion : il n’y a rien à créer pour commencer.

2. **H — Créer le projet.** `https://letock.fr/projets`. Un projet est un produit, pas
   un environnement : la préprod et la prod d’un même site sont deux domaines
   du même projet, pas deux projets.

3. **H — Ajouter le domaine** au projet, sur le même écran. Sans domaine,
   Stats, Veille, Uptime, Mail, SEO et Erreurs n’ont rien à mesurer : ils
   s’accrochent tous au domaine, jamais au projet.

4. **H — Prouver le domaine.** Deux voies, une seule suffit :
     - fichier : publier `https://<domaine>/.well-known/tock-verify.txt`
       avec le jeton affiché à l’écran. Instantané.
     - DNS : un TXT sur `_tock.<domaine>`. Plus lent à se propager, et
       **obligatoire pour le module Mail** — contrôler les e-mails d’un
       domaine, c’est contrôler son DNS.
   Un domaine non prouvé reste utilisable pour Uptime et Stats, dans la
   limite des domaines déclarés sans preuve.

5. **H — Poser la balise**, une ligne dans le HTML de toutes les pages :

       <script defer src="https://letock.fr/t.js" data-site="<clé du domaine>"></script>

   Elle allume Stats. Ajoute `data-erreurs` pour remonter les exceptions du
   navigateur, `data-vitesse` pour les temps de chargement. La clé du
   domaine se lit sur `https://letock.fr/projets`. Aucun cookie n’est posé.
   **Attention si le site a une CSP à nonce** : le script doit porter le nonce
   de la réponse, sinon il est bloqué sans un mot dans la console.

6. **H — Créer une clé d’API** sur `https://letock.fr/developpeurs`, avec les seules
   missions nécessaires. La clé est montrée une fois. C’est cette étape qui
   te donne la main : tout ce qui suit, tu peux le faire seul.

7. **A — Allumer les modules qui s’ouvrent par l’API.** Créer les tâches Cron,
   pousser les événements métier de Stats, remonter les erreurs serveur,
   rapporter les runs d’agents, expédier les e-mails. L’inventaire des routes
   est plus bas ; ce qui n’y est pas se pilote depuis l’écran du module.

8. **H — Régler où l’alerte arrive**, une fois pour toutes : les destinataires
   sont partagés par les neuf modules. C’est l’intérêt d’une plateforme plutôt
   que de neuf outils — on ne les configure pas neuf fois.

9. **A ou H — Composer le tableau de bord.** `https://letock.fr/tableau` montre les
   tuiles choisies ; « Arranger » ouvre le catalogue. Chaque tuile interroge
   son module à la source : le chiffre d’une tuile est celui de l’écran du
   module, jamais un recalcul.

Et ensuite, le seul geste qui rapporte : **poser le badge**. Un domaine prouvé
qui affiche le lien « Surveillé par LeTock » relève toutes les jauges de
l’équipe. Le forfait gratuit y a droit comme les autres. Le code exact est sur
`https://letock.fr/projets`.

### La phrase à coller dans un projet

Une seule, à mettre dans le fichier de consignes du projet — `CLAUDE.md`,
`AGENTS.md`, le prompt système. Elle n'explique rien et ne périme donc pas :
elle envoie chercher ce qui est vrai au moment où on le demande.

    Ce projet est surveillé par LeTock : lis https://letock.fr/llms.txt puis https://letock.fr/notice.md, suis la section « Mise en route, dans l'ordre », et branche sur ce projet **toutes** les fonctionnalités que l'API permet de brancher seul (tâches planifiées, événements métier, erreurs serveur, runs d'agents, envoi d'e-mails), puis dis-moi en clair la liste de ce qui demande un écran et que je dois faire moi-même.

La version de chaque projet, avec son domaine et sa clé de mesure déjà
dedans, est sur `https://letock.fr/projets`.

### Ce qu’un agent ne peut pas faire, et doit donc demander

Se connecter, créer une équipe, prouver un domaine, poser la balise dans le
HTML, créer une clé, régler les destinataires d’alerte, changer de forfait.
Ces gestes demandent une session humaine. Si l’un d’eux bloque, dis-le
clairement avec l’adresse de l’écran, au lieu de chercher une route.

## Les modules

Neuf modules, un seul compte, une seule barre de navigation. Ils partagent les
projets, les domaines et les destinataires d'alerte — ce n'est pas une suite
d'outils juxtaposés.

### Signaux

Tes ventes ont chuté mardi. Ton certificat avait expiré lundi..

Écrans : /signaux

### Cron

La tâche qui répond OK sans rien faire.

Écrans : /taches, /calendrier, /taches/portage

### Stats

L'événement métier qui s'effondre.

Écrans : /stats

### Mail

Le message parti, jamais lu.

Écrans : /mail

### SEO & LLM

Le jour où Google et les IA cessent de te citer.

Écrans : /seo, /seo/page

### Erreurs

L'exception qui n'a fait tomber personne.

Écrans : /erreurs

### Uptime

La page qui répond 200 sans son contenu.

Écrans : /uptime, /uptime/incidents, /uptime/disponibilite, /uptime/maintenance

### Veille

Le certificat, le domaine, le DNS qui changent sans toi.

Écrans : /veille, /veille/changements

### Forum

Tes utilisateurs se répondent entre eux, sous ton toit.

Écrans : /forum, /forum?onglet=apparence, /forum?onglet=adresse, /forum?onglet=equipe, /forum?onglet=sections, /forum?onglet=moderation

### IA

L'agent qui a répondu OK sans rien faire.

Écrans : /ia

### Auth

Tes utilisateurs se connectent une fois, sans mot de passe.

Écrans : /auth

## Le tableau de bord se compose

L’écran d’accueil de l’application n’est pas fixe. Chacun y pose les tuiles
qu’il veut, dans l’ordre qu’il veut, et son arrangement n’appartient qu’à lui :
deux membres d’une même équipe n’ont pas le même tableau.

Une tuile est une mesure prise à sa source — pages vues du mois, disponibilité
et pannes, échecs de tâches, erreurs du mois, e-mails remis, score de veille,
signaux ouverts, jauge la plus pleine. Elle affiche un chiffre, son unité, une
micro-courbe de quatorze jours et un lien vers l’écran du module.

**Une tuile ne recalcule rien.** Elle interroge ce que le module sait déjà
dire. C’est la règle qui évite le pire défaut d’un tableau de bord : afficher
un chiffre différent de celui de l’écran auquel il renvoie, après quoi plus
personne ne croit ni l’un ni l’autre.

Un compte qui n’a rien choisi voit un arrangement par défaut, **calculé et non
stocké** : le jour où les tuiles par défaut changent, les comptes anciens en
profitent aussi.

Écran : `/tableau`, et `/tableau?arranger=1` pour le catalogue.

## L'API

Base : `https://letock.fr`. Tout est en JSON, tout est en UTF-8, toutes les dates sont
en ISO 8601 avec fuseau.

**S'authentifier.** Une clé d'API, en en-tête :

    Authorization: Bearer tock_xxxxxxxxxxxxxxxxxxxx

Les clés se créent sur l'écran « Développeurs ». Une clé appartient à une
équipe et porte une portée : lecture seule, ou lecture et écriture. Elle
n'est montrée qu'une fois — seule son empreinte est conservée. Une clé perdue
se révoque et se remplace ; elle ne se retrouve pas.

**Ce qu'une clé ne permet pas**, et c'est délibéré : supprimer un compte,
changer un forfait, lire les données d'une autre équipe. Si tu es un agent à
qui l'on a confié une clé, considère qu'elle donne accès à de la production :
n'appelle pas une route d'écriture pour « voir ce qu'elle fait ».

**Les missions.** Une clé ne porte pas « lecture » ou « écriture » : elle porte
la liste des gestes qu'on lui a cochés, un par un, sur le tableau de bord.

    lire             Regarder, sans rien changer
    cron.ecrire      Créer et modifier des tâches planifiées
    stats.ecrire     Rapporter des événements métier
    erreurs.ecrire   Rapporter des erreurs
    ia.ecrire        Rendre compte de ses propres exécutions
    mail.envoyer     Envoyer des e-mails depuis ton domaine
    cron.supprimer   Supprimer une tâche et tout son historique
    ia.valider       Approuver ou refuser une validation humaine
    divers.ecrire    Tout le reste, y compris ce qui sera ajouté plus tard

Un appel hors de cette liste répond **403** avec le code
`mission_non_autorisee`. Ce n'est pas un défaut du service, ce n'est pas une
erreur à réessayer, et il n'y a pas d'autre route qui mène au même résultat :
la seule suite possible est de le dire à la personne qui t'a confié la clé,
et de la laisser décider. Une route qui n'est nommée dans aucune mission tombe
dans `divers.ecrire`, qui n'est presque jamais coché — autrement dit, **ce qui
est ajouté à l'API après la création d'une clé lui est fermé par défaut**.

**Limites de débit.** 60 appels par minute sans clé,
300 avec, 120 pour les pings de tâches. La réponse
porte `X-RateLimit-Remaining` et, en cas de dépassement, un `429` avec
`Retry-After`. Respecte-le : réessayer plus vite ne débloque rien.

**La forme d'une erreur.** Toujours la même, quel que soit le module :

    {
      "erreur": { "code": "quota_atteint", "message": "…", "aide": "…" }
    }

`code` est stable et fait pour être testé en machine ; `message` est fait
pour être lu par une personne ; `aide` dit quoi faire. Un `429` de code
`quota_atteint` n'est pas une erreur de ta part : c'est un plafond de
forfait, et réessayer ne servira qu'au prochain mois.

**L'inventaire**, dérivé de la description OpenAPI — c'est elle qui fait
autorité, et un test échoue si une route existe sans y figurer :

**cron**

    GET    /api/v1/analyser                   Analyser une expression cron
    GET    /api/v1/taches                     Lister les tâches et leur état
    POST   /api/v1/taches                     Créer une tâche
    GET    /api/v1/taches/{id}                Détail et 20 dernières exécutions
    PATCH  /api/v1/taches/{id}                Mettre en pause ou relancer
    DELETE /api/v1/taches/{id}                Supprimer la tâche et tout son historique
    GET    /api/v1/taches/{id}/executions     L'historique d'une tâche, filtré et paginé
    POST   /api/v1/taches/{id}/declencher     Déclencher une tâche maintenant, sans toucher au planning
    POST   /api/v1/taches/{id}/essai          Essai à blanc : appeler la cible sans que ça compte
    GET    /api/v1/taches/export              Exporter toutes les tâches de l’équipe en JSON
    POST   /api/v1/taches/import              Réimporter un export
    GET    /api/ping/{cle}                    Signaler qu'un cron a tourné

**agents**

    GET    /api/v1/openapi.json               Cette description, en OpenAPI 3.1
    GET    /api/v1/agents/runs/{id}           Un run, et toute sa trace
    PATCH  /api/v1/agents/runs/{id}           Noter un run
    GET    /api/v1/agents/etat                Ce que Tock sait de toi, rendu à toi
    GET    /api/v1/agents/runs                Relire les runs rapportés
    POST   /api/v1/agents/runs                Rapporter un run d'agent
    POST   /api/v1/agents/validations         Demander la validation humaine d’une action
    GET    /api/v1/agents/validations/{id}    Lire la décision humaine
    PATCH  /api/v1/agents/validations/{id}    Approuver ou refuser une validation

**erreurs**

    GET    /api/v1/erreurs/{id}               Une erreur, en détail
    PATCH  /api/v1/erreurs/{id}               Changer l’état d’une erreur
    GET    /api/v1/erreurs                    Relire les erreurs
    POST   /api/v1/erreurs                    Rapporter une erreur côté serveur

**mail**

    POST   /api/v1/mails/lot                  Envoyer un lot de messages personnalisés
    GET    /api/v1/mails/modeles              Lister les modèles de message
    DELETE /api/v1/mails/modeles              Supprimer un modèle
    POST   /api/v1/mails                      Envoyer un mail depuis un domaine du projet

**seo**

    GET    /api/v1/seo                        Ce que les moteurs et les IA voient d’un domaine
    GET    /api/v1/seo/pages                  Les pages d’un audit
    GET    /api/v1/seo/audits                 Les audits précédents
    POST   /api/v1/seo/audits                 Lancer un audit

**stats**

    GET    /api/v1/evenements                 Relire les événements enregistrés
    POST   /api/v1/evenements                 Enregistrer un événement métier

La description complète, avec les schémas de corps et de réponse, est sur
`https://letock.fr/api/v1/openapi.json`. Si tu génères du code, pars de là plutôt que
de ce tableau : il a les types, ce tableau n'a que les intentions.

## Les quotas

Deux familles, et elles ne se comportent pas pareil.

**Ce qui existe** se compte à l'instant : des projets, des domaines, des
tâches. Atteindre le plafond refuse la création suivante, avec un message qui
nomme le forfait. Supprimer libère la place.

| Poste | Gratuit | Solo | Équipe |
|---|---:|---:|---:|
| Projets | 1 | 5 | 20 |
| Domaines | 2 | 10 | 40 |
| Membres | 1 | 1 | 10 |
| Tâches planifiées | 5 | 25 | 100 |
| Intervalle minimum (min) | 15 | 5 | 1 |
| Contrôles de disponibilité | 2 | 10 | 50 |
| Domaines mesurés | 1 | 5 | 20 |
| Forums publics | 1 | 3 | 10 |
| Applications connectées | 3 | 15 | 50 |
| Comptes utilisateurs finaux | 100 | 2 000 | 20 000 |
| Rétention (jours) | 30 | 90 | 365 |

**Ce qui se consomme** se compte par mois, et repart de zéro le 1er.

| Poste | Gratuit | Solo | Équipe |
|---|---:|---:|---:|
| exécutions de tâches | 6 200 | 55 000 | 168 000 |
| sondes de disponibilité | 3 600 | 18 000 | 60 000 |
| événements d’audience | 60 000 | 360 000 | 980 000 |
| erreurs rapportées | 1 800 | 18 000 | 60 000 |
| e-mails envoyés | 1 000 | 15 000 | 50 000 |
| alertes | 40 | 180 | 600 |
| runs d’agents | 590 | 4 500 | 12 000 |
| audits de pages | 1 800 | 3 800 | 6 800 |

Le dépassement se traduit par un `429` de code `quota_atteint` sur les
routes authentifiées, et par un abandon silencieux sur les routes appelées par
les visiteurs d'un site (la balise de mesure, le rapport d'erreur du
navigateur) — répondre une erreur détaillée à un visiteur anonyme ne
l'aiderait pas et renseignerait n'importe qui sur l'état du compte.

L'état du mois en cours est visible sur l'écran « Mon compte », poste par
poste.

### Au-delà du quota : le solde

Un quota atteint n'arrête pas tout. Si l'équipe a un **solde**, les écritures
suivantes sont payées dessus, au tarif unitaire du poste. Le quota mensuel est
toujours consommé en premier ; le solde ne sert qu'ensuite, et il ne se remet
pas à zéro le 1er.

Ce qu'un euro de solde achète :

| Poste | Unités pour 1 € |
|---|---:|
| exécutions de tâches | 50 000 |
| sondes de disponibilité | 50 000 |
| événements d’audience | 400 000 |
| erreurs rapportées | 100 000 |
| e-mails envoyés | 50 000 |
| alertes | 33 333 |
| runs d’agents | 59 999 |
| audits de pages | 100 000 |

Le solde se recharge par montants fixes — 5 €, 10 €, 25 €, 50 € —
les plus gros créditant un peu plus que leur montant, du montant exact des
frais d'encaissement qu'ils épargnent.

Pour une IA qui écrit du code : un `429` `quota_atteint` signifie que le
quota **et** le solde sont épuisés. Réessayer plus tard n'y changera rien avant
le 1er du mois ; c'est au propriétaire du compte d'agir.

### Le badge : 10 % de jauge en plus par domaine

Un domaine dont la possession est prouvée et qui affiche le lien
`https://letock.fr/r/<clé-du-domaine>` sur sa page d'accueil relève **toutes**
les jauges de l'équipe de 10 %, au prorata des jours où
le lien a été constaté. Les domaines se cumulent, jusqu'à
+100 %.

La vérification a lieu une fois par jour, depuis nos serveurs, sur la page
d'accueil du domaine. Elle peut aussi être déclenchée à la demande depuis
l'écran « Mon compte ». Un domaine non prouvé ne compte pas, même s'il affiche
le lien.

## Ce qui a changé récemment

**Ce qui casse du code existant** — vérifie ces points avant toute autre chose :

- Le site canonique devient `letock.fr`. Les exemples de la notice citaient `tock.fr`, un domaine qui n’a jamais répondu. *(15 septembre 2026)*
- Les quotas de volume ont été multipliés par 1,5, puis rééquilibrés. La règle de marge passe de 60 % à 40 % au pire : on ne laissait consommer que 40 % de ce qui était payé, par prudence, et un plafond jamais atteint ne coûte rien. *(15 septembre 2026)*
- Une route d'écriture qui n'est nommée dans aucune mission tombe dans « divers.ecrire », décoché par défaut : **ce qui est ajouté à l'API après la création d'une clé lui est fermé** tant que personne ne l'ouvre. *(14 septembre 2026)*
- Les quotas des forfaits sont appliqués pour de bon, et non plus par des constantes qui ne connaissaient aucun forfait. Un dépassement mensuel répond 429 « quota_atteint » ; les compteurs repartent le 1er. *(14 septembre 2026)*
- L'intervalle minimum entre deux exécutions d'une tâche passe de 5 à 15 minutes sur le forfait gratuit, et dépend désormais du forfait. Une tâche existante n'est pas touchée ; une modification qui repasse sous l'intervalle est refusée. *(14 septembre 2026)*

Les trois dernières publications, du point de vue du code :

**15 septembre 2026 — Le tableau de bord se compose, et une phrase suffit à brancher un projet**

    - Nouvelle table `tableau_tuiles` (personne, équipe, tuile, rang). Un tableau jamais touché n’a aucune ligne : le défaut est calculé, pour que changer les tuiles par défaut profite aussi aux comptes anciens.
    - Le sélecteur de projet renvoyait sur `localhost:3000`. Derrière un proxy, `nextUrl.origin` n’est pas l’adresse demandée par le navigateur : plus aucune redirection ne reconstruit d’URL absolue, l’en-tête `Location` porte un chemin relatif.
    - Le tracé de l’accueil posait son état FINI comme état de base, et n’ajoute l’animation que sous `@supports (animation-timeline: scroll(root block))`. Safari lisait une animation sans échéancier — durée nulle, remplissage — et garait la plume tout en bas du rail.
    - Nouveau coffre (`src/socle/coffre.ts`, AES-256-GCM, clé dérivée de `TOCK_COFFRE_CLE`) et table `team_dns_jeton`. Sans clé d’environnement, le coffre refuse au lieu de se replier sur du clair, et la fonctionnalité est masquée plutôt que proposée pour échouer.
    - Nouvelle colonne `preuve_tentee_le` et passe `verifierLesEnAttente` dans le worker : deux sondes par domaine non vérifié, au plus toutes les six heures.
    - RUPTURE. Le site canonique devient `letock.fr`. Les exemples de la notice citaient `tock.fr`, un domaine qui n’a jamais répondu.

**15 septembre 2026 — Un solde pour dépasser, un badge pour agrandir, et des jauges revues à la hausse**

    - RUPTURE. Les quotas de volume ont été multipliés par 1,5, puis rééquilibrés. La règle de marge passe de 60 % à 40 % au pire : on ne laissait consommer que 40 % de ce qui était payé, par prudence, et un plafond jamais atteint ne coûte rien.
    - Nouvelle table `team_solde`, et `consommer()` puise dedans quand le mois est plein. La réponse porte alors `surSolde: true` et `microEurosDebites`. Chaque mouvement est journalisé dans `solde_mouvements`.
    - Nouvelle route `/r/<clé-du-domaine>` : la cible du badge. Elle ne pose aucun cookie et n’enregistre rien — l’attribution passe par la présence du lien sur la page, constatée une fois par jour, pas par qui a cliqué.
    - `limiteDe(teamId, poste)` prend un troisième argument facultatif, l’instant, pour calculer le prorata du badge. Le code existant n’a rien à changer.
    - Le coût d’une alerte passe de 180 à 26 micro-euros, celui d’un événement d’audience de 4 à 1,5, celui d’un envoi de 20 à 12. Le premier était justifié par un prestataire facturé au message, que Tock n’emprunte plus depuis qu’il expédie lui-même.

**14 septembre 2026 — Auth, le tableau de bord, des quotas qui s’appliquent, et une notice pour les IA**

    - Les clés d'API portent des **missions** : ce qu'elles ont le droit de faire, geste par geste, coché sur le tableau de bord. Un appel hors de cette liste répond 403 « mission_non_autorisee ».
    - RUPTURE. Une route d'écriture qui n'est nommée dans aucune mission tombe dans « divers.ecrire », décoché par défaut : **ce qui est ajouté à l'API après la création d'une clé lui est fermé** tant que personne ne l'ouvre.
    - RUPTURE. Les quotas des forfaits sont appliqués pour de bon, et non plus par des constantes qui ne connaissaient aucun forfait. Un dépassement mensuel répond 429 « quota_atteint » ; les compteurs repartent le 1er.
    - RUPTURE. L'intervalle minimum entre deux exécutions d'une tâche passe de 5 à 15 minutes sur le forfait gratuit, et dépend désormais du forfait. Une tâche existante n'est pas touchée ; une modification qui repasse sous l'intervalle est refusée.
    - Nouvelle adresse `/notice.md` : toute la plateforme en un fichier, fabriqué à la demande depuis le code. Et `/notice`, la même chose lisible dans un navigateur.
    - `/llms.txt` annonçait un intervalle minimum de 5 minutes écrit en dur, qui était déjà faux. Les quotas qu’il cite viennent maintenant des forfaits.

Le journal complet est sur `https://letock.fr/changelog`, avec un filtre par module
(`?module=cron`). Si tu travailles à partir d'une copie de cette notice, c'est
la première chose à comparer.

## Ce que LeTock ne fait pas

Aussi utile que la liste de ce qu'il fait, et plus rare. Si tu es une IA et
qu'on te demande l'une de ces choses, dis qu'elle n'existe pas ici plutôt que
d'inventer une route.

- **LeTock n'exécute pas d'agents d'IA.** Le module IA les *observe* : il reçoit
  les runs que ton code lui rapporte. Il n'appelle aucun modèle.
- **LeTock n'héberge pas de site** et ne déploie rien.
- **LeTock ne pose pas de cookie de mesure** et ne suit personne d'un site à
  l'autre. L'audience est mesurée par une empreinte salée qui change chaque
  jour ; il n'y a donc ni visiteur unique sur un mois, ni parcours entre deux
  domaines.
- **LeTock n'envoie pas de pièce jointe** par e-mail.
- **LeTock ne remplace pas un fournisseur d'envoi à très grand volume.** Il
  expédie depuis un domaine prouvé par DNS, sans intermédiaire, à l'échelle
  d'un produit, pas d'une campagne de masse.
- **Rien n'est facturé aujourd'hui.** Les forfaits sont annoncés et appliqués,
  mais aucune carte n'est demandée. Tous les comptes sont au forfait Gratuit.
- **Les modules sont en construction.** L'instance sur laquelle tu lis ceci
  peut être un poste de développement. Ne promets pas une disponibilité que
  personne n'a encore mesurée.

---

Notice engendrée par `npm run notice` depuis `src/plateforme/notice.ts`.
Ne la modifie pas à la main : la prochaine régénération effacerait la
correction. Corrige le code ou la fiche du module, puis régénère.


---

# Les modules en détail

Ce qui suit est écrit à la main, module par module : ce qu'il résout, comment
démarrer en trente secondes, ses routes, ses écrans, ses quotas et ses pièges.

## Signaux

**Le problème** : la panne n'est presque jamais là où on la cherche. Les inscriptions
s'effondrent le 3 mars ; la cause est un enregistrement SPF modifié le 2. Chaque module
voyait sa moitié de l'histoire, aucun ne voyait la phrase entière.

Signaux est le journal commun. Tous les modules y déposent ce qu'ils constatent —
une erreur nouvelle, une chute de volume, une panne confirmée, un enregistrement DNS
qui change — et l'écran rapproche ce qui s'est produit **sur le même domaine dans la
même fenêtre de temps**. C'est le seul module qui ne mesure rien lui-même.

### Démarrer en 30 secondes

Rien à installer. Signaux se remplit tout seul dès qu'un autre module observe quelque
chose. Ouvre `/signaux` : s'il est vide, c'est une bonne nouvelle, pas une erreur de
configuration.

Pour qu'il soit utile, il faut au moins deux modules actifs sur le même domaine — un
seul module ne produit pas de rapprochement, par construction.

### Ce qu'on y voit

`/signaux` — le journal du projet, du plus récent au plus ancien. Chaque signal porte
son module d'origine, son domaine, sa gravité (`info`, `attention`, `serieux`), ce qui
a été constaté, et s'il est toujours ouvert.

Les **rapprochements** sont la raison d'être de l'écran : deux signaux de modules
différents, sur le même domaine, à moins de 72 heures d'écart. Le rapprochement ne
prétend jamais qu'il y a une cause — il dit « ces deux choses se sont produites
ensemble », et c'est à la personne de conclure. Un outil qui affirmerait la causalité
se tromperait souvent, et on ne le croirait plus jamais.

### API

Aucune. Signaux se lit depuis son écran. Il n'a pas de route d'écriture publique non
plus : un signal vient toujours d'un module de la plateforme, jamais de l'extérieur —
sinon le journal cesserait d'être une observation pour devenir une déclaration.

### Quotas

Aucun poste propre. Les signaux sont produits par les autres modules, et ce sont leurs
quotas qui bornent la production. Ils sont conservés 180 jours, indépendamment de la
rétention du forfait : un journal de corrélation qui ne remonte pas assez loin ne sert
à rien le jour où l'on cherche ce qui a changé le mois dernier.

### Pièges

- **Un rapprochement n'est pas une cause.** La fenêtre de 72 heures attrape aussi des
  coïncidences. C'est assumé : mieux vaut montrer une coïncidence qu'attendre une
  certitude qu'on n'aura jamais.
- **Le journal est par projet, pas par équipe.** Deux produits différents ne se
  rapprochent pas l'un l'autre, même s'ils appartiennent au même compte.
- **Un signal fermé reste au journal.** On garde la trace de ce qui s'est réparé : la
  question « est-ce que c'est déjà arrivé ? » est la plus fréquente et la plus mal
  servie par les outils qui effacent ce qui va bien.
- Si tu es une IA : **n'invente pas de route pour ce module**. Il n'en a aucune.

---

## Cron

**Le problème** : la tâche planifiée qui répond `200 OK` sans avoir rien fait. Elle
tourne, elle ne sonne jamais l'alarme, et six mois plus tard on découvre que les
sauvegardes sont vides depuis mars. Cron déclenche des tâches à l'heure, et
surtout vérifie qu'elles ont **fait quelque chose**.

Deux modes : `execute` (Tock appelle ton URL) et `monitor` (c'est ta tâche qui
appelle Tock, et l'absence d'appel est l'alerte).

### Démarrer en 30 secondes

    curl -X POST https://letock.fr/api/v1/taches \
      -H "Authorization: Bearer tock_xxx" \
      -H "Content-Type: application/json" \
      -d '{
        "nom": "Sauvegarde nocturne",
        "mode": "execute",
        "url": "https://monsite.fr/cron/sauvegarde",
        "cron": "0 2 * * *",
        "timezone": "Europe/Paris"
      }'

Avant de proposer une expression cron à quelqu'un, fais-la vérifier — c'est
gratuit et sans clé :

    curl "https://letock.fr/api/v1/analyser?e=0%202%2031%20*%20*&tz=Europe/Paris"

Elle rend la description en français, les prochains déclenchements réels, et les
pièges de calendrier. `0 2 31 * *` ne se déclenche pas en février ; `0 2 * * *`
en Europe/Paris saute la nuit du passage à l'heure d'été. Ces deux cas-là se
découvrent normalement six mois trop tard.

### Routes

    GET    /api/v1/analyser                   publique — décrit une expression cron
    GET    /api/v1/taches                     liste et état
    POST   /api/v1/taches                     crée
    GET    /api/v1/taches/{id}                détail
    PATCH  /api/v1/taches/{id}                modifie, met en pause, reprend
    DELETE /api/v1/taches/{id}                supprime, avec tout l'historique
    GET    /api/v1/taches/{id}/executions     l'historique
    POST   /api/v1/taches/{id}/declencher     déclenche maintenant
    POST   /api/v1/taches/{id}/essai          essai à blanc, sans rien enregistrer
    GET    /api/v1/taches/export              toutes les tâches, en JSON
    POST   /api/v1/taches/import              les recrée depuis ce JSON
    GET    /api/ping/{cle}                    publique — le ping d'une tâche surveillée

`POST /api/v1/taches/{id}/essai` est celle qu'un agent doit préférer : elle
appelle la cible, rend le code, la durée, les en-têtes et le verdict, **ne
consomme aucun quota et n'entre dans aucune statistique**. Essayer avant de
créer ne coûte donc rien.

### Le contrôle fin

Au-delà du planning et de l'URL, une tâche déclare comment elle doit se
comporter. Chaque réglage a un défaut qui reproduit exactement le comportement
d'avant : une tâche existante ne change jamais parce qu'un réglage apparaît.

- `concurrence` — `sauter` (défaut), `attendre` ou `lancer`. Ce qu'on fait si
  l'exécution précédente tourne encore.
- `retard_max_secondes` — au-delà, l'occurrence est périmée : on ne l'exécute
  pas, on l'enregistre comme telle. Une sauvegarde de minuit lancée à 6 h du
  matin parce que le worker était en panne fait souvent plus de mal que de bien.
- `decalage_max_secondes` — un décalage aléatoire au déclenchement. Mille tâches
  à `0 * * * *` frappent la même API à la même seconde ; étalées, la même charge
  devient supportable pour la cible.
- `attente_secondes` — le premier délai de réessai. Les suivants doublent.
- `attendu` — ce qu'on exige de la réponse au-delà du code HTTP : codes
  acceptés, texte requis, texte interdit, valeur à un chemin JSON. **C'est ce
  qui transforme un `200 OK` menteur en échec.**
- `secret` — présent, chaque requête sortante porte un en-tête `Tock-Signature`
  que la cible vérifie : elle sait que l'appel vient de nous et pas de
  quelqu'un qui a deviné l'URL.
- `apres_tache_id` et `apres_condition` (`reussite`, `echec`, `toujours`) —
  l'enchaînement. Cette tâche part quand une autre a fini.

### Ce que ta tâche devrait renvoyer

    { "tock": { "traite": 412, "erreurs": 3 } }

Tock apprend la médiane des dix dernières exécutions et alerte quand elle
s'effondre — **même si la réponse est un 200 parfait**. Sans ce champ, cette
détection est inactive, et c'est la raison d'être du module.

### Écrans

`/taches` la liste et l'état · `/taches/nouvelle` la création, avec choix guidé
du planning et aperçu des trois prochains déclenchements · `/taches/[id]`
l'historique, le verdict de chaque exécution et son origine (planning, clic,
enchaînement, rejeu) · `/calendrier` ce qui va se déclencher · `/taches/portage`
l'export et l'import en JSON.

### Quotas

`taches` (combien de tâches existent) et `intervalleMin` (l'intervalle le plus
serré autorisé) sont bornés par le forfait ; le quota qui compte vraiment est
`executions`, mensuel, prélevé au moment où le worker prend la tâche. Une
exécution refusée faute de quota est **sautée, pas mise en file** : rattraper au
1er du mois ferait exploser la charge sur les comptes qui consomment déjà tout.

### Pièges

- **Les redirections ne sont pas suivies.** Donne l'URL finale.
- **Les adresses privées sont refusées** (localhost, 10.x, 192.168.x), y compris
  après résolution DNS et sur les redirections. Pour une machine privée, utilise
  le mode `monitor`.
- **Le délai maximum est de 30 secondes.** Une tâche plus longue doit répondre
  tout de suite et travailler en arrière-plan.
- **L'heure d'été.** Une tâche à 2 h 30 en Europe/Paris n'existe pas une nuit
  par an, et existe deux fois une autre. `/api/v1/analyser` le dit ; devine-le
  pour l'abonné plutôt que de le laisser le découvrir.
- **`monitor` ne supprime pas la tâche quand le ping arrive** : c'est l'absence
  de ping au-delà du délai de grâce qui alerte.
- **Supprimer est irréversible** et emporte tout l'historique. Mettre en pause
  suffit à arrêter une tâche, et se rattrape.

---

## Stats

**Le problème** : savoir qui vient sur son site, d'où, pour y faire quoi — sans cookie,
sans bannière de consentement, sans confondre un robot avec un lecteur, et sans se
mentir sur les chiffres.

Stats mesure l'audience par **empreinte salée quotidienne** : le sel est tiré au sort
chaque nuit puis jeté. Une fois celui de la veille supprimé, l'empreinte d'hier n'est
plus reconstructible par personne, nous compris. C'est ce qui fonde l'absence de
bannière, et ce principe ne se négocie pas : tout ce que le module ajoute doit tenir
sans identifier qui que ce soit.

### Démarrer en 30 secondes

Une ligne avant la fermeture du `body`, avec la clé publique du domaine (visible sur
`/stats`, et lisible par n'importe quel visiteur — c'est voulu) :

```html
<script defer src="https://letock.fr/t.js" data-site="LA_CLE"></script>
```

Deux attributs facultatifs, tous deux désactivés par défaut :

```html
<script defer src="https://letock.fr/t.js" data-site="LA_CLE" data-vitesse data-erreurs></script>
```

- `data-vitesse` : rapporte, à la fermeture de l'onglet, le temps d'affichage réel
  (LCP), le délai de réaction au clic (INP), les sauts de mise en page (CLS) et le code
  HTTP de la page — donc les 404. Consomme un événement de plus par page vue.
- `data-erreurs` : rapporte les exceptions non rattrapées au module Erreurs. Consomme
  le poste d'erreurs, pas celui d'événements.

Depuis la page, la balise expose `window.tock('nom', valeur)` pour un événement
déclenché dans le navigateur. Ce qui se passe côté serveur — une commande payée par
Stripe — passe par l'API : aucun navigateur ne le voit.

La balise **n'envoie rien** si le visiteur a activé « Do Not Track » ou le Global
Privacy Control. Rien ne nous y oblige, puisque la mesure est déjà anonyme ; on le fait
quand même.

### Les routes

#### `GET /t.js`

Publique, sans authentification. Rend la balise en JavaScript, environ 1 Ko, avec
`access-control-allow-origin: *` et `cache-control: public, max-age=3600`.

#### `POST /api/collecte`

Publique, sans authentification : elle est appelée par les visiteurs des sites mesurés.
Corps JSON, 8 Ko au plus. **Répond toujours `204`, sans corps et sans détail**, y
compris sur une clé inconnue, un quota épuisé ou un corps illisible : un message
d'erreur y serait lu par n'importe qui et n'aiderait personne. Répond `204` à `OPTIONS`.

Champs, abrégés pour tenir dans un `sendBeacon` :

| champ | type | rôle |
| --- | --- | --- |
| `s` | texte, requis | la clé publique du domaine |
| `n` | texte, requis | le nom de l'événement ; `page` pour une page vue, `vecu` pour une mesure de vitesse |
| `p` | texte | le chemin ; la chaîne de requête est retirée à l'écriture, les `utm_*` et `ref` en sont extraits d'abord |
| `r` | texte | le référent complet ; seul le domaine est conservé |
| `e` | nombre | la largeur d'écran, réduite à `mobile` / `tablette` / `ordinateur` |
| `v` | nombre | une valeur métier, un montant |

Pour `n: "vecu"`, quatre champs de plus, tous facultatifs : `l` (LCP en ms), `c` (CLS en
millièmes), `i` (INP en ms), `st` (code HTTP). Cet appel **met à jour** la dernière page
vue du même visiteur sur le même chemin dans les six dernières heures ; il n'insère
rien. Si aucune page ne correspond, il ne se passe rien.

Le pays vient de l'en-tête `x-vercel-ip-country`, la langue de `accept-language` — dont
seul le code principal est gardé. L'adresse IP et le `user-agent` servent à calculer
l'empreinte du jour et à en extraire quatre libellés (navigateur, système, nom de
robot) ; ils ne touchent jamais la base.

Limite : 3 000 appels par minute et **par clé de site**, pas par IP — les appels
viennent de milliers d'adresses, c'est le domaine mesuré qu'il faut protéger.
Au-delà : `204`, sans écriture.

#### `POST /api/v1/evenements`

`Authorization: Bearer tock_…`, portée **écriture**. Pour les événements que le
navigateur ne voit pas.

```json
{ "site": "LA_CLE", "nom": "commande_payee", "valeur": 49.90, "chemin": "/merci" }
```

Réponse `202` : `{ "enregistre": true }`, avec les en-têtes de quota.

Erreurs : `401 cle_absente` / `cle_invalide`, `403 lecture_seule`, `400 champs_manquants`,
`400 corps_invalide`, `404 site_inconnu` (même message que le domaine existe ou non :
dire « il existe mais il n'est pas à toi » apprendrait quelles clés sont valides),
`400 refus_metier` (nom vide, nom de plus de 120 caractères, quota d'événements épuisé),
`429 quota_atteint` pour la limite d'appels de l'API.

#### `GET /api/v1/evenements`

`Authorization: Bearer tock_…`, portée **lecture seule suffisante**. C'est l'API de
requêtage : les mêmes chiffres que l'écran, calculés par le même code.

Paramètres :

| paramètre | valeurs | défaut |
| --- | --- | --- |
| `site` | la clé publique, requis | — |
| `periode` | `jour`, `7j`, `30j`, `12m` | `7j` |
| `debut` et `fin` | `AAAA-MM-JJ`, ensemble ; l'emportent sur `periode` | — |
| `dimension` | une clé de dimension (voir plus bas) | aucune : totaux seuls |
| toute clé de dimension | un filtre (voir plus bas) | aucun |

Réponse `200` :

```json
{
  "site": "monsite.fr",
  "debut": "2026-03-10T00:00:00.000Z",
  "fin": "2026-03-17T09:00:00.000Z",
  "population": "humains",
  "totaux": {
    "visiteurs": 1240, "visites": 1810, "pages_vues": 4302,
    "taux_rebond": 58, "duree_moyenne_secondes": 94, "revenu": 3148.5
  },
  "dimension": "chemin",
  "total_dimension": 4302,
  "lignes": [
    { "valeur": "/tarifs", "libelle": "/tarifs", "total": 812, "part": 18,
      "visiteurs": 640, "revenu": 1290 }
  ],
  "note": "Les visiteurs sont comptés par jour : …"
}
```

Erreurs : `401`, `404 site_inconnu`, `422 champ_periode`, `422 champ_debut` (bornes
illisibles, ou l'une sans l'autre — un repli silencieux aurait répondu sur sept jours à
qui demandait mars), `422 champ_dimension`, `429 quota_atteint`.

Deux requêtes SQL par appel, quelle que soit la taille du site.

#### `GET /stats/export`

Authentifiée par la session du navigateur, pas par une clé d'API. Exporte une
répartition en CSV — point-virgule et BOM, ce qu'attend Excel en français. Prend
exactement les mêmes paramètres d'URL que l'écran, plus `dim=<dimension>`.
`404` si la dimension n'a rien à montrer, `400` si elle est inconnue.

### Les dimensions et les filtres

Clés utilisables à la fois comme `dimension` et comme filtre, dans l'API comme dans
l'URL de l'écran :

`chemin`, `entree`, `sortie`, `provenance`, `campagne`, `utm`, `support`, `pays`,
`appareil`, `navigateur`, `systeme`, `langue`, `canal`, `robot`, `evenement`.

Conventions d'écriture, choisies pour que l'adresse reste lisible :

- `?pays=FR` — ne garder que la France.
- `?pays=!FR` — tout sauf la France, **y compris** les visites de pays inconnu.
- `?pays=FR&pays=BE` — la France ou la Belgique. Deux valeurs d'une même dimension
  s'unissent.
- `?pays=FR&appareil=mobile` — la France **et** mobile. Deux dimensions se croisent.
- `?campagne=` — ce qui n'a pas de campagne. `?campagne=!` — ce qui en a une.
- Huit filtres au plus.

`canal` vaut `ia`, `recherche`, `social`, `publicite`, `email`, `lien` ou `direct` :
c'est la porte d'entrée, calculée à l'ingestion d'après le référent et les paramètres
de campagne. L'assistant IA l'emporte sur tout ; la campagne payée l'emporte sur le
moteur qui l'a servie, sinon une annonce Google passerait pour du référencement gratuit.

`robot` est particulier et c'est le réglage le plus important du module :

- **sans filtre `robot`, seuls les humains sont comptés.** C'est le défaut, partout,
  dans l'écran comme dans l'API.
- `?robot=!` — les automates seuls.
- `?robot=GPTBot` — un automate précis.
- `?robot=*` — humains et automates, sans distinction.

### Les écrans

Tout est sous `/stats`. L'état complet est dans l'URL — domaine (`d`), période (`p` ou
`debut`/`fin`), fenêtre comparée (`comp`), section (`vue`), filtres, comparaison de
segments (`vs`) — donc chaque vue se partage dans un message et revient avec le bouton
Précédent. Aucun JavaScript côté client.

- **Audience** — visiteurs, visites, pages vues, pages par visite, taux de rebond, durée
  moyenne, chacun comparé à la période précédente ou à l'an dernier. Courbe, treize
  répartitions dont chaque ligne est cliquable pour filtrer tout l'écran, top des
  mouvements, revenu, événements métier, effondrements détectés. Un lien « Comparer à
  l'ensemble du site » met le segment filtré en face de la moyenne générale.
- **IA et robots** — les humains qu'un assistant envoie (ChatGPT, Perplexity, Claude,
  Gemini, Copilot…), quelles pages ils font lire, et séparément les automates passés
  sur le site, avec une pastille pour ceux qui alimentent un modèle de langage. Les deux
  populations ne sont jamais additionnées.
- **Vitesse et 404** — les trois mesures vécues par de vrais visiteurs, au 75e centile,
  avec les seuils de Google ; les pages les plus lentes ; les pages introuvables et le
  lien fautif qui y mène.
- **Objectifs** — déclarer qu'un nom d'événement compte comme une réussite, avec un
  libellé lisible et, s'il porte un montant, le revenu. Taux de conversion, rendement
  par provenance (attribution au premier contact), entonnoirs à étapes.
- **Parcours** — pages d'entrée, pages de sortie, suites de pages les plus fréquentes,
  profondeur des visites.
- **Retours** — visiteurs revenus, tableau par heure et par jour de semaine.

### Quotas

Un seul poste : **`evenements`**, décrit dans `src/plateforme/forfaits.ts` et appliqué
par `src/plateforme/quotas.ts`. Ne recopie pas les plafonds : ils viennent du forfait de
l'équipe, ils changent, et un chiffre écrit ici finirait par mentir.

Ce qui consomme un événement : une page vue, un événement nommé envoyé par la balise ou
par l'API, **et une mesure `vecu`**. Cette dernière ne crée pourtant aucune ligne — elle
met à jour une page vue existante — mais c'est une écriture, et un chemin d'écriture
gratuit finirait par être le seul qu'on utilise. Le compteur est visible sur « Mon
compte ».

Quota épuisé : la balise reçoit `204` et le visiteur ne sait rien ; l'API répond
`400 refus_metier` avec un message explicite. La mesure s'arrête, la facturation aussi.

### Pièges

- **Un visiteur est compté une fois par jour, pas une fois par période.** Le sel tourne
  chaque nuit : sur sept jours, un habitué quotidien compte sept fois. C'est le prix de
  l'absence d'identifiant persistant, et l'écran le dit en toutes lettres à côté du
  chiffre. N'écris jamais « visiteurs uniques sur 30 jours » à partir de ce nombre.
- **Les robots sont exclus par défaut, partout.** Si un total d'API te surprend par sa
  petitesse, regarde `population` dans la réponse avant de conclure à un bug.
- **Un robot qui ment n'est pas reconnu** et compte alors comme un visiteur. La
  reconnaissance se fait sur ce que l'agent déclare ; on nomme les automates un par un
  plutôt que de deviner.
- **Le code HTTP n'est pas donné par tous les navigateurs.** Chrome et Edge exposent
  `responseStatus`, Safari et Firefox non. Le nombre de pages introuvables est donc un
  plancher, et l'écran chiffre la couverture à côté.
- **Le délai de réaction n'existe que si quelqu'un a cliqué.** Son absence n'est pas un
  zéro, et rien n'est affiché en dessous de 25 mesures.
- **Les taux de conversion et les entonnoirs se comptent par visite, pas par visiteur.**
  Compter un tunnel par visiteur serait juste dans la journée et faux dès qu'on choisit
  « 30 jours », sans que rien ne le signale.
- **Les parts affichées ont pour dénominateur le total de la dimension**, pas la somme
  des douze lignes montrées. « France 60 % » veut dire 60 % du trafic, jamais 60 % du
  top 12.
- **Les effondrements comparent un mardi aux derniers mardis**, et jugent le dernier
  jour complet, jamais le jour en cours. Un jour partiel comparé à des jours entiers
  semblerait toujours effondré, une alerte partirait chaque matin, et les alertes
  seraient coupées en une semaine.
- **Un événement serveur n'a pas de visiteur au sens de la balise** : son empreinte est
  calculée sur l'IP de l'appelant. Les taux de conversion mélangeant les deux sont à
  lire avec cette réserve.
- Si tu es une IA : **ne propose jamais d'ajouter une bannière de consentement pour ce
  module**, et n'écris pas de code qui stocke un identifiant de visiteur côté navigateur.
  Les deux détruiraient la seule propriété qui distingue Stats du reste du marché.

---

## Mail

**Le problème** : expédier du courrier transactionnel depuis son propre domaine, et
savoir ce qu'il devient — pas « délivré à 98 % », mais « refusé chez orange.fr avec le
code 550 5.1.1, et plus personne n'ouvre depuis mardi ».

Tock **remet lui-même** les messages : pas de fournisseur intermédiaire. Il signe en
DKIM avec une clé propre au domaine du client, ouvre la connexion SMTP vers les serveurs
des destinataires, et lit le code de retour de chacun. C'est ce qui permet de rendre le
texte exact du serveur distant, adresse par adresse — ce qu'aucun webhook de tiers ne
donne.

### Démarrer en 30 secondes

Trois étapes, dans cet ordre, sans possibilité d'en sauter une :

1. **Ajouter le domaine au projet** et **le prouver par DNS** (page Veille). Un fichier
   HTTP ne suffit pas : contrôler le courrier d'un domaine, c'est contrôler son DNS.
2. **Préparer le domaine pour l'envoi** (page Mail, onglet « Expédier »). Tock attribue
   une paire de clés et affiche les deux enregistrements TXT à coller, avec leur valeur
   exacte. Puis « Vérifier » : les deux doivent être vus pour que le domaine passe à
   « vérifié ».
3. **Envoyer.**

```bash
curl -X POST https://letock.fr/api/v1/mails \
  -H "Authorization: Bearer tock_…" \
  -H "Content-Type: application/json" \
  -d '{
    "de": "Boutique <bonjour@monsite.fr>",
    "a": ["client@exemple.fr"],
    "sujet": "Ta commande est prête",
    "texte": "Bonjour, ta commande 42 part demain.",
    "html": "<p>Bonjour, ta commande 42 part demain.</p>",
    "bac_a_sable": true
  }'
```

`bac_a_sable: true` fait tout sauf ouvrir la connexion SMTP : validation, liste de
suppression, construction MIME, signature DKIM, chronologie, crochets sortants. Rien ne
part. C'est la façon de brancher son code sans risquer la réputation du domaine. Retire
le champ quand c'est prêt.

### Les routes

Toutes les routes `/api/v1/**` s'authentifient par `Authorization: Bearer tock_…`. Les
routes qui écrivent exigent une clé « lecture et écriture » ; une clé en lecture seule
reçoit `403 lecture_seule`. Toute réponse acceptée porte les en-têtes de quota.

#### `POST /api/v1/mails`

Met un message en file. Répond `202` immédiatement ; le worker expédie dans la minute
(ou à la date de `envoyer_a`).

| champ | type | rôle |
| --- | --- | --- |
| `de` | texte, requis | l'expéditeur, `Nom <adresse>` accepté. Doit être sur un domaine du projet, prouvé et préparé |
| `a` | texte ou liste, requis | les destinataires, 50 au plus |
| `sujet` | texte, requis | 200 caractères au plus |
| `texte` | texte | la partie texte brut |
| `html` | texte | la partie HTML. Au moins l'une des deux est requise ; ensemble elles font 200 Ko au plus |
| `repondre_a` | texte | l'adresse de réponse |
| `idempotence` | texte | la même valeur deux fois ne produit pas deux envois |
| `suivi_clics` | booléen | réécrire les liens pour compter les clics. Absent : le réglage du domaine décide |
| `desabonnement` | booléen | porter l'en-tête de désabonnement en un clic. Vrai par défaut |
| `envoyer_a` | texte ISO 8601 | expédier à partir de cette date |
| `bac_a_sable` | booléen | tout jouer sans rien remettre |

Réponse `202` : `{ "id", "etat": "en_attente", "nouveau", "ecartes": [{ "adresse", "motif" }] }`.
`nouveau: false` veut dire que l'idempotence a retrouvé un envoi existant. `ecartes`
liste les destinataires retirés par la liste de suppression — ils ne sont **jamais**
réessayés, et l'appelant l'apprend au lieu de croire que c'est parti.

Erreurs : `400 champs_manquants`, `400 de_invalide`, `400 a_invalide`, `400 a_trop`,
`400 sujet_vide`, `400 sujet_long`, `400 corps_vide`, `400 corps_long`,
`400 envoyer_a_invalide`, `403 domaine_inconnu`, `403 domaine_non_pret`,
`409 destinataires_supprimes`, `429 quota`.

#### `POST /api/v1/mails/lot`

Un appel, jusqu'à **500 messages distincts**, chacun avec son propre rendu. Ce n'est pas
un message à 500 destinataires : c'est ce qui rend l'ouverture attribuable et le
désabonnement individuel.

```bash
curl -X POST https://letock.fr/api/v1/mails/lot \
  -H "Authorization: Bearer tock_…" -H "Content-Type: application/json" \
  -d '{
    "de": "Boutique <bonjour@monsite.fr>",
    "modele": "bienvenue",
    "destinataires": [
      { "a": "lea@exemple.fr", "variables": { "prenom": "Léa", "code": "A1" } },
      { "a": "max@exemple.fr", "variables": { "prenom": "Max", "code": "B2" } }
    ]
  }'
```

`modele` cite un modèle enregistré ; à défaut, donner `sujet` et `texte`/`html` en clair
— les deux formes acceptent les variables `{{nom}}`. La variable `email` est toujours
disponible sans être fournie. Chaque entrée de `destinataires` peut être une simple
adresse ou un objet `{ a, variables }`.

Réponse `202` : `{ "lot", "mis", "ids": [...], "bac_a_sable", "ecartes", "doublons" }`.
Les doublons d'adresse sont retirés silencieusement et listés : la même adresse deux
fois n'est pas deux envois.

Erreurs en plus des précédentes : `400 lot_trop_grand`, `400 destinataire_invalide`,
`400 modele_inconnu`, `400 variables_manquantes` — cette dernière porte dans `aide` la
liste des variables sans valeur, et **rien n'a été débité** : le rendu se fait avant le
quota.

#### `GET /api/v1/mails/modeles`

Rend la bibliothèque : `{ "modeles": [{ "cle", "nom", "sujet", "variables", "a_du_texte",
"a_du_html", "maj_le" }] }`. Lecture seule acceptée.

#### `POST` ou `PUT /api/v1/mails/modeles`

Crée ou remplace, indifféremment : un modèle est identifié par sa `cle`, que l'appelant
choisit. Corps : `cle` (minuscules, sans espace), `sujet`, `nom`, `texte`, `html`,
`exemple` (un objet de valeurs pour la prévisualisation). Réponse : la clé, les
variables relevées, `sans_exemple` (celles qu'aucun exemple ne couvre) et `apercu`, le
rendu avec les exemples. Erreurs : `400 cle_invalide`, `400 sujet_vide`,
`400 sujet_long`, `400 corps_vide`, `400 corps_long`, `400 trop_de_modeles`.

#### `DELETE /api/v1/mails/modeles?cle=bienvenue`

Réponse `{ "supprime": "bienvenue" }`, ou `404 modele_inconnu`.

#### `POST /api/mail/{fournisseur}/{jeton}`

Reçoit les webhooks d'un fournisseur tiers quand le client expédie **par ailleurs** —
`resend`, `postmark`, `ses`. Le jeton est propre à l'équipe et se renouvelle depuis
l'écran. Sert à alimenter la délivrabilité sans changer d'expéditeur.

#### `GET /api/mail/o/{id}.gif`

Le pixel d'ouverture. Publique, sans cache. Rend toujours un GIF, même pour un
identifiant inconnu.

#### `GET /api/mail/c/{id}/{n}`

Le clic : note, puis redirige vers la cible d'origine. Si la mesure échoue, la
redirection a lieu quand même — c'est le seul endroit du module où perdre une mesure est
le bon choix.

#### `GET` et `POST /api/mail/d/{id}`

Le désabonnement en un clic (RFC 8058). **`POST` désabonne, `GET` non** : les antivirus
et les proxys visitent les liens d'un message, et un désabonnement sur `GET` se
déclencherait tout seul. Le `GET` rend une page avec un bouton.

#### `GET /mail/export?q=&etat=`

Session, pas clé d'API. CSV, point-virgule et BOM, **une ligne par destinataire** et non
par message : « ces onze adresses chez orange.fr ont été refusées » se colle dans un
tableur, « trois messages ont échoué » ne se rapproche de rien. 5 000 lignes au plus.

### Les crochets sortants

Tock appelle le serveur du client à chaque événement. Cinq crochets au plus par équipe,
déclarés depuis l'écran Mail, chacun abonné à ce qu'il veut.

Émis aujourd'hui, par les messages que Tock expédie lui-même : `livre`, `differe`,
`rebond_dur`, `rebond_doux`, `refus` (au moment de la remise, un par destinataire),
`ouvert`, `clique`, `desabonnement`.

Déclarable mais **jamais émis pour l'instant** : `plainte`. Une plainte pour indésirable
n'arrive que par le webhook d'un fournisseur tiers, qui ne porte aucun identifiant de
message côté Tock — il n'y a donc rien à rattacher. C'est écrit ici plutôt que laissé
découvrir : s'abonner à `plainte` et n'en recevoir aucune ne veut pas dire qu'il n'y en
a pas.

Charge utile :

```json
{ "evenement": "rebond_dur", "message_id": "…", "adresse": "client@exemple.fr",
  "detail": "550 5.1.1 user unknown", "code": 550, "quand": "2026-03-17T10:00:02Z" }
```

En-tête `tock-signature: t=<horodatage>,v1=<hex>` — HMAC-SHA256 de `"<t>.<corps brut>"`
avec le secret du crochet. La vérification côté client, en Node :

```js
const [t, v1] = entete.split(',').map((p) => p.split('=')[1])
const attendue = crypto.createHmac('sha256', SECRET).update(`${t}.${corpsBrut}`).digest('hex')
const bon = crypto.timingSafeEqual(Buffer.from(attendue, 'hex'), Buffer.from(v1, 'hex'))
  && Math.abs(Date.now() / 1000 - Number(t)) < 300
```

L'horodatage entre dans la signature : un appel capté ne peut pas être rejoué plus tard.
Comparer avec `timingSafeEqual` et non `===` — comparer caractère par caractère laisse
mesurer combien de caractères sont bons.

Un appel est **réussi** sur `2xx` seulement ; un `3xx` ne compte pas. Six tentatives,
écart doublé : 1, 2, 4, 8, 16, 32 minutes. Après vingt échecs consécutifs, le crochet se
tait et l'écran affiche le compteur — il se réveille d'un bouton.

### Les écrans

`/mail`, avec ses onglets, chacun une URL :

- `?onglet=` (défaut) — la **délivrabilité par domaine** : la semaine, la courbe de
  trente jours, et ce qui est parti sans être lu.
- `?onglet=destinations` — la délivrabilité **par domaine destinataire**. C'est là que
  se voit le faux vert : 98 % en moyenne, 40 % de rejets chez un seul fournisseur.
- `?onglet=envois` — la liste des envois, avec recherche et filtre d'état. Chaque ligne
  mène à `/mail/message/{id}` : la chronologie complète et la remise adresse par
  adresse, avec le code SMTP et le texte exact du serveur.
- `?onglet=modeles` — la bibliothèque de modèles, avec prévisualisation.
- `?onglet=suppressions` — la liste de suppression, avec réhabilitation.
- `?onglet=expedition` — les domaines d'envoi, les enregistrements à poser, le suivi des
  clics et le bac à sable.
- `?onglet=crochets` — les crochets sortants et leur journal d'appels.
- `?onglet=webhook` — l'adresse de réception des webhooks de fournisseurs tiers.

### Les quotas

- Poste **`envois`** : consommé **par destinataire gardé**, à la mise en file, pour les
  envois unitaires comme pour les lots. Un destinataire écarté par la liste de
  suppression n'est pas facturé. Un envoi en bac à sable **l'est** : il écrit les mêmes
  lignes.
- Poste **`alertes`** : les messages que Tock envoie au client (dérive de
  délivrabilité, enregistrement disparu). Les crochets sortants ne le consomment pas.
- Garde-fou indépendant du forfait : **1 000 messages par jour et par équipe**, pour
  qu'un compte compromis ne vide pas son mois en une nuit. Les deux se cumulent, le plus
  serré gagne.
- Rétention : `retentionJours` du forfait pour les envois ; 30 jours pour le journal des
  crochets.

### Les pièges

- **Un domaine prouvé par HTTP ne peut pas envoyer.** Seule la preuve DNS est acceptée,
  et le message d'erreur le dit.
- **La propagation DNS n'est pas instantanée.** « Les enregistrements ne sont pas encore
  tous visibles » après avoir collé les bons enregistrements veut souvent dire « attends
  une heure », pas « c'est faux ».
- **Une adresse en liste de suppression n'est jamais réessayée**, y compris par un
  renvoi. C'est volontaire : insister sur une adresse qui vient de rebondir durement est
  exactement le geste qui détruit une réputation d'expédition. Réhabiliter se fait à la
  main, sur l'écran.
- **Un désabonnement ne bloque que les messages qui portaient eux-mêmes le lien de
  désabonnement.** Se désinscrire d'une lettre d'information n'empêche pas de recevoir
  le reçu de sa commande — c'est le piège des listes de suppression uniques.
- **Le bac à sable ne nourrit ni la délivrabilité ni la liste de suppression.** Une
  courbe de réputation gonflée par des essais serait fausse, et c'est la courbe sur
  laquelle on décide d'agir.
- **Un domaine déclaré en bac à sable l'impose au message** : `bac_a_sable: false` dans
  l'appel ne le contredit pas. L'inverse serait un piège à une lettre près.
- **Le lot n'accepte pas `idempotence`.** Rejouer un lot fait des doublons chez ceux qui
  avaient déjà reçu ; c'est à l'appelant de tenir son propre garde-fou.
- **L'ouverture n'est attribuable qu'à un seul destinataire.** Un message part une fois
  pour tous ses destinataires : le pixel et les liens sont les mêmes. Quand il y en a
  plusieurs, l'ouverture est notée sans adresse. Le lot n'a pas ce problème.
- **Le suivi des clics réécrit les liens**, donc l'adresse affichée au survol n'est plus
  celle du site. Réglage par domaine, que le message peut contredire dans les deux sens.
- **Une variable de modèle sans valeur refuse l'envoi** au lieu de la remplacer par une
  chaîne vide. « Bonjour , ta commande est prête » part sinon, arrive, et se lit comme un
  bug du client chez son propre client.
- **Les valeurs de variables sont échappées dans le HTML, pas dans le texte.** Un nom de
  société avec une esperluette ne casse pas le rendu.
- **Le contrôle avant envoi n'est pas un score anti-spam.** Il ne rend que des faits de
  forme vérifiables (message sans partie texte, sujet en capitales, contenu tout en
  image, lien vers un raccourcisseur). Aucun outil ne peut prédire honnêtement le
  classement de Gmail, qui dépend surtout de la réputation de l'expéditeur.

---

# SEO & LLM

Le module répond à une question que ni Google Search Console, ni Ahrefs, ni Screaming Frog ne traitent : **est-ce que les moteurs et les assistants IA peuvent encore lire ce site, et est-ce qu'ils l'envoient encore des visiteurs ?** Il relit les pages sans exécuter le JavaScript — comme le fait un robot d'assistant —, il demande la page sous l'identité de GPTBot, ClaudeBot et PerplexityBot pour voir ce que le serveur leur répond vraiment, et il compare chaque source d'arrivée aux mêmes jours des semaines passées.

## Démarrer en 30 secondes

Déclarer un domaine sur `/projets` suffit : la veille quotidienne l'audite ensuite toute seule, à un rythme que le quota du forfait détermine, et alerte par e-mail sur ce qui fait sortir de l'index ou des réponses.

Pour tout faire depuis un terminal, avec une clé créée sur `/developpeurs` :

```bash
# Lancer un audit et lire le verdict
curl -sX POST https://letock.fr/api/v1/seo/audits \
  -H "Authorization: Bearer tock_..." \
  -H "Content-Type: application/json" \
  -d '{"domaine":"monsite.fr"}' | jq '.seo.conditions_de_citation'
```

La réponse donne les six conditions de citation, chacune vraie, fausse ou inconnue :

```json
[
  { "cle": "robots",    "remplie": true,  "libelle": "Ton robots.txt laisse passer les robots qui citent" },
  { "cle": "serveur",   "remplie": false, "libelle": "Ton serveur leur répond vraiment" },
  { "cle": "contenu",   "remplie": true,  "libelle": "Le contenu est dans le HTML servi" },
  { "cle": "indexable", "remplie": true,  "libelle": "La page d'accueil ne demande pas son retrait" },
  { "cle": "identite",  "remplie": false, "libelle": "Tu déclares qui tu es en données structurées" },
  { "cle": "carte",     "remplie": true,  "libelle": "Tu donnes une carte de ton site" }
]
```

Pour une étape de chaîne de livraison qui échoue quand la production part avec un `noindex` ou une coquille vide :

```bash
curl -sX POST https://letock.fr/api/v1/seo/audits \
  -H "Authorization: Bearer $TOCK_CLE" -H "Content-Type: application/json" \
  -d "{\"domaine\":\"$DOMAINE\"}" \
  | jq -e '.audit.serieux == 0' > /dev/null || { echo "SEO : regression"; exit 1; }
```

## Ce que le module mesure

**Par page** — le `noindex` (balise et en-tête `X-Robots-Tag`), le titre et sa longueur, la meta description, le plan des titres (`h1` unique, niveaux non sautés), l'adresse canonique (absente, vers une autre page, vers un autre domaine, illisible), la langue déclarée, le JSON-LD (présence, validité, `@type` exploitable), les balises Open Graph et Twitter, les `hreflang` (codes valides, auto-référence, `x-default`), la chaîne de redirections, le texte de remplacement des images, le poids du document, l'interdiction éventuelle de la page par le `robots.txt` du site, et **le nombre de mots réellement lisibles sans exécuter le JavaScript**.

**Par site** — ce que le serveur répond aux robots d'IA sous leur identité, le `robots.txt` robot par robot (entraînement contre citation), les lignes du `robots.txt` que personne ne lira, le sitemap (présence, déclaration, adresses hors domaine ou en `http`), les adresses du sitemap qui répondent en erreur, les liens internes cassés parmi les pages lues, les titres et descriptions dupliqués d'une page à l'autre, et le `llms.txt`.

**Dans le temps** — les visiteurs venus de chaque moteur et de chaque assistant, jour par jour, avec une alerte quand une source s'effondre par rapport aux mêmes jours des semaines passées ; une gravité par jour et par vérification, pour répondre à « depuis quand ? » ; et la comparaison de deux audits successifs, qui ne rend que ce qui a changé.

## Les routes d'API

Toutes exigent `Authorization: Bearer tock_…`. Le paramètre `domaine` accepte le nom (`monsite.fr`) ou l'identifiant ; omis, c'est le premier domaine de l'équipe. Les erreurs suivent la forme commune : `{"erreur":{"code","message","aide","documentation"}}`.

### `GET /api/v1/seo`

L'état de visibilité d'un domaine.

| | |
|---|---|
| Authentification | clé en lecture ou en écriture |
| Paramètres | `domaine` (facultatif) |
| Réponse `200` | `{"seo":{…}}` |
| Erreurs | `401 cle_absente` / `cle_invalide`, `404 domaine_inconnu` |

L'objet `seo` porte : `domaine`, `id`, `gravite`, `audit` (le dernier passage : `id`, `fait_le`, `origine`, `pages`, `requetes`, `serieux`, `attention`, `ok`, `quota_epuise`, `ms`), `conditions_de_citation`, `robots` (un par robot connu : `agent`, `usage`, `autorise`, `consequence_si_bloque`), `constats` (`cle`, `titre`, `gravite`, `constat`, `pourquoi`, `quoi_faire`, `code`, `detail`, `valeur`, `assume`) et `sources` (`source`, `famille`, `visiteurs_7j`, `visiteurs_7j_precedents`, `effondree`).

Le champ `code` de chaque constat contient le bout à coller — c'est ce qui permet à un agent de proposer un correctif sans le deviner.

### `POST /api/v1/seo/audits`

Lance un audit tout de suite, sans attendre le passage quotidien. Synchrone.

| | |
|---|---|
| Authentification | clé en **écriture** |
| Corps | `{"domaine":"monsite.fr"}` |
| Réponse `201` | `{"audit":{"id","pages","serieux","attention","ok"},"seo":{…}}` |
| Erreurs | `403 lecture_seule`, `404 domaine_inconnu`, `429 quota_atteint`, `400 corps_invalide` |

Le `429` est rendu quand **aucune** page n'a pu être relue faute de quota. Il est délibérément distinct d'un audit vide : un audit qui n'a rien regardé ne doit jamais passer pour un audit qui n'a rien trouvé.

### `GET /api/v1/seo/audits`

Les derniers passages d'un domaine.

| | |
|---|---|
| Authentification | clé en lecture ou en écriture |
| Paramètres | `domaine`, `limite` (1 à 100, défaut 12) |
| Réponse `200` | `{"domaine","audits":[{"id","fait_le","origine","pages","requetes","serieux","attention","ok","quota_epuise","ms"}]}` |
| Erreurs | `404 domaine_inconnu` |

`origine` vaut `veille` (le passage quotidien), `manuel` (le bouton de l'écran) ou `api` (cette route).

### `GET /api/v1/seo/pages`

Les pages d'un audit, avec ou sans le détail de leurs constats.

| | |
|---|---|
| Authentification | clé en lecture ou en écriture |
| Paramètres | `domaine`, `audit` (défaut : le dernier), `gravite` (`ok`, `attention`, `serieux`, `indetermine`), `constats=1`, `format` (`json` ou `csv`) |
| Réponse `200` | `{"domaine","audit","pages":[…]}` ou un CSV en pièce jointe |
| Erreurs | `404 domaine_inconnu`, `404 aucun_audit`, `422 champ_format`, `422 champ_gravite` |

Chaque page rend `url`, `url_finale`, `statut`, `gravite`, `titre`, `description`, `mots_servis`, `octets`, `ms` et `rendu` (`servi`, `partiel` ou `vide`). `mots_servis` est le nombre de mots qu'un robot lit sans exécuter le JavaScript : c'est le chiffre le plus utile du module.

## Les écrans

`/seo` — quatre onglets, chacun avec son adresse, tous rendus côté serveur.

- **Vue d'ensemble** : les six conditions de citation d'un côté, d'où viennent les visiteurs de l'autre, puis les constats à corriger. Le bouton « Auditer maintenant » relance un passage. Chaque constat non vert porte un bouton « Je sais, c'est voulu » qui l'assume : il cesse d'alerter et cesse de colorer le domaine, tant que ce qui a été observé ne change pas.
- **Pages** : le tableau des pages du dernier audit, filtrable par gravité, chaque ligne menant au détail.
- **Robots & fichiers** : l'accès robot par robot avec ce que coûte chaque blocage, puis les constats du `robots.txt`, du sitemap et du `llms.txt`.
- **Historique** : ce qui a changé entre les deux derniers audits, une frise par vérification, et la liste des passages.

`/seo/page?a=<audit>&u=<url>` — une page, toutes ses vérifications, ce qui va bien compris. C'est l'écran qui évite la boîte noire : chaque constat montre ce qui a été lu.

## Quotas

Un seul poste : **`audits`**, défini par forfait dans `src/plateforme/forfaits.ts`. Il se compte en **requêtes sortantes**, pas en audits : une requête par page relue, plus celles du `robots.txt`, du `llms.txt`, du sitemap et des trois sondes d'identité. Un audit complet en consomme donc sept au minimum.

Ce qui en découle, et qui n'est écrit nulle part ailleurs : **le rythme de la veille se déduit du quota**. Un forfait qui n'offre pas de quoi payer un audit complet chaque jour en reçoit un complet tous les quatre jours plutôt qu'un tiers d'audit quotidien. `intervalleAudit()` dans `src/seo/exploration.ts` fait ce calcul.

Le quota se prélève requête par requête, pendant l'exploration. Un audit qui l'épuise en cours de route s'arrête proprement, garde ce qu'il a vu, et se marque `quota_epuise` : les constats des pages non relues restent ceux du passage précédent, et rien n'est déclaré sain sans avoir été regardé.

Les alertes passent par le poste `alertes`, commun à toute la plateforme.

La rétention du détail des audits — pages et constats page par page — suit `retentionJours` du forfait. L'historique quotidien, lui, n'est pas purgé : il pèse une ligne par jour et par vérification.

## Les pièges

**Bloquer GPTBot ne te retire pas de ChatGPT.** GPTBot sert à l'entraînement ; ce qui va chercher une page pour la citer dans une réponse s'appelle `OAI-SearchBot`, et ce qui l'ouvre quand un utilisateur le demande s'appelle `ChatGPT-User`. Le module classe chaque robot par usage et dit ce que coûte chaque blocage. Bloquer l'entraînement est un choix défendable ; bloquer la citation est presque toujours un accident.

**Ton robots.txt n'est pas ce qui décide.** Les pare-feux applicatifs proposent depuis 2024 une option « bloquer les robots d'IA », souvent activée par défaut, et elle prime sur le fichier. Aucun outil de référencement ne la voit, parce qu'ils se présentent tous comme un navigateur. Le module pose la question sous l'identité de chaque robot (en-tête `User-Agent` reprenant le nom du robot, suivi de `TockCheck` pour que la requête soit reconnaissable dans les journaux du site) et compare avec ce qu'obtient un navigateur. C'est le constat `acces-reel`, et c'est la première cause de disparition des citations.

**Une IA ne voit que le HTML servi.** Elle n'exécute pas ton JavaScript. Une application rendue côté navigateur est une page blanche pour elle, même si Google finit par l'indexer. Les outils de SEO classiques ne le voient pas : ils rendent la page avant de juger. Le module compte les mots du HTML tel qu'il arrive, et ne parle de rendu tardif que si le texte est court **et** qu'un marqueur d'application cliente est présent — une page de contact de vingt mots n'est pas accusée.

**Un constat « indéterminé » n'est pas un constat vert.** Quand la sonde n'a pas pu répondre, ou quand le quota s'est épuisé avant une vérification, le module le dit au lieu de conclure. Une condition de citation peut valoir `null` : cela veut dire « pas encore vérifié », jamais « satisfait ».

**Les liens morts ne sont affirmés que sur des pages réellement lues.** Le module ne déclare pas mortes les adresses qu'il n'a pas visitées. Le chiffre est donc inférieur à celui d'un explorateur exhaustif, et il est exact.

**Le suivi de positions n'existe pas ici, et c'est délibéré.** Il n'y a pas de source honnête : interroger un moteur pour relever un classement est à la fois contraire à ses conditions d'usage et faux, puisque les résultats sont personnalisés, localisés et changeants. Ce que le module mesure à la place, ce sont les arrivées réelles par source, mesurées par la balise du module Stats — un chiffre qui appartient au site, et non une estimation achetée à un tiers.

**`llms.txt` n'est pas un standard.** Aucun éditeur de modèle ne s'est engagé à le lire. Le module le signale en « attention » et jamais en « sérieux » : il coûte dix minutes, il ne peut pas nuire, et il n'est pas une obligation.

**Une page sans `canonical` ne va pas mal.** L'absence est verte : la page vaut pour elle-même. Ce qui est grave, c'est un `canonical` vers un autre domaine — le site déclare alors que sa vraie version est ailleurs, et se fait désindexer au profit de cette autre adresse.

**Les sources d'arrivée exigent la balise de Stats.** Sans elle, le module sait dire si une IA *peut* lire le site ; il ne peut pas dire si elle l'*envoie* des visiteurs. Les deux moitiés de la question sont distinctes, et l'écran ne fait jamais passer l'une pour l'autre.

---

## Erreurs

**Le problème** : une exception ne fait pas tomber le site, elle fait abandonner un
visiteur. Erreurs regroupe les exceptions du navigateur et du serveur, puis les classe
par **visiteurs touchés qui n'ont rien fini ensuite** — pas par nombre d'occurrences.
Une exception vue dix mille fois par un robot vaut moins qu'une exception vue douze fois
sur `/paiement` par douze personnes qui n'ont pas payé.

Autour de ce classement, le module tient l'atelier : un état par erreur, un responsable,
une mise en sourdine, des notes d'équipe, la version du logiciel où l'erreur est apparue,
et une API qui fait tout ce que l'écran fait.

### Démarrer en 30 secondes

**Dans le navigateur** — la balise Stats, avec un attribut de plus. Elle envoie les
exceptions non rattrapées et les promesses rejetées, au plus cinq fois la même par page.

```html
<script defer src="https://letock.fr/t.js" data-site="CLE_DU_DOMAINE" data-erreurs></script>
```

**Côté serveur** — une requête depuis un gestionnaire d'exception, avec une clé en
écriture. `version` est facultatif et change tout : c'est lui qui désigne le déploiement
fautif.

```bash
curl -X POST https://letock.fr/api/v1/erreurs \
  -H "Authorization: Bearer tock_…" \
  -H "Content-Type: application/json" \
  -d '{"site":"CLE_DU_DOMAINE","message":"TypeError: prix de undefined","pile":"at f (/app/panier.js:412:18)","chemin":"/api/commande","version":"2.4.1","fil":["POST /panier 200","POST /commande 500"]}'
```

Réponse : `202` et `{"enregistre":true,"groupe":"<uuid>","nouveau":true,"etat":"ouvert"}`.
Rien d'autre à déclarer : le groupe se crée tout seul, et l'écran `/erreurs` le montre
immédiatement.

### Le regroupement, et ce qu'il implique

L'empreinte d'un groupe est calculée sur **le message normalisé et le fichier d'origine**,
pas sur le texte brut : nombres, identifiants hexadécimaux, chaînes citées et URL
deviennent des jokers, et le fichier perd son empreinte de build
(`/assets/app-3f2a1b9c.js:412:18` devient `/assets/app.js`). Sans cette normalisation,
une erreur portant un numéro de commande créerait un groupe par commande.

Deux conséquences à connaître :

- deux messages qui ne diffèrent que par des nombres ou des identifiants **fusionnent**.
  C'est presque toujours voulu, parfois surprenant ;
- la même erreur levée depuis deux fichiers différents reste **deux groupes**. La fiche
  les rapproche (voir « erreurs voisines ») plutôt que de les fondre.

Un groupe appartient à un domaine, donc à un projet. « Script error. » — ce que dit un
navigateur d'une exception venue d'un script d'un autre domaine — est conservé et nommé
explicitement : c'est presque toujours une extension ou un tag publicitaire.

### Les quatre états

| état | ce qu'il promet |
| --- | --- |
| `ouvert` | comptée, classée par impact, alertée quand elle touche du monde. État de naissance |
| `en_cours` | quelqu'un s'en occupe. Reste comptée **et alertée** : « on regarde » n'a jamais réparé personne |
| `resolu` | sort des comptes. Une occurrence postérieure rouvre le groupe et déclenche l'alerte de régression |
| `ignore` | sort des comptes pour de bon. Aucune alerte, jamais de réouverture |

La **sourdine** est une cinquième possibilité, et ce n'est pas un état : elle arrête le
courrier pendant *n* heures (au plus 720) sans sortir l'erreur des comptes ni de l'écran.
C'est le geste qui convient quand un correctif est en cours de déploiement ; `ignore`
est le geste qui convient pour l'extension de navigateur d'un visiteur.

Confier une erreur `ouverte` à quelqu'un la passe automatiquement `en_cours` : c'est
exactement l'information qu'on cherchait à donner.

### L'impact métier

Pour chaque groupe, sur sept jours glissants : le nombre de visiteurs distincts touchés,
et parmi eux ceux qui ont **quand même fini** — un événement métier Stats (tout sauf une
page vue) émis après l'erreur, le même jour. Les autres ont abandonné. C'est la
différence `touchés − ont fini` qui commande le tri par défaut.

Le rapprochement passe par l'empreinte de visiteur de Stats, calculée avec le même sel du
jour : il est possible dans la journée, impossible le lendemain. C'est voulu, et c'est ce
qui permet de mesurer sans jamais identifier personne.

Si le domaine n'émet aucun événement métier, l'écran le dit au lieu d'afficher un zéro :
« impact inconnu ». Un zéro aurait été un mensonge.

### Suivi par version

Quand `version` accompagne une erreur, Tock retient la version de la **première** et de la
**dernière** occurrence du groupe, et tient un compte par version. La fiche répond alors
à la question qui ferme le dossier : quel déploiement l'a introduite, et lequel ne l'a
pas fait disparaître.

Le compteur par version survit à la purge des occurrences : il est tenu au fil de l'eau,
pas recalculé.

### Les alertes

Deux moments, et pas plus.

- **Une erreur nouvelle qui touche du monde** : trois visiteurs distincts sur sept jours,
  ou dix occurrences côté serveur. Une alerte, une seule, jamais à la première occurrence
  — un visiteur avec une extension cassée n'est pas une panne.
- **Une erreur résolue qui revient** : dès la première occurrence, parce qu'on savait
  déjà qu'elle comptait. C'est l'alerte la plus utile du module.

Aucun résumé quotidien en plus : l'écran classe par impact, le courrier dit ce qui est
nouveau. La sourdine et l'état `ignore` éteignent l'un comme l'autre le courrier ; seul
`ignore` éteint aussi la pastille du module.

Chaque alerte dépose en plus un signal dans le journal de la plateforme, ce qui permet à
Signaux de rapprocher une erreur d'un déploiement ou d'une chute de conversion.

### Les routes d'API

Toutes les routes `/api/v1` s'authentifient par `Authorization: Bearer tock_…`.
Les lectures exigent la mission « lire » ; l'écriture d'erreurs exige la mission
« rapporter des erreurs » (`erreurs.ecrire`) et une clé en écriture. 300 appels par
minute et par clé.

#### POST /api/erreurs — la route publique de la balise

Sans authentification, appelée par le navigateur des visiteurs. Corps JSON abrégé :
`s` (clé publique du domaine), `m` (message), `t` (pile), `f` (fichier), `p` (chemin),
`v` (version), `c` (contexte, objet). **Répond toujours `204`**, quoi qu'il arrive : une
clé fausse, un quota atteint, une limite dépassée donnent le même silence. C'est
volontaire — cette route est appelée par les visiteurs de nos clients, elle ne doit rien
révéler et rien coûter. Au-delà de 300 requêtes par minute et par domaine, le reste est
jeté : une boucle d'erreur sur une page en envoie des milliers.

Corollaire : **on ne diagnostique jamais une intégration de balise par cette route**. On
vérifie sur l'écran, ou avec la route authentifiée.

La route accepte `v` et `c`, mais **la balise ne les envoie pas encore** : côté
navigateur, la version et le contexte ne remontent que si vous appelez la route vous-même.
Côté serveur, `version`, `contexte` et `fil` fonctionnent dès aujourd'hui.

#### POST /api/v1/erreurs — signaler une erreur serveur

| champ | type | obligatoire |
| --- | --- | --- |
| `site` | clé publique du domaine | oui |
| `message` | texte | oui |
| `pile` | texte, 4 000 caractères | non |
| `chemin` | texte, 500 caractères | non |
| `version` | texte, 60 caractères | non |
| `contexte` | objet libre, 4 000 caractères une fois sérialisé | non |
| `fil` | tableau des derniers gestes avant l'erreur, 20 entrées | non |

`202` → `{ "enregistre": true, "groupe": "<uuid>", "nouveau": bool, "etat": "<état>" }`.

Erreurs : `400 champs_manquants`, `400 corps_invalide`, `400 refus_metier` (message vide),
`401 cle_absente` / `cle_invalide`, `403 lecture_seule` / `mission_non_autorisee`,
`404 site_inconnu`, `429 quota_atteint`.

#### GET /api/v1/erreurs — chercher

Paramètres, tous facultatifs : `projet` (sinon le projet par défaut), `site` (une clé de
domaine, qui restreint à ce domaine et fixe le projet), `etat` (une ou plusieurs valeurs
séparées par des virgules ; défaut `ouvert,en_cours`), `cote` (`navigateur` ou `serveur`),
`q` (recherche libre), `version`, `tri` (`impact` par défaut, `frequence`, `recence`,
`nouveaute`), `limite` (200 au plus), `depuis_rang` (décalage de pagination), `format`
(`json`, `csv`, `ndjson`).

`200` → `{ "erreurs": [ … ], "total": n, "projet": "<uuid>" }`. En `csv` et `ndjson`, le
corps est le fichier lui-même, sans enveloppe.

Chaque erreur porte : `id`, `titre`, `etat`, `cote`, `domaine`, `fichier`,
`premiere_vue_le`, `derniere_vue_le`, `total`, `occurrences_7j`, `visiteurs_touches`,
`visiteurs_qui_ont_fini`, `abandons`, `pages`, `assigne_a`, `sourdine_jusqu_au`,
`premiere_version`, `derniere_version`, `notes`, `serie_14j`.

La recherche `q` porte sur trois choses : le titre, le **message normalisé** (donc sans
les identifiants qui changent à chaque occurrence) et le fichier d'origine. Chercher
`commande # introuvable` trouve les erreurs dont le titre contient un numéro de commande.

Erreurs : `422 champ_etat` / `champ_cote` / `champ_tri` / `champ_format`,
`404 site_inconnu` / `projet_inconnu`.

#### GET /api/v1/erreurs/{id} — la fiche

`200` → `{ erreur, versions, pages, occurrences, notes, similaires }`. `erreur` reprend
les champs ci-dessus, plus `pile` et `impact_mesurable` (le domaine émet-il des événements
métier). `occurrences` donne les douze dernières avec `fil` et `contexte`. `similaires`
donne les groupes voisins avec leur `raison` : `fichier` ou `message`.

`404 introuvable` si le groupe n'est pas à cette équipe — même réponse que s'il n'existait
pas, et c'est délibéré.

#### PATCH /api/v1/erreurs/{id} — travailler l'erreur

Corps : au moins un de `etat`, `assigne_a`, `sourdine_h`, `note`.

```bash
curl -X PATCH https://letock.fr/api/v1/erreurs/ID -H "Authorization: Bearer tock_…" \
  -H "Content-Type: application/json" \
  -d '{"etat":"resolu","note":"corrigé en 2.4.2, déployé mardi"}'
```

`assigne_a` prend **l'adresse e-mail** d'un membre de l'équipe, ou `null` — personne ne
connaît par cœur l'identifiant de son collègue. `sourdine_h` prend un nombre d'heures
(720 au plus) ou `null` pour rétablir les alertes. `note` ajoute une note d'équipe ; posée
par une clé d'API, elle n'a pas d'auteur et l'écran l'affiche ainsi.

`200` → `{ "erreur": { … } }` avec l'état après coup.
Erreurs : `422 corps_vide` / `champ_etat` / `champ_assigne_a` / `champ_sourdine_h` /
`champ_note`, `404 introuvable` / `membre_inconnu`.

**Il n'y a pas de `DELETE`.** Supprimer un groupe et son historique tomberait aujourd'hui
dans la mission « rapporter des erreurs », donc dans les mains de tout agent qui remonte
ses exceptions. L'oubli définitif reste un geste d'humain connecté, sur la fiche ; passer
le groupe en `ignore` couvre le besoin réel sans rien détruire.

### Les écrans

**`/erreurs`** — la liste. Cinq onglets, chacun à son adresse : *À traiter*
(`ouvert` et `en_cours`, le défaut), *Pour moi*, *Résolues*, *Ignorées*, *Toutes*. Une
barre de filtres — recherche, côté, version, tri — et une pagination. Chaque ligne montre
l'impact, la frise de quatorze jours, l'état, le responsable, la version d'apparition, et
trois gestes rapides : marquer résolue, prendre l'erreur, ignorer. Tout l'état de l'écran
est dans l'URL : un onglet, une recherche et une page s'envoient par message.

**`/erreurs/{id}`** — la fiche. L'impact et les chiffres, les états et leurs promesses,
l'assignation, la sourdine, le tableau par version, la pile d'un exemple, les pages
touchées, les douze dernières occurrences avec leur fil d'Ariane et leur contexte, les
notes d'équipe, les erreurs voisines, les appels d'API prêts à copier, et le retrait
définitif derrière une confirmation.

### Quotas

Une occurrence enregistrée consomme une unité du poste **`erreurs`** du forfait de
l'équipe (les valeurs sont dans `src/plateforme/forfaits.ts` ; la page « Mon compte » les
montre). Au-delà, la route authentifiée répond `429 quota_atteint` et la route publique
se tait. Une erreur refusée par quota n'est **pas** comptée : marteler une API déjà
refusée ne gonfle pas le compteur.

Le regroupement, l'assignation, les notes et les changements d'état ne consomment rien.

### Les pièges

- **La route publique ne dira jamais qu'elle refuse.** Toujours `204`. Pour diagnostiquer
  une intégration, passer par l'écran ou la route authentifiée.
- **`resolu_le` vaut pour « résolu » comme pour « ignoré »** : c'est la date à laquelle le
  groupe a cessé de compter. Seul `resolu` se rouvre à l'occurrence suivante.
- **La sourdine n'est pas « ignoré »** : elle arrête le courrier, pas le comptage ni
  l'affichage.
- **« Qui a fini » exige des événements métier Stats sur le même domaine, le même jour.**
  Sans eux, l'impact est affiché comme inconnu, et le tri retombe sur les visiteurs
  touchés.
- **Les occurrences sont purgées à trente jours**, et un groupe sans occurrence depuis
  trente jours disparaît avec ses notes et ses versions. L'écran ne prévient pas.
- **Il n'y a pas de source maps.** Une pile minifiée reste minifiée : Tock ne stocke pas
  vos fichiers de correspondance et ne les applique pas. Le fichier d'origine est
  débarrassé de son empreinte de build, ce qui suffit au regroupement, pas à la lecture
  d'une pile. Envoyez `version` et une pile non minifiée côté serveur.
- **Le tri par impact peut mettre en tête une erreur à faible volume.** C'est le propos :
  trois personnes bloquées au paiement comptent plus que mille robots.
- **Une erreur résolue qui revient ne réalerte qu'une fois.** L'alerte de régression est
  posée, puis le groupe redevient un groupe ouvert ordinaire.

---

## Uptime

**Le problème** : savoir que le site est tombé avant que le client ne l'écrive — y
compris quand il répond `200` avec une page vide, un bouton « Payer » disparu au dernier
déploiement, un port de base de données fermé, un enregistrement DNS effacé, un
certificat expiré, ou une sauvegarde nocturne qui ne tourne plus.

Un contrôle ne regarde pas seulement le code de retour. C'est tout l'écart avec un
surveillant classique : la panne la plus coûteuse répond `200`.

### Démarrer en 30 secondes

Sur `/uptime`, choisir un domaine du projet, un chemin, et — facultatif — un texte qui
doit figurer dans la page :

- chemin `/paiement`
- texte attendu `Payer`

C'est tout. Le contrôle part toutes les cinq minutes, alerte par e-mail après **deux**
observations concordantes, et ne redit rien tant que la panne dure.

Pour surveiller un traitement qui n'a pas d'adresse — une sauvegarde de nuit — prendre
le type **battement de coeur**, et coller la ligne rendue à la fin du script :

```bash
pg_dump … && curl -fsS https://letock.fr/api/battement/LA_CLE
```

Le silence devient la panne : si l'appel n'arrive pas dans l'intervalle déclaré plus le
délai de grâce, Tock alerte. C'est le seul montage qui détecte l'**absence**
d'exécution.

### Les cinq types de contrôle

| type | ce qu'il vise | champs propres |
| --- | --- | --- |
| `http` | une page ou une API. Le défaut | `chemin`, assertions, parcours |
| `tcp` | un port ouvert : base de données, SMTP, file | `cible` (hôte), `port` (requis) |
| `dns` | un enregistrement présent, et sa valeur | `dnsType` (A, AAAA, MX, TXT, NS, CNAME), `dnsAttendu` |
| `ssl` | la date d'expiration d'un certificat | `cible`, `port` (443 par défaut) |
| `battement` | l'appel que le client émet lui-même | `graceMin`, clé d'appel |

`tcp` et `ssl` **exigent que le domaine ait été prouvé** (page Veille) : ouvrir une
prise vers un hôte choisi par l'appelant, sans preuve, serait un balayage de ports offert
à tout inscrit. `cible` doit rester sous le domaine déclaré — `smtp.monsite.fr` oui,
`evil.example.com` non.

**ICMP (le « ping ») n'existe pas et n'existera pas.** Un ping demande une prise brute,
donc les privilèges de l'administrateur du système ; appeler le binaire `ping` donnerait
une sonde qui marche sur une machine et pas sur l'autre. Il n'apprend d'ailleurs presque
rien de plus qu'un TCP — la moitié des hébergeurs filtrent l'ICMP, et un serveur qui
répond au ping avec toutes ses applications mortes est le cas normal.

### Les assertions d'un contrôle HTTP

Toutes facultatives ; un contrôle qui n'en porte aucune vaut « statut inférieur à 400 ».
Elles sont évaluées du transport vers le contenu, et **le premier constat s'arrête là** :
une page qui répond 503 ne mérite pas qu'on lui reproche aussi l'absence de son bouton.

- `statutAttendu` — le code exact exigé.
- `attendu` — un texte qui doit figurer dans la page.
- `absent` — un texte qui ne doit **pas** y figurer : « Erreur 500 », « maintenance ».
- `enteteNom` / `enteteValeur` — un en-tête attendu, et ce qu'il doit contenir.
- `maxMs` — un budget de temps. Le dépasser est une **panne**, pas une lenteur : un
  budget déclaré est une promesse tenue ou non.
- `jsonChemin` / `jsonValeur` — une valeur dans le JSON : `etat`, `data.0.id`. Les
  chemins sont volontairement pauvres — segments séparés par des points, entiers pour les
  tableaux. Personne ne surveille une API avec un filtre récursif.

Un **parcours** enchaîne jusqu'à cinq étapes sur le même domaine en gardant les cookies :
accueil, connexion (POST avec un corps `application/x-www-form-urlencoded`), tableau de
bord. C'est ce qui répond à « est-ce qu'on peut encore se connecter ».

### Les routes

#### `GET`, `POST` ou `HEAD /api/battement/{cle}`

Publique, **sans authentification**, et c'est délibéré : cette adresse finit dans un
`curl` d'une ligne écrit par quelqu'un qui n'ouvrira pas de documentation. La clé est
dans le chemin, imprévisible, et le pire qu'un tiers la connaissant puisse faire est de
**taire** une alerte — jamais d'en lire une ni d'en fabriquer une.

Les trois verbes comptent comme un battement : `curl` fait un GET, `wget --post-data` un
POST, certains ordonnanceurs un HEAD.

Réponse `200` : `{ "ok": true, "retabli": false }`. `retabli: true` quand cet appel
referme une panne ouverte. `404 battement_inconnu` pour une clé inconnue ou révoquée,
`404 cle_invalide` pour une clé mal formée. Limite : 120 appels par minute.

#### `GET /statut/{cle}`

La page de statut publique du projet, activée depuis `/uptime`. La clé rend l'adresse
imprévisible sans être un secret — la page ne montre que ce que le client a choisi de
publier : l'état de chaque contrôle, la frise des derniers relevés, les incidents avec
leur message écrit à la main, et les maintenances déclarées.

Elle montre **tous** les contrôles du projet, y compris les battements et les contrôles
de port. Leur libellé est celui de la cible (`db.monsite.fr:5432`, `MX monsite.fr`) ou le
nom donné au contrôle pour un battement — jamais une adresse HTTP inventée. Un
traitement interne qu'on ne veut pas publier ne doit donc pas vivre dans un projet dont
la page de statut est publiée.

### Les canaux d'alerte

L'e-mail est le canal par défaut et ne se supprime pas : il se déduit des membres de
l'équipe. Cinq canaux **en plus** par projet :

- **`webhook`** — un `POST` JSON signé, en-tête `tock-signature: t=…,v1=…`, HMAC-SHA256
  de `"<t>.<corps>"` (même vérification que les crochets de Mail). Charge utile :
  `{ evenement: "panne"|"relance"|"retabli", controle_id, nom, cible, raison, duree_min, quand }`.
- **`slack`** — l'adresse doit commencer par `https://hooks.slack.com/`. La charge est
  l'objet `{ "text": … }` que Slack sait lire ; le client n'a rien à fabriquer.

**Sans file, sans réessai.** Une alerte remise une heure plus tard n'a plus de valeur :
la panne est finie et le message arrive comme un fantôme. On tente une fois, on note le
code, et l'écran montre le compteur d'échecs. Après vingt échecs consécutifs, le canal
se tait.

Ni SMS ni appel téléphonique : ce sont un contrat d'opérateur, une facturation à l'unité
et une vérification de numéro par pays — une ligne de métier, pas une fonction. Un
webhook branché sur un service d'astreinte fait la même chose, et le client choisit son
prestataire.

### Les écrans

- `/uptime` — les contrôles du projet, l'ajout, les canaux d'alerte, la publication de
  la page de statut.
- `/uptime/{id}` — un contrôle : ses réglages, ses étapes de parcours, ses derniers
  relevés, ses incidents. Pour un battement : la ligne à coller et la date du dernier
  appel reçu.
- `/uptime/incidents` — les incidents du projet, ouverts d'abord, avec le message
  publiable sur la page de statut.
- `/uptime/disponibilite?periode=24h|7j|30j|90j` — la disponibilité **calculée en
  temps**, pas en nombre de relevés : le temps d'indisponibilité vient des incidents, le
  temps de maintenance déclarée en est retiré. C'est ce qui rend le budget d'erreur
  lisible : « il te reste 12 min avant de manquer ton objectif du mois ».
- `/uptime/maintenance` — les fenêtres déclarées à l'avance.

### Les quotas

- Poste **`sondes`** : une unité par observation, prélevée **avant** de sonder. Un
  battement reçu en consomme une aussi — il écrit exactement ce qu'écrit une sonde. Un
  contrôle sauté faute de quota **ne laisse pas de relevé** : inventer un « inconnu »
  ferait baisser une disponibilité affichée pour une raison étrangère au site surveillé.
- Poste **`controles`** : le nombre de contrôles déclarés, tous types confondus. C'est
  le forfait qui décide, plus une constante.
- Poste **`alertes`** : les e-mails d'ouverture, de relance et de retour. Les canaux
  webhook et Slack ne le consomment pas — compter deux fois la même alerte ferait taire
  le courrier au milieu d'une panne.
- `intervalleMin` du forfait borne la cadence la plus serrée.
- Rétention : 30 jours de relevés, un an d'incidents et de maintenances.

### Les pièges

- **Une sonde isolée ne prouve rien.** Le réglage `confirmations` (2 par défaut, 1 à 5)
  dit combien d'observations concordantes il faut avant d'alerter. Entre-temps l'état est
  « suspect » : visible à l'écran, sans réveiller personne. Mettre 1 fait des fausses
  alertes, et au troisième plus personne ne lit les alertes.
- **« lent » n'alerte jamais.** C'est un signal à lire, pas une urgence. Au-delà de trois
  secondes une page est lente ; pour en faire une panne, poser un `maxMs`.
- **Chercher un texte dans une page qui redirige est un contresens** : le corps d'un 302
  est vide. Le contrôle le dit et demande l'adresse finale, au lieu d'accuser la page
  d'avoir perdu son contenu.
- **Le type d'un contrôle ne se modifie pas.** Changer un HTTP en battement garderait ses
  relevés, ses incidents et sa disponibilité, qui ne parlent plus de la même chose. En
  créer un autre.
- **Un battement n'est jamais sondé.** Il n'apparaît pas dans les contrôles dus, et son
  retour au vert n'est pas constaté par le worker : c'est l'appel du client qui referme
  l'incident.
- **Le délai de grâce d'un battement n'est pas décoratif.** Une sauvegarde prévue toutes
  les 24 h prend vingt minutes un mardi et quarante un vendredi. Sans grâce, le contrôle
  alerte au premier vendredi. Par défaut : le quart de l'intervalle, entre 1 et 60
  minutes.
- **Un contrôle DNS dont le résolveur ne répond pas est « lent », pas « en panne ».**
  C'est notre résolveur qui a failli, pas le domaine du client. Le distinguer est tout le
  sujet : « aucun enregistrement » est une panne, « je n'ai pas pu demander » n'en est
  pas une.
- **Un contrôle SSL passe en panne à sept jours de l'expiration**, pas le jour J : un
  certificat qui expire dans la semaine est une panne programmée, et la traiter autrement
  ne la ferait pas renouveler. Entre 8 et 30 jours, il est « lent ».
- **L'escalade est facultative et vaut null par défaut.** Sans elle, la panne est dite à
  l'ouverture et au retour, jamais entre les deux — un e-mail par minute pendant six
  heures fait désactiver les alertes. Avec elle (5 minutes à 24 heures), la panne se
  redit à chaque échéance, et une seule fois par échéance.
- **Une maintenance déclarée ne fait pas taire la confirmation.** On continue de compter
  les échecs, pour que la panne soit déjà confirmée si elle survit à la fenêtre. Le temps
  de maintenance est relevé comme tel et ne compte ni en vert ni en rouge.
- **Deux contrôles sur le même hôte et le même type doivent différer par le chemin, la
  cible ou le port.** Deux ports TCP du même hôte coexistent ; deux fois le même, non.

---

## Veille

**Le problème** : le certificat, le domaine et le DNS changent sans qu'on le sache, et
on l'apprend par la panne. Veille re-mesure chaque jour ce qui fait tenir un domaine,
garde l'**avant et l'après** de tout ce qui bouge, et prévient — y compris quand le
changement ne casse rien encore.

L'audit public sur `/verifier` est une photo. L'abonnement, c'est la même photo reprise
chaque jour, comparée à celle de la veille. C'est le même code de sondes des deux côtés,
ce qui évite d'avoir deux vérités sur l'état d'un domaine.

### Démarrer en 30 secondes

1. Ajouter le domaine au projet (`/projets`).
2. Le prouver, par l'une des deux méthodes que l'écran affiche — un fichier à publier,
   ou un enregistrement TXT à poser. **Le DNS l'emporte** : c'est la preuve la plus
   forte, et la seule que le module Mail accepte.
3. Ouvrir `/veille`. Le premier audit part dans la minute ; ensuite, une fois par jour.

Rien à installer, rien à brancher. Chaque constat dit ce qui a été vu, pourquoi c'est un
problème, et la ligne exacte à coller dans la zone DNS.

### Ce qui est vérifié

Quinze constats, chacun sous une clé stable — c'est elle que le journal des changements
et les alertes citent.

| clé | ce qu'elle dit |
| --- | --- |
| `tls` | la date d'expiration du certificat, avec relance à 30, 14 et 7 jours |
| `ns` | les serveurs de noms déclarés : combien, et chez qui |
| `delegation` | chaque serveur de noms **répond-il** pour la zone |
| `pendants` | les enregistrements qui pointent vers un nom qui n'existe plus |
| `adresses` | où pointe le domaine, en IPv4 et IPv6 |
| `mx` | la réception du courrier |
| `caa` | quelles autorités ont le droit d'émettre un certificat |
| `spf` | qui a le droit d'envoyer du courrier au nom du domaine |
| `dmarc` | la politique appliquée à ce qui échoue |
| `bimi` | le logo dans les boîtes de réception |
| `https` | ce que répond le port 80 : redirige-t-il vers HTTPS |
| `entetes` | les en-têtes de sécurité de la page d'accueil |
| `robots` | ce que `robots.txt` autorise |
| `sitemap` | le plan du site |
| `llms` | la description que les modèles peuvent citer |
| `accueil` | la page d'accueil répond-elle, et en combien |

Chaque constat porte une **gravité** : `ok`, `attention`, `serieux`, ou `indetermine`.

### La délégation pendante, et ce qu'on ne prétend pas dire

`pendants` est la vérification que les outils grand public ne font pas, et c'est la plus
utile de la liste.

Un CNAME vers `mon-appli.herokuapp.com` survit à la suppression de l'application. Le nom
cesse d'exister chez l'hébergeur, l'enregistrement reste dans la zone du client, et
**n'importe qui peut reprendre ce nom chez l'hébergeur** et servir ce qu'il veut sous le
domaine du client. C'est la « prise de contrôle de sous-domaine ». Même famille : un MX
qui pointe vers un serveur disparu, et le courrier entrant tombe dans le vide sans rebond
visible pendant des jours.

Ce qui est constaté est un fait vérifiable : **cet enregistrement pointe vers un nom qui
ne résout vers rien.** Une liste d'hébergeurs où un nom libéré se reprend sans preuve de
possession sert à hausser la gravité de `attention` à `serieux` — pas à affirmer que le
sous-domaine est récupérable. Le savoir exigerait d'essayer de le récupérer, ce qui est
précisément l'attaque.

Sont regardés : le CNAME de `www`, et les cibles des MX. **Pas l'inventaire des
sous-domaines oubliés** : l'obtenir demanderait d'interroger les journaux de transparence
des certificats, un service tiers qui limite ses appels et tombe. Un inventaire incomplet
présenté comme complet est pire que pas d'inventaire.

`delegation` interroge **directement** chaque serveur de noms déclaré et lui demande le
SOA de la zone. Un serveur délégué qui ne connaît pas la zone est une délégation
boiteuse : invisible depuis un navigateur — les autres serveurs répondent — et payée en
lenteur inexplicable, jusqu'au jour où un autre serveur tombe.

### Le score

Un chiffre entre 0 et 100, noté une fois par jour et par domaine, avec sa courbe sur
trente jours et la comparaison entre domaines du même projet.

Il n'a de sens que **comparé à lui-même**, hier et le mois dernier, sur le même domaine —
ou entre domaines d'un même projet, où les quinze mêmes vérifications s'appliquent. Le
comparer à celui d'un inconnu ne veut rien dire. Sa seule raison d'être : une équipe qui
ne peut pas constater qu'elle avance cesse d'avancer, et « il te reste deux attentions »
ne dit pas s'il y en avait six ou une.

Le calcul : un `serieux` coûte quatre fois un `attention`, un `ok` ne coûte rien, et un
`indetermine` **sort du calcul** dans les deux sens. Le compter comme un défaut ferait
chuter le score pendant une panne de résolveur ; le compter comme du vert rassurerait à
tort. Un domaine dont tout est indéterminé vaut 100 faute de mieux, et l'écran montre le
compte d'indéterminés à côté.

Deux audits le même jour ne font qu'un point : la courbe doit raconter l'état du domaine,
pas la fréquence des audits.

### Les routes

Veille n'expose pas d'API d'écriture : un domaine s'ajoute au projet, et tout le reste
est mesuré. Deux routes publiques la servent.

#### `GET /verifier`

La page d'audit publique, sans compte. Limitée à 10 audits par minute et par appelant,
et 20 par domaine cible, pour qu'elle ne devienne pas un balayage.

#### `GET /api/v1/analyser?e=…&tz=…&n=…`

Publique, sans clé. Appartient à Cron mais partage la même limite d'appel publique ;
citée ici parce que les deux pages publiques la voisinent.

#### `GET /veille/export`

Session, pas clé d'API. CSV, point-virgule et BOM, **une ligne par constat et par
domaine** — pas une par domaine : c'est le grain auquel la question se pose, et le seul
qui se filtre dans un tableur. Le score et celui d'il y a trente jours sont répétés sur
chaque ligne, pour qu'un tri par colonne ne casse pas le rapprochement. 5 000 lignes au
plus. Fait pour être envoyé à qui tient le DNS ou l'hébergement, et qui n'a pas de compte.

Toute requête sortante — HTTP, TLS, DNS — passe par le garde anti-SSRF
`src/core/urlGuard.ts` : les adresses privées sont refusées, la poignée de main TLS
s'ouvre sur l'**adresse** vérifiée et non sur le nom, ce qui ferme au passage la fenêtre
du « DNS rebinding ».

### Les écrans

- `/veille` — les domaines du projet, leur score et son évolution, et les constats de
  chacun triés par gravité : `serieux`, puis `attention`, puis `indetermine`, puis `ok`.
  L'indéterminé passe **avant** le vert : on n'a pas pu conclure, et ça se lit en premier.
- `/veille/changements` — le journal : quoi, quand, la valeur d'avant et celle d'après.
  C'est ce qui répond à « pourquoi les mails n'arrivent plus depuis le 4 ».

### Les quotas

- Poste **`domaines`** : le nombre de domaines déclarés, tous modules confondus.
- Poste **`alertes`** : un e-mail par alerte ouverte, par palier d'échéance et par retour
  au vert, plus un message groupé par jour et par domaine pour les changements.
- L'audit quotidien lui-même ne consomme aucun poste de volume : il est lié au nombre de
  domaines, déjà borné.
- Rétention : un an de changements, un an de courbe de score.

### Les pièges

- **« Invérifiable » n'est pas « absent ».** Un `indetermine` n'alerte pas, ne clôt
  aucune alerte en cours, ne produit aucun changement et ne compte pas dans le score.
  Envoyer un e-mail là-dessus transformerait une panne de résolveur en fausse alerte ;
  clore l'alerte annoncerait une réparation qui n'a pas eu lieu.
- **Le premier audit d'un domaine ne produit aucun changement.** Tout y serait
  « nouveau », et quinze lignes d'historique le jour de l'inscription apprennent à ne
  plus regarder l'historique.
- **Le journal compare la valeur canonique, pas le texte affiché.** Reformuler un message
  ne produit pas de changement, et un enregistrement réordonné par le résolveur non plus.
- **Une échéance relance à chaque palier** — 30, 14 et 7 jours. Prévenir une seule fois,
  un mois avant, c'est prévenir au moment où personne n'agit.
- **Les changements d'un même jour partent en un seul message.** Trois enregistrements
  modifiés le même jour sont un seul événement — une migration — et trois e-mails le
  feraient passer pour trois incidents.
- **Un audit par jour et par domaine**, pas plus : c'est l'échelle à laquelle ces choses
  bougent. Pour une surveillance à la minute d'une expiration de certificat sur un hôte
  précis, c'est le contrôle `ssl` d'Uptime qu'il faut.
- **`https` ne se plaint pas d'un port 80 muet.** Un port fermé est une réponse
  légitime — meilleure, même, qu'une redirection.
- **La preuve HTTP ne donne pas accès à tout.** Elle suffit à Veille et à Stats ; Mail
  exige la preuve DNS, et les contrôles TCP et SSL d'Uptime exigent l'une des deux.

---

## Forum

**Le problème** : tes utilisateurs ont des questions, et tu n'as ni le temps
d'installer un Discourse ni l'envie de les envoyer sur un Discord que personne
n'indexera jamais. Forum donne à chaque projet un espace public, modéré par ton
équipe, hébergé par Tock — et **indexable**, ce qui est tout l'intérêt : une
question résolue est une page qui répond à la requête que le prochain tapera.

Deux forums coexistent sans se confondre : celui de Tock (`/forum/tock`, une
section par module, pour l'entraide entre développeurs qui utilisent la
plateforme) et **le tien**, sous ta clé ou ton propre domaine.

### Démarrer en 30 secondes

Rien à installer, rien à appeler. Sur `/forum`, ton projet a déjà son espace :
tu choisis son adresse, ses sections, son apparence, et qui modère. La page
publique est immédiatement en ligne à `https://letock.fr/forum/<ta-clé>`, ou sur
ton propre sous-domaine si tu en déclares un.

Pour l'entraide sur Tock lui-même, chaque module porte un lien « Questions et
réponses sur … » dans son fil d'Ariane, qui ouvre la bonne section directement.

### Ce que sait faire un fil

- **Verrouiller** plutôt que supprimer : on lit encore, on ne répond plus. C'est
  le geste de modération le plus utile et le moins violent — supprimer efface
  aussi ce que les autres venaient chercher.
- **Fusionner** un doublon vers l'original, sans le faire disparaître : le lien
  qui circulait continue de mener quelque part.
- **Épingler**, **étiqueter** (la section dit où l'on est, l'étiquette dit de
  quoi on parle), **accepter une réponse**.
- **S'abonner**, et être prévenu par e-mail d'une réponse, d'une mention ou de
  l'acceptation. L'abonnement est automatique quand on ouvre un fil ou qu'on y
  répond, et l'e-mail dit toujours **pourquoi** on le reçoit.
- **Signaler** un message : il entre dans une file de modération. Modérer sans
  file suppose de tout lire tous les jours — personne ne le fait.
- **Chercher**, y compris dans les réponses. C'est là qu'est la solution ;
  chercher dans les seuls titres revenait à chercher les questions.

### Écrire un message

Le balisage accepté : `` `code` ``, un bloc entre trois accents graves,
`**gras**`, `> citation`, `@pseudo`. L'aperçu est un aller-retour serveur — il
n'y a pas une ligne de JavaScript dans ce produit — et le texte est conservé en
brouillon, donc l'aperçu ne perd jamais la saisie. Le brouillon survit aussi à
la fermeture de l'onglet.

### API

Aucune, et c'est délibéré. Un forum se lit et s'écrit par des personnes ; une
API d'écriture sur un forum public est d'abord une API de publication
automatisée. Les deux routes qui existent servent l'affichage public
(`/api/forum/sujets`, `/api/forum/encart`) et ne sont pas des points d'entrée à
appeler soi-même.

Si tu es une IA : **n'invente pas de route pour ce module.** Tu peux lire les
pages publiques comme n'importe quel lecteur.

### Écrans

`/forum` la vue d'ensemble et les derniers sujets · `?onglet=sections` les
sections · `?onglet=apparence` les couleurs et le titre · `?onglet=adresse` la
clé publique et le domaine propre · `?onglet=equipe` qui modère et avec quels
droits · `?onglet=moderation` la file des signalements. Côté public :
`/forum/<clé>` et le fil de chaque sujet.

### Quotas

`forums` — le nombre de forums publics ouverts, borné par le forfait. Les
messages ne sont pas comptés : ils ne coûtent presque rien et les plafonner
reviendrait à décourager exactement ce qu'on cherche à obtenir. Les avis envoyés
par e-mail consomment le poste `alertes`.

### Pièges

- **Un forum public est public.** Ce qu'un utilisateur y écrit est indexé,
  archivé, et cité ailleurs. L'écran le dit avant la première publication.
- **La clé d'un forum peut être renommée**, contrairement à celle d'Auth : ne
  t'en sers jamais comme identifiant stable dans du code.
- **Le domaine propre exige une preuve DNS**, comme partout ailleurs dans Tock.
- **Verrouiller n'est pas supprimer** : un fil verrouillé reste indexé et
  continue de rendre service. C'est presque toujours le bon geste.

---

## IA

**Le problème** : un agent qui « a fini » sans appeler un outil ni rien produire a répondu
OK sans rien faire, et personne ne s'en aperçoit — ni l'équipe, qui voit un journal vert,
ni l'agent, qui a cru avoir fini. IA observe les agents qu'on déploie déjà : runs, étapes,
jetons, coût, latence, traces imbriquées, jugements. Il fait attendre les actions
sensibles qu'un humain les approuve. Et il rend l'état de l'agent **à l'agent**, ce
qu'aucun outil d'observabilité ne fait.

Ce n'est pas un chatbot, pas un SDK, pas une instrumentation : des requêtes HTTP en JSON,
que l'agent émet lui-même après coup.

### Démarrer en 30 secondes

Rapporter un run après chaque exécution. L'agent existe dès son premier run — rien à
déclarer avant.

```bash
curl -X POST https://letock.fr/api/v1/agents/runs \
  -H "Authorization: Bearer tock_…" -H "Content-Type: application/json" \
  -d '{"agent":"assistant-commercial","resultat":"ok",
       "resume":"Devis 4521 préparé","declencheur":"mail reçu",
       "etapes":[{"outil":"gmail.lire","duree_ms":420,"ok":true},
                 {"outil":"crm.tarifs","duree_ms":80,"ok":true}],
       "jetons":8200,"cout_centimes":3,"modele":"claude-sonnet-5"}'
```

Réponse : `202` et `{"enregistre":true,"run":"<uuid>","trace":"<uuid>","resultat":"ok","nouveau":true}`.

`resultat` ne vaut que `ok` ou `echec`. Le troisième résultat, `inerte`, n'est jamais
envoyé : **Tock le déduit**. Un run `ok` qui n'a appelé aucun outil en succès et n'a rien
résumé est inerte, quoi qu'en dise l'agent.

### Le vocabulaire

- **Agent** : un nom (`[a-z0-9._-]`, 60 caractères), un rôle facultatif, rattaché à un
  projet. Créé au premier run.
- **Run** : une exécution. Déclencheur, étapes, résultat, résumé, jetons, coût en
  centimes, modèle, début et fin.
- **Trace** : un travail, et non des runs épars. Un run qui en déclenche un autre passe
  `parent` ; les deux portent alors le même `trace_id`, et le coût de la demande
  s'additionne.
- **Variante** : une étiquette libre posée sur le run — `prompt-v3`, `sans-outil-web`.
  C'est elle qui rend deux versions comparables.
- **Note** : `bon` ou `mauvais`, avec un motif. Posée par un humain sur l'écran
  (`note_source: humain`) ou par une évaluation automatique via l'API (`auto`). C'est la
  seule information que la plateforme ne peut pas produire seule.
- **Validation** : une action sensible que l'agent dépose et qu'un humain approuve ou
  refuse. Sans réponse avant l'échéance, c'est un **refus**.

### Les cinq signaux

Une fois par jour, sur le dernier jour complet, chaque agent est jugé **contre lui-même** :
la valeur du jour contre la médiane des mêmes jours de semaine, cinq semaines en arrière.
Rien ne se déclenche sous cinq runs dans la journée.

| signal | déclenchement |
| --- | --- |
| `inerte` | la moitié des runs ont fini « OK » sans appeler d'outil ni rien produire |
| `echecs` | plus d'un run sur quatre en échec |
| `cout` | le coût du jour au double de la médiane des mêmes jours |
| `latence` | le 95e centile de la durée au double de la médiane des mêmes jours |
| `qualite` | plus d'un run jugé sur deux est noté mauvais (au moins trois jugements) |

Une alerte à l'ouverture d'un signal, une au retour à la normale, et rien entre les deux.
Chaque ouverture dépose aussi un signal dans le journal de la plateforme, ce qui permet à
Signaux de rapprocher la dérive d'un agent d'un déploiement ou d'une panne ailleurs.

La latence est jugée au 95e centile, pas à la moyenne : un agent qui répond en deux
secondes sauf une fois sur dix où il met trois minutes a une moyenne rassurante et des
utilisateurs qui abandonnent.

### L'agent qui s'interroge lui-même

C'est ce qui n'existe nulle part ailleurs. L'agent appelle `GET /api/v1/agents/etat` au
début de son run et reçoit son propre état, dont un champ `conseils` écrit pour être
recopié tel quel dans le contexte du modèle.

```bash
curl "https://letock.fr/api/v1/agents/etat?agent=assistant-commercial" \
  -H "Authorization: Bearer tock_…"
```

```json
{
  "agent": "assistant-commercial",
  "etat": "attention",
  "hier": { "jour": "2026-03-17", "runs": 10, "inertes": 6, "cout_centimes": 240, "duree_p95_ms": 9100 },
  "sept_jours": { "runs": 61, "echecs": 2, "inertes": 9, "juges": 4, "mauvais": 3 },
  "constats": [ { "signal": "inerte", "raison": "6 runs sur 10 ont fini « OK » …" } ],
  "alertes": ["inerte"],
  "validations_en_attente": [ { "id": "…", "action": "Envoyer le devis n° 4521", "expire_le": "…" } ],
  "runs_juges_mauvais": [ { "run": "…", "motif": "a répondu sur un autre dossier" } ],
  "quota": { "poste": "runsIa", "consomme": 612, "limite": 1000, "restant": 388, "part": 0.61 },
  "conseils": [
    "6 runs sur 10 ont fini « OK » sans appeler un outil ni rien produire. Avant de conclure, vérifie que tes outils ont répondu…",
    "1 action que tu as déposée attend encore un accord humain depuis 20 h. Ne la refais pas et ne la contourne pas : interroge GET /api/v1/agents/validations/…"
  ]
}
```

Une clé en **lecture seule** suffit, et c'est la bonne façon de s'en servir : l'agent lit
son état avec une clé qui ne peut rien écrire.

Ce que les conseils couvrent : chaque constat ouvert avec le geste correspondant, les
validations en attente et leur ancienneté, les runs récemment jugés mauvais avec leurs
motifs, et le quota du mois dès 70 % puis dès 90 %. Quand il n'y a rien à dire, ils le
disent — une phrase, pas un silence qu'on interpréterait mal.

### Les routes d'API

`Authorization: Bearer tock_…` partout. Les lectures exigent la mission « lire » ;
l'écriture de runs et les demandes de validation exigent « rendre compte de ses propres
exécutions » (`ia.ecrire`) ; trancher une validation exige « approuver des validations »
(`ia.valider`), qui n'est jamais proposée à un agent. 300 appels par minute et par clé.

#### POST /api/v1/agents/runs — rapporter un run

| champ | type | notes |
| --- | --- | --- |
| `agent` | texte | obligatoire |
| `resultat` | `ok` \| `echec` | obligatoire |
| `resume` | texte, 2 000 caractères | ce que l'agent a produit |
| `declencheur` | texte, 120 caractères | ce qui l'a lancé |
| `etapes` | tableau de `{outil, duree_ms, ok, detail}`, 200 entrées | |
| `jetons`, `cout_centimes` | entiers | le coût est en **centimes**, jamais en euros flottants |
| `modele` | texte | |
| `debut`, `fin` | dates ISO 8601 | défaut : maintenant |
| `id` | texte, 200 caractères | identifiant de l'appelant ; le même ne crée pas deux runs |
| `projet` | uuid | sinon le projet par défaut de l'équipe |
| `parent` | identifiant Tock **ou** identifiant de l'appelant du run appelant | forme la trace |
| `variante` | texte, 80 caractères | l'étiquette à comparer |
| `note`, `note_motif` | `bon` \| `mauvais`, texte | verdict d'une évaluation automatique |

`202` → `{ enregistre, run, trace, resultat, nouveau }`. `resultat` peut valoir `inerte`
alors que vous avez envoyé `ok` : c'est le jugement de Tock.

Un `parent` introuvable — purgé, ou d'une autre équipe — n'est pas une erreur : le run
existe quand même, en tête de sa propre trace. Perdre le travail parce qu'on a perdu son
contexte serait le pire des deux maux.

Erreurs : `400 champs_manquants`, `400 refus_metier` (nom d'agent invalide, début après la
fin), `422 champ_note`, `404 projet_inconnu`, `429 quota_atteint`.

#### GET /api/v1/agents/runs — chercher

Paramètres : `projet`, `agent`, `resultat` (`ok`, `echec`, `inerte`), `variante`, `note`
(`bon`, `mauvais`, `aucune`), `depuis` et `jusqua` (dates ISO), `limite` (500 au plus),
`depuis_rang` (décalage), `format` (`json`, `csv`, `ndjson`).

`200` → `{ "runs": [ … ], "total": n, "projet": "<uuid>" }`. Chaque run porte `id`,
`agent`, `declencheur`, `resultat`, `resume`, `outils`, `jetons`, `cout_centimes`,
`duree_ms`, `modele`, `variante`, `note`, `note_source`, `note_motif`, `trace`, `parent`,
`id_externe`, `debut`, `fin`.

#### GET /api/v1/agents/runs/{id} — la trace

On peut désigner n'importe quel maillon — souvent celui qui a échoué — et on obtient
l'arbre entier.

`200` → `{ trace, runs, agents, cout_centimes, jetons, duree_ms, echecs, arbre }`.
`arbre` est une liste de racines, chaque nœud portant ses `appels` (ses enfants).
`duree_ms` est le temps **vécu**, du début du premier run à la fin du dernier, pas la
somme des durées. Au-delà de 500 runs, la trace est coupée.

#### PATCH /api/v1/agents/runs/{id} — juger un run

```bash
curl -X PATCH https://letock.fr/api/v1/agents/runs/ID -H "Authorization: Bearer tock_…" \
  -H "Content-Type: application/json" -d '{"note":"mauvais","motif":"a répondu sur un autre dossier"}'
```

`note` vaut `bon`, `mauvais`, ou `null` pour retirer le jugement. Posée par une clé
d'API, la note est marquée `auto` ; posée sur l'écran, `humain`. `200` → `{ run, note }`.

#### POST /api/v1/agents/validations — demander un accord

| champ | type | notes |
| --- | --- | --- |
| `agent` | texte | obligatoire |
| `action` | texte, 3 à 300 caractères | ce que l'agent veut faire, en une phrase |
| `detail` | texte, 4 000 caractères | |
| `delai_h` | entier, 1 à 168 | défaut 48 |
| `webhook` | URL **https** | reçoit la décision |
| `run` | uuid | le run qui demande |
| `projet` | uuid | |

`202` → `{ validation, etat, expire_le }`. L'équipe reçoit un courrier avec un lien vers
l'écran. Erreurs : `400 champs_manquants`, `400 refus_metier` (action trop courte,
webhook non https), `404 projet_inconnu`.

#### GET /api/v1/agents/validations/{id} — interroger la décision

`200` → `{ validation, agent, action, etat, reponse, cree_le, decide_le, expire_le }`.
`etat` vaut `en_attente`, `approuvee`, `refusee` ou `expiree`. C'est l'appel que fait
l'agent en boucle lente, ou qu'il évite en donnant un `webhook`.

#### PATCH /api/v1/agents/validations/{id} — trancher depuis ailleurs

Corps `{ "decision": "approuvee" | "refusee", "reponse": "…" }`. Réservé à une intégration
que vous écrivez — un bouton dans un Slack — et protégé par la mission « approuver des
validations », qui n'est jamais proposée à un agent : une validation humaine que l'agent
peut s'accorder lui-même n'est plus une validation.

#### GET /api/v1/agents/etat — l'état rendu à l'agent

Paramètres : `agent` (obligatoire), `projet`. Réponse décrite plus haut.
`404 agent_inconnu` tant qu'aucun run n'a été rapporté sous ce nom.

### Le webhook des validations

Si `webhook` est donné, Tock appelle cette adresse en `POST` dès que la validation est
tranchée ou expirée, avec
`{ "validation": "<uuid>", "etat": "…", "reponse": "…", "action": "…" }`.

Un seul appel, jamais deux : la validation est marquée comme notifiée **avant** l'appel.
Il n'y a donc **pas de réessai** — un webhook injoignable au mauvais moment perd la
notification, et l'agent doit retomber sur l'interrogation. C'est assumé : rappeler le
webhook d'un client toutes les minutes parce qu'il est mort coûte plus cher que
l'interrogation qu'il remplace. L'adresse est vérifiée avant l'appel (pas d'IP privée,
pas de redirection vers le réseau interne).

### Les écrans

**`/ia`** — cinq sections, chacune à son adresse.

- *Agents* (défaut) : les actions en attente d'accord en tête, puis chaque agent avec sa
  semaine, ses trente jours en barres, ses constats, ses huit derniers runs et les deux
  boutons de jugement.
- *Traces* : les dernières traces à plusieurs runs, avec le coût et la durée de la demande
  entière.
- *Coûts* : le mois en cours, la projection de fin de mois au rythme actuel, le mois
  précédent, le coût par jour sur trente jours, puis le détail par modèle (coût par run,
  jetons, durée, échecs) et par agent.
- *Comparer* : pour chaque agent qui a plusieurs variantes, le tableau des mesures et une
  comparaison écrite qui dit **quand elle ne conclut pas**.
- *Validations* : ce qui attend, et l'historique des décisions.

**`/ia/{run}`** — la trace d'un run : l'arbre des délégations, les étapes de chaque nœud,
et le formulaire de jugement avec motif.

### Comparer deux variantes

Le tableau donne runs, échecs, inertes, coût moyen par run, médiane, 95e centile et part
de runs jugés bons. La comparaison écrite applique un test de proportions à deux
échantillons : au-delà de deux écarts-types, l'écart est dit « net » ; en deçà, il est dit
« peut être le hasard ». **En dessous de vingt runs de chaque côté, aucun écart n'est
qualifié** — les chiffres sont affichés, avec la phrase qui dit de ne pas les croire.

Il n'y a volontairement **aucun score global**. Additionner un coût, une latence et un
taux d'échec dans un seul chiffre revient à choisir à la place de l'équipe, avec des
pondérations qu'on ne lui a jamais montrées.

### Quotas

Un run rapporté consomme une unité du poste **`runsIa`** du forfait de l'équipe (valeurs
dans `src/plateforme/forfaits.ts`, visibles sur « Mon compte »). Au-delà :
`429 quota_atteint`, et le run n'est pas enregistré. Un run refusé n'est pas compté.

Les validations, les jugements, les traces et les lectures ne consomment rien de ce poste.
Les courriers d'alerte consomment le poste `alertes`, commun à toute la plateforme.

### Les pièges

- **`inerte` n'est pas une valeur qu'on envoie**, c'est un verdict. Un `ok` sans outil en
  succès et sans résumé devient `inerte`. Envoyer un résumé vide ne trompe personne ;
  envoyer un résumé honnête est le bon geste.
- **Un run reste isolé si `parent` manque.** La trace ne se devine pas : sans le champ,
  deux runs liés restent deux lignes, et le coût de la demande ne s'additionne jamais.
- **`id` (l'identifiant de l'appelant) dédoublonne, il ne met pas à jour.** Renvoyer le
  même `id` avec d'autres chiffres ne corrige rien : le premier run fait foi.
- **Le coût est en centimes entiers.** Un run à 0,004 € rapporté comme `cout_centimes: 0`
  disparaît des totaux. Agréger avant de rapporter, ou accepter l'arrondi.
- **Une note posée par une clé d'API est marquée `auto`.** Un agent peut donc se noter
  lui-même, et le signal `qualite` compte ces notes. Si le jugement doit peser, qu'il
  vienne d'un humain sur l'écran, ou d'une évaluation indépendante de l'agent jugé.
- **Les runs sont purgés à quatre-vingt-dix jours.** Les traces, les variantes et les
  jugements disparaissent avec eux ; les comparaisons portent sur trente jours.
- **La veille ne juge que le dernier jour complet, une fois par jour.** Un agent qui
  déraille ce matin sera jugé demain. Ce qui est immédiat, c'est l'écran — et
  `/api/v1/agents/etat`, qui lit les mêmes séries à la demande.
- **Sans réponse, une validation est refusée.** Jamais approuvée par défaut, jamais
  prolongée en silence.
- **La projection de fin de mois est une règle de trois** : dépense du mois rapportée aux
  jours écoulés. Elle ne connaît ni vos jours fériés ni votre campagne de la semaine
  prochaine. Elle est là pour faire réagir, pas pour budgéter.

---

## Auth

**Le problème** : tu as un produit, il a des utilisateurs, et tu ne veux ni écrire une
authentification ni en confier une de plus à un tiers. Auth fait de Tock le
**fournisseur d'identité de ton produit** : un serveur OpenID Connect complet, par
projet, avec ses propres utilisateurs finaux.

Deux populations qui ne se mélangent jamais : les **comptes Tock** (toi, ton équipe) et
les **utilisateurs finaux de ton produit**, qui n'ont accès à rien chez Tock et
n'existent que dans le périmètre d'un projet. Deux tables, jamais un drapeau — un
drapeau finit toujours par être oublié dans une clause `WHERE`, et ce jour-là
l'utilisateur d'un client entre chez un autre.

### Démarrer en 30 secondes

1. Sur `/auth`, ouvre l'authentification pour ton projet. Tu obtiens une clé
   d'émetteur, par exemple `k3f9wq`.
2. Déclare ton application : son nom, son type (`public` pour ce qui tourne dans un
   navigateur, `confidentiel` pour un serveur), et ses adresses de redirection.
3. Pointe ta bibliothèque OIDC sur l'adresse de découverte :

       https://letock.fr/o/k3f9wq/.well-known/openid-configuration

C'est tout. N'importe quelle bibliothèque OIDC correcte lit ce document et se
configure seule. Il n'y a pas d'API propriétaire à apprendre.

### Les points d'entrée

Pour une clé d'émetteur `<cle>` :

    GET  /o/<cle>/.well-known/openid-configuration   la découverte
    GET  /o/<cle>/autoriser                          l'écran d'autorisation (navigateur)
    POST /api/oidc/<cle>/token                       l'échange du code, et le rafraîchissement
    GET  /api/oidc/<cle>/userinfo                    les informations du compte, jeton porteur
    GET  /api/oidc/<cle>/jwks                        les clés publiques de signature
    GET  /o/<cle>/deconnexion                        la fin de session

L'émetteur (`iss`) des jetons est `https://letock.fr/o/<cle>`. Il ne change jamais : c'est
pourquoi la clé d'Auth est distincte de celle du forum, qui, elle, peut être renommée.

### Ce qui est implémenté, et strictement

- **Code d'autorisation avec PKCE `S256`**, exigé pour **tous** les clients, y compris
  les confidentiels. Un secret de client fuite aussi, et PKCE ne coûte rien.
- **Redirections comparées à l'identique**, jamais par préfixe. Un préfixe autorise une
  redirection vers un chemin que le client n'a pas choisi : c'est la voie classique du
  vol de code d'autorisation. HTTPS obligatoire, sauf sur `localhost`.
- **Un code ne vaut qu'une fois.** Un second échange n'est pas une erreur, c'est une
  attaque : les jetons déjà émis pour ce code sont révoqués.
- **Rotation des jetons de rafraîchissement.** Rejouer un jeton déjà remplacé révoque
  toute la chaîne — la seule défense contre un jeton volé qu'on ne peut pas distinguer
  autrement.
- **Signature RS256**, clés publiées en JWKS. Plusieurs clés coexistent le temps d'une
  rotation.
- Durées : code 60 secondes, jeton d'accès 15 minutes, rafraîchissement 30 jours,
  session hébergée 30 jours.

### Les portées

    openid    l'identifiant du compte (obligatoire, c'est elle qui rend la demande OIDC)
    email     l'adresse et si elle est vérifiée
    profile   le pseudo

Trois portées, et pas trente. Une portée qu'on ne sait pas expliquer en une ligne sur
l'écran d'autorisation est une portée que l'utilisateur accepte sans comprendre.

### Comment tes utilisateurs se connectent

Par lien reçu par e-mail, valable 15 minutes, cinq demandes par heure et par adresse. Pas
de mot de passe : un mot de passe qu'on stocke est un mot de passe qu'on peut perdre, et
la moitié des incidents de sécurité d'un petit produit commencent là.

Une session ouverte sur l'écran de Tock vaut pour toutes tes applications : c'est ce qui
évite de redemander à l'utilisateur de s'identifier à chaque application.

### Écrans

`/auth` — ouvrir l'authentification, déclarer et révoquer les applications, voir les
utilisateurs finaux du projet, en bloquer un, révoquer toutes ses sessions.
`/o/<cle>/compte` — l'écran que voit **l'utilisateur final** : ses sessions, sa
déconnexion. Il n'y verra jamais rien de Tock.

### Quotas

`applicationsAuth` (applications déclarées par projet) et `utilisateursAuth` (comptes
d'utilisateurs finaux) : deux quotas de la famille « ce qui existe », plafonnés par le
forfait de l'équipe. Les liens de connexion envoyés comptent dans le poste `envois`.

### Pièges

- **Un client public ne reçoit pas de secret.** Lui en donner un donnerait l'illusion
  d'une protection : tout ce qui part au navigateur est lisible.
- **Le secret n'est montré qu'une fois.** Seule son empreinte est conservée. Un secret
  perdu se remplace, il ne se retrouve pas.
- **Vérifie toujours `iss` et `aud`** côté application. Un jeton signé par Tock pour un
  autre projet est un jeton parfaitement valide — qui ne parle pas de toi.
- **Ne fabrique pas les URL à la main.** Lis la découverte : les chemins peuvent
  changer, le document de découverte, lui, dit toujours la vérité.
- **Une adresse e-mail, un compte, par projet.** Le même utilisateur chez deux de tes
  clients aura deux comptes qui ne se touchent pas. C'est voulu.
