fix: trois chemins vers une inbox en double, et la lecture qui manquait

Une application a rapporté quatre appels simultanés sur un même document
enregistrant trois inboxes. Le contrat garantissait l'inverse. En cherchant, on
en a trouvé DEUX autres, indépendantes, qui produisent le même dégât durable :
le propriétaire surveille une inbox pendant que les dépôts arrivent dans une
autre.

La concurrence. openDocumentInbox ne partageait rien avec userInbox — module
différent, registre propre, aucune coalescence. Reproduit pire que rapporté :
quatre appels donnaient QUATRE inboxes. Une carte en vol par (détenteur,
document), et le corps déplacé pour que l'invariant soit porté par la
composition plutôt que par la position d'une vérification.

La limite est nommée plutôt que cachée : deux onglets ne partagent aucune carte,
chacun lit, chacun ne trouve rien, chacun frappe. Ce n'est pas réparable ici —
une branche est en ajout seul, et ça ne se réconcilie pas après coup, le
propriétaire lisant sa branche User quand un déposant lit l'adresse publiée du
document. Le contrat porte donc une garantie positive ET une non-garantie.

La page froide. readInboxCapPairs était le seul lecteur de store sans barrière,
correct uniquement parce qu'une autre fonction s'exécutait avant lui à la
connexion. Une dépendance d'ordre, pas une garantie portée par la lecture : sur
une page froide il lisait le store privé non synchronisé, répondait « aucune
inbox » et en frappait une seconde. Un seul appel, aucune concurrence. La
barrière est désormais dans la lecture, et elle ne coûte rien aux chemins
connectés, la connexion ayant déjà ouvert les trois stores.

Et la lecture qui manquait. readSynced donnait la garantie, readForDocument
l'adressage, pas leur intersection — si bien que matérialiser des dépôts
obligeait une application à résoudre une adresse d'inbox elle-même, ce que le
contrat lui interdit explicitement. inbox.readSyncedForDocument la lui épargne.
Elle traverse deux dépôts, l'adresse vivant sur l'en-tête du document et les
dépôts sur l'inbox — franchir la barrière sur la seule inbox ne réparait rien.

Au passage, le compteur d'identifiants de la doublure était par page : une page
rechargée refrappait le même identifiant PAR-DESSUS une inbox existante,
aliasant deux dépôts en silence. Il est monotone.
This commit is contained in:
Sylvain Duchesne
2026-08-17 09:46:16 +02:00
parent 90712e0ad0
commit 76ae9ffbb7
9 changed files with 686 additions and 17 deletions
@@ -79,6 +79,7 @@ export const inbox: {
read(targetInbox: NuriLike): Promise<Deposit[]>; // only your own
readForDocument(doc: NuriLike): Promise<Deposit[]>;
readSynced(targetInbox: NuriLike): Promise<Deposit[]>;
readSyncedForDocument(doc: NuriLike): Promise<Deposit[]>;
processInbox(targetInbox: NuriLike): Promise<Deposit[]>;
watch(targetInbox: NuriLike, onDeposits: (d: Deposit[]) => void): () => void;
// `materialize` (a second published name for `read`) was REMOVED on 2026-08-14 —
@@ -114,6 +115,8 @@ Only a document's owner writes to it. Holding its read key never grants a write.
`inbox.post` refuses a target that is not an inbox; to reach a document's owner, use `inbox.postToDocument(doc, …)`. Anyone may deposit into an inbox; only its owner reads it.
**You never resolve an inbox address, on either side.** You deposit by naming a document (`inbox.postToDocument`), and you read what was left on your own by naming it too — `inbox.readForDocument(doc)` at any time, or `inbox.readSyncedForDocument(doc)` on a page that has just loaded. The second is the one to call when an empty answer has to MEAN empty: a session that has just loaded has synced neither the document nor its inbox, and an unsynced read of either comes back empty with no error — so the ungated form can answer `[]` for a document whose inbox holds messages. `readSyncedForDocument` waits for both before answering. `inbox.readSynced` is the same guarantee on an inbox you already hold the address of, which no application does: it takes an address, so nothing here hands you one.
`ensureIdentity()` settles the identity, completes the connection work it starts, and returns the identity. It takes no identifier, and no other call takes one.
It resolves **only once that work has actually completed**: if what was shared with you could not be restored, or a queue could not be drained, it throws instead of returning. So a resolved call means everything shared with you is readable — and a rejected one must not be rendered past, since the interface would show an empty account rather than an empty screen.
@@ -124,6 +127,10 @@ It resolves **only once that work has actually completed**: if what was shared w
Where a call must first find out whether something already exists — a document's record in its store, a user's inbox — it throws when it could not find out, instead of proceeding as though the answer were "nothing". So `createEntityDoc` throws if the document cannot be recorded in its store, and resolving an inbox throws rather than handing back a second one. **A rejection means "unknown", never "absent"** — retry it or surface it, but do not read it as an empty result.
**`storeRegistry.openDocumentInbox(doc)` is idempotent, including when calls overlap.** Asks for the same document that are in flight together are answered by one call, and every one of them gets the same inbox — you do not have to serialise them yourself, and firing one per component as they mount is a supported way to use it. This holds **within one page**; two pages doing it in the same moment is a non-guarantee below, and it is the only part of this you have to think about.
**A reactive read says "nothing" and "I could not find out" differently.** `watchShape` 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 no document of that shape — the distinction the surface exists for. Until 2026-08-17 a scope whose listing did not answer published that very snapshot, so an interface showed "you have created nothing" for "the store did not answer"; it now publishes `isError` carrying the error. 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.
The same rule reaches what a call hands BACK, not only what it looked up first: **`listMyEntityDocs` returns a listing whose documents you can open, or it throws.** It reads which documents are in the store and what opens each, and it throws if either did not answer — including when the documents came back and their keys did not. Nothing about a keyless listing is visible to you: it is the same `Nuri[]`, and the difference would only appear at the next read, empty, long after the cause. An empty array therefore means this account created nothing.
## Non-guarantees
@@ -140,6 +147,8 @@ The same rule reaches what a call hands BACK, not only what it looked up first:
**No cross-broker reference.** A returned reference resolves for users of the same broker.
**`openDocumentInbox` does not coalesce across PAGES.** Two tabs — or two sessions of the same person — that open the same document's inbox in the same moment can each create one, and the document is left with two: its owner drains one while deposits arrive in the other. Nothing raises, nothing reports it, and neither page can detect it afterwards. It is not an oversight to be patched later: a branch MERGES records rather than refusing the second, so there is no "create only if absent" to build the guarantee on, and the address a depositor reads is a separate record from the one the owner resolves — so the two cannot even be made to agree on which of the pair won. Open a document's inbox from one place: the page that creates the document, or one call the rest of the interface waits on.
**No unfiltered read through `useShape`.** Members that yield items are filtered and mutations pass through; anything else throws. A document reached through that view alone, read nowhere else first, does not appear.
## Change policy
+15 -3
View File
@@ -244,6 +244,10 @@ export function watchShape<T = UnionSubject>(
): ShapeObservable<T>;
```
**A scope that did not answer is `isError`, never `isSuccess` with `data: []` — since 2026-08-17.** Step 1 of the pipeline asks `listMyEntityDocs` which documents are mine in this scope. That failure used to be caught and logged, and the empty set flowed on: a barrier over zero documents is trivially reached, so the surface published `{ data: [], isPending: false, isSuccess: true }` — byte for byte the synced-but-empty snapshot, which means the one distinction this module exists for was the one it destroyed. An application rendered "you have created nothing" for "the store did not answer". `listMyEntityDocs` had stopped handing out that reading the day before (see § 12); catching it here put it straight back one floor up. It now travels the LOAD-STATE channel, which is where "this is not an answer" already lives on this surface, and which an application must already read to tell pending from empty — so the third state costs it no new vocabulary.
**`data` survives an error rather than emptying.** A one-shot call rejects and is done; an observable has already handed a list to a subscriber that rendered it, and cannot un-emit. Collapsing `data` to `[]` on failure would put the empty answer back in the one field a view actually paints, for exactly the case that must never read as empty — so `data` keeps the last read that ANSWERED, `isSuccess` stays false so that array is never offered as a reply to the question that just failed, and the subscriber is notified so a view gating on the load state can say so. Before any answer there is nothing to keep and `data` is `[]`, published under `isError` and never under `isSuccess`.
### Target
**Partly ASSUMPTION — flagged deliberately.** `surface/watch-shape.ts`'s header says it "anticipates NextGraph's planned `useShape(shape, scope)` upgrade, which will natively distinguish 'sync in progress' from 'synced but empty'". **No provenance for that plan exists in this repo's docs or in the `nextgraph-rs` clone** — treat the "planned upgrade" as an assumption, not a stated NextGraph direction. What IS verified at level 3 is that the distinction is *expressible* today, just not through the hook:
@@ -257,6 +261,8 @@ public close(): void;
So the constraint on the bet: the target can already answer "synced?" (`readyPromise`), and `useShape` today returns "an empty set, if still loading" (its own doc comment, `useShape.ts:29-31`) — indistinguishable from synced-empty. `watchShape` surfaces the distinction with a TanStack-`useQuery`-minimal vocabulary (`isPending`/`isSuccess`/`isError`), which is a **shape of this library's choosing**. If the future hook exposes load state under different names, the consumer's binding code changes; the underlying distinction it teaches (pending ≠ empty) is target-expressible and safe to learn.
**But `readyPromise` answers two of those three questions, not three — VERIFIED 2026-08-17, and it bears on `isError` specifically.** It is constructed with `resolve` alone (`new Promise<void>((resolve) => { this.resolveReady = resolve; })`, `GraphOrmSubscription.ts` constructor), nothing anywhere rejects it, and `resolveReady()` is called on one path only — the arrival of initial data. The subscription that fails does not settle it: `orm_start_graph` is awaited inside a `try` whose `catch` is `console.error(e)`, so the promise stays pending forever and every `await this.readyPromise_` behind it hangs. Upstream's "I could not find out" IS its "still pending". So `isPending` and `isSuccess` map onto something real at level 3, while `isError` has **no counterpart at any level** — it is this library's third state, and the reason it exists is that swallowing a failed read into a plausible-looking result is the shape that took this project a week to close everywhere else. A consumer should keep reading it; if the future hook ships without one, the failure is the pending that never ends, which is a worse thing to have to render, not a reason to have learned less.
---
## 6. One-shot listing — the read-model
@@ -424,6 +430,7 @@ export async function readForDocument(doc: NuriLike): Promise<Deposit[]>;
// an alias and nothing else: no call site, and upstream has no such member, so it was a
// symbol an application could learn and would then have to unlearn. Use `read`.
export async function readSynced(targetInbox: NuriLike): Promise<Deposit[]>;
export async function readSyncedForDocument(doc: NuriLike): Promise<Deposit[]>;
export async function processInbox(targetInbox: NuriLike): Promise<Deposit[]>;
export function watch(
targetInbox: NuriLike,
@@ -445,8 +452,9 @@ Consequences per function:
- `post` / `postToDocument` — the sender-side act exists in the model (the broker routes `InboxPost` natively, `engine/net/src/server_broker.rs`); its JS surface does not. **The future SDK's name and signature are unknown**`docs/nextgraph-current-state.md:187` records that nothing is announced. `postToDocument`'s resolution step (find the document's inbox address) rides on a **deliberate divergence**: this lib PUBLISHES the address on the document (Header-branch emulation), whereas upstream an address is only ever TRANSMITTED (`ContactDetails` carries `ng:site_inbox`/`ng:protected_inbox`, `engine/verifier/src/inbox_processor.rs:778-830`; the address→repo association lives in `inboxes: PubKey → RepoId`, a table of the **verifier** — one per user, `verifier.rs:105`). *(Corrected 2026-08-10: this said the table was "session-local, rebuilt empty". It is initialized empty (`verifier.rs:520,2820`) and then repopulated at every load — `Verifier::load` → `add_repo_without_saving` → `add_repo_`, `verifier.rs:534-566,2871,2887` — with the inbox private key persisted per repo, `user_storage/repo.rs:61,171,207,362`. The property that matters is that it is **per verifier**, not that it is ephemeral.)* Documented in `docs/briefs/2026-08-03-document-inbox-addressing.md`.
- `share` — a **gap upstream, not a disagreement**, verified at both ends: `ContactDetails.read_cap: Option<ReadCap>` exists (`engine/net/src/types.rs:4233`) but building a message with it is `unimplemented!()` (`types.rs:3786`), its only caller passes `with_readcap: false`, and the receiving arm never reads the field (`inbox_processor.rs:778-830`). `InboxMsgContent::Link` is a **unit variant carrying nothing** (`types.rs:4252`) — do not read it as the delivery channel. The recipient-side filing the lib emulates is real: `AddLink { read_cap }` on the User branch (`engine/repo/src/types.rs:1939-1948`). The consumer's *act* (share one document's cap to one inbox) is target-shaped; only the transport is emulated.
- `read` / `readSynced` / `processInbox` / `watch`**stand-ins for the recipient's own verifier processing**, which has no consumer-facing JS surface upstream and may never have this list-of-deposits shape. A consumer should treat "my inbox gets processed when I connect, and applied caps just appear in what I hold" as the durable contract (that is what `connectedUser` automates, § 13); code that leans on enumerating raw deposits as a mailbox UI is coding against emulation detail it may have to unlearn. The consumer-payload case (`Deposit.payload` as app data) maps to `InboxMsgContent` variants upstream (`types.rs:4249-4260`), of which only `ContactDetails` and `SocialQuery` are more than unit variants today — arbitrary app payloads through the inbox are an **ASSUMPTION**, constrained by the model only in that messages are sealed, per-recipient, and applied by the recipient.
- `readForDocument(doc)` — the owner's side of a document's inbox, named by the DOCUMENT. Same LEVEL-1 SHAPE ruling as `read`: it is the recipient's own processing, which has no consumer-facing JS surface upstream, and enumerating its deposits is emulation detail. It exists so an application never handles an inbox address.
- `read` / `readSynced` / `readSyncedForDocument` / `processInbox` / `watch`**stand-ins for the recipient's own verifier processing**, which has no consumer-facing JS surface upstream and may never have this list-of-deposits shape. A consumer should treat "my inbox gets processed when I connect, and applied caps just appear in what I hold" as the durable contract (that is what `connectedUser` automates, § 13); code that leans on enumerating raw deposits as a mailbox UI is coding against emulation detail it may have to unlearn. The consumer-payload case (`Deposit.payload` as app data) maps to `InboxMsgContent` variants upstream (`types.rs:4249-4260`), of which only `ContactDetails` and `SocialQuery` are more than unit variants today — arbitrary app payloads through the inbox are an **ASSUMPTION**, constrained by the model only in that messages are sealed, per-recipient, and applied by the recipient.
- `readForDocument(doc)` — the owner's side of a document's inbox, named by the DOCUMENT. Same LEVEL-1 SHAPE ruling as `read`: it is the recipient's own processing, which has no consumer-facing JS surface upstream, and enumerating its deposits is emulation detail. It exists so an application never handles an inbox address. It is the WARM form (it delegates to `read`), so on a session that has just loaded it can answer `[]` for a document that has deposits.
- `readSyncedForDocument(doc)` — added 2026-08-17, the intersection the surface was missing: `readSynced`'s barrier on `readForDocument`'s address. **What an application no longer does: resolve an inbox address itself.** It had to, because materializing deposits needs both halves and only one call carried each — and resolving an address is the exact gesture § *Guarantees* says an application never performs. The document-addressed path crosses TWO repos and a cold session loses the answer at either: the address is read off the DOCUMENT's Header branch, so an unsynced document reads as "no inbox"; the deposits are read off the INBOX, which is what `readSynced` gates. This gates both, document first. Same LEVEL-1 SHAPE ruling as `read` and the same fate — it adds no divergent ACT, it composes two published ones so the caller does not have to hold an address to reach them (`packages/polyfill/test/cold-read-for-document.test.ts` pins the pair: on one cold state, `readForDocument` answers empty and this answers the message).
- `share(doc, toUser)` **refuses an unknown recipient** since 2026-08-10. It used to provision one: a mistyped name minted that name's stores and an inbox, and the cap landed where nobody looks. Upstream a deposit is sealed to an inbox pubkey that reached you through an inbound contact, so you cannot address a name you invented.
- `watch`'s `_opts?: { intervalMs?: number }` is accepted and **ignored** (kept for signature compatibility with a removed polling watcher) — dead surface, see § 15.
@@ -593,6 +601,10 @@ export async function openDocumentInbox(doc: NuriLike): Promise<Nuri>;
**`listMyEntityDocs` answers a VERIFIED listing, or it rejects — since 2026-08-17.** It stands on two reads of the store document, and both propagate: the Main branch says which documents are in there, the Store branch says what opens each. Handing back the listing over a key read that never answered was tolerated until then, on the ground that the array was already in hand by that point — which is exactly what made it a half-truth rather than a shortcut, since nothing distinguishes a listing you can open from one you cannot. It is the same `Nuri[]`; the difference shows at the next read, empty, with the cause long gone. It is the ruling the connection path already runs on, applied to the last call that escaped it: **a rejection means "unknown", never "absent"**, and only "there was nothing to do" resolves quietly — so an empty array here means this account created no document in that scope, and never that the store went unread.
**`openDocumentInbox` coalesces concurrent asks — and one JS realm is the honest extent of it, since 2026-08-17.** The call reads the User branch to find out whether an inbox is already recorded and mints when the answer is no, with a dozen awaits between the two. Callers that arrive together therefore all read before any of them writes: each read ANSWERS, each answer is honestly "none", and each mints. Nothing fails, which is what sets this apart from the read-that-could-not-answer family swept through this document — no rejection-means-unknown ruling touches it, only coalescing does. Reported from an application: four simultaneous calls on one document registered three inboxes, after which the owner drained one while deposits arrived in another. Asks for the same `(holder, document)` now share one call.
**What that does NOT reach, and why it cannot be fixed here.** Two tabs share no in-flight map, so the durable fork survives — and it is not a matter of doing more work. Preventing it needs a conditional write ("record only if absent"), which no level of the target offers: a branch is an add-only CRDT, so two `AddInboxCap` records merge rather than one being refused. Reconciling it afterwards the way `canonicalDoc` reconciles a forked account pointer does not work either, because the two sides read different records — the owner resolves from `AddInboxCap` on its User branch, a depositor from the address published on the document's Header branch, and the latter is written DELETE-then-INSERT, so it is last-write-wins and need not name the same one. Making both sides agree would mean letting a document accumulate two addresses to pick a canonical one from, and that is a state the target model has no meaning for: `inboxes: PubKey → RepoId` is a function (`engine/verifier/src/verifier.rs:105`) and `repo.inbox` a single `Option<PrivKey>`. So the gap is stated in the app contract as a non-guarantee rather than papered over. At migration it closes on its own — upstream an inbox is a keypair created WITH the repo, not a document minted on demand, so there is no read-then-write to race.
### Target — split by what each piece maps to
- **`createEntityDoc(id, scope)` → level 2, VERIFIED direction.** Target: `doc_create(session_id, crdt, class_name, destination, store_repo)` aimed at the identity's real per-scope store (see § 7 for the store-targeting nuance — the nodejs SDK already takes `store_type`/`store_repo` strings). The two writes the lib performs by hand are **native side effects** of `doc_create` upstream: the `ldp:contains` listing on the store's Main branch and the `AddRepo { read_cap }` on its Store branch (`engine/verifier/src/request_processor.rs:697-710`). The `id` parameter is already gone from the published call (2026-08-10); expect `createEntityDoc(scope)` to become `doc_create(sid, …, storeOf(scope))` with no listing/cap bookkeeping.
@@ -712,7 +724,7 @@ Exported, but not SDK surface. Coding against these builds knowledge that migrat
```text
direct: BaseType, DeepSignalSet, DocChange, DocChangeType, EventuallyConfig, NG, NgLike, Nuri, NuriLike, PrincipalId, Schema, Scope, ShapeObservable, ShapeQuery, ShapeType, SharedWalletConfig, UnionSubject, Unsubscribe, UseShapeLike, configure, docChangeType, ensureIdentity, init, initNg, ng, readUnion, subscribeDoc, subscribeDocs, useShape, watchShape
docs: docCreate, sparqlQuery, sparqlUpdate
inbox: Deposit, PostOptions, post, postToDocument, processInbox, read, readForDocument, readSynced, share, watch
inbox: Deposit, PostOptions, post, postToDocument, processInbox, read, readForDocument, readSynced, readSyncedForDocument, share, watch
storeRegistry: createEntityDoc, listMyEntityDocs, openDocumentInbox, resolveScopeGraph, resolveWriteGraph
```
@@ -403,6 +403,22 @@ function encodeInboxCap(doc: Nuri, inbox: Nuri): string {
* half of the drain list `connect.connectedUser` works from, and an empty answer would
* make a document's queue silently un-drained a share that was delivered and never
* applied, with nothing to see anywhere.
*
* **Barrier-AUTHORITATIVE, since 2026-08-17.** This was the only reader of a user's store
* in this file with no {@link ensureRepoOpen} of its own `readLinks` next door,
* `restoreOwnCaps` and `readUserStore` all carry one and on a fresh page over the same
* persistent wallet an anchored read of a not-yet-synced repo returns no rows, no error
* (`open-repo.ts`). What made that survive was caller ORDER: connecting opens the three
* stores ({@link restoreOwnCaps}) before anything asks. Order is not a guarantee, and the
* one caller that decides on the answer proved it {@link openDocumentInbox} MINTS when
* this reads empty, so on a page that had settled an identity without connecting it yet,
* one call, no race, gave a note a SECOND inbox: two `AddInboxCap` records, and the
* address published on the document replaced by the new one, so later deposits land where
* none of the earlier ones are. Same ruling as the family around it (`e32b6d0`): only a
* VERIFIED absence may mint, and a read that could not answer is not one.
*
* Costs nothing on the paths that already connected the open registry is per-session and
* a repo already open is a map hit (`open-repo.ts`).
*/
// @provenance readInboxCapPairs kind=declared-not-wired level=1 ref=engine/repo/src/types.rs:AddInboxCapV0 — the record is keyed by `repo_id` and `update_inbox_cap_v0` applies it with no is-store check — but the engine only ever commits one for the two STORE repos, never for a plain document
export async function readInboxCapPairs(): Promise<Array<{ doc: Nuri; inbox: Nuri }>> {
@@ -413,6 +429,8 @@ export async function readInboxCapPairs(): Promise<Array<{ doc: Nuri; inbox: Nur
if (!store) return [];
const s = await session();
const out: Array<{ doc: Nuri; inbox: Nuri }> = [];
// The sync barrier, before the read that decides — see the note above.
await ensureRepoOpen(store);
try {
const res = await sparqlQuery(
s.sessionId,
@@ -585,6 +603,45 @@ export async function readLinks(forHolder?: PrincipalId): Promise<ReadCap[]> {
}
/**
* The `openDocumentInbox` calls currently in flight, keyed by `(holder, document)`
* mirrors `account-registry.userInbox`'s `inboxInFlight`, which had the same read-then-
* mint shape and was made concurrency-safe first. This one was not, and the gap was
* reported from an application: four simultaneous calls on ONE document minted three
* inboxes, after which the owner drained one while deposits arrived in another.
*
* Note what did NOT happen: nothing failed. Every caller read the register, every read
* ANSWERED, and every answer was honestly "no inbox recorded" because none of the writes
* had landed yet. So this is not the "a failure resolved like a success" family the rest of
* this file guards against; it is a read-then-write with no coalescing, and only the
* coalescing closes it.
*
* Keyed by the HOLDER as well as the document, because the answer is the holder's: the
* register lives on their User branch, and a non-owner asking gets a refusal, never an
* inbox. Keyed on `accountKey` so `@Alice` and `alice ` one person share one entry,
* and joined with `\u0000` for the reason `userInbox` uses it: a separator no identifier
* can contain is the only one that cannot make two different pairs share a key.
*
* Deliberately holds no RESOLVED value, unlike the `inboxCache` beside its counterpart:
* entries are dropped the instant the call settles, whether it answered or threw, so the
* next ask re-reads the durable register rather than trusting a memo and a refusal
* never lingers as one. That is also why this map needs no reset hook: nothing in it can
* go stale, because nothing in it has finished.
*
* **Its reach is one JS realm, and that is the whole of what it promises.** Two browser
* tabs, or two sessions, share no map: each reads the register, each finds nothing, and
* each mints the durable fork this cannot prevent. Preventing it needs a conditional
* write ("insert only if absent") that no layer of the target offers: a branch is an
* add-only CRDT, so two `AddInboxCap` records simply merge. Nor can it be reconciled
* after the fact the way `canonicalDoc` reconciles a forked account pointer: the owner
* resolves from the User branch and a depositor from the document's published address,
* two different records, and the second is last-write-wins by construction upstream
* `inboxes: PubKey → RepoId` is a function and `repo.inbox` a single `Option`, so
* accumulating two addresses to pick a canonical one is a state the model has no meaning
* for. The contract says one realm; see `contract_polyfill-surface.md`.
*/
const openInboxInFlight = new Map<string, Promise<Nuri>>();
/**
* The inbox of a document this user owns resolved, and created on first ask.
*
@@ -635,6 +692,12 @@ export async function readLinks(forHolder?: PrincipalId): Promise<ReadCap[]> {
* reading half, so it would silently divert to itself the deposits meant for the
* owner. To deposit into someone else's document, resolve
* {@link documentInboxAddress} and `inbox.post` into it.
*
* **Idempotent, including under concurrency within ONE JS realm.** Simultaneous asks
* for the same document by the same holder are coalesced onto a single call
* ({@link openInboxInFlight}), which is what stops N callers each reading "no inbox" and
* each minting one. Two TABS still fork, and cannot be stopped from here read the note
* on that map before assuming otherwise.
*/
// @provenance storeRegistry.openDocumentInbox kind=declared-not-wired level=1 ref=engine/repo/src/types.rs:AddInboxCapV0 — every `Repo` carries `inbox: Option<PrivKey>` and the record is keyed by any `repo_id`, but `new_store_default` attaches one only to non-private STORES and `doc_create` leaves `inbox: None`. PUBLISHING the address is a separate, divergent act — see `publishInboxAddress`
export async function openDocumentInbox(docLike: NuriLike): Promise<Nuri> {
@@ -643,6 +706,35 @@ export async function openDocumentInbox(docLike: NuriLike): Promise<Nuri> {
const doc = toNuri(docLike, "openDocumentInbox");
const holder = getCurrentUser();
if (holder === null) throw new Error("[ng-eventually] openDocumentInbox: no identity is set");
// Everything below is one read-then-write: it asks the register whether an inbox is
// already recorded, and mints when the answer is no. A dozen awaits separate the two,
// so callers that arrive together all read before any of them writes — each finds
// nothing, each mints, and the document ends up with several. Coalescing them onto ONE
// call is the whole fix, and it is where the guarantee is enforced rather than merely
// hoped for: a second caller never runs the body at all, it awaits the first.
const key = `${accountKey(holder)}\u0000${doc}`;
const pending = openInboxInFlight.get(key);
if (pending) return pending;
const p = resolveOrMintDocumentInbox(doc, holder);
openInboxInFlight.set(key, p);
try {
return await p;
} finally {
// Dropped whether it resolved or threw. A refusal (not the owner) or a failed persist
// must not linger as an answer: the next ask has to look again, exactly as it would
// have if it had arrived a moment later.
openInboxInFlight.delete(key);
}
}
/**
* The body of {@link openDocumentInbox}, minus the coalescing a separate function so
* the "one call per (holder, document)" invariant is carried by the composition rather
* than by where a check sits inside a long block. Never call it directly: it is the
* un-coalesced path, and reaching it twice concurrently is the bug.
*/
async function resolveOrMintDocumentInbox(doc: Nuri, holder: PrincipalId): Promise<Nuri> {
const known = (await readInboxCapsFor(doc)) ?? null;
if (known) return known;
+60 -2
View File
@@ -429,18 +429,39 @@ export async function share(doc: NuriLike, toUser: string): Promise<void> {
await post(await userInbox(toUser, "protected"), { payload: { kind: LINK_KIND, cap } });
}
/**
* Turn a DOCUMENT into its deposits the one place the document-addressed reads resolve
* an address, shared by both of them ({@link readForDocument} and
* {@link readSyncedForDocument}) so the two cannot come to disagree about what "this
* document has no inbox" means, exactly as {@link DEPOSITS_QUERY} is shared by the two
* readers of an inbox.
*
* Empty when the document has no inbox, which is a state and not an error. WHICH read of
* the inbox follows is the caller's, and it is the only thing the two differ by.
*/
async function depositsForDocument(
doc: Nuri,
readInbox: (inbox: Nuri) => Promise<Deposit[]>,
): Promise<Deposit[]> {
const address = await documentInboxAddress(doc);
return address ? readInbox(address) : [];
}
/**
* The messages left on a document YOU own the read side of {@link postToDocument}.
*
* Named by the DOCUMENT, like the deposit side: an owner reading their own messages has
* no more reason to handle an inbox address than a depositor does. Empty when the
* document has no inbox, which is a state and not an error.
*
* The WARM read, like {@link read} it delegates to: it gates on no sync barrier, so on a
* fresh session over the same persistent wallet it can answer `[]` for a document that
* has deposits. {@link readSyncedForDocument} is the same address with that guarantee.
*/
// @provenance inbox.readForDocument kind=divergent level=1 ref=engine/verifier/src/inbox_processor.rs:process_inbox — upstream an inbox is a queue the verifier consumes and APPLIES; this enumerates it instead, non-destructively
export async function readForDocument(docLike: NuriLike): Promise<Deposit[]> {
const doc = toNuri(docLike, "inbox.readForDocument");
const address = await documentInboxAddress(doc);
return address ? read(address) : [];
return depositsForDocument(doc, read);
}
// --- the read guard ------------------------------------------------------
@@ -598,6 +619,43 @@ export async function readSynced(targetInboxLike: NuriLike): Promise<Deposit[]>
return read(targetInbox);
}
/**
* COLD, BARRIER-GATED read of the messages left on a document YOU own the guarantee of
* {@link readSynced} on the address of {@link readForDocument}, and the one call that
* materializes deposits without an application ever holding an inbox address.
*
* Why the two do not compose by themselves
* The document-addressed path crosses TWO repos, and a cold session (a reconnection, a new
* page) loses the answer at either one:
*
* 1. the DOCUMENT, whose Header branch carries the address (`documentInboxAddress`)
* unopened, that anchored read matches nothing, so the call concludes "no inbox" and
* answers `[]` for a document whose inbox is full;
* 2. the INBOX, whose deposits are the answer the cold-start {@link readSynced} exists
* for.
*
* So this crosses the barrier on both, in that order: the document FIRST, because its
* address is what the second open is even for. Past it, an empty result MEANS empty, on
* the read an application actually makes.
*
* Gating only the inbox would fix nothing that is `readSynced`, and reaching it needs an
* address. There is deliberately no published call that hands one out (see
* {@link postToDocument}), so composing the two was never the application's to do; the gap
* was reported by one that had resolved an address itself to get here.
*/
// @provenance inbox.readSyncedForDocument kind=divergent level=1 ref=engine/verifier/src/inbox_processor.rs:process_inbox — the same divergence as the two halves it composes: upstream an inbox is a queue the verifier consumes and applies, and the address is TOLD to you rather than read off a document. It adds no divergent ACT, it spares the caller one
export async function readSyncedForDocument(docLike: NuriLike): Promise<Deposit[]> {
const doc = toNuri(docLike, "inbox.readSyncedForDocument");
// The cold, connection-triggered entry point, marked in the trace before the two
// BARRIER lines (open-repo.ts) it is about to produce — the document's, then its
// inbox's. A live session shows the whole document-addressed materialization together.
logStage("READSYNCEDFORDOCUMENT " + shortNuri(doc) + " (cold, barrier-gated)");
// The DOCUMENT, before the address is asked for. An unsynced document does not answer
// "this has no inbox" — it answers nothing, and the two are the same value here.
await ensureRepoOpen(doc);
return depositsForDocument(doc, readSynced);
}
/**
* PROCESS an inbox: read it, and **apply** what it contains.
*
@@ -0,0 +1,175 @@
/**
* cold-open-document-inbox.test.ts asking again for the inbox my note already has.
*
* The defect this closes
* `openDocumentInbox` is a resolve-or-mint: it asks the User branch of the owner's PRIVATE
* store whether an inbox is already recorded for this document (`readInboxCapPairs`, the
* emulated `AddInboxCap`), and mints one when the answer is no. That read had no sync
* barrier of its own. On a fresh page over the same persistent wallet the private store is
* present but unsynced, and an anchored read of an unsynced repo returns no rows no
* error (`emulated-verifier/open-repo.ts`). So the register answered "no inbox recorded"
* for a document that has one, and the call minted a SECOND.
*
* What that leaves behind is durable and wrong in the way that cannot be seen: two
* `AddInboxCap` records for one document, and the address published ON the note replaced by
* the new inbox so from then on deposits arrive in one box while everything left before
* sits in the other. Nothing fails, nothing is logged.
*
* Why it needs no concurrency, and how this suite reaches it
* The same symptom (several inboxes for one document) was reported by a consuming
* application from FOUR simultaneous calls, and that race is closed by coalescing them onto
* one call (`openInboxInFlight`). This is the other road to it, and one page is enough:
* a single call, no race, on a page whose private store has not been opened yet.
*
* A page is in exactly that state between SETTLING an identity and CONNECTING it the
* split the access gate makes on purpose (`shared-wallet/access-gate.ts` `settleIdentity`
* records who is acting through `adoptCurrentUser`, and `ensureIdentity` connects
* afterwards). Connecting is what opens the three stores today (`restoreOwnCaps`), so
* before it the register's read is cold. The suite settles the identity exactly as the gate
* does, and everything else it does is published calls an application makes.
*
* Nothing is planted: the second page sees only what the first one WROTE, and the broker
* fake withholds exactly what this page has not subscribed to (`wallet-fake.ts`,
* `unsyncedUntilSubscribed`).
*/
import { test, expect, describe, afterAll, beforeEach } from "bun:test";
import { docs, storeRegistry } from "../src/index";
import { adoptCurrentUser } from "../src/shared-wallet/bootstrap";
import { getSyncState } from "../src/emulated-verifier/open-repo";
import { bootPage, forgetEverything, reloadPage, signIn, SESSION, type Quad } from "./wallet-fake";
import type { Nuri, PrincipalId } from "../src/model/types";
const TITLE = "urn:test:title";
/** The broker's own cold start — see `wallet-fake.WalletOptions`. */
const COLD = { unsyncedUntilSubscribed: true } as const;
/**
* Record who is acting, and stop there the SETTLE half of signing in, which is what the
* access gate does before it connects (`settleIdentity` `adoptCurrentUser`). Not a test
* shortcut into an invented state: every page passes through it, and an application that
* settles in its `init()` and acts before `ensureIdentity()` resolves is in it for real.
*/
function settledButNotConnected(id: PrincipalId): void {
adoptCurrentUser(id);
}
/** Alice writes a note and opens it for messages — both named by the NOTE, as an app does. */
async function aNoteOpenedForMessages(quads: Quad[]): Promise<Nuri> {
bootPage(quads, COLD);
await signIn("alice");
const note = await storeRegistry.createEntityDoc("public");
await docs.sparqlUpdate(
SESSION.sessionId,
`INSERT DATA { <${note}> <${TITLE}> "Courses" }`,
note,
"writeEntity",
);
await storeRegistry.openDocumentInbox(note);
return note;
}
/** The `AddInboxCap` records the wallet holds for `note` read off the wallet, because no
* published call hands out an inbox address (which is the point of `postToDocument`). */
function inboxesRecordedFor(quads: Quad[], note: Nuri): Nuri[] {
return quads
.filter((q) => q.p === "urn:ng-eventually:shim:inboxCap" && q.o.startsWith(note + " "))
.map((q) => q.o.split(" ")[1] as Nuri);
}
/** The address published ON the note — where a depositor is sent. */
function addressPublishedOn(quads: Quad[], note: Nuri): Nuri[] {
return quads
.filter((q) => q.g === note && q.p === "urn:ng-eventually:shim:inboxAddress")
.map((q) => q.o as Nuri);
}
/** Alice's private store, off the wallet — the repo the register lives in. */
function herPrivateStore(quads: Quad[]): Nuri {
const record = quads.find((q) => q.p === "urn:ng-eventually:shim:docPrivate");
if (!record) throw new Error("alice has no account in this wallet");
return record.o as Nuri;
}
/** Alice's note, found the way her application finds it: by listing her own store. */
async function myNote(): Promise<Nuri> {
const mine = await storeRegistry.listMyEntityDocs("public");
const note = mine[0];
if (!note) throw new Error("the note Alice wrote is not in her store");
return note;
}
beforeEach(() => {
forgetEverything();
});
afterAll(() => {
forgetEverything();
});
describe("opening my note for messages again, on a page that has just loaded", () => {
test("it resolves the inbox the note already has — it does not mint a second", async () => {
const quads: Quad[] = [];
const written = await aNoteOpenedForMessages(quads);
const [theInbox] = inboxesRecordedFor(quads, written);
reloadPage(quads, COLD);
settledButNotConnected("alice");
const note = await myNote();
expect(await storeRegistry.openDocumentInbox(note)).toBe(theInbox!);
});
test("and the wallet still holds ONE record and ONE published address for it", async () => {
const quads: Quad[] = [];
const written = await aNoteOpenedForMessages(quads);
const [theInbox] = inboxesRecordedFor(quads, written);
reloadPage(quads, COLD);
settledButNotConnected("alice");
await storeRegistry.openDocumentInbox(await myNote());
// The durable damage, which is the part nobody can see at the time: a second record
// makes the owner's own drain list ambiguous, and a re-published address sends every
// later deposit to a box that holds none of the earlier ones.
expect(inboxesRecordedFor(quads, written)).toEqual([theInbox!]);
expect(addressPublishedOn(quads, written)).toEqual([theInbox!]);
});
test("it crossed the sync barrier on the store the register lives in", async () => {
const quads: Quad[] = [];
await aNoteOpenedForMessages(quads);
const store = herPrivateStore(quads);
reloadPage(quads, COLD);
settledButNotConnected("alice");
await storeRegistry.openDocumentInbox(await myNote());
// The guarantee itself, not the outcome: past the first `State`, presence is
// guaranteed and absence definitive — so "no inbox recorded" would MEAN it.
expect(getSyncState(store)).toBe("synced");
});
test("a note nobody has opened for messages still gets one — absence is not ignorance", async () => {
const quads: Quad[] = [];
bootPage(quads, COLD);
await signIn("alice");
const bare = await storeRegistry.createEntityDoc("public");
await docs.sparqlUpdate(
SESSION.sessionId,
`INSERT DATA { <${bare}> <${TITLE}> "Vierge" }`,
bare,
"writeEntity",
);
reloadPage(quads, COLD);
settledButNotConnected("alice");
const note = await myNote();
const inbox = await storeRegistry.openDocumentInbox(note);
// The barrier makes an empty answer definitive; it does not stop the mint that a real
// absence calls for. Both records land: the entitlement, and the address depositors read.
expect(inboxesRecordedFor(quads, note)).toEqual([inbox]);
expect(addressPublishedOn(quads, note)).toEqual([inbox]);
});
});
@@ -0,0 +1,164 @@
/**
* cold-read-for-document.test.ts coming back to the messages left on my note.
*
* The gap this closes
* The inbox surface offered a read with the SYNC GUARANTEE (`readSynced`) and a read
* ADDRESSED BY DOCUMENT (`readForDocument`), and not their intersection. An application
* materializing deposits needs both, so it had to resolve an inbox address itself the
* one gesture the contract says an application never performs.
*
* Why the document-addressed path needs the barrier TWICE
* Reading a document's messages crosses two repos: the DOCUMENT, whose Header branch
* carries the address, and the INBOX, which carries the deposits. On a fresh session over
* the same persistent wallet both are present and unsynced, and an anchored read of an
* unsynced repo returns no rows no error (`emulated-verifier/open-repo.ts`). So the
* ADDRESS read comes back empty, `readForDocument` concludes "this document has no inbox",
* and answers `[]` for a note whose inbox holds the message somebody left on it.
*
* That is the state this suite starts from, reached the way a real page reaches it: Alice
* writes a note and opens it for messages, Bob leaves one, and Alice comes back on a new
* page. Nothing is planted a second page sees exactly what the first one WROTE, and the
* broker fake only withholds what this page has not subscribed to yet
* (`wallet-fake.ts`, `unsyncedUntilSubscribed`).
*
* The pair of tests is the point: on ONE state, the ungated read answers empty and the
* gated one answers the message. A test that only showed `readSyncedForDocument` returning
* deposits would pass just as well over a plain `read`.
*/
import { test, expect, describe, afterAll, beforeEach } from "bun:test";
import { docs, inbox as inboxSurface, storeRegistry } from "../src/index";
import { getSyncState } from "../src/emulated-verifier/open-repo";
import { bootPage, forgetEverything, reloadPage, signIn, SESSION, type Quad } from "./wallet-fake";
import type { Nuri } from "../src/model/types";
const TITLE = "urn:test:title";
const MESSAGE = "j'apporte le café";
/** The broker's own cold start — see `wallet-fake.WalletOptions`. */
const COLD = { unsyncedUntilSubscribed: true } as const;
/**
* The first visit, in the application's own vocabulary: Alice writes a note and opens it
* for messages; Bob leaves one on it. Both name the NOTE and nothing else.
*
* Returns the note, which is all an application ever holds.
*/
async function aNoteWithAMessageOnIt(quads: Quad[]): Promise<Nuri> {
bootPage(quads, COLD);
await signIn("alice");
const note = await storeRegistry.createEntityDoc("public");
await docs.sparqlUpdate(
SESSION.sessionId,
`INSERT DATA { <${note}> <${TITLE}> "Courses" }`,
note,
"writeEntity",
);
await storeRegistry.openDocumentInbox(note);
await signIn("bob");
await inboxSurface.postToDocument(note, { payload: { text: MESSAGE }, from: "bob", ts: 1 });
return note;
}
/**
* The inbox the note was opened on, read off the WALLET the emulated `AddInboxCap`
* record. The test asks the wallet because no application can ask the package: there is
* deliberately no published call that hands out an address, which is the whole reason
* `readSyncedForDocument` has to exist.
*/
function inboxOnTheNote(quads: Quad[], note: Nuri): Nuri {
const record = quads.find(
(q) => q.p === "urn:ng-eventually:shim:inboxCap" && q.o.startsWith(note + " "),
);
if (!record) throw new Error("no AddInboxCap record was written for the note");
return record.o.split(" ")[1] as Nuri;
}
/** Alice's note, found the way her application finds it: by listing her own store. */
async function myNote(): Promise<Nuri> {
const mine = await storeRegistry.listMyEntityDocs("public");
const note = mine[0];
if (!note) throw new Error("the note Alice wrote is not in her store");
return note;
}
/** Alice comes back on a NEW page, over the wallet the first one wrote. */
async function aliceComesBack(quads: Quad[]): Promise<Nuri> {
reloadPage(quads, COLD);
await signIn("alice");
return myNote();
}
beforeEach(() => {
forgetEverything();
});
afterAll(() => {
forgetEverything();
});
describe("reading the messages left on my note, on a page that has just loaded", () => {
test("the ungated document-addressed read answers EMPTY — the note's repo never synced", async () => {
const quads: Quad[] = [];
await aNoteWithAMessageOnIt(quads);
const note = await aliceComesBack(quads);
// Not a failure anyone can see: the message is on the broker, Alice owns the inbox,
// and the call returns a perfectly ordinary empty list.
expect(await inboxSurface.readForDocument(note)).toEqual([]);
// …because nothing ever brought the NOTE into view. The address lives on it.
expect(getSyncState(note)).toBe("unknown");
});
test("the synced document-addressed read answers the message, over that same state", async () => {
const quads: Quad[] = [];
await aNoteWithAMessageOnIt(quads);
const note = await aliceComesBack(quads);
const mine = await inboxSurface.readSyncedForDocument(note);
expect(mine.map((d) => (d.payload as { text: string }).text)).toEqual([MESSAGE]);
expect(mine.map((d) => d.from)).toEqual(["bob"]);
});
test("it crosses the sync barrier on BOTH repos the answer depends on", async () => {
const quads: Quad[] = [];
const written = await aNoteWithAMessageOnIt(quads);
const inbox = inboxOnTheNote(quads, written);
const note = await aliceComesBack(quads);
await inboxSurface.readSyncedForDocument(note);
// The guarantee itself, not the payload: past the first `State` on each, presence is
// guaranteed and absence definitive — so an empty answer would MEAN empty. The note's
// barrier is the one this call adds (nothing else on the page opens a note); the
// inbox's is `readSynced`'s, and connecting may have crossed it already.
expect(getSyncState(note)).toBe("synced");
expect(getSyncState(inbox)).toBe("synced");
});
test("a document nobody opened an inbox on answers empty, not an error", async () => {
const quads: Quad[] = [];
bootPage(quads, COLD);
await signIn("alice");
const bare = await storeRegistry.createEntityDoc("public");
reloadPage(quads, COLD);
await signIn("alice");
expect(await inboxSurface.readSyncedForDocument(bare)).toEqual([]);
});
test("it is still a read of MY inbox — the owner's guard is not bypassed", async () => {
const quads: Quad[] = [];
await aNoteWithAMessageOnIt(quads);
const note = await aliceComesBack(quads);
// Bob can find where to deposit for Alice's public note, and that is all: reading it
// would collect the caps addressed to her. A second door onto the same read must not
// be a way around the guard the first one carries.
await signIn("bob");
await expect(inboxSurface.readSyncedForDocument(note)).rejects.toThrow(
/does not belong to the connected wallet/i,
);
});
});
@@ -25,7 +25,11 @@ import {
resolveWriteGraph,
userInbox,
} from "../src/shared-wallet/account-registry";
import { documentInboxAddress, openDocumentInbox } from "../src/emulated-verifier/branch-registers";
import {
documentInboxAddress,
openDocumentInbox,
readInboxCapPairs,
} from "../src/emulated-verifier/branch-registers";
import type { RegistrySession } from "../src/shared-wallet/account-registry";
import { configure } from "../src/index";
import { configureStoreRegistry, setCurrentUser } from "../src/shared-wallet/bootstrap";
@@ -611,6 +615,88 @@ test("opening an inbox publishes ONE address, and re-opening does not accumulate
expect((await readInbox(dedicated)).map((d) => d.payload)).toEqual([{ signingUp: true }]);
});
// CONCURRENCY, and it is NOT the "a failure resolved like a success" family this suite is
// otherwise full of: nothing here fails. `openDocumentInbox` reads "have I already opened
// one" and mints when the answer is no, with a dozen awaits between the two — so callers
// that ask at the same time each look, each honestly finds nothing, and each mints. The
// sequential test above passes because the first call's write has landed before the second
// one reads. Reported by a consuming application: four simultaneous calls on one document
// registered THREE inboxes in 0.3s, after which the owner drained one while deposits
// arrived in another — the same end state as a fork, reached without an error anywhere.
test("concurrent opens on ONE document converge on ONE inbox", async () => {
inject();
setCurrentUser("alice");
const doc = await createEntityDoc("alice", "public");
// Four at once — no await between them, which is what an application does when four
// components mount together and each opens the inbox of the document it renders.
const handed = await Promise.all([
openDocumentInbox(doc),
openDocumentInbox(doc),
openDocumentInbox(doc),
openDocumentInbox(doc),
]);
expect(new Set(handed).size).toBe(1);
// …and the DURABLE record has to agree, which is the half that actually bites: a second
// `AddInboxCap` pair means `readInboxCapsFor` picks one of two afterwards, and the
// published address is whichever write landed last. One pair, one address, and the
// address is what every caller was handed.
const pairs = (await readInboxCapPairs()).filter((p) => p.doc === doc);
expect(pairs.map((p) => p.inbox)).toEqual([handed[0]!]);
resetRegistryCache(); // a depositor's session, not a warmed cache
setCurrentUser("bob");
expect(await documentInboxAddress(doc)).toBe(handed[0]!);
await postToDocument(doc, { payload: { racing: true } });
setCurrentUser("alice");
expect((await readInbox(handed[0]!)).map((d) => d.payload)).toEqual([{ racing: true }]);
});
// The coalescing must not outlive the call that needed it, nor answer for a DIFFERENT
// document: a map keyed too coarsely (or never emptied) would pass the test above while
// handing document B the inbox opened for A.
test("concurrent opens on DIFFERENT documents get their own inbox each", async () => {
inject();
setCurrentUser("alice");
const a = await createEntityDoc("alice", "public");
const b = await createEntityDoc("alice", "public");
const [inboxA, inboxB] = await Promise.all([openDocumentInbox(a), openDocumentInbox(b)]);
expect(inboxA).not.toBe(inboxB);
expect((await readInboxCapPairs()).filter((p) => p.doc === a).map((p) => p.inbox)).toEqual([inboxA!]);
expect((await readInboxCapPairs()).filter((p) => p.doc === b).map((p) => p.inbox)).toEqual([inboxB!]);
// …and a LATER burst, once the record exists, still answers with the recorded inbox
// rather than treating "the in-flight map is empty" as "nobody opened one".
const again = await Promise.all([openDocumentInbox(a), openDocumentInbox(a)]);
expect(again).toEqual([inboxA!, inboxA!]);
});
// A refusal must not be cached as an answer, and must not leave a poisoned entry behind
// for the callers that follow: Bob asking four times at once gets four refusals, and
// Alice's own record is untouched.
test("concurrent opens by a NON-owner are all refused, and leave no residue", async () => {
inject();
setCurrentUser("alice");
const doc = await createEntityDoc("alice", "public");
const aliceInbox = await openDocumentInbox(doc);
resetRegistryCache(); // another session, not a warmed cache
setCurrentUser("bob");
const outcomes = await Promise.allSettled([
openDocumentInbox(doc),
openDocumentInbox(doc),
openDocumentInbox(doc),
openDocumentInbox(doc),
]);
expect(outcomes.map((o) => o.status)).toEqual(["rejected", "rejected", "rejected", "rejected"]);
setCurrentUser("alice");
expect((await readInboxCapPairs()).filter((p) => p.doc === doc).map((p) => p.inbox)).toEqual([aliceInbox]);
expect(await documentInboxAddress(doc)).toBe(aliceInbox);
});
test("the inbox address is machinery: it never surfaces as the document's data", async () => {
inject();
setCurrentUser("alice");
@@ -512,6 +512,11 @@ test("nothing about the deferred service reaches the published surface", () => {
"read",
"readForDocument",
"readSynced",
// Added 2026-08-17. It names a DOCUMENT, like every other door an application has
// here: the two reads it composes were published separately, so reaching the synced
// one meant holding an inbox address. It processes nobody else's queue — the owner
// guard is `readSynced`'s, unchanged.
"readSyncedForDocument",
"share",
"watch",
]);
+79 -11
View File
@@ -47,6 +47,18 @@ export interface Quad {
o: string;
}
/**
* The repo ids this fake broker has ever handed out MONOTONIC, and deliberately not a
* counter inside {@link makeWallet}.
*
* A per-wallet counter restarted at each {@link bootPage}, so a reloaded page re-issued the
* NURIs the previous one had minted: a document created after a reload came back as
* `did:ng:o:doc6` when `did:ng:o:doc6` was already somebody else's inbox, and the two
* aliased into one repo with no error anywhere. A broker never mints a repo id twice an
* id is a public key so neither does this.
*/
let minted = 0;
/** Reverse of the lib's `escapeLiteral`: one left-to-right pass over `\x`. */
function unescapeLiteral(s: string): string {
let out = "";
@@ -110,21 +122,70 @@ export interface FakeWallet {
doc_create: ReturnType<typeof mock>;
sparql_update: ReturnType<typeof mock>;
sparql_query: ReturnType<typeof mock>;
/** Present only under {@link WalletOptions.unsyncedUntilSubscribed}. */
doc_subscribe?: ReturnType<typeof mock>;
_quads: Quad[];
}
export interface WalletOptions {
/**
* Model the broker's cold start: a repo this PAGE has not subscribed to answers an
* anchored read with **nothing**, and `doc_subscribe` is what brings its commits into
* view (pushing the first `State` the sync barrier `ensureRepoOpen` awaits).
*
* Why this is the real system's state, not a convenient one
* On a fresh session over the same persistent wallet, `Verifier::load` repopulates
* `self.repos` from user storage, so the repo is PRESENT but unsynced and the anchored
* query legitimately matches nothing no error, no rows (the mechanism written out in
* `emulated-verifier/open-repo.ts`, corrected there on 2026-08-03). Two consequences the
* fake keeps faithfully:
*
* - a repo CREATED on this page is synced by construction (`doc_create` opens it, and
* there is no remote history to fetch), which is why the defect is invisible to the
* session that wrote the data;
* - a WRITE does not sync anything. Appending a commit to a repo whose remote commits
* have not arrived leaves them just as absent, so `sparql_update` never marks a repo
* synced only `doc_subscribe` does.
*
* OFF by default: the two reload suites that predate this run without a `doc_subscribe`
* at all, where `ensureRepoOpen` is the documented no-op of the unit-fake path.
*/
unsyncedUntilSubscribed?: boolean;
}
/**
* A quad-store fake `ng` over `quads` the durable half. The library holds nothing across
* a {@link reloadPage}; this does.
*
* No `doc_subscribe`: `ensureRepoOpen` is then the documented no-op of the unit-fake path
* (`emulated-verifier/open-repo.ts`), so an anchored read resolves directly. A limit of the
* fake broker, not a library state.
* By default no `doc_subscribe`: `ensureRepoOpen` is then the documented no-op of the
* unit-fake path (`emulated-verifier/open-repo.ts`), so an anchored read resolves directly.
* A limit of the fake broker, not a library state and the one
* {@link WalletOptions.unsyncedUntilSubscribed} lifts, for the suites that are about the
* sync barrier itself.
*/
export function makeWallet(quads: Quad[]): FakeWallet {
let docCounter = 0;
export function makeWallet(quads: Quad[], options: WalletOptions = {}): FakeWallet {
/** The repos whose commits this PAGE can see — created here, or subscribed to. */
const synced = new Set<string>();
const cold = options.unsyncedUntilSubscribed === true;
const doc_create = mock(async () => `did:ng:o:doc${++docCounter}`);
const doc_create = mock(async () => {
const nuri = `did:ng:o:doc${++minted}`;
// Created here: nothing remote to wait for. This is why the session that wrote the
// data never sees the cold-start defect, and the next one does.
synced.add(nuri);
return nuri;
});
const doc_subscribe = mock(async (...a: unknown[]) => {
const nuri = a[0] as string;
const onChange = a[2] as (r: unknown) => void;
synced.add(nuri);
// `TabInfo` first, then the initial `State` — the platform's own order, so a waiter
// that resolved on "the first push of any kind" would return BEFORE the barrier.
setTimeout(() => onChange({ V0: { TabInfo: {} } }), 0);
setTimeout(() => onChange({ V0: { State: {} } }), 0);
return () => {};
});
const sparql_update = mock(async (...a: unknown[]) => {
const query = a[1] as string;
@@ -157,6 +218,11 @@ export function makeWallet(quads: Quad[]): FakeWallet {
const anchor = a[3] as string | undefined;
const wrapped = query.match(/GRAPH\s+<([^>]+)>/);
const g = wrapped ? wrapped[1]! : anchor;
// The repo the verifier resolves the read against — the anchor when there is one,
// otherwise the graph named in the query.
const target = anchor ?? g;
// COLD: present but unsynced. No error, no rows — which is exactly why it is dangerous.
if (cold && target !== undefined && !synced.has(target)) return { results: { bindings: [] } };
const inGraph = quads.filter((q) => q.g === g);
// The whole-document read (`read-model.readDoc`).
@@ -233,12 +299,14 @@ export function makeWallet(quads: Quad[]): FakeWallet {
return { results: { bindings: [] } };
});
return { doc_create, sparql_update, sparql_query, _quads: quads };
return cold
? { doc_create, doc_subscribe, sparql_update, sparql_query, _quads: quads }
: { doc_create, sparql_update, sparql_query, _quads: quads };
}
/** Wire the library onto `quads` — what a page load does. */
export function bootPage(quads: Quad[]): FakeWallet {
const ng = makeWallet(quads);
export function bootPage(quads: Quad[], options: WalletOptions = {}): FakeWallet {
const ng = makeWallet(quads, options);
configure({ ng: ng as unknown as NgLike, useShape: (() => {}) as unknown as UseShapeLike });
configureStoreRegistry({ getSession: async () => SESSION, normalizeId: (id) => id.trim() });
return ng;
@@ -262,9 +330,9 @@ export function forgetEverything(): void {
}
/** A page RELOAD: the library forgets, the wallet does not. */
export function reloadPage(quads: Quad[]): FakeWallet {
export function reloadPage(quads: Quad[], options: WalletOptions = {}): FakeWallet {
forgetEverything();
return bootPage(quads);
return bootPage(quads, options);
}
/**