3257afe8c0
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.
642 lines
40 KiB
Markdown
642 lines
40 KiB
Markdown
# 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.** `shareCap(cap, toInbox)` 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.
|
||
- **`shareCap(cap, toInbox)`** — 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).
|
||
- **`getCaps().publishRepoLink(doc)`** — upstream `RepoLinkV0`: a shareable link
|
||
**whoever receives it** can open. Put the *link* in what you make discoverable, not
|
||
the bare NURI, or no reader can open it. Publication is **not 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.
|
||
|
||
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, `shareCap` delivers one,
|
||
`publishRepoLink` emits an openable link). 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 (`shareCap`), 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.
|