Skip to content

v0.1.143

Choose a tag to compare

@github-actions github-actions released this 30 Aug 15:11
· 37 commits to main since this release
200158f

Changed

  • La rotation du présentateur suit désormais son audience en direct. Jusqu'ici elle restait
    locale : le présentateur redressait un document couché et l'audience continuait de voir le document
    couché pendant qu'il commentait un document droit. Migration 0024doc_presentations gagne
    view_rotation.
    ⚠️ view_rotation et non rotation, pour deux raisons. Le nom dit rotation de la vue, par
    opposition au /Rotate que porte le fichier — deux choses que le player compose au lieu de les
    confondre. Et le nom court serait entré en collision avec l'option rotation de pdf.js, présente
    partout dans le code de la visionneuse : la garde qui vérifie qu'une colonne migrée n'est jamais
    écrite sans condition aurait alors crié en permanence sur des lignes qui ne touchent pas la base.
    Une alerte qui sonne quand tout va bien apprend à cliquer à côté.
    ⚠️ Sans la migration, rien ne casse — et c'est ce qui a demandé le plus de soin. PostgREST
    rejette le PATCH entier sur une colonne inconnue : nommer view_rotation chez un hôte non
    migré ne ferait pas perdre la rotation, ça ferait perdre aussi le changement de page, donc le
    pilotage en direct tout entier. Le champ n'est donc écrit que derrière la sonde de schéma, et un
    banc le prouve avec une doublure qui lève sur une colonne inconnue, comme PostgREST.
    ⚠️ La rotation voyage avec la page, par le même message. Lui donner son action propre lui aurait
    donné son propre rang d'écriture, et deux écritures concurrentes auraient pu s'inverser — l'audience
    recevant une rotation postérieure à la page qu'elle précède. Un message, un ordre.
    ⚠️ Elle est normalisée à la réception, des deux côtés. Sur la voie broadcast, cette valeur
    vient du navigateur du présentateur : un viewport oblique casserait la couche de texte de toute
    l'audience, pas seulement de celui qui l'envoie. Le serveur applique une liste blanche ; le
    module de décision ramène au quart de tour. ⚠️ Et les deux portes ne disaient pas la même
    chose
    Number("90") vaut 90, donc le serveur acceptait une chaîne que le navigateur rejetait.
    Relevé par un banc, pas par une relecture : deux validateurs du même geste qui divergent, c'est
    l'un des deux qui ment et on ne sait pas lequel.

  • Panneau de vignettes, à gauche du document — bouton dans la barre et entrée de menu, replié par
    défaut, tiroir plutôt que colonne sous 860 px. Cliquer une vignette navigue ; la vignette courante
    est marquée. Masqué sur un document image ou d'une seule page, où il n'aurait rien à montrer.
    ⚠️ Le moteur vient du chat, sauf la partie qui compte. Le générateur de vignettes de
    gabarit-live.js apporte le cache borné, la file de concurrence et le chargement paresseux. Ce qui
    ne se transpose pas est son getDocument suivi d'un destroy : ce panneau montre les pages du
    document déjà ouvert. Le reprendre tel quel téléchargerait le fichier une seconde fois et ferait
    tourner deux workers sur le même PDF. Un banc compte les requêtes vers le fichier et exige zéro.
    ⚠️ Le suivi automatique se désarme dès que le lecteur touche le panneau — sinon chaque
    changement de page ramène le panneau sur la page en cours et le lecteur se bat contre son outil. On
    écoute les gestes, pas l'événement de défilement : un défilement que nous provoquons n'est pas une
    intention du lecteur.
    ⚠️ Deux défauts trouvés en mesurant, tous deux miens. Un rendu de vignette en échec restait
    marqué « faite » et n'était jamais retenté — précisément ce que le chemin principal des pages
    documente et évite. Et le correctif naïf en introduisait un pire : ré-observer un élément déjà
    visible rappelle l'observateur immédiatement, donc une boucle infinie sur un moteur où les
    rendus échouent en série. La reprise est bornée à deux tentatives ; mesuré : la file s'arrête à 2
    rejets au lieu de tourner sans fin.
    ⚠️ Et deux de mes sondes annonçaient « borné ✓ » et « libéré ✓ » sur zéro vignette rendue. Un
    zéro qui vient de ce qui n'a jamais eu lieu satisfait « au plus 48 » aussi bien qu'un moteur qui
    marche. Les quatre bancs portent désormais un plancher qui refuse un panneau vide.
    ⚠️ Et une course, trouvée par la forge et impossible à voir ailleurs. Ouvrir le panneau
    reconstruit le document et reporte de 30 ms la restauration de la page courante — la géométrie
    n'est pas stable avant. Ce report survivait à une navigation faite dans l'intervalle et la
    défaisait : ouvrir le panneau puis cliquer aussitôt une vignette ramenait le lecteur à sa page
    de départ. Invisible dans l'environnement de développement, où les vignettes ne se rendaient pas et
    où le banc attendait donc bien au-delà des 30 ms. Toute navigation explicite périme désormais un
    report en attente
    , et un banc ouvre le panneau et clique dans le même instant.

  • Rotation du document à 90°, à gauche ou à droite — dans la barre et dans le menu « ⋯ », comme
    le zoom, et repliée dans le menu sous 860 px par la même règle. Un quart de tour par clic ; quatre
    clics ramènent à l'identique. Rotation du document, pas de la page : une rotation par page est
    un autre modèle de données et une autre interface.
    ⚠️ La rotation du fichier est COMPOSÉE, jamais écrasée. getViewport({rotation}) de pdf.js dit
    « si omise, elle vaut la rotation de la page » : passer une valeur absolue écrase le /Rotate que
    portent très couramment les documents numérisés en paysage. Un « remettre à zéro » naïf ne
    redresserait pas un document de travers — il coucherait un document qui était droit. Un banc
    ouvre un document portant /Rotate 90 sans rien tourner et exige qu'il s'affiche couché.
    ⚠️ La proportion tournée protège le SUIVI DE LECTURE, pas seulement l'affichage. Elle fixe la
    hauteur des gabarits posés avant rendu, qui fixe la longueur du document, qui décide de la page que
    l'observateur d'intersection appelle « courante » — et c'est celle-là que le suivi enregistre.
    Mesuré : la hauteur totale change bien avec la rotation, et la page courante survit au quart de tour.
    ⚠️ Les documents image pivotent aussi, par un second chemin. Sans pdf.js il n'y a pas de
    viewport : la rotation est une transformation CSS, et un élément transformé occupe toujours sa boîte
    d'origine — sans échanger les dimensions du cadre, la page suivante viendrait se poser par-dessus.
    ⚠️ Et le banc de ce cas passait à vide au premier jet : l'image du harnais est carrée, donc
    échanger sa largeur et sa hauteur y est invisible. Le harnais porte désormais une image franchement
    rectangulaire, et le banc refuse de tourner sur une image carrée.
    Mesuré aussi : tourner pendant que le document est zoomé conserve le zoom, inverse la proportion, et
    le pire canvas reste sous le budget de pixels annoncé.

  • ⚠️ Le pincement au trackpad zoomait l'écran entier — parce que personne n'écoutait. Sur
    trackpad, un pincement n'est pas un événement tactile : le navigateur l'envoie comme un wheel
    portant ctrlKey. Rien dans la visionneuse ne le lisait, donc le navigateur appliquait son défaut.
    ⚠️ Et le défaut était plus large qu'un confort manquant : sous 860 px la barre replie les
    boutons de zoom, donc sur mobile le pincement était le seul zoom qu'un lecteur pouvait tenter
    et il déformait toute l'interface. Le geste est désormais réclamé sur la surface du document
    (trackpad, Ctrl+molette, événements de geste de Safari, deux doigts), et le zoom du navigateur
    reste disponible sur le reste de l'interface
    : le retirer partout serait une régression
    d'accessibilité pour qui grossit le chrome plutôt que le document.
    ⚠️ En deux temps, parce qu'un seul ne tient pas. Une reconstruction vide le conteneur, annule
    les rendus en vol et recrée toutes les pages : juste pour un clic, ruineux pour un geste continu.
    Pendant le pincement, une simple transformation ; la reconstruction arrive une fois, à l'arrêt.
    Mesuré dans Chromium : 25 événements de pincement → 1 reconstruction.
    ⚠️ Le point visé ne fuit plus, et la mesure a corrigé le modèle. Première écriture : 14,1 px
    de fuite mesurés dans un vrai navigateur. La cause n'était pas le geste mais l'arithmétique — les
    22 px de marge en haut du conteneur arrivent avant la première page et ne s'étirent pas, alors
    que les espaces entre pages sont proportionnels au nombre de pages au-dessus du point visé.
    Épargner cette tête ramène la fuite à 0,9 px. Les deux valeurs sont figées dans les bancs.
    ⚠️ Et les boutons héritent de l'ancrage : leur dérive — les pages changent de largeur, le
    défilement reste en pixels — existait déjà et disparaît.
    ⚠️ Défaut trouvé en chemin : le zoom ne faisait rien sur un document image. Le garde
    if (pdfDoc) est faux pour une image, alors que le commentaire du rendu d'image annonce que « tout
    le chrome — loader, zoom, plein écran… — fonctionne tel quel ». Inaperçu tant que le zoom tenait à
    deux boutons ; intercepter le pincement sans le corriger aurait avalé le geste sur ces
    documents, donc fait pire qu'avant.

  • Socle du chantier « trois gestes » : l'arithmétique du zoom au geste et de la rotation entre dans
    src/viewer.ts, sans aucun changement visible.
    Trois fonctions pures et une extension, couvertes
    par vingt-deux bancs de plus — le module sans DOM est le seul endroit où ces calculs sont
    éprouvables, le reste vivant dans un littéral de gabarit qu'aucun banc n'exécute.
    ⚠️ rotationEffective COMPOSE la rotation du fichier avec celle demandée, au lieu de l'écraser.
    getViewport({rotation}) de pdf.js dit : « si omise, elle vaut la rotation de la page » — donner
    une valeur absolue écrase donc le /Rotate que portent très couramment les documents numérisés en
    paysage. Un « remettre à zéro » naïf ne redresserait pas un document de travers : il coucherait
    un document qui était droit.
    ⚠️ aspectApresRotation gouverne le suivi de lecture, pas seulement l'affichage. La proportion
    fixe la hauteur des gabarits, qui fixe la longueur du document, qui décide de la page que
    l'observateur d'intersection appelle « courante » — et c'est celle-là que le suivi enregistre. Une
    proportion non tournée à 90° fausserait les statistiques d'un partage sans rien casser à l'écran.
    ⚠️ ancrageApresZoom traite la marge de centrage, sans quoi il ancre correctement un document
    zoomé et fait sauter un document vu en entier — au moment exact où le lecteur commence à zoomer. Il
    distingue aussi la part du contenu qui NE grandit pas avec le zoom : cent pages séparées de 16 px
    portent plus de 1500 px d'espacements fixes.
    ⚠️ Et une mutation survivante a révélé du code mort, retiré plutôt que couvert : la symétrie
    appelait une marge de centrage « après » ; elle n'est non nulle que si le contenu reste plus étroit
    que le cadre, cas où la butée haute vaut zéro et la sortie vaut zéro quoi qu'on ajoute. Les deux
    conditions s'excluent. Un balayage de 195 840 combinaisons a confirmé zéro cas observable avant de
    la supprimer. Les onze autres mutations rougissent.

  • ⚠️ Deux règles de revue tirées de l'épisode de la requête de diagnostic, l'une de nous, l'autre
    d'un hôte.
    La première : une sonde qui demande « est-ce que ça échoue ? » à une question qui est
    « qu'est-ce que ça rend ? » rend compte d'elle-même, pas de son sujet.
    Deux de nos vérifications
    n'ont regardé que le code de sortie et ont déclaré sûre une requête qui rendait deux lignes
    fausses ; un hôte, qui dit avoir fait la même erreur trois fois dans la semaine, en a donné la
    version la plus nette — « j'ai annoncé "0 échec" alors que zéro test avait tourné ».
    La seconde, entièrement de lui : énumérer les valeurs d'une variable n'est pas un argument de
    couverture.
    Notre banc couvre les quatre valeurs que pg_policies.roles peut porter, et les deux
    défauts qui ont réellement mordu venaient d'ailleurs — l'écart entre les rôles du geste et ceux de
    la politique, puis l'inventaire de rôles du CLUSTER, qui n'est la propriété d'aucune politique.
    ⚠️ Et le défaut est qu'un axe exhaustif SE LIT comme une couverture, d'autant plus que
    l'énumération est complète. Un banc dit donc désormais quelles dimensions il fait varier et
    lesquelles il tient fixes
    : ce sont les fixes qui produiront le prochain signalement.
    ⚠️ Et « on a regardé et conclu que non » n'est pas « ce banc ne l'exerce pas » : les deux
    tombent dans le même paragraphe « non mesuré », et seule la seconde survit à celui qui la lit dans
    six mois. Une dimension écartée par raisonnement est donc inscrite en FIXE quand même, le
    raisonnement à côté d'elle et jamais à sa place.
    ⚠️ Et la règle sur le code de sortie porte désormais le déplacement dont elle est un cas,
    généralisé par le même hôte : « chaque fois qu'un instrument échoue, ce qu'il rend décrit
    l'instrument »
    . Ce dépôt en avait catalogué trois autres sans voir que c'était le même geste — le
    sha256 du vide, un 403 lu comme une absence, un grep sur un fichier qu'il ne sait pas lire.

  • ⚠️ Il y avait un QUATRIÈME profil de politique, et c'est la forme par défaut : {public}.
    Trouvé par un hôte le 29/08 pendant que le banc à trois profils tournait déjà. Une politique écrite
    sans clause TO s'applique à PUBLIC, donc à tous les rôles y compris anon — et
    pg_policies l'affiche {public}, jamais {anon}. Le filtre in ('anon','authenticated') la
    sautait intégralement. Ce n'est pas un cas rare : c'est ce que produit l'interface Supabase pour un
    bucket public, et l'hôte en a relevé cinq chez lui dont les trois qui servent ses buckets publics.
    Un hôte qui posait le revoke puis lançait la requête obtenait zéro ligne pendant que ses
    buckets étaient morts
    — le tableau exact que la requête existe pour éviter, sur le profil que
    personne n'avait construit.
    ⚠️ Et la piste proposée avec le signalement portait l'angle mort que son auteur annonçait :
    tester anon comme représentant de public se tait là où seul authenticated est privé, et
    nomme public comme rôle — ce qui laisse l'hôte sans savoir quel droit rendre. La forme retenue
    déplie public en ses rôles concrets, et nomme celui qui a perdu le droit.
    ⚠️ Et to_regrole n'est pas une coquetterie : sur une installation sans anon ni
    authenticated — tout ce qui n'est pas Supabase — has_table_privilege('anon', …) ne rend pas
    faux, il lève, et la requête entière échoue. Filtrer sur pg_roles ne suffit pas, mesuré : le
    planificateur évalue la fonction dans le même filtre de jointure. La forge contrôle désormais ce
    cas avant de créer les rôles, seul moment où sa base est dans cet état.
    Le banc passe à quatre profils et cinq scénarios, et garde les trois écritures écartées :
    l'écriture d'origine ne voit pas {authenticated}, la deuxième manque {anon,authenticated}, la
    troisième manque {public} quand seul authenticated est privé.

  • ⚠️ Un brouillon écrit avant une découverte n'est pas un brouillon neutre — il est faux, et il
    attend d'être envoyé.
    Le 28/08, six messages aux hôtes étaient rédigés ; le défaut de la requête
    de diagnostic qu'ils portaient a été trouvé entre la rédaction et l'envoi ; trois sont partis en
    l'affirmant quand même
    , dont un vers l'hôte qui venait précisément de la démonter, lui reposant
    sa propre question invalidée. Cet hôte a nommé ce que ça coûte du côté du destinataire — « un
    message qui croise n'est pas neutre : il se lit comme une réponse »
    — et le second a reçu la
    correction accompagnée de deux messages plus anciens qui la contredisaient, tous deux affirmant que
    le dépôt avait adopté la requête étroite, ce qui était vrai à l'heure où ils avaient été écrits.
    Deux contre un, le dépôt paraissant du mauvais côté : la correction pouvait raisonnablement passer
    pour l'erreur. AGENTS.md porte désormais la règle et son remède — relire tout message non envoyé
    à la lumière de ce qu'on a appris depuis, signaler explicitement un message qui en croise un autre,
    et nommer dans un erratum les messages qu'il périme ainsi que les phrases qu'il retire.

  • ⚠️ La requête de diagnostic donnée aux hôtes est désormais EXÉCUTÉE par la forge, contre trois
    profils de politique construits pour l'occasion — elle était jusqu'ici affirmée.
    Le job schema
    monte un vrai Postgres, y pose trois politiques — l'une nommant {authenticated}, l'une
    {anon, authenticated}, l'une {anon} —, retire le droit dans chacune à son tour, et exige que la
    requête nomme la table morte et le rôle mort à chaque fois. Les deux écritures écartées sont
    dans le banc en toutes lettres : le banc ne vaut pas parce que la bonne passe, il vaut parce qu'il
    distingue — la première ne voit pas le profil {authenticated}, la seconde manque la politique
    qui nomme les deux rôles.
    ⚠️ Et c'est la requête DOCUMENTÉE qui est exécutée, pas une copie : elle est extraite du bloc
    « Accès » d'init.sql entre deux marques (tools/requete-diagnostic.mjs). Une copie diverge, et
    le jour où elle diverge le banc resterait vert sur un texte que personne n'applique pendant que les
    hôtes appliqueraient celui du fichier — le défaut « un compte d'un instrument, un verdict d'un
    autre », transposé à une requête. L'extraction refuse plutôt que de rendre un SQL vide : marque
    absente, marques doublées ou croisées, bloc vide, sans select ou non terminé par ; sont non
    concluants
    , jamais conformes — un SQL vide ne rend jamais de ligne, donc ne signale jamais rien,
    et virerait le banc au vert pour la raison exacte qu'il existe pour interdire.
    ⚠️ Ce qui a motivé un banc plutôt qu'une relecture de plus : des trois écritures de cette
    requête, aucune n'a été trouvée fautive par son auteur. Et l'hôte qui a rejoué la forme retenue
    chez lui a nommé la raison pour laquelle sa propre base ne pouvait pas trancher — 26 politiques,
    zéro nommant les deux rôles
    , donc son écriture y aurait rendu le bon résultat « pour une raison
    qui n'a rien à voir avec sa justesse »
    . Valider une sonde sur la base qu'on a sous la main, c'est
    la valider sur un profil parmi trois. On construit les trois.

  • ⚠️ La requête de diagnostic que ce dépôt donnait aux hôtes rendait un zéro rassurant sur une base
    au maximum exposée.
    Elle filtrait roles::text like '%anon%' — alors que le geste qu'elle
    vérifie retire le droit à anon et à authenticated. Une base dont toutes les politiques
    nomment authenticated obtient donc zéro ligne, c'est-à-dire un feu vert, pendant que chacune
    de ses politiques mourrait au premier revoke. Relevé par un hôte le 28/08, chez qui 28
    politiques sur 28 nomment authenticated et aucune anon
    — le profil exact que la sonde ne
    voyait pas. Une sonde dont le filtre est plus étroit que le geste qu'elle vérifie rend un zéro qui
    veut dire « je n'ai pas regardé », pas « il n'y a rien ».
    ⚠️ Et la deuxième écriture portait le même défaut d'un cran plus bas : elle choisissait UN rôle
    par politique. Sur une politique nommant les deux rôles où seul authenticated a perdu le droit,
    elle inspecte anon, le trouve intact, et se tait. Mesuré contre un vrai Postgres monté pour
    l'occasion, sur trois profils de politique : la première écriture voit 0 des cas
    {authenticated}, la deuxième 1 des 2 tables cassées, la troisième les voit toutes.
    La forme retenue déplie chaque rôle nommé et mesure l'état résultant
    (has_table_privilege) au lieu de demander si le revoke a été posé — c'est la seule chose qui
    compte pour l'hôte, et elle répond avant comme après. Rien à faire côté hôte, sauf si vous aviez
    lancé la version étroite : relancez celle-ci.

  • ⚠️ La règle « un nombre au présent rouille » se lisait plus étroite qu'elle n'est — elle ne
    nommait que des comptes et des dates.
    Ses quatre cas travaillés sont des décomptes de tests et
    des dates de mesure ; aucun n'est une POSITION. C'est pourquoi deux renvois par numéro de ligne
    ont survécu, dans le fichier même qui porte la règle, à la matinée qui a produit ces quatre cas.
    Un hôte a fait le même constat chez lui le 28/08 et l'a mieux formulé que nous : « je cherchais
    des COMPTES, pas des POSITIONS. La règle était juste, ma lecture de son périmètre était trop
    étroite. »
    Il en a trouvé deux, dont un écrit la veille contre une version d'il y a quatre
    publications, déjà faux. La règle nomme désormais la position comme cas, avec ce qui la rend le
    plus périssable des nombres au présent : un compte survit à une édition qui n'ajoute rien à
    compter ; une position ne survit à aucune insertion au-dessus d'elle, dans un fichier qu'on
    n'édite même pas.
    Rien à faire côté hôte.

  • ⚠️ Le conseil du revoke que ce dépôt donne aux hôtes était incomplet — sa précondition
    manquait, et sans elle il casse.
    Les en-têtes de 0021 et d'init.sql invitaient un hôte à
    poser revoke select … from anon, authenticated comme seconde couche sous la RLS. Vrai sur les
    tables du player — zéro politique, rien d'ouvert à refermer. Faux dès qu'une politique permissive
    nomme anon
    : la RLS dit oui, le droit dit non, et la surface publique tombe sans qu'aucune
    configuration paraisse fautive. Un hôte l'a mesuré le 28/08 en généralisant le geste : posé sur les
    dix tables du player, pas posé sur ses 221 tables applicatives, dont trois portent une telle
    politique. Sa formulation est reprise — « un revoke global n'est sûr que là où aucune politique
    n'accorde »
    — avec la requête d'une ligne qui vérifie la condition.

  • Le contrat dit désormais que &schema=1 rend les verdicts paresseux inobservables. Cette
    requête sonde toutes les attentes AVANT de répondre, puis retient pour la vie du processus : son
    verdict vaut complet, ou indetermine si le témoin ne répond pas — jamais non-sonde. Un hôte
    qui interroge la carte ainsi ne peut donc pas rencontrer la dégradation contre laquelle la page le
    met en garde. Les lecteurs qui ont besoin de l'avertissement et ceux qui le rencontrent sont
    disjoints
    , et la page le dit maintenant plutôt que de laisser chacun vérifier un avertissement
    qui ne parle pas de son instance. Relevé par un hôte qui lisait complet depuis une semaine et
    l'attribuait à la chance. Rien à faire côté hôte.

  • ⚠️ AGENTS.md renvoyait deux fois à docs/HOST-CONTRACT.md par numéro de ligne, et les deux
    renvois étaient faux.
    Pas « devenus faux un jour » : faux dès le commit suivant qui a touché la
    page visée, quelques heures après avoir été écrits. Le premier menait à un séparateur de tableau,
    le second à un tout autre sujet — l'explication qu'il annonçait avait glissé d'une trentaine de
    positions. Le fichier qui porte la section « A number in the present tense rots » le violait
    deux cents lignes plus haut : un numéro de ligne EST un nombre au présent, et le plus fragile qui
    soit — il ne survit à aucune insertion au-dessus de lui, dans un fichier qu'on n'édite même pas.
    ⚠️ Et il rouille du côté du lecteur, en silence. Relevé par un hôte qui vérifiait nos renvois,
    et qui en a nommé le coût : un lecteur qui suit un renvoi périmé lit un autre contenu, plausible,
    ne trouve pas ce qu'il cherche, et peut conclure à une absence. C'est le champ que personne ne
    relit parce qu'il est presque toujours juste. Les deux renvois désignent désormais leur objet — la
    ligne du tableau, la phrase d'ouverture du paragraphe — et tools/renvois-par-position.mjs tient
    la classe sur les documents de navigation (AGENTS.md, README.md, docs/). Le CHANGELOG en est
    exclu comme classe et non comme exception : ses sections sont datées et figées, et personne n'y
    cherche son chemin.
    ⚠️ La première écriture de la garde ne voyait qu'un des deux défauts — elle exigeait qu'un nom
    de fichier suive le numéro, et le renvoi nu est pourtant le pire : le lecteur ne sait même pas
    quelle page ouvrir. Mesuré en rejouant la sonde sur la version d'avant correction : 1 sur 2, puis
    2 sur 2. Cinq mutations, cinq rouges. Rien à faire côté hôte.

  • ⚠️ Le contrat d'hôte demandait implicitement de signaler les écarts — une consigne que personne
    ne peut appliquer.
    Une sixième règle la remplace : décrire ce qu'on fait, y compris ce qu'on
    croit trivial.
    Un hôte ne peut pas savoir ce qui est un écart sans connaître cette page mieux
    que nous — or la phrase sur-spécifiée de la section tts-cache y a vécu des semaines. Il a fallu
    qu'un hôte cite son propre nommage comme une curiosité pour que quiconque regarde. Dans ses
    mots (27/08) : « je ne l'ai décrite que parce que je citais preview-fr-v2 comme une curiosité,
    sans savoir que c'était un écart. Si j'avais su que votre page l'interdisait, je me serais
    probablement conformé. »
    S'y conformer aurait orphelinné 908 objets, définitivement. Une
    règle qui demande de repérer l'écart ne peut pas marcher ; une règle qui demande de décrire son
    intégration n'exige rien que l'hôte n'ait déjà. Rien à faire côté hôte — c'est une invitation,
    pas une obligation.

  • ⚠️ Une correction qui retire une forme nommera désormais les occurrences qui RESTENT, pas celles
    qu'elle a retirées.
    Le chiffre que la note de la 0.1.142 donnait — « écrit dix fois à
    l'identique dans trois fichiers »
    — était inutilisable pour un hôte : il compte ce qui a
    disparu. La sonde d'un hôte en a trouvé quatre ; les deux nombres ne se rencontrent jamais.
    Un décompte de ce qui reste se rejoue sur le tarball publié, et transforme une absence silencieuse
    en incohérence entre deux sources — le seul signal qui n'exige aucune vigilance. Les pièges
    sont nommés avec : une occurrence dans un fichier à octet de contrôle est invisible à un grep
    nu. ⚠️ Rangée dans les conventions que les personnes imposent, pas les gardes : aucune ne peut
    savoir qu'une note aurait dû porter un décompte. Un décompte absent n'est donc pas un décompte
    faux, et son absence ne doit pas se lire « rien à signaler ».

  • AGENTS.md gagne la règle des deux instruments, formulée par un hôte et plus nette que la
    nôtre : « une sonde qui compte avec grep et classe avec du code hérite des angles morts du
    premier sans hériter de sa visibilité »
    . Quand un outil décide combien et un autre quoi, le
    chiffre porte la portée du premier et l'autorité du second. Écrite à côté de la règle voisine sur
    les deux entrées d'un contrôle, avec le cas mesuré la veille — quatre sites rendus là où cinq
    existaient.

Security

  • ⚠️ Dix tables déclaraient la RLS, et rien ne demandait jamais à la base si elle en avait tenu
    compte.
    enable row level security est écrit dix fois dans supabase/ — et zéro banc, zéro
    étape ne le confrontait au moteur. Une posture de sécurité affirmée en commentaire et jamais
    vérifiée est une affirmation, pas une propriété ; elle serait tombée sans bruit le jour où une
    migration aurait créé une table en oubliant la ligne. Le job schema relève désormais ce que les
    sources déclarent et le confronte à ce que la base a retenu (pg_class.relrowsecurity),
    puis vérifie qu'aucune politique n'ouvre ces tables.
    ⚠️ Avec son contrôle positif, et c'est lui qui fait la valeur de l'étape. « Aucune politique »
    est un constat d'absence, donc indiscernable d'une sonde qui vise à côté : l'étape pose une
    politique témoin, vérifie que la sonde la voit, puis l'enlève. Sans ce détour, le zéro qui
    suit ne prouverait rien. Mesuré contre un vrai Postgres : quatre mutations, quatre rouges — RLS
    désactivée en base, politique permissive ajoutée, contrôle positif débranché, relevé des sources
    vide. Rien à faire côté hôte : la propriété tient déjà, personne ne la mesurait.
  • ⚠️ Un grep sans -a saute un fichier contenant un octet de contrôle sans dire un mot. Un
    hôte a relu le 27/08 les occurrences restantes d'une forme corrigée dans server/ et a conclu
    « quatre sites, zéro manqué ». Il y en avait cinq : la cinquième vit dans un banc qui contient
    un octet NUL — délibérément, puisqu'il éprouve les caractères de contrôle. GNU grep classe alors
    le fichier comme binaire et n'imprime aucune ligne.
    ⚠️ Ce n'est pas un faux négatif ordinaire, c'est un saut muet : la sonde ne dit pas « je n'ai
    pas pu lire ce fichier », elle rend un nombre plus petit, d'apparence normale, sur lequel on
    conclut. Aucune de nos étapes n'était aveugle — leurs globs ne descendaient pas dans __tests__/
    — mais la propriété tenait par la forme d'un glob, pas par une décision. tools/greps-sans-angle-mort.mjs
    garde désormais la classe : tout grep d'un workflow qui lit du source porte -a.
    ⚠️ Et cette garde était VERTE sur ses trois propres violations à sa première exécution : elle
    cherchait le jeton grep alors que le jeton réel est sans_commentaires=$(grep. Verte, et
    fausse — la question 2 de AGENTS.md retournée contre la garde qui venait l'écrire. Le banc porte
    ce cas nommément. Six mutations, six rouges, dont une qui a d'abord survécu : le banc du « motif
    cité » passait pour la mauvaise raison, et il a fallu un motif contenant une espace pour le rendre
    probant. Rien à faire côté hôte.
  • ⚠️ « Personne, sauf le rôle de service » était vrai en effet, pas en droit — et la nuance est
    toute la protection.
    Un hôte l'a relevé le 27/08 en appliquant la 0021 : sur une installation
    de type Supabase, anon possède le droit SELECT sur ces tables, hérité des privilèges par défaut
    du schéma public. Ce n'est donc pas l'absence de grant qui les ferme, c'est uniquement la
    RLS, et il n'y a rien en dessous. Une politique permissive ajoutée « pour débloquer un cas » ne
    retire pas une protection sur deux : elle retire la seule. Les en-têtes de 0021 et de init.sql
    le disent maintenant, et nomment le revoke que l'hôte peut poser chez lui — ce dépôt ne le pose
    pas à sa place, ces rôles étant ceux de son installation et non de Postgres.

Fixed

  • ⚠️ La carte de schéma rendait un « complet » qui ne pouvait pas rougir — et l'hôte qui suit
    l'ordre sûr est précisément celui qu'elle ne renseignait pas.
    La session STUDIO l'a mesuré des
    deux côtés le 30/08 : sur 0.1.142 elle applique la migration 0024, relit
    GET /api/doc?contract=1&schema=1, et obtient attendues: 9 · sondees: 9 · complet · manquant: []
    — mot pour mot ce qu'elle lisait avant de l'appliquer. Elle ne pouvait pas voir la 0024 quitter
    une liste où elle n'était jamais entrée : la liste des attentes est celle du code qui tourne, et
    view_rotation n'y entre qu'avec la version qui apporte la fonction.
    ⚠️ complet a donc toujours voulu dire « complet pour le code que je suis », jamais « complet
    pour le dépôt » — et rien, ni dans la réponse ni dans HOST-CONTRACT.md, ne disait la différence.
    Un hôte qui applique le schéma avant le code (l'ordre sûr, celui qu'on recommande) est
    exactement celui que ce vert laisse sans réponse.
    ⚠️ Aucune version ne peut connaître une migration postérieure à elle — ce défaut-là est
    structurel et ne se corrige pas. Ce qui se corrige, c'est de dire ce qu'on connaît : la carte
    publie connues, la liste des migrations que ce code sait attendre. « 0024 n'est pas dedans »
    devient une lecture directe, au lieu d'une déduction qui exige de connaître notre historique de
    publication. manquant nomme ses fichiers dans les mêmes chaînes, pour que l'appartenance soit une
    comparaison et non une interprétation — un banc l'exige, parce que deux listes publiées pour être
    comparées et qui divergeraient de vocabulaire resteraient « justes » séparément et inutilisables
    ensemble.
    ⚠️ Et pour une migration que ce player ne connaît pas, HOST-CONTRACT.md renvoie désormais à la
    base plutôt qu'à la carte
    — une requête information_schema ne dépend pas de la version qui la
    lance, ce qui est le seul cas où on a besoin d'une réponse. C'est la même faute qu'un paragraphe
    voisin de ce document décrit déjà un cran plus bas (« les lecteurs qui ont besoin de
    l'avertissement et ceux qui le rencontrent sont disjoints »), d'un niveau au-dessus.

  • ⚠️ Reprendre une présentation rendait la page, pas l'orientation — et le présentateur et son
    audience voyaient alors deux documents différents sans que rien ne le dise.
    Un rechargement remet
    la vue du présentateur à son état d'origine (zoom 1, rotation 0) : c'est une décision, pas un
    oubli, et c'est ce que font tous les lecteurs de documents. Mais l'audience, elle, continuait
    d'afficher la view_rotation de la base. Le présentateur voyait donc son document droit
    pendant que son audience le voyait couché, et il n'avait aucune raison de toucher le bouton de
    rotation : de son côté tout allait bien.
    ⚠️ La règle valait déjà pour la page, il lui manquait l'orientation. À la reprise, la
    présentation l'emporte sur l'état local — la reprise sautait déjà à la page en cours de la base.
    Au démarrage c'est l'inverse, et les deux sont cohérents : startPresent pousse la rotation
    courante du présentateur, parce qu'il n'y a encore rien à reprendre.
    ⚠️ On adopte, on ne pousse pas. La base porte déjà la valeur : la renvoyer serait une écriture
    pour rien, et le message de pilotage emporte aussi la page — or la page courante vaut 1 tant
    que le document se charge, donc pousser à la reprise ramènerait toute l'audience à la page 1. C'est
    la raison pour laquelle la reprise ne pousse rien, et elle vaut encore.
    ⚠️ Aucun piège de migration ici, et le banc le prouve au lieu de le supposer : on lit, et
    la lecture se fait en select=*, donc chez un hôte non migré la colonne est simplement absente
    (undefined → 0). Le rejet du PATCH entier sur colonne inconnue ne concerne que l'écriture.
    ⚠️ La liste blanche des quatre orientations n'est plus écrite qu'une fois. Elle était en dur
    dans l'écriture ; la lecture en aurait fait une seconde copie, et deux copies d'une liste blanche
    sont une divergence en attente — c'est exactement ce qui venait d'arriver entre le serveur et le
    navigateur sur le type accepté.

  • ⚠️ Le scan ZAP annonçait « alerte non triée » quand il ne s'était pas terminé du tout, et
    envoyait son lecteur trier ce qui n'existait pas.
    Le 27/08 (course 33102994676),
    zap-baseline.py s'est interrompu sur la surface doc après trente secondes : aucune ligne
    PASS:, aucune ligne de synthèse FAIL-NEW: … PASS: … là où les deux autres surfaces en
    impriment soixante-trois et une. Un témoin indépendant le confirme deux étapes plus bas — « there
    will be 2 files uploaded », deux rapports pour trois surfaces. Il n'y avait pas d'alerte
    non triée : il n'y avait pas eu de tri. Le message renvoyait pourtant vers .zap/rules.tsv, où
    il n'y avait rien à trier, et vers un rapport jamais écrit.
    ⚠️ La cause tenait en deux caractères : || echec="$echec $cible" écrasait tous les codes non
    nuls en un seul fait, puis la ligne suivante nommait celui qu'elle savait dire. C'est la classe de
    défaut que ce dépôt retire partout ailleurs depuis le 21/08 — deux rouges différents sous un seul
    code — et l'étape ZAP était le dernier endroit où elle vivait, dans du shell plutôt que dans du
    JavaScript. La décision vit maintenant dans tools/verdict-zap.mjs, avec resultat-garde :
    violation (le scan a conclu, l'alerte attend dans la branche) ou non concluant (le scan
    n'a pas conclu, le correctif n'est pas dans la branche).
    ⚠️ Et le vert était aussi vulnérable que le rouge : rien ne vérifiait qu'un scan sorti en 0
    avait écrit son rapport. Un scanner muet rendant 0 passait pour une surface saine — la vacuité que
    l'en-tête du même fichier condamne deux étapes plus haut (« un scan d'un 404 est vert et vide »).
    Le rapport HTML est désormais la preuve exigée de chaque surface, et c'est son croisement avec
    le code de sortie qui donne le verdict — jamais le code seul, dont la table n'est pas mesurable
    hors CI. Six mutations, six rouges. Rien à faire côté hôte.

What changed in the package, by zone — 0.1.1420.1.143

Measured on the two tarballs, by the release workflow. Not written by hand.

Zone What it is Added Removed Changed
documents what a human reads 0 0 1
manifest package.json — version, exports, dependencies 0 0 1
server the code the host executes 0 0 6
context the injected-context implementations 0 0 0
browser-types the declarations the host's tsc reads for « ./bridge » — breaks a build, never a page 0 0 0
browser what the visitors' page executes 0 0 0
cli the command-line entry point 0 0 0
types declarations for the server and context entry points — breaks a build, never runtime 0 0 0
database the schema and the migrations the host applies itself 1 0 2
The files themselves
~ docs/HOST-CONTRACT.md
~ package.json
~ server/browser.generated.js
~ server/page-audience.js
~ server/page-visionneuse.js
~ server/presentations.js
~ server/routes-direct.js
~ server/schema.js
+ supabase/migrations/0024-rotation-en-direct.sql
~ supabase/init.sql
~ supabase/migrations/0021-empreintes-de-voix.sql

⚠️ database — 2 file(s) changed, not added

a migration already applied elsewhere must be immutable. A changed file here is not a count — it is a stop. Check the objects listed below against your live database: a migration registry only records what went through one particular path, so absence from it does not mean the migration was never applied.

  • supabase/init.sql
    objects it touches — table public.commercial_doc_shares — create, alter, index cds_doc_id_idx — create, index cds_parent_idx — create, index idx_doc_shares_brand_key — create, index idx_shares_revoked_at — create, table public.commercial_doc_views — create, alter, index cdv_slug_idx — create, index cdv_doc_idx — create, index commercial_doc_views_at_idx — create, table public.commercial_doc_sessions — create, alter, index cds_sess_slug_idx — create, index cds_sess_doc_idx — create, index idx_cds_last_at — create, table public.commercial_doc_internal_sessions — create, alter, index cdis_doc_idx — create, index commercial_doc_internal_sessions_last_at_idx — create, table public.doc_presentations — create, alter, index doc_presentations_active_idx — create, index doc_presentations_last_seen_idx — create, index doc_presentations_owner_active_idx — create, index doc_presentations_owner_email_active_idx — create, table public.doc_presentation_messages — create, alter, index dpm_slug_idx — create, function public.player_bump_message_seq — create, trigger dpm_bump_modseq — drop, create, index idx_dpm_slug_modseq — create, table public.doc_presentation_attendees — create, alter, index doc_presentation_attendees_slug_idx — create, index idx_attendees_slug_creator — create, table public.doc_bot_sessions — create, alter, index idx_bot_last_at — create, index doc_bot_sessions_share_idx — create, table public.player_rate_limits — create, alter, index player_rate_limits_expires_idx — create, table public.doc_tts_objects — create, alter, index doc_tts_objects_created_idx — create, function public.player_rate_limit_bump — create, function public.player_attendance_bump — drop, create, function public.player_archive_scellee — create, trigger dpm_archive_scellee — drop, create, trigger dpa_archive_scellee — drop, create, table public. — alter, function public.player_page_lue — create, function public.player_stats_overview — create, function public.player_stats_overview_internes — create, function public.player_stats_doc — create, function public.player_stats_doc_funnel — create

    @@ -174,6 +174,10 @@
       doc_title      text,
       presenter_name text,
       current_page   integer not null default 1,
    +  -- Orientation imposée par le PRÉSENTATEUR (0/90/180/270), distincte du /Rotate que porte le
    +  -- fichier : le player les COMPOSE. Voir 0024 — elle est normalisée à la RÉCEPTION, cette valeur
    +  -- pouvant arriver du navigateur du présentateur par la voie broadcast.
    +  view_rotation  integer not null default 0,
       active         boolean not null default true,
       created_at     timestamptz not null default now(),
       updated_at     timestamptz not null default now(),
    @@ -359,7 +363,8 @@
       on public.doc_tts_objects (created_at);
     -- RLS activée SANS politique : sous RLS, l'absence de politique refuse tout — seul le rôle de
     -- service passe. Même posture que `player_rate_limits`, et pour la même raison : ni un visiteur ni
    --- l'équipe n'ont de raison de lire cette table.
    +-- l'équipe n'ont de raison de lire cette table. ⚠️ « Seul le rôle de service » est vrai en effet,
    +-- pas en droit : voir le bloc « Accès » plus bas, et l'en-tête de la migration 0021.
     alter table public.doc_tts_objects enable row level security;
     comment on table public.doc_tts_objects is
       'Trace des objets écrits dans le bucket public tts-cache : une empreinte, une date, jamais le '
    @@ -588,6 +593,88 @@
     -- RLS activé SANS politique permissive : seul `service_role` (donc la route du player) passe.
     -- ⚠️ N'ajoutez pas de politique de lecture publique « pour que le direct fonctionne ». C'est
     -- précisément l'état dont l'hôte historique a dû sortir : le suivi passe par broadcast.
    +--
    +-- ⚠️ ET « SEUL `service_role` PASSE » EST VRAI EN EFFET, PAS EN DROIT. Un hôte l'a relevé le 27/08 :
    +-- sur une installation de type Supabase, `anon` possède le droit SELECT sur ces tables, hérité des
    +-- privilèges par défaut du schéma `public`. Ce n'est donc pas l'absence de `grant` qui les ferme,
    +-- c'est UNIQUEMENT la RLS — et il n'y a rien en dessous. Une politique permissive ajoutée « pour
    +-- débloquer un cas » ne retire pas une protection sur deux : elle retire la seule. Un hôte qui veut
    +-- la seconde couche l'écrit chez lui (`revoke select … from anon, authenticated`) ; ce dépôt ne la
    +-- pose pas à sa place, ces rôles étant ceux de son installation et non de Postgres.
    +--
    +-- ⚠️ ET CE CONSEIL ÉTAIT INCOMPLET — SA PRÉCONDITION MANQUAIT, ET SANS ELLE IL CASSE. Un `revoke`
    +-- global n'est sûr QUE là où aucune politique n'accorde. Là où une politique permissive nomme
    +-- `anon`, la RLS dit oui et le droit dit non : la surface publique tombe, sans erreur de
    +-- configuration nulle part. Sur les tables de CE fichier la condition est remplie — zéro politique,
    +-- rien d'ouvert à refermer — mais un hôte qui généralise le geste à SA base applicative peut y
    +-- perdre une carte publique. Un hôte l'a mesuré le 28/08 : il a posé le `revoke` sur les dix tables
    +-- du player, et ne l'a PAS posé sur ses 221 tables applicatives, dont trois portent une politique
    +-- nommant `anon`.
    +--
    +-- ⚠️ ET LA VÉRIFICATION QUI TRANCHE N'EST PAS CELLE QU'ON CROIT — TROIS ÉCRITURES, TROIS PORTÉES.
    +-- La première proposée ici filtrait `roles::text like '%anon%'`. Or le geste conseillé retire le
    +-- droit à `anon` ET à `authenticated` : une base dont toutes les politiques nomment
    +-- `authenticated` obtient donc ZÉRO LIGNE — un feu vert — alors qu'elle est au maximum exposée.
    +-- Relevé par un hôte le 28/08, chez qui 28 politiques sur 28 nomment `authenticated` et aucune
    +-- `anon`. Une sonde dont le filtre est plus étroit que le geste qu'elle vérifie rend un zéro qui
    +-- veut dire « je n'ai pas regardé », pas « il n'y a rien ».
    +--
    +-- ⚠️ ET LA DEUXIÈME ÉCRITURE AVAIT LE MÊME DÉFAUT D'UN CRAN PLUS BAS : elle choisissait UN rôle par
    +-- politique (`case when … then 'anon' else 'authenticated' end`). Sur une politique qui nomme les
    +-- deux et où seul `authenticated` a perdu le droit, elle regarde `anon`, le trouve intact, et ne
    +-- dit rien. Mesuré contre un vrai Postgres : elle voit 1 des 2 tables cassées.
    +--
    +-- La forme qui tient déplie CHAQUE rôle nommé, et mesure l'ÉTAT RÉSULTANT plutôt que de demander si
    +-- le `revoke` a été posé — c'est la seule chose qui compte pour l'hôte :
    +--
    +-- ⚠️ LES DEUX MARQUES CI-DESSOUS NE SONT PAS DÉCORATIVES : LA CI EXTRAIT CE QUI EST ENTRE ELLES ET
    +-- L'EXÉCUTE. Ce bloc n'est donc pas la transcription d'une requête éprouvée ailleurs — c'est
    +-- l'artefact éprouvé lui-même. Le job `schema` monte trois profils de politique contre un vrai
    +-- Postgres, en casse un dans chacun, et exige que CETTE requête nomme la table morte à chaque fois.
    +-- Si vous l'éditez, c'est votre version qui est mesurée à la course suivante ; si vous la cassez, la
    +-- CI le dit avant l'hôte. Aucune des trois écritures de cette requête n'a été trouvée fautive par
    +-- son auteur — d'où le banc plutôt qu'une relecture de plus.
    +-- ⚠️ ET IL Y AVAIT UN QUATRIÈME PROFIL, QUI EST LA FORME PAR DÉFAUT — TROUVÉ PAR UN HÔTE LE 29/08
    +-- ALORS QUE LE BANC CI-DESSOUS TOURNAIT DÉJÀ SUR TROIS. Une politique écrite SANS clause `TO`
    +-- s'applique à `PUBLIC`, donc à tous les rôles, `anon` compris — et `pg_policies` l'affiche
    +-- `{public}`, jamais `{anon}`. Un filtre `in ('anon','authenticated')` la saute intégralement. Ce
    +-- n'est pas un cas exotique : c'est ce que produit l'interface Supabase pour un bucket public, et
    +-- l'hôte en a trouvé cinq chez lui, dont les trois qui servent ses buckets publics. Un hôte qui pose
    +-- le `revoke` puis lance la requête obtient alors zéro ligne pendant que ses buckets sont morts.
    +--
    +-- ⚠️ ET LE `case` NE CHOISIT PAS UN RÔLE, IL EN DÉPLIE DEUX. Tester `anon` comme représentant de
    +-- `public` raterait le cas où seul `authenticated` est privé — l'hôte l'a écrit lui-même en
    +-- proposant sa piste, et c'est mesuré : sur une politique `{public}` dont seul `authenticated` a
    +-- perdu le droit, la version « un représentant » se tait, celle qui déplie rend la ligne.
    +--
    +-- ⚠️ ET `to_regrole` N'EST PAS UNE COQUETTERIE. Sur une installation qui n'a NI `anon` NI
    … 49 more line(s) not shown — a diff this long is itself the answer
  • supabase/migrations/0021-empreintes-de-voix.sql
    objects it touches — table public.doc_tts_objects — create, alter, index doc_tts_objects_created_idx — create

    @@ -41,6 +41,33 @@
     -- politique n'est pas « ouverte par défaut » : sous RLS, l'absence de politique REFUSE tout. C'est
     -- la même posture que `player_rate_limits`, et elle est délibérée : cette table n'a aucune raison
     -- d'être lue par un visiteur, ni par l'équipe.
    +--
    +-- ⚠️ ET « PERSONNE » EST VRAI EN EFFET, PAS EN DROIT — LA NUANCE EST TOUT CE QUI VOUS PROTÈGE.
    +-- Un hôte l'a relevé le 27/08 en appliquant cette migration : sur une installation de type
    +-- Supabase, `anon` possède le droit SELECT sur cette table, hérité des privilèges par défaut du
    +-- schéma `public`. Ce n'est donc PAS l'absence de `grant` qui ferme la table — c'est UNIQUEMENT la
    +-- RLS, et il n'y a rien en dessous. Le jour où quelqu'un ajoute une politique permissive « pour
    +-- débloquer un cas », il ne retire pas une protection sur deux : il retire la seule. Si vous
    +-- voulez la seconde couche, elle s'écrit chez vous (`revoke select on public.doc_tts_objects from
    +-- anon, authenticated;`) — ce dépôt ne la pose pas à votre place, parce que ces rôles sont ceux de
    +-- votre installation, pas de Postgres.
    +--
    +-- ⚠️ ET LA PRÉCONDITION DE CE CONSEIL, QUI MANQUAIT : un `revoke` n'est sûr QUE là où aucune
    +-- politique n'accorde. Sur cette table la condition est remplie (zéro politique). Sur VOTRE base
    +-- applicative, une politique permissive qui nomme `anon` OU `authenticated` ferait dire oui à la RLS
    +-- et non au droit : la surface publique tombe sans qu'aucune configuration paraisse fautive.
    +--
    +-- ⚠️ ET LA REQUÊTE DE VÉRIFICATION DOIT COUVRIR CHAQUE RÔLE NOMMÉ — Y COMPRIS CEUX QU'UNE POLITIQUE
    +-- NE NOMME PAS. Sans quoi elle rend un zéro rassurant sur une base exposée : TROIS écritures s'y
    +-- sont cassées, chacune trouvée par quelqu'un d'autre que son auteur, toutes mesurées contre un vrai
    +-- Postgres. La dernière butait sur le cas le plus courant de tous — une politique écrite sans clause
    +-- `TO` porte `{public}`, jamais `{anon}`, et c'est la forme que produit l'interface Supabase pour un
    +-- bucket public. La forme qui tient est dans le bloc « Accès » d'`init.sql` ; elle déplie `public`
    +-- en ses rôles concrets et mesure l'ÉTAT RÉSULTANT (`has_table_privilege`) au lieu de demander si le
    +-- `revoke` a été posé. La forge l'EXÉCUTE à chaque course contre quatre profils de politique.
    +--
    +-- Ce que la forge vérifie désormais à chaque course, contre une vraie base : que la RLS déclarée
    +-- ici est bien RETENUE par le moteur, et qu'AUCUNE politique n'ouvre cette table.
     alter table public.doc_tts_objects enable row level security;
     
     comment on table public.doc_tts_objects is