Le modèle amont est explicite dans `PublicRepoLinkV0` : le lien ne porte AUCUN `read_cap`, et son commentaire dit pourquoi — *"The latest ReadCap of the branch will be downloaded from the outerOverlay, if the peer brokers listed below allow it […] the public site are served differently by brokers"* (engine/net/src/types.rs:5098). La clé n'est pas remise par un émetteur : elle est donnée par le réseau à qui la demande, parce que le broker a épinglé l'overlay externe (`expose_outer`). La bibliothèque refusait jusqu'ici la forme sans cap quel que soit le store. Sûr dans le bon sens, mais une application ne pouvait pas exprimer « fais circuler, la référence suffit » — le seul acte que le modèle rend gratuit — et son unique contournement était de distribuer la clé, ce qui détruit la confidentialité composable. `emulated-verifier/public-store.ts` émule le mécanisme SANS toucher à la garde. La possession reste l'unique critère : un document public est lisible non par exception mais parce que son cap est *obtenable*. Chaque porte de lecture demande d'abord (`readUnion`, `docs.sparqlQuery`, `ensureRepoOpen`, `documentInboxAddress`), puis le chemin ordinaire s'applique. Lire n'est pas écrire. Ce que le store sert est un droit de LECTURE : `learnFromPublicStore` le classe à part et `assertMayWrite` refuse l'écriture dessus. Sans cela une référence nue achetait une écriture, ce qu'aucun store amont n'accorde. Autres conséquences : - `recordInPublicStore` (marquer + frapper) devient `markInPublicStore` (marquer). Frapper un second cap à côté de celui qu'on vient de télécharger donnerait deux clés différentes le jour où la constante devient un secret. - `hasCap` quitte la porte polyfill : il se lisait « ai-je le droit de lire ceci ? » et un document public y répondait `false` jusqu'à ce qu'on demande son cap. Aucun appelant hors des tests. - Les tests cross-user ne font plus traverser de cap par une variable JS : Bob n'obtient que la référence nue, comme une vraie application. Écarts documentés plutôt que masqués : le pari sur un modèle DÉCLARÉ (`expose_outer` est câblé à `false` côté client et `ExtTopicSyncReq` est `unimplemented!()`), la découverte limitée à ce qu'on sait déjà nommer, `useShape` qui n'a pas d'await à dépenser, et l'absence de `locator`. 179 tests unitaires, e2e 42/42 contre le broker en ligne.
@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.