Skip to content

v0.1.147

Choose a tag to compare

@github-actions github-actions released this 01 Sep 20:41
· 2 commits to main since this release
12845dc

⚠️ 0.1.146 n'existera jamais, et voici pourquoi — pour que personne n'ait à le deviner en
voyant un trou dans la suite. Le tag v0.1.146 a été posé sur le commit qui PRÉCÈDE la coupe,
lequel déclarait encore 0.1.145 et ne portait aucune section [0.1.146]. verifier refuse un
tag qui ne s'accorde pas avec package.json : rien n'a été publié — ni npm, ni Release, ni
attestation — et le registre est resté sur 0.1.145. Le préflight avait refusé lui aussi, une
ligne plus haut, en imprimant « Préflight de publication — v0.1.145 » et
« REFUSÉ : 2 contrôle(s) en échec ». C'est très exactement le mode de panne que
docs/RELEASING.md décrit depuis la 0.1.141, et son remède est écrit : un tag ne se reprend
pas — la protection interdit sa suppression —, donc on coupe le numéro suivant. Avec
v0.1.147 posé, le tag mort cesse d'être le plus haut et image-reconcile redevient sain sans
qu'aucune garde soit désarmée. Le numéro sauté est le prix.

Removed

  • The raw User-Agent is erased too, on both tables. We had argued for keeping it — the only
    source from which device, os and browser could be recomputed on rows already written — and
    the argument that settled it was ours turned around: those three are derived at write time and
    are what a reading record carries, so the raw string had no reader, and "we might re-parse it one
    day" does not justify thirteen months of a fingerprint kept for nobody. This release stops serving
    it, stops writing it, and ships migration 0027, which erases what was there — same shape and same
    measurement as the IP purge, with the column removals deferred together to a later release.
    On commercial_doc_views the case was starker than anyone had noticed: unlike the sessions
    table it has no device, os or browser, so it derived nothing from the string, wrote it, and
    none of the six queries that touch these two tables has ever read it back. It went unnoticed
    because the coverage that existed asked what a session hands out — and this table is never handed
    out, so nothing asked what it merely keeps. A column nothing serves is not a column without a
    question; it is a column whose question has no guardian, and one now exists. docs/RETENTION.md
    also answers, precisely, from what date a purge is complete end to end — including the part that
    is uncomfortable: the automatic sweep is opt-in, so on a host that enabled neither it nor a
    manual run, the 13-month window describes an intention rather than an event.

  • The reader's IP address is erased. This release stops serving it, stops writing it,
    and migration 0026 erases what thirteen months of journal still held in the clear — the half
    the code could not reach on its own. The column is emptied, not dropped, and emptying is what
    actually erases
    — which is the reverse of the intuition, so it was measured rather than
    assumed. An ALTER TABLE … DROP of a column marks the attribute dropped without rewriting the
    rows: on PostgreSQL 16.13 with pageinspect, every address is still physically present after the
    drop, still present after a routine VACUUM — the rows are live, so there is nothing to
    reclaim — and only a VACUUM FULL, which rewrites the table under an exclusive lock, removes
    them. Dropping the column alone would therefore have left every address on disk indefinitely,
    invisible to any query and so never checked by anyone again, while the schema swore it was not
    there. With the UPDATE … SET ip = NULL, ordinary autovacuum reclaims the old row versions by
    itself, with no lock and no operator action. Verified end to end on a populated database: 200 rows
    kept, 200 addresses gone after a routine vacuum, the migration replayable with no further effect.
    The erasure is complete today; what is deferred is the shape of the schema. The column stays
    because a migration here must be safe to apply while the previous version of the player is
    running — the rule that makes the deployment order harmless, and a test enforces it: every
    published version
    still writes ip, PostgREST rejects a write carrying an unknown column, and dropping it today
    would fail every session write of a host that migrates before deploying, with an error naming
    a column rather than a version. Removal is a later release, once no supported version writes it;
    until then the column is always NULL and carries a comment in the database saying so, which is
    also how a host attests that 0026 ran — a migration that only erases data leaves no trace in
    information_schema, and a purge is precisely the migration a host is most likely to be asked to
    prove. What the migration cannot reach — write-ahead logs, backups, exports — follows the host's
    own retention policy and is stated in docs/RETENTION.md rather than simulated, alongside a
    notice written before the change for any host that queried the column directly. The raw ua is
    kept: it is the only source from which device, os and browser can be recomputed on rows
    already written, and dropping it is a separate decision rather than one implied by this one. One
    guard was missing and now exists: the list of session columns served or withheld with a reason
    was checked in one direction only, so an entry motivating a column that no longer exists could
    have sat there indefinitely.

Fixed

  • Four host-facing documents announced two versions that had never been published, in the past
    tense — eighteen statements in all, naming the two version numbers immediately after the published
    one and describing what they "stopped serving" and "stopped writing". The registry serves
    0.1.145, which still serves and still writes the reader IP and the raw User-Agent, and ships
    migrations only up to 0024. (The numbers are not repeated here: putting an unpublished version
    into a host-facing document is the artefact being removed, and the new guard refused this entry
    until they came out — correctly.) An integrating host read docs/RETENTION.md,
    believed the change was live, and was then asked to apply migrations that were in no package; it
    found the gap by unpacking the version the registry actually serves. Every such claim now names
    something that exists — the next release, or a migration number — and docs/RETENTION.md opens
    the purge section by saying plainly that none of it is published yet and what to check. A new
    guard refuses any document naming a version greater than package.json's
    , which
    docs/RELEASING.md already holds equal to the tag and to the changelog's top section: between
    releases it is the newest version that exists, and during a release the bump lands in the same
    commit as the section describing it, so the window closes itself with no exception to write. The
    guard caught a perimeter defect in its own first line — a double-star pathspec matches nothing in
    git ls-files, so it read only the changelog while the sixteen documents holding the error went
    unseen, with a non-zero count keeping its floor happy — and it now requires every declared root to
    yield a subject.

  • Reading sessions no longer carry the reader's IP address nor the raw User-Agent. Both docshare.sessions and
    docshare.sessionsByRecipient returned the stored row as-is, which includes ip — the datum
    docs/RETENTION.md calls the most sensitive in the schema, that nothing in the player reads
    back, and that a sales record does not need in order to say someone read four pages in six
    minutes. The same product had already decided the other way elsewhere: a presentation attendee's
    address is kept as a salted HMAC bound to the slug, never in the clear. Two opposite decisions on
    the same datum, and nothing had ever put them side by side. What a session hands out is now an
    explicit allow-list rather than the whole row, in both the select= and the projection, so a
    column added later does not leave by default — in that direction an oversight is a leak, in the
    other it is an absence the first reader reports. A bench reads the table's columns from
    supabase/init.sql and fails when one is neither served nor withheld with a written reason. The
    raw ua goes for a different reason than the IP: it is redundant. device, os and
    browser are derived from it when the session is written and are served; the full string carries
    nothing more that a reader of the record reads, only enough to recognise one device across
    sessions. Both columns are still recorded — not serving a column and not keeping it are two
    different decisions, and the second one touches thirteen months of journal.

  • docshare.sessions returned every reading session of a document to any member allowed to call
    it, and that table carries the recipient's address and IP — so a member read the prospects of
    their colleagues, which is exactly what the list / list.all split has prevented on
    docshare.list since a host asked for it. A strict door had a wide door beside it, and two calls
    were enough to use the second. sessions now asks the same second question and answers with the
    same scope field. The scope follows the chain of origin, not the last link: createReshare
    sets created_by to the parent's recipient, so filtering on created_by alone would have hidden
    from a salesperson the readings of their own forwarded links — the ones they caused. Each session
    now also carries its filiation (parent_slug and the parent's recipient), without which the rule
    is invisible to the caller. Hosts: see docs/HOST-CONTRACT.md — a member you answer no to on
    list.all now sees only their own chain.

  • The twelve regular-expression probes that neither their guard nor any bench could see are closed
    out. Ten now die to a case that names them: what a fenced code block hides from a language check,
    which of three string forms a crypto-binding guard strips, which shell a workflow step runs under
    and what survives of its refusal, the root permissions: block, a SQL block comment that must not
    fabricate a signature, and the whitespace fold that makes a comment's fingerprint depend on its
    text rather than on the file's layout. Two cannot die and now say so with a measurement:
    secrets-en-clair normalises base64url before decoding, and Node's decoder already accepts -
    and _ — the bench computes both decodings and asserts they are equal, so the redundancy is
    checked at every run instead of being claimed in a comment nobody has re-verified. Three of the
    ten took two attempts, each because the first fixture differed from its subject in more than one
    way and was caught by a second mechanism before reaching the probe it named: a call glued to its
    quote that the call-shape probe already refuses, a closed fence whose six backticks the inline
    stripper pairs off by itself, and a comment folded at the head where trim suffices. The fence
    stripper changes the verdict on zero of the repository's 31 markdown files — it guards a shape
    no document has yet — and the bench pins that too, so the next reader does not mistake proximity
    for coverage.

  • The probes that are not regular expressions had never been measured, and they hold up worse than
    the ones that are: 152 of them, 90 leaving their guard green, against 55 of 143 for the regexes.
    Five had a live subject. surface-publique loaded three modules instead of seven and read
    eighteen symbols instead of seventy-five while exiting green — its two floors had been set to
    refuse the empty, and a half-blind probe is not empty; a declared public subpath must now have a
    readable target. surface-base's floor on tables held in the empty case and nowhere else, through
    a JavaScript coercion: blinded, it refused exactly zero tables and passed one, two or three.
    image-documentee carried a perimeter written as a list of what to look at, now inverted into a
    list of what is excused with a reason each. liaison-de-crypto computed its perimeter inside its
    verdict, where nothing could test it. Ten AST type guards in the two tools that walk a tree were
    seen by neither their guard nor their bench, and their failure direction is the wrong one: a node
    that stops being recognised is a read that vanishes from the record. The twenty survivors that
    nothing sees are named in AGENTS.md with their direction, along with what remains outside even
    this sweep.

  • The three tools the mutation sweep had recorded as measured on nothing were measured against
    their benches instead of their exit codes, which is the only judge they have: 11 regex literals,
    8 already covered, 3 survivors, now none. The one that mattered is verdict-zap's reader of the
    report files present on disk — the input its own comment calls the one witness independent of the
    caller. The bench exercised that rule by injecting the list; the function that reads it was tested
    nowhere, so blinding it emptied the list in every circumstance and no orphan report would ever be
    seen again. release-preflight's parsing of git ls-remote --tags was lifted out of a function
    that cannot run without a repository, a network and a version to ship, and now has a bench
    covering the ^{} suffix an annotated tag carries; zones-du-tarball's handling of quoted SQL
    identifiers is stated in tests, including the fact that a fully quoted name is not matched at all.

  • Six guards had a probe blind on a subject the repository contains, found by opening every one of
    the 49 survivors the mutation sweep had left untriaged. Four of them printed a sentence that was
    false while blind: shell-des-workflows announced 112 blocks analysed by bash having analysed
    none (and, from the other copy of the same question, 0 blocks analysed while exiting green);
    changelog announced no repeated title or subtitle with neither duplicate rule running — the
    defect that reached this file twice in two days; liens-des-documents announced 2 relative
    links recognised in 0 published documents
    ; documents-publies stopped recognising .md files
    as documents, so nothing watched what travels in the tarball unannounced; images-epinglees
    stopped reading the major a tag announces, which is what makes the docker job say tag and
    digest name the same major
    without comparing anything. Three of those six are one defect wearing
    three faces — the same question written twice, once for the judge and once for the count that
    gets printed.

  • requete-diagnostic returned a query whose every line was still commented out, and both of its
    anti-vacuity floors passed: select matches inside a comment and the last line still ends in
    ;. CI pipes that output to psql, so the job that checks which roles can read which tables
    executed nothing, returned nothing, and went green. The extraction now requires every non-empty
    line of the block to have changed — compared before and after rather than recognised a second
    time.

  • The glob-to-regex conversion in codeowners-valide and attributs-des-generes had its escaping
    tested nowhere: blinded, a pattern silently matches more than it says, so the guard demands less.
    Covered by benches; the remaining twelve survivors that neither a guard nor a bench sees are
    recorded in AGENTS.md with their direction and their measurement, rather than counted as passed.

  • Four guards had a probe blind on a subject the repository actually contains, found by re-running
    the mutation sweep on an extractor rebuilt on the TypeScript AST — 143 real regex literals
    against 171 by pattern, 28 of which were slashes inside string constants. images-des-workflows
    no longer misses a $IMAGE_… used without being declared; outils-servis refuses when it can no
    longer tell which tools depend on an installed package; portes-de-reponse no longer misses a
    text/… body served without nosniff; and migrations-detectables now requires each of the ten
    kinds of sign attested by a real migration to still be seen, instead of printing a count its
    own comment treated as the protection — blinding its detectors one at a time left the guard green
    in three cases, with eleven signs out of sixty-three vanishing in silence. Three tools
    (release-preflight, verdict-zap, zones-du-tarball) refuse before any mutation for want of a
    release context and were measured on nothing; that, and some fifty survivors not individually
    verified, are recorded in AGENTS.md rather than counted as passed.

  • secrets-en-clair recognises eight distinct credential shapes and a rule of its own on .env
    names; its positive control planted a single AWS-key sample, proving one shape out of nine
    was still seen. An exhaustive sweep — every regex literal of every tool blinded one at a time —
    measured that 19 of its 20 patterns could stop matching while the guard stayed green. There is
    now one sample per shape, assembled at runtime, and a check that every searched shape has one,
    itself exercised on tables where a shape is deliberately orphaned.

  • The stripped-environment bench built its tree outside any git repository, so git ls-files
    failed there whether or not git was on the PATH: its central comparison held two identical
    environments side by side. The tree is now a real repository, and the comparison extends from the
    exit code to the summary line — a guard that swallows a git failure and falls back to a
    plausible list exits 0 on both sides while having examined a hundred files on one and one on the
    other. A mutant reported as killed by three benches, and in fact surviving all three, is now
    killed by this one alone.

  • The viewer page told the host's bot plugin that voice was available whenever
    ELEVENLABS_API_KEY was set, while rendering no voice control unless the plugin also declared
    wiresVoice: true. Two halves of the same page disagreed, and the half derived from
    configuration carried the rule docs/HOST-CONTRACT.md has said was corrected since 26/08 — the
    key proves the server can synthesise, never that a click leads anywhere
    . Both halves now ask one
    exported predicate, and a bench asserts they agree in every configuration rather than pinning a
    value.

  • renvois-par-position had a written list hiding inside a perimeter that looks derived: the tree
    walk covers everything, but the scope filter enumerated four root filenames plus docs/. The same
    reference, word for word, was caught in docs/ and invisible at the repository root — five
    existing documents (CLA.md, CODE_OF_CONDUCT.md, MAINTAINERS.md, ROADMAP.md, SUPPORT.md)
    were outside the rule with no decision having said so. The scope is now a list of what is
    permitted out, with CHANGELOG.md as its one written exception, and a bench asserts that
    exception still has a subject. Eighteen of the twenty disk-derived guards were probed by dropping
    a new violating file into the tree; this was the only one that did not see it.

  • Five CI guards asserted an absence over the repository and could not tell found nothing from
    looked at nothing: greps-sans-angle-mort, shell-des-workflows, image-documentee,
    liens-des-documents and langue-publiee each printed a full green while their probe was blind.
    All five already carried an anti-vacuity floor — each floor counted what had been opened
    (blocks read, documents read, files in the tarball) where the rule turns on what the probe
    still recognises
    . Every floor now counts the recognised form, and says so in the green line.

  • shell-des-workflows announced 112 bloc(s) « run: » analysés par bash, aucun refusé with
    bash -n never invoked. Its control is injected — a script known to be broken, handed to the
    same analyser the verdict uses — because a derived one would demand of the repository the very
    thing the rule forbids.

  • Three guards gained a single shared traversal (appelsDesBlocs, referencesLues, liensLus)
    so that judge and witness read through one probe, named once, rather than two copies that can
    drift apart.

  • surface-publique reported 3 stable, 4 experimental, 2 document, 1 manifeste — a count taken
    from an object literal in its own source, which cannot fall whatever breaks. Three separate
    blindings (an empty subpath list, a silent module load, an emptied loop) each left it fully green
    with that identical line. It now floors and prints what it actually walked: public subpaths,
    modules loaded, symbols read. Its verdict loop also re-implemented the subpath filter instead of
    calling publics(); it now walks the one list, and a bench asserts the two agree.

Changed

  • The three examples now pin 0.1.145, the version the registry serves. This is the
    post-publication step docs/RELEASING.md describes, done on the train that published it rather
    than left to the margin: a pin on the version just published survives the next release and only
    falls out on the one after, so a single skipped bump costs nothing visible and the second one
    turns main red on a check that has nothing to do with whoever opened the PR.

Added

  • A standalone host can finally arm the retention purge. server/retention.js requires
    config.retention.balayage === true, and that strict opt-in is right — the windows are business
    decisions, and a deletion should act only where an operator has written it down. But
    context/standalone.js exposed no retention key at all: a host consuming that context as-is
    had nowhere to write it, so only those hand-writing their own context could ever arm the sweep.
    The others accumulated with no recourse, and without knowing a recourse existed. An opt-in that
    part of the fleet cannot reach is not an opt-in, it is an unavailability
    — and it surfaced only
    because two hosts counted what had piled up in their own databases. PLAYER_RETENTION_SWEEP=1
    arms it, and the four windows come with it rather than after: exposing the switch alone would arm
    the purge on our defaults, which is precisely the failure mode retention.js already
    describes, reached by an environment variable instead of an oversight. Whoever arms it must be
    able to decide what they are arming. A missing variable sets no key at all — writing undefined
    over a default would make validation fail and refuse every purge for the most ordinary case,
    a host that arms without setting months — and an unreadable value is refused by the core, naming
    the key, with zero deletions, rather than corrected here where it would go silent.

  • docshare.sessionsByRecipient reads one person's reading sessions across every document —
    address, optional date bound, cursor pagination — under the same scope as docshare.list and
    docshare.sessions. Migration 0025 adds the partial index (recipient_email, last_at desc)
    the query needs; without it the call answers identically and more slowly, and the more slowly the
    older the journal. Two things the payload states rather than implies: cursor: null marks the end
    and the length of a page never does — the scope filter runs after the read, so a page can be short
    or empty while more remains, and the cursor carries the last row examined so nothing is skipped
    or served twice; and the order is (last_at, session_id) descending, because two sessions can
    share a timestamp and a cursor on time alone would drop one of them.

  • tools/__tests__/environnementDepouille.test.js runs every tool with a PATH containing nothing
    but node — no git, no npm — against a garnished tree, and asserts the taxonomy holds: never
    exit 1 (which would accuse a branch for an environment fault), and a stripped environment never
    manufactures a green. Three tools reach their external call only in that configuration. The file
    states in its own header that no mutant is known to be killed by it alone: the neighbouring
    empty-tree bench already covers most of this ground, because a temp directory is not a git
    repository either.

  • The tree builder both benches use now lives in one place (__tests__/aide/arbre-outils.mjs)
    rather than in two copies that could drift apart.

What changed in the package, by zone — 0.1.1450.1.147

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 2
manifest package.json — version, exports, dependencies 0 0 1
server the code the host executes 0 0 4
context the injected-context implementations 0 0 1
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 3 0 1
The files themselves
~ docs/HOST-CONTRACT.md
~ docs/RETENTION.md
~ package.json
~ server/gabarit-agent.js
~ server/page-visionneuse.js
~ server/routes-liens.js
~ server/shares.js
~ context/standalone.js
+ supabase/migrations/0025-sessions-par-destinataire.sql
+ supabase/migrations/0026-purge-des-adresses-ip.sql
+ supabase/migrations/0027-purge-des-user-agents.sql
~ supabase/init.sql

⚠️ database — 1 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, index idx_cds_recipient_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

    @@ -108,6 +108,10 @@
       seconds         integer constraint ck_views_seconds_borne check (seconds is null or (seconds >= 0 and seconds <= 86400)),
       session_id      text,
       at              timestamptz not null default now(),
    +  -- ⚠️ VIDE, ET PLUS JAMAIS ÉCRITE (0027, demande ADV du 01/09/2026). Cette table n'a ni `device`,
    +  -- ni `os`, ni `browser` : elle ne dérivait RIEN de cette chaîne, l'écrivait, et aucune requête ne
    +  -- l'a jamais relue. Elle demeure le temps qu'aucune version supportée ne l'écrive — voir la note
    +  -- de `commercial_doc_sessions.ip` ci-dessous pour la raison, qui est la même.
       ua              text
     );
     create index if not exists cdv_slug_idx on public.commercial_doc_views (slug);
    @@ -128,7 +132,17 @@
       max_page        integer constraint ck_sessions_max_page_borne check (max_page is null or (max_page >= 0 and max_page <= 10000)),
       total_seconds   integer default 0 constraint ck_sessions_total_seconds_borne check (total_seconds is null or (total_seconds >= 0 and total_seconds <= 86400)),
       pages_time      jsonb   default '{}'::jsonb,
    +  -- ⚠️ VIDE, ET PLUS JAMAIS ÉCRITE (0027). `device`, `os` et `browser` ci-dessous en sont dérivés À
    +  -- L'ÉCRITURE et sont, eux, servis : la chaîne brute n'a plus de lecteur. Même sort que `ip`, même
    +  -- raison de survivre encore.
       ua              text,
    +  -- ⚠️ VIDE, ET PLUS JAMAIS ÉCRITE (0026, arbitrage ADV du 01/09/2026). Elle a porté l'adresse du
    +  -- lecteur en clair ; la 0.1.146 a cessé de la SERVIR, la 0026 efface ce qui restait et plus
    +  -- aucune écriture ne la remplit. Elle demeure ici parce qu'une migration doit rester sûre
    +  -- pendant que la version PRÉCÉDENTE du code tourne — celle-là l'écrit encore, et PostgREST
    +  -- rejette une écriture portant une colonne inconnue : la supprimer aujourd'hui ferait échouer
    +  -- TOUTES les écritures de session d'un hôte pas encore déployé. Sa suppression est le geste
    +  -- d'une livraison ULTÉRIEURE, quand plus aucune version supportée ne l'écrit.
       ip              text,
       device          text,
       os              text,
    @@ -139,6 +153,12 @@
     create index if not exists cds_sess_slug_idx on public.commercial_doc_sessions (slug);
     create index if not exists cds_sess_doc_idx  on public.commercial_doc_sessions (doc_id, last_at desc);
     create index if not exists idx_cds_last_at on public.commercial_doc_sessions (last_at);
    +-- « Toutes les lectures de cette personne, la plus récente d'abord » — la question de la fiche
    +-- par destinataire. Partiel : un lien anonyme laisse `recipient_email` nul et ne répond jamais à
    +-- cette question ; le porter dans l'index le grossirait sans qu'aucune requête ne l'y cherche.
    +create index if not exists idx_cds_recipient_last_at
    +  on public.commercial_doc_sessions (recipient_email, last_at desc)
    +  where recipient_email is not null;
     
     create table if not exists public.commercial_doc_internal_sessions (
       session_id    text primary key,
    @@ -787,6 +807,27 @@
     alter table public.doc_presentations
       add column if not exists view_rotation integer not null default 0;
     
    +-- ⚠️ ET LE RATTRAPAGE VAUT AUSSI POUR CE QU'ON EFFACE (0026). Une base installée avant aujourd'hui
    +-- porte treize mois d'adresses ; `create table if not exists` ne touche pas une table déjà là, donc
    +-- rejouer ce fichier ne les effacerait jamais. Le geste est celui de la 0026, à l'identique, et il
    +-- ne coûte rien sur une base neuve — où la colonne est vide par construction.
    +update public.commercial_doc_sessions set ip = null where ip is not null;
    +-- 0027 — même geste pour le User-Agent brut, sur les DEUX tables.
    +update public.commercial_doc_sessions set ua = null where ua is not null;
    +update public.commercial_doc_views set ua = null where ua is not null;
    +comment on column public.commercial_doc_sessions.ua is
    +  'VIDE ET PLUS JAMAIS ECRITE depuis la 0027. A porte le User-Agent brut du lecteur. Les champs '
    +  'device, os et browser en sont derives A L''ECRITURE et sont, eux, servis. Conservee le temps '
    +  'qu''aucune version supportee du lecteur ne l''ecrive. Voir docs/RETENTION.md.';
    +comment on column public.commercial_doc_views.ua is
    +  'VIDE ET PLUS JAMAIS ECRITE depuis la 0027. A porte le User-Agent brut du lecteur. Cette table '
    +  'n''en derivait rien et aucune requete ne l''a jamais relue. Conservee le temps qu''aucune '
    +  'version supportee du lecteur ne l''ecrive. Voir docs/RETENTION.md.';
    +comment on column public.commercial_doc_sessions.ip is
    +  'VIDE ET PLUS JAMAIS ECRITE depuis la 0026. A porte l''adresse IP du lecteur en clair. '
    +  'Conservee le temps qu''aucune version supportee du lecteur ne l''ecrive : la supprimer '
    +  'aujourd''hui casserait les ecritures d''un hote pas encore deploye. Voir docs/RETENTION.md.';
    +
     -- ⚠️ ET LE RATTRAPAGE VAUT AUSSI POUR LES CONTRAINTES, PAS SEULEMENT POUR LES COLONNES (0020).
     -- Elles sont déclarées dans le corps des tables ci-dessus, ce qui règle la base VIERGE — et ne
     -- touche pas une base déjà installée, où `create table if not exists` ne fait rien. Le scénario