16 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
Sylvain Duchesne 33751388fe Ask that a document's inbox be addressable by anyone holding the document
`documentInbox(doc)` answers "which inbox do I know for this document?" and mints
a fresh one when the answer is none. A third party therefore never reaches the
owner's inbox: they get one of their own, the owner never reads it, and the
deposit vanishes with no error.

That breaks the consumer's central act — signing up to someone else's event —
and it blocks the directory document that has to replace discovery, since
creators must deposit into a document they do not own.

The consumer tried solving it on its side: resolve the address once at creation
and publish it on the public document, so participants read it instead of
deriving it. It works, and it is still the wrong place. It puts a technical
address into a domain shape; it doubles document creation on every event, since
resolving an inbox creates one (the @data suite went from 9m37 to 21m30, with
sign-up scenarios timing out during setup on a FRESH profile — not wallet
bloat); and the library already claims this job, having stated when per-document
inboxes landed that draining them "is the library's job, not the app's".
Addressing is the other half of that same job. The app-side change is reverted.

No shape is prescribed here. Deriving the address from the document would cost
nothing and match how the rest of the model works — an overlay is derived, a
keyring is looked up, nothing is handed over out of band — with the read guard
keeping the asymmetry where it belongs. Publishing it library-side would work
too and leaves room to make it lazy.

One question decides where this note ultimately belongs: is "a document has an
inbox addressable by any holder" part of NextGraph's target model, or this
library's own invention? Upstream, it is emulation to align; otherwise it is a
design decision to take deliberately.
2026-08-03 14:47:13 +02:00
Sylvain Duchesne 88f396a7ac fix(caps): créer un document en donne le cap, + corriger 9 faits NextGraph
Le trou trouvé par l'e2e contre le broker en ligne : `docs.docCreate` ne
déposait aucun cap pour le créateur, donc un consommateur pouvait créer un
document par la primitive publique puis se voir refuser sa lecture et son
écriture. En amont c'est impossible — `doc_create` commite
`AddRepo { read_cap }` sur la branche Store du store, et le créateur le détient
dès le premier instant. Délibérément non répliqué dans `physical.ts` : les
documents du shim n'appartiennent à aucun utilisateur virtuel, et
`store-registry` classe leurs caps là où il sait à qui ils sont.

e2e : 22 passés / 8 échoués → 39 / 0. Les autres échecs venaient du harnais,
qui agissait comme une seconde identité sans l'établir, ou lisait un document
quelconque comme une inbox. Un run e2e contre un wallet persistant exige une
identité FRAÎCHE par run : `walletInbox(id)` rend l'inbox stable pour son
propriétaire — c'est son intérêt — donc un id fixe accumule les dépôts des runs
précédents (vert au 2e run, rouge au 3e, à code inchangé).

Revue adverse de la documentation, 9 défauts, tous vérifiés à la source avant
correction :

- « chaque document a une inbox native » est FAUX. Seuls les repos de store
  public et protected en ont une (`site.rs:128,149`) ; `new_store_default` n'en
  pose que `if !private` et `doc_create` laisse `inbox: None`. Le store privé
  n'en a pas non plus. Ce que le code fait est donc une ANTICIPATION — assumée
  et notée comme telle dans `documentInbox`, le brief et l'ADR discovery. Ce qui
  est vérifié, c'est la FORME : `AddInboxCapV0` est clé par `repo_id`.
- `InboxMsgContent::Link` est une variante unit sans charge utile : l'inbox ne
  transporte aucun ReadCap. `shareCap` était juste et le reste ; ses citations
  sont complétées aux deux bouts (émetteur `unimplemented!()`, récepteur qui
  ignore `details.read_cap`).
- les 3 stores appartiennent au user (`SiteV0`), pas au wallet ;
- le TODO `OpenRepo` ne concerne pas la lecture cross-wallet — il est dans
  `open_branch_`, après `RepoNotFound` ; charger par cap, c'est
  `load_repo_from_read_cap` ;
- la liste des méthodes JS était un sous-ensemble présenté comme la surface
  (77 exportées) ;
- `outbox-log.ts` n'enregistre rien : il inspecte l'outbox du SDK ;
- l'ADR private-store-nuri-scope citait `orm_start_graph` au présent, remplacé
  par `ensureRepoOpen` ;
- l'incident write-loss plaçait `disconnections_sender.send` dans `broker.rs` ;
- la section « Apps & services » n'a aucune citation et rien ne lui correspond
  dans le moteur : marquée à re-confirmer, pas à citer comme vérifiée.

Aussi : `fileOwnCaps` n'existe plus (`holdOwnCap` / `readStoreCaps` /
`fileOwnStructure`) — pointeur mort corrigé dans `caps.ts`.
2026-08-03 12:17:40 +02:00
Sylvain Duchesne 9d3e2d2bfe Fix documentation defects found by an adversarial review
Fifteen findings, all verified before acting. The ones that mattered:

- Corrections added without updating what they corrected. §5's table still
  said a cap-less NURI is one "without :k:", two hundred lines after §4
  established the discriminant is `r:`. Same shape of defect in the P1a
  report, which kept the sentence "it is the owner's keyring, upstream the
  keyring is the wallet" — the exact sentence §4quater declares wrong, and the
  one that produced a global in-memory keyring.

- A wrong source citation: RootCapRefresh/BranchCapRefresh live in
  verifier/src/commits/mod.rs, not repo/src/commit.rs, and are no-op stubs.

- Documentation describing deleted code: isolation.ts, discovery.readIndex,
  the global index, and an acceptance test that was dropped with discovery.

- The P1a implementation report had aged into being wrong in four places
  (caps not persisted, inbox processing not started, plain string types, the
  :k: segment). It is dated, so it now carries a header saying what later lots
  overtook, rather than being rewritten.

- vision.md stated "a document's data is stored encrypted" in the present
  tense. That is the target; here the cap value is the constant OK and nothing
  is encrypted. Said plainly now.

- Prose left mangled by an earlier mechanical find-and-replace, in four places
  I had claimed were repaired.

Also: reach.ts and connect.ts had no home in the permanent docs — the boundary
and the connection sequence are now described in simulation.md, not only in a
brief.
2026-08-03 11:34:24 +02:00
Sylvain Duchesne ae9c32e271 Align the cap emulation on NextGraph's model, and confine it to a virtual user
Two batches, verified against nextgraph-rs throughout.

P1a — the capability surface. Reading was an ACL (Map<doc, Set<principal>>),
the exact inversion of key possession. It is now possession: `capFor(nuri)` is
the only question, there is no principal parameter anywhere, and nothing turns
a bare reference into a cap. Sharing is `shareCap(cap, toInbox)`, a Link
deposit; receiving needs no operation. `Nuri` and `ReadCap` are template
literal types, so passing a bare reference where a cap belongs is a compile
error, with runtime guards behind it for JavaScript callers.

The virtual user boundary. Every access function is now confined to the
connected user, through two rules on one criterion (possession), implemented in
two places so a lapse in either is caught by the other: authorization at the
passage points, and "do not even attempt" at the callers. The polyfill's own
machinery moved to physical.ts — unguarded, never exported — which replaced an
exemption list: the machinery no longer gets waved through the guard, it calls
something the guard never saw.

Removed, as emulating capabilities the target does not have:
- discovery.ts and its global index. There is no discovery in NextGraph; you
  follow links. It also pooled user data across wallets.
- the cross-account fan-out (listEntityDocs, resolveReadGraphs, allAccounts,
  loadShim), which was cross-user enumeration by construction.
- resolveInboxAnchor, a single inbox common to every user.

Caps are now stored where NextGraph stores them, and read back rather than
recomputed: AddRepo on the store's Store branch for documents a user creates,
AddLink on its User branch for caps received. Inboxes belong to someone — the
user's own, plus one per document — and connecting a user drains them all;
that is the library's job, not the app's.

Corrections worth recording: a ReadCap is `r:`, not `:k:` (reported by
NextGraph's developer, verified in BlockRef::readcap_nuri); received caps DO
have a register (AddLink), contrary to what this repo's notes claimed; and
"wallet" upstream means keyring — what owns three stores is a user, so the
vocabulary follows.

The cap value is the constant OK: the only question the emulation answers is
whether a cap is held. P1b replaces that one constant with a real key.

After this the shape is right and the isolation is still fake. Nothing here may
be described as anonymous or private.
2026-08-03 11:22:01 +02:00
61 changed files with 6162 additions and 1673 deletions
+92 -18
View File
@@ -29,11 +29,10 @@ Docs (this library's own engineering doctrine, under [`docs/`](./docs/)):
behaviour on one shared wallet (shim, per-document ReadCaps, emulated inbox, behaviour on one shared wallet (shim, per-document ReadCaps, emulated inbox,
write guard, the two axes, the double-proxy constraint). write guard, the two axes, the double-proxy constraint).
- [`docs/read-model.md`](./docs/read-model.md) — the read model the polyfill - [`docs/read-model.md`](./docs/read-model.md) — the read model the polyfill
implements: events via the global index, everything else by following a shared implements: you follow links, you never enumerate; listing via a bounded set of
graph; listing via a bounded set of per-doc anchored `sparql_query`s; reactivity per-doc anchored `sparql_query`s; reactivity via re-query on a change signal.
via re-query on a change signal.
- [`docs/decisions/`](./docs/decisions/) — current-SDK ADRs (private-store scope, - [`docs/decisions/`](./docs/decisions/) — current-SDK ADRs (private-store scope,
SPARQL delete, shared-wallet identity, discovery mechanism). SPARQL delete, shared-wallet identity).
- [`docs/fork-inbox-fallback.md`](./docs/fork-inbox-fallback.md) — the Rust-patch / - [`docs/fork-inbox-fallback.md`](./docs/fork-inbox-fallback.md) — the Rust-patch /
self-host inbox path not taken (kept as a fallback). self-host inbox path not taken (kept as a fallback).
- [`docs/migration-guide.md`](./docs/migration-guide.md) — the checklist for when - [`docs/migration-guide.md`](./docs/migration-guide.md) — the checklist for when
@@ -52,12 +51,12 @@ is needed), and how this lib emulates it today.
| Capability | What the consumer application does | Real NextGraph target | Current NextGraph status (why a workaround) | Current emulation | | Capability | What the consumer application does | Real NextGraph target | Current NextGraph status (why a workaround) | Current emulation |
|---|---|---|---|---| |---|---|---|---|---|
| Multi-identity / per-identity wallet | Treats each identity id as its own wallet with its own documents | Each identity opens its own real wallet; native cross-wallet reads | Not-yet-implemented: the JS SDK exposes no cross-wallet read, so one session cannot read another identity's wallet | One shared wallet everyone opens; "identities" are virtual wallets — shim accounts keyed by an id, each mapped to its documents in `store-registry.ts` | | Multi-identity / per-identity wallet | Treats each identity id as its own wallet with its own documents | Each identity opens its own real wallet; native cross-wallet reads | Not-yet-implemented: the JS SDK exposes no cross-wallet read, so one session cannot read another identity's wallet | One shared wallet everyone opens; "identities" are virtual users — shim accounts keyed by an id, each mapped to its documents in `store-registry.ts` |
| Three native stores per identity | Places entities by scope `public` / `protected` / `private` | The identity's three real native stores hold the entity documents | Not-yet-implemented: `doc_create`/ORM can target only the private (and protected) native store today; a `public`/arbitrary `StoreRepo` is not JS-constructible | Three emulated scope-index documents per account — each "store" is an index doc listing its entity-doc NURIs; all physically live in the one shared private store, and scope is a logical label | | 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 | Declares a document's read policy via `getCaps().open(doc, scope, owner)`, then issues directed read grants (`grantRead(doc, granteeId)`) | 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`) | Bug/gap for emulation purposes: there is no cap-introspection API — a client cannot ask "may this identity read this doc?", so the polyfill cannot mirror the broker's decision from NextGraph itself | An emulated `CapRegistry` (`caps.ts`, per-document read/write caps) + a read filter (`read-filter.ts`, a defence-in-depth view) that keep only documents the current identity may read; `canRead`/`governsRead` are emulation-only, with no NextGraph API behind them | | 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 for each relationship issues directed read grants on the owner's protected documents | A native per-document ReadCap issued to a specific identity — but note this target is itself not-yet-built in nextgraph-rs today, not merely unexposed in JS: `AccessGrantV0{grantee}` is unpersisted scaffolding and cap-send is `unimplemented!()`, so directing a grant to another identity has no working platform primitive yet | Not-yet-implemented: sending a cap to another identity is `unimplemented!()`, and no relationship/mutuality primitive exists — relationship is an application concept, not a platform one | The app selects the owner's protected documents via `getCaps().protectedDocsOf(owner)` and calls `grantRead(doc, granteeId)` per grantee; the lib records the per-document grant | | 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 | `submitToIndex(ref)` / `readIndex()` | A real owned global document (owner undecided — a singleton-app path), fed via its native inbox, read as a materialized index | Not-yet-implemented / undecided: an identity's apps and services see only what it shares, so there is no global backend index yet | A global index document owned by a reserved special account (`@index`), fed via its inbox, read with dedup; a stable NURI every client resolves | | ~~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) |
| Writes | Writes an entity to its scope | Writes land in the entity's real store via native primitives | Not-yet-implemented: `doc_create` can target only the private/protected store today (`StoreRepo` not JS-constructible) | Per-entity documents via direct SPARQL (`docs.sparqlUpdate` on the real injected `ng`) | | Writes | Writes an entity to its scope | Writes land in the entity's real store via native primitives | Not-yet-implemented: `doc_create` can target only the private/protected store today (`StoreRepo` not JS-constructible) | Per-entity documents via direct SPARQL (`docs.sparqlUpdate` on the real injected `ng`) |
@@ -70,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
@@ -83,13 +77,93 @@ 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
testable). testable).
- Authorization is emulated capabilities: documents carry grants; the client - Authorization is emulated capabilities: documents carry grants; the client
enforces them generically (read filter + write guard). The app declares a enforces them generically (read filter + write guard). The app declares a
document's read policy and issues directed grants — the same acts it will document, shares one document's cap to an inbox — the same acts it will
perform in the target. No policy is injected. perform in the target. No policy is injected.
- Inbox: the client `inbox` namespace deposits (`post`) and, in the shared-wallet - Inbox: the client `inbox` namespace deposits (`post`) and, in the shared-wallet
emulation, reads the deposits back (`read` / `materialize` / `watch`) in place emulation, reads the deposits back (`read` / `materialize` / `watch`) in place
@@ -102,13 +176,13 @@ away; the app code (SDK-shaped) is unchanged.
Implemented. The polyfill mechanisms are wired against a real broker, not stubbed: Implemented. The polyfill mechanisms are wired against a real broker, not stubbed:
- Shared-wallet shim — `store-registry.ts` (`(account, scope) → document NURI`, - Shared-wallet shim — `store-registry.ts` (`(account, scope) → document NURI`,
`createEntityDoc` / `listEntityDocs` + per-scope index, cross-device via the RDF `createEntityDoc` / `listMyEntityDocs` + per-user stores, cross-device via the RDF
shim anchored in the private store). shim anchored in the private store).
- Document / SPARQL primitive — `docs.ts`, calling the real injected `ng` directly - Document / SPARQL primitive — `docs.ts`, calling the real injected `ng` directly
(avoids the `@ng-org` double-proxy `DataCloneError`). (avoids the `@ng-org` double-proxy `DataCloneError`).
- Emulated ReadCaps — `caps.ts` (`CapRegistry`, per-document, directed grants) + - Emulated ReadCaps — `caps.ts` (`CapRegistry`, per-document, directed grants) +
read filter `read-filter.ts` (reactive-set `Proxy` view), applied by read filter `read-filter.ts` (reactive-set `Proxy` view), applied by
`use-shape.ts` only when a policy is declared. `use-shape.ts` only once a cap exists (`caps.isEnforcing()`).
- Write guard — `ng-proxy.ts` (`sparql_update` override, emulated write cap). - Write guard — `ng-proxy.ts` (`sparql_update` override, emulated write cap).
- Inbox — `inbox.ts` (`post` / `read` / `materialize` / `watch`). - Inbox — `inbox.ts` (`post` / `read` / `materialize` / `watch`).
- Identity — `accounts.ts` (`IdentityStore`, injected storage). - Identity — `accounts.ts` (`IdentityStore`, injected storage).
+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`.
@@ -35,7 +35,7 @@ This **prevents the shortcuts** the adversary pointed out (#4/#6: reading the pl
**This inventory IS the scope of P1b.** The only existing guard (`caps.canRead`) is moreover a **set-membership ACL** — the very inversion the vision forbids. **This inventory IS the scope of P1b.** The only existing guard (`caps.canRead`) is moreover a **set-membership ACL** — the very inversion the vision forbids.
1. **Two distinct reference shapes**: cap-less (names/locates without reading — aligned with the NURI without `:k:`) vs cap-bearing (id + key/token). Absent today. 1. **Two distinct reference shapes**: cap-less (names/locates without reading — aligned with the NURI without `:r:`) vs cap-bearing (id + key/token). Absent today.
2. **Grant = delivering a cap-token to a recipient** (emulating the sealing: the recipient *receives* the token in their inbox; it is **possession** of the token that authorizes reading — not an ACL row checked per principal). 2. **Grant = delivering a cap-token to a recipient** (emulating the sealing: the recipient *receives* the token in their inbox; it is **possession** of the token that authorizes reading — not an ACL row checked per principal).
3. **Enforcement by possession**: readers (`read-filter`, `use-shape`) only see what they **hold the token for**, not "what they are in the readers set for". 3. **Enforcement by possession**: readers (`read-filter`, `use-shape`) only see what they **hold the token for**, not "what they are in the readers set for".
4. **Resolving a cap-less** = naming / proving existence / counting, **without** exposing the content (support for anonymous presence). 4. **Resolving a cap-less** = naming / proving existence / counting, **without** exposing the content (support for anonymous presence).
@@ -75,16 +75,16 @@ Erroneous content kept below as a record:
## Open questions ## Open questions
- **SETTLED (PO directive, 2026-07-21)**: we **simulate the crypto** (per-doc encrypted data, cap = key). "Semantics only" (a token registry) is **discarded** — it turns back into an ACL and lets the plaintext be read. What remains to settle is the **level** of simulation (real lightweight encryption vs masked read-model projection), **before P1**. - **SETTLED (PO directive, 2026-07-21)**: we **simulate the crypto** (per-doc encrypted data, cap = key). "Semantics only" (a token registry) is **discarded** — it turns back into an ACL and lets the plaintext be read. What remains to settle is the **level** of simulation (real lightweight encryption vs masked read-model projection), **before P1**.
- NURI representation, cap-less vs cap-bearing, in the emulation (mirror `:k:`). - NURI representation, cap-less vs cap-bearing, in the emulation (mirror `:r:`).
- ~~Should **keyless fetch** be allowed (resolving a cap-less into existence/count without the content)~~ — **SETTLED, and negatively (2026-07-27)**: not constructible. Addressing itself presupposes the cap, so there is nothing to expose. See the corrected Q1 verdict below. Kept struck through rather than deleted: the hypothesis is intuitive and will otherwise be re-formed. - ~~Should **keyless fetch** be allowed (resolving a cap-less into existence/count without the content)~~ — **SETTLED, and negatively (2026-07-27)**: not constructible. Addressing itself presupposes the cap, so there is nothing to expose. See the corrected Q1 verdict below. Kept struck through rather than deleted: the hypothesis is intuitive and will otherwise be re-formed.
- API migration: `declareConnections`/`grantRead``seal(cap, recipient)` + `inbox → received caps`. Breaks consumers (the app-side `declareConnections` disappears). - API migration: `declareConnections`/`grantRead``seal(cap, recipient)` + `inbox → received caps`. Breaks consumers (the app-side `declareConnections` disappears).
## P0 — "keyless-resolve" spike (the blocker, BEFORE any P1) ## P0 — "keyless-resolve" spike (the blocker, BEFORE any P1)
**Load-bearing question**: can a holder of a **cap-less reference** (`did:ng:o:{id}:v:{overlay}`, without `:k:`), **without ever reading the content**: **Load-bearing question**: can a holder of a **cap-less reference** (`did:ng:o:{id}:v:{overlay}`, without `:r:`), **without ever reading the content**:
- **Q1 — Existence / fetch**: prove/retrieve the presence of the (encrypted) blocks from the broker? Or does the broker require a ReadCap/membership in order to serve the blocks? - **Q1 — Existence / fetch**: prove/retrieve the presence of the (encrypted) blocks from the broker? Or does the broker require a ReadCap/membership in order to serve the blocks?
- **Q2 — Deletion**: distinguish "exists" from "deleted"? *(The FRAGILE point: NextGraph is an append-only CRDT — a withdrawal = a **tombstone commit** that one would have to **read** in order to know about → potentially **the key is required**. And the **decrement on leave** depends on it.)* - **Q2 — Deletion**: distinguish "exists" from "deleted"? *(The FRAGILE point: NextGraph is an append-only CRDT — a withdrawal = a **tombstone commit** that one would have to **read** in order to know about → potentially **the key is required**. And the **decrement on leave** depends on it.)*
- **Q3 — Confidentiality**: does the key (`:k:`) remain **required** in order to decrypt (keyless never gives the content)? - **Q3 — Confidentiality**: does the key remain **required** in order to decrypt (keyless never gives the content)?
**Why this is the blocker**: the whole **anonymous counter** (counting/validating cap-less refs without reading) AND the **decrement on leave** depend on it. **If NO** → "anonymous counter via cap-less ref" is **not constructible in the target** → Festipod must **not** code that shape (guaranteed rewrite). **If YES** → P1 exposes `resolveCapLess(nuri) → {exists|deleted}` (never any content), and the emulation simulates it faithfully. **Why this is the blocker**: the whole **anonymous counter** (counting/validating cap-less refs without reading) AND the **decrement on leave** depend on it. **If NO** → "anonymous counter via cap-less ref" is **not constructible in the target** → Festipod must **not** code that shape (guaranteed rewrite). **If YES** → P1 exposes `resolveCapLess(nuri) → {exists|deleted}` (never any content), and the emulation simulates it faithfully.
@@ -109,11 +109,11 @@ Erroneous content kept below as a record:
- **Withdrawal has to be a message, not an observation.** On the consumer side: an explicit *nudge*. The polyfill has **nothing** to emulate for that — it just must not pretend otherwise. - **Withdrawal has to be a message, not an observation.** On the consumer side: an explicit *nudge*. The polyfill has **nothing** to emulate for that — it just must not pretend otherwise.
- **Settled by the Q1 correction**: the anonymous counter can**not** rest on an existence validation — that is not constructible. So it rests on something **declarative**, which is acceptable (outside the security scope) as long as the **exposed shape does not lie**: do not expose an existence primitive that the target will not offer. - **Settled by the Q1 correction**: the anonymous counter can**not** rest on an existence validation — that is not constructible. So it rests on something **declarative**, which is acceptable (outside the security scope) as long as the **exposed shape does not lie**: do not expose an existence primitive that the target will not offer.
## P1a — the surface ## P1a — the surface — **DONE (2026-07-28)**
**Extracted into its own brief: [`2026-07-27-p1a-cap-surface.md`](2026-07-27-p1a-cap-surface.md).** **Extracted into its own brief: [`2026-07-27-p1a-cap-surface.md`](2026-07-27-p1a-cap-surface.md), which records what landed where.**
This batch is **specified and ready to implement**; it has its own note so that one can code from it without wading through the retracted material of this document. The ACL inversion — *the central defect this whole chantier exists to fix* — is gone: `caps.ts` is a keyring, sharing is a per-document delivery to an inbox, and a bare reference reads nothing. **P1b is now the blocker for any privacy claim**: the emulated key is derived (hence reproducible) and the bypass inventory below is untouched.
In two lines: a single new type (`ReadCap`), a keyring (`capFor`), a per-document share to an inbox (`shareCap`) — and nothing else. The branded types, `resolveCapLess`, `receivedCaps`, `refOf`, `parseNuri` and `PrincipalId` were **discarded** after a double adversarial review; the reasons are in that note. In two lines: a single new type (`ReadCap`), a keyring (`capFor`), a per-document share to an inbox (`shareCap`) — and nothing else. The branded types, `resolveCapLess`, `receivedCaps`, `refOf`, `parseNuri` and `PrincipalId` were **discarded** after a double adversarial review; the reasons are in that note.
@@ -121,8 +121,8 @@ This brief remains the **overall effort**: P0 verdicts, P1b scope, P2P4 batch
## Phase sketch ## Phase sketch
- **P1a** — **the surface**: one new type (`ReadCap`), a keyring (`capFor`), per-document sharing to an inbox (`shareCap`). **The only batch that blocks Festipod.** Specified in its own note: [`2026-07-27-p1a-cap-surface.md`](2026-07-27-p1a-cap-surface.md). *(An earlier draft listed `DocRef`/`DocCap` branded types, `resolveCapLess` and a durable `sealCapTo` here — all three were **dropped** after adversarial review; the note says why.)* - ~~**P1a** — **the surface**~~ **DONE 2026-07-28**: one new type (`ReadCap`), a keyring (`capFor`), per-document sharing to an inbox (`shareCap`). It was the only batch blocking Festipod, and it no longer does. See [`2026-07-27-p1a-cap-surface.md`](2026-07-27-p1a-cap-surface.md) for what landed where. *(An earlier draft listed `DocRef`/`DocCap` branded types, `resolveCapLess` and a durable `sealCapTo` here — all three were **dropped** after adversarial review; the note says why.)*
- **P1b** — **the enforcement**: per-doc encryption (cap = key) and closing out the inventory of bypasses. Without it the shape is right but the isolation remains false — so nothing "anonymous" can be claimed. - **P1b** — **the enforcement**: per-doc encryption (cap = key) and closing out the inventory of bypasses. Without it the shape is right but the isolation remains false — so nothing "anonymous" can be claimed. **Requalified 2026-07-30**: the bypass inventory below is really a **virtual user boundary** problem, and it is now specified on its own in [`2026-07-30-virtual-wallet-boundary.md`](2026-07-30-virtual-wallet-boundary.md). That lot precedes or absorbs this one — encrypting each document while any wallet can reach any document secures the windows with the door open.
- **P2** — replace the ACL with a **token possession** model (grant = deliver to a recipient; enforcement = possession). *Requalified by the adversarial review: the real content of P2 is **durability + cap-less + re-sharing by the holder**, not "inverting the ACL" — without crypto, inverting produces no observable delta.* - **P2** — replace the ACL with a **token possession** model (grant = deliver to a recipient; enforcement = possession). *Requalified by the adversarial review: the real content of P2 is **durability + cap-less + re-sharing by the holder**, not "inverting the ACL" — without crypto, inverting produces no observable delta.*
- **P3** — revocation by re-key (invalidation + re-delivery, non-retroactive). - **P3** — revocation by re-key (invalidation + re-delivery, non-retroactive).
- ~~**PW** — WriteCap = membership~~ **DROPPED (2026-07-27)**. This batch rested on a notion of membership that **does not exist** in the model (everything is keys and URLs); see the struck-through section above. It was moreover justified by a need for **dedup via signature verification** that the consumer turns out not to have: its dedup rests on the overlay, which is store-scoped. *For the record, two facts verified along the way, not to be re-discovered*: author signature verification **is not called at runtime**; and the author digest is **not** keyed under the read secret — it is keyed by the **outer** overlay, which is public *(it is the commit's **content** that is encrypted, hence the fact that verifying still presupposes being able to read)*. Detail in `nextgraph-current-state.md`. - ~~**PW** — WriteCap = membership~~ **DROPPED (2026-07-27)**. This batch rested on a notion of membership that **does not exist** in the model (everything is keys and URLs); see the struck-through section above. It was moreover justified by a need for **dedup via signature verification** that the consumer turns out not to have: its dedup rests on the overlay, which is store-scoped. *For the record, two facts verified along the way, not to be re-discovered*: author signature verification **is not called at runtime**; and the author digest is **not** keyed under the read secret — it is keyed by the **outer** overlay, which is public *(it is the commit's **content** that is encrypted, hence the fact that verifying still presupposes being able to read)*. Detail in `nextgraph-current-state.md`.
+203 -3
View File
@@ -1,6 +1,206 @@
# Brief — P1a: the capability surface # Brief — P1a: the capability surface
**Status: specified, ready to implement.** Extracted from `2026-07-20-caps-emulation-alignment.md` (which remains the wider chantier: P0 findings, P1b enforcement, P2P4, and the adversarial reviews). This file is the actionable lot; read it alone to implement. **Status: IMPLEMENTED 2026-07-28, awaiting review.** Extracted from `2026-07-20-caps-emulation-alignment.md` (which remains the wider chantier: P0 findings, P1b enforcement, P2P4, and the adversarial reviews). This file is the actionable lot; read it alone to implement.
The spec below is unchanged — read it first. Everything from here to *Why this lot exists* is the implementation report: what landed, the exact surface a consumer codes against, the decisions taken, and what is **not** verified.
---
# Implementation report (2026-07-28)
> **Superseded in places by later lots — read with [`2026-07-30-virtual-wallet-boundary.md`](2026-07-30-virtual-wallet-boundary.md).** This report was accurate on 2026-07-28 and is kept as written; four of its statements have since been overtaken, and a fifth was wrong when written:
>
> - *"ReadCaps are NOT persisted as caps anywhere. There is no key store"* — **no longer true.** Both durable registers are now emulated: `shim:readCap` on the store's Store branch (`AddRepo`) and `shim:link` on its User branch (`AddLink`). Caps are read back, not recomputed.
> - *"Processing inboxes … Not started"* — **done** (`src/connect.ts`), at both levels, including per-document inboxes.
> - *"`Nuri` and `ReadCap` are plain strings"* — **superseded the same week**: they are template literal types, so the confusion the runtime guard catches is now also a compile error. The *Typing* section below records the change; the earlier sentences were not rewritten.
> - The `:k:` segment throughout — **a ReadCap is `r:`** (`BlockRef::readcap_nuri`, `engine/repo/src/types.rs:518-521`), reported by NextGraph's developer and verified. `:k:` belongs to objects, files and commits.
> - *"That branch lists the store's documents… It is the owner's keyring. Upstream, the keyring is the wallet"* — **wrong when written**, and it is the sentence that produced a global in-memory "keyring". There is no keyring object; the wallet holds one root key per user. See [`../readcap-and-nuri-model.md`](../readcap-and-nuri-model.md) §4quater. The word *keyring* is left standing everywhere below because this report is kept as written; read it as *"what the holder holds"*, which is what the code now calls it.
> - `fileOwnCaps` — **renamed and split.** Writing a created document's cap is `holdOwnCap`, reading them back is `readStoreCaps`, and a user's own structure (three stores + inbox) is `fileOwnStructure`. Searching the code for `fileOwnCaps` finds nothing.
## What landed
| Spec | Where |
|---|---|
| `Nuri` / `ReadCap` (plain strings, `:r:` discriminant) | `packages/client/src/types.ts`, `src/nuri.ts` (internal parse/mint/derive) |
| Keyring, one per identity — `capFor` | `src/caps.ts` (`CapRegistry`), surfaced as `capFor` in `src/polyfill.ts` |
| Caps of my OWN documents (the emulated `AddRepo { read_cap }`) | `src/store-registry.ts` `fileOwnCaps`, called from `createEntityDoc` and `listMyEntityDocs` |
| `shareCap(cap, toInbox)` + reception with no dedicated operation | `src/inbox.ts` (`shareCap`, and the inline absorption in `read`) |
| `publishRepoLink` | `src/caps.ts`. *(The published-only guard it fed lived in `src/discovery.ts`, removed 2026-07-30 — see the boundary brief.)* |
| Possession gate on reads | `src/read-model.ts` (`readUnion`), `src/read-filter.ts`, `src/use-shape.ts` |
| Cap-mutation signal (a delivered cap re-triggers reads) | `CapRegistry.onChange``src/watch-shape.ts` |
| Acceptance test (§8) | `test/cross-user-access.test.ts` (see below); isolation end-to-end in `test/isolation-active.test.ts`. *Originally `test/watch-shape.test.ts` (e), on the discovery fold — dropped 2026-07-30 with `discovery.ts`; the property it proved is covered on the model's own terms by the cross-user scenario.* |
| Cross-user scenario (§5 non-recursiveness) | `test/cross-user-access.test.ts` — see below |
### The cross-user scenario, as the PO specified it (`test/cross-user-access.test.ts`)
Alice owns a **protected** document holding a secret, and a **public** document that carries a **reference** to it — a bare NURI. Then:
- **Bob** holds the public document's link. He reads it, finds the reference, and can NAME Alice's protected document while reading nothing of it. Publication is **not recursive**.
- **Charlie** holds the same link, plus the protected document's cap — delivered by Alice to his inbox. Same document, same reference, same path: he reads through it.
- **The only difference between them is what their keyring holds.** Nobody was named to the registry; Alice addressed an inbox.
- **Dynamic**: Bob is refused, Alice delivers the cap to *his* inbox, his client processes it — and the read that was empty yields the content. Filing the cap fires `CapRegistry.onChange`, so a reader wired to that signal (which is what `watchShape` does internally) re-reads on its own.
One property this makes explicit and that is worth confirming: **the bare NURI of a PUBLIC document is not enough either** — its repo link is. See *Publication travels as a link* below.
## The exact surface a consumer codes against
From `@ng-eventually/client/polyfill`:
```ts
capFor(nuri: Nuri): ReadCap | undefined // the keyring lookup
shareCap(cap: ReadCap, toInbox: Nuri): Promise<void>
getCaps(): CapRegistry
resetCaps(): void // tests / fresh wallet ONLY — never on identity change
setCurrentUser(id: PrincipalId | null): void // selects WHICH keyring is consulted
```
On `CapRegistry` (reached via `getCaps()`):
```ts
open(nuri: Nuri, scope: Scope): ReadCap // "this document is mine, in this scope"
mint(nuri: Nuri): ReadCap // …its protected/private half
publishRepoLink(nuri: Nuri): ReadCap // …its public half — returns the SHAREABLE LINK
learn(cap: ReadCap): void // file a cap I was given (throws on a bare reference)
capFor(nuri: Nuri): ReadCap | undefined
isPublished(nuri: Nuri): boolean
isEnforcing(): boolean // false until the first cap exists
onChange(listener: () => void): () => void // keyring mutations
grantWrite / canWrite / governsWrite / hasWritePolicy // unchanged, decorative, P1b
clear(): void
```
Plus the narrowing guards, from the SDK-identical entry:
```ts
isNuri(s: string): s is Nuri // an untrusted string → a Nuri
hasReadCap(s: string): s is ReadCap // …→ a ReadCap; the ONLY such narrowing
```
Types: `Nuri`, `ReadCap`, `Scope`, `PrincipalId` are all exported from the SDK-identical entry (`export * from "./types"`). `Scope` is a literal union, so `open(doc, "protected")` is compiler-checked. `capFor` returns `ReadCap | undefined`, so under `strict` the consumer is forced to handle "I hold nothing".
Every call accepting a `Nuri` also accepts the cap-bearing form and normalizes it (`targetOf`), so passing a cap where a NURI is expected is never a silent mismatch — and it type-checks, because `ReadCap` is assignable to `Nuri`.
## Typing — template literal types, not `string`, not branded types
**Amended on the PO's instruction (2026-07-30), after the first pass shipped both as `type X = string`.** The types are now:
```ts
type Nuri = `did:ng:${string}`
type ReadCap = `did:ng:${string}:r:${string}`
```
Still **strings** — assignable to `string`, JSON-serializable, no wrapper object — so nothing has to be *un*-typed when the real SDK arrives and takes `nuri: String`. §1's two documented objections to branded types do not apply: there is nothing to un-type at migration, and the cost at the ORM/SPARQL boundaries was **measured at zero** (see below). What the template buys is the single asymmetry that matters: a `ReadCap` is freely usable wherever a `Nuri` is expected (a cap IS a NURI with the key inside — upstream's one `NuriV0`), while a bare `Nuri` where a `ReadCap` is required is a **compile error**.
This is the one place the implementation departs from the letter of §7 (*"the discrimination lives in what you can obtain, not in what the compiler permits"*). It was an explicit PO decision: the consumer app benefits from the distinction, and possession is still what actually decides — the compiler only stops the app from writing a call the model has no meaning for.
**Cost, measured on the whole repo**: typing both aliases produced 16 errors, all of them at genuine boundaries, and every one resolved by narrowing rather than casting:
| Boundary | Resolution |
|---|---|
| Broker (`docs.docCreate`, whose `ng` is `any`) | Validates with `isNuri` and throws — the declared `Promise<Nuri>` was an unchecked promise every typed NURI downstream rested on |
| SPARQL (`store-registry` `canonicalDoc`, `readScopeIndex`) | Narrow with `isNuri`; a stored value that is not a reference is now discarded instead of flowing through as a "document NURI" |
| ORM (`read-filter` `docOf`, an untyped `@graph`) | Narrow with `isNuri` |
| Inbox payload | Free — it already tested `hasReadCap`, which is now a **type guard** (`s is ReadCap`) |
| `assertNuri` | Made generic (`<T extends string>(nuri: T): T`) so the caller's type flows through instead of widening to `string` |
| `nuri.ts` `targetOf` | **The one cast in the library**, in the primitive that defines the contract, so no caller needs one |
| Playwright bridge (e2e) | An `asNuri` helper that throws — arguments cross the bridge as plain strings |
`isNuri` and `hasReadCap` are **exported from the SDK-identical entry**, so a consumer narrows its own strings (storage, URL, JSON, a form) the same way instead of casting.
**The runtime guards stay, and are not redundant**: a JavaScript consumer never meets the compiler, and a cap read back from storage and *cast* rather than narrowed reaches the library just the same. `CapRegistry.file` — the single door into any keyring — refuses a reference with no `:r:`, and `inbox.shareCap` does likewise. That guard was added during implementation after the trap was demonstrated: `learn(someBareNuri)` filed the bare reference under its own name, `capFor` returned it, and the document read — "naming is not reading" silently becoming "naming is reading".
*(An earlier version of this section flagged a gap around what a consumer put into a discovery `ref`. Moot since 2026-07-30: `discovery.ts` was removed — there is no discovery. Circulating a link is now an explicit act, `shareCap(link, inbox)`, whose argument is typed `ReadCap` and checked at runtime.)*
## How this articulates with the virtual users
This is the part worth reviewing closely, because P1a puts a NextGraph concept (the keyring = the wallet) on top of an emulation that already fakes wallets.
**Upstream, the keyring IS the wallet.** Here there is ONE physical user that everybody opens, and an "identity" is a *virtual* wallet: a shim account in `store-registry`, mapped to three scope-index documents. So the registry holds **one keyring per virtual user**`Map<accountKey, Map<Nuri, ReadCap>>` — and `setCurrentUser(id)` selects which one is consulted. Switching identity switches keyrings structurally; there is nothing to reset and nothing is wiped.
**Where a virtual user's caps come from, and what makes them survive a reload:**
- *Its own documents* — the scope-index document of the (account × scope) plays the role of the store branch that carries `AddRepo { read_cap }` upstream. `createEntityDoc` files the cap on creation; `listMyEntityDocs` refiles them on any later session. Nothing is persisted as a key store: the emulated key is derived from the NURI, so listing the documents is enough to hold them again. **This is why a fresh page reads its own documents with nothing re-declared.**
- *Documents shared with it* — the cap lives in the recipient's **inbox document**, which is persistent in the shared wallet. It re-enters the keyring when the consumer processes that inbox.
**Today, caps received are refiled only when the inbox is read** — which means the consumer's startup sequence has to do it. **The PO has ruled that this is wrong** (see *Follow-up* below): inbox processing belongs to the polyfill, on connection, not to the app.
**A defect found while writing this up, and fixed.** The keyring was keyed on the **raw** `currentUser`, while the shim keys accounts through the consumer-injected `normalizeId`. So `setCurrentUser("@Alice")` and `setCurrentUser("alice")` — ONE shim account, one set of documents — produced **two keyrings**, and the second one was empty: the identity stopped reading its own documents. The keyring now keys the same way the shim does, so one virtual user has exactly one keyring however its id is spelled. Locked by `test/isolation-active.test.ts` *one keyring per virtual WALLET, not per spelling of its id*.
**Still per-process, and that is correct**: the keyring is in memory, so two tabs have two keyrings. Each rebuilds itself the same way (scope index + inbox), which is exactly how a real wallet behaves on two devices.
## Where NURIs and ReadCaps are actually stored
Worth stating plainly, because "the keyring" is in memory and that sounds fragile until you see what backs it.
**NURIs are persisted, in RDF, in the shared wallet** — they always were:
| What | Where it lives | Written by |
|---|---|---|
| account → its 3 scope-index documents | the **doc-shim**, itself named by a write-once pointer triple in the private store-root | `store-registry.writeRecord` |
| scope index → the NURIs of that scope's entity documents | the per-(account × scope) **index document**, as `shim:contains` literals | `store-registry.createEntityDoc` |
| a document's own content, including any reference to another document | that **document's** graph | the consumer's write path |
| an inbox deposit (payload, ts, from) | the **inbox document's** graph | `inbox.post` |
**ReadCaps are NOT persisted as caps anywhere.** There is no key store, no trousseau document, nothing on disk that says "this identity holds these keys". The keyring is a plain in-memory `Map<accountKey, Map<Nuri, ReadCap>>` inside `CapRegistry`, rebuilt from scratch on every page load out of two persisted sources:
- **my own documents** → `listMyEntityDocs` reads the store's document list (persisted NURIs) and re-mints each cap, whose value is the constant `OK`. So *knowing which documents are mine is knowing their caps*. Upstream the key really is stored, on the Store branch (`AddRepo { read_cap }`); emulating that storage rather than re-minting is a separate lot.
- **documents shared with me** → the cap sits **inside an inbox deposit's JSON payload**, which IS persisted (it is an ordinary triple in the inbox document's graph). Processing the inbox re-files it. So a shared cap survives a reload because the *delivery* is durable, not because we stored a key — which is exactly the upstream shape, where the seal sits in the inbox until the verifier applies it.
Consequence to keep in view: **a cap is only as durable as its delivery**. That is why the PO's follow-up below (the polyfill processing inboxes on connection) matters more than it looks — until it lands, the durability of a shared cap depends on the app remembering to read its inbox.
Second consequence, on the emulated key being derived: anyone can compute any document's cap from its NURI. That is the P1a/P1b line, stated once more — possession is a **shape** here, not a protection.
## Publication travels as a link — a choice to confirm
§5 says a public item is read by "whoever has the URL", and §8 says a harvested **bare** reference must yield nothing. Both hold only if what circulates for a public document is its **repo link** (`publishRepoLink``did:ng:o:…:r:…`), not its bare NURI. That is what was implemented, and the cross-user test pins it: Bob holding only the public document's bare NURI reads nothing; holding its link, he reads it.
The alternative — making a published document readable from its bare NURI — was rejected because the "published" fact would then live **only in the local registry**: another tab, another process, another user would have no way to know a document was published, and the emulation would stop being portable. Carrying the fact **in the data** (the link) is what makes it work across processes, and it matches `RepoLinkV0` upstream.
This is the point where the emulation is furthest from the eventual target, where the public store may not encrypt at all and a bare NURI would suffice. Per §5 that is fine — *"if the public store does not behave as this principle describes, this library adapts, not the consumer"* — but it is a deliberate divergence and the PO should confirm it.
## A debt this lot created — the unguarded inbox — **CLOSED 2026-07-30**
`inbox.read` had no guard and **absorbs caps into the reader's keyring**, so `inbox.read(someoneElsesInbox)` pocketed the caps addressed to them and directed sharing was defeatable by anyone who knew an inbox NURI. The inbox was never guarded before either, but before P1a it carried nothing that granted access.
Fixed in step 2 of [`2026-07-30-virtual-wallet-boundary.md`](2026-07-30-virtual-wallet-boundary.md): an inbox now BELONGS to a virtual user (`storeRegistry.walletInbox`), and only its owner may read it. Depositing into anyone's inbox stays open — that is the one legitimate cross-wallet act, and the only way a link crosses between wallets at all.
## Follow-up decided by the PO — to plan, NOT in this lot
**Processing inboxes is the polyfill's job, not the app's** (PO, 2026-07-30). As soon as the app connects a user/wallet, the polyfill must process that identity's inboxes itself — the consumer should never have to remember to do it for its shared documents to become readable.
And it is inboxes, plural, at **two levels**:
- the **user/wallet** inbox — where ReadCaps arrive;
- the inbox of **every document the user owns**, so deposits land per document too. *(The justification originally given here — "a document has its own inbox upstream" — is **false**; see the correction in [`2026-07-30-virtual-wallet-boundary.md`](2026-07-30-virtual-wallet-boundary.md) step 7. The design decision stands on the consumer's need and on the record's per-`repo_id` shape, not on an upstream document inbox that does not exist.)*
What exists today and is reusable: `inbox.readSynced` (the cold, barrier-gated read meant exactly for "process the inbox at (re)connection"), the inline cap absorption in `inbox.read`, and `CapRegistry.onChange` to re-trigger the reads a late cap unblocks. What is missing is the **orchestration**: a connection hook that enumerates the identity's inboxes (wallet-level + one per owned document, via the scope indexes) and processes them, idempotently and without polling.
Not started. It changes the consumer contract in the right direction (one less obligation), so it should land before the consumer re-architecture settles.
## Decisions taken while implementing, none contradicting the spec
- **`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.
- **`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.
- **`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.
## Verification status
- **Unit suite green — 146 tests**, typecheck clean on `src`, `test` and the e2e harness.
- The typing was verified from a **consumer's** point of view, not just the library's: a synthetic app compiled against the entry points shows the two real mistakes (`shareCap(bareNuri, …)` and passing a raw `string` from storage) as compile errors, while every correct path — `capFor(doc)``shareCap(cap, inbox)`, and narrowing with the exported guards — needs no cast.
- The acceptance test was **mutation-checked**: reverting both gardes (the discovery fold and the `readUnion` possession gate) makes `watch-shape.test.ts` (e) fail with the bare-referenced document reappearing. The test has teeth.
- **The e2e ran against the live broker (`nextgraph.eu`) on 2026-08-03 — 39 passed, 0 failed.** The first run was 22/8, and the eight refusals were not test noise: they exposed a **real hole in the surface**. `docs.docCreate` filed no cap for the creator, so a consumer could create a document through the public primitive and then be refused reading or writing it. Upstream that cannot happen — `doc_create` commits `AddRepo { read_cap }` to the store's Store branch, so the creator holds it from the first instant. Fixed at `packages/client/src/docs.ts:73`, and deliberately NOT replicated in `physical.ts`: the shim's own documents belong to no user, and `store-registry` files their caps where it knows whose they are. The remaining failures were the harness acting as a second identity without establishing it (`createEntityDoc(id, …)` with someone else connected) or reading an arbitrary document as an inbox; both are now `setCurrentUser` + `walletInbox`, which is what a consumer must do too.
- **An e2e run against a persistent wallet must use a FRESH identity per run.** The second run was green and the third was not, on unchanged code: moving the inbox tests onto `walletInbox(id)` made the inbox *stable for its owner* — which is the point of an inbox — so a fixed id accumulates every past run's deposits and `deposits.length === 2` drifts to 4. Green-then-red on identical code is the tell. The disposable thing is the **user**, not the inbox: `run.ts` now stamps `@inbox-user-`/`@watcher-`/`@friend-` with `Date.now()`, as it already did for `@alice-`. Any future step that resolves a durable per-user document (inbox, stores, Links) inherits this constraint.
- **The cap registry is process-wide and `bun test` shares modules across files**, so suites that read without declaring caps now reset explicitly (`read-model.test.ts`, `watch-shape.test.ts`). Worth knowing before adding a suite.
## Documentation state
The permanent documentation was updated in the same pass (root `README.md`, `packages/client/README.md`, `docs/simulation.md`, `docs/migration-guide.md` §1 + the assumed `declareConnections` break, `docs/read-model.md`, `docs/readcap-and-nuri-model.md` §5, `docs/nextgraph-current-state.md`, `packages/client/docs/sdk-reference.md`). **If the review changes the surface, those are the files to re-align** — they describe the code as it stands now, not a validated state.
---
Written 2026-07-27, after two adversarial reviews and three corrections from the PO. Background: `../vision.md` (why this library exists), `../readcap-and-nuri-model.md` (the target model, verified against `nextgraph-rs`). Written 2026-07-27, after two adversarial reviews and three corrections from the PO. Background: `../vision.md` (why this library exists), `../readcap-and-nuri-model.md` (the target model, verified against `nextgraph-rs`).
@@ -31,11 +231,11 @@ Every invented name is **vocabulary debt**: the reader has to carry a translatio
### 1. Types — one new name ### 1. Types — one new name
A NURI is **one object**, with or without the key inside — upstream, `NuriV0 { target, access }`, where a cap-less NURI simply has an empty `access`. `did:ng:` is the **URI scheme prefix**, present on inboxes, branches and overlays alike; it does not mean "without cap". The discriminant is the **`:k:` segment**. A NURI is **one object**, with or without the key inside — upstream, `NuriV0 { target, access }`, where a cap-less NURI simply has an empty `access`. `did:ng:` is the **URI scheme prefix**, present on inboxes, branches and overlays alike; it does not mean "without cap". The discriminant is the **`:r:` segment** *(the spec said `:k:`; corrected 2026-07-30 on a report from NextGraph's developer — `:k:` belongs to objects/files/commits, a ReadCap is `r:{base64url(serde_bare(ObjectRef))}`, `repo/types.rs:518`)*.
```ts ```ts
type Nuri = string // did:ng:o:{doc}:v:{overlay} — names, does not read type Nuri = string // did:ng:o:{doc}:v:{overlay} — names, does not read
type ReadCap = string // …:k:{key} — names AND reads type ReadCap = string // …:r:{cap} — names AND reads
``` ```
`Nuri` **keeps its current meaning** in this package (~90 call sites, untouched): the cap-less form. `ReadCap` is the upstream name — do not invent another. `Nuri` **keeps its current meaning** in this package (~90 call sites, untouched): the cap-less form. `ReadCap` is the upstream name — do not invent another.
@@ -0,0 +1,116 @@
# Brief — align on NextGraph's own model: users, stores, branches
> ## REFUTED by adversarial review, 2026-07-30 — do not implement as written
>
> Seven defects, four of them factual errors about NextGraph. The three that break the plan outright:
>
> 1. **D4 rests on a false premise.** There IS a register for received caps: `AddLink { read_cap }` on the **User branch** of the private store — *"so that a user can share with all its device a new Link they received… Only external repos are accepted"* (`engine/repo/src/types.rs:1934-1950`, verifier arm `commits/mod.rs:681`). It is wallet-resident and **cross-device** — the exact opposite of D4's per-browser localStorage. Corrected in [`../readcap-and-nuri-model.md`](../readcap-and-nuri-model.md) §4quinquies.
> 2. **D2's rejection of the named graph is factually wrong.** A `GRAPH <…:v:…:b:…>` quad IS resolved to that branch and committed on **its own** topic (`engine/verifier/src/commits/transaction.rs:386-434`); the verifier does exactly this in `update_header`. An unknown branch id errors rather than silently landing on Main. And a branch **is** a valid SPARQL-update target: `TargetBranchV0::is_valid_for_sparql_update` returns true for `BranchId` (`engine/net/src/app_protocol.rs:77-82`) — the fact table's claim to the contrary was wrong twice over.
> 3. **The Store branch holds no triples at all.** `BranchCrdt::None`, *"used by Overlay, Store and User BranchTypes"* (`engine/repo/src/types.rs:1420`). It is a stream of service commits (`AddRepo`/`RemoveRepo`), not a graph — so all three D2 candidates were RDF inventions dressed as fidelity.
>
> And four more, all confirmed:
>
> 4. **D4 would delete a working recovery path.** Inbox deposits are never removed (`packages/client/src/inbox.ts`), so a second device/tab recovers its caps by re-reading. localStorage-without-re-reading loses them permanently, and contradicts P1a's delivered doctrine that per-process rebuild "is correct".
> 5. **D3 is false outside entity documents.** `capFor(scopeIndexDoc)` and `capFor(walletInbox)` are undefined before *and after* `listMyEntityDocs` — their caps can only ever be derived. Yet the boundary brief requires them reachable. Upstream that root comes from the wallet plus `AddSignerCap` on the User branch — a level the fact table omitted entirely.
> 6. **`doc_create` writes four times, not two** (+ the class quad on the Header branch, + `AddSignerCap` on the User branch).
> 7. **Ordering defect: D2 before the boundary guard opens cap harvesting.** Once caps are triples in `scopeIndexDoc(bob,…)`, and both `scopeIndexDoc` and `docs.sparqlQuery` are exported, `setCurrentUser("mallory")` reads Bob's caps. Today `mintCap` is unexported, so a NURI yields nothing. **The guard must land before the caps become triples.**
>
> Also flagged: "store" already means the *native* store in this codebase (`RegistrySession.privateStoreId`), so D1's `privateStore`/`storeDoc` collide head-on; "the keyring notion disappears" contradicts `readcap-and-nuri-model.md` §4quater, which calls the Store branch the owner's keyring; upstream `ldp#contains` takes an **IRI** object while the polyfill writes a **literal**, so D1 is not "nothing behavioural".
>
> Rewrite required. The verified facts are being folded back into `readcap-and-nuri-model.md` first; the plan is re-derived from there, not from this text.
**Status: REFUTED 2026-07-30 — superseded, kept as the record of what was wrong. Original header follows.**
**Status: plan, not started. 2026-07-30.** Companion to [`2026-07-30-virtual-wallet-boundary.md`](2026-07-30-virtual-wallet-boundary.md) (whose steps 34 are still pending) and to the caps chantier. This lot is about saying — and storing — what NextGraph says and stores, instead of a parallel vocabulary of our own.
## Why this lot exists
Two PO corrections, 2026-07-30:
> If NextGraph stores the key next to the document, then that is what we do. Without encryption we simply store a stand-in value. Stay as close as possible to how NextGraph works.
> I don't like the name "scope container". Let's keep NextGraph's names. We adapt for the polyfill when we need to, but there too we must stay as close as possible.
And the vocabulary correction underneath both:
> In the NextGraph code "wallet" is called "user", because a wallet is only a keyring. Virtual user → **virtual user**; physical user → **physical user**.
The library currently invents three things NextGraph does not have — a *keyring*, a *scope index*, a *virtual user* — and recomputes a key NextGraph stores. Each invention is a translation table a reader must carry, and each is a place where the consumer learns a model that will not exist.
## Verified facts this plan rests on
All read in `nextgraph-rs` (`git 213338f6`) on 2026-07-30, recorded in full in [`../readcap-and-nuri-model.md`](../readcap-and-nuri-model.md) §4quinquies. The load-bearing ones:
| Fact | Source |
|---|---|
| A wallet holds SEVERAL users: `SensitiveWalletV0.sites: HashMap<String, SiteV0>` | `engine/wallet/src/types.rs:434,457` |
| A user (site) owns the three stores; `UserId = PubKey` | `engine/verifier/src/site.rs:23`; `engine/repo/src/types.rs:453` |
| The wallet holds ONE root key per user — the private store's read cap | `site.rs:52` (`get_individual_site_private_store_read_cap`) |
| `doc_create` writes TWICE: `AddRepo { read_cap }` on the **Store branch**, and `ldp:contains` on the **Main branch** | `engine/verifier/src/request_processor.rs:697-710`; `verifier.rs:2172-2199` |
| Replaying the Store branch is what reloads the repos with their keys | `engine/verifier/src/commits/mod.rs:644-664` |
| A branch is a NAMED GRAPH `did:ng:o:{repo}:v:{overlay}:b:{branch}`, with its own pub/sub topic and CRDT | `engine/net/src/app_protocol.rs:267-273`; `engine/repo/src/types.rs:1478-1501` |
| Anchoring on a document targets its **Main** branch | `request_processor.rs:161-168` |
| SPARQL cannot be anchored on a branch — every other target is `unimplemented!()` | `request_processor.rs:283` |
| A `Store` branch exists only on a real store, created on a path `doc_create` does not take | `engine/repo/src/store.rs:425-440` |
| A received cap has NO register: `ContactDetails.read_cap` is never read by the receiver | `engine/verifier/src/inbox_processor.rs:778-847` |
| Durability of a received cap comes from OPENING the repo, which persists its `read_cap` in local user storage | `engine/verifier/src/user_storage/repo.rs:109,219,248,359`; `verifier.rs:542-544` |
**Not verified, and therefore not relied on anywhere below**: whether a `GRAPH <…:b:…>` write from the JS SDK round-trips through a real broker. It is assumed BROKEN and avoided.
## Decisions
### D1 — Vocabulary follows NextGraph
| Ours today | Becomes | Why |
|---|---|---|
| virtual user | **virtual user** | a wallet is a keyring; what owns three stores is a user (site) |
| physical user | **physical user** | NextGraph sees exactly one user; our identities are virtual ones |
| keyring (`CapRegistry`) | *(the notion disappears — see D3)* | there is no keyring object in NextGraph, and the wallet does NOT hold every key |
| scope index / scope container (`scopeIndexDoc`, `readScopeIndex`, `indexDocOf`, `INDEX_SUBJECT`) | **store** (`storeDoc`, `readStore`, …) | the thing that lists a user's documents IS a store |
| `shim:contains` | `ldp:contains` | NextGraph's own predicate for exactly this |
`docPublic` / `docProtected` / `docPrivate` on `AccountRecord` already read as stores; keep them, or rename to `publicStore` / `protectedStore` / `privateStore` for symmetry.
### D2 — Emulate the Store branch as a distinct SUBJECT, not a distinct graph or document
The store document gains, beside its `ldp:contains` list, the read cap of each document it lists — the emulation of `AddRepo { read_cap }` on the Store branch.
Three candidate shapes were considered:
- **A distinct named graph** (`GRAPH <…:b:store>`) — **rejected**. No branch would exist behind it; the content would be committed on the anchor's Main branch while claiming to live elsewhere. It misrepresents the structure, and its sync behaviour against a real broker is unverified (see above).
- **A separate document per branch** — structurally closest (a document is what the JS SDK gives us that most resembles a branch: own topic, own sync, independently addressable), but it doubles the documents per store and adds an indirection to resolve them, to imitate a second pub/sub flow the polyfill will not use.
- **A distinct subject in the same document** — **chosen**. It buys what actually matters: the key stored *next to* the document, separate from the list, read rather than recomputed. It does not buy a second event flow, which the polyfill cannot reproduce anyway.
*The honest cost of this choice*: our two "branches" share one commit stream and one topic, where NextGraph has two. Anything that comes to depend on them being separately subscribable will have to move to the separate-document shape.
### D3 — Deriving becomes minting, and `CapRegistry` stops being a keyring
Today `fileOwnCaps` **re-derives** each cap from its NURI, which only works because the emulated key is a function of the NURI. After D2 the cap is **read from the store document**. Derivation survives only inside `nuri.ts` as how a stand-in value is *minted at creation* — the single function P1b replaces with a real key.
The in-memory `CapRegistry` then stops being "the keyring" and becomes what it actually mirrors: **the verifier's local user storage** (fact table, last row) — the per-user cache of every opened repo and its read cap.
### D4 — Received caps: persist as local user storage, not as a document
Verified: there is no received-caps register upstream, and inventing one would expose a shape the target does not have. What upstream does is persist the `read_cap` of every **opened** repo in local user storage.
So the emulation is a **local, per-virtual-user store** — the same nature as `accounts.ts`'s existing `IdentityStore` (localStorage). This ends "re-read the inbox every session to recover caps", which the PO identified as the wrong model: an inbox is a queue you consume, not a store you re-read.
*Open*: whether to do D4 in this lot or after the boundary lot. It is the piece with the most design risk, and it is not needed for D1D3 to be correct.
## Plan
1. **D1 vocabulary**, mechanically and in one pass — code, tests, docs. Nothing behavioural. Doing it first stops every later diff from being written in two vocabularies.
2. **D2 + D3**: the store document carries each listed document's cap; `fileOwnCaps` reads it instead of re-deriving; `mintCap` keeps minting at creation only. Round-trip test: create → drop all in-memory state → re-list → the cap comes back **read, not recomputed** (assert by minting a *different* stand-in value in the test and checking the stored one wins).
3. **D4** local per-user persistence of opened caps, replacing inbox re-reading.
4. Then resume the boundary lot's steps 34 (guard at the four passage points; remove the cross-account fan-out), which are written in the new vocabulary.
## What this breaks
`storeRegistry`'s exported names change (`scopeIndexDoc`, `listEntityDocs`, `AccountRecord` fields). `shim:contains` becomes `ldp:contains`, so **existing dev wallets stop resolving their documents** — acceptable for dev data, and consistent with how the pointer/doc-shim migration was handled before, but it must be stated rather than discovered.
## Risks I want challenged
- D2's "distinct subject" may be too weak a reading of "stay close to NextGraph" — the separate-document shape is defensible and I may be under-weighting it.
- D3 assumes reading the stored cap is always possible where deriving was — i.e. that every path reaching `fileOwnCaps` has the store document at hand.
- D4 introduces browser-local state to a library that currently keeps everything in the shared wallet; that may be a bigger departure than it looks.
- The vocabulary change touches the boundary brief and the P1a brief, which are mid-flight.
@@ -0,0 +1,199 @@
# Brief — the virtual user boundary
**Status: specified 2026-07-30; all seven steps done** (four planned, three added as the work uncovered them). Sits alongside `2026-07-20-caps-emulation-alignment.md` (the wider caps chantier) and `2026-07-27-p1a-cap-surface.md` (the surface, implemented). This lot is about something more fundamental than either: **what a virtual user is allowed to reach.**
## Why this lot exists
A virtual user must **simulate the boundary of the future single-user wallet**. Today it does not: it is a grouping fiction — a shim account listing three index documents — and nothing enforces it. Every access function reaches any document of any identity, given a session id and a NURI.
Stated by the PO on 2026-07-30, on discovering the state:
> A virtual user must simulate the boundary of the future mono-user wallet. So the access functions must all be restricted to the virtual user currently "connected" (`setCurrentUser`). No cross-wallet access may be permitted, otherwise we are building on a fundamentally wrong model.
This is the same failure mode the whole caps chantier exists to prevent, one level down. P1a fixed the *shape* of reading (possession, not an ACL). It left the *reach* unbounded — and a consumer coded against an unbounded reach is coded against a world that will never exist, exactly like one coded against an ACL.
## The rule
> **The only reads/writes not confined to a virtual user are those that make multi-wallet operation possible at all** (e.g. the index of virtual users). — PO, 2026-07-30
And its sharpened form, which decides the hard cases:
> **Nothing common — only indexing mechanisms to make the virtual users work.** — PO, 2026-07-30
So an exemption must be *plumbing*, never *pooled user data*. The test: **does removing it stop the virtual users from functioning, or does it merely stop users from seeing each other's content?** Only the first justifies living outside a wallet. The shim passes (remove it and no wallet is resolvable); a shared index of user announcements does not (remove it and every wallet still works — you simply have to be given links).
Everything else is confined. The exemption list is short, explicitly named, and each entry has to justify itself against those two sentences — an exemption that merely *helps* is not an exemption.
## The premise that collapsed: there is no discovery
Recorded here because it removes a whole module rather than guarding it (PO, 2026-07-30 — see [`../readcap-and-nuri-model.md`](../readcap-and-nuri-model.md) §4ter-bis, where the principle is documented in full):
> **You cannot discover. You can only follow links.** NextGraph is local-first: publishing is *place the data in your public store* **and** *circulate the link* — into inboxes, or into somewhere already reachable by the people concerned. It is seen only by those who received the link. Private distribution is the same act plus the ReadCap.
`discovery.ts` therefore fails on **both** counts: it emulates a global-list capability the target will never have, and it is pooled user data across wallets. It is not a boundary to guard, it is a module to remove — with `watchShape('public')`'s discovery fold, `INDEX_ACCOUNT`, and the `submitToIndex` guard along with it.
What replaces it is not a mechanism but the model itself: a link reaches you through an **inbox**, or through a document you already hold. Which makes the inbox the bootstrap of the entire reachability graph — the reason its guard (below) and its automatic processing matter more than they first appear.
*Consequence for P1a's acceptance test, resolved*: `test/watch-shape.test.ts` (e) proved "a harvested bare reference reads nothing, the repo link reads the document" **on the discovery fold**. The property is independent of discovery and survives — `test/cross-user-access.test.ts` already proves it on the model's own terms (Bob follows a reference found in a document he holds), so (e) was dropped rather than re-based.
## The good news: the boundary already exists
**The keyring is the boundary.** A document is legitimately reachable when `capFor(doc)` answers — either because this wallet created it (its scope index refiles the cap, the emulated `AddRepo { read_cap }`) or because someone delivered the cap to it. No new notion is needed; the guard is written. What is missing is applying it.
And the surface to guard is small. Everything in the library reaches NextGraph through **four functions in two modules**:
- `docs.docCreate`, `docs.sparqlUpdate`, `docs.sparqlQuery`
- `subscribe`'s `ng.doc_subscribe`
Nothing else touches `ng`. (`open-repo` only tests whether `doc_subscribe` exists; `ng-proxy` is the app-facing proxy.)
## What is confined, and what is exempt
**Exempt — each one passes the rule:**
| Exemption | Why it makes multi-wallet operation possible |
|---|---|
| The store-root **pointer** + the **doc-shim**, to resolve THE CURRENT account | This is the index of virtual users. Without it no virtual user is resolvable at all. **Resolution only** — enumerating every account is not covered (see below). |
| The **reserved accounts** (the inbox anchor; `@index` is gone with `discovery.ts`) | They host infrastructure documents; they are not anybody's wallet. |
| **Depositing** into another wallet's inbox (write-only) | Without a cross-wallet write channel there is no sharing, hence no useful multi-wallet — and it carries no pooled data: a deposit is addressed to one wallet, not shared between them. This IS the NextGraph model: an inbox deposit is anonymous and sealed, and grants the depositor nothing in return. |
~~Reading a discovery index~~**withdrawn 2026-07-30**, on both counts: it emulates a capability that does not exist, and it is pooled user data. See *The premise that collapsed* above.
**Confined — none of these passes the rule:**
| Path | Today | Becomes |
|---|---|---|
| `docs.sparqlQuery` / `sparqlUpdate` (**exported from the SDK entry**) | any document, any wallet | guarded on the anchor: the cap must be held |
| `inbox.read` / `readSynced` / `watch` | **any inbox, including someone else's** | only inboxes belonging to the current wallet |
| `subscribeDoc` | any document | only documents whose cap is held |
| `storeRegistry.listEntityDocs` / `resolveReadGraphs` | fan-out over every account | **removed** — cross-wallet enumeration, and its former justification (feeding discovery) is gone too |
| `storeRegistry.allAccounts` / `loadShim` | enumerates every virtual user | **removed**, or reduced to the reserved-account resolution that infrastructure needs |
| `storeRegistry.ensureAccount(id)` | any id | the current identity, plus the reserved accounts |
| `readModel.readUnion` | ✅ already guarded (P1a) | unchanged |
## The breach P1a opened, and which this lot must close first
`inbox.read` has no guard, and since P1a it **absorbs caps into the reader's keyring**. So:
```ts
setCurrentUser("mallory");
await inbox.read(bobsInbox); // mallory pockets the caps addressed to Bob
```
Directed sharing is therefore defeatable by anyone who knows an inbox NURI. Strictly speaking this is not a regression — the inbox was never guarded — but before P1a it carried nothing that granted access, and now it does. **This is the first thing to fix**, and it is arguably P1a's own debt rather than this lot's.
Closing it needs a notion that does not exist yet: **"my inbox"**. Today an inbox is an arbitrary NURI supplied by the caller. Which is the same brick as the PO's other instruction, so they should land together:
> Processing inboxes is the polyfill's job, not the app's — as soon as the app connects a user/wallet, at **two levels**: the wallet inbox (where ReadCaps arrive) and the inbox of **every document the user owns**.
## Design notes for the implementation
**Scaffolding in the keyring, not in the exemption list.** The current account's three scope-index documents belong to its wallet, so they should be *in its keyring* rather than exempted. That keeps the exemption list down to what genuinely serves multi-wallet operation (the shim, the reserved accounts). Only the pointer and the doc-shim stay outside.
**Exemptions are named, never inferred.** A NURI is exempt because it is *the* shim document or *a* reserved account's document, resolved as such — never because it "looks like infrastructure". An inferred exemption is a hole.
**Write-only really means write-only.** Depositing into another wallet's inbox must not make that inbox readable, subscribable, or listable as a side effect. This is the one asymmetric permission in the model and it needs its own test.
**The guard belongs to a function, not to a position.** One named predicate ("may the current wallet reach this document?"), called at each of the four passage points — not four inline checks that drift apart.
## Hardening is the polyfill's responsibility, not a negotiation with the consumer
Stated by the PO on 2026-07-30, closing the question "what does the app actually use?":
> We do not need to know what the app uses: the polyfill must harden **everything it exposes**. That is its responsibility. **Nothing may allow its own mechanisms to be bypassed** — the virtual user in particular.
This settles how the lot proceeds, and it generalizes past it. A surface that lets a caller go around the wallet boundary does not merely risk misuse: it **teaches a model that will not exist**, which is the one thing this library exists to prevent. So an exposed function that can bypass a mechanism the polyfill provides is a defect *whether or not anyone calls it that way*, and "the consumer might depend on it" is not an argument for keeping it — if the consumer depends on it, the consumer depends on something the target will refuse.
Applies to every exported surface, including ones added later: **if it is exposed, it is guarded**.
## What this breaks
`docs.*` is exported from the SDK-identical entry and can reach any document; the cross-account fan-out enumerates every wallet; `discovery.*` disappears entirely. The consumer will have to change where it relied on any of them. That is the point, not a side effect: each one is the API starting to tell the truth about a boundary that will exist. Update `../migration-guide.md` accordingly.
## Order of work
1. ~~**Remove `discovery.***~~**DONE 2026-07-30.** `src/discovery.ts` and `test/discovery.test.ts` deleted; `INDEX_ACCOUNT`, `watchShape`'s public-scope fold and its discovery-index container subscription, `nurisFromRef`, the `submitToIndex` guard, and the e2e discovery block all removed. P1a's acceptance test did not need re-basing: `test/cross-user-access.test.ts` already proves the same property (a bare reference reads nothing, the link reads the document) on the model's own terms — following a link — so `watch-shape.test.ts` (e), which proved it on the discovery fold, was dropped. Docs realigned: the ADR is marked superseded, `read-model.md` now describes ONE regime (follow, never enumerate), and the root README's capability row records the removal.
2. ~~**"My inbox" + the inbox read guard**~~**DONE 2026-07-30.** `storeRegistry.walletInbox(id)` gives every virtual user its own inbox document, created on first sight and recorded in the doc-shim under its own predicate (`shim:docInbox`), read by its OWN query so an account record written before this existed still resolves — the fixed account SELECT did not grow a fourth required field. `isOwnInbox(nuri)` is the predicate; `inbox.read` / `readSynced` (hence `watch`, which reads through it) refuse an inbox that is not the connected wallet's, and refuse outright when no identity is set. **Depositing stays open**`post` / `shareCap` are untouched, because that is the one legitimate cross-wallet act. The shared `resolveInboxAnchor` (a reserved account's document, an inbox COMMON to every wallet) was removed: it was unused by the library and violated *nothing common*. Locked by `test/isolation-active.test.ts` *an inbox may be DEPOSITED into by anyone, and READ only by its owner*, which walks the exact breach — Alice deposits, cannot read back; Mallory knowing the NURI absorbs nothing; anonymous is refused; Bob reads his own and only then does the cap land.
*Not done, and deliberately*: per-DOCUMENT inboxes. ~~Upstream every document has one~~**false, corrected 2026-08-03**: no document has an inbox upstream, and neither does the private store (see step 7's correction). Here only the wallet does. **The PO has ruled they must come** (2026-07-30) — *"it can come in a second step, but it must come"* — so this is a commitment, not an option. The guard predicate (`isOwnInbox`) is where they plug in: it answers "is this inbox mine?", which extends to "…one of my documents' inboxes" without changing a single caller.
### Two defects this step surfaced — the first still open, the second closed by steps 56
**`walletInbox(id)` is a directory, and directories do not exist.** It resolves ANY wallet's inbox from its identity id, and it is exported (`storeRegistry.*` is re-exported from the SDK entry). But you cannot look someone up in NextGraph — you cannot discover, you can only follow links. Their inbox NURI reaches you because *they gave it to you*, not because you resolved it from a name. Resolving **my own** inbox is legitimate plumbing; resolving **anyone's** is the same shape as the discovery index just removed. Fix: the public surface becomes "my inbox" (no argument), and reaching someone else's requires a NURI you were given. Resolution-by-id stays internal, for the shim and the tests.
**The keyring is not stored anywhere, and the shape is wrong — fix it now, not at P1b.** It is an in-memory `Map<accountKey, Map<Nuri, ReadCap>>`, rebuilt from scratch each session. Nothing persists a cap *as a cap*. PO directive, 2026-07-30:
> If NextGraph stores the key next to the document, then that is what we do. Without encryption we simply store a stand-in value. Stay as close as possible to how NextGraph works.
So this is not a P1b concern, it is a **shape** concern — the one thing this library exists to get right — and the stand-in key is stored exactly where the real one will be. Two storage sites, mirroring upstream:
- **My own documents** → the cap goes **beside the NURI in the scope container**, which is the emulation of `AddRepo { read_cap }` on a branch of the store. Today the container stores only `shim:contains <nuri>` and the cap is **re-derived** from that NURI; it gains a `shim:readCap` beside it. Deriving then stops being how a cap is *recovered* and becomes merely how the stand-in value is *minted* — the single function P1b replaces.
- **Caps I received** → into the emulation of `AddLink { read_cap }` on the **User branch** of the private store. Verified 2026-07-30 (see [`../readcap-and-nuri-model.md`](../readcap-and-nuri-model.md) §4quinquies): that register exists, it is explicitly for **external repos**, and its stated purpose is to *"share with all its device a new Link they received"* — wallet-resident and cross-device. So a received cap belongs **inside the virtual user**, like everything else. *(Two earlier versions of this note were wrong and are recorded in [`2026-07-30-users-stores-branches.md`](2026-07-30-users-stores-branches.md): the first blamed key derivation and proposed a "keyring document"; the second concluded no register existed at all and proposed browser-local storage — which would have put library data OUTSIDE even the physical user. Both were refuted; `AddLink` is the answer.)*
3. ~~**The guard at the four passage points**~~**DONE 2026-07-30.** `src/reach.ts` holds the boundary as **two rules on one criterion — possession — implemented in two places** (PO directive):
- **Rule 1, authorization**, at the passage points (`assertMayReach`, called from `docs.sparqlQuery` / `sparqlUpdate`): nothing reaches `ng` unless the connected user possesses the document's cap.
- **Rule 2, do not even attempt**, at the callers (`mustNotAttempt`, applied in `read-model.readUnion`, which now filters BEFORE opening or reading): a reader that holds no cap does not issue the operation at all. Upstream you cannot even address a repo you have no cap for, so asking about one is not "a read that will be refused" — it is a read with no meaning.
The redundancy is the point, and a test pins it: a caller that forgets rule 2 is still refused by rule 1, so a bookkeeping lapse fails loudly instead of succeeding quietly.
**Possession decides, never the shape of the reference in hand.** A caller legitimately manipulates a bare NURI while holding its cap elsewhere — references travel bare through content and indexes, the cap sits in what the user holds. `targetOf` first, so both forms answer alike. (An earlier reading of the directive checked the string for `:r:` instead; corrected on the PO's clarification.)
Exemptions are **declared**, never inferred from a NURI's shape (`declareInfrastructure`, called by the store-registry for the store-root pointer and the doc-shim — the index of virtual users, the only thing that passes the "remove it and no user resolves at all" test). A user also reaches its own three stores and its own inbox, or the boundary would lock it out of itself.
Not done: `subscribeDoc` is not yet guarded — it interacts with `ensureRepoOpen`, which opens documents before their cap is known on some cold-start paths. Left for step 4 with the barrier tests in view.
4. ~~**Remove the cross-account fan-out**~~**DONE 2026-07-30.** `listEntityDocs`, `resolveReadGraphs`, `allAccounts`, `loadShim` and the full-shim cache are gone. Nothing in the library used them any more once `discovery` was removed; only their own tests did. `subscribeDoc` is now guarded too (rule 1) — a subscription IS an access, since the push carries the document's state, so leaving it open would have been a door beside the gate.
### Machinery vs virtual user: two APIs, and only one is the app's
The PO's framing, which replaced the exemption list entirely:
> Clearly distinguish what is polyfill machinery (and therefore the PHYSICAL user) from what is a virtual user's operation. Use different functions, probably grouped in different namespaces — because one API is exposed to the app and the other must never be.
`src/physical.ts` now holds `physicalCreate` / `physicalQuery` / `physicalUpdate`, with `ensurePhysicalRepoOpen` and `subscribePhysicalDoc` as their open/subscribe counterparts. They are unguarded, and **never exported from the package** — a test asserts it, because a regression there is silent and total.
The dividing line:
> Does this operate on the **index of virtual users** (the shim), or on the **content of one virtual user**? The first is machinery; everything else is the user's, and is confined.
So the store-root pointer, the doc-shim and the account records go through the machinery; a virtual user's stores, its inbox and its documents go through the guarded `docs.*`, even though the library is what calls them on the user's behalf.
**This is strictly stronger than the exemption list it replaces.** `declareInfrastructure` is deleted. The machinery no longer calls the guarded primitive and gets waved through — it calls a different primitive that was never guarded. There is no list to widen, to get wrong, or to infer from a NURI's shape, and the boundary now has no `if` in it that could be talked into saying yes.
5. ~~**The Link, and inbox processing on connection**~~**DONE 2026-07-30**, after the four steps above.
**The Link.** Giving access is a `Link` deposited into the recipient's inbox — upstream's word at all three stages (`InboxMsgContent::Link` for the message, `AddLink { read_cap }` for the filing, `RemoveLink` for the withdrawal). `shareCap` deposits one; the deposit kind is `…:inbox:link`.
**Applying it durably.** `storeRegistry.addLink` / `readLinks` emulate `AddLink` on the **User branch of the private store** — a distinct subject (`shim:userBranch`) in the private store document, kept separate from the `ldp:contains` listing exactly as upstream keeps the User branch separate from Main. Idempotent, so re-processing costs nothing.
**The split that matters**: `inbox.read` KEEPS a Link (in the session's keyring) but does not FILE it — reading a queue must not write to a user's store. `inbox.processInbox` *applies*: it reads, then files. That is what an inbox is upstream — **a queue you consume, not a store you re-read**.
**On connection.** `setCurrentUser` fires `connect.connectedUser()`: restore the already-applied Links from the User branch, then drain the inbox. Restore-first means a reconnecting user reads its shared documents immediately, without waiting on the queue. Fire-and-forget, because the setter is synchronous and every consumer calls it from synchronous code — the work announces itself through `CapRegistry.onChange`, which `watchShape` already listens to. `connectedUser()` is exported for a caller that needs to await it.
**Two things it deliberately does NOT do.** It does not **provision**: connecting an identity that does not exist creates nothing (`resolveAccount`, not `ensureAccount`) — otherwise connecting would mint a user's stores and caps as a background side effect, arming the whole emulation at a moment nothing controls. And it does not drain **per-document** inboxes, which do not exist yet.
Proven by `test/cross-user-access.test.ts`: a cap shared to Bob survives **with his inbox emptied** and every in-memory cap dropped — restored from the User branch, not from the queue.
*Cost noted*: `setCurrentUser` now has observable asynchronous effects (it reads, and it logs). Three log-assertion tests had to await `connectedUser()` before counting lines. That is the honest price of moving the obligation off the app, and it is worth naming rather than discovering.
6. ~~**The Store branch**~~**DONE 2026-08-03.** A document's cap is now STORED when it is created — `shim:readCap` on a `storeBranch` subject of the store document, the emulated `AddRepo { read_cap }` — and READ back by `listMyEntityDocs`, never recomputed. That closes the asymmetry left by the Link work, where received caps were filed durably while created ones were re-minted.
Two things this pinned down, both of which would have cost more later:
- **The listing and the keys stay separate**, as Main and Store branches are upstream: `contains` on one subject, `readCap` on another, written as two statements because upstream they are two commits.
- **Creation mints the cap exactly once.** It used to mint twice — once to write, once to hold — which agreed only because the stand-in value is a constant. With P1b's real key those would be two different keys, and a creator would hold one that does not open its own document. A test pins it, and another proves the cap is read rather than recomputed by corrupting the stored value and checking the corruption wins.
Honest about the emulation: upstream the Store branch carries **no triples at all** (`BranchCrdt::None`). Representing it as RDF is ours; what is faithful is the storage beside the document and the separation from the listing.
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.
*(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.
**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
P1b (per-document encryption, closing the read paths that bypass the guard) largely **becomes** this lot, better framed. Encrypting each document while leaving the wallet boundary open would be securing the windows with the door open — and conversely, once every access is confined to the connected wallet, "the emulated key is derivable" stops being the load-bearing weakness. This lot should therefore precede P1b, or absorb it.
@@ -0,0 +1,64 @@
# Brief — a document's inbox has to be addressable by anyone holding the document
**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
`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.
## Why that breaks the consumer
Festipod's central act is signing up to **someone else's** event. The participant deposits into the event's inbox; the owner drains it at their next connection and materializes the count. With addressing scoped to the caller, only the owner can deposit into their own event — every other sign-up is silently lost.
The same wall stands in front of the directory document the app needs to rebuild discovery: creators must deposit a reference into a document they do not own.
## What was tried in the app, and why it was wrong
The consumer published the address on the document itself: resolve `documentInbox(eventDoc)` once at creation, write the NURI into the public event, and have participants read it from there instead of deriving it.
It works. It is still the wrong place, for three reasons — and the third is the one that settles it.
- **It puts infrastructure in the domain.** The `Event` shape starts carrying a technical address, and the domain model encodes a temporary state of the library.
- **It costs.** `documentInbox` *creates a document*. Publishing the address at creation takes every event creation from one document to two — and document creation is a round-trip that does not overlap. Measured on the test suite: the `@data` run went from **9m37 to 21m30**, with sign-up scenarios timing out on their setup step (4 failures out of 7 on a **fresh** profile, so not test-wallet bloat). That cost belongs to whoever can amortize or defer it. The app can do neither, because it does not own the mechanism.
- **The library already claims this job.** The commit that introduced per-document inboxes says: *"Inboxes belong to someone — the user's own, plus one per document — and connecting a user drains them all; that is the library's job, not the app's."* Draining was taken as the library's job. Addressing was left to the caller. Those two halves belong together.
The consumer's own doctrine says the same thing from the other side: when something does not work, the question is never how to work around it in the app, but what the library has to compensate. The app-side change has been reverted.
## What is being asked
**Given a document, any holder of that document should be able to name the inbox its owner reads** — without owning it, and without being handed the address out of band.
How is the library's call. Two shapes come to mind, neither prescriptive:
- Make the address **derivable** from the document, so `documentInbox(doc)` returns the same NURI for everyone, and only the owner can *read* it — the read guard already enforces that, and it is where the asymmetry belongs.
- Or have the library **publish** the address itself, at document creation, somewhere it controls — so it stays out of the consumer's shapes and the library keeps the freedom to make it lazy.
The second keeps the current create-time cost unless it is deferred; the first has none, and matches how the rest of the model works — an overlay is derived, a keyring is looked up, nothing is handed over out of band.
## Open question worth settling first
Is *"a document has an inbox addressable by any holder"* part of NextGraph's target model, or an invention of this library?
If it is upstream, this is emulation to align. If it is the library's own, it is a design decision to take deliberately — and the answer decides whether the note about it belongs in the shared NextGraph inbox as a gap, or here.
## Consumer state meanwhile
Sign-ups across identities do not converge, and the app does not pretend otherwise. Nothing in Festipod works around it.
+22 -1
View File
@@ -1,6 +1,27 @@
# ADR — Discovery mechanism (inbox-fed index, fan-out) # ADR — Discovery mechanism (inbox-fed index, fan-out)
**Date:** 2026-06-16 · **Status:** mechanism accepted; target owner undecided. > ## SUPERSEDED — 2026-07-30. The premise does not hold.
>
> **There is no discovery in NextGraph. You cannot discover; you can only follow links** (PO, 2026-07-30 — the principle is documented in [`../readcap-and-nuri-model.md`](../readcap-and-nuri-model.md) §4ter-bis). Publishing is two acts: place the data in your public store, **and** circulate the link — into inboxes, or into somewhere already reachable by the people concerned. It is seen only by those who received the link. This is a foundation of local-first, not a gap to be filled.
>
> A global index therefore fails on **two independent counts**:
>
> 1. it emulates a capability the target will never have — teaching consumers a model that does not exist, which is the one failure mode this library exists to prevent;
> 2. it is **data common to several users/wallets**, and nothing may be common — only indexing mechanisms that make the virtual users work (the shim qualifies; a shared index of user announcements does not).
>
> 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).
>
> 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.
>
> What survives, and is worth keeping from the text below: the **3-stage frame** (`discovery → synchronization → query`) is still exactly right, with stage 1 re-read as *"a link reached you"* rather than *"you consulted an index"*. You still cannot query what you have not synchronized, and you still do not synchronize what nobody gave you. The **inbox** is what feeds stage 1 — which makes it the bootstrap of the whole reachability graph, not a side feature.
>
> Kept in full below as a record of what was built and why, and of the reasoning that has to be re-read through the correction above.
**Date:** 2026-06-16 · **Status:** SUPERSEDED 2026-07-30 (see the block above). *Originally: mechanism accepted; target owner undecided.*
Ported here for the discovery mechanism it defines — the piece this lib Ported here for the discovery mechanism it defines — the piece this lib
realizes (`inbox.ts` post/materialize/watch; `store-registry.ts` fan-out). The realizes (`inbox.ts` post/materialize/watch; `store-registry.ts` fan-out). The
product intent (what a consumer application *should* surface) is the consumer product intent (what a consumer application *should* surface) is the consumer
+13 -5
View File
@@ -9,7 +9,8 @@ the shim opens repos. Original context: the consuming app.
> **protected** store (`did:ng:${protected_store_id}`), verified to open without > **protected** store (`did:ng:${protected_store_id}`), verified to open without
> `RepoNotFound`. **The central insight of this ADR still holds** and now applies > `RepoNotFound`. **The central insight of this ADR still holds** and now applies
> to **both** stores: you must open the repo via the store's NURI > to **both** stores: you must open the repo via the store's NURI
> (`orm_start_graph`) or you get `RepoNotFound`. > or you get `RepoNotFound`. *(How it is opened has since changed — see the note
> under Decision.)*
## Context ## Context
@@ -39,10 +40,17 @@ entity to reuse) → falls back to `doc_create` and the same `RepoNotFound`.
## Decision ## Decision
**Option B**: use the store NURI as both the `useShape` scope AND the write **Option B**: use the store NURI as both the `useShape` scope AND the write
`@graph`, exactly like `expense-tracker-rdf`. This is why this lib's shim opens a `@graph`, exactly like `expense-tracker-rdf`. This is why this lib's shim opens the
store repo via `orm_start_graph` before writing, and why **`did:ng:i` must never store repo before writing, and why **`did:ng:i` must never be used as a scope** (it
be used as a scope** (it breaks writes with `RepoNotFound`). See the breaks writes with `RepoNotFound`). See the scope rule in
`orm_start_graph` scope rule in [`../simulation.md`](../simulation.md). [`../simulation.md`](../simulation.md).
*The decision stands; the mechanism named in it has been replaced.* Opening was
`orm_start_graph` when this was written. It is now `ensureRepoOpen``doc_subscribe`
plus a wait for the first `State` (`packages/client/src/open-repo.ts:167`) — after
`orm_start_graph` was found to hang on a fan-out (`subscribe.ts:28,181`). What must be
read here is the invariant *"open the repo, by its store NURI, before writing"*, not the
call that used to implement it.
## Consequences ## Consequences
@@ -25,11 +25,11 @@ An entity written just before a period of inactivity can be **silently lost**: i
Interpretation (**plausible mechanism, not settled**): the write was pushed into the local **outbox**, but the socket died before it was **durably flushed** into the broker topic; on reconnection, the outbox replay fails (`Err(TopicNotFound)`) because the topic was **never created on the broker side** → the event is abandoned. The account, for its part, had already been durably resolved (`resolveAccount → 1 record`): it is neither lost nor forked. Interpretation (**plausible mechanism, not settled**): the write was pushed into the local **outbox**, but the socket died before it was **durably flushed** into the broker topic; on reconnection, the outbox replay fails (`Err(TopicNotFound)`) because the topic was **never created on the broker side** → the event is abandoned. The account, for its part, had already been durably resolved (`resolveAccount → 1 record`): it is neither lost nor forked.
> **Epistemic caveat.** The evidence establishes the *symptom* (loss + `Err(TopicNotFound)` + `readScopeIndex → 0`). The exact *mechanism* is not settled between **(i) loss at write time** (the write never durably reaches the broker) and **(ii) cold-rehydration failure** (the write *is* on the broker but a fresh session does not reopen its own scope). The `Err(TopicNotFound)` on the outbox replay leans toward **(i) in this Firefox case**. See the @data repro below, which exhibits a neighboring symptom but **does not settle** (i) vs (ii). > **Epistemic caveat.** The evidence establishes the *symptom* (loss + `Err(TopicNotFound)` + the scope read returning 0 — logged above as `readScopeIndex`, since renamed `readUserStore`). The exact *mechanism* is not settled between **(i) loss at write time** (the write never durably reaches the broker) and **(ii) cold-rehydration failure** (the write *is* on the broker but a fresh session does not reopen its own scope). The `Err(TopicNotFound)` on the outbox replay leans toward **(i) in this Firefox case**. See the @data repro below, which exhibits a neighboring symptom but **does not settle** (i) vs (ii).
## Causal chain (TRACED — reading of the NextGraph core, to be re-verified) ## Causal chain (TRACED — reading of the NextGraph core, to be re-verified)
- The `SerializationError` closes the socket. The core emits the disconnection: `broker.rs` `LocalBrokerMessage::Disconnected` `disconnections_sender.send(...)` (≈ `broker.rs:1051`, to be re-verified — volatile number, navigate by symbol). - The `SerializationError` closes the socket. The core emits the disconnection in **two** places, and they are not the same file: `engine/net/src/broker.rs:1074` sends `LocalBrokerMessage::Disconnected`, which the SDK turns into `disconnections_sender.send(...)` at `sdk/rust/src/local_broker.rs:648`. Navigate by symbol — the line numbers are volatile, and the earlier note in this file put the `send` in `broker.rs`, which it never was.
- This disconnection is **pushed** to subscribers via `disconnections_subscribe(cb)` (PUSH stream). - This disconnection is **pushed** to subscribers via `disconnections_subscribe(cb)` (PUSH stream).
- **NextGraph reconnection is an unimplemented `// TODO`** (≈ `broker.rs:1051-1076`): nothing re-establishes the socket nor re-flushes the outbox. - **NextGraph reconnection is an unimplemented `// TODO`** (≈ `broker.rs:1051-1076`): nothing re-establishes the socket nor re-flushes the outbox.
- `user_connect` returns a **snapshot** `{ server_id, server_ip, error, since }` at call time — not a stream, unusable for detecting a later drop. - `user_connect` returns a **snapshot** `{ server_id, server_ip, error, since }` at call time — not a stream, unusable for detecting a later drop.
+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.
+49 -18
View File
@@ -14,13 +14,33 @@ has no clear target image, that is a drift signal (see
## Checklist ## Checklist
### 1. Emulated ReadCaps → real capabilities ### 1. Emulated ReadCaps → real capabilities
Translate the per-document `CapRegistry` (`caps.ts`) into real NextGraph caps: the The shape is already the target's (P1a): a `ReadCap` is the document's key, a
broker/verifier enforces them, and `useShape` already returns only authorized each identity holds a set of caps, and there is no read-ACL anywhere. So
documents. The directed `grantRead(doc, granteeId)` maps to a native per-document this step swaps the *emulated* key for the real one, not the model:
ReadCap issued to that identity. The read filter (`read-filter.ts`) and the write
guard (`ng-proxy.ts` `sparql_update` override) are then dead code — remove them. The - `caps.ts`'s per-identity record becomes the verifier's own local user storage —
access unit is already the document (`@graph`), matching the native per-repo cap it was always the cache, not the register. The two durable registers we emulate
model, so this is a data step, not a reshape. (`readCap` on the store's Store branch, `link` on its User branch) become the real
`AddRepo` / `AddLink` commits. Remove the emulation; the wallet and the branches
already hold them.
- `nuri.ts`'s stand-in cap value — the constant `OK` — becomes the real
`r:{base64url(serde_bare(ObjectRef))}`. It is **one function** (`mintCap`), because
every path now READS a stored cap instead of recomputing one. `hasReadCap` /
`targetOf` stay meaningful: the `r:` discriminant is upstream grammar, not ours.
- `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
recipient's own verifier applying queued messages. **The consumer's call does not
change.**
- `publishRepoLink` becomes `RepoLinkV0`.
- The read filter (`read-filter.ts`) and the possession gate in
`read-model.readUnion` are then dead code — the broker only delivers documents
whose cap the wallet holds. Remove them.
- The write guard (`ng-proxy.ts` `sparql_update` override) is a separate axis and
is decorative today (every internal writer bypasses the proxy); it belongs to the
P1b batch, not here.
The access unit is already the document (`@graph`), matching the native per-repo cap
model, so this is a key-material step, not a reshape.
### 2. Place documents in real native stores ### 2. Place documents in real native stores
Today `docCreate(..., undefined)` writes every document into the shared wallet's Today `docCreate(..., undefined)` writes every document into the shared wallet's
@@ -39,7 +59,7 @@ in the shim (see the two-axes section in [`simulation.md`](./simulation.md)).
- At that point `store-registry.ts` maps `(account, scope)` to the user's real - At that point `store-registry.ts` maps `(account, scope)` to the user's real
store NURI instead of a document in the shared wallet; the per-scope index store NURI instead of a document in the shared wallet; the per-scope index
document (the store-container emulation) is replaced by the store itself. The document (the store-container emulation) is replaced by the store itself. The
surface facing the consumer application (`createEntityDoc`, `listEntityDocs`, surface facing the consumer application (`createEntityDoc`, `listMyEntityDocs`,
resolvers) is designed to survive that swap unchanged. resolvers) is designed to survive that swap unchanged.
### 3. Drop the resolver / shim ### 3. Drop the resolver / shim
@@ -53,12 +73,10 @@ 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 (see the deferred global-index note in the top-level 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.)*
README and [`decisions/discovery-model.md`](./decisions/discovery-model.md)). The
single global index replaces the cross-account fan-out.
### 5. Retire the identity store → real per-user login ### 5. Retire the identity store → real per-user login
Remove `accounts.ts` (the `IdentityStore` that persists the identity id in Remove `accounts.ts` (the `IdentityStore` that persists the identity id in
@@ -67,11 +85,10 @@ becomes the real per-user login
(see [`decisions/shared-wallet-login-flow.md`](./decisions/shared-wallet-login-flow.md)). (see [`decisions/shared-wallet-login-flow.md`](./decisions/shared-wallet-login-flow.md)).
The flow shape ("broker redirect → app") does not change. The flow shape ("broker redirect → app") does not change.
### 6. Drop the isolation scaffold ### 6. ~~Drop the isolation scaffold~~ — already gone
`isolation.ts` (application-visibility scaffold) disappears against a `isolation.ts` (the old application-visibility filter) was deleted from the library;
different piece of infra than the caps: real per-account wallets, and the nothing remains to remove at migration. Kept as a numbered step so the following
relationship concept the consumer application owns. Distinct axis from ReadCaps — numbers stay stable across references.
remove independently.
### 7. Remove the build alias — the client becomes the real SDK ### 7. Remove the build alias — the client becomes the real SDK
The consumer application imports `@ng-org/web` / `@ng-org/orm` resolved to this lib The consumer application imports `@ng-org/web` / `@ng-org/orm` resolved to this lib
@@ -80,9 +97,23 @@ resolve to the real SDK — the `ng`/`useShape`/`inbox` surface is SDK-identical
no consumer code changes. The one non-SDK call — `configure(...)` / no consumer code changes. The one non-SDK call — `configure(...)` /
`@ng-eventually/client/polyfill` — is deleted. The lib itself disappears. `@ng-eventually/client/polyfill` — is deleted. The lib itself disappears.
## The one break already taken: `declareConnections`
P1a broke the consumer once, deliberately and early, so that migration would not.
The old surface was an ACL held in memory, which forced the consumer to re-declare
every grant on every session (`declareConnections`). That call **disappears**: with
delivered caps the grant moves to the moment a connection is *accepted* — one
`shareCap(capFor(doc), theirInbox)` per document shared — and it persists, because
the delivery lives in the recipient's inbox rather than in a map that empties at
reload. There is no analogue of `protectedDocsOf` + the re-derivation loop.
This is a consumer **re-architecture**, not an API swap, and it is the price of
being coded against a model that will exist. Nothing else about the migration below
touches consumer code.
## What does not change ## What does not change
The consumer application's code. Shapes, screens, the *acts* of granting The consumer application's code. Shapes, screens, the *acts* of sharing
access, entity→scope mapping, the relationship graph — all injected, all untouched. access, entity→scope mapping, the relationship graph — all injected, all untouched.
Migration is entirely inside this library plus removing the alias + the bootstrap Migration is entirely inside this library plus removing the alias + the bootstrap
call. That asymmetry — a mature SDK face outward, all compensation inward — is the call. That asymmetry — a mature SDK face outward, all compensation inward — is the
+131 -40
View File
@@ -22,14 +22,24 @@ Where the ground truth lives, so future re-verification is cheap:
dispatch (the truth on what is actually *processed*). dispatch (the truth on what is actually *processed*).
- `engine/net/src/types.rs` — inbox types (`InboxPost`, `InboxMsg`, `InboxMsgContent`). - `engine/net/src/types.rs` — inbox types (`InboxPost`, `InboxMsg`, `InboxMsgContent`).
- `engine/verifier/src/inbox_processor.rs` — inbox message handling. - `engine/verifier/src/inbox_processor.rs` — inbox message handling.
- `engine/verifier/src/verifier.rs:1423`the `OpenRepo` TODO (cross-wallet read). - `engine/verifier/src/verifier.rs:2237``load_repo_from_read_cap`, the one path that
brings a repo in FROM a cap (`pub(crate)`, see § *Capability / ReadCap granularity*).
- `engine/verifier/src/verifier.rs:1423` — the `OpenRepo` TODO. It is **not** about
loading an unheld repo: it sits inside `open_branch_`, past
`self.repos.get_mut(repo_id).ok_or(RepoNotFound)?` (`:1331`), so the repo is already
held by the time that line runs. What is missing is the broker-side `OpenRepo`
request, worked around with a pin.
- `engine/repo/src/types.rs``RootBranchV0.store: StoreOverlay` (repo → its store). - `engine/repo/src/types.rs``RootBranchV0.store: StoreOverlay` (repo → its store).
## The 5 store types ## The 5 store types
Every wallet has the **3 default stores** out of the box (session fields The **3 default stores** belong to a **user**, not to the wallet. A wallet holds
`private_store_id`, `protected_store_id`, `public_store_id`). Group and Dialog `sites: HashMap<String, SiteV0>` (`engine/wallet/src/types.rs:456`), and it is `SiteV0`
are created on demand. that carries `public` / `protected` / `private` (`engine/verifier/src/site.rs:31-37`) —
one wallet can hold several, which is exactly why "wallet" is the wrong unit to reason
in (see `docs/readcap-and-nuri-model.md` §4quinquies, *Nomenclature first*). A session exposes the three as
`private_store_id`, `protected_store_id`, `public_store_id` — those are the connected
USER's. Group and Dialog are created on demand.
| Store | Read | Write | Creation | | Store | Read | Write | Creation |
|---|---|---|---| |---|---|---|---|
@@ -83,8 +93,10 @@ users/quorum (write/permissions), **not** read-cap possession. (Repos of a
> entity. Read isolation is cryptographic in the target: with no cap for a repo, a > entity. Read isolation is cryptographic in the target: with no cap for a repo, a
> union / reactive read returns empty (the repo is never decrypted), while a > union / reactive read returns empty (the repo is never decrypted), while a
> targeted read of an unheld repo returns `RepoNotFound`. There is no > targeted read of an unheld repo returns `RepoNotFound`. There is no
> cap-introspection API — the polyfill's `canRead` / `governsRead` are > cap-introspection API, and there is nothing to introspect: reading is key
> emulation-only, with no NextGraph API behind them. > possession, so the polyfill asks the only question the model admits —
> `capFor(doc)`, "do I hold it?". Its *implementation* is emulation-only; its shape
> is the target's.
### Store ↔ document confusion (recurring) ### Store ↔ document confusion (recurring)
@@ -102,11 +114,56 @@ offline"*; *"removing permissions … requires a SyncSignature"* (synchronous).
## Inbox ## Inbox
Every document has a native inbox. A non-editor can deposit a link (DID **Only two repos have an inbox today: a user's public and protected STORES.** Not
cap) into it without being invited as an editor; the owner moderates. NURI: documents, and not the private store. `new_store_default` attaches one solely `if
`did:ng:d:<inbox_id>`. Content: the `InboxMsgContent` enum (`ContactDetails`, !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
`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.
**But the engine SUPPORTS an inbox on any repo — "does not" and "cannot" are different
statements.** `inbox: Option<PrivKey>` is a field of EVERY `Repo`
(`engine/repo/src/repo.rs:126`), not of a store structure. `AddInboxCapV0` is keyed by
`repo_id` (`engine/repo/src/types.rs:1973`) — *"Repo the Inbox is opened for"*. And
`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
moderates. NURI: `did:ng:d:<inbox_id>`. Content: the `InboxMsgContent` enum (`ContactDetails`,
`DialogRequest`, `Link`, `Patch`, `ServiceRequest`, `ExtRequest`, `DialogRequest`, `Link`, `Patch`, `ServiceRequest`, `ExtRequest`,
`RemoteQuery`, `SocialQuery`). Messages are sealed (`crypto_box::seal`) to `RemoteQuery`, `SocialQuery`, `engine/net/src/types.rs:4249-4261`). Note what `Link`
is: a **unit variant, carrying nothing** — not a link, not a cap, just a discriminant.
Reading it as "the inbox can deliver a read capability" is the trap this file exists to
prevent; see the *Consequence for this lib* below, which says the same thing from the
other end. Messages are sealed (`crypto_box::seal`) to
the inbox pubkey, so only the owner decrypts. The `from` field is optional, so an the inbox pubkey, so only the owner decrypts. The `from` field is optional, so an
anonymous sender is possible. This is the "identified if known, anonymous anonymous sender is possible. This is the "identified if known, anonymous
otherwise" behaviour native to the protocol. otherwise" behaviour native to the protocol.
@@ -123,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).
@@ -239,7 +299,8 @@ with the whole wallet, which is why the read path is per-doc anchored: the ancho
read makes a non-empty wallet irrelevant. At the real multi-store read makes a non-empty wallet irrelevant. At the real multi-store
migration this is unchanged (the anchored read is native); only bringing a repo into migration this is unchanged (the anchored read is native); only bringing a repo into
the session changes: opening a real per-user store repo by cap becomes a native the session changes: opening a real per-user store repo by cap becomes a native
broker sync (the `OpenRepo` TODO at `verifier.rs:1423`). Opening still requires the broker sync, through `load_repo_from_read_cap` (`verifier.rs:2237`) — not through the
`OpenRepo` TODO at `:1423`, which concerns a repo already held. Opening still requires the
repo's NURI + ReadCap — there is no store-level read inheritance (see repo's NURI + ReadCap — there is no store-level read inheritance (see
§ Capability / ReadCap granularity). § Capability / ReadCap granularity).
@@ -261,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
@@ -353,10 +429,16 @@ Listing must go through a one-shot union `sparql_query` instead — see
installed version) **does NOT expose**: Group/Dialog store creation; capability installed version) **does NOT expose**: Group/Dialog store creation; capability
sharing (a NURI with rights); permission manipulation; inbox deposit/read. sharing (a NURI with rights); permission manipulation; inbox deposit/read.
Available JS methods: `doc_create`, `doc_subscribe`, `sparql_query`, The JS methods this lib USES: `doc_create`, `doc_subscribe`, `sparql_query`,
`sparql_update`, `orm_start_graph`, `orm_start_discrete`, `graph_orm_update`, `sparql_update`, `orm_start_graph`, `orm_start_discrete`, `graph_orm_update`,
`discrete_orm_update`, `file_get`, `app_request_stream`. The docs announce *"An `discrete_orm_update`, `file_get`, `app_request_stream`. That is a working subset,
API will be provided for permission manipulation"* (no date). **not** the surface: `NGModule` exports **77** (`@ng-org/web@0.1.2-alpha.13`,
`dist/index.d.ts:140-268`), including `app_request`, `session_stop`,
`disconnections_subscribe`, `social_query_start`, `upload_start`/`upload_chunk`/
`upload_done`, and the whole `wallet_*` family. Read "not in the list above" as "we do
not call it", never as "it does not exist" — several sections of this very file discuss
methods absent from that subset. The docs announce *"An API will be provided for
permission manipulation"* (no date).
## Integration & deployment model ## Integration & deployment model
@@ -432,30 +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".
- **Apps AND services are mono-user.** They see only **what the user makes > **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.
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 **What the engine actually has — types, no behaviour (verified 2026-08-03):**
glimpsed is a singleton app whose global document is administered by the
developer-user — though this is not implemented and not guaranteed (simpler - `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.
paths may exist; to explore later). The model that does exist is this - 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`.
singleton-app one; a dedicated service with its own wallet sharing a - **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.
freely-readable index is not a NextGraph shape (a service is mono-user, no global - 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.
data). This is why a global-index package is deferred in this lib (see the top-level - **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`.
README). - 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).
**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
@@ -661,8 +750,8 @@ On a FRESH session over the SAME persistent wallet (reconnect, new page, re-logi
anchored `sparql_query` against a document written in an earlier session comes back with anchored `sparql_query` against a document written in an earlier session comes back with
**0 rows and no error** — persisted documents read as empty. Observed on every anchored **0 rows and no error** — persisted documents read as empty. Observed on every anchored
reader of the polyfill and healed identically in each (`ensureRepoOpen` before the read, reader of the polyfill and healed identically in each (`ensureRepoOpen` before the read,
`packages/client/src/open-repo.ts`): the discovery index (`discovery.ts` `readIndex`), `packages/client/src/open-repo.ts`): the user's own documents,
the per-scope index (`store-registry.ts` `readScopeIndex`), the by-need doc batch the user's store (`store-registry.ts` `readUserStore`), the by-need doc batch
(`read-model.ts` `readUnion`), and the store-root pointer read (`store-registry.ts` (`read-model.ts` `readUnion`), and the store-root pointer read (`store-registry.ts`
`resolvePointer`). The heal is `doc_subscribe(nuri)` → await the first `State` (the sync `resolvePointer`). The heal is `doc_subscribe(nuri)` → await the first `State` (the sync
barrier) → THEN the anchored read, and it is verified to return the data. barrier) → THEN the anchored read, and it is verified to return the data.
@@ -732,6 +821,8 @@ semantics of the replay path itself (VERIFIED by reading `send_outbox`).
**Consequence for this lib:** a queued write can be dropped without any observable error, **Consequence for this lib:** a queued write can be dropped without any observable error,
and one unknown topic can take the rest of the queue with it. The polyfill's own and one unknown topic can take the rest of the queue with it. The polyfill's own
`outbox-log.ts` records write intents but cannot replay them into the core, and no `outbox-log.ts` does not record anything: it exports a single `inspectOutbox()` that
READS the SDK's own `sessionStorage` outbox and logs how many peers still have queued
writes. It observes the symptom; it holds nothing it could replay, and no
write-durability confirmation exists to await — so "the write returned" is not "the write write-durability confirmation exists to await — so "the write returned" is not "the write
is durable". is durable".
+22 -22
View File
@@ -37,31 +37,29 @@ The governing constraints (all verified in `nextgraph-rs`, cited there):
- No reactive union query, and the reactive ORM hangs if handed a per-entity - No reactive union query, and the reactive ORM hangs if handed a per-entity
/ unsynced graph fan-out (`RepoNotFound` aborts `orm_start_graph`). / unsynced graph fan-out (`RepoNotFound` aborts `orm_start_graph`).
## Two read regimesenumerate vs follow ## One read regime — follow, never enumerate
There is **no cross-wallet read** in current NextGraph, so nothing is globally There is **no cross-wallet read** in current NextGraph, and there is no discovery
enumerable "for free". The polyfill splits every list into one of two regimes: either: **you cannot discover, you can only follow links**
([`readcap-and-nuri-model.md`](./readcap-and-nuri-model.md) §4ter-bis). Nothing is
globally enumerable, and nothing is meant to be.
### Events (all public) = the global index — the one enumeration hack > An earlier version of this document described a second regime — "all public
> events, enumerated through a global index" — presented as the one justified
> "hack". It was removed on 2026-07-30 along with `discovery.ts`: a global index
> emulates a capability the target will never have, and it pools data across
> wallets. A public document is reached because someone circulated its link, never
> because it was listed.
Public events are the only thing enumerated across accounts, via the emulated ### Everything = follow a graph, never enumerate across accounts
discovery index (`discovery.readIndex`, see
[`simulation.md`](./simulation.md) § *Emulated discovery index*). This is the one
"hack", and it is justified precisely because P2P has no cross-wallet read: without
a shared index a client could never learn that another account's public event-doc
exists. `readIndex` yields the event-doc NURIs to open/sync; those repos
then enter the local union and become union-queryable.
### Everything else = follow a graph, never enumerate across accounts
My participations / my profile, protected data an owner has granted me, my My participations / my profile, protected data an owner has granted me, my
notifications — none of these is enumerated across accounts. Each is reached by notifications — none of these is enumerated across accounts. Each is reached by
what is already reachable to me: what is already reachable to me:
- my own docs (always in `self.repos`); - my own docs (always in `self.repos`, and whose caps I hold);
- docs an owner has granted me via a directed per-document read grant - docs whose cap an owner has delivered to my inbox (`shareCap` — see the
(`grantRead(doc, granteeId)` — see the per-document ReadCap in per-document ReadCap in [`simulation.md`](./simulation.md));
[`simulation.md`](./simulation.md));
- my inbox (deposits addressed to me). - my inbox (deposits addressed to me).
The rule of thumb: access is not discovery. You only union-query over graphs you The rule of thumb: access is not discovery. You only union-query over graphs you
@@ -71,14 +69,16 @@ Accessing a document without read rights yields an empty result: a reactive / un
read never decrypts a repo you hold no cap for, so it simply returns nothing (this read never decrypts a repo you hold no cap for, so it simply returns nothing (this
matches NextGraph's union read). A targeted read of a repo you do not hold diverges matches NextGraph's union read). A targeted read of a repo you do not hold diverges
in one way — it raises `RepoNotFound` rather than returning empty — and the read in one way — it raises `RepoNotFound` rather than returning empty — and the read
path tolerates that per-doc (a doc that throws is skipped). The cap-introspection path tolerates that per-doc (a doc that throws is skipped). The held-caps lookup used
used here (`canRead` / `governsRead`) is emulation-only; there is no NextGraph API here (`capFor`) is emulation-only in its *implementation*; its shape is the target's
behind it, so it has no migration target. (possession), so what disappears at migration is the lookup, not the model. Note
there is deliberately no "may identity X read doc D?" call: the real model cannot
answer that either.
## Listing = a bounded set of per-doc anchored reads (never a union-scan, never the ORM fan-out) ## Listing = a bounded set of per-doc anchored reads (never a union-scan, never the ORM fan-out)
To produce a list, take the bounded, by-need set of doc NURIs (the index-yielded To produce a list, take the bounded, by-need set of doc NURIs (my own docs, and the
event NURIs, my own docs, the NURIs an owner has granted me) and read each one with its NURIs whose cap someone delivered to me) and read each one with its
own anchored `sparql_query` (`SELECT ?s ?p ?o WHERE { ?s ?p ?o }`, anchor = that own anchored `sparql_query` (`SELECT ?s ?p ?o WHERE { ?s ?p ?o }`, anchor = that
doc NURI, in parallel and tolerant per-doc). The anchor restricts the query to that doc NURI, in parallel and tolerant per-doc). The anchor restricts the query to that
one repo's graph, so each read is O(1) in the doc's own size and independent of how one repo's graph, so each read is O(1) in the doc's own size and independent of how
+176 -24
View File
@@ -2,7 +2,21 @@
**Established 2026-07-20**, VERIFIED by direct reading of the `nextgraph-rs` Rust core (except for points marked INFERRED). The `file:line` references are dated — line numbers are volatile, navigate by symbol/regex. **Established 2026-07-20**, VERIFIED by direct reading of the `nextgraph-rs` Rust core (except for points marked INFERRED). The `file:line` references are dated — line numbers are volatile, navigate by symbol/regex.
Purpose: to give the ground truth of NextGraph's access-rights model, in order to align the polyfill's `caps.ts` emulation (today an ACL — the inverse of the real model). This is the basis for the item "align ReadCap/WriteCap with NextGraph". Purpose: to give the ground truth of NextGraph's access-rights model, in order to align the polyfill's `caps.ts` emulation (an ACL until P1a — the inverse of the real model; realigned 2026-07-28, see §5). This is the basis for the item "align ReadCap/WriteCap with NextGraph".
> ## How to use this document — verify, never infer
>
> **NextGraph works very differently from what general knowledge of distributed systems suggests.** Assert nothing about it that is not, at minimum, in this repository's docs — and preferably read in `nextgraph-rs` itself, with a `file:line`. Reasoning by analogy with git, with ACL systems, with pub/sub brokers, or with "how this normally works" produces confident, wrong statements. Every correction recorded below started that way.
>
> **Write down everything you learn about NextGraph, as you learn it** (PO, 2026-07-30) — at least everything that helps move forward or that corrects a direction. Not at the end of an investigation, not only in the brief that happened to need it: a fact read in `nextgraph-rs` and left in a conversation is a fact the next agent will re-derive, and will get wrong.
>
> It does not all have to land in this file. This is where the **access model** accumulates (caps, NURIs, stores, branches, who can read what); platform behaviour and SDK gaps belong in [`nextgraph-current-state.md`](./nextgraph-current-state.md), and how the polyfill fakes something belongs in [`simulation.md`](./simulation.md). What matters is that it is written down somewhere durable and findable, with a `file:line` — not which file.
>
> Three traps in particular, all of which have already caught an agent more than once:
>
> - **A comment describing the CURRENT state is not the intent.** §3's DIRECTION block exists because `RepoLinkV0`'s comment was read as the target model. It is not.
> - **A word you recognise probably does not mean what you think.** `branch` is not git's. `wallet` is only a keyring — what we call a virtual user is a **user** (a *site*). Check the type before using the word.
> - **"I looked and it is not there" is not a finding.** §4quinquies once stated that no register existed for received caps, after checking one code path. `AddLink` had been sitting next to `AddRepo` in the same file the whole time. Absence needs at least as much evidence as presence — and an implementation *cache* (like local user storage) is never the model: it is what the model fills.
--- ---
@@ -34,7 +48,7 @@ So "wallet targeting" lives in the **sealing envelope**, not in the cap: the cap
A delivered key is not "taken back". To revoke = **re-encrypt** with a new key and re-seal it only to the remaining authorized holders. A delivered key is not "taken back". To revoke = **re-encrypt** with a new key and re-seal it only to the remaining authorized holders.
- "Capabilities are not durable: they can be refreshed by members and previously shared Caps become obsolete/revoked… if [a member] doesn't subscribe, they lose access after the refresh" (`net/types.rs:5055-5058`). - "Capabilities are not durable: they can be refreshed by members and previously shared Caps become obsolete/revoked… if [a member] doesn't subscribe, they lose access after the refresh" (`net/types.rs:5055-5058`).
- Mechanism: `RootCapRefresh` / `BranchCapRefresh` (`repo/src/commit.rs:616,630`; perms `types.rs:1748-1749`). - Mechanism: `RootCapRefresh` / `BranchCapRefresh` (`engine/verifier/src/commits/mod.rs:616,630` — both no-op `Ok(())` stubs today; perms `types.rs:1748-1749`).
- Consequences: **coarse** (repo/branch scale), **non-retroactive** (what was read before remains known to the former holder; they only decrypt the versions **prior to** the refresh). - Consequences: **coarse** (repo/branch scale), **non-retroactive** (what was read before remains known to the former holder; they only decrypt the versions **prior to** the refresh).
- **Durable** delivery of a cap = `PermaCap` — still **TODO** (`repo/types.rs:578`). - **Durable** delivery of a cap = `PermaCap` — still **TODO** (`repo/types.rs:578`).
@@ -46,11 +60,11 @@ A delivered key is not "taken back". To revoke = **re-encrypt** with a new key a
So access is **not lost**, it is **deferred** until the next connection — consistent with local-first. Shape consequences: **no subscription obligation** to expose to the consumer; a re-delivery takes **the same channel** as the initial delivery, so the sharing mechanism covers both with no special case. **Revocation** remains "stop re-delivering", non-retroactive. So access is **not lost**, it is **deferred** until the next connection — consistent with local-first. Shape consequences: **no subscription obligation** to expose to the consumer; a re-delivery takes **the same channel** as the initial delivery, so the sharing mechanism covers both with no special case. **Revocation** remains "stop re-delivering", non-retroactive.
## 4. NURI grammar: cap-less vs cap-bearing (the `:k:` segment) ## 4. NURI grammar: cap-less vs cap-bearing (the `r:` segment)
**Clearing up the confusion first**: `did:ng:` is **not** a "cap-less" marker, it is the **URI scheme prefix** — present everywhere (inbox `did:ng:d:…`, branch `did:ng:b:…`, overlay `did:ng:v:…`, document `did:ng:o:…`). A NURI **is** a `did:ng:…`. So there is no "the did" on one side and "the NURI" on the other: it is **a single object**, with or without the key inside it — a single type upstream, `NuriV0 { target, access }`, where a cap-less NURI simply has an empty `access`. **Clearing up the confusion first**: `did:ng:` is **not** a "cap-less" marker, it is the **URI scheme prefix** — present everywhere (inbox `did:ng:d:…`, branch `did:ng:b:…`, overlay `did:ng:v:…`, document `did:ng:o:…`). A NURI **is** a `did:ng:…`. So there is no "the did" on one side and "the NURI" on the other: it is **a single object**, with or without the key inside it — a single type upstream, `NuriV0 { target, access }`, where a cap-less NURI simply has an empty `access`.
The discriminant is the **`:k:{key}`** segment: present = cap-bearing; **absent = cap-less** (names/locates **without** granting the right to read). This is **first-class** in the type: `NuriV0.target` (ids) and `access`/`objects` (the cap) are **separate fields** — an id-only NURI parses with `access: vec![]` (`engine/net/src/app_protocol.rs:53-62, 99-118, 181-195, 659-677`). The discriminant is the **`r:` segment** (see the correction below — this document said `:k:` until 2026-07-30): present = cap-bearing; **absent = cap-less** (names/locates **without** granting the right to read). This is **first-class** in the type: `NuriV0.target` (ids) and `access`/`objects` (the cap) are **separate fields** — an id-only NURI parses with `access: vec![]` (`engine/net/src/app_protocol.rs:53-62, 99-118, 181-195, 659-677`).
**Cap-less** (id + optional overlay, no key) — formatters in `app_protocol.rs`, regexes in `net/types.rs`: **Cap-less** (id + optional overlay, no key) — formatters in `app_protocol.rs`, regexes in `net/types.rs`:
- `did:ng:o:{repo_id}` (`:315`, `RE_REPO_O` types.rs:52) - `did:ng:o:{repo_id}` (`:315`, `RE_REPO_O` types.rs:52)
@@ -59,10 +73,28 @@ The discriminant is the **`:k:{key}`** segment: present = cap-bearing; **absent
- `did:ng:o:{repo_id}:c:{commit_id}` (`:355`) - `did:ng:o:{repo_id}:c:{commit_id}` (`:355`)
- `did:ng:b:{branch}` / `h:{topic}` / `v:{overlay}` / `d:{inbox}` (`:327,323,319,359`) - `did:ng:b:{branch}` / `h:{topic}` / `v:{overlay}` / `d:{inbox}` (`:327,323,319,359`)
**Cap-bearing** (embeds the key): **Cap-bearing — and `:k:` is NOT the ReadCap segment.** CORRECTED 2026-07-30, on a report from NextGraph's developer, verified in the source. There are **two different encodings**, and confusing them was an error in this document:
- `did:ng:j:{id}:k:{key}` — object/file read cap (`repo/types.rs:511`, `RE_FILE_READ_CAP` types.rs:49)
- `did:ng:o:{repo}:c:{commit}:k:{key}` (`RE_COMMIT` types.rs:73) | Segment | Shape | What it is |
- list `RE_OBJECTS` `…:[cj]:{id}:k:{key}…:l:{locator}` (types.rs:64) |---|---|---|
| `:k:` | `{id}:k:{key}` — id and key as **two segments** | an **object / file / commit** ref: `j:{id}:k:{key}` (`repo/types.rs:510`), `c:{id}:k:{key}` (`:514`) |
| `r:` | `r:{base64url(serde_bare(ObjectRef))}` — id and key **serialized together into one** | a **ReadCap**`BlockRef::readcap_nuri()` (`repo/types.rs:518-521`) |
```rust
pub fn readcap_nuri(&self) -> String {
let ser = serde_bare::to_vec(self).unwrap();
format!("r:{}", base64_url::encode(&ser))
}
```
Used to surface a branch's / root branch's read cap (`engine/verifier/src/verifier.rs:278,320`; `rocksdb_user_storage.rs:162,172`).
So a ReadCap is **not** "a NURI with `:k:{key}` appended". It is an opaque `r:` segment carrying the whole `ObjectRef { id, key }`. Note also that **no regex matches a cap-bearing repo NURI**: `RE_REPO_O` (`did:ng:o:{id}`) and `RE_REPO` (`…:v:{overlay}`) are both cap-less, and `RE_COMMIT`/`RE_FILE_READ_CAP` are about commits and files, not repos (`net/types.rs:48-73`).
The `:k:` forms, for completeness:
- `did:ng:j:{id}:k:{key}` — object/file read cap (`RE_FILE_READ_CAP` types.rs:48)
- `did:ng:o:{repo}:c:{commit}:k:{key}` (`RE_COMMIT` types.rs:72)
- list `RE_OBJECTS` `…:[cj]:{id}:k:{key}…:l:{locator}` (types.rs:63)
The `:v:` segment is the **overlay**, which has its own section below — it is the point with the heaviest consequences for anonymous-presence models. The `:v:` segment is the **overlay**, which has its own section below — it is the point with the heaviest consequences for anonymous-presence models.
@@ -109,36 +141,156 @@ This is a **second mechanism**, alongside key possession (§1) — not a breach
*Implementation detail, NOT to be carried by the shape*: NextGraph is moving toward **not encrypting** the content of the public store (the data remaining **signed**). A surface must not depend on it. And if the public store does not behave the way this principle describes, it is **the polyfill** that adapts, not the consumer. *Implementation detail, NOT to be carried by the shape*: NextGraph is moving toward **not encrypting** the content of the public store (the data remaining **signed**). A surface must not depend on it. And if the public store does not behave the way this principle describes, it is **the polyfill** that adapts, not the consumer.
## 4quater. The keyring: where the owner gets the caps for THEIR OWN documents ## 4ter-bis. THERE IS NO DISCOVERY — you only ever follow links
On every document creation, an `AddRepo { read_cap }` is committed to a **store branch** — the store being itself a repo, endowed with **typed** branches (the word "branch" has nothing to do with git: it is a compartment with a defined role). That branch lists **the store's documents, each with its read key**. **Stated by the PO, 2026-07-30, as one of NextGraph's foundations.** It bears on more design decisions than any other point in this document, and it is the easiest to violate without noticing, so it is stated before anything is built on top of it:
So it **is** the **owner's keyring**: the mechanism by which they find the caps of their own documents. Upstream of that, the keyring is the **wallet**. > **You cannot discover. You can only follow links.**
**This is NOT the sharing mechanism.** An easy and costly confusion: concluding "we share at the store level" is wrong — delivering a store cap would give access to **all** of its content, present and future. **The unit of sharing is the document** (§2). The keyring is a private index, not an act of sharing. NextGraph is **local-first**. There is no global index, no registry, no crawler, no "list everything public" — and nothing of the kind is planned. Nothing exists *to be found*; things exist *to be reached*, and reaching them means someone handed you the way in.
*(VERIFIED for the `AddRepo { read_cap }` mechanism; the **exact name** of the branches and the enumeration of their types have not been re-traced — to be confirmed if this point becomes load-bearing.)* So **publishing is two acts, never one**:
## 5. What the polyfill emulates (caps.ts) — and where it diverges 1. **Place** the data in your public store — that makes it readable *by whoever reaches it*, not visible;
2. **Circulate the link** — post it into inboxes, or put it somewhere already reachable by the people concerned (a document they already hold).
`packages/client/src/caps.ts` models `readers: Map<Nuri, Set<PrincipalId>>` + `grantRead(doc, grantee)` (`:29-30, 41-42`) — **a per-document ACL of principals, that is the exact INVERSION of the real model** (key). Divergences: And it is seen **only by those who received the information**, i.e. the link. There is no audience beyond the people you reached, and no way to enumerate one. Private distribution is the same act, plus the ReadCap: place, then circulate — the cap being what turns "reached" into "readable".
| | Real NextGraph | caps.ts emulation | **The consequences, which are not obvious:**
- **A "global list of everything public" is not constructible**, and a surface that offers one exposes a capability the target will never have — precisely the failure mode this whole chantier exists to prevent. Whatever such a surface is emulated on, it teaches the consumer a model that does not exist.
- **Reachability is a graph, not a directory.** The only way in is a link somebody gave you: in an inbox, or inside a document you already hold. Which is why the inbox is not a side feature — it is *the* bootstrap of the whole graph, the only channel through which a link crosses from one wallet to another.
- **This is what makes §4ter operational.** "Whoever has the URL reads the content" is not a weaker form of public: the URL *is* the access. Having it means someone gave it to you.
- **An audience cannot be counted, only addressed.** No primitive answers "who can see this"; you know who you sent it to.
### And the second reason, which stands on its own: nothing is COMMON
Even setting discovery aside, a global index is **data shared between users/wallets**, and that is not acceptable in an emulation whose whole job is to simulate the boundary of a single-user wallet (PO, 2026-07-30):
> Nothing common — only **indexing mechanisms to make the virtual users work**.
The distinction is the operative one, and it is sharp:
| | Verdict | Why |
|---|---|---| |---|---|---|
| Nature | possession of a **key** | **ACL** (set of principals) | | The **shim** (pointer → doc-shim → account → its scope documents) | **acceptable** | pure plumbing: it holds no user data, only the table that makes a virtual user resolvable at all. Remove it and no wallet exists. |
| Grant | seal the key (crypto_box) to the inbox | add a principal to the set | | A **discovery index** (announcements deposited by users, read by everyone) | **not acceptable** | it is application data pooled across wallets. Remove it and every wallet still works — you simply have to be given links, which is the model. |
| Durability | **durable** (key delivered once) | **ephemeral** (Map empty every session → re-declared) |
| Revocation | coarse **re-key**, non-retroactive | removal from the set: **instantaneous and total** |
| Granularity | repo / branch / commit / object | **one cap per doc-NURI** |
| Ref. without rights | **cap-less NURI** (no `:k:`) | no such notion (the ACL says who may) |
**App-facing**: `declareConnections` (on the consumer side), which re-declares "my connections read my protected entities" **every session**, is an **artifact of this ephemeral ACL** — moot in the real model (there the seals are durable; one seals per-doc at share time, not per-session). The test to apply to anything shared: *does removing it stop the virtual users from functioning, or does it merely stop users from seeing each other's content?* Only the first justifies existing outside a wallet.
*Impact on this library, RESOLVED 2026-07-30 — the module was removed*: `discovery.ts` (a global index owned by a reserved `@index` account, `submitToIndex` / `readIndex` / `watchIndex`) emulates exactly the capability described above as non-existent, **and** holds pooled user data, and `watchShape('public')` folds it into its read set. The ADR that specified it ([`decisions/discovery-model.md`](decisions/discovery-model.md)) already recorded that a freely-readable global index "is not a NextGraph shape" and rested on a singleton-app path that is "not implemented, uncertain". That reservation is now a verdict on both counts. See [`briefs/2026-07-30-virtual-wallet-boundary.md`](briefs/2026-07-30-virtual-wallet-boundary.md).
## 4quater. Where an owner gets the caps for THEIR OWN documents — the Store branch
**There is no "keyring" object in NextGraph, and this section used to say there was.** It read *"the store branch **is** the owner's keyring… upstream of that, the keyring is the wallet"*, which is wrong twice: the wallet holds **one** key per user (the private store's read cap, §4quinquies level 1), not every key; and the caps of one's own documents live on a **Store branch**, per store, not in any single trousseau. An agent built a global in-memory "keyring" on that sentence. Corrected 2026-07-30 on the PO's instruction — *use the Store branch logic, not an invented keyring*.
What is actually true:
On every document creation, an `AddRepo { read_cap }` is committed to the store's **Store branch** — the store being itself a repo with **typed** branches (the word "branch" has nothing to do with git: it is a compartment with a defined role, its own pub/sub topic, and here `BranchCrdt::None` — service commits, not triples). That branch lists **the store's documents, each with its read cap**, and replaying it is what reloads them (`AddRepo::verify``load_repo_from_read_cap`, `engine/verifier/src/commits/mod.rs:644-664`).
So the answer to *"how does an owner find the cap of a document they created?"* is: **it is on the Store branch of the store that document lives in** — one such branch per store, reached from the root key the wallet does hold.
**This is NOT the sharing mechanism.** An easy and costly confusion: concluding "we share at the store level" is wrong — delivering a store's cap would give access to **all** of its content, present and future. **The unit of sharing is the document** (§2), and a cap received for someone else's document goes somewhere else entirely (`AddLink` on the User branch, §4quinquies).
*(VERIFIED for the `AddRepo { read_cap }` mechanism and for `BranchType::Store` / `BranchCrdt::None`; the full enumeration of branch types is in `engine/repo/src/types.rs:1536-1551`.)*
## 4quinquies. WHERE the caps actually live — three levels, and one of them does not exist yet
**VERIFIED 2026-07-30** by reading `nextgraph-rs` (`git 213338f6`), answering "where does a received cap get stored?".
### Nomenclature first — `wallet` in the source is NOT what we call a wallet
A **wallet is only a keyring**. What we have been calling a "virtual user" is, upstream, a **user** (a *site*): `SensitiveWalletV0.sites: HashMap<String, SiteV0>` (`engine/wallet/src/types.rs:434,457`) — one wallet holds SEVERAL sites. `SiteV0` (`engine/verifier/src/site.rs:23`) is what owns the three stores (`public`, `protected`, `private`), and `UserId = PubKey` (`engine/repo/src/types.rs:453`). **Our vocabulary must follow: virtual user → user.**
### The three levels
**1. The wallet (keyring) holds ONE root key per user.** `SiteV0.site_type = SiteType::Individual((priv_key, read_cap))`, read back by `get_individual_site_private_store_read_cap` (`site.rs:52`) — the read cap of the **private store**, and nothing else. Everything else is reached *from* it. Following links applied to your own data.
**2. The store's own branch carries `AddRepo { read_cap }` — one per document.** `doc_create` performs **four distinct writes**; the two that matter here (`engine/verifier/src/request_processor.rs:697-710`):
- `send_add_repo_to_store` → a commit `AddRepo { read_cap }` on the **Store branch** of the store (`verifier.rs:2172-2199`) — *the key*;
- `INSERT DATA { <store> ldp:contains <doc> }` on the store's **main branch***the listing*.
*(The other two: the class quad on the **Header** branch, `request_processor.rs:719-728`; and `AddSignerCap` on the private store's **User** branch, `verifier.rs:3022-3040`.)*
**The key and the list are separate, deliberately.** Replaying the Store branch is what reloads the repos with their keys: `AddRepo::verify` calls `load_repo_from_read_cap` then `add_doc` (`engine/verifier/src/commits/mod.rs:644-664`). Our `shim:contains` emulates `ldp:contains` and `shim:readCap` (on a `storeBranch` subject) emulates `AddRepo` — so a created document's cap is stored beside it and read back, not recomputed.
> **The Store branch holds NO triples.** Its CRDT is `BranchCrdt::None`*"used by Overlay, Store and User BranchTypes"* (`engine/repo/src/types.rs:1420`; `store.rs:426`). It is a stream of **service commits** (`AddRepo` / `RemoveRepo`), not a graph. Any RDF we use to emulate it is our invention, and should be labelled as such rather than presented as "the same thing".
**3. Local user storage persists the read cap of EVERY opened repo.** `user_storage/repo.rs` stores `READ_CAP` as a property per repo (`:109,:219,:248,:359`), and a persistent verifier reloads from it at startup (`verifier.rs:542-544`). This is a **local store (RocksDB / IndexedDB), not a NextGraph document** — the verifier's own cache, per user.
### Giving access is a **Link** — one word, three places, all already named
**VERIFIED 2026-07-30.** The delivery message, the register and the record all exist upstream under the same word, which is what a shape being real looks like:
| Step | Upstream | State |
|---|---|---|
| The message deposited in the recipient's inbox | `InboxMsgContent::Link` (`engine/net/src/types.rs:4249-4261`) | **declared, payload-less** — a variant with no fields, i.e. specified and not implemented |
| Where the recipient files it on processing | `AddLink { read_cap }` on the **User branch** of the private store (`engine/repo/src/types.rs:1934-1950`) | implemented (verifier arm `commits/mod.rs:681`) |
| Withdrawing it | `RemoveLink`, ORset (`engine/repo/src/types.rs:1952`) | implemented |
| What circulates | `RepoLinkV0 { read_cap, … }` (`engine/net/src/types.rs:5062`) | implemented |
So: **deposit a Link into the recipient's inbox; on connection the recipient processes the inbox and files it with `AddLink` on their User branch.** That is the whole gesture, and every piece of it has a name.
Two consequences worth stating, because both are easy to get wrong:
- **What travels is a cap-BEARING reference.** A bare NURI in a Link grants nothing — it names a document the recipient still cannot open. `AddLink` carries a `read_cap`, not a `RepoId`.
- **`ContactDetails` is a different gesture.** It shares a *profile* (with an optional `read_cap` on it), not an arbitrary document. Do not route document sharing through it.
### A cap received from someone else: the **User branch**, via `AddLink`
**CORRECTED 2026-07-30 after adversarial review — an earlier version of this section claimed there was no register at all. That was wrong, and it was the kind of wrong this document exists to prevent: concluding "it does not exist" from having looked in one place.**
There IS a register, and it is a fourth commit type next to `AddRepo`:
```rust
/// Adds a link into the user branch, so that a user can share with all its device a new Link they received.
/// The repo's `store` field should not match with any store of the user. Only external repos are accepted here.
pub struct AddLinkV0 { pub read_cap: ReadCap, /* … */ }
```
`engine/repo/src/types.rs:1934-1950`, with `RemoveLink` as its ORset counterpart (`:1952`) and a verifier arm at `engine/verifier/src/commits/mod.rs:681`. So:
- it lives on the **User branch** — created only on the **private store** (`engine/repo/src/store.rs:448-452`; the public/protected stores get an `Overlay` branch instead), which also carries `AddInboxCap { repo_id, overlay, priv_key }`*"so that a user can share with all its device"* (`engine/repo/src/types.rs:1969-1981`). So the User branch answers two questions with one mechanism: **which caps I received**, and **which inboxes I may read**;
- it is explicitly for **external repos** — someone else's documents, exactly the received-cap case;
- and its stated purpose is to **share the link with all of the user's devices**. It is wallet-resident and cross-device, not a local cache.
**Level 3 (local user storage) is therefore a cache, not the register.** The register is level 2': `AddLink` on the User branch of the private store.
What remains true, and is a separate matter — the *delivery* path is unimplemented:
- `InboxMsgContent::ContactDetails` processing (`engine/verifier/src/inbox_processor.rs:778-847`) creates a contact document holding the profile, inbox, name and email — and **never reads `details.read_cap`**. Confirmed on sight: the receiver discards it. So the cap never reaches the User branch today — the register exists, the road to it does not.
- `RepoLinkV0` states the intended flow (`engine/net/src/types.rs:5055-5061`): *"the link is shared and then the recipient opens it and subscribes soon afterward"*. **The key IS kept**: opening the repo persists its `read_cap` in local user storage, so the next session decrypts fine. What is not durable is the key's **validity** — a `RootCapRefresh` (§3) mints a new one, and receiving it depends on **the rotating party choosing to send it to you** (§3's DIRECTION block), not on any subscription state.
> **Do not write "only a subscriber receives the new key".** That reads the `RepoLinkV0` comment as intent, which §3 already forbids. **Subscribing is a purely LOCAL act** — automatic pull of changes — and the other party records nothing about it; there is no subscriber list to send to. Who gets a rotated key is the rotating party's decision, delivered to an inbox.
- `PermaCap` — still a **TODO** (`engine/repo/src/types.rs:578`) — covers exactly the gap that leaves: a link *"stored on disk and kept there unopened for a long period"*, i.e. never loaded, therefore never subscribed, therefore missing every refresh.
> **So there are TWO registers, by origin**: `AddRepo` on the **Store** branch for the documents a user creates in that store, and `AddLink` on the **User** branch of the private store for caps received for someone else's documents. Local user storage caches both. Opening a repo persists its cap locally, but that is the cache filling — not the durable record.
*Consequence for this library*: **both durable registers are now emulated** (2026-07-30) — `AddRepo` as a `shim:readCap` record on a distinct subject of the store document (`storeBranch`), `AddLink` as `shim:link` on another (`userBranch`) — and the in-memory `CapRegistry` is what it always was, level 3: the cache. Caps are READ back from those records, never recomputed. What stays an invention is representing branches as RDF subjects at all: upstream both branches carry `BranchCrdt::None` and hold service commits, not triples. What is faithful is that the key sits beside the document, and that the listing (`contains`, the Main branch) is separate from the keys.
## 5. What the polyfill emulates (caps.ts) — and where it still diverges
**Realigned 2026-07-28 (batch P1a).** `packages/client/src/caps.ts` used to model `readers: Map<Nuri, Set<PrincipalId>>` + `grantRead(doc, grantee)` — a per-document **ACL of principals**, the exact INVERSION of the real model. It now records, **per identity**, the caps that identity holds (`Map<Nuri, ReadCap>`) — whose only question is `capFor(nuri)` — and `nuri.ts` carries the cap-less / cap-bearing distinction on the `r:` segment. The durable registers are emulated in `store-registry.ts` (`readCap` on the Store branch, `link` on the User branch); this in-memory record is their cache.
| | Real NextGraph | caps.ts emulation (post-P1a) |
|---|---|---|
| Nature | possession of a **key** | possession of a **key** — recorded per identity, indexed by the cap-less NURI |
| Grant | seal the key (crypto_box) to the inbox | `shareCap(cap, toInbox)` → an inbox deposit, absorbed inline on read |
| Durability | **durable** (key delivered once) | durable **in shape**: creation and re-listing refile own caps from the scope index (the emulated `AddRepo` branch); a delivered cap persists in the recipient's inbox document |
| Revocation | coarse **re-key**, non-retroactive | **not emulated** (P3). Nothing pretends to revoke |
| Granularity | repo / branch / commit / object | **one cap per doc-NURI** |
| Ref. without rights | **cap-less NURI** (no `r:` segment) | same — `Nuri` names, `ReadCap` names and reads |
**The divergence that REMAINS**: the stand-in cap value is the constant `OK` rather than a secret. The read paths that once consulted no cap at all are now confined to the connected virtual user (`reach.ts`, 2026-07-30) — `docs.sparqlQuery`/`sparqlUpdate` and `subscribeDoc` are guarded, the inbox is read only by its owner, and the shim's own machinery moved to unguarded primitives that are never exported. So what is left for **P1b** is per-document encryption: replacing one constant with a real key. Until then, nothing may be claimed "anonymous" or "private".
**App-facing**: `declareConnections` (on the consumer side), which re-declared "my connections read my protected entities" **every session**, was an artifact of the ephemeral ACL — **it disappears**. The grant moves to the moment a connection is accepted (`shareCap` once, per document), which is a consumer **re-architecture**, not an API swap.
## 6. Implications for consumers (e.g. Festipod) ## 6. Implications for consumers (e.g. Festipod)
- "**protected scope = my network can read**" is **not** an ACL checked by the broker: it is "I have **sealed my read key** to each of my connections". The "scope = ACL" mental model is wrong at the NextGraph level. - "**protected scope = my network can read**" is **not** an ACL checked by the broker: it is "I have **sealed my read key** to each of my connections". The "scope = ACL" mental model is wrong at the NextGraph level.
- **Anonymous references are possible**: putting a **cap-less NURI** in a third party's collection lets that third party **name/count** without **reading the identity**; the cap-bearing one is sealed separately to the authorized parties only. (Basis for a presence model of the form "self-owned participation + curated cap-less Set + cap sealed to the connections".) - **Anonymous references are possible**: putting a **cap-less NURI** in a third party's collection lets that third party **name/count** without **reading the identity**; the cap-bearing one is sealed separately to the authorized parties only. (Basis for a presence model of the form "self-owned participation + curated cap-less Set + cap sealed to the connections".)
- **Alignment to do**: when the real cap operations become available, replace the emulated ACL with durable per-doc key sealing, and `declareConnections`-as-a-re-declared-ACL disappears. - **Alignment DONE for the surface (P1a, 2026-07-28)**: the emulated ACL is gone, replaced by per-identity cap possession + per-document delivery to an inbox; `declareConnections`-as-a-re-declared-ACL has disappeared. What remains for the real cap operations is swapping the stand-in key value (`OK`) for the real one and closing the bypasses (P1b) — a key-material step, not a reshape. See `migration-guide.md` §1.
## Caveats / gaps ## Caveats / gaps
+305 -170
View File
@@ -32,34 +32,45 @@ application fiction the lib maintains. On top of that one wallet the lib rebuild
by emulation, the per-user stores + capabilities + inbox the consumer application by emulation, the per-user stores + capabilities + inbox the consumer application
codes against. codes against.
## Physical wallet vs virtual wallet — never enumerate the physical one ## Physical user vs virtual user — never enumerate the physical one
**Nomenclature (aligned on NextGraph, 2026-07-30).** A **wallet** upstream is only a
**keyring**; what owns three stores is a **user** (a *site*), and one wallet holds
several of them (`SensitiveWalletV0.sites`, `engine/wallet/src/types.rs:434,457`).
So this document says *user*, not *wallet*, for the thing an identity is — the two
words meant the opposite of each other here until this was corrected.
Because the emulation runs on ONE shared wallet, distinguish two levels: Because the emulation runs on ONE shared wallet, distinguish two levels:
- **Physical wallet** — the real NextGraph wallet everyone opens. Its local store - **The physical user** — the single NextGraph user everybody's session opens. Its
holds every account's documents plus the lib's own internals (the shim index, stores hold every account's documents plus the library's own internals (the
the inbox docs, the discovery index) as named graphs. It accumulates without pointer, the doc-shim, the inbox documents) as named graphs, accumulating without
bound across sessions/runs. Listing or scanning "all documents" of the physical bound across sessions and runs. Listing or scanning "all documents" at this level
wallet is meaningless and O(size) it mixes every user's data with lib internals, is meaningless and O(size): it mixes every virtual user's data with library
and it is exactly what a `sparql_query` with no anchor (`GRAPH ?g { … }`) does internals, and it is exactly what an anchorless `sparql_query` (`GRAPH ?g { … }`)
(it spans every synced graph). The physical wallet is a substrate, does. The physical user is a substrate, not something to enumerate.
not something to enumerate.
- **Virtual wallet** — the lib's emulation of one user's wallet: the set of - **A virtual user** — the library's emulation of one identity: the documents the
documents the shim attributes to that account (its per-scope index in shim attributes to that account (its three store documents in
`store-registry.ts`). This is what "the user owns". Over a *virtual* wallet, `store-registry.ts`). This is what "the user owns", and over it "list my
"list my documents" is meaningful and bounded (only that account's docs). documents" is meaningful and bounded.
**Consequence for reads (see `read-model.md`):** to list a user's entities you **Which API you use follows that line, and it is enforced** (see `physical.ts`):
enumerate the *virtual* wallet — the account's scope index (bounded, O(my docs)), machinery operating on the *index of virtual users* — the store-root pointer, the
not the physical union — then read those specific documents with a per-doc anchored doc-shim, the account records — goes through unguarded primitives that are never
`sparql_query`. A non-empty / bloated physical wallet then costs nothing, because the exported from the package. Everything touching a virtual user's own content goes
physical union is never scanned. Discovery (all public events) is the one bounded through the guarded `docs.*`, even when the library is what calls it. One API is the
enumeration hack and goes through the discovery index, not a physical scan. app's; the other must never be.
At migration each virtual wallet becomes a real per-user wallet; the physical/virtual **Consequence for reads (see `read-model.md`):** to list an identity's entities you
distinction — and the "never enumerate the physical wallet" rule — dissolves into enumerate the *virtual* user — that account's store document, bounded to its own
native per-wallet reads. documents — never the physical union. A bloated shared wallet then costs nothing,
and nothing is enumerated across users at all: you read your own documents and the
ones whose cap you were given.
At migration each virtual user becomes a real user with its own wallet; the
physical/virtual distinction, the "never enumerate the physical one" rule, and
`physical.ts` all dissolve into native per-user reads.
## Two axes, never conflate them (store ≠ document) ## Two axes, never conflate them (store ≠ document)
@@ -120,15 +131,14 @@ public/protected/private stores — on top of one shared wallet.
is its own document/repo with a future inbox) and appends its NURI to the is its own document/repo with a future inbox) and appends its NURI to the
account's scope index document — the index doc plays the role of the future account's scope index document — the index doc plays the role of the future
store-container (it lists the entity-document NURIs "in" that scope). store-container (it lists the entity-document NURIs "in" that scope).
`listEntityDocs(scope)` unions the contained NURIs across all accounts. This is a `listMyEntityDocs(id, scope)` reads back ONE user's documents — bounded to that
fallback / test-only path, not the read path: enumerating every account and user, and the only listing there is: the cross-account fan-out
handing the NURIs to `useShape({ graphs })` opens/syncs other accounts' possibly- (`listEntityDocs` / `resolveReadGraphs` / `allAccounts` / `loadShim`) was
unsynced docs and hangs (the ORM fan-out — see **removed on 2026-07-30**, being cross-user enumeration by construction. The real read path is
[`read-model.md`](./read-model.md)). The real read path is
`readModel.readUnion(docs)`, which reads the by-need doc set with one per-doc `readModel.readUnion(docs)`, which reads the by-need doc set with one per-doc
anchored `sparql_query`, never an anchorless union-scan of the physical anchored `sparql_query`, never an anchorless union-scan of the physical
wallet (see [`read-model.md`](./read-model.md)). The consumer application resolves wallet (see [`read-model.md`](./read-model.md)). The consumer application resolves
the by-need doc set from the discovery index (public events) and the by-need doc set from the current wallet's own scope index and
`listMyEntityDocs(id, scope)` (its own account, bounded — no cross-account fan-out). `listMyEntityDocs(id, scope)` (its own account, bounded — no cross-account fan-out).
- **Generic by construction.** The registry knows only the three native scopes, - **Generic by construction.** The registry knows only the three native scopes,
zero application entity kind. The consumer application maps its entities to a scope zero application entity kind. The consumer application maps its entities to a scope
@@ -139,16 +149,16 @@ The `store≠document` two axes materialize here directly: the registry moves al
axis B (more documents = more isolation), never axis A (it always writes into the axis B (more documents = more isolation), never axis A (it always writes into the
one private store via `docCreate(..., undefined)`). one private store via `docCreate(..., undefined)`).
### A virtual wallet's structure — the three emulated stores ### A virtual user's structure — the three emulated stores
A *virtual wallet* = one account in the shim, keyed by its virtual-wallet id A *virtual user* = one account in the shim, keyed by its virtual-wallet id
(the technical identifier the consumer application sets when the physical wallet is (the technical identifier the consumer application sets when the physical user is
opened; it identifies *which* virtual wallet, and is an id rather than a opened; it identifies *which* virtual user, and is an id rather than a
human-friendly handle). Its structure mirrors the target "1 user = 1 wallet with 3 human-friendly handle). Its structure mirrors the target "1 user = 1 wallet with 3
native stores": native stores":
``` ```
Virtual wallet (id) Virtual user (id)
├── public store = docPublic index → [ entity doc NURI, entity doc NURI, … ] ├── public store = docPublic index → [ entity doc NURI, entity doc NURI, … ]
├── protected store = docProtected index → [ record doc NURI, record doc NURI, … ] ├── protected store = docProtected index → [ record doc NURI, record doc NURI, … ]
└── private store = docPrivate index → [ record doc NURI, … ] └── private store = docPrivate index → [ record doc NURI, … ]
@@ -160,17 +170,17 @@ So the 3 native stores (public/protected/private) are present, but emulated: eac
per-entity documents in that scope. It is not a physical native store. per-entity documents in that scope. It is not a physical native store.
Everything is physical in one place: the 3 index documents, every per-entity Everything is physical in one place: the 3 index documents, every per-entity
document, and the shim anchor itself all live in the shared physical wallet's document, and the shim anchor itself all live in the shared physical user's
private store (`docCreate(..., undefined)`). The 3-store structure is the per-account private store (`docCreate(..., undefined)`). The 3-store structure is the per-account
logical layer the lib maintains on top. logical layer the lib maintains on top.
``` ```
Physical wallet (shared, one) → private_store (physical) holds everything: Physical user (shared, one) → private_store (physical) holds everything:
• the shim anchor: virtual-wallet-id → { docPublic, docProtected, docPrivate } • the shim anchor: virtual-wallet-id → { docPublic, docProtected, docPrivate }
• every account's 3 scope-index docs + all per-entity docs + inbox + discovery index • every account's 3 scope-index docs + all per-entity docs + inboxes
``` ```
At migration each virtual wallet's 3 index documents become the user's 3 **real** At migration each virtual user's 3 index documents become the user's 3 **real**
native stores, the entity documents move into them physically, and the native stores, the entity documents move into them physically, and the
virtual/physical distinction dissolves (see [`migration-guide.md`](./migration-guide.md)). virtual/physical distinction dissolves (see [`migration-guide.md`](./migration-guide.md)).
@@ -190,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.
- **`resolveInboxAnchor()`** — the anchor where emulated inbox deposits land: a - **`walletInbox(id)` / `openDocumentInbox(doc)`** — an inbox BELONGS to someone. The
dedicated inbox document (a reserved account's public scope document, from first is a user's own inbox (where Links arrive), the second a DEDICATED inbox for
`docCreate` — a real repo NURI, stable across clients), not the shared one of its documents, opened on demand by its **owner only** (ownership read from the
wallet's private-store root. Why dedicated: the shim (the account→document trust Store branches — a received cap is not ownership, and a recipient must not be able to
root) lives in the private-store graph and is scanned on every `loadShim`; redirect the owner's deposits to itself). Both are dedicated documents (real repo
routing every inbox deposit into that same graph bloats it without bound NURIs from `docCreate`), never the private-store root: routing deposits into the shim
(thousands of deposit triples across sessions), turning `loadShim` into a graph would bloat the account→document trust root without bound. `myInboxes()`
multi-second full-graph scan. A separate inbox document keeps the shim graph enumerates both levels — what `connect.ts` drains at connection — and `isOwnInbox`
small and the deposits isolated. At migration it becomes the host's native answers from the same record. *(The former `resolveInboxAnchor`, a single inbox COMMON
inbox NURI. 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
@@ -255,77 +289,142 @@ In the target the broker only delivers documents the wallet holds a ReadCap
for, so `useShape` already returns an authorized subset. Here (single shared for, so `useShape` already returns an authorized subset. Here (single shared
wallet, everything readable) the lib reproduces that with a read-filtered view: wallet, everything readable) the lib reproduces that with a read-filtered view:
- **`CapRegistry` (`caps.ts`)** models ReadCaps as faithfully as a data layer - **`CapRegistry` (`caps.ts`)** models a ReadCap as what it is: **the document's
can. The access unit is the document = repo NURI (an item's `@graph`), key**. The access unit is the document = repo NURI (an item's `@graph`), never
never the item — because in `nextgraph-rs` a store is just a container repo the item — because in `nextgraph-rs` a store is just a container repo and holding
and holding its cap does not grant the repos it references (no store-level read its cap does not grant the repos it references (no store-level read inheritance;
inheritance; verified). So the registry is purely per-document: verified). The registry records, **per identity**, the caps it holds — `Map<Nuri, ReadCap>`
`grantRead(doc, granteeId)` issues a directed read grant to one identity, — and answers exactly one question: `capFor(nuri)`, *do I hold this document's
alongside `grantWrite` / `makePublic` / `open(doc, scope, owner)` / cap?* There is deliberately **no** "may principal P read document D": that is an
`canRead` / `canWrite` / `governsRead` / `hasReadPolicy`, plus the read-only ACL question, and the real model cannot answer it either.
accessor `protectedDocsOf(owner)` the consumer application uses to pick which - **`nuri.ts`** carries the cap-less / cap-bearing distinction, which upstream is
protected docs to grant. The consumer application performs the *acts* of granting one object (`NuriV0 { target, access }`) discriminated by the `:r:{cap}` segment.
(create-public, grant a specific doc to a specific identity…) exactly as it `Nuri` names, `ReadCap` names *and* reads. Both are plain strings — the real SDK
will in the target; the lib injects no policy. takes `nuri: String` and enforces at runtime through cryptography, so a branded
type would be a concept NextGraph does not have. The stand-in key value is the
constant `OK` (see the module header): the only question the emulation answers is
*do I hold this cap or not*, so the value says exactly that and pretends nothing
more. P1b, not P1a, is the batch that turns the shape into a protection.
- **`read-filter.ts`** — `makeReadFilteredView` wraps the reactive set in a - **`read-filter.ts`** — `makeReadFilteredView` wraps the reactive set in a
`Proxy`: iteration / `size` / `forEach` are filtered by `Proxy`: iteration / `size` / `forEach` keep only items whose `@graph` the
`caps.canRead(item['@graph'], user)`; everything else (`add`, `delete`, `has`, current holder holds; everything else (`add`, `delete`, `has`, `getById`…) forwards to
`getById`…) forwards to the target, preserving writes and reactivity. An item the target, preserving writes and reactivity. An item with no `@graph` is kept (it
with no `@graph`, or in a document under no cap policy, is kept (the filter only names no document, so there is no cap to hold). `filterReadable` is the pure
restricts documents that *declare* a cap — no regression on ungoverned data). variant. Note the absence of a `user` parameter — that absence *is* the model.
`filterReadable` is the pure variant. - **`useShape` (`use-shape.ts`)** applies the view only once a cap exists at all
- **`useShape` (`use-shape.ts`)** applies the view only if (`caps.isEnforcing()`) — before that it passes the real set through unchanged (no
`caps.hasReadPolicy()` — otherwise it passes the real set through unchanged regression for a consumer that never touches caps). Once ANY cap is issued the
(no regression when the consumer application declares no caps). regime is possession for **every** holder, including one who holds nothing:
that is the isolation.
In a mono-store layout (every item in one repo) this is all-or-nothing on that In a mono-store layout (every item in one repo) this is all-or-nothing on that
document — exactly the native behaviour, and why fine-grained isolation requires document — exactly the native behaviour, and why fine-grained isolation requires
one document per entity (axis B). one document per entity (axis B).
### Making the ReadCap active — current identity + directed grants ### Where caps come from — stored, never derived
The filter only discriminates once the consumer application (a) tells the SDK who is `doc_create` returns a **cap-less** NURI, so "no function ever goes from a bare
reading and (b) declares the access policy on the documents. Both are plain SDK reference to a cap" cannot be the whole rule — it would lock a document's own creator
calls; the consumer application never touches the registry internals: out of it. The real mechanism: creating a document commits `AddRepo { read_cap }` to
the store's **Store branch**, separately from the `ldp:contains` listing on its Main
branch. That is where an owner finds the caps of what it created; a cap RECEIVED for
someone else's document goes elsewhere, on the **User branch** (`AddLink`). The wallet
itself holds one key per user — the private store's read cap — from which the rest is
reached. Hence the invariant:
> **You do not derive a cap from a bare reference. You look it up in what you hold —
> or you were given it.**
Three ways a cap arrives, and there are no others:
- **Creation.** `createEntityDoc(id, scope)` writes the cap on the store's emulated
Store branch (`shim:readCap`) and the creator holds it. The consumer declares
nothing, and the cap is minted exactly ONCE — the stored value is the held value,
which is what keeps this correct when P1b makes the key real.
- **Re-listing.** `listMyEntityDocs(id, scope)` READS those records back. It does not
recompute anything: that is the whole reason for storing them, and it is what lets
a **fresh session** read its own documents again with nothing re-declared — the
durability the old in-memory ACL faked and lost every reload.
- **Delivery.** `shareCap(cap, toInbox)` deposits one document's cap into one
recipient's inbox; `inbox.read` applies it inline, exactly as the recipient's own
verifier applies queued messages upstream. **Receiving needs no operation** — a
consumer already watching its inbox gets them, and the resulting change
re-triggers the reads that were empty for want of that cap.
**The caps a holder holds are not the sharing mechanism.** Handing over a *store* cap would give
away everything the store contains, present and future. The unit of sharing is the
document; the Store branch is a private index.
Switching identity **switches** records — it never wipes one. If it wiped,
durability would be a lie and per-session re-declaration would come back under
another name.
### Sharing, publication, and the recipient
- **`setCurrentUser(id)` (`polyfill.ts`)** — the SDK's "current identity" call. - **`setCurrentUser(id)` (`polyfill.ts`)** — the SDK's "current identity" call.
`useShape`'s filtered view reads it lazily, so the delivered subset always It selects *whose* caps are consulted, lazily, so the delivered subset always
reflects the identity in effect at read time. Until it is set, the filter has no reflects the identity in effect at read time.
principal and (per `canRead(doc, null)`) only public documents pass — which is - **`shareCap(cap, toInbox)`** — the one sharing act the lib exposes. Recipients
why isolation stays dormant until the consumer application makes this call. are addressed as **inboxes**, which `inbox.post(targetInbox)` already does here;
- **`getCaps().open(doc, scope, owner)`** — declares a document's policy when the there is no `PrincipalId` in this surface, because that notion exists nowhere
consumer application creates it: `public` → world-readable; `protected`/`private` upstream. Reaching several recipients means calling it once per inbox, which is
→ owner reads, owner holds the write cap. `open` also remembers `(scope, owner)` what the real model does too (each delivery is sealed to one recipient).
per document so `protectedDocsOf(owner)` can later enumerate the protected ones. - **`getCaps().publishRepoLink(doc)`** — upstream `RepoLinkV0`: a shareable link
- **`grantRead(doc, granteeId)` (`caps.ts`, exposed via `getCaps()`)** — the one **whoever receives it** can open. Put the *link* in what you make discoverable, not
relationship-shaped sharing act the lib exposes: a directed per-document read the bare NURI, or no reader can open it. Publication is **not recursive**: a public
grant issued to a specific identity. Public docs stay world-readable; private document may reference private ones, and the reference grants nothing on what it
docs stay owner-only; a protected doc becomes readable by `granteeId` once the references — which is what lets a public object point at a private identity without
owner grants it. The consumer application passes a document NURI and a grantee id disclosing it.
— no store id.
The relationship concept — who is "connected" to whom, and therefore which of Upstream, directed delivery is a **gap, not a disagreement**: `ContactDetails.read_cap`
their protected docs to grant — is owned by the consumer application, not the lib. exists, but the message construction is `unimplemented!()`, its only caller passes
A connection or friendship is not a NextGraph primitive; the only platform-mappable "without read_cap", and the receiver discards the cap. The shape is right; the
primitive is the directed per-document read grant above. So the consumer application implementation is absent, so this lib emulates it meanwhile.
decides a relationship exists and, for each protected doc it wants to share, calls
`grantRead(doc, granteeId)` — typically iterating `protectedDocsOf(owner)` to pick
the owner's protected docs. The intended target of such a directed grant is a native
per-document ReadCap issued to that identity — but that target is itself
scaffolding-only in nextgraph-rs today, not merely unexposed in JS: `AccessGrantV0
{grantee}` is unpersisted and cap-send is `unimplemented!()`, so directing a grant
to another identity is not-yet-built at the platform level. There is no bilateral
capability exchange to mirror, only (eventually) individual directed grants.
The result is the target's discrimination reproduced end-to-end: private → **Key rotation needs nothing on this surface.** A rotated key is re-sent to the
owner; protected → owner + whoever the owner has directly granted; public → all. inbox of whoever keeps access, and that inbox is processed automatically at the next
Proven in `test/isolation-active.test.ts`: an unconnected principal is denied a connection — so access is not lost, it is *deferred*, consistent with local-first.
protected document, granted it after the owner issues a directed `grantRead`, and Same channel as the initial delivery, so there is **no subscription obligation** to
reads the public document throughout. expose and no special case to write. Revocation stays what it is: stop re-delivering,
non-retroactive.
This discrimination is only observable because each entity is its own document The relationship concept — who is "connected" to whom, and therefore whose documents
(the consumer application creates per-entity docs via `createEntityDoc` and `open`s to share — is owned by the consumer application, not the lib. A connection or
each) — in a mono-store layout the per-document ReadCap is all-or-nothing. friendship is not a NextGraph primitive; the only platform-mappable primitive is the
per-document cap delivery above.
The result is the target's discrimination reproduced end-to-end: you read the
documents whose caps you hold, and nothing else. Proven in
`test/isolation-active.test.ts` (a document nobody shared is unreadable; a share to
one inbox reveals it there and only there; a bare reference reads nothing while the
repo link opens the published document; a returning identity keeps its caps) and in
`test/cross-user-access.test.ts`, the acceptance test below.
This discrimination is only observable because each entity is its own document (the
consumer application creates per-entity docs via `createEntityDoc`) — in a mono-store
layout the per-document ReadCap is all-or-nothing.
### The acceptance test — no cryptography required
Alice owns a protected document holding a secret and a public one that carries a
**reference** to it. Bob, holding the public document's link, reads it, finds the
reference, and can NAME the protected document while reading nothing of it —
publication is **not recursive**. Charlie, holding the same link plus the protected
document's cap (delivered to his inbox), reads through the very same reference. The
only difference between them is each of them holds; nobody was named to any
registry. And dynamically: the cap lands in Bob's inbox, his client processes it, and
the read that was empty yields the content — the held-caps signal re-running it.
That is what real NextGraph does, and it holds **without a line of encryption**
which is what makes the P1a (shape) / P1b (enforcement) split honest rather than
cosmetic. Proven in `test/cross-user-access.test.ts`.
> **After P1a the shape is right and the isolation is still fake.** The stand-in key
> is a constant, and several read paths (`docs.sparqlQuery`/`sparqlUpdate`, the whole
> inbox, `store-registry`, `subscribe`, `open-repo`) consult no cap at all — worse,
> any wallet can reach any document. That is the subject of
> [`briefs/2026-07-30-virtual-wallet-boundary.md`](./briefs/2026-07-30-virtual-wallet-boundary.md).
> Nothing may be claimed "anonymous" or "private" until it lands.
### Write-guard coverage (honest scope) ### Write-guard coverage (honest scope)
@@ -342,16 +441,16 @@ natively at migration); the read side is what makes isolation observably active.
### The per-document ReadCap is the isolation path (item-level filter retired) ### The per-document ReadCap is the isolation path (item-level filter retired)
Isolation is enforced by the per-document ReadCap (`caps.ts` + `read-filter.ts`) Isolation is enforced by the per-document ReadCap (`caps.ts` + `read-filter.ts`)
alone: the access unit is the document (`@graph` = repo), and grants are explicit alone: the access unit is the document (`@graph` = repo), and the only acts are
(`open` / `grantRead` / `makePublic`) — for `protected`, the owner issues a directed possession-shaped (`createEntityDoc` files a cap, `shareCap` delivers one,
`grantRead(doc, granteeId)` per identity it wants to share with. Because the consumer `publishRepoLink` emits an openable link). Because the consumer application writes
application now writes one document per entity (`createEntityDoc` + `open` per entity), one document per entity, the per-document cap discriminates at entity granularity
the per-document cap discriminates at entity granularity — the target's behaviour. the target's behaviour.
The old item-level application-visibility filter (`isolation.ts` The old item-level application-visibility filter (the since-deleted `isolation.ts`
`applyIsolation`, a `Set`-of-records filter keyed on owner+scope) is retired `applyIsolation`, a `Set`-of-records filter keyed on owner+scope) is retired
from the consumer path: the application carries no access logic — it declares its from the consumer path: the application carries no access logic — it declares its
identity and issues directed grants, and trusts the SDK. Its matrix functions are identity and shares caps, and trusts the SDK. Its matrix functions are
dead scaffolding kept for reference and removed at migration. There is no longer a dead scaffolding kept for reference and removed at migration. There is no longer a
second, coexisting app-layer filter to reconcile — the single axis is the second, coexisting app-layer filter to reconcile — the single axis is the
per-document cap, exactly as in the target. per-document cap, exactly as in the target.
@@ -365,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.
@@ -388,69 +487,105 @@ emulates the inbox on the shared wallet:
polls `read` and fires when the deposit count changes (the polyfill has no polls `read` and fires when the deposit count changes (the polyfill has no
reactive inbox subscription). Fires once immediately; returns an unsubscribe. reactive inbox subscription). Fires once immediately; returns an unsubscribe.
### An inbox BELONGS to a virtual user (2026-07-30)
`storeRegistry.walletInbox(id)` resolves — creating on first sight — the inbox
document of one virtual user, recorded in the doc-shim under `shim:docInbox` and
read by its own query (so an account written before this existed still resolves).
The asymmetry that matters:
- **Depositing into anyone's inbox is open.** It is the ONLY way a link crosses
from one wallet to another, and since you cannot discover, it is the bootstrap of
the whole reachability graph. A deposit grants the depositor nothing in return —
upstream it is an anonymous sealed box.
- **Reading an inbox is confined to its owner** (`isOwnInbox`, enforced in `read` /
`readSynced`, hence in `watch`). Since P1a routes ReadCaps through deposits, an
unguarded read let anyone who knew an inbox NURI collect the caps addressed to its
owner — defeating directed sharing. Anonymous owns no inbox and reads none.
At migration this guard disappears into cryptography: an inbox is sealed to its
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 (see the deferred global-index note in announced — and the read side is served by the recipient's own verifier unsealing
the top-level README and [`decisions/discovery-model.md`](./decisions/discovery-model.md)). 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 — e.g. a registration/deposit in one consumer app and submission to a purposes — a registration/deposit, a cap delivery (`shareCap`), a link handed to
discovery index — same `post` API, same watcher. someone — same `post` API, same watcher.
## Emulated discovery index + special account (`discovery.ts`) ## The virtual user boundary (`reach.ts` + `physical.ts`)
Discovery is a surface on top of the inbox, not a new primitive. Access is not the Every access function is confined to the user currently connected: no cross-user
same as discovery: a public entity is world-readable *with its NURI*; the discovery access, so the consumer is coded against a reach that will actually exist.
index is how a client learns that NURI exists without holding a relationship **Two rules, one criterion — possession — implemented in two places**, deliberately
to its creator (see [`decisions/discovery-model.md`](./decisions/discovery-model.md)). redundant so a lapse in either is caught by the other:
The model is: one global index = an owned document (public read), fed via
its inbox. Nobody writes the index directly — a creator deposits a reference into
the index's inbox, and the index is built up from those deposits. That build-up
step is the natural dedup / moderation point.
- **The special account (polyfill owner).** "Who owns the global index" is - **Rule 1, authorization** (`assertMayReach`, at the passage points `docs.sparqlQuery`
undecided in the target (NextGraph is mono-user with no global data — a / `sparqlUpdate` / `subscribeDoc`): nothing reaches `ng` unless the connected user
singleton app is the only glimpsed path). So the polyfill parks ownership on a possesses that document's cap. It fires on a request that should never have been
reserved special account in the shim — `INDEX_ACCOUNT = reservedAccount("index")`. made, and makes it fail loudly rather than succeed quietly.
This is NOT the key `"index"` / `"@index"`: `reservedAccount` mints a - **Rule 2, do not even attempt** (`mustNotAttempt`, at the callers — `readUnion`
sentinel-prefixed key in the shim's reserved namespace (e.g. `" reserved:index"`) filters before opening or reading, `ensureRepoOpen` returns): a reader holding no
that `normalizeId` can never produce, so no user id — not even one typed as cap does not issue the operation at all. Upstream you cannot even *address* a repo
"index" or "@index", which normalizes to the disjoint key "index" — can collide you have no cap for, so asking is not "a read that will be refused" — it is a read
with or hijack the index account (asserted in `discovery.test.ts`). It is a with no meaning.
normal shim account (so its 3 scope documents are created on first sight like
any other), but never a real user; it only hosts the index document. Its
`public` scope document is the index document, and its inbox receives the
deposits — a stable NURI: every client opening the same shared wallet
resolves the same account, hence the same document, so all clients read/write one
shared index.
- **`submitToIndex(ref, opts?)`** — the SDK act "make this discoverable".
Deposits `ref` into the index document's inbox via `inbox.post`. `from` follows
the inbox convention (bound to the current identity; anonymous when `null`).
`ref` is opaque here — the consumer application serializes whatever locates the
entity (e.g. an entity document NURI + discovery metadata). Public-only guard: when
`opts.doc` names the document being surfaced, a document under a non-public
(protected/private) read policy is refused (`caps.governsRead(doc) &&
!caps.canRead(doc, null)`) — the global index is world-readable, so admitting a
governed doc's NURI would leak it past its scope. Proven in
`test/discovery.test.ts` case (d).
- **`readIndex()`** — the emulated read side. Reads every submission, dedups by
serialized `ref` (the moderation point: a duplicate submission surfaces
once), returns entries sorted by `ts`. `watchIndex(onEntries, opts?)` is the
emulated watcher (polls `readIndex`).
This replaces the cross-account fan-out (`store-registry.ts` **Possession decides, never the shape of the reference in hand.** A caller
`listEntityDocs('public')` / `resolveReadGraphs`) as the app-facing discovery legitimately holds a bare NURI while possessing its cap elsewhere — references travel
path: the consumer application submits public entities to the index and reads the bare through content and stores, the cap sits in what the user holds.
index, instead of fanning out over every account's public documents. The fan-out
survives only as an internal lib fallback — kept for the per-scope listing it also
powers (e.g. `resolveReadGraphs`), never the app's discovery route.
`discovery.ts` knows no application domain — the consumer application defines the The exception is **depositing** into another user's inbox (`docs.depositInto`): a
`ref` shape and its meaning. At migration the special account disappears: named primitive rather than a flag, because it is a different act — you hold no cap,
ownership moves to the decided global-index owner, `submitToIndex` becomes the you cannot read back, and you get nothing in return. It is the only channel by which
native `inbox_post_link` (proposed/future) on the index's inbox, and `readIndex` a link crosses between users, hence the bootstrap of the whole reachability graph.
queries the real index document. The consumer surface (`submitToIndex` / `readIndex`)
is designed to survive that swap unchanged. The machinery lives in `physical.ts` (see *Physical user vs virtual user* above):
unguarded primitives, never exported from the package, used only for the index of
virtual users. Separating the FUNCTIONS is what replaced an earlier exemption list —
the machinery does not get waved through the guard, it calls something the guard
never saw.
## Connecting a user (`connect.ts`)
Processing inboxes is the **library's** job, not the app's: a consumer must never
have to remember to drain a queue for documents shared with it to become readable —
forgetting would look like "the share did not work" rather than "nobody consumed the
queue". So `setCurrentUser` fires `connectedUser()`, which does two things in order:
1. **Restore** — read back the caps this user already applied (`readLinks`, the
emulated `AddLink` records on its User branch) into what it holds. Durable state,
one read, no inbox involved.
2. **Drain** — process every inbox it may read (`myInboxes`: its own, plus one per
document it opened an inbox on), filing any new Link durably.
Restore-first is what lets a reconnecting user read its shared documents immediately
instead of waiting on a queue round-trip.
**Fire-and-forget, deliberately.** The setter is synchronous and every consumer calls
it from synchronous code; making it async would push the wait back onto the app,
which is the obligation this removes. The work announces itself through
`CapRegistry.onChange` — which `watchShape` already listens to — so a view that was
empty for want of a cap re-reads when the cap lands. `connectedUser()` is exported
for a caller that needs to await it (tests, a deterministic startup).
**It does not provision.** Connecting an identity that does not exist creates
nothing (`resolveAccount`, not `ensureAccount`): otherwise connecting would mint a
user's stores and their caps as a background side effect, arming the whole emulation
at a moment nothing controls.
*Cost worth knowing*: `setCurrentUser` therefore has observable asynchronous effects
— it reads, and it logs. Tests asserting on log output must await `connectedUser()`
first.
## ~~Emulated discovery index + special account~~ — REMOVED 2026-07-30
**There is no discovery in NextGraph. You cannot discover; you can only follow links** (see [`readcap-and-nuri-model.md`](./readcap-and-nuri-model.md) §4ter-bis). Publishing is two acts — place the data in your public store, **and** circulate its link (into an inbox, or into a document the reader already holds) — and it is seen only by those who received the link.
`discovery.ts` (a global index owned by a reserved `@index` account, `submitToIndex` / `readIndex` / `watchIndex`), its tests, and `watchShape`'s public-scope fold were **removed**. The module failed on two independent counts: it emulated a capability the target will never have — teaching consumers a model that does not exist — and it was **data common to several wallets**, where nothing may be common but the indexing mechanisms that make the virtual users work.
The ADR that specified it ([`decisions/discovery-model.md`](./decisions/discovery-model.md)) is marked superseded, and keeps the part that survives: the `discovery → synchronization → query` frame still holds, with stage 1 re-read as *"a link reached you"* rather than *"you consulted an index"*. Which makes the **inbox** the bootstrap of the whole reachability graph — see [`briefs/2026-07-30-virtual-wallet-boundary.md`](./briefs/2026-07-30-virtual-wallet-boundary.md).
## Emulated write guard (`ng-proxy.ts`) ## Emulated write guard (`ng-proxy.ts`)
+3 -1
View File
@@ -18,7 +18,9 @@ Without a minimum of crypto simulation, damaging shortcuts get taken (reading th
> **A `did` (bare id, WITHOUT a ReadCap) and a NURI (WITH a ReadCap) are treated GENUINELY differently: the former does NOT allow reading the data; the latter is SUFFICIENT and REQUIRED.** > **A `did` (bare id, WITHOUT a ReadCap) and a NURI (WITH a ReadCap) are treated GENUINELY differently: the former does NOT allow reading the data; the latter is SUFFICIENT and REQUIRED.**
Concretely: a document's data is **stored encrypted** (per-doc symmetric encryption, however lightweight); the **ReadCap = the key**; without it, **decrypting/reading is impossible**. No ACL, no plaintext accessible "on the side". Obtaining read access = **holding the key**, exactly as in the target model. Concretely, **in the target**: a document's data is **stored encrypted** (per-doc symmetric encryption, however lightweight); the **ReadCap = the key**; without it, **decrypting/reading is impossible**. No ACL, no plaintext accessible "on the side". Obtaining read access = **holding the key**, exactly as in the target model.
> **Not yet true here, and saying so matters.** The shape is in place — possession decides, every access is confined to the connected virtual user, caps are stored and read back — but the cap value is the constant `OK` and nothing is encrypted. Per-document encryption is **P1b**, and it is one function (`nuri.ts` `mintCap`). Until it lands, nothing this library does may be described as anonymous or private.
## Shape consequences (to respect everywhere) ## Shape consequences (to respect everywhere)
+74 -8
View File
@@ -6,7 +6,14 @@ separate:
| Import | Surface | | Import | Surface |
|---|---| |---|---|
| `@ng-eventually/client` | The same signature as the SDK — `ng`, `useShape`, `inbox` (+ types). A drop-in for `@ng-org/web` / `@ng-org/orm`; as NextGraph matures it resolves to the real SDK (build alias removed) with no code change. | | `@ng-eventually/client` | The same signature as the SDK — `ng`, `useShape`, `inbox` (+ types). A drop-in for `@ng-org/web` / `@ng-org/orm`; as NextGraph matures it resolves to the real SDK (build alias removed) with no code change. |
| `@ng-eventually/client/polyfill` | The only non-SDK surface — `configure`, `setCurrentUser`, and capability helpers (`getCaps`, `grantRead`, `canRead`/`canWrite`). It falls away as NextGraph matures. | | `@ng-eventually/client/polyfill` | The only non-SDK surface — `configure`, `setCurrentUser`, and the capability surface (`capFor`, `shareCap`, `getCaps`). It falls away as NextGraph matures. |
> **Reading is key possession, and the isolation here is still fake.** The cap
> surface has the shape of the real model — you hold a document's `ReadCap` or you
> do not read it, and there is no authorization list anywhere — but nothing is
> encrypted yet and several read paths bypass the guard entirely. Nothing this
> library does may be described as "anonymous" or "private" until per-document
> encryption lands (P1b).
```ts ```ts
// bootstrap (the only non-SDK call) — inject the real SDK // bootstrap (the only non-SDK call) — inject the real SDK
@@ -36,13 +43,72 @@ What the polyfill adds on top of the real SDK (each emulated for now, native as
NextGraph matures): NextGraph matures):
- Shared-wallet identity (one wallet for everyone; the current identity id is - Shared-wallet identity (one wallet for everyone; the current identity id is
relayed to the SDK). relayed to the SDK).
- Capability enforcement — a read filter + write guard over emulated grants - Capability emulation — per-identity **cap possession** (`capFor`) and a read filter
attached to documents; the app declares a document's read policy and issues over it: you read the documents whose cap you hold. Creating a document files its
directed read grants. cap; receiving one is an inbox deposit. There is no authorization list.
- Anticipated methods (inbox `post`, capability ops) with their future-SDK shapes, - Anticipated methods (inbox `post`, `shareCap`) with their future-SDK shapes,
emulated for now. emulated for now.
Generic: no application domain. The consumer application injects its shapes and Generic: no application domain. The consumer application injects its shapes and
performs the acts of granting access. The relationship concept ("who is connected performs the acts of sharing. The relationship concept ("who is connected to whom")
to whom") is the consumer application's own — the client exposes only directed is the consumer application's own — the client exposes only "share this one
per-document read grants. document's cap to that inbox".
### The cap surface in three calls
```ts
import { capFor, shareCap, getCaps } from "@ng-eventually/client/polyfill";
import { storeRegistry } from "@ng-eventually/client";
// Creating a document records its cap and you hold it — nothing to declare.
const doc = await storeRegistry.createEntityDoc(myId, "protected");
capFor(doc); // → `${doc}:r:…` — you hold it
// Share it with one recipient, addressed by their inbox. They need no "receive"
// operation: their existing inbox.watch absorbs it.
await shareCap(capFor(doc)!, theirInbox);
// Publishing is TWO acts: place the data in your public store, and circulate its
// LINK. There is no discovery — you cannot be found, you can only be reached — so
// the link has to travel: into an inbox, or into a document the reader already
// holds. The bare NURI would name the document without opening it.
const link = getCaps().publishRepoLink(publicDoc);
await shareCap(link, theirInbox);
```
The one invariant to keep in mind: **you never derive a cap from a bare reference.**
You look it up in what you hold, or you were given it. A `did:ng:o:…` without `:r:`
names a document and grants nothing.
### The types carry that invariant
`Nuri` and `ReadCap` are **template literal types**, not `string` aliases:
```ts
type Nuri = `did:ng:${string}`
type ReadCap = `did:ng:${string}:r:${string}`
```
They are still strings — assignable to `string`, JSON-serializable, no wrapper — but
the distinction is checked. A `ReadCap` goes wherever a `Nuri` is expected (a cap
*is* a NURI with the key inside); the reverse does not compile:
```ts
await shareCap(doc, theirInbox); // ✗ Argument of type '`did:ng:${string}`' is not
// assignable to '`did:ng:${string}:r:${string}`'
```
A string that comes from outside your code — storage, a URL, JSON, a form — is a
plain `string`. **Narrow it, do not cast it**: a cast re-opens exactly the confusion
the types close.
```ts
import { isNuri, hasReadCap } from "@ng-eventually/client";
const saved = localStorage.getItem("cap");
if (saved && hasReadCap(saved)) await shareCap(saved, theirInbox); // ✓ narrowed
```
The runtime guards remain regardless — a JavaScript caller never meets the compiler,
and a cast bypasses it — so passing a bare reference where a cap belongs throws with
a message that says so.
+37 -9
View File
@@ -92,6 +92,9 @@ useShape<T extends BaseType>(
- `shape` — the ORM shape type (generated from a SHEX shape). Names the entity type - `shape` — the ORM shape type (generated from a SHEX shape). Names the entity type
and the properties to materialize. and the properties to materialize.
- `scope` — where to read: a `{ graphs, subjects }` scope object or a NURI string. - `scope` — where to read: a `{ graphs, subjects }` scope object or a NURI string.
**Not to be confused with this library's `Scope`** (`types.ts`), which is the
literal union `public | protected | private` naming a store. Same word, two
meanings: the ORM's is a read target, ours is a placement.
`undefined` yields an empty read. `undefined` yields an empty read.
- **Returns** a `DeepSignalSet<T>` — a **live reactive set**. Iterate it like a set; - **Returns** a `DeepSignalSet<T>` — a **live reactive set**. Iterate it like a set;
the component re-renders whenever the set changes. the component re-renders whenever the set changes.
@@ -202,11 +205,18 @@ Data is isolated **per document (repo)**, and each document lives in a **scope**
| Scope | Read | Write | | Scope | Read | Write |
|---|---|---| |---|---|---|
| **Private** | Owner only | Owner only | | **Private** | Owner only | Owner only |
| **Protected** | Owner + explicit grant holders | Owner + permissioned collaborators | | **Protected** | Owner + whoever the owner delivered the cap to | Owner + permissioned collaborators |
| **Public** | Everyone (no capability needed) | **Owner only** | | **Public** | Whoever has the URL (the repo link) | **Owner only** |
Consequences a consumer must internalize: Consequences a consumer must internalize:
- **Reading is key possession, never an authorization list.** You hold a document's
`ReadCap` (`…:r:{cap}`) or you do not read it — there is no "may X read Y?" to ask,
here or upstream. A cap-less `did:ng:o:…` **names** a document without granting
anything, which is what lets public content point at private content without
disclosing it. Caps reach you two ways: creating a document files its own, and
someone delivering one to your inbox (`shareCap`). Nothing derives a cap from a
bare reference.
- **Isolation is per-document, not per-store.** Holding a store's cap does **not** - **Isolation is per-document, not per-store.** Holding a store's cap does **not**
grant read on the documents it contains — each document has its own ReadCap. Fine- grant read on the documents it contains — each document has its own ReadCap. Fine-
grained isolation therefore means **one document per entity** grained isolation therefore means **one document per entity**
@@ -224,9 +234,28 @@ Consequences a consumer must internalize:
permission enum (`engine/repo/src/types.rs:1729`, `PermissionV0`) has `WriteAsync`/ permission enum (`engine/repo/src/types.rs:1729`, `PermissionV0`) has `WriteAsync`/
`WriteSync` but **no** add-only/append permission and **no** public-writable grant. `WriteSync` but **no** add-only/append permission and **no** public-writable grant.
To surface data to others without a shared write, use the **inbox** (any identity — To surface data to others without a shared write, use the **inbox** (any identity —
even anonymous — can deposit into a document's native inbox; the owner materializes even anonymous — can deposit; only the owner reads back) or make the document
deposits) or make the document **public-readable** and let each identity own its own **public-readable** and let each identity own its own document. *Per-document inboxes
document. 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`).*
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
@@ -263,13 +292,12 @@ from the reactive contract:
for a **single already-opened document**; it is the per-entity **fan-out** that is for a **single already-opened document**; it is the per-entity **fan-out** that is
unfit today. unfit today.
2. **Inbox and discovery index use polling watchers.** The inbox is emulated 2. **The inbox uses a polling watcher.** The inbox is emulated
(`AppRequestCommandV0::InboxPost` has no verifier arm today; no wasm helper seals a (`AppRequestCommandV0::InboxPost` has no verifier arm today; no wasm helper seals a
deposit), so `inbox.watch` ([`../src/inbox.ts`](../src/inbox.ts)) and deposit), so `inbox.watch` ([`../src/inbox.ts`](../src/inbox.ts)) **polls** via
`discovery.watchIndex` ([`../src/discovery.ts`](../src/discovery.ts)) **poll** 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)`,
+39 -38
View File
@@ -224,7 +224,9 @@ async function main(): Promise<void> {
// ── inbox ─────────────────────────────────────────────────────────────── // ── inbox ───────────────────────────────────────────────────────────────
console.log("\n── inbox ──"); console.log("\n── inbox ──");
await step("inbox post → read round-trip", async () => { await step("inbox post → read round-trip", async () => {
const r = await sdk<any>(frame, "inboxPostRead", { k: "a" }, { k: "b" }); // Fresh user per run: an inbox is stable for its owner, so a reused id would
// read back the previous runs' deposits too (the wallet persists).
const r = await sdk<any>(frame, "inboxPostRead", "@inbox-user-" + Date.now(), { k: "a" }, { k: "b" });
const payloads = (r.deposits || []).map((d: any) => JSON.stringify(d.payload)); const payloads = (r.deposits || []).map((d: any) => JSON.stringify(d.payload));
check( check(
"post then read returns both deposits (sorted)", "post then read returns both deposits (sorted)",
@@ -233,7 +235,7 @@ async function main(): Promise<void> {
); );
}); });
await step("inbox watch fires on deposit", async () => { await step("inbox watch fires on deposit", async () => {
await sdk(frame, "inboxWatchStart"); await sdk(frame, "inboxWatchStart", "@watcher-" + Date.now());
await frame.waitForFunction(() => (window as any).__sdk.inboxWatchState().fires >= 1, { timeout: 20000 }); await frame.waitForFunction(() => (window as any).__sdk.inboxWatchState().fires >= 1, { timeout: 20000 });
const base = await sdkGet<any>(frame, "inboxWatchState"); const base = await sdkGet<any>(frame, "inboxWatchState");
await sdk(frame, "inboxWatchDeposit", { landed: true }); await sdk(frame, "inboxWatchDeposit", { landed: true });
@@ -246,38 +248,23 @@ 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}`);
}); });
// ── discovery index ─────────────────────────────────────────────────────
console.log("\n── discovery index ──");
await step("discovery submit → read", async () => {
const ref = { doc: "did:ng:o:some-public-doc", title: "t" };
const r = await sdk<any>(frame, "discoverySubmitRead", ref);
const refs = (r.entries || []).map((e: any) => JSON.stringify(e.ref));
check("submitToIndex then readIndex returns the entry", refs.includes(JSON.stringify(ref)), `entries=${r.entries.length}`);
});
await step("discovery watchIndex fires reactively", async () => {
await sdk(frame, "discoveryWatchStart");
await frame.waitForFunction(() => (window as any).__sdk.discoveryWatchState().fires >= 1, { timeout: 20000 });
const base = await sdkGet<any>(frame, "discoveryWatchState");
await sdk(frame, "discoverySubmit", { doc: "did:ng:o:doc2", title: "t2", n: Date.now() });
await frame.waitForFunction(
(b) => (window as any).__sdk.discoveryWatchState().fires > (b as number),
base.fires,
{ timeout: 20000 },
);
const after = await sdkGet<any>(frame, "discoveryWatchState");
check("watchIndex fires on a new submission", after.fires > base.fires, `fires=${after.fires}`);
await sdk(frame, "discoveryWatchStop");
});
await step("reserved @index account isolation", async () => {
const r = await sdk<any>(frame, "discoveryIndexIsolation");
check("user '@index' resolves disjoint from the reserved index owner", r.disjoint === true, `disjoint=${r.disjoint}`);
});
// ── store-registry ────────────────────────────────────────────────────── // ── store-registry ──────────────────────────────────────────────────────
console.log("\n── store-registry ──"); console.log("\n── store-registry ──");
await step("ensureAccount idempotent", async () => { await step("ensureAccount idempotent", async () => {
@@ -337,17 +324,31 @@ async function main(): Promise<void> {
// ── caps / read-filter (in-memory cap model) ──────────────────────────── // ── caps / read-filter (in-memory cap model) ────────────────────────────
console.log("\n── caps / read-filter (in-memory cap model) ──"); console.log("\n── caps / read-filter (in-memory cap model) ──");
await step("read-filter: protected hidden from stranger", async () => { await step("read-filter: you read what your keyring holds, nothing else", async () => {
const r = await sdk<any>(frame, "capsReadFilter"); const r = await sdk<any>(frame, "capsReadFilter");
const ownerSeesProt = r.ownerView.includes("protected-item"); // The owner reads the documents whose caps their keyring holds — and NOT the
const strangerHiddenProt = !r.strangerView.includes("protected-item"); // one it does not, even though its NURI is right there in the set.
const bothSeePublic = r.ownerView.includes("public-item") && r.strangerView.includes("public-item"); const ownerReadsHeld =
const bothSeeUngoverned = r.ownerView.includes("ungoverned-item") && r.strangerView.includes("ungoverned-item"); r.ownerView.includes("protected-item") && r.ownerView.includes("public-item");
check("owner reads protected; stranger does not; public+ungoverned visible to both", ownerSeesProt && strangerHiddenProt && bothSeePublic && bothSeeUngoverned, `owner=${JSON.stringify(r.ownerView)} stranger=${JSON.stringify(r.strangerView)}`); const ownerMissesUnheld = !r.ownerView.includes("unheld-item");
// A stranger holds nothing at all — a bare reference names without reading.
const strangerReadsNothing = r.strangerView.length === 0;
// …until the repo link of the PUBLISHED document reaches them.
const linkOpensPublic =
r.strangerWithLinkView.length === 1 && r.strangerWithLinkView.includes("public-item");
check(
"owner reads held docs only; stranger reads nothing; the repo link opens the published one",
ownerReadsHeld && ownerMissesUnheld && strangerReadsNothing && linkOpensPublic,
`owner=${JSON.stringify(r.ownerView)} stranger=${JSON.stringify(r.strangerView)} withLink=${JSON.stringify(r.strangerWithLinkView)}`,
);
}); });
await step("read-filter: directed grant reveals the doc", async () => { await step("shareCap: a cap delivered to an inbox reveals the doc", async () => {
const r = await sdk<any>(frame, "capsDirectedGrant"); const r = await sdk<any>(frame, "capsShareCap", "@friend-" + Date.now());
check("grantRead reveals the protected doc to the grantee", r.before === 0 && r.after === 1, `before=${r.before} after=${r.after}`); check(
"shareCap → inbox processed → the shared doc becomes readable, and the delivery is not surfaced",
r.before === 0 && r.after === 1 && r.surfacedDeposits === 0,
`before=${r.before} after=${r.after} surfaced=${r.surfacedDeposits}`,
);
}); });
// ── accounts (IdentityStore) ──────────────────────────────────────────── // ── accounts (IdentityStore) ────────────────────────────────────────────
+202 -106
View File
@@ -15,23 +15,53 @@
*/ */
import { ng as realNg, init as realInit } from "@ng-org/web"; import { ng as realNg, init as realInit } from "@ng-org/web";
import { configure, configureStoreRegistry, setCurrentUser, getCaps, resetCaps } from "@ng-eventually/client/polyfill"; import {
configure,
configureStoreRegistry,
setCurrentUser,
capFor,
getCaps,
resetCaps,
shareCap,
connectedUser,
} from "@ng-eventually/client/polyfill";
import { import {
docs, docs,
subscribeDoc, subscribeDoc,
subscribeDocs, subscribeDocs,
readModel, readModel,
inbox, inbox,
discovery,
storeRegistry, storeRegistry,
useShape as libUseShape, useShape as libUseShape,
watchShape, watchShape,
accounts,
} from "@ng-eventually/client"; } from "@ng-eventually/client";
import type { ShapeObservable, ShapeQuery } 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 type { Nuri, ShapeObservable, ShapeQuery } from "@ng-eventually/client";
const { IdentityStore } = accounts; const { IdentityStore } = accounts;
/**
* The Playwright boundary. Every NURI reaching this harness crosses the bridge as
* a plain `string` (Playwright serializes arguments), so it arrives untyped even
* though the library's `Nuri` is a template literal type. Narrow it here, loudly:
* a test that passes something which is not a NextGraph reference should fail with
* that message, not with a confusing downstream error. Never cast a cast would
* re-open exactly the confusion the types exist to close.
*/
function asNuri(s: string): Nuri {
if (!isNuri(s)) throw new Error(`[e2e] not a NextGraph reference: ${JSON.stringify(s)}`);
return s;
}
/** Same, for an optional anchor. */
function asAnchor(s?: string): Nuri | undefined {
return s === undefined ? undefined : asNuri(s);
}
// ── The broker session, resolved once the iframe connects ────────────────── // ── The broker session, resolved once the iframe connects ──────────────────
interface BrokerSession { interface BrokerSession {
session_id: string; session_id: string;
@@ -69,7 +99,7 @@ configure({
}); });
configureStoreRegistry({ configureStoreRegistry({
// The registry (+ subscribe/inbox/discovery/read-model) reach the session // The registry (+ subscribe/inbox/read-model) reach the session
// through this. It resolves once the broker connects. // through this. It resolves once the broker connects.
getSession: async () => { getSession: async () => {
// Read the CURRENT session (mutable): a fresh session (session_stop+session_start // Read the CURRENT session (mutable): a fresh session (session_stop+session_start
@@ -144,11 +174,11 @@ const identity = new IdentityStore(
}, },
async sparqlUpdate(query: string, anchor?: string) { async sparqlUpdate(query: string, anchor?: string) {
const s = await sessionReady; const s = await sessionReady;
return docs.sparqlUpdate(s.session_id, query, anchor); return docs.sparqlUpdate(s.session_id, query, asAnchor(anchor));
}, },
async sparqlQuery(query: string, anchor?: string) { async sparqlQuery(query: string, anchor?: string) {
const s = await sessionReady; const s = await sessionReady;
return docs.sparqlQuery(s.session_id, query, undefined, anchor); return docs.sparqlQuery(s.session_id, query, undefined, asAnchor(anchor));
}, },
/** /**
* The load-bearing graph-behavior characterization against the REAL broker. * The load-bearing graph-behavior characterization against the REAL broker.
@@ -252,7 +282,7 @@ const identity = new IdentityStore(
*/ */
async readUnionOverDocs(n: number, includeBad: boolean) { async readUnionOverDocs(n: number, includeBad: boolean) {
const s = await sessionReady; const s = await sessionReady;
const docNuris: string[] = []; const docNuris: Nuri[] = [];
for (let i = 0; i < n; i++) { for (let i = 0; i < n; i++) {
const d = await docs.docCreate(s.session_id, "Graph", "data:graph", "store", undefined); const d = await docs.docCreate(s.session_id, "Graph", "data:graph", "store", undefined);
await docs.sparqlUpdate( await docs.sparqlUpdate(
@@ -262,20 +292,22 @@ const identity = new IdentityStore(
); );
docNuris.push(d); docNuris.push(d);
} }
const toRead = includeBad ? [...docNuris, "did:ng:o:definitely-not-a-real-doc-xyz"] : docNuris; const toRead: Nuri[] = includeBad ? [...docNuris, "did:ng:o:definitely-not-a-real-doc-xyz"] : docNuris;
const subjects = await readModel.readUnion(toRead); const subjects = await readModel.readUnion(toRead);
return { docNuris, subjectCount: subjects.length, subjects }; return { docNuris, subjectCount: subjects.length, subjects };
}, },
/** /**
* readUnion cap gate: create a doc, mark it protected for owner O, set the * readUnion possession gate: create a doc as owner O (whose keyring gets its
* current user to a DIFFERENT identity, and readUnion the doc is dropped. * cap), then read it as a DIFFERENT identity, which holds nothing dropped.
* The stranger has the document's NURI in hand throughout: naming is not reading.
*/ */
async readUnionCapGate() { async readUnionCapGate() {
const s = await sessionReady; const s = await sessionReady;
resetCaps(); resetCaps();
const doc = await docs.docCreate(s.session_id, "Graph", "data:graph", "store", undefined); const doc = await docs.docCreate(s.session_id, "Graph", "data:graph", "store", undefined);
await docs.sparqlUpdate(s.session_id, `INSERT DATA { <urn:e2e:cg> <urn:e2e:p> "x" }`, doc); await docs.sparqlUpdate(s.session_id, `INSERT DATA { <urn:e2e:cg> <urn:e2e:p> "x" }`, doc);
getCaps().open(doc, "protected", "owner-O"); setCurrentUser("owner-O");
getCaps().open(doc, "protected");
setCurrentUser("someone-else"); setCurrentUser("someone-else");
const asStranger = await readModel.readUnion([doc]); const asStranger = await readModel.readUnion([doc]);
setCurrentUser("owner-O"); setCurrentUser("owner-O");
@@ -308,7 +340,7 @@ const identity = new IdentityStore(
await docs.sparqlUpdate( await docs.sparqlUpdate(
s.session_id, s.session_id,
`INSERT DATA { <urn:e2e:sub:${marker}> <urn:e2e:m> "${marker}" }`, `INSERT DATA { <urn:e2e:sub:${marker}> <urn:e2e:m> "${marker}" }`,
doc, asNuri(doc),
); );
}, },
subscribeStop(handle: string) { subscribeStop(handle: string) {
@@ -341,10 +373,18 @@ const identity = new IdentityStore(
}, },
// ── inbox ──────────────────────────────────────────────────────────────── // ── inbox ────────────────────────────────────────────────────────────────
async inboxPostRead(payloadA: unknown, payloadB: unknown) { /**
const s = await sessionReady; * `id` must be FRESH per run (run.ts stamps it). A user's inbox is stable over time
const target = await docs.docCreate(s.session_id, "Graph", "data:graph", "store", undefined); * that is the point of it so re-running against a reused id accumulates the previous
setCurrentUser("inbox-user"); * runs' deposits on a persistent wallet, and the exact-count assertion drifts. The
* thing to make disposable is the user, not the inbox.
*/
async inboxPostRead(id: string, payloadA: unknown, payloadB: unknown) {
// The target must be that user's OWN inbox, not an arbitrary document: you may
// deposit into anyone's, you may only read your own. Establishing the identity
// FIRST is what makes `walletInbox` resolve (and file) that user's inbox.
setCurrentUser(id);
const target = await storeRegistry.walletInbox(id);
await inbox.post(target, { payload: payloadA, from: null, ts: 1000 }); await inbox.post(target, { payload: payloadA, from: null, ts: 1000 });
await inbox.post(target, { payload: payloadB, from: null, ts: 2000 }); await inbox.post(target, { payload: payloadB, from: null, ts: 2000 });
const deposits = await inbox.read(target); const deposits = await inbox.read(target);
@@ -353,9 +393,12 @@ const identity = new IdentityStore(
}, },
// watch (doc_subscribe-based) fires when a deposit lands. // watch (doc_subscribe-based) fires when a deposit lands.
_inboxWatch: { fires: 0, lastLen: -1, unsub: () => {}, target: "" }, _inboxWatch: { fires: 0, lastLen: -1, unsub: () => {}, target: "" },
async inboxWatchStart() { /** `id` fresh per run, for the same reason as {@link inboxPostRead}. */
const s = await sessionReady; async inboxWatchStart(id: string) {
const target = await docs.docCreate(s.session_id, "Graph", "data:graph", "store", undefined); // Watching an inbox is READING it continuously, so the watcher stays connected
// for the whole probe — including across `inboxWatchDeposit`.
setCurrentUser(id);
const target = await storeRegistry.walletInbox(id);
const rec = { fires: 0, lastLen: -1, unsub: () => {}, target }; const rec = { fires: 0, lastLen: -1, unsub: () => {}, target };
(window as any).__sdk._inboxWatch = rec; (window as any).__sdk._inboxWatch = rec;
rec.unsub = inbox.watch(target, (deposits) => { rec.unsub = inbox.watch(target, (deposits) => {
@@ -366,9 +409,7 @@ const identity = new IdentityStore(
}, },
async inboxWatchDeposit(payload: unknown) { async inboxWatchDeposit(payload: unknown) {
const rec = (window as any).__sdk._inboxWatch; const rec = (window as any).__sdk._inboxWatch;
setCurrentUser("watcher");
await inbox.post(rec.target, { payload, from: null }); await inbox.post(rec.target, { payload, from: null });
setCurrentUser(null);
}, },
inboxWatchState() { inboxWatchState() {
const r = (window as any).__sdk._inboxWatch; const r = (window as any).__sdk._inboxWatch;
@@ -376,6 +417,7 @@ const identity = new IdentityStore(
}, },
inboxWatchStop() { inboxWatchStop() {
(window as any).__sdk._inboxWatch.unsub(); (window as any).__sdk._inboxWatch.unsub();
setCurrentUser(null);
}, },
// spoof guard: depositing as another principal throws. // spoof guard: depositing as another principal throws.
async inboxSpoofGuard() { async inboxSpoofGuard() {
@@ -396,53 +438,12 @@ const identity = new IdentityStore(
return { spoofRejected: threw, selfOk, anonOk }; return { spoofRejected: threw, selfOk, anonOk };
}, },
// ── discovery index ──────────────────────────────────────────────────────
async discoverySubmitRead(ref: unknown) {
setCurrentUser("publisher");
await discovery.submitToIndex(ref);
setCurrentUser(null);
const entries = await discovery.readIndex();
return { entries };
},
_discWatch: { fires: 0, lastLen: -1, unsub: () => {} },
discoveryWatchStart() {
const rec = { fires: 0, lastLen: -1, unsub: () => {} };
(window as any).__sdk._discWatch = rec;
rec.unsub = discovery.watchIndex((entries) => {
rec.fires += 1;
rec.lastLen = entries.length;
});
},
async discoverySubmit(ref: unknown) {
setCurrentUser("publisher2");
await discovery.submitToIndex(ref);
setCurrentUser(null);
},
discoveryWatchState() {
const r = (window as any).__sdk._discWatch;
return { fires: r.fires, lastLen: r.lastLen };
},
discoveryWatchStop() {
(window as any).__sdk._discWatch.unsub();
},
// reserved @index account isolation: a real user named "index"/"@index" resolves
// to a DIFFERENT account than the reserved index owner.
async discoveryIndexIsolation() {
const userIndex = await storeRegistry.ensureAccount("@index");
const reserved = await storeRegistry.ensureAccount(discovery.INDEX_ACCOUNT);
return {
userIndexDoc: userIndex.docPublic,
reservedDoc: reserved.docPublic,
disjoint: userIndex.docPublic !== reserved.docPublic,
};
},
// ── 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],
@@ -453,18 +454,25 @@ 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,
// 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.
setCurrentUser(idA);
const dA1 = await storeRegistry.createEntityDoc(idA, "public"); const dA1 = await storeRegistry.createEntityDoc(idA, "public");
const dA2 = await storeRegistry.createEntityDoc(idA, "public"); const dA2 = await storeRegistry.createEntityDoc(idA, "public");
setCurrentUser(idB);
const dB1 = await storeRegistry.createEntityDoc(idB, "public"); const dB1 = await storeRegistry.createEntityDoc(idB, "public");
// listMyEntityDocs(A) → only A's docs (poll: the index append can lag). // listMyEntityDocs(A) → only A's docs (poll: the index append can lag).
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));
} }
setCurrentUser(null);
return { return {
dA1, dA2, dB1, dA1, dA2, dB1,
listA, listA,
@@ -486,8 +494,12 @@ 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
// document is filed under nobody and the very session that created it is
// refused the write below.
setCurrentUser(id);
const entityNuri = await storeRegistry.createEntityDoc(id, scope); const entityNuri = await storeRegistry.createEntityDoc(id, scope);
const marker = "recon-" + Date.now(); const marker = "recon-" + Date.now();
await docs.sparqlUpdate( await docs.sparqlUpdate(
@@ -500,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));
@@ -510,7 +522,7 @@ const identity = new IdentityStore(
/** /**
* RECONNECTION read (phase 2, run in a FRESH session over the SAME wallet). First a * RECONNECTION read (phase 2, run in a FRESH session over the SAME wallet). First a
* DIAGNOSTIC raw anchored read with NO open (rawRowCount), then re-resolve the * DIAGNOSTIC raw anchored read with NO open (rawRowCount), then re-resolve the
* account's entity docs of `scope` (listMyEntityDocs readScopeIndex) and readUnion * account's entity docs of `scope` (listMyEntityDocs readUserStore) and readUnion
* them, purely from the persistent wallet nothing from phase 1's session state * them, purely from the persistent wallet nothing from phase 1's session state
* carries over. The SDK's open-before-read heal (open-repo.ts) opens each repo via * carries over. The SDK's open-before-read heal (open-repo.ts) opens each repo via
* doc_subscribe before the anchored reads. NB: on the SDK/broker version tested here * doc_subscribe before the anchored reads. NB: on the SDK/broker version tested here
@@ -519,22 +531,32 @@ const identity = new IdentityStore(
* fail-without-the-fix proof (see run.ts's reconnection step comment). * fail-without-the-fix proof (see run.ts's reconnection step comment).
*/ */
async reconnectRead(id: string, scope: "public" | "protected" | "private", entityNuri: string, marker: string) { async reconnectRead(id: string, scope: "public" | "protected" | "private", entityNuri: string, marker: string) {
// DIAGNOSTIC: a RAW anchored read of the entity doc with NO open at all, first
// thing in the fresh session — reports how many rows the bare anchored query
// resolves for a not-yet-opened repo (the premise: 0 until opened). Uses the
// low-level docs primitive directly, bypassing readUnion's open step.
const s = session ?? (await sessionReady); const s = session ?? (await sessionReady);
// A fresh session holds nothing in memory: connect AS the user so the caps are
// restored from the durable registers (own documents from the Store branches,
// received ones from the Links) before anything is read back.
setCurrentUser(id);
await connectedUser();
registryInternals.resetRegistryCache();
const listed = await storeRegistry.listMyEntityDocs(id, scope);
// 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
// premise: 0 until opened). Uses the low-level docs primitive directly, bypassing
// readUnion's open step.
//
// Placed AFTER `listMyEntityDocs`, which is what restores the caps of the user's
// own documents from the Store branch. Before it, the boundary refuses the read
// and the probe would measure the guard rather than the open — a number that
// looks like the premise holding while proving nothing about it.
let rawRowCount = -1; let rawRowCount = -1;
try { try {
const raw: any = await docs.sparqlQuery(s.session_id, "SELECT ?s ?p ?o WHERE { ?s ?p ?o }", undefined, entityNuri); const raw: any = await docs.sparqlQuery(s.session_id, "SELECT ?s ?p ?o WHERE { ?s ?p ?o }", undefined, asNuri(entityNuri));
rawRowCount = Array.isArray(raw) ? raw.length : (raw?.results?.bindings?.length ?? 0); rawRowCount = Array.isArray(raw) ? raw.length : (raw?.results?.bindings?.length ?? 0);
} catch (e: any) { } catch (e: any) {
rawRowCount = -2; // threw (e.g. RepoNotFound / InvalidNuri) rawRowCount = -2; // threw (e.g. RepoNotFound / InvalidNuri)
} }
const subjects = await readModel.readUnion(listed.length ? listed : [asNuri(entityNuri)]);
storeRegistry.resetRegistryCache();
const listed = await storeRegistry.listMyEntityDocs(id, scope);
const subjects = await readModel.readUnion(listed.length ? listed : [entityNuri]);
const markers: string[] = []; const markers: string[] = [];
for (const subj of subjects) { for (const subj of subjects) {
for (const vals of Object.values(subj.props)) { for (const vals of Object.values(subj.props)) {
@@ -545,7 +567,7 @@ const identity = new IdentityStore(
rawRowCount, rawRowCount,
listed, listed,
listedCount: listed.length, listedCount: listed.length,
foundEntity: listed.includes(entityNuri), foundEntity: listed.includes(asNuri(entityNuri)),
subjectCount: subjects.length, subjectCount: subjects.length,
markerPresent: markers.includes(marker), markerPresent: markers.includes(marker),
markers, markers,
@@ -561,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() {
@@ -588,7 +610,7 @@ const identity = new IdentityStore(
anchor + anchor +
"> { ?acc a <urn:ng-eventually:shim:Account> } }"; "> { ?acc a <urn:ng-eventually:shim:Account> } }";
try { try {
const res: any = await docs.sparqlQuery(s.session_id, query, undefined, anchor); const res: any = await docs.sparqlQuery(s.session_id, query, undefined, asNuri(anchor));
const rows = Array.isArray(res) ? res.length : (res?.results?.bindings?.length ?? 0); const rows = Array.isArray(res) ? res.length : (res?.results?.bindings?.length ?? 0);
return { threw: false, error: null, rows, anchor }; return { threw: false, error: null, rows, anchor };
} catch (e: any) { } catch (e: any) {
@@ -603,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,
@@ -624,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: "" };
@@ -650,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");
@@ -663,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));
@@ -721,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 = {
@@ -754,39 +776,113 @@ const identity = new IdentityStore(
// The read-filter over the injected useShape Set-like. Boundary note: the // The read-filter over the injected useShape Set-like. Boundary note: the
// caps/read-filter are EMULATED in-memory (CapRegistry) — the real broker does // caps/read-filter are EMULATED in-memory (CapRegistry) — the real broker does
// NOT yet enforce per-doc read caps here (one shared wallet reads everything). // NOT yet enforce per-doc read caps here (one shared wallet reads everything).
// We test what the SDK enforces: the in-memory read-filtered VIEW. // We test what the SDK enforces: the in-memory read-filtered VIEW, which after
// P1a is KEY POSSESSION — you read what your keyring holds, nothing else.
capsReadFilter() { capsReadFilter() {
resetCaps(); resetCaps();
injectedSetItems = [ injectedSetItems = [
{ "@graph": "did:ng:o:protdoc", "@id": "1", v: "protected-item" }, { "@graph": "did:ng:o:protdoc", "@id": "1", v: "protected-item" },
{ "@graph": "did:ng:o:pubdoc", "@id": "2", v: "public-item" }, { "@graph": "did:ng:o:pubdoc", "@id": "2", v: "public-item" },
{ "@graph": "did:ng:o:ungoverned", "@id": "3", v: "ungoverned-item" }, { "@graph": "did:ng:o:unheld", "@id": "3", v: "unheld-item" },
]; ];
getCaps().open("did:ng:o:protdoc", "protected", "owner-O");
getCaps().makePublic("did:ng:o:pubdoc");
// as owner-O
setCurrentUser("owner-O"); setCurrentUser("owner-O");
getCaps().open("did:ng:o:protdoc", "protected");
const link = getCaps().publishRepoLink("did:ng:o:pubdoc");
const ownerView = [...(libUseShape(null, null) as Iterable<any>)].map((i) => i.v); const ownerView = [...(libUseShape(null, null) as Iterable<any>)].map((i) => i.v);
// as a stranger // A stranger holds nothing — including the PUBLISHED document, until the repo
// link reaches them (§5: whoever has the URL reads the content).
setCurrentUser("stranger"); setCurrentUser("stranger");
const strangerView = [...(libUseShape(null, null) as Iterable<any>)].map((i) => i.v); const strangerView = [...(libUseShape(null, null) as Iterable<any>)].map((i) => i.v);
getCaps().learn(link);
const strangerWithLinkView = [...(libUseShape(null, null) as Iterable<any>)].map((i) => i.v);
resetCaps(); resetCaps();
injectedSetItems = []; injectedSetItems = [];
setCurrentUser(null); setCurrentUser(null);
return { ownerView, strangerView }; return { ownerView, strangerView, strangerWithLinkView };
}, },
capsDirectedGrant() { /**
* Sharing a cap the way the model does it: the owner deposits it into the
* recipient's INBOX, and the recipient processing that inbox absorbs it. No
* "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.
*/
/**
* 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) {
const s = await sessionReady;
resetCaps(); resetCaps();
injectedSetItems = [{ "@graph": "did:ng:o:sharedoc", "@id": "1", v: "shared-item" }]; // The recipient's OWN inbox — the address a cap is delivered to. Resolved while
getCaps().open("did:ng:o:sharedoc", "protected", "owner-O"); // connected as them, since that is who owns it and who may later read it.
setCurrentUser("friend"); // `friendId` is fresh per run: this test's assertions survive accumulated caps, but
// the recipient's durable Links would grow run after run on a persistent wallet,
// making every later `connectedUser()` re-apply a longer and longer history.
setCurrentUser(friendId);
const friendInbox = await storeRegistry.walletInbox(friendId);
setCurrentUser("owner-O");
const doc = await docs.docCreate(s.session_id, "Graph", "data:graph", "store", undefined);
injectedSetItems = [{ "@graph": doc, "@id": "1", v: "shared-item" }];
getCaps().open(doc, "protected");
const cap = capFor(doc)!;
setCurrentUser(friendId);
const before = [...(libUseShape(null, null) as Iterable<any>)].length; const before = [...(libUseShape(null, null) as Iterable<any>)].length;
getCaps().grantRead("did:ng:o:sharedoc", "friend");
setCurrentUser("owner-O");
await shareCap(cap, friendInbox);
setCurrentUser(friendId);
const absorbed = await inbox.read(friendInbox); // processing it applies the cap
const after = [...(libUseShape(null, null) as Iterable<any>)].length; const after = [...(libUseShape(null, null) as Iterable<any>)].length;
resetCaps(); resetCaps();
injectedSetItems = []; injectedSetItems = [];
setCurrentUser(null); setCurrentUser(null);
return { before, after }; // `absorbed` must be EMPTY: a cap delivery is infrastructure, never surfaced
// to the consumer as a deposit.
return { before, after, surfacedDeposits: absorbed.length };
}, },
// ── accounts (IdentityStore) ───────────────────────────────────────────── // ── accounts (IdentityStore) ─────────────────────────────────────────────
@@ -849,7 +945,7 @@ const identity = new IdentityStore(
unsub: () => {}, unsub: () => {},
}; };
(window as any).__sdk._stateProbe = probe; (window as any).__sdk._stateProbe = probe;
probe.unsub = subscribeDoc(doc, (resp: any) => { probe.unsub = subscribeDoc(asNuri(doc), (resp: any) => {
const elapsedMs = Date.now() - probe.startMs; const elapsedMs = Date.now() - probe.startMs;
// AppResponse shape: { V0: { State: … } } | { V0: { Patch: … } } | { V0: { TabInfo: … } } | … // AppResponse shape: { V0: { State: … } } | { V0: { Patch: … } } | { V0: { TabInfo: … } } | …
let typeKey = "unknown"; let typeKey = "unknown";
+2 -2
View File
@@ -6,7 +6,7 @@
* document scoped to another identity. When it does (identity B reading identity * document scoped to another identity. When it does (identity B reading identity
* A's doc), the leak is invisible in the data it looks like a normal read. This * A's doc), the leak is invisible in the data it looks like a normal read. This
* probe makes it VISIBLE: every real read/write is logged, prefixed by the ACTIVE * probe makes it VISIBLE: every real read/write is logged, prefixed by the ACTIVE
* identity (the discriminating virtual identity, NOT the constant physical wallet * identity (the discriminating virtual identity, NOT the constant physical user
* id), so replaying the scenario shows the exact line where a doc is accessed * id), so replaying the scenario shows the exact line where a doc is accessed
* under the wrong identity. * under the wrong identity.
* *
@@ -54,7 +54,7 @@ export function enabled(): boolean {
/** /**
* The identity to prefix an access line with: the ACTIVE virtual identity * The identity to prefix an access line with: the ACTIVE virtual identity
* (`getCurrentUser`) the account/space the operation is scoped under, which is * (`getCurrentUser`) the account/space the operation is scoped under, which is
* the discriminating signal for the isolation leak. NOT the physical wallet id * the discriminating signal for the isolation leak. NOT the physical user id
* (shared, constant useless). `(none)` when no identity is set yet (startup). * (shared, constant useless). `(none)` when no identity is set yet (startup).
* Exported so every other polyfill-layer log site (store-registry, inbox, * Exported so every other polyfill-layer log site (store-registry, inbox,
* outbox-log, ) shares the exact same identity resolution as the access log, * outbox-log, ) shares the exact same identity resolution as the access log,
+232 -101
View File
@@ -1,117 +1,251 @@
/** /**
* Capability emulation generic, with no domain rules. It models NextGraph * Capability emulation key POSSESSION, not an authorization list.
* ReadCaps (and write caps) as a data layer can.
* *
* In NextGraph a ReadCap is possession of a document's (repo's) read key: the * In NextGraph a ReadCap **is** the document's read key: whoever holds it reads,
* broker only delivers documents the wallet holds a cap for. The access unit is * and there is no read-ACL anywhere. This module emulates that shape (see
* therefore the document = repo, identified here by its NURI the `@graph` an * `docs/briefs/2026-07-27-p1a-cap-surface.md`), which means it answers exactly one
* item lives in, rather than the item. (A store is just a container repo, and * question *do I hold this document's cap?* and cannot answer "may principal P
* holding a store's cap does not grant the repos it references each document * read document D", because the real model cannot either.
* carries its own cap so this registry is purely per-document, with no
* store-level inheritance.)
* *
* Sharing here is DIRECTED: a grant issues one grantee the read cap of one * Where caps come from and why this is NOT "a keyring"
* document (`grantRead(doc, granteeId)`). Whether two identities are "connected" * There is no keyring object in NextGraph, and calling this one invited a wrong
* and therefore whether such a grant should be issued is an application * mental model: that some single place holds every key. It does not. Upstream the
* concept the consumer owns; this layer only records the resulting per-document * caps of a user are in **two** places, by origin (see
* grants. At migration this whole layer disappears: the broker/verifier enforces * `docs/readcap-and-nuri-model.md` §4quater/§4quinquies):
* the real caps and `useShape` returns only authorized documents. *
* - documents the user CREATED `AddRepo { read_cap }` on the **Store branch**
* of the store they live in one such branch per store;
* - caps RECEIVED for someone else's documents `AddLink { read_cap }` on the
* **User branch** of the private store.
*
* The wallet itself holds exactly one key per user: the private store's read cap,
* from which everything else is reached. Hence the invariant:
*
* > You do not derive a cap from a bare reference. You look it up in what you
* > hold or you were given it.
*
* This class is the in-memory record of what the connected holder currently holds:
* upstream's local user storage, not a durable register. The durable ones are
* emulated in `store-registry.ts` for created documents, `holdOwnCap` writes and
* `readStoreCaps` reads the Store branch back; for received ones, `addLink` /
* `readLinks` on the User branch. `connect.ts` restores the Links at connection;
* the own-document caps come back through `listMyEntityDocs`.
*
* One record PER holder, since one shared wallet hosts every identity. Switching
* identity therefore SWITCHES records; it never wipes one (a wipe would make
* durability a lie and bring per-session re-declaration back under another name).
*
* Sharing
* Not here: the unit of sharing is the document and the recipient is an INBOX, so
* sharing is `inbox.shareCap(cap, toInbox)` a **Link** deposit and receiving is
* the recipient processing their inbox. Handing over a store's cap is NOT the
* gesture: it would give away everything that store contains, present and future.
*
* What this module does NOT do
* Enforce. The shape is right after P1a; the isolation is still fake. Per-document
* encryption and closing the read paths that bypass the guard (`docs.sparqlQuery`,
* the inbox, `store-registry`, `subscribe`, `open-repo`) are P1b. Nothing may be
* claimed "anonymous" or "private" until then. The write caps below are likewise
* decorative the guard they feed (`ng-proxy`) is bypassed by every internal
* writer; they are left as-is and belong to P1b.
*/ */
import type { Nuri, PrincipalId, Scope } from "./types"; import { hasReadCap, mintCap, targetOf } from "./nuri";
import type { Nuri, PrincipalId, ReadCap, Scope } from "./types";
/** The map key of the anonymous holder (no identity established yet). */
const ANONYMOUS = "";
/**
* Who holds the read/write cap of each document. The consumer populates it via
* cap operations (make-public, directed grant) exactly as it will in the
* target; this layer enforces possession generically, with no policy of its own.
*/
export class CapRegistry { export class CapRegistry {
/** doc NURI → principals holding its READ cap. */ /** holder → the caps they hold, indexed by the cap-less NURI. */
private readers = new Map<Nuri, Set<PrincipalId>>(); private heldByHolder = new Map<string, Map<Nuri, ReadCap>>();
/** doc NURI → principals holding its WRITE cap. */ /**
* Documents published as a shareable repo link (`RepoLinkV0`) the emulated
* public store. This is NOT a read grant: a published document is read by
* whoever HOLDS the link, exactly like §5 of the brief says ("whoever has the
* URL reads the content"), and holding it means having received it. The set
* exists so the library can refuse to surface a document its holder never
* published. *(This fed `discovery.submitToIndex`, removed 2026-07-30; the flag is kept because publishing is still what turns a document into a shareable link.)*
*/
private published = new Set<Nuri>();
/** doc NURI → principals holding its WRITE cap. Decorative until P1b. */
private writers = new Map<Nuri, Set<PrincipalId>>(); private writers = new Map<Nuri, Set<PrincipalId>>();
/** doc NURIs readable by everyone (public_store repos — no cap needed). */ /** Fired whenever a holder gains a cap a cap delivered asynchronously must
private publicDocs = new Set<Nuri>(); * re-trigger the reads that were empty for want of it. */
/** doc NURI its declared (scope, owner), as recorded at {@link open}. Lets private listeners = new Set<() => void>();
* the consumer re-derive which documents are `protected` and who owns them /** Has any cap been issued at all? Gates the whole emulation (see {@link isEnforcing}). */
* (see {@link protectedDocsOf}) so it can issue directed grants, without private issued = false;
* re-supplying that per-document it already declared it at open. */
private policy = new Map<Nuri, { scope: Scope; owner: PrincipalId }>();
/** Grant `grantee` the READ cap of document `doc` — a directed grant. */ /**
grantRead(doc: Nuri, grantee: PrincipalId): void { * @param holder resolves WHO is holding the current identity. Looked up through it on every
add(this.readers, doc, grantee); * call, so an identity switch switches records with nothing to reset. Defaults to the anonymous holder.
*/
constructor(private readonly holder: () => PrincipalId | null = () => null) {}
// --- what the holder holds ----------------------------------------------
/** What the current holder holds, created on first use. */
private heldCaps(): Map<Nuri, ReadCap> {
const key = this.holder() ?? ANONYMOUS;
let ring = this.heldByHolder.get(key);
if (!ring) this.heldByHolder.set(key, (ring = new Map()));
return ring;
} }
/**
* File `cap` among what the current holder holds the ONE door in, so
* the invariant is carried here rather than by each caller remembering it.
*
* A reference with no `:r:` is REFUSED. `Nuri` and `ReadCap` are both `string`
* (deliberately the real SDK takes `nuri: String`), so the compiler cannot
* catch a caller passing the naming form where the reading form is meant. Left
* unchecked, that mistake files a bare reference under its own name, `capFor`
* then returns it, and the document reads turning "naming is not reading" into
* "naming is reading", which is the exact inversion this batch exists to remove.
* The check is cheap and it is the only thing standing between the two.
*
* Returns whether the cap was new.
*/
private file(cap: ReadCap): boolean {
if (!hasReadCap(cap)) {
throw new Error(
"[ng-eventually] caps: expected a ReadCap (a NURI carrying `:r:`), got a bare " +
`reference — naming is not reading, and no cap derives from one: ${JSON.stringify(cap)}`,
);
}
const target = targetOf(cap);
const ring = this.heldCaps();
if (ring.get(target) === cap) return false;
ring.set(target, cap);
this.issued = true;
this.notify();
return true;
}
/**
* The cap of a document I just CREATED, filed among what I hold the emulated
* `AddRepo { read_cap }`. Idempotent. Returns the cap.
*/
mint(nuri: Nuri): ReadCap {
const cap = mintCap(nuri);
this.file(cap);
return cap;
}
/**
* File a cap I was GIVEN an inbox deposit of kind `cap`, or a repo link found
* in world-readable content. This is the ONLY way a cap arrives from
* outside: nothing turns a bare reference into a cap.
*
* @throws if `cap` carries no `:r:` see {@link file}. Passing a bare `Nuri`
* here is the one type confusion that would silently invert the model, and both
* forms are `string`, so it is rejected at runtime instead.
*/
learn(cap: ReadCap): void {
this.file(cap);
}
/**
* Do I hold the cap of `nuri`? Returns it, or `undefined` when I hold
* none which is the whole answer the model can give. Absorbs the former
* `canRead(doc, principal)`: there is no principal parameter, because there is
* no list to look a principal up in.
*/
capFor(nuri: Nuri): ReadCap | undefined {
return this.heldCaps().get(targetOf(nuri));
}
// --- publication (the public store) -------------------------------------
/**
* Publish `nuri` as a shareable repo link and return it the upstream
* `RepoLinkV0 { read_cap }`, which whoever receives it can open. The consumer
* puts this link (not the bare NURI) in what it makes discoverable.
*
* NOT recursive: the published document may REFERENCE private documents, and the
* reference grants nothing on what it references that non-recursiveness is
* what lets a public object point at a private identity without disclosing it.
*/
publishRepoLink(nuri: Nuri): ReadCap {
const target = targetOf(nuri);
this.published.add(target);
return this.mint(target);
}
/** Was `nuri` published as a repo link? (An emitter-side guard, not a right.) */
isPublished(nuri: Nuri): boolean {
return this.published.has(targetOf(nuri));
}
/**
* Record a document the current holder owns in `scope`: its cap lands in their
* what they hold, and a `public` one is additionally published as a repo link. Returns
* the cap (the shareable link when public). Idempotent the store-registry calls
* it both when creating a document and when listing the holder's own documents
* back, which is how a holder's caps are rebuilt on a fresh session.
*
* Deliberately does NOT touch write caps: those are decorative until P1b, and
* arming their guard here would be enforcement this batch does not do.
*/
open(nuri: Nuri, scope: Scope): ReadCap {
return scope === "public" ? this.publishRepoLink(nuri) : this.mint(nuri);
}
// --- enforcement gate ---------------------------------------------------
/**
* Is the cap emulation in force? False until the first cap is issued, so a
* consumer that never touches caps keeps reading everything (no regression).
* Once ANY cap exists the regime is possession for EVERY holder including one
* who holds nothing, which is exactly the isolation being emulated.
*/
isEnforcing(): boolean {
return this.issued;
}
// --- change signal ------------------------------------------------------
/**
* Subscribe to changes in what the holder holds. A cap that arrives asynchronously (an inbox
* deposit) must make the views that were empty for want of it re-read; without
* this signal they stay stale until an unrelated change happens to fire.
*/
onChange(listener: () => void): () => void {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
}
private notify(): void {
for (const l of this.listeners) {
try {
l();
} catch (error) {
console.error("[caps] change listener threw", error);
}
}
}
// --- write caps (decorative until P1b) ----------------------------------
/** Grant `principal` the WRITE cap of document `doc`. */ /** Grant `principal` the WRITE cap of document `doc`. */
grantWrite(doc: Nuri, principal: PrincipalId): void { grantWrite(doc: Nuri, principal: PrincipalId): void {
add(this.writers, doc, principal); const target = targetOf(doc);
} let s = this.writers.get(target);
if (!s) this.writers.set(target, (s = new Set()));
/** Mark `doc` public (readable without a cap — a public_store repo). */ s.add(principal);
makePublic(doc: Nuri): void {
this.publicDocs.add(doc);
}
/**
* Apply the caps a creator attaches to a fresh document, by scope. Public
* world-readable; protected/private only the owner reads. The owner always
* holds the write cap. Further sharing is a separate explicit grant.
*/
open(doc: Nuri, scope: Scope, owner: PrincipalId): void {
if (scope === "public") this.makePublic(doc);
else this.grantRead(doc, owner);
this.grantWrite(doc, owner);
this.policy.set(doc, { scope, owner });
}
/**
* The `protected` documents owned by `owner`, as recorded at {@link open}. The
* consumer uses this to issue directed read grants: it decides who may read an
* owner's protected documents (its own relationship concept) and calls
* {@link grantRead} on each of these documents for each such reader. Public
* documents are already world-readable and private documents stay owner-only,
* so only the protected ones are surfaced here.
*
* This mirrors a native cap operation: in the target, sharing a protected repo
* with another identity issues that identity the repo's ReadCap. Here the
* consumer selects the documents via this accessor and grants the emulated read
* cap on the same unit.
*/
protectedDocsOf(owner: PrincipalId): Nuri[] {
const out: Nuri[] = [];
for (const [doc, { scope, owner: o }] of this.policy) {
if (scope === "protected" && o === owner) out.push(doc);
}
return out;
}
/** Is `doc` under any READ-cap policy? (Undeclared docs are not enforced.) */
governsRead(doc: Nuri): boolean {
return this.publicDocs.has(doc) || this.readers.has(doc);
}
/** Does `principal` hold a READ cap for `doc` (or is `doc` public)? */
canRead(doc: Nuri, principal: PrincipalId | null): boolean {
if (this.publicDocs.has(doc)) return true;
if (principal === null) return false;
return this.readers.get(doc)?.has(principal) ?? false;
} }
/** Is `doc` under any WRITE-cap policy? */ /** Is `doc` under any WRITE-cap policy? */
governsWrite(doc: Nuri): boolean { governsWrite(doc: Nuri): boolean {
return this.writers.has(doc); return this.writers.has(targetOf(doc));
} }
/** Does `principal` hold a WRITE cap for `doc`? */ /** Does `principal` hold a WRITE cap for `doc`? */
canWrite(doc: Nuri, principal: PrincipalId | null): boolean { canWrite(doc: Nuri, principal: PrincipalId | null): boolean {
if (principal === null) return false; if (principal === null) return false;
return this.writers.get(doc)?.has(principal) ?? false; return this.writers.get(targetOf(doc))?.has(principal) ?? false;
}
/** No READ policy declared → the read filter stays inert (passthrough). */
hasReadPolicy(): boolean {
return this.readers.size > 0 || this.publicDocs.size > 0;
} }
/** No WRITE policy declared → the write guard stays inert (passthrough). */ /** No WRITE policy declared → the write guard stays inert (passthrough). */
@@ -119,16 +253,13 @@ export class CapRegistry {
return this.writers.size > 0; return this.writers.size > 0;
} }
/** Drop every holder's caps and every publication. Tests / a fresh wallet only
* NOT what an identity change does (that switches heldByHolder, see the header). */
clear(): void { clear(): void {
this.readers.clear(); this.heldByHolder.clear();
this.published.clear();
this.writers.clear(); this.writers.clear();
this.publicDocs.clear(); this.issued = false;
this.policy.clear(); this.notify();
} }
} }
function add(m: Map<Nuri, Set<PrincipalId>>, doc: Nuri, principal: PrincipalId): void {
let s = m.get(doc);
if (!s) m.set(doc, (s = new Set()));
s.add(principal);
}
+93
View File
@@ -0,0 +1,93 @@
/**
* connect what the polyfill does when the app connects a virtual user.
*
* Processing inboxes is the LIBRARY's job, not the app's
* Stated by the PO, 2026-07-30. A consumer must not have to remember to drain its
* inbox for documents shared with it to become readable; forgetting would look
* like "the share did not work" rather than "nobody processed the queue". So the
* moment an identity is connected ({@link setCurrentUser}), this runs.
*
* Two steps, in order, and the order matters:
*
* 1. **Restore** read the Links already applied (`storeRegistry.readLinks`, the
* emulated `AddLink` records on the User branch of the private store) back into
* what this user holds. This is durable state; it costs one read and needs no inbox.
* 2. **Process** drain the user's inbox (`inbox.processInbox`), which files any
* new Link durably and puts it among what the user holds.
*
* Restoring first means a reconnecting user can read its shared documents
* immediately, without waiting on the inbox round-trip.
*
* Fire-and-forget, on purpose
* `setCurrentUser` is synchronous and every consumer calls it from synchronous
* code. Making it async would push the wait onto the app, which is exactly the
* obligation this removes. So the work runs in the background and announces itself
* through the registry's change signal (`CapRegistry.onChange`), which is what
* `watchShape` already listens to a view that was empty for want of a cap
* re-reads when the cap lands. {@link connectedUser} is there for a caller that
* genuinely needs to await it (tests, an app that wants a deterministic start).
*
* Every inbox, at both levels
* The user's own inbox AND the inbox of every document it opened one on. Upstream
* both are answered by the same place `AddInboxCap` records on the User branch
* (`engine/repo/src/types.rs:1969`) so `storeRegistry.myInboxes()` enumerates
* them and this drains each in turn.
*/
import { getCaps, getCurrentUser } from "./polyfill";
import { myInboxes, readLinks, resolveAccount } from "./store-registry";
import { processInbox } from "./inbox";
/** The in-flight connection work, per user key — so two calls do not race. */
const inFlight = new Map<string, Promise<void>>();
/**
* Restore and drain for the connected user. Idempotent per user while in flight.
*
* Tolerant by construction: it runs on every `setCurrentUser`, including in
* contexts where the store registry was never configured (unit tests, an app
* setting the identity before the session resolves). Those simply have nothing to
* restore, and a failure here must never break connecting.
*/
export async function connectedUser(): Promise<void> {
const holder = getCurrentUser();
if (holder === null) return;
const pending = inFlight.get(holder);
if (pending) return pending;
const run = (async (): Promise<void> => {
try {
// Connecting must not PROVISION. `ensureAccount` would create the user on
// first sight, so connecting an identity that does not exist yet would
// silently mint its stores and their caps — arming the whole emulation as a
// background side effect, at a moment nothing controls. An account that does
// not exist has nothing to restore and no inbox to drain.
if ((await resolveAccount(holder)) === null) return;
// 1. Durable first: what this user has already applied.
for (const cap of await readLinks()) getCaps().learn(cap);
// 2. Then the queues: ALL of them — the user's own inbox, plus one per
// document it opened an inbox on. Both levels, as the PO specified, and
// both are answered by the same User-branch record (`AddInboxCap`).
// Sequential rather than parallel: each `processInbox` writes what it
// applies to the SAME private store, and interleaving those writes buys
// nothing on a queue that is nearly always empty.
for (const inbox of await myInboxes()) await processInbox(inbox);
} catch {
// Not configured yet, or offline. Nothing to restore, and connecting must
// not fail because a queue could not be reached — the next connection, or
// an explicit `connectedUser()`, picks it up.
}
})();
inFlight.set(holder, run);
try {
await run;
} finally {
inFlight.delete(holder);
}
}
/** Fire the connection work without awaiting it. Called by `setCurrentUser`. */
export function startConnect(): void {
void connectedUser();
}
-236
View File
@@ -1,236 +0,0 @@
/**
* discovery a GENERIC discovery-index surface, reusing the ONE deposit +
* materialization mechanism (`inbox.ts`). GENERIC by construction: this module
* knows no application domain (no event, no meeting-point). The consumer submits
* an opaque reference and interprets the entries it reads back.
*
* The mechanism (see docs/decisions/discovery-model.md)
* Access and discovery are separate concerns. A public entity is world-readable
* with its NURI; the discovery index is how a client learns that NURI exists
* without holding a grant to read its creator's other documents. There is one
* global index an owned document (public read), fed via its own inbox. A
* creator deposits a reference into the index's inbox; reading the index folds
* those deposits into entries, deduplicating identical references along the way.
*
* The special account (polyfill owner)
* Ownership of a truly global index is undecided in the real platform, where an
* identity's apps and services see only what that identity shares. The polyfill
* therefore parks ownership on a reserved special account in the shim
* ({@link INDEX_ACCOUNT}). Its `public` scope document is the index document;
* deposits land in that document's inbox (a stable NURI: every client opening the
* same shared wallet resolves the same account, so the same document). This is
* the app-facing discovery path, in place of a cross-account fan-out
* (`store-registry.ts` `listEntityDocs`), which survives only as an internal
* fallback (see {@link readIndex}).
*
* Real target vs this emulation
* The intended real shape is: `submitToIndex` seals a reference into the index
* document's own inbox (a future `inbox_post_link`), and reading the index is a
* query on the materialized index document. Here, everything runs in-lib on the
* shared wallet (deposit via `inbox.post`, fold via `inbox.read`). Against real
* NextGraph the special account gives way to the decided global-index owner and
* `readIndex` points at that document; the consumer surface (`submitToIndex` /
* `readIndex`) is designed to survive that change unchanged.
*
* All NextGraph I/O routes through `inbox.ts` (which routes through the `docs`
* primitives, the real injected `ng`), so this module imports no `@ng-org`
* package.
*/
import * as inbox from "./inbox";
import { subscribeDoc } from "./subscribe";
import { ensureRepoOpen } from "./open-repo";
import { ensureAccount, reservedAccount } from "./store-registry";
import { getCaps } from "./polyfill";
import type { Nuri, PrincipalId } from "./types";
/**
* The reserved special account that owns the global discovery index in the
* polyfill. It hosts the index document but is never a real identity. It lives in
* the registry's reserved namespace ({@link reservedAccount}), whose key
* `normalizeId` can never produce, so an id of "index"/"@index" cannot hijack it
* (it normalizes to "index", a disjoint key). Removed against real NextGraph
* (see file header).
*/
export const INDEX_ACCOUNT = reservedAccount("index");
/** One entry as materialized from the discovery index. */
export interface IndexEntry {
/** The reference submitted by a creator (opaque — the consumer interprets it). */
ref: unknown;
/** The submitter, if identified; `null` when the submission was anonymous. */
from: PrincipalId | null;
/** Submission timestamp (ms epoch). */
ts: number;
}
/** Options for {@link submitToIndex}. */
export interface SubmitOptions {
/**
* Who is submitting. Omit for the current identity, or pass `null` for an
* anonymous submission. `from` is bound to the current identity by the inbox
* (naming another identity is rejected as a spoof see {@link inbox.post}).
*/
from?: PrincipalId | null;
/**
* The NURI of the document being made discoverable. When given, the index
* admits only a public document: one under a non-public (protected/private)
* read policy is refused, so the world-readable index never exposes a governed
* document's NURI. Omit it only for a ref with no addressable document (rare);
* a governed document passes it so the guard can fire.
*/
doc?: Nuri;
/** Optional deposit timestamp (ms epoch). Omitted `Date.now()`. Passing it
* keeps tests deterministic. */
ts?: number;
}
/**
* Resolve the NURI of the index document the stable inbox where discovery
* submissions land. The special account owns this document (its `public` scope
* document, a real repo NURI from `docCreate`); deposits go into that document's
* inbox exactly as host-registration deposits go into a host inbox. Because the
* special account lives in the shim (persisted in the shared wallet's private
* store), EVERY client opening the same wallet resolves the same account the
* same document NURI ONE shared index for all clients. Distinct from
* host-registration inboxes because it is a distinct document NURI.
*/
async function indexInboxNuri(): Promise<Nuri> {
// Ensure the special account exists (idempotent) so its scope documents are
// created and stably resolvable across clients.
const record = await ensureAccount(INDEX_ACCOUNT);
return record.docPublic;
}
/**
* The NURI of the global discovery-index document (the inbox where submissions
* land). Exposed so a reactive reader ({@link watchShape}) that folds discovery
* into the public read-set can SUBSCRIBE to this document and re-resolve when a
* new public entity is announced. This is exactly {@link watchIndex}'s subscribe
* anchor. Removed against real NextGraph along with the special account.
*/
export async function indexDocNuri(): Promise<Nuri> {
return indexInboxNuri();
}
/**
* Submit a reference to the global discovery index the SDK act "make this
* discoverable". Deposits `ref` into the index document's inbox via
* {@link inbox.post}; reading the index ({@link readIndex}) folds it into an
* entry. `ref` is opaque here (the consumer serializes whatever a client needs to
* later locate the entity e.g. an entity document NURI plus discovery metadata).
* `from` follows the inbox convention (anonymous when `null`).
*
* When `opts.doc` names the document being surfaced, a document under a
* non-public read policy (protected/private) is refused: the global index is
* world-readable, so admitting a governed document's NURI would expose it past
* its scope.
*/
export async function submitToIndex(ref: unknown, opts?: SubmitOptions): Promise<void> {
const doc = opts?.doc;
if (doc !== undefined) {
const caps = getCaps();
// A governed doc is submittable ONLY if it is public (anonymous may read it).
if (caps.governsRead(doc) && !caps.canRead(doc, null)) {
throw new Error(
"[ng-eventually] submitToIndex: only PUBLIC documents may be submitted to " +
"the discovery index — a protected/private document must not be surfaced.",
);
}
}
const target = await indexInboxNuri();
await inbox.post(target, {
payload: ref,
...(opts && "from" in opts ? { from: opts.from } : {}),
...(opts?.ts !== undefined ? { ts: opts.ts } : {}),
});
}
/**
* Read the global discovery index. Reads every submission from the index inbox,
* deduplicates by serialized `ref` (a duplicate submission surfaces once the
* discovery model's moderation point), and returns the entries sorted by `ts`
* ascending. Against real NextGraph this becomes a query on the materialized
* index document.
*/
export async function readIndex(): Promise<IndexEntry[]> {
const target = await indexInboxNuri();
// COLD-START heal (polyfill-era): on a FRESH session over a persistent wallet the
// discovery-index inbox repo is not yet in the verifier's `self.repos`, so the
// anchored `inbox.read` below would resolve an unopened repo and silently return 0
// deposits — the same self-inflicted cold-read gap `readScopeIndex`/`readUnion`
// heal. This is what made the PUBLIC read's discovery fold come back empty on a
// reconnect, so a fresh page's home stayed empty for tens of seconds while the doc
// slowly synced by other means. Open/subscribe the index repo ONCE and await its
// first `State` (the sync barrier) before the anchored read. Idempotent per session;
// no-op with the unit fake ng (no `doc_subscribe`). This is done HERE (a cold direct
// reader) rather than inside `inbox.read`, because `inbox.watch` already holds the
// repo open via its own subscription and must not spawn a second bootstrap open. See
// open-repo.ts.
await ensureRepoOpen(target);
const deposits = await inbox.read(target);
const seen = new Set<string>();
const entries: IndexEntry[] = [];
for (const d of deposits) {
// Dedup on the serialized reference — the materialization moderation point.
const key = JSON.stringify(d.payload ?? null);
if (seen.has(key)) continue;
seen.add(key);
entries.push({ ref: d.payload, from: d.from, ts: d.ts });
}
return entries;
}
/**
* Watch the discovery index **event-driven, not polled**. Subscribes to the
* index document via {@link subscribeDoc} (the platform's `doc_subscribe` push):
* `onEntries` fires once on the initial state push and again on every subsequent
* change to the index document a local submission OR a broker-synced remote one.
* Returns an unsubscribe. (Deduplication is applied on each read.)
*
* The `intervalMs` option is accepted for signature compatibility but IGNORED:
* there is no polling. The index is a single document, so this is immune to the
* ORM fan-out hang (see {@link subscribeDoc}).
*/
export function watchIndex(
onEntries: (entries: IndexEntry[]) => void,
_opts?: { intervalMs?: number },
): () => void {
let stopped = false;
let lastCount = -1;
let unsubscribe: (() => void) | null = null;
const refresh = async (): Promise<void> => {
if (stopped) return;
try {
const entries = await readIndex();
if (!stopped && entries.length !== lastCount) {
lastCount = entries.length;
onEntries(entries);
}
} catch (error) {
console.error("[discovery] watchIndex read failed:", error);
}
};
// The index document NURI is resolved async (ensureAccount); subscribe once it
// is known. The initial State push fires the first read (onEntries fires once),
// each later Patch a re-read.
void (async () => {
try {
const anchor = await indexInboxNuri();
if (stopped) return;
unsubscribe = subscribeDoc(anchor, () => void refresh());
} catch (error) {
console.error("[discovery] watchIndex subscribe failed:", error);
}
})();
return () => {
stopped = true;
if (unsubscribe) {
unsubscribe();
unsubscribe = null;
}
};
}
+53 -1
View File
@@ -13,8 +13,10 @@
* app's storeRegistry usage), so this is a drop-in for those raw calls. * app's storeRegistry usage), so this is a drop-in for those raw calls.
*/ */
import { getConfig } from "./polyfill"; import { getCaps, getConfig } from "./polyfill";
import { logAccess, enabled as accessLogEnabled } from "./access-log"; import { logAccess, enabled as accessLogEnabled } from "./access-log";
import { isNuri } from "./nuri";
import { assertMayReach } from "./reach";
import type { Nuri } from "./types"; import type { Nuri } from "./types";
// The low common point for ALL document access: every read in the SDK routes // The low common point for ALL document access: every read in the SDK routes
@@ -50,6 +52,25 @@ export async function docCreate(
): Promise<Nuri> { ): Promise<Nuri> {
const { ng } = getConfig(); const { ng } = getConfig();
const nuri = await ng.doc_create(sessionId, crdt, cls, dest, store); const nuri = await ng.doc_create(sessionId, crdt, cls, dest, store);
// The BROKER boundary. `ng` is a permissive property bag (`NgLike`), so what
// comes back is `any` and this function's `Promise<Nuri>` would otherwise be an
// unchecked promise — every typed NURI downstream rests on it. Validate once,
// here, rather than let a non-reference propagate as a document.
if (typeof nuri !== "string" || !isNuri(nuri)) {
throw new Error(
`[ng-eventually] docCreate: the broker returned something that is not a NextGraph reference: ${JSON.stringify(nuri)}`,
);
}
// **Creating a document gives you its cap.** Upstream that is not a courtesy but
// the mechanism: `doc_create` commits `AddRepo { read_cap }` to the store's Store
// branch, so the creator holds it from the first instant. Without this, a caller
// could create a document through this primitive and then be refused reading or
// writing it — which is what the e2e run against the live broker exposed.
//
// `physical.ts`'s counterpart deliberately does NOT do this: the shim's own
// documents belong to no virtual user, and `store-registry` files their caps
// itself, where it knows whose they are.
getCaps().mint(nuri);
// A container creation is a WRITE; the NURI only exists after the call. // A container creation is a WRITE; the NURI only exists after the call.
logAccess("WRITE", nuri, "docCreate"); logAccess("WRITE", nuri, "docCreate");
return nuri; return nuri;
@@ -68,11 +89,38 @@ export async function sparqlUpdate(
label = "sparqlUpdate", label = "sparqlUpdate",
): Promise<void> { ): Promise<void> {
const { ng } = getConfig(); const { ng } = getConfig();
// The boundary: a write may only touch what the connected virtual user reaches.
if (anchor !== undefined) assertMayReach(anchor, "docs.sparqlUpdate");
// `label` is a lib-internal access-log tag, NOT forwarded to `ng`. // `label` is a lib-internal access-log tag, NOT forwarded to `ng`.
logAccess("WRITE", anchor ?? "(no anchor)", label); logAccess("WRITE", anchor ?? "(no anchor)", label);
return ng.sparql_update(sessionId, query, anchor); return ng.sparql_update(sessionId, query, anchor);
} }
/**
* Deposit into ANOTHER virtual user's inbox the one write that legitimately
* crosses the boundary, and therefore the one that skips {@link assertMayReach}.
*
* Why this is a separate primitive rather than a flag: depositing is not "a write
* that happens to be allowed", it is a different act. You cannot read the inbox you
* deposit into, you hold no cap for it, and you get nothing back upstream it is an
* anonymous sealed box. Naming the exception makes it greppable and keeps
* {@link sparqlUpdate} free of a bypass that would otherwise be reusable for
* anything.
*
* The recipient's ownership of the inbox is what bounds this: `inbox.post` is the
* only caller, and reading is guarded separately (`inbox.read`).
*/
export async function depositInto(
sessionId: string,
query: string,
targetInbox: Nuri,
label = "deposit",
): Promise<void> {
const { ng } = getConfig();
logAccess("WRITE", targetInbox, label, " (cross-user deposit)");
return ng.sparql_update(sessionId, query, targetInbox);
}
/** /**
* Run a SPARQL SELECT/CONSTRUCT/ASK query the raw SDK result. * Run a SPARQL SELECT/CONSTRUCT/ASK query the raw SDK result.
* *
@@ -87,6 +135,10 @@ export async function sparqlQuery(
label = "sparqlQuery", label = "sparqlQuery",
): Promise<unknown> { ): Promise<unknown> {
const { ng } = getConfig(); const { ng } = getConfig();
// The boundary: an ANCHORED read may only touch what the connected virtual user
// reaches. An anchorless query spans the local union — a different problem (it is
// O(wallet size), and the read path never uses it), not one this guard can bound.
if (anchor !== undefined) assertMayReach(anchor, "docs.sparqlQuery");
// `label` is a lib-internal access-log tag, NOT forwarded to `ng`. // `label` is a lib-internal access-log tag, NOT forwarded to `ng`.
const result = await ng.sparql_query(sessionId, query, base, anchor); const result = await ng.sparql_query(sessionId, query, base, anchor);
// Log AFTER the read so the row count (a strong leak signal: a doc rendering // Log AFTER the read so the row count (a strong leak signal: a doc rendering
+201 -12
View File
@@ -10,25 +10,29 @@
* 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.
*/ */
import { sparqlUpdate, sparqlQuery } from "./docs"; import { depositInto, sparqlQuery } from "./docs";
import { subscribeDoc } from "./subscribe"; import { subscribeDoc } from "./subscribe";
import { ensureRepoOpen } from "./open-repo"; import { ensureRepoOpen } from "./open-repo";
import { getCurrentUser, getStoreRegistryDeps } from "./polyfill"; import { getCaps, getCurrentUser, getStoreRegistryDeps } from "./polyfill";
import { addLink, documentInboxAddress, isOwnInbox } from "./store-registry";
import { escapeLiteral } from "./sparql"; import { escapeLiteral } from "./sparql";
import { hasReadCap } from "./nuri";
import { import {
accessLogPrefix, accessLogPrefix,
enabled as accessLogEnabled, enabled as accessLogEnabled,
@@ -36,7 +40,7 @@ import {
logStage, logStage,
shortNuri, shortNuri,
} from "./access-log"; } from "./access-log";
import type { Nuri, PrincipalId } from "./types"; import type { Nuri, PrincipalId, ReadCap } from "./types";
// --- deposit model -------------------------------------------------------- // --- deposit model --------------------------------------------------------
@@ -172,7 +176,8 @@ export async function post(targetInbox: Nuri, opts: PostOptions): Promise<void>
<${P.payload}> "${payloadLiteral}" ; <${P.payload}> "${payloadLiteral}" ;
<${P.ts}> "${ts}"${fromTriple} . <${P.ts}> "${ts}"${fromTriple} .
}`; }`;
await sparqlUpdate(sid, update, targetInbox, "deposit"); // A deposit crosses the boundary on purpose — see docs.depositInto.
await depositInto(sid, update, targetInbox, "deposit");
// Domain-level diagnostic (on top of docs.ts's generic access-path WRITE log): // Domain-level diagnostic (on top of docs.ts's generic access-path WRITE log):
// who deposited WHAT into which inbox — the decoded payload, not just the // who deposited WHAT into which inbox — the decoded payload, not just the
// triple-write. Gated by the same access-log flag; skip the JSON work when off. // triple-write. Gated by the same access-log flag; skip the JSON work when off.
@@ -186,6 +191,136 @@ 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 ---------------------------------------------------------
/**
* A **Link** the deposit that carries a ReadCap. The word is upstream's, and it
* is the same one at all three stages: `InboxMsgContent::Link` is the message
* (`engine/net/src/types.rs:4249-4261`, declared but payload-less so far),
* `AddLink { read_cap }` is where the recipient files it (`repo/types.rs:1934-1950`),
* `RemoveLink` withdraws it. So giving access is: deposit a Link, and on connection
* the recipient processes their inbox and files it.
*
* It travels the SAME channel as any other deposit, which is why key ROTATION needs
* no special case on the surface a re-delivered cap is just another Link.
*/
const LINK_KIND = "urn:ng-eventually:inbox:link";
/** Links observed during the last read of an inbox, awaiting durable filing. */
const seenByInbox = new Map<Nuri, ReadCap[]>();
function capsSeenIn(inbox: Nuri): ReadCap[] {
return seenByInbox.get(inbox) ?? [];
}
/** The cap a deposit carries, if it is a Link rather than consumer data. */
function capOfPayload(payload: unknown): ReadCap | null {
const p = payload as { kind?: unknown; cap?: unknown } | null;
if (!p || typeof p !== "object" || p.kind !== LINK_KIND) return null;
return typeof p.cap === "string" && hasReadCap(p.cap) ? p.cap : null;
}
/**
* Share ONE document's read cap with ONE recipient, addressed by their inbox.
*
* The unit of sharing is the DOCUMENT: never hand over a store's cap, which would
* give away everything the store contains, present and future. The recipient needs
* no dedicated operation to receive it the cap arrives as a deposit that their
* existing {@link watch} absorbs into what they hold (see {@link read}).
*
* Reaching several recipients means calling this once per inbox, which is what the
* real model does too: each delivery is sealed to one recipient.
*
* Upstream this path is a GAP, not a disagreement verified at both ends:
* - the field exists, `ContactDetails.read_cap: Option<ReadCap>`
* (`engine/net/src/types.rs:4233`), but building a message that carries one is
* `read_cap: if with_readcap { unimplemented!() }` (`types.rs:3786`);
* - and the receiver ignores it: `InboxMsgContent::ContactDetails` writes only
* `ng:site`/`ng:protected` + `ng:*_inbox` into a fresh contact document
* (`engine/verifier/src/inbox_processor.rs:778-830`), never `details.read_cap`.
*
* Do NOT read `InboxMsgContent::Link` as the intended channel either: it is a **unit
* variant carrying nothing** (`engine/net/src/types.rs:4251`).
*
* The shape is right; the implementation is absent at both ends, so we emulate it
* meanwhile.
*/
export async function shareCap(cap: ReadCap, toInbox: Nuri): Promise<void> {
if (!hasReadCap(cap)) {
throw new Error(
"[ng-eventually] inbox.shareCap: expected a ReadCap (a NURI carrying `:r:`), " +
`got a bare reference — naming is not reading: ${JSON.stringify(cap)}`,
);
}
await post(toInbox, { payload: { kind: LINK_KIND, cap } });
}
// --- the read guard ------------------------------------------------------
/**
* Refuse to READ an inbox that is not the current wallet's.
*
* Depositing into someone else's inbox is the one legitimate cross-wallet act (it
* is how a link reaches another wallet at all see {@link post} / {@link shareCap});
* READING one is not, and it is not symmetric with it. Since caps travel as
* deposits, an unguarded read let anyone who knew an inbox NURI collect the caps
* addressed to its owner, which defeats directed sharing entirely.
*
* Anonymous owns no inbox, so it can read none an identity has to be established
* first. At migration this disappears: an inbox is sealed to its owner's key, and
* the guard is the cryptography.
*/
async function assertOwnInbox(targetInbox: Nuri, op: string): Promise<void> {
if (getCurrentUser() === null) {
throw new Error(
`[ng-eventually] inbox.${op}: no identity is set, so no inbox belongs to this ` +
"session — call setCurrentUser() first. Depositing (post/shareCap) stays open.",
);
}
if (!(await isOwnInbox(targetInbox))) {
throw new Error(
`[ng-eventually] inbox.${op}: refusing to read an inbox that does not belong to ` +
"the connected wallet. You may DEPOSIT into anyone's inbox; you may only READ " +
"your own — otherwise the caps addressed to its owner would be collectable by " +
`whoever knows its NURI: ${JSON.stringify(targetInbox)}`,
);
}
}
// --- read -------------------------------------------------------------- // --- read --------------------------------------------------------------
/** /**
@@ -194,11 +329,18 @@ export async function post(targetInbox: Nuri, opts: PostOptions): Promise<void>
* it processes the inbox; here this read stands in for that until the * it processes the inbox; here this read stands in for that until the
* sealed-inbox path is available. The consumer interprets each deposit's * sealed-inbox path is available. The consumer interprets each deposit's
* `payload`. * `payload`.
*
* Cap deliveries ({@link shareCap}) are applied inline and NOT returned: they land
* in what the current holder holds, like the verifier applying a queued message.
* That is why receiving a cap needs no dedicated operation a consumer already
* watching its inbox gets them, and the resulting change re-triggers the
* reads that were empty for want of that cap.
*/ */
export async function read(targetInbox: Nuri): Promise<Deposit[]> { export async function read(targetInbox: Nuri): Promise<Deposit[]> {
await assertOwnInbox(targetInbox, "read");
const sid = await sessionId(); const sid = await sessionId();
// NOTE: cold-start repo opening is done by the COLD DIRECT readers that need it // NOTE: cold-start repo opening is done by the COLD DIRECT readers that need it
// (e.g. `discovery.readIndex` → `ensureInboxRepoOpen`), NOT here — `inbox.watch` // (a cold reader that opens the repo before reading), NOT here — `inbox.watch`
// already holds the repo open via its own `subscribeDoc`, so opening a second // already holds the repo open via its own `subscribeDoc`, so opening a second
// bootstrap subscription from inside a watch's re-read would be redundant and can // bootstrap subscription from inside a watch's re-read would be redundant and can
// race the watch's own initial-`State` delivery. Keeping `read` a pure anchored // race the watch's own initial-`State` delivery. Keeping `read` a pure anchored
@@ -230,6 +372,23 @@ export async function read(targetInbox: Nuri): Promise<Deposit[]> {
deposits.push({ from: fromValue ? fromValue : null, payload, ts }); deposits.push({ from: fromValue ? fromValue : null, payload, ts });
} }
deposits.sort((a, b) => a.ts - b.ts); deposits.sort((a, b) => a.ts - b.ts);
// Links are infrastructure, not consumer data: they never reach the caller. They
// are only KEPT here (in memory, for this session) — FILING them durably is
// `processInbox`'s job, because reading an inbox must not quietly write to a
// user's store. Filing fires the registry's change signal, which is what makes a
// view that was empty for want of that cap re-read instead of staying stale.
const delivered: Deposit[] = [];
const links: ReadCap[] = [];
for (const d of deposits) {
const cap = capOfPayload(d.payload);
if (cap) {
getCaps().learn(cap);
links.push(cap);
continue;
}
delivered.push(d);
}
if (links.length > 0) seenByInbox.set(targetInbox, links);
// Domain-level diagnostic (on top of docs.ts's generic access-path READ log // Domain-level diagnostic (on top of docs.ts's generic access-path READ log
// of raw triple-rows): how many DEPOSITS were found, and the decoded data of // of raw triple-rows): how many DEPOSITS were found, and the decoded data of
// each — the exact visibility needed to trace materialization at the owner // each — the exact visibility needed to trace materialization at the owner
@@ -239,9 +398,12 @@ export async function read(targetInbox: Nuri): Promise<Deposit[]> {
"READ", "READ",
targetInbox, targetInbox,
"inbox materialize", "inbox materialize",
" → " + deposits.length + " message(s)", " → " + delivered.length + " message(s)" +
(deposits.length !== delivered.length
? " (+" + (deposits.length - delivered.length) + " cap deliver(y/ies) absorbed)"
: ""),
); );
for (const d of deposits) { for (const d of delivered) {
logAccess( logAccess(
"READ", "READ",
targetInbox, targetInbox,
@@ -250,7 +412,7 @@ export async function read(targetInbox: Nuri): Promise<Deposit[]> {
); );
} }
} }
return deposits; return delivered;
} }
/** Alias for {@link read} — the name that reads as "process the inbox now". */ /** Alias for {@link read} — the name that reads as "process the inbox now". */
@@ -267,7 +429,7 @@ export const materialize = read;
* `self.repos`, so a plain anchored `read` resolves an unopened repo and silently * `self.repos`, so a plain anchored `read` resolves an unopened repo and silently
* returns 0 deposits even for a deposit a remote session already synced to the * returns 0 deposits even for a deposit a remote session already synced to the
* broker. Gating on the sync barrier makes the read see the synced deposits. This * broker. Gating on the sync barrier makes the read see the synced deposits. This
* is the SAME cold-read heal `discovery.readIndex` applies to the index inbox. * is the same cold-read heal any cold direct reader needs.
* *
* NOT for the `watch` path: {@link watch} already holds the repo open via its own * NOT for the `watch` path: {@link watch} already holds the repo open via its own
* `subscribeDoc`, so opening a second bootstrap subscription from inside a watch * `subscribeDoc`, so opening a second bootstrap subscription from inside a watch
@@ -282,10 +444,35 @@ export async function readSynced(targetInbox: Nuri): Promise<Deposit[]> {
// (from the read() this wraps) follow right after, so a live session shows // (from the read() this wraps) follow right after, so a live session shows
// the whole owner-reconnect sequence together. // the whole owner-reconnect sequence together.
logStage("READSYNCED " + shortNuri(targetInbox) + " (cold, barrier-gated)"); logStage("READSYNCED " + shortNuri(targetInbox) + " (cold, barrier-gated)");
await assertOwnInbox(targetInbox, "readSynced");
await ensureRepoOpen(targetInbox); await ensureRepoOpen(targetInbox);
return read(targetInbox); return read(targetInbox);
} }
/**
* PROCESS an inbox: read it, and **apply** what it contains.
*
* Applying a {@link shareCap} Link means filing it durably `storeRegistry.addLink`,
* the emulated `AddLink { read_cap }` on the User branch of the private store so
* the cap survives the session. Upstream this is what a verifier does when it
* processes queued messages: an inbox is a **queue you consume**, not a store you
* re-read. Re-reading an inbox every session to recover caps is using a queue as a
* database, and it is the thing this replaces.
*
* Idempotent: `addLink` ignores a Link it already holds, so processing twice (a
* second tab, a reconnect) costs nothing. Returns the consumer deposits, exactly as
* {@link read} does Links are never surfaced.
*/
export async function processInbox(targetInbox: Nuri): Promise<Deposit[]> {
const deposits = await readSynced(targetInbox);
// `readSynced` already put every Link in memory for this session; now make
// them durable. Reading the raw deposits again would mean re-parsing, so the caps
// are taken from what the read just observed.
for (const cap of capsSeenIn(targetInbox)) await addLink(cap);
seenByInbox.delete(targetInbox);
return deposits;
}
/** /**
* Subscription over an inbox **event-driven, not polled**. Subscribes to the * Subscription over an inbox **event-driven, not polled**. Subscribes to the
* inbox document via {@link subscribeDoc} (the platform's `doc_subscribe` push): * inbox document via {@link subscribeDoc} (the platform's `doc_subscribe` push):
@@ -341,6 +528,8 @@ export function watch(
// Subscribe to the inbox document: the initial State push fires the first read // Subscribe to the inbox document: the initial State push fires the first read
// (so onDeposits fires once immediately, as before), each later Patch a re-read. // (so onDeposits fires once immediately, as before), each later Patch a re-read.
// The ownership guard runs inside `read`, so a watch on someone else's inbox
// yields nothing but logged refusals rather than their deposits.
const unsubscribe = subscribeDoc(targetInbox, () => void refresh()); const unsubscribe = subscribeDoc(targetInbox, () => void refresh());
return () => { return () => {
stopped = true; stopped = true;
+27 -14
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";
@@ -17,17 +26,12 @@ export { watchShape } from "./watch-shape";
export type { ShapeQuery, ShapeObservable } from "./watch-shape"; export type { ShapeQuery, ShapeObservable } from "./watch-shape";
export { init, initNg } from "./lifecycle"; export { init, initNg } from "./lifecycle";
export * as inbox from "./inbox"; export * as inbox from "./inbox";
export * as discovery from "./discovery";
export type { IndexEntry, SubmitOptions } from "./discovery";
export * as docs from "./docs"; export * as docs from "./docs";
export { subscribeDoc, subscribeDocs, docChangeType } from "./subscribe"; 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
@@ -35,6 +39,15 @@ export type { AccountStorage } from "./accounts";
// validate trusted-shaped NURIs before embedding them in an IRI. // validate trusted-shaped NURIs before embedding them in an IRI.
export { escapeLiteral, escapeIri, assertNuri } from "./sparql"; export { escapeLiteral, escapeIri, assertNuri } from "./sparql";
// NURI type guards — the doors through which an app's own `string` (read back
// from storage, a URL, JSON, a form) becomes a typed `Nuri` or `ReadCap`. `Nuri`
// and `ReadCap` are template literal types, so an app that narrows with these
// gets the same compile-time distinction the library uses internally — in
// particular, it cannot pass a bare reference where a cap is required. Narrow
// with these rather than casting: a cast re-opens exactly the confusion the
// types exist to close.
export { isNuri, hasReadCap } from "./nuri";
// SDK type re-exports — so the app imports these from @ng-eventually/client too, // SDK type re-exports — so the app imports these from @ng-eventually/client too,
// not from @ng-org. `export type` is ERASED at build, so this adds NO runtime // not from @ng-org. `export type` is ERASED at build, so this adds NO runtime
// @ng-org import to the lib (no risk of a duplicate SDK copy in the bundle). // @ng-org import to the lib (no risk of a duplicate SDK copy in the bundle).
+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.
+118
View File
@@ -0,0 +1,118 @@
/**
* NURI primitives the cap-less / cap-bearing distinction, kept as ONE object.
*
* Upstream a NURI is a single type, `NuriV0 { target, access }`: a cap-less NURI
* simply has an empty `access`. `did:ng:` is the URI SCHEME prefix (inboxes,
* branches and overlays all carry it) it does NOT mean "without cap". The
* discriminant is the `:r:` segment:
*
* did:ng:o:{doc}:v:{overlay} names, does NOT read (a {@link Nuri})
* did:ng:o:{doc}:v:{overlay}:r:{cap} names AND reads (a {@link ReadCap})
*
* Why `:r:` and not `:k:`
* Reported by NextGraph's developer and verified in the source: a **ReadCap** is
* `r:{base64url(serde_bare(ObjectRef))}` `BlockRef::readcap_nuri()`,
* `engine/repo/src/types.rs:518-521` where id AND key are serialized together
* into ONE opaque segment. The `:k:` forms are a different thing: they belong to
* **objects, files and commits** (`j:{id}:k:{key}`, `c:{id}:k:{key}`, `:510`/`:514`),
* where id and key are two separate segments. This library used `:k:` until
* 2026-07-30; it was the wrong letter *and* the wrong structure.
*
* These helpers are INTERNAL to the library. The parsed form {@link parseNuri}
* mirrors `NuriV0 { target, access }` 1:1 but never surfaces in the SDK-identical
* entry's signatures the real SDK takes plain `String`s and enforces at runtime,
* through cryptography, so no branded type and no parsed struct leaks outward.
*
* The stand-in key (deliberately NOT a secret)
* This library is deliberately insecure (see docs/vision.md). The only question it
* can answer is **do I hold this document's cap, or not** so the key value is the
* constant `OK`, which says exactly that and pretends nothing more. What identifies
* the document is the NURI the key is attached to; the value carries no information.
* Real per-document encryption is P1b's job, and it replaces this one constant.
* Until then, possession is a SHAPE, not a protection.
*/
import type { Nuri, ReadCap } from "./types";
/** The URI scheme prefix every NextGraph reference carries. */
const SCHEME = "did:ng:";
/** The segment that turns a naming NURI into a reading one upstream's ReadCap
* encoding (`readcap_nuri`), NOT the `:k:` used for objects/files/commits. */
const CAP_SEGMENT = ":r:";
/**
* Is this string a NextGraph reference at all? A **type guard**: it is the door
* through which an untrusted `string` a SPARQL binding, an ORM `@graph`, a value
* an app read back from storage or a URL becomes a {@link Nuri}. Exported from
* the SDK entry so a consumer narrows its own strings the same way, rather than
* casting.
*/
export function isNuri(s: string): s is Nuri {
return s.startsWith(SCHEME);
}
/**
* Does this reference carry a read cap (a `:r:` segment)? A **type guard**: the
* ONLY narrowing from a bare string (or a {@link Nuri}) to a {@link ReadCap}.
* Nothing else may produce a `ReadCap` from a reference that carries no key
* that would be deriving a cap from a bare reference, which the model forbids.
*/
export function hasReadCap(s: string): s is ReadCap {
return isNuri(s) && s.includes(CAP_SEGMENT);
}
/**
* The cap-less form of a reference what it NAMES, with any cap stripped.
*
* The one internal cast of this module, and it is load-bearing: `slice` returns
* `string`, yet slicing a `did:ng:…` at the `:r:` boundary can only yield a
* `did:ng:…` which the compiler cannot know. Keeping the cast HERE, in the
* primitive that defines the contract, is what lets every caller stay typed with
* no cast of its own.
*/
export function targetOf(nuri: Nuri): Nuri {
const i = nuri.indexOf(CAP_SEGMENT);
return i === -1 ? nuri : (nuri.slice(0, i) as Nuri);
}
/**
* The parsed form a 1:1 mirror of upstream `NuriV0 { target, access }`, where a
* cap-less NURI has no `readCap`. Library-internal (see the module header).
*/
export function parseNuri(nuri: Nuri): { target: Nuri; readCap?: ReadCap } {
return hasReadCap(nuri) ? { target: targetOf(nuri), readCap: nuri } : { target: nuri };
}
/**
* The stand-in cap value. A CONSTANT, on purpose.
*
* Upstream this segment carries `base64url(serde_bare(ObjectRef))` the block id
* and its key serialized together. Here it carries `OK`.
*
* The only question this library can answer today is **do I hold this document's
* cap, or not** a boolean. An earlier version derived a per-document digest,
* which looked like a key and was not one: it invited the reader to believe
* something was protected, and it made "the key is reproducible" a subtlety to
* explain rather than a fact you can see. `OK` says what it is a presence
* marker. The document a cap opens is already identified by the NURI it is
* attached to, so the value carries no information anyway.
*
* P1b replaces this single constant with a real key. Nothing else has to change:
* every path already reads a cap rather than recomputing one.
*/
const STAND_IN_CAP = "OK";
/**
* Build the cap-bearing form of `nuri` `{target}:r:OK`. Passing an already
* cap-bearing reference yields the same value.
*
* This is INTERNAL: nothing on the library's surface turns a bare reference into a
* cap, because that is not how the model works you look a cap up in what you
* hold, or you were given it (see `caps.ts`).
*
* No cast needed on the way out: the compiler derives `` `did:ng:…:r:…` `` from the
* template itself, which is exactly the {@link ReadCap} shape.
*/
export function mintCap(nuri: Nuri): ReadCap {
return `${targetOf(nuri)}${CAP_SEGMENT}${STAND_IN_CAP}`;
}
+29 -4
View File
@@ -3,7 +3,7 @@
* *
* The cold-start defect this heals * The cold-start defect this heals
* The anchored read path (`read-model.ts` `readDoc`, `store-registry.ts` * The anchored read path (`read-model.ts` `readDoc`, `store-registry.ts`
* `readScopeIndex`) assumes the target repo is already in the verifier's * `readUserStore`) assumes the target repo is already in the verifier's
* `self.repos` true within the session that CREATED the doc (every `doc_create` * `self.repos` true within the session that CREATED the doc (every `doc_create`
* opens it), but FALSE on a FRESH session over the same persistent wallet * opens it), but FALSE on a FRESH session over the same persistent wallet
* (reconnection / new page / re-login). On that fresh session nothing has opened * (reconnection / new page / re-login). On that fresh session nothing has opened
@@ -13,7 +13,7 @@
* *
* The circularity that made this self-inflicted: `doc_subscribe` WOULD open the * The circularity that made this self-inflicted: `doc_subscribe` WOULD open the
* repo, but the reactive layer only subscribes AFTER the listing produced NURIs * repo, but the reactive layer only subscribes AFTER the listing produced NURIs
* and the listing (`readScopeIndex`) is itself an anchored read of a not-yet-open * and the listing (`readUserStore`) is itself an anchored read of a not-yet-open
* index repo 0 rows nothing to subscribe nothing ever opens. Verified fix * index repo 0 rows nothing to subscribe nothing ever opens. Verified fix
* (adversarial pass): on a fresh session, `doc_subscribe(<docNuri>)` THEN the * (adversarial pass): on a fresh session, `doc_subscribe(<docNuri>)` THEN the
* anchored re-read returns the data. So we OPEN the repo before the anchored read. * anchored re-read returns the data. So we OPEN the repo before the anchored read.
@@ -59,8 +59,9 @@
* resolves a same-session repo directly. Polyfill-era, removed with the shim. * resolves a same-session repo directly. Polyfill-era, removed with the shim.
*/ */
import { mustNotAttempt } from "./reach";
import { getConfig, getStoreRegistryDeps } from "./polyfill"; import { getConfig, getStoreRegistryDeps } from "./polyfill";
import { subscribeDoc, type Unsubscribe } from "./subscribe"; import { subscribePhysicalDoc, type Unsubscribe } from "./subscribe";
import { logStage, shortNuri } from "./access-log"; import { logStage, shortNuri } from "./access-log";
import type { Nuri } from "./types"; import type { Nuri } from "./types";
@@ -165,6 +166,27 @@ async function syncSession(): Promise<void> {
*/ */
export async function ensureRepoOpen(nuri: Nuri): Promise<void> { export async function ensureRepoOpen(nuri: Nuri): Promise<void> {
if (!nuri) return; if (!nuri) return;
// RULE 2 — do not even attempt. Opening a repo IS an access: it subscribes and
// pulls its state. A user that holds no cap for it has no business asking.
// (`ensurePhysicalRepoOpen` is the machinery's door — see physical.ts.)
if (mustNotAttempt(nuri)) return;
return openRepoUnguarded(nuri);
}
/**
* Open a repo as the PHYSICAL user the shim's own documents (store-root,
* doc-shim). The machinery's counterpart to {@link ensureRepoOpen}: resolving
* WHICH documents a virtual user owns cannot itself be confined to that user.
* See `physical.ts` for why this is a separate function and not an exemption.
*
* Never exported from the package.
*/
export async function ensurePhysicalRepoOpen(nuri: Nuri): Promise<void> {
if (!nuri) return;
return openRepoUnguarded(nuri);
}
async function openRepoUnguarded(nuri: Nuri): Promise<void> {
// Drop the registry if the session changed (in-page re-login → fresh verifier). // Drop the registry if the session changed (in-page re-login → fresh verifier).
await syncSession(); await syncSession();
if (opened.has(nuri)) return; if (opened.has(nuri)) return;
@@ -214,7 +236,10 @@ export async function ensureRepoOpen(nuri: Nuri): Promise<void> {
// is the whole point. We wait for the FIRST `State` event specifically (the // is the whole point. We wait for the FIRST `State` event specifically (the
// barrier), NOT any push: the platform pushes `TabInfo` before `State`, and // barrier), NOT any push: the platform pushes `TabInfo` before `State`, and
// resolving on `TabInfo` would return before the real sync barrier. // resolving on `TabInfo` would return before the real sync barrier.
const unsub = subscribeDoc(nuri, (_r, type) => { // Unguarded on purpose: the caller already decided. `ensureRepoOpen` applied
// rule 2 above; `ensurePhysicalRepoOpen` is the machinery's door and is not
// subject to the boundary at all (see physical.ts).
const unsub = subscribePhysicalDoc(nuri, (_r, type) => {
if (type === "State") onState(); if (type === "State") onState();
}); });
held.set(nuri, unsub); held.set(nuri, unsub);
+103
View File
@@ -0,0 +1,103 @@
/**
* physical the polyfill's OWN machinery, operating on the PHYSICAL user.
*
* Two levels, two APIs, and only one of them is the app's
* NextGraph sees exactly one user: the physical one, whose wallet everybody opens.
* On top of it the library fabricates **virtual users** what the consumer calls
* an identity. Those are two different levels, and conflating them is how a
* boundary gets a hole in it:
*
* | | Level | Who calls it | Guarded |
* |---|---|---|---|
* | `docs.*`, `subscribeDoc` | the **virtual user** | the consumer app, and the library on the user's behalf | YES confined to the connected user (`reach.ts`) |
* | this module | the **physical user** | the library's own machinery, and nothing else | no it *is* the machinery the boundary is built on |
*
* **Nothing here is exported from the package.** `index.ts` must never re-export
* this module: an app holding these functions could read any document of any
* virtual user, which is precisely the boundary they exist below.
*
* Why a separate module rather than exemptions
* The store-root pointer and the doc-shim the index of virtual users cannot be
* subject to the boundary: resolving *which* documents a virtual user owns is what
* makes virtual users exist at all. An earlier version handled that with a list of
* exempt NURIs consulted by the guard. Separating the FUNCTIONS is stronger: the
* machinery does not call the guarded primitive and get waved through, it calls a
* different primitive that was never guarded. There is no exemption list to widen,
* to get wrong, or to infer.
*
* The rule for deciding which side a call belongs to:
*
* > Does this operate on the index of virtual users (the shim), or on the content
* > of one virtual user? The first is machinery; everything else is the user's,
* > and is confined.
*
* A virtual user's own stores, its inbox and its documents are the user's they go
* through `docs.*` and are guarded, even though the library is what calls them.
*
* At migration this module disappears with the shim: there is no physical/virtual
* split once each user opens their own wallet.
*/
import { getConfig } from "./polyfill";
import { logAccess } from "./access-log";
import { isNuri } from "./nuri";
import type { Nuri } from "./types";
/**
* Create a document as the PHYSICAL user the shim's own documents (the doc-shim,
* a virtual user's store documents at provisioning time, an inbox document).
*
* Creation is the one operation with no boundary to check: the document does not
* exist yet, so nobody can hold its cap. What matters is who is credited with it
* afterwards, which the caller decides by filing the cap among the caps that holder holds.
*/
export async function physicalCreate(
sessionId: string,
crdt = "Graph",
cls = "data:graph",
dest = "store",
store?: unknown,
): Promise<Nuri> {
const { ng } = getConfig();
const nuri = await ng.doc_create(sessionId, crdt, cls, dest, store);
if (typeof nuri !== "string" || !isNuri(nuri)) {
throw new Error(
`[ng-eventually] physicalCreate: the broker returned something that is not a NextGraph reference: ${JSON.stringify(nuri)}`,
);
}
logAccess("WRITE", nuri, "physicalCreate");
return nuri;
}
/**
* Read as the PHYSICAL user for the shim only (the store-root pointer, the
* doc-shim's account records).
*
* Unguarded by design: this is how the library learns which documents a virtual
* user owns, so it cannot itself depend on knowing that. Do not reach for it to
* read a virtual user's content that is `docs.sparqlQuery`, which is confined.
*/
export async function physicalQuery(
sessionId: string,
query: string,
base: string | undefined,
anchor: Nuri,
label = "physicalQuery",
): Promise<unknown> {
const { ng } = getConfig();
const result = await ng.sparql_query(sessionId, query, base, anchor);
logAccess("READ", anchor, label, " (physical)");
return result;
}
/** Write as the PHYSICAL user — the shim's own records. See {@link physicalQuery}. */
export async function physicalUpdate(
sessionId: string,
query: string,
anchor: Nuri,
label = "physicalUpdate",
): Promise<void> {
const { ng } = getConfig();
logAccess("WRITE", anchor, label, " (physical)");
return ng.sparql_update(sessionId, query, anchor);
}
+80 -10
View File
@@ -8,11 +8,12 @@
* here is removed at migration. * here is removed at migration.
*/ */
import type { NgLike, UseShapeLike, PrincipalId } from "./types"; import type { NgLike, UseShapeLike, Nuri, PrincipalId, ReadCap } from "./types";
import type { RegistrySession } from "./store-registry"; import type { RegistrySession } from "./store-registry";
import { CapRegistry } from "./caps"; import { CapRegistry } from "./caps";
import { setAccessLog } from "./access-log"; import { setAccessLog } from "./access-log";
import { inspectOutbox } from "./outbox-log"; import { inspectOutbox } from "./outbox-log";
import { startConnect } from "./connect";
/** /**
* Consumer-injected dependencies of the storeRegistry (polyfill-era). The * Consumer-injected dependencies of the storeRegistry (polyfill-era). The
@@ -70,9 +71,30 @@ type ResolvedRegistryDeps = Required<
Pick<StoreRegistryDeps, "getSession" | "normalizeId" | "pointerGuard"> Pick<StoreRegistryDeps, "getSession" | "normalizeId" | "pointerGuard">
>; >;
let registryDeps: ResolvedRegistryDeps | null = null; let registryDeps: ResolvedRegistryDeps | null = null;
/** The emulated ReadCap/WriteCap registry. Empty until the app declares caps; /**
* while it has no read policy the read filter passes through (no regression). */ * The map key of the current identity deliberately NOT the raw id.
let caps = new CapRegistry(); *
* A virtual user IS a shim account, and the shim keys accounts by the
* consumer-injected `normalizeId` ("@Alice" and "alice" are ONE account, with one
* set of scope documents). This record must key the same way, or a consumer that
* spells its own id differently between two calls gets a SECOND record and stops
* reading its own documents the caps are filed under one spelling and looked up
* under the other. Falls back to the raw id while the registry deps are not yet
* configured (nothing can be filed before that anyway).
*/
function capsHolder(): PrincipalId | null {
if (currentUser === null) return null;
return registryDeps ? registryDeps.normalizeId(currentUser) : currentUser;
}
/**
* The emulated cap registry one record PER identity (per virtual user),
* resolved through {@link capsHolder} on every call. So switching identity
* SWITCHES heldByHolder (nothing to reset, nothing wiped); see `caps.ts`. Empty until
* the first cap is issued, and while it is empty the read filter passes through
* (no regression).
*/
let caps = new CapRegistry(capsHolder);
export function configure(c: EventuallyConfig): void { export function configure(c: EventuallyConfig): void {
cfg = c; cfg = c;
@@ -147,25 +169,73 @@ export function resetStoreRegistry(): void {
* who is acting. Passing `null` clears it (no identity yet, e.g. during startup). * who is acting. Passing `null` clears it (no identity yet, e.g. during startup).
*/ */
export function setCurrentUser(id: PrincipalId | null): void { export function setCurrentUser(id: PrincipalId | null): void {
const changed = currentUser !== id;
currentUser = id; currentUser = id;
// Connecting a user is what triggers inbox processing — the library's job, not
// the app's. Fire-and-forget: this setter is synchronous and every consumer calls
// it from synchronous code, so the work announces itself through the cap
// registry's change signal instead of making callers await. See `connect.ts`.
//
// Gated on the registry being configured, and that is not a test convenience: an
// identity set before the session resolves has nothing to restore and no inbox to
// reach, so firing would be I/O that can only fail. The consumer's real sequence
// is `configureStoreRegistry` then `setCurrentUser`; anything else can call
// `connectedUser()` explicitly.
if (changed && id !== null && registryDeps !== null) startConnect();
} }
export function getCurrentUser(): PrincipalId | null { export function getCurrentUser(): PrincipalId | null {
return currentUser; return currentUser;
} }
/** The emulated cap registry the app opens a document's read policy and issues /** The emulated cap registry what the current identity holds, plus the emulated
* directed read grants on it (as it will via real cap operations in the target). * public store. The read filter and the read-model consult it. */
* The read filter consults it. */
export function getCaps(): CapRegistry { export function getCaps(): CapRegistry {
return caps; return caps;
} }
/** Reset all emulated caps (mainly for tests / fresh sessions). */ /**
* Do I hold the cap of `nuri`? the held-caps lookup, the ONLY way a cap is
* obtained besides being given one. Returns `undefined` when what I hold has none;
* that is the whole answer the model can give (there is no "may P read D?").
*
* Shorthand for `getCaps().capFor(nuri)`, exposed because it is the surface the
* consumer actually uses.
*/
export function capFor(nuri: Nuri): ReadCap | undefined {
return caps.capFor(nuri);
}
/**
* Drop EVERY holder's caps (tests / a fresh wallet). This is **not** what an identity
* change does: switching identity switches heldByHolder, it never wipes one if it
* wiped, durability would be a lie and per-session re-declaration would come back
* under another name. Nothing in the library calls this on `setCurrentUser`.
*/
export function resetCaps(): void { export function resetCaps(): void {
caps = new CapRegistry(); // Clear IN PLACE rather than rebuilding: whoever subscribed to the registry's
// change signal (`watchShape`) stays subscribed to the live instance instead of
// silently holding a listener on an orphaned one.
caps.clear();
} }
// Cap surface — polyfill-era (caps are emulated now; native at migration). // Cap surface — polyfill-era (caps are emulated now; native at migration).
// Re-exported here so the whole polyfill API lives under /polyfill. // Re-exported here so the whole polyfill API lives under /polyfill. `shareCap`
// lives in `inbox.ts` because sharing IS an inbox deposit (upstream: a sealed
// message carrying the cap), but it is surfaced here so the cap vocabulary stays
// on the polyfill side of the boundary rather than in the SDK-identical entry.
export { CapRegistry } from "./caps"; export { CapRegistry } from "./caps";
export { shareCap } from "./inbox";
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";
+133
View File
@@ -0,0 +1,133 @@
/**
* reach may the CONNECTED virtual user touch this document at all?
*
* The one predicate every path to `ng` consults, so the boundary is decided in a
* single place instead of being re-argued at each call site.
*
* The boundary
* A virtual user must simulate the boundary of the future single-user wallet:
* every access function is confined to the user currently connected
* (`setCurrentUser`), and no cross-user access is permitted. Otherwise the
* consumer is coded against a reach that will never exist the same failure mode
* as an ACL where the real model is key possession, one level down.
*
* Two ways a document is legitimately reachable, and no others:
*
* 1. **You hold its cap.** Either because you created it (the store refiles the
* cap) or because someone delivered it to you. This is the whole of the
* access model, so it is the whole of the predicate.
* 2. **It is declared INFRASTRUCTURE.** A short, explicitly-registered list
* never inferred from the shape of a NURI, because an inferred exemption is
* a hole. See {@link declareInfrastructure}.
*
* What may be exempt, and why so little
* > The only reads/writes not confined to a virtual user are those that make
* > multi-user operation possible at all. Nothing common only the indexing
* > mechanisms that make the virtual users work.
*
* The test an exemption must pass: *does removing it stop the virtual users from
* functioning, or does it merely stop users from seeing each other's content?*
* Only the first qualifies. The shim passes (remove it and no user is resolvable
* at all); a shared index of user content does not (remove it and every user still
* works you simply have to be given links).
*
* Depositing into another user's inbox is NOT handled here: it is a write to a
* document you do not hold, and it is legitimate the only channel by which a
* link crosses from one user to another, hence the bootstrap of the whole
* reachability graph. It is allowed at the inbox surface, which is where the
* asymmetry (deposit yes, read no) is expressed.
*
* At migration this module disappears: the boundary becomes the wallet itself.
*/
import { getCaps } from "./polyfill";
import { targetOf } from "./nuri";
import type { Nuri } from "./types";
/**
* NURIs of the polyfill's own scaffolding, registered as they are resolved.
*
* Explicit registration rather than pattern-matching: the store-root and the
* doc-shim are exempt because they ARE the index of virtual users, not because
* they look a certain way. A NURI is in here because some code path put it here,
* knowing what it was.
*/
const infrastructure = new Set<Nuri>();
/**
* Register `nuri` as scaffolding that the boundary does not apply to. Called by
* the store-registry as it resolves the store-root pointer and the doc-shim
* the only two documents that qualify, because without them no virtual user can
* be resolved at all.
*
* Deliberately NOT exported from the package: nothing outside the library may
* widen the exemption list.
*/
export function declareInfrastructure(nuri: Nuri): void {
infrastructure.add(nuri);
}
/** Is `nuri` registered scaffolding? */
export function isInfrastructure(nuri: Nuri): boolean {
return infrastructure.has(nuri);
}
/** Forget every declared exemption (tests / a fresh wallet). */
export function resetInfrastructure(): void {
infrastructure.clear();
}
/**
* Do we POSSESS the cap of `nuri`? Not "does this string carry one" a caller may
* legitimately be holding the bare form and possess the cap elsewhere, which is the
* normal case: NURIs travel bare through content and indexes, while the cap sits in
* what the user holds. Possession is what decides; the shape of the reference the
* caller happens to have in hand decides nothing.
*
* `targetOf` first, so a cap-bearing reference and its bare form answer alike.
*
* Inert until the first cap exists (`caps.isEnforcing()`), so a consumer that never
* touches caps keeps working. Once ANY cap has been issued the boundary applies to
* every user, including one holding nothing: that is the isolation.
*/
export function mayReach(nuri: Nuri): boolean {
const caps = getCaps();
if (!caps.isEnforcing()) return true;
const target = targetOf(nuri);
return isInfrastructure(target) || caps.capFor(target) !== undefined;
}
/**
* **Rule 1 authorization**, at the PASSAGE POINTS (`docs.*`, `subscribe`).
*
* Nothing reaches `ng` unless the connected user possesses the document's cap. This
* is the guard: it fires on a request that should never have been made, and its job
* is to make sure the attempt fails rather than succeeds quietly.
*
* Deliberately duplicated with rule 2 below see {@link mustNotAttempt}. Two rules,
* two places, one criterion: a lapse in either is caught by the other.
*/
export function assertMayReach(nuri: Nuri, op: string): void {
if (mayReach(nuri)) return;
throw new Error(
`[ng-eventually] ${op}: refused — the connected user does not hold this document's ` +
"cap. Naming a document does not grant access to it: a cap is looked up in what " +
`you hold, or it was delivered to you. ${JSON.stringify(nuri)}`,
);
}
/**
* **Rule 2 do not even attempt**, at the CALLERS (`read-model`, `open-repo`,
* `subscribe`'s callers).
*
* A reader that does not hold a document's cap must not issue the operation at all.
* Not attempting and being refused are different things: the first is a caller that
* knows what it holds, the second is one that hoped and got caught. Only the first
* is the model upstream you cannot even address a repo you have no cap for.
*
* Practically it also stops the library from asking the broker for documents it has
* no business asking about, which is work, noise, and a leak of intent.
*/
export function mustNotAttempt(nuri: Nuri): boolean {
return !mayReach(nuri);
}
+29 -28
View File
@@ -1,47 +1,52 @@
/** /**
* Read filter the polyfill of capability-based read access. * Read filter the polyfill of capability-based read access.
* *
* In the target, the broker only delivers documents the user holds a **ReadCap** * In the target, the broker only delivers documents the holder has the **ReadCap**
* for, so `useShape` already returns an authorized subset. Here (single shared * of, so `useShape` already returns an authorized subset. Here (single shared
* wallet, everything readable) we reproduce that with a read-filtered VIEW over * wallet, everything readable) we reproduce that with a read-filtered VIEW over
* the reactive set: it keeps only items whose **document** (its `@graph` = the * the reactive set: it keeps only items whose **document** (its `@graph` = the
* repo it lives in) the current user may read, per the {@link CapRegistry}. * repo it lives in) is in what the current holder holds, per the
* {@link CapRegistry}.
* *
* Faithful to NextGraph: the access unit is the DOCUMENT, not the item. In a * Faithful to NextGraph: the access unit is the DOCUMENT, not the item. In a
* mono-store layout (every item in one repo) the filter is therefore all-or- * mono-store layout (every item in one repo) the filter is therefore all-or-
* nothing on that document which is exactly the native behavior, and why * nothing on that document which is exactly the native behavior, and why
* fine-grained isolation requires one document per entity. Removed at migration. * fine-grained isolation requires one document per entity. Removed at migration.
*
* Note there is no `user` parameter anywhere below, and that is the point: reading
* is key possession, so the only question askable is "do I hold this document's
* cap?". "May principal P read document D?" is an ACL question the real model
* cannot answer either. Which holder's caps are consulted follows the identity the
* registry resolves, so the view reflects the holder in effect at read time.
*/ */
import type { CapRegistry } from "./caps"; import type { CapRegistry } from "./caps";
import type { PrincipalId } from "./types"; import { isNuri } from "./nuri";
import type { Nuri } from "./types";
/** The document (repo NURI) an item lives in — its `@graph`. */ /** The document (repo NURI) an item lives in its `@graph`. The ORM boundary:
function docOf(item: unknown): string | null { * `@graph` is an untyped value on a property bag, so it is narrowed here rather
* than cast. Anything that is not a NextGraph reference names no document. */
function docOf(item: unknown): Nuri | null {
const g = (item as Record<string, unknown> | null)?.["@graph"]; const g = (item as Record<string, unknown> | null)?.["@graph"];
return typeof g === "string" ? g : null; return typeof g === "string" && isNuri(g) ? g : null;
} }
/** /**
* May `user` read this item? An item with no `@graph`, or in a document under no * Do I hold this item's document? An item with no `@graph` is KEPT (it names no
* cap policy, is KEPT (the filter only restricts documents that DECLARE a cap * document, so there is no cap to hold). Everything else needs the cap: a bare
* mirrors the prior behavior and keeps ungoverned data flowing). * reference names without reading.
*/ */
function readable(item: unknown, caps: CapRegistry, user: PrincipalId | null): boolean { function readable(item: unknown, caps: CapRegistry): boolean {
const doc = docOf(item); const doc = docOf(item);
if (doc === null) return true; if (doc === null) return true;
if (!caps.governsRead(doc)) return true; return caps.capFor(doc) !== undefined;
return caps.canRead(doc, user);
} }
/** Pure: keep only the items the user may read. */ /** Pure: keep only the items whose document the current holder holds. */
export function filterReadable<T>( export function filterReadable<T>(items: Iterable<T>, caps: CapRegistry): T[] {
items: Iterable<T>,
caps: CapRegistry,
user: PrincipalId | null,
): T[] {
const out: T[] = []; const out: T[] = [];
for (const item of items) if (readable(item, caps, user)) out.push(item); for (const item of items) if (readable(item, caps)) out.push(item);
return out; return out;
} }
@@ -49,15 +54,11 @@ export function filterReadable<T>(
* A read-filtered VIEW over a reactive set (a `DeepSignalSet`, or any Set-like). * A read-filtered VIEW over a reactive set (a `DeepSignalSet`, or any Set-like).
* Iteration / `size` / `forEach` yield only readable items; everything else * Iteration / `size` / `forEach` yield only readable items; everything else
* (`add`, `delete`, `has`, `getById`, ) forwards to the target, so writes and * (`add`, `delete`, `has`, `getById`, ) forwards to the target, so writes and
* the underlying reactivity are preserved. The current user is read lazily (via * the underlying reactivity are preserved. What the holder holds is consulted lazily, so the
* `getUser`) so the view reflects the user in effect at read time. * view reflects the holder in effect at read time.
*/ */
export function makeReadFilteredView<S extends object>( export function makeReadFilteredView<S extends object>(set: S, caps: CapRegistry): S {
set: S, const keep = (item: unknown): boolean => readable(item, caps);
caps: CapRegistry,
getUser: () => PrincipalId | null,
): S {
const keep = (item: unknown): boolean => readable(item, caps, getUser());
return new Proxy(set, { return new Proxy(set, {
get(target, prop, receiver) { get(target, prop, receiver) {
if (prop === Symbol.iterator) { if (prop === Symbol.iterator) {
+20 -9
View File
@@ -42,9 +42,11 @@
*/ */
import { docCreate, sparqlUpdate, sparqlQuery } from "./docs"; import { docCreate, sparqlUpdate, sparqlQuery } from "./docs";
import { getCaps, getCurrentUser, getStoreRegistryDeps } from "./polyfill"; import { getCaps, getStoreRegistryDeps } from "./polyfill";
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
@@ -140,33 +142,42 @@ export async function readUnion(docs: Nuri[]): Promise<UnionSubject[]> {
const unique = [...new Set(docs.filter(Boolean))]; const unique = [...new Set(docs.filter(Boolean))];
if (unique.length === 0) return []; if (unique.length === 0) return [];
// RULE 2 — do not even attempt. Drop the documents whose cap this user does not
// hold BEFORE opening or reading anything: upstream you cannot address a repo you
// have no cap for, so asking about one is not "a read that will be refused", it is
// a read that has no meaning. (The passage points enforce rule 1 regardless — see
// reach.ts — so a lapse here is caught, not exploited.)
const reachable = unique.filter((d) => !mustNotAttempt(d));
// COLD-START heal (polyfill-era): on a fresh session over a persistent wallet the // COLD-START heal (polyfill-era): on a fresh session over a persistent wallet the
// target repos are not yet in `self.repos`, so an anchored read would return 0 // target repos are not yet in `self.repos`, so an anchored read would return 0
// rows. Open/subscribe each repo ONCE (idempotent, per session) and await its // rows. Open/subscribe each repo ONCE (idempotent, per session) and await its
// initial-state push before the anchored reads. No-op once opened / when the // initial-state push before the anchored reads. No-op once opened / when the
// injected `ng` has no `doc_subscribe` (unit fake). See open-repo.ts. // injected `ng` has no `doc_subscribe` (unit fake). See open-repo.ts.
await ensureReposOpen(unique); await ensureReposOpen(reachable);
// One anchored query per doc, in parallel, tolerant (a bad doc yields []). // One anchored query per doc, in parallel, tolerant (a bad doc yields []).
const perDoc = await Promise.all( const perDoc = await Promise.all(
unique.map(async (d) => ({ doc: assertNuri(d), rows: await readDoc(sid, d) })), reachable.map(async (d) => ({ doc: assertNuri(d), rows: await readDoc(sid, d) })),
); );
// Cap gate (defence-in-depth). A doc whose read policy the current user may not // Possession gate, kept as defence in depth behind rule 2 above: `reachable`
// satisfy is dropped. Isolation holds both by construction (the app only resolves // already excluded these, so this loop should never drop anything. In this
// docs it is entitled to) and by filter here. Generic: the lib owns the cap
// registry; a doc under no policy (`!governsRead`) flows through unchanged. In this
// polyfill each subject IRI is its own document NURI, so the cap key is the doc NURI. // polyfill each subject IRI is its own document NURI, so the cap key is the doc NURI.
const caps = getCaps(); const caps = getCaps();
const user = getCurrentUser();
const bySubject = new Map<string, UnionSubject>(); const bySubject = new Map<string, UnionSubject>();
for (const { doc, rows } of perDoc) { for (const { doc, rows } of perDoc) {
if (caps.governsRead(doc) && !caps.canRead(doc, user)) continue; if (caps.isEnforcing() && caps.capFor(doc) === undefined) continue;
// Anchored to `doc`, so every row belongs to `doc`; the subject is the doc NURI // Anchored to `doc`, so every row belongs to `doc`; the subject is the doc NURI
// (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;
+6 -1
View File
@@ -86,8 +86,13 @@ export function escapeIri(value: string): string {
* should never carry IRI-breaking characters; if one does, we throw rather than * should never carry IRI-breaking characters; if one does, we throw rather than
* emit a query that could be malformed or injected. Returns the value unchanged * emit a query that could be malformed or injected. Returns the value unchanged
* so it can be used inline: `<${assertNuri(doc)}>`. * so it can be used inline: `<${assertNuri(doc)}>`.
*
* Generic in its argument so the caller's type flows THROUGH: passing a `Nuri`
* gives back a `Nuri`, not a widened `string`. This function checks characters,
* not the `did:ng:` shape (it legitimately accepts `urn:…` IRIs too), so it must
* not be the thing that mints a `Nuri` that is {@link isNuri}'s job.
*/ */
export function assertNuri(nuri: string): string { export function assertNuri<T extends string>(nuri: T): T {
if (typeof nuri !== "string" || nuri.length === 0) { if (typeof nuri !== "string" || nuri.length === 0) {
throw new Error(`[sparql] invalid NURI (empty): ${JSON.stringify(nuri)}`); throw new Error(`[sparql] invalid NURI (empty): ${JSON.stringify(nuri)}`);
} }
+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";
File diff suppressed because it is too large Load Diff
+22
View File
@@ -34,6 +34,7 @@
*/ */
import { getConfig, getStoreRegistryDeps } from "./polyfill"; import { getConfig, getStoreRegistryDeps } from "./polyfill";
import { assertMayReach } from "./reach";
import type { Nuri } from "./types"; import type { Nuri } from "./types";
/** /**
@@ -103,6 +104,27 @@ async function sessionId(): Promise<string> {
export function subscribeDoc( export function subscribeDoc(
nuri: Nuri, nuri: Nuri,
onChange: (r: DocChange, type: DocChangeType) => void, onChange: (r: DocChange, type: DocChangeType) => void,
): Unsubscribe {
// RULE 1 — a subscription IS an access: the push carries the document's state.
// Guarding the read paths while leaving this open would be a door beside the gate.
assertMayReach(nuri, "subscribeDoc");
return subscribeDocUnguarded(nuri, onChange);
}
/**
* Subscribe as the PHYSICAL user the shim's own documents. The machinery's
* counterpart to {@link subscribeDoc}; never exported from the package.
*/
export function subscribePhysicalDoc(
nuri: Nuri,
onChange: (r: DocChange, type: DocChangeType) => void,
): Unsubscribe {
return subscribeDocUnguarded(nuri, onChange);
}
function subscribeDocUnguarded(
nuri: Nuri,
onChange: (r: DocChange, type: DocChangeType) => void,
): Unsubscribe { ): Unsubscribe {
const { ng } = getConfig(); const { ng } = getConfig();
let stopped = false; let stopped = false;
+30 -2
View File
@@ -2,8 +2,36 @@
* Generic, NextGraph-shaped types. ZERO application domain. * Generic, NextGraph-shaped types. ZERO application domain.
*/ */
/** A NextGraph URI (document / store / inbox). */ /**
export type Nuri = string; * A NextGraph URI (document / store / inbox) in its **cap-less** form it NAMES
* and locates, it does not grant the right to read: `did:ng:o:{doc}:v:{overlay}`.
* `did:ng:` is the URI scheme prefix, not a "without cap" marker; the discriminant
* is the `:r:` segment (see {@link ReadCap}).
*/
export type Nuri = `did:ng:${string}`;
/**
* A NextGraph URI that carries the document's read cap `…:r:{cap}`. It NAMES
* *and* READS: reading is key possession, never an authorization list. This is the
* upstream name (`ReadCap`).
*
* Why a template literal type, and not a branded one
* Both this and {@link Nuri} are **still strings** assignable to `string`,
* JSON-serializable, no wrapper object so nothing has to be *un*-typed when the
* real SDK arrives and takes `nuri: String`. What the template buys is the one
* direction that matters: a `ReadCap` is freely usable wherever a `Nuri` is
* expected (a cap IS a NURI with the key inside upstream's single `NuriV0`),
* while a bare `Nuri` passed where a `ReadCap` is required is a **compile error**.
* That confusion, left to runtime, silently turns "naming is not reading" into
* "naming is reading" the exact inversion this model exists to remove.
*
* It constrains the consumer's code the same way, which is the point: an app that
* reads a cap back from storage, a URL or JSON gets a `string` and must pass it
* through {@link isNuri} / {@link hasReadCap} (exported from the SDK entry) to use
* it a validation it should be doing anyway. The runtime guards stay regardless:
* a JavaScript consumer bypasses the compiler entirely.
*/
export type ReadCap = `did:ng:${string}:r:${string}`;
/** NextGraph-native store scopes. The *mapping* of entities to scopes is the /** NextGraph-native store scopes. The *mapping* of entities to scopes is the
* consumer's concern; this layer only knows the three scopes exist. */ * consumer's concern; this layer only knows the three scopes exist. */
+8 -8
View File
@@ -1,17 +1,17 @@
/** /**
* Wrapped `useShape`: same signature as `@ng-org/orm`. When a read-cap policy is * Wrapped `useShape`: same signature as `@ng-org/orm`. Once the cap emulation is
* declared, the returned set is a read-filtered VIEW (only items in documents the * in force, the returned set is a read-filtered VIEW (only items in documents the
* current user holds a ReadCap for); otherwise it passes the real set through * current holder has the ReadCap of); before the first cap is issued it passes the
* unchanged. At migration the filtering disappears the broker only delivers * real set through unchanged. At migration the filtering disappears the broker
* authorized documents. * only delivers documents whose cap the wallet holds.
*/ */
import { getConfig, getCurrentUser, getCaps } from "./polyfill"; import { getConfig, getCaps } from "./polyfill";
import { makeReadFilteredView } from "./read-filter"; import { makeReadFilteredView } from "./read-filter";
export function useShape(shapeType: unknown, scope: unknown): unknown { export function useShape(shapeType: unknown, scope: unknown): unknown {
const set = getConfig().useShape(shapeType, scope) as object; const set = getConfig().useShape(shapeType, scope) as object;
const caps = getCaps(); const caps = getCaps();
if (!caps.hasReadPolicy()) return set; // no policy configured → passthrough if (!caps.isEnforcing()) return set; // no cap issued yet → passthrough
return makeReadFilteredView(set, caps, getCurrentUser); return makeReadFilteredView(set, caps);
} }
+54 -52
View File
@@ -15,11 +15,13 @@
* scope still syncing reads `{ data: [], isPending: true, isSuccess: false }`. * scope still syncing reads `{ data: [], isPending: true, isSuccess: false }`.
* *
* What the observable OWNS (the whole read pipeline) * What the observable OWNS (the whole read pipeline)
* 1. Resolve the logical scope the doc set: the current identity's per-entity * 1. Resolve the logical scope the doc set: the CURRENT wallet's own per-entity
* docs for that scope (`storeRegistry.listMyEntityDocs`), PLUS for `public` * documents for that scope (`storeRegistry.listMyEntityDocs`), and nothing
* only the discovery index folded in (`discovery.readIndex`), so the app * else. There is no "everything public" to fold in **you cannot discover,
* never orchestrates discovery to read. Faithful to the future * you can only follow links** (see docs/readcap-and-nuri-model.md §4ter-bis),
* `useShape(shape, 'public')`. * and a link reaches you through an inbox or through a document you already
* hold. A document whose cap you were given is read by NAMING it
* (`readModel.readUnion`), not by turning up in a scope you never put it in.
* 2. Open the docs (`ensureReposOpen`) this AWAITS the sync BARRIER (first * 2. Open the docs (`ensureReposOpen`) this AWAITS the sync BARRIER (first
* `State` per doc, `getSyncState` `synced`, or `timed-out` on the bounded * `State` per doc, `getSyncState` `synced`, or `timed-out` on the bounded
* fallback). `isPending` holds until the barrier is reached for the current * fallback). `isPending` holds until the barrier is reached for the current
@@ -31,12 +33,15 @@
* ShapeType, not from any application concept. * ShapeType, not from any application concept.
* *
* Reactivity WITHOUT polling (no `setInterval`) * Reactivity WITHOUT polling (no `setInterval`)
* Reactivity is push-only (rule no-broker-polling): `subscribeDoc` on every doc in * Reactivity is push-only (rule no-broker-polling). It has TWO sources: document
* the current set re-runs `readUnion` on any push. The set is DYNAMIC (creating an * pushes, and the KEYRING a cap that arrives asynchronously (an inbox delivery
* entity appends a NURI to the scope-index doc; announcing a public entity appends * absorbed by the consumer's `inbox.watch`) makes documents readable that were not,
* to the discovery index), so we ALSO subscribe to the scope-index document (and, * so `CapRegistry.onChange` re-reads. Without that, a view stays stale until an
* for `public`, the discovery-index document): a push there re-RESOLVES the scope * unrelated push happens to fire. On the document side, `subscribeDoc` on every doc
* and re-keys the subscribed set. Subscriptions are idempotent an already-followed * in the current set re-runs `readUnion` on any push. The set is DYNAMIC (creating an
* entity appends a NURI to the scope-index doc), so we ALSO subscribe to the
* scope-index document: a push there re-RESOLVES the scope and re-keys the
* subscribed set. Subscriptions are idempotent an already-followed
* doc is not re-subscribed. Everything reuses `subscribe.ts` / `open-repo.ts`; no * doc is not re-subscribed. Everything reuses `subscribe.ts` / `open-repo.ts`; no
* parallel channel. * parallel channel.
* *
@@ -46,12 +51,11 @@
* `isError` fires ONLY on a real thrown exception in the pipeline. * `isError` fires ONLY on a real thrown exception in the pipeline.
*/ */
import { getCurrentUser } from "./polyfill"; import { getCaps, getCurrentUser } from "./polyfill";
import { ensureReposOpen, getSyncState } from "./open-repo"; import { ensureReposOpen, getSyncState } from "./open-repo";
import { readUnion, type UnionSubject } from "./read-model"; import { readUnion, type UnionSubject } from "./read-model";
import { subscribeDoc, type Unsubscribe } from "./subscribe"; import { subscribeDoc, type Unsubscribe } from "./subscribe";
import { listMyEntityDocs, scopeIndexDoc } from "./store-registry"; import { listMyEntityDocs, userStoreDoc } from "./store-registry";
import { readIndex, indexDocNuri } from "./discovery";
import type { Nuri, Scope } from "./types"; import type { Nuri, Scope } from "./types";
/** /**
@@ -183,6 +187,12 @@ export function watchShape<T = UnionSubject>(
// Container subscriptions (scope-index doc; discovery-index doc for `public`) — // Container subscriptions (scope-index doc; discovery-index doc for `public`) —
// a push here means the doc SET may have changed → re-resolve. // a push here means the doc SET may have changed → re-resolve.
const containerSubs = new Map<Nuri, Unsubscribe>(); const containerSubs = new Map<Nuri, Unsubscribe>();
// Unsubscribe from the held-caps change signal (see the subscription in `start`).
let capsUnsub: (() => void) | null = null;
// True while `resolveDocs` runs. Folding a repo link files a cap, which fires the
// held-caps signal; the resolution in progress already accounts for it, so the
// signal is ignored during that window instead of restarting the cycle.
let resolving = false;
// Monotonic token so a slow in-flight refresh cannot clobber a newer one. // Monotonic token so a slow in-flight refresh cannot clobber a newer one.
let refreshToken = 0; let refreshToken = 0;
@@ -201,30 +211,20 @@ export function watchShape<T = UnionSubject>(
emit(); emit();
} }
/** Extract candidate document NURIs from an opaque discovery `ref` every /** Resolve the logical scope the current doc set: the CURRENT wallet's own
* string, recursively, that looks like a NextGraph doc NURI (`did:ng:`). Generic: * entity documents for that scope, and nothing else. Tolerant: a resolution
* the app puts the entity doc NURI inside the ref it submits; we fold those docs * failure yields whatever resolved.
* into the read-set so the app need not orchestrate discovery. Non-NURI refs *
* contribute nothing (and readUnion+shape-filter drop anything irrelevant). */ * There is no "everything public" to fold in. You cannot discover; you can only
function nurisFromRef(ref: unknown, out: Set<Nuri>): void { * follow links, and a link reaches you through an inbox or through a document
if (typeof ref === "string") { * you already hold never through a shared index. A document someone gave you
if (ref.startsWith("did:ng:")) out.add(ref); * the cap for is read by naming it (`readModel.readUnion`), not by appearing in
return; * a scope you did not put it in. */
}
if (Array.isArray(ref)) {
for (const v of ref) nurisFromRef(v, out);
return;
}
if (ref && typeof ref === "object") {
for (const v of Object.values(ref)) nurisFromRef(v, out);
}
}
/** Resolve the logical scope the current doc set (my entity docs + discovery
* fold for `public`). Tolerant: a resolution failure yields whatever resolved. */
async function resolveDocs(): Promise<Nuri[]> { async function resolveDocs(): Promise<Nuri[]> {
const user = getCurrentUser(); const user = getCurrentUser();
const set = new Set<Nuri>(); const set = new Set<Nuri>();
resolving = true;
try {
if (user) { if (user) {
try { try {
for (const d of await listMyEntityDocs(user, scope)) set.add(d); for (const d of await listMyEntityDocs(user, scope)) set.add(d);
@@ -232,33 +232,22 @@ export function watchShape<T = UnionSubject>(
console.error("[watch-shape] listMyEntityDocs failed", error); console.error("[watch-shape] listMyEntityDocs failed", error);
} }
} }
if (scope === "public") { } finally {
try { resolving = false;
for (const e of await readIndex()) nurisFromRef(e.ref, set);
} catch (error) {
console.error("[watch-shape] discovery readIndex failed", error);
}
} }
return [...set]; return [...set];
} }
/** Subscribe to the CONTAINER documents (scope-index; discovery-index for public) /** Subscribe to the CONTAINER document (the scope index) so a change to the doc
* so a change to the doc SET re-resolves. Idempotent per NURI. */ * SET re-resolves. Idempotent per NURI. */
async function ensureContainerSubs(): Promise<void> { async function ensureContainerSubs(): Promise<void> {
const containers: Nuri[] = []; const containers: Nuri[] = [];
const user = getCurrentUser(); const user = getCurrentUser();
if (user) { if (user) {
try { try {
containers.push(await scopeIndexDoc(user, scope)); containers.push(await userStoreDoc(user, scope));
} catch (error) { } catch (error) {
console.error("[watch-shape] scopeIndexDoc failed", error); console.error("[watch-shape] userStoreDoc failed", error);
}
}
if (scope === "public") {
try {
containers.push(await indexDocNuri());
} catch (error) {
console.error("[watch-shape] indexDocNuri failed", error);
} }
} }
for (const c of containers) { for (const c of containers) {
@@ -341,6 +330,15 @@ export function watchShape<T = UnionSubject>(
function start(): void { function start(): void {
if (started) return; if (started) return;
started = true; started = true;
// A cap that arrives ASYNCHRONOUSLY (an inbox deposit absorbed by the
// consumer's `inbox.watch`) makes documents readable that were not. Without
// this the view would stay stale until some unrelated push happened to fire —
// so re-read whenever they change. This is the delivery channel key
// ROTATION uses too, which is why keeping an access needs no subscription
// obligation on the consumer's side.
capsUnsub = getCaps().onChange(() => {
if (!resolving) void refresh();
});
void refresh(); void refresh();
} }
@@ -372,6 +370,10 @@ export function watchShape<T = UnionSubject>(
} }
docSubs.clear(); docSubs.clear();
containerSubs.clear(); containerSubs.clear();
if (capsUnsub) {
capsUnsub();
capsUnsub = null;
}
started = false; started = false;
} }
}; };
+21 -4
View File
@@ -28,6 +28,8 @@ import {
resetConfig, resetConfig,
setCurrentUser, setCurrentUser,
getCurrentUser, getCurrentUser,
resetCaps,
connectedUser,
} from "../src/polyfill"; } from "../src/polyfill";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -97,6 +99,17 @@ afterAll(() => {
// Tests // Tests
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Two process-wide things bite this suite, which only wants to watch the log:
// - the cap registry: once ANY cap exists the reach guard applies to every reader;
// - connecting a user does WORK (restore + drain its inbox, see connect.ts), which
// both logs and files caps, asynchronously.
// So: let any in-flight connection finish, THEN clear. Awaiting rather than hoping
// is what makes this deterministic — `setCurrentUser` is fire-and-forget by design.
beforeEach(async () => {
await connectedUser();
resetCaps();
});
describe("access-log: OFF by default", () => { describe("access-log: OFF by default", () => {
beforeEach(() => { beforeEach(() => {
// Force the env var OFF for these tests, regardless of the shell environment. // Force the env var OFF for these tests, regardless of the shell environment.
@@ -147,8 +160,10 @@ describe("access-log: OFF by default", () => {
}); });
describe("access-log: ON via configure({ debugAccessLog: true })", () => { describe("access-log: ON via configure({ debugAccessLog: true })", () => {
beforeEach(() => { beforeEach(async () => {
setCurrentUser("alice"); setCurrentUser("alice");
await connectedUser(); // drain the connection work before counting log lines
resetCaps();
}); });
it("sparqlQuery emits a READ line with identity, nuri, label, and row-count", async () => { it("sparqlQuery emits a READ line with identity, nuri, label, and row-count", async () => {
@@ -262,9 +277,11 @@ describe("access-log: identity follows setCurrentUser", () => {
} finally { } finally {
restore(); restore();
} }
expect(lines.length).toBe(2); // Only this test's own lines: connecting a user legitimately logs its own reads.
expect(lines[0]).toMatch(/^\[first-user\]\[polyfill\] /); // identity-first, glued [polyfill] prefix const mine = lines.filter((l) => l.includes("step1") || l.includes("step2"));
expect(lines[1]).toMatch(/^\[second-user\]\[polyfill\] /); expect(mine.length).toBe(2);
expect(mine[0]).toMatch(/^\[first-user\]\[polyfill\] /); // identity-first, glued [polyfill] prefix
expect(mine[1]).toMatch(/^\[second-user\]\[polyfill\] /);
}); });
it("prefix is (none) when no identity is set", async () => { it("prefix is (none) when no identity is set", async () => {
+2 -2
View File
@@ -23,7 +23,6 @@ import { describe, it, expect, mock, beforeEach, afterAll } from "bun:test";
import { import {
ensureAccount, ensureAccount,
resolveAccount, resolveAccount,
loadShim,
resetRegistryCache, resetRegistryCache,
} from "../src/store-registry"; } from "../src/store-registry";
import type { RegistrySession } from "../src/store-registry"; import type { RegistrySession } from "../src/store-registry";
@@ -225,7 +224,8 @@ describe("deterministic resolution over a doc-shim corrupted by fork residue", (
const r1 = await resolveAccount("dupuser"); const r1 = await resolveAccount("dupuser");
resetRegistryCache(); resetRegistryCache();
const r2 = await resolveAccount("dupuser"); const r2 = await resolveAccount("dupuser");
const viaShim = (await loadShim()).get("dupuser"); resetRegistryCache();
const viaShim = await resolveAccount("dupuser");
// Canonical = lexicographically smallest → "did:ng:o:pub-a". // Canonical = lexicographically smallest → "did:ng:o:pub-a".
expect(r1?.docPublic).toBe("did:ng:o:pub-a"); expect(r1?.docPublic).toBe("did:ng:o:pub-a");
+144 -62
View File
@@ -1,83 +1,165 @@
/**
* caps.test.ts the cap surface as KEY POSSESSION.
*
* What these prove is a SHAPE, not a protection (the library is deliberately
* insecure until P1b): the only question the registry can answer is "do I hold
* this document's cap?", there is no principal to look up in a list, and no
* function turns a bare reference into a cap.
*/
import { test, expect } from "bun:test"; import { test, expect } from "bun:test";
import { CapRegistry } from "../src/caps"; import { CapRegistry } from "../src/caps";
import { hasReadCap, targetOf } from "../src/nuri";
import type { ReadCap } from "../src/types";
test("public documents are readable by anyone, even anonymous", () => { /** A registry whose holder the test drives. */
const caps = new CapRegistry(); function registry(initial: string | null = "alice") {
caps.open("did:ng:o:pub", "public", "alice"); let holder = initial;
expect(caps.canRead("did:ng:o:pub", null)).toBe(true); const caps = new CapRegistry(() => holder);
expect(caps.canRead("did:ng:o:pub", "bob")).toBe(true); return { caps, become: (id: string | null) => (holder = id) };
}
test("a cap NAMES and READS; the bare reference only names", () => {
const { caps } = registry();
const doc = "did:ng:o:doc1:v:overlay";
// Before anything: naming a document tells you nothing about reading it.
expect(caps.capFor(doc)).toBeUndefined();
const cap = caps.mint(doc);
expect(hasReadCap(cap)).toBe(true); // carries `:r:`
expect(hasReadCap(doc)).toBe(false);
expect(targetOf(cap)).toBe(doc); // same object, key inside
expect(caps.capFor(doc)).toBe(cap);
// Looking the cap up by the cap-bearing form resolves the same document.
expect(caps.capFor(cap)).toBe(cap);
}); });
test("protected documents: owner + explicitly granted principals only", () => { test("no cap is derivable from a bare reference — you look it up or you were given it", () => {
const caps = new CapRegistry(); const { caps } = registry();
caps.open("did:ng:o:prot", "protected", "alice"); caps.mint("did:ng:o:mine");
expect(caps.canRead("did:ng:o:prot", "alice")).toBe(true); // A document that never entered the held caps stays unreadable, however well-formed
expect(caps.canRead("did:ng:o:prot", "bob")).toBe(false); // its reference is. There is no `grantRead`, and no principal to name.
caps.grantRead("did:ng:o:prot", "bob"); // a directed grant issues bob the read cap expect(caps.capFor("did:ng:o:someone-else")).toBeUndefined();
expect(caps.canRead("did:ng:o:prot", "bob")).toBe(true);
}); });
test("private documents: owner only", () => { // Passing the naming form where the reading form is meant is now a COMPILE error
const caps = new CapRegistry(); // (`ReadCap` is a template literal type). The runtime refusal still has to hold,
caps.open("did:ng:o:priv", "private", "alice"); // because a JavaScript consumer — or a cap read back from storage, a URL or JSON
expect(caps.canRead("did:ng:o:priv", "alice")).toBe(true); // and cast rather than narrowed — never meets the compiler. The `as` below is
expect(caps.canRead("did:ng:o:priv", "bob")).toBe(false); // exactly that consumer: it is how the mistake reaches the library at all.
expect(caps.canRead("did:ng:o:priv", null)).toBe(false); // Unchecked, it would file a bare reference as its own cap and make the document
// read — the exact inversion this batch removes.
test("learn REFUSES a bare reference, even when the compiler was bypassed", () => {
const { caps } = registry();
const bare = "did:ng:o:someone-elses-doc" as ReadCap; // a JS consumer / an unchecked cast
expect(() => caps.learn(bare)).toThrow(/naming is not reading|bare reference/i);
expect(caps.capFor("did:ng:o:someone-elses-doc")).toBeUndefined(); // nothing was filed
expect(caps.isEnforcing()).toBe(false); // and nothing was issued
}); });
test("protectedDocsOf surfaces an owner's protected documents for directed grants", () => { test("holding one document's cap grants nothing on another (no inheritance)", () => {
const caps = new CapRegistry(); const { caps } = registry();
caps.open("did:ng:o:prot1", "protected", "alice"); caps.mint("did:ng:o:doc1");
caps.open("did:ng:o:prot2", "protected", "alice"); expect(caps.capFor("did:ng:o:doc1")).toBeDefined();
caps.open("did:ng:o:pub", "public", "alice"); // not protected → excluded expect(caps.capFor("did:ng:o:doc2")).toBeUndefined(); // separate repo, separate cap
caps.open("did:ng:o:priv", "private", "alice"); // not protected → excluded
caps.open("did:ng:o:bob", "protected", "bob"); // other owner → excluded
expect(caps.protectedDocsOf("alice").sort()).toEqual([
"did:ng:o:prot1",
"did:ng:o:prot2",
]);
expect(caps.protectedDocsOf("bob")).toEqual(["did:ng:o:bob"]);
expect(caps.protectedDocsOf("carol")).toEqual([]);
// A directed grant on one of them makes the reader read that doc only.
caps.grantRead("did:ng:o:prot1", "carol");
expect(caps.canRead("did:ng:o:prot1", "carol")).toBe(true);
expect(caps.canRead("did:ng:o:prot2", "carol")).toBe(false);
}); });
test("write is restricted to write-cap holders; the creator always holds it", () => { test("one set of held caps PER holder: switching identity switches heldByHolder, it does not wipe", () => {
const caps = new CapRegistry(); const { caps, become } = registry("alice");
caps.open("did:ng:o:pub", "public", "alice"); const doc = "did:ng:o:alice-doc";
expect(caps.canWrite("did:ng:o:pub", "alice")).toBe(true); const cap = caps.mint(doc);
expect(caps.canWrite("did:ng:o:pub", "bob")).toBe(false);
expect(caps.canWrite("did:ng:o:pub", null)).toBe(false); become("bob");
expect(caps.capFor(doc)).toBeUndefined(); // bob holds nothing of alice's
become("alice");
expect(caps.capFor(doc)).toBe(cap); // …and alice did not lose hers
}); });
test("holding a document's cap does NOT grant another document (no inheritance)", () => { test("a cap received (learn) reads, exactly like one minted", () => {
const caps = new CapRegistry(); const alice = registry("alice");
caps.grantRead("did:ng:o:doc1", "alice"); const doc = "did:ng:o:shared";
expect(caps.canRead("did:ng:o:doc1", "alice")).toBe(true); const cap = alice.caps.mint(doc);
expect(caps.canRead("did:ng:o:doc2", "alice")).toBe(false); // separate repo, separate cap
const bob = registry("bob");
expect(bob.caps.capFor(doc)).toBeUndefined();
bob.caps.learn(cap); // delivered to bob's inbox, absorbed
expect(bob.caps.capFor(doc)).toBe(cap);
}); });
test("governsRead / hasReadPolicy distinguish governed from ungoverned documents", () => { test("publishRepoLink returns a cap-bearing link; reading it still means HOLDING it", () => {
const caps = new CapRegistry(); const { caps, become } = registry("alice");
expect(caps.hasReadPolicy()).toBe(false); const doc = "did:ng:o:public-doc";
caps.grantRead("did:ng:o:doc1", "alice"); const link = caps.publishRepoLink(doc);
expect(caps.hasReadPolicy()).toBe(true);
expect(caps.governsRead("did:ng:o:doc1")).toBe(true); expect(hasReadCap(link)).toBe(true);
expect(caps.governsRead("did:ng:o:unknown")).toBe(false); // not declared → not enforced expect(targetOf(link)).toBe(doc);
expect(caps.isPublished(doc)).toBe(true);
expect(caps.isPublished("did:ng:o:other")).toBe(false);
// Publication is not a world-wide read grant: whoever HAS the URL reads it.
become("bob");
expect(caps.capFor(doc)).toBeUndefined();
caps.learn(link); // bob received the link (e.g. from the discovery index)
expect(caps.capFor(doc)).toBe(link);
}); });
test("governsWrite / hasWritePolicy distinguish governed from ungoverned documents", () => { test("open(): a public document is published as a link, a private one is not", () => {
const caps = new CapRegistry(); const { caps } = registry();
const pub = caps.open("did:ng:o:pub", "public");
const prot = caps.open("did:ng:o:prot", "protected");
const priv = caps.open("did:ng:o:priv", "private");
expect(caps.isPublished("did:ng:o:pub")).toBe(true);
expect(caps.isPublished("did:ng:o:prot")).toBe(false);
expect(caps.isPublished("did:ng:o:priv")).toBe(false);
// All three are readable BY THEIR OWNER — a creator is never locked out.
for (const [doc, cap] of [["did:ng:o:pub", pub], ["did:ng:o:prot", prot], ["did:ng:o:priv", priv]] as const) {
expect(caps.capFor(doc)).toBe(cap);
}
});
test("open() is idempotent — re-listing my own documents refiles the same caps", () => {
const { caps } = registry();
const first = caps.open("did:ng:o:doc", "protected");
let fired = 0;
caps.onChange(() => (fired += 1));
expect(caps.open("did:ng:o:doc", "protected")).toBe(first);
expect(fired).toBe(0); // nothing changed → no spurious re-read
});
test("isEnforcing is false until the first cap exists, then holds for every holder", () => {
const { caps, become } = registry("alice");
expect(caps.isEnforcing()).toBe(false);
caps.mint("did:ng:o:doc1");
expect(caps.isEnforcing()).toBe(true);
// …including for a holder whose own holds nothing: that IS the isolation.
become("bob");
expect(caps.isEnforcing()).toBe(true);
expect(caps.capFor("did:ng:o:doc1")).toBeUndefined();
});
test("a cap arriving fires the change signal — an asynchronous delivery must re-trigger reads", () => {
const { caps } = registry();
let fired = 0;
const unsub = caps.onChange(() => (fired += 1));
caps.learn(caps.mint("did:ng:o:doc1")); // mint fires once; the learn is a no-op
expect(fired).toBe(1);
unsub();
caps.mint("did:ng:o:doc2");
expect(fired).toBe(1); // unsubscribed
});
test("write is restricted to write-cap holders (decorative until P1b)", () => {
const { caps } = registry();
expect(caps.hasWritePolicy()).toBe(false); expect(caps.hasWritePolicy()).toBe(false);
caps.open("did:ng:o:doc1", "private", "alice"); // owner gets the write cap caps.grantWrite("did:ng:o:doc", "alice");
expect(caps.hasWritePolicy()).toBe(true); expect(caps.hasWritePolicy()).toBe(true);
expect(caps.governsWrite("did:ng:o:doc1")).toBe(true); expect(caps.governsWrite("did:ng:o:doc")).toBe(true);
expect(caps.governsWrite("did:ng:o:unknown")).toBe(false); // not declared → not enforced expect(caps.governsWrite("did:ng:o:unknown")).toBe(false); // not declared → not enforced
// A public doc grants read to all but its write cap is still owner-only. expect(caps.canWrite("did:ng:o:doc", "alice")).toBe(true);
const pub = new CapRegistry(); expect(caps.canWrite("did:ng:o:doc", "bob")).toBe(false);
pub.open("did:ng:o:pub", "public", "alice"); expect(caps.canWrite("did:ng:o:doc", null)).toBe(false);
expect(pub.hasWritePolicy()).toBe(true);
expect(pub.governsWrite("did:ng:o:pub")).toBe(true);
}); });
+13 -1
View File
@@ -13,7 +13,7 @@
* *
* The heal: `resolveAccount`/`loadShim`/`ensureAccount` call `ensureRepoOpen(anchor)` * The heal: `resolveAccount`/`loadShim`/`ensureAccount` call `ensureRepoOpen(anchor)`
* (open-repo.ts, via `doc_subscribe` + first-`State` barrier) before touching the * (open-repo.ts, via `doc_subscribe` + first-`State` barrier) before touching the
* shim the same open-before-read guard `readScopeIndex` already applies to its * shim the same open-before-read guard `readUserStore` already applies to its
* index doc. This suite models a fake `ng` where the anchor throws `RepoNotFound` * index doc. This suite models a fake `ng` where the anchor throws `RepoNotFound`
* UNTIL it has been `doc_subscribe`-d, and asserts the registry provisions cleanly. * UNTIL it has been `doc_subscribe`-d, and asserts the registry provisions cleanly.
* *
@@ -28,7 +28,10 @@ import {
configureStoreRegistry, configureStoreRegistry,
resetStoreRegistry, resetStoreRegistry,
resetConfig, resetConfig,
resetCaps,
setCurrentUser,
} from "../src/polyfill"; } from "../src/polyfill";
import { resetInfrastructure } from "../src/reach";
const SESSION = { sessionId: "sid-cold", privateStoreId: "PRIV-COLD" }; const SESSION = { sessionId: "sid-cold", privateStoreId: "PRIV-COLD" };
const ANCHOR = `did:ng:${SESSION.privateStoreId}`; const ANCHOR = `did:ng:${SESSION.privateStoreId}`;
@@ -38,11 +41,20 @@ afterAll(() => {
resetStoreRegistry(); resetStoreRegistry();
resetRegistryCache(); resetRegistryCache();
resetOpenedRepos(); resetOpenedRepos();
resetCaps();
resetInfrastructure();
setCurrentUser(null);
}); });
// The reach guard is process-wide and so is the cap registry: once ANY cap exists
// the boundary applies to every reader. A suite that declares none must therefore
// start from an empty one, or it inherits another suite's enforcement.
beforeEach(() => { beforeEach(() => {
resetRegistryCache(); resetRegistryCache();
resetOpenedRepos(); resetOpenedRepos();
resetCaps();
resetInfrastructure();
setCurrentUser(null);
}); });
interface Quad { g: string; s: string; p: string; o: string } interface Quad { g: string; s: string; p: string; o: string }
@@ -0,0 +1,527 @@
/**
* Cross-user access the scenario that proves the model end to end.
*
* Alice owns a PROTECTED document and a PUBLIC one, and the public one carries a
* REFERENCE to the protected one. Then:
*
* - **Bob** has the public document's link. He reads it, sees the reference, and
* cannot read what it points at. Naming is not reading, and publication is
* **not recursive**: a public object may point at private content without
* disclosing it.
* - **Charlie** has the public document's link AND was given the protected
* document's cap. Same reference, same path he reads through it.
* - **Bob, dynamically**: Alice delivers the cap to Bob's inbox. Processing the
* inbox files it, which fires the held-caps signal, which re-runs the read the
* protected document appears with nothing else happening.
*
* The difference between Bob and Charlie is ONLY each of them holds. There is
* no authorization list anywhere, and nobody was named to the registry.
*/
import { test, expect, mock, afterAll } from "bun:test";
import {
createEntityDoc,
documentInboxAddress,
openDocumentInbox,
resetRegistryCache,
walletInbox,
} from "../src/store-registry";
import type { RegistrySession } from "../src/store-registry";
import {
configure,
configureStoreRegistry,
resetStoreRegistry,
resetConfig,
capFor,
getCaps,
resetCaps,
setCurrentUser,
shareCap,
connectedUser,
} from "../src/polyfill";
import { post, postToDocument, read as readInbox } from "../src/inbox";
import { readUnion } from "../src/read-model";
import { sparqlUpdate } from "../src/docs";
import type { Nuri } from "../src/types";
afterAll(() => {
resetConfig();
resetStoreRegistry();
resetCaps();
setCurrentUser(null);
});
const SESSION: RegistrySession = { sessionId: "sid-x", privateStoreId: "PRIV-X" };
const SHIM = "urn:ng-eventually:shim";
const INBOX = "urn:ng-eventually:inbox";
/** The predicate Alice uses to point from her public doc at her protected one. */
const REFERS_TO = "urn:e2e:refersTo";
const SECRET = "urn:e2e:secret";
interface Quad { g: string; s: string; p: string; o: string }
function unescapeLiteral(s: string): string {
let out = "";
for (let i = 0; i < s.length; i++) {
if (s[i] === "\\" && i + 1 < s.length) {
const next = s[++i];
out += next === "n" ? "\n" : next === "r" ? "\r" : next === "t" ? "\t" : next!;
} else out += s[i];
}
return out;
}
/** A stateful fake `ng`: the shim SPARQL, the inbox SPARQL, and the anchored
* per-doc `?s ?p ?o` read the read-model uses. */
function makeFakeNg() {
const quads: Quad[] = [];
let docCounter = 0;
const doc_create = mock(async () => `did:ng:o:doc${++docCounter}`);
const sparql_update = mock(async (...a: unknown[]) => {
const query = a[1] as string;
const anchor = a[2] as string | 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 sm = body.match(/<([^>]+)>/);
if (!sm) return undefined;
const s = sm[1]!;
const pairRe = /(?:a|<([^>]+)>)\s+(?:"((?:[^"\\]|\\.)*)"|<([^>]+)>)/g;
let m: RegExpExecArray | null;
const after = body.slice(body.indexOf(sm[0]) + sm[0].length);
while ((m = pairRe.exec(after)) !== null) {
const p = m[1] ?? (query.includes(`${INBOX}:Deposit`) ? `${INBOX}:Deposit` : `${SHIM}:Account`);
const o = m[2] !== undefined ? unescapeLiteral(m[2]) : (m[3] ?? "");
quads.push({ g: anchor, s, p, o });
}
return undefined;
});
const sparql_query = mock(async (...a: unknown[]) => {
const query = a[1] as string;
const anchor = a[3] as string | undefined;
if (query.includes(`<${SHIM}:shimDoc>`)) {
return { results: { bindings: quads.filter((q) => q.g === anchor && q.p === `${SHIM}:shimDoc`).map((q) => ({ shimDoc: { value: q.o } })) } };
}
if (query.includes(`<${SHIM}:id>`)) {
const subjM = query.match(/<([^>]+)>\s+a\s+<urn:ng-eventually:shim:Account>/);
const only = subjM ? subjM[1]! : null;
const bySubject = new Map<string, Record<string, string>>();
for (const q of quads) {
if (q.g !== anchor) continue;
if (only !== null && q.s !== only) continue;
const rec = bySubject.get(q.s) ?? {};
if (q.p === `${SHIM}:id`) rec.id = q.o;
if (q.p === `${SHIM}:docPublic`) rec.docPublic = q.o;
if (q.p === `${SHIM}:docProtected`) rec.docProtected = q.o;
if (q.p === `${SHIM}:docPrivate`) rec.docPrivate = q.o;
bySubject.set(q.s, rec);
}
return {
results: {
bindings: [...bySubject.values()].filter((r) => r.id).map((r) => ({
id: { value: r.id! },
docPublic: { value: r.docPublic ?? "" },
docProtected: { value: r.docProtected ?? "" },
docPrivate: { value: r.docPrivate ?? "" },
})),
},
};
}
if (query.includes(`<${INBOX}:payload>`)) {
const bySubject = new Map<string, Record<string, string>>();
for (const q of quads) {
if (q.g !== anchor) continue;
const rec = bySubject.get(q.s) ?? {};
if (q.p === `${INBOX}:payload`) rec.payload = q.o;
if (q.p === `${INBOX}:ts`) rec.ts = q.o;
if (q.p === `${INBOX}:from`) rec.from = q.o;
bySubject.set(q.s, rec);
}
return {
results: {
bindings: [...bySubject.values()]
.filter((r) => r.payload !== undefined && r.ts !== undefined)
.map((r) => {
const row: Record<string, { value: string }> = { payload: { value: r.payload! }, ts: { value: r.ts! } };
if (r.from !== undefined) row.from = { value: r.from };
return row;
}),
},
};
}
// User-branch `link` SELECT (the emulated AddLink records).
// User-branch `inboxCap` SELECT (the emulated AddInboxCap records).
if (query.includes(`<${SHIM}:inboxCap>`)) {
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).
if (query.includes(`<${SHIM}:readCap>`)) {
return { results: { bindings: quads.filter((q) => q.g === anchor && q.p === `${SHIM}:readCap`).map((q) => ({ c: { value: q.o } })) } };
}
if (query.includes(`<${SHIM}:link>`)) {
return { results: { bindings: quads.filter((q) => q.g === anchor && q.p === `${SHIM}:link`).map((q) => ({ c: { value: q.o } })) } };
}
if (query.includes(`<${SHIM}:contains>`)) {
return { results: { bindings: quads.filter((q) => q.g === anchor && q.p === `${SHIM}:contains`).map((q) => ({ e: { value: q.o } })) } };
}
// Anchored per-doc read (readUnion `SELECT ?s ?p ?o`) — the document's content.
return {
results: {
bindings: quads
.filter((q) => q.g === anchor)
.map((q) => ({ s: { value: q.s }, p: { value: q.p }, o: { value: q.o } })),
},
};
});
return { doc_create, sparql_update, sparql_query, _quads: quads };
}
function inject() {
const ng = makeFakeNg();
configure({ ng: ng as any, useShape: (() => {}) as any });
configureStoreRegistry({ getSession: async () => SESSION, normalizeId: (id) => id.trim().toLowerCase() });
resetRegistryCache();
resetCaps();
setCurrentUser(null);
return ng;
}
/** Write one triple into `doc`, as the consumer's write path would. */
async function write(doc: Nuri, p: string, o: string): Promise<void> {
await sparqlUpdate(SESSION.sessionId, `INSERT DATA { <${doc}> <${p}> "${o}" }`, doc, "test");
}
/** The values `p` carries in the documents `docs`, as the current holder reads them. */
async function readValues(docs: Nuri[], p: string): Promise<string[]> {
const subjects = await readUnion(docs);
return subjects.flatMap((s) => s.props[p] ?? []);
}
/**
* Alice's world: a protected document holding a secret, and a public document that
* REFERS to it by bare NURI. Returns what each actor could plausibly come to hold.
*/
async function aliceSetsUpHerDocuments() {
setCurrentUser("alice");
const protDoc = await createEntityDoc("alice", "protected");
await write(protDoc, SECRET, "the-protected-content");
const pubDoc = await createEntityDoc("alice", "public");
// The reference is the BARE NURI of the protected document: it names it, and
// grants nothing. This is the whole point of the scenario.
await write(pubDoc, REFERS_TO, protDoc);
const pubLink = capFor(pubDoc)!; // the shareable repo link of the public doc
const protCap = capFor(protDoc)!; // the cap Alice may hand to whoever she chooses
return { protDoc, pubDoc, pubLink, protCap };
}
/** Follow the reference found in the public document — what a reader actually does. */
function referenceFoundIn(values: string[]): Nuri {
const ref = values[0];
expect(ref).toBeDefined();
return ref as Nuri;
}
test("Bob: reads the public document, sees the reference, and cannot read through it", async () => {
inject();
const { protDoc, pubDoc, pubLink } = await aliceSetsUpHerDocuments();
setCurrentUser("bob");
// Bob was given the public document's link — "whoever has the URL reads it".
getCaps().learn(pubLink);
// He reads the public document and finds the reference.
const refs = await readValues([pubDoc], REFERS_TO);
const ref = referenceFoundIn(refs);
expect(ref).toBe(protDoc); // he can NAME Alice's protected document
// …and that is all it gets him: no cap, no read. Publication is NOT recursive.
expect(capFor(ref)).toBeUndefined();
expect(await readValues([ref], SECRET)).toEqual([]);
});
test("Charlie: same public document, same reference — and he reads through it", async () => {
inject();
const { protDoc, pubDoc, pubLink, protCap } = await aliceSetsUpHerDocuments();
const CHARLIE_INBOX = await walletInbox("charlie");
// Alice decides Charlie may read that ONE document, and delivers its cap to his
// inbox. She names no principal to the registry; she addresses an inbox.
setCurrentUser("alice");
await shareCap(protCap, CHARLIE_INBOX);
setCurrentUser("charlie");
getCaps().learn(pubLink);
await readInbox(CHARLIE_INBOX); // processing the inbox files the cap
const ref = referenceFoundIn(await readValues([pubDoc], REFERS_TO));
expect(ref).toBe(protDoc);
expect(capFor(ref)).toBe(protCap);
expect(await readValues([ref], SECRET)).toEqual(["the-protected-content"]);
});
test("the ONLY difference between Bob and Charlie is each of them holds", async () => {
inject();
const { protDoc, pubLink, protCap } = await aliceSetsUpHerDocuments();
const CHARLIE_INBOX = await walletInbox("charlie");
setCurrentUser("alice");
await shareCap(protCap, CHARLIE_INBOX);
setCurrentUser("bob");
getCaps().learn(pubLink);
const bobSees = await readValues([protDoc], SECRET);
setCurrentUser("charlie");
getCaps().learn(pubLink);
await readInbox(CHARLIE_INBOX);
const charlieSees = await readValues([protDoc], SECRET);
expect(bobSees).toEqual([]);
expect(charlieSees).toEqual(["the-protected-content"]);
});
// The dynamic version: Bob is refused, then the cap lands in his inbox and the read
// that was empty becomes full — with nothing re-declared and nobody re-authorized.
test("dynamic: a cap delivered to Bob's inbox makes the refused document readable, and signals it", async () => {
inject();
const { pubDoc, pubLink, protCap } = await aliceSetsUpHerDocuments();
const BOB_INBOX = await walletInbox("bob");
setCurrentUser("bob");
getCaps().learn(pubLink);
const ref = referenceFoundIn(await readValues([pubDoc], REFERS_TO));
// Before: named, unreadable.
expect(await readValues([ref], SECRET)).toEqual([]);
// A reader that re-reads whenever what it holds changes — this is exactly what
// `watchShape` wires internally, played here on an ad-hoc read.
let reread = 0;
let latest: string[] = [];
const unsub = getCaps().onChange(() => {
reread += 1;
void readValues([ref], SECRET).then((v) => (latest = v));
});
// Alice delivers the cap. Bob's client processes his inbox — the only thing that
// happens; no "receive" call exists.
setCurrentUser("alice");
await shareCap(protCap, BOB_INBOX);
setCurrentUser("bob");
await readInbox(BOB_INBOX);
// Filing the cap fired the signal…
expect(reread).toBeGreaterThan(0);
await Promise.resolve();
await new Promise((r) => setTimeout(r, 0));
// …and the read that was empty now yields the content.
expect(capFor(ref)).toBe(protCap);
expect(latest).toEqual(["the-protected-content"]);
expect(await readValues([ref], SECRET)).toEqual(["the-protected-content"]);
unsub();
});
test("a bare reference to the PUBLIC document is not enough either — the link is", async () => {
inject();
const { pubDoc, pubLink } = await aliceSetsUpHerDocuments();
setCurrentUser("bob");
// Bob knows the public document's NURI but was never given its link.
expect(await readValues([pubDoc], REFERS_TO)).toEqual([]);
getCaps().learn(pubLink);
expect((await readValues([pubDoc], REFERS_TO)).length).toBe(1);
});
// THE POINT OF THE LINK: a cap survives because it was APPLIED, not because the
// inbox is re-read. Upstream, processing an inbox message files it — `AddLink
// { read_cap }` on the User branch of the private store — and the queue is consumed.
// Re-reading a queue to recover state is using it as a database.
test("a Link is APPLIED durably: the cap survives with the inbox emptied", async () => {
const ng = inject();
const { protDoc, protCap } = await aliceSetsUpHerDocuments();
const bobInbox = await walletInbox("bob");
setCurrentUser("alice");
await shareCap(protCap, bobInbox);
// Bob connects: the library restores + drains, with nothing asked of the app.
setCurrentUser("bob");
await connectedUser();
expect(await readValues([protDoc], SECRET)).toEqual(["the-protected-content"]);
// Now EMPTY the inbox — as a consumed queue would be — and drop every in-memory
// cap, then re-arm the emulation so the boundary is actually in force again.
for (let k = ng._quads.length - 1; k >= 0; k--) {
if (ng._quads[k]!.g === bobInbox) ng._quads.splice(k, 1);
}
resetCaps();
setCurrentUser("alice");
await createEntityDoc("alice", "private"); // re-arms: a cap exists again
setCurrentUser("bob");
expect(await readValues([protDoc], SECRET)).toEqual([]); // bob holds nothing yet
// Connecting restores it — from the User branch, since the inbox has nothing left.
await connectedUser();
expect(capFor(protDoc)).toBe(protCap);
expect(await readValues([protDoc], SECRET)).toEqual(["the-protected-content"]);
});
test("connecting a user that does not exist provisions nothing", async () => {
inject();
setCurrentUser("nobody");
await connectedUser();
// No account, no stores, no caps — connecting must not create a user as a side
// effect, or the emulation would arm itself in the background.
expect(getCaps().isEnforcing()).toBe(false);
});
// PER-DOCUMENT INBOXES. Upstream a repo carries `inbox: Option<PrivKey>` and its
// owner records the private half with `AddInboxCap` on the User branch — the same
// branch as `AddLink`. So "which inboxes may I read" has one answer, and connecting
// drains them all: the user's own, and one per document it opened an inbox on.
test("a document has its own inbox: anyone deposits, only the owner reads", async () => {
inject();
setCurrentUser("alice");
const doc = await createEntityDoc("alice", "public");
const aliceInbox = await openDocumentInbox(doc);
expect(aliceInbox).not.toBe(await walletInbox("alice"));
const link = capFor(doc)!; // the repo link alice circulates — links DO travel
// 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");
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 he cannot read it back: depositing grants nothing.
await expect(readInbox(bobTarget!)).rejects.toThrow(/does not belong to the connected wallet/i);
// Alice reads her document's inbox, because she opened it.
setCurrentUser("alice");
const deposits = await readInbox(aliceInbox);
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 () => {
inject();
setCurrentUser("alice");
const protDoc = await createEntityDoc("alice", "protected");
const pubDoc = await createEntityDoc("alice", "public");
const docInbox = await openDocumentInbox(pubDoc);
const aliceInbox = await walletInbox("alice");
// Two deposits, one at each level, both made by someone else.
setCurrentUser("carol");
const carolDoc = await createEntityDoc("carol", "protected");
await shareCap(capFor(carolDoc)!, aliceInbox); // a Link, to alice herself
await post(docInbox, { payload: { onTheDocument: true }, ts: 2 });
// Alice connects: one call, both queues.
setCurrentUser("alice");
await connectedUser();
expect(capFor(carolDoc)).toBeDefined(); // the Link was applied
expect(await readValues([protDoc], SECRET)).toEqual([]); // (protDoc holds no secret here)
const left = await readInbox(docInbox);
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);
});
-333
View File
@@ -1,333 +0,0 @@
import { test, expect, mock, beforeEach, afterAll } from "bun:test";
import { submitToIndex, readIndex, watchIndex, INDEX_ACCOUNT } from "../src/discovery";
import type { IndexEntry } from "../src/discovery";
import {
configure,
configureStoreRegistry,
resetStoreRegistry,
resetConfig,
setCurrentUser,
getCaps,
resetCaps,
} from "../src/polyfill";
import { resetRegistryCache, ensureAccount } from "../src/store-registry";
import type { RegistrySession } from "../src/store-registry";
// discovery.ts submits to / reads from a global index owned by a RESERVED
// SPECIAL ACCOUNT (@index) in the shim. This suite injects one fake `ng` that
// emulates BOTH the shim SPARQL (ensureAccount('@index') → doc_create ×3 +
// shim INSERT/SELECT) AND the inbox SPARQL (deposit INSERT + read SELECT), over
// a single in-memory quad store. Restore un-configured state at the end.
afterAll(() => {
resetConfig();
resetStoreRegistry();
setCurrentUser(null);
resetCaps();
});
test("throws a clear error when configureStoreRegistry() was not called", async () => {
resetStoreRegistry();
resetRegistryCache();
await expect(submitToIndex({ ref: 1 })).rejects.toThrow(
/configureStoreRegistry\(\) must be called before use/,
);
});
interface Quad { g: string; s: string; p: string; o: string }
const SHIM = "urn:ng-eventually:shim";
const INBOX = "urn:ng-eventually:inbox";
/** Reverse of the lib's escapeLiteral: single left-to-right pass over `\x`. */
function unescapeLiteral(s: string): string {
let out = "";
for (let i = 0; i < s.length; i++) {
if (s[i] === "\\" && i + 1 < s.length) {
const next = s[++i];
out += next === "n" ? "\n" : next === "r" ? "\r" : next === "t" ? "\t" : next;
} else {
out += s[i];
}
}
return out;
}
// A stateful fake `ng` serving BOTH the shim and the inbox SPARQL.
function makeFakeNg() {
const quads: Quad[] = [];
let docCounter = 0;
// Reactive subscriptions (see inbox.test.ts): doc_subscribe registers a
// callback per anchor + fires an initial push; sparql_update pushes a Patch to
// that anchor's subscribers, so discovery.watchIndex (now event-driven) works
// without a timer.
const subs = new Map<string, Set<(r: unknown) => void>>();
const doc_subscribe = mock(async (nuri: string, _sid: unknown, cb: (r: unknown) => void) => {
let set = subs.get(nuri);
if (!set) {
set = new Set();
subs.set(nuri, set);
}
set.add(cb);
queueMicrotask(() => cb({ V0: { State: { doc: nuri } } }));
return () => set!.delete(cb);
});
const pushTo = (anchor: string): void => {
for (const cb of subs.get(anchor) ?? []) cb({ V0: { Patch: { doc: anchor } } });
};
const doc_create = mock(async (..._a: unknown[]) => `did:ng:o:doc${++docCounter}`);
const sparql_update = mock(async (...a: unknown[]) => {
const query = a[1] as string;
const anchor = a[2] as string | undefined;
// TWO shapes coexist: the shim account write STILL uses `GRAPH <${priv}>`
// (the private-store repo's graph name equals the plain store NURI → it
// round-trips; key by that GRAPH IRI). The inbox deposit write has NO
// explicit GRAPH — the real broker keys it by the ANCHORED repo's default
// graph (repo_graph_name(id, overlay)); key it by the ANCHOR arg (a[2]).
const gm = query.match(/GRAPH <([^>]+)>\s*\{([\s\S]*)\}/);
let g: string;
let body: string;
if (gm) {
g = gm[1]!;
body = gm[2]!;
} else {
if (!anchor) return undefined;
g = anchor;
body = query.replace(/^\s*INSERT DATA\s*\{/, "").replace(/\}\s*$/, "");
}
const sm = body.match(/<([^>]+)>/);
if (!sm) return undefined;
const s = sm[1]!;
const after = body.slice(body.indexOf(sm[0]) + sm[0].length);
const pairRe = /(?:a|<([^>]+)>)\s+(?:"((?:[^"\\]|\\.)*)"|<([^>]+)>)/g;
let m: RegExpExecArray | null;
while ((m = pairRe.exec(after)) !== null) {
// `a` → an rdf:type marker; the two type IRIs the modules use differ, so
// pick by which body we're in (deposit vs account) — harmless if wrong,
// the SELECT filters by the real predicates below.
const isDeposit = query.includes(`${INBOX}:Deposit`);
const p = m[1] ?? (isDeposit ? `${INBOX}:Deposit` : `${SHIM}:Account`);
const o = m[2] !== undefined ? unescapeLiteral(m[2]) : (m[3] ?? "");
quads.push({ g, s, p, o });
}
pushTo(g); // local-push to the written graph's subscribers
return undefined;
});
const sparql_query = mock(async (...a: unknown[]) => {
const query = a[1] as string;
const anchor = a[3] as string | undefined;
// Pointer SELECT: `<shim:root> <shim:shimDoc> ?shimDoc` in the store-root graph.
if (query.includes(`<${SHIM}:shimDoc>`)) {
const bindings = quads
.filter((q) => q.g === anchor && q.p === `${SHIM}:shimDoc`)
.map((q) => ({ shimDoc: { value: q.o } }));
return { results: { bindings } };
}
// Shim account SELECT (anchored to the doc-shim, no GRAPH wrapper). Two shapes:
// the full scan (`?acc a <Account>`) and the TARGETED bounded resolve (`<subj> a
// <Account>`) — honour that subject filter so the bounded query is O(1)/exact.
if (query.includes(`<${SHIM}:id>`)) {
const subjM = query.match(new RegExp(`<([^>]+)>\\s+a\\s+<${SHIM}:Account>`));
const onlySubject = subjM ? subjM[1]! : null;
const bySubject = new Map<string, Record<string, string>>();
for (const q of quads) {
if (q.g !== anchor) continue;
if (onlySubject !== null && q.s !== onlySubject) continue;
const rec = bySubject.get(q.s) ?? {};
if (q.p === `${SHIM}:id`) rec.id = q.o;
if (q.p === `${SHIM}:docPublic`) rec.docPublic = q.o;
if (q.p === `${SHIM}:docProtected`) rec.docProtected = q.o;
if (q.p === `${SHIM}:docPrivate`) rec.docPrivate = q.o;
bySubject.set(q.s, rec);
}
const bindings = [...bySubject.values()]
.filter((r) => r.id)
.map((r) => ({
id: { value: r.id! },
docPublic: { value: r.docPublic ?? "" },
docProtected: { value: r.docProtected ?? "" },
docPrivate: { value: r.docPrivate ?? "" },
}));
return { results: { bindings } };
}
// Inbox deposit SELECT (?payload ?ts ?from).
if (query.includes(`<${INBOX}:payload>`)) {
const bySubject = new Map<string, Record<string, string>>();
for (const q of quads) {
if (q.g !== anchor) continue;
if (q.p === `${INBOX}:Deposit`) {
if (!bySubject.has(q.s)) bySubject.set(q.s, {});
continue;
}
const rec = bySubject.get(q.s) ?? {};
if (q.p === `${INBOX}:payload`) rec.payload = q.o;
if (q.p === `${INBOX}:ts`) rec.ts = q.o;
if (q.p === `${INBOX}:from`) rec.from = q.o;
bySubject.set(q.s, rec);
}
const bindings = [...bySubject.values()]
.filter((r) => r.payload !== undefined && r.ts !== undefined)
.map((r) => {
const row: Record<string, { value: string }> = {
payload: { value: r.payload! },
ts: { value: r.ts! },
};
if (r.from !== undefined) row.from = { value: r.from };
return row;
});
return { results: { bindings } };
}
// Entity-index SELECT (shim contains) — unused here.
return { results: { bindings: [] } };
});
return { doc_create, doc_subscribe, sparql_update, sparql_query, _quads: quads };
}
const SESSION: RegistrySession = { sessionId: "sid-1", privateStoreId: "PRIV" };
function inject() {
const ng = makeFakeNg();
configure({ ng: ng as any, useShape: (() => {}) as any });
configureStoreRegistry({
getSession: async () => SESSION,
normalizeId: (u) => u.trim().replace(/^@+/, "").toLowerCase(),
});
resetRegistryCache();
setCurrentUser(null);
return ng;
}
let fake: ReturnType<typeof makeFakeNg>;
beforeEach(() => {
fake = inject();
});
test("submitToIndex creates the @index special account on first sight (3 docs)", async () => {
await submitToIndex({ nuri: "did:ng:o:event1", title: "Concert" });
// ensureAccount('@index') created its 3 scope docs + 1 doc-shim (first login).
expect(fake.doc_create).toHaveBeenCalledTimes(4);
// The deposit landed in the @index public document (its inbox).
const depositCall = fake.sparql_update.mock.calls.find((c) =>
(c[1] as string).includes(`${INBOX}:Deposit`),
)!;
expect(depositCall, "a deposit INSERT was issued").not.toBeUndefined();
expect(depositCall[2]).toMatch(/^did:ng:o:doc/); // the index document NURI
});
test("submit → read round-trips the reference as an index entry", async () => {
setCurrentUser("alice"); // `from` is bound to the current identity
const ref = { nuri: "did:ng:o:event1", title: "Concert au parc" };
await submitToIndex(ref, { from: "alice", ts: 100 });
const entries = await readIndex();
expect(entries).toHaveLength(1);
expect(entries[0]).toEqual({ ref, from: "alice", ts: 100 } as IndexEntry);
});
test("a reference submitted by A is discovered by a NON-connected reader via the index", async () => {
// A submits (identified). No connection is ever declared. A separate reader
// materializes the SAME index (same special account → same document) and sees
// the reference — discovery is via the index, not any direct fan-out/link.
setCurrentUser("alice");
const ref = { nuri: "did:ng:o:evA", title: "Public event by A" };
await submitToIndex(ref, { ts: 100 });
// Reader B: a fresh cache, never connected to A, reads the index.
resetRegistryCache();
setCurrentUser("bob");
const entries = await readIndex();
const refs = entries.map((e) => e.ref);
expect(refs).toContainEqual(ref);
expect(entries.find((e) => JSON.stringify(e.ref) === JSON.stringify(ref))!.from).toBe("alice");
});
test("readIndex deduplicates identical references (materialization moderation point)", async () => {
const ref = { nuri: "did:ng:o:dup", title: "Twice" };
// Anonymous submissions (dedup keys on the ref, not the submitter).
await submitToIndex(ref, { from: null, ts: 100 });
await submitToIndex(ref, { from: null, ts: 200 }); // duplicate reference
const entries = await readIndex();
expect(entries).toHaveLength(1); // surfaced once
});
test("from: null makes an anonymous submission", async () => {
await submitToIndex({ nuri: "did:ng:o:anon" }, { from: null, ts: 100 });
const entries = await readIndex();
expect(entries[0]!.from).toBeNull();
});
// (d) PUBLIC-ONLY: a protected/private document must NOT be submittable to the
// world-readable discovery index; a public (or ungoverned) document is fine.
test("(d) submitToIndex refuses a PROTECTED/PRIVATE document (public-only)", async () => {
resetCaps();
// A PROTECTED and a PRIVATE governed document, and a PUBLIC one.
getCaps().open("did:ng:o:prot", "protected", "alice");
getCaps().open("did:ng:o:priv", "private", "alice");
getCaps().open("did:ng:o:pub", "public", "alice");
// Submitting the protected doc's NURI is REJECTED.
await expect(
submitToIndex({ nuri: "did:ng:o:prot" }, { from: null, doc: "did:ng:o:prot" }),
).rejects.toThrow(/PUBLIC|public-only|protected\/private/i);
// Private too.
await expect(
submitToIndex({ nuri: "did:ng:o:priv" }, { from: null, doc: "did:ng:o:priv" }),
).rejects.toThrow(/PUBLIC|public-only|protected\/private/i);
// The PUBLIC document passes.
await submitToIndex({ nuri: "did:ng:o:pub" }, { from: null, doc: "did:ng:o:pub", ts: 1 });
const entries = await readIndex();
expect(entries.map((e) => (e.ref as { nuri: string }).nuri)).toEqual(["did:ng:o:pub"]);
resetCaps();
});
test("INDEX_ACCOUNT lives in the reserved namespace (no typed id can equal it)", () => {
// The index account occupies a key no consumer input can produce: it is prefixed
// with a NUL control char, which a user cannot type into an id field and
// which no `normalizeId` output (a typeable value) contains. So it is
// disjoint from the keys "index" / "@index" a hostile user would submit.
expect(INDEX_ACCOUNT.startsWith("\u0000")).toBe(true); // unreachable-by-typing sentinel
expect(INDEX_ACCOUNT).not.toBe("index");
expect(INDEX_ACCOUNT).not.toBe("@index");
});
test("a user named 'index'/'@index' does NOT resolve to the index account's document", async () => {
// The discovery index lives on INDEX_ACCOUNT. A hostile (or unlucky) user who
// registers as "index" or "@index" normalizes to key "index" — which must be
// a DISJOINT key from the reserved index account, so they get their own
// documents and cannot hijack / read-write the global index document.
const indexRecord = await ensureAccount(INDEX_ACCOUNT);
// A real user "index" — same normalized form as "@index".
const userIndex = await ensureAccount("index");
expect(userIndex.docPublic).not.toBe(indexRecord.docPublic);
expect(userIndex.docProtected).not.toBe(indexRecord.docProtected);
expect(userIndex.docPrivate).not.toBe(indexRecord.docPrivate);
// "@index" must land on the SAME account as "index" (both normalize to
// "index") — and still NOT on the reserved index account.
const userAtIndex = await ensureAccount("@index");
expect(userAtIndex.docPublic).toBe(userIndex.docPublic);
expect(userAtIndex.docPublic).not.toBe(indexRecord.docPublic);
});
test("watchIndex fires immediately then when a submission arrives", async () => {
const seen: IndexEntry[][] = [];
const stop = watchIndex((e) => seen.push(e), { intervalMs: 5 });
await new Promise((r) => setTimeout(r, 20));
expect(seen.length).toBeGreaterThanOrEqual(1);
expect(seen[seen.length - 1]).toEqual([]);
await submitToIndex({ nuri: "did:ng:o:watched" }, { from: null, ts: 1 });
await new Promise((r) => setTimeout(r, 20));
const last = seen[seen.length - 1]!;
expect(last.map((e) => (e.ref as any).nuri)).toContain("did:ng:o:watched");
stop();
const countAfterStop = seen.length;
await submitToIndex({ nuri: "did:ng:o:after" }, { from: null, ts: 2 });
await new Promise((r) => setTimeout(r, 20));
expect(seen.length).toBe(countAfterStop);
});
+9 -2
View File
@@ -1,5 +1,12 @@
import { test, expect, mock } from "bun:test"; import { test, expect, mock, beforeEach } from "bun:test";
import { docCreate, sparqlUpdate, sparqlQuery } from "../src/docs"; import { docCreate, sparqlUpdate, sparqlQuery } from "../src/docs";
// The reach guard is process-wide: once ANY cap exists it applies to every reader.
// This suite declares none, so it must not inherit another suite's enforcement.
beforeEach(() => {
resetCaps();
setCurrentUser(null);
});
import * as ngProxy from "../src/ng-proxy"; import * as ngProxy from "../src/ng-proxy";
// NOTE ORDER: the "not configured → throw" case MUST run before any configure() // NOTE ORDER: the "not configured → throw" case MUST run before any configure()
@@ -18,7 +25,7 @@ test("throws a clear error when configure() was not called", async () => {
}); });
// From here on, a fake real `ng` is injected via configure(). // From here on, a fake real `ng` is injected via configure().
import { configure } from "../src/polyfill"; import { configure, resetCaps, setCurrentUser } from "../src/polyfill";
function fakeNg() { function fakeNg() {
return { return {
+11 -4
View File
@@ -1,5 +1,6 @@
import { test, expect, mock, beforeEach, afterAll } from "bun:test"; import { test, expect, mock, beforeEach, afterAll } from "bun:test";
import { post, read, materialize, watch } from "../src/inbox"; import { post, read, materialize, watch } from "../src/inbox";
import { walletInbox, resetRegistryCache } from "../src/store-registry";
import type { Deposit } from "../src/inbox"; import type { Deposit } from "../src/inbox";
import { import {
configure, configure,
@@ -147,7 +148,8 @@ function makeFakeNg() {
} }
const SESSION: RegistrySession = { sessionId: "sid-1", privateStoreId: "PRIV" }; const SESSION: RegistrySession = { sessionId: "sid-1", privateStoreId: "PRIV" };
const TARGET = "did:ng:o:host-inbox"; /** Resolved per test: an inbox BELONGS to a wallet, and only its owner may read it. */
let TARGET: `did:ng:${string}`;
function inject() { function inject() {
const ng = makeFakeNg(); const ng = makeFakeNg();
@@ -159,15 +161,20 @@ function inject() {
} }
let fake: ReturnType<typeof makeFakeNg>; let fake: ReturnType<typeof makeFakeNg>;
beforeEach(() => { beforeEach(async () => {
fake = inject(); fake = inject();
resetRegistryCache();
setCurrentUser("alice");
TARGET = await walletInbox("alice");
}); });
test("post writes via the real injected ng.sparql_update (not makeNg), scoped to the inbox", async () => { test("post writes via the real injected ng.sparql_update (not makeNg), scoped to the inbox", async () => {
setCurrentUser("alice"); // `from` is bound to the current identity setCurrentUser("alice"); // `from` is bound to the current identity
// Count from HERE: resolving this wallet's own inbox already wrote to the shim.
const before = fake.sparql_update.mock.calls.length;
await post(TARGET, { from: "alice", payload: { kind: "join" }, ts: 100 }); await post(TARGET, { from: "alice", payload: { kind: "join" }, ts: 100 });
expect(fake.sparql_update).toHaveBeenCalledTimes(1); expect(fake.sparql_update.mock.calls.length).toBe(before + 1);
const call = fake.sparql_update.mock.calls[0]!; const call = fake.sparql_update.mock.calls[before]!;
expect(call[0]).toBe("sid-1"); // sessionId from the injected session expect(call[0]).toBe("sid-1"); // sessionId from the injected session
expect(call[2]).toBe(TARGET); // anchored to the target inbox expect(call[2]).toBe(TARGET); // anchored to the target inbox
// The write targets the anchored DEFAULT graph — NO explicit `GRAPH <…>` // The write targets the anchored DEFAULT graph — NO explicit `GRAPH <…>`
+359 -76
View File
@@ -1,31 +1,36 @@
/** /**
* ReadCap ACTIVE end-to-end proof that the emulated SDK enforces per-DOCUMENT * ReadCap ACTIVE end-to-end proof that the emulated SDK enforces per-DOCUMENT
* isolation, driven by per-entity documents + DIRECTED read grants. * isolation, driven by per-entity documents + KEY POSSESSION.
* *
* Mirrors what the app does: create an entity document through the REAL registry * Mirrors what the app does: create an entity document through the REAL registry
* (`createEntityDoc`), declare its cap policy via `getCaps().open(doc, scope, * (`createEntityDoc`) which files its cap in the creator's held caps, the emulated
* owner)`, set the current identity, and — when the app decides two identities * `AddRepo { read_cap }` and, when the app decides two identities are related,
* are related issue a DIRECTED read grant on each of the owner's protected * SHARE that one document's cap to the other's inbox (`shareCap`). The recipient
* documents (`getCaps().grantRead(doc, granteeId)`). Whether identities are * needs no dedicated operation: processing their inbox absorbs it.
* "connected" is the application's own concept: this test plays that role *
* directly. The read filter then discriminates: * What the read filter then shows:
* (a) an ungranted principal is denied a PROTECTED doc; granted once the owner * (a) a document nobody shared is unreadable, and stays unreadable for a third
* issues a directed grant; PUBLIC readable throughout via the ACTIVE * party after a share to someone else sharing is per-document, per-inbox;
* ReadCap. * (b) a bare reference grants NOTHING (naming is not reading), while the repo
* (b) no grant no protected read (a reader cannot grant itself). * link of a published document opens it for whoever receives it;
* (c) switching identity SWITCHES heldByHolder it never wipes one.
*/ */
import { test, expect, mock, afterAll } from "bun:test"; import { test, expect, mock, afterAll } from "bun:test";
import { createEntityDoc, resetRegistryCache } from "../src/store-registry"; import { createEntityDoc, resetRegistryCache, walletInbox, listMyEntityDocs } from "../src/store-registry";
import type { RegistrySession } from "../src/store-registry"; import type { RegistrySession } from "../src/store-registry";
import type { ReadCap } from "../src/types";
import { import {
configure, configure,
configureStoreRegistry, configureStoreRegistry,
resetStoreRegistry, resetStoreRegistry,
resetConfig, resetConfig,
capFor,
getCaps, getCaps,
resetCaps, resetCaps,
setCurrentUser, setCurrentUser,
shareCap,
} from "../src/polyfill"; } from "../src/polyfill";
import { read as readInbox } from "../src/inbox";
import { filterReadable } from "../src/read-filter"; import { filterReadable } from "../src/read-filter";
afterAll(() => { afterAll(() => {
@@ -36,93 +41,371 @@ afterAll(() => {
}); });
const SESSION: RegistrySession = { sessionId: "sid", privateStoreId: "PRIV" }; const SESSION: RegistrySession = { sessionId: "sid", privateStoreId: "PRIV" };
const SHIM = "urn:ng-eventually:shim";
const INBOX = "urn:ng-eventually:inbox";
function inject() { interface Quad { g: string; s: string; p: string; o: string }
let n = 0;
const ng = { /** Reverse of the lib's escapeLiteral: single left-to-right pass over `\x`. */
doc_create: mock(async () => `did:ng:o:doc${++n}`), function unescapeLiteral(s: string): string {
sparql_update: mock(async () => undefined), let out = "";
sparql_query: mock(async () => ({ results: { bindings: [] } })), for (let i = 0; i < s.length; i++) {
if (s[i] === "\\" && i + 1 < s.length) {
const next = s[++i];
out += next === "n" ? "\n" : next === "r" ? "\r" : next === "t" ? "\t" : next!;
} else out += s[i];
}
return out;
}
/** A stateful fake `ng` serving BOTH the shim SPARQL and the inbox SPARQL. */
function makeFakeNg() {
const quads: Quad[] = [];
let docCounter = 0;
const doc_create = mock(async () => `did:ng:o:doc${++docCounter}`);
const sparql_update = mock(async (...a: unknown[]) => {
const query = a[1] as string;
const anchor = a[2] as string | undefined;
const gm = query.match(/GRAPH <([^>]+)>\s*\{([\s\S]*)\}/);
let g: string;
let body: string;
if (gm) {
g = gm[1]!;
body = gm[2]!;
} else {
if (!anchor) return undefined;
g = anchor;
body = query.replace(/^\s*INSERT DATA\s*\{/, "").replace(/\}\s*$/, "");
}
const sm = body.match(/<([^>]+)>/);
if (!sm) return undefined;
const s = sm[1]!;
const pairRe = /(?:a|<([^>]+)>)\s+(?:"((?:[^"\\]|\\.)*)"|<([^>]+)>)/g;
let m: RegExpExecArray | null;
const after = body.slice(body.indexOf(sm[0]) + sm[0].length);
while ((m = pairRe.exec(after)) !== null) {
const p = m[1] ?? (query.includes(`${INBOX}:Deposit`) ? `${INBOX}:Deposit` : `${SHIM}:Account`);
const o = m[2] !== undefined ? unescapeLiteral(m[2]) : (m[3] ?? "");
quads.push({ g, s, p, o });
}
return undefined;
});
const sparql_query = mock(async (...a: unknown[]) => {
const query = a[1] as string;
const anchor = a[3] as string | undefined;
// Pointer SELECT (store-root → doc-shim).
if (query.includes(`<${SHIM}:shimDoc>`)) {
const bindings = quads
.filter((q) => q.g === anchor && q.p === `${SHIM}:shimDoc`)
.map((q) => ({ shimDoc: { value: q.o } }));
return { results: { bindings } };
}
// Account SELECT.
if (query.includes(`<${SHIM}:id>`)) {
const subjM = query.match(/<([^>]+)>\s+a\s+<urn:ng-eventually:shim:Account>/);
const onlySubject = subjM ? subjM[1]! : null;
const bySubject = new Map<string, Record<string, string>>();
for (const q of quads) {
if (q.g !== anchor) continue;
if (onlySubject !== null && q.s !== onlySubject) continue;
const rec = bySubject.get(q.s) ?? {};
if (q.p === `${SHIM}:id`) rec.id = q.o;
if (q.p === `${SHIM}:docPublic`) rec.docPublic = q.o;
if (q.p === `${SHIM}:docProtected`) rec.docProtected = q.o;
if (q.p === `${SHIM}:docPrivate`) rec.docPrivate = q.o;
bySubject.set(q.s, rec);
}
const bindings = [...bySubject.values()]
.filter((r) => r.id)
.map((r) => ({
id: { value: r.id! },
docPublic: { value: r.docPublic ?? "" },
docProtected: { value: r.docProtected ?? "" },
docPrivate: { value: r.docPrivate ?? "" },
}));
return { results: { bindings } };
}
// Inbox deposit SELECT.
if (query.includes(`<${INBOX}:payload>`)) {
const bySubject = new Map<string, Record<string, string>>();
for (const q of quads) {
if (q.g !== anchor) continue;
const rec = bySubject.get(q.s) ?? {};
if (q.p === `${INBOX}:payload`) rec.payload = q.o;
if (q.p === `${INBOX}:ts`) rec.ts = q.o;
if (q.p === `${INBOX}:from`) rec.from = q.o;
bySubject.set(q.s, rec);
}
const bindings = [...bySubject.values()]
.filter((r) => r.payload !== undefined && r.ts !== undefined)
.map((r) => {
const row: Record<string, { value: string }> = {
payload: { value: r.payload! },
ts: { value: r.ts! },
}; };
if (r.from !== undefined) row.from = { value: r.from };
return row;
});
return { results: { bindings } };
}
// User-branch `link` SELECT (the emulated AddLink records).
// User-branch `inboxCap` SELECT (the emulated AddInboxCap records).
if (query.includes(`<${SHIM}:inboxCap>`)) {
return { results: { bindings: quads.filter((q) => q.g === anchor && q.p === `${SHIM}:inboxCap`).map((q) => ({ c: { value: q.o } })) } };
}
// Store-branch `readCap` SELECT (the emulated AddRepo records).
if (query.includes(`<${SHIM}:readCap>`)) {
return { results: { bindings: quads.filter((q) => q.g === anchor && q.p === `${SHIM}:readCap`).map((q) => ({ c: { value: q.o } })) } };
}
if (query.includes(`<${SHIM}:link>`)) {
const bindings = quads
.filter((q) => q.g === anchor && q.p === `${SHIM}:link`)
.map((q) => ({ c: { value: q.o } }));
return { results: { bindings } };
}
// Scope-index `contains` SELECT.
if (query.includes(`<${SHIM}:contains>`)) {
const bindings = quads
.filter((q) => q.g === anchor && q.p === `${SHIM}:contains`)
.map((q) => ({ e: { value: q.o } }));
return { results: { bindings } };
}
return { results: { bindings: [] } };
});
return { doc_create, sparql_update, sparql_query, _quads: quads };
}
function inject(normalizeId: (id: string) => string = (id) => id.trim()) {
const ng = makeFakeNg();
configure({ ng: ng as any, useShape: (() => {}) as any }); configure({ ng: ng as any, useShape: (() => {}) as any });
// Synchronous fake store → no sync lag; disable the anti-fork retry backoff. configureStoreRegistry({ getSession: async () => SESSION, normalizeId });
configureStoreRegistry({ getSession: async () => SESSION, normalizeId: (id) => id.trim() });
resetRegistryCache(); resetRegistryCache();
resetCaps(); resetCaps();
setCurrentUser(null); setCurrentUser(null);
return ng; return ng;
} }
/** The app's relationship concept, played inline: grant `reader` the read cap of /** The items an ORM set would carry, one per document. */
* every protected document owned by `owner`. */ const item = (doc: string, id: string) => ({ "@graph": doc, "@id": id });
function grantOwnerProtectedTo(owner: string, reader: string) { /** What the current holder reads out of `items`. */
for (const doc of getCaps().protectedDocsOf(owner)) getCaps().grantRead(doc, reader); const view = (items: Array<{ "@graph": string; "@id": string }>) =>
} filterReadable(items, getCaps()).map((i) => i["@id"]).sort();
test("ReadCap active: a private entity doc created via the real registry is hidden from another principal", async () => {
test("a created document is readable by its creator and by nobody else", async () => {
inject(); inject();
setCurrentUser("alice");
const aliceDoc = await createEntityDoc("alice", "private"); const aliceDoc = await createEntityDoc("alice", "private");
getCaps().open(aliceDoc, "private", "alice"); setCurrentUser("bob");
const bobDoc = await createEntityDoc("bob", "private");
const bobDoc = await createEntityDoc("bob", "public"); const items = [item(aliceDoc, "a1"), item(bobDoc, "b1")];
getCaps().open(bobDoc, "public", "bob");
const items = [ setCurrentUser("alice");
{ "@graph": aliceDoc, "@id": "a1", label: "alice-private" }, expect(view(items)).toEqual(["a1"]);
{ "@graph": bobDoc, "@id": "b1", label: "bob-public" }, setCurrentUser("bob");
]; expect(view(items)).toEqual(["b1"]);
setCurrentUser(null);
expect(filterReadable(items, getCaps(), "bob").map((i) => i["@id"])).toEqual(["b1"]); expect(view(items)).toEqual([]); // anonymous holds nothing
expect(filterReadable(items, getCaps(), "alice").map((i) => i["@id"]).sort()).toEqual(["a1", "b1"]); expect(getCaps().isEnforcing()).toBe(true);
expect(filterReadable(items, getCaps(), null).map((i) => i["@id"])).toEqual(["b1"]);
expect(getCaps().hasReadPolicy()).toBe(true);
}); });
// (a) protected hidden while ungranted → revealed after a DIRECTED grant; public // (a) Sharing is per-document AND per-recipient: a share to bob leaves carol out.
// readable regardless — all through the ACTIVE ReadCap. test("(a) sharing one document's cap to ONE inbox reveals it there, and only there", async () => {
test("(a) PROTECTED doc: hidden ungranted, revealed after a DIRECTED grant, PUBLIC always readable", async () => {
inject(); inject();
setCurrentUser("alice");
const shared = await createEntityDoc("alice", "protected");
const kept = await createEntityDoc("alice", "protected");
const items = [item(shared, "s1"), item(kept, "k1")];
const aliceProtected = await createEntityDoc("alice", "protected"); // BEFORE the share: bob reads nothing of alice's.
getCaps().open(aliceProtected, "protected", "alice"); setCurrentUser("bob");
const alicePublic = await createEntityDoc("alice", "public"); expect(view(items)).toEqual([]);
getCaps().open(alicePublic, "public", "alice");
const items = [ // The app decides alice↔bob are related: alice shares ONE document's cap into
{ "@graph": aliceProtected, "@id": "p1" }, // bob's OWN inbox — the only cross-wallet act there is.
{ "@graph": alicePublic, "@id": "u1" }, const bobInbox = await walletInbox("bob");
]; setCurrentUser("alice");
const view = (u: string) => filterReadable(items, getCaps(), u).map((i) => i["@id"]).sort(); await shareCap(capFor(shared)!, bobInbox);
// BEFORE any grant: bob sees only the public item. // bob processes his inbox — no dedicated "receive" operation exists.
expect(view("bob")).toEqual(["u1"]); setCurrentUser("bob");
expect(view("alice")).toEqual(["p1", "u1"]); await readInbox(bobInbox);
expect(view(items)).toEqual(["s1"]); // the shared one only — not `kept`
// The app decides alice↔bob are related and grants bob the read cap of alice's // carol, who was not shared with, still reads nothing.
// protected documents. setCurrentUser("carol");
grantOwnerProtectedTo("alice", "bob"); await readInbox(await walletInbox("carol"));
expect(view(items)).toEqual([]);
expect(view("bob")).toEqual(["p1", "u1"]);
// A third, ungranted principal still sees only the public one.
expect(view("carol")).toEqual(["u1"]);
}); });
// (b) An identity gets no protected read until the OWNER issues the grant — a test("a cap deposit is absorbed, not surfaced as a consumer deposit", async () => {
// reader cannot grant itself.
test("(b) no directed grant → no protected read", async () => {
inject(); inject();
setCurrentUser("alice");
const doc = await createEntityDoc("alice", "protected");
const bobInbox = await walletInbox("bob");
await shareCap(capFor(doc)!, bobInbox);
const aliceProtected = await createEntityDoc("alice", "protected"); setCurrentUser("bob");
getCaps().open(aliceProtected, "protected", "alice"); const deposits = await readInbox(bobInbox);
const items = [{ "@graph": aliceProtected, "@id": "p1" }]; expect(deposits).toEqual([]); // infrastructure, not consumer data
const view = (u: string) => filterReadable(items, getCaps(), u).map((i) => i["@id"]); expect(capFor(doc)).toBeDefined(); // …but it landed in bob's held caps
});
// mallory holds no grant on alice's protected doc → denied.
expect(view("mallory")).toEqual([]); // (b) A bare reference grants nothing; the repo link of a published document does.
test("(b) a bare reference reads nothing; the repo link of a published document opens it", async () => {
// Granting bob (a different, legitimate reader) leaves mallory denied. inject();
grantOwnerProtectedTo("alice", "bob"); setCurrentUser("alice");
expect(view("mallory")).toEqual([]); const pub = await createEntityDoc("alice", "public");
expect(view("bob")).toEqual(["p1"]); const items = [item(pub, "u1")];
expect(getCaps().isPublished(pub)).toBe(true);
const link = capFor(pub)!;
// bob HAS the document's bare NURI (it is right there in `items`) and reads nothing.
setCurrentUser("bob");
expect(view(items)).toEqual([]);
// Receiving the repo link — what a discovery entry actually carries — opens it.
getCaps().learn(link);
expect(view(items)).toEqual(["u1"]);
});
// (c) Identity change switches heldByHolder; it does not wipe them.
test("(c) switching identity switches heldByHolder — a returning identity keeps its caps", async () => {
inject();
setCurrentUser("alice");
const doc = await createEntityDoc("alice", "protected");
const cap = capFor(doc);
expect(cap).toBeDefined();
setCurrentUser("bob");
expect(capFor(doc)).toBeUndefined();
setCurrentUser("alice");
expect(capFor(doc)).toBe(cap!); // durable across the switch — nothing re-declared
});
// A virtual user IS a shim account, and the shim keys accounts through the
// consumer's `normalizeId`. The held caps must key the SAME way: otherwise an app
// that spells its own identity differently between two calls ("@Alice" at login,
// "alice" later) gets a second held caps and stops reading its own documents.
test("one held caps per virtual WALLET, not per spelling of its id", async () => {
inject((id) => id.trim().replace(/^@+/, "").toLowerCase());
setCurrentUser("@Alice");
const doc = await createEntityDoc("@Alice", "protected");
const cap = capFor(doc);
expect(cap).toBeDefined();
// Same account, spelled differently — same shim account, so the same held caps.
setCurrentUser("alice");
expect(capFor(doc)).toBe(cap!);
setCurrentUser(" ALICE ");
expect(capFor(doc)).toBe(cap!);
// A genuinely different account still holds nothing.
setCurrentUser("bob");
expect(capFor(doc)).toBeUndefined();
});
// THE BREACH P1a OPENED. Caps travel as inbox deposits, so an unguarded inbox read
// let anyone who knew an inbox NURI collect the caps addressed to its owner —
// defeating directed sharing entirely. Depositing stays open (it is the only way a
// link crosses between wallets at all); reading does not.
test("an inbox may be DEPOSITED into by anyone, and READ only by its owner", async () => {
inject();
setCurrentUser("alice");
const secret = await createEntityDoc("alice", "protected");
const bobInbox = await walletInbox("bob");
// Alice deposits into bob's inbox — allowed, and it grants her nothing back.
await shareCap(capFor(secret)!, bobInbox);
await expect(readInbox(bobInbox)).rejects.toThrow(/does not belong to the connected wallet/i);
expect(capFor(secret)).toBeDefined(); // still hers, obviously
// Mallory knows the NURI of bob's inbox and tries to pocket what is in it.
setCurrentUser("mallory");
await expect(readInbox(bobInbox)).rejects.toThrow(/does not belong to the connected wallet/i);
expect(capFor(secret)).toBeUndefined(); // nothing was absorbed
// Anonymous owns no inbox at all.
setCurrentUser(null);
await expect(readInbox(bobInbox)).rejects.toThrow(/no identity is set/i);
// Bob reads his own, and only then does the cap land.
setCurrentUser("bob");
await readInbox(bobInbox);
expect(capFor(secret)).toBeDefined();
});
test("a fresh session rebuilds the held caps from the scope index (the emulated AddRepo)", async () => {
inject();
setCurrentUser("alice");
const doc = await createEntityDoc("alice", "protected");
const items = [item(doc, "p1")];
// Simulate a new session over the same wallet: caps are in memory, so they go —
// the registry cache too. Only the persisted documents remain.
resetCaps();
resetRegistryCache();
expect(view(items)).toEqual([]);
// Listing my own documents refiles their caps: this is the store branch that
// carries `AddRepo { read_cap }` upstream.
const { listMyEntityDocs } = await import("../src/store-registry");
expect(await listMyEntityDocs("alice", "protected")).toEqual([doc]);
expect(view(items)).toEqual(["p1"]);
});
// The Store branch exists so a cap is READ back, not recomputed. Without this test
// the two are indistinguishable: with a stand-in value, re-minting happens to give
// the same string. So corrupt the stored cap and check the corruption wins — proof
// the value comes from the store, and proof that P1b's real key will too.
test("a document's cap is READ from the Store branch, never recomputed", async () => {
const ng = inject();
setCurrentUser("alice");
const doc = await createEntityDoc("alice", "protected");
// The store recorded `AddRepo { read_cap }` beside the `contains` listing.
const stored = ng._quads.filter((q) => q.p === "urn:ng-eventually:shim:readCap");
expect(stored.length).toBe(1);
expect(stored[0]!.o).toBe(`${doc}:r:OK`);
// Rewrite it to a DIFFERENT value, then start a fresh session.
stored[0]!.o = `${doc}:r:FROM-THE-STORE`;
resetCaps();
resetRegistryCache();
expect(await listMyEntityDocs("alice", "protected")).toEqual([doc]);
// Recomputing would have produced `:r:OK`; this is what was stored.
expect(capFor(doc)).toBe(`${doc}:r:FROM-THE-STORE` as ReadCap);
});
// The listing and the keys are separate upstream (Main vs Store branch), and the
// separation has to survive here or a document could be listed without its cap.
test("the listing and the caps are two separate records", async () => {
const ng = inject();
setCurrentUser("alice");
await createEntityDoc("alice", "private");
const subjects = new Set(ng._quads.filter((q) => q.p.startsWith("urn:ng-eventually:shim:")).map((q) => q.s));
expect(subjects.has("urn:ng-eventually:shim:index")).toBe(true); // Main branch: contains
expect(subjects.has("urn:ng-eventually:shim:storeBranch")).toBe(true); // Store branch: readCap
});
// P1b will make the stand-in value a real, non-derivable key. The moment it does,
// any path that mints a SECOND cap instead of using the stored one breaks: the
// creator would hold a key that does not open its own document. This pins that the
// creation path mints exactly once.
test("creation mints the cap ONCE — the stored value is the one held", async () => {
const ng = inject();
setCurrentUser("alice");
const doc = await createEntityDoc("alice", "protected");
const stored = ng._quads.find((q) => q.p === "urn:ng-eventually:shim:readCap")!;
expect(capFor(doc)).toBe(stored.o as ReadCap); // same value, not two mints that agree by luck
}); });
+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);
});
+10 -8
View File
@@ -8,9 +8,11 @@ import {
setCurrentUser, setCurrentUser,
} from "../src/polyfill"; } from "../src/polyfill";
// This suite injects a fake `ng` via configure() and declares write caps. Reset // This suite injects a fake `ng` via configure() and declares WRITE caps —
// both after each test so the docs.test.ts "not configured" guard still holds // which stay an authorization list on purpose: only READING is key possession
// and no cap policy leaks into another suite. // (P1a). The write axis is decorative until P1b (every internal writer bypasses
// this proxy). Reset after each test so the docs.test.ts "not configured" guard
// still holds and no cap leaks into another suite.
afterEach(() => { afterEach(() => {
resetConfig(); resetConfig();
resetCaps(); resetCaps();
@@ -40,7 +42,7 @@ test("write guard: passthrough when NO write policy is declared (no regression)"
test("write guard: passthrough for an UNGOVERNED doc even when a policy exists elsewhere", async () => { test("write guard: passthrough for an UNGOVERNED doc even when a policy exists elsewhere", async () => {
const ng = inject(); const ng = inject();
getCaps().open("did:ng:o:other", "private", "alice"); // policy on another doc getCaps().grantWrite("did:ng:o:other", "alice"); // policy on another doc
setCurrentUser("bob"); setCurrentUser("bob");
const proxy = makeNg(); const proxy = makeNg();
await proxy.sparql_update("sid", UPDATE, DOC); // DOC itself is ungoverned await proxy.sparql_update("sid", UPDATE, DOC); // DOC itself is ungoverned
@@ -49,7 +51,7 @@ test("write guard: passthrough for an UNGOVERNED doc even when a policy exists e
test("write guard: REJECTS when the doc is governed and the user lacks the write cap", async () => { test("write guard: REJECTS when the doc is governed and the user lacks the write cap", async () => {
const ng = inject(); const ng = inject();
getCaps().open(DOC, "private", "alice"); // alice holds write cap getCaps().grantWrite(DOC, "alice"); // alice holds the write cap
setCurrentUser("bob"); // bob does not setCurrentUser("bob"); // bob does not
const proxy = makeNg(); const proxy = makeNg();
await expect(proxy.sparql_update("sid", UPDATE, DOC)).rejects.toThrow( await expect(proxy.sparql_update("sid", UPDATE, DOC)).rejects.toThrow(
@@ -60,7 +62,7 @@ test("write guard: REJECTS when the doc is governed and the user lacks the write
test("write guard: REJECTS an anonymous (null) user on a governed doc", async () => { test("write guard: REJECTS an anonymous (null) user on a governed doc", async () => {
const ng = inject(); const ng = inject();
getCaps().open(DOC, "public", "alice"); getCaps().grantWrite(DOC, "alice");
setCurrentUser(null); setCurrentUser(null);
const proxy = makeNg(); const proxy = makeNg();
await expect(proxy.sparql_update("sid", UPDATE, DOC)).rejects.toThrow( await expect(proxy.sparql_update("sid", UPDATE, DOC)).rejects.toThrow(
@@ -71,7 +73,7 @@ test("write guard: REJECTS an anonymous (null) user on a governed doc", async ()
test("write guard: ALLOWS the write-cap holder", async () => { test("write guard: ALLOWS the write-cap holder", async () => {
const ng = inject(); const ng = inject();
getCaps().open(DOC, "private", "alice"); getCaps().grantWrite(DOC, "alice");
setCurrentUser("alice"); // owner always holds the write cap setCurrentUser("alice"); // owner always holds the write cap
const proxy = makeNg(); const proxy = makeNg();
await proxy.sparql_update("sid", UPDATE, DOC); await proxy.sparql_update("sid", UPDATE, DOC);
@@ -80,7 +82,7 @@ test("write guard: ALLOWS the write-cap holder", async () => {
test("write guard: passthrough when anchor is omitted (cannot scope the guard)", async () => { test("write guard: passthrough when anchor is omitted (cannot scope the guard)", async () => {
const ng = inject(); const ng = inject();
getCaps().open(DOC, "private", "alice"); getCaps().grantWrite(DOC, "alice");
setCurrentUser("bob"); setCurrentUser("bob");
const proxy = makeNg(); const proxy = makeNg();
await proxy.sparql_update("sid", "INSERT DATA {}"); // no anchor → passthrough await proxy.sparql_update("sid", "INSERT DATA {}"); // no anchor → passthrough
+15 -1
View File
@@ -20,7 +20,15 @@
import { describe, it, expect, mock, beforeEach, afterAll } from "bun:test"; import { describe, it, expect, mock, beforeEach, afterAll } from "bun:test";
import { ensureRepoOpen, ensureReposOpen, resetOpenedRepos } from "../src/open-repo"; import { ensureRepoOpen, ensureReposOpen, resetOpenedRepos } from "../src/open-repo";
import { readUnion } from "../src/read-model"; import { readUnion } from "../src/read-model";
import { configure, configureStoreRegistry, resetStoreRegistry, resetConfig } from "../src/polyfill"; import {
configure,
configureStoreRegistry,
resetStoreRegistry,
resetConfig,
resetCaps,
setCurrentUser,
} from "../src/polyfill";
import { resetInfrastructure } from "../src/reach";
import { resetRegistryCache } from "../src/store-registry"; import { resetRegistryCache } from "../src/store-registry";
afterAll(() => { afterAll(() => {
@@ -30,9 +38,15 @@ afterAll(() => {
resetOpenedRepos(); resetOpenedRepos();
}); });
// The reach guard and the cap registry are process-wide: once ANY cap exists the
// boundary applies to every reader. A suite that declares none must start from an
// empty one, or it inherits another suite's enforcement.
beforeEach(() => { beforeEach(() => {
resetOpenedRepos(); resetOpenedRepos();
resetRegistryCache(); resetRegistryCache();
resetCaps();
resetInfrastructure();
setCurrentUser(null);
}); });
const SESSION = { sessionId: "sid-or", privateStoreId: "PRIV-OR" }; const SESSION = { sessionId: "sid-or", privateStoreId: "PRIV-OR" };
+219
View File
@@ -0,0 +1,219 @@
/**
* reach.test.ts the virtual user boundary, at the passage points.
*
* A virtual user must simulate the boundary of the future single-user wallet: the
* access functions are confined to the user currently connected, and no cross-user
* access is permitted. Before this, `docs.sparqlQuery`/`sparqlUpdate` both
* exported from the SDK entry reached ANY document of ANY identity given a
* session id and a NURI.
*
* The one act that legitimately crosses: DEPOSITING into someone's inbox. It is
* how a link travels between users at all, and it gives the depositor nothing back.
*/
import { test, expect, mock, afterAll } from "bun:test";
import { sparqlQuery, sparqlUpdate, depositInto } from "../src/docs";
import { createEntityDoc, resetRegistryCache, walletInbox } from "../src/store-registry";
import type { RegistrySession } from "../src/store-registry";
import {
configure,
configureStoreRegistry,
resetStoreRegistry,
resetConfig,
resetCaps,
setCurrentUser,
} from "../src/polyfill";
import { mayReach, mustNotAttempt } from "../src/reach";
import { hasReadCap } from "../src/nuri";
afterAll(() => {
resetConfig();
resetStoreRegistry();
resetCaps();
setCurrentUser(null);
});
const SESSION: RegistrySession = { sessionId: "sid-reach", privateStoreId: "PRIV-REACH" };
function inject() {
let n = 0;
const quads: Array<{ g: string; s: string; p: string; o: string }> = [];
const ng = {
doc_create: mock(async () => `did:ng:o:reach${++n}`),
sparql_update: mock(async (...a: unknown[]) => {
quads.push({ g: String(a[2]), s: "", p: "", o: String(a[1]) });
return undefined;
}),
sparql_query: mock(async () => ({ results: { bindings: [] } })),
};
configure({ ng: ng as any, useShape: (() => {}) as any });
configureStoreRegistry({ getSession: async () => SESSION, normalizeId: (id) => id.trim() });
resetRegistryCache();
resetCaps();
setCurrentUser(null);
return { ng, quads };
}
const READ = "SELECT ?s ?p ?o WHERE { ?s ?p ?o }";
test("the guard is inert until the first cap exists (no regression for a cap-free consumer)", async () => {
const { ng } = inject();
// Nothing has been created, so no cap has been issued: everything flows.
expect(mayReach("did:ng:o:anything")).toBe(true);
await sparqlQuery(SESSION.sessionId, READ, undefined, "did:ng:o:anything");
expect(ng.sparql_query).toHaveBeenCalledTimes(1);
});
test("once caps exist, a document outside the connected user's reach is refused — read AND write", async () => {
inject();
setCurrentUser("alice");
const mine = await createEntityDoc("alice", "private");
// Mine: reachable.
expect(mayReach(mine)).toBe(true);
await sparqlQuery(SESSION.sessionId, READ, undefined, mine);
// A well-formed NURI I hold nothing for: named, unreachable. Both directions.
const theirs = "did:ng:o:someone-elses-doc" as const;
expect(mayReach(theirs)).toBe(false);
await expect(sparqlQuery(SESSION.sessionId, READ, undefined, theirs)).rejects.toThrow(
/does not hold this document.s cap/i,
);
await expect(
sparqlUpdate(SESSION.sessionId, "INSERT DATA { <a> <b> \"c\" }", theirs),
).rejects.toThrow(/does not hold this document.s cap/i);
});
test("the boundary follows the connected user — one user's document is another's forbidden NURI", async () => {
inject();
setCurrentUser("alice");
const aliceDoc = await createEntityDoc("alice", "private");
setCurrentUser("bob");
const bobDoc = await createEntityDoc("bob", "private");
expect(mayReach(bobDoc)).toBe(true);
expect(mayReach(aliceDoc)).toBe(false); // bob is connected
await expect(sparqlQuery(SESSION.sessionId, READ, undefined, aliceDoc)).rejects.toThrow();
setCurrentUser("alice");
expect(mayReach(aliceDoc)).toBe(true);
expect(mayReach(bobDoc)).toBe(false);
});
test("a user reaches its OWN stores and inbox — the boundary must not lock it out of itself", async () => {
inject();
setCurrentUser("alice");
await createEntityDoc("alice", "protected"); // provisions alice's account
const inbox = await walletInbox("alice");
expect(mayReach(inbox)).toBe(true);
await sparqlQuery(SESSION.sessionId, READ, undefined, inbox);
// …and not another user's inbox.
setCurrentUser("bob");
expect(mayReach(inbox)).toBe(false);
});
test("DEPOSITING into another user's inbox crosses the boundary, and gives nothing back", async () => {
const { ng } = inject();
setCurrentUser("bob");
const bobInbox = await walletInbox("bob");
setCurrentUser("alice");
await createEntityDoc("alice", "private"); // alice now holds caps → guard is armed
expect(mayReach(bobInbox)).toBe(false); // she holds no cap for it
// The deposit goes through anyway — it is the one legitimate cross-user act.
const before = ng.sparql_update.mock.calls.length;
await depositInto(SESSION.sessionId, 'INSERT DATA { <a> <b> "c" }', bobInbox);
expect(ng.sparql_update.mock.calls.length).toBe(before + 1);
// …and it grants her nothing: she still cannot read that inbox.
expect(mayReach(bobInbox)).toBe(false);
await expect(sparqlQuery(SESSION.sessionId, READ, undefined, bobInbox)).rejects.toThrow(
/does not hold this document.s cap/i,
);
});
test("the shim is reached by the MACHINERY, not by an exemption in the boundary", async () => {
inject();
setCurrentUser("alice");
await createEntityDoc("alice", "private"); // arms the emulation, resolves the shim
// The store-root and the doc-shim are NOT reachable through the virtual-user
// surface — there is no exemption list any more. The machinery reaches them
// through its own primitives (`physical.ts`), which the boundary never sees and
// which are never exported from the package.
expect(mayReach(`did:ng:${SESSION.privateStoreId}`)).toBe(false);
await expect(
sparqlQuery(SESSION.sessionId, READ, undefined, `did:ng:${SESSION.privateStoreId}`),
).rejects.toThrow(/does not hold this document's cap/i);
// …yet the registry works, because it never asked through that door.
const doc = await createEntityDoc("alice", "protected");
expect(mayReach(doc)).toBe(true);
});
// The two rules are deliberately redundant, and this is what that buys.
test("rule 1 and rule 2 are independent — the guard still holds if a caller forgets to check", async () => {
inject();
setCurrentUser("alice");
await createEntityDoc("alice", "private"); // arms the emulation
const theirs = "did:ng:o:not-mine" as const;
// RULE 2 — a caller that checks first simply does not issue the operation.
expect(mustNotAttempt(theirs)).toBe(true);
// RULE 1 — and a caller that does NOT check is refused anyway. This is the whole
// point of implementing the same criterion in two places: rule 2 is where the
// model lives (you cannot address what you hold no cap for), rule 1 is what makes
// a lapse in rule 2 fail loudly instead of quietly succeeding.
await expect(sparqlQuery(SESSION.sessionId, READ, undefined, theirs)).rejects.toThrow(
/does not hold this document's cap/i,
);
});
// Possession decides, not the shape of the reference the caller happens to hold.
test("a BARE reference is reachable when the cap is possessed elsewhere", async () => {
inject();
setCurrentUser("alice");
const doc = await createEntityDoc("alice", "private");
// `doc` is the bare form — it carries no cap — yet alice possesses that cap, so
// reaching it is legitimate. Manipulating a bare NURI is normal: references travel
// bare through content and indexes while the cap sits in what the user holds.
expect(hasReadCap(doc)).toBe(false);
expect(mayReach(doc)).toBe(true);
await sparqlQuery(SESSION.sessionId, READ, undefined, doc);
// The cap-bearing form of the same document answers alike.
expect(mayReach(`${doc}:r:OK`)).toBe(true);
// And bob, holding neither, cannot reach it in either form.
setCurrentUser("bob");
expect(mayReach(doc)).toBe(false);
expect(mayReach(`${doc}:r:OK`)).toBe(false);
});
// The whole point of splitting the machinery out: one API is the app's, the other
// must never be. A regression here is silent and total — an app holding the
// machinery reaches every virtual user's documents.
test("the machinery is NOT part of the package's public surface", async () => {
const entry: Record<string, unknown> = await import("../src/index");
const polyfill: Record<string, unknown> = await import("../src/polyfill");
for (const surface of [entry, polyfill]) {
for (const name of Object.keys(surface)) {
expect(name).not.toMatch(/^physical/);
}
}
// Named explicitly, so adding one and forgetting the rule fails here.
for (const forbidden of ["physicalQuery", "physicalUpdate", "physicalCreate", "subscribePhysicalDoc"]) {
expect(entry[forbidden]).toBeUndefined();
expect(polyfill[forbidden]).toBeUndefined();
}
// The cross-account fan-out is gone from the registry entirely.
const registry = entry.storeRegistry as Record<string, unknown>;
for (const gone of ["listEntityDocs", "resolveReadGraphs", "allAccounts", "loadShim"]) {
expect(registry[gone]).toBeUndefined();
}
});
+55 -30
View File
@@ -3,57 +3,82 @@ import { filterReadable, makeReadFilteredView } from "../src/read-filter";
import { CapRegistry } from "../src/caps"; import { CapRegistry } from "../src/caps";
// The access unit is the DOCUMENT (an item's `@graph` = the repo it lives in), // The access unit is the DOCUMENT (an item's `@graph` = the repo it lives in),
// not the item. Items here carry `@graph`; caps are granted per document. // not the item. Items here carry `@graph`; each holder holds caps per document.
interface Item { id: string; "@graph"?: string } interface Item { id: string; "@graph"?: string }
const PRIV: Item = { id: "a", "@graph": "did:ng:o:alice" }; // alice's doc const MINE: Item = { id: "a", "@graph": "did:ng:o:alice" }; // alice's doc
const PUB: Item = { id: "p", "@graph": "did:ng:o:public" }; // public doc const LINKED: Item = { id: "p", "@graph": "did:ng:o:public" }; // a published doc
const UNGOV: Item = { id: "n", "@graph": "did:ng:o:other" }; // doc under no policy const FOREIGN: Item = { id: "n", "@graph": "did:ng:o:other" }; // no cap held
const NOGRAPH: Item = { id: "x" }; // no document → kept const NOGRAPH: Item = { id: "x" }; // names no document
function caps(): CapRegistry { /** A registry whose holder the test drives; alice created one doc and published one. */
const c = new CapRegistry(); function setup(initial: string | null = "alice") {
c.grantRead("did:ng:o:alice", "alice"); let holder = initial;
c.makePublic("did:ng:o:public"); const caps = new CapRegistry(() => holder);
return c; const before = holder;
holder = "alice";
caps.mint("did:ng:o:alice");
const link = caps.publishRepoLink("did:ng:o:public");
holder = before;
return { caps, link, become: (id: string | null) => (holder = id) };
} }
test("filterReadable keeps public, cap-held, ungoverned and graphless items", () => { test("filterReadable keeps only documents whose cap is held; a graphless item names none", () => {
const items = [PRIV, PUB, UNGOV, NOGRAPH]; const items = [MINE, LINKED, FOREIGN, NOGRAPH];
expect(filterReadable(items, caps(), "alice").map(i => (i as Item).id)).toEqual(["a", "p", "n", "x"]); const { caps, become } = setup("alice");
expect(filterReadable(items, caps(), "bob").map(i => (i as Item).id)).toEqual(["p", "n", "x"]); expect(filterReadable(items, caps).map((i) => i.id)).toEqual(["a", "p", "x"]);
expect(filterReadable(items, caps(), null).map(i => (i as Item).id)).toEqual(["p", "n", "x"]);
// bob holds nothing — including the published doc, until he receives its link.
become("bob");
expect(filterReadable(items, caps).map((i) => i.id)).toEqual(["x"]);
}); });
test("makeReadFilteredView filters iteration/size, reflects the current user", () => { test("a bare reference yields nothing — naming is not reading", () => {
const set = new Set<Item>([PRIV, PUB, UNGOV, NOGRAPH]); const { caps } = setup("alice");
let user: string | null = "bob"; // `did:ng:o:other` is perfectly well-formed and perfectly unreadable.
const view = makeReadFilteredView(set, caps(), () => user); expect(filterReadable([FOREIGN], caps)).toEqual([]);
});
expect([...view].map(i => i.id)).toEqual(["p", "n", "x"]); test("receiving the repo link is what opens a published document", () => {
const { caps, link, become } = setup("alice");
become("bob");
expect(filterReadable([LINKED], caps)).toEqual([]);
caps.learn(link);
expect(filterReadable([LINKED], caps).map((i) => i.id)).toEqual(["p"]);
});
test("makeReadFilteredView filters iteration/size, and follows the holder in effect", () => {
const set = new Set<Item>([MINE, LINKED, FOREIGN, NOGRAPH]);
const { caps, become } = setup("bob");
const view = makeReadFilteredView(set, caps);
expect([...view].map((i) => i.id)).toEqual(["x"]);
expect(view.size).toBe(1);
become("alice"); // the held caps are read lazily → the view updates without rewrapping
expect([...view].map((i) => i.id)).toEqual(["a", "p", "x"]);
expect(view.size).toBe(3); expect(view.size).toBe(3);
user = "alice"; // read lazily → view updates without rewrapping
expect([...view].map(i => i.id)).toEqual(["a", "p", "n", "x"]);
expect(view.size).toBe(4);
}); });
test("makeReadFilteredView forwards mutations and membership to the target", () => { test("makeReadFilteredView forwards mutations and membership to the target", () => {
const set = new Set<Item>([PUB]); const set = new Set<Item>([LINKED]);
const view = makeReadFilteredView(set, caps(), () => "bob"); const { caps } = setup("alice");
const view = makeReadFilteredView(set, caps);
const C: Item = { id: "c", "@graph": "did:ng:o:public" }; const C: Item = { id: "c", "@graph": "did:ng:o:public" };
view.add(C); view.add(C);
expect(set.has(C)).toBe(true); // mutation reached the real set expect(set.has(C)).toBe(true); // mutation reached the real set
expect([...view].map(i => i.id)).toEqual(["p", "c"]); expect([...view].map((i) => i.id)).toEqual(["p", "c"]);
view.delete(C); view.delete(C);
expect(set.has(C)).toBe(false); expect(set.has(C)).toBe(false);
}); });
test("forEach is filtered too", () => { test("forEach is filtered too", () => {
const set = new Set<Item>([PRIV, PUB]); const set = new Set<Item>([MINE, LINKED]);
const seen: string[] = []; const seen: string[] = [];
makeReadFilteredView(set, caps(), () => "bob").forEach((i) => seen.push((i as Item).id)); const { caps, become } = setup("alice");
expect(seen).toEqual(["p"]); become("bob");
makeReadFilteredView(set, caps).forEach((i) => seen.push((i as Item).id));
expect(seen).toEqual([]);
}); });
+42 -2
View File
@@ -1,6 +1,21 @@
import { test, expect, mock } from "bun:test"; import { test, expect, mock, afterAll } from "bun:test";
import { readUnion } from "../src/read-model"; import { readUnion } from "../src/read-model";
import { configure, configureStoreRegistry } from "../src/polyfill"; import type { Nuri } from "../src/types";
import {
configure,
configureStoreRegistry,
getCaps,
resetCaps,
setCurrentUser,
} from "../src/polyfill";
// The cap registry is process-wide, so each inject() starts from an empty one:
// once ANY cap exists the possession gate is in force for every reader, and a
// suite that never declares caps must not inherit another suite's.
afterAll(() => {
resetCaps();
setCurrentUser(null);
});
// A fake `ng` whose sparql_query answers the ANCHORED per-doc query (SELECT ?s ?p ?o // A fake `ng` whose sparql_query answers the ANCHORED per-doc query (SELECT ?s ?p ?o
// WHERE { ?s ?p ?o }, anchor = the doc NURI) with ONLY that doc's triples. There is // WHERE { ?s ?p ?o }, anchor = the doc NURI) with ONLY that doc's triples. There is
@@ -31,6 +46,8 @@ function fakeNgWith(triplesByDoc: Record<string, Array<[string, string]>>) {
function inject(triplesByDoc: Record<string, Array<[string, string]>>) { function inject(triplesByDoc: Record<string, Array<[string, string]>>) {
const ng = fakeNgWith(triplesByDoc); const ng = fakeNgWith(triplesByDoc);
resetCaps();
setCurrentUser(null);
configure({ ng: ng as any, useShape: (() => {}) as any }); configure({ ng: ng as any, useShape: (() => {}) as any });
configureStoreRegistry({ configureStoreRegistry({
getSession: async () => ({ sessionId: "sid-rm", privateStoreId: "priv" }), getSession: async () => ({ sessionId: "sid-rm", privateStoreId: "priv" }),
@@ -103,3 +120,26 @@ test("a doc that fails to read is skipped, not aborting the batch", async () =>
// The bad doc failed its read but the good one still lists. // The bad doc failed its read but the good one still lists.
expect(subjects.map((s) => s.subject)).toEqual(["did:ng:o:ok"]); expect(subjects.map((s) => s.subject)).toEqual(["did:ng:o:ok"]);
}); });
// The possession gate, at the read-model's own level: once ANY cap exists, a doc
// whose cap is not in what the current holder holds is dropped — however well its
// NURI resolves. Before the first cap the gate is inert (no regression).
test("readUnion drops a doc whose cap the holder does not hold", async () => {
inject({
"did:ng:o:mine": [[TYPE, `${FP}Event`], [`${FP}title`, "mine"]],
"did:ng:o:theirs": [[TYPE, `${FP}Event`], [`${FP}title`, "theirs"]],
});
const both: Nuri[] = ["did:ng:o:mine", "did:ng:o:theirs"];
// Inert: no cap issued yet → everything flows through.
expect((await readUnion(both)).map((s) => s.subject).sort()).toEqual(both);
// One cap issued → possession is now the rule for every document.
setCurrentUser("alice");
getCaps().mint("did:ng:o:mine");
expect((await readUnion(both)).map((s) => s.subject)).toEqual(["did:ng:o:mine"]);
// …and for every holder: bob holds nothing, so bob reads nothing.
setCurrentUser("bob");
expect(await readUnion(both)).toEqual([]);
});
+21 -45
View File
@@ -1,14 +1,12 @@
import { test, expect, mock, beforeEach, afterAll } from "bun:test"; import { test, expect, mock, beforeEach, afterAll } from "bun:test";
import { import {
ensureAccount, ensureAccount,
allAccounts,
loadShim,
resolveWriteGraph, resolveWriteGraph,
resolveReadGraphs, resolveAccount,
listMyEntityDocs,
resolveScopeGraph, resolveScopeGraph,
resolveInboxAnchor, walletInbox,
createEntityDoc, createEntityDoc,
listEntityDocs,
resetRegistryCache, resetRegistryCache,
} from "../src/store-registry"; } from "../src/store-registry";
import type { RegistrySession } from "../src/store-registry"; import type { RegistrySession } from "../src/store-registry";
@@ -223,19 +221,10 @@ test("ensureAccount de-dupes CONCURRENT provisions (anti-fork): one account, 3 d
for (const r of results) expect(r).toEqual(results[0]!); for (const r of results) expect(r).toEqual(results[0]!);
}); });
test("loadShim round-trips a persisted account across a cache reset", async () => {
await ensureAccount("Bob");
resetRegistryCache(); // force a re-read from the fake store
const map = await loadShim();
const rec = map.get("bob");
expect(rec?.id).toBe("Bob");
expect(rec?.docPublic).toMatch(/^did:ng:o:doc/);
});
test("resolveWriteGraph returns the per-scope index doc; resolveReadGraphs fans out", async () => { test("resolveWriteGraph returns the per-scope index doc", async () => {
const rec = await ensureAccount("Carol"); const rec = await ensureAccount("Carol");
expect(await resolveWriteGraph("carol", "protected")).toBe(rec.docProtected); expect(await resolveWriteGraph("carol", "protected")).toBe(rec.docProtected);
expect(await resolveReadGraphs("public")).toEqual([rec.docPublic]);
}); });
test("resolveScopeGraph maps scopes to native store NURIs (no store-id leaks to the caller)", async () => { test("resolveScopeGraph maps scopes to native store NURIs (no store-id leaks to the caller)", async () => {
@@ -255,14 +244,15 @@ test("resolveScopeGraph maps scopes to native store NURIs (no store-id leaks to
expect(await resolveScopeGraph("private")).toBe("did:ng:PRIV"); expect(await resolveScopeGraph("private")).toBe("did:ng:PRIV");
expect(await resolveScopeGraph("protected")).toBe("did:ng:PROT"); expect(await resolveScopeGraph("protected")).toBe("did:ng:PROT");
expect(await resolveScopeGraph("public")).toBe("did:ng:PROT"); // co-located expect(await resolveScopeGraph("public")).toBe("did:ng:PROT"); // co-located
// The inbox anchor is now a DEDICATED inbox DOCUMENT (a reserved account's // An inbox belongs to ONE virtual user — it is a dedicated document (from
// public scope doc, from docCreate) — NOT the private-store root so inbox // docCreate), not the private-store root, so deposits never bloat the shim graph.
// deposits don't bloat the shim graph. It is a real repo NURI and STABLE // Stable per wallet, and DISJOINT between wallets: reading someone else's inbox
// across calls (same reserved account → same document). // would collect the caps addressed to them (see inbox.ts's read guard).
const anchor = await resolveInboxAnchor(); const mine = await walletInbox("@alice");
expect(anchor).toMatch(/^did:ng:o:doc/); expect(mine).toMatch(/^did:ng:o:doc/);
expect(anchor).not.toBe("did:ng:PRIV"); expect(mine).not.toBe("did:ng:PRIV");
expect(await resolveInboxAnchor()).toBe(anchor); // stable expect(await walletInbox("@alice")).toBe(mine); // stable
expect(await walletInbox("@bob")).not.toBe(mine); // another wallet, another inbox
}); });
test("resolveScopeGraph falls back to the private store when no protected id is injected", async () => { test("resolveScopeGraph falls back to the private store when no protected id is injected", async () => {
@@ -272,34 +262,21 @@ test("resolveScopeGraph falls back to the private store when no protected id is
expect(await resolveScopeGraph("public")).toBe("did:ng:PRIV"); expect(await resolveScopeGraph("public")).toBe("did:ng:PRIV");
}); });
test("createEntityDoc + listEntityDocs round-trip via the per-scope index", async () => { test("createEntityDoc + listMyEntityDocs round-trip via the per-scope index", async () => {
const rec = await ensureAccount("Dave"); const rec = await ensureAccount("Dave");
const e1 = await createEntityDoc("dave", "public"); const e1 = await createEntityDoc("dave", "public");
const e2 = await createEntityDoc("dave", "public"); const e2 = await createEntityDoc("dave", "public");
const other = await createEntityDoc("dave", "protected"); const other = await createEntityDoc("dave", "protected");
// Public listing unions dave's public entities only. // Public listing unions dave's public entities only.
const pub = await listEntityDocs("public"); const pub = await listMyEntityDocs("dave", "public");
expect(pub.sort()).toEqual([e1, e2].sort()); expect(pub.sort()).toEqual([e1, e2].sort());
const prot = await listEntityDocs("protected"); const prot = await listMyEntityDocs("dave", "protected");
expect(prot).toEqual([other]); expect(prot).toEqual([other]);
// The index append targets the account's public index doc. // The index append targets the account's public index doc.
expect(rec.docPublic).toMatch(/^did:ng:o:doc/); expect(rec.docPublic).toMatch(/^did:ng:o:doc/);
}); });
test("listEntityDocs fans out across multiple accounts", async () => {
await ensureAccount("Eve");
await ensureAccount("Frank");
const e = await createEntityDoc("eve", "public");
const f = await createEntityDoc("frank", "public");
expect((await listEntityDocs("public")).sort()).toEqual([e, f].sort());
});
test("allAccounts reflects every ensured account", async () => {
await ensureAccount("Gina");
await ensureAccount("Hank");
const names = (await allAccounts()).map((a) => a.id).sort();
expect(names).toEqual(["Gina", "Hank"]);
});
// --- SPARQL injection hardening (F1) -------------------------------------- // --- SPARQL injection hardening (F1) --------------------------------------
// //
@@ -384,12 +361,11 @@ test("injection: a malicious id still round-trips through the shim", async () =>
const rec = await ensureAccount(evil); const rec = await ensureAccount(evil);
expect(rec.id).toBe(evil); expect(rec.id).toBe(evil);
resetRegistryCache(); resetRegistryCache();
const map = await loadShim(); // The stored id comes back verbatim (escaping is lossless) when resolved by its
// The stored id came back verbatim (escaping is lossless) under its // own key — and no injected extra subject answers in its place.
// normalized key, and exactly ONE account exists (no injected extra subject). const back = await resolveAccount(evil);
const key = evil.trim().replace(/^@+/, "").toLowerCase(); expect(back?.id).toBe(evil);
expect(map.get(key)?.id).toBe(evil); expect(back?.docPublic).toBe(rec.docPublic);
expect(map.size).toBe(1);
}); });
test("normalizeId defaults to trim when not provided", async () => { test("normalizeId defaults to trim when not provided", async () => {
+18
View File
@@ -30,6 +30,7 @@ import {
configureStoreRegistry, configureStoreRegistry,
resetStoreRegistry, resetStoreRegistry,
resetConfig, resetConfig,
resetCaps,
setCurrentUser, setCurrentUser,
} from "../src/polyfill"; } from "../src/polyfill";
import { resetRegistryCache, createEntityDoc } from "../src/store-registry"; import { resetRegistryCache, createEntityDoc } from "../src/store-registry";
@@ -156,6 +157,18 @@ function makeFake(opts?: { holdState?: boolean }) {
})); }));
return { results: { bindings } }; return { results: { bindings } };
} }
if (query.includes("<urn:ng-eventually:shim:inboxCap>")) {
const bindings = quads
.filter((q) => q.g === anchor && q.p === "urn:ng-eventually:shim:inboxCap")
.map((q) => ({ c: { value: q.o } }));
return { results: { bindings } };
}
if (query.includes("<urn:ng-eventually:shim:readCap>")) {
const bindings = quads
.filter((q) => q.g === anchor && q.p === "urn:ng-eventually:shim:readCap")
.map((q) => ({ c: { value: q.o } }));
return { results: { bindings } };
}
if (query.includes("<urn:ng-eventually:shim:contains>")) { if (query.includes("<urn:ng-eventually:shim:contains>")) {
const bindings = quads const bindings = quads
.filter((q) => q.g === anchor && q.p === "urn:ng-eventually:shim:contains") .filter((q) => q.g === anchor && q.p === "urn:ng-eventually:shim:contains")
@@ -214,6 +227,7 @@ function inject(ng: ReturnType<typeof makeFake>) {
}); });
resetRegistryCache(); resetRegistryCache();
resetOpenedRepos(); resetOpenedRepos();
resetCaps();
} }
// Insert a triple straight into a doc's graph in the fake store (no push). // Insert a triple straight into a doc's graph in the fake store (no push).
@@ -242,6 +256,9 @@ afterAll(() => {
resetStoreRegistry(); resetStoreRegistry();
resetRegistryCache(); resetRegistryCache();
resetOpenedRepos(); resetOpenedRepos();
// The cap registry is process-wide: leaving caps behind would put the possession
// gate in force for a suite that never declares any.
resetCaps();
}); });
describe("watchShape", () => { describe("watchShape", () => {
@@ -369,4 +386,5 @@ describe("watchShape", () => {
expect(snap.data.length).toBe(1); expect(snap.data.length).toBe(1);
unsub(); unsub();
}); });
}); });