Revue adversariale : la barriere livree au tour precedent rendait le refus
TERMINAL. Le signal du trousseau s'ecrit `if (query !== null) publish(...)`, parce
qu'avant la barriere il n'y avait qu'une seule raison de n'avoir pas de requete -
rien n'avait encore ete tente - et qu'une cle qui arrive ne pouvait changer que ce
qu'une requete OUVERTE avait le droit de montrer. La barriere en a ajoute une
seconde : elle abandonne AVANT que la requete existe, donc le seul canal qui fait
converger une cle tardive passait a cote de l'etat qui en avait le plus besoin.
Une page qui s'abonne pendant que son trousseau se restaure, ou un document
partage sous les yeux de son destinataire, publiait `isError` et y restait. Ce
n'est pas une surface plus stricte, c'est une pire : echanger un vide mensonger
contre une erreur bloquee ne repare rien, et cela casse une garantie que ce paquet
publie.
Le signal rouvre donc un scope refuse, et seulement quand le refus est leve
(`namedDocumentsReachable`). Le garde-fou porte autant que la reouverture :
rouvrir a chaque changement de trousseau rejouerait la barriere pour chaque cle
qu'une connexion depose, redemanderait au reseau tout document dont le
telechargement avait ECHOUE - un echec n'est deliberement pas memoise - et
republierait la meme erreur a chaque abonne. La variante indiscriminee casse deux
tests, dont celui de demontage qui preexistait.
La barriere passe en parallele. Elle etait serie, donc N documents qui ne poussent
jamais de `State` coutaient N replis bornes, 8s chacun en production. Les refus
restent lus dans l'ordre du scope apres coup.
Et ce qui a laisse passer les deux : l'e2e ne parcourait pas ce chemin. Les deux
journeys `useShapeQuery` lisent `{graphs: "did:ng:i"}`, donc 43/43 n'a jamais rien
prouve sur la barriere. Quatre controles ajoutes sur un scope NOMME, contre le
vrai broker : la proprietaire lit son document, un porteur de rien recoit
`isError` et jamais un succes vide, un document PUBLIC s'ouvre pour lui depuis la
seule reference via la requete de la barriere, et une cle qui tombe pour un AUTRE
document laisse le scope refuse en place.
@ng-eventually/polyfill
One entry point. Most of what it publishes has the same signature as the future SDK —
ng, useShape, useShapeQuery, getObjects, insertObject, docs, inbox,
storeRegistry (+ types) -
and is 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.
One call does not, and it is the whole of what you will delete: configure. It
exists because one shared wallet hosts every user; upstream, an application imports the
SDK and each user opens their own wallet. src/index.ts groups it under a heading that
says so. (ensureIdentity is a second in substance — the shared-wallet gate — but its
call site survives: an application still awaits a session before it renders.)
(There were two entry points until 2026-08-07, . and ./polyfill, and the second one
WAS that list. One door is easier to import from and says less — hence the grouping, and
hence docs/api-contract.md, whose export inventory a test keeps honest.)
Per-symbol, with the target signature and an epistemic label on every claim:
docs/api-contract.md.
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 the stand-in key is a constant. Nothing this library does may be described as "anonymous" or "private" until cap-enforcement lands per-document encryption.
import {
// SDK-shaped — the real SDK replaces these in place.
ensureIdentity, storeRegistry, inbox, docs,
// Polyfill-era — one call, and it is the whole of what goes away.
configure,
} from "@ng-eventually/polyfill";
configure({
ng: realNg, useShape: realUseShape, ormSubscription, getObjects, insertObject,
initNg, sharedWallet,
});
await ensureIdentity(); // who I am (returned), connection work awaited
// A store is named by its reference — `doc_create` takes one, and the session is where
// upstream gets it. Omit it and the document goes to the private store, as upstream does.
const doc = await storeRegistry.createEntityDoc(await storeRegistry.protectedStore());
await docs.sparqlUpdate(sid, `INSERT DATA { … }`, doc);
// Reading is by SHAPE, over the SDK's own scope. `did:ng:i` is the whole local dataset;
// naming documents in `graphs` reads exactly those. There is no "list my documents" call
// and no way to scope a read by store — neither exists upstream.
// It reads through the SDK's own shape query (`RdfOrmSubscription.getOrCreate`, passed to
// `configure` as `ormSubscription`), and hands back the shape's own objects: `@graph`,
// `@id` and one field per predicate the shape DECLARES.
const query = useShapeQuery(NoteShape, { graphs: "did:ng:i" });
// The ORM's one-shot read and its subscription-free write, at the SDK's own names and
// signatures. Import them from HERE, never from `@ng-org/orm`: one wallet hosts every
// identity, so the unwrapped read returns everybody's objects and the unwrapped write
// reaches anybody's document. Wrapped, the read comes back filtered by what you hold and
// the write is refused unless you own the document its `@graph` names.
const notes = await getObjects(NoteShape, { graphs: "did:ng:i" });
await insertObject(NoteShape, { "@id": "", "@graph": doc, title: "a note" });
Principle — the polyfill compensates, it never extends
Its only reason to exist is to bridge a NextGraph implementation gap. Every non-SDK surface must map to something NextGraph will provide natively, and must fall away at that point — no bespoke features, no observability, no convenience API that isn't strictly "NextGraph will do this later". The test for any proposed addition: does it compensate a real, exhibited gap? If not, it belongs in the consumer application. And a compensation whose gap is not actually exhibited on the target broker is dead weight, not defensive code.
Both halves are binding — the surface AND the implementation stay as close as possible to what NextGraph plans. The question to ask at every choice: would this make a caller learn something it has to UNLEARN at migration? If yes, it is a deviation, whatever it buys.
What the polyfill adds, each emulated now and native later:
- Shared-wallet identity — one wallet hosts every user, so the library fabricates
virtual users and confines every access to the connected one
(
emulated-verifier/reach.ts). Upstream, each user opens their own wallet. - Capability emulation — per-identity cap possession plus a read filter over it: you read the documents whose cap you hold. There is no authorization list, because the real model has none.
- Inbox —
post,postToDocument,share, and the recipient's processing. The model is verified (an inbox is a keypair on one repo); no JS surface exists yet.
Generic by construction: no application domain here. See
examples/notebook for an application written against it,
which the e2e suite drives.
How a document is reached — the three acts, and no others
import { storeRegistry, inbox, docs } from "@ng-eventually/polyfill";
// 1. CREATE — you hold its cap, with nothing to declare. No identity parameter: a
// session belongs to one user, exactly as the target's own `doc_create` assumes.
const doc = await storeRegistry.createEntityDoc("protected");
// 2. GIVE TO READ — name the document and the person. The key is looked up and
// sealed into a deposit; the recipient applies it by connecting, with nothing
// to call. Irreversible: there is no revoking a key already handed out.
await inbox.share(doc, "bob");
// 3. CIRCULATE THE REFERENCE — no call at all. Every reference this surface returns
// is BARE: it names the document and grants nothing. If the document sits in a
// PUBLIC store, the store serves its read cap to whoever asks, so the bare
// reference is enough to read it — and if it does not, the reference still names
// it and opens nothing.
const publicDoc = await storeRegistry.createEntityDoc("public");
// …put `publicDoc` in a QR code, a message, another document. Nothing else to do.
// A stranger holding only that reference reads it with ONE anchored query — the anchor
// scopes the read to that document's own graph, and the key is fetched from the public
// store on the way in. `readUnion([publicDoc])` did this until `1.0.0-dev.8`; it was
// this call in a loop, so the loop is yours now and the call is unchanged.
await docs.sparqlQuery(sessionId, "SELECT ?s ?p ?o WHERE { ?s ?p ?o }", undefined, publicDoc);
The invariant behind all three: you never derive a cap from a bare reference. You
look it up in what you hold, you were given it, or a public store served it. A
did:ng:o:… without :r: names a document and opens nothing — which is what makes
confidentiality composable: a widely circulated document may point at a restricted
one, and following the reference gets you a name, not a key. See
docs/readcap-and-nuri-model.md § 0.
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.
Permissive in, precise out. Public entries take NuriLike (Nuri | string) and
validate at the door, so a value coming from storage, a URL or a form needs no
narrowing and no cast on your side; what they return is a precise Nuri. The
runtime checks stay regardless — a JavaScript caller never meets the compiler.
const saved = localStorage.getItem("doc"); // string | null
// ✓ validated at the door — `anchor` is a `NuriLike`, so nothing to narrow here
if (saved) await docs.sparqlQuery(sessionId, "SELECT ?s ?p ?o WHERE { ?s ?p ?o }", undefined, saved);