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:
+276
-166
@@ -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`)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user