Broulala

Comment ça marche, de bout en bout

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

  1. Ce que la plateforme fait, et ce qu'elle refuse de savoir
  2. Les machines, et ce qui tourne dessus
  3. Les deux portes, et le joint entre elles
  4. Les six frontières
  5. Le parcours d'un cours, pas à pas
  6. Pourquoi un tunnel, et pas une adresse
  7. La capacité, et le bail qui expire
  8. Le contenu, et la seule inversion de la hiérarchie
  9. L'argent : compteurs, plafonds, tarifs
  10. Ce qui n'arrive jamais

1. Ce que la plateforme fait, et ce qu'elle refuse de savoir

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.

La règle qui tient tout le reste

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.

2. Les machines, et ce qui tourne dessus

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.

Les deux machines et le lien entre elles À gauche, votre serveur : les conteneurs api, worker, db, un whisper sans carte qui sert le mode dégradé, et le tunnel. À droite, les machines louées, une par moteur : celle de transcription, qui porte WhisperX ; celle de génération, qui portera Qwen et n'est pas encore construite — elle est dessinée en pointillé pour cette raison. Chacune porte son propre client SSH. Le worker a deux chemins vers un moteur de transcription — le conteneur d'à côté quand aucune machine n'est louée, et la boucle locale que le tunnel a peuplée quand il y en a une. C'est la machine louée qui compose le tunnel, jamais l'inverse. À vous · que vous exploitez Le serveur api Fastify · les deux portes · 127.0.0.1:3000 worker dépile, transcrit, génère db PostgreSQL 16 dédié · 127.0.0.1:5433 whisper le mode dégradé · sans carte · whisper:9000 tunnel sshd · écoute :2222 — le seul port public Tout le reste est sur la boucle locale. Louée à la minute · EEE · à quelqu’un d’autre Les machines GPU — une par moteur Machine de transcription whisper-asr-webservice · 127.0.0.1:9000 moteur WhisperX · large-v3 Machine de génération llama-server · Qwen2.5-14B · 127.0.0.1:8080 décidée, pas encore construite dial.sh sur chacune — c’est elle qui compose Aucun port publié. Les moteurs n’écoutent que sur 127.0.0.1 : rien ne les joint, sauf le tunnel. Une machine, un moteur — décision B12. Ensemble, Qwen tenait quinze des vingt-quatre gigaoctets et affamait whisper. ssh -R · le pod appelle Deux chemins : le conteneur d’à côté quand aucune machine n’est louée, et 127.0.0.1:19000 et :18080 que le tunnel a peuplés.
Le sens de la flèche est tout le sujet. La machine louée ne reçoit rien : c’est elle qui compose, vers le seul port public du montage. 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.

Et le worker a deux chemins, pas un. Quand une machine est levée, il parle à la boucle locale que le tunnel a peuplée. Quand il n’y en a pas, il parle au conteneur 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.
ConteneurOùCe qu'il faitCe qu'il expose
apichez vousLes deux portes HTTP, la validation d'enveloppe, la mise en file127.0.0.1:3000 — boucle locale seule
workerchez vousDé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-cirien
dbchez vousPostgreSQL dédié — sa propre instance, son volume, son réseau127.0.0.1:5433
tunnelchez vousReçoit l'appel du pod et publie ses moteurs en local:2222 — le seul port public
whisperchez vousLe 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
migratechez vousApplique les migrations. Une décision d'exploitant, jamais un conteneur qui démarrerien
le podloué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 factureaucun port
Pourquoi une base dédiée

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.

3. Les deux portes, et le joint entre elles

Deux publics, deux portes, et elles ne communiquent pas.

Les deux portes et le mur entre elles À gauche, la porte des systèmes clients, authentifiée par clé d’API. À droite, celle des humains, authentifiée par jeton de session. Chaque greffon ne reçoit qu’une seule façon d’authentifier, et aucun n’importe le module de l’autre : il n’existe aucun chemin de code de l’une à l’autre. Un système client — Notula, GestiCSE… une machine, jamais un humain Un humain — la console d’administration jamais un système client Greffon /v1/* authenticateApiKey Bearer <clé d’API> · SHA-256 POST /upload-urls · POST /jobs GET · DELETE /jobs/:id GET /templates · GET /capacity Portée : son organisation. Le catalogue est commun. Greffon /admin/v1/* authenticateSession Bearer <jeton de session> · SHA-256 POST /session · /organisations /members · /api-keys · /templates /jobs · /usage · /rates Portée : ses memberships, plus le drapeau global. Aucun chemin de code de l’un à l’autre
Ce n’est pas une règle qu’on se rappelle à la relecture, c’est une propriété de structure. Chaque greffon reçoit exactement une façon d’authentifier, et aucun n’importe le module de l’autre — il n’y a rien à percer parce qu’il n’y a rien à traverser. Un test vérifie les deux sens quand même : une clé d’API sur /admin/v1/*, un jeton de session sur /v1/*.
L'invariant le plus important du dépôt

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.

Ce qui a disparu derrière la porte des humains, le 18/09/2026

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.

4. Les six frontières

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.

PortProductionFictiveCe qu'il protège
StorageLocalDiskStorageInMemoryStorageUn S3Storage s'ajoute sans toucher au métier
TranscriptionEngineWhisperWebserviceEngineFakeTranscriptionEngineLe format interne — segments horodatés — est le nôtre
GenerationEngineLlamaCppEngineFakeGenerationEngineMarkdown restreint ou objet conforme, jamais l'enveloppe brute d'un moteur
ComputeRunPodComputeFakeComputeChanger de fournisseur de machines est une journée
WebhookSenderSignedHttpWebhookSenderInMemoryWebhookSenderUne livraison se teste sans serveur en face
MailerSmtpMailerInMemoryMailerUn 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.

Ce que les faux ont coûté et rapporté

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.

5. Le parcours d'un cours, pas à pas

D'abord, les quatre états d'un job — et les étapes, qui n'en sont pas

Les états d’un job et ses étapes d’affichage Un job a quatre états : queued, processing, done et error. Les étapes — waiting_capacity, transcribing, generating — sont un renseignement d’affichage porté par les deux premiers états, et non des états supplémentaires. Les états · ce sur quoi un client peut décider queued en attente processing au travail done résultat disponible error avec un code reprise · backoff plafonné avec gigue Les étapes · un renseignement d’affichage, jamais une machine à états waiting_capacity porté par queued transcribing porté par processing generating porté par processing Toujours nul sur un état terminal. Un client n’a pas le droit d’en faire une machine à états. L’attente de capacité Le job n’est pas pris, plutôt que pris et reposé. Aucune tentative consommée, le chronomètre ne démarre pas.
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.

1
Notulason serveur

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.

2
Notula → apiPOST /v1/uploads

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.

3
Notula → apiPATCH · tus

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.

4
Notula → apiPOST /v1/jobs

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" }.

5
workerSELECT … SKIP LOCKED

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.

6
superviseurlève une machine

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.

7
le pod → tunnelssh -R

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.

8
worker → whisperPOST /asr

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.

9
worker → llamadécodage contraint

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.

10
plateformevérifie

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.

11
workertermine

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.

12
worker → NotulaPOST, signé

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.

13
Notula ← apiGET /v1/jobs/:id

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.

14
superviseurcouche la machine

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 ».

6. Pourquoi un tunnel, et pas une adresse

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.

C'est l'adresse qui décide, pas le schéma

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.

7. La capacité, et le bail qui expire

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.

modeservesce 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
Pourquoi deux champs, et pas un mot de plus

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.

Une ligne vieille se lit « personne ne veille »

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éeTemplateMachines levées
médianontranscription
médiaouitranscription et génération
texte ou transcriptouigénération seule
texte ou transcriptnonaucune

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.

Ce qui existe, et la seule chose qui manque

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.

8. Le contenu, et la seule inversion de la hiérarchie

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.

Ce qu'un super-administrateur voit

L'existence de l'appel, son état, son entrée, son template, son mode, son coût, son erreur, et le nombre de mots.

Ce qu'il ne voit pas

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.

Pourquoi cette inversion

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.

9. L'argent : compteurs, plafonds, tarifs

Une idée traverse tout : l'argent s'écrit, il ne se réécrit pas.

ObjetRèglePourquoi
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.

10. Ce qui n'arrive jamais

Et une règle qui gouverne le reste

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.