Files
ng-eventually/docs/migration-guide.md
T
Sylvain Duchesne ae9c32e271 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.
2026-08-03 11:22:01 +02:00

7.0 KiB

Migration guide — when real NextGraph matures

The whole point of this library: the consumer application already writes SDK-shaped code, so when NextGraph ships cross-wallet reads, capabilities and inboxes, only this lib changes. The consumer application's code does not change. This is the checklist.

Guiding invariant

Every emulated piece has a 1:1 image in the real infra. Migration = swap the emulation for the real primitive, remove the scaffold. If a piece of the emulation has no clear target image, that is a drift signal (see simulation.md).

Checklist

1. Emulated ReadCaps → real capabilities

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 private store, and the public|protected|private scope is a logical label in the shim (see the two-axes section in simulation.md).

  • doc_create cannot target a non-private native store today — verified: StoreRepo is not JS-constructible from the SDK, so there is no way to pass a public/protected store as the create destination (docCreate's trailing store arg is left undefined → private store). The private store works only because it opens without RepoNotFound.
  • When the SDK lets you construct/target a native store, the migration adds a getNativeStore(scope)-style resolver returning the real store to pass as the docCreate destination, so the logical scope label becomes a real store placement. (No such helper exists yet — it is blocked on the SDK gap above.)
  • 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, listMyEntityDocs, resolvers) is designed to survive that swap unchanged.

3. Drop the resolver / shim

The sharedWalletShim (account → 3 scope-document NURIs, held in a subscribable doc-shim reached via a write-once pointer in the store-root — see nextgraph-current-state.md § The pointer → doc-shim indirection) has no target equivalent — the target has no central directory. Remove it entirely: store-registry.ts, configureStoreRegistry, the pointer + doc-shim resolution, and the pointerGuard dep. Cross-wallet reads replace the fan-out; per-user wallets replace the shared one.

4. Real inbox → drop the in-lib read emulation

Replace the emulated inbox.ts deposit (docs.sparqlUpdate into a shared-wallet document) with the native inbox_post_link (proposed/future). On the read side the recipient's own verifier unseals each queued sealed message and applies it inline when it processes its inbox — there is no separate curator to build; the in-lib read emulation simply goes away (see the deferred global-index note in the top-level README and decisions/discovery-model.md). The single global index replaces the cross-account fan-out.

5. Retire the identity store → real per-user login

Remove accounts.ts (the IdentityStore that persists the identity id in localStorage) and the app-level "Connexion" screen. The technical broker gate becomes the real per-user login (see decisions/shared-wallet-login-flow.md). The flow shape ("broker redirect → app") does not change.

6. Drop the isolation scaffold

isolation.ts (application-visibility scaffold) disappears against a different piece of infra than the caps: real per-account wallets, and the relationship concept the consumer application owns. Distinct axis from ReadCaps — remove independently.

7. Remove the build alias — the client becomes the real SDK

The consumer application imports @ng-org/web / @ng-org/orm resolved to this lib via a build alias during the polyfill period. Removing the alias makes those imports resolve to the real SDK — the ng/useShape/inbox surface is SDK-identical, so 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 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 library's reason to exist.