Files
ng-eventually/README.md
T
Sylvain Duchesne 0b936d2119 fix: écrire est une PROPRIÉTÉ, et trois portes qui n'auraient pas dû être ouvertes
Suite de la revue adverse. Quatre trous de frontière, tous hors du champ « l'isolation
est fausse jusqu'à P1b » — P1b parle de matériau de clé, ceux-ci sont des défauts de
FORME et resteraient des trous avec une vraie clé.

**La garde d'écriture reposait sur la mauvaise question.** Elle demandait « ce cap m'a-t-il
été servi par un store public ? ». Ce prédicat était faux dans les deux sens à la fois :
trop laxiste — une clé reçue dans une inbox donnait l'écriture, alors qu'en amont un Link
est « external repos only » et qu'écrire est l'appartenance au repo ; trop strict — la
propriétaire de son propre document public était refusée dès qu'elle l'ouvrait depuis sa
référence avant que son store ne soit listé. Un prédicat poussé dans deux sens est le
signe que c'était le mauvais prédicat.

Écrire dépend désormais de la PROPRIÉTÉ, lue sur la branche Store (l'`AddRepo` émulé),
plus la paternité de session pour les documents créés par la primitive brute qui n'a
aucun store où s'inscrire. Conséquence assumée et documentée : seul le propriétaire écrit,
ce qui est l'état amont d'un repo tant qu'aucun membre n'a été ajouté — mécanisme qu'on
n'émule pas.

**`docs.depositInto` quittait la frontière en la publiant.** Sa doc disait « `inbox.post`
est le seul appelant » : vrai dans la bibliothèque, faux dès qu'on le publie. Démontré :
avec la seule référence nue d'un document public, on réécrit l'adresse d'inbox posée
dessus et on détourne les dépôts destinés à son propriétaire. Une porte qui saute une
garde ne doit pas être ouvrable par une application — elle rejoint la machinerie.

**Le filtre de lecture n'interceptait que trois membres** et transmettait tout le reste
lié à la CIBLE : `.values()`, `.map()`, `.getById()` rendaient le contenu d'un autre
utilisateur — précisément les membres qu'une API de set réactif met en avant. Les membres
qui rendent des éléments sont désormais filtrés, les mutations passent (elles ne rendent
rien), et **tout membre inconnu lève** au lieu de transmettre : une transmission est une
fuite silencieuse, une levée est bruyante et greppable.

**Le mémo du store public était par document.** Le premier demandeur déclenchait le
téléchargement, le cap était classé chez LUI, et tout demandeur suivant recevait « oui »
en ne détenant rien. En amont un broker qui sert un overlay externe répond à TOUS. Le
mémo garde la valeur, l'appelant la classe pour qui est connecté.

Aussi : l'exemption `declareInfrastructure` supprimée — zéro appelant, ensemble toujours
vide, et une doc décrivant deux documents exemptés qui ne l'ont jamais été. Et les caps
d'écriture décrits comme « partiels » sont dits **inertes**, ce qu'ils sont : `grantWrite`
n'a aucun appelant de production.

**Ce que l'e2e a rattrapé.** Ma première version de la garde refusait au créateur
l'écriture sur un document fait par `docs.docCreate` — 7 étapes rouges contre le broker,
après une suite unitaire restée verte. La primitive brute n'inscrit la paternité nulle
part ; c'est ce que `mintedHere` couvre désormais.

185 tests unitaires (dont quatre régressions : la propriétaire écrit, le destinataire non,
le store public sert tout demandeur, aucun membre non filtré ne transmet), e2e 40/40 et
applicatif 10/10.
2026-08-07 13:59:13 +02:00

202 lines
18 KiB
Markdown

# ng-eventually
A generic polyfill layer over the [NextGraph](https://nextgraph.org) JS SDK.
NextGraph's JS SDK does not yet expose cross-wallet reads, capabilities, inboxes
or group stores. `ng-eventually` lets a consumer application behave as if those
existed today, by emulating them on top of a single shared wallet / broker. It is
generic: it contains no application domain — the consumer application injects its
shapes and the acts of granting access.
The name: *eventually* NextGraph will ship these features; until then this layer
fills the gap (and nods at eventual consistency / events).
## The boundary — mature face out, compensation in
The asymmetry is the point. The consumer application writes SDK-shaped code as if
NextGraph were finished: per-entity documents in public/protected/private stores,
capabilities, inboxes. This library owns the current-state NextGraph knowledge and
the simulation that fabricates that mature face — a shared-wallet emulation — so
the application never sees it. As NextGraph matures, this library changes; the
consumer application's code does not.
Docs (this library's own engineering doctrine, under [`docs/`](./docs/)):
- [`docs/nextgraph-current-state.md`](./docs/nextgraph-current-state.md) — the
authoritative reference on what the current SDK/broker do and do not expose
(the ground truth each polyfill compensates for).
- [`docs/simulation.md`](./docs/simulation.md) — how this lib emulates the mature
behaviour on one shared wallet (shim, per-document ReadCaps, emulated inbox,
write guard, the two axes, the double-proxy constraint).
- [`docs/read-model.md`](./docs/read-model.md) — the read model the polyfill
implements: you follow links, you never enumerate; listing via a bounded set of
per-doc anchored `sparql_query`s; reactivity via re-query on a change signal.
- [`docs/decisions/`](./docs/decisions/) — current-SDK ADRs (private-store scope,
SPARQL delete, shared-wallet identity).
- [`docs/fork-inbox-fallback.md`](./docs/fork-inbox-fallback.md) — the Rust-patch /
self-host inbox path not taken (kept as a fallback).
- [`docs/migration-guide.md`](./docs/migration-guide.md) — the checklist for when
real NextGraph matures.
## What is emulated
Nothing in this library is a real NextGraph feature. Each behaviour below is
emulated — a stopgap fabricated on top of the current, immature NextGraph (one
shared wallet, everything physically readable). The consumer application always
sees the mature SDK face; the emulation lives entirely here.
The table reads: what the consumer application does, the real NextGraph target it
is written against, the current NextGraph implementation status (why a workaround
is needed), and how this lib emulates it today.
| Capability | What the consumer application does | Real NextGraph target | Current NextGraph status (why a workaround) | Current emulation |
|---|---|---|---|---|
| Multi-identity / per-identity wallet | Treats each identity id as its own wallet with its own documents | Each identity opens its own real wallet; native cross-wallet reads | Not-yet-implemented: the JS SDK exposes no cross-wallet read, so one session cannot read another identity's wallet | One shared wallet everyone opens; "identities" are virtual users — shim accounts keyed by an id, each mapped to its documents in `shared-wallet/account-registry.ts` |
| Three native stores per identity | Places entities by scope `public` / `protected` / `private` | The identity's three real native stores hold the entity documents | Not-yet-implemented: `doc_create`/ORM can target only the private (and protected) native store today; a `public`/arbitrary `StoreRepo` is not JS-constructible | Three emulated scope-index documents per account — each "store" is an index doc listing its entity-doc NURIs; all physically live in the one shared private store, and scope is a logical label |
| Per-document read isolation | Nothing to declare: creating a document records its cap on its store, and its creator holds it. Reading is `capFor(doc)` — you hold the key or you do not read | The broker/verifier delivers only documents the wallet holds a ReadCap for; accessing a document without the cap yields an empty result in a union read (a targeted read of an unheld repo errors with `RepoNotFound`) | The model itself is the point: reading is key possession, and there is no read-ACL to introspect — a client cannot ask "may this identity read this doc?" because that question does not exist upstream | Caps recorded per identity: `AddRepo` on the store's emulated Store branch for documents it creates, `AddLink` on its User branch for caps received; `emulated-verifier/caps.ts` caches them for the session. A read filter (`emulated-verifier/read-filter.ts`) plus the boundary (`emulated-verifier/reach.ts`) keep only documents whose cap is held. The cap value is the stand-in `OK` — enforcement is P1b |
| Directed read sharing | Owns the relationship concept ("who is connected to whom") itself, and on acceptance shares one document's cap to the other's inbox (`shareCap(cap, theirInbox)`) | The cap sealed to the recipient's inbox key (`ContactDetails.read_cap`), opened by their own verifier while processing the inbox | Not-yet-implemented — a **gap, not a disagreement**: the field exists but the message construction is `unimplemented!()`, its only caller passes "without read_cap", and the receiver discards the cap. The shape is right; the implementation is absent | `shareCap` deposits the cap into the recipient's inbox document; the recipient's existing `inbox.watch` absorbs it into what they hold. No "receive" operation, and no principal is ever named to the registry |
| Inbox (registration notifications) | `inbox.post` / `read` / `watch` | A message is sealed to the recipient's key and queued in their inbox; the recipient's own verifier unseals and applies each queued message inline while processing the inbox | Not reachable from JS: the verifier has no `InboxPost` arm, and no `inbox` method exists in `@ng-org/web`. (`inbox_post_link` is OUR proposed name from [`docs/fork-inbox-fallback.md`](docs/fork-inbox-fallback.md), not an announced NextGraph API — no such symbol exists in `nextgraph-rs`.) | Deposits written as RDF into an inbox document via SPARQL; `read`/`watch` read the deposits back — an in-lib stand-in for the recipient's own inbox processing |
| ~~Discovery of all public events~~ **REMOVED 2026-07-30** | Circulates the link itself — into inboxes, or into a document the reader already holds | **There is no discovery.** You cannot discover, you can only follow links: publishing = place the data in your public store **and** circulate the link, seen only by those who received it (a foundation of local-first) | Not a gap to be filled — a global index is not a NextGraph shape, and it would pool data across wallets | Nothing. `discovery.ts` and its global index were removed: they emulated a capability the target will never have. See [`docs/readcap-and-nuri-model.md`](docs/readcap-and-nuri-model.md) §4ter-bis |
| Reads / listing | Lists the documents it needs, by scope, and reads them | Native per-wallet reads over the real per-identity stores | Bug/perf: an anchorless union query spans every named graph in the session store, which on a shared / accumulating wallet is O(wallet size) and stalls | A bounded, by-need set of per-doc anchored `sparql_query`s (each anchored to one repo's default graph), independent of wallet size |
| Reactivity | Lists update on change | Native reactive reads | Not-yet-implemented: there is no reactive union query across graphs | Re-query the bounded per-doc anchored set on a lightweight change signal (`doc_subscribe` / ORM on an already-opened single store) |
| Writes | Writes an entity to its scope | Writes land in the entity's real store via native primitives | Not-yet-implemented: `doc_create` can target only the private/protected store today (`StoreRepo` not JS-constructible) | Per-entity documents via direct SPARQL (`docs.sparqlUpdate` on the real injected `ng`) |
| Current identity | Sets the current identity id (established at wallet import) via the SDK's current-identity call | Opening one's own wallet at the broker gate establishes the session identity | Not-yet-implemented for the shared-wallet case: everyone shares one wallet, so the broker cannot distinguish identities | A relayed id (`shared-wallet/virtualUsers.ts` `IdentityStore` persists it); the read filter and inbox `from` read it |
| Write-guard | Writes refused without the write cap | The broker/verifier enforces the write cap natively — writing is repo membership (`verify_permission`, reachable only from `Commit::verify`) | Not-yet-implemented for delegation: this library emulates no `AddMember`/`AddPermission`, so only a document's OWNER writes — a repo's upstream state until someone is added | An ownership check at the write door (`emulated-verifier/reach.ts` `assertMayWrite`, reading authorship from the Store branch). The older write-cap proxy (`surface/ng-proxy.ts`) is **inert**`grantWrite` has no production caller, so its policy set is always empty |
## Packages
| Package | Role |
|---|---|
| `@ng-eventually/sdk` *(was `@ng-eventually/client` until 2026-08-07)* | The SDK-identical wrapper the app imports instead of `@ng-org/web` / `@ng-org/orm`. It adds the polyfills the broker/verifier will do natively (shared-wallet identity, capability enforcement, anticipated cap/inbox methods). As NextGraph matures, the app points back at the real SDK (build alias removed) and this package falls away. |
A global-index package is deferred. Data common to all of an application's users comes from a **singleton app**: a document or store shared by all users and hardcoded in the app, write-owned by the developer and delegable — but never to all users, so user contributions reach it **through an inbox** (nothing in NextGraph is freely writable by everyone). That is the direction the NextGraph developer has named; it is **not implemented**, and several points are still open (what exactly is hardcoded, how delegation travels, who materializes the inbox). So there is no second package for now — it will be introduced once the mechanism exists, and it will be separate from the SDK wrapper. See [`docs/nextgraph-current-state.md`](docs/nextgraph-current-state.md) § Apps & services.
## Design principle
The application code is written as if the target NextGraph existed. All
compensation lives here, beside the app. As NextGraph matures, this layer falls
away; the app code (SDK-shaped) is unchanged.
**Both halves are binding, and the second is the one that gets traded away.** The
SURFACE must be as close as possible to the future SDK — that much is obvious, it is
what the consumer codes against. But the IMPLEMENTATION must be as close as possible to
what NextGraph actually plans, and there is no exception to that. Where upstream's
behaviour is known, it is a specification, not a reference: **when it is known, hold to
it**. What "known" means here is narrow — read in `nextgraph-rs` or stated by the
NextGraph developer, never inferred from what an npm package happens to expose, and
never inferred from an absent implementation ("the engine has no X" says nothing about
whether the target will).
The pressure to deviate never announces itself as a deviation. It shows up as a cost, a
latency, an ergonomic wrinkle — a real one. Two instances, both caught only by asking
the question:
- *Every document has a native inbox* was written into the docs from general
reasoning. It is false, and it had already become an implementation.
- A per-document inbox was made to point at **the owner's** inbox, to avoid a measured
cost (9m37 → 21m30 on the consumer's suite). It emulates a many-to-one relation
upstream cannot express: the verifier routes by `inboxes: PubKey → RepoId` and unseals
with that one repo's key (`engine/verifier/src/verifier.rs:1677,1928`), and a message
carries no target document because it needs none. Reverted. The cost was then solved
without touching the shape — only documents meant to receive open an inbox.
The tell in both: an implementation choice that would make the consumer learn something
it must **unlearn** at migration. That is the thing this library exists to prevent, so
it outranks cost, latency and convenience. When the shape and the cost conflict, keep
the shape and attack the cost elsewhere — and if it truly cannot be solved, say so
rather than bend the model quietly.
### The three references, numbered bottom-up
"NextGraph" is not one layer, and conflating them is how a fact about one gets asserted
about another. They are **stacked**, each built on the one below, so they are numbered
from the bottom:
| # | Layer | Where |
|---|---|---|
| **3** | **JS SDK / ORM** | `@ng-org/orm`, `@ng-org/shex-orm` — source in `sdk/js/orm` (TypeScript) |
| **2** | **wasm binding** | `@ng-org/web` — source in `sdk/js/lib-wasm` (77 exported methods) |
| **1** | **Rust engine** | `engine/``repo`, `verifier`, `net`, `broker`, `wallet` |
**These are REFERENCES, not places we write code.** Every line this library ships lives
in the polyfill; none of these three layers is ours to touch, and `nextgraph-rs` is a
read-only source of truth. Saying "level 1" about a piece of our code means *"it is
aligned on the engine's model"* — never *"it lives in the engine"*.
**Which reference to align on: take the HIGHEST one that answers, and go down only when
it does not.**
- **Level 3 answers fully → do not implement it here.** Pass through. Compensation code
that doubles a working SDK function is code to delete later, and it diverges meanwhile.
- **Level 3 is absent or unsatisfactory → align on the level-2 call** that does the job.
Ergonomics are lost, semantics are kept — and migrating later means moving up one
step, not rewriting.
- **Nothing at level 2 either → align on the level-1 MODEL**: cardinalities, addressing
units, what a structure can and cannot express.
Level 1's facts are the hardest-won, but aligning there means **inventing a JS surface**,
since none exists yet. So always **say which level a choice came from**. A level-3
passthrough is a fact; a level-1 shape is a bet constrained by the engine. Presenting
them alike is what manufactures false certainty — `inbox_post_link` was cited across
eight files as a planned NextGraph API when it was only a name proposed in
[`docs/fork-inbox-fallback.md`](./docs/fork-inbox-fallback.md).
Concretely for the inbox: level 3 has nothing, level 2 has **no `inbox` method at all**
(and the verifier has no `InboxPost` arm), so `inbox.*` is aligned on level 1 — the
engine's model (one inbox ↔ one repo, addressed by `(overlay, pubkey)`, no target
document in the message) with a JS surface of our own making.
This cascade answers *"we need X — what do we align on?"*. It is **not** a checklist to
run over what the target exposes: an unused binding method is not a debt, and "it was in
the unused list" is not a reason to investigate it.
> **Do not confuse these levels with the other "three levels" in this repo.**
> [`docs/readcap-and-nuri-model.md`](./docs/readcap-and-nuri-model.md) §4quinquies numbers
> *where a cap is stored* (wallet root key → the Store/User branch registers → the local
> in-memory cache). Same word, unrelated axis: these three are **layers of NextGraph to
> align on**, those three are **places a key lives**. When it is not obvious from the
> sentence, say "reference level" or "storage level".
- SDK-identical surface: the client wraps the real `ng` (a Proxy that forwards
everything and overrides only what must be emulated) and `useShape`. The real
SDK is injected via `configure()` (no hard import → build-alias safe and
testable).
- Authorization is emulated capabilities: documents carry grants; the client
enforces them generically (read filter + write guard). The app declares a
document, shares one document's cap to an inbox — the same acts it will
perform in the target. No policy is injected.
- Inbox: the client `inbox` namespace deposits (`post`) and, in the shared-wallet
emulation, reads the deposits back (`read` / `materialize` / `watch`) in place
of the recipient's own inbox processing.
- Tests of the polyfill (against a real broker) live in this repo, in **two** suites,
and the split is deliberate: `packages/sdk/e2e/run.ts` (`test:e2e`) characterises the
primitives and the platform contracts, while `packages/sdk/e2e/notebook.ts`
(`test:e2e:app`) drives the example application through the DOM, one browser page per
identity. Only the second can tell whether an application is *writable* — a harness
can pass a value between two identities through a variable, and an application cannot.
A consuming app can test its own features against a clean, mocked API.
## Status
Implemented. The polyfill mechanisms are wired against a real broker, not stubbed:
- Shared-wallet shim — `shared-wallet/account-registry.ts` (`(account, scope) → document NURI`,
`createEntityDoc` / `listMyEntityDocs` + per-user stores, cross-device via the RDF
shim anchored in the private store).
- Document / SPARQL primitive — `docs.ts`, calling the real injected `ng` directly
(avoids the `@ng-org` double-proxy `DataCloneError`).
- Emulated ReadCaps — `emulated-verifier/caps.ts` (`CapRegistry`, per-document, directed grants) +
read filter `emulated-verifier/read-filter.ts` (reactive-set `Proxy` view), applied by
`surface/use-shape.ts` only once a cap exists (`caps.isEnforcing()`).
- Write guard — `surface/ng-proxy.ts` (`sparql_update` override, emulated write cap).
- Inbox — `inbox.ts` (`post` / `read` / `materialize` / `watch`).
- Identity — `shared-wallet/virtualUsers.ts` (`IdentityStore`, injected storage).
- SPARQL hardening — `sparql.ts` (`escapeLiteral` / `escapeIri` / `assertNuri`).
The remaining `TODO` markers are narrow: the shared-wallet credential passthrough
in the `session_start` proxy branch, and the anticipated cap/inbox SDK signatures
to reconcile if the official API differs. See
[`docs/simulation.md`](./docs/simulation.md) for what each piece does and
[`docs/migration-guide.md`](./docs/migration-guide.md) for what changes as
NextGraph matures.