v0.1.143
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 0024 —doc_presentationsgagne
view_rotation.
⚠️ view_rotationet nonrotation, pour deux raisons. Le nom dit rotation de la vue, par
opposition au/Rotateque porte le fichier — deux choses que le player compose au lieu de les
confondre. Et le nom court serait entré en collision avec l'optionrotationde 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 : nommerview_rotationchez 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 voiebroadcast, 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.jsapporte le cache borné, la file de concurrence et le chargement paresseux. Ce qui
ne se transpose pas est songetDocumentsuivi d'undestroy: 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/Rotateque
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 90sans 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 unwheel
portantctrlKey. 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.
⚠️ rotationEffectiveCOMPOSE 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/Rotateque 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.
⚠️ aspectApresRotationgouverne 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.
⚠️ ancrageApresZoomtraite 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 quepg_policies.rolespeut 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, ungrepsur 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 clauseTOs'applique àPUBLIC, donc à tous les rôles y comprisanon— et
pg_policiesl'affiche{public}, jamais{anon}. Le filtrein ('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 lerevokepuis 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 :
testeranoncomme représentant depublicse tait là où seulauthenticatedest privé, et
nommepubliccomme rôle — ce qui laisse l'hôte sans savoir quel droit rendre. La forme retenue
dépliepublicen ses rôles concrets, et nomme celui qui a perdu le droit.
⚠️ Etto_regrolen'est pas une coquetterie : sur une installation sansanonni
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 surpg_rolesne 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 seulauthenticatedest 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.mdporte 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 jobschema
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.sqlentre 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, sansselectou 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 filtraitroles::text like '%anon%'— alors que le geste qu'elle
vérifie retire le droit àanonet àauthenticated. Une base dont toutes les politiques
nommentauthenticatedobtient donc zéro ligne, c'est-à-dire un feu vert, pendant que chacune
de ses politiques mourrait au premierrevoke. Relevé par un hôte le 28/08, chez qui 28
politiques sur 28 nommentauthenticatedet aucuneanon— 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ù seulauthenticateda perdu le droit,
elle inspecteanon, 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 lerevokea é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 durevokeque ce dépôt donne aux hôtes était incomplet — sa précondition
manquait, et sans elle il casse. Les en-têtes de0021et d'init.sqlinvitaient un hôte à
poserrevoke select … from anon, authenticatedcomme 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
nommeanon: 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 — « unrevokeglobal 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=1rend les verdicts paresseux inobservables. Cette
requête sonde toutes les attentes AVANT de répondre, puis retient pour la vie du processus : son
verdict vautcomplet, ouindeterminesi le témoin ne répond pas — jamaisnon-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 lisaitcompletdepuis une semaine et
l'attribuait à la chance. Rien à faire côté hôte. -
⚠️ AGENTS.mdrenvoyait deux fois àdocs/HOST-CONTRACT.mdpar 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 — ettools/renvois-par-position.mjstient
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 sectiontts-cachey 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 citaispreview-fr-v2comme 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 à ungrep
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.mdgagne la règle des deux instruments, formulée par un hôte et plus nette que la
nôtre : « une sonde qui compte avecgrepet 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 securityest écrit dix fois danssupabase/— 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 jobschemarelè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.⚠️ Ungrepsans-asaute 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 dansserver/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 : toutgrepd'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 jetongrepalors que le jeton réel estsans_commentaires=$(grep. Verte, et
fausse — la question 2 deAGENTS.mdretourné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,anonpossède le droit SELECT sur ces tables, hérité des privilèges par défaut
du schémapublic. Ce n'est donc pas l'absence degrantqui 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 de0021et deinit.sql
le disent maintenant, et nomment lerevokeque 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 : sur0.1.142elle applique la migration 0024, relit
GET /api/doc?contract=1&schema=1, et obtientattendues: 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_rotationn'y entre qu'avec la version qui apporte la fonction.
⚠️ completa 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 dansHOST-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
publieconnues, 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.manquantnomme 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.mdrenvoie désormais à la
base plutôt qu'à la carte — une requêteinformation_schemane 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 laview_rotationde 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 :startPresentpousse 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 enselect=*, 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.pys'est interrompu sur la surfacedocaprès trente secondes : aucune ligne
PASS:, aucune ligne de synthèseFAIL-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 danstools/verdict-zap.mjs, avecresultat-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.142 → 0.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 addeda 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