ae9c32e271
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.
617 lines
38 KiB
Markdown
617 lines
38 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
|
||
`store-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 `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
|
||
`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 `store-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: `StoreRepo` is not JS-constructible (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 (`store-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 accounts.
|
||
- **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
|
||
`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 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
|
||
(`AccountRecord.{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.
|
||
- **`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
|
||
`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 `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 (`caps.ts` + `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` (`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` 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).
|
||
|
||
### 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/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)
|
||
|
||
The emulated write guard (`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 (`caps.ts` + `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 (`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 (`inbox_post_link(...)`, a proposed/future API) 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.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.
|
||
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 (`reach.ts` + `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 `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`)
|
||
|
||
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 (`accounts.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
|
||
`AccountStorage` (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.
|