Files
ng-eventually/docs/simulation.md
T
Sylvain Duchesne 0832338201 feat: un document en store public sert son ReadCap, une référence nue suffit
Le modèle amont est explicite dans `PublicRepoLinkV0` : le lien ne porte AUCUN
`read_cap`, et son commentaire dit pourquoi — *"The latest ReadCap of the branch
will be downloaded from the outerOverlay, if the peer brokers listed below allow
it […] the public site are served differently by brokers"*
(engine/net/src/types.rs:5098). La clé n'est pas remise par un émetteur : elle est
donnée par le réseau à qui la demande, parce que le broker a épinglé l'overlay
externe (`expose_outer`).

La bibliothèque refusait jusqu'ici la forme sans cap quel que soit le store. Sûr
dans le bon sens, mais une application ne pouvait pas exprimer « fais circuler, la
référence suffit » — le seul acte que le modèle rend gratuit — et son unique
contournement était de distribuer la clé, ce qui détruit la confidentialité
composable.

`emulated-verifier/public-store.ts` émule le mécanisme SANS toucher à la garde. La
possession reste l'unique critère : un document public est lisible non par exception
mais parce que son cap est *obtenable*. Chaque porte de lecture demande d'abord
(`readUnion`, `docs.sparqlQuery`, `ensureRepoOpen`, `documentInboxAddress`), puis le
chemin ordinaire s'applique.

Lire n'est pas écrire. Ce que le store sert est un droit de LECTURE :
`learnFromPublicStore` le classe à part et `assertMayWrite` refuse l'écriture
dessus. Sans cela une référence nue achetait une écriture, ce qu'aucun store amont
n'accorde.

Autres conséquences :

- `recordInPublicStore` (marquer + frapper) devient `markInPublicStore` (marquer).
  Frapper un second cap à côté de celui qu'on vient de télécharger donnerait deux
  clés différentes le jour où la constante devient un secret.
- `hasCap` quitte la porte polyfill : il se lisait « ai-je le droit de lire ceci ? »
  et un document public y répondait `false` jusqu'à ce qu'on demande son cap. Aucun
  appelant hors des tests.
- Les tests cross-user ne font plus traverser de cap par une variable JS : Bob
  n'obtient que la référence nue, comme une vraie application.

Écarts documentés plutôt que masqués : le pari sur un modèle DÉCLARÉ (`expose_outer`
est câblé à `false` côté client et `ExtTopicSyncReq` est `unimplemented!()`), la
découverte limitée à ce qu'on sait déjà nommer, `useShape` qui n'a pas d'await à
dépenser, et l'absence de `locator`.

179 tests unitaires, e2e 42/42 contre le broker en ligne.
2026-08-06 19:55:32 +02:00

644 lines
40 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# How this library emulates mature NextGraph on one shared wallet
> Everything in this file is emulation. None of the behaviours described here is a
> real NextGraph feature: each is a stopgap the lib fabricates on top of the
> current, immature NextGraph (the exact gaps it compensates for are in
> [`nextgraph-current-state.md`](./nextgraph-current-state.md)). Every piece has a
> real target and goes away when NextGraph matures — the swap is lib-only, and the
> consumer application's code is unchanged. The per-behaviour recap table lives in the
> top-level [`README.md`](../README.md) (*What is emulated (and how it goes away)*);
> the removal checklist is [`migration-guide.md`](./migration-guide.md). Read this
> file for *how* each emulation works; read those two for *what is fake* and *what
> replaces it*.
The consumer application writes against `@ng-eventually/client` as if NextGraph
already shipped per-entity documents in public/protected/private stores, capabilities
and inboxes. It hasn't (see [`nextgraph-current-state.md`](./nextgraph-current-state.md)).
This file is the lib's own engineering doctrine on how it fabricates that mature
face on top of one single shared wallet / broker. Everything here is
polyfill-era and disappears at migration ([`migration-guide.md`](./migration-guide.md)).
## The premise: one shared wallet, everything readable
Current NextGraph has no cross-wallet read (`OpenRepo` is a TODO at
`engine/verifier/src/verifier.rs:1423`; a foreign NURI raises `RepoNotFound`; a
session only holds its own 3 stores in `self.repos`). So "each user their own
wallet" is blocked at the root — no data ever crosses the boundary between two
wallets.
The lib's answer: everyone opens the same wallet. NextGraph sees a single
identity, so everything is physically readable. "Multi-user" becomes an
application fiction the lib maintains. On top of that one wallet the lib rebuilds,
by emulation, the per-user stores + capabilities + inbox the consumer application
codes against.
## Physical user vs virtual user — never enumerate the physical one
**Nomenclature (aligned on NextGraph, 2026-07-30).** A **wallet** upstream is only a
**keyring**; what owns three stores is a **user** (a *site*), and one wallet holds
several of them (`SensitiveWalletV0.sites`, `engine/wallet/src/types.rs:434,457`).
So this document says *user*, not *wallet*, for the thing an identity is — the two
words meant the opposite of each other here until this was corrected.
Because the emulation runs on ONE shared wallet, distinguish two levels:
- **The physical user** — the single NextGraph user everybody's session opens. Its
stores hold every account's documents plus the library's own internals (the
pointer, the doc-shim, the inbox documents) as named graphs, accumulating without
bound across sessions and runs. Listing or scanning "all documents" at this level
is meaningless and O(size): it mixes every virtual user's data with library
internals, and it is exactly what an anchorless `sparql_query` (`GRAPH ?g { … }`)
does. The physical user is a substrate, not something to enumerate.
- **A virtual user** — the library's emulation of one identity: the documents the
shim attributes to that account (its three store documents in
`shared-wallet/account-registry.ts`). This is what "the user owns", and over it "list my
documents" is meaningful and bounded.
**Which API you use follows that line, and it is enforced** (see `shared-wallet/physical.ts`):
machinery operating on the *index of virtual users* — the store-root pointer, the
doc-shim, the account records — goes through unguarded primitives that are never
exported from the package. Everything touching a virtual user's own content goes
through the guarded `docs.*`, even when the library is what calls it. One API is the
app's; the other must never be.
**Consequence for reads (see `read-model.md`):** to list an identity's entities you
enumerate the *virtual* user — that account's store document, bounded to its own
documents — never the physical union. A bloated shared wallet then costs nothing,
and nothing is enumerated across users at all: you read your own documents and the
ones whose cap you were given.
At migration each virtual user becomes a real user with its own wallet; the
physical/virtual distinction, the "never enumerate the physical one" rule, and
`shared-wallet/physical.ts` all dissolve into native per-user reads.
## Two axes, never conflate them (store ≠ document)
The single most load-bearing distinction. Two **orthogonal** axes the
terminology historically fused:
- **Axis A — which native store?** A wallet has 3: `private_store_id`,
`protected_store_id`, `public_store_id`. Historic origin of "mono-store /
multi-store" (use 1 store vs the 3).
- **Axis B — how many documents in a store?** A store contains documents; the
document (= repo = `@graph`) is the sharing + rights boundary. The ReadCap —
hence isolation — is per-document.
`docCreate(sessionId, "Graph", "data:graph", "store", undefined)` targets the shared
wallet's private store. The trailing `store` arg left `undefined` targets the
private store (this is what `shared-wallet/account-registry.ts`'s `createDoc()` does). So every
document the shim creates physically lives in one store (private), and the
`public|protected|private` scope is a logical label tracked in RDF by the
shim — not a NextGraph store. Therefore what a consumer application's "multi-store"
flag switches on is really multi-document with logical scope labels, never
multi-store. Do not read `Scope` (`types.ts`) as a physical store — it is the
logical label the registry attaches.
> Why `undefined` and not a real store? Because `doc_create` **cannot target a
> non-private native store** today from the WEB build: `StoreRepo` is not constructible there (verified
> — see the parked `getNativeStore` note in
> [`migration-guide.md`](./migration-guide.md)). The private store is reachable
> because it opens without `RepoNotFound`.
## The shared-wallet shim (`shared-wallet/account-registry.ts`)
Emulates the target infrastructure — where each user owns their own
public/protected/private stores — on top of one shared wallet.
- **One document per (account × scope)** inside the shared wallet, created via the
`docs.docCreate` primitive. The `scope` (`public|protected|private`) is a
logical attribute tracked here, not a physical store.
- **The `sharedWalletShim`** is the mapping `account → its 3 scope-document
NURIs`. It is persisted as RDF, but **not directly in the store-root graph** — it
lives in a subscribable **doc-shim** reached through a write-once **pointer** in the
store-root, an indirection forced by a NextGraph fact: "findable-without-lookup"
(store-root) and "subscribable / cold-read-authoritative" (`did:ng:o:` repo with a
first-`State` barrier) are DISJOINT. The pointer (findable) names the doc-shim
(authoritative); resolution reads the pointer from the store-root, opens the doc-shim
through its barrier, and reads the account authoritatively — so a fresh reconnecting
session never mistakes sync-lag for "account absent" (which would provision a FORK).
Full rationale — including why the old account-level retry (`provisionRetry`) is
removed (pre-indirection store-root records are NOT recovered; such wallets are dev
data and simply get a fresh doc-shim) — is in
[`nextgraph-current-state.md`](./nextgraph-current-state.md) §§ *Findable vs
subscribable* / *The pointer → doc-shim indirection*. This map is the
account→document trust root, which is why every untrusted value that reaches its
SPARQL is escaped (see SPARQL hardening below). It makes identity resolution
cross-device: another device opening the same wallet reads the same pointer → the
same doc-shim → the same virtualUsers.
- **Per-entity documents + per-scope index.** `createEntityDoc(id, scope)`
makes a dedicated document for one entity (mirrors the target, where each entity
is its own document/repo with a future inbox) and appends its NURI to the
account's scope index document — the index doc plays the role of the future
store-container (it lists the entity-document NURIs "in" that scope).
`listMyEntityDocs(id, scope)` reads back ONE user's documents — bounded to that
user, and the only listing there is: the cross-account fan-out
(`listEntityDocs` / `resolveReadGraphs` / `allAccounts` / `loadShim`) was
**removed on 2026-07-30**, being cross-user enumeration by construction. The real read path is
`readUnion(docs)`, which reads the by-need doc set with one per-doc
anchored `sparql_query`, never an anchorless union-scan of the physical
wallet (see [`read-model.md`](./read-model.md)). The consumer application resolves
the by-need doc set from the current wallet's own scope index and
`listMyEntityDocs(id, scope)` (its own account, bounded — no cross-account fan-out).
- **Generic by construction.** The registry knows only the three native scopes,
zero application entity kind. The consumer application maps its entities to a scope
and injects the session + identity-id normalization via `configureStoreRegistry({
getSession, normalizeId })` (`polyfill.ts`).
The `store≠document` two axes materialize here directly: the registry moves along
axis B (more documents = more isolation), never axis A (it always writes into the
one private store via `docCreate(..., undefined)`).
### A virtual user's structure — the three emulated stores
A *virtual user* = one account in the shim, keyed by its virtual-wallet id
(the technical identifier the consumer application sets when the physical user is
opened; it identifies *which* virtual user, and is an id rather than a
human-friendly handle). Its structure mirrors the target "1 user = 1 wallet with 3
native stores":
```
Virtual user (id)
├── public store = docPublic index → [ entity doc NURI, entity doc NURI, … ]
├── protected store = docProtected index → [ record doc NURI, record doc NURI, … ]
└── private store = docPrivate index → [ record doc NURI, … ]
```
So the 3 native stores (public/protected/private) are present, but emulated: each
"store" is an index document
(`VirtualUserRecord.{docPublic,docProtected,docPrivate}`) that lists the NURIs of the
per-entity documents in that scope. It is not a physical native store.
Everything is physical in one place: the 3 index documents, every per-entity
document, and the shim anchor itself all live in the shared physical user's
private store (`docCreate(..., undefined)`). The 3-store structure is the per-account
logical layer the lib maintains on top.
```
Physical user (shared, one) → private_store (physical) holds everything:
• the shim anchor: virtual-wallet-id → { docPublic, docProtected, docPrivate }
• every account's 3 scope-index docs + all per-entity docs + inboxes
```
At migration each virtual user's 3 index documents become the user's 3 **real**
native stores, the entity documents move into them physically, and the
virtual/physical distinction dissolves (see [`migration-guide.md`](./migration-guide.md)).
### SDK-shaped scope resolvers — the consumer application holds no store-id
The consumer application must never construct a `did:ng:${store_id}` NURI itself:
physical placement is the lib's job (the whole point of the SDK boundary). Two
resolvers turn a logical scope into an opaque graph NURI without exposing any
store-id:
- **`resolveScopeGraph(scope)`** — the graph where the current session writes
entities of `scope`, and whose repo `useShape` subscribes to read them back.
Use the returned value as BOTH the read scope (`useShape(shape, nuri)`) and the
`@graph` write target. Placement lives HERE (Axis A): `private` → the private
native store; `public` + `protected` → the **protected** native store, because
`doc_create`/ORM cannot target a non-private/protected native store today (SDK
blocker, [`migration-guide.md`](./migration-guide.md)). At migration each scope
resolves to the user's real per-scope store — the change is in this function,
and the consumer application is unchanged.
- **`userInbox(id)` / `openDocumentInbox(doc)`** — an inbox BELONGS to someone. The
first is a user's own inbox (where Links arrive), the second a DEDICATED inbox for
one of its documents, opened on demand by its **owner only** (ownership read from the
Store branches — a received cap is not ownership, and a recipient must not be able to
redirect the owner's deposits to itself). Both are dedicated documents (real repo
NURIs from `docCreate`), never the private-store root: routing deposits into the shim
graph would bloat the account→document trust root without bound. `myInboxes()`
enumerates both levels — what `emulated-verifier/connect.ts` drains at connection — and `isOwnInbox`
answers from the same record. *(The former `resolveInboxAnchor`, a single inbox COMMON
to every user, was removed on 2026-07-30: nothing may be common but the mechanisms
that make the virtual users work.)*
- **`documentInboxAddress(doc)` — the DEPOSIT side, and the one a third party uses.**
Reading an inbox and finding where to deposit into it are opposite acts with opposite
audiences, and conflating them is what made per-document inboxes unusable at first:
resolution answered *"which inbox do I know for this document"*, so a depositor got
one of their own and their deposit vanished silently
([`briefs/2026-08-03-document-inbox-addressing.md`](./briefs/2026-08-03-document-inbox-addressing.md)).
A document that has an inbox carries its address on its emulated **Header branch** —
a reserved subject inside the document, so any holder of the document reads it, and
`read-model` filters the whole `urn:ng-eventually:` namespace out of consumer data
(`src/emulated-verifier/machinery.ts`). This mirrors upstream's split: a depositor seals with the inbox
PUBLIC key and needs nothing else, only the owner holds the private half.
**One inbox belongs to one document** — never several documents behind one inbox, a
relation upstream cannot express (the verifier routes by `inboxes: PubKey → RepoId`
and unseals with that repo's key, `engine/verifier/src/verifier.rs:1677,1928`), which
is also why a deposit carries no target document: the address identifies it. A fresh
document therefore has NO inbox and `documentInboxAddress` returns `undefined` — its
owner opens one when the document is meant to receive, which is what keeps the cost
proportional. At migration the address becomes the repo's native inbox pubkey and the
resolution moves; the consumer-facing act is unchanged.
- **`inbox.postToDocument(doc, { payload })`** — the one call an app makes to reach a
document's owner: it names the DOCUMENT, never an inbox. **Throws** when the document
has no inbox, rather than returning quietly: a deposit that vanishes without an error
is the exact bug this path shipped with.
Both resolve the native store ids from the injected session
(`RegistrySession.protectedStoreId` / `publicStoreId`, alongside the existing
`privateStoreId` anchor). The consumer application hands the whole session to the
lib at the one injection point (`configureStoreRegistry({ getSession })`) — that is
wiring, not placement logic; everything else in the consumer application speaks only
in scopes. If the session omits `protectedStoreId`, the non-private scopes fall back
to the private store rather than emit a broken NURI.
## `RepoNotFound` and the `orm_start_graph` scope rule
A hard constraint inherited from the SDK: to read **and** write entities through
the ORM, the store's repo must be **explicitly opened** in the verifier's
`self.repos` HashMap. `orm_start_graph` with a store's NURI opens that repo;
without it, `orm_frontend_update` fails with `RepoNotFound`.
- **Scope** for `useShape`: the store NURI, e.g. `did:ng:${privateStoreId}` (or,
in the consumer application, a per-user store once that migration happens).
- **`@graph`** (write target): the same store NURI.
- Never use `did:ng:i` as a scope: it subscribes to the user's whole site via
a special code path (`NuriTargetV0::UserSite`) that does not open individual
repos, breaking every write with `RepoNotFound`.
Both the private and the protected native stores were verified to open the same
way for ORM+SPARQL (round-trip probe, no `RepoNotFound`). The original arbitration
is preserved in [`decisions/private-store-nuri-scope.md`](./decisions/private-store-nuri-scope.md).
## The `@ng-org` double-proxy `DataCloneError` constraint
A validated hard constraint, not a style choice: `docs.ts` calls the real
injected `ng` (`getConfig().ng`) directly, never the public `ng` proxy
(`makeNg` in `surface/ng-proxy.ts`).
`@ng-org/web`'s `ng` is already an iframe-RPC proxy (postMessage marshaling,
see [`nextgraph-current-state.md`](./nextgraph-current-state.md) § integration).
Wrapping it in the lib's own JS `Proxy` (double proxy) breaks `doc_create`'s
postMessage marshaling with `DataCloneError: function ... could not be cloned`.
Reaching the real `ng` held in the config avoids the double-proxy. This was
verified: routing the shim's `doc_create`/SPARQL through the public proxy turned
4 multistore scenarios red, so it was reverted. The integration boundary is:
- **Through the lib's public proxy** (validated): `useShape` (ORM + ReadCap
filter), `init`/`initNg`, `login`.
- **Through the real injected `ng`** (`docs.ts` primitives): `doc_create` + all
shim/inbox SPARQL.
`docs.ts` therefore imports **no** `@ng-org` package and must **not** import from
`./ng-proxy`.
## Emulated ReadCap — per document (`emulated-verifier/caps.ts` + `emulated-verifier/read-filter.ts`)
In the target the broker only delivers documents the wallet holds a ReadCap
for, so `useShape` already returns an authorized subset. Here (single shared
wallet, everything readable) the lib reproduces that with a read-filtered view:
- **`CapRegistry` (`emulated-verifier/caps.ts`)** models a ReadCap as what it is: **the document's
key**. The access unit is the document = repo NURI (an item's `@graph`), never
the item — because in `nextgraph-rs` a store is just a container repo and holding
its cap does not grant the repos it references (no store-level read inheritance;
verified). The registry records, **per identity**, the caps it holds — `Map<Nuri, ReadCap>`
— and answers exactly one question: `capFor(nuri)`, *do I hold this document's
cap?* There is deliberately **no** "may principal P read document D": that is an
ACL question, and the real model cannot answer it either.
- **`nuri.ts`** carries the cap-less / cap-bearing distinction, which upstream is
one object (`NuriV0 { target, access }`) discriminated by the `:r:{cap}` segment.
`Nuri` names, `ReadCap` names *and* reads. Both are plain strings — the real SDK
takes `nuri: String` and enforces at runtime through cryptography, so a branded
type would be a concept NextGraph does not have. The stand-in key value is the
constant `OK` (see the module header): the only question the emulation answers is
*do I hold this cap or not*, so the value says exactly that and pretends nothing
more. P1b, not P1a, is the batch that turns the shape into a protection.
- **`emulated-verifier/read-filter.ts`** — `makeReadFilteredView` wraps the reactive set in a
`Proxy`: iteration / `size` / `forEach` keep only items whose `@graph` the
current holder holds; everything else (`add`, `delete`, `has`, `getById`…) forwards to
the target, preserving writes and reactivity. An item with no `@graph` is kept (it
names no document, so there is no cap to hold). `filterReadable` is the pure
variant. Note the absence of a `user` parameter — that absence *is* the model.
- **`useShape` (`surface/use-shape.ts`)** applies the view only once a cap exists at all
(`caps.isEnforcing()`) — before that it passes the real set through unchanged (no
regression for a consumer that never touches caps). Once ANY cap is issued the
regime is possession for **every** holder, including one who holds nothing:
that is the isolation.
In a mono-store layout (every item in one repo) this is all-or-nothing on that
document — exactly the native behaviour, and why fine-grained isolation requires
one document per entity (axis B).
### Where caps come from — stored, never derived
`doc_create` returns a **cap-less** NURI, so "no function ever goes from a bare
reference to a cap" cannot be the whole rule — it would lock a document's own creator
out of it. The real mechanism: creating a document commits `AddRepo { read_cap }` to
the store's **Store branch**, separately from the `ldp:contains` listing on its Main
branch. That is where an owner finds the caps of what it created; a cap RECEIVED for
someone else's document goes elsewhere, on the **User branch** (`AddLink`). The wallet
itself holds one key per user — the private store's read cap — from which the rest is
reached. Hence the invariant:
> **You do not derive a cap from a bare reference. You look it up in what you hold —
> or you were given it.**
Three ways a cap arrives, and there are no others:
- **Creation.** `createEntityDoc(id, scope)` writes the cap on the store's emulated
Store branch (`shim:readCap`) and the creator holds it. The consumer declares
nothing, and the cap is minted exactly ONCE — the stored value is the held value,
which is what keeps this correct when P1b makes the key real.
- **Re-listing.** `listMyEntityDocs(id, scope)` READS those records back. It does not
recompute anything: that is the whole reason for storing them, and it is what lets
a **fresh session** read its own documents again with nothing re-declared — the
durability the old in-memory ACL faked and lost every reload.
- **Delivery.** `inbox.share(doc, toUser)` deposits one document's cap into one
recipient's inbox; `inbox.read` applies it inline, exactly as the recipient's own
verifier applies queued messages upstream. **Receiving needs no operation** — a
consumer already watching its inbox gets them, and the resulting change
re-triggers the reads that were empty for want of that cap.
**The caps a holder holds are not the sharing mechanism.** Handing over a *store* cap would give
away everything the store contains, present and future. The unit of sharing is the
document; the Store branch is a private index.
Switching identity **switches** records — it never wipes one. If it wiped,
durability would be a lie and per-session re-declaration would come back under
another name.
### Sharing, publication, and the recipient
- **`setCurrentUser(id)` (`polyfill.ts`)** — the SDK's "current identity" call.
It selects *whose* caps are consulted, lazily, so the delivered subset always
reflects the identity in effect at read time.
- **`inbox.share(doc, toUser)`** — the one sharing act the lib exposes. Recipients
are addressed as **inboxes**, which `inbox.post(targetInbox)` already does here;
there is no `PrincipalId` in this surface, because that notion exists nowhere
upstream. Reaching several recipients means calling it once per inbox, which is
what the real model does too (each delivery is sealed to one recipient).
- **A document created in the `public` scope** needs no sharing act at all. The store
serves its ReadCap to whoever asks (`emulated-verifier/public-store.ts`, emulating
*"the latest ReadCap will be downloaded from the outerOverlay"* — `PublicRepoLinkV0`,
`engine/net/src/types.rs:5098`), so what an application circulates is the **bare
reference**, exactly as it will after migration. Never recursive: a public document
may reference private ones, and the reference grants nothing on what it references —
which is what lets a public object point at a private identity without disclosing it.
And never a write right: what the store serves is a read cap.
Upstream, directed delivery is a **gap, not a disagreement**: `ContactDetails.read_cap`
exists, but the message construction is `unimplemented!()`, its only caller passes
"without read_cap", and the receiver discards the cap. The shape is right; the
implementation is absent, so this lib emulates it meanwhile.
**Key rotation needs nothing on this surface.** A rotated key is re-sent to the
inbox of whoever keeps access, and that inbox is processed automatically at the next
connection — so access is not lost, it is *deferred*, consistent with local-first.
Same channel as the initial delivery, so there is **no subscription obligation** to
expose and no special case to write. Revocation stays what it is: stop re-delivering,
non-retroactive.
The relationship concept — who is "connected" to whom, and therefore whose documents
to share — is owned by the consumer application, not the lib. A connection or
friendship is not a NextGraph primitive; the only platform-mappable primitive is the
per-document cap delivery above.
The result is the target's discrimination reproduced end-to-end: you read the
documents whose caps you hold, and nothing else. Proven in
`test/isolation-active.test.ts` (a document nobody shared is unreadable; a share to
one inbox reveals it there and only there; a bare reference reads nothing while the
repo link opens the published document; a returning identity keeps its caps) and in
`test/cross-user-access.test.ts`, the acceptance test below.
This discrimination is only observable because each entity is its own document (the
consumer application creates per-entity docs via `createEntityDoc`) — in a mono-store
layout the per-document ReadCap is all-or-nothing.
### The acceptance test — no cryptography required
Alice owns a protected document holding a secret and a public one that carries a
**reference** to it. Bob, holding the public document's link, reads it, finds the
reference, and can NAME the protected document while reading nothing of it —
publication is **not recursive**. Charlie, holding the same link plus the protected
document's cap (delivered to his inbox), reads through the very same reference. The
only difference between them is each of them holds; nobody was named to any
registry. And dynamically: the cap lands in Bob's inbox, his client processes it, and
the read that was empty yields the content — the held-caps signal re-running it.
That is what real NextGraph does, and it holds **without a line of encryption** —
which is what makes the P1a (shape) / P1b (enforcement) split honest rather than
cosmetic. Proven in `test/cross-user-access.test.ts`.
> **After P1a the shape is right and the isolation is still fake.** The stand-in key
> is a constant, and several read paths (`docs.sparqlQuery`/`sparqlUpdate`, the whole
> inbox, `store-registry`, `subscribe`, `open-repo`) consult no cap at all — worse,
> any wallet can reach any document. That is the subject of
> [`briefs/2026-07-30-virtual-wallet-boundary.md`](./briefs/2026-07-30-virtual-wallet-boundary.md).
> Nothing may be claimed "anonymous" or "private" until it lands.
### Write-guard coverage (honest scope)
The emulated write guard (`surface/ng-proxy.ts`, `sparql_update` override) enforces the
per-document write cap on the public `ng` proxy only. In practice the
consumer application's write paths (`docs.sparqlUpdate`, ORM `ngSet`) call the real
injected `ng` directly — never the public proxy — for the validated `DataCloneError`
reason above. So the guard is best-effort: it fires for any write routed
through the public proxy, but the consumer application's real write paths bypass it
and are not guarded today. This is a deliberate, recorded limitation of the emulation
(the write guard becomes effective only when the broker/verifier enforces caps
natively at migration); the read side is what makes isolation observably active.
### The per-document ReadCap is the isolation path (item-level filter retired)
Isolation is enforced by the per-document ReadCap (`emulated-verifier/caps.ts` + `emulated-verifier/read-filter.ts`)
alone: the access unit is the document (`@graph` = repo), and the only acts are
possession-shaped (`createEntityDoc` files a cap, `inbox.share` delivers one, a public
store serves one to whoever asks). Because the consumer application writes
one document per entity, the per-document cap discriminates at entity granularity —
the target's behaviour.
The old item-level application-visibility filter (the since-deleted `isolation.ts`
`applyIsolation`, a `Set`-of-records filter keyed on owner+scope) is retired
from the consumer path: the application carries no access logic — it declares its
identity and shares caps, and trusts the SDK. Its matrix functions are
dead scaffolding kept for reference and removed at migration. There is no longer a
second, coexisting app-layer filter to reconcile — the single axis is the
per-document cap, exactly as in the target.
## Emulated inbox (`inbox.ts`)
Current NextGraph does not expose the inbox to the JS SDK (verifier has no
`InboxPost` arm; no wasm sealing helper — see
[`nextgraph-current-state.md`](./nextgraph-current-state.md) § Inbox). Rather than
fork the broker ([`fork-inbox-fallback.md`](./fork-inbox-fallback.md)), the lib
emulates the inbox on the shared wallet:
- **Target vs polyfill.** In the target, `post` seals a reference into the owner's
native inbox — through a JS call that **does not exist and is not announced** — and the recipient's
own verifier unseals each queued message and applies it inline when it processes
its inbox — there is no separate curator or materialization process. Here,
everything is readable, so the lib emulates the read side in-lib.
- **`post(targetInbox, opts)`** appends a deposit `{ from, payload, ts }` as RDF
into the inbox document (in the shared wallet) via `docs.sparqlUpdate`. Each
deposit is a unique RDF subject, so concurrent deposits don't collide. `from` is
bound to the current identity (`getCurrentUser`) — it is authenticated, not
caller-supplied: omit it to stamp the current user, pass `null` to deposit
anonymously, and a `from` naming another principal is rejected as a spoof.
This reproduces the protocol's "identified if known, anonymous otherwise" and
the target's guarantee that a client cannot forge another's sender identity (in
the target the broker seals `from` from the wallet's own key; here the check
closes the spoof the shared wallet would otherwise allow). The emulation stores
`from = null` as *absence of a triple*, so it does not provide the target's
crypto anonymity (`from = None` sealed), which only a native inbox would.
Proven in `test/inbox.test.ts` case (c).
- **`read` / `materialize` (alias)** emulate the recipient-side read: they read the
deposits back via `docs.sparqlQuery`, JSON-parse each payload, sort by `ts`.
- **`watch(targetInbox, onDeposits, { intervalMs })`** is the emulated watcher: it
polls `read` and fires when the deposit count changes (the polyfill has no
reactive inbox subscription). Fires once immediately; returns an unsubscribe.
### An inbox BELONGS to a virtual user (2026-07-30)
`storeRegistry.userInbox(id)` resolves — creating on first sight — the inbox
document of one virtual user, recorded in the doc-shim under `shim:docInbox` and
read by its own query (so an account written before this existed still resolves).
The asymmetry that matters:
- **Depositing into anyone's inbox is open.** It is the ONLY way a link crosses
from one wallet to another, and since you cannot discover, it is the bootstrap of
the whole reachability graph. A deposit grants the depositor nothing in return —
upstream it is an anonymous sealed box.
- **Reading an inbox is confined to its owner** (`isOwnInbox`, enforced in `read` /
`readSynced`, hence in `watch`). Since P1a routes ReadCaps through deposits, an
unguarded read let anyone who knew an inbox NURI collect the caps addressed to its
owner — defeating directed sharing. Anonymous owns no inbox and reads none.
At migration this guard disappears into cryptography: an inbox is sealed to its
owner's key.
The module knows no domain — the consumer application supplies the inbox document
NURI and interprets `payload`. At migration `post` becomes the native sealed
deposit — **whose JS name and signature are not known**, since none is exposed or
announced — and the read side is served by the recipient's own verifier unsealing
queued messages inline.
The inbox + watcher is the one deposit/read mechanism a consumer reuses for its own
purposes — a registration/deposit, a cap delivery (`inbox.share`), a link handed to
someone — same `post` API, same watcher.
## The virtual user boundary (`emulated-verifier/reach.ts` + `shared-wallet/physical.ts`)
Every access function is confined to the user currently connected: no cross-user
access, so the consumer is coded against a reach that will actually exist.
**Two rules, one criterion — possession — implemented in two places**, deliberately
redundant so a lapse in either is caught by the other:
- **Rule 1, authorization** (`assertMayReach`, at the passage points `docs.sparqlQuery`
/ `sparqlUpdate` / `subscribeDoc`): nothing reaches `ng` unless the connected user
possesses that document's cap. It fires on a request that should never have been
made, and makes it fail loudly rather than succeed quietly.
- **Rule 2, do not even attempt** (`mustNotAttempt`, at the callers — `readUnion`
filters before opening or reading, `ensureRepoOpen` returns): a reader holding no
cap does not issue the operation at all. Upstream you cannot even *address* a repo
you have no cap for, so asking is not "a read that will be refused" — it is a read
with no meaning.
**Possession decides, never the shape of the reference in hand.** A caller
legitimately holds a bare NURI while possessing its cap elsewhere — references travel
bare through content and stores, the cap sits in what the user holds.
The exception is **depositing** into another user's inbox (`docs.depositInto`): a
named primitive rather than a flag, because it is a different act — you hold no cap,
you cannot read back, and you get nothing in return. It is the only channel by which
a link crosses between users, hence the bootstrap of the whole reachability graph.
The machinery lives in `shared-wallet/physical.ts` (see *Physical user vs virtual user* above):
unguarded primitives, never exported from the package, used only for the index of
virtual users. Separating the FUNCTIONS is what replaced an earlier exemption list —
the machinery does not get waved through the guard, it calls something the guard
never saw.
## Connecting a user (`emulated-verifier/connect.ts`)
Processing inboxes is the **library's** job, not the app's: a consumer must never
have to remember to drain a queue for documents shared with it to become readable —
forgetting would look like "the share did not work" rather than "nobody consumed the
queue". So `setCurrentUser` fires `connectedUser()`, which does two things in order:
1. **Restore** — read back the caps this user already applied (`readLinks`, the
emulated `AddLink` records on its User branch) into what it holds. Durable state,
one read, no inbox involved.
2. **Drain** — process every inbox it may read (`myInboxes`: its own, plus one per
document it opened an inbox on), filing any new Link durably.
Restore-first is what lets a reconnecting user read its shared documents immediately
instead of waiting on a queue round-trip.
**Fire-and-forget, deliberately.** The setter is synchronous and every consumer calls
it from synchronous code; making it async would push the wait back onto the app,
which is the obligation this removes. The work announces itself through
`CapRegistry.onChange` — which `watchShape` already listens to — so a view that was
empty for want of a cap re-reads when the cap lands. `connectedUser()` is exported
for a caller that needs to await it (tests, a deterministic startup).
**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 their caps as a background side effect, arming the whole emulation
at a moment nothing controls.
*Cost worth knowing*: `setCurrentUser` therefore has observable asynchronous effects
— it reads, and it logs. Tests asserting on log output must await `connectedUser()`
first.
## ~~Emulated discovery index + special account~~ — REMOVED 2026-07-30
**There is no discovery in NextGraph. You cannot discover; you can only follow links** (see [`readcap-and-nuri-model.md`](./readcap-and-nuri-model.md) §4ter-bis). Publishing is two acts — place the data in your public store, **and** circulate its link (into an inbox, or into a document the reader already holds) — and it is seen only by those who received the link.
`discovery.ts` (a global index owned by a reserved `@index` account, `submitToIndex` / `readIndex` / `watchIndex`), its tests, and `watchShape`'s public-scope fold were **removed**. The module failed on two independent counts: it emulated a capability the target will never have — teaching consumers a model that does not exist — and it was **data common to several wallets**, where nothing may be common but the indexing mechanisms that make the virtual users work.
The ADR that specified it ([`decisions/discovery-model.md`](./decisions/discovery-model.md)) is marked superseded, and keeps the part that survives: the `discovery → synchronization → query` frame still holds, with stage 1 re-read as *"a link reached you"* rather than *"you consulted an index"*. Which makes the **inbox** the bootstrap of the whole reachability graph — see [`briefs/2026-07-30-virtual-wallet-boundary.md`](./briefs/2026-07-30-virtual-wallet-boundary.md).
## Emulated write guard (`surface/ng-proxy.ts`)
The public `ng` proxy overrides `sparql_update` to enforce an emulated write
cap: a write is refused unless the current user holds the target document's
write cap. It passes through (no regression) unless a write policy exists and that
specific document (the `anchor` arg) is governed by it — ungoverned docs (the
mono-store default, no cap declared) flow through unchanged. This mirrors the target
broker/verifier, which refuses a write without the document's write cap.
## Identity store (`shared-wallet/virtualUsers.ts`)
The real NextGraph login (redirect to the broker, opening the single shared
wallet) is perceived as a technical access barrier (see the login
flow in [`decisions/shared-wallet-login-flow.md`](./decisions/shared-wallet-login-flow.md)).
This layer is not a login: it is an `IdentityStore` that holds the current
identity id the consumer application relays to it:
- The identity id is set at wallet-import time by the consumer application and
relayed to the lib via its current-identity call. It is persisted in
`localStorage` so the id survives reloads and lands on the same account
when the shared wallet re-opens. In practice the id is often a human-friendly
handle the consumer application chose, but the lib's surface speaks only of an id.
- `set(id)` / `clear()` / `get()` only read/write the id in storage. They never
call NextGraph (no `session_stop` / `wallet_close`) — the shared wallet stays
open underneath. The real logout lives elsewhere (hidden in the consumer
application's settings/debug), because it forces a new redirect.
- Framework-agnostic: no React, no DOM beyond an optional injected
`VirtualUserStorage` (a `window.localStorage`, a test fake, or `null` for SSR). The
React `Context`/`Provider` stays in the consumer application. `normalizeId`
(case-insensitive, optional leading `@` stripped, trimmed) is the pure
normalizer, reusable as the shim key normalizer.
## SPARQL injection hardening (`sparql.ts`)
Every module that builds SPARQL by interpolation (inbox, store-registry) routes
untrusted values through `sparql.ts` first, because a `"` closes a literal and a
`>` closes an IRI, letting an injected value wreck the shim graph (the account →
document trust root):
- **`escapeLiteral`** — for LITERAL position (`"..."`): escapes backslash,
double-quote, C0 whitespace. Lossless (literals legitimately carry arbitrary
text — JSON payloads, display names).
- **`escapeIri`** — for untrusted values embedded into an IRI (`<PREFIX:${…}>`,
e.g. an identity id minted into an account-subject IRI): percent-encodes every
IRI-hostile character so any id (spaces, unicode, punctuation) stays
usable while breakout is impossible.
- **`assertNuri`** — for trusted-shaped NURIs coming back from `ng`
(`did:ng:...`): validates and throws on IRI-breaking chars rather than emitting
a malformed/injected query.
These are re-exported from `@ng-eventually/client` so the consumer application
reuses the same escaping when it builds SPARQL.