12 Commits

Author SHA1 Message Date
Sylvain Duchesne d07b3642aa refactor(api): séparer la surface de l'app et la machinerie
L'entrée SDK déversait la machinerie par deux fuites :

- `export * as storeRegistry from "./store-registry"` exportait TOUT le module —
  `ensureAccount`, `addLink`, `readLinks`, `resolveAccount`, `reservedAccount`,
  `resetRegistryCache`, `isOwnInbox`, `myInboxes`, `userStoreDoc`. Remplacé par
  `store-registry-api.ts`, qui ne ré-expose que les sept appels destinés à
  l'app : createEntityDoc, listMyEntityDocs, resolveScopeGraph,
  resolveWriteGraph, walletInbox, openDocumentInbox, documentInboxAddress.
- `accounts.*` — persistance d'identité navigateur, sans aucun pendant SDK —
  passe sur `/polyfill`, où sa disparition à la migration se lit sur la ligne
  d'import.

L'en-tête d'`index.ts` affirmait n'exposer « que ce que @ng-org/web et
@ng-org/orm exposent ». C'était faux et enseignait une frontière fausse : un
consommateur en déduisait que tout ce qui s'importe de l'entrée survit à la
migration, ce qui ne valait ni pour `accounts` ni pour l'essentiel de
`storeRegistry`. Il énonce désormais ce que l'entrée promet vraiment : tout
symbole y a un pendant dans le futur SDK, vérifié ou assumé, et rien n'y est de
la machinerie.

La frontière mord : le typecheck e2e a échoué aussitôt, le harnais atteignant
`ensureAccount` et `resetRegistryCache` par l'entrée publique. Il passe
désormais par le chemin interne, comme les tests unitaires — légitime, il teste
la bibliothèque.

Deux documents plutôt qu'un, mêmes exigences, publics différents :
`docs/api-contract.md` (la surface de l'app, avec pour chaque sujet la signature
que le futur SDK devrait exposer, et l'étiquette qui distingue le vérifié de
l'assumé) et `docs/internal-contract.md` (le complément exact).

157 tests unitaires, e2e 40/40 contre le broker en ligne.
2026-08-04 12:16:59 +02:00
Sylvain Duchesne e5779ca850 docs: une adresse d'inbox est TRANSMISE en amont, nous la PUBLIONS
Assertion fausse retirée de `openDocumentInbox` : « en amont, l'acte équivalent
est le propriétaire qui commite AddInboxCap avec la clé du repo — personne
d'autre ne le peut ». Personne d'autre ne le peut est inventé. Ce commit atterrit
sur la branche User de CELUI QUI LE FAIT, donc n'importe qui peut en écrire un
nommant le repo de n'importe qui. Le moteur ne pose aucune garde là-dessus.

Ce qui protège en amont n'est pas une garde, c'est le mode de circulation :
`inboxes: PubKey → RepoId` est une table du Verifier (`verifier.rs:105`),
reconstruite vide à chaque session — l'association inbox→repo est LOCALE, pas
publiée. Un déposant apprend une pubkey parce qu'on la lui a ENVOYÉE : dans un
`ContactDetails` (`contact.inbox`) ou par le QR de profil. Une paire forgée
n'atteint personne, faute que quiconque en ait été informé.

D'où une divergence à assumer et non à maquiller : nous PUBLIONS l'adresse sur
le document, seul moyen qu'un tiers la trouve dans une émulation sans canal de
messages. Cela crée un vecteur que le moteur n'a pas — qui peut écrire le
document peut rediriger ses dépôts — et c'est ce que la garde `ownsDocument`
compense. Elle compense NOTRE conception ; elle ne reproduit aucune règle amont.

Manquait aussi dans la carte des inbox dressée juste avant : elle disait qui A
une inbox, et omettait comment l'adresse circule — la dimension dont tout le
reste dépend.
2026-08-03 18:03:03 +02:00
Sylvain Duchesne 2406afec8b docs: l'inbox par document n'est pas une anticipation, c'est une capacité du moteur
Glissement à corriger : de « le moteur ne le FAIT pas » (vrai) j'ai conclu
« c'est notre anticipation » (faux). Le moteur le PERMET, et de façon générique
par conception :

- `inbox: Option<PrivKey>` est un champ de TOUT `Repo` (`repo.rs:126`), pas
  d'une structure de store ;
- `AddInboxCapV0` est clé par `repo_id` — « Repo the Inbox is opened for » ;
- `update_inbox_cap_v0` l'applique via `self.repos.get_mut(repo_id)` sans
  AUCUNE vérification `is_store` (`verifier.rs:1920`) ;
- et à tout moment, `AddInboxCap` étant un commit de branche User dont le type
  documente le cas de mise à jour.

Ce qui est vrai est plus étroit : aucun chemin de code n'en CRÉE une pour un
document. « Ne fait pas » n'est pas « ne peut pas » — c'est précisément
l'interdit que ma propre règle pose, et je l'ai enfreint en le formulant.

Donc l'inbox par document s'aligne sur le modèle du moteur (niveau 1) ; ce qui
est de nous est la surface JS, aucune n'étant exposée aux niveaux 2 et 3.
Corrigé dans store-registry.ts, nextgraph-current-state.md et les deux briefs.
2026-08-03 17:55:46 +02:00
Sylvain Duchesne 88efe4fea4 docs: la cascade répond à un besoin, pas à un inventaire
Garde-fou ajouté après avoir produit exactement le travers qu'il interdit :
proposer d'instruire `graph_orm_update` / `discrete_orm_update` au seul motif
qu'elles figuraient dans la liste des méthodes du binding non utilisées. Aucun
besoin, aucun symptôme, aucun coût — un audit qui a l'air rigoureux et ne répond
à rien.

Une fonction inutilisée n'est pas une dette. La cascade répond à « il faut faire
X : sur quoi s'aligner ? », pas à « vérifions qu'on se sert de tout ».

Au passage, l'argument avancé était faux : `graph_orm_update` est dans
`lib-wasm` (`lib.rs:1998`), donc de niveau 2 comme `sparql_update`, et non de
niveau 3. Et sa signature `(subscription_id, diff, session_id)` la réserve à
l'intérieur d'une souscription ORM active — c'est le canal d'écriture de l'ORM,
pas une primitive d'écriture générale, donc pas un substitut.
2026-08-03 17:46:20 +02:00
Sylvain Duchesne 07312cd0a2 docs: doc_fetch_*_subscribe ne remplacent pas doc_subscribe
Vérifié parce qu'elles ressemblent à des appels « ouvrir un repo » tout faits, ce
qui aurait rendu notre `ensureRepoOpen` redondant. Elles ne le sont pas : ni
l'une ni l'autre ne fait d'I/O — chacune construit un `AppRequest` et le renvoie
sérialisé (`lib.rs:1890`, `:1900`), sans session_id ni callback. `doc_subscribe`
construit la MÊME requête (`AppRequest::doc_fetch_repo_subscribe` →
`Fetch(Subscribe)`), y ajoute la session et la passe à `app_request_stream_`.
Ce sont des constructeurs de requête pour qui veut dépêcher lui-même.

Donc rien à récupérer : les employer reviendrait à réimplémenter ce que
`doc_subscribe` fait déjà. Et `doc_fetch_private_subscribe` ne diffère que par
sa cible (le store-root privé), ce qui ne change rien à la barrière — un
store-root n'en a toujours pas.
2026-08-03 17:40:50 +02:00
Sylvain Duchesne 6a3bd9c3a9 docs: numéroter les références du bas vers le haut, et lever une confusion
Les trois références sont empilées, chacune bâtie sur celle du dessous : la
numérotation suit désormais la pile — 1 = moteur Rust, 2 = binding wasm, 3 = SDK
JS / ORM. On s'aligne sur le niveau le plus HAUT qui répond, et on descend
seulement s'il ne répond pas.

Correction d'une confusion introduite par ma formulation : ce sont des
RÉFÉRENCES, pas des endroits où l'on écrit du code. Tout ce que cette
bibliothèque produit vit dans le polyfill ; aucune des trois couches n'est à
nous, `nextgraph-rs` est une source en lecture seule qu'on ne maintient pas.
« Ce code est de niveau 1 » veut dire « il s'aligne sur le modèle du moteur »,
jamais « il est dans le moteur ». Dit ainsi, la question « comment du code de
niveau 2 rendrait du code de niveau 1 inutile » ne se pose plus : ce qui
disparaît, c'est notre propre code de compensation, quand une couche au-dessus
finit par offrir ce qu'il fabriquait.

Ajout d'un garde-fou : ces niveaux-là n'ont rien à voir avec les « trois
niveaux » de readcap-and-nuri-model §4quinquies, qui numérotent OÙ vit une clé.
Même mot, deux axes.
2026-08-03 17:28:41 +02:00
Sylvain Duchesne 1de9be9167 docs: la cascade des trois références, et l'inbox s'ouvre à tout moment
« NextGraph » n'est pas une couche. Trois niveaux, à parcourir de haut en bas
en s'arrêtant au premier qui répond : (1) le SDK JS / ORM — s'il couvre le
besoin, ne rien implémenter ici, passthrough ; (2) le binding wasm — si le
niveau 1 manque ou ne convient pas, s'appuyer sur l'appel équivalent ; (3) le
moteur Rust — si rien au-dessus ne répond, se tenir à son MODÈLE : cardinalités,
unités d'adressage, ce qu'une structure peut ou ne peut pas exprimer.

Et dire de quel niveau vient un choix. Un passthrough de niveau 1 est un fait ;
une forme de niveau 3 est un pari contraint par le moteur. Les présenter à
l'identique est ce qui fabrique les fausses certitudes — `inbox_post_link` a été
cité dans huit fichiers comme une API NextGraph prévue alors que c'était un nom
proposé dans notre propre plan de fork.

Sur l'ouverture d'une inbox, le moteur tranche contre ce que j'avançais : elle
n'est pas réservée à la création. `AddInboxCap` est un commit de branche User
(`commit.rs:1043-1050`), donc une entrée dans un flux ouvert dans le temps, et
son type documente explicitement le cas tardif — « DEPS to the previous
AddInboxCap commit(s) if it is an update. in this case, repo_id should match ».
Le verifier l'applique à un repo déjà chargé. Que `new_store_default` pose les
deux inbox de store à la création d'un site décrit comment ces deux-là sont
faites, pas une contrainte du modèle.

Donc `openDocumentInbox` reste appelable à tout moment, et le
`createEntityDoc(…, { withInbox })` que j'avais proposé aurait rétréci le
modèle. Écrit dans le code pour qu'il ne soit pas « simplifié » plus tard.
2026-08-03 17:19:19 +02:00
Sylvain Duchesne 4f5c3ed03b docs: inbox_post_link est notre nom, pas une API NextGraph annoncée
Le symbole n'existe nulle part dans `nextgraph-rs`, et aucune méthode de
`@ng-org/web` ne contient « inbox ». Il vient de notre propre plan de fork
(`docs/fork-inbox-fallback.md:32` — « expose `pub async fn inbox_post_link` »),
d'où il a essaimé dans huit autres endroits, cité comme une API « proposed/
future » de NextGraph. Une proposition interne devenue un fait par répétition —
le même mécanisme que « chaque document a une inbox native » et que l'inbox
mutualisée.

Corrigé partout sauf dans le plan de fork, où le nom est légitime puisque c'est
lui qui le propose. Et l'énoncé exact est désormais posé : on ne connaît NI le
nom NI la forme de la future surface JS pour les inbox — ce n'est pas
« non implémenté », c'est inconnu.

Ce qui est réellement vérifié côté moteur : `AppRequestCommandV0::InboxPost`
existe et `AppRequest::inbox_post()` le construit, mais le request_processor n'a
aucun arm pour lui — l'envoyer ne déclenche rien. Le seul dépôt qu'un client JS
peut provoquer aujourd'hui passe par `import_contact_from_qrcode`, qui appelle
`post_to_inbox(InboxPost::new_contact_details(...))` avec `with_readcap = false`
— la variante `true` étant `unimplemented!()`.
2026-08-03 17:04:30 +02:00
Sylvain Duchesne fba0128a7e docs(inbox): commentaires alignés sur l'inbox par document
Deux commentaires de openDocumentInbox décrivaient encore l'adresse publiée à
la création, retirée en 5a7009b. Le critère de refus est la propriété, et la
raison est nommée : ouvrir l'inbox est ce qui PUBLIE l'adresse du document, donc
un non-propriétaire s'y router les dépôts. En amont l'acte équivalent est le
propriétaire qui commite `AddInboxCap` avec la clé du repo — personne d'autre ne
le peut.
2026-08-03 16:58:10 +02:00
Sylvain Duchesne 5a7009bd75 fix(inbox): une inbox appartient à un document, jamais à plusieurs
Retour sur l'adresse par défaut livrée en 8a382f2, qui faisait pointer tout
document vers l'inbox de son propriétaire. C'était acheter le coût au prix de
la forme — le mauvais arbitrage pour cette bibliothèque.

Vérifié en amont : le verifier route un message entrant par
`inboxes: PubKey → RepoId` (`engine/verifier/src/verifier.rs:1677,1928`) et le
déchiffre avec la moitié privée de CE repo. Et `InboxMsgBody`
(`engine/net/src/types.rs:4265`) ne porte aucun document cible — il n'en a pas
besoin : l'adresse EST l'identification. Une inbox appartient donc à exactement
un repo, et faire tenir plusieurs documents derrière une inbox émule une
relation que le modèle ne peut pas exprimer.

Conséquences :

- `createEntityDoc` ne publie plus rien. Un document neuf n'a pas d'inbox et
  `documentInboxAddress` rend `undefined`.
- Une inbox s'ouvre par `openDocumentInbox(doc)`, sur décision du propriétaire.
  C'est aussi ce qui règle le coût sans toucher à la forme : seuls les
  documents destinés à RECEVOIR en paient une — l'app le sait, la bibliothèque
  non.
- `inbox.postToDocument(doc, { payload })` : l'app nomme le DOCUMENT, jamais une
  inbox. Lève quand le document n'en a pas, au lieu de rendre la main
  silencieusement — un dépôt qui disparaît sans erreur est exactement le bug que
  ce chemin traînait.
- Pas de champ « document cible » sur un dépôt. Ce serait une invention que les
  apps devraient désapprendre à la migration.

README, principe de conception : les deux moitiés sont contraignantes, et c'est
la seconde qu'on brade. La surface doit être au plus près du futur SDK, mais
l'IMPLÉMENTATION aussi doit être au plus près de ce que NextGraph prévoit, sans
exception. Ce qui est connu vaut spécification. La pression à dévier ne se
présente jamais comme une déviation : elle arrive comme un coût, une latence,
une gêne d'ergonomie — bien réels. Deux cas déjà rencontrés sont consignés, avec
le signal commun : un choix qui ferait apprendre au consommateur quelque chose
qu'il devra DÉSAPPRENDRE.

157 tests unitaires, e2e 40/40 contre le broker en ligne.
2026-08-03 16:45:28 +02:00
Sylvain Duchesne 8a382f29f8 feat(inbox): l'inbox d'un document est adressable par tout détenteur
Répond au brief 2026-08-03 remonté depuis le consommateur. `documentInbox(doc)`
répondait « quelle inbox est-ce que MOI je connais pour ce document » et en
créait une quand la réponse était « aucune » : un tiers n'atteignait jamais
l'inbox du propriétaire, il en obtenait une à lui, que personne ne lit, et son
dépôt disparaissait sans erreur. C'est l'acte central du consommateur —
s'inscrire à l'événement d'un autre — qui était silencieusement perdu.

Lire une inbox et savoir où y déposer sont deux actes opposés, avec des publics
opposés. Ils sont désormais deux fonctions :

- `openDocumentInbox(doc)` — le PROPRIÉTAIRE ouvre une inbox dédiée. Refuse sur
  la PROPRIÉTÉ (lue depuis les branches Store), pas sur la possession du cap :
  un cap se reçoit, et un destinataire ne doit pas pouvoir rediriger vers lui
  les dépôts destinés au propriétaire.
- `documentInboxAddress(doc)` — n'importe quel détenteur trouve où déposer. Ne
  crée jamais rien.

L'adresse est publiée dès la CRÉATION, sur la branche Header émulée du document
— un sujet réservé à l'intérieur du document, donc lisible par qui détient le
document. Publier seulement le jour où le propriétaire ouvre une inbox dédiée
laisserait une fenêtre pendant laquelle un tiers lit le document, ne trouve
aucune adresse, et ne peut pas joindre le propriétaire du tout.

Sur le coût mesuré par le brief (9m37 → 21m30) : il venait de la création d'un
DOCUMENT supplémentaire par document. L'adresse publiée pointe vers l'inbox
propre du propriétaire, qui existe déjà et s'amortit sur tous ses documents ;
la création grandit d'un triple, pas d'un document. Le dépôt porte le document
concerné, donc le propriétaire matérialise toujours par document. La forme
« dérivable » du brief n'était pas disponible : notre inbox est un document, et
un NURI dérivé nommerait un repo que `doc_create` n'a jamais créé.

Le tout reflète la séparation d'amont : un déposant scelle avec la clé PUBLIQUE
de l'inbox et n'a besoin de rien d'autre, seul le propriétaire détient la
moitié privée — une adresse est donc publique par nature.

`src/machinery.ts` : l'espace de noms `urn:ng-eventually:` que la bibliothèque
se réserve, et le prédicat que le chemin de lecture utilise. La branche Header
est le premier compartiment logé dans un document que le consommateur lit ;
`read-model` écarte désormais tout sujet de cet espace, par SUJET et non par
prédicat — ce qui couvre toutes les branches émulées, présentes et futures.

Question ouverte du brief, tranchée : « une inbox de document adressable par
tout détenteur » est une invention de cette bibliothèque, pas de l'amont — aucun
document n'y a d'inbox, ni le store privé. Ce qui EST vérifié, c'est la forme
qui rend l'anticipation défendable : `AddInboxCapV0` est clé par `repo_id`.

Tests : le test qui validait « n'importe qui dépose » passait le NURI d'inbox au
déposant par une variable du test — chemin qu'aucune app n'a. Réécrit avec les
deux acteurs cloisonnés : le déposant reçoit le lien du document, qui est la
seule chose qui circule dans ce modèle, et doit trouver l'adresse lui-même. Le
fake `ng` gagne le SELECT de la branche Header et le `DELETE WHERE` (sans quoi
un remplacement devenait une accumulation, précisément le bug qu'il évite).

157 tests unitaires, e2e 40/40 contre le broker en ligne.
2026-08-03 16:02:11 +02:00
Sylvain Duchesne e24a20cc46 docs: le modèle applicatif partagé passe par une app singleton
Réécriture de « Apps & services » : la version précédente déduisait la forme
cible de l'absence d'implémentation dans le moteur — exactement ce que le
principe de conception du README interdit — et concluait l'inverse de ce que le
développeur NextGraph énonce.

Deux couches désormais séparées et étiquetées comme telles :

- ce que le moteur CONTIENT (vérifié) : `AppManifestV0` avec `singleton: bool`,
  `access_requests`, `installs`, `dependencies` ; `init(callback, singleton,
  access_requests)` côté JS. Et personne ne le consomme — le module
  `permissions` n'est importé par aucune crate, `AppManifest` n'est construit
  nulle part. Du vocabulaire, pas du comportement.
- ce que le modèle SERA (énoncé par le développeur, non implémenté) : une app
  singleton peut aussi gérer les documents par utilisateur ; les données
  communes prennent la forme d'un document ou d'un store partagé par tous les
  utilisateurs et codé en dur dans l'app ; le développeur détient les droits
  d'écriture et peut les déléguer, jamais à tous — les contributions arrivent
  par une inbox.

Le commentaire du champ dit `/// cannot create Documents?`, avec le point
d'interrogation. Une seconde glose publiée le contredit et rejoint ce que dit
le développeur : `sdk/js/web/README.md:90,108` annote l'argument « will your app
create many docs in the system, or should it be launched as a unique instance »
— `singleton` porte sur la multiplicité d'instance, pas sur une interdiction de
créer des documents. C'est cette lecture qu'il faut retenir.

Conséquence sur l'ADR discovery : sa moitié « la voie app singleton est
incertaine » est caduque et doit être re-posée, sans que cela rétablisse la
découverte, qui tient sur son propre appui.
2026-08-03 16:01:46 +02:00
23 changed files with 1727 additions and 128 deletions
+82 -7
View File
@@ -55,7 +55,7 @@ is needed), and how this lib emulates it today.
| 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 | | 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; `caps.ts` caches them for the session. A read filter (`read-filter.ts`) plus the boundary (`reach.ts`) keep only documents whose cap is held. The cap value is the stand-in `OK` — enforcement is P1b | | 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; `caps.ts` caches them for the session. A read filter (`read-filter.ts`) plus the boundary (`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 | | 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-yet-implemented: the sender-side seal-into-inbox call (`inbox_post_link`) is proposed/future, not exposed in the JS SDK | 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 | | 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 | | ~~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 | | 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) | | 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) |
@@ -69,12 +69,7 @@ is needed), and how this lib emulates it today.
|---|---| |---|---|
| `@ng-eventually/client` | 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. | | `@ng-eventually/client` | 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. In NextGraph an identity's apps and services 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 client. See [`docs/nextgraph-current-state.md`](docs/nextgraph-current-state.md) § Apps & services.
see only what it shares, so there is no multi-identity backend. A global index
would come from a singleton app (a global document administered by the developer),
which is not implemented and undecided; simpler paths may exist. So there is no
second package for now — it will be introduced once the global-index mechanism is
decided, and it will be separate from the client.
## Design principle ## Design principle
@@ -82,6 +77,86 @@ The application code is written as if the target NextGraph existed. All
compensation lives here, beside the app. As NextGraph matures, this layer falls compensation lives here, beside the app. As NextGraph matures, this layer falls
away; the app code (SDK-shaped) is unchanged. 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 - 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 everything and overrides only what must be emulated) and `useShape`. The real
SDK is injected via `configure()` (no hard import → build-alias safe and SDK is injected via `configure()` (no hard import → build-alias safe and
+612
View File
@@ -0,0 +1,612 @@
# API contract — what `@ng-eventually/client` exposes today, and what the future SDK should expose per subject
**Scope: the APP-FACING contract only.** Everything reachable from the two published entry points, and nothing else. The library's internal modules — the shim machinery, the read paths, the boundary guards — are held to the same standard (as close as possible to what NextGraph does or plans) but have their own document, `docs/internal-contract.md`: a consumer never reads that one, a maintainer does. This split was made on 2026-08-03, together with the export change described in § 15.
**Scope.** The real exported surface of `@ng-eventually/client` (verified against the `export` statements in `packages/client/src/index.ts` and `packages/client/src/polyfill.ts``package.json` maps exactly two entry points, `.` and `./polyfill`), and, for each subject, the target signature the future NextGraph JS SDK is expected to expose. Written 2026-08-03, verified against the `nextgraph-rs` clone (HEAD `213338f6`, 2026-05-16) and the installed `@ng-org/web@0.1.2-alpha.13` type declarations (`node_modules/.bun/@ng-org+web@0.1.2-alpha.13/node_modules/@ng-org/web/dist/index.d.ts`, hereafter `index.d.ts`).
**How to read the epistemic labels.** Every target-side claim carries one of:
- **PASSTHROUGH (level 3 / level 2, VERIFIED)** — the target function exists today; the lib forwards to it. Citation into `nextgraph-rs` or the installed `.d.ts`. Level numbers per `README.md` § *The three references*: 3 = JS ORM (`sdk/js/orm`), 2 = wasm binding / `@ng-org/web` (`sdk/js/lib-wasm`, `sdk/js/web`), 1 = Rust engine (`engine/`).
- **LEVEL-1 SHAPE (model VERIFIED, JS surface ASSUMED)** — the engine's model constrains the shape and is cited, but **no JS surface exists at any level**, so the signature offered here is this library's invention. The future SDK's name and parameter order for it are unknown.
- **ASSUMPTION** — nothing at any layer constrains this; the bet and what bounds it are stated.
- **NO COUNTERPART** — the subject has no image in the target at any layer, usually because it is shared-wallet machinery that disappears at migration. That is a finding about the emulation, not a gap in the target.
Per the design principle (`README.md` § *Design principle*): an absent implementation is never treated as evidence about the future — "the engine does not do X" and "the SDK will not offer X" are kept apart throughout.
---
## 1. Bootstrap and configuration
### Today — `@ng-eventually/client/polyfill` (everything here is removed at migration)
```ts
// polyfill.ts:44
export interface EventuallyConfig {
ng: NgLike;
useShape: UseShapeLike;
sharedWallet?: { name: string; secret: string };
currentUser?: PrincipalId;
debugAccessLog?: boolean;
init?: (...args: any[]) => any;
initNg?: (...args: any[]) => any;
}
// polyfill.ts:99
export function configure(c: EventuallyConfig): void;
// polyfill.ts:113 — tests only
export function resetConfig(): void;
// polyfill.ts:24
export interface StoreRegistryDeps {
getSession: () => Promise<RegistrySession>;
normalizeId?: (id: string) => string;
pointerGuard?: { attempts?: number; baseMs?: number; maxStepMs?: number };
}
// polyfill.ts:124
export function configureStoreRegistry(deps: StoreRegistryDeps): void;
// polyfill.ts:161 — tests only
export function resetStoreRegistry(): void;
// polyfill.ts:106 / :153 — both tagged @internal, exported so the SDK-shaped wrappers can reach the injected SDK
export function getConfig(): EventuallyConfig;
export function getStoreRegistryDeps(): ResolvedRegistryDeps;
```
### Target
**NO COUNTERPART, by design.** The whole subject is the polyfill bootstrap: it exists to inject the real SDK without a hard import (build-alias safety). At migration the consumer initializes the real SDK directly, with the two calls in § 2, and `configure` / `configureStoreRegistry` are deleted (`docs/migration-guide.md` § 7). Nothing in the target takes an "injected `ng`".
---
## 2. Lifecycle
### Today — `@ng-eventually/client`
```ts
// lifecycle.ts:11 — forwards to the real @ng-org/web init injected at configure()
export function init(...args: any[]): any;
// lifecycle.ts:18 — forwards to the real @ng-org/orm initNg injected at configure()
export function initNg(...args: any[]): any;
```
### Target
**PASSTHROUGH, VERIFIED at both levels.** The wrapper's `...args: any[]` is deliberately shapeless; the real signatures it forwards to are:
```ts
// level 2 — @ng-org/web: index.d.ts:108, source sdk/js/web/src/index.ts:51
export declare const init: (callback: Function | null, singleton: boolean, access_requests: any) => Promise<void>;
// level 3 — @ng-org/orm: sdk/js/orm/src/connector/initNg.ts:51 (exported as initNg from core.ts)
export function initNgSignals(ngImpl: NG, session: Session): void;
// level 2 — the Session initNg consumes: index.d.ts:264-272
export declare type Session = {
session_id: string | number;
protected_store_id: string;
private_store_id: string;
public_store_id: string;
ng: typeof NGModule;
[key: string]: unknown;
};
```
Divergence: none in behaviour (pure forwarding), but the wrapper erases the parameter types. A consumer typing calls against the wrapper learns nothing it must unlearn — it just gets no compile-time help the real SDK would give.
---
## 3. The `ng` object
### Today — `@ng-eventually/client`
```ts
// index.ts:55
export const ng: Record<string, any>;
// type re-export, index.ts:50
export type { NG } from "@ng-org/web";
```
`ng` is a `Proxy` (`ng-proxy.ts:10`) forwarding every property to the injected real `ng`, overriding exactly two things: `login` / `session_start` (currently a passthrough with a TODO for shared-wallet credentials) and `sparql_update` (the emulated write-cap guard, rejecting a write when a write policy governs the anchored document and the current user lacks the cap).
### Target
**PASSTHROUGH (level 2, VERIFIED).** `export declare const ng: NG` with `NG = typeof NGModule`, 77 exported members (`index.d.ts:136-231`). The surface is identical by construction — the proxy adds no member and removes none.
The two overrides:
- `session_start(wallet_name: string, user_id: any): Promise<any>` (`index.d.ts:276`) — target signature unchanged; only the emulated credential injection disappears.
- `sparql_update(session_id: any, sparql: string, nuri: any): Promise<any>` (`index.d.ts:297`) — target signature unchanged. The native enforcement the guard stands in for is the engine's permission model (`verify_perm`, `engine/repo/src/commit.rs:897`), which today is **called only from tests** (its enclosing `Commit::verify` has no runtime caller — see `docs/nextgraph-current-state.md` § *Author-signature verification*). That absence says nothing about the target: write permissions are the engine's declared model, so the guard's *behaviour* (a refused write) is target-shaped even though its *mechanism* (a JS-side check) is emulation. Known limit, documented in `README.md`: the guard fires only on this proxy, and the lib's own writers call the injected `ng` directly, so it is best-effort until P1b.
---
## 4. Reactive typed reads — `useShape`
### Today — `@ng-eventually/client`
```ts
// use-shape.ts:12
export function useShape(shapeType: unknown, scope: unknown): unknown;
// type re-exports, index.ts:48-49
export type { ShapeType, BaseType, Schema } from "@ng-org/shex-orm";
export type { DeepSignalSet } from "@ng-org/alien-deepsignals";
```
Behaviour: forwards to the injected real `useShape`; once any emulated cap exists (`caps.isEnforcing()`), the returned set is wrapped in a read-filtered view keeping only items whose document cap the current holder has.
### Target
**PASSTHROUGH (level 3, VERIFIED), with a signature the wrapper widens.** The real hook:
```ts
// level 3 — @ng-org/orm/react: sdk/js/orm/src/frontendAdapters/react/useShape.ts:86-124
const useShape = <T extends BaseType>(
shape: ShapeType<T>,
scope: Scope | string | undefined
) => DeepSignalSet<T>;
// its Scope — sdk/js/orm/src/types.ts:25-38 (NOT this lib's Scope, see § 12)
export type Scope = {
graphs?: string[] | string;
subjects?: string[];
};
```
The read filter disappears at migration: in the target, isolation is cryptographic — a repo whose cap the wallet does not hold is never decrypted, a union read over it yields nothing, and a targeted read errors `RepoNotFound` (`engine/verifier/src/request_processor.rs:155,163` via `resolve_target`). VERIFIED at level 1; the *consumer-visible* result (you only see what you hold) is the same, which is the point of the emulation.
Divergence to note: the wrapper types everything `unknown`, losing the generic `T`. A consumer wanting typed sets today must cast; at migration the real generic signature gives it back. Nothing to unlearn, only ergonomics deferred.
---
## 5. Reactive typed reads with load state — `watchShape`
### Today — `@ng-eventually/client`
```ts
// watch-shape.ts:73
export interface ShapeQuery<T = UnionSubject> {
data: T[];
isPending: boolean;
isSuccess: boolean;
isError: boolean;
error: unknown;
}
// watch-shape.ts:90
export interface ShapeObservable<T = UnionSubject> {
getSnapshot(): ShapeQuery<T>;
subscribe(onChange: () => void): () => void;
refetch(): void;
}
// watch-shape.ts:166
export function watchShape<T = UnionSubject>(
shapeType: unknown,
scope: Scope,
): ShapeObservable<T>;
```
### Target
**Partly ASSUMPTION — flagged deliberately.** `watch-shape.ts`'s header says it "anticipates NextGraph's planned `useShape(shape, scope)` upgrade, which will natively distinguish 'sync in progress' from 'synced but empty'". **No provenance for that plan exists in this repo's docs or in the `nextgraph-rs` clone** — treat the "planned upgrade" as an assumption, not a stated NextGraph direction. What IS verified at level 3 is that the distinction is *expressible* today, just not through the hook:
```ts
// level 3, VERIFIED — sdk/js/orm/src/connector/GraphOrmSubscription.ts:228,260,274
OrmSubscription.getOrCreate<T extends BaseType>(shape: ShapeType<T>, scope: NormalizedScope): OrmSubscription<T>;
get readyPromise(): Promise<void>; // resolves when the subscription is synced — the native "no longer pending" signal
public close(): void;
```
So the constraint on the bet: the target can already answer "synced?" (`readyPromise`), and `useShape` today returns "an empty set, if still loading" (its own doc comment, `useShape.ts:29-31`) — indistinguishable from synced-empty. `watchShape` surfaces the distinction with a TanStack-`useQuery`-minimal vocabulary (`isPending`/`isSuccess`/`isError`), which is a **shape of this library's choosing**. If the future hook exposes load state under different names, the consumer's binding code changes; the underlying distinction it teaches (pending ≠ empty) is target-expressible and safe to learn.
---
## 6. One-shot listing — the read-model
### Today — `@ng-eventually/client`
```ts
// read-model.ts:59
export interface UnionSubject {
subject: string;
graph: string;
props: Record<string, string[]>;
}
// read-model.ts:140
export async function readUnion(docs: Nuri[]): Promise<UnionSubject[]>;
```
Behaviour: one anchored `sparql_query` per doc (default-graph body, no `GRAPH` wrapper), parallel, per-doc failure tolerance, cap filter applied inside, machinery subjects dropped.
### Target
Two verified counterparts, one per level; neither returns `UnionSubject` — that grouping is lib-invented:
```ts
// level 2, VERIFIED — the primitive readUnion composes: index.d.ts:295, source sdk/js/lib-wasm/src/lib.rs:352 (nodejs) / :555 (web)
declare function sparql_query(session_id: any, sparql: string, base: any, nuri: any): Promise<any>;
// level 3, VERIFIED — the one-shot typed read: sdk/js/orm/src/connector/getObjects.ts:23
export async function getObjects<T extends BaseType>(
shapeType: ShapeType<T>,
scope: Scope | string
); // returns a deep-cloned Set of matching objects
```
The anchored-read mechanics are level-1 VERIFIED: an anchor restricts the query to that repo's graph as default graph (`resolve_target_for_sparql`, `engine/verifier/src/request_processor.rs:256-285`), an anchorless query unions every named graph in the session store (same function, `UserSite → None``set_default_graph_as_union`). At migration `readUnion` survives as composition (the anchored per-doc read is native); a consumer that wants typed results should be on `useShape`/`getObjects`, not on `UnionSubject` — the property-bag shape is a polyfill artifact, kept generic precisely so the consumer maps it into its own types and can drop it later.
---
## 7. Raw document / SPARQL primitives — `docs.*`
### Today — `@ng-eventually/client` (namespace `docs`)
```ts
// docs.ts:46
export async function docCreate(
sessionId: string,
crdt: string,
cls: string,
dest: string,
store?: unknown,
): Promise<Nuri>;
// docs.ts:85
export async function sparqlUpdate(
sessionId: string,
query: string,
anchor?: Nuri,
label = "sparqlUpdate",
): Promise<void>;
// docs.ts:130
export async function sparqlQuery(
sessionId: string,
query: string,
base?: string,
anchor?: Nuri,
label = "sparqlQuery",
): Promise<unknown>;
// docs.ts:113 — machinery, see § 15
export async function depositInto(
sessionId: string,
query: string,
targetInbox: Nuri,
label = "deposit",
): Promise<void>;
```
### Target
**PASSTHROUGH (level 2, VERIFIED)** — these mirror the real methods 1:1 minus the trailing `label` (a lib-internal access-log tag, never forwarded):
```ts
// index.d.ts:60 — the installed web SDK's doc_create
declare function doc_create(session_id: any, crdt: string, class_name: string, destination: string, store_repo: any): Promise<any>;
// index.d.ts:297
declare function sparql_update(session_id: any, sparql: string, nuri: any): Promise<any>;
// index.d.ts:295
declare function sparql_query(session_id: any, sparql: string, base: any, nuri: any): Promise<any>;
```
`depositInto` has **NO COUNTERPART as a SPARQL write**: upstream a deposit is a sealed message, not an update into the recipient's graph (§ 9). It exists only because the emulated inbox is an RDF document.
**Store targeting — a nuance this repo's docs understate.** `docs/nextgraph-current-state.md` and `docs/migration-guide.md` say a public/arbitrary `StoreRepo` "is not JS-constructible". Verified in the clone, the picture is finer:
- The **web** wasm variant (`sdk/js/lib-wasm/src/lib.rs:1575`, `#[cfg(not(wasmpack_target = "nodejs"))]`) deserializes its 5th argument as `Option<StoreRepo>` via serde — so a value CAN be passed, but no JS helper exists to build the serde form, which keeps it out of practical reach. The published `.d.ts` documents this 5-arg form.
- The **nodejs** variant (`lib.rs:1618`, 6 args) takes `store_type: Option<String>` + `store_repo: Option<String>` and builds the store via `StoreRepo::from_type_and_repo(store_type, repo_id_str)` with `store_type ∈ "public" | "protected" | "private" | "group"` (`sdk/rust/src/local_broker.rs:2969-2987`, `engine/repo/src/types.rs:819-828`).
So the target's direction for scope placement is **already visible in the source** (level 2, VERIFIED, nodejs SDK): name the store by type + repo id strings. The migration-guide's anticipated `getNativeStore(scope)`-style resolver should expect to produce exactly that pair (or the serde `StoreRepo` once a web helper lands) — not a new concept.
---
## 8. Per-document subscription — `subscribeDoc`
### Today — `@ng-eventually/client`
```ts
// subscribe.ts:47,60,79
export type DocChange = unknown;
export type DocChangeType = string | undefined;
export type Unsubscribe = () => void;
// subscribe.ts:69
export function docChangeType(resp: DocChange): DocChangeType;
// subscribe.ts:104
export function subscribeDoc(
nuri: Nuri,
onChange: (r: DocChange, type: DocChangeType) => void,
): Unsubscribe;
// subscribe.ts:184
export function subscribeDocs(
nuris: Nuri[],
onChange: (nuri: Nuri, r: DocChange, type: DocChangeType) => void,
): Unsubscribe;
```
### Target
**PASSTHROUGH (level 2, VERIFIED) with two deliberate ergonomic deltas:**
```ts
// index.d.ts:66, source sdk/js/lib-wasm/src/lib.rs:1908
declare function doc_subscribe(repo_o: string, session_id: any, callback: Function): Promise<any>;
```
- The real call is `async` and resolves to an unsubscribe function; the wrapper returns the unsubscribe **synchronously** and honours an early cancel when the promise settles. A consumer coding against the sync return will keep working against the real SDK only through an adapter — a small, known unlearn, traded for not forcing `await` on every subscription site.
- The real callback receives one argument, the serialized `AppResponse` (`{ V0: { State | Patch | TabInfo | … } }`); the wrapper adds a second, pre-extracted `type`. `docChangeType` is a convenience over the verified payload shape (pinned by the e2e CONTRACT-3 probe), not an upstream API.
- `subscribeDocs` has **NO COUNTERPART and needs none**: it is client-side composition (a set of `doc_subscribe` with per-doc error isolation). The upstream fan-out primitive that looks like it (`orm_start_graph(graph_scope, …)`, `index.d.ts:243`) aborts wholesale on one `RepoNotFound` (`sdk/js/orm``engine/verifier/src/request_processor.rs:53-66`) — the reason this composition exists.
---
## 9. Inbox — deposits, and cap delivery
### Today — `@ng-eventually/client` (namespace `inbox`; `shareCap` also re-exported from `/polyfill`)
```ts
// inbox.ts:48,58
export interface Deposit {
from: PrincipalId | null;
payload: unknown;
ts: number;
}
export interface PostOptions {
from?: PrincipalId | null;
payload: unknown;
ts?: number;
}
// inbox.ts:140
export async function post(targetInbox: Nuri, opts: PostOptions): Promise<void>;
// inbox.ts:215
export async function postToDocument(doc: Nuri, opts: PostOptions): Promise<void>;
// inbox.ts:282
export async function shareCap(cap: ReadCap, toInbox: Nuri): Promise<void>;
// inbox.ts:339
export async function read(targetInbox: Nuri): Promise<Deposit[]>;
// inbox.ts:419
export const materialize = read;
// inbox.ts:441
export async function readSynced(targetInbox: Nuri): Promise<Deposit[]>;
// inbox.ts:466
export async function processInbox(targetInbox: Nuri): Promise<Deposit[]>;
// inbox.ts:492
export function watch(
targetInbox: Nuri,
onDeposits: (deposits: Deposit[]) => void,
_opts?: { intervalMs?: number },
): () => void;
```
### Target
**LEVEL-1 SHAPE throughout — the model is VERIFIED, every JS signature here is this library's invention.** There is no inbox method in `@ng-org/web` (none in the 77 `index.d.ts` exports, re-verified), and the verifier's dispatch has no `InboxPost` arm (arms actually handled listed at `engine/verifier/src/request_processor.rs:53-1444`, re-verified). The engine model that constrains the shape:
- An inbox is a keypair on **exactly one repo**: `pub inbox: Option<PrivKey>` (`engine/repo/src/repo.rs:126`); routing is `inboxes: HashMap<PubKey, RepoId>` on the verifier (`engine/verifier/src/verifier.rs:105`, looked up at `:1677`, inserted at `:1928`).
- A message is sealed to the inbox pubkey and carries **no target document**`InboxMsgBody { to_overlay, to_inbox: PubKey, from_overlay: Option<OverlayId>, from_inbox: Option<PubKey>, … }` (`engine/net/src/types.rs:4265`). The address identifies the recipient repo; nothing else is needed. This is why `Deposit` has no document field and why `post` takes only the inbox NURI.
- `from` optional upstream (`from_inbox: Option<PubKey>`) — the "identified if known, anonymous otherwise" behaviour `PostOptions.from` mirrors, including the `null`-means-anonymous case.
- The recipient's own verifier unseals and **applies** queued messages when it processes its inbox (`engine/verifier/src/verifier.rs:1674-1690``process_inbox`); an inbox is a consumed queue, not a store you re-read.
Consequences per function:
- `post` / `postToDocument` — the sender-side act exists in the model (the broker routes `InboxPost` natively, `engine/net/src/server_broker.rs`); its JS surface does not. **The future SDK's name and signature are unknown**`docs/nextgraph-current-state.md:187` records that nothing is announced. `postToDocument`'s resolution step (find the document's inbox address) rides on a **deliberate divergence**: this lib PUBLISHES the address on the document (Header-branch emulation), whereas upstream an address is only ever TRANSMITTED (`ContactDetails` carries `ng:site_inbox`/`ng:protected_inbox`, `engine/verifier/src/inbox_processor.rs:778-830`; the verifier's `inboxes` table is session-local, rebuilt empty — `verifier.rs:520,2820`). Documented in `docs/briefs/2026-08-03-document-inbox-addressing.md`.
- `shareCap` — a **gap upstream, not a disagreement**, verified at both ends: `ContactDetails.read_cap: Option<ReadCap>` exists (`engine/net/src/types.rs:4233`) but building a message with it is `unimplemented!()` (`types.rs:3786`), its only caller passes `with_readcap: false`, and the receiving arm never reads the field (`inbox_processor.rs:778-830`). `InboxMsgContent::Link` is a **unit variant carrying nothing** (`types.rs:4252`) — do not read it as the delivery channel. The recipient-side filing the lib emulates is real: `AddLink { read_cap }` on the User branch (`engine/repo/src/types.rs:1939-1948`). The consumer's *act* (share one document's cap to one inbox) is target-shaped; only the transport is emulated.
- `read` / `materialize` / `readSynced` / `processInbox` / `watch`**stand-ins for the recipient's own verifier processing**, which has no consumer-facing JS surface upstream and may never have this list-of-deposits shape. A consumer should treat "my inbox gets processed when I connect, and applied caps just appear in what I hold" as the durable contract (that is what `connectedUser` automates, § 13); code that leans on enumerating raw deposits as a mailbox UI is coding against emulation detail it may have to unlearn. The consumer-payload case (`Deposit.payload` as app data) maps to `InboxMsgContent` variants upstream (`types.rs:4249-4260`), of which only `ContactDetails` and `SocialQuery` are more than unit variants today — arbitrary app payloads through the inbox are an **ASSUMPTION**, constrained by the model only in that messages are sealed, per-recipient, and applied by the recipient.
- `watch`'s `_opts?: { intervalMs?: number }` is accepted and **ignored** (kept for signature compatibility with a removed polling watcher) — dead surface, see § 15.
---
## 10. Capabilities — possession, not ACL
### Today
```ts
// @ng-eventually/client — nuri.ts:50,60 (type guards; the only doors from string to typed)
export function isNuri(s: string): s is Nuri;
export function hasReadCap(s: string): s is ReadCap;
// @ng-eventually/client — types.ts:11,34
export type Nuri = `did:ng:${string}`;
export type ReadCap = `did:ng:${string}:r:${string}`;
// @ng-eventually/client/polyfill — polyfill.ts:205
export function capFor(nuri: Nuri): ReadCap | undefined;
// polyfill.ts:193 — hands out the registry itself
export function getCaps(): CapRegistry;
// polyfill.ts:215 — tests / fresh wallet only
export function resetCaps(): void;
// @ng-eventually/client/polyfill — caps.ts:59 (class CapRegistry)
constructor(holder?: () => PrincipalId | null);
mint(nuri: Nuri): ReadCap;
learn(cap: ReadCap): void;
capFor(nuri: Nuri): ReadCap | undefined;
publishRepoLink(nuri: Nuri): ReadCap;
isPublished(nuri: Nuri): boolean;
open(nuri: Nuri, scope: Scope): ReadCap;
isEnforcing(): boolean;
onChange(listener: () => void): () => void;
grantWrite(doc: Nuri, principal: PrincipalId): void; // decorative until P1b
governsWrite(doc: Nuri): boolean; // decorative until P1b
canWrite(doc: Nuri, principal: PrincipalId | null): boolean; // decorative until P1b
hasWritePolicy(): boolean; // decorative until P1b
clear(): void;
```
### Target
**LEVEL-1 SHAPE.** There is no capability API at level 2 or 3 (no cap method in `index.d.ts`, none in the ORM), and there is **nothing to introspect upstream**: reading is key possession. The model, VERIFIED:
- A ReadCap is the serialized `ObjectRef``format!("r:{}", base64_url::encode(&ser))` (`BlockRef::readcap_nuri`, `engine/repo/src/types.rs:518-521`). The lib's `ReadCap` template-literal grammar (`…:r:{cap}`) is upstream's, with the stand-in constant `OK` in place of the key material (P1b swaps the value, not the shape).
- Caps live in two durable registers by origin: created documents → `AddRepo { read_cap }` on the store's Store branch (`engine/repo/src/types.rs:1890-1899`, committed by `doc_create` via `send_add_repo_to_store`, `engine/verifier/src/request_processor.rs:698`); received caps → `AddLink { read_cap }` on the private store's User branch (`types.rs:1939-1948`).
- The one path that loads a repo from a cap is `pub(crate)``Verifier::load_repo_from_read_cap` (`engine/verifier/src/verifier.rs:2237`) — unexposed to JS.
`capFor(nuri)` asks the only question the model admits — "do I hold this document's key?" — and returning `undefined` is the whole possible answer. There is no "may principal P read D?" anywhere, and the future SDK cannot offer one without inventing an ACL the engine does not have. That absence is a **finding about the target's model**, not a missing feature: a consumer should never expect a cap-introspection API.
The `CapRegistry` class itself is machinery (the in-memory record of what the connected holder holds — upstream's local user storage). The consumer-facing surface is `capFor` + the acts (`shareCap`, creating a document, processing one's inbox); see § 15.
---
## 11. NURI and SPARQL string utilities
### Today — `@ng-eventually/client`
```ts
// sparql.ts:32,66,95
export function escapeLiteral(value: string): string;
export function escapeIri(value: string): string;
export function assertNuri<T extends string>(nuri: T): T;
```
(`isNuri` / `hasReadCap` are in § 10; `targetOf`, `parseNuri`, `mintCap` exist in `nuri.ts` but are **not** exported from either entry point — deliberately: nothing on the surface turns a bare reference into a cap.)
### Target
**NO COUNTERPART at any level, and none expected.** Neither `@ng-org/web` nor the ORM exposes SPARQL escaping helpers (re-verified against `index.d.ts` and `sdk/js/orm/src`); the engine does its own ad-hoc literal escaping internally where it builds SPARQL (e.g. `update_header`, `engine/verifier/src/request_processor.rs:196-208`). These are generic injection-safety utilities, not SDK anticipation: they stay useful to any app that builds SPARQL by interpolation, against this lib or the real SDK. Nothing to unlearn; also nothing that migration replaces.
---
## 12. Scope resolution, per-entity documents, and the store registry
### Today — `@ng-eventually/client` (namespace `storeRegistry`) — plus `Scope` from `types.ts`
> **Narrowed 2026-08-03.** The entry used to re-export the WHOLE `store-registry` module. It now re-exports an app-facing slice (`src/store-registry-api.ts`): `createEntityDoc`, `listMyEntityDocs`, `resolveScopeGraph`, `resolveWriteGraph`, `walletInbox`, `openDocumentInbox`, `documentInboxAddress`. The rest — `userStoreDoc`, `isOwnInbox`, `myInboxes`, `addLink`, `readLinks`, `resolveAccount`, `ensureAccount`, `reservedAccount`, `resetRegistryCache`, and the `AccountRecord` / `RegistrySession` types — is **no longer importable from `@ng-eventually/client`** and is covered by `docs/internal-contract.md`. The signatures below are kept for the record, marked accordingly.
```ts
// types.ts:38 — NB: NOT the ORM's Scope (a graphs/subjects filter); this is the store scope
export type Scope = "public" | "protected" | "private";
// store-registry.ts:90,234
export interface AccountRecord {
id: string;
docPublic: Nuri;
docProtected: Nuri;
docPrivate: Nuri;
}
export interface RegistrySession {
sessionId: string;
privateStoreId: string;
protectedStoreId?: string;
publicStoreId?: string;
}
// consumer-facing, designed to survive migration (store-registry.ts:917, 1358, 1079, 735, 698)
export async function createEntityDoc(id: string, scope: Scope): Promise<Nuri>;
export async function listMyEntityDocs(id: string, scope: Scope): Promise<Nuri[]>;
export async function userStoreDoc(id: string, scope: Scope): Promise<Nuri>;
export async function resolveScopeGraph(scope: Scope): Promise<Nuri>;
export async function resolveWriteGraph(id: string, scope: Scope): Promise<Nuri>;
// inbox-side (store-registry.ts:772, 837, 1133, 1203, 1286)
export async function walletInbox(id: string): Promise<Nuri>;
export async function isOwnInbox(nuri: Nuri): Promise<boolean>;
export async function openDocumentInbox(doc: Nuri): Promise<Nuri>;
export async function documentInboxAddress(doc: Nuri): Promise<Nuri | undefined>;
export async function myInboxes(): Promise<Nuri[]>;
// User-branch registers (store-registry.ts:1307, 1331)
export async function addLink(cap: ReadCap): Promise<void>;
export async function readLinks(): Promise<ReadCap[]>;
// shim machinery (store-registry.ts:542, 631, 213, 278)
export async function resolveAccount(id: string): Promise<AccountRecord | null>;
export async function ensureAccount(id: string): Promise<AccountRecord>;
export function reservedAccount(name: string): string;
export function resetRegistryCache(): void;
```
### Target — split by what each piece maps to
- **`createEntityDoc(id, scope)` → level 2, VERIFIED direction.** Target: `doc_create(session_id, crdt, class_name, destination, store_repo)` aimed at the identity's real per-scope store (see § 7 for the store-targeting nuance — the nodejs SDK already takes `store_type`/`store_repo` strings). The two writes the lib performs by hand are **native side effects** of `doc_create` upstream: the `ldp:contains` listing on the store's Main branch and the `AddRepo { read_cap }` on its Store branch (`engine/verifier/src/request_processor.rs:697-710`). The `id` parameter disappears (the session IS the identity); expect `createEntityDoc(id, scope)` to become `doc_create(sid, …, storeOf(scope))` with no listing/cap bookkeeping.
- **`listMyEntityDocs(id, scope)` → level 1/2, VERIFIED mechanism.** Upstream the listing is the store's `ldp:contains` graph (written at `request_processor.rs:706-708`), readable with an anchored `sparql_query` on the store; the caps come back by replaying the Store branch (`AddRepo::verify``load_repo_from_read_cap`). The function's shape (give me my per-scope doc NURIs) survives; its implementation becomes one native read.
- **`userStoreDoc(id, scope)` / `resolveScopeGraph(scope)` / `resolveWriteGraph(id, scope)` → level 2, VERIFIED.** The target answers these from the session: `did:ng:` + `session.private_store_id | protected_store_id | public_store_id` (`Session`, `index.d.ts:264-272`). The store IS the container; the per-scope index document disappears.
- **`walletInbox(id)` → level 1, VERIFIED counterpart with a different granularity.** Upstream a user's inboxes are their public and protected STORE repos' inboxes — the only two `AddInboxCap` commits in the engine (`engine/verifier/src/site.rs:128,149`). An identity-level "my inbox" therefore maps to a store inbox; the resolution moves into the lib/SDK and the consumer's act (deposit to an address, process my own) is unchanged.
- **`openDocumentInbox(doc)` / `documentInboxAddress(doc)` → level 1, VERIFIED support, no exerciser.** Every `Repo` carries `inbox: Option<PrivKey>` (`engine/repo/src/repo.rs:126`); `AddInboxCapV0` is keyed by `repo_id` with no is-store restriction (`engine/repo/src/types.rs:1973`; applied at `engine/verifier/src/verifier.rs:1920-1928`); but no code path creates one for a plain document (`doc_create` leaves `inbox: None`, `repo.rs:574`) and no level-2/3 API exposes any of it. So: the *capability* is engine-verified; the *functions* are invented surface; and the **address publication is a real, deliberate divergence** (upstream transmits addresses, never publishes them — § 9), with the ownership guard compensating our design, not mirroring an upstream rule.
- **`addLink(cap)` / `readLinks()` → level 1, VERIFIED model, no JS surface.** The emulated `AddLink { read_cap }` register (`engine/repo/src/types.rs:1939-1948`*"so that a user can share with all its device a new Link they received"*, external repos only). Upstream this filing happens inside the verifier when it processes the inbox; the future SDK most likely never exposes these as calls, so consumers should not code against them (§ 15).
- **`resolveAccount` / `ensureAccount` / `AccountRecord` / `RegistrySession` / `reservedAccount` / `resetRegistryCache` → NO COUNTERPART.** The shared-wallet shim (accounts directory, pointer → doc-shim indirection) has no image in the target — the target has no central directory of identities (`docs/migration-guide.md` § 3). The whole group disappears with the shim.
- **`isOwnInbox` / `myInboxes` → NO COUNTERPART as API.** Upstream the question "which inboxes may I read" is answered inside the verifier by the User branch's `AddInboxCap` records; nothing suggests a JS API for it. These exist for the emulated read guard and the connection drain.
---
## 13. Identity and connection
### Today
```ts
// @ng-eventually/client — accounts.ts (namespace accounts)
export const ACCOUNT_STORAGE_KEY = "ng-eventually.account.id"; // :18
export interface AccountStorage { // :26
getItem(key: string): string | null;
setItem(key: string, value: string): void;
removeItem(key: string): void;
}
export class IdentityStore { // :37
constructor(storage: AccountStorage | null, key?: string);
get(): string | null;
set(id: string): string | null;
clear(): void;
}
export function browserIdentityStore(key?: string): IdentityStore; // :89
// @ng-eventually/client/polyfill
export function setCurrentUser(id: PrincipalId | null): void; // polyfill.ts:171
export function getCurrentUser(): PrincipalId | null; // polyfill.ts:187
export async function connectedUser(): Promise<void>; // connect.ts:52
```
### Target
**PASSTHROUGH-to-be at level 2, VERIFIED signatures.** In the target the identity is established by opening one's own wallet and starting a per-user session — there is no "set the current user" call because the session IS the user:
```ts
// index.d.ts:276, 280, 313, 315
declare function session_start(wallet_name: string, user_id: any): Promise<any>;
declare function session_stop(user_id: string): Promise<void>;
declare function user_connect(client_info: any, user_id: string, location?: string | null): Promise<any>;
declare function user_disconnect(user_id: string): Promise<void>;
```
- `accounts.*` (the persisted identity id) — **NO COUNTERPART**; removed at migration (`docs/migration-guide.md` § 5). It exists only because every virtual user shares one wallet. It is exported from the SDK entry, which is a placement wart (§ 15).
- `setCurrentUser` / `getCurrentUser`**NO COUNTERPART**; the relay of an identity the broker cannot see. Disappears with the shared wallet.
- `connectedUser()` — the awaitable form of what the target does **automatically**: the recipient's verifier processes its inbox as messages arrive/at connection (`Verifier::inbox`, `engine/verifier/src/verifier.rs:1674`). VERIFIED at level 1 that no consumer call is needed upstream; the polyfill fires it from `setCurrentUser` for the same reason. A consumer should treat it as "await a deterministic start" (tests), not as an operation the future SDK will name.
---
## 14. Type re-exports
`@ng-eventually/client` re-exports, type-only (erased at build, `index.ts:48-50`):
```ts
export type { ShapeType, BaseType, Schema } from "@ng-org/shex-orm";
export type { DeepSignalSet } from "@ng-org/alien-deepsignals";
export type { NG } from "@ng-org/web";
```
**PASSTHROUGH (levels 2/3, VERIFIED)**`ShapeType`/`BaseType` at `@ng-org/shex-orm` `dist/types.d.ts:5,12` (installed 0.1.2-alpha.8); `NG` at `index.d.ts:136`. At migration these imports point at the same packages directly; nothing changes for the consumer.
---
## 15. Machinery on the surface — what a consumer should NOT code against
Exported, but not SDK surface. Coding against these builds knowledge that migration deletes:
- **`docs.depositInto`** — the named boundary-crossing write `inbox.post` uses. It is exported only because `inbox.ts` lives in another module; a consumer must always go through `inbox.post` / `inbox.shareCap`. Upstream a deposit is a sealed message, not a SPARQL update — this function's very signature is emulation.
- **`getConfig` / `getStoreRegistryDeps`** — tagged `@internal` in source, exported for the lib's own wrappers.
- **`resetConfig` / `resetStoreRegistry` / `resetCaps` / `storeRegistry.resetRegistryCache`** — test/reset machinery. In particular `resetCaps` wipes EVERY holder's caps, which no product flow should ever do.
- **`getCaps()` and the `CapRegistry` class** — the registry is the emulation's engine room. The consumer surface is `capFor` (possession lookup), `inbox.shareCap` (grant), and the acts that file caps implicitly (creating a document, processing one's inbox). `CapRegistry.grantWrite` / `governsWrite` / `canWrite` / `hasWritePolicy` are explicitly decorative until P1b — the guard they feed is bypassed by every internal writer.
- ~~**`storeRegistry.reservedAccount`, `resolveAccount`, `ensureAccount`, `AccountRecord`, `RegistrySession`**~~ — **RESOLVED 2026-08-03**: no longer exported. Shim internals, now in `docs/internal-contract.md`. The consumer's legitimate touchpoint is `configureStoreRegistry` (bootstrap) plus the scope/entity resolvers.
- ~~**`storeRegistry.addLink` / `readLinks`**~~ — **RESOLVED 2026-08-03**: no longer exported. Consumers receive caps by processing their inbox (automated at connection); calling these directly baked in a register the verifier owns upstream.
- ~~**`accounts.*` on the SDK entry**~~ — **RESOLVED 2026-08-03**: moved to `/polyfill`, where its disappearance at migration is visible at the import line.
- **`inbox.watch`'s `_opts?: { intervalMs?: number }`** — accepted and ignored (no polling exists). Dead compatibility surface; do not pass it.
- **The `label` parameters** on `docs.sparqlUpdate` / `docs.sparqlQuery` / `docs.depositInto` — lib-internal access-log tags, never forwarded to `ng`. The real signatures have no such parameter.
### Places the current surface teaches something to unlearn
- ~~**The SDK entry is not as pure as its header claims.**~~ **FIXED 2026-08-03.** The header claimed the entry "exposes ONLY what `@ng-org/web` / `@ng-org/orm` expose" while also shipping `accounts` and the whole `store-registry` module. Both are gone from it, and the header now states what the entry actually promises: *every symbol here has a target-SDK counterpart, verified or assumed, listed in this document*. It still exports `docs`, `readModel`, `watchShape`, `subscribeDoc(s)`, the SPARQL helpers and the NURI guards — justified inventions, documented per subject above — so the promise is no longer "@ng-org surface only", which was never true, but "nothing here is machinery".
- **`shareCap` is importable from both entries** (`inbox.shareCap` on the SDK entry via `export * as inbox`, and a named re-export on `/polyfill`). The polyfill re-export exists "so the cap vocabulary stays on the polyfill side" — but the namespace export undoes that. Harmless functionally; blurs the same boundary.
- **`inbox.read`/`materialize` as a mailbox** — enumerating raw deposits is emulation detail (§ 9); the durable contract is deposit-and-it-gets-applied. An app building UI on the deposit list should expect that surface to change shape entirely.
- **`watchShape`'s "planned `useShape` upgrade"** — stated in the module header with no provenance in this repo or the clone (§ 5). The load-state *distinction* is safe; the claim that NextGraph plans this exact hook shape is an assumption and must not be cited as an announced API.
- **`UnionSubject` property bags** — polyfill read-model shape, not a target type; map them into app types at the boundary (which `watchShape`'s design already assumes).
- **The sync-returning `subscribeDoc` unsubscribe** vs the target's promise-resolved one (§ 8) — a deliberate, documented ergonomic delta; an adapter is one line at migration, but it is a delta.
---
## Appendix — full export inventory (for diffing)
`@ng-eventually/client` (from `index.ts`): types `Nuri`, `ReadCap`, `Scope`, `PrincipalId`, `NgLike`, `UseShapeLike`, `ShapeQuery`, `ShapeObservable`, `DocChange`, `DocChangeType`, `Unsubscribe`, `UnionSubject`, `AccountRecord`, `RegistrySession`, `AccountStorage`, `Deposit`, `PostOptions` (via namespaces), re-exported `ShapeType`, `BaseType`, `Schema`, `DeepSignalSet`, `NG`; values `ng`, `useShape`, `watchShape`, `init`, `initNg`, `subscribeDoc`, `subscribeDocs`, `docChangeType`, `escapeLiteral`, `escapeIri`, `assertNuri`, `isNuri`, `hasReadCap`; namespaces `inbox` (`post`, `postToDocument`, `shareCap`, `read`, `materialize`, `readSynced`, `processInbox`, `watch`), `docs` (`docCreate`, `sparqlUpdate`, `sparqlQuery`, `depositInto`), `readModel` (`readUnion`), `storeRegistry` (`reservedAccount`, `resetRegistryCache`, `resolveAccount`, `ensureAccount`, `resolveWriteGraph`, `resolveScopeGraph`, `walletInbox`, `isOwnInbox`, `createEntityDoc`, `userStoreDoc`, `openDocumentInbox`, `documentInboxAddress`, `myInboxes`, `addLink`, `readLinks`, `listMyEntityDocs`), `accounts` (`ACCOUNT_STORAGE_KEY`, `IdentityStore`, `browserIdentityStore`).
`@ng-eventually/client/polyfill` (from `polyfill.ts`): types `StoreRegistryDeps`, `EventuallyConfig`; values `configure`, `getConfig`, `resetConfig`, `configureStoreRegistry`, `getStoreRegistryDeps`, `resetStoreRegistry`, `setCurrentUser`, `getCurrentUser`, `getCaps`, `capFor`, `resetCaps`, `CapRegistry`, `shareCap`, `connectedUser`.
Not exported from either entry (internal, listed to preempt "why isn't X documented"): `nuri.targetOf` / `parseNuri` / `mintCap`, `subscribePhysicalDoc`, `machinery.*`, `open-repo.*`, `read-filter.*`, `reach.*`, `physical.*`, `access-log.*`, `outbox-log.*`, `connect.startConnect`.
+1 -1
View File
@@ -182,7 +182,7 @@ Not started. It changes the consumer contract in the right direction (one less o
- **`open(nuri, scope)` was kept** (it is in neither the §6 table nor the removals) as the single "this document is mine, in this scope" act — `mint` for protected/private, `publishRepoLink` for public. It no longer touches write caps: arming that guard would be enforcement this batch does not do. - **`open(nuri, scope)` was kept** (it is in neither the §6 table nor the removals) as the single "this document is mine, in this scope" act — `mint` for protected/private, `publishRepoLink` for public. It no longer touches write caps: arming that guard would be enforcement this batch does not do.
- **`grantWrite` / `canWrite` were left exactly as they were** (an authorization list, decorative, guard bypassed by every internal writer) and now have to be called explicitly — `open` used to set them as a side effect. They belong to P1b. - **`grantWrite` / `canWrite` were left exactly as they were** (an authorization list, decorative, guard bypassed by every internal writer) and now have to be called explicitly — `open` used to set them as a side effect. They belong to P1b.
- **`shareCap` is implemented in `inbox.ts`** and re-exported from `/polyfill`, so it is reachable both as `inbox.shareCap` (SDK-identical entry, via `export * as inbox`) and from the polyfill surface. Deliberate: sharing a cap **is** an inbox deposit upstream, and at migration this call becomes `inbox_post_link` — a real SDK method — so hiding it from the SDK entry would have been the less faithful choice. §7's boundary holds where it matters: the registry, `capFor` and `CapRegistry` stay on the polyfill side, and every signature is a plain string. - **`shareCap` is implemented in `inbox.ts`** and re-exported from `/polyfill`, so it is reachable both as `inbox.shareCap` (SDK-identical entry, via `export * as inbox`) and from the polyfill surface. Deliberate: sharing a cap **is** an inbox deposit upstream, and at migration this call becomes the native sealed deposit — a real SDK method, whatever it ends up being called (`inbox_post_link` was our own proposed name, not an announced API) — so hiding it from the SDK entry would have been the less faithful choice. §7's boundary holds where it matters: the registry, `capFor` and `CapRegistry` stay on the polyfill side, and every signature is a plain string.
- **The stand-in key is the constant `OK`** (`nuri.ts`; it was a derived FNV-1a digest until the PO simplified it on 2026-07-30). The only question the emulation answers is *do I hold this cap or not*, so the value says that and nothing more — a digest merely looked like a key. Possession is a shape here, not a protection; P1b replaces the constant with a real key. - **The stand-in key is the constant `OK`** (`nuri.ts`; it was a derived FNV-1a digest until the PO simplified it on 2026-07-30). The only question the emulation answers is *do I hold this cap or not*, so the value says that and nothing more — a digest merely looked like a key. Possession is a shape here, not a protection; P1b replaces the constant with a real key.
- **`resetCaps()` clears in place** rather than rebuilding the registry, so a `watchShape` subscribed to the change signal does not end up holding a listener on an orphaned instance. - **`resetCaps()` clears in place** rather than rebuilding the registry, so a `watchShape` subscribed to the change signal does not end up holding a listener on an orphaned instance.
- **The scope-index feed is holder-scoped** (`fileOwnCaps` compares through the shim key): the cross-account fan-out `listEntityDocs` files nothing, because other accounts' caps are emphatically not ours to hold. - **The scope-index feed is holder-scoped** (`fileOwnCaps` compares through the shim key): the cross-account fan-out `listEntityDocs` files nothing, because other accounts' caps are emphatically not ours to hold.
@@ -186,11 +186,13 @@ So the store-root pointer, the doc-shim and the account records go through the m
7. ~~**Per-document inboxes**~~**DONE 2026-08-03.** Upstream a repo carries `inbox: Option<PrivKey>` (`engine/repo/src/repo.rs:126`): an inbox is a keypair on the document whose PRIVATE half the owner holds, recorded with `AddInboxCap { repo_id, overlay, priv_key }` on the **User branch** — the same branch as `AddLink`, and with the same stated purpose (*"so that a user can share with all its device"*). So "which inboxes may I read" has exactly one answer, and it is the one place to look. 7. ~~**Per-document inboxes**~~**DONE 2026-08-03.** Upstream a repo carries `inbox: Option<PrivKey>` (`engine/repo/src/repo.rs:126`): an inbox is a keypair on the document whose PRIVATE half the owner holds, recorded with `AddInboxCap { repo_id, overlay, priv_key }` on the **User branch** — the same branch as `AddLink`, and with the same stated purpose (*"so that a user can share with all its device"*). So "which inboxes may I read" has exactly one answer, and it is the one place to look.
`storeRegistry.documentInbox(doc)` resolves — creating on first ask — the inbox of a document this user owns, recording the pair on its User branch. **Lazy**: minting an inbox document for every entity up front would double every `createEntityDoc` for inboxes most documents never receive anything in. `myInboxes()` enumerates both levels, `isOwnInbox` answers from the same record, and `connect.connectedUser` drains them all in one call. *(Renamed and split on 2026-08-03 — `documentInbox` became `openDocumentInbox` (own the inbox) + `documentInboxAddress` (find where to deposit). Conflating the two made per-document inboxes unusable by anyone but their owner; see [`2026-08-03-document-inbox-addressing.md`](2026-08-03-document-inbox-addressing.md).)*
`storeRegistry.openDocumentInbox(doc)` resolves — creating on first ask — the inbox of a document this user owns, recording the pair on its User branch. **Lazy**: minting an inbox document for every entity up front would double every `createEntityDoc` for inboxes most documents never receive anything in. `myInboxes()` enumerates both levels, `isOwnInbox` answers from the same record, and `connect.connectedUser` drains them all in one call.
The asymmetry holds at both levels, and a test walks it: **anyone deposits** into a document's inbox (that is how a third party reaches its owner at all), **only the owner reads** it. The asymmetry holds at both levels, and a test walks it: **anyone deposits** into a document's inbox (that is how a third party reaches its owner at all), **only the owner reads** it.
**Correction 2026-08-03 — this step ANTICIPATES, it does not mirror.** The paragraph above overstated the upstream fact, and an adversarial doc review caught it. Verified: `new_store_default` attaches an inbox only `if !private` (`engine/verifier/src/verifier.rs:2994`), and `doc_create` goes through `new_repo_default`, which leaves `inbox: None` (`engine/repo/src/repo.rs:574`). The **only** two `AddInboxCap` commits in the engine are for the public and protected STORE repos (`engine/verifier/src/site.rs:128,149`). So today **no document has an inbox, and neither does the private store**. What is genuinely verified is the *shape*: `AddInboxCapV0` is keyed by `repo_id` (`engine/repo/src/types.rs:1973`), so the record accommodates an inbox on any repo. The step stands as delivered — a per-document inbox is what the consumer needs and what the record's shape supports — but it must be read as a bet on where upstream goes, not as an emulation of what it already does. **Correction 2026-08-03, twice.** *First:* the paragraph above overstated the upstream fact an adversarial doc review caught it. *Then the correction itself overshot*, calling the step an ANTICIPATION: the engine **supports** an inbox on any repo (`inbox` is a field of every `Repo`, `AddInboxCapV0` is keyed by `repo_id`, and `update_inbox_cap_v0` applies it with no `is_store` check — `engine/verifier/src/verifier.rs:1920`). What no code path does is CREATE one for a document. Verified: `new_store_default` attaches an inbox only `if !private` (`engine/verifier/src/verifier.rs:2994`), and `doc_create` goes through `new_repo_default`, which leaves `inbox: None` (`engine/repo/src/repo.rs:574`). The **only** two `AddInboxCap` commits in the engine are for the public and protected STORE repos (`engine/verifier/src/site.rs:128,149`). So today **no document is GIVEN an inbox, and neither is the private store** — which says nothing about what may have one. The step stands as delivered, and it is aligned on the engine's model rather than betting past it.
## Relation to P1b ## Relation to P1b
@@ -2,6 +2,24 @@
**Raised 2026-08-03, from the consumer side (Festipod), after an attempt to solve it in the app proved it does not belong there.** **Raised 2026-08-03, from the consumer side (Festipod), after an attempt to solve it in the app proved it does not belong there.**
> ## IMPLEMENTED 2026-08-03 — shape 2 (the library publishes), with the cost objection taken as binding
>
> `storeRegistry.documentInboxAddress(doc)` answers *"where do I deposit for this document"* for **any holder**, and `inbox.post` into it. The address is published **at creation**, in a compartment the library owns — so it never enters a consumer shape.
>
> **On the cost — and the wrong answer that was tried first.** The measured regression (9m37 → 21m30) came from creating a second **DOCUMENT** per document. The first fix pointed every document's published address at the owner's **own** inbox: no second document, cost amortized. **It was reverted the same day**, because it emulates a relation upstream cannot express — the verifier routes an incoming message by `inboxes: PubKey → RepoId` and unseals it with THAT repo's private half (`engine/verifier/src/verifier.rs:1677,1928`), and `InboxMsgBody` carries no target document (`engine/net/src/types.rs:4265`) because the address already identifies it. Many documents behind one inbox would have forced consumers to tag deposits with their document — a habit to unlearn at migration, which is precisely what this library exists to prevent.
>
> **The cost, actually answered:** only documents meant to RECEIVE open an inbox, and their owner is who knows. `createEntityDoc` publishes nothing; an app calls `openDocumentInbox(doc)` for the documents that need one (in the consumer's case: events, not every entity). Cost becomes proportional to the need, with the shape intact.
>
> Shape 1 of this brief (derivation) was **not available**: our inbox is a document, and a derived NURI would name a repo `doc_create` never created — upstream can derive because an inbox there is a keypair on the repo, not a document.
>
> **`inbox.postToDocument(doc, { payload })`** is the one call an app makes: it names the DOCUMENT, never an inbox, and **throws** when the document has no inbox rather than returning quietly — a deposit that vanishes without an error is the bug this whole path exists to close. There is deliberately **no target-document field on a deposit**, for the reason above.
>
> **Where the address lives.** On the document's emulated **Header branch** (`urn:ng-eventually:shim:headerBranch`), beside the content rather than in it — the same subject-as-compartment shape already used for the Store and User branches. `read-model` now drops every subject under the reserved `urn:ng-eventually:` namespace (`src/machinery.ts`), so the address cannot surface as one of the entity's properties. That filter is by SUBJECT, so it covers every emulated compartment present and future.
>
> **The open question, answered.** *"Is 'a document has an inbox addressable by any holder' upstream, or this library's invention?"* — **upstream, as a capability**; ours only as a JS surface. The engine lets any repo have an inbox: `inbox: Option<PrivKey>` is a field of every `Repo` (`engine/repo/src/repo.rs:126`), `AddInboxCapV0` is keyed by `repo_id` (`engine/repo/src/types.rs:1973`), and `update_inbox_cap_v0` applies it with **no `is_store` check** (`engine/verifier/src/verifier.rs:1920`), at any time (User-branch commit, `commit.rs:1043-1050`, whose type documents the update case). What no code path does is CREATE one for a document: `new_store_default` attaches one only `if !private` (`verifier.rs:2994`), `doc_create` leaves `inbox: None` (`repo.rs:574`), and the engine's only two `AddInboxCap` commits are for the public and protected STORE repos (`site.rs:128,149`). "Does not" is not "cannot" — an earlier version of this note called the feature an anticipation on exactly that confusion. The half-split is upstream's too — a depositor seals with the inbox PUBLIC key (`engine/net/src/types.rs:4299`) and only the owner holds the private half — which is why an address is public by nature and belongs on the document, not on the owner's User branch.
>
> **Also fixed, and it was the root of the reported symptom.** `openDocumentInbox` (formerly `documentInbox`) called by a non-owner used to mint a parallel inbox and record it for the caller — no error, deposits lost. It now refuses, on OWNERSHIP (read from the Store branches), not on cap possession: a cap can be received, and a recipient must not be able to redirect the owner's deposits to itself.
## The problem, in one sentence ## The problem, in one sentence
`documentInbox(doc)` answers *"which inbox do **I** know for this document?"* — and mints a fresh one when the answer is none. So a third party never reaches the owner's inbox: they get one of their own, which the owner never reads, and their deposit vanishes without an error. `documentInbox(doc)` answers *"which inbox do **I** know for this document?"* — and mints a fresh one when the answer is none. So a third party never reaches the owner's inbox: they get one of their own, which the owner never reads, and their deposit vanishes without an error.
+2
View File
@@ -11,6 +11,8 @@
> >
> This ADR already recorded the first half of that verdict — *"a dedicated service with its own wallet sharing a freely-readable index is not a NextGraph shape"*, resting on a singleton-app path *"not implemented, uncertain"*. That reservation is now the conclusion. > This ADR already recorded the first half of that verdict — *"a dedicated service with its own wallet sharing a freely-readable index is not a NextGraph shape"*, resting on a singleton-app path *"not implemented, uncertain"*. That reservation is now the conclusion.
> >
> **Amended 2026-08-03 — the singleton-app half must be re-put, not cited as closed.** The NextGraph developer has since named the singleton app as *the* way an application holds data common to all its users: a document or store shared by all users, hardcoded in the app, write-owned by the developer and delegable — never delegated to all users, so user contributions arrive **through an inbox**. Still unimplemented, but no longer uncertain in direction. This does **not** reinstate discovery — the "you cannot discover, you can only follow links" verdict stands on its own footing, and count 2 above is about pooling users' data across wallets, not about an app's own data. What is void is treating "the singleton path is uncertain" as a settled argument. See [`../nextgraph-current-state.md`](../nextgraph-current-state.md) § Apps & services.
>
> **`discovery.ts` and its tests were removed on 2026-07-30**, along with `watchShape`'s public-scope fold and `INDEX_ACCOUNT`. See [`../briefs/2026-07-30-virtual-wallet-boundary.md`](../briefs/2026-07-30-virtual-wallet-boundary.md). > **`discovery.ts` and its tests were removed on 2026-07-30**, along with `watchShape`'s public-scope fold and `INDEX_ACCOUNT`. See [`../briefs/2026-07-30-virtual-wallet-boundary.md`](../briefs/2026-07-30-virtual-wallet-boundary.md).
> >
> One factual error below is worth naming so it is not carried into a future design: *"a native inbox (a primitive present on every document)"* is **false**. No document has an inbox upstream — only the public and protected STORE repos do (`engine/verifier/src/site.rs:128,149`; `doc_create` leaves `inbox: None`, `engine/repo/src/repo.rs:574`). See [`../nextgraph-current-state.md`](../nextgraph-current-state.md) § Inbox. > One factual error below is worth naming so it is not carried into a future design: *"a native inbox (a primitive present on every document)"* is **false**. No document has an inbox upstream — only the public and protected STORE repos do (`engine/verifier/src/site.rs:128,149`; `doc_create` leaves `inbox: None`, `engine/repo/src/repo.rs:574`). See [`../nextgraph-current-state.md`](../nextgraph-current-state.md) § Inbox.
+269
View File
@@ -0,0 +1,269 @@
# Internal contract — what `@ng-eventually/client` keeps off its surface, and what NextGraph does or would do about each subject
**Scope.** The complement of [`docs/api-contract.md`](./api-contract.md): every module export under `packages/client/src/` that is NOT reachable from the two published entry points (`package.json` maps exactly `.``src/index.ts` and `./polyfill``src/polyfill.ts`). A consumer never reads this document; a maintainer does. The internal code is held to the same standard as the surface — as close as possible to what NextGraph does or plans — so every subject below carries the same target-side analysis. Written 2026-08-04, verified against the `nextgraph-rs` clone (HEAD `213338f6`) and the installed `@ng-org/web@0.1.2-alpha.13` declarations (`node_modules/.bun/@ng-org+web@0.1.2-alpha.13/node_modules/@ng-org/web/dist/index.d.ts`, hereafter `index.d.ts`).
**How the boundary was computed — mechanically, from the `export` statements.** `index.ts` re-exports wholesale (`export *` / `export * as ns`) from `types.ts`, `inbox.ts`, `docs.ts`, `read-model.ts`, and by name everything `use-shape.ts`, `watch-shape.ts`, `lifecycle.ts`, `sparql.ts` export, plus `isNuri`/`hasReadCap` from `nuri.ts` and `subscribeDoc`/`subscribeDocs`/`docChangeType` (+ types) from `subscribe.ts`; its `storeRegistry` namespace is the **`store-registry-api.ts` slice only** (7 functions: `createEntityDoc`, `listMyEntityDocs`, `resolveScopeGraph`, `resolveWriteGraph`, `walletInbox`, `openDocumentInbox`, `documentInboxAddress`). `polyfill.ts` re-exports `CapRegistry` from `caps.ts`, `shareCap` from `inbox.ts`, `connectedUser` from `connect.ts`, `* as accounts` from `accounts.ts`, and the types `AccountStorage`, `AccountRecord`, `RegistrySession`. Everything else that carries `export` in a `src/` module is internal and inventoried here. Eight modules are internal in their entirety: `access-log.ts`, `machinery.ts`, `ng-proxy.ts`, `open-repo.ts`, `outbox-log.ts`, `physical.ts`, `reach.ts`, `read-filter.ts`. Four are internal in part: `nuri.ts`, `connect.ts`, `subscribe.ts`, `store-registry.ts`.
**Labels** are those of `docs/api-contract.md`: **PASSTHROUGH (level 3/2, VERIFIED)**, **LEVEL-1 SHAPE (model VERIFIED, JS surface ASSUMED)**, **ASSUMPTION**, **NO COUNTERPART**. Level numbers per `README.md` § *The three references*: 3 = JS ORM, 2 = wasm binding (`@ng-org/web`), 1 = Rust engine. One label recurs here that the surface contract rarely needs: **NO COUNTERPART, shared-wallet machinery** — the code below the emulation's floor, which the target has no image of because the target has no shared wallet. Per the design principle, an absent implementation is never treated as evidence about the future.
---
## 1. The wrapped `ng` factory — `ng-proxy.ts`
```ts
// ng-proxy.ts:10
export function makeNg(): Record<string, any>;
```
Builds the published `ng` Proxy (consumed once, `index.ts:61`): forwards every property to the injected real `ng`, overriding `login`/`session_start` (passthrough with a shared-wallet-credentials TODO) and `sparql_update` (the emulated write-cap guard).
- The factory itself is **NO COUNTERPART, by design** — the target has no "wrap the SDK" step; at migration `ng` IS `@ng-org/web`'s and `makeNg` is deleted.
- The `sparql_update` guard stands in for the engine's write-permission model (`verify_perm` inside `Commit::verify`, `engine/repo/src/commit.rs:892-899`) — same analysis as `docs/api-contract.md` § 3.
- **Defect — the `login` arm fabricates a member (see Findings F1).** `@ng-org/web` has no `login`: none among the exports of `index.d.ts` (re-verified), and no `fn login` in `sdk/js/lib-wasm/src/lib.rs`. The proxy nevertheless returns a function for `prop === "login"` (`ng-proxy.ts:16-22`), so `typeof ng.login === "function"` on the wrapper while the real SDK yields `undefined` — the one place the proxy adds a member, contradicting its own header and the surface contract's "adds no member and removes none" (§ 3). Calling it throws at runtime (`ng[prop]` is undefined). **ASSUMPTION with no provenance** — no target layer names a `login`.
- Disappears at migration (the whole module).
## 2. NURI internals — the unexported slice of `nuri.ts`
```ts
// nuri.ts:73
export function targetOf(nuri: Nuri): Nuri;
// nuri.ts:82
export function parseNuri(nuri: Nuri): { target: Nuri; readCap?: ReadCap };
// nuri.ts:116
export function mintCap(nuri: Nuri): ReadCap;
```
`targetOf` strips a `:r:` cap segment to the naming form; `parseNuri` is the parsed pair; `mintCap` builds the cap-bearing form with the stand-in value `OK`. Kept off the surface deliberately: nothing published turns a bare reference into a cap.
- `targetOf` / `parseNuri`**LEVEL-1 SHAPE, model VERIFIED**: a 1:1 mirror of upstream's one-type-with-optional-access NURI. The ReadCap encoding they discriminate on is `r:{base64url(serde_bare(ObjectRef))}` (`BlockRef::readcap_nuri`, `engine/repo/src/types.rs:518-521`), distinct from the `:k:` object/commit forms (`object_nuri`/`commit_nuri`, `types.rs:510-514`). No JS surface parses NURIs at level 2 or 3 — the real SDK takes plain strings — so these helpers never surface in signatures and survive only as internals.
- `mintCap`**NO COUNTERPART as an operation, and that is the point**: upstream a ReadCap is produced by the engine when a repo is created, never derived from a bare reference by a caller. `mintCap` exists solely because the emulation needs a cap VALUE at creation time and P1b has not yet supplied real key material; the constant `OK` pretends nothing (`nuri.ts:87-103`). It has exactly two call sites (`store-registry.ts` `createEntityDoc`; `caps.ts` internals) — the minting points of the emulation. At P1b the constant becomes a real key; at migration the function is deleted (the engine mints).
## 3. The reach boundary — `reach.ts`
```ts
// reach.ts:66
export function declareInfrastructure(nuri: Nuri): void;
// reach.ts:71
export function isInfrastructure(nuri: Nuri): boolean;
// reach.ts:76
export function resetInfrastructure(): void;
// reach.ts:93
export function mayReach(nuri: Nuri): boolean;
// reach.ts:110
export function assertMayReach(nuri: Nuri, op: string): void;
// reach.ts:131
export function mustNotAttempt(nuri: Nuri): boolean;
```
The single predicate deciding whether the CONNECTED virtual user may touch a document at all: cap possession, or explicitly-declared infrastructure (the store-root and doc-shim). `assertMayReach` guards the passage points (rule 1, throw on refusal); `mustNotAttempt` guards the callers (rule 2, do not even issue the operation). Inert until the first cap exists (`caps.isEnforcing()`).
- **NO COUNTERPART, shared-wallet machinery — the emulated stand-in for the wallet boundary itself.** In the target the boundary is cryptographic, not a predicate: a repo whose cap the wallet does not hold is never decrypted, a targeted read of it errors `RepoNotFound` (`resolve_target_for_sparql`, `engine/verifier/src/request_processor.rs:264,269`), and the only path that loads a repo from a cap is `pub(crate)` (`Verifier::load_repo_from_read_cap`, `engine/verifier/src/verifier.rs:2237`). The two-rules split (refuse at the gate AND do not attempt) is redundancy this lib chose; upstream only "cannot" exists — there is nothing to refuse because the request cannot be formed.
- The infrastructure exemption (`declareInfrastructure`, registered by `store-registry.ts` for the store-root and doc-shim only) has **no image in the target**: there is no shim to exempt. Registration-not-pattern-matching is a lib-internal safety choice.
- Everything here disappears at migration; the durable lesson it protects (naming a document does not grant access) is the target's own model.
## 4. The physical user's primitives — `physical.ts`
```ts
// physical.ts:54
export async function physicalCreate(sessionId: string, crdt = "Graph", cls = "data:graph", dest = "store", store?: unknown): Promise<Nuri>;
// physical.ts:80
export async function physicalQuery(sessionId: string, query: string, base: string | undefined, anchor: Nuri, label = "physicalQuery"): Promise<unknown>;
// physical.ts:94
export async function physicalUpdate(sessionId: string, query: string, anchor: Nuri, label = "physicalUpdate"): Promise<void>;
```
The unguarded counterparts of `docs.docCreate` / `sparqlQuery` / `sparqlUpdate`, callable only by the library's own machinery on the shim's documents (store-root pointer, doc-shim, provisioning). Separated as FUNCTIONS rather than as an exemption list so machinery never gets "waved through" a guard (module header, `physical.ts:19-27`).
- As wire calls: **PASSTHROUGH (level 2, VERIFIED)** — the same `doc_create` / `sparql_query` / `sparql_update` the published `docs.*` forwards to (`index.d.ts:60,295,297`; sources `sdk/js/lib-wasm/src/lib.rs:1575` web / `:1618` nodejs, `:352`/`:555`), minus the lib-internal `label`.
- As a CONCEPT: **NO COUNTERPART, shared-wallet machinery.** The physical/virtual user split exists only because one wallet hosts many identities; the target has exactly one user per wallet and no privileged "machinery caller". The module disappears with the shim.
## 5. Physical subscription — the unexported slice of `subscribe.ts`
```ts
// subscribe.ts:118
export function subscribePhysicalDoc(nuri: Nuri, onChange: (r: DocChange, type: DocChangeType) => void): Unsubscribe;
```
`subscribeDoc` minus the reach guard — the machinery's door to `doc_subscribe`, used by `open-repo.ts` to hold shim repos open. Same wire behaviour as the published `subscribeDoc` (analysed in `docs/api-contract.md` § 8, target `doc_subscribe`, `index.d.ts:66`, `sdk/js/lib-wasm/src/lib.rs:1908`).
- **NO COUNTERPART, shared-wallet machinery** — the guarded/unguarded pair collapses to one call when the wallet is the boundary. Disappears with `physical.ts`.
## 6. Bootstrap repo opening — `open-repo.ts`
```ts
// open-repo.ts:75
export type SyncState = "syncing" | "synced" | "timed-out";
// open-repo.ts:104 — TEST-ONLY
export function setOpenTimeoutForTests(ms: number): void;
// open-repo.ts:110
export function resetOpenedRepos(): void;
// open-repo.ts:135
export function getSyncState(nuri: Nuri): SyncState | "unknown";
// open-repo.ts:167
export async function ensureRepoOpen(nuri: Nuri): Promise<void>;
// open-repo.ts:184
export async function ensurePhysicalRepoOpen(nuri: Nuri): Promise<void>;
// open-repo.ts:259
export async function ensureReposOpen(nuris: Nuri[]): Promise<void>;
```
Heals the cold-start defect of the anchored read path: on a fresh session a not-yet-open repo reads empty, so before an anchored read the repo is opened by subscribing (`subscribePhysicalDoc`) and awaiting the first `State` push — the sync barrier — with a bounded timeout. The subscription is held for the session; per-nuri `SyncState` keeps `synced` and `timed-out` apart.
- The opening mechanism is **level 2, VERIFIED as a composition**: `doc_subscribe` exists (`sdk/js/lib-wasm/src/lib.rs:1908`), and the push variants `TabInfo`/`State`/`Patch` are the engine's `AppResponseV0` (`engine/net/src/app_protocol.rs:1354-1358`). The ORDER (TabInfo first, then the initial State) and "first State = presence guaranteed, absence definitive" are **empirical, pinned by the in-repo e2e CONTRACT-3 probe — an ASSUMPTION about ordering as far as upstream is concerned**: no upstream statement fixes the push order, so a future reordering upstream would silently break the barrier. Bound: the e2e probe fails loudly if the order changes.
- "Hold a live subscription to keep the repo open" — **ASSUMPTION** (nothing upstream documents subscription lifetime as what retains a repo in `self.repos`); observed to work, bounded by the same probe.
- **Defect — the header's mechanism claim is contradicted at the source (see Findings F2).** `open-repo.ts:10-12` says an anchored `sparql_query` on a repo absent from `self.repos` "silently returns 0 rows (never a `RepoNotFound`)". Verified upstream: absence from `self.repos` yields `Err(NgError::RepoNotFound)` (`request_processor.rs:264,269`), the ReadQuery arm converts it into `AppResponse::error` (`:1293-1296`), and the web binding REJECTS the JS promise with it (`sdk/js/lib-wasm/src/lib.rs:606`). The observed 0-rows-no-error behaviour has two candidate explanations that the source does support: a persistent verifier reloads every known repo into `self.repos` at `Verifier::load` (`engine/verifier/src/verifier.rs:535-560`) so the repo is present-but-unsynced (a genuine 0-row read), and/or the lib's own per-doc tolerance (`readUserStore`, `read-model.ts` per-doc catch) converts a rejection into an empty result. The healed symptom is real and the fix correct; the stated mechanism is not established, and a maintainer reasoning from it would mispredict behaviour whenever the verifier is not persistent.
- `SyncState` and `getSyncState` are lib-invented vocabulary — **NO COUNTERPART** (upstream has no consumer-facing "sync state of a repo" API at any level; `OrmSubscription.readyPromise`, `sdk/js/orm/src/connector/GraphOrmSubscription.ts:260`, is the closest level-3 signal, per-subscription not per-repo).
- At migration the whole module becomes "open the store by cap at bootstrap" (native) and is removed with the shim.
## 7. The read filter — `read-filter.ts`
```ts
// read-filter.ts:47
export function filterReadable<T>(items: Iterable<T>, caps: CapRegistry): T[];
// read-filter.ts:60
export function makeReadFilteredView<S extends object>(set: S, caps: CapRegistry): S;
```
The polyfill of capability-based read access: a Proxy view over the reactive set keeping only items whose `@graph` document the current holder holds; applied by `use-shape.ts` once `caps.isEnforcing()`.
- **NO COUNTERPART, by design — it stands in for cryptographic non-delivery.** In the target the broker/verifier simply never yields what the wallet holds no cap for (targeted read errors, `request_processor.rs:264,269`; union read yields nothing for undecrypted repos — the § 4 analysis of `docs/api-contract.md`). There is no post-hoc filter to migrate to; the module is deleted.
- The `@graph` key it filters on is **level 3, VERIFIED**: the ORM annotates every object with its graph NURI (`sdk/js/orm/src/frontendAdapters/react/useShape.ts:41`, `sdk/js/orm/src/types.ts:19`). Items with no `@graph` are kept — a lib policy choice (they name no document), not an upstream rule.
- Access unit = the DOCUMENT, not the item — faithful to the model (a ReadCap opens a repo, `types.rs:518-521`), and the reason the filter is all-or-nothing per document.
## 8. Connection trigger — the unexported slice of `connect.ts`
```ts
// connect.ts:91
export function startConnect(): void;
```
Fire-and-forget wrapper over the published `connectedUser()` (restore Links, then drain every inbox), called by `setCurrentUser` so inbox processing is the library's job, not the app's.
- **LEVEL-1 SHAPE for the timing, VERIFIED**: upstream the recipient's verifier processes inbox messages as they arrive, with no consumer call (`Verifier::inbox``process_inbox`, `engine/verifier/src/verifier.rs:1674-1690`); firing on connection is the emulation's equivalent moment. The restore-before-drain order is a lib choice; upstream "restore" does not exist as a step (applied caps are already in the User branch replay).
- `startConnect` itself disappears at migration; the automatic-processing behaviour it fabricates is native.
## 9. The shim registry — the unexported slice of `store-registry.ts`
The sharpest boundary case: `store-registry-api.ts` publishes the 7 app-facing calls; the 9 exports below stay internal (importable by the lib's modules, unit tests and the e2e harness, not by an application through the package entries). The types `AccountRecord` (`store-registry.ts:90`) and `RegistrySession` (`:234`) are published via `/polyfill` and covered by the surface contract.
### 9a. Account shim — provision, resolve, reserved names, cache
```ts
// store-registry.ts:213
export function reservedAccount(name: string): string;
// store-registry.ts:278
export function resetRegistryCache(): void;
// store-registry.ts:542
export async function resolveAccount(id: string): Promise<AccountRecord | null>;
// store-registry.ts:631
export async function ensureAccount(id: string): Promise<AccountRecord>;
```
`resolveAccount` — barrier-authoritative O(1) lookup of one account's record in the doc-shim; `ensureAccount` — resolve-or-provision (creates the three scope docs on first sight, concurrency-deduped); `reservedAccount` — NUL-prefixed sentinel namespace for lib-internal accounts; `resetRegistryCache` — test/wallet-switch reset.
- **NO COUNTERPART, shared-wallet machinery — the whole group.** The target has no directory of identities to resolve or provision: a user's site (three stores + their inboxes) is created once at wallet creation (`engine/verifier/src/site.rs` — the site-creation flow committing the stores and the two store-inbox `AddInboxCap`s at `:128,149`), and "which user" is the session. `ensureAccount`'s provision-on-first-sight has no target analogue and is exactly what `connectedUser` refuses to trigger (`connect.ts:60-65`). All of it disappears with the shim (`docs/migration-guide.md` § 3).
- `reservedAccount`'s collision-safety rests on an **ASSUMPTION about a consumer-injected function**: the comment (`store-registry.ts:200-206`) asserts the injected `normalizeId` can never produce a U+0000-prefixed key, but `normalizeId` is injected by the consumer and the lib's own default is a bare `trim()` (`polyfill.ts:145`), which does not strip U+0000. Bound: a consumer id would have to begin with a literal NUL to collide — implausible from any UI, but the property is the consumer's to keep, not the lib's (see Findings F5).
### 9b. Scope-index resolution
```ts
// store-registry.ts:1079
export async function userStoreDoc(id: string, scope: Scope): Promise<Nuri>;
```
The scope-INDEX document of one account (what `watchShape` subscribes to for container changes). Same resolution as the published `resolveWriteGraph`, without doubling as a write target.
- **Level 2, VERIFIED counterpart with the indirection removed**: in the target the container IS the store, and its id is on the session (`Session.private_store_id | protected_store_id | public_store_id`, `index.d.ts:264-272`); the listing a subscriber watches is the store's `ldp:contains` graph, written natively by `doc_create` (`request_processor.rs:706-708`). The function's question survives; the per-account parameter and the index-document indirection disappear.
### 9c. Inbox ownership predicates
```ts
// store-registry.ts:837
export async function isOwnInbox(nuri: Nuri): Promise<boolean>;
// store-registry.ts:1286
export async function myInboxes(): Promise<Nuri[]>;
```
`isOwnInbox` — the read guard's question (may the current identity READ this inbox); `myInboxes` — the drain list for `connectedUser` (own wallet inbox + one per document this user opened an inbox on).
- **LEVEL-1 SHAPE for the underlying record, VERIFIED; NO COUNTERPART as API.** Upstream "which inboxes may I read" is answered inside the verifier by the User branch's `AddInboxCap` records (`AddInboxCapV0 { repo_id, overlay, priv_key }`, `engine/repo/src/types.rs:1969-1981`, applied at `verifier.rs:1916-1932`) and consulted implicitly when a message arrives (`verifier.rs:1674-1690`); nothing suggests a JS API for the question at any level. Both functions exist only because the emulation must ANSWER it in JS (the read guard, the drain); they disappear with the emulated inbox read side.
### 9d. The durable Link register
```ts
// store-registry.ts:1307
export async function addLink(cap: ReadCap): Promise<void>;
// store-registry.ts:1331
export async function readLinks(): Promise<ReadCap[]>;
```
File / read back a received cap on the emulated User branch of the private store — what makes a received cap durable across sessions.
- **LEVEL-1 SHAPE, model VERIFIED; no JS surface anywhere.** The record emulated is `AddLink { read_cap }` on the User branch — *"so that a user can share with all its device a new Link they received"*, external repos only (`engine/repo/src/types.rs:1934-1950`). Upstream the filing happens inside the verifier while processing the inbox; the future SDK most likely never exposes these as calls (the surface contract's § 12 finding, restated here because these are now internal-only: the one caller is `connect.ts` / `inbox.ts`, which is exactly where upstream's verifier sits).
- At migration both are deleted; the verifier files and replays.
## 10. The machinery namespace — `machinery.ts`
```ts
// machinery.ts:32
export const MACHINERY_NS = "urn:ng-eventually:";
// machinery.ts:40
export function isMachinerySubject(subject: string | undefined): boolean;
```
The URN prefix every triple the lib writes for itself lives under, and the one predicate read paths use to keep machinery out of consumer data.
- **NO COUNTERPART, by design — the seam where the emulation pays for having no branches.** Upstream the separation is structural: a compartment is a different BRANCH with its own CRDT and topic (`BranchType`, `engine/repo/src/types.rs:1536-1551`; the Store/User/Overlay branches carry no triples at all, `BranchCrdt::None`, `types.rs:1420`), so machinery cannot appear in a content read and no subject filter exists to write. The namespace, the filter, and the four `shim:*` compartment subjects it protects all disappear at migration.
## 11. Diagnostics — `access-log.ts` and `outbox-log.ts`
```ts
// access-log.ts:25,45,50,63,76,87,99,116
export type AccessOp = "READ" | "WRITE";
export function setAccessLog(on: boolean): void;
export function enabled(): boolean;
export function activeIdentity(): string;
export function accessLogPrefix(): string;
export function logStage(line: string): void;
export function shortNuri(nuri: string): string;
export function logAccess(op: AccessOp, nuri: string, label: string, extra?: string): void;
// outbox-log.ts:62
export function inspectOutbox(): void;
```
`access-log.ts` — the off-by-default per-identity access trace for the shared-wallet isolation leak (toggled by `configure({ debugAccessLog })` or `NG_EVENTUALLY_ACCESS_LOG=1`). `outbox-log.ts` — a read-only count of the real SDK's offline write outbox at session bootstrap, warning when non-empty.
- `access-log.ts`**NO COUNTERPART, shared-wallet machinery**: the leak it makes visible cannot exist in the target (isolation is per-wallet), and the "active identity" it prefixes is the relayed virtual id that disappears with `setCurrentUser`. Deleted at migration.
- `outbox-log.ts`**NO COUNTERPART as API, but every fact it relies on is level-2 VERIFIED** in the clone: the outbox is persisted through `JsStorageConfig` (`sdk/rust/src/local_broker.rs:89-100`), keyed `ng_peer_last_seq@<peerId>` (`:119,141`) and `ng_outboxes@<peerId>@start` / `@<idx>` with zero-padded `{:05}` indexes (`:163-213`, pad at `:183,210`); the real `outbox_read_function` DRAINS on read (`session_del` per key plus the start key, `:218-224`) — which is why the probe only counts and never touches; and the storage callbacks land in browser `sessionStorage` (`sdk/js/api-web/main.ts:47,57,66`), whose access-denied error string is the one `convert_error` handles (`main.ts:18-22`). The probe reads a private persistence format of the injected SDK — acknowledged in its header as out-of-contract, hence count-only. Deleted with the rest of the trace instrumentation at migration.
---
## Findings — defects and migration risks
**F1 — `ng-proxy.ts` fabricates a `login` member the real SDK does not have.** `ng-proxy.ts:16-22` intercepts `prop === "login"`, but `@ng-org/web` exports no `login` (none in `index.d.ts`, re-verified against the full `declare function` list; no `fn login` in `sdk/js/lib-wasm/src/lib.rs`). On the wrapper `ng.login` is a function; on the real SDK it is `undefined`; calling it throws. This contradicts the module's own "surface stays identical" header and `docs/api-contract.md` § 3's "the proxy adds no member and removes none". No target layer names a `login` — the arm is an unprovenanced assumption. Cheap fix: drop the `login` case (keep `session_start`), or gate it on `typeof ng.login === "function"`.
**F2 — `open-repo.ts`'s stated mechanism is contradicted at the source.** The header (`open-repo.ts:10-12`) asserts an anchored `sparql_query` on a repo absent from `self.repos` "silently returns 0 rows (never a `RepoNotFound`)". Upstream, absence from `self.repos` errors `RepoNotFound` (`engine/verifier/src/request_processor.rs:264,269`), the ReadQuery arm wraps it as `AppResponse::error` (`:1293-1296`), and the web binding rejects the JS promise (`sdk/js/lib-wasm/src/lib.rs:606`). The observed behaviour is real but its cause is one (or both) of: the repo WAS in `self.repos` (a persistent verifier reloads all known repos at `Verifier::load`, `engine/verifier/src/verifier.rs:535-560`) and read 0 rows because unsynced; or the lib's own catch-and-continue layers absorbed the rejection. The fix (open before reading) is correct either way; the diagnosis in the header should not be relied on, and mispredicts non-persistent-verifier behaviour.
**F3 — incomplete citation in `subscribe.ts`.** `subscribe.ts:31` cites the ORM fan-out abort as "`initialize.rs:125-128`" with no path. The file is `engine/verifier/src/orm/graph/initialize.rs`; lines 125-128 are the graph loop calling `self.open_for_target(&nuri.target, true).await?` — verified, the `?` propagates `RepoNotFound` and aborts the whole subscription. Substance correct; the bare filename is unfindable without this note.
**F4 — `docs/api-contract.md` lags the `store-registry-api.ts` split.** Its § 12 and appendix still list `resolveAccount`, `ensureAccount`, `userStoreDoc`, `isOwnInbox`, `myInboxes`, `addLink`, `readLinks`, `reservedAccount`, `resetRegistryCache` as the SDK entry's `storeRegistry` namespace, and § 13/§ 15 place `accounts.*` on the SDK entry — since the split (`index.ts:34` routes through `store-registry-api.ts`; `polyfill.ts:238` carries `accounts`) those are internal or `/polyfill`. That file is being edited concurrently; noted here, deliberately not fixed by this document.
**F5 — `reservedAccount`'s collision guarantee is asserted about code the lib does not own.** `store-registry.ts:200-206` states the injected `normalizeId` "strips a leading `@`, trims, and lowercases, so a NUL prefix is unreachable" — that describes ONE consumer's normalizer, not a contract; the lib's own default is `id.trim()` (`polyfill.ts:145`), which passes U+0000 through. The reserved namespace is disjoint only if every consumer's normalizer keeps it so. Either document the requirement on `StoreRegistryDeps.normalizeId`, or reject NUL-prefixed raw ids at `accountKey`.
**Migration-risk flags (shapes that will not travel):**
- **The `(document, inbox)` pair is persisted as a space-joined string literal** (`"${doc} ${inbox}"`, written `store-registry.ts:1174`, parsed by `split(" ")` at `:1268`). Upstream the record is the typed `AddInboxCapV0 { repo_id, overlay, priv_key }` (`engine/repo/src/types.rs:1969-1981`). Internal-only and replaced wholesale at migration, but it is the one shim record with an ad hoc micro-format a future reader must know to parse.
- **`isOwnInbox` / `myInboxes`** encode questions the target answers only inside the verifier (§ 9c) — any new internal caller added to them deepens a dependency that has no successor API; keep callers to the read guard and the connection drain.
- **The sync barrier is empirical** (§ 6): "TabInfo before the first State" and "held subscription keeps the repo open" are pinned by the in-repo e2e probe, not by any upstream statement. If upstream changes push ordering or repo retention, `open-repo.ts` is the module that breaks first; the probe is the tripwire.
- **`ensureAccount`'s provision-on-first-sight** (§ 9a) is a behaviour with no target image; `connect.ts` already refuses to trigger it. Any future internal path that provisions as a side effect of resolving would be teaching the emulation something the target contradicts (creation is an explicit act at wallet/site creation, `engine/verifier/src/site.rs`).
---
## Appendix — full internal export inventory (for diffing)
Fully internal modules: `access-log.ts` (`AccessOp`, `setAccessLog`, `enabled`, `activeIdentity`, `accessLogPrefix`, `logStage`, `shortNuri`, `logAccess`); `machinery.ts` (`MACHINERY_NS`, `isMachinerySubject`); `ng-proxy.ts` (`makeNg`); `open-repo.ts` (`SyncState`, `setOpenTimeoutForTests`, `resetOpenedRepos`, `getSyncState`, `ensureRepoOpen`, `ensurePhysicalRepoOpen`, `ensureReposOpen`); `outbox-log.ts` (`inspectOutbox`); `physical.ts` (`physicalCreate`, `physicalQuery`, `physicalUpdate`); `reach.ts` (`declareInfrastructure`, `isInfrastructure`, `resetInfrastructure`, `mayReach`, `assertMayReach`, `mustNotAttempt`); `read-filter.ts` (`filterReadable`, `makeReadFilteredView`).
Internal slices of partially-published modules: `nuri.ts` (`targetOf`, `parseNuri`, `mintCap`); `connect.ts` (`startConnect`); `subscribe.ts` (`subscribePhysicalDoc`); `store-registry.ts` (`reservedAccount`, `resetRegistryCache`, `resolveAccount`, `ensureAccount`, `userStoreDoc`, `isOwnInbox`, `myInboxes`, `addLink`, `readLinks`).
Modules with no internal exports (everything they export is published): `types.ts`, `docs.ts`, `inbox.ts`, `read-model.ts`, `accounts.ts`, `caps.ts`, `sparql.ts`, `lifecycle.ts`, `use-shape.ts`, `watch-shape.ts`, `store-registry-api.ts`, and the two entry points.
+2 -2
View File
@@ -27,7 +27,7 @@ this step swaps the *emulated* key for the real one, not the model:
`r:{base64url(serde_bare(ObjectRef))}`. It is **one function** (`mintCap`), because `r:{base64url(serde_bare(ObjectRef))}`. It is **one function** (`mintCap`), because
every path now READS a stored cap instead of recomputing one. `hasReadCap` / every path now READS a stored cap instead of recomputing one. `hasReadCap` /
`targetOf` stay meaningful: the `r:` discriminant is upstream grammar, not ours. `targetOf` stay meaningful: the `r:` discriminant is upstream grammar, not ours.
- `shareCap(cap, toInbox)` becomes the native sealed delivery (`inbox_post_link` - `shareCap(cap, toInbox)` becomes the native sealed delivery (whatever the SDK ends up naming it — see the note below
and `ContactDetails.read_cap`), and `inbox.read`'s inline absorption becomes the 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 recipient's own verifier applying queued messages. **The consumer's call does not
change.** change.**
@@ -73,7 +73,7 @@ per-user wallets replace the shared one.
### 4. Real inbox → drop the in-lib read emulation ### 4. Real inbox → drop the in-lib read emulation
Replace the emulated `inbox.ts` deposit (`docs.sparqlUpdate` into a shared-wallet 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 document) with the native sealed deposit, once one is exposed to JS. **Its name and shape are NOT known**: no inbox method exists in `@ng-org/web`, the verifier has no `InboxPost` arm, and `inbox_post_link` is OUR proposed name (`fork-inbox-fallback.md`), not an announced API. On the read side the
recipient's own verifier unseals each queued sealed message and applies it inline 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 when it processes its inbox — there is no separate curator to build; the in-lib read
emulation simply goes away. *(There is no global index to replace the cross-account fan-out: both were removed on 2026-07-30 — you cannot discover in NextGraph, you follow links.)* emulation simply goes away. *(There is no global index to replace the cross-account fan-out: both were removed on 2026-07-30 — you cannot discover in NextGraph, you follow links.)*
+81 -36
View File
@@ -121,11 +121,40 @@ documents, and not the private store. `new_store_default` attaches one solely `i
`AddInboxCap` commits in the whole engine are the two in `engine/verifier/src/site.rs:128,149` `AddInboxCap` commits in the whole engine are the two in `engine/verifier/src/site.rs:128,149`
— one for the public store repo, one for the protected one. — one for the public store repo, one for the protected one.
The *register* is nonetheless per-repo: `AddInboxCapV0 { repo_id, overlay, priv_key }` **But the engine SUPPORTS an inbox on any repo — "does not" and "cannot" are different
(`engine/repo/src/types.rs:1973`) records **which repo** an inbox is opened for, so the statements.** `inbox: Option<PrivKey>` is a field of EVERY `Repo`
shape accommodates an inbox on any repo. Nothing creates one, which is a different (`engine/repo/src/repo.rs:126`), not of a store structure. `AddInboxCapV0` is keyed by
statement from the shape forbidding it — and it is why this lib's per-document inbox is `repo_id` (`engine/repo/src/types.rs:1973`) — *"Repo the Inbox is opened for"*. And
an ANTICIPATION of that shape, not an emulation of something upstream already does. `update_inbox_cap_v0` applies it via `self.repos.get_mut(repo_id)` with **no `is_store`
check of any kind** (`engine/verifier/src/verifier.rs:1920`). It is generic by
construction, and available at any time: `AddInboxCap` is a User-branch commit
(`engine/repo/src/commit.rs:1043-1050`) whose type documents the late case — *"DEPS to
the previous AddInboxCap commit(s) if it is an update"*.
So a per-document inbox is **not an anticipation**: it is an engine capability that no
code path exercises automatically and that no level-2 or level-3 API exposes. This lib
implements it aligned on the engine's model.
**An inbox address is TRANSMITTED, never published — and nothing in the engine says who
may open one.** Two facts that decide more than they look:
- `inboxes: HashMap<PubKey, RepoId>` is a field of the **Verifier**
(`engine/verifier/src/verifier.rs:105`), rebuilt empty on each construction (`:520`,
`:2820`). The inbox → repo association is **local to a session**, not a published
fact. A depositor learns a pubkey because it was **sent** to them — in a
`ContactDetails` message (`contact.inbox`) or through a profile QR code; the reply
path reads its own `repo.inbox` to include it (`request_processor.rs:736-750`).
- There is therefore **no engine guard on who opens an inbox for a repo**.
`AddInboxCap` lands on the committer's OWN User branch, so anyone may write one naming
anyone's repo. It simply reaches nobody: no one was told that pubkey means that
document.
*Consequence for this lib, and it is a real divergence:* we **publish** the address on
the document (its Header branch) because that is the only way a third party can find it
in an emulation with no message channel. That creates a vector the engine does not have
— whoever can write the document can redirect its deposits — so `openDocumentInbox`
guards on ownership. That guard compensates OUR design; it does not mirror an upstream
rule. Do not cite it as one.
A non-editor can deposit into an inbox without being invited as an editor; the owner A non-editor can deposit into an inbox without being invited as an editor; the owner
moderates. NURI: `did:ng:d:<inbox_id>`. Content: the `InboxMsgContent` enum (`ContactDetails`, moderates. NURI: `did:ng:d:<inbox_id>`. Content: the `InboxMsgContent` enum (`ContactDetails`,
@@ -151,8 +180,11 @@ it processes its inbox — there is no separate curator or materialization servi
`QrCodeProfile(Import)`, `Header`, `Create`, `FilePut`). Sending an `InboxPost` `QrCodeProfile(Import)`, `Header`, `Create`, `FilePut`). Sending an `InboxPost`
triggers nothing. triggers nothing.
- Building an `InboxPost` requires crypto sealing on the Rust side; no wasm - Building an `InboxPost` requires crypto sealing on the Rust side; no wasm
helper exposes it. A high-level `inbox_post_link` is a proposed/future API, not helper exposes it, and **no `inbox` method exists in `@ng-org/web` at all**.
yet present. *(`inbox_post_link`, named across this repo's docs, is OUR proposed name from
[`fork-inbox-fallback.md`](./fork-inbox-fallback.md) — grep `nextgraph-rs` and it
is nowhere. Nothing is announced about the eventual JS surface for inboxes: its
name and shape are unknown, not merely unimplemented.)*
- Inbox deposit is only triggered internally by `QrCodeProfileImport` - Inbox deposit is only triggered internally by `QrCodeProfileImport`
(`post_to_inbox(new_contact_details)`) and `social_query_start` (contact (`post_to_inbox(new_contact_details)`) and `social_query_start` (contact
propagation via inbox). propagation via inbox).
@@ -290,6 +322,21 @@ has both**:
has no first-`State` barrier**: an anchored read on it can return 0 rows during has no first-`State` barrier**: an anchored read on it can return 0 rows during
sync-lag with no signal distinguishing "still syncing" from "genuinely empty". sync-lag with no signal distinguishing "still syncing" from "genuinely empty".
> **`doc_fetch_repo_subscribe` / `doc_fetch_private_subscribe` are NOT alternatives to
> `doc_subscribe`** — checked 2026-08-03, because they look like ready-made
> "open a repo" calls and they are not. Neither performs any I/O: each **builds an
> `AppRequest` and returns it serialized** (`sdk/js/lib-wasm/src/lib.rs:1890`, `:1900`),
> with no `session_id` and no callback. `doc_subscribe` builds the *same* request
> (`AppRequest::doc_fetch_repo_subscribe`, `engine/net/src/app_protocol.rs:930`
> `Fetch(Subscribe)`), then adds the session id and runs it through
> `app_request_stream_` (`lib.rs:1921-1923`). They exist for a caller that wants to
> construct the request and dispatch it itself. So `ensureRepoOpen`'s
> `doc_subscribe` + wait-for-first-`State` is not duplicating an available call — using
> them instead would mean re-implementing what `doc_subscribe` already does. The
> difference in `doc_fetch_private_subscribe` is only its target
> (`NuriV0::new_private_store_target()`, the private store-root), which changes nothing
> about the barrier: a store-root still has none.
These two are **mutually exclusive**: the guessable target (store-root) is not These two are **mutually exclusive**: the guessable target (store-root) is not
barrier-authoritative, and the barrier-authoritative target (`o:` repo) is not barrier-authoritative, and the barrier-authoritative target (`o:` repo) is not
guessable. **Consequence:** you cannot build a lookup table that is BOTH reachable guessable. **Consequence:** you cannot build a lookup table that is BOTH reachable
@@ -467,39 +514,37 @@ methods: `doc_subscribe`, `orm_start_graph`, `orm_start_discrete`, `file_get`,
(`pnpm webfilebuild`). First boot is **interactive** (admin-wallet invitation (`pnpm webfilebuild`). First boot is **interactive** (admin-wallet invitation
link). Official Dockerfiles are **broken**. link). Official Dockerfiles are **broken**.
## Apps & services: mono-user, no global data ## Apps & services: shared app data goes through a hardcoded app store (section rewritten 2026-08-03)
NextGraph's app/service execution model — important because it **invalidates** NextGraph's app/service execution model, and the answer to "can an application hold data common to all its users".
the idea of "a service with its own wallet sharing global data".
> **Provenance: NOT verified against `nextgraph-rs`.** Every other section of this file > **Provenance — two layers, do not mix them.** What the **engine** contains is verified below and is vocabulary only. What the **model will be** was stated by the NextGraph developer on 2026-08-03 and is **not implemented**: treat it as the target's declared direction, not as something the clone can confirm. Per [`../README.md`](../README.md)'s design principle, never infer the target's shape from the source's current state — an absent implementation says nothing about the intended one. The previous version of this section drew exactly that inference and concluded the opposite of what the developer states; it has been replaced.
> cites the engine; this one cites nothing, and nothing in the local clone corresponds to
> it — no app/service runtime, no settings-document type, no singleton notion. It arrived
> with `bea9f51`, moved wholesale from the consumer app's repo, and its own source (an
> exchange with the PO, the official docs, or an inference) was not recorded. The
> conclusion below carries real weight — it is what deferred a global index in this lib —
> so treat it as **a claim to re-confirm with the PO**, not as an engine fact. Do not
> extend it, and do not cite it as verified.
- **Apps AND services are mono-user.** They see only **what the user makes **What the engine actually has — types, no behaviour (verified 2026-08-03):**
available** to them. There is **no global data** natively, and no central
service holding shared data.
- **Local settings document.** Every app — even a singleton — and every service
has a **settings document** the user configures it through.
- **Multi-instance apps.** A **non-singleton** app can be **instantiated several
times** (e.g. a text editor, once per open file).
- **Singleton apps.** Also **mono-user**, but **bound to a particular user (the
developer)**. A singleton app **can hold a global document**, administered by
that user.
**Consequence for a "global document" (e.g. a discovery index):** the only path - `AppManifestV0` (`engine/wallet/src/permissions.rs:113`) carries `nuri`, `origin` (webapps), `singleton: bool`, `access_requests`, `installs` (Viewer / Editor / ReadService / WriteService / Model, keyed by PrimaryClass), `dependencies`, and presentation fields.
glimpsed is a singleton app whose global document is administered by the - The JS surface exists: `init(callback, singleton, access_requests)` (`sdk/js/web/src/index.ts:51`) relays `singleton` to the wallet origin by `postMessage`. Every example passes `true`.
developer-user — though this is not implemented and not guaranteed (simpler - **Nothing consumes it.** The `permissions` module is declared by `engine/wallet/src/lib.rs:19` and imported by no other crate; `AppManifest` is constructed nowhere; no code reads `singleton`; the surrounding `AccessRequest` / `AccessGrant` machinery is in the same state. There is no app runtime, no app store, no global-document type.
paths may exist; to explore later). The model that does exist is this - The field's doc comment reads `/// cannot create Documents?` — with the question mark, in the source. It is an open design note, and the developer's statement below settles it the other way. Do not treat it as the semantics.
singleton-app one; a dedicated service with its own wallet sharing a - **A second, published gloss of the same flag disagrees with that doc comment, and it is the one that matches the developer's statement.** `sdk/js/web/README.md:90,108` annotates the argument as *"will your app create many docs in the system, or should it be launched as a unique instance"* — i.e. `singleton` is about **instance multiplicity**, not about being forbidden to create documents. That is consistent with "a singleton app can also manage ordinary per-user documents" below, and it is the reading to carry. Both README examples pass `true`.
freely-readable index is not a NextGraph shape (a service is mono-user, no global - Unrelated homonyms, so a grep does not mislead: singleton *commits* (`engine/repo/`), the broker singleton (`engine/net/src/broker.rs`), Oxigraph's `empty_singleton` (SPARQL optimiser).
data). This is why a global-index package is deferred in this lib (see the top-level
README). **The declared model (NextGraph developer, 2026-08-03 — not implemented):**
- A singleton app can **also** manage ordinary per-user documents, i.e. cover what a non-singleton app does. When both modes are needed, declaring one app as singleton is enough — there is no need for two apps.
- Centralized data for an application takes the form of a **document, or a store, shared by all its users and hardcoded in the app**.
- The **app's developer holds the write rights** on the app's documents and stores, and can **delegate** them.
- Delegation is **never to all users**. User contributions reach app-owned data **through an inbox** — this is NextGraph's general model, in which nothing is freely writable by everyone.
**Reading of the last point, since it decides the shape of any index:** an app-owned index is not a place users write to. It is a document they can read (its NURI being hardcoded) and **deposit into**, with an authority derived from the developer materializing the deposits — the same deposit-then-materialize shape the inbox already imposes elsewhere, moved up to the app level.
**Open questions to put to the developer before designing on this:**
- What exactly is hardcoded — the store's or document's NURI, and does that carry the read capability?
- How is write delegation transmitted, and is it revocable?
- Who processes the app store's inbox: an instance running with the developer's rights, a service, something else? A deposit nobody materializes is not an index.
**Bearing on [`decisions/discovery-model.md`](decisions/discovery-model.md):** that ADR's superseding block leans in part on the singleton-app path being "not implemented, uncertain". The path is still not implemented, but it is no longer uncertain in direction — the developer names it as *the* way to hold data common to all users. That does not reinstate discovery: the "you cannot discover, you can only follow links" verdict rests on its own footing (the PO, 2026-07-30). It does mean the *singleton-app* half of that reasoning must be re-put rather than cited as closed.
## Third-party wallet auto-import constraint ## Third-party wallet auto-import constraint
+39 -14
View File
@@ -200,16 +200,40 @@ store-id:
blocker, [`migration-guide.md`](./migration-guide.md)). At migration each scope blocker, [`migration-guide.md`](./migration-guide.md)). At migration each scope
resolves to the user's real per-scope store — the change is in this function, resolves to the user's real per-scope store — the change is in this function,
and the consumer application is unchanged. and the consumer application is unchanged.
- **`walletInbox(id)` / `documentInbox(doc)`** — an inbox BELONGS to someone. The - **`walletInbox(id)` / `openDocumentInbox(doc)`** — an inbox BELONGS to someone. The
first is a virtual user's own inbox (where Links arrive), the second the inbox of first is a user's own inbox (where Links arrive), the second a DEDICATED inbox for
one of its documents, created on first ask. Both are dedicated documents (real one of its documents, opened on demand by its **owner only** (ownership read from the
repo NURIs from `docCreate`), never the private-store root: routing deposits into Store branches — a received cap is not ownership, and a recipient must not be able to
the shim graph would bloat the account→document trust root without bound. redirect the owner's deposits to itself). Both are dedicated documents (real repo
`myInboxes()` enumerates both levels — what `connect.ts` drains at connection — NURIs from `docCreate`), never the private-store root: routing deposits into the shim
and `isOwnInbox` answers from the same record. *(The former `resolveInboxAnchor`, graph would bloat the account→document trust root without bound. `myInboxes()`
a single inbox COMMON to every user, was removed on 2026-07-30: nothing may be enumerates both levels — what `connect.ts` drains at connection — and `isOwnInbox`
common but the mechanisms that make the virtual users work.)* At migration these answers from the same record. *(The former `resolveInboxAnchor`, a single inbox COMMON
become native per-document inboxes. to every user, was removed on 2026-07-30: nothing may be common but the mechanisms
that make the virtual users work.)*
- **`documentInboxAddress(doc)` — the DEPOSIT side, and the one a third party uses.**
Reading an inbox and finding where to deposit into it are opposite acts with opposite
audiences, and conflating them is what made per-document inboxes unusable at first:
resolution answered *"which inbox do I know for this document"*, so a depositor got
one of their own and their deposit vanished silently
([`briefs/2026-08-03-document-inbox-addressing.md`](./briefs/2026-08-03-document-inbox-addressing.md)).
A document that has an inbox carries its address on its emulated **Header branch**
a reserved subject inside the document, so any holder of the document reads it, and
`read-model` filters the whole `urn:ng-eventually:` namespace out of consumer data
(`src/machinery.ts`). This mirrors upstream's split: a depositor seals with the inbox
PUBLIC key and needs nothing else, only the owner holds the private half.
**One inbox belongs to one document** — never several documents behind one inbox, a
relation upstream cannot express (the verifier routes by `inboxes: PubKey → RepoId`
and unseals with that repo's key, `engine/verifier/src/verifier.rs:1677,1928`), which
is also why a deposit carries no target document: the address identifies it. A fresh
document therefore has NO inbox and `documentInboxAddress` returns `undefined` — its
owner opens one when the document is meant to receive, which is what keeps the cost
proportional. At migration the address becomes the repo's native inbox pubkey and the
resolution moves; the consumer-facing act is unchanged.
- **`inbox.postToDocument(doc, { payload })`** — the one call an app makes to reach a
document's owner: it names the DOCUMENT, never an inbox. **Throws** when the document
has no inbox, rather than returning quietly: a deposit that vanishes without an error
is the exact bug this path shipped with.
Both resolve the native store ids from the injected session Both resolve the native store ids from the injected session
(`RegistrySession.protectedStoreId` / `publicStoreId`, alongside the existing (`RegistrySession.protectedStoreId` / `publicStoreId`, alongside the existing
@@ -440,7 +464,7 @@ fork the broker ([`fork-inbox-fallback.md`](./fork-inbox-fallback.md)), the lib
emulates the inbox on the shared wallet: emulates the inbox on the shared wallet:
- **Target vs polyfill.** In the target, `post` seals a reference into the owner's - **Target vs polyfill.** In the target, `post` seals a reference into the owner's
native inbox (`inbox_post_link(...)`, a proposed/future API) and the recipient's native inbox — through a JS call that **does not exist and is not announced** and the recipient's
own verifier unseals each queued message and applies it inline when it processes own verifier unseals each queued message and applies it inline when it processes
its inbox — there is no separate curator or materialization process. Here, its inbox — there is no separate curator or materialization process. Here,
everything is readable, so the lib emulates the read side in-lib. everything is readable, so the lib emulates the read side in-lib.
@@ -483,9 +507,10 @@ At migration this guard disappears into cryptography: an inbox is sealed to its
owner's key. owner's key.
The module knows no domain — the consumer application supplies the inbox document The module knows no domain — the consumer application supplies the inbox document
NURI and interprets `payload`. At migration `post` becomes the native NURI and interprets `payload`. At migration `post` becomes the native sealed
`inbox_post_link` (proposed/future) and the read side is served by the recipient's deposit — **whose JS name and signature are not known**, since none is exposed or
own verifier unsealing queued messages inline. announced — and the read side is served by the recipient's own verifier unsealing
queued messages inline.
The inbox + watcher is the one deposit/read mechanism a consumer reuses for its own The inbox + watcher is the one deposit/read mechanism a consumer reuses for its own
purposes — a registration/deposit, a cap delivery (`shareCap`), a link handed to purposes — a registration/deposit, a cap delivery (`shareCap`), a link handed to
someone — same `post` API, same watcher. someone — same `post` API, same watcher.
+19 -1
View File
@@ -239,6 +239,24 @@ Consequences a consumer must internalize:
are this library's, not the engine's: upstream only the public and protected store are this library's, not the engine's: upstream only the public and protected store
repos carry one (`engine/verifier/src/site.rs:128,149`).* repos carry one (`engine/verifier/src/site.rs:128,149`).*
Depositing into a document you do not own is **one** call, and it names the document:
```ts
await inbox.postToDocument(doc, { payload: { signingUp: true } });
```
You need the **document** (its cap), nothing else — the address rides on it. It
**throws** if the document has no inbox: its owner opens one with
`storeRegistry.openDocumentInbox(doc)` for documents meant to receive, so a fresh
document has none. When "no inbox" is an expected case, check first with
`storeRegistry.documentInboxAddress(doc)` (→ `Nuri | undefined`).
A deposit carries no target document, deliberately — one inbox belongs to one
document, so the address already identifies it, exactly as upstream (`inboxes:
PubKey → RepoId`). Do not encode the document in your payload; you would have to
unlearn it. Reading that inbox is a different right, and it stays the owner's
(`inbox.read` refuses otherwise).
The consumer asks the SDK for what it needs and trusts the result; it does not The consumer asks the SDK for what it needs and trusts the result; it does not
construct NURIs, pick union-vs-anchor, or reason about caps. The domain-shaped list construct NURIs, pick union-vs-anchor, or reason about caps. The domain-shaped list
helpers live in the consumer app; the SDK exposes the generic reactive/by-need read. helpers live in the consumer app; the SDK exposes the generic reactive/by-need read.
@@ -279,7 +297,7 @@ from the reactive contract:
deposit), so `inbox.watch` ([`../src/inbox.ts`](../src/inbox.ts)) **polls** via deposit), so `inbox.watch` ([`../src/inbox.ts`](../src/inbox.ts)) **polls** via
`setInterval` (default 1s) instead of subscribing. The finished contract is push `setInterval` (default 1s) instead of subscribing. The finished contract is push
(the broker already routes the inbox natively); these become subscriptions when the (the broker already routes the inbox natively); these become subscriptions when the
sealed-inbox path (`inbox_post_link`) lands. sealed-inbox path is exposed to JS (no such method exists today).
3. **No cross-wallet / on-demand repo open.** There is no JS primitive to sync an 3. **No cross-wallet / on-demand repo open.** There is no JS primitive to sync an
*unknown* repo by NURI+ReadCap today (`load_repo_from_read_cap` is `pub(crate)`, *unknown* repo by NURI+ReadCap today (`load_repo_from_read_cap` is `pub(crate)`,
+12
View File
@@ -248,6 +248,18 @@ async function main(): Promise<void> {
check("watch fires when a deposit lands", after.fires > base.fires && after.lastLen >= 1, `fires=${after.fires} lastLen=${after.lastLen}`); check("watch fires when a deposit lands", after.fires > base.fires && after.lastLen >= 1, `fires=${after.fires} lastLen=${after.lastLen}`);
await sdk(frame, "inboxWatchStop"); await sdk(frame, "inboxWatchStop");
}); });
await step("a document's inbox: owner opens, a third party resolves and deposits", async () => {
const t = Date.now();
const r = await sdk<any>(frame, "documentInboxDeposit", "@owner-" + t, "@depositor-" + t);
check(
"the depositor RESOLVES the same inbox from the document, deposits into it, and the address stays out of the data",
r.sameInbox === true &&
r.openRefused === true &&
JSON.stringify(r.deposits) === JSON.stringify([{ viaPostToDocument: true }, { joining: true }]) &&
!r.props.some((p: string) => p.startsWith("urn:ng-eventually:")),
`sameInbox=${r.sameInbox} openRefused=${r.openRefused} deposits=${JSON.stringify(r.deposits)} props=${JSON.stringify(r.props)}`,
);
});
await step("inbox spoof guard", async () => { await step("inbox spoof guard", async () => {
const r = await sdk<any>(frame, "inboxSpoofGuard"); const r = await sdk<any>(frame, "inboxSpoofGuard");
check("post as another principal is rejected; self + anon allowed", r.spoofRejected && r.selfOk && r.anonOk, `spoof=${r.spoofRejected} self=${r.selfOk} anon=${r.anonOk}`); check("post as another principal is rejected; self + anon allowed", r.spoofRejected && r.selfOk && r.anonOk, `spoof=${r.spoofRejected} self=${r.selfOk} anon=${r.anonOk}`);
+67 -19
View File
@@ -34,8 +34,12 @@ import {
storeRegistry, storeRegistry,
useShape as libUseShape, useShape as libUseShape,
watchShape, watchShape,
accounts,
} from "@ng-eventually/client"; } from "@ng-eventually/client";
// The harness tests the LIBRARY, so it legitimately reaches machinery a consumer
// application must not — but through the internal path, never the published entry.
// `storeRegistry` above is the app-facing slice; these are the shim internals.
import * as registryInternals from "../src/store-registry";
import * as accounts from "../src/accounts";
import { isNuri } from "@ng-eventually/client"; import { isNuri } from "@ng-eventually/client";
import type { Nuri, ShapeObservable, ShapeQuery } from "@ng-eventually/client"; import type { Nuri, ShapeObservable, ShapeQuery } from "@ng-eventually/client";
@@ -436,10 +440,10 @@ const identity = new IdentityStore(
// ── store-registry ─────────────────────────────────────────────────────── // ── store-registry ───────────────────────────────────────────────────────
async ensureAccountIdempotent(id: string) { async ensureAccountIdempotent(id: string) {
storeRegistry.resetRegistryCache(); registryInternals.resetRegistryCache();
const first = await storeRegistry.ensureAccount(id); const first = await registryInternals.ensureAccount(id);
storeRegistry.resetRegistryCache(); registryInternals.resetRegistryCache();
const second = await storeRegistry.ensureAccount(id); const second = await registryInternals.ensureAccount(id);
return { return {
firstDocs: [first.docPublic, first.docProtected, first.docPrivate], firstDocs: [first.docPublic, first.docProtected, first.docPrivate],
secondDocs: [second.docPublic, second.docProtected, second.docPrivate], secondDocs: [second.docPublic, second.docProtected, second.docPrivate],
@@ -450,7 +454,7 @@ const identity = new IdentityStore(
}; };
}, },
async entityDocsBounded(idA: string, idB: string) { async entityDocsBounded(idA: string, idB: string) {
storeRegistry.resetRegistryCache(); registryInternals.resetRegistryCache();
// Each user creates its OWN documents: you act as one virtual user at a time, // Each user creates its OWN documents: you act as one virtual user at a time,
// and the caps of what you create are filed under the identity you were acting // and the caps of what you create are filed under the identity you were acting
// as. Creating B's document while connected as A is not a thing the model has. // as. Creating B's document while connected as A is not a thing the model has.
@@ -463,7 +467,7 @@ const identity = new IdentityStore(
setCurrentUser(idA); setCurrentUser(idA);
let listA: string[] = []; let listA: string[] = [];
for (let i = 0; i < 12; i++) { for (let i = 0; i < 12; i++) {
storeRegistry.resetRegistryCache(); registryInternals.resetRegistryCache();
listA = await storeRegistry.listMyEntityDocs(idA, "public"); listA = await storeRegistry.listMyEntityDocs(idA, "public");
if (listA.includes(dA1) && listA.includes(dA2)) break; if (listA.includes(dA1) && listA.includes(dA2)) break;
await new Promise((r) => setTimeout(r, 1000)); await new Promise((r) => setTimeout(r, 1000));
@@ -490,7 +494,7 @@ const identity = new IdentityStore(
* expected values to assert against. * expected values to assert against.
*/ */
async reconnectSeed(id: string, scope: "public" | "protected" | "private") { async reconnectSeed(id: string, scope: "public" | "protected" | "private") {
storeRegistry.resetRegistryCache(); registryInternals.resetRegistryCache();
const s = await sessionReady; const s = await sessionReady;
// Seed AS the user whose document this is — otherwise the cap of the created // Seed AS the user whose document this is — otherwise the cap of the created
// document is filed under nobody and the very session that created it is // document is filed under nobody and the very session that created it is
@@ -508,7 +512,7 @@ const identity = new IdentityStore(
// data is persisted before the fresh session tries to read it back. // data is persisted before the fresh session tries to read it back.
let listed: string[] = []; let listed: string[] = [];
for (let i = 0; i < 15; i++) { for (let i = 0; i < 15; i++) {
storeRegistry.resetRegistryCache(); registryInternals.resetRegistryCache();
listed = await storeRegistry.listMyEntityDocs(id, scope); listed = await storeRegistry.listMyEntityDocs(id, scope);
if (listed.includes(entityNuri)) break; if (listed.includes(entityNuri)) break;
await new Promise((r) => setTimeout(r, 1000)); await new Promise((r) => setTimeout(r, 1000));
@@ -534,7 +538,7 @@ const identity = new IdentityStore(
setCurrentUser(id); setCurrentUser(id);
await connectedUser(); await connectedUser();
storeRegistry.resetRegistryCache(); registryInternals.resetRegistryCache();
const listed = await storeRegistry.listMyEntityDocs(id, scope); const listed = await storeRegistry.listMyEntityDocs(id, scope);
// DIAGNOSTIC: a RAW anchored read of the entity doc with NO open — reports how // DIAGNOSTIC: a RAW anchored read of the entity doc with NO open — reports how
// many rows the bare anchored query resolves for a not-yet-opened repo (the // many rows the bare anchored query resolves for a not-yet-opened repo (the
@@ -579,8 +583,8 @@ const identity = new IdentityStore(
* first so the resolve goes to the shim, not a same-session in-memory hit. * first so the resolve goes to the shim, not a same-session in-memory hit.
*/ */
async accountDocs(id: string) { async accountDocs(id: string) {
storeRegistry.resetRegistryCache(); registryInternals.resetRegistryCache();
const rec = await storeRegistry.ensureAccount(id); const rec = await registryInternals.ensureAccount(id);
return { docPublic: rec.docPublic, docProtected: rec.docProtected, docPrivate: rec.docPrivate }; return { docPublic: rec.docPublic, docProtected: rec.docProtected, docPrivate: rec.docPrivate };
}, },
async scopeResolvers() { async scopeResolvers() {
@@ -621,9 +625,9 @@ const identity = new IdentityStore(
* truthy iff provisioning succeeded) so the runner can gate on real persistence. * truthy iff provisioning succeeded) so the runner can gate on real persistence.
*/ */
async coldEnsureAccount(id: string) { async coldEnsureAccount(id: string) {
storeRegistry.resetRegistryCache(); registryInternals.resetRegistryCache();
try { try {
const rec = await storeRegistry.ensureAccount(id); const rec = await registryInternals.ensureAccount(id);
return { return {
threw: false, threw: false,
error: null, error: null,
@@ -642,9 +646,9 @@ const identity = new IdentityStore(
* docs coldEnsureAccount minted (real persistence, no RepoNotFound). * docs coldEnsureAccount minted (real persistence, no RepoNotFound).
*/ */
async verifyShimPersisted(id: string) { async verifyShimPersisted(id: string) {
storeRegistry.resetRegistryCache(); registryInternals.resetRegistryCache();
try { try {
const rec = await storeRegistry.ensureAccount(id); const rec = await registryInternals.ensureAccount(id);
return { threw: false, error: null, docPublic: rec.docPublic, docProtected: rec.docProtected, docPrivate: rec.docPrivate }; return { threw: false, error: null, docPublic: rec.docPublic, docProtected: rec.docProtected, docPrivate: rec.docPrivate };
} catch (e: any) { } catch (e: any) {
return { threw: true, error: String(e?.message ?? e), docPublic: "", docProtected: "", docPrivate: "" }; return { threw: true, error: String(e?.message ?? e), docPublic: "", docProtected: "", docPrivate: "" };
@@ -668,7 +672,7 @@ const identity = new IdentityStore(
* doc/type so the runner can assert the data landed. * doc/type so the runner can assert the data landed.
*/ */
async watchShapeSeedAndSubscribe(handle: string, cls: string) { async watchShapeSeedAndSubscribe(handle: string, cls: string) {
storeRegistry.resetRegistryCache(); registryInternals.resetRegistryCache();
const id = "@ws-" + handle; const id = "@ws-" + handle;
setCurrentUser(id); setCurrentUser(id);
const doc = await storeRegistry.createEntityDoc(id, "protected"); const doc = await storeRegistry.createEntityDoc(id, "protected");
@@ -681,7 +685,7 @@ const identity = new IdentityStore(
); );
// Wait until this session sees the index append (data persisted on the broker). // Wait until this session sees the index append (data persisted on the broker).
for (let i = 0; i < 15; i++) { for (let i = 0; i < 15; i++) {
storeRegistry.resetRegistryCache(); registryInternals.resetRegistryCache();
const listed = await storeRegistry.listMyEntityDocs(id, "protected"); const listed = await storeRegistry.listMyEntityDocs(id, "protected");
if (listed.includes(doc)) break; if (listed.includes(doc)) break;
await new Promise((r) => setTimeout(r, 1000)); await new Promise((r) => setTimeout(r, 1000));
@@ -739,7 +743,7 @@ const identity = new IdentityStore(
* the handle; poll watchShapeSnapshot for the transition. * the handle; poll watchShapeSnapshot for the transition.
*/ */
watchShapeEmptyStart(handle: string, cls: string) { watchShapeEmptyStart(handle: string, cls: string) {
storeRegistry.resetRegistryCache(); registryInternals.resetRegistryCache();
const id = "@ws-empty-" + handle; const id = "@ws-empty-" + handle;
setCurrentUser(id); setCurrentUser(id);
const shape = { const shape = {
@@ -802,6 +806,50 @@ const identity = new IdentityStore(
* "receive" operation exists, and no principal is ever named to the registry. * "receive" operation exists, and no principal is ever named to the registry.
* Runs against the REAL broker inbox document, so it exercises the whole path. * Runs against the REAL broker inbox document, so it exercises the whole path.
*/ */
/**
* The DEPOSIT side of a document's inbox, end to end against the real broker: the
* owner opens it, a third party RESOLVES its address from the document itself and
* deposits, the owner reads it back.
*
* The point of the step is the resolution: nothing hands `depositorId` the address.
* It gets the document's link (which is what circulates in this model) and must find
* where to deposit on its own which is exactly what a consumer app has to do, and
* what a unit test passing the NURI through a variable cannot prove.
*/
async documentInboxDeposit(ownerId: string, depositorId: string) {
registryInternals.resetRegistryCache();
setCurrentUser(ownerId);
const doc = await storeRegistry.createEntityDoc(ownerId, "public");
const ownerInbox = await storeRegistry.openDocumentInbox(doc);
const link = capFor(doc)!; // the repo link the owner circulates
setCurrentUser(depositorId);
getCaps().learn(link);
const resolved = await storeRegistry.documentInboxAddress(doc);
// The one-call form an app actually uses: it names the DOCUMENT, never an inbox.
await inbox.postToDocument(doc, { payload: { viaPostToDocument: true }, ts: 900 });
// Opening one on someone else's document must be refused, not silently forked.
let openRefused = false;
try {
await storeRegistry.openDocumentInbox(doc);
} catch {
openRefused = true;
}
if (resolved) await inbox.post(resolved, { payload: { joining: true }, ts: 1000 });
setCurrentUser(ownerId);
const deposits = await inbox.read(ownerInbox);
// The address is machinery: it must not surface among the document's properties.
const subjects = await readModel.readUnion([doc]);
const props = Object.keys(subjects[0]?.props ?? {});
setCurrentUser(null);
return {
sameInbox: resolved === ownerInbox,
openRefused,
deposits: deposits.map((d) => d.payload),
props,
};
},
async capsShareCap(friendId: string) { async capsShareCap(friendId: string) {
const s = await sessionReady; const s = await sessionReady;
resetCaps(); resetCaps();
+40 -4
View File
@@ -10,15 +10,17 @@
* In real NextGraph, a message is sealed to the recipient's key and queued into * In real NextGraph, a message is sealed to the recipient's key and queued into
* their inbox; the recipient's own verifier unseals each queued message and * their inbox; the recipient's own verifier unseals each queued message and
* applies it inline as it processes the inbox there is no separate curator * applies it inline as it processes the inbox there is no separate curator
* process. A future `inbox_post_link` is the intended way to seal a link into an * process. There is NO sender-side JS call for this today: the verifier has no
* inbox from the sender side; it is not exposed yet. * `InboxPost` arm and `@ng-org/web` exposes no inbox method at all. (`inbox_post_link`,
* named elsewhere in these docs, is OUR proposal from `docs/fork-inbox-fallback.md`
* no such symbol exists in `nextgraph-rs`. Do not cite it as a planned API.)
* *
* Here, on one shared wallet where everything is readable, both sides run in-lib: * Here, on one shared wallet where everything is readable, both sides run in-lib:
* - `post` appends a deposit `{ from, payload, ts }` as RDF into the inbox * - `post` appends a deposit `{ from, payload, ts }` as RDF into the inbox
* document (in the shared wallet) via the `docs.sparqlUpdate` primitive; * document (in the shared wallet) via the `docs.sparqlUpdate` primitive;
* - `read` / `watch` read the deposits back via `docs.sparqlQuery` and expose * - `read` / `watch` read the deposits back via `docs.sparqlQuery` and expose
* them. This in-lib read stands in for the recipient's own inbox processing * them. This in-lib read stands in for the recipient's own inbox processing
* until the sealed-inbox path (`inbox_post_link`) is available. * until a sealed-inbox path is exposed to JS.
* *
* All NextGraph I/O routes through the `docs` primitives (the real injected `ng`, * All NextGraph I/O routes through the `docs` primitives (the real injected `ng`,
* never `makeNg`), so this module imports no `@ng-org` package. * never `makeNg`), so this module imports no `@ng-org` package.
@@ -28,7 +30,7 @@ import { depositInto, sparqlQuery } from "./docs";
import { subscribeDoc } from "./subscribe"; import { subscribeDoc } from "./subscribe";
import { ensureRepoOpen } from "./open-repo"; import { ensureRepoOpen } from "./open-repo";
import { getCaps, getCurrentUser, getStoreRegistryDeps } from "./polyfill"; import { getCaps, getCurrentUser, getStoreRegistryDeps } from "./polyfill";
import { addLink, isOwnInbox } from "./store-registry"; import { addLink, documentInboxAddress, isOwnInbox } from "./store-registry";
import { escapeLiteral } from "./sparql"; import { escapeLiteral } from "./sparql";
import { hasReadCap } from "./nuri"; import { hasReadCap } from "./nuri";
import { import {
@@ -189,6 +191,40 @@ export async function post(targetInbox: Nuri, opts: PostOptions): Promise<void>
} }
} }
/**
* Deposit into the inbox of a DOCUMENT resolve where, then deposit there.
*
* The call an app makes to reach a document's owner: it needs the document (which it
* must be able to read) and nothing else. Where the inbox is, and whether the owner
* ever opened one, are the library's business.
*
* **No target-document field on the deposit, deliberately.** Upstream an inbox belongs
* to exactly one repo the verifier routes by `inboxes: PubKey → RepoId` and unseals
* with that repo's key (`engine/verifier/src/verifier.rs:1677`) and `InboxMsgBody`
* carries no document (`engine/net/src/types.rs:4265`), because the address already
* identifies it. Tagging deposits with their document would be an invention consumers
* would have to unlearn at migration, so this resolves the address and stops there.
*
* @throws if the document has no inbox its owner never opened one, so there is
* nowhere for this to go. Throwing rather than returning quietly is the whole lesson of
* this path: a deposit that vanishes without an error is worse than a refusal, and it
* is exactly the bug per-document inboxes shipped with
* (`docs/briefs/2026-08-03-document-inbox-addressing.md`). Call
* `storeRegistry.documentInboxAddress(doc)` first when "no inbox" is an expected case.
*/
export async function postToDocument(doc: Nuri, opts: PostOptions): Promise<void> {
const target = await documentInboxAddress(doc);
if (target === undefined) {
throw new Error(
"[ng-eventually] inbox.postToDocument: this document has no inbox — either its owner " +
"never opened one, or you cannot read the document (the address rides on it): " +
JSON.stringify(doc),
);
}
return post(target, opts);
}
// --- cap delivery --------------------------------------------------------- // --- cap delivery ---------------------------------------------------------
/** /**
+18 -12
View File
@@ -1,14 +1,23 @@
/** /**
* @ng-eventually/client **SDK-identical** surface. * @ng-eventually/client the surface a consumer application codes against.
* *
* This entry exposes ONLY what `@ng-org/web` / `@ng-org/orm` expose (current + * Everything here has a target-SDK counterpart, verified or assumed, listed in
* anticipated: `inbox`). Import `ng` / `useShape` from here instead of the SDK * `docs/api-contract.md`. Import `ng` / `useShape` from here rather than from the
* during the polyfill period; at migration the build alias is removed and these * SDK during the polyfill period; at migration the build alias is removed and
* resolve to the real SDK with **no code change**. * these resolve to the real SDK.
* *
* The one non-SDK piece the polyfill bootstrap (`configure`, capability * **This entry carries no machinery.** The earlier header claimed it exposed "ONLY
* helpers, current user) lives at `@ng-eventually/client/polyfill`, and is the * what `@ng-org/web` / `@ng-org/orm` expose", which was false as written: it also
* only thing removed at migration. * shipped the whole `store-registry` module (account resolution, cap registers,
* cache resets) and `accounts` (browser identity persistence, polyfill-era with no
* SDK counterpart). Both leaked machinery onto the entry whose promise is that it
* survives migration. `storeRegistry` is now the app-facing slice only
* (`store-registry-api.ts`); `accounts` moved to `/polyfill`.
*
* The polyfill bootstrap `configure`, the capability helpers, the current user,
* identity persistence lives at `@ng-eventually/client/polyfill`: everything an
* application needs TODAY that will not exist tomorrow, kept apart so what goes
* away is visible at the import line.
*/ */
export * from "./types"; export * from "./types";
@@ -22,10 +31,7 @@ export { subscribeDoc, subscribeDocs, docChangeType } from "./subscribe";
export type { DocChange, DocChangeType, Unsubscribe } from "./subscribe"; export type { DocChange, DocChangeType, Unsubscribe } from "./subscribe";
export * as readModel from "./read-model"; export * as readModel from "./read-model";
export type { UnionSubject } from "./read-model"; export type { UnionSubject } from "./read-model";
export * as storeRegistry from "./store-registry"; export * as storeRegistry from "./store-registry-api";
export type { AccountRecord, RegistrySession } from "./store-registry";
export * as accounts from "./accounts";
export type { AccountStorage } from "./accounts";
// SPARQL injection-safety helpers — so the app can reuse the same escaping / // SPARQL injection-safety helpers — so the app can reuse the same escaping /
// validation when it builds SPARQL by interpolation. `escapeLiteral` for string // validation when it builds SPARQL by interpolation. `escapeLiteral` for string
+42
View File
@@ -0,0 +1,42 @@
/**
* The namespace this library reserves for its OWN triples, and the one predicate a
* read path needs about it: *is this subject machinery, or is it the consumer's data?*
*
* Why this exists
* The polyfill has no branches, so it emulates each of a repo's compartments with a
* distinct SUBJECT inside a document (`shim:index` for the store's Main branch,
* `shim:storeBranch`, `shim:userBranch`, `shim:headerBranch` see `store-registry.ts`).
* That was invisible as long as those subjects only ever appeared in documents the
* consumer never reads through the data path store documents and the doc-shim.
*
* The Header branch broke that: it lives in an ENTITY document, the one the consumer
* reads with `SELECT ?s ?p ?o`. Without a filter, the address of a document's inbox
* would surface as one of that entity's properties machinery leaking into domain
* data. Filtering by SUBJECT rather than by predicate is what makes this hold for
* every compartment, present and future: a new emulated branch needs no new filter.
*
* Upstream this problem does not exist, because there the separation is real a
* branch is a different CRDT with its own topic, not a subject in the same graph. This
* module is the seam where our emulation pays for that.
*/
/**
* The URN namespace every triple this library writes for itself lives under
* `urn:ng-eventually:shim:…` (store-registry's compartments) and
* `urn:ng-eventually:inbox:…` (inbox deposits).
*
* A consumer that writes its own data under this prefix would have it filtered out of
* its reads. That is a deliberate reservation, not a hazard to guard against: the
* namespace names this library.
*/
export const MACHINERY_NS = "urn:ng-eventually:";
/**
* Is `subject` one of this library's own, rather than consumer data?
*
* Tolerant of `undefined` so a read path can hand it a possibly-absent binding
* without a preliminary check an absent subject is not machinery.
*/
export function isMachinerySubject(subject: string | undefined): boolean {
return subject !== undefined && subject.startsWith(MACHINERY_NS);
}
+1 -1
View File
@@ -48,7 +48,7 @@ export function makeNg(): Record<string, any> {
}; };
} }
// TODO(anticipated API): inbox_post_link + capability operations — expose // TODO(anticipated API): a sealed inbox deposit + capability operations — expose
// here with their anticipated signatures, emulated for now. // here with their anticipated signatures, emulated for now.
// Everything else: passthrough to the real SDK, unchanged. // Everything else: passthrough to the real SDK, unchanged.
+12
View File
@@ -227,3 +227,15 @@ export function resetCaps(): void {
export { CapRegistry } from "./caps"; export { CapRegistry } from "./caps";
export { shareCap } from "./inbox"; export { shareCap } from "./inbox";
export { connectedUser } from "./connect"; export { connectedUser } from "./connect";
// --- identity persistence (polyfill-era, no SDK counterpart) ----------------
//
// Moved here from the SDK-identical entry on 2026-08-03. `accounts` persists WHICH
// virtual user is connected, in browser storage — a notion that exists only because
// one shared wallet hosts several identities. The real SDK has no counterpart: there
// each user opens their own wallet, and "who am I" is the session. Shipping it from
// the SDK entry advertised as durable something that disappears at migration.
export * as accounts from "./accounts";
export type { AccountStorage } from "./accounts";
// Config-shaped types the bootstrap needs; both describe the shim, not the SDK.
export type { AccountRecord, RegistrySession } from "./store-registry";
+6
View File
@@ -46,6 +46,7 @@ import { getCaps, getStoreRegistryDeps } from "./polyfill";
import { mustNotAttempt } from "./reach"; import { mustNotAttempt } from "./reach";
import { ensureReposOpen } from "./open-repo"; import { ensureReposOpen } from "./open-repo";
import { assertNuri } from "./sparql"; import { assertNuri } from "./sparql";
import { isMachinerySubject } from "./machinery";
import type { Nuri } from "./types"; import type { Nuri } from "./types";
// Keep the primitives referenced so tree-shaking never drops the import used by // Keep the primitives referenced so tree-shaking never drops the import used by
@@ -172,6 +173,11 @@ export async function readUnion(docs: Nuri[]): Promise<UnionSubject[]> {
// (writeEntity invariant). Pin subject/graph to the doc NURI (the anchor), which // (writeEntity invariant). Pin subject/graph to the doc NURI (the anchor), which
// is stable regardless of the repo_graph_name overlay suffix the store carries. // is stable regardless of the repo_graph_name overlay suffix the store carries.
for (const row of rows) { for (const row of rows) {
// The polyfill's own compartments live as reserved SUBJECTS inside the very
// documents the consumer reads (the Header branch carrying a document's inbox
// address is the first). They are machinery, not this entity's properties —
// drop them here, once, for every compartment present and future.
if (isMachinerySubject(row.s?.value)) continue;
const p = row.p?.value; const p = row.p?.value;
const o = row.o?.value; const o = row.o?.value;
if (!p || o === undefined) continue; if (!p || o === undefined) continue;
+37
View File
@@ -0,0 +1,37 @@
/**
* The app-facing slice of `store-registry` and the reason it exists as a file.
*
* `store-registry.ts` holds two things that must not be exported together: the
* placement/addressing calls a consumer application legitimately makes, and the
* shim machinery that makes virtual users work at all (account resolution, the
* durable cap registers, the inbox-ownership predicate, cache resets). Until now
* `index.ts` did `export * as storeRegistry from "./store-registry"` and shipped
* both, so an application could reach `ensureAccount`, `addLink` or
* `resetRegistryCache` from the SDK-identical entry machinery it must never call,
* on the entry whose whole promise is "this survives migration unchanged".
*
* What is re-exported here is only what an application needs to do its own work,
* and each has a target-SDK counterpart (see `docs/api-contract.md`). Everything
* else stays reachable at `./store-registry` for the library's own modules, the
* unit tests and the e2e harness an internal path, not a published one.
*
* At migration this file disappears: placement becomes the user's real per-scope
* stores and the calls below become native SDK ones.
*/
export {
/** Create a document for ONE entity in `scope`, and record it in that scope's store. */
createEntityDoc,
/** The entity documents this user owns in `scope` — with their caps recovered. */
listMyEntityDocs,
/** The NURI to use as a READ scope for `scope` (what `useShape` is pointed at). */
resolveScopeGraph,
/** The NURI where GROUPED entities of `scope` are written (no per-entity document). */
resolveWriteGraph,
/** A user's own inbox — where caps and messages addressed to THEM arrive. */
walletInbox,
/** Open an inbox on a document you OWN, so others can deposit into it. */
openDocumentInbox,
/** WHERE to deposit for a document — readable by any holder of it. `undefined` if none. */
documentInboxAddress,
} from "./store-registry";
+199 -19
View File
@@ -67,6 +67,7 @@ import { getCaps, getCurrentUser, getStoreRegistryDeps } from "./polyfill";
import { ensureRepoOpen, ensurePhysicalRepoOpen } from "./open-repo"; import { ensureRepoOpen, ensurePhysicalRepoOpen } from "./open-repo";
import { escapeLiteral, escapeIri, assertNuri } from "./sparql"; import { escapeLiteral, escapeIri, assertNuri } from "./sparql";
import { hasReadCap, isNuri, mintCap } from "./nuri"; import { hasReadCap, isNuri, mintCap } from "./nuri";
import { mustNotAttempt } from "./reach";
import { accessLogPrefix, logStage, shortNuri } from "./access-log"; import { accessLogPrefix, logStage, shortNuri } from "./access-log";
import type { Nuri, ReadCap, Scope } from "./types"; import type { Nuri, ReadCap, Scope } from "./types";
@@ -105,6 +106,7 @@ const P = {
link: `${SHIM}:link`, // user branch → a ReadCap received for an EXTERNAL document link: `${SHIM}:link`, // user branch → a ReadCap received for an EXTERNAL document
readCap: `${SHIM}:readCap`, // store branch → the ReadCap of a document IN this store readCap: `${SHIM}:readCap`, // store branch → the ReadCap of a document IN this store
inboxCap: `${SHIM}:inboxCap`, // user branch → an inbox this user may READ inboxCap: `${SHIM}:inboxCap`, // user branch → an inbox this user may READ
inboxAddress: `${SHIM}:inboxAddress`, // header branch → WHERE to deposit for this document
} as const; } as const;
// Fixed subject of the per-(account×scope) index document. The index doc plays // Fixed subject of the per-(account×scope) index document. The index doc plays
// the role of the future store-container: it lists the NURIs of the entity // the role of the future store-container: it lists the NURIs of the entity
@@ -142,6 +144,34 @@ const USER_BRANCH_SUBJECT = `${SHIM}:userBranch`;
* keys stay separate. * keys stay separate.
*/ */
const STORE_BRANCH_SUBJECT = `${SHIM}:storeBranch`; const STORE_BRANCH_SUBJECT = `${SHIM}:storeBranch`;
/**
* Fixed subject of the **Header branch** emulation, inside an ENTITY document the
* first compartment we put in a document the consumer also reads, hence the filter in
* `read-model.ts` (every `${SHIM}:` subject is machinery and never surfaces as data).
*
* It carries what must be readable by *whoever can read the document*, as opposed to
* what belongs to its owner alone. Today that is one thing: the ADDRESS of the
* document's inbox.
*
* Why the address must live here and not on the owner's User branch. Upstream an inbox
* is a KEYPAIR (`repo.inbox: Option<PrivKey>`, `engine/repo/src/repo.rs:126`) and the
* two halves have opposite audiences: a depositor seals with the PUBLIC key
* (`InboxMsg::new` `crypto_box::seal(&to_inbox.to_dh_slice(), …)`,
* `engine/net/src/types.rs:4299`) and needs nothing else; only the owner holds the
* private half (`AddInboxCap`, on the User branch). An address is therefore public by
* nature upstream it travels with the profile (`ContactDetails` carries
* `ng:site_inbox` / `ng:protected_inbox`, `engine/verifier/src/inbox_processor.rs:823`).
* Keeping it only on the owner's User branch, as this lib first did, made the deposit
* side unreachable: a third party had no way to learn where to deposit.
*
* **Not `BranchType::Header` upstream.** That branch exists (`engine/repo/src/types.rs:1551`)
* but is CLOSED: `update_header` writes only `title`/`about`
* (`engine/verifier/src/request_processor.rs:173-211`) and `fetch_header` reads back
* only `title`/`about`/`class` (`:1240-1284`). It cannot carry an inbox address. The
* name is borrowed for the shape a compartment of the document that is not its
* content not for the upstream branch's contract.
*/
const HEADER_BRANCH_SUBJECT = `${SHIM}:headerBranch`;
// --- pointer (store-root → doc-shim indirection) -------------------------- // --- pointer (store-root → doc-shim indirection) --------------------------
// //
@@ -927,9 +957,55 @@ export async function createEntityDoc(id: string, scope: Scope): Promise<Nuri> {
} }
// …and the creator holds THAT cap for this session. // …and the creator holds THAT cap for this session.
holdOwnCap(id, scope, entityNuri, cap); holdOwnCap(id, scope, entityNuri, cap);
// NO inbox here, and NOT the owner's own inbox published as this document's address.
// Upstream an inbox belongs to exactly ONE repo: the verifier routes an incoming
// message by `inboxes: PubKey → RepoId` (`engine/verifier/src/verifier.rs:1677,1928`)
// and unseals it with THAT repo's private half, while `InboxMsgBody` carries no
// target document at all (`engine/net/src/types.rs:4265`) — because it needs none,
// the address IS the identification. Pointing several documents at one inbox would
// emulate a many-to-one relation the model cannot express, and would teach consumers
// to tag deposits with their document, a habit that has to be unlearned at migration.
//
// So a document gets an inbox only when its owner opens one
// ({@link openDocumentInbox}), which is also what keeps the cost proportional: only
// documents meant to RECEIVE pay for one (see
// `docs/briefs/2026-08-03-document-inbox-addressing.md`).
return entityNuri; return entityNuri;
} }
/**
* Publish WHERE to deposit for `doc`, on its Header branch the compartment any
* holder of the document can read.
*
* Replacement, not addition: a document has exactly ONE inbox upstream (the verifier's
* `inboxes: PubKey → RepoId` is a function, and `repo.inbox` a single `Option<PrivKey>`),
* so two addresses on one document is a state the model has no meaning for and a
* depositor picking the stale one writes where nobody reads.
*/
async function publishInboxAddress(doc: Nuri, inbox: Nuri): Promise<void> {
const s = await session();
try {
// Two separate updates, not one compound statement: `DELETE WHERE { … }` is the
// form verified against the real broker (see
// `docs/decisions/sparql-delete-for-orm-objects.md`), whereas a `;`-joined update
// is not exercised anywhere in this lib.
await sparqlUpdate(
s.sessionId,
`DELETE WHERE { <${HEADER_BRANCH_SUBJECT}> <${P.inboxAddress}> ?a }`,
doc,
"publishInboxAddress:clear",
);
await sparqlUpdate(
s.sessionId,
`INSERT DATA { <${HEADER_BRANCH_SUBJECT}> <${P.inboxAddress}> "${escapeLiteral(inbox)}" }`,
doc,
"publishInboxAddress",
);
} catch (error) {
console.error(accessLogPrefix() + " publishInboxAddress failed:", error);
}
}
/** /**
* The ReadCaps recorded on a store's Store branch its documents, each with its * The ReadCaps recorded on a store's Store branch its documents, each with its
* key. The emulated replay of `AddRepo`, and the reason a fresh session recovers * key. The emulated replay of `AddRepo`, and the reason a fresh session recovers
@@ -1023,30 +1099,69 @@ export async function userStoreDoc(id: string, scope: Scope): Promise<Nuri> {
* same branch that carries `AddLink`. So "which inboxes may I read" is answered by the * same branch that carries `AddLink`. So "which inboxes may I read" is answered by the
* User branch, and that is what this emulates. * User branch, and that is what this emulates.
* *
* **This ANTICIPATES: no document has an inbox upstream today.** `new_store_default` * **The engine SUPPORTS this; nothing exercises it automatically.** Those are two
* attaches one only `if !private` (`engine/verifier/src/verifier.rs:2994`), and * different statements, and conflating them is what made an earlier version of this
* `doc_create` goes through `new_repo_default`, which leaves `inbox: None` * comment call the feature an "anticipation". It is not. `inbox: Option<PrivKey>` is a
* (`engine/repo/src/repo.rs:574`) the only two `AddInboxCap` commits in the engine * field of EVERY `Repo` (`engine/repo/src/repo.rs:126`), not of a store structure;
* are for the public and protected STORE repos (`engine/verifier/src/site.rs:128,149`). * `AddInboxCapV0` is keyed by `repo_id` (`engine/repo/src/types.rs:1973`); and
* What is verified is the SHAPE: the record is keyed by `repo_id`, so it accommodates * `update_inbox_cap_v0` applies it with `self.repos.get_mut(repo_id)` and **no
* an inbox on any repo. What is not verified is that anything upstream will create one * `is_store` check of any kind** (`engine/verifier/src/verifier.rs:1920`). Generic by
* per document. At migration this either becomes native or stays emulated here; either * construction, and at any time (see the User-branch note above).
* way the consumer-facing act is unchanged.
* *
* Lazy on purpose: creating an inbox document for every entity up front would * What is true is narrower: no code path CREATES one for a document `new_store_default`
* double every `createEntityDoc` for inboxes most documents never receive anything * attaches one only `if !private` (`verifier.rs:2994`), `doc_create` leaves `inbox: None`
* in. Upstream the keypair is cheap; here an inbox is a document, so it is minted * (`repo.rs:574`), and the only two `AddInboxCap` commits in the engine are for the
* when first asked for. * public and protected STORE repos (`engine/verifier/src/site.rs:128,149`). So the
* capability exists and is simply unexposed above level 1: this function is aligned on
* the engine's model, it does not bet past it.
* *
* Only for documents this user holds you cannot open an inbox on someone else's * Lazy on purpose, for the same reason: creating an inbox document for every entity up
* document, you can only deposit into it. * front would double every `createEntityDoc` for inboxes most documents never receive
* anything in. Upstream the keypair is cheap; here an inbox is a document, so it is
* minted when first asked for.
*
* *(Not covered: ROTATING an inbox key the engine's "update" case with a new
* `priv_key`. This function is idempotent and returns the existing inbox instead. A
* known limit, not an oversight.)*
*
* Only for a document this user OWNS see {@link ownsDocument}. Opening an inbox on
* someone else's document would be usurpation, not a courtesy: the opener keeps the
* reading half, so it would silently divert to itself the deposits meant for the
* owner. To deposit into someone else's document, resolve
* {@link documentInboxAddress} and `inbox.post` into it.
*/ */
export async function documentInbox(doc: Nuri): Promise<Nuri> { export async function openDocumentInbox(doc: Nuri): Promise<Nuri> {
const holder = getCurrentUser(); const holder = getCurrentUser();
if (holder === null) throw new Error("[ng-eventually] documentInbox: no identity is set"); if (holder === null) throw new Error("[ng-eventually] openDocumentInbox: no identity is set");
const known = (await readInboxCapsFor(doc)) ?? null; const known = (await readInboxCapsFor(doc)) ?? null;
if (known) return known; if (known) return known;
// OWNERSHIP is the criterion, and holding a cap is NOT ownership — a cap can be
// received. Opening an inbox is what PUBLISHES this document's address, so a
// non-owner doing it would route the owner's deposits to itself, silently, on a
// document it merely reads.
//
// **This guard compensates OUR design, not an upstream constraint** — an earlier
// comment here claimed "upstream only the owner can commit `AddInboxCap`", which is
// false: that commit lands on the committer's OWN User branch, so anyone may write
// one naming anyone's repo. What protects upstream is that an inbox address is never
// PUBLISHED — it is TRANSMITTED (in a `ContactDetails` message, or a profile QR
// code), and `inboxes: PubKey → RepoId` is a per-verifier local table
// (`engine/verifier/src/verifier.rs:105`, rebuilt empty each session). A forged pair
// reaches nobody, because nobody was told about it.
//
// We publish instead of transmitting — the only way a third party can find the
// address at all here — which creates a vector upstream does not have: whoever can
// write the document can redirect its deposits. Hence this guard. It is a real
// divergence, deliberately taken; see `docs/briefs/2026-08-03-document-inbox-addressing.md`.
if (!(await ownsDocument(doc))) {
throw new Error(
"[ng-eventually] openDocumentInbox: refused — you may only open an inbox on a document " +
`you own. Deposit into its published address instead (storeRegistry.documentInboxAddress ` +
`then inbox.post): ${JSON.stringify(doc)}`,
);
}
const inbox = await createDoc(); const inbox = await createDoc();
const s = await session(); const s = await session();
const record = await ensureAccount(holder); const record = await ensureAccount(holder);
@@ -1058,15 +1173,80 @@ export async function documentInbox(doc: Nuri): Promise<Nuri> {
s.sessionId, s.sessionId,
`INSERT DATA { <${USER_BRANCH_SUBJECT}> <${P.inboxCap}> "${escapeLiteral(doc + " " + inbox)}" }`, `INSERT DATA { <${USER_BRANCH_SUBJECT}> <${P.inboxCap}> "${escapeLiteral(doc + " " + inbox)}" }`,
store, store,
"documentInbox", "openDocumentInbox",
); );
} catch (error) { } catch (error) {
console.error(accessLogPrefix() + " documentInbox persist failed:", error); console.error(accessLogPrefix() + " openDocumentInbox persist failed:", error);
} }
} }
// …and the PUBLIC half, in the document itself, so a depositor can find it at all.
// Without this the inbox is reachable only by its owner — the opposite of what an
// inbox is for, and the bug this path shipped with.
await publishInboxAddress(doc, inbox);
return inbox; return inbox;
} }
/**
* WHERE to deposit for `doc` its inbox address, or `undefined` if its owner never
* opened one. The deposit-side counterpart of {@link openDocumentInbox}, and the
* function an app calls before `inbox.post`.
*
* Readable by whoever can read the document, because it lives on its Header branch
* an address is public by nature (upstream a depositor needs only the inbox PUBLIC
* key). Conversely someone who cannot read the document learns nothing, which is
* faithful too: upstream the inbox pubkey is not derivable from a RepoId, it has to
* reach you.
*
* **Never creates.** Asking where to deposit must not bring an inbox into existence
* only its owner opens one, and only on its own document.
*/
export async function documentInboxAddress(doc: Nuri): Promise<Nuri | undefined> {
// RULE 2 — do not even attempt. Not holding the document, we have no address to
// find: upstream the inbox pubkey travels WITH what you can read, so "where do I
// deposit for a document I cannot read" is not a refused question, it is a question
// with no referent. Answering `undefined` here keeps the caller's shape (an address
// or none) instead of turning the boundary into an exception it must catch.
if (mustNotAttempt(doc)) return undefined;
const s = await session();
try {
const res = await sparqlQuery(
s.sessionId,
`SELECT ?a WHERE { <${HEADER_BRANCH_SUBJECT}> <${P.inboxAddress}> ?a }`,
undefined,
doc,
"documentInboxAddress",
);
for (const row of readBindings(res)) {
const a = bindingValue(row, "a");
if (a && isNuri(a)) return a;
}
} catch (error) {
// Unreadable document (no cap) or not synced → no address to give. Refusing to
// read is the boundary doing its job, not an error to propagate here.
console.error(accessLogPrefix() + " documentInboxAddress failed:", error);
}
return undefined;
}
/**
* Does the connected user own `doc`? Answered from its **Store branches** the
* register of the documents it created across the three scopes, which is the only
* place that records authorship. Holding a cap is NOT ownership: a cap can be
* received, and a recipient must not be able to open an inbox on what it merely reads.
*/
async function ownsDocument(doc: Nuri): Promise<boolean> {
const holder = getCurrentUser();
if (holder === null) return false;
const record = await resolveAccount(holder);
if (record === null) return false;
for (const scope of ["public", "protected", "private"] as const) {
const store = storeOf(record, scope);
if (!store) continue;
if ((await readUserStore(store)).includes(doc)) return true;
}
return false;
}
/** The `(document, inbox)` pairs recorded on this user's User branch. */ /** The `(document, inbox)` pairs recorded on this user's User branch. */
async function readInboxCapPairs(): Promise<Array<{ doc: Nuri; inbox: Nuri }>> { async function readInboxCapPairs(): Promise<Array<{ doc: Nuri; inbox: Nuri }>> {
const holder = getCurrentUser(); const holder = getCurrentUser();
+113 -10
View File
@@ -18,7 +18,13 @@
* no authorization list anywhere, and nobody was named to the registry. * no authorization list anywhere, and nobody was named to the registry.
*/ */
import { test, expect, mock, afterAll } from "bun:test"; import { test, expect, mock, afterAll } from "bun:test";
import { createEntityDoc, documentInbox, resetRegistryCache, walletInbox } from "../src/store-registry"; import {
createEntityDoc,
documentInboxAddress,
openDocumentInbox,
resetRegistryCache,
walletInbox,
} from "../src/store-registry";
import type { RegistrySession } from "../src/store-registry"; import type { RegistrySession } from "../src/store-registry";
import { import {
configure, configure,
@@ -32,7 +38,7 @@ import {
shareCap, shareCap,
connectedUser, connectedUser,
} from "../src/polyfill"; } from "../src/polyfill";
import { post, read as readInbox } from "../src/inbox"; import { post, postToDocument, read as readInbox } from "../src/inbox";
import { readUnion } from "../src/read-model"; import { readUnion } from "../src/read-model";
import { sparqlUpdate } from "../src/docs"; import { sparqlUpdate } from "../src/docs";
import type { Nuri } from "../src/types"; import type { Nuri } from "../src/types";
@@ -76,6 +82,19 @@ function makeFakeNg() {
const query = a[1] as string; const query = a[1] as string;
const anchor = a[2] as string | undefined; const anchor = a[2] as string | undefined;
if (!anchor) return undefined; if (!anchor) return undefined;
// `DELETE WHERE { <s> <p> ?var }` — the form the lib uses to REPLACE a value
// (see docs/decisions/sparql-delete-for-orm-objects.md). Without this arm the
// fake would treat the delete as an insert and the replacement would silently
// become an accumulation — the exact bug a replacement exists to prevent.
const del = query.match(/^\s*DELETE\s+WHERE\s*\{\s*<([^>]+)>\s+<([^>]+)>\s+\?/);
if (del) {
const [s0, p0] = [del[1]!, del[2]!];
for (let i = quads.length - 1; i >= 0; i--) {
const q = quads[i]!;
if (q.g === anchor && q.s === s0 && q.p === p0) quads.splice(i, 1);
}
return undefined;
}
const body = query.replace(/^\s*INSERT DATA\s*\{/, "").replace(/\}\s*$/, ""); const body = query.replace(/^\s*INSERT DATA\s*\{/, "").replace(/\}\s*$/, "");
const sm = body.match(/<([^>]+)>/); const sm = body.match(/<([^>]+)>/);
if (!sm) return undefined; if (!sm) return undefined;
@@ -149,6 +168,10 @@ function makeFakeNg() {
if (query.includes(`<${SHIM}:inboxCap>`)) { if (query.includes(`<${SHIM}:inboxCap>`)) {
return { results: { bindings: quads.filter((q) => q.g === anchor && q.p === `${SHIM}:inboxCap`).map((q) => ({ c: { value: q.o } })) } }; return { results: { bindings: quads.filter((q) => q.g === anchor && q.p === `${SHIM}:inboxCap`).map((q) => ({ c: { value: q.o } })) } };
} }
// Header-branch `inboxAddress` SELECT (where to deposit for this document).
if (query.includes(`<${SHIM}:inboxAddress>`)) {
return { results: { bindings: quads.filter((q) => q.g === anchor && q.p === `${SHIM}:inboxAddress`).map((q) => ({ a: { value: q.o } })) } };
}
// Store-branch `readCap` SELECT (the emulated AddRepo records). // Store-branch `readCap` SELECT (the emulated AddRepo records).
if (query.includes(`<${SHIM}:readCap>`)) { if (query.includes(`<${SHIM}:readCap>`)) {
return { results: { bindings: quads.filter((q) => q.g === anchor && q.p === `${SHIM}:readCap`).map((q) => ({ c: { value: q.o } })) } }; return { results: { bindings: quads.filter((q) => q.g === anchor && q.p === `${SHIM}:readCap`).map((q) => ({ c: { value: q.o } })) } };
@@ -383,28 +406,98 @@ test("a document has its own inbox: anyone deposits, only the owner reads", asyn
inject(); inject();
setCurrentUser("alice"); setCurrentUser("alice");
const doc = await createEntityDoc("alice", "public"); const doc = await createEntityDoc("alice", "public");
const docInbox = await documentInbox(doc); const aliceInbox = await openDocumentInbox(doc);
expect(docInbox).not.toBe(await walletInbox("alice")); expect(aliceInbox).not.toBe(await walletInbox("alice"));
const link = capFor(doc)!; // the repo link alice circulates — links DO travel
// Bob deposits into the document's inbox — the cross-user act, open to all. // Bob RESOLVES the address himself, from the document. The only thing he is handed
// is the link, which is the one thing the model says circulates. The address is not
// passed to him — if it had to be, there would be no way for an app to get it.
setCurrentUser("bob"); setCurrentUser("bob");
await post(docInbox, { payload: { joining: true }, ts: 1 }); getCaps().learn(link);
const bobTarget = await documentInboxAddress(doc);
expect(bobTarget).toBe(aliceInbox); // …and it is the SAME inbox alice reads
await post(bobTarget!, { payload: { joining: true }, ts: 1 });
// …and cannot read it back: depositing grants nothing. // …and he cannot read it back: depositing grants nothing.
await expect(readInbox(docInbox)).rejects.toThrow(/does not belong to the connected wallet/i); await expect(readInbox(bobTarget!)).rejects.toThrow(/does not belong to the connected wallet/i);
// Alice reads her document's inbox, because she opened it. // Alice reads her document's inbox, because she opened it.
setCurrentUser("alice"); setCurrentUser("alice");
const deposits = await readInbox(docInbox); const deposits = await readInbox(aliceInbox);
expect(deposits.map((d) => d.payload)).toEqual([{ joining: true }]); expect(deposits.map((d) => d.payload)).toEqual([{ joining: true }]);
}); });
test("opening an inbox on someone else's document is refused, not silently forked", async () => {
inject();
setCurrentUser("alice");
const doc = await createEntityDoc("alice", "public");
const aliceInbox = await openDocumentInbox(doc);
const link = capFor(doc)!;
// Bob holds the document — that is a READ right, and it is not ownership.
setCurrentUser("bob");
getCaps().learn(link);
await expect(openDocumentInbox(doc)).rejects.toThrow(/already has an inbox|you may only open an inbox/i);
// The address he resolves is still alice's, so his deposits reach her.
expect(await documentInboxAddress(doc)).toBe(aliceInbox);
});
test("a fresh document has NO inbox — one belongs to one document, and only its owner opens it", async () => {
inject();
setCurrentUser("alice");
const doc = await createEntityDoc("alice", "public");
const link = capFor(doc)!;
// Not "the owner's inbox by default": upstream an inbox belongs to exactly ONE repo
// (the verifier routes by `inboxes: PubKey → RepoId`), so pointing several documents
// at one inbox is a relation the model cannot express.
setCurrentUser("bob");
getCaps().learn(link);
expect(await documentInboxAddress(doc)).toBeUndefined();
// …and depositing THROWS rather than vanishing — a lost deposit is the bug this
// whole path exists to close.
await expect(postToDocument(doc, { payload: { x: 1 } })).rejects.toThrow(/has no inbox/i);
});
test("opening an inbox publishes ONE address, and re-opening does not accumulate", async () => {
inject();
setCurrentUser("alice");
const doc = await createEntityDoc("alice", "public");
const dedicated = await openDocumentInbox(doc);
expect(await openDocumentInbox(doc)).toBe(dedicated); // idempotent
const link = capFor(doc)!;
setCurrentUser("bob");
getCaps().learn(link);
expect(await documentInboxAddress(doc)).toBe(dedicated);
// The deposit reaches the owner, addressed by the document alone.
await postToDocument(doc, { payload: { signingUp: true } });
setCurrentUser("alice");
expect((await readInbox(dedicated)).map((d) => d.payload)).toEqual([{ signingUp: true }]);
});
test("the inbox address is machinery: it never surfaces as the document's data", async () => {
inject();
setCurrentUser("alice");
const doc = await createEntityDoc("alice", "public");
await write(doc, SECRET, "s1");
await openDocumentInbox(doc);
// The consumer read returns the entity's properties and nothing of the compartment
// that carries the address — the Header branch is beside the content, not in it.
const subjects = await readUnion([doc]);
const props = subjects[0]?.props ?? {};
expect(Object.keys(props)).toEqual([SECRET]);
});
test("connecting drains BOTH levels: the user's inbox and its documents'", async () => { test("connecting drains BOTH levels: the user's inbox and its documents'", async () => {
inject(); inject();
setCurrentUser("alice"); setCurrentUser("alice");
const protDoc = await createEntityDoc("alice", "protected"); const protDoc = await createEntityDoc("alice", "protected");
const pubDoc = await createEntityDoc("alice", "public"); const pubDoc = await createEntityDoc("alice", "public");
const docInbox = await documentInbox(pubDoc); const docInbox = await openDocumentInbox(pubDoc);
const aliceInbox = await walletInbox("alice"); const aliceInbox = await walletInbox("alice");
// Two deposits, one at each level, both made by someone else. // Two deposits, one at each level, both made by someone else.
@@ -422,3 +515,13 @@ test("connecting drains BOTH levels: the user's inbox and its documents'", async
const left = await readInbox(docInbox); const left = await readInbox(docInbox);
expect(left.map((d) => d.payload)).toEqual([{ onTheDocument: true }]); // consumer data stays expect(left.map((d) => d.payload)).toEqual([{ onTheDocument: true }]); // consumer data stays
}); });
// The same resolution property one level up: a user's own inbox.
test("a third party resolves another user's inbox (the wallet level)", async () => {
inject();
setCurrentUser("alice");
const aliceView = await walletInbox("alice");
setCurrentUser("bob");
const bobView = await walletInbox("alice");
expect(bobView).toBe(aliceView);
});
+51
View File
@@ -0,0 +1,51 @@
/**
* The reserved-namespace predicate, in isolation.
*
* It is one `startsWith`, but it is the seam that keeps the polyfill's emulated
* branches out of the consumer's data (see `machinery.ts`), so its edges are worth
* pinning: get it wrong in one direction and machinery leaks into domain properties;
* wrong in the other and real data silently disappears from reads.
*/
import { test, expect } from "bun:test";
import { MACHINERY_NS, isMachinerySubject } from "../src/machinery";
test("the emulated branch subjects are all machinery", () => {
// The four compartments store-registry emulates, verbatim.
for (const s of [
"urn:ng-eventually:shim:index",
"urn:ng-eventually:shim:storeBranch",
"urn:ng-eventually:shim:userBranch",
"urn:ng-eventually:shim:headerBranch",
]) {
expect(isMachinerySubject(s)).toBe(true);
}
});
test("inbox deposits are machinery too — a second prefix under the same namespace", () => {
expect(isMachinerySubject("urn:ng-eventually:inbox:deposit:1700:abc")).toBe(true);
});
test("consumer subjects are not machinery — including a NURI, which is what entities use", () => {
expect(isMachinerySubject("did:ng:o:doc1")).toBe(false);
expect(isMachinerySubject("urn:e2e:secret")).toBe(false);
expect(isMachinerySubject("http://example.org/thing")).toBe(false);
});
test("a look-alike prefix is NOT machinery — the boundary is exact, not fuzzy", () => {
// Anything that merely resembles the namespace must fall on the data side, or a
// consumer's own vocabulary could vanish from its reads.
expect(isMachinerySubject("urn:ng-eventuallyX:thing")).toBe(false);
expect(isMachinerySubject("urn:ng-event:thing")).toBe(false);
expect(isMachinerySubject("x-urn:ng-eventually:shim:index")).toBe(false);
});
test("an absent subject is not machinery — read paths hand bindings straight in", () => {
expect(isMachinerySubject(undefined)).toBe(false);
expect(isMachinerySubject("")).toBe(false);
});
test("the namespace is the prefix both writers actually use", () => {
// Guards against the constant drifting away from store-registry/inbox.
expect("urn:ng-eventually:shim".startsWith(MACHINERY_NS)).toBe(true);
expect("urn:ng-eventually:inbox".startsWith(MACHINERY_NS)).toBe(true);
});