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
@@ -38,6 +38,8 @@ Three rules follow, and all three are load-bearing:
1. **A breakdown rejects.** Anything that stops a read from happening - a repo that will not open, a broker that does not answer, an emulation step that could not be performed - reaches the caller as a failure. `A rejection means "unknown", never "absent"` is the contract's own wording.
2. **An empty document is a SUCCESS holding zero objects.** The repair for (1) must never be bought by making a real emptiness into an error; that only moves the lie. An empty document, an empty scope, a document nobody opened an inbox on, a document in a public store that exposes no key - each is an answer, and each stays one.
3. **The unsynced/empty collision is closed by the barrier, not by an error.** Where this package can cross the sync barrier before answering, it does (`emulated-verifier/open-repo.ts`), which is what makes a zero-row answer mean something. Where it cannot, the answer is still a success, and `getSyncState` is what says whether it was authoritative. Turning an unsynced read into a rejection would be inventing a third outcome the engine does not have.
3. **The unsynced/empty collision is closed by the barrier, and where the barrier was not crossed the read says so rather than answering.** Crossing it is what makes a zero-row answer mean something (`emulated-verifier/open-repo.ts`, which keeps `synced` and `timed-out` deliberately apart). A reactive read over documents it NAMES consults that distinction and stays PENDING when the barrier was not reached - "I have no authoritative answer" rather than "there is nothing". Turning it into a rejection would be inventing a third outcome the engine does not have, and answering a success would be the collapse this whole leaf is about.
**What an application can see of this, exactly.** `useShapeQuery` folds it into its own states, and that is the only published channel: a named scope whose barrier was not crossed does not reach `isSuccess`. `inbox.readSyncedForDocument` is the same guarantee on the inbox path. `getSyncState` is INTERNAL and is deliberately not published - this leaf offered it until 2026-09-24, which was a pointer at something no caller can import. It stays internal because it has no counterpart upstream at any level: publishing it would hand an application a polyfill-era symbol to unlearn at migration, on a surface that is busy removing exactly those. The consequence to state plainly rather than paper over: 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** - that is the collapse upstream has too, and it is why the reactive read is the one that carries the distinction.
Related: `knowledge_what-a-read-scope-is-upstream` (what a read request can SAY, including the `[]` that means the empty set and the `[]` that means no filtering), and `polyfill-surface/contract_polyfill-surface` `## Guarantees` for the caller-facing form of all of this.
@@ -193,13 +193,13 @@ Where a call must first find out whether something already exists — a document
**A reactive read says "nothing" and "I could not find out" differently.** `useShapeQuery` answers in three states and only two of them are answers about your data: `isPending` while the question is still open, `isSuccess` once it has been answered, `isError` when it could not be. An empty `data` under `isSuccess` means this scope holds nothing of that shape — the distinction the surface exists for, and the one thing it will never do is publish that snapshot for a read that did not happen. And because an observable cannot take back a list a subscriber has already rendered, `data` under `isError` keeps the **last read that answered** rather than emptying — so an empty `data` is never handed to you as a failure's answer. Read the load state before `data`: **a rejection means "unknown", never "absent"** here too.
**A document you NAME in `graphs` is reached before the read opens, since `1.0.0-dev.9`.** Naming a document in a scope is the same act as anchoring `docs.sparqlQuery` on it, and it is now answered the same way: the key is asked of a public store if you do not already hold it, the document is refused out loud if you hold no key for it, and its repo is synced before the question is put. A named document you cannot read publishes `isError`, where until this version it reached the engine, came back, and was emptied by the per-document filter into `isSuccess` with `data: []` - the one snapshot this surface reserves for "this scope holds nothing". A named document that has never synced published that same snapshot for a document that is not empty; it too is now `isError` or a longer `isPending`, never a false emptiness. **Naming SEVERAL documents fails as a whole** when one of them cannot be reached: a short list is the same lie as an empty one, so nothing is answered rather than half of it. What is NOT touched, and each stays a success: `graphs: "did:ng:i"` or `""` (you named no document - the per-document filter is the whole of the answer there, and an empty result means you hold nothing of that shape), `graphs: []` (no objects, by NextGraph's own definition), and a named document you can read that holds nothing of the shape.
**A document you NAME in `graphs` is reached before the read opens, since `1.0.0-dev.9`.** Naming a document in a scope is the same act as anchoring `docs.sparqlQuery` on it, and it is now answered the same way: the key is asked of a public store if you do not already hold it, the document is refused out loud if you hold no key for it, and its repo is synced before the question is put. A named document you cannot read publishes `isError`, where until this version it reached the engine, came back, and was emptied by the per-document filter into `isSuccess` with `data: []` - the one snapshot this surface reserves for "this scope holds nothing". A named document that has never synced published that same snapshot for a document that is not empty; it now stays **PENDING** instead - the sync barrier is consulted on every publication, and a named document whose barrier was not crossed is not an answer. Nothing failed there, so it is not `isError`: the subscribe succeeded and the read did not throw, what is missing is the authority to say the document is empty. If the document later syncs and brings data, the read answers by itself. If it never does, the read stays pending for that session - handle a `isPending` that does not end, as you already must for a shape query the engine refuses. **Naming SEVERAL documents fails as a whole** when one of them cannot be reached: a short list is the same lie as an empty one, so nothing is answered rather than half of it. What is NOT touched, and each stays a success: `graphs: "did:ng:i"` or `""` (you named no document - the per-document filter is the whole of the answer there, and an empty result means you hold nothing of that shape), `graphs: []` (no objects, by NextGraph's own definition), and a named document you can read that holds nothing of the shape.
**And it converges by itself.** A named scope refused this way is not a dead view. Whether you may reach the documents you named is re-read on every publication rather than decided once, so a key arriving turns the refusal into the document through the same channel that already re-published a filtered read - and a scope whose barrier failed outright is attempted again on that signal. The ordinary sequences all converge with no call from the application: a page whose components subscribe before the connection work has restored its keys, a document shared with you while you are looking at the screen, a broker that hiccupped once and healed, and a key arriving in the same instant the read was deciding. One consequence to design for: an interface that renders `isError` will briefly show it where it used to show an empty success, so branch on the load state rather than on `data` alone. Nothing here polls, so a scope refused in a session whose keyring never moves again stays `isError` until your own `refetch()` - which is what `isError` has always meant here, and what it means for every other failure this call reports.
**A standing failure is the SAME snapshot.** While a refusal persists unchanged, the snapshot object does not change either, so `useSyncExternalStore` and anything else comparing by identity see no update - even though the read is being attempted again underneath. A failure that says something different is published normally.
**What it costs.** One public-store ask and one sync barrier per document named, once per session. The opens for a scope naming several documents run together, so the wait is the slowest of them rather than their sum. A document that never answers the barrier is bounded by an internal fallback rather than waiting for ever, and until `1.0.0-dev.9` that same document left the read `isPending` with no bound at all.
**What it costs.** One public-store ask and one sync barrier per document named, once per session. The opens for a scope naming several documents run together, so the wait is the slowest of them rather than their sum. The wait itself is bounded internally, so a document that never answers does not hold the open for ever - but the READ over it stays pending, because a barrier that was never crossed is not authority to call anything empty.
**Which failure lands in `isError` changed in `1.0.0-dev.6`, and one of them now stays PENDING.** The read goes through the SDK's own shape query, so what `isError` carries is the query failing to open: a shape the ORM cannot subscribe, or a `configure` that was never given `ormSubscription`. A start the underlying engine REFUSES does not reach it: NextGraph catches that failure and logs it, and its readiness signal is only ever resolved, never rejected, so such a read stays `isPending` indefinitely. The guarantee above is unaffected — you are still never told "there is nothing" for "nobody answered" — but an interface that handles only `isError` will show its loading state where it used to show an error. Handle a `isPending` that does not end.
@@ -278,6 +278,7 @@ What each level means here, in this package's own terms:
`1.0.0-dev.9` is a **major**, and all of it is one rule: **a read that could not reach its document now FAILS, where it used to answer as though the document were empty.** Nothing is added, nothing is renamed, nothing is removed from the entry - three existing calls narrow.
- **`useShapeQuery` publishes `isError` for a document you NAME and cannot read.** Until this version a document named in `scope.graphs` was handed straight to the engine; whatever came back was filtered per document by what you hold, and a document you hold no key for was emptied out of the result. The snapshot was `isSuccess` with `data: []` - indistinguishable, byte for byte, from the scope genuinely holding nothing. It now asks the public store for the key, refuses out loud if there is none to be had, and crosses the sync barrier first, so the same read answers `isError`. **Handle `isError` on a named scope**; an interface that only branched on `isPending` / `isSuccess` will show its success state with nothing in it. Naming several documents fails as a whole when one of them fails.
- **A named document that has not SYNCED keeps the read pending**, where it used to publish the same empty success. Reaching a document and hearing back from it are two questions, and only the first was being asked. An interface must therefore expect a named scope to sit in its loading state while a document it named is not answering, rather than reading an empty success as "nothing there".
- **A named scope now costs round-trips it did not cost before**, once per named document per session: the key fetch, and the subscribe that syncs the repo. The opens run together, so several named documents cost the slowest wait rather than the sum. `graphs: "did:ng:i"` is unchanged and spends nothing.
- **A refused named scope RECOVERS on its own.** Reachability is re-read on every publication rather than remembered, and a scope whose barrier failed is attempted again when the keyring moves - including when the move happened while the read was still deciding. Without that a refusal would be terminal, and trading a silent empty answer for a permanently stuck error would have been no improvement at all. Expect to see `isError` briefly on a page that subscribes before its keys are restored. There is no polling: in a session whose keyring never moves again, `refetch()` is the recovery, as it is for every other failure this call reports.
- **`inbox.postToDocument`, `inbox.readForDocument` and `inbox.readSyncedForDocument` reject where they used to report "no inbox".** Resolving a document's inbox address reads that document, and when the read could not happen - the broker could not answer for it, or its public store could not be asked for the key - all three used to render the breakdown as the document's owner having opened nothing: a refusal on the deposit side, an empty list on the read side. They now reject. A document that genuinely has no inbox is unchanged, and still answers exactly as before.
@@ -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.
@@ -109,6 +109,17 @@ const syncState = new Map<Nuri, SyncState>();
/** The session id the current `opened`/`held` entries belong to. A change means a
* new verifier session (fresh `self.repos`) → the registry must be invalidated. */
let boundSessionId: string | number | null = null;
/**
* Which GENERATION of the registry an open belongs to, bumped by {@link resetOpenedRepos}.
*
* An open's continuation records the repo as opened, and it runs one microtask after the
* open settles - a window a reset can land in. Without this it wrote that record into the
* registry that had just replaced its own, and every later open of the same 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. A reset must INVALIDATE what it abandons, not only
* end it.
*/
let registryEpoch = 0;
/**
* Max wait (ms) for the initial-state push before proceeding with the read anyway.
@@ -151,6 +162,9 @@ export function resetOpenedRepos(): void {
// nothing an absence. The abandoned open's own fallback fires later and sets the same
// value, so the two cannot disagree.
const abandoned = [...settleInFlight.entries()];
// Everything in flight now belongs to a registry that no longer exists, so its
// continuation must write nothing - see {@link registryEpoch}.
registryEpoch += 1;
opened.clear();
inFlight.clear();
settleInFlight.clear();
@@ -272,6 +286,7 @@ export async function openRepoUnguarded(nuri: Nuri): Promise<void> {
return;
}
const myEpoch = registryEpoch;
// Subscribed, first `State` not yet seen.
syncState.set(nuri, "syncing");
// Barrier clock: how long the subscribe→first-State (or fallback) round-trip
@@ -321,6 +336,10 @@ export async function openRepoUnguarded(nuri: Nuri): Promise<void> {
held.set(nuri, unsub);
setTimeout(onTimeout, OPEN_TIMEOUT_MS);
});
// Only if this open still belongs to the registry it started in. After a reset the
// three maps below are somebody else's, and `opened` in particular would become a
// permanent lie: nothing re-subscribes a repo that is already recorded as open.
if (myEpoch !== registryEpoch) return;
opened.add(nuri);
inFlight.delete(nuri);
settleInFlight.delete(nuri);
@@ -176,7 +176,7 @@
import { getCaps, getConfig } from "../shared-wallet/bootstrap";
import { filterReadable } from "../emulated-verifier/read-filter";
import { ensureRepoOpen } from "../emulated-verifier/open-repo";
import { ensureRepoOpen, getSyncState } from "../emulated-verifier/open-repo";
import { assertMayReach } from "../emulated-verifier/reach";
import { isNuri } from "../model/nuri";
import type { Nuri, OrmSubscriptionHandle, OrmSubscriptionLike } from "../model/types";
@@ -417,6 +417,35 @@ export function useShapeQuery<T extends BaseType = BaseType>(
return null;
}
/**
* Has every document the scope NAMES actually ARRIVED - not merely "may this holder
* reach it", which is a different question and the only one this module used to ask?
*
* `emulated-verifier/open-repo.ts` keeps three values apart for exactly this reader, and
* which of them counts as an answer is the whole of the decision:
*
* - `"synced"` - the first `State` came. THE truth signal: zero rows means zero
* objects, and that is the success this surface exists to publish.
* - `"timed-out"` - the bounded fallback fired with no `State`. Opened best-effort,
* sync unconfirmed. The document may hold anything and this read has no standing to
* call it empty. This is the case the barrier was added for and did not close.
* - `"syncing"` - unreachable once the open has resolved (it resolves on the `State`
* or on the fallback, and each sets its own value); refused anyway, because if it
* ever IS reached then the barrier demonstrably was not.
* - `"unknown"` - "never requested, or the fake-ng no-op path: no `State` semantics".
* **Passes, deliberately.** There is no barrier to have missed where there are no
* barrier semantics, and an `ng` without `doc_subscribe` is the whole unit suite and
* every early path. Treating it as not-synced is the inert regression this line of
* work has already shipped twice.
*/
function namedDocumentsArrived(): boolean {
for (const doc of namedDocuments(scope)) {
const state = getSyncState(doc);
if (state === "timed-out" || state === "syncing") return false;
}
return true;
}
/**
* Publish what the subscription holds right now. Only ever called on a READY
* generation, so this snapshot is always an answer.
@@ -441,6 +470,14 @@ export function useShapeQuery<T extends BaseType = BaseType>(
setUnknown(refusal);
return;
}
// NOT AUTHORITATIVE YET, so say nothing rather than say "empty". A named document
// whose sync barrier was never crossed may hold anything, and publishing the ORM's
// view of it as `isSuccess` is the original defect in the one case the barrier was
// added to close. Returning here leaves the snapshot where it was - the initial
// pending one on a first publication - which is the contract's "a longer `isPending`",
// and never a successful empty. Re-read on every publication, so a `State` that lands
// later and brings data republishes through the ORM's own change signal and answers.
if (!namedDocumentsArrived()) return;
setSnapshot({
data: readable(handle),
isPending: false,
+33
View File
@@ -204,6 +204,39 @@ describe("ensureRepoOpen", () => {
expect(getSyncState("did:ng:o:abandoned")).toBe("timed-out");
});
// A reset must INVALIDATE the opens it abandons, not merely end them.
//
// The continuation of an in-flight open records the repo as opened. Settling that open
// from the reset (2026-09-21) made the continuation run right after the maps were
// cleared, so it wrote a stale `opened` entry into the FRESH registry - and every later
// `ensureRepoOpen` for that nuri then 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 that change the same line ran only when the 8s fallback fired, so a re-open
// inside that window did re-subscribe; ending the open promptly is right, but it moved
// the write into the window where it does damage.
it("a reset INVALIDATES the opens it abandons, so the new session subscribes again", async () => {
const ng = makeFakeNgWithSubscribe({}, { pushState: false });
inject(ng);
const abandoned = ensureRepoOpen("did:ng:o:reopened");
await new Promise((r) => setTimeout(r, 5));
expect(ng.doc_subscribe).toHaveBeenCalledTimes(1);
resetOpenedRepos();
await abandoned; // its continuation runs here, on the cleared registry
await new Promise((r) => setTimeout(r, 5));
// The new session has subscribed NOTHING yet, so this must open the repo for real.
// NOT awaited: this broker never pushes a `State`, so the open runs to its bounded
// fallback - and what is under test is that the subscribe HAPPENS, not how it ends.
const reopened = ensureRepoOpen("did:ng:o:reopened");
await new Promise((r) => setTimeout(r, 5));
expect(ng.doc_subscribe).toHaveBeenCalledTimes(2);
// Released so the fallback's timer does not outlive the test with a live handle.
resetOpenedRepos();
await reopened;
});
it("the anchored document read triggers doc_subscribe then returns data (integration path)", async () => {
const ng = makeFakeNgWithSubscribe({
"did:ng:o:p": [[TYPE, `${FP}Participation`], [`${FP}event`, "did:ng:o:e"]],
+93 -7
View File
@@ -27,7 +27,7 @@ import { configure } from "../src/index";
import { configureStoreRegistry, setCurrentUser, getCaps } from "../src/shared-wallet/bootstrap";
import { resetCaps, resetConfig, resetStoreRegistry } from "../src/shared-wallet/bootstrap";
import { resetRegistryCache, createEntityDoc } from "../src/shared-wallet/account-registry";
import { resetOpenedRepos, setOpenTimeoutForTests } from "../src/emulated-verifier/open-repo";
import { getSyncState, resetOpenedRepos, setOpenTimeoutForTests } from "../src/emulated-verifier/open-repo";
import { mintCap } from "../src/emulated-verifier/caps";
import { makeOrm, type FakeOrm } from "./orm-fake";
import type { BaseType, ShapeType } from "@ng-org/shex-orm";
@@ -59,7 +59,7 @@ interface SubRec { nuri: string; cb: (r: unknown) => void }
* The stateful fake `ng` - the shim account, the per-scope index and the document
* creation the page does around the read. It is NOT what answers the read any more.
*/
function makeFake(options: { pushState?: boolean } = {}) {
function makeFake(options: { pushState?: boolean; subscribe?: boolean } = {}) {
/** Anchors whose anchored read throws. Mutable after boot, so a suite builds a healthy
* world first and breaks only the one call it is about. */
const unreachable = new Set<string>();
@@ -172,12 +172,27 @@ function makeFake(options: { pushState?: boolean } = {}) {
return () => {};
});
return { doc_create, sparql_update, sparql_query, doc_subscribe, _quads: quads, _unreachable: unreachable };
return {
doc_create,
sparql_update,
sparql_query,
doc_subscribe,
_quads: quads,
_unreachable: unreachable,
// An `ng` with NO `doc_subscribe` AT ALL - the documented no-op path, where there are
// no `State` semantics and so no barrier to have missed. `wallet-fake.ts` is like this
// by default, and so is any consumer whose `ng` does not carry the member. The member
// is withheld in `inject` rather than here, so the two suites that count subscribes
// keep a mock to count.
_noSubscribe: options.subscribe === false,
};
}
function inject(ng: ReturnType<typeof makeFake>, orm: FakeOrm) {
const forwarded: Record<string, unknown> = { ...ng };
if (ng._noSubscribe) delete forwarded.doc_subscribe;
configure({
ng: ng as unknown as NgLike,
ng: forwarded as unknown as NgLike,
useShape: (() => {}) as unknown as UseShapeLike,
ormSubscription: orm.query,
});
@@ -793,9 +808,22 @@ describe("useShapeQuery", () => {
// documents that never push.
expect(subscribesTo()).toBe(3);
// ...and it still answers, once the fallbacks have fired.
for (let i = 0; i < 40 && !obs.getSnapshot().isSuccess; i++) await tick();
expect(titles(obs.getSnapshot().data)).toEqual(["A", "B", "C"]);
// CORRECTED 2026-09-24. This used to wait for `isSuccess` and assert the three titles,
// which PINNED A DEFECT: the fallbacks had fired, no `State` had arrived for any of the
// three, and the read published what the ORM happened to hold as though the barrier had
// been crossed. A document whose sync barrier was never reached may hold anything, so
// there is nothing here to call authoritative - the answer is that the question is
// still open. Not a weakened assertion: the old one was wrong.
for (let i = 0; i < 40; i++) await tick();
const snap = obs.getSnapshot();
expect(snap.isSuccess).toBe(false);
expect(snap.isError).toBe(false);
expect(snap.isPending).toBe(true);
expect([getSyncState(a), getSyncState(b), getSyncState(c)]).toEqual([
"timed-out",
"timed-out",
"timed-out",
]);
unsub();
});
@@ -1049,6 +1077,64 @@ describe("useShapeQuery", () => {
unsub();
});
// -- REACHED is not ARRIVED -----------------------------------------------
//
// The barrier asked whether the holder may reach each named document and never whether
// the open it had just awaited actually SYNCED. `open-repo.ts` keeps `"synced"` and
// `"timed-out"` apart precisely so a reactive reader can tell a barrier that was crossed
// from a fallback that fired, and names that reader; this is it.
it("(bb) a named document whose sync barrier was NOT reached is not called empty", async () => {
const ng = makeFake({ pushState: false });
const orm = makeOrm();
inject(ng, orm);
setOpenTimeoutForTests(30); // after `inject`, which restores the 8s default
setCurrentUser("sam");
// HIS OWN, so the capability question passes cleanly and the only thing left standing
// between the read and an answer is the sync barrier - which this broker never
// delivers. The document is NOT empty; the page simply has no authority to say so.
const mine = await createEntityDoc("sam", "protected");
seedEvent(orm, mine, `${mine}:q:one`, "NotEmptyAtAll");
const obs = useShapeQuery(EventShape, { graphs: [mine] });
const unsub = obs.subscribe(() => {});
for (let i = 0; i < 40 && obs.getSnapshot().isPending; i++) await tick();
// The defect this pins: `isSuccess` with `data: []` here is the original lie, in the
// one case the barrier was added to close. Either honest answer is allowed by the
// contract - a longer `isPending`, or `isError` - and what must never appear is a
// successful empty.
const snap = obs.getSnapshot();
expect(snap.isSuccess).toBe(false);
expect(getSyncState(mine)).toBe("timed-out");
unsub();
});
it("(cc) an `ng` with no doc_subscribe has NO barrier to miss, and still answers", async () => {
// The trap on the other side of (bb), and the one this line of work has fallen into
// twice: `"unknown"` means "never requested, OR no `State` semantics on this `ng`".
// Refusing to answer on `"unknown"` would leave every such page pending for ever -
// the whole unit suite, and any consumer whose `ng` lacks the member. There is no
// barrier to have missed where there are none.
const ng = makeFake({ subscribe: false });
const orm = makeOrm();
inject(ng, orm);
setCurrentUser("tess");
const mine = await createEntityDoc("tess", "protected");
seedEvent(orm, mine, `${mine}:q:one`, "Answered");
const obs = useShapeQuery(EventShape, { graphs: [mine] });
const unsub = obs.subscribe(() => {});
for (let i = 0; i < 20 && !obs.getSnapshot().isSuccess; i++) await tick();
expect(getSyncState(mine)).toBe("unknown");
const snap = obs.getSnapshot();
expect(snap.isSuccess).toBe(true);
expect(snap.isPending).toBe(false);
expect(titles(snap.data)).toEqual(["Answered"]);
unsub();
});
// -- teardown ------------------------------------------------------------
it("(k) the last unsubscribe closes the shape query", async () => {