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
+43 -9
View File
@@ -14,13 +14,33 @@ has no clear target image, that is a drift signal (see
## Checklist
### 1. Emulated ReadCaps → real capabilities
Translate the per-document `CapRegistry` (`caps.ts`) into real NextGraph caps: the
broker/verifier enforces them, and `useShape` already returns only authorized
documents. The directed `grantRead(doc, granteeId)` maps to a native per-document
ReadCap issued to that identity. The read filter (`read-filter.ts`) and the write
guard (`ng-proxy.ts` `sparql_update` override) are then dead code — remove them. The
access unit is already the document (`@graph`), matching the native per-repo cap
model, so this is a data step, not a reshape.
The shape is already the target's (P1a): a `ReadCap` is the document's key, a
each identity holds a set of caps, and there is no read-ACL anywhere. So
this step swaps the *emulated* key for the real one, not the model:
- `caps.ts`'s per-identity record becomes the verifier's own local user storage —
it was always the cache, not the register. The two durable registers we emulate
(`readCap` on the store's Store branch, `link` on its User branch) become the real
`AddRepo` / `AddLink` commits. Remove the emulation; the wallet and the branches
already hold them.
- `nuri.ts`'s stand-in cap value — the constant `OK` — becomes the real
`r:{base64url(serde_bare(ObjectRef))}`. It is **one function** (`mintCap`), because
every path now READS a stored cap instead of recomputing one. `hasReadCap` /
`targetOf` stay meaningful: the `r:` discriminant is upstream grammar, not ours.
- `shareCap(cap, toInbox)` becomes the native sealed delivery (`inbox_post_link`
and `ContactDetails.read_cap`), and `inbox.read`'s inline absorption becomes the
recipient's own verifier applying queued messages. **The consumer's call does not
change.**
- `publishRepoLink` becomes `RepoLinkV0`.
- The read filter (`read-filter.ts`) and the possession gate in
`read-model.readUnion` are then dead code — the broker only delivers documents
whose cap the wallet holds. Remove them.
- The write guard (`ng-proxy.ts` `sparql_update` override) is a separate axis and
is decorative today (every internal writer bypasses the proxy); it belongs to the
P1b batch, not here.
The access unit is already the document (`@graph`), matching the native per-repo cap
model, so this is a key-material step, not a reshape.
### 2. Place documents in real native stores
Today `docCreate(..., undefined)` writes every document into the shared wallet's
@@ -39,7 +59,7 @@ in the shim (see the two-axes section in [`simulation.md`](./simulation.md)).
- At that point `store-registry.ts` maps `(account, scope)` to the user's real
store NURI instead of a document in the shared wallet; the per-scope index
document (the store-container emulation) is replaced by the store itself. The
surface facing the consumer application (`createEntityDoc`, `listEntityDocs`,
surface facing the consumer application (`createEntityDoc`, `listMyEntityDocs`,
resolvers) is designed to survive that swap unchanged.
### 3. Drop the resolver / shim
@@ -80,9 +100,23 @@ resolve to the real SDK — the `ng`/`useShape`/`inbox` surface is SDK-identical
no consumer code changes. The one non-SDK call — `configure(...)` /
`@ng-eventually/client/polyfill` — is deleted. The lib itself disappears.
## The one break already taken: `declareConnections`
P1a broke the consumer once, deliberately and early, so that migration would not.
The old surface was an ACL held in memory, which forced the consumer to re-declare
every grant on every session (`declareConnections`). That call **disappears**: with
delivered caps the grant moves to the moment a connection is *accepted* — one
`shareCap(capFor(doc), theirInbox)` per document shared — and it persists, because
the delivery lives in the recipient's inbox rather than in a map that empties at
reload. There is no analogue of `protectedDocsOf` + the re-derivation loop.
This is a consumer **re-architecture**, not an API swap, and it is the price of
being coded against a model that will exist. Nothing else about the migration below
touches consumer code.
## What does not change
The consumer application's code. Shapes, screens, the *acts* of granting
The consumer application's code. Shapes, screens, the *acts* of sharing
access, entity→scope mapping, the relationship graph — all injected, all untouched.
Migration is entirely inside this library plus removing the alias + the bootstrap
call. That asymmetry — a mature SDK face outward, all compensation inward — is the