fix!: joindre un document n-est pas en avoir des nouvelles, et une reinit invalide ce qu-elle abandonne

Deux defauts, aucun des deux sur la reprise - elle a passe cette revue intacte et
n'a pas ete rouverte.

1. La barriere verifiait qu'un document nomme peut etre JOINT, jamais qu'il a
   REPONDU. Elle attendait `ensureRepoOpen` puis demandait `assertMayReach`, qui
   est la question de la capacite, et ne consultait pas l'etat de synchro que
   l'ouverture venait de produire. Un document dont l'abonnement a reussi mais dont
   le premier `State` n'est jamais venu publiait donc `isSuccess` avec `data: []` :
   le defaut d'origine, dans le cas precis pour lequel la barriere a ete ajoutee.

   La verification rejoint le reste de la decision, dans `publish`, donc rejouee a
   chaque publication au lieu d'etre memorisee. Les trois valeurs, enumerees avant
   d'ecrire la condition : `"synced"` fait autorite - zero ligne VEUT DIRE zero
   objet, la ligne tenue depuis cinq revues -, `"timed-out"` et `"syncing"` non, et
   `"unknown"` PASSE deliberement, parce qu'il veut dire « pas de semantique de
   `State` sur ce `ng` » et que le traiter autrement est exactement la regression
   du cas inerte livree aux tours 1 et 3.

   `isPending` plutot que `isError`, des deux que le contrat autorise : rien n'a
   echoue, il manque l'autorite de dire que le document est vide.

   Le test (t) EPINGLAIT le defaut - il attendait `isSuccess` apres trois
   barrieres expirees. Corrige. Ce n'est pas un test affaibli : l'ancienne
   assertion etait fausse.

   Et un trou dans ma propre suite, trouve par mutation : inverser la condition
   pour refuser `"unknown"` passait tous les tests, faute d'un `ng` SANS
   `doc_subscribe` nulle part. (cc) le couvre.

2. Regression de mon correctif du tour 4. Terminer une ouverture abandonnee
   faisait tourner sa continuation juste apres que la reinit avait vide les
   tables, donc elle ecrivait une entree `opened` perimee dans le registre neuf -
   et toute ouverture ulterieure de ce nuri court-circuitait dessus. Le depot
   n'etait jamais abonne dans la nouvelle session. Une epoque de registre corrige
   cela : une reinit l'incremente, une continuation dont l'epoque ne correspond
   plus n'ecrit rien. Une reinit INVALIDE ce qu'elle abandonne, en plus de le
   terminer.

`getSyncState` reste interne, et la doctrine cesse de l'offrir : elle pointait sur
un symbole qu'aucun appelant ne peut importer.
This commit is contained in:
Sylvain Duchesne
2026-09-24 11:03:07 +02:00
parent 8c890411bb
commit 4af0be433d
7 changed files with 224 additions and 15 deletions
@@ -83,6 +83,21 @@ Every row was read in this working copy. `fetchReadCap` reaches the network only
**Partial scopes.** `graphs: [docA, docB]` with only docB held refuses the WHOLE query rather than answering docB's objects. A short answer is the same lie as an empty one, and this repo has already ruled twice against skip-and-carry-on: `readUnion`'s per-document tolerance was removed as an invention in `1.0.0-dev.8`, and an ORM write refuses the whole batch when one document is not writable.
### Round 5 - the sync-state enumeration, written before the condition
`useShapeQuery` checked that a named document may be REACHED and never that the open it had just awaited ARRIVED. Every value `getSyncState` can answer with, in every state it can be in when `reachNamedDocuments` has resolved - because treating the wrong one as not-synced is how the inert case regressed in rounds 1 and 3:
| value | what it means (`open-repo.ts`) | how it is reached after the open resolved | verdict |
|---|---|---|---|
| `"synced"` | the first `State` arrived - the TRUTH signal | the ordinary path with a real `doc_subscribe` | **authoritative.** Zero rows MEANS zero objects; `isSuccess` with `data: []` is the answer, and this is the line four passes have held |
| `"timed-out"` | the bounded fallback fired with NO `State`; opened best-effort, sync UNCONFIRMED | a broker that accepts the subscription and never pushes | **not authoritative.** This is the whole defect: the document may hold anything, and the read has no standing to call it empty |
| `"syncing"` | subscribed, no `State` yet | unreachable once the open has resolved - the open resolves on `State` or on the fallback, and each sets its own value. Treated as not authoritative anyway, because if it ever IS reached the barrier demonstrably was not | **not authoritative** (defensively) |
| `"unknown"` | never requested, **or the fake-ng no-op path: no `State` semantics** | an injected `ng` with no `doc_subscribe` - the whole unit suite, and any consumer whose `ng` lacks it. Also, until round 5 fixed it, a stale `opened` entry left by a reset | **authoritative, deliberately.** There is no barrier to have missed where there are no barrier semantics. Treating it as not-synced is exactly the inert regression rounds 1 and 3 shipped, and it would leave every early path pending for ever |
So the condition is `"timed-out"` or `"syncing"`, and `"unknown"` passes. With a real broker the post-open state is only ever `"synced"` or `"timed-out"`, so the check bites precisely where `State` semantics exist.
**`isPending` rather than `isError`**, and the contract allows either (`:196`, `:280`). Chosen because nothing FAILED: the subscribe succeeded, the query opened, the read did not throw - what is missing is an authoritative answer, and `isPending` is this surface's word for a question still open, while `isError` has meant "something threw" through five passes. It also needs no recovery machinery of its own: `publish` re-reads the level on every publication, so if the `State` does arrive later and brings data, the ORM's own change signal republishes and the read succeeds. What remains, and it is stated rather than hidden: a document whose `State` never arrives leaves the read pending for that session. The contract already names that state and tells an application to handle a `isPending` that does not end.
### The design, and what was rejected
Chosen: **`fetchReadCap` stops converting a failed download into `false`.** `downloadReadCap` right below it already distinguishes resolving from rejecting and documents why; the collapse was one `catch` destroying that distinction one line later. Removing the `catch` leaves `boolean` honestly meaning "a cap was obtained", and gives all three live call sites the one thing each of them wanted. Plus the `useShapeQuery` barrier above, which is where the published empty success actually comes from.
@@ -131,7 +146,7 @@ Two findings, both reproduced here before anything was changed, both diagnoses v
Fix, round 2: the keyring signal RE-OPENS a scope the barrier refused, gated on `namedDocumentsReachable()`. **That gate was itself wrong and round 3 refuted it - see below.**
**Finding 2 - the INERT claim was false.** Reproduced by reading and pinned as (u): `ensureRepoOpen` runs whatever the registry is enforcing, so the inert case gained a real `doc_subscribe` and a barrier wait. The open is right and stays; what was wrong was calling it "unchanged". The measured COST was real though: the barrier was SERIAL, so N documents that never push a `State` cost N bounded fallbacks - 8s each in production. Now parallel, so the wait is the slowest rather than the sum, with the refusals still read in scope order afterwards. Pinned by (t), which fails on the serial form.
**Finding 2 - the INERT claim was false.** Reproduced by reading and pinned as (u): `ensureRepoOpen` runs whatever the registry is enforcing, so the inert case gained a real `doc_subscribe` and a barrier wait. The open is right and stays; what was wrong was calling it "unchanged". The measured COST was real though: the barrier was SERIAL, so N documents that never push a `State` cost N bounded fallbacks - 8s each in production. Now parallel, so the wait is the slowest rather than the sum, with the refusals still read in scope order afterwards. Pinned by (t) - which, as round 5 found, was at the same time asserting the WRONG outcome for those timed-out barriers, and is corrected there.
**And the thing that let both through: the e2e never walked this path.** Confirmed - both `useShapeQuery` journeys read `{graphs: "did:ng:i"}`, so `43 passed / 43` was never evidence for the barrier, and reporting it as such was the real failure of round 1. Four live checks added over a NAMED scope: the owner reads her own document (the barrier does not break a live named read); a holder of nothing is `isError` with the refusal's own message and `dataLen: 0`, never an empty success; a PUBLIC document opens for that same holder from a bare reference, through the barrier's own fetch; and a cap landing for ANOTHER document leaves the refused scope refused. That fourth one measures the OUTCOME only - it does not separate "no re-open was attempted" from "one was attempted and refused again"; the targeting itself is measured by the notification count in `use-shape-query.test.ts` (s). Saying which of the two a green check proves is the whole lesson of the round that shipped this barrier with no named-scope coverage.
@@ -176,14 +191,30 @@ Three defects and two false claims, all reproduced here before anything was touc
Two fixtures were also racing themselves and are corrected, with the reason written beside them: `use-shape-query.test.ts`'s fake minted into `did:ng:o:doc<n>`, the same namespace as `wallet-fake.ts`, so two fakes aliased each other's repos in the process-global open registry - the lesson `wallet-fake` already carries at its own scale, one scope wider; and the INERT test asserted `isEnforcing() === false` and then subscribed, while signing in arms the registry a tick later, so it was pinning the schedule. It now runs with nobody signed in, which is inert for the whole test.
### ROUND 5 - two defects, neither about recovery
The recovery design survived this pass and was not reopened. Both findings reproduced here before anything was changed.
**Finding 1, blocking: the barrier checked that a document may be REACHED and never that it ARRIVED.** `reachNamedDocuments` awaited `ensureRepoOpen` and then asked `assertMayReach`, which is the CAPABILITY question; it never consulted the sync state that open had just produced. So a document whose subscribe succeeded and whose first `State` never came published `isSuccess` with `data: []` - the original defect, in the exact case the barrier was added to close. Reproduced as (bb), and test (t) was PINNING it: it waited for `isSuccess` after three timed-out barriers and asserted the three titles. (t) is corrected, and that is not a weakened assertion - the old one was wrong, and the commit says so.
The fix sits where the rest of the decision already sits: `publish` calls `namedDocumentsArrived()` on every publication, so the question is re-asked at the level rather than remembered from the barrier. The enumeration of the three values, written before the condition, is above under "Round 5". The short form: `"synced"` is authority (zero rows MEANS zero objects - the line that has held through five passes), `"timed-out"` and `"syncing"` are not, and **`"unknown"` PASSES deliberately** because it means "no `State` semantics on this `ng`" and treating it as not-synced is exactly the inert regression rounds 1 and 3 shipped.
**`isPending`, not `isError`**, of the two the contract allows. Nothing failed: the subscribe succeeded, the query opened, no read threw - what is missing is the authority to call the document empty, and `isPending` is this surface's word for a question still open while `isError` has meant "something threw" for five passes. It also needs no machinery: `publish` re-reads the level, so a `State` that lands later and brings data republishes through the ORM's own change signal. Stated rather than hidden: a document whose `State` never arrives leaves that read pending for the session, which is the state the contract already tells applications to handle.
**A GAP IN MY OWN SUITE, found by mutation rather than by review.** Inverting the condition to treat `"unknown"` as not-arrived - the precise regression the blast-radius note warned about - passed every test. Nothing in the file exercised an `ng` WITHOUT `doc_subscribe`, which is the only way `"unknown"` is reached, and it is what `wallet-fake` does by default and what any consumer lacking the member does. Closed by (cc), which now fails on that inversion.
**Finding 2, major, and a regression from my own round-4 fix.** Settling an abandoned open made its continuation run just after `resetOpenedRepos()` had cleared the maps, so it wrote a stale `opened` entry into the fresh registry - and every later `ensureRepoOpen` for that nuri short-circuited on it. The repo was never subscribed in the new session and a read over it answered as though it had been. Before round 4 that line ran only when the 8s fallback fired, so a re-open inside the window did re-subscribe; ending the open promptly was right, but it moved the write into the window where it does damage. Fixed with a registry epoch: a reset bumps it, and a continuation whose epoch no longer matches writes nothing. A reset now INVALIDATES what it abandons as well as ending it. Pinned by a new `open-repo.test.ts` case that fails without the epoch check.
**`getSyncState`: decided, not left dangling.** It stays INTERNAL, and `knowledge_what-a-failed-read-answers-upstream` no longer offers it - that pointer was at a symbol no caller can import. The reason for not exporting: it has no counterpart upstream at any level, so publishing it would hand an application one more polyfill-era symbol to unlearn, on a surface that is busy removing exactly those; and the distinction it carries is now folded into `useShapeQuery`'s own states, which is where a caller needs it. The leaf now also states the consequence plainly instead of papering over it: a caller using the anchored `docs.sparqlQuery` directly gets zero rows for an unsynced document and has no published way to tell that from an empty one - the same collapse upstream has.
### Gate
- `bun test` -> `442 pass / 0 fail`, `Ran 442 tests across 40 files` (baseline `421 pass / 0 fail`, measured before any change; +21 new tests, none deleted or skipped). **Run six times: green every time, 2.25-2.53s.** Two tests were REWRITTEN rather than weakened, each because the design under them changed and the old assertion had become a statement about something that is no longer true: (s) asserted the targeting predicate round 3 proved circular, and now asserts that a re-ask is made for the class it can repair and not for the one it cannot, each half guarding the other against vacuity; (w) asserted that no query is opened under another identity, a rule that left two classes terminal, and now asserts the stronger invariant that replaced it - no snapshot ever claims success for a document the CURRENT holder cannot reach.
- **Mutation-tested, five ways.** Removing the raced-edge re-attempt kills (y); removing the publish-time refusal kills (w); removing the snapshot-stability guard kills (aa) and (k); removing the re-attempt entirely kills (r)(s)(v)(w)(z); `Promise.all` in place of `allSettled` kills (x). Round 2's circular gate kills (s)(t)(v) and round 3's holder rule kills (w)(z), so the suite now discriminates against both previously-shipped designs. Removing the abandoned-open settle kills the new `open-repo` test.
- `bun test` -> `445 pass / 0 fail`, `Ran 445 tests across 40 files` (baseline `421 pass / 0 fail`, measured before any change; +24 new tests, none deleted or skipped). **Run six times: green every time, 2.78-2.98s.** Two tests were REWRITTEN rather than weakened, each because the design under them changed and the old assertion had become a statement about something that is no longer true: (s) asserted the targeting predicate round 3 proved circular, and now asserts that a re-ask is made for the class it can repair and not for the one it cannot, each half guarding the other against vacuity; (w) asserted that no query is opened under another identity, a rule that left two classes terminal, and now asserts the stronger invariant that replaced it - no snapshot ever claims success for a document the CURRENT holder cannot reach.
- **Mutation-tested, ten ways.** Round 5: removing the arrival check kills (bb) and (t); inverting it to refuse `"unknown"` kills (cc); removing the registry-epoch invalidation kills the new `open-repo` case. Earlier rounds: Removing the raced-edge re-attempt kills (y); removing the publish-time refusal kills (w); removing the snapshot-stability guard kills (aa) and (k); removing the re-attempt entirely kills (r)(s)(v)(w)(z); `Promise.all` in place of `allSettled` kills (x). Round 2's circular gate kills (s)(t)(v) and round 3's holder rule kills (w)(z), so the suite now discriminates against both previously-shipped designs. Removing the abandoned-open settle kills the new `open-repo` test.
- `bunx tsc --noEmit -p packages/polyfill/tsconfig.json` -> exit 0
- `bunx tsc --noEmit -p packages/polyfill/e2e/tsconfig.json` -> exit 0
- `python3 ~/.claude/skills/concept/lint.py` -> 0 ERROR (warnings pre-existing; the new leaf carries none)
- polyfill e2e, run at HEAD: `Polyfill e2e summary: 46 passed, 0 failed, 46 total - batch 3.5 min, slowest cold sync 29s`. 43 was the count before this task; three named-scope checks were added and a fourth removed as non-discriminating. NOTE: the broker login is flaky and independent of this work - three of twelve runs in this session died with `[e2e broker login] gave up after 45.0s - the wallet-list screen never changed`, at a different step each time, on the login UI before any library code runs. Every such run was re-run to a clean result.
- polyfill e2e, run at HEAD: `Polyfill e2e summary: 46 passed, 0 failed, 46 total - batch 3.5 min, slowest cold sync 30s`. **No reviewer has ever run this suite; every count reported for it in this task is mine alone.** 43 was the count before this task; three named-scope checks were added and a fourth removed as non-discriminating. NOTE: the broker login is flaky and independent of this work - three of thirteen runs in this session died with `[e2e broker login] gave up after 45.0s - the wallet-list screen never changed`, at a different step each time, on the login UI before any library code runs. Every such run was re-run to a clean result.
**What the e2e PROVES**, and it is the whole of it: against the real broker, a named scope reads for the holder who owns the document; a holder of nothing is refused out loud with `dataLen: 0` rather than told the document is empty; and a public document opens for that same holder from nothing but its reference, through the barrier's own fetch. **What it does NOT prove**: anything about the re-attempt. Every recovery path - the public class repaired by re-asking, the cost of a doomed re-attempt, and the holder a re-attempt is made as - is measured only in the unit suite, against the `CapRegistry`, which is where that mechanism lives and which the e2e's own header says the broker does not implement. Driving the positive recovery live needs a second actor delivering through `inbox.share`, and belongs in the applicative suite.