La correction de nomenclature du 2026-07-30 — en amont un *wallet* n'est qu'un trousseau, ce qui possède des stores est un **user** (un *site*) — s'était faite à la main. `walletInbox` y a échappé et a vécu des semaines, en faisant des dégâts : le nom rendait « une inbox par wallet » évident, masquant qu'un user en a **deux** en amont (repos de store public et protected, les deux seuls `AddInboxCap` du moteur). Une discipline appliquée à la main en oublie un ; un test non. D'où `test/vocabulary.test.ts` : tout nom publié est bâti sur des mots que la CIBLE emploie — vérifiés dans `nextgraph-rs` — ou porte un marqueur disant POURQUOI il n'existe qu'ici (`virtual`, `physical`, `shim`, `emulated`, `polyfill`), ce qui dit aussi quand il disparaît. Un échec n'est pas « renommer pour faire passer le test », c'est une question : la cible a-t-elle un mot pour ça ? la chose n'existe-t-elle qu'ici ? le mot est-il vraiment de la glue ? Ce que le test a trouvé, et les réponses : - `walletInbox` → `userInbox`, avec l'écart de cardinalité écrit noir sur blanc plutôt que caché par le nom. - `accounts` / `AccountRecord` / `AccountStorage` → `virtualUsers` / `VirtualUserRecord` / `VirtualUserStorage`, module `accounts.ts` → `virtual-users.ts`. « account » n'est pas de la cible : c'est notre mot pour l'utilisateur virtuel, et le marqueur le dit désormais. - `readModel` → la fonction `readUnion`, exposée directement. « model » n'était ni de la cible ni de la glue, et le namespace ne tenait qu'une fonction. - Le reste était du vocabulaire légitime à déclarer (`subject`, `base`, `schema`, `connected`, le modèle réactif de l'ORM). Corrigé au passage, sur signalement du contrat interne : l'en-tête d'`open-repo` justifiait son correctif par un mécanisme que le source contredit. Un repo absent de `self.repos` lève bien `RepoNotFound` (`engine/verifier/src/request_processor.rs:264,269`). Les 0 lignes observées viennent d'ailleurs — `Verifier::load` repeuple `self.repos` depuis le stockage sur un profil persistant (`verifier.rs:535-560`), et notre propre `readDoc` attrape toute erreur et rend `[]`. Le correctif est bon, le diagnostic écrit à côté ne l'était pas. 159 tests unitaires, typecheck src/test/e2e vert, e2e 40/40 contre le broker.
@ng-eventually/client
Two entry points — the data-plane is SDK-identical, the polyfill bootstrap is separate:
| Import | Surface |
|---|---|
@ng-eventually/client |
The same signature as the SDK — ng, useShape, inbox (+ types). A drop-in for @ng-org/web / @ng-org/orm; as NextGraph matures it resolves to the real SDK (build alias removed) with no code change. |
@ng-eventually/client/polyfill |
The only non-SDK surface — configure, setCurrentUser, and the capability surface (capFor, shareCap, getCaps). It falls away as NextGraph matures. |
Reading is key possession, and the isolation here is still fake. The cap surface has the shape of the real model — you hold a document's
ReadCapor you do not read it, and there is no authorization list anywhere — but nothing is encrypted yet and several read paths bypass the guard entirely. Nothing this library does may be described as "anonymous" or "private" until per-document encryption lands (P1b).
// bootstrap (the only non-SDK call) — inject the real SDK
import { configure } from "@ng-eventually/client/polyfill";
configure({ ng: realNg, useShape: realUseShape, sharedWallet, currentUser });
// from here on, a pure SDK surface:
import { ng, useShape, inbox } from "@ng-eventually/client";
await ng.doc_create(/* … */);
const set = useShape(MyShape, scope); // filtered to what the identity may read
await inbox.post(targetInbox, ref); // deposit (anticipated SDK API)
Principle — the polyfill compensates, it never extends
The polyfill's ONLY reason to exist is to bridge a NextGraph implementation gap or a bug. Every non-SDK surface must map to a capability NextGraph will provide natively, and must fall away at that point. The polyfill MUST NOT add functionality of its own — no bespoke features, no observability/tooling, no convenience API that isn't strictly "NextGraph will do this natively later." The test for any proposed addition: does it compensate a real, exhibited NextGraph gap or bug? If not, it does not belong here — build it in the consumer application, not in the polyfill. Corollary: a compensation whose gap is not actually exhibited on the target broker is dead weight, not defensive code — it should be removed, not kept "just in case."
What the polyfill adds on top of the real SDK (each emulated for now, native as NextGraph matures):
- Shared-wallet identity (one wallet for everyone; the current identity id is relayed to the SDK).
- Capability emulation — per-identity cap possession (
capFor) and a read filter over it: you read the documents whose cap you hold. Creating a document files its cap; receiving one is an inbox deposit. There is no authorization list. - Anticipated methods (inbox
post,shareCap) with their future-SDK shapes, emulated for now.
Generic: no application domain. The consumer application injects its shapes and performs the acts of sharing. The relationship concept ("who is connected to whom") is the consumer application's own — the client exposes only "share this one document's cap to that inbox".
The cap surface in three calls
import { capFor, shareCap, getCaps } from "@ng-eventually/client/polyfill";
import { storeRegistry } from "@ng-eventually/client";
// Creating a document records its cap and you hold it — nothing to declare.
const doc = await storeRegistry.createEntityDoc(myId, "protected");
capFor(doc); // → `${doc}:r:…` — you hold it
// Share it with one recipient, addressed by their inbox. They need no "receive"
// operation: their existing inbox.watch absorbs it.
await shareCap(capFor(doc)!, theirInbox);
// Publishing is TWO acts: place the data in your public store, and circulate its
// LINK. There is no discovery — you cannot be found, you can only be reached — so
// the link has to travel: into an inbox, or into a document the reader already
// holds. The bare NURI would name the document without opening it.
const link = getCaps().publishRepoLink(publicDoc);
await shareCap(link, theirInbox);
The one invariant to keep in mind: you never derive a cap from a bare reference.
You look it up in what you hold, or you were given it. A did:ng:o:… without :r:
names a document and grants nothing.
The types carry that invariant
Nuri and ReadCap are template literal types, not string aliases:
type Nuri = `did:ng:${string}`
type ReadCap = `did:ng:${string}:r:${string}`
They are still strings — assignable to string, JSON-serializable, no wrapper — but
the distinction is checked. A ReadCap goes wherever a Nuri is expected (a cap
is a NURI with the key inside); the reverse does not compile:
await shareCap(doc, theirInbox); // ✗ Argument of type '`did:ng:${string}`' is not
// assignable to '`did:ng:${string}:r:${string}`'
A string that comes from outside your code — storage, a URL, JSON, a form — is a
plain string. Narrow it, do not cast it: a cast re-opens exactly the confusion
the types close.
import { isNuri, hasReadCap } from "@ng-eventually/client";
const saved = localStorage.getItem("cap");
if (saved && hasReadCap(saved)) await shareCap(saved, theirInbox); // ✓ narrowed
The runtime guards remain regardless — a JavaScript caller never meets the compiler, and a cast bypasses it — so passing a bare reference where a cap belongs throws with a message that says so.