Align the cap emulation on NextGraph's model, and confine it to a virtual user

Two batches, verified against nextgraph-rs throughout.

P1a — the capability surface. Reading was an ACL (Map<doc, Set<principal>>),
the exact inversion of key possession. It is now possession: `capFor(nuri)` is
the only question, there is no principal parameter anywhere, and nothing turns
a bare reference into a cap. Sharing is `shareCap(cap, toInbox)`, a Link
deposit; receiving needs no operation. `Nuri` and `ReadCap` are template
literal types, so passing a bare reference where a cap belongs is a compile
error, with runtime guards behind it for JavaScript callers.

The virtual user boundary. Every access function is now confined to the
connected user, through two rules on one criterion (possession), implemented in
two places so a lapse in either is caught by the other: authorization at the
passage points, and "do not even attempt" at the callers. The polyfill's own
machinery moved to physical.ts — unguarded, never exported — which replaced an
exemption list: the machinery no longer gets waved through the guard, it calls
something the guard never saw.

Removed, as emulating capabilities the target does not have:
- discovery.ts and its global index. There is no discovery in NextGraph; you
  follow links. It also pooled user data across wallets.
- the cross-account fan-out (listEntityDocs, resolveReadGraphs, allAccounts,
  loadShim), which was cross-user enumeration by construction.
- resolveInboxAnchor, a single inbox common to every user.

Caps are now stored where NextGraph stores them, and read back rather than
recomputed: AddRepo on the store's Store branch for documents a user creates,
AddLink on its User branch for caps received. Inboxes belong to someone — the
user's own, plus one per document — and connecting a user drains them all;
that is the library's job, not the app's.

Corrections worth recording: a ReadCap is `r:`, not `:k:` (reported by
NextGraph's developer, verified in BlockRef::readcap_nuri); received caps DO
have a register (AddLink), contrary to what this repo's notes claimed; and
"wallet" upstream means keyring — what owns three stores is a user, so the
vocabulary follows.

The cap value is the constant OK: the only question the emulation answers is
whether a cap is held. P1b replaces that one constant with a real key.

After this the shape is right and the isolation is still fake. Nothing here may
be described as anonymous or private.
This commit is contained in:
Sylvain Duchesne
2026-08-03 11:22:01 +02:00
parent 6f0d0586e2
commit ae9c32e271
51 changed files with 4245 additions and 1543 deletions
+276 -166
View File
@@ -32,34 +32,45 @@ application fiction the lib maintains. On top of that one wallet the lib rebuild
by emulation, the per-user stores + capabilities + inbox the consumer application
codes against.
## Physical wallet vs virtual wallet — never enumerate the physical one
## 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:
- **Physical wallet** — the real NextGraph wallet everyone opens. Its local store
holds every account's documents plus the lib's own internals (the shim index,
the inbox docs, the discovery index) as named graphs. It accumulates without
bound across sessions/runs. Listing or scanning "all documents" of the physical
wallet is meaningless and O(size) it mixes every user's data with lib internals,
and it is exactly what a `sparql_query` with no anchor (`GRAPH ?g { … }`) does
(it spans every synced graph). The physical wallet is a substrate,
not something to enumerate.
- **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.
- **Virtual wallet** — the lib's emulation of one user's wallet: the set of
documents the shim attributes to that account (its per-scope index in
`store-registry.ts`). This is what "the user owns". Over a *virtual* wallet,
"list my documents" is meaningful and bounded (only that account's docs).
- **A virtual user** — the library's emulation of one identity: the documents the
shim attributes to that account (its three store documents in
`store-registry.ts`). This is what "the user owns", and over it "list my
documents" is meaningful and bounded.
**Consequence for reads (see `read-model.md`):** to list a user's entities you
enumerate the *virtual* wallet — the account's scope index (bounded, O(my docs)),
not the physical union — then read those specific documents with a per-doc anchored
`sparql_query`. A non-empty / bloated physical wallet then costs nothing, because the
physical union is never scanned. Discovery (all public events) is the one bounded
enumeration hack and goes through the discovery index, not a physical scan.
**Which API you use follows that line, and it is enforced** (see `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.
At migration each virtual wallet becomes a real per-user wallet; the physical/virtual
distinction — and the "never enumerate the physical wallet" rule — dissolves into
native per-wallet reads.
**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
`physical.ts` all dissolve into native per-user reads.
## Two axes, never conflate them (store ≠ document)
@@ -120,15 +131,14 @@ public/protected/private stores — on top of one shared wallet.
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).
`listEntityDocs(scope)` unions the contained NURIs across all accounts. This is a
fallback / test-only path, not the read path: enumerating every account and
handing the NURIs to `useShape({ graphs })` opens/syncs other accounts' possibly-
unsynced docs and hangs (the ORM fan-out — see
[`read-model.md`](./read-model.md)). The real read path is
`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
`readModel.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 discovery index (public events) and
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
@@ -139,16 +149,16 @@ The `store≠document` two axes materialize here directly: the registry moves al
axis B (more documents = more isolation), never axis A (it always writes into the
one private store via `docCreate(..., undefined)`).
### A virtual wallet's structure — the three emulated stores
### A virtual user's structure — the three emulated stores
A *virtual wallet* = one account in the shim, keyed by its virtual-wallet id
(the technical identifier the consumer application sets when the physical wallet is
opened; it identifies *which* virtual wallet, and is an id rather than a
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 wallet (id)
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, … ]
@@ -160,17 +170,17 @@ So the 3 native stores (public/protected/private) are present, but emulated: eac
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 wallet's
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 wallet (shared, one) → private_store (physical) holds everything:
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 + inbox + discovery index
• every account's 3 scope-index docs + all per-entity docs + inboxes
```
At migration each virtual wallet's 3 index documents become the user's 3 **real**
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)).
@@ -190,16 +200,16 @@ store-id:
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.
- **`resolveInboxAnchor()`** — the anchor where emulated inbox deposits land: a
dedicated inbox document (a reserved account's public scope document, from
`docCreate` — a real repo NURI, stable across clients), not the shared
wallet's private-store root. Why dedicated: the shim (the account→document trust
root) lives in the private-store graph and is scanned on every `loadShim`;
routing every inbox deposit into that same graph bloats it without bound
(thousands of deposit triples across sessions), turning `loadShim` into a
multi-second full-graph scan. A separate inbox document keeps the shim graph
small and the deposits isolated. At migration it becomes the host's native
inbox NURI.
- **`walletInbox(id)` / `documentInbox(doc)`** — an inbox BELONGS to someone. The
first is a virtual user's own inbox (where Links arrive), the second the inbox of
one of its documents, created on first ask. 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 `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.)* At migration these
become native per-document inboxes.
Both resolve the native store ids from the injected session
(`RegistrySession.protectedStoreId` / `publicStoreId`, alongside the existing
@@ -255,77 +265,142 @@ 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` (`caps.ts`)** models ReadCaps as faithfully as a data layer
can. 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). So the registry is purely per-document:
`grantRead(doc, granteeId)` issues a directed read grant to one identity,
alongside `grantWrite` / `makePublic` / `open(doc, scope, owner)` /
`canRead` / `canWrite` / `governsRead` / `hasReadPolicy`, plus the read-only
accessor `protectedDocsOf(owner)` the consumer application uses to pick which
protected docs to grant. The consumer application performs the *acts* of granting
(create-public, grant a specific doc to a specific identity…) exactly as it
will in the target; the lib injects no policy.
- **`CapRegistry` (`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.
- **`read-filter.ts`** — `makeReadFilteredView` wraps the reactive set in a
`Proxy`: iteration / `size` / `forEach` are filtered by
`caps.canRead(item['@graph'], user)`; everything else (`add`, `delete`, `has`,
`getById`…) forwards to the target, preserving writes and reactivity. An item
with no `@graph`, or in a document under no cap policy, is kept (the filter only
restricts documents that *declare* a cap — no regression on ungoverned data).
`filterReadable` is the pure variant.
- **`useShape` (`use-shape.ts`)** applies the view only if
`caps.hasReadPolicy()` — otherwise it passes the real set through unchanged
(no regression when the consumer application declares no caps).
`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` (`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).
### Making the ReadCap active — current identity + directed grants
### Where caps come from — stored, never derived
The filter only discriminates once the consumer application (a) tells the SDK who is
reading and (b) declares the access policy on the documents. Both are plain SDK
calls; the consumer application never touches the registry internals:
`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.
`useShape`'s filtered view reads it lazily, so the delivered subset always
reflects the identity in effect at read time. Until it is set, the filter has no
principal and (per `canRead(doc, null)`) only public documents pass — which is
why isolation stays dormant until the consumer application makes this call.
- **`getCaps().open(doc, scope, owner)`** — declares a document's policy when the
consumer application creates it: `public` → world-readable; `protected`/`private`
→ owner reads, owner holds the write cap. `open` also remembers `(scope, owner)`
per document so `protectedDocsOf(owner)` can later enumerate the protected ones.
- **`grantRead(doc, granteeId)` (`caps.ts`, exposed via `getCaps()`)** — the one
relationship-shaped sharing act the lib exposes: a directed per-document read
grant issued to a specific identity. Public docs stay world-readable; private
docs stay owner-only; a protected doc becomes readable by `granteeId` once the
owner grants it. The consumer application passes a document NURI and a grantee id
— no store id.
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.
The relationship concept — who is "connected" to whom, and therefore which of
their protected docs to grant — 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 directed per-document read grant above. So the consumer application
decides a relationship exists and, for each protected doc it wants to share, calls
`grantRead(doc, granteeId)` — typically iterating `protectedDocsOf(owner)` to pick
the owner's protected docs. The intended target of such a directed grant is a native
per-document ReadCap issued to that identity — but that target is itself
scaffolding-only in nextgraph-rs today, not merely unexposed in JS: `AccessGrantV0
{grantee}` is unpersisted and cap-send is `unimplemented!()`, so directing a grant
to another identity is not-yet-built at the platform level. There is no bilateral
capability exchange to mirror, only (eventually) individual directed grants.
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.
The result is the target's discrimination reproduced end-to-end: private →
owner; protected → owner + whoever the owner has directly granted; public → all.
Proven in `test/isolation-active.test.ts`: an unconnected principal is denied a
protected document, granted it after the owner issues a directed `grantRead`, and
reads the public document throughout.
**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.
This discrimination is only observable because each entity is its own document
(the consumer application creates per-entity docs via `createEntityDoc` and `open`s
each) — in a mono-store layout the per-document ReadCap is all-or-nothing.
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/watch-shape.test.ts` (e), 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 what what they hold 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)
@@ -342,16 +417,16 @@ 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 (`caps.ts` + `read-filter.ts`)
alone: the access unit is the document (`@graph` = repo), and grants are explicit
(`open` / `grantRead` / `makePublic`) — for `protected`, the owner issues a directed
`grantRead(doc, granteeId)` per identity it wants to share with. Because the consumer
application now writes one document per entity (`createEntityDoc` + `open` per entity),
the per-document cap discriminates at entity granularity — the target's behaviour.
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 (`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 issues directed grants, and trusts the SDK. Its matrix functions are
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.
@@ -388,69 +463,104 @@ emulates the inbox on the shared wallet:
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.walletInbox(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
`inbox_post_link` (proposed/future) and the read side is served by the recipient's
own verifier unsealing queued messages inline (see the deferred global-index note in
the top-level README and [`decisions/discovery-model.md`](./decisions/discovery-model.md)).
own verifier unsealing queued messages inline.
The inbox + watcher is the one deposit/read mechanism a consumer reuses for its own
purposes — e.g. a registration/deposit in one consumer app and submission to a
discovery index — same `post` API, same watcher.
purposes — a registration/deposit, a cap delivery (`shareCap`), a link handed to
someone — same `post` API, same watcher.
## Emulated discovery index + special account (`discovery.ts`)
## The virtual user boundary (`reach.ts` + `physical.ts`)
Discovery is a surface on top of the inbox, not a new primitive. Access is not the
same as discovery: a public entity is world-readable *with its NURI*; the discovery
index is how a client learns that NURI exists without holding a relationship
to its creator (see [`decisions/discovery-model.md`](./decisions/discovery-model.md)).
The model is: one global index = an owned document (public read), fed via
its inbox. Nobody writes the index directly — a creator deposits a reference into
the index's inbox, and the index is built up from those deposits. That build-up
step is the natural dedup / moderation point.
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:
- **The special account (polyfill owner).** "Who owns the global index" is
undecided in the target (NextGraph is mono-user with no global data — a
singleton app is the only glimpsed path). So the polyfill parks ownership on a
reserved special account in the shim — `INDEX_ACCOUNT = reservedAccount("index")`.
This is NOT the key `"index"` / `"@index"`: `reservedAccount` mints a
sentinel-prefixed key in the shim's reserved namespace (e.g. `" reserved:index"`)
that `normalizeId` can never produce, so no user id — not even one typed as
"index" or "@index", which normalizes to the disjoint key "index" — can collide
with or hijack the index account (asserted in `discovery.test.ts`). It is a
normal shim account (so its 3 scope documents are created on first sight like
any other), but never a real user; it only hosts the index document. Its
`public` scope document is the index document, and its inbox receives the
deposits — a stable NURI: every client opening the same shared wallet
resolves the same account, hence the same document, so all clients read/write one
shared index.
- **`submitToIndex(ref, opts?)`** — the SDK act "make this discoverable".
Deposits `ref` into the index document's inbox via `inbox.post`. `from` follows
the inbox convention (bound to the current identity; anonymous when `null`).
`ref` is opaque here — the consumer application serializes whatever locates the
entity (e.g. an entity document NURI + discovery metadata). Public-only guard: when
`opts.doc` names the document being surfaced, a document under a non-public
(protected/private) read policy is refused (`caps.governsRead(doc) &&
!caps.canRead(doc, null)`) — the global index is world-readable, so admitting a
governed doc's NURI would leak it past its scope. Proven in
`test/discovery.test.ts` case (d).
- **`readIndex()`** — the emulated read side. Reads every submission, dedups by
serialized `ref` (the moderation point: a duplicate submission surfaces
once), returns entries sorted by `ts`. `watchIndex(onEntries, opts?)` is the
emulated watcher (polls `readIndex`).
- **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.
This replaces the cross-account fan-out (`store-registry.ts`
`listEntityDocs('public')` / `resolveReadGraphs`) as the app-facing discovery
path: the consumer application submits public entities to the index and reads the
index, instead of fanning out over every account's public documents. The fan-out
survives only as an internal lib fallback — kept for the per-scope listing it also
powers (e.g. `resolveReadGraphs`), never the app's discovery route.
**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.
`discovery.ts` knows no application domain — the consumer application defines the
`ref` shape and its meaning. At migration the special account disappears:
ownership moves to the decided global-index owner, `submitToIndex` becomes the
native `inbox_post_link` (proposed/future) on the index's inbox, and `readIndex`
queries the real index document. The consumer surface (`submitToIndex` / `readIndex`)
is designed to survive that swap unchanged.
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 `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 (`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 (`ng-proxy.ts`)