Broulala
Les briques, les conteneurs, et le trajet complet d'un cours déposé par un système client jusqu'à la fiche qu'il récupère. Notula sert d'exemple parce que c'est le premier client, et que le cœur de la plateforme vient de chez lui.
Au programme
Broulala fait deux choses, et deux seulement. Transcrire : un ou plusieurs fichiers audio deviennent des segments horodatés. Générer : un texte plus un template deviennent une sortie, en Markdown ou en JSON selon ce que le template déclare.
Ce qu'elle ne connaît pas, et ne doit jamais apprendre à connaître : les utilisateurs finaux de ses clients. L'étudiante qui dépose un cours, la voix sur un enregistrement de réunion, le patient d'une thérapeute — aucun n'a de ligne ici, aucun n'a d'identifiant ici. Un client qui sert mille personnes les segmente chez lui.
Une clé d'API = une intégration. Pas de sous-comptes, jamais. C'est ce qui garde le
modèle petit : un test vérifie qu'aucune charge utile de /v1/* ne porte
d'identifiant de personne.
Deux sortes de machines, et elles n'ont pas le même statut. L'une est la vôtre — un serveur que vous exploitez, où vit la base et où le contenu est chiffré au repos. Les autres sont louées à la minute chez un fournisseur, quelque part dans l'EEE, et elles appartiennent à quelqu'un d'autre. Tout ce qui suit découle de cette asymétrie.
Et une machine louée sert un seul moteur. Elle en a porté deux pendant une journée, et la journée a suffi : Qwen2.5-14B avec 64k de contexte occupe une quinzaine des vingt-quatre gigaoctets de la carte dès l'allumage, WhisperX a demandé le reste sur un enregistrement de deux heures et demie, et ne l'a pas eu. C'est la décision B12 — et elle ne parle pas d'abord de mémoire : une carte partagée se loue pour la somme des pointes, alors que séparées, chacune prend celle qui lui va.
whisper d’à côté : le même moteur sans carte, même sortie, beaucoup plus
lente. C’est ce que le contrat appelle le mode dégradé, et c’est une transcription
seulement — la génération n’a pas d’équivalent ici, alors un job qui porte un template
attend plutôt que de produire une transcription suivie d’un échec certain.
| Conteneur | Où | Ce qu'il fait | Ce qu'il expose |
|---|---|---|---|
api | chez vous | Les deux portes HTTP, la validation d'enveloppe, la mise en file | 127.0.0.1:3000 — boucle locale seule |
worker | chez vous | Dépile par FOR UPDATE SKIP LOCKED, transcrit puis génère. Vit dans le namespace réseau du tunnel : c'est la seule façon d'atteindre les ports redirigés, qui n'existent que sur la loopback de celui-ci | rien |
db | chez vous | PostgreSQL dédié — sa propre instance, son volume, son réseau | 127.0.0.1:5433 |
tunnel | chez vous | Reçoit l'appel du pod et publie ses moteurs en local | :2222 — le seul port public |
whisper | chez vous | Le chemin dégradé — le même moteur sans carte, même sortie, beaucoup plus lent. Il n'a pas de jumeau pour la génération : le modèle n'y tient pas (décision B7) | rien |
migrate | chez vous | Applique les migrations. Une décision d'exploitant, jamais un conteneur qui démarre | rien |
| le pod | loué | Un moteur, jamais deux (décision B12), et il le
sait : un POD_ROLE décide quel moteur prend le premier plan, quelle
redirection son client SSH ouvre, et avec quelle clé il compose. Un rôle absent est
refusé au démarrage plutôt que découvert sur une carte qui facture | aucun port |
Pas de base partagée avec un client co-hébergé, même sur la même machine. Une instance commune voudrait dire un superutilisateur commun, des sauvegardes mêlées, et une migration de l'un capable d'arrêter l'autre. Le prix est quelques centaines de mégaoctets, et il est payé une fois.
Le port est publié sur la boucle locale seule, pour que les migrations soient exécutables depuis l'hôte. La marge que ça laisse est dite plutôt que tue : un client co-hébergé pourrait techniquement y venir s'il avait les identifiants. D'où la règle que le binding ne peut pas faire respecter, et qui est le contrôle réel — un client co-hébergé ne reçoit jamais les identifiants de la base de Broulala.
Deux publics, deux portes, et elles ne communiquent pas.
/admin/v1/*, un
jeton de session sur /v1/*.
Une clé d'API n'ouvre jamais /admin/v1/*. Un jeton de session n'ouvre
jamais /v1/*. Un test vérifie les deux sens.
Ce n'est pas une règle qu'on se rappelle à la relecture, c'est une propriété de structure : chaque greffon Fastify reçoit exactement une façon d'authentifier, et aucun n'importe le module de l'autre. Il n'y a pas de chemin de code d'une porte à l'autre. Les percer serait donner à un système client le droit d'écrire sa propre configuration.
La ressource Context est retirée, avec son versionnement et ses cinq
routes — décision B21. Elle enregistrait un jeu de valeurs nommé, rattaché à un template,
pour une organisation ; rien ne le lisait. Aucune route de /v1/*, aucun job,
aucun chemin de traitement : son seul consommateur était le formulaire qui l'écrivait.
Et le constat de fond est plus simple que l'audit : un contexte tient à un appel, pas à
une organisation. Chez le seul client en production, le titre du cours change à chaque
cours. Ce qui le remplace n'est donc pas une entité mais une relecture — le détail d'un
appel rend son context à un admin membre, si bien qu'un intégrateur qui cherche
à quoi ressemble un appel qui a marché obtient un appel qui a marché.
Son entrée de menu est devenue le catalogue, en lecture seule : une carte par
template, ouvrable sur ce qu'il produit, ce qu'il impose et le schéma contre lequel votre
context sera validé — jamais son prompt, qui est le travail de la
plateforme. La question d'un administrateur est qu'est-ce que je peux demander,
jamais quelles valeurs ai-je sauvegardées.
Tout ce qui est remplaçable passe par une interface, et chacune a au moins deux implémentations — dont une fictive, utilisée par les tests. Une implémentation unique ne prouve rien.
| Port | Production | Fictive | Ce qu'il protège |
|---|---|---|---|
Storage | LocalDiskStorage | InMemoryStorage | Un S3Storage s'ajoute sans toucher au métier |
TranscriptionEngine | WhisperWebserviceEngine | FakeTranscriptionEngine | Le format interne — segments horodatés — est le nôtre |
GenerationEngine | LlamaCppEngine | FakeGenerationEngine | Markdown restreint ou objet conforme, jamais l'enveloppe brute d'un moteur |
Compute | RunPodCompute | FakeCompute | Changer de fournisseur de machines est une journée |
WebhookSender | SignedHttpWebhookSender | InMemoryWebhookSender | Une livraison se teste sans serveur en face |
Mailer | SmtpMailer | InMemoryMailer | Un courriel se teste sans serveur SMTP, et son contenu s'inspecte |
La sixième a été ouverte le 19/09/2026, et il a fallu une décision pour cela — B22. La liste était close à cinq depuis le premier jour ; ce qui l'a rouverte est qu'un compte créé depuis la console n'avait aucun chemin vers un mot de passe, donc ne pouvait jamais entrer. La plateforme envoie désormais une invitation et ne connaît jamais le mot de passe : le jeton part vers l'adresse, et c'est qui tient la boîte qui choisit.
Aucun fichier de src/domain/ ni de src/routes/ n'importe depuis
src/adapters/. Ce n'est pas une convention : le lint typé le vérifie, et
c'est la raison pour laquelle le plancher TypeScript du dépôt ne bouge pas tant que
l'outillage ne suit pas.
Un bouchon écrit d'après l'annotation de type confirme l'hypothèse au lieu de l'éprouver : l'adaptateur et son faux deviennent cohérents entre eux, et faux tous les deux. Ça a caché cinq défauts de l'adaptateur du fournisseur — dont un corps de requête refusé d'emblée, qui rendait toute création de machine impossible.
Les bouchons se façonnent désormais d'après une réponse capturée
(tests/fixtures/), et celui du fournisseur refuse ce que le fournisseur
refuse : une clé ajoutée à la requête sans être ajoutée au schéma d'entrée fait échouer
la suite, et non la facture.
waiting_capacity est une étape de queued, pas un cinquième
état, et la distinction a une conséquence mesurable : un job retenu n’est jamais dépilé,
donc il ne consomme pas de tentative et n’entame pas le délai de traitement de deux heures.
La règle vit dans le SELECT lui-même — il n’y a rien à compenser ensuite.
Notula est une application où des étudiantes déposent des enregistrements de cours. Elle veut une fiche de révision. Voici ce qui se passe, dans l'ordre. Les lignes bleutées sont Notula, les ocre la machine louée.
Une étudiante dépose un cours de 90 minutes.
Notula la connaît ; Broulala ne la connaîtra pas. Aucun identifiant de personne ne traversera l'appel.
Crée l'envoi : Upload-Length: 84213765, en tus 1.0.0.
La taille est annoncée d'abord, de sorte qu'un fichier hors plafond soit refusé avant que quelqu'un passe une demi-heure à l'envoyer. Elle était auparavant signée dans l'URL et le stockage refusait lui-même : la garde était dans le lien, elle est passée dans du code — c'est ce que la décision B10 a coûté, et il faut le savoir perdu.
La réponse est un 201 sans corps, dont le Location porte
l'upload_id : il n'y a pas deux identifiants à faire correspondre.
Envoie les octets par tronçons de 4 Mio, reprenables.
Un corps unique de 150 Mo est refusé par le reverse-proxy devant la plateforme —
mesuré le 17/09/2026, un 413 émis par Cloudflare alors que la plateforme
avait dit oui. Un tronçon passe en quelques secondes, donc le délai d'inactivité de
cent secondes d'un proxy ne devient jamais un sujet non plus.
Une reprise repart du décalage que le serveur annonce, jamais de ce que le
client croit avoir envoyé. Un désaccord se refuse en 409 au lieu de se
rattraper : un serveur qui rattrape écrit un jour des octets au mauvais endroit.
Le type réel est établi par ses octets magiques, jamais par l'extension ni par
le Content-Type déclaré. Aucun nom de fichier n'est accepté ni
rendu : c'est de la donnée personnelle sans consommateur, et elle finirait dans un
journal.
Écrit chiffré au repos, en AES-256-GCM.
Crée le job : les upload_ids, un template_id, un context.
L'enveloppe est close : Zod refuse tout champ inconnu plutôt que de l'ignorer.
Le context est validé contre le context_schema du template —
un champ manquant est un CONTEXT_INVALID que le client peut corriger.
Les deux plafonds mensuels sont vérifiés ici et pas à la fin : un job refusé après avoir tourné a coûté le temps machine qu'on voulait éviter.
Réponse : 201, { "job_id": "job_…", "status": "queued" }.
Dépile — ou ne dépile pas.
Si le client a demandé on_degraded: "wait" et que la capacité n'est pas
pleine, le job n'est pas pris. Ce n'est pas « pris puis reposé » : ne pas le
prendre ne consomme aucune tentative et ne démarre pas le chronomètre des deux heures.
La règle est dans le SELECT lui-même.
Et un job qui porte un template n'est pas pris non plus en mode dégradé, même
sous proceed — décision B7. Le chemin dégradé est une transcription sans
carte ; la génération n'a pas d'équivalent, parce que le modèle ne tient pas sur la
machine de la plateforme. proceed veut dire « pars avec ce qui est
disponible », et pour ce job-là ce dont il a besoin ne l'est pas : le prendre
produirait une transcription puis un échec certain, en ayant dépensé le temps machine
et le quota. Même SELECT, une condition de plus.
Il y a du travail pour ce moteur, et aucune machine : il en loue une.
Cartes essayées dans l'ordre du moins cher au plus disponible, centres de données vérifiés dans l'EEE au démarrage — une « région Europe » de fournisseur couvrirait Londres ou Istanbul.
Ce qu'il compte n'est pas la longueur de la file mais ce qu'elle réclame : un job dont l'entrée est déjà du texte n'appellera jamais ce moteur, et ne fait donc lever aucune machine de transcription.
La machine met quelques minutes : WhisperX et 9 Go de poids à charger.
La machine appelle chez vous et publie ses moteurs.
Deux redirections seulement, autorisées nommément par permitlisten :
19000 pour la transcription, 18080 pour la génération. Le
compte du tunnel n'a ni shell, ni commande, ni redirection sortante.
Transcrit. Les 90 minutes deviennent des segments horodatés.
L'audio part en multipart et la connexion reste ouverte sans rien dire jusqu'à
la fin — d'où node:http plutôt que fetch, dont le délai de
cinq minutes couperait chaque transcription.
Une réponse 200 qui ne contient aucun segment est un
échec — PROCESSING_FAILED — jamais un résultat vide qu'un client
stockerait et découvrirait des semaines plus tard.
Génère la fiche, dans l'ordre garanti.
Le prompt est composé invariants de la plateforme, puis template, puis
context. Cet ordre est une garantie, et elle est testée : un
context portant une consigne ne modifie pas le comportement d'un template.
Le contexte d'un client est une donnée, jamais une instruction.
Pour un template à sortie JSON, le schéma est envoyé comme contrainte de décodage : le moteur ne peut pas produire autre chose que sa forme.
Les citations sont localisées, pas crues.
Le moteur écrit l'identifiant du segment et le texte repris mot pour mot ; c'est la plateforme qui calcule les indices en le localisant. Un modèle ne sait pas compter des caractères — mesuré : sur une phrase courte et un mot évident, il rend la bonne longueur à la mauvaise position.
Un texte introuvable dans le segment cité est une citation qui ne tient pas, et
l'élément qui la porte est écarté — les autres sont rendus, avec un
CITATIONS_DISCARDED qui dit combien et pourquoi. Le tout-ou-rien a tenu
jusqu'au 18/09/2026 et ne se dégradait pas, il s'effondrait : le moteur rate une
citation sur douze, donc douze citations donnaient 72 % d'échec — il punissait le
plus durement les templates qui citent le plus, c'est-à-dire les plus utiles. La
garantie, elle, ne cède pas : ce qui atteint le client reste vérifié. Décision B19.
Si aucune citation ne tient, c'est OUTPUT_INVALID. Une sortie dont
rien ne tient n'est pas appauvrie, elle est fausse, et un tableau vide rendu en
done serait le silence que le §2.7 refuse.
Et une citation est bornée par ce qu'elle cite (décision B18) : la grammaire envoyée au moteur plafonne le passage au plus long segment de l'entrée, en octets UTF-8. Ce n'est pas un réglage mais un fait de l'entrée — une chaîne plus longue ne peut être la citation d'aucun segment, donc aucune citation légitime n'est refusée. Sans elle, un moteur lancé dans une chaîne n'a rien qui le pousse à la fermer : douze minutes et trente-quatre secondes de carte louée pour du JSON inachevé, mesurées.
Le job passe à done, le média est supprimé.
Immédiatement, y compris l'objet sur disque. Broulala ne rejoue jamais un média : elle le lit une fois, puis l'efface — c'est aussi pourquoi le chiffrement au repos est simple plutôt que seekable.
Les compteurs du jour s'incrémentent — appels et secondes d'audio, une ligne par
organisation et par jour. Le coût, lui, n'est pas encore calculé : aucune ligne de
tarif n'existe, et le détail d'un appel rend donc cost_cents: null plutôt
qu'un zéro qui voudrait dire « on ne sait pas ». Le mécanisme prévu — calculer une
fois, au tarif en vigueur, puis recopier la référence de ce tarif, pour que changer un
prix ne réécrive pas les factures passées — reste à construire.
Ce qui reste, un balayage l'efface. Ce n'est pas un job (décision B14) : une purge n'a ni propriétaire, ni client qui la relit, ni identifiant à rendre, donc elle tourne dans la boucle du worker avant le dépilage, comme l'expiration des attentes et la sonnerie des webhooks. Un envoi que personne ne réclame vit 24 h ; le média d'un job en erreur, 48 h — le temps de reproduire l'incident ; un résultat et le texte qui l'a produit, 30 jours.
La sonnette — et elle ne porte rien du contenu.
Quatre champs : l'identifiant de l'événement, son type, l'instant, et un
data qui dit le job, son état et le template. Le client apprend qu'il doit
aller voir, et va voir avec sa clé. Une transcription pèse des centaines de kilo-octets
et les reprises la réexpédieraient autant de fois ; un gros corps atterrit dans les
journaux d'accès du destinataire ; et surtout une livraison peut se perdre — si
le résultat ne vient que par là, elle perd la donnée.
Signée sur t et le corps brut, en HMAC-SHA256. Le
t est dans le message signé et pas seulement à côté : sinon la
fenêtre de cinq minutes que vérifie le destinataire ne voudrait plus rien dire.
Elle échoue sans rien changer : 1, 5, 15, 60, 360 minutes puis abandon, et le job reste exactement aussi consultable. C'est ce qui permet au contrat d'appeler le webhook un confort et jamais un chemin — l'étape suivante, elle, est le contrat.
Récupère la transcription et la fiche.
Avec service.mode — le mode réellement employé — et
waited_seconds. Un client qui estime des durées doit segmenter son
échantillon là-dessus : trois minutes et cent cinq minutes pour le même audio sont deux
populations.
Le bloc engine nomme les moteurs qui ont réellement répondu et la
version du template. Un client qui doit rester capable d'interpréter une vieille sortie
persiste ce bloc avec elle.
Et « réellement » se constate, il ne s'épingle pas (décision B16).
generation_model porte le fichier de poids et le commit lus dans le
GET /props du moteur —
…q4_k_m-00001-of-00003.gguf@b466e1f8… — parce que
llama-server -hf résout son dépôt en un commit à chaque démarrage :
un dépôt qui bouge ferait charger d'autres poids sous exactement le même nom. La forme
dit sa propre fiabilité : une valeur avec @ a été constatée, une valeur
sans @ est l'alias configuré, écrit parce que personne n'a pu regarder.
transcription_model reste sans @ — ce moteur-là ne dit nulle
part ce qu'il a chargé, et l'absence le dit au lieu de le taire.
Plus de travail : la machine est détruite, pas arrêtée.
Après une grâce de quelques minutes, parce que le travail arrive entre deux tours : détruire prend des secondes, recréer prend des minutes de chargement de modèles.
Détruite et non arrêtée, parce qu'une machine arrêtée garde sa spécification chez le fournisseur — donc ses secrets continuent d'être publiés après « l'arrêt ».
C'est le lien vers les moteurs qui transporte l'audio et les transcriptions. C'est donc la règle qui compte le plus : chiffré dès qu'il quitte la machine, et du clair n'est acceptable que vers une adresse que l'internet ne peut pas joindre.
http://127.0.0.1:19000 est honnête. http://<pod public>:9000
ne l'est pas, quoi qu'on écrive à côté. L'adaptateur refuse d'exister sur une
adresse publique en clair — au démarrage, pas au premier job d'un client.
Une machine louée appartient à quelqu'un d'autre : le lien traverse l'internet public et n'a
aucune des deux propriétés qu'il faudrait. Le port TCP direct d'un fournisseur est en clair,
et son proxy HTTPS coupe une connexion au bout de cent secondes là où un POST /asr
en tient une ouverte pendant toute la transcription sans rien envoyer avant la fin.
C'est ce qui impose le tunnel.
Et le sens de l'appel est délibéré : c'est le pod qui compose. Un pod qui n'expose aucun port n'a aucune surface, et son adresse publique — réattribuée à chaque démarrage — n'a plus besoin d'être connue de personne.
La plateforme dit aux clients dans quel état elle est, par GET /v1/capacity. La
ligne qu'elle publie porte un bail et jamais un drapeau.
Elle porte aussi deux axes et non un, depuis que les deux moteurs vivent sur deux
machines qui peuvent manquer séparément. mode est ce qu'il a toujours été — la
qualité du chemin de transcription, carte louée, processeur de la plateforme, ou
rien — et serves dit ce que la plateforme accepte : transcription,
génération, les deux, ou aucun.
mode | serves | ce que ça dit |
|---|---|---|
full | ["transcription"] | une carte pour transcrire, pas de génération |
full | ["transcription","generation"] | tout, et vite |
degraded | ["transcription"] | notre processeur seulement |
unavailable | ["generation"] | aucune transcription, mais une fiche peut se rédiger |
serves nomme ce qui est servi, jamais ce qui tourne : un client
n'apprend pas combien de machines sont louées, il apprend si son job sera pris. C'est ce
qui le distingue d'une fuite de topologie — le jour où un troisième moteur arrive, le
champ gagne une valeur et rien d'autre ne bouge.
Les deux axes restent séparés parce que mode ne fait pas qu'informer :
il route. Le worker choisit son moteur de transcription dessus. Une première
rédaction posait que full impliquait les deux services — une plateforme
transcrivant sur carte louée sans machine de génération aurait alors dû s'annoncer
degraded, et se serait donc routée sur son propre processeur : cent cinq
minutes au lieu de trois, pendant qu'une carte allumée et facturée ne faisait rien.
Passé mode_until, plus rien dans la ligne n'est affirmé. Un superviseur qui
meurt cesse de promettre au lieu de continuer à raconter que tout allait bien à
l'instant où il est mort.
La plateforme applique la règle elle-même plutôt que de la laisser au client : un client qui
oublie la vérification obtiendrait un mensonge précisément quand la plateforme est au plus
mal. Un bail périmé rend degraded — l'échec sûr dans les deux sens.
Le superviseur est un service, pas une frontière : il consomme Compute.
Chacune de ses règles répond à la même question — que faire quand une lecture manque, tarde,
ou se contredit.
Ce que chaque job demande se lit sur lui, jamais sur sa catégorie — c'est la correction que B12 a dû s'apporter à elle-même, car elle a d'abord été écrite « un job à template lève les deux machines », ce qui est faux du job à template dont l'entrée est du texte.
| Entrée | Template | Machines levées |
|---|---|---|
| média | non | transcription |
| média | oui | transcription et génération |
| texte ou transcript | oui | génération seule |
| texte ou transcript | non | aucune |
Quand les deux sont nécessaires, elles sont levées en parallèle et non en séquence : le chargement de Qwen se compte en minutes, et le placer après la transcription en ferait du temps mort pur, alors qu'en parallèle il se cache derrière un travail plus long que lui.
La machine de génération existe depuis le 18/09/2026, et le montage à deux machines
a porté un job de bout en bout le jour même — média plus template,
mode: "full", dix-neuf minutes et quarante secondes dont cinq d'attente de
capacité, les deux cartes levées en parallèle comme B12 le demande : transcription de
11h28 à 11h52, génération de 11h31 à 11h49.
Un pod porte un rôle : start.sh n'exécute au premier plan que le moteur
que son rôle nomme, dial.sh n'ouvre que la redirection de ce rôle, et chaque
rôle a sa propre clé de tunnel bornée à son propre port — de sorte qu'une clé fuitée d'une
machine de génération n'ouvre pas le port par lequel passe l'audio déchiffré.
Il lève les deux rôles, et ce n'était pas vrai jusqu'au 18/09/2026 : il comptait la demande de transcription et pas celle de génération, parce que le §2.8 interdit un compteur avant son consommateur. Les deux existent désormais, et une passe tourne par rôle avec sa propre politique.
Éprouvé le 20/09/2026 : quatre machines levées dans l'après-midi sans que personne
ne les demande — deux de transcription, deux de génération — et leurs quatre fenêtres
fermées par lui. Ce qui le prouve n'est pas le journal mais la base :
MachineWindow.heldUntil n'est posé que par une levée à la main, et les quatre
lignes l'ont à null. La commande npm run pod:up generation reste
la voie de secours.
Un super-administrateur n'est pas un administrateur avec davantage de droits. Il voit tout ce qui décrit l'exploitation — organisations, quotas, clés, volumétrie, coûts, paramètres, durées, moteurs, versions de template, avertissements — et il ne voit pas le contenu.
L'existence de l'appel, son état, son entrée, son template, son mode, son coût, son erreur, et le nombre de mots.
Ni la transcription, ni la sortie générée, ni un extrait de l'une ou de l'autre.
Ce contenu n'est visible qu'à un admin membre de l'organisation qui l'a
produit.
La question posée est donc « êtes-vous administrateur membre de l'organisation propriétaire
de cet appel », et elle ne se rabat jamais sur le drapeau global. Un humain qui porte
les deux casquettes voit le contenu de sa organisation, par sa ligne de
Membership, et d'aucune autre.
La frontière se tient au mot. Un nombre de mots est une métadonnée ; les mots sont du contenu. Un nombre de segments, oui ; le texte d'un segment, non. Une durée, un coût, un code d'erreur, oui ; un message de moteur qui citerait le texte, non.
Un cas de support n'a presque jamais besoin du texte : il a besoin des paramètres, de l'erreur, des durées, du moteur et de la version du template. On veut la transcription quand la sortie est mauvaise — et c'est précisément le moment où le client peut l'envoyer lui-même. Le jour où un tiers est client, c'est la règle qu'on peut lui promettre sans réserve.
Et chaque lecture de contenu est journalisée — qui, quel appel, quand. C'est ce qui reste quand la confiance ne suffit plus.
Une idée traverse tout : l'argent s'écrit, il ne se réécrit pas.
| Objet | Règle | Pourquoi |
|---|---|---|
DailyUsage |
Une ligne par organisation et par jour. Aucune clé étrangère. | Elle survit à la suppression de l'organisation — une contestation peut porter sur un mois clos. Une relation coupée garderait la ligne et perdrait le « à qui ». C'est sûr parce qu'elle ne porte rien d'autre : ni identifiant de job, ni paramètre, ni contenu. |
quota_minutes |
En minutes d'audio. null = illimité. |
Un job de dix minutes et un de deux heures diffèrent d'un facteur douze en temps de
machine. null et jamais 999999 : un nombre magique finit
affiché tel quel sur une jauge. |
budget_cents |
Un disjoncteur, indépendant des minutes. | Il attrape ce que les minutes ne voient pas : une génération repartant d'une entrée
transcript ne consomme aucune minute d'audio et coûte pourtant du calcul.
Une organisation illimitée le garde — il ne protège pas d'un client, il protège d'une
boucle. |
Rate |
Une ligne de plus, jamais une modification. | Un tarif modifiable réécrirait le passé : les 12 € du mois dernier deviendraient 15 € parce qu'on a touché une valeur. |
| Rallonge | S'ajoute au plafond de la période, ne le modifie pas. | « 600 minutes, plus 600 accordées le 15 par untel » est une phrase vérifiable six mois plus tard. « 1200 minutes » ne l'est pas. |
Illimité mesure quand même. Seul le plafond disparaît, pas le compteur : l'organisation sans limite est celle dont on veut le plus connaître la consommation. Et la console écrit « Illimité » comme un mot, jamais comme une jauge pleine — une barre inventerait un nombre que la plateforme refuse de porter.
http://app-cliente:3000/… sur un réseau de conteneurs étant un déploiement
légitime que n'importe quel filtre de plages privées aurait refusé.request-id entrant n'est jamais cru. Fastify l'accepte par défaut ;
Broulala le refuse et frappe le sien. Laisser un appelant le choisir se paie au premier
incident./health rend {"status":"ok"} et rien d'autre. Ni version,
ni identifiant de build, ni état d'une dépendance — et elle ne touche pas la base, sinon
elle devient un moyen de la sonder sans authentification.deletedAt est interdit : supprimer
supprime, y compris l'objet sur disque. Sauf une clé d'API, qui ne se supprime
jamais — « cette clé a servi jusqu'au 12 mars » est ce qu'on vient chercher six mois
plus tard.null. Jamais une approximation, jamais un zéro qui veut dire « on ne sait
pas ».maxLength à côté d'un enum était ignoré
en silence, et un pattern contenant un accent cassait la construction de la
grammaire — donc tous les jobs d'un template, sur une plateforme dont la seule langue est
le français. Une version publiée est immuable : c'est une promesse, et publier une
promesse qu'on ne tiendra pas est le défaut.details dit qui répare. Décision
B20. OUTPUT_INVALID reste un seul code — la conduite est la même dans les
trois cas, ne pas retenter, le décodage étant déterministe — mais il porte un
details.reason parmi schema, truncated et
citations : un template mal écrit et un moteur qui produit trop ne se
réparent pas par la même personne, et router sur de la prose française est ce que ce
contrat interdit ailleurs.Si un client demande quelque chose que l'enveloppe ne sait pas exprimer, la réponse par défaut est un template, pas un champ. C'est ce qui garde la plateforme petite pendant que le catalogue grossit.
Les règles citées ici vivent dans CLAUDE.md, les contrats dans
docs/api-contract.md et docs/admin-api-contract.md, les mesures dans
docs/mesure-whisperx.md, docs/mesure-generation.md et
docs/mesure-image-elaguee.md, le tunnel dans
docs/tunnel.md. En cas de contradiction, les contrats et
CLAUDE.md priment sur ce document : celui-ci explique, il ne décide pas.