fix!: un scope nomme refuse se rouvre quand la cle arrive, et la barriere cesse d-etre serie

Revue adversariale : la barriere livree au tour precedent rendait le refus
TERMINAL. Le signal du trousseau s'ecrit `if (query !== null) publish(...)`, parce
qu'avant la barriere il n'y avait qu'une seule raison de n'avoir pas de requete -
rien n'avait encore ete tente - et qu'une cle qui arrive ne pouvait changer que ce
qu'une requete OUVERTE avait le droit de montrer. La barriere en a ajoute une
seconde : elle abandonne AVANT que la requete existe, donc le seul canal qui fait
converger une cle tardive passait a cote de l'etat qui en avait le plus besoin.
Une page qui s'abonne pendant que son trousseau se restaure, ou un document
partage sous les yeux de son destinataire, publiait `isError` et y restait. Ce
n'est pas une surface plus stricte, c'est une pire : echanger un vide mensonger
contre une erreur bloquee ne repare rien, et cela casse une garantie que ce paquet
publie.

Le signal rouvre donc un scope refuse, et seulement quand le refus est leve
(`namedDocumentsReachable`). Le garde-fou porte autant que la reouverture :
rouvrir a chaque changement de trousseau rejouerait la barriere pour chaque cle
qu'une connexion depose, redemanderait au reseau tout document dont le
telechargement avait ECHOUE - un echec n'est deliberement pas memoise - et
republierait la meme erreur a chaque abonne. La variante indiscriminee casse deux
tests, dont celui de demontage qui preexistait.

La barriere passe en parallele. Elle etait serie, donc N documents qui ne poussent
jamais de `State` coutaient N replis bornes, 8s chacun en production. Les refus
restent lus dans l'ordre du scope apres coup.

Et ce qui a laisse passer les deux : l'e2e ne parcourait pas ce chemin. Les deux
journeys `useShapeQuery` lisent `{graphs: "did:ng:i"}`, donc 43/43 n'a jamais rien
prouve sur la barriere. Quatre controles ajoutes sur un scope NOMME, contre le
vrai broker : la proprietaire lit son document, un porteur de rien recoit
`isError` et jamais un succes vide, un document PUBLIC s'ouvre pour lui depuis la
seule reference via la requete de la barriere, et une cle qui tombe pour un AUTRE
document laisse le scope refuse en place.
This commit is contained in:
Sylvain Duchesne
2026-09-21 15:57:56 +02:00
parent aa38b37f33
commit f36ed5c164
6 changed files with 413 additions and 19 deletions
@@ -193,7 +193,11 @@ 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. The practical cost is that this read now spends round-trips a named scope did not spend before, once per document per session.
**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.
**And it converges by itself when the key arrives.** A named scope refused this way is not a dead view: the keyring signal that already re-published a filtered read now also RE-OPENS a scope the barrier refused, as soon as the holder reaches every document it names. So the ordinary sequence - a page whose components subscribe while the connection work is still restoring keys, or a document shared with you while you are looking at the screen - shows `isError` and then the document, with no call from the application. Two things follow, and both are deliberate: an interface that renders `isError` will briefly show it where it used to show an empty success, so render the load state rather than only `data`; and the retry is TARGETED - a key landing for some other document leaves the scope refused rather than spending a round-trip on it, so a failure that no key can lift (the broker did not answer) stays until `refetch()`, which is what `isError` has always meant here.
**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.
**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.
@@ -272,7 +276,8 @@ 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 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. `graphs: "did:ng:i"` is unchanged and spends nothing.
- **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** when the key arrives, through the same keyring signal that already re-published a filtered read. 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.
- **`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.
- **Nothing that was already an ANSWER moves.** An empty document is a success holding zero objects, `graphs: []` is a success holding none, a document in a public store that exposes no key is a plain "no", and a document with no inbox answers `[]`. The whole change is that a *failure* stopped borrowing those shapes.
- **Why this direction, and not a richer set of outcomes.** The step that collapsed the two was this package's own emulation of a public store serving its key; NextGraph has no counterpart for it, because upstream nothing consults a capability on the read path at all. So there is no signature to mirror, and the alignment falls to the engine's own observable: an anchored query on a repo the verifier does not hold answers `NgError::RepoNotFound`, a rejected promise - **a read rejects, or it does not**. Inventing a third outcome for a caller to branch on would have been a vocabulary the target will never grow.
@@ -79,7 +79,7 @@ Every row was read in this working copy. `fetchReadCap` reaches the network only
- a named document, held and synced, holding nothing of the shape: the barrier passes, the ORM answers empty, `isSuccess` with `data: []`. **This is the line that must not move.**
- a held document exposing no read cap: `fetchReadCap` answers `false` and MEMOISES it. An absence was read; it is an answer.
- a held document with no inbox: `documentInboxAddress` answers `undefined`, `depositsForDocument` answers `[]`.
- the INERT case throughout: before the first cap exists, `isEnforcing()` is false, `fetchReadCap` returns without a round-trip, `mayReach` answers true for everything, and nothing new refuses. Every row above is unchanged there.
- the INERT case throughout: before the first cap exists, `isEnforcing()` is false, `fetchReadCap` returns without a round-trip, `mayReach` answers true for everything, and nothing new refuses. **CORRECTED 2026-09-21 after an adversarial review**: this row said "every row above is unchanged there", and that was FALSE. The REFUSAL is inert; the OPEN is not. `ensureRepoOpen` runs whatever the registry is enforcing, so a named scope now issues a real `doc_subscribe` and waits on the sync barrier where it used to open the query at once. That is deliberate - without it a named document this session has never synced answers `isSuccess` with `data: []`, the very lie the barrier removes - but it is a change, and calling it "unchanged" hid a cost. Pinned by `use-shape-query.test.ts` (u).
**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.
@@ -123,13 +123,27 @@ Four tests in `public-store.test.ts`, all of them `expect(await fetchReadCap(unr
Nothing else broke. The reason is worth recording: the three live `fetchReadCap` callers that read a STORE or an INBOX already hold that cap (`fileOwnStructure` / `holdOwnCap` file it at connection), so `fetchReadCap` answers from the registry and never reaches the network. The blast radius is much narrower than four call sites suggest - it is a third party naming a document it does not hold.
### CORRECTED after the adversarial review (round 1 was REFUTED)
Two findings, both reproduced here before anything was changed, both diagnoses verified independently.
**Finding 1, blocking - a refused named scope was TERMINAL.** Reproduced as `use-shape-query.test.ts` (r): a named document whose cap arrives AFTER the subscribe stayed `{isError: true, data: []}` for ever. Diagnosis confirmed by reading: the keyring handler is `if (query !== null) publish(query, generation)`, and the barrier aborts BEFORE any query is created, so the one channel that makes a late cap converge went straight past the state that needed it most. It broke a guarantee this package publishes and that `read-filter.ts`, `use-shape-query.ts` and `inbox.ts` all restate - a document shared with you while you watch becomes readable with no call from the application. **Trading a silent empty answer for a stuck error is worse than the defect**, and the ordinary sequence (a page subscribing while its keyring is still being restored) hits it.
Fix: the keyring signal now RE-OPENS a scope the barrier refused, gated on `namedDocumentsReachable()` - every named document reachable now. Blast radius traced before writing it, and the gate is the load-bearing half: re-opening on any keyring change would re-run the barrier for every cap a connection files, re-ask the network for any document whose fetch had FAILED (a failure is deliberately not memoised) and re-publish the same error to every subscriber each time. `barrierRefused` keeps a `configure` with no `ormSubscription` - which no cap will ever repair - out of the retry entirely. The indiscriminate variant was tried and breaks two tests, (s) and the pre-existing teardown test (k).
**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.
**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 rather than churning.
**What the e2e still does NOT prove, stated plainly.** The POSITIVE recovery - the cap for the refused document itself arriving - is proven at unit level only (test r, non-vacuous). Driving it live needs a second actor delivering through `inbox.share`, and on one page that means switching the current identity while the observable is subscribed, which would re-open the refused scope under the OTHER identity. That is a pre-existing property of this observable (`publish` has always read the keyring of whoever is connected), it cannot arise in an application (one identity per page load, no call takes an identifier), and the repo has already ruled that such montages belong in the applicative suite - `capsShareCap` was moved there for exactly this reason. Left as a named follow-up rather than encoded here as if it were the library's behaviour.
### Gate
- `bun test` -> `431 pass / 0 fail`, `Ran 431 tests across 40 files` (baseline `421 pass / 0 fail`, measured before any change; +10 new tests, no test deleted or skipped)
- `bun test` -> `435 pass / 0 fail`, `Ran 435 tests across 40 files` (baseline `421 pass / 0 fail`, measured before any change; +14 new tests, no test deleted or skipped)
- `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: 43 passed, 0 failed, 43 total - batch 3.3 min, slowest cold sync 27s`. Same count as T01.h's baseline. NOTE: the broker login is flaky - two of five runs died with `[e2e broker login] gave up after 45.0s - the wallet-list screen never changed`, at a different step each time, before any library code runs. A clean 43/43 was obtained twice, once at `75313f7` and once at HEAD.
- polyfill e2e, run at HEAD: `Polyfill e2e summary: 47 passed, 0 failed, 47 total - batch 3.5 min, slowest cold sync 30s`. 43 was the count before this task; the four added are the named-scope checks described above. NOTE: the broker login is flaky - two of eight runs died with `[e2e broker login] gave up after 45.0s - the wallet-list screen never changed`, at a different step each time, before any library code runs.
### The contract
@@ -152,13 +166,16 @@ Nothing else broke. The reason is worth recording: the three live `fetchReadCap`
- **VERIFIED: `useShapeQuery`'s empty success does not come from `fetchReadCap` at all.** That call site does not exist on this path - T01.d removed it with the scope translation. It comes from the capability filter emptying a named document's objects, and from a named-but-unsynced document answering nothing. Tests (l) and (n) fail without the barrier; the task's three-layer chain describes the ANCHORED path, which is a different (and already loud) one.
- **VERIFIED: the blast radius is narrow.** Six of the eight `ensureRepoOpen` callers hold the cap they are about to use, so they never ask the network. That is why only four tests broke.
- **VERIFIED: `g === ""` in `namedDocuments` is unreachable** - `isNuri("")` is false. Kept as documentation of the SDK's own alias, said so in the comment and the commit. Test (p) is therefore a behaviour guard, not a discriminating test, and is labelled as one.
- **INFERRED: the barrier's cost.** A named scope now spends one public-store fetch and one sync barrier per document per session, serially. Memoised, so a re-subscribe is a map hit; the worst case is a cold scope naming many documents, each bounded by the 8s fallback. Stated in the contract; no measurement was taken.
- **VERIFIED, and it was under-reported in round 1: the barrier's cost.** A named scope spends one public-store fetch and one sync barrier per document per session. The opens now run TOGETHER, so the wait is the slowest rather than the sum; the serial form cost N bounded fallbacks, 8s each in production, which an adversarial review measured. Stated in the contract.
- **The round-1 Result contained two claims this review falsified**, both corrected above: that the INERT case was unchanged (the open is new), and that the e2e was evidence for the barrier (it never walked a named scope). Both were overstatements of what had actually been checked, and the second is the one that mattered - a green suite reported as proof of a path it does not exercise.
### Follow-ups, reported and not fixed
- `packages/polyfill/src/shared-wallet/account-registry.ts:78` imports `documentInboxAddress` and never uses it - a dead import. `tsconfig.base.json` sets no `noUnusedLocals`, so nothing flags it.
- Every e2e run logs, at connection: `connect failed: docs.sparqlQuery: refused - the connected user does not hold this document's cap ... at readStoreCaps <- restoreOwnCaps <- restoreAndDrain`. That is `assertMayReach`'s own pre-existing message (this work would have produced a different one), so `restoreOwnCaps` is being refused one of the connected user's OWN store documents at connect, and the failure is logged rather than raised.
- The e2e broker login fails intermittently at 45s on the wallet-list screen, before any library code runs - 2 of 5 runs in this session.
- The e2e broker login fails intermittently at 45s on the wallet-list screen, before any library code runs - 2 of 8 runs in this session.
- A two-actor journey for "a document is shared with someone while they are watching, and their named `useShapeQuery` converges" belongs in the applicative suite (`e2e/notebook.ts`), beside the journey that already checks "after Alice shares it, the same reference opens it". It is the live form of unit test (r).
- Pre-existing and out of scope, surfaced while tracing the fix: this observable re-publishes, and now re-opens, under whoever is connected at that instant rather than under the identity that subscribed. Harmless for an application (one identity per page load, no call takes an identifier); it only shows on a harness playing several identities on one page.
- `docs/internal-contract.md:7` still names `surface/watch-shape.ts`, `surface/read-model.ts` and a `storeRegistry` namespace listing three removed symbols. Out of this task's boundary.
- `knowledge_what-a-failed-read-answers-upstream` cites `213338f6`, the checkout T01.h found to be three ORM releases behind. The facts it cites are engine-side rather than ORM-side, but T01.k owns reconciling what a citation's version means.
+67
View File
@@ -989,6 +989,73 @@ const identity = new IdentityStore(
if (rec) rec.unsub();
setCurrentUser(null);
},
/**
* NAMED-SCOPE seed. Under a fresh identity, create TWO entity documents carrying the
* shape's type - one PROTECTED, one PUBLIC - and seed each with a distinguishable
* title. Returns the owner and both references, which is all any of the three named
* subscriptions below is given.
*
* Why this exists at all: until `1.0.0-dev.9` every `useShapeQuery` journey in this
* suite read `{ graphs: "did:ng:i" }`, so the scope axis that NAMES a document was
* measured nowhere against the real broker - and the barrier that names introduced
* shipped with no live coverage. A suite that is green on a path it never walks is
* worse than one that is red.
*/
async useShapeQueryNamedSeed(cls: string) {
registryInternals.resetRegistryCache();
const owner = "@wsn-" + Date.now();
setCurrentUser(owner);
const s = await sessionReady;
const prot = await storeRegistry.createEntityDoc(await storeRegistry.protectedStore());
await docs.sparqlUpdate(
s.session_id,
`INSERT DATA { <${prot}> <http://www.w3.org/1999/02/22-rdf-syntax-ns#type> <${cls}> ; <urn:e2e:ws:title> "named-protected" }`,
prot,
);
// PUBLIC, so its ReadCap is exposed on its Header branch at creation - the one
// channel by which a bare reference alone opens a document, and the acquisition the
// barrier now performs on this call.
const pub = await storeRegistry.createEntityDoc(await storeRegistry.publicStore());
await docs.sparqlUpdate(
s.session_id,
`INSERT DATA { <${pub}> <http://www.w3.org/1999/02/22-rdf-syntax-ns#type> <${cls}> ; <urn:e2e:ws:title> "named-public" }`,
pub,
);
setCurrentUser(null);
return { owner, prot, pub };
},
/**
* Subscribe a NAMED scope under `who` - the owner, or somebody holding nothing.
*
* The identity is set here and left set for the probe's lifetime, like the two
* journeys above: `useShapeQuery` asks the keyring about whoever is connected on every
* read, exactly as an application with one stable identity per page does.
* `useShapeQuerySnapshot` and `useShapeQueryStop` serve this probe too.
*/
useShapeQueryNamedSubscribe(handle: string, who: string, doc: string, cls: string) {
registryInternals.resetRegistryCache();
setCurrentUser(who);
const obs = useShapeQuery(wsShape(cls), { graphs: [doc] }) as ShapeObservable;
const unsub = obs.subscribe(() => {});
const initial = obs.getSnapshot();
(window as any).__sdk._wsProbes[handle] = { obs, initial, unsub };
return {
initial: {
isPending: initial.isPending,
isSuccess: initial.isSuccess,
isError: initial.isError,
},
};
},
/** The error message a named scope published, so the runner can tell the refusal from
* any other failure rather than accepting `isError` as such. */
useShapeQueryError(handle: string) {
const rec = (window as any).__sdk._wsProbes[handle];
if (!rec) return null;
const snap: ShapeQuery = rec.obs.getSnapshot();
return snap.error === undefined ? null : String((snap.error as Error)?.message ?? snap.error);
},
/**
* EMPTY-scope proof: a brand-new identity with NO entity docs at all. `useShapeQuery`
* must reach isSuccess with data:[] (synced-but-empty), NOT stay pending. Returns
+93
View File
@@ -441,6 +441,99 @@ async function main(): Promise<void> {
await sdk(frame, "useShapeQueryStop", h);
});
// The NAMED half of the scope axis, which this suite did not walk until
// `1.0.0-dev.9` - both journeys above read `{ graphs: "did:ng:i" }`, so the barrier
// that `useShapeQuery` now crosses for every document a scope NAMES had no live
// coverage at all. Three readings of the same two documents, one per outcome: the
// owner reads hers, somebody holding nothing is REFUSED rather than told the
// document is empty, and the same somebody opens the PUBLIC one from nothing but its
// reference - which is the barrier's own fetch reaching this broker.
await step("useShapeQuery: a NAMED scope reads, refuses, and opens a public document from a bare reference", async () => {
const CLS = "urn:e2e:ws:Event";
const seed = await sdk<any>(frame, "useShapeQueryNamedSeed", CLS);
const stranger = "@wsn-stranger-" + Date.now();
const own = "named-own" + Date.now();
await sdk<any>(frame, "useShapeQueryNamedSubscribe", own, seed.owner, seed.prot, CLS);
await frame.waitForFunction(
(hh) => {
const s = (window as any).__sdk.useShapeQuerySnapshot(hh as string);
return s && (s.isSuccess || s.isError);
},
own,
{ timeout: 30000 },
);
const ownSnap = await sdkGet<any>(frame, "useShapeQuerySnapshot", own);
check(
"the owner naming her own document reads it (the barrier does not break a live named read)",
ownSnap.isSuccess && !ownSnap.isError && ownSnap.titles.includes("named-protected"),
`snap=${JSON.stringify(ownSnap)}`,
);
await sdk(frame, "useShapeQueryStop", own);
const refused = "named-refused" + Date.now();
await sdk<any>(frame, "useShapeQueryNamedSubscribe", refused, stranger, seed.prot, CLS);
await frame.waitForFunction(
(hh) => {
const s = (window as any).__sdk.useShapeQuerySnapshot(hh as string);
return s && (s.isSuccess || s.isError);
},
refused,
{ timeout: 30000 },
);
const refusedSnap = await sdkGet<any>(frame, "useShapeQuerySnapshot", refused);
const refusedWhy = await sdkGet<any>(frame, "useShapeQueryError", refused);
// The whole point of the change: NOT `isSuccess` with `data: []`, which is what
// this same call published before `1.0.0-dev.9` - the capability filter emptied
// the result and the snapshot said the document holds nothing of that shape.
check(
"a named document the holder cannot reach is isError, never an empty success",
refusedSnap.isError && !refusedSnap.isSuccess && refusedSnap.dataLen === 0 &&
typeof refusedWhy === "string" && refusedWhy.includes("does not hold this document"),
`snap=${JSON.stringify(refusedSnap)} why=${String(refusedWhy).slice(0, 80)}`,
);
// Left OPEN on purpose for the last reading below: a refused scope standing while
// a cap for some OTHER document lands is exactly what the keyring signal must not
// act on. (`useShapeQueryStop` would also clear the identity, which is why it is
// not called here - the next subscribe is the SAME holder.)
const publicRef = "named-public" + Date.now();
await sdk<any>(frame, "useShapeQueryNamedSubscribe", publicRef, stranger, seed.pub, CLS);
await frame.waitForFunction(
(hh) => {
const s = (window as any).__sdk.useShapeQuerySnapshot(hh as string);
return s && (s.isSuccess || s.isError);
},
publicRef,
{ timeout: 30000 },
);
const publicSnap = await sdkGet<any>(frame, "useShapeQuerySnapshot", publicRef);
// The acquisition the barrier newly performs on this call: the same identity that
// was just refused the protected document opens the public one holding nothing but
// its reference, because the barrier asks the public store and this broker answers.
check(
"a public document named by a holder of nothing opens, through the barrier's own fetch",
publicSnap.isSuccess && !publicSnap.isError && publicSnap.titles.includes("named-public"),
`snap=${JSON.stringify(publicSnap)}`,
);
// That fetch FILED a cap, which fires `CapRegistry.onChange` - the channel a
// refused scope is re-opened on since `1.0.0-dev.9`, because the barrier aborts
// before any query exists and `publish` has nothing to re-filter. It has to be
// TARGETED: this cap says nothing about the protected document, so the refusal
// stands rather than buying a round-trip per cap for the rest of the session.
// The positive direction - the cap for the refused document itself arriving -
// needs a second actor delivering it, and lives in the applicative suite.
const afterOtherCap = await sdkGet<any>(frame, "useShapeQuerySnapshot", refused);
check(
"a cap landing for ANOTHER document leaves a refused scope refused, not churning",
afterOtherCap.isError && !afterOtherCap.isSuccess && afterOtherCap.dataLen === 0,
`snap=${JSON.stringify(afterOtherCap)}`,
);
await sdk(frame, "useShapeQueryStop", refused);
await sdk(frame, "useShapeQueryStop", publicRef);
});
// ── caps / read-filter (in-memory cap model) ────────────────────────────
console.log("\n── caps / read-filter (in-memory cap model) ──");
await step("read-filter: you read what your keyring holds, nothing else", async () => {
@@ -105,6 +105,25 @@
* The barrier is why the open below is asynchronous: it prolongs `isPending`, and nothing
* else about the states moves.
*
* -- A refusal that could not be re-tried would be worse than the defect ------------
* The first version of this barrier was TERMINAL, and an adversarial pass found it. The
* keyring signal below is written as `if (query !== null) publish(...)` because until the
* barrier existed there was only ever one reason to have no query - nothing had been
* tried yet - and a cap arriving could only ever change what an OPEN query was allowed to
* show. The barrier added a second reason: it aborts BEFORE the query is created, so the
* one channel that makes a late cap converge went straight past the state that needed it
* most. A page subscribing while its keyring was still being restored, or a document
* shared with someone while they watched, published `isError` and stayed there for the
* life of the view. Trading a silent empty answer for a stuck error is not a stricter
* surface, it is a worse one - and it broke a guarantee this package publishes, which
* `emulated-verifier/read-filter.ts` and `surface/inbox.ts` both restate.
*
* So the signal re-OPENS a refused scope, and does it only when the refusal has actually
* been lifted ({@link namedDocumentsReachable}). Re-opening on any keyring change would
* re-run the barrier for every cap a connection files - re-asking the network for any
* document whose fetch had FAILED, since a failure is deliberately not memoised, and
* re-publishing the same error to every subscriber each time.
*
* -- An observable cannot un-emit: `data` survives an error -----------------
* A one-shot call rejects and is done. An observable has already handed a list to a
* subscriber that rendered it, so its failure snapshot has to say something about
@@ -117,7 +136,7 @@
import { getCaps, getConfig } from "../shared-wallet/bootstrap";
import { filterReadable } from "../emulated-verifier/read-filter";
import { ensureRepoOpen } from "../emulated-verifier/open-repo";
import { assertMayReach } from "../emulated-verifier/reach";
import { assertMayReach, mayReach } from "../emulated-verifier/reach";
import { isNuri } from "../model/nuri";
import type { Nuri, OrmSubscriptionHandle, OrmSubscriptionLike } from "../model/types";
// The SDK's own types, imported as TYPES (erased at build, so no runtime `@ng-org`
@@ -239,6 +258,16 @@ export function useShapeQuery<T extends BaseType = BaseType>(
/** Has the CURRENT generation's initial payload arrived? Gates every publication:
* a change signal that beats `readyPromise` must not be read as an answer. */
let ready = false;
/**
* Why there is no query in force: the BARRIER refused this scope, rather than the
* shape query failing to open or nothing having been tried yet.
*
* The distinction is what the keyring signal needs. A cap arriving can lift a refusal
* and can lift nothing else - a `configure` with no `ormSubscription` is not going to
* be fixed by one - so re-opening on any keyring change while any error stood would
* churn for a misconfiguration that no amount of retrying repairs.
*/
let barrierRefused = false;
function emit(): void {
for (const l of listeners) {
@@ -362,16 +391,36 @@ export function useShapeQuery<T extends BaseType = BaseType>(
* `assertMayReach` says it out loud immediately after - without that, an unheld
* document would simply produce no objects and read as a document holding none.
*
* Sequential rather than parallel, deliberately: the first document that cannot be
* reached is the one reported, and there is no batch policy to own (`ensureReposOpen`,
* the parallel form, was removed in `1.0.0-dev.8`/`.9` for having none worth keeping).
* The opens run TOGETHER and the refusals are read afterwards, in scope order. A first
* pass ran them one at a time so that the first unreachable document would be the one
* reported; that determinism is preserved by the loop below, while the cost of the
* serial form was not acceptable - each open waits on the sync barrier, bounded by
* `OPEN_TIMEOUT_MS` (8s) for a document that never pushes a `State`, so a scope naming
* N of them paid N timeouts before answering anything. `Promise.all` attaches a handler
* to every one of them, so a rejection never escapes as an unhandled one, and the opens
* that did succeed are memoised per session exactly as they would have been.
*
* Each open is memoised per session, so a re-subscribe costs a map hit.
*/
async function reachNamedDocuments(): Promise<void> {
for (const doc of namedDocuments(scope)) {
await ensureRepoOpen(doc);
assertMayReach(doc, "useShapeQuery");
const docs = namedDocuments(scope);
if (docs.length === 0) return;
await Promise.all(docs.map((doc) => ensureRepoOpen(doc)));
for (const doc of docs) assertMayReach(doc, "useShapeQuery");
}
/**
* Does the holder now reach every document the scope NAMES? The precondition for
* re-opening a scope the barrier refused - and the whole of it.
*
* Synchronous and cap-only, so it asks nothing of the network: it answers "has the
* thing that blocked us been lifted", which is exactly what a keyring change can have
* done and the only question worth spending a re-open on. A scope that is still
* unreachable is left alone, so a connection filing a dozen caps does not buy a dozen
* round-trips for a view none of them was about.
*/
function namedDocumentsReachable(): boolean {
return namedDocuments(scope).every((doc) => mayReach(doc));
}
/**
@@ -386,11 +435,13 @@ export function useShapeQuery<T extends BaseType = BaseType>(
function open(): void {
closeQuery();
const gen = ++generation;
barrierRefused = false;
void (async () => {
try {
await reachNamedDocuments();
} catch (error) {
if (gen !== generation) return;
barrierRefused = true;
setUnknown(error);
return;
}
@@ -435,7 +486,17 @@ export function useShapeQuery<T extends BaseType = BaseType>(
// ROTATION uses too, which is why keeping an access needs no subscription
// obligation on the consumer's side.
capsUnsub = getCaps().onChange(() => {
if (query !== null) publish(query, generation);
if (query !== null) {
publish(query, generation);
return;
}
// No query in force. Since `1.0.0-dev.9` there is a second way to be here, and it
// is the one this signal exists for: the BARRIER refused the scope, so the query
// was never created and there is nothing for `publish` to re-filter. A view left
// like that would be TERMINAL - the very case this channel was added to prevent,
// arriving by a door that did not exist when the channel was written. So re-open,
// and only when the refusal has actually been lifted.
if (barrierRefused && namedDocumentsReachable()) open();
});
open();
}
+154 -3
View File
@@ -27,7 +27,8 @@ 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 } from "../src/emulated-verifier/open-repo";
import { 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";
import type { NgLike, UseShapeLike } from "../src/model/types";
@@ -58,7 +59,12 @@ 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() {
function makeFake(options: { pushState?: boolean } = {}) {
// A broker that never pushes the first `State` is the pathological case the bootstrap
// open's bounded fallback exists for - not the ordinary one, which CONTRACT 3 in
// `e2e/` measures at about a millisecond. It is what makes the barrier's cost visible
// without waiting for it.
const pushState = options.pushState !== false;
const quads: Quad[] = [];
let docCounter = 0;
const subs: SubRec[] = [];
@@ -148,7 +154,7 @@ function makeFake() {
const doc_subscribe = mock(async (nuri: string, _sid: string, cb: (r: unknown) => void) => {
subs.push({ nuri, cb });
setTimeout(() => cb({ V0: { TabInfo: {} } }), 0);
setTimeout(() => cb({ V0: { State: {} } }), 0);
if (pushState) setTimeout(() => cb({ V0: { State: {} } }), 0);
return () => {};
});
@@ -657,6 +663,151 @@ describe("useShapeQuery", () => {
expect(orm.openCount()).toBe(0);
});
// -- the cap that arrives LATE, which is the ordinary case --------------
//
// A page reload, a `settle` still in flight, an inbox delivery landing while the
// person is looking at the screen: in all three the view is subscribed BEFORE the
// keyring holds what it needs. The contract promises that such a view converges by
// itself - "a `useShapeQuery` that was empty for want of that `ReadCap` re-reads and
// publishes the document it now opens" - and `CapRegistry.onChange` is the channel.
// A refusal that cannot be re-tried on that signal is not a stricter answer, it is a
// dead view.
it("(r) a NAMED document whose cap arrives after the subscribe converges, it does not stay isError", async () => {
const ng = makeFake();
const orm = makeOrm();
inject(ng, orm);
// Alice writes the note. Bob is the one reading, and he holds nothing yet - the
// state a page has while its connection work is still restoring the keyring.
setCurrentUser("alice");
const note = await createEntityDoc("alice", "protected");
seedEvent(orm, note, `${note}:q:one`, "Shared");
setCurrentUser("bob");
expect(getCaps().isEnforcing()).toBe(true);
const obs = useShapeQuery(EventShape, { graphs: [note] });
const unsub = obs.subscribe(() => {});
await tick();
await tick();
// Honest for now: he cannot read it, and being told so beats an empty success.
expect(obs.getSnapshot().isError).toBe(true);
// ...and now the cap lands, exactly as the applied share does it
// (`branch-registers.addLink` for the connected holder, `inbox-dispatch` for a named
// one): filed into the keyring, which fires `CapRegistry.onChange`.
getCaps().learn(mintCap(note));
await tick();
await tick();
const snap = obs.getSnapshot();
expect(snap.isError).toBe(false);
expect(snap.isSuccess).toBe(true);
expect(titles(snap.data)).toEqual(["Shared"]);
unsub();
});
it("(s) a cap for some OTHER document does not re-open a scope that is still unreachable", async () => {
const ng = makeFake();
const orm = makeOrm();
inject(ng, orm);
setCurrentUser("alice");
const note = await createEntityDoc("alice", "protected");
seedEvent(orm, note, `${note}:q:one`, "Shared");
setCurrentUser("bob");
const obs = useShapeQuery(EventShape, { graphs: [note] });
let notes = 0;
const unsub = obs.subscribe(() => {
notes += 1;
});
await tick();
await tick();
expect(obs.getSnapshot().isError).toBe(true);
const notesAfterRefusal = notes;
// A keyring change that says nothing about THIS scope - every connection files
// several, and an inbox delivery for another document files one more. Re-opening on
// each of them would re-run the barrier, re-ask the network for any document whose
// fetch had FAILED (a failure is deliberately not memoised) and re-publish the same
// error to every subscriber, for as long as the session lasts.
//
// The count of NOTIFICATIONS is what separates the targeted retry from the
// indiscriminate one: re-opening republishes, and republishing wakes every view
// bound to this observable. Asserting on the ORM instead proves nothing here - the
// barrier refuses before the engine is ever asked, either way.
getCaps().learn(mintCap("did:ng:o:something-else"));
await tick();
await tick();
expect(obs.getSnapshot().isError).toBe(true);
expect(notes).toBe(notesAfterRefusal);
unsub();
});
// -- what the barrier COSTS, which is not nothing -------------------------
it("(t) named documents cross the barrier TOGETHER, so N that never sync cost one wait, not N", async () => {
const ng = makeFake({ pushState: false });
const orm = makeOrm();
inject(ng, orm);
setOpenTimeoutForTests(40); // after `inject`, which resets it to the 8s default
setCurrentUser("quinn");
const a = await createEntityDoc("quinn", "protected");
const b = await createEntityDoc("quinn", "protected");
const c = await createEntityDoc("quinn", "protected");
seedEvent(orm, a, `${a}:q:one`, "A");
seedEvent(orm, b, `${b}:q:one`, "B");
seedEvent(orm, c, `${c}:q:one`, "C");
const named: string[] = [a, b, c];
const subscribesTo = (): number =>
ng.doc_subscribe.mock.calls.filter((call) => named.includes(String(call[0]))).length;
expect(subscribesTo()).toBe(0);
const obs = useShapeQuery(EventShape, { graphs: [a, b, c] });
const unsub = obs.subscribe(() => {});
await tick();
// The deterministic form of "the waits overlap": all three opens are in flight
// before any of them has answered. A serial barrier would show exactly one here and
// pay the bounded fallback three times over before publishing anything - which on
// the production timeout is 24 seconds of `isPending` for a scope naming three
// 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"]);
unsub();
});
it("(u) INERT is not untouched: no refusal, but the cold-start open is real and is paid", async () => {
const ng = makeFake();
const orm = makeOrm();
inject(ng, orm);
setCurrentUser("rex");
const early = "did:ng:o:inert-named";
seedEvent(orm, early, `${early}:q:one`, "Early");
expect(getCaps().isEnforcing()).toBe(false);
const obs = useShapeQuery(EventShape, { graphs: [early] });
const unsub = obs.subscribe(() => {});
await tick();
await tick();
// Nothing REFUSES - that half is genuinely inert, since `mayReach` answers true for
// everything before the first cap exists. But the open is not: `ensureRepoOpen` runs
// whatever the registry is enforcing, and issues a real `doc_subscribe` held for the
// session. That is deliberate and it is the cold-start heal - without it a named
// document this session has never synced answers `isSuccess` with `data: []`, the
// very lie the barrier is here to remove - but it is a CHANGE, and a scope naming a
// document now waits on that barrier where it used to open the query at once.
expect(ng.doc_subscribe.mock.calls.some((call) => call[0] === early)).toBe(true);
const snap = obs.getSnapshot();
expect(snap.isError).toBe(false);
expect(titles(snap.data)).toEqual(["Early"]);
unsub();
});
// -- teardown ------------------------------------------------------------
it("(k) the last unsubscribe closes the shape query", async () => {