Files
ng-eventually/docs/briefs/2026-07-30-virtual-wallet-boundary.md
Sylvain Duchesne 3257afe8c0 feat(inbox): un utilisateur a DEUX inboxes, comme en amont
Tranché par la cascade plutôt qu'en attendant une réponse : le niveau 3 (ORM) ne
dit rien des inbox, le niveau 2 non plus — `@ng-org/web` n'expose aucune méthode
contenant « inbox » et la session n'en publie aucune. C'est donc le modèle du
moteur qui décide, et il dit DEUX : un site porte une inbox sur son repo de store
public et une autre sur son protégé (`engine/verifier/src/site.rs:127-152`), les
seuls `AddInboxCap` du moteur, `new_store_default` n'en posant une que
`if !private`. Elles sont adressées séparément jusque dans les enregistrements de
contact, qui choisissent leur prédicat selon le profil visé — `ng:site_inbox` pour
un profil public, `ng:protected_inbox` sinon
(`engine/verifier/src/inbox_processor.rs:787,823-824`).

`userInbox(id)` en exposait une : une cardinalité que cette bibliothèque avait
inventée, et que le nom `walletInbox` avait contribué à masquer. Elle prend
désormais le scope, et le store PRIVÉ n'en a pas — d'où `InboxScope` plutôt que
`Scope` : demander l'inbox privée n'est pas une recherche qui ne rend rien, c'est
une question sans référent dans le modèle, et le type l'interdit.

`myInboxes` énumère les deux, `isOwnInbox` reconnaît les deux. Le shim garde un
triple par (user, scope).

160 tests unitaires, typecheck src/test/e2e vert, e2e 40/40 contre le broker.
2026-08-04 16:19:49 +02:00

26 KiB
Raw Permalink Blame History

Brief — the virtual user boundary

Status: specified 2026-07-30; all seven steps done (four planned, three added as the work uncovered them). Sits alongside 2026-07-20-caps-emulation-alignment.md (the wider caps chantier) and 2026-07-27-p1a-cap-surface.md (the surface, implemented). This lot is about something more fundamental than either: what a virtual user is allowed to reach.

Why this lot exists

A virtual user must simulate the boundary of the future single-user wallet. Today it does not: it is a grouping fiction — a shim account listing three index documents — and nothing enforces it. Every access function reaches any document of any identity, given a session id and a NURI.

Stated by the PO on 2026-07-30, on discovering the state:

A virtual user must simulate the boundary of the future mono-user wallet. So the access functions must all be restricted to the virtual user currently "connected" (setCurrentUser). No cross-wallet access may be permitted, otherwise we are building on a fundamentally wrong model.

This is the same failure mode the whole caps chantier exists to prevent, one level down. P1a fixed the shape of reading (possession, not an ACL). It left the reach unbounded — and a consumer coded against an unbounded reach is coded against a world that will never exist, exactly like one coded against an ACL.

The rule

The only reads/writes not confined to a virtual user are those that make multi-wallet operation possible at all (e.g. the index of virtual users). — PO, 2026-07-30

And its sharpened form, which decides the hard cases:

Nothing common — only indexing mechanisms to make the virtual users work. — PO, 2026-07-30

So an exemption must be plumbing, never pooled user data. The test: does removing it stop the virtual users from functioning, or does it merely stop users from seeing each other's content? Only the first justifies living outside a wallet. The shim passes (remove it and no wallet is resolvable); a shared index of user announcements does not (remove it and every wallet still works — you simply have to be given links).

Everything else is confined. The exemption list is short, explicitly named, and each entry has to justify itself against those two sentences — an exemption that merely helps is not an exemption.

The premise that collapsed: there is no discovery

Recorded here because it removes a whole module rather than guarding it (PO, 2026-07-30 — see ../readcap-and-nuri-model.md §4ter-bis, where the principle is documented in full):

You cannot discover. You can only follow links. NextGraph is local-first: publishing is place the data in your public store and circulate the link — into inboxes, or into somewhere already reachable by the people concerned. It is seen only by those who received the link. Private distribution is the same act plus the ReadCap.

discovery.ts therefore fails on both counts: it emulates a global-list capability the target will never have, and it is pooled user data across wallets. It is not a boundary to guard, it is a module to remove — with watchShape('public')'s discovery fold, INDEX_ACCOUNT, and the submitToIndex guard along with it.

What replaces it is not a mechanism but the model itself: a link reaches you through an inbox, or through a document you already hold. Which makes the inbox the bootstrap of the entire reachability graph — the reason its guard (below) and its automatic processing matter more than they first appear.

Consequence for P1a's acceptance test, resolved: test/watch-shape.test.ts (e) proved "a harvested bare reference reads nothing, the repo link reads the document" on the discovery fold. The property is independent of discovery and survives — test/cross-user-access.test.ts already proves it on the model's own terms (Bob follows a reference found in a document he holds), so (e) was dropped rather than re-based.

The good news: the boundary already exists

The keyring is the boundary. A document is legitimately reachable when capFor(doc) answers — either because this wallet created it (its scope index refiles the cap, the emulated AddRepo { read_cap }) or because someone delivered the cap to it. No new notion is needed; the guard is written. What is missing is applying it.

And the surface to guard is small. Everything in the library reaches NextGraph through four functions in two modules:

  • docs.docCreate, docs.sparqlUpdate, docs.sparqlQuery
  • subscribe's ng.doc_subscribe

Nothing else touches ng. (open-repo only tests whether doc_subscribe exists; ng-proxy is the app-facing proxy.)

What is confined, and what is exempt

Exempt — each one passes the rule:

Exemption Why it makes multi-wallet operation possible
The store-root pointer + the doc-shim, to resolve THE CURRENT account This is the index of virtual users. Without it no virtual user is resolvable at all. Resolution only — enumerating every account is not covered (see below).
The reserved accounts (the inbox anchor; @index is gone with discovery.ts) They host infrastructure documents; they are not anybody's wallet.
Depositing into another wallet's inbox (write-only) Without a cross-wallet write channel there is no sharing, hence no useful multi-wallet — and it carries no pooled data: a deposit is addressed to one wallet, not shared between them. This IS the NextGraph model: an inbox deposit is anonymous and sealed, and grants the depositor nothing in return.

Reading a discovery indexwithdrawn 2026-07-30, on both counts: it emulates a capability that does not exist, and it is pooled user data. See The premise that collapsed above.

Confined — none of these passes the rule:

Path Today Becomes
docs.sparqlQuery / sparqlUpdate (exported from the SDK entry) any document, any wallet guarded on the anchor: the cap must be held
inbox.read / readSynced / watch any inbox, including someone else's only inboxes belonging to the current wallet
subscribeDoc any document only documents whose cap is held
storeRegistry.listEntityDocs / resolveReadGraphs fan-out over every account removed — cross-wallet enumeration, and its former justification (feeding discovery) is gone too
storeRegistry.allAccounts / loadShim enumerates every virtual user removed, or reduced to the reserved-account resolution that infrastructure needs
storeRegistry.ensureAccount(id) any id the current identity, plus the reserved accounts
readUnion already guarded (P1a) unchanged

The breach P1a opened, and which this lot must close first

inbox.read has no guard, and since P1a it absorbs caps into the reader's keyring. So:

setCurrentUser("mallory");
await inbox.read(bobsInbox);   // mallory pockets the caps addressed to Bob

Directed sharing is therefore defeatable by anyone who knows an inbox NURI. Strictly speaking this is not a regression — the inbox was never guarded — but before P1a it carried nothing that granted access, and now it does. This is the first thing to fix, and it is arguably P1a's own debt rather than this lot's.

Closing it needs a notion that does not exist yet: "my inbox". Today an inbox is an arbitrary NURI supplied by the caller. Which is the same brick as the PO's other instruction, so they should land together:

Processing inboxes is the polyfill's job, not the app's — as soon as the app connects a user/wallet, at two levels: the wallet inbox (where ReadCaps arrive) and the inbox of every document the user owns.

Design notes for the implementation

Scaffolding in the keyring, not in the exemption list. The current account's three scope-index documents belong to its wallet, so they should be in its keyring rather than exempted. That keeps the exemption list down to what genuinely serves multi-wallet operation (the shim, the reserved accounts). Only the pointer and the doc-shim stay outside.

Exemptions are named, never inferred. A NURI is exempt because it is the shim document or a reserved account's document, resolved as such — never because it "looks like infrastructure". An inferred exemption is a hole.

Write-only really means write-only. Depositing into another wallet's inbox must not make that inbox readable, subscribable, or listable as a side effect. This is the one asymmetric permission in the model and it needs its own test.

The guard belongs to a function, not to a position. One named predicate ("may the current wallet reach this document?"), called at each of the four passage points — not four inline checks that drift apart.

Hardening is the polyfill's responsibility, not a negotiation with the consumer

Stated by the PO on 2026-07-30, closing the question "what does the app actually use?":

We do not need to know what the app uses: the polyfill must harden everything it exposes. That is its responsibility. Nothing may allow its own mechanisms to be bypassed — the virtual user in particular.

This settles how the lot proceeds, and it generalizes past it. A surface that lets a caller go around the wallet boundary does not merely risk misuse: it teaches a model that will not exist, which is the one thing this library exists to prevent. So an exposed function that can bypass a mechanism the polyfill provides is a defect whether or not anyone calls it that way, and "the consumer might depend on it" is not an argument for keeping it — if the consumer depends on it, the consumer depends on something the target will refuse.

Applies to every exported surface, including ones added later: if it is exposed, it is guarded.

What this breaks

docs.* is exported from the SDK-identical entry and can reach any document; the cross-account fan-out enumerates every wallet; discovery.* disappears entirely. The consumer will have to change where it relied on any of them. That is the point, not a side effect: each one is the API starting to tell the truth about a boundary that will exist. Update ../migration-guide.md accordingly.

Order of work

  1. ~~**Remove discovery.***~~ — **DONE 2026-07-30.** src/discovery.tsandtest/discovery.test.tsdeleted;INDEX_ACCOUNT, watchShape's public-scope fold and its discovery-index container subscription, nurisFromRef, the submitToIndexguard, and the e2e discovery block all removed. P1a's acceptance test did not need re-basing:test/cross-user-access.test.tsalready proves the same property (a bare reference reads nothing, the link reads the document) on the model's own terms — following a link — sowatch-shape.test.ts(e), which proved it on the discovery fold, was dropped. Docs realigned: the ADR is marked superseded,read-model.md` now describes ONE regime (follow, never enumerate), and the root README's capability row records the removal.

  2. "My inbox" + the inbox read guardDONE 2026-07-30. storeRegistry.userInbox(id) gives every virtual user its own inbox document, created on first sight and recorded in the doc-shim under its own predicate (shim:docInbox), read by its OWN query so an account record written before this existed still resolves — the fixed account SELECT did not grow a fourth required field. isOwnInbox(nuri) is the predicate; inbox.read / readSynced (hence watch, which reads through it) refuse an inbox that is not the connected wallet's, and refuse outright when no identity is set. Depositing stays openpost / shareCap are untouched, because that is the one legitimate cross-wallet act. The shared resolveInboxAnchor (a reserved account's document, an inbox COMMON to every wallet) was removed: it was unused by the library and violated nothing common. Locked by test/isolation-active.test.ts an inbox may be DEPOSITED into by anyone, and READ only by its owner, which walks the exact breach — Alice deposits, cannot read back; Mallory knowing the NURI absorbs nothing; anonymous is refused; Bob reads his own and only then does the cap land.

    Not done, and deliberately: per-DOCUMENT inboxes. Upstream every document has onefalse, corrected 2026-08-03: no document has an inbox upstream, and neither does the private store (see step 7's correction). Here only the wallet does. The PO has ruled they must come (2026-07-30) — "it can come in a second step, but it must come" — so this is a commitment, not an option. The guard predicate (isOwnInbox) is where they plug in: it answers "is this inbox mine?", which extends to "…one of my documents' inboxes" without changing a single caller.

Two defects this step surfaced — the first still open, the second closed by steps 56

userInbox(id) is a directory, and directories do not exist. It resolves ANY wallet's inbox from its identity id, and it is exported (storeRegistry.* is re-exported from the SDK entry). But you cannot look someone up in NextGraph — you cannot discover, you can only follow links. Their inbox NURI reaches you because they gave it to you, not because you resolved it from a name. Resolving my own inbox is legitimate plumbing; resolving anyone's is the same shape as the discovery index just removed. Fix: the public surface becomes "my inbox" (no argument), and reaching someone else's requires a NURI you were given. Resolution-by-id stays internal, for the shim and the tests.

The keyring is not stored anywhere, and the shape is wrong — fix it now, not at P1b. It is an in-memory Map<accountKey, Map<Nuri, ReadCap>>, rebuilt from scratch each session. Nothing persists a cap as a cap. PO directive, 2026-07-30:

If NextGraph stores the key next to the document, then that is what we do. Without encryption we simply store a stand-in value. Stay as close as possible to how NextGraph works.

So this is not a P1b concern, it is a shape concern — the one thing this library exists to get right — and the stand-in key is stored exactly where the real one will be. Two storage sites, mirroring upstream:

  • My own documents → the cap goes beside the NURI in the scope container, which is the emulation of AddRepo { read_cap } on a branch of the store. Today the container stores only shim:contains <nuri> and the cap is re-derived from that NURI; it gains a shim:readCap beside it. Deriving then stops being how a cap is recovered and becomes merely how the stand-in value is minted — the single function P1b replaces.
  • Caps I received → into the emulation of AddLink { read_cap } on the User branch of the private store. Verified 2026-07-30 (see ../readcap-and-nuri-model.md §4quinquies): that register exists, it is explicitly for external repos, and its stated purpose is to "share with all its device a new Link they received" — wallet-resident and cross-device. So a received cap belongs inside the virtual user, like everything else. (Two earlier versions of this note were wrong and are recorded in 2026-07-30-users-stores-branches.md: the first blamed key derivation and proposed a "keyring document"; the second concluded no register existed at all and proposed browser-local storage — which would have put library data OUTSIDE even the physical user. Both were refuted; AddLink is the answer.)
  1. The guard at the four passage pointsDONE 2026-07-30. src/emulated-verifier/reach.ts holds the boundary as two rules on one criterion — possession — implemented in two places (PO directive):

    • Rule 1, authorization, at the passage points (assertMayReach, called from docs.sparqlQuery / sparqlUpdate): nothing reaches ng unless the connected user possesses the document's cap.
    • Rule 2, do not even attempt, at the callers (mustNotAttempt, applied in read-model.readUnion, which now filters BEFORE opening or reading): a reader that holds no cap does not issue the operation at all. Upstream you cannot even address a repo you have no cap for, so asking about one is not "a read that will be refused" — it is a read with no meaning.

    The redundancy is the point, and a test pins it: a caller that forgets rule 2 is still refused by rule 1, so a bookkeeping lapse fails loudly instead of succeeding quietly.

    Possession decides, never the shape of the reference in hand. A caller legitimately manipulates a bare NURI while holding its cap elsewhere — references travel bare through content and indexes, the cap sits in what the user holds. targetOf first, so both forms answer alike. (An earlier reading of the directive checked the string for :r: instead; corrected on the PO's clarification.)

    Exemptions are declared, never inferred from a NURI's shape (declareInfrastructure, called by the store-registry for the store-root pointer and the doc-shim — the index of virtual users, the only thing that passes the "remove it and no user resolves at all" test). A user also reaches its own three stores and its own inbox, or the boundary would lock it out of itself.

    Not done: subscribeDoc is not yet guarded — it interacts with ensureRepoOpen, which opens documents before their cap is known on some cold-start paths. Left for step 4 with the barrier tests in view.

  2. Remove the cross-account fan-outDONE 2026-07-30. listEntityDocs, resolveReadGraphs, allAccounts, loadShim and the full-shim cache are gone. Nothing in the library used them any more once discovery was removed; only their own tests did. subscribeDoc is now guarded too (rule 1) — a subscription IS an access, since the push carries the document's state, so leaving it open would have been a door beside the gate.

Machinery vs virtual user: two APIs, and only one is the app's

The PO's framing, which replaced the exemption list entirely:

Clearly distinguish what is polyfill machinery (and therefore the PHYSICAL user) from what is a virtual user's operation. Use different functions, probably grouped in different namespaces — because one API is exposed to the app and the other must never be.

src/shared-wallet/physical.ts now holds physicalCreate / physicalQuery / physicalUpdate, with ensurePhysicalRepoOpen and subscribePhysicalDoc as their open/subscribe counterparts. They are unguarded, and never exported from the package — a test asserts it, because a regression there is silent and total.

The dividing line:

Does this operate on the index of virtual users (the shim), or on the content of one virtual user? The first is machinery; everything else is the user's, and is confined.

So the store-root pointer, the doc-shim and the account records go through the machinery; a virtual user's stores, its inbox and its documents go through the guarded docs.*, even though the library is what calls them on the user's behalf.

This is strictly stronger than the exemption list it replaces. declareInfrastructure is deleted. The machinery no longer calls the guarded primitive and gets waved through — it calls a different primitive that was never guarded. There is no list to widen, to get wrong, or to infer from a NURI's shape, and the boundary now has no if in it that could be talked into saying yes.

  1. The Link, and inbox processing on connectionDONE 2026-07-30, after the four steps above.

    The Link. Giving access is a Link deposited into the recipient's inbox — upstream's word at all three stages (InboxMsgContent::Link for the message, AddLink { read_cap } for the filing, RemoveLink for the withdrawal). shareCap deposits one; the deposit kind is …:inbox:link.

    Applying it durably. storeRegistry.addLink / readLinks emulate AddLink on the User branch of the private store — a distinct subject (shim:userBranch) in the private store document, kept separate from the ldp:contains listing exactly as upstream keeps the User branch separate from Main. Idempotent, so re-processing costs nothing.

    The split that matters: inbox.read KEEPS a Link (in the session's keyring) but does not FILE it — reading a queue must not write to a user's store. inbox.processInbox applies: it reads, then files. That is what an inbox is upstream — a queue you consume, not a store you re-read.

    On connection. setCurrentUser fires connect.connectedUser(): restore the already-applied Links from the User branch, then drain the inbox. Restore-first means a reconnecting user reads its shared documents immediately, without waiting on the queue. Fire-and-forget, because the setter is synchronous and every consumer calls it from synchronous code — the work announces itself through CapRegistry.onChange, which watchShape already listens to. connectedUser() is exported for a caller that needs to await it.

    Two things it deliberately does NOT do. It does not provision: connecting an identity that does not exist creates nothing (resolveAccount, not ensureAccount) — otherwise connecting would mint a user's stores and caps as a background side effect, arming the whole emulation at a moment nothing controls. And it does not drain per-document inboxes, which do not exist yet.

    Proven by test/cross-user-access.test.ts: a cap shared to Bob survives with his inbox emptied and every in-memory cap dropped — restored from the User branch, not from the queue.

    Cost noted: setCurrentUser now has observable asynchronous effects (it reads, and it logs). Three log-assertion tests had to await connectedUser() before counting lines. That is the honest price of moving the obligation off the app, and it is worth naming rather than discovering.

  2. The Store branchDONE 2026-08-03. A document's cap is now STORED when it is created — shim:readCap on a storeBranch subject of the store document, the emulated AddRepo { read_cap } — and READ back by listMyEntityDocs, never recomputed. That closes the asymmetry left by the Link work, where received caps were filed durably while created ones were re-minted.

    Two things this pinned down, both of which would have cost more later:

    • The listing and the keys stay separate, as Main and Store branches are upstream: contains on one subject, readCap on another, written as two statements because upstream they are two commits.
    • Creation mints the cap exactly once. It used to mint twice — once to write, once to hold — which agreed only because the stand-in value is a constant. With P1b's real key those would be two different keys, and a creator would hold one that does not open its own document. A test pins it, and another proves the cap is read rather than recomputed by corrupting the stored value and checking the corruption wins.

    Honest about the emulation: upstream the Store branch carries no triples at all (BranchCrdt::None). Representing it as RDF is ours; what is faithful is the storage beside the document and the separation from the listing.

  3. Per-document inboxesDONE 2026-08-03. Upstream a repo carries inbox: Option<PrivKey> (engine/repo/src/repo.rs:126): an inbox is a keypair on the document whose PRIVATE half the owner holds, recorded with AddInboxCap { repo_id, overlay, priv_key } on the User branch — the same branch as AddLink, and with the same stated purpose ("so that a user can share with all its device"). So "which inboxes may I read" has exactly one answer, and it is the one place to look.

    (Renamed and split on 2026-08-03 — documentInbox became openDocumentInbox (own the inbox) + documentInboxAddress (find where to deposit). Conflating the two made per-document inboxes unusable by anyone but their owner; see 2026-08-03-document-inbox-addressing.md.)

    storeRegistry.openDocumentInbox(doc) resolves — creating on first ask — the inbox of a document this user owns, recording the pair on its User branch. Lazy: minting an inbox document for every entity up front would double every createEntityDoc for inboxes most documents never receive anything in. myInboxes() enumerates both levels, isOwnInbox answers from the same record, and connect.connectedUser drains them all in one call.

    The asymmetry holds at both levels, and a test walks it: anyone deposits into a document's inbox (that is how a third party reaches its owner at all), only the owner reads it.

    Correction 2026-08-03, twice. First: the paragraph above overstated the upstream fact — an adversarial doc review caught it. Then the correction itself overshot, calling the step an ANTICIPATION: the engine supports an inbox on any repo (inbox is a field of every Repo, AddInboxCapV0 is keyed by repo_id, and update_inbox_cap_v0 applies it with no is_store check — engine/verifier/src/verifier.rs:1920). What no code path does is CREATE one for a document. Verified: new_store_default attaches an inbox only if !private (engine/verifier/src/verifier.rs:2994), and doc_create goes through new_repo_default, which leaves inbox: None (engine/repo/src/repo.rs:574). The only two AddInboxCap commits in the engine are for the public and protected STORE repos (engine/verifier/src/site.rs:128,149). So today no document is GIVEN an inbox, and neither is the private store — which says nothing about what may have one. The step stands as delivered, and it is aligned on the engine's model rather than betting past it.

Relation to P1b

P1b (per-document encryption, closing the read paths that bypass the guard) largely becomes this lot, better framed. Encrypting each document while leaving the wallet boundary open would be securing the windows with the door open — and conversely, once every access is confined to the connected wallet, "the emulated key is derivable" stops being the load-bearing weakness. This lot should therefore precede P1b, or absorb it.