122 Commits

Author SHA1 Message Date
Sylvain Duchesne abfb619c76 docs: ce qu'un IRI dit en amont, et pourquoi l'échafaudage ne partage pas le namespace de la donnée 2026-08-21 22:28:09 +02:00
Sylvain Duchesne 3dfc231005 feat: un dépôt est aiguillé sur son type, comme en amont, et l'index a son bras 2026-08-21 19:28:48 +02:00
Sylvain Duchesne 1b4fcb567e docs: qui traite une inbox, acteur par acteur — et watch absorbe sans classer 2026-08-20 14:31:24 +02:00
Sylvain Duchesne 0e57f20ca4 docs: consigner deux défauts vivants, dont une absence qui masque un échec 2026-08-20 11:05:34 +02:00
Sylvain Duchesne c61b181ed8 chore: le manifeste rattrape la version que le contrat annonce 2026-08-20 11:04:49 +02:00
Sylvain Duchesne 6a99585efc fix: dire le pari comme un pari, et distinguer deux manques d'inbox que rien ne distinguait 2026-08-20 10:47:39 +02:00
Sylvain Duchesne c507e79f8a docs: ce que NextGraph offre vraiment autour des inbox, et ce qui n'est qu'un nom réservé 2026-08-20 10:30:31 +02:00
Sylvain Duchesne fafac38907 docs: dire l'observation continue, sa limite, et ce qu'ensureIdentity ne promet pas 2026-08-17 14:47:40 +02:00
Sylvain Duchesne c7c6cb96ea fix: une tentative dépassée n'ouvre plus rien, et libère ce qu'elle avait ouvert 2026-08-17 14:01:07 +02:00
Sylvain Duchesne 7b35300723 fix: un rejet tardif ne ferme plus un canal vivant, un registre illisible ne perd plus toutes les inbox 2026-08-17 12:17:59 +02:00
Sylvain Duchesne 6eaff0b985 docs: une seule copie du polyfill par application, sinon deux identités courantes 2026-08-17 11:48:25 +02:00
Sylvain Duchesne 33212a8b00 fix: un abonnement en échec n'empoisonne plus le document, et un dépôt ne change plus de destinataire 2026-08-17 11:47:21 +02:00
Sylvain Duchesne 520c8c59a8 refactor(e2e): prendre les types navigateur chez les helpers plutôt que chez playwright 2026-08-17 11:12:26 +02:00
Sylvain Duchesne 935cce4d7b fix: un second doc_subscribe tuait le premier, et les inbox n'étaient lues qu'à la connexion 2026-08-17 11:11:04 +02:00
Sylvain Duchesne 6dfdf2f036 feat(e2e-helpers): republier les types Playwright que notre surface mentionne 2026-08-17 11:10:30 +02:00
Sylvain Duchesne 72bb72c0b7 docs: useShape ne démarre pas contre un broker antérieur au renommage 2026-08-17 11:05:38 +02:00
Sylvain Duchesne bf1e16e1f3 docs: un tag de dépôt ne dit pas quel paquet il a figé 2026-08-17 10:53:05 +02:00
Sylvain Duchesne 1957a8fe0c docs: des contrats à deux voix, et une politique de version qui dit la vérité
Le régime des contrats est devenu bidirectionnel : chaque partie rédige sa
position dans son dépôt et tire celle de l'autre. Le fournisseur écrit son
engagement, chaque consommateur écrit sa déclaration — ce qu'il appelle
vraiment, sous quelles contraintes, et avec quelles frictions.

Ce dernier point change nos usages : la section Frictions est le chemin par
lequel un consommateur expose un problème, le canal hors-bande ne portant que le
signal. Les huit points que Festipod nous a remontés auraient dû arriver par là,
et notre réponse appartient à l'engagement plutôt qu'à un échange qui s'évapore.

Trois interfaces existaient, une seule était documentée. Elles ont maintenant
leur dossier et leurs deux voix : la surface du polyfill, ng-e2e-helpers, et la
couche d'indexation. On a écrit les engagements dont nous sommes l'auteur et les
déclarations des consommateurs qui sont à nous — délibérément AUCUN
usage_festipod : ce n'est pas notre dépôt, et décider à leur place ce qu'ils
consomment viderait le mécanisme de son sens.

Et la Change policy s'inverse. Elle disait « ce paquet n'offre pas de stabilité
sémantique ». Refuser un schéma ne ralentit pas le mouvement — ça retire au
consommateur le seul outil pour le gérer, alors qu'on faisait déjà le travail
d'une publication versionnée sans lui en donner l'étiquette.

Semver, donc, et les majeurs sont le cas NORMAL : une surface qui converge vers
une cible qu'elle n'atteint pas encore casse souvent, et c'est le signal vrai.
Le texte dit les déclencheurs plutôt que le nom du schéma — retirer un symbole
publié ou resserrer un appel est un majeur, ajouter est un mineur. Version
pleine sur main, pré-version sur une branche : Festipod peut épingler
aujourd'hui sans qu'on lui promette du stable, et sans qu'on ait à lui retirer
son adresse à la fusion.

Au passage, la signature publiée de watchShape était fausse — elle annonçait un
argument unique là où l'appel en prend deux. Rapporté par Festipod, confirmé
indépendamment.
2026-08-17 10:10:12 +02:00
Sylvain Duchesne a8d53010c2 fix: une lecture réactive qui n'a pas pu répondre ne dit plus « rien »
watchShape attrapait l'échec de résolution des documents, journalisait, et
passait une liste vide. Or une barrière sur zéro document est franchie
trivialement — la surface publiait donc { data: [], isPending: false,
isSuccess: true }, octet pour octet l'instantané « synchronisé et vide ». La
seule distinction pour laquelle ce module existe était celle qu'il détruisait.

Un échec de résolution devient isError, jamais isSuccess. Et comme un
observable ne peut pas dé-émettre, l'état « je ne sais plus » conserve la
DERNIÈRE lecture qui a répondu, avec isSuccess à faux : une liste vide n'est
jamais la réponse d'un échec.

Le canal choisi est l'état de chargement, parce que c'est celui qu'une
application lit déjà pour distinguer « en attente » de « vide ». La troisième
valeur ne lui coûte aucun vocabulaire neuf.

Vérification faite en amont plutôt qu'en supposant : readyPromise n'aurait pas
aidé — construit avec resolve seul, rien ne le rejette, et l'échec
d'orm_start_graph n'est qu'un console.error. Le « je n'ai pas pu savoir » de la
cible EST son « toujours en attente ». La classification invention tient, et les
annotations le disent désormais.

C'est le dernier membre connu de cette famille dans le polyfill : après
connectedUser, resolveAccount, userInbox, readInboxCapPairs et
listMyEntityDocs, la couche réactive était le dernier endroit où un échec se
présentait comme une absence.
2026-08-17 09:55:54 +02:00
Sylvain Duchesne 76ae9ffbb7 fix: trois chemins vers une inbox en double, et la lecture qui manquait
Une application a rapporté quatre appels simultanés sur un même document
enregistrant trois inboxes. Le contrat garantissait l'inverse. En cherchant, on
en a trouvé DEUX autres, indépendantes, qui produisent le même dégât durable :
le propriétaire surveille une inbox pendant que les dépôts arrivent dans une
autre.

La concurrence. openDocumentInbox ne partageait rien avec userInbox — module
différent, registre propre, aucune coalescence. Reproduit pire que rapporté :
quatre appels donnaient QUATRE inboxes. Une carte en vol par (détenteur,
document), et le corps déplacé pour que l'invariant soit porté par la
composition plutôt que par la position d'une vérification.

La limite est nommée plutôt que cachée : deux onglets ne partagent aucune carte,
chacun lit, chacun ne trouve rien, chacun frappe. Ce n'est pas réparable ici —
une branche est en ajout seul, et ça ne se réconcilie pas après coup, le
propriétaire lisant sa branche User quand un déposant lit l'adresse publiée du
document. Le contrat porte donc une garantie positive ET une non-garantie.

La page froide. readInboxCapPairs était le seul lecteur de store sans barrière,
correct uniquement parce qu'une autre fonction s'exécutait avant lui à la
connexion. Une dépendance d'ordre, pas une garantie portée par la lecture : sur
une page froide il lisait le store privé non synchronisé, répondait « aucune
inbox » et en frappait une seconde. Un seul appel, aucune concurrence. La
barrière est désormais dans la lecture, et elle ne coûte rien aux chemins
connectés, la connexion ayant déjà ouvert les trois stores.

Et la lecture qui manquait. readSynced donnait la garantie, readForDocument
l'adressage, pas leur intersection — si bien que matérialiser des dépôts
obligeait une application à résoudre une adresse d'inbox elle-même, ce que le
contrat lui interdit explicitement. inbox.readSyncedForDocument la lui épargne.
Elle traverse deux dépôts, l'adresse vivant sur l'en-tête du document et les
dépôts sur l'inbox — franchir la barrière sur la seule inbox ne réparait rien.

Au passage, le compteur d'identifiants de la doublure était par page : une page
rechargée refrappait le même identifiant PAR-DESSUS une inbox existante,
aliasant deux dépôts en silence. Il est monotone.
2026-08-17 09:46:16 +02:00
Sylvain Duchesne 90712e0ad0 fix: une liste qu'on ne peut pas ouvrir n'est pas une liste
listMyEntityDocs rendait la liste des documents même quand la lecture des clés
échouait. Vu de l'appelant, une liste dont les documents ne s'ouvrent pas est
INDISCERNABLE d'une liste dont ils s'ouvrent : rien ne signale la différence
jusqu'à une lecture ultérieure qui revient vide, la cause étant alors loin
derrière.

C'était un choix délibéré — ne pas transformer un appel publié en levée, la
liste étant déjà en main à ce moment-là. C'est précisément ce qui en faisait une
demi-vérité plutôt qu'un raccourci. Et c'était le dernier membre connu de la
famille qui a produit une panne chez une application cette semaine.

Les deux lectures sur lesquelles l'appel repose remontent désormais : la branche
Main dit quels documents sont là, la branche Store dit ce qui ouvre chacun. Un
tableau vide signifie donc que ce compte n'a rien créé dans cette portée, et
jamais que le store n'a pas été lu.

readUserStore avalait le même échec pour son propre compte ; son autre appelant,
ownsDocument, garde le comportement actuel par un catch explicite et documenté —
toutes ses réponses étant des refus, il échoue en fermeture, ce qui est la
règle qu'e32b6d0 avait posée.

C'est un changement de comportement d'un appel publié, donc le contrat le dit,
et docs/api-contract.md aussi.

Au passage, deux citations pourries corrigées en citant un SYMBOLE plutôt qu'une
ligne — types.rs:4251 désignait DialogRequest et non Link, index.d.ts:138
désignait const ng et non le type NG. Les douze autres références numériques du
voisinage ont été vérifiées : aucune n'avait bougé.
2026-08-17 08:59:34 +02:00
Sylvain Duchesne 43aadbeb45 docs: chaque symbole dit d'où il vient
98 annotations posées à côté des déclarations, et un test qui les exige sur la
surface publiée. Elles portent trois choses : le niveau qui répond, la référence
amont, et la catégorie parmi les cinq.

La cinquième est celle qui manquait : declared-not-wired, quand la cible DÉFINIT
la forme et ne la câble pas. Neuf symboles en relèvent, dont readLinks — que
j'avais classé « notre invention » en raisonnant depuis l'absence, alors que
c'est le meilleur alignement disponible.

Les références citent un SYMBOLE, jamais une ligne : trois citations du document
avaient déjà pourri. Cinq corrections au passage, toutes vérifiées à la source —
un chemin ORM qui n'existe pas, deux plages de lignes fausses, et surtout
docs.* et subscribeDoc étiquetés PASSTHROUGH alors qu'ils sont alignés : nos
noms, plus un argument jamais transmis. La sémantique survit à la migration,
les sites d'appel non, et la nuance disparaissait sous une étiquette trop
flatteuse.

Le test échoue à l'annotation retirée, à la catégorie mal orthographiée, et à
une invention qui prétendrait citer une référence — vérifié en cassant les
trois. Il a aussi attrapé un défaut en lui-même : le gabarit de format placé
dans index.ts se faisait analyser comme une annotation.

La classification couvre l'interne qui prétend ressembler à la cible — tout
emulated-verifier — et exclut ce qui ne le prétend pas. La faute d'origine
portait sur une fonction non exportée ; n'être pas publié n'a protégé personne.

Quatre symboles ont résisté et sont annotés avec leur catégorie dominante, la
seconde nommée dans la note plutôt que lissée.
2026-08-16 22:53:50 +02:00
Sylvain Duchesne 6138d831da docs: une définition est un fait, son absence de câblage est un silence
J'ai classé readLinks comme notre invention parce qu'AddLinkV0 n'est construit
nulle part en amont. C'était raisonner depuis l'absence — ce que cette règle
interdit, et que je citais dans la même session. La bonne lecture était que la
cible a DÉFINI la forme et ne l'a pas câblée : nous l'implémentons, ce qui est
le meilleur alignement disponible.

Les deux arrivent ensemble et l'absence est la plus bruyante, ce qui lui fait
gagner des arguments qu'elle devrait perdre. D'où la règle : lire la
déclaration, et laisser le silence autour ne rien dire.

Elle gagne sa place dans la taxonomie, parce que la confondre avec « invention »
fait croire qu'on est plus loin de la cible qu'on ne l'est — et un lecteur
ultérieur, voyant « notre idée », se sentira libre de la changer.

Cinq catégories désormais, et une provenance à côté de chaque symbole plutôt que
dans un document qui dérive : le niveau qui répond, la référence amont, la
catégorie. Un test l'exige sur la surface publiée. Il ne peut pas vérifier que
la citation est vraie, et c'est acceptable : une citation se réfute en quelques
secondes, une prose non — docs/api-contract.md affirmait un passthrough « 1:1 »
faux sur deux points et un décompte de membres erroné, sans que rien n'oblige
personne à aller voir.

La classification vaut aussi pour le code non publié qui PRÉTEND ressembler à la
cible : la faute qui motive tout ceci a été commise sur une fonction interne, et
n'être pas exportée n'a protégé personne.
2026-08-16 22:35:59 +02:00
Sylvain Duchesne 98ee511d3a test(e2e): le parcours qui manquait — Alice revient, et tout est encore à elle
Deux défauts ont été livrés et rapportés par une application, sans que la suite
applicative puisse les voir. Le trou était précis : aucun parcours ne faisait
revenir un PROPRIÉTAIRE après qu'il a ouvert son document aux messages. Le
parcours 3 fait ouvrir Alice et revenir Bob ; Alice, elle, ne se reconnecte
jamais. Il s'arrêtait une reconnexion trop tôt.

Alice écrit deux notes publiques, en ouvre une aux messages, Bob y dépose en la
nommant, puis Alice revient. Elle doit être reconnue, lire la note qu'elle a
faite en ne tenant que sa référence, POUVOIR ENCORE Y ÉCRIRE, et trouver le
message laissé en son absence.

L'écriture compte autant que la lecture : un document public survit à une
relecture après rechargement, sa clé étant retrouvée dans le store, et n'échoue
qu'à l'écriture. Un parcours qui se contenterait de relire aurait manqué la
moitié.

Et il a été vérifié contre le code d'AVANT le correctif, dans un worktree
jetable : les quatre vérifications échouent, sur
« docs.sparqlQuery: refused — the connected user does not hold this document's
cap ». Ce refus apparaît une fois dans chaque journal d'avant et zéro fois dans
les trois d'après. Un parcours qui passe des deux côtés ne prouve rien — c'est
exactement comme ça que ce trou avait survécu.

La reconnexion est vraie : nouvelle page, réalisme JS neuf, donc tous les caches
de module disparaissent pendant que le portefeuille reste intact. Rien n'est
pré-injecté — la référence qu'Alice colle, elle l'a lue sur son propre écran.

Total de vérifications : 34 → 39.
2026-08-16 19:04:25 +02:00
Sylvain Duchesne a33fb8a214 docs: se connecter restaure ce qu'on possède, et un dépôt fautif ne verrouille personne
Deux affirmations à corriger, toutes deux écrites par moi et démenties par le
bug d'aujourd'hui.

La feuille disait que se connecter « restaure ce qui vous a été partagé ». C'est
la moitié : le portefeuille garde vos propres clés ailleurs, et ne rejouer que
le registre des partages refusait à une application son PROPRE document après un
rechargement. La règle qui reste : rejouer TOUS les registres durables, pas
celui qui se trouve sur le chemin qu'on teste.

Et la règle « tout échec remonte » était trop grossière. Elle était juste pour
la restauration ; appliquée à chaque étape, elle verrouillait. Un dépôt
inapplicable n'est pas consommé par son échec, donc il refusait la session à
chaque tentative — une personne qui ne peut plus entrer, là où il n'y avait
qu'un partage manquant.

La distinction consignée : ATTEINDRE les files et restaurer sont de
l'infrastructure, et refuser la session y est juste ; APPLIQUER un élément est
de la donnée, et ça se signale sans priver personne de sa session.

Plus la forme générale, parce qu'elle se reproduira : devant un « tout échec
remonte », demander lesquels se rattrapent en réessayant plus tard et lesquels
sont définitifs pour l'acteur. Les définitifs ne doivent jamais garder une porte
qu'il n'a pas d'autre moyen de franchir.
2026-08-16 17:58:25 +02:00
Sylvain Duchesne f6d1734679 fix: à la reconnexion, on retrouve ce qu'on possède — pas seulement ce qu'on a reçu
Une application signalait deux symptômes. Ils sont indépendants, et ils ont une
racine commune : se connecter ne rejouait qu'UN des registres durables du
portefeuille.

connectedUser lisait les AddLink de la branche User — ce qu'on vous a partagé —
et rien d'autre. Les capacités des documents que vous avez FAITS vivent sur la
branche Store, et un seul chemin les relisait : listMyEntityDocs. Une
application qui recharge et va droit à sa note ne tenait donc rien pour elle.
Constaté : capFor(note) vaut undefined juste après une connexion résolue, et
devient défini dès que listMyEntityDocs passe.

Le public survivait en lecture parce que fetchReadCap va rechercher la clé dans
le store ; il échouait quand même à l'écriture, qui ne fait pas cette démarche.

Deuxième défaut : myInboxes énumérait les inbox sans en remettre la clé au
détenteur. Se connecter demandait donc un document qu'on n'avait pas de quoi
lire — sur une inbox, que l'application n'a jamais nommée puisque rien ne le
lui permet.

Troisième défaut, et c'est lui qui rendait tout ça fatal : un drainage refusé
faisait échouer toute la connexion. Une inbox n'étant pas consommée par un
échec, elle refusait à chaque tentative suivante. D'où le « trois fois sur
trois », et d'où un verrouillage plutôt qu'un partage manquant.

C'est ma spécification qui l'a créé. En rendant les échecs visibles j'avais
écrasé une distinction : ne pas ATTEINDRE la file est une panne, et refuser la
session est juste ; ne pas pouvoir APPLIQUER un élément est une donnée, et ça ne
doit priver personne de sa session. Le drainage par inbox est désormais isolé —
signalé haut et fort, les autres files drainées, la session accordée — tandis
qu'énumérer les files et restaurer rejettent toujours.

Le parcours e2e ratait les deux : personne ne se reconnecte après avoir ouvert
son document aux messages. Il s'arrêtait une reconnexion trop tôt.
2026-08-16 17:23:27 +02:00
Sylvain Duchesne b50591f5bd feat(e2e-helpers): fabriquer un portefeuille, pour un test comme pour un déploiement
Trois appelants veulent la même chose — un portefeuille neuf, ses octets et son
mot de passe : la suite du polyfill à son démarrage, la suite d'une application
au sien, et un humain une fois pour provisionner un déploiement. Rien ne
produisait ce fichier ; il fallait aller le chercher à la main sur nextgraph.eu.

Mêmes entrées, mêmes sorties, mais des appelants de nature différente : une
suite ne va pas lancer un sous-processus et analyser sa sortie, et un humain ne
va pas écrire un fichier jetable pour appeler une fonction. Donc une fonction,
et un script mince par-dessus.

mintWalletBytes(password, name) rend les OCTETS, pas un chemin. C'est une
correction de ce qui existait : exportWalletFile imposait le disque à tout le
monde, et la suite applicative écrivait un fichier temporaire pour le relire
aussitôt en mémoire, puis devait le nettoyer. Elle ne le fait plus — serveApp
prend les octets. Qui veut un fichier l'écrit ; personne n'y est forcé.

Le script exige --password et refuse d'en inventer un, et refuse d'écraser un
.ngw existant sans --force, vérifié AVANT de fabriquer quoi que ce soit.
Exécuté pour de vrai : 800 octets, et le mot de passe imprimé est celui passé en
entrée. C'est le seul des trois cas qu'aucune suite n'exerce, donc le seul qui
pouvait être livré cassé sans que rien ne le dise.

Ce qui ne change pas : les suites fabriquent un portefeuille par exécution et
n'en héritent jamais ; le provisionnement veut l'inverse, durable et conservé.
Ils partagent la fabrication et l'export, ils divergent sur la durée de vie.
Les identifiants en dur deviennent un paramètre — les suites passent toujours
les leurs, le script prend ceux qu'on lui donne, et il n'existe aucun mot de
passe par défaut.
2026-08-16 15:44:37 +02:00
Sylvain Duchesne 07dfe68473 feat: un dépôt est traité vingt secondes après, sans attendre son destinataire
Un dépôt attendait la prochaine connexion de son destinataire — potentiellement
des heures. Un vrai NextGraph aura un service qui traite les inbox en continu ;
il n'existe pas. On l'émule : après une écriture dans une inbox, une échéance
unique de vingt secondes draine l'inbox DE LA CIBLE.

C'est une usurpation d'identité, possible seulement parce qu'un portefeuille
partagé détient toutes les identités virtuelles. Elle est acceptable parce que
l'application n'apprend rien de faux : elle observe que les dépôts finissent par
converger, ce qui restera vrai avec un vrai service. Ce qui ne doit pas fuir,
c'est le mécanisme.

Trois gardes, tenues par du code et non par des consignes.

Rien n'atteint la surface publiée : les exports sont épinglés par un test, et
publier ceci reviendrait à publier un appel qui traite l'inbox d'autrui — après
quoi il ne resterait rien du modèle de confidentialité.

Le drainage agit avec un détenteur EXPLICITE, jamais l'identité ambiante. Le
propriétaire vient d'un enregistrement de routage (shim:inboxOwner), et cet
identifiant est passé à chaque étape. C'était le vrai danger : readLinks et
myInboxes demandent getCurrentUser() au moment où elles s'exécutent, donc un
drainage lancé pendant la session d'Alice aurait classé les capacités de Bob
chez elle. Le test l'épingle — après le drainage, Alice n'a aucune capacité sur
le document concerné, et les deux Links sont bien chez Bob, durablement.

Et les échecs remontent au journal d'accès au lieu de disparaître. Une boucle
différée qui avale ses erreurs, c'est la famille retirée en 8c8ade7 et e32b6d0.

Coalescence : une seule échéance en attente par cible, et deux drainages d'une
même inbox ne se chevauchent jamais — processInbox écrit ce qu'il applique.

Limite assumée : si la page disparaît avant l'échéance, le dépôt attend la
prochaine connexion. C'est le comportement honnête d'une émulation qui tient la
place d'un service absent.
2026-08-16 15:17:49 +02:00
Sylvain Duchesne 9e7aeef269 docs: deux exécutions peuvent maintenant se chevaucher
La feuille disait le contraire, et c'est moi qui l'ai écrite hier : « deux
exécutions ne doivent jamais se chevaucher », « c'est désormais tenu par un
verrou », « une suite d'un dépôt consommateur entre en collision ». Les trois
sont fausses depuis l'extraction.

Chaque exécution a son propre répertoire de profil et tue le navigateur qui le
détient en finissant. Les exécutions concurrentes — y compris celle d'une
application consommatrice, depuis son propre dépôt — sont indépendantes par
construction.

Et la leçon de forme est consignée, parce qu'elle se généralise : le verrou ne
traitait qu'un symptôme, supprimer le partage a supprimé le besoin. Quand deux
exécutions se disputent une chose, la rendre propre à chacune plutôt que de
sérialiser l'accès.
2026-08-16 14:17:38 +02:00
Sylvain Duchesne 0607843b81 chore: le lockfile connaissait encore packages/client
Il nommait le paquet d'avant deux renommages — donc il était périmé depuis
plusieurs jours, et un clone frais suivi de bun install n'aurait pas produit
l'arborescence sur laquelle on mesurait. L'extraction l'a rendu visible : il a
fallu créer les liens d'espace de travail à la main pour que le nouveau paquet
se résolve.

Régénéré : ng-e2e-helpers y figure, packages/client a disparu, et les portes
tiennent après réinstallation.

Et .gitignore listait encore le répertoire de profil partagé, supprimé avec le
verrou.
2026-08-16 14:16:34 +02:00
Sylvain Duchesne 1271d48e9f refactor(e2e): la mécanique de test devient un paquet à part, ng-e2e-helpers
Créer un portefeuille, en obtenir le .ngw, traverser le broker : ce n'est pas du
ressort du polyfill. C'est un besoin commun au polyfill et à toute application
NextGraph — et surtout, ça SURVIT à la migration, alors que le polyfill est fait
pour disparaître. L'y laisser, c'était le faire mourir avec lui ou rendre le
polyfill indéracinable.

Le paquet n'importe rien du polyfill — vérifié mécaniquement — et déclare
Playwright et @ng-org/web en pairs, le consommateur devant maîtriser les
versions. Sa surface : attentes bornées, mesure, navigateur, profils,
portefeuille, traversée du broker, rapport d'exécution, et reconnaissance des
modes de panne connus.

La preuve qu'il est utilisable de l'extérieur : le polyfill le CONSOMME, sans
garder de copie. Restent chez lui les parcours, la barrière et les identités
virtuelles, qui lui sont propres.

Le verrou entre exécutions disparaît, remplacé par un profil par exécution. Il
ne traitait qu'un symptôme — un répertoire partagé que la création de
portefeuille effaçait. Avec un profil par exécution il n'y a plus rien à
sérialiser, les exécutions concurrentes deviennent indépendantes, et la
collision entre deux dépôts s'évanouit au lieu d'être exportée. Six exécutions :
aucun répertoire ni Chromium orphelin.

Et la connaissance descriptive est séparée du pilotage : URL, sélecteurs et
inventaire ordonné des écrans sont des données, passées DANS la page pour la
reconnaissance — donc un échec nomme le même écran que celui sur lequel on
dispatchait.

Un échec de navigateur est désormais nommé comme tel — « the actors browser
STOPPED ANSWERING » — au lieu de sortir sous le nom de l'opération innocente qui
se trouvait en vol.
2026-08-16 14:16:11 +02:00
Sylvain Duchesne cf3c7c7d8b docs: une borne englobante plus courte que ses étapes rend tout échec muet
La leçon du tour, et elle valait des jours : SIGN_IN_MS valait 180 s sur des
étapes totalisant 270 s. La borne du dessus se déclenchait donc toujours la
première, et chaque échec rapportait son nom à elle — jamais celui de l'étape en
cause. On a cherché une cause que le harnais était structurellement incapable de
nommer.

D'où les deux règles consignées : calculer une borne englobante à partir de ses
parties au lieu de choisir un nombre, et dimensionner chaque borne terminale sur
une durée MESURÉE inscrite à côté d'elle. Un chiffre nu ne dit pas s'il est
généreux ou serré, et pourrit sans que personne le voie.

Plus une troisième : déclarer les parcours et leurs vérifications avant que quoi
que ce soit puisse échouer, pour qu'une exécution rende toujours le même nombre
de lignes. Quand le total bouge avec la panne, deux exécutions ne sont plus
comparables — et un total qui rétrécit se lit comme un problème plus petit alors
qu'il est plus gros.

Enfin, un fait observé : notre verrou ne garde que ce dépôt. Une suite
appartenant à une application consommatrice, lancée depuis son propre checkout
contre le même broker, entre en collision exactement comme deux des nôtres — et
c'est invisible des deux côtés.
2026-08-16 12:38:29 +02:00
Sylvain Duchesne ed0f872f5d test(e2e): un échec ne peut plus emporter les suivants, et les bornes sont mesurées
Trois choses, dont une qui explique pourquoi aucun diagnostic n'aboutissait.

SIGN_IN_MS valait 180 s alors que la somme de ses propres étapes en faisait 270.
La borne englobante se déclenchait donc TOUJOURS avant celle de l'étape en
cause, et ne pouvait dire qu'une chose : « bob-… to sign in ». Le message était
structurellement condamné à ne rien apprendre — on a cherché des jours une cause
que le harnais s'interdisait de nommer. Les bornes englobantes sont maintenant
des sommes calculées de leurs étapes.

Les parcours sont isolés. La suite déclare ses 7 parcours et leurs 27
vérifications AVANT tout lancement de navigateur, et rend donc toujours 34
lignes — y compris quand le montage meurt, où les parcours non exécutés sont
rapportés comme tels. Auparavant le total valait 24, 26 ou 27 selon ce qui
mourait : deux exécutions ne mesuraient même pas la même chose. Un échec est
contenu, pas absorbé — il reste compté.

Et chaque borne est dimensionnée sur une durée MESURÉE, inscrite à côté d'elle
dans le code. Le premier rendu d'un acteur prend 4,9 à 7,4 s et vaut 45 s ; la
traversée du broker 1,3 à 2,8 s et vaut 75 s. Un nombre nu n'apprend rien et
pourrit en silence.

Au passage : un walletPage.close() n'avait aucune borne du tout.
2026-08-16 12:28:44 +02:00
Sylvain Duchesne 1ecf511e9d docs(contract): un rejet veut dire « inconnu », jamais « absent »
Le contrat n'énonçait qu'un cas particulier — createEntityDoc lève si le
document ne peut pas être enregistré — alors que c'est devenu la règle de toute
une famille d'appels.

Ceux qui doivent d'abord SAVOIR si une chose existe avant d'agir lèvent
désormais quand ils n'ont pas pu le savoir, au lieu de continuer comme si la
réponse était « rien ». C'est ce qui a cessé de fabriquer une seconde inbox, un
compte dupliqué ou une racine de registre en double.

La conséquence est actionnable et doit être dite : un rejet ne signifie pas que
la chose est absente, il signifie qu'on l'ignore. Le relancer ou le remonter,
mais surtout ne pas le lire comme un résultat vide — ce qui reviendrait à
réintroduire côté appelant exactement le défaut qu'on a retiré.
2026-08-16 10:22:36 +02:00
Sylvain Duchesne f30685bdb9 docs: la doctrine enseignait le réflexe qui a coûté la journée
La feuille disait : « si la suite rapporte un délai nommé plutôt qu'une
assertion échouée, soupçonner le transport avant le code ». C'est faux, et je
l'ai écrite hier.

Un délai dit seulement qu'une chose n'est pas arrivée à temps. Il ne dit jamais
pourquoi — et se tourner vers l'environnement est la réponse confortable,
puisqu'elle exonère le code.

Le cas est maintenant raconté dans la feuille : tous les sign-in expiraient, on
a accusé le broker et le réseau de l'hôte pendant des heures, et la cause était
une correspondance de sous-chaîne sur une URL, lisible depuis le début. Le
propriétaire du dépôt a tranché contre cette attribution — « je n'ai jamais eu
de problème avec le broker, les tests si » — et il avait raison.

La discipline devient : lire son propre harnais d'abord, et ne parler de
transport qu'une fois le mécanisme nommé.
2026-08-14 10:57:21 +02:00
Sylvain Duchesne 7062364569 fix(e2e): le harnais reconnaissait la page du broker comme l'application
Le prédicat qui cherchait l'iframe applicative était une correspondance de
sous-chaîne : f.url().includes("127.0.0.1"). Or la page d'authentification du
broker porte l'adresse de l'application DANS SA PROPRE requête —
nextgraph.eu/auth/#/?o=http%3A%2F%2F127.0.0.1%3A39975. Elle correspondait donc
dès le premier instant.

Conséquence en chaîne : la boucle d'attente sortait immédiatement, le clic sur
le portefeuille et la saisie du mot de passe étaient sautés comme « déjà
connecté », et la frame principale du broker était rendue à l'appelant comme si
c'était l'application. Celui-ci attendait alors une minute un élément qui
n'existe pas sur cette page.

Ce qui sauvait une exécution était le clic sur « Login », qui change l'URL et
lui retire le paramètre — gardé par une sonde de 2 s sur un bouton mesuré à
1,0–1,6 s d'affichage. Ce tirage au sort était toute l'intermittence, et il
expliquait l'asymétrie : le premier acteur fait la cérémonie du mot de passe,
les suivants non, le portefeuille étant déjà ouvert et diffusé entre les onglets
du broker par BroadcastChannel — vérifié, 3,4 s contre 1,4 s.

Le harnais attend désormais des ÉTATS, plus des durées : il énumère les écrans
possibles, attend celui qui se présente, et aiguille — sous une seule échéance.
Plus aucun waitForTimeout dans ce chemin. Le chemin sans mot de passe est une
branche attendue de plein droit.

Et un échec nomme maintenant le dernier écran reconnu, l'origine attendue, la
trace horodatée des écrans traversés, les URL de toutes les frames et le texte
visible. Les échecs d'aujourd'hui ne disaient qu'une chose : qu'une chose
n'était pas apparue. C'est ce silence qui a coûté la journée en conjectures.

J'avais attribué tout ça au broker. C'était faux, et c'était lisible dans le
code.
2026-08-14 10:56:49 +02:00
Sylvain Duchesne 12eba6eea6 fix: quatre écarts entre la surface publiée et ce que NextGraph déclare
Un audit de la surface contre la source amont en a trouvé cinq ; voici les
quatre mécaniques. La cinquième — l'adresse d'inbox, qui traverse sept symboles
— relève du dessin et reste ouverte.

L'identifiant de session bloquait. Amont le déclare string | number
(sdk/js/web/src/index.ts:16) et le binding désérialise un u64 ; nous exigions
une chaîne. Une application ne pouvait donc pas passer la valeur que le SDK
venait de lui remettre. Élargi à ce qu'amont déclare, sur toute la chaîne, et
jamais converti : une chaîne échoue pour de vrai (Deserialization error of
session_id JsValue("1"), observé).

sparqlUpdate annonçait Promise<void> alors qu'il relayait DÉJÀ les commits.
C'était donc un mensonge de typage, pas un comportement — et la doublure de test
qui rendait undefined, un état que le vrai broker ne produit jamais, est ce qui
l'a laissé sans contradicteur.

ng était publié en Record<string, any>, ce qui perdait les 88 membres typés
d'amont — 88, pas 77 : le chiffre de notre propre documentation était faux.

Et materialize, second nom publié de read, sans appelant ni contrepartie amont,
est retiré.

docs/api-contract.md qualifiait docs.* de passthrough « 1:1 ». C'était faux sur
les deux premiers points. Corrigé, pas complété : un document qui se déclare
vérifié et qui ment est pire qu'un document absent, parce qu'on cesse d'aller
voir.

Une déviation assumée : amont type le retour en any, interdit ici ; on rend
unknown, comme sparqlQuery le fait déjà pour le même any amont.
2026-08-14 10:00:40 +02:00
Sylvain Duchesne e32b6d04fc fix: un échec de lecture ne fabrique plus d'état durable et faux
userInbox avalait deux échecs, et chacun laissait des dégâts sur le disque.

À la lecture : la requête « quelle inbox ce compte possède-t-il » levait, on
journalisait, et on créait une inbox de plus. Deux associations pour un même
couple utilisateur/portée — vérifié en conditions, sdoc5 et sdoc6 coexistants.

À l'écriture : l'INSERT levait, on retournait quand même la référence en la
mettant en cache. Le propriétaire tenait sdoc5 pendant qu'un déposant résolvait
sdoc6. Il lit une boîte où nul n'écrit, ils écrivent dans une boîte que nul ne
lit.

Et un troisième que je n'avais pas vu : recordInbox avalait son propre INSERT
puis marquait son index en mémoire — l'inbox était associée mais refusée à la
session suivante, donc tous les dépôts rebondissaient, définitivement.

Le balayage demandé a trouvé la famille entière : onze sites de cette forme, un
catch qui journalise puis une exécution qui continue comme si la chose cherchée
était absente. Les huit autres corrigés vont d'une seconde racine de registre
créée sur budget épuisé, à un document public qui ne sert plus jamais sa clé.

La règle appliquée partout : seule une absence VÉRIFIÉE autorise à créer, et une
référence n'est remise à personne avant que son association ait atterri.

Les sites laissés échouent en fermeture — un refus, une liste vide — sans rien
écrire. Ils sont listés, pas oubliés.
2026-08-13 13:21:54 +02:00
Sylvain Duchesne 0f1e493e93 docs(contract): ensureIdentity lève quand la connexion n'a pas abouti
Le contrat disait qu'il « termine le travail de connexion qu'il entame », sans
dire ce qui arrive quand il n'y parvient pas. Depuis que les échecs remontent,
il lève — et c'est actionnable : une application qui rend son interface malgré
un rejet affiche un compte vide, pas un écran vide, ce qu'aucun utilisateur ne
sait interpréter.

Dans l'autre sens c'est une garantie renforcée : résolu signifie que tout ce
qui vous a été partagé est lisible.
2026-08-13 12:31:46 +02:00
Sylvain Duchesne 8c8ade7a9e fix: une connexion qui échoue ne se résout plus comme une réussite
connectedUser() restaure les capacités reçues et draine les inbox. Aucun de ses
chemins ne pouvait échouer : un broker injoignable rendait exactement la même
promesse qu'un succès complet. L'application affichait alors des listes vides,
et rien nulle part ne disait que la restauration n'avait pas eu lieu.

L'énumération m'avait échappé sur deux points, l'agent les a établis.

resolveAccount attrapait tout et rendait null : une lecture qui ÉCHOUAIT
ressortait donc comme « ce compte n'existe pas ». L'échec était déguisé en
absence — c'est la racine du partage cassé trouvé ce matin, dont on n'avait
traité que le déclencheur. lookupAccount le remplace : le silence n'est plus
possible que sur une absence VÉRIFIÉE.

Et readLinks comme readInboxCapPairs avalaient leur propre erreur en rendant un
tableau vide, un étage sous le catch de connect. Une panne n'y parvenait même
pas. Elles relèvent désormais.

La règle est simple : tout échec remonte, seul « il n'y avait rien à faire »
se résout en silence. Ce qui reste silencieux — aucun détenteur, compte
réellement absent, identité changée en route — l'est parce que c'est la vérité.

Le piège consigné hier est fermé par là même : une exécution qui ne peut pas
répondre rejette, et ceux qui la rejoignent en héritent. Sa feuille est
supprimée, la question qu'elle laissait ouverte étant tranchée.

Trois fixtures de test utilisaient un ng vide — une forme qu'aucune plateforme
ne présente, dont le TypeError était mangé par le catch. Remplacées par un
broker au portefeuille vide. Aucune assertion modifiée.
2026-08-13 11:58:29 +02:00
Sylvain Duchesne 55714d0a23 fix: trois coûts qui revenaient à l'appelant reviennent au paquet
Le contrat faisait porter à l'application trois choses qui sont des artefacts de
notre implémentation, pas de la cible.

Le rechargement de page. Au retour depuis le cache du navigateur, la barrière se
rechargeait pour rejouer init() — et détruisait au passage l'état de
l'application, qui ne pouvait ni s'y opposer ni nettoyer avant. Le paquet
détenait pourtant ce qu'il fallait : la fonction init injectée et le callback de
l'appelant. Il enregistre désormais sa délégation, ranime sa barrière au retour
— champ conservé, bouton réactivé — et redélègue à la confirmation. Rien hors de
la barrière n'est touché. Vérifié dans le bundle amont : en page de tête, init
navigue à chaque appel, sa garde « une seule fois » ne portant que sur la
branche iframe.

L'ordre d'appel silencieux. ensureIdentity() attendu avant init() ne se
résolvait jamais, sans erreur. Le paquet possédant la session, il distingue
maintenant les deux cas sans délai ni heuristique : session pas encore arrivée →
il attend ; init jamais appelé → elle n'arrivera pas, il lève en nommant l'appel
à faire d'abord.

Et la clause qui annonçait la barrière était rangée dans les exigences de
déploiement, alors qu'une application n'y peut rien. Elle passe dans les
garanties, avec ce qui la remplace : la page n'est jamais rechargée.

Il reste deux lignes d'exigences : servir le fichier de portefeuille, et appeler
init avant d'attendre l'identité — ce qui échoue désormais bruyamment.
2026-08-13 09:49:24 +02:00
Sylvain Duchesne f77317c4d1 docs(contract): trois comportements que l'appelant doit connaître
Les signatures étaient à jour, les affirmations non. Trois choses visibles par
une application n'y figuraient pas.

L'ordre d'appel : ensureIdentity() se résout quand une session est ouverte, donc
l'attendre avant d'avoir appelé init() ne se résout JAMAIS. Silencieux, sans
erreur — le pire mode de défaillance possible pour qui intègre. C'est aussi la
raison de dire lequel des deux init() appeler, comme on le dit déjà pour ng.

La barrière monte à chaque chargement en page de tête, pas seulement au premier.

Et la page peut se recharger toute seule quand on y revient : une application
qui garde de l'état en mémoire le perd, et ne pouvait pas le deviner.

Aucune de ces phrases ne décrit comment c'est fait — seulement ce qu'un appelant
doit prévoir.
2026-08-13 08:45:00 +02:00
Sylvain Duchesne dbd99738f0 docs: deux affirmations que les mesures ont démenties
La doctrine disait que la suite applicative ne peut pas être mesurée sur une
machine dont le réseau bouge. Elle vient de passer 27/27 quatre fois d'affilée
sous exactement ce bruit. Ce qui tranche n'est donc pas l'état de la machine
mais la FORME de l'échec — un délai nommé sur une opération broker désigne le
transport, une assertion qui rend une valeur inattendue désigne le code — et la
répétition.

Et la feuille sur le règlement de l'identité décrivait encore la session comme
un thunk fourni par l'application. Elle appartient désormais au paquet, dont le
wrapper init() capture l'événement : plus aucune application ne peut la câbler
de travers, ce qui était pourtant la cause exacte de la régression racontée
juste au-dessus.

Dette de doc soldée.
2026-08-12 18:32:23 +02:00
Sylvain Duchesne cc8a95d303 feat: le polyfill possède la session et la normalisation des identités
Pour démarrer, une application devait écrire une promesse autour du callback
d'init(), attraper l'événement loggedin, puis fournir un thunk getSession qui
dépiaute session_id et les trois identifiants de store dans notre forme. Plus un
normalizeId. C'est précisément la plomberie que ce paquet existe pour absorber :
chaque application la réécrirait à l'identique, et c'est elle qui a produit deux
défauts aujourd'hui — un blocage et un partage cassé en silence.

En amont, une session est RENDUE ; une application n'en assemble jamais une à
partir de champs bruts. Et les identités virtuelles sont une invention du
polyfill, donc leur normalisation lui appartient.

Le wrapper init() enveloppe désormais le callback de l'appelant : il capture
l'événement, en dérive la session, puis appelle le callback avec le même
événement. Le paquet n'appelle jamais init de sa propre initiative — il
l'enveloppe. Sans callback, il capture quand même.

getSession et normalizeId quittent la surface publiée. Le chemin d'injection
reste pour les harnais, mais inatteignable depuis l'entrée : vérifié par un
import à l'exécution et par un configure() refusé à la compilation.

Défaut trouvé et corrigé en route : le broker envoie session_id en NOMBRE, et le
convertir en chaîne faisait refuser tous les appels par le binding wasm. La
valeur ne fait que transiter, elle est relayée telle quelle. Reste que toute la
chaîne la type string — inexactitude antérieure à ce commit, à traiter à part.

Une application écrit maintenant : configure({ ng, useShape, init, sharedWallet }).
2026-08-12 17:39:12 +02:00
Sylvain Duchesne 7a4d9b492f refactor: une application ne fournit pas sa propre identité
configure() acceptait un champ currentUser publié. Il faisait manier à
l'appelant ce que la cible ne lui remettra jamais : en amont, session_start
reçoit un identifiant qui VIENT du portefeuille qu'une personne a ouvert.

Et il rouvrait la porte qu'on venait de fermer : renseigné, il fait sauter la
barrière en page de tête — exactement le comportement retiré au commit
précédent. Un commentaire disait « rien de ce qu'expédie une application ne fait
ça » ; c'est une note, pas un mécanisme, et une exception nommée cesse d'en être
une dès qu'elle est publiée.

Personne ne s'en servait — aucun test, aucun harnais, aucun exemple. Un symbole
gardé parce qu'il était déjà là n'est pas une décision.

Le chemin interne subsiste pour les harnais qui pilotent la surface sans
navigateur, mais il n'est plus atteignable depuis la surface publiée.
2026-08-12 15:56:07 +02:00
Sylvain Duchesne 218c9ab6b3 docs: la doctrine dit quand la barrière s'affiche, pas seulement ce qu'elle fait
La feuille décrivait l'ordre de résolution de l'identifiant mais taisait la
règle qui décide de l'affichage — page de tête toujours, iframe jamais — et
c'est exactement ce qui vient d'être corrigé dans le code.

Elle porte désormais le raisonnement : le discriminant est le cadre parce que
l'identifiant est un état observable alors que la présence d'un portefeuille ne
l'est pas, et qu'un écran conditionnel doit donc deviner l'état qui compte. Plus
les deux faits observés sur les sites réels qui ferment les alternatives —
l'import ouvre un sélecteur sur place, et notre onglet ne reçoit aucun signal
quand il réussit.
2026-08-12 14:59:37 +02:00
Sylvain Duchesne f5a3adc385 fix: la barrière s'affiche à chaque chargement en page de tête, comme chez Festipod
Notre version décidait d'afficher la barrière sur la présence de l'identifiant.
Festipod décidait sur la session — jamais établie en page de tête, donc l'écran
s'affichait toujours, l'identifiant servant seulement à pré-remplir le champ.

La différence n'est pas ergonomique. L'identifiant est un état qu'on observe ;
le portefeuille, lui, vit dans le stockage d'une autre origine et nous est
illisible. Un écran conditionnel doit donc DEVINER cet état invisible — et
quand il devine « déjà installé » alors que le portefeuille a disparu du
navigateur, il cache les seuls contrôles qui répareraient la situation et
précipite la personne dans une impasse.

Impasse observée sur le site réel : sans portefeuille, la page du broker affiche
un texte statique, zéro bouton, un seul lien vers nextgraph.eu qui NE TRANSPORTE
AUCUN retour vers l'application. Le retour arrière du navigateur est la seule
issue — et il ne sert à rien si la barrière ne reprend pas la personne à
l'arrivée.

Le discriminant devient le cadre, pas l'identifiant : page de tête → toujours,
iframe → on s'efface. C'est le signal que @ng-org/web utilise lui-même et que
Festipod utilisait un étage plus bas.

On ne détecte rien et on ne demande rien. Les trois étapes s'affichent toujours ;
qui possède déjà son portefeuille ignore les deux premières. Aucune case « je
l'ai déjà » : savoir si l'on a importé un portefeuille dans ce navigateur est
une question trop technique pour être posée.

Garde-fou repris de Festipod, qu'on n'avait pas : après un aller-retour vers
l'onglet NextGraph et un retour arrière, la barrière restait figée sur un bouton
mort. Elle recharge désormais sur pageshow persisted — seul moyen de rejouer le
init() qui porte la redirection.

Vérifié sur navigateur : cliquer « Import a Wallet File » ouvre un sélecteur sur
place, sans navigation ni changement d'onglet, et notre onglet ne reçoit AUCUN
signal quand l'import réussit. Détecter le retour est donc impossible, pas
seulement fragile.
2026-08-12 14:59:10 +02:00
Sylvain Duchesne c5b4703687 test(e2e): le parcours d'un primo-arrivant, et un harnais qui échoue au lieu de se pendre
Aucun parcours n'avait jamais marché le chemin d'un nouvel arrivant : tous
pré-injectaient l'identifiant dans l'URL, ce qui fait résoudre l'identité sans
jamais afficher la barrière. La suite était verte par-dessus un lien de
téléchargement pointant sur un fichier que personne ne servait — et le serveur
de test répondait la page HTML de l'application pour tout chemin inconnu, donc
un fichier manquant ne POUVAIT pas échouer.

Le nouveau parcours part d'un profil vide : barrière, téléchargement réel,
import dans l'application portefeuille, saisie de l'identifiant, remise au
broker, retour dans l'iframe. Il vérifie neuf points, dont celui qui compte —
l'identifiant a survécu et l'identité rapportée est celle qui a été saisie.

Le harnais, lui, se pendait au lieu d'échouer. Cause observée : le tuyau
devtools de Chromium lâche et Playwright n'émet ni close ni disconnected, si
bien que la suite bloquait dans son propre nettoyage sans imprimer ni résumé ni
l'échec déjà en route. Toutes les attentes sont désormais bornées et nomment ce
qu'elles attendaient ; vérifié en cassant délibérément une attente, et observé
en conditions réelles — trois minutes et « gave up waiting for: alice to sign
in » là où j'ai tué trois exécutions d'une heure ce matin.

Deux exécutions simultanées ne se détruisent plus : verrou atomique sur le
profil, et récupération d'un navigateur laissé par une exécution tuée. Le
marqueur devient .user-consumed — il n'a jamais attesté d'une disponibilité,
seulement qu'un lot avait déjà pris l'utilisateur de ce profil. Au passage, la
destruction du profil dépendait du marqueur, écrit en FIN de lot : une
exécution tuée avant laissait un profil que la suivante réutilisait, et
héritait de sa casse. Elle dépend maintenant du profil.

La suite applicative reste non mesurée sur cette machine : un conteneur en
boucle de redémarrage recycle son interface réseau, et sept exécutions sur dix
échouent sur le transport. Trois sont passées 21/21.
2026-08-11 19:34:09 +02:00
Sylvain Duchesne 7c2e8d8f1f docs: deux concepts pour ce que la journée a appris
sign-in — comment un utilisateur passe de rien à une identité qui agit. La
barrière, l'identifiant qui franchit une frontière de partition par l'URL, et
surtout : la redirection vers le broker appartient à @ng-org/web, vérifié dans
son bundle. Le polyfill ne la réimplémente pas ; ce qui lui revient est la seule
chose qu'init() ne peut pas faire, écrire l'identifiant dans l'URL avant qu'il
ne la lise.

La feuille centrale dit pourquoi régler l'identité et se connecter sont deux
actes séparés : l'un ne demande aucune session, l'autre en exige une, et les
confondre bloque dans un sens et casse le partage en silence dans l'autre. Le
piège encore vivant — une connexion abandonnée qui reste joignable — est
consigné comme tel, non corrigé.

e2e-harness — ce que chaque suite juge, et deux choses qu'un agent doit savoir
avant de diagnostiquer : le raccourci qui pré-injectait l'identifiant dans l'URL
a tenu deux défauts invisibles pendant des mois (un lien de téléchargement vers
un 404 que rien ne servait, et une barrière inatteignable pour tout nouvel
arrivant) ; et un tuyau devtools qui lâche tue une exécution sans que Playwright
n'émette d'événement, ce qui ressemble à un défaut produit et n'en est pas.

Le vocabulaire gagne settle, barrier, journey et batch. J'avais aussi introduit
« hand-over » pour la redirection : retiré, c'est le mot de NextGraph qui
l'emporte.
2026-08-11 19:24:00 +02:00
Sylvain Duchesne 16e24f67f9 refactor: les commentaires disent cap-surface et cap-enforcement
Suite du balayage commencé dans la doc : 20 occurrences de P1a/P1b dans les
commentaires, les titres de tests et le README.

Les phrases ont été récrites, pas substituées : « the breach P1a opened »
devient « the breach that cap-surface opened », et « labelled P1b's » ne
survivait pas à un nom plus long. Un lecteur qui n'a jamais entendu ni l'un ni
l'autre doit comprendre la phrase.

L'avertissement de déploiement du README garde sa force et gagne un nom :
« "anonymous" or "private" until cap-enforcement lands per-document
encryption. »

Reste une occurrence dans e2e/polyfill-entry.ts, qui part avec le lot e2e.
2026-08-11 19:14:49 +02:00
Sylvain Duchesne 2726f4a26f docs: nommer par la fonction, et n'annoncer qu'un point d'entrée
Deux corrections indépendantes dans la doc vivante, les briefs et décisions
datés restant tels qu'écrits.

P1a et P1b ne disaient rien à personne. Six mois plus tard il aurait fallu lire
le code pour savoir de quoi on parle, et le coût de la recherche se repaie à
chaque lecture. Ils deviennent cap-surface — la forme des capacités, livrée le
2026-07-28 — et cap-enforcement — ce qui reste : le chiffrement par document et
les gardes d'écriture aujourd'hui décoratives. 28 occurrences.

Et api-contract.md se contredisait à quatre lignes d'intervalle : il annonçait
deux points d'entrée en tête, et en bas qu'il n'y en a qu'un depuis la fusion du
2026-08-07. Vérifié dans package.json avant d'écrire — exports mappe exactement
{".": "./src/index.ts"} et src/polyfill.ts n'existe pas.

Ce qui identifie un symbole polyfill-era ne change pas : le bloc marqué dans
src/index.ts et le test de vocabulaire, plus aucun chemin d'import.
2026-08-11 19:10:34 +02:00
Sylvain Duchesne 3be8da2178 fix: régler l'identité n'a plus le droit de réclamer une session
Le partage était cassé : Bob n'ouvrait pas le document qu'Alice venait de lui
partager, sans erreur, juste « (illisible) ». Régression introduite en scindant
ensureIdentity().

Chaîne observée aux sondes, pas déduite : settleIdentity() appelait
setCurrentUser, qui déclenche startConnect(), qui va chercher la session via le
thunk getSession de l'application. Or l'exemple appelle init() DEPUIS
L'EXÉCUTEUR qui construit sessionReady — le thunk ne peut donc pas répondre, par
construction. Il lève, resolveAccount rend null, l'exécution est abandonnée sans
restauration ni drainage, mais s'est déjà enregistrée « en vol ». Le
ensureIdentity() suivant rejoint cette exécution morte et se résout sans avoir
rien fait.

Avant la scission, rien n'appelait setCurrentUser pendant l'évaluation du
module : la session existait, l'exécution était saine, et la rejoindre était
sans danger. C'était bien une affaire de moment.

bootstrap.ts scinde le setter : adoptCurrentUser enregistre qui agit,
setCurrentUser reste « enregistrer + connecter » pour tous les autres appelants.
La moitié sans session ne réclame donc plus de session, et se connecter
redevient l'affaire du seul ensureIdentity(), attendu, là où une session existe.

Ce que ça bloque, tracé avant de livrer : une application qui appellerait init()
sans jamais appeler ensureIdentity() n'aurait plus de restauration en arrière-
plan. Aucun appelant de ce genre n'existe, et avant la scission init() était un
passthrough nu qui ne déclenchait rien — c'est une répartition rétablie, pas un
comportement retiré.

Reste connu, non corrigé : connectedUser mémorise toujours une exécution
abandonnée. Le piège est documenté sur setCurrentUser.
2026-08-11 19:03:58 +02:00
Sylvain Duchesne 3547de202c fix: régler l'identité ne demande pas de session, se connecter oui
init() de @ng-org/web redirige vers le broker en première instruction, dès qu'on
est en tête. L'application appelait donc init() au chargement du module, la page
partait, et ensureIdentity() ne s'exécutait jamais : la barrière n'apparaissait
pas, ?ng-id= restait absent de l'URL remise au broker, et un primo-arrivant se
retrouvait devant la page de connexion sans portefeuille et sans moyen d'en
obtenir un — sans la moindre erreur.

Appeler ensureIdentity() avant init() ne marchait pas non plus : il attend la
session, que seul le callback d'init() résout. Cycle vérifié empiriquement.

La cause n'était ni l'ordre ni la redirection, mais une confusion dans
ensureIdentity() entre deux actes de nature différente — régler qui est
l'utilisateur (barrière, URL, stockage : aucune session) et se connecter
(session requise). settleIdentity() porte le premier ; le wrapper init() du
polyfill l'attend avant de déléguer. L'invariant d'ordre est ainsi porté par la
composition, pas par une consigne d'ordre d'appel que personne ne lit.

Piège trouvé et épinglé en écrivant les tests : init() et ensureIdentity() dans
le même tick montaient deux barrières, l'utilisateur répondait à l'une et
l'autre ne se résolvait jamais. Le règlement en vol est désormais partagé.
2026-08-11 12:52:49 +02:00
Sylvain Duchesne fc3c129bd3 fix: l'identifiant est dans l'URL avant qu'init() ne l'emporte
@ng-org/web fait déjà la redirection vers le broker — même hôte, même forme,
même test de cadre (dist/ngweb.js). Le polyfill n'a donc rien à réimplémenter
là : ce qui lui revient, c'est la seule chose qu'init() ne peut pas faire, à
savoir mettre ?ng-id= dans window.location.href avant qu'il ne le lise.

rememberIdentity() s'exécute désormais sur les trois chemins — identité posée
par l'appelant, venue du stockage, ou saisie à la barrière. Le cas du stockage
était le silencieux : le paramètre restait absent, l'iframe lisait une identité
vide et provisionnait un second espace virtuel, sans erreur.

L'écriture dans le stockage et celle dans la barre d'adresse sont deux try
indépendants : un stockage qui refuse d'écrire ne doit pas emporter avec lui le
paramètre, qui est le seul à franchir la frontière de partition.

Le contrat retire l'obligation « être ouverte via la redirection du broker » :
elle n'a jamais été celle de l'application. Ni broker, ni iframe, ni redirection
n'y sont plus nommés.
2026-08-11 11:59:57 +02:00
Sylvain Duchesne 9c487b59f3 refactor: le résumé du harnais e2e dit polyfill, pas SDK 2026-08-10 17:19:39 +02:00
Sylvain Duchesne 737729c9ce refactor: le paquet s'appelle polyfill, « SDK » désigne celui de NextGraph
Le nom @ng-eventually/sdk entrait en collision avec le SDK de NextGraph, dont
ce paquet est justement un polyfill. Impossible d'écrire « le SDK » sans lever
l'ambiguïté à chaque phrase — et le contrat publié, lu par une application,
était le pire endroit pour laisser traîner ça.

packages/sdk → packages/polyfill, @ng-eventually/sdk → @ng-eventually/polyfill,
contract_sdk-surface → contract_polyfill-surface, e2e/sdk-entry.ts →
e2e/polyfill-entry.ts, docs/sdk-reference.md → docs/polyfill-reference.md.

Les occurrences de « SDK » qui désignent celui de NextGraph restent intactes,
y compris les chemins dans nextgraph-rs (sdk/js/orm, sdk/js/web). Le tri s'est
fait occurrence par occurrence, pas par substitution.

Le contrat énonce désormais son identité en une phrase : « This package is a
polyfill of NextGraph's SDK. »
2026-08-10 17:14:25 +02:00
Sylvain Duchesne 49b046268e docs(contract): le contrat décrit l'API, et rien d'autre
Récriture complète de `contract_sdk-surface.md` sur le cadrage du propriétaire du projet :
**le contrat dit ce que le SDK met à disposition, point.**

Ce qui en sort, et pourquoi ça n'y avait pas sa place :

- **tout état d'implémentation** — chiffrement, confidentialité, valeur de remplacement,
  ce qui est émulé, ce qui n'est pas encore fait. Le lecteur est un agent qui développe
  une application appelante : s'il lit qu'une chose est provisoire, il conçoit des
  compensations — sa propre couche de chiffrement, un choix de ne pas stocker telle
  donnée, un avertissement d'interface — toutes fausses et toutes à retirer. Il doit
  pouvoir considérer que ce SDK **est** celui de NextGraph ;
- **la fabrique** — « polyfill », « portefeuille partagé », « multi-utilisateurs », la
  migration, ce que l'application supprimera un jour, les écarts par rapport à la cible ;
- **l'argumentaire** — ce que le modèle « permet », ce que telle règle « achète », la
  confidentialité composable. Un appelant a besoin de savoir qu'une référence rendue ne
  porte pas de clé, pas de savoir ce que ça lui apporte.

Ce qui entre : les **trois obligations de déploiement**, vérifiées dans le code — servir un
`.ngw` depuis son bundle et le passer à `configure`, être ouverte via la redirection du
broker, appeler `ensureIdentity()` dans un contexte navigateur avant de rendre — et trois
clauses contraignantes qui manquaient : l'identifiant rendu est **opaque**, le préfixe
`urn:ng-eventually:` est **réservé sur les sujets**, et le placement recommandé est un
document par entité métier, plusieurs objets dans un document restant permis.

`## Guarantees` devient une suite d'énoncés plats. `## Non-guarantees` ne liste que des
**absences de capacité** — pas de nom d'affichage, pas de révocation, rien par lecteur sur
un document en store public, pas d'écriture déléguée — jamais un manque par rapport à
autre chose.

L'application d'exemple n'affiche plus l'identifiant comme un nom : elle le montre pour ce
qu'il est, un identifiant technique. C'était exactement ce que la clause « opaque »
interdit, dans le fichier censé montrer le bon geste.

202 tests, typechecks propres, `lint` sans erreur. 148 → 135 lignes.
2026-08-10 15:03:08 +02:00
Sylvain Duchesne 33b96fdc8d docs(concept): la règle interdit la divergence, elle ne la met plus en balance
La feuille testait ce que l'APPELANT apprendrait. Le propriétaire du projet a énoncé la
règle plus large : rester au plus près de NextGraph, et n'admettre aucune implémentation
qui en diverge — que l'appelant s'en aperçoive ou non.

L'ordre des deux questions est ce qui a manqué. Sur la fusion de `readUnion`, posée en
premier, « l'appelant devra-t-il désapprendre ? » ne tranchait pas : « une entité par
document » est une bonne pratique par ailleurs, alors que désapprendrait-il au juste ? Le
raisonnement a piétiné des heures là-dessus. Posée en premier, « la cible fait-elle ça ? »
a demandé un regard : le niveau 1 rend les sujets réels, et l'ORM du niveau 3 porte `@id`
ET `@graph` sur chaque objet en fabriquant le premier quand on l'omet. Divergence, fin.

« Devra-t-il désapprendre ? » reste, mais mesure la gravité d'une divergence inévitable —
jamais son autorisation. Et un tell est ajouté, celui qui a produit ce défaut : une
recommandation que le code impose au lieu de la guider, en rendant l'autre disposition
invisible.
2026-08-10 14:51:17 +02:00
Sylvain Duchesne 7076c0cca8 fix: readUnion regroupe par sujet réel — la fusion était une divergence
`readUnion` indexait par DOCUMENT une table nommée `bySubject`, créait ses entrées avec
`subject: doc`, et jetait le sujet réellement lu après s'en être servi pour écarter la
machinerie. Tout triplet non-machinerie d'un document tombait donc dans un sac unique
étiqueté par la référence du document : deux entités écrites sous deux sujets revenaient
**conflées**, une entité écrite sous un autre sujet revenait **ré-étiquetée**. Sans erreur,
sans trace.

**C'était une divergence, et c'est à ce titre qu'elle tombe.** NextGraph dit l'inverse aux
deux niveaux : une requête ancrée résout le graphe du repo comme graphe par défaut et rend
les sujets tels qu'ils sont ; et l'ORM porte sur chaque objet **deux** propriétés
distinctes, `@id` et `@graph`, dont il FABRIQUE la première quand on la laisse vide
(`graphIri + ":q:" + aléa`). Plusieurs objets par graphe est le cas prévu, et `@id` existe
pour les distinguer à l'intérieur d'un `@graph`.

La règle du projet reste **« un document séparé par entité métier »**, mais c'est une
recommandation de placement dictée par le modèle de sécurité — une clé est par repo, donc
l'isolation par entité exige un repo par entité. Ce n'est pas une contrainte que la lecture
a le droit d'imposer en rendant l'autre disposition invisible. Le contrat porte désormais la
recommandation, le code porte la capacité ; il faisait exactement l'inverse.

**La justification de l'épinglage était une erreur de catégorie**, et elle a été retirée
plutôt que contournée : `repo_graph_name` formate un nom de GRAPHE, il est estampillé sur
les quads et aucun sujet n'est réécrit. Deux confirmations indépendantes, dont la suite e2e
qui écrit un sujet puis le relit par correspondance exacte contre le vrai broker.

`UnionSubject.subject` passe de `Nuri` à `string` — un sujet RDF réel est un IRI
quelconque. `graph` reste `Nuri` et devient le champ à repasser au SDK ; l'app d'exemple
l'utilise à ses deux sites, où le sens était « le document ».

**Et la suite e2e ne comptait que les entrées.** C'est pour cela qu'elle est restée verte
pendant tout le défaut : compter ne distingue pas un regroupement par document d'un
regroupement par sujet. Elle écrit maintenant deux sujets dans le dernier document et
vérifie les trois choses qui comptent — quatre entrées pour trois documents, chaque entrée
portant le sujet sous lequel elle a été écrite, et son `graph` étant la référence du
document.

202 tests unitaires (5 ajoutés, dont 3 échouent si l'on restaure l'ancien repliage),
e2e 42/42 et applicatif 12/12.
2026-08-10 14:50:07 +02:00
Sylvain Duchesne f378c71739 docs: sept affirmations sur NextGraph requalifiées à la source
Lot F de la revue adverse. Chaque affirmation relue dans `nextgraph-rs` par symbole avant
d'être réécrite ; aucune ne s'est révélée exacte.

- `AddLink` / `RemoveLink` / `RepoLinkV0` étaient présentés comme **implémentés**. Leurs
  arms de vérificateur sont des `Ok(())`, là où celui d'`AddRepo` fait un vrai travail, et
  rien ne les construit. Le tableau dit désormais « déclaré, stubbé », et la conclusion qui
  en déduisait « le registre existe, seule la livraison manque » est corrigée : **les deux
  bouts** sont déclarés-et-stubbés.
- La table `inboxes` était dite « reconstruite vide à chaque session » — elle est
  repeuplée au chargement, et la clé privée d'inbox est persistée par repo. L'argument de
  sécurité qui s'appuyait dessus repose maintenant sur le bon motif : la table est **par
  vérificateur**, pas éphémère.
- La citation de « `doc_create` laisse `inbox: None` » pointait un constructeur réservé aux
  tests ; re-ciblée sur le chemin de production.
- `ExtObjectGet` était dit « le seul » primitif accessible à un non-membre et exigeant les
  clés : il y en a trois, et sa structure n'a aucun champ de clé.
- L'en-tête de `public-store.ts` était marqué **VERIFIED** alors qu'il repose sur un
  commentaire de doc, et la condition qu'il citait (« si les brokers pairs l'autorisent »)
  disparaissait de la conclusion. Requalifié en **pari**, condition rétablie.
- Deux sur-restrictions corrigées (ce qu'écrit le traitement d'un `ContactDetails`, et le
  prétendu « miroir 1:1 » de `NuriV0`, qui a dix champs).

**Sur la grammaire du ReadCap, une correction de MA correction.** J'avais écrit que la
forme `{target}:r:{cap}` était notre invention. Faux : le segment `r:` et son encodage sont
ceux d'amont, et l'auteur de NextGraph l'a énoncé. Ce qui est établi est plus étroit —
aucun parseur amont n'accepte aujourd'hui un NURI de repo qui le porte, et le segment est
produit comme valeur de champ. J'avais conclu d'une implémentation absente à ce que la
cible ferait, ce que la doctrine du projet interdit nommément. Seule « P1b remplace la
valeur, pas la forme » est corrigée, requalifiée en **pari**.

**Et la surface ne publie plus de type que personne n'utilise.** `export * from
"./model/types"` publiait huit types en bloc ; c'est une liste nommée de six. `ReadCap`
sort — aucune signature publiée ne le prend ni ne le rend, seules deux fonctions privées
de `inbox.ts` s'en servent — et `InboxScope` aussi. Un type n'est publié que si une
signature publiée l'utilise.

197 tests, 0 échec ; les trois typechecks propres ; `lint` sans erreur.
2026-08-10 12:09:20 +02:00
Sylvain Duchesne 0d9e2bbe97 fix: mon correctif sur createEntityDoc était faux dans les deux sens
Second tour adverse sur le lot C. Six trouvailles, dont trois sur ce que je venais de
livrer.

**Le correctif de `createEntityDoc` reproduisait le défaut qu'il annonçait avoir fermé.**
Il levait sur la PREMIÈRE écriture de registre en échec. Or :

- lever sur le listing sautait l'écriture de la clé — détruisant le chemin de
  récupération que le commentaire d'à côté décrit explicitement (« la clé doit rester
  récupérable même si le listing a échoué »), et laissant le document orphelin ;
- lever sur la clé laissait le document LISTÉ sans clé — précisément l'état « se lit vide
  pour toujours » que je prétendais empêcher, en pire, puisque l'appelant n'a même plus
  sa référence.

Les deux écritures sont désormais tentées, ce qui atterrit reste, et l'échec est rapporté
après en nommant la moitié manquante.

**Le refus de `share` reposait sur une valeur qui confond absence et ignorance.**
`resolveAccount` avale toute erreur de lecture et rend `null`, si bien qu'un incident
réseau faisait répondre « personne ne s'est connecté sous ce nom » à propos de quelqu'un
qui existe. `lookupAccount` propage désormais l'erreur ; `resolveAccount` reste la forme
tolérante que tous les autres appelants veulent.

**J'avais livré ce comportement sans un seul test.** `test/app-surface.test.ts` en ajoute
huit, tous sur ce qu'un APPELANT voit : `ensureIdentity` rend l'identité, un appel de
placement avant connexion nomme l'erreur, le placement agit comme l'utilisateur connecté,
`share` refuse un nom inventé mais laisse remonter une panne, et une création à moitié
écrite échoue en disant quelle moitié — dont le cas « le listing a échoué, la clé est
quand même là ».

En écrivant ces tests j'ai refait dans leur faux la faute que cette revue a corrigée
ailleurs : ignorer le sujet dans la requête de compte, ce qui rendait le dossier d'un
autre utilisateur. Deux des huit échouaient pour cette raison, sans rapport avec le code.

**Et la documentation contredisait le code livré dans le même commit** : le README
enseignait encore `createEntityDoc(me, "protected")` — en JS la portée devient `"alice"` —
et le contrat déclarait `Nuri` là où le code et la feuille disent `NuriLike`.

197 tests unitaires, e2e 40/40 et applicatif 12/12.
2026-08-10 10:48:57 +02:00
Sylvain Duchesne cdc09a1a1d refactor(api): l'application ne nomme plus son identité — elle l'apprend
Lot C de la revue adverse. Cinq corrections, dont une qui change la forme de la surface.

**L'application ne pouvait pas obtenir son identité par l'API.** `ensureIdentity()`
rendait `void`, `getCurrentUser` n'est plus publié — et pourtant `createEntityDoc(id, …)`
et `listMyEntityDocs(id, …)` l'exigeaient. L'app d'exemple s'en sortait en lisant
`localStorage["ng-eventually:identity"]` et le paramètre `?ng-id`, deux constantes
PRIVÉES du portail d'accès. Une frontière qu'aucun consommateur ne devrait voir, et
encore moins dont il devrait dépendre.

Vérifié au niveau 2 avant de trancher : `session_start(wallet_name, user_id)` prend
l'identité — donc en amont l'application la DÉTIENT, elle la tient du portefeuille
qu'elle a ouvert. Ici c'est le portail qui la choisit, donc c'est au portail de la
rendre. Deux changements, tous deux vers la cible :

- `ensureIdentity()` rend l'identité qu'il a établie ;
- `createEntityDoc(scope)`, `listMyEntityDocs(scope)`, `resolveWriteGraph(scope)`
  perdent leur paramètre d'identité. En amont `doc_create(session_id, …)` ne porte
  aucun utilisateur : une session EST celle d'un utilisateur. Passer la sienne à chaque
  appel de placement était un geste sans successeur.

L'application garde l'identité pour l'afficher, et ne la passe plus à rien.

**`inbox.share` provisionnait un destinataire inexistant.** Une faute de frappe créait
les trois stores et l'inbox de ce nom, et la clé atterrissait où personne ne regarde —
sans la moindre erreur. En amont on ne peut pas viser un nom qu'on invente : un dépôt est
scellé vers une clé d'inbox qui vous est parvenue par un contact entrant. Refuser est
fidèle ; provisionner était l'invention.

**`createEntityDoc` avalait l'échec de ses deux écritures** et rendait quand même une
référence — le document n'était dans aucun store, donc la session suivante ne le listait
pas et sa lecture rendait vide, en silence. Il lève maintenant, comme `doc_create` en
amont propage les siennes.

**Deux entrées prenaient `Nuri` au lieu de `NuriLike`** (`inbox.watch`,
`openDocumentInbox`), ce qui contredisait la raison même pour laquelle aucune garde de
type n'est publiée. Et **deux messages d'erreur nommaient des symboles retirés**
(`storeRegistry.documentInboxAddress`, `setCurrentUser`) : une erreur qui envoie vers une
fonction inexistante est pire qu'une erreur muette.

Contrat d'API et feuille `contract_sdk-surface` mis à jour ; `readForDocument` et le refus
de `share` obtiennent enfin leur règle en §9.

189 tests unitaires, e2e 40/40 et applicatif 12/12.
2026-08-10 10:31:15 +02:00
Sylvain Duchesne b7dc8ca2c3 fix: la suite n'était pas hermétique, et deux tests ne pouvaient pas échouer
Second tour adverse sur le lot D. Trois trouvailles, et une erreur de diagnostic de ma
part qui vaut d'être consignée.

**La suite verte dépendait de l'ordre des fichiers.** `bun test isolation-active
public-store` donnait 5 échecs quand chaque fichier seul était vert — donc un checkout de
CI avec un autre ordre d'inodes livrait rouge. Deux causes distinctes :

- le travail de connexion, lancé sans être attendu par `setCurrentUser`, débordait d'un
  fichier sur le suivant et armait l'émulation. `connectedUser` abandonne désormais dès
  que l'identité pour laquelle il a démarré n'est plus connectée — ce qui est de toute
  façon la bonne sémantique : en amont une session appartient à un utilisateur, et
  changer d'utilisateur est une autre session ;
- et surtout **mon propre test de store public exposait le cap d'un document que
  personne ne détient** — un état que la bibliothèque ne produit jamais. Il ne passait
  que tant que l'émulation était désarmée. Alice crée sa note avant de l'exposer,
  maintenant. Balayage des 21 paires de fichiers : plus aucune ne pollue.

**Le contrôle symétrique ajouté hier ne pouvait pas échouer.** « La liste d'Alice ne
contient pas la note de Bob » lisait un rendu ANTÉRIEUR à l'écriture de Bob : l'attente
de `showScope` était satisfaite au premier sondage par le marqueur déjà à l'écran, sans
synchroniser quoi que ce soit. Alice écrit désormais une note APRÈS celle de Bob —
`writeNote` attend son apparition, donc ce qui suit est un rendu qui post-date. Et le
`.catch` qui avalait le délai d'attente est retiré : une liste qui ne se stabilise jamais
est un échec à voir, pas une dégradation à absorber.

**Le test anti-fork prouvait « pas le premier », pas « le canonique ».** Son minimum
lexicographique était aussi le DERNIER élément, si bien qu'un choix positionnel — la
faute exacte que ce test existe pour attraper — restait vert. Le minimum est déplacé au
milieu ; vérifié par mutation, « prendre le dernier » le fait rougir.

**Mon erreur de diagnostic.** J'ai cru trouver, sous la trouvaille d'ordre, une fuite
entre utilisateurs — les caps d'Alice classés chez Bob — et je l'ai « reproduite ». Le
repro était faux : son faux `ng` ignorait le sujet dans la requête d'inbox, donc l'inbox
de Bob résolvait vers celle d'Alice. Une fois le faux corrigé, la fuite ne se reproduit
plus, ni avec ni sans correctif. Le danger reste réel en lecture du code — trois chemins
classent des caps plusieurs `await` après la garde qui les autorisait — donc
`caps.holderKey`/`learnFor` le ferment par construction, mais les commentaires disent
maintenant ce que c'est : un risque fermé, pas un défaut observé.

189 tests unitaires, e2e 40/40 et applicatif 12/12.
2026-08-10 10:02:45 +02:00
Sylvain Duchesne 30f6263db5 docs(concept): le contrat entre le polyfill et l'application qui l'utilise
Amorce le système `concept` dans ce dépôt et ouvre `app-contract` — la frontière
entre cette bibliothèque et les applications qui la consomment.

C'est un contrat **inter-dépôts** et ce dépôt en est le FOURNISSEUR : les applications
vivent ailleurs et tireront `sdk-surface` d'ici. D'où le type `contract_`, ses cinq
sections obligatoires, et l'inscription dans `.project/contracts.yaml` — c'est
l'inscription qui publie.

Trois feuilles :

- **`contract_sdk-surface`** — l'engagement, écrit du point de vue de l'appelant.
  Ce qu'il peut tenir pour acquis : permissif en entrée et précis en sortie ; toute
  référence rendue est NUE, aucun appel ne rend jamais de clé ; lire est la possession,
  écrire est la propriété ; donner à lire est un seul acte et le destinataire n'appelle
  rien ; un dépôt s'adresse à une inbox, jamais à un document ; `ensureIdentity()` est
  toute la connexion ; et `configure` est le seul appel qu'il supprimera.
  Ce qu'il ne doit PAS tenir pour acquis, dit aussi crûment : aucune confidentialité,
  rien de « par lecteur » sur un document public, aucune révocation, aucune écriture
  déléguée, et les références ne voyagent que dans un déploiement.

- **`rule_would-the-caller-unlearn-it`** — le test qui décide de tout : est-ce que
  ceci ferait apprendre à l'appelant quelque chose qu'il devra DÉSAPPRENDRE ? Avec les
  deux tells que la revue de ces jours-ci a rendus concrets : une exception nommée
  cesse d'en être une dès qu'on la publie, et un symbole gardé parce qu'il était là
  n'est pas une décision.

- **`knowledge_what-an-app-deletes-at-migration`** — les deux destins d'un symbole
  publié, le cas intermédiaire d'`ensureIdentity` (substance jetée, site d'appel
  conservé), et le fait que la liste de suppression n'est plus portée par un chemin
  d'import depuis la fusion des entrées : une garantie mécanique remplacée par une
  garantie documentaire, dont seule la moitié est tenue par un test.

Le vocabulaire du concept fixe trois termes que ce projet a déjà payé cher :
`reference` (jamais « lien »), `ReadCap`, `polyfill-era`. `lint` est conformant.

Reste à décider : ce dépôt n'a pas de `CLAUDE.md` racine, donc l'`AGENTS.md` généré
n'est chargé nulle part.
2026-08-10 09:25:01 +02:00
Sylvain Duchesne 44a9b6ee04 test: quatre tests prouvaient autre chose que ce qu'ils annonçaient
Lot D de la revue adverse. Aucun changement de comportement de la bibliothèque : ce sont
les tests qui mentaient, et deux faux `ng` qui fabriquaient un état que le vrai broker ne
produit pas.

**« Un tiers résout l'inbox d'un autre utilisateur » prouvait le CACHE.** `userInbox`
indexe par (compte, portée) sans regarder qui demande, donc Bob tombait sur l'entrée que
la session d'Alice venait de chauffer. Rien de la persistance n'était exercé — le faux ne
servait même pas la requête `docInbox` — si bien que dans une seconde SESSION, ou une
seconde page de navigateur comme en pilote la suite applicative, Bob aurait obtenu une
inbox DIFFÉRENTE et son dépôt serait parti où personne ne lit. C'est la panne que cette
bibliothèque a déjà payée une fois.

Deux causes, toutes deux dans les faux : la requête `docInbox` n'était servie nulle part,
et le faux de `cross-user-access` prenait le **nom de graphe** pour le sujet dans un
`INSERT DATA { GRAPH <g> { … } }` — donc le pointeur du shim n'était jamais retrouvé et
chaque résolution à froid créait un nouveau shim. Les deux corrigées, et les tests qui
franchissent une frontière d'identité purgent maintenant le cache à la frontière.

**Mon propre index d'inbox avait le même défaut**, découvert en faisant ça : `isKnownInbox`
ne répondait que par sa mémoire, parce qu'aucun faux ne servait la requête. La moitié
durable n'était pas exercée — exactement la faute que ce lot corrigeait ailleurs.

**« Connecting drains BOTH levels » n'observait pas le second niveau.** Rejoué contre un
`connectedUser` qui ne draine que les inbox de l'utilisateur, il restait vert. La raison
n'est pas un test faible : le second niveau n'a **aucun producteur**. Le seul appel qui
dépose un cap est `inbox.share(doc, toUser)`, qui résout l'inbox d'un UTILISATEUR, jamais
celle d'un document. Drainer une inbox de document n'applique donc rien. Le test dit
désormais ce qu'il prouve, et l'anticipation est nommée comme telle : en amont
`AddInboxCap` est générique sur les repos et `InboxMsgContent::Link` existe, donc viser
cela est légitime — annoncer que c'est exercé ne l'était pas.

**« La liste de Bob ne contient pas la note d'Alice » n'avait pas de contrôle positif.**
Bob n'écrivait jamais de note publique : sa liste était vide quoi qu'il arrive. Il en
écrit une maintenant, et la vérification symétrique est ajoutée. Au passage, `showScope`
lisait le DOM avant le rendu — le gestionnaire `change` de l'application lance
`refresh()` sans l'attendre.

189 tests unitaires, e2e 40/40 et applicatif 12/12.
2026-08-10 09:10:51 +02:00
Sylvain Duchesne b5f05472d9 fix: second tour adverse — mes correctifs avaient quatre trous, dont un qui les annulait
J'ai relancé un adversaire sur les correctifs du commit précédent, comme la règle
l'exige. Il en a trouvé quatre. Le premier annulait la garde que je venais d'écrire.

**Le registre de propriété était écrivable depuis la surface publiée.** `assertMayWrite`
lit la propriété dans l'index de store de l'appelant — et `caps.open` marquait les
documents de STRUCTURE (les trois stores, les inbox) comme « créés par moi ». Donc un
porteur pouvait, par le `docs.sparqlUpdate` publié, insérer `contains "<n'importe quel
document>"` dans son propre index et s'en déclarer propriétaire. Démontré : Bob écrit dans
le document protégé d'Alice, et détourne l'inbox d'un de ses documents — exactement le
vecteur que le commit précédent prétendait fermer. `open` classe désormais sans marquer :
un document de structure n'est possédé par personne au sens de la paternité, donc les deux
moitiés de la garde répondent non, ce qui est correct.

**`inbox.post` acceptait n'importe quel NURI.** Déplacer `depositInto` hors de la surface
ne suffisait pas : `post` atteint la même porte, qui saute les deux gardes par
conception. Bob, ne détenant rien, écrivait quatre triplets dans le document d'Alice. En
amont la confusion est impossible — `InboxPost` scelle vers une CLÉ d'inbox et le broker
route par `inboxes: PubKey → RepoId` ; adresser un document n'est pas refusé, c'est
inexprimable. Le shim tient maintenant un index des inbox, l'équivalent émulé de ce que
le broker sait par construction, et `post` refuse ce qui n'en est pas une.

**Le filtre de lecture fuyait encore par les clés dunder.** `DeepSignalSet` expose la
collection brute sur `__raw__` / `__meta__` : `[...view]` rendait zéro élément pendant que
`view.__raw__` rendait le Set complet, tous utilisateurs confondus. Mon en-tête affirmait
qu'« une propriété simple ne porte aucun élément » — faux pour ce type.

**Et il cassait des membres légitimes** : ma liste blanche couvrait la moitié des
helpers d'itération, si bien que `toArray`, `reduce`, `first`, `take`, `drop`, `flatMap`
levaient sur les données du porteur lui-même. Tous filtrés désormais ; le refus ne vaut
que pour l'inconnu.

**Deux tests réparés à la source plutôt qu'en affaiblissant les gardes.** Le faux
`doc_create` de `inbox.test.ts` rendait une CONSTANTE — tous les documents créés étaient
le même NURI, donc la garde de propriété n'avait rien à distinguer et deux tests lisaient
l'inbox d'Alice sous l'identité de Bob sans que rien ne proteste. Et le harnais e2e
utilisait un document ordinaire comme inbox.

Enfin, mon propre cache d'inbox a reproduit la faute que la revue avait relevée ailleurs :
un mémo qui survit à sa session. Rattaché à `resetRegistryCache`.

189 tests unitaires (six régressions de plus), e2e 40/40 et applicatif 10/10 — après un
échec réseau non reproductible, relancé sans modification.
2026-08-07 14:26:32 +02:00
Sylvain Duchesne 0b936d2119 fix: écrire est une PROPRIÉTÉ, et trois portes qui n'auraient pas dû être ouvertes
Suite de la revue adverse. Quatre trous de frontière, tous hors du champ « l'isolation
est fausse jusqu'à P1b » — P1b parle de matériau de clé, ceux-ci sont des défauts de
FORME et resteraient des trous avec une vraie clé.

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

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

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

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

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

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

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

185 tests unitaires (dont quatre régressions : la propriétaire écrit, le destinataire non,
le store public sert tout demandeur, aucun membre non filtré ne transmet), e2e 40/40 et
applicatif 10/10.
2026-08-07 13:59:13 +02:00
Sylvain Duchesne c5878c6126 test: le contrôle de contrat disait oui à des noms absents
`isNamespaceMember` cherchait le nom dans **tout le texte** des modules de namespace,
commentaires compris. Un nom mentionné n'importe où comptait donc comme exporté.

Mesuré : `documentInboxAddress`, `escapeLiteral`, `getCaps`, `addLink`, `setCurrentUser`
passaient tous — et **`linkTo`** aussi, la fonction supprimée parce qu'elle brisait la
règle d'accès, dont l'absence est expliquée dans un commentaire de `surface/placement.ts`.
Le commentaire qui dit pourquoi elle n'est pas là suffisait à la faire passer pour
publiée.

C'est très probablement ainsi que la dérive du contrat a traversé cinq sections sans
rien faire rougir. Un contrôle qui dit oui à un nom absent est pire que pas de contrôle :
il se lit comme une vérification.

Les exports sont désormais extraits **après retrait des commentaires**, déclarations et
ré-exports nommés, par un seul `moduleExports()` que les trois contrôles partagent.
Vérifié : les six fantômes ci-dessus sont maintenant contrôlés, et les vrais membres
(`post`, `share`, `createEntityDoc`, `docCreate`) toujours reconnus.

Ajouté au passage un contrôle qui manquait dans l'autre sens : chaque groupe de namespace
de l'annexe doit correspondre **exactement** aux exports réels de son module. Un membre
retiré de `inbox:` ne faisait rougir personne.

Trouvé par une revue adverse, reproduit puis corrigé ici. 181 tests, 0 échec.
2026-08-07 13:32:49 +02:00
Sylvain Duchesne 0455a408b6 refactor(api): le bootstrap redescend de quatre appels à un
L'objectif acté était deux appels spécifiques au polyfill, voire un. Il en publiait
quatre. Chacun des trois de trop était une raison que la BIBLIOTHÈQUE a, pas un besoin
qu'une application a :

- **`configureStoreRegistry`** existait parce qu'il y a deux internes à câbler — le SDK
  injecté d'un côté, la session de l'autre. Vu de l'appelant, les deux disent « voici ce
  qu'il te faut pour tourner ». Replié dans `configure`, qui prend désormais
  `getSession` / `normalizeId` / `pointerGuard`.
- **`setCurrentUser`** n'a plus lieu d'être publié depuis que le portail d'accès est
  passé dans le polyfill : c'est lui qui pose l'identité. Et une application qui nomme
  sa propre identité est exactement le geste qui inverse le modèle — il ne doit pas
  exister d'appel publié vers lequel se tourner. Le harnais e2e, lui, joue plusieurs
  identités sur une même page ; il y accède par le chemin interne, ce qu'un harnais a
  le droit de faire et une application non.
- **`connectedUser`** est maintenant attendu DANS `ensureIdentity`. Ce n'était pas une
  commodité : la suite applicative avait montré qu'une app devait l'attendre elle-même,
  sinon une note qu'on venait de lui partager se lisait comme illisible. J'avais traité
  le symptôme dans l'app d'exemple ; le défaut était côté bibliothèque. En amont, ouvrir
  la session EST la connexion — aucune application n'attend un second appel.

Reste donc `configure({ … })`, plus `await ensureIdentity()` dont le site d'appel
survit à la migration : une application attendra toujours une session avant de rendre.

Le test étendu hier a fait son travail : les deux contrôles de contrat sont passés au
rouge sur `configureStoreRegistry`, `connectedUser` et `StoreRegistryDeps` dès que la
surface a bougé.

180 tests unitaires, e2e 40/40 (3,4 min) et applicatif 10/10 (0,8 min).
2026-08-07 12:06:15 +02:00
Sylvain Duchesne b98fcaa77d docs+test: le contrat avait dérivé — et le mécanisme ne voyait pas les règles
En vérifiant l'alignement de la surface, cinq sections du contrat s'étaient
désynchronisées du code sans que rien ne rougisse :

- § 1 montrait `getConfig`, `getStoreRegistryDeps`, `resetConfig` et
  `resetStoreRegistry` comme exportés — retirés à la fusion des portes ;
- § 11 documentait `escapeLiteral` / `escapeIri` / `assertNuri` comme publiés — ils ne
  le sont plus, et l'absence de garde de type est désormais expliquée par sa raison :
  les portes valident elles-mêmes (`NuriLike`), publier une garde inviterait le cast
  que les types servent à empêcher ;
- § 12 listait sept fonctions `storeRegistry` — il y en a cinq depuis que les deux
  fonctions d'ADRESSE d'inbox sont parties (une app nomme un document ou une personne,
  jamais une adresse) ;
- § 13 listait `IdentityStore`, `browserIdentityStore` et `getCurrentUser` comme
  publiés — retirés le 2026-08-05 ;
- `ensureIdentity` était publié **sans aucune règle**, et l'annexe renvoyait à un
  « § 2bis » qui n'existait pas.

**§ 2bis est écrit** : le portail d'accès n'a aucune contrepartie en substance — en
amont un utilisateur ouvre SON portefeuille et il n'y a rien à nommer — mais son SITE
D'APPEL survit, et c'est pourquoi sa signature ne prend pas d'identifiant : nommer son
identité est précisément la partie qui disparaît, donc elle ne doit pas figurer dans les
paramètres.

**Le mécanisme est étendu.** `test/vocabulary.test.ts` épinglait l'annexe — les NOMS —
et ne voyait pas les sections, là où vivent les règles. Une règle périmée est pire
qu'une règle absente : elle se lit comme vérifiée. Désormais tout `export` montré dans
un bloc « ### Today » doit être réellement exporté ; ce qu'on garde pour mémoire passe
en commentaire, que le contrôle ignore par construction. Les cinq dérives ci-dessus
auraient été rouges le jour même.

Nettoyé aussi : deux commentaires de doc orphelins dans `surface/placement.ts`,
restés au-dessus de l'accolade fermante après le retrait des fonctions qu'ils
décrivaient.

180 tests unitaires, typecheck bibliothèque / exemple / harnais.
2026-08-07 11:51:24 +02:00
Sylvain Duchesne aa6bbc436e test(e2e): les parcours applicatifs passent par l'app d'exemple
Une seconde suite e2e, `packages/sdk/e2e/notebook.ts` (`test:e2e:app`), qui pilote
`examples/notebook` par le DOM — une page de navigateur par identité — contre le même
broker réel.

**Pourquoi une seconde suite plutôt qu'un ajout dans la première.** `run.ts` parle à un
sac de méthodes sur `window.__sdk` : cela prouve que les fonctions TOURNENT, jamais
qu'une application peut s'écrire avec. L'écart a déjà coûté un défaut livré — l'inbox
d'un document était verte ici et inutilisable en pratique, parce que le harnais pouvait
faire traverser une adresse d'une identité à l'autre par une variable, canal qu'aucune
application n'a. Ici, rien ne traverse que ce qui traverse dans la vie : la RÉFÉRENCE
d'une note, recopiée d'un écran, et un identifiant tapé dans un champ.

Quatre parcours, qui se lisent comme des parcours :

- Bob lit la note publique d'Alice depuis sa seule référence — la propriété pour
  laquelle l'émulation du store public existe, vérifiée bout en bout et sans qu'aucune
  clé ne circule ;
- la note protégée d'Alice reste fermée jusqu'à ce qu'elle la partage — même geste côté
  Bob, issue opposée, décidée par où la note se trouve ;
- Bob laisse un message sur la note d'Alice, et seule Alice le lit — il TROUVE l'adresse
  depuis la note, personne ne la lui donne ;
- la liste de chacun ne contient que ses notes.

**Deux étapes quittent `run.ts`** (`documentInboxDeposit`, `capsShareCap`), avec un
commentaire disant où elles sont parties : ce sont des parcours, et ils valent plus joués
sur deux écrans que sur deux appels d'une même page. Ce qui reste là-bas est ce qu'une
application ne fait pas : primitives, caractérisation, régressions de démarrage à froid.

**Trois défauts trouvés en écrivant la suite**, tous côté application et invisibles pour
le harnais : `connectedUser()` devait être attendu à la connexion (sinon une note qu'on
vient de vous partager se lit comme illisible — ce qui ressemble à un problème de droits
alors que c'est un problème de moment) ; une réponse périmée restait affichée à côté
d'une question fraîche ; et changer de portée ne rafraîchissait pas la liste. L'app
affiche désormais la référence de chaque note — ce qu'aucun écran ne montre, aucun
utilisateur ne peut le faire circuler.

Corrigé au passage : le `.gitignore` pointait encore `packages/client/`, si bien que le
commit de renommage a embarqué le profil Playwright de la suite e2e (226 fichiers). Les
chemins sont réalignés et le commit précédent a été amendé — rien n'était poussé.

179 tests unitaires, e2e 40/40 (3,2 min) et applicatif 10/10 (0,7 min).
2026-08-07 11:20:58 +02:00
Sylvain Duchesne 0eb25286c8 refactor: renommer client → sdk, et fusionner les deux portes en une
Deux mouvements de surface, aucun changement de comportement.

**`packages/client` → `packages/sdk`, `@ng-eventually/client` → `@ng-eventually/sdk`.**
« client » ne disait rien : ce paquet EST le SDK que l'application appelle, et c'est
tout ce qu'elle appelle. L'ancien nom reste comme mot-clé de recherche dans
`docs/source-layout-by-fate.md` et le tableau des paquets du README.

**Une seule entrée.** L'entrée `./polyfill` disparaît ; ses symboles applicatifs —
`configure`, `configureStoreRegistry`, `setCurrentUser`, `connectedUser` et leurs types
— vivent dans un bloc `POLYFILL-ERA` de `src/index.ts`.

Ce que la seconde porte portait mérite d'être nommé avant d'être retiré : *ce qu'on
importe de ce chemin est exactement ce qu'on supprimera à la migration*. Une seule
porte perd ce signal — rien à la ligne d'import ne distingue `configure`, qui part, de
`docs`, que le vrai SDK remplace sur place. Trois choses le portent désormais : le bloc
lui-même, l'inventaire d'exports de `docs/api-contract.md` (épinglé par
`test/vocabulary.test.ts`, donc il ne peut pas rancir en silence), et le contrôle de
vocabulaire sur les noms publiés.

**Six symboles quittent la surface au passage**, et la fusion est ce qui a rendu le
choix visible plutôt qu'hérité :

- `getConfig` / `getStoreRegistryDeps` — câblage interne, atteint par
  `shared-wallet/bootstrap` ;
- `resetConfig` / `resetStoreRegistry` / `resetCaps` — remises à zéro de test, atteintes
  par leur chemin interne, ce qui est leur raison d'être ;
- le `share` direct — `inbox.share` a toujours été la même fonction, et la publier deux
  fois brouillait la frontière qu'elle servait à marquer.

Corrections d'affirmations fausses trouvées en chemin : le contrat annonçait `isNuri` /
`hasReadCap` sur la porte SDK alors qu'ils ne sont plus exportés depuis le passage au
permissif en entrée (`NuriLike` validé à la porte) ; le README du paquet documentait
`capFor`, `shareCap`, `getCaps` et `publishRepoLink`, dont aucun n'existe ; et le README
de l'app d'exemple affirmait que la suite e2e la pilote, ce qui reste à faire.

179 tests unitaires, typecheck bibliothèque / exemple / harnais, e2e 42/42 contre le
broker en ligne — mesuré une fois après le renommage, une fois après la fusion.
2026-08-07 11:16:57 +02:00
Sylvain Duchesne 0832338201 feat: un document en store public sert son ReadCap, une référence nue suffit
Le modèle amont est explicite dans `PublicRepoLinkV0` : le lien ne porte AUCUN
`read_cap`, et son commentaire dit pourquoi — *"The latest ReadCap of the branch
will be downloaded from the outerOverlay, if the peer brokers listed below allow
it […] the public site are served differently by brokers"*
(engine/net/src/types.rs:5098). La clé n'est pas remise par un émetteur : elle est
donnée par le réseau à qui la demande, parce que le broker a épinglé l'overlay
externe (`expose_outer`).

La bibliothèque refusait jusqu'ici la forme sans cap quel que soit le store. Sûr
dans le bon sens, mais une application ne pouvait pas exprimer « fais circuler, la
référence suffit » — le seul acte que le modèle rend gratuit — et son unique
contournement était de distribuer la clé, ce qui détruit la confidentialité
composable.

`emulated-verifier/public-store.ts` émule le mécanisme SANS toucher à la garde. La
possession reste l'unique critère : un document public est lisible non par exception
mais parce que son cap est *obtenable*. Chaque porte de lecture demande d'abord
(`readUnion`, `docs.sparqlQuery`, `ensureRepoOpen`, `documentInboxAddress`), puis le
chemin ordinaire s'applique.

Lire n'est pas écrire. Ce que le store sert est un droit de LECTURE :
`learnFromPublicStore` le classe à part et `assertMayWrite` refuse l'écriture
dessus. Sans cela une référence nue achetait une écriture, ce qu'aucun store amont
n'accorde.

Autres conséquences :

- `recordInPublicStore` (marquer + frapper) devient `markInPublicStore` (marquer).
  Frapper un second cap à côté de celui qu'on vient de télécharger donnerait deux
  clés différentes le jour où la constante devient un secret.
- `hasCap` quitte la porte polyfill : il se lisait « ai-je le droit de lire ceci ? »
  et un document public y répondait `false` jusqu'à ce qu'on demande son cap. Aucun
  appelant hors des tests.
- Les tests cross-user ne font plus traverser de cap par une variable JS : Bob
  n'obtient que la référence nue, comme une vraie application.

Écarts documentés plutôt que masqués : le pari sur un modèle DÉCLARÉ (`expose_outer`
est câblé à `false` côté client et `ExtTopicSyncReq` est `unimplemented!()`), la
découverte limitée à ce qu'on sait déjà nommer, `useShape` qui n'a pas d'await à
dépenser, et l'absence de `locator`.

179 tests unitaires, e2e 42/42 contre le broker en ligne.
2026-08-06 19:55:32 +02:00
Sylvain Duchesne 32ef756b0b docs: deux actes, pas quatre besoins
La section listait quatre « besoins » — nommer, donner à lire, faire trouver,
joindre. C'était confondre ce qu'un acte FAIT avec ce dont son résultat dépend.
Un émetteur ne fait que deux choses, et tout cas d'usage est l'une des deux :

- transmettre la RÉFÉRENCE (`access` vide) : le destinataire peut nommer le
  document — et le lire si celui-ci se trouve dans un store public. Même valeur
  transmise, résultat différent, décidé par l'endroit où est le document et non
  par ce qu'on a envoyé.
- transmettre la référence ET la clé : le destinataire lit. Irréversible.

« Faire trouver » n'était donc pas un troisième acte, mais le premier appliqué à
un document en store public. Et « joindre » n'est pas un acte du tout : le
locator dit si la référence est assez COMPLÈTE pour être résolue par un
inconnu — cela relève du destinataire.

D'où la conséquence pour tout ce que cette bibliothèque expose : un appel
remet la clé ou ne la remet pas, et ce doit être l'intention explicite de
l'appelant — jamais l'effet de bord d'une demande de référence.

Où en est la bibliothèque, acte par acte : transmettre la référence est couvert
sans appel dédié (toutes les références rendues sont nues) ; transmettre
référence et clé l'est par `inbox.share`. Et une propriété du modèle n'est PAS
émulée : un document en store public est lisible depuis une référence nue, là où
`mayReach` exige toujours un cap détenu. L'écart va dans le sens sûr — nous
sous-accordons — mais une app ne peut pas exprimer « je diffuse largement, la
référence suffit », et son seul contournement serait de remettre la clé, ce qui
casse la confidentialité composable.

Section redondante supprimée, et le mot banni retiré des exemples.
2026-08-06 17:35:39 +02:00
Sylvain Duchesne 94173fd3d3 docs: consigner l'écart sur l'inbox choisie par share
`inbox.share` dépose toujours sur l'inbox PROTÉGÉE du destinataire. En amont le
choix n'est pas fixe : un enregistrement de contact prend son inbox du PROFIL par
lequel la personne a été atteinte — `a_or_b = if details.profile.is_public()
{ "site" } else { "protected" }` (`inbox_processor.rs:787`, écrit `ng:site_inbox`
ou `ng:protected_inbox` en `:823-824`).

Mineur aujourd'hui, mais ça aplatit une distinction du modèle, et ce sera faux le
jour où une application partagera avec quelqu'un rencontré par son profil public.

Consigné plutôt que corrigé : le correctif demande une notion que rien n'a
établi ici. Ce qui EST vérifié se limite à un wallet qui contient des `sites`, un
`SiteV0` avec `id: PubKey`, `name`, `site_type` et trois stores. L'enum
`Identity`, qui nommerait le reste, est entièrement COMMENTÉ en amont
(`engine/repo/src/types.rs:586-595`) — il n'y a donc aucun modèle de profil à
lire, et rien à supposer.

Rappelé au passage, parce que la question s'est posée : le store privé n'a
aucune inbox — `new_store_default` n'en attache une que `if !private` — d'où
`InboxScope`, qui rend « l'inbox privée » inécrivable plutôt que simplement vide.
2026-08-06 16:25:33 +02:00
Sylvain Duchesne cb56f91b5e docs+fix: le modèle de sécurité local-first, et le retrait de linkTo qui le brisait
La règle d'accès est « qui a la référence ET la clé lit » — jamais « qui a la
référence lit ». Il n'y a pas de tiers dans cette phrase : le moteur ne vérifie
une permission qu'à l'ÉCRITURE, jamais à la lecture. Le contenu est chiffré, la
clé EST le droit. C'est ce que veut dire local-first ici : il n'y a personne à
qui demander, donc la possession est tout le mécanisme.

Ce que cette règle achète, et qui n'était consigné nulle part : une référence
n'accordant rien, **elle n'est pas récursive**. Un document largement diffusé
peut pointer vers un document restreint — la référence dit qu'il existe, la clé
dit qui le lit. D'où la confidentialité COMPOSABLE : un sommaire diffusé qui
renvoie à des chapitres restreints, un événement public qui renvoie à sa liste
de participants. L'auteur diffuse un document et décide encore, document
référencé par document référencé, qui reçoit la clé.

`linkTo` détruisait cette construction. Il rendait la CLÉ là où un appelant
demande une référence, transformant la règle en « qui a la référence lit » —
pour ce document et pour tout ce qu'il MENTIONNE. Ajouté et retiré le même jour.

Documenté en §0, avant tout le reste, avec la raison pour laquelle un agent
perd ce point avec constance : les réflexes client-serveur fournissent la moitié
manquante sans qu'on s'en aperçoive — quelque part un serveur vérifierait, un
lien serait un identifiant inoffensif, « Bob a-t-il le droit ? » aurait une
réponse. Aucun des trois n'est vrai ici, et du code écrit là-dessus n'échoue
pas : il accorde en silence. Rien ne passe au rouge.

Renommé au passage : `publishRepoLink`/`isPublished` → `recordInPublicStore`/
`isInPublicStore`. Ces méthodes n'ont pas de pendant amont et « publier » ne
désigne rien de précis ici.

171 tests unitaires, e2e 42/42 en 3,6 min.
2026-08-06 16:04:31 +02:00
Sylvain Duchesne 3c981ffadb docs: le nommage de NextGraph l'emporte toujours — y compris sur nos propres règles
La règle que je venais d'écrire bannissait « publish » sans réserve. Or le
moteur emploie `publisher` 126 fois — `as_publisher`, `publisher_advert` — pour
un rôle pub/sub sur un topic. Une règle appliquée à la lettre aurait fait
rejeter du vocabulaire amont, ce qui est exactement l'inverse du but.

Le principe est donc posé au-dessus, et il prime sur tout : là où la cible a un
mot, c'est le mot, point. Ce qui reste banni est NOTRE « publier un document »,
qui n'a aucun référent en amont et recouvre trois actes distincts — placer dans
un store public, rendre trouvable, remettre une clé. Ça n'autorise jamais à
renommer ce que l'amont appelle `publisher`.

`publisher`, `topic` et `advert` rejoignent le vocabulaire déclaré du contrôle
de noms, avec la raison en commentaire.

Le renommage `publishRepoLink` → `recordInPublicStore` reste justifié : cette
méthode n'a pas de pendant amont, elle enregistre un document en store public
(et, artefact d'émulation, lui frappe une clé).
2026-08-06 15:50:54 +02:00
Sylvain Duchesne 7672915bb9 docs: ce qu'un NURI transporte, et les quatre besoins d'un lien
Une discussion de conception a calé plusieurs fois sur une séparation supposée
entre « un NURI » et « un lien ». Elle n'existe pas, et l'avoir crue est ce qui a
fait livrer un `linkTo` faux sur trois points à la fois.

`NuriV0` porte exactement ce qu'un lien porte — `target`, `overlay`,
`access: Vec<NgAccessV0>`, `locator` — et `NgLinkV0` en est la forme structurée.
La vraie question n'est donc pas « NURI ou lien » mais ce qu'on met dans
`access` et `locator`. Nuance qui compte pour tout ce qui s'imprime : le TYPE a
tous les emplacements, la GRAMMAIRE de chaîne ne les expose pas tous.

`access` est une LISTE de formes d'accès (`ReadCap | Token | ExtRequest | Key |
Inbox | Topic`), donc une liste VIDE est un état légitime et courant : la
référence NOMME sa cible et n'accorde rien.

D'où les quatre besoins, chacun avec ce que la référence doit porter : nommer
(access vide), donner à lire (+ ReadCap, irréversible), faire trouver un
document public (overlay/locator, aucune clé), joindre depuis ailleurs
(locator, sans quoi un inconnu n'ouvre rien).

Trois confusions nommées parce que chacune a été faite ici : « sans clé » ne
veut pas dire « public » ; nommer n'est pas lire et c'est l'acte PAR DÉFAUT
(ce que notre test central prouve déjà) ; rien n'est vérifié à l'accès, donc
une référence ne peut pas être neutre en droits — ce qu'on y met EST
l'autorisation, et un lien diffusé ne se révoque pas.

Et la divergence la plus lourde, qui n'était consignée nulle part : publier
signifie ICI distribuer une clé, alors qu'en amont un document public est
lisible parce que son STORE l'est et que les brokers le servent. L'émulation
est trop stricte, pas inversée — elle sous-accorde — mais un consommateur ne
doit pas en conclure que publier est un acte de distribution de clé, ni
attendre quoi que ce soit de per-lecteur sur un document public.
2026-08-06 12:52:35 +02:00
Sylvain Duchesne c8d02619b1 refactor(api): partager nomme le document, détenir répond par oui ou non
`shareCap(cap, toUser)` faisait tenir une clé à l'appelant. En amont il n'en
tient aucune : c'est le verifier qui remplit `ContactDetails.read_cap`, et une
inbox se résout depuis un profil. Cette signature a déjà changé deux fois
aujourd'hui — `(cap, toInbox)` puis `(cap, toUser)` — et les deux laissaient à
l'app quelque chose qu'elle ne tiendra pas plus tard.

- `inbox.share(doc, toUser)` : les deux choses qu'une application a, un document
  et une personne. Ni la clé ni l'adresse n'apparaissent.
- `hasCap(doc)` remplace `capFor(doc)` et rend un BOOLÉEN. C'est la seule
  question que le modèle admette, et l'unique appelant qui utilisait la valeur
  s'en servait pour la passer à `shareCap`.

Les tests ont fait apparaître un besoin que ces retraits allaient casser :
obtenir le lien PARTAGEABLE d'un document publié, pour le faire circuler. C'est
distinct du partage dirigé et ça existe en amont — un `RepoLinkV0 { read_cap }`
est ce qu'on passe, `ContactDetails.read_cap` est la remise à quelqu'un. D'où
`linkTo(doc)`, seul endroit où une app tient légitimement une clé : on ne peut
pas faire circuler ce qu'on n'a pas le droit de toucher. La clé d'un document
protégé, elle, ne sort jamais par là — elle passe par `share`.

171 tests unitaires, e2e 42/42 en 3,5 min (synchro à froid 29s, stable contre
30s au run précédent — le wallet par batterie tient).
2026-08-06 11:37:47 +02:00
Sylvain Duchesne da6ef4b8b8 test(e2e): un user physique par batterie — 20 min et 286s de synchro tombent à 3,6 min et 30s
La suite se ralentissait elle-même, de façon monotone. Chaque batterie crée ~11
identités virtuelles FRAÎCHES (`@alice-…`, `@owner-…`, `@recon-…`), chacune avec
ses trois documents de scope et son inbox, et toutes atterrissaient dans le MÊME
user physique — un wallet créé le 10 juillet et réutilisé depuis, que rien ne
nettoyait. Or une resynchronisation à froid est O(taille du user physique), ce
que la doc de cette bibliothèque énonce elle-même. D'où 250s il y a une semaine,
286s avant-hier, et une batterie qui a fini par dépasser les 20 minutes.

Les identités fraîches ne sont pas la faute : ce sont elles qui rendent une
batterie reproductible, une inbox stable accumulant sinon les dépôts des runs
précédents. La faute était de conserver le user physique qui les héberge.

Mesuré : 42/42 en **3,6 min** au lieu de 20+, synchro à froid la plus lente à
**30s** au lieu de 286s.

Le profil reste persistant À L'INTÉRIEUR d'une batterie — CONTRACT 1 et 2
testent précisément cela (reconnexion fidèle sur le même profil, absence de fork
de compte au travers).

Deux garde-fous pour que la prochaine dérive se voie :

- **Le budget appartient au runner**, qui échoue en nommant la cause probable.
  Un `timeout` posé autour de la commande tuait le navigateur, et la suite
  rapportait « Target page, context or browser has been closed » — un message
  qui se lit comme un défaut applicatif, et que j'ai diagnostiqué deux fois de
  travers avant de comparer les durées.
- **La synchro à froid remonte dans le résumé.** C'est le nombre qui a dérivé
  pendant un mois sans que personne le regarde, parce qu'il n'apparaissait qu'au
  détour de la ligne de détail d'une étape.
2026-08-06 11:22:04 +02:00
Sylvain Duchesne ebf866b1f2 refactor(api): précis en sortie, permissif en entrée — plus de guard publié
Un polyfill ne doit rien faire de plus que ce qui est prévu. `isNuri` /
`hasReadCap` et les utilitaires SPARQL `escapeLiteral` / `escapeIri` /
`assertNuri` n'ont de pendant à aucun niveau et n'en auront pas : le binding
prend `nuri: String`, le moteur est fortement typé en Rust et n'a besoin
d'aucun prédicat, l'ORM n'expose rien de tel. Le contrat les justifiait parce
qu'ils « restent utiles à n'importe quelle app » — c'est exactement le
raisonnement à refuser : utile n'est pas prévu, et chacun serait un appel à
réécrire le jour du SDK.

Le besoin d'un guard venait de notre propre signature : les entrées publiques
exigeaient `Nuri`, donc un consommateur devait narrower ce qu'il lisait d'une
URL ou du stockage. Elles prennent désormais `NuriLike` — n'importe quelle
chaîne — et valident à l'intérieur (`toNuri`). Ce que la bibliothèque REND
reste typé `Nuri` : l'app en profite gratuitement, et un type plus large ne
cassera rien quand le SDK rendra des chaînes.

Les guards et les utilitaires restent, internes, là où la validation se fait.

Un défaut introduit puis corrigé en chemin, qui valait le test qu'il a produit :
`readUnion` a toujours toléré les trous dans sa liste — un index de scope peut
porter une entrée blanche, et un appelant qui assemble depuis des valeurs
optionnelles n'a pas à compacter. Valider AVANT de filtrer a transformé cette
tolérance en exception. Vide est une absence, pas une référence malformée ; les
deux sont désormais distingués par un test.

170 tests unitaires, e2e 42/42 contre le broker, typecheck vert sur la
bibliothèque, l'exemple et le harnais.
2026-08-06 10:51:36 +02:00
Sylvain Duchesne 54f8389e9e refactor(api): l'app nomme une personne ou un document, jamais une adresse d'inbox
L'app d'exemple a servi de juge, et elle a immédiatement montré ce que
l'inventaire ne montrait pas : pour partager une note elle résolvait l'inbox du
destinataire, pour lire ses messages elle résolvait l'adresse de la sienne. Deux
gestes qu'aucune application n'aura à faire une fois la chose native — donc deux
gestes qu'elle ne doit pas apprendre.

- `shareCap(cap, toUser)` remplace `shareCap(cap, toInbox)`. Partager est un acte
  envers quelqu'un ; où est son inbox regarde la bibliothèque.
- `inbox.readForDocument(doc)` : le propriétaire lit ses messages en nommant la
  note, comme le déposant la nomme pour en laisser un.
- `storeRegistry.userInbox` et `documentInboxAddress` sortent de la surface
  publiée. Ils restent joignables en interne, où le shim en a besoin.

Sortent aussi de `/polyfill`, chacun parce qu'une app qui code contre apprend ce
qu'il faudra désapprendre :

- `getCaps` / `CapRegistry` — la salle des machines. La question du consommateur
  est `capFor(doc)` : est-ce que je le détiens ? Le registre n'a ni successeur ni
  forme inerte ; ce qui s'appuie dessus sera à réécrire, pas à laisser en place.
- `getCurrentUser` — une app sait qui elle a connecté ; le redemander à la
  bibliothèque est une commodité du wallet partagé.
- `virtualUsers` / `IdentityStore` — se souvenir d'une identité entre deux
  sessions est aussi le travail de l'app en amont. L'écran d'accès persiste ce
  dont IL a besoin ; rien d'autre n'a à être exposé.

Reste sur `/polyfill` ce qu'une app appelle vraiment : `configure` et
`setCurrentUser`. Le reste y est du test ou de l'injection interne.

170 tests unitaires, e2e 42/42 contre le broker, typecheck vert sur la
bibliothèque, l'exemple et le harnais.
2026-08-05 18:55:30 +02:00
Sylvain Duchesne d35e735c8b feat(example): une app d'exemple, écrite comme un consommateur
Le harnais e2e parlait à un sac de méthodes posé sur `window.__sdk`. Il prouvait
que les fonctions s'exécutaient, jamais qu'on pouvait écrire une application avec
— et cet écart a livré un vrai défaut : l'inbox d'un document était verte en test
et inutilisable en vrai, parce que le harnais faisait traverser une adresse d'une
identité à l'autre par une variable, ce qu'aucune application ne peut faire.

`examples/notebook` est une application minimale en DOM natif, qui résout
`@ng-eventually/client` comme un consommateur externe (workspace, dépendance
déclarée, aucun import privilégié). Elle ne peut faire que ce qu'une application
peut faire.

Elle s'est déjà payée deux fois pendant son écriture :

- `UnionSubject.subject` et `.graph` étaient typés `string` alors que ce sont
  toujours des références de document. Un consommateur devait donc caster ce
  qu'il venait de lire avant de le repasser — un cast à cet endroit précis
  rouvre la confusion que les types template literal existent pour fermer.
- l'écran d'accès normalisait ce que l'utilisateur SAISIT mais pas ce que l'URL
  porte, si bien qu'un lien `?ng-id=@Erin` ouvrait un espace différent de celui
  de la même personne tapant `erin`. Une seule normalisation désormais, celle
  du registre.

Le domaine est volontairement mince — des notes — mais suffit à exercer le
placement par scope, la possession de caps, le partage dirigé, les inbox par
document et la lecture réactive.

170 tests unitaires, typecheck vert sur la lib, l'exemple et le harnais.
2026-08-05 18:25:47 +02:00
Sylvain Duchesne 66a40fbb89 feat(access-gate): le parcours de connexion passe dans le polyfill
Chaque étape de cet écran n'existe que parce qu'un wallet héberge plusieurs
identités. Une app qui l'implémente écrit du code qu'elle devra supprimer, et
pire, du code qui enseigne à ses auteurs un modèle que NextGraph n'a pas :
« je nomme mon identité ». Le premier consommateur en avait ~300 lignes — un
gate, un écran, un module wallet, un contexte d'identité, trois features. C'est
le travail de la bibliothèque, pas le sien.

`ensureIdentity()` : un appel, avant que l'app s'affiche. Il ne prend PAS
d'identifiant — nommer une identité est l'étape qui disparaîtra, donc elle ne
doit pas figurer dans la signature. Le jour où le wallet fournit l'identité,
l'appel se résout sans rien afficher et le code de l'appelant ne bouge pas.

L'écran est en DOM natif, sous shadow root : c'est une barrière technique
montrée avant qu'une application s'affiche, comme une demande de mot de passe
sur une bêta fermée. La lier à un framework obligerait chaque consommateur à
adopter ce framework pour un écran voué à disparaître.

L'ordre de résolution de l'identité est pinné par des tests, parce que s'y
tromper échoue en SILENCE : le parcours traverse deux partitions localStorage
distinctes — la page et l'iframe du broker — et seul l'URL franchit la
frontière. Si le stockage l'emportait, l'iframe lirait une identité vide,
provisionnerait un second utilisateur virtuel, et l'utilisateur reviendrait dans
un espace vide sans la moindre erreur.

Les identifiants du wallet partagé (fichier, mot de passe) passent par
`configure()` : ce sont des données de déploiement, et cet appel est déjà celui
qui devient inerte à la migration. Au passage, l'ancien champ `sharedWallet:
{ name, secret }` — inutilisé nulle part — est remplacé.

168 tests unitaires, typecheck vert.
2026-08-05 17:06:10 +02:00
Sylvain Duchesne c42236bc00 test: l'inventaire du contrat est tenu par un test, plus à la main
L'appendice « inventaire des exports pour diff » d'`api-contract.md` était
périmé : il listait encore les internes du shim dans le namespace
`storeRegistry` alors que l'entrée avait été réduite à sept fonctions. Or c'est
précisément l'instrument qu'on diffe quand la surface bouge — et un inventaire
périmé est pire qu'aucun, il se lit comme vérifié.

Régénéré depuis les `export`, et désormais tenu par `vocabulary.test.ts` : si la
liste et le code divergent, le test échoue. Le document suit le code au lieu de
dériver.

Au passage, le contrôle de vocabulaire suit maintenant `export * from`, ce qui
lui a fait voir trois types qu'il ignorait — d'où le suffixe structurel `…Like`
(`NgLike` = « ce qui a la forme de ng ») déclaré comme de la glue de typage et
non un mot de domaine.

162 tests unitaires, typecheck src/test/e2e vert.
2026-08-04 18:14:53 +02:00
Sylvain Duchesne 3257afe8c0 feat(inbox): un utilisateur a DEUX inboxes, comme en amont
Tranché par la cascade plutôt qu'en attendant une réponse : le niveau 3 (ORM) ne
dit rien des inbox, le niveau 2 non plus — `@ng-org/web` n'expose aucune méthode
contenant « inbox » et la session n'en publie aucune. C'est donc le modèle du
moteur qui décide, et il dit DEUX : un site porte une inbox sur son repo de store
public et une autre sur son protégé (`engine/verifier/src/site.rs:127-152`), les
seuls `AddInboxCap` du moteur, `new_store_default` n'en posant une que
`if !private`. Elles sont adressées séparément jusque dans les enregistrements de
contact, qui choisissent leur prédicat selon le profil visé — `ng:site_inbox` pour
un profil public, `ng:protected_inbox` sinon
(`engine/verifier/src/inbox_processor.rs:787,823-824`).

`userInbox(id)` en exposait une : une cardinalité que cette bibliothèque avait
inventée, et que le nom `walletInbox` avait contribué à masquer. Elle prend
désormais le scope, et le store PRIVÉ n'en a pas — d'où `InboxScope` plutôt que
`Scope` : demander l'inbox privée n'est pas une recherche qui ne rend rien, c'est
une question sans référent dans le modèle, et le type l'interdit.

`myInboxes` énumère les deux, `isOwnInbox` reconnaît les deux. Le shim garde un
triple par (user, scope).

160 tests unitaires, typecheck src/test/e2e vert, e2e 40/40 contre le broker.
2026-08-04 16:19:49 +02:00
Sylvain Duchesne b62bfe1e63 refactor: ouvrir une inbox est un acte de registre, et deux invariants rendus explicites
`openDocumentInbox` passe du shim aux registres de branche. Ce qu'il fait est de
la comptabilité du verifier : vérifier la propriété, enregistrer la moitié
lecture sur la branche User, publier l'adresse. Seule la création du document
support relève du shim, et elle est appelée, pas hébergée. En amont l'acte
équivalent est générer une paire de clés et commiter `AddInboxCap`.

Deux risques de migration signalés par le contrat interne, transformés en
invariants vérifiés plutôt que supposés :

- **Le couple `(document, inbox)`** est un littéral RDF séparé par une espace là
  où l'amont a une structure typée (`AddInboxCapV0 { repo_id, overlay, priv_key }`).
  L'espace est sûr parce qu'un NURI n'en contient pas — alphabet base64url et
  segments `:` — mais c'était une propriété implicite. `encodeInboxCap` la
  vérifie désormais : un découpage erroné classerait une inbox sous un document
  tronqué et perdrait les dépôts sans erreur, la classe de panne que ce chemin a
  déjà payée une fois.
- **Le namespace réservé** garantit qu'aucun identifiant utilisateur ne peut s'y
  loger — sauf que `normalizeId` est injecté par le consommateur et que le
  défaut de la bibliothèque ne fait que trimmer. Une collision ne serait pas
  cosmétique : un utilisateur se retrouverait sur un compte d'infrastructure, à
  lire et écrire des documents qui ne sont pas les siens. Vérifié à la
  normalisation, avec un test qui simule un `normalizeId` hostile.

160 tests unitaires, typecheck src/test/e2e vert, e2e 40/40 contre le broker.
2026-08-04 15:02:29 +02:00
Sylvain Duchesne 107f9d1633 refactor(vocabulary): les noms publiés parlent la langue de la cible, et un test le tient
La correction de nomenclature du 2026-07-30 — en amont un *wallet* n'est qu'un
trousseau, ce qui possède des stores est un **user** (un *site*) — s'était faite
à la main. `walletInbox` y a échappé et a vécu des semaines, en faisant des
dégâts : le nom rendait « une inbox par wallet » évident, masquant qu'un user en
a **deux** en amont (repos de store public et protected, les deux seuls
`AddInboxCap` du moteur). Une discipline appliquée à la main en oublie un ; un
test non.

D'où `test/vocabulary.test.ts` : tout nom publié est bâti sur des mots que la
CIBLE emploie — vérifiés dans `nextgraph-rs` — ou porte un marqueur disant
POURQUOI il n'existe qu'ici (`virtual`, `physical`, `shim`, `emulated`,
`polyfill`), ce qui dit aussi quand il disparaît. Un échec n'est pas « renommer
pour faire passer le test », c'est une question : la cible a-t-elle un mot pour
ça ? la chose n'existe-t-elle qu'ici ? le mot est-il vraiment de la glue ?

Ce que le test a trouvé, et les réponses :

- `walletInbox` → `userInbox`, avec l'écart de cardinalité écrit noir sur blanc
  plutôt que caché par le nom.
- `accounts` / `AccountRecord` / `AccountStorage` → `virtualUsers` /
  `VirtualUserRecord` / `VirtualUserStorage`, module `accounts.ts` →
  `virtual-users.ts`. « account » n'est pas de la cible : c'est notre mot pour
  l'utilisateur virtuel, et le marqueur le dit désormais.
- `readModel` → la fonction `readUnion`, exposée directement. « model » n'était
  ni de la cible ni de la glue, et le namespace ne tenait qu'une fonction.
- Le reste était du vocabulaire légitime à déclarer (`subject`, `base`,
  `schema`, `connected`, le modèle réactif de l'ORM).

Corrigé au passage, sur signalement du contrat interne : l'en-tête d'`open-repo`
justifiait son correctif par un mécanisme que le source contredit. Un repo absent
de `self.repos` lève bien `RepoNotFound`
(`engine/verifier/src/request_processor.rs:264,269`). Les 0 lignes observées
viennent d'ailleurs — `Verifier::load` repeuple `self.repos` depuis le stockage
sur un profil persistant (`verifier.rs:535-560`), et notre propre `readDoc`
attrape toute erreur et rend `[]`. Le correctif est bon, le diagnostic écrit à
côté ne l'était pas.

159 tests unitaires, typecheck src/test/e2e vert, e2e 40/40 contre le broker.
2026-08-04 14:35:01 +02:00
Sylvain Duchesne e01a8dbab1 refactor(layout): séparer les registres de branche du shim
`store-registry.ts` portait deux destins dans un seul fichier de 1379 lignes.
`emulated-verifier/branch-registers.ts` prend les compartiments durables —
registre de caps de branche Store (`AddRepo`), registre de Links de branche User
(`AddLink`), enregistrements d'inbox (`AddInboxCap`), adresses de branche Header
— chacun nommant son mécanisme natif. Ils émulent la comptabilité du VERIFIER et
survivent conceptuellement : à la migration le natif les reprend, seule notre
représentation RDF disparaît.

`shared-wallet/account-registry.ts` garde le shim proprement dit — indirection
pointeur → doc-shim, résolution et provisionnement des comptes, cache. Aucun
pendant amont, s'évapore en entier.

Les imports croisés entre les deux sont délibérés et visibles : un registre a
besoin du shim pour savoir À QUI il est, le shim classe la structure d'un user au
moment où il le résout. Tout l'usage est en corps de fonction, donc le cycle de
modules est inerte à l'évaluation.

157 tests unitaires, typecheck src/test/e2e vert, e2e 40/40 contre le broker.
2026-08-04 14:09:06 +02:00
Sylvain Duchesne 36c0148750 refactor(layout): extraire le magasin d'injection vers shared-wallet/bootstrap
L'entrée publique `/polyfill` portait le magasin d'injection (le `ng` injecté,
les dépendances du registre, QUI est connecté, le singleton `CapRegistry`). Tout
module interne l'importait donc pour atteindre la config — ce qui faisait de
l'entrée une dépendance du code qu'elle publie, avec les cycles
`polyfill` <-> `connect` et `polyfill` <-> `inbox`.

Le magasin rejoint `shared-wallet/` : rien n'est injecté en amont, l'app importe
le SDK et « qui suis-je » est la session — il n'y a pas de relais d'utilisateur
courant parce qu'un wallet a exactement un user. Ce module est la forme de cette
absence, il s'évapore en entier à la migration.

L'entrée ne fait plus que ré-exporter. Plus aucun module interne n'importe
`polyfill`.
2026-08-04 13:51:56 +02:00
Sylvain Duchesne 0b37d17c2f docs: reprendre les citations après le rangement, et retirer le membre login fabriqué
Les deux contrats et la doctrine portent des centaines de citations `file:line`
vers `src/`. Le rangement par destin les périmait en bloc — 208 reprises (45
chemins `src/x.ts`, 163 mentions nues). Sans ça la réorganisation pourrissait
l'instrument même qui tient la discipline qu'elle sert.

Et un défaut relevé par le contrat interne, vérifié : `ng-proxy` fabriquait un
membre `login`. `@ng-org/web` n'expose aucune méthode de ce nom — zéro
occurrence dans les déclarations installées comme dans `sdk/js/lib-wasm/src/lib.rs`
— mais le proxy répondait une fonction au lieu d'`undefined`, laquelle plantait
à l'appel. C'était le seul endroit où ce wrapper ajoutait à la surface du SDK,
contre son propre en-tête.

157 tests unitaires, typecheck src/test/e2e vert.
2026-08-04 12:56:02 +02:00
Sylvain Duchesne cd096de2b0 refactor(layout): les portes non gardées en un seul module, mintCap chez le verifier
Trois extractions que le rangement par destin a rendues évidentes :

- `subscribePhysicalDoc` et `ensurePhysicalRepoOpen` rejoignent
  `shared-wallet/physical.ts`, qui devient l'API non gardée de la machinerie
  en un seul endroit — ce que sa propre doctrine réclamait (« des fonctions
  séparées, jamais des exemptions »). Elles vivaient jusqu'ici à côté de leurs
  jumelles gardées, à un import près d'être atteintes par erreur. Les deux
  cœurs sont désormais exportés sous un nom `Unguarded`, pour cet unique
  importateur.
- `mintCap` et sa valeur de remplacement quittent `model/nuri.ts` pour
  `emulated-verifier/caps.ts`. `model/` transcrit le vocabulaire d'adressage de
  la cible ; frapper une clé n'en fait pas partie — en amont le moteur frappe à
  la création du repo, et ensuite on cherche un cap dans ce qu'on détient ou on
  le reçoit. Sa présence dans le module modèle contredisait l'en-tête de ce
  module et logeait la seule valeur inventée de l'émulation dans le fichier qui
  se dit vocabulaire vérifié.

Un cycle est apparu au passage (`open-repo` ↔ `physical`) : résolu en faisant
appeler à `open-repo` le cœur non gardé plutôt que la porte de la machinerie.

157 tests unitaires, typecheck src/test/e2e vert.
2026-08-04 12:49:59 +02:00
Sylvain Duchesne 88914f50ae refactor(layout): ranger les modules par destin à la migration
Les 25 modules étaient à plat, nommés d'après ce qu'ils font mécaniquement
(`store-registry`, `read-model`, `reach`, `caps`). Rien dans l'arborescence ne
disait lesquels DEVIENDRONT le vrai SDK, lesquels tiennent lieu du travail que
le verifier fera nativement, et lesquels n'existent que parce qu'un wallet est
partagé — trois destins sans rapport.

Quatre dossiers, les deux fichiers d'entrée restant à la racine pour que
l'`exports` du paquet et le code du consommateur ne bougent pas :

- `model/` — le modèle d'adressage de la cible, transcrit : vocabulaire pur,
  pas d'I/O. Survit comme connaissance.
- `surface/` — ce que l'app touche, chaque symbole ayant un pendant cible
  documenté. Supprimé quand l'alias bascule ; le code de l'app est inchangé.
- `emulated-verifier/` — les doublures de ce que le verifier fait nativement :
  possession, dépôt des caps, frontière, non-livraison, traitement des inbox,
  registres de branche, ouverture de repo. **C'est le dossier où diverger du
  modèle est possible.** Le préfixe `emulated-` porte le sens : tient lieu de,
  jamais est — cette bibliothèque ne réside dans aucune couche de la cible,
  elle les référence.
- `shared-wallet/` — n'existe que parce qu'un wallet héberge toutes les
  identités. Aucun pendant, rien sur quoi s'aligner ; sa seule loi est de
  rester invisible depuis `surface/`. S'évapore, remplacé par rien.

`store-registry-api.ts` devient `surface/placement.ts` : il faisait déjà à la
main ce que la frontière de dossier fait structurellement — c'est la meilleure
preuve interne du bien-fondé de ce rangement.

Ce commit ne fait que déplacer et recâbler les imports (src, test, e2e). Les
scissions des modules à cheval suivent.

157 tests unitaires, typecheck src/test/e2e vert.
2026-08-04 12:46:44 +02:00
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
Sylvain Duchesne 6f0d0586e2 docs(brief): passer le brief caps en anglais et solder trois incohérences
Dernier document du dossier docs/ encore en français. Traduction fidèle : mêmes
sections, mêmes tableaux, mêmes items, mêmes balises. Glossaire repris tel quel
du passage précédent pour que le vocabulaire soit cohérent d'un fichier à
l'autre.

Les DEUX passages délibérément rétractés sont préservés à l'identique — la
section barrée « Widened scope: the WriteCap (= membership) » avec son bloc
<details>, et le lot barré PW dans la liste des phases. Ils sont là comme
garde-fous : sans eux, quelqu'un re-proposera ces idées, qui paraissent toutes
raisonnables au premier abord. C'était le risque de la traduction — nettoyer ce
qui ressemble à du bruit — d'où la vérification chiffrée demandée.

Trois incohérences relevées à la relecture et corrigées :

- La liste des phases décrivait encore l'ANCIENNE P1a (types de marque
  DocRef/DocCap, resolveCapLess, sealCapTo durable) et renvoyait à « Specified
  above », qui ne pointait plus vers rien depuis l'extraction du lot dans sa
  propre fiche. Créée par ma restructuration : j'avais remplacé la section sans
  toucher à son résumé ailleurs.
- « Open questions » demandait encore si le fetch keyless devait être permis,
  alors que le verdict corrigé plus bas tranche la question négativement.
  Conservée barrée : l'hypothèse est intuitive et se reformerait sinon.
- La revue adverse annonçait 6 constats et en listait 7.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014GbGgNEHRejVKoREvFuDFg
2026-07-28 15:59:37 +02:00
Sylvain Duchesne 0d52c82ba9 docs: passer vision, readcap-and-nuri-model et l'incident en anglais
Le reste du dossier docs/ était déjà en anglais ; ces trois fichiers avaient été
rédigés en français par erreur. Traduction fidèle, sans changement de fond :
mêmes sections, mêmes tableaux, mêmes blocs de code. Le retour à la ligne dur à
78 colonnes est levé (une ligne par paragraphe, convention du projet).

Marqueurs épistémiques préservés et rendus aussi visibles : VERIFIED / INFERRED /
CORRECTED / DIRECTION / GAP. Les citations verbatim de commentaires amont restent
intactes.

Deux incohérences de FOND signalées par la traduction et corrigées ici — elles
étaient invisibles tant qu'on lisait chaque section isolément :

- readcap-and-nuri-model, section « Caveats / gaps » : elle listait encore le
  fetch keyless comme hypothèse INFÉRÉE à confirmer, alors que le bloc CORRIGÉ du
  §4bis la déclare fausse et non constructible. Contradiction interne née de ma
  correction partielle. Conservée barrée plutôt que supprimée : l'hypothèse est
  intuitive et se reformera sinon.
- incident write-loss : l'intro affirmait en fait établi que « l'écriture
  n'atteint jamais durablement le broker », alors que la réserve épistémique plus
  bas dit explicitement que l'alternative (perte d'écriture vs réhydratation à
  froid) n'est pas tranchée. L'intro ne rapporte plus que le symptôme observé.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014GbGgNEHRejVKoREvFuDFg
2026-07-28 15:51:22 +02:00
Sylvain Duchesne 518292498a docs(brief): extraire P1a dans son propre brief, en anglais
P1a n'était qu'une section d'un brief de ~370 lignes charriant beaucoup de
matière rétractée (lot PW barré, section membership en <details>, verdicts
corrigés). Coder depuis ce fichier aurait été pénible et risqué.

docs/briefs/2026-07-27-p1a-cap-surface.md — le lot actionnable, lisible seul :
- un seul type nouveau, ReadCap, le nom de l'amont ;
- capFor(nuri) sur le trousseau (la branche de store), avec l'avertissement que
  le trousseau n'est PAS le mécanisme de partage ;
- shareCap(cap, toInbox) — un document, vers une ou plusieurs inboxes ;
- rotation de clé : re-livraison automatique, rien à implémenter côté consommateur ;
- contenu public : lisible par l'URL, non récursif ;
- la frontière index.ts / polyfill, tranchée : signatures sur des chaînes, comme
  le vrai SDK ;
- le test de recette sans crypto (watch-shape moissonne aujourd'hui toute chaîne
  did🆖 et la replie dans l'ensemble LU) ;
- et ce que le lot ne fait PAS, pour ne pas le croire fini.

Chaque écart écarté y est justifié plutôt que tu : types de marque, resolveCapLess,
receivedCaps, refOf, parseNuri, PrincipalId. Le premier jet introduisait 8 notions
nouvelles ; il en reste 2, et le critère est écrit noir sur blanc — toute notion
inventée est une dette de vocabulaire.

Le brief d'origine reste le chantier d'ensemble et pointe vers la fiche.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014GbGgNEHRejVKoREvFuDFg
2026-07-28 15:44:24 +02:00
Sylvain Duchesne b2cb774124 docs: état courant NextGraph enrichi + modèle cible aligné + retrait du lot PW
MODÈLE CIBLE (readcap-and-nuri-model) — trois ajouts, deux corrections :
- Store public : lisible par l'URL, et NON récursif — un contenu public peut
  référencer du contenu privé sans y donner accès. C'est la non-récursivité qui
  porte la valeur (objet public pointant vers de l'identité privée).
- Le trousseau : la branche de store, où chaque création commite AddRepo{read_cap}
  — avec l'avertissement explicite que ce n'est PAS le mécanisme de partage.
  Confondre l'index privé et le geste de partage mène à « on partage le store »,
  ce qui livrerait tout son contenu présent et futur.
- Rotation de clé : re-livraison par inbox, traitée automatiquement à la
  connexion. Écrit comme DIRECTION, en signalant que le commentaire amont dont ça
  partait décrit l'état courant.
- Levée de la confusion did/NURI en tête de la section grammaire : `did🆖` est
  un préfixe de schéma présent partout, pas un marqueur de « sans cap ». C'est un
  seul objet, avec ou sans la clé dedans.
- Livraison de cap par inbox signalée comme MANQUE (forme bonne, chemin absent).

ÉTAT COURANT (nextgraph-current-state) — 218 lignes ajoutées, structure intacte :
livraison de cap par inbox non implémentée ; vérification de signature d'auteur
jamais appelée au runtime (members map vide, //TODO) ; aucune sonde d'existence
au niveau SDK ; expose_outer codé en dur à false, absent du SDK ; protocole Ext
sans aucun contrôle. Plus trois constats d'exploitation : heal cold-start,
fork de compte sur provision concurrente, et l'abort du flush outbox sur
TopicNotFound. La mort du socket est seulement référencée (déjà couverte).

CORRECTION D'UN FAIT QUE J'AVAIS ÉNONCÉ FAUX : le digest d'auteur n'est PAS clé
sous le secret de lecture — il est clé par l'overlay outer, public. C'est le
CONTENU du commit qui est chiffré. La conclusion « vérifier suppose de pouvoir
lire » tient, le mécanisme diffère.

Lot PW (WriteCap = membership) RETIRÉ de la liste des phases : il restait planifié
alors que le brief déclare plus haut qu'il n'y a pas de membership. Il était en
outre justifié par un besoin de dédup par signature que le consommateur n'a pas —
sa dédup s'appuie sur l'overlay.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014GbGgNEHRejVKoREvFuDFg
2026-07-27 17:42:14 +02:00
Sylvain Duchesne 8764daff4f docs: corriger la rotation de clé et poser le principe du store public
Deux corrections de direction, données par le PO — et les deux viennent de la
même erreur de méthode : avoir lu l'ÉTAT COURANT du source comme s'il donnait
l'INTENTION. C'est précisément ce que ce brief met en garde de faire.

1. Rotation de clé. La spec disait « qui n'est pas resté abonné perd l'accès »
   et demandait d'exposer une obligation d'abonnement au consommateur. Faux
   comme cible : quand une clé tourne, la nouvelle est envoyée dans l'inbox des
   ayants droit, et cette inbox est traitée automatiquement à la connexion
   suivante d'un client. L'accès n'est pas perdu, il est différé — cohérent avec
   le local-first. Donc rien à implémenter côté consommateur, et la re-livraison
   emprunte le même canal que la livraison initiale : le mécanisme de partage
   couvre les deux sans cas particulier.

2. Store public. Principe à exposer tel quel : un élément du store public est
   public — qui a l'URL lit le contenu — mais PAS récursivement. Un contenu
   public peut référencer du contenu privé sans donner accès au référencé. C'est
   la non-récursivité qui porte la valeur : elle permet un objet public pointant
   vers de l'identité privée, le cas exact du consommateur. NextGraph s'oriente
   par ailleurs vers un non-chiffrement du contenu public (données toujours
   signées) : détail d'implémentation dont la surface ne doit pas dépendre. Si le
   store public ne se comporte pas comme le principe le décrit, c'est le polyfill
   qui s'adapte, pas le consommateur.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014GbGgNEHRejVKoREvFuDFg
2026-07-27 17:05:36 +02:00
Sylvain Duchesne 60a9fd3ede docs: réécrire P1a après double revue adverse, corriger le verdict Q1 sur-lu
Deux adversaires à mandats disjoints (alignement NextGraph / économie
conceptuelle). Résultat : P1a fond de 8 notions nouvelles à 2, et un fait que
j'avais consigné comme VÉRIFIÉ était sur-lu.

CORRECTION DE FOND — le fetch keyless n'est PAS constructible. Le spike P0
concluait « Q1 OUI partiel, seul garde : l'overlay ». Il s'arrêtait au contrôle
d'accès sans regarder l'ADRESSAGE : aucune commande d'existence au niveau SDK ;
la seule sonde est interne au crate, exige des BlockId ET un repo chargé, et vise
l'overlay inner dérivé du secret de lecture. Une référence cap-less porte un
RepoId et l'overlay outer — ni BlockId, ni le bon overlay. L'adressage
présuppose le cap. Note corrigée sur place (pas empilée), avec la leçon
transposable : vérifier qu'une garde laisse passer ne prouve pas qu'une
opération est atteignable — encore faut-il pouvoir NOMMER ce qu'on demande.

P1a réécrite :
- UN seul type nouveau, `ReadCap`, le nom de l'amont. `Nuri` reste ce qu'il est
  déjà (~90 usages) : la forme cap-less. Les types de marque disparaissent — le
  SDK réel prend `nuri: String` et enforce au RUNTIME par la crypto ; une
  garantie de compilation est un concept que NextGraph n'a pas, et un
  consommateur qui typerait tout devrait dé-typer plus tard.
- `capFor(nuri) → ReadCap | undefined` : le trousseau. Comble un trou fatal du
  premier jet — `doc_create` renvoie un NURI cap-less, donc l'invariant « on ne
  va jamais d'une référence nue à un cap » empêchait le créateur d'obtenir le cap
  de son propre document. Le trousseau existe déjà : la branche de store, où
  chaque création commite AddRepo{read_cap}. En amont c'est le wallet.
- `shareCap(cap, toInbox)` : on partage UN DOCUMENT, à une ou plusieurs inboxes.
  Pas le store — donner un cap de store livrerait tout son contenu présent et
  futur. Les caps reçus arrivent comme dépôts d'inbox, consommés par le
  inbox.watch existant (ce qui règle le point 7 de la revue adverse).
- Durabilité : ne PAS la promettre. Verbatim amont, les caps sont « not durable »
  et qui ne reste pas abonné perd l'accès ; PermaCap est un TODO. La surface doit
  exposer l'obligation d'abonnement, sinon le consommateur retient des caps morts.
- `PrincipalId` sort de la surface caps : n'existe pas en amont, et le brief le
  supprimait de canRead en le qualifiant d'inversion ACL avant de le réintroduire
  dans sealCapTo. On adresse des inboxes, comme inbox.post le fait déjà.
- Section 0 conservant les erreurs du premier jet : elles sont instructives.
- Exception publique actée : un lien de repo public n'a PAS de read_cap (il se
  télécharge depuis l'outer overlay) — pour du public, « référence nue → contenu »
  est bien la forme cible.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014GbGgNEHRejVKoREvFuDFg
2026-07-27 16:08:02 +02:00
Sylvain Duchesne d7e0ee6a4b docs(brief): spécifier P1a (la surface) et corriger l'inventaire des contournements
Découpage de P1 en deux natures de travail : P1a = la FORME exposée aux
consommateurs, P1b = l'ENFORCEMENT. Seul P1a bloque Festipod, puisque l'app doit
être écrite comme si NextGraph était fini. Après P1a la forme est juste et
l'isolation reste fausse — le brief le dit explicitement pour qu'on n'affirme
rien d'anonyme avant P1b.

P1a spécifié :
- Types DocRef / DocCap distincts À LA COMPILATION (aujourd'hui `Nuri = string`,
  aucun parseur, aucune notion de segment de clé). L'invariant central : AUCUNE
  fonction ne va de DocRef vers DocCap — on n'obtient pas un cap en le demandant,
  seulement en le recevant. Le compilateur refuse alors de lire depuis un
  identifiant nu, et le consommateur ne PEUT PLUS écrire le modèle mental faux.
- resolveCapLess(ref) → { exists }, jamais de contenu et jamais d'état `deleted`
  (vérifié : la cible ne pourra pas l'offrir).
- sealCapTo(cap, recipient) durable + receivedCaps(), qui remplacent grantRead.
  Trois deltas réels vs l'ACL : durabilité, livraison-chez-le-destinataire,
  re-partage par le détenteur. C'est ce qui fait disparaître declareConnections.
- Table de ce qui disparaît : le paramètre `principal` de canRead EST l'inversion
  ACL ; resetCaps doit BASCULER de trousseau, pas effacer, sinon la durabilité
  est un mensonge.
- Test de recette naturel : watch-shape moissonne aujourd'hui toute chaîne
  `did🆖` et la replie dans l'ensemble LU — sémantique exactement inversée.
  Avec les types, elle ne peut plus qu'être résolue en existence. Vérifiable sans
  une ligne de crypto.

Corrigé aussi : l'inventaire des contournements était écrit beaucoup trop
doucement. Cartographie vérifiée — seuls 4 sites consultent les caps ; l'inbox
entière, store-registry (racine de confiance compte→NURI), discovery.readIndex,
subscribe et open-repo rendent de la donnée sans garde. Et le garde d'ÉCRITURE
est déjà mort-né : docs contourne ng-proxy par conception et tous les écrivains
internes passent par docs — grantWrite/canWrite ne se déclenchent jamais.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014GbGgNEHRejVKoREvFuDFg
2026-07-27 14:48:48 +02:00
Sylvain Duchesne ead5aececf docs: l'overlay est store-scopé ; retrait de la fausse piste "membership"
readcap-and-nuri-model — nouvelle section sur l'OVERLAY, le concept qui
manquait à la référence. C'est l'espace réseau d'un STORE : deux formes
(outer = BLAKE3 public du store_id, calculable par tous ; inner = BLAKE3 keyed
par le ReadCapSecret, réservé aux détenteurs de la clé). Le `✌️` d'un NURI de
DOCUMENT porte l'overlay de son store — VÉRIFIÉ de bout en bout, avec une
contre-preuve mécanique : dans Store, get/put/del/has passent tous
`&self.overlay_id`, donc tous les documents d'un store partagent le namespace
de blocs et un overlay par-document est structurellement impossible.

Conséquence documentée, qui contraint tout modèle de présence anonyme : le
`✌️` est un pseudonyme stable et permanent de la personne, présent dans toute
référence cap-less vers n'importe lequel de ses documents protected. Le même
bit d'information sert à dédupliquer sans lire ET à tracer — indissociables.

vision — correction d'une forme fausse. Le document affirmait « écriture =
membership/permissions », en miroir de « lecture = possession de clé ». Faux :
il n'y a pas de notion d'appartenance dans le modèle, uniquement des clés et
des URLs. Toute forme en member/role/permission est une MAUVAISE forme.

brief caps — la section « périmètre élargi : WriteCap = membership » est
retirée, conservée barrée comme garde-fou, avec la leçon de méthode qui vaut
plus qu'elle : lire l'état courant de nextgraph-rs pour en DÉDUIRE la forme
cible est une erreur — le source contient de l'échafaudage inerte (AddMember,
PermissionV0, verify_sig jamais appelé hors tests). Le source sert à vérifier
un mécanisme, jamais à inférer une intention.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014GbGgNEHRejVKoREvFuDFg
2026-07-27 12:07:24 +02:00
Sylvain Duchesne f2c5b30527 test(e2e): déterminer si doc_subscribe pousse réellement (SELF et CROSS)
Couverture manquante de la couche réactive — c'est son absence qui a laissé
passer un bug de réactivité. Le runner exerce les DEUX poussées porteuses en
production contre le VRAI broker, par la même surface publique que l'app
(`subscribeDoc` → `ng.doc_subscribe`) :
  SELF  — l'écriture d'une session vers un doc qu'elle-même souscrit ;
  CROSS — une seconde session (même wallet partagé) écrit sur ce doc.

Chaque push est enregistré comme événement typé ({typeKey, elapsedMs}) : le
verdict est le fait que le callback re-tire, pas une relecture du document.
Chaque attente est une promesse événementielle unique + timeout (pas de boucle
de relecture) — un timeout est donc un « n'a PAS tiré » définitif.

Verdict obtenu : doc_subscribe POUSSE bien dans les deux cas — le défaut de
réactivité observé côté app n'est donc pas ce primitif.

Standalone (pas `bun test`) : `bun run test:e2e:reactivity`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014GbGgNEHRejVKoREvFuDFg
2026-07-27 11:30:50 +02:00
Sylvain Duchesne 1791c31f42 docs: vision du polyfill (shape-fidelity), modèle ReadCap/NURI réel, brief caps
vision.md — la charte : le polyfill n'est PAS une couche de sécurité (wallet
partagé + pas de crypto = insécurité ACCEPTÉE) ; seul objectif = exposer la
BONNE FORME des primitives futures pour que les consommateurs n'aient rien à
réécrire. Invariant tenu par une simulation crypto légère : un `did` nu (sans
ReadCap) ne permet PAS de lire ; un NURI avec ReadCap est suffisant et requis.

readcap-and-nuri-model.md — le vrai modèle, VÉRIFIÉ par lecture de
`nextgraph-rs` : ReadCap = ObjectRef {id BLAKE3, clé ChaCha20} = possession de
clé, PAS une ACL ; grant = sceller la clé à l'inbox du destinataire ;
révocation = re-key grossier et non-rétroactif ; grammaire NURI cap-less vs
cap-porteur (le segment `:k:` est le discriminant) ; table des divergences avec
l'émulation `caps.ts` (aujourd'hui une ACL — l'inversion exacte).

briefs/2026-07-20-caps-emulation-alignment.md — le chantier d'alignement :
spike P0 keyless-resolve (verdicts vérifiés : existence sans clé OUI,
détection de suppression sans clé NON, confidentialité OUI), puis P1 cap-less
vs cap-porteur, P2 possession, P3 re-key, P4 migration d'API. Inclut la revue
adverse (WriteCap = membership et non possession ; sans crypto la privacy de
lecture n'est pas applicable ; migration = re-architecture consommateur).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014GbGgNEHRejVKoREvFuDFg
2026-07-27 11:30:33 +02:00
Sylvain Duchesne 127ca3159e docs: known issues (perte écriture, réhydratation à froid, écho auto-écriture) + gap 4 sdk-reference
Section 'Known open issues' dans nextgraph-current-state (A ouvert, B indéterminé, C hypothèse-en-cours) + gap 4 (auto-écho non confirmé) dans sdk-reference. Statuts préservés.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014GbGgNEHRejVKoREvFuDFg
2026-07-20 13:16:51 +02:00
Sylvain Duchesne 138d37c02f docs(incident): perte d'écriture sur mort de socket (SerializationError)
Post-mortem 2026-07-14 (ouvert) : symptôme + preuves Firefox verbatim, chaîne causale tracée (socket→Disconnected→reconnexion en TODO), réserve (i) perte-écriture vs (ii) réhydratation à froid, repro @data décisive (test de reconnexion existant faux-vert = lecture IndexedDB locale).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014GbGgNEHRejVKoREvFuDFg
2026-07-14 18:58:38 +02:00
Sylvain Duchesne cf9500f0cf feat(inbox): logs du cycle de l'inbox virtuelle (dépôt/lecture/watch)
Réutilise le format identité-first [<id>][polyfill] d'access-log : deposit/read/materialize/readSynced + watch (materializing vs unchanged-skip). Gated par debugAccessLog, aucun changement de comportement. tsc 0, bun test 126.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014GbGgNEHRejVKoREvFuDFg
2026-07-14 18:58:38 +02:00
172 changed files with 29106 additions and 4287 deletions
+4 -2
View File
@@ -5,6 +5,8 @@ dist/
bun.lockb bun.lockb
# SDK e2e real-broker harness — dedicated wallet profile + bundle (never committed) # SDK e2e real-broker harness — dedicated wallet profile + bundle (never committed)
packages/client/e2e/.playwright-profile-lib/ packages/polyfill/e2e/.dist/
packages/client/e2e/.dist/
*.ngw *.ngw
# Per-developer contract access map — canonical identities are committed, local paths are not
.project/contracts.local.yaml
+18
View File
@@ -0,0 +1,18 @@
<!-- generated — edit .project/concepts/*/_overview.md vocabulary blocks, then run lint --fix; do not edit this file -->
## Project vocabulary — canonical terms: use VERBATIM in any language, marked `like this`
```text
«app-contract»
`reference` a NURI that names a document and grants nothing — what an application circulates (never: link, lien, share-link · see: contract_polyfill-surface)
`ReadCap` upstream's word for what opens a document — a reference carrying its secret (never: token, credential, permission)
`polyfill-era` a published symbol with no counterpart in the target SDK, deleted at migration (never: transitional, shim-only · see: knowledge_what-an-app-deletes-at-migration)
«e2e-harness»
`journey` one named end-to-end story in the applicative suite, reported pass or fail as a whole (never: scenario, test case, spec)
`batch` one run of a suite, which mints its own physical user and discards the previous one (never: session, campaign)
«sign-in»
`settle` decide which identity is acting, from the URL, storage, or the barrier — needs no session (never: resolve identity, determine user, login · see: knowledge_settling-is-not-connecting)
`barrier` the full-screen overlay this package mounts to hand out the wallet and take an identifier (never: modal, popup, login screen)
```
@@ -0,0 +1,43 @@
---
type: overview
summary: What an application may rely on from @ng-eventually/polyfill, and what it will have to delete
triggers:
keywords: [polyfill, sdk, surface, contract, publish, published, entry, export, migration, unlearn, consumer, app-facing, inbox, deposit, IRI, urn, namespace, vocabulary, ontology, predicate]
paths:
- "packages/polyfill/src/index.ts"
- "packages/polyfill/src/surface/**"
- "packages/polyfill/src/emulated-verifier/machinery.ts"
- "packages/polyfill/src/emulated-verifier/index-deposit.ts"
- "packages/polyfill/README.md"
- "examples/notebook/**"
- "docs/api-contract.md"
vocabulary:
- term: reference
gloss: a NURI that names a document and grants nothing — what an application circulates
not: [link, lien, share-link]
see: contract_polyfill-surface
- term: ReadCap
gloss: upstream's word for what opens a document — a reference carrying its secret
not: [token, credential, permission]
- term: polyfill-era
gloss: a published symbol with no counterpart in the target SDK, deleted at migration
not: [transitional, shim-only]
see: knowledge_what-an-app-deletes-at-migration
---
# app-contract — the boundary between this library and the applications that use it
This library exists so an application can be **written today against the NextGraph that does not ship yet**, and keep its code when it does. Everything under this concept governs that boundary: what the package publishes, what a caller may rely on, what it must not, and what disappears at migration.
The distinguishing question, asked at every choice — published signature as much as internal mechanism: **does the target do this?** If it does it differently, we do it their way, whatever that costs. *Would a caller have to unlearn it?* comes second, and measures how bad a divergence is — never whether one is allowed. See `rule_no-divergence-from-nextgraph`.
This repo is the **provider** of `polyfill-surface`; consuming applications live in other repos, pull the engagement, and declare back what they actually consume — one `usage_<consumer>.md` beside the contract, in the interface folder. The per-symbol ruling, with an epistemic label on every target-side claim, stays here in `docs/api-contract.md` — that is maintainer material, not the engagement.
## Read first
- `polyfill-surface/contract_polyfill-surface` — the engagement itself, written from the caller's point of view.
- `rule_no-divergence-from-nextgraph` — the test that decides what may be built at all.
- `knowledge_what-an-app-deletes-at-migration` — the two fates a published symbol can have.
- `knowledge_what-an-iri-says-upstream` — the same two fates for a written *triple*: NextGraph's own vocabulary, ours, and which of our IRIs must outlive the migration.
- `knowledge_what-an-inbox-is-upstream` — what the target actually offers around inboxes, and where a choice of ours begins.
- `knowledge_who-processes-an-inbox` — the division of labour along a deposit's path: what this package applies, what it merely delivers, and what a layer above owes itself.
@@ -0,0 +1,28 @@
---
type: bug
severity: major
summary: An inbox watch that failed to open is re-opened only by a narrow set of later events, so a session whose activity does not produce one stays unwatched and never converges
opened: 2026-08-17
last_checked: 2026-08-17
---
# A lost inbox watch is never retried
While an identity is connected, every inbox it may read is watched, and a deposit is applied as it lands. When opening one of those watches **fails** — a broker hiccup at sign-in is enough — the failure is reported and the watch is dropped. It is then re-opened only by a later event, and the list of events that qualify is narrower than it looks:
- creating a document,
- opening an inbox on one,
- a `ReadCap` arriving through an inbox **still** being watched,
- the first read of a document in a public store.
**An ordinary write does not qualify.** A `sparqlUpdate` on a document the identity already owns fires none of these. So it is not only a read-only session that stays blind — it is any session whose activity stays inside what it already holds.
There is deliberately no polling: the deferred inbox processing states that as a design choice, and a timer here would be the wrong repair. What is missing is a recovery signal that does not depend on the identity performing a capability-bearing act.
**The visible consequence.** A document is shared with the affected identity while it sits connected, and nothing arrives. No error is raised, and the application sees an empty result rather than a failure — the same shape as having been given nothing at all.
**Workaround today:** a fresh `ensureIdentity()` re-enters and re-establishes the watches.
**Verifying it:** drive an identity to a state where one inbox watch fails to open, let the broker recover, deposit for that identity, and observe that nothing converges while it stays connected. A reproduction existed as a scratchpad probe during the work that found this and did not survive the session; rebuilding it from this description is the honest cost of that.
Predates the continuous-observation work — the earlier behaviour processed inboxes only at connection, so the same hiccup produced the same silence by a shorter route.
@@ -0,0 +1,21 @@
---
type: bug
severity: normal
summary: When the account record cannot be resolved, the inbox enumeration reports a COMPLETE list holding zero inboxes, so a total failure is indistinguishable from an identity that legitimately has none
opened: 2026-08-17
last_checked: 2026-08-17
---
# An empty inbox list can mean a failure
Enumerating the inboxes an identity may read reports, alongside the list, whether that list is **complete** or **short** — and a shortfall is logged so a lost watch is at least visible.
That signal has a hole. When resolving the account record yields nothing rather than raising, the enumeration reports a list that is **complete and empty**. Every inbox is missing, including the identity's own, and nothing distinguishes it from an identity that genuinely has none yet — which is a real and ordinary state, since a first-visit identity connects with no account.
So the worst outcome — watching nothing at all — is reported as the most benign one. This is the family this package has closed repeatedly: **a failure wearing the shape of an absence.**
The fix is not to make the enumeration throw. Reaching the registers is infrastructure and may fail; what is missing is that *resolving nothing* and *there being nothing* are the same answer here, and they are not the same event.
**Verifying it:** make the account resolution return nothing (rather than reject) and observe that the enumeration reports a complete list with no entries, and that no shortfall is logged.
Related, and distinct: [[bug_a-lost-inbox-watch-is-never-retried]] — that one is about a watch that failed to open; this one is about never learning there was a watch to open.
@@ -0,0 +1,52 @@
---
type: bug
summary: A live read through the published `useShape` never starts against a broker deployed before `orm_start` was renamed `orm_start_graph` — it stays empty and pending forever, with one console error as the only trace
severity: major
opened: 2026-08-17
last_checked: 2026-08-17
---
# `useShape` opens no live read against the deployed broker
The published `useShape` is a passthrough to `@ng-org/orm`'s hook (`packages/polyfill/src/surface/use-shape.ts`) — this package adds the read filter and forwards. Against the broker deployed today, the subscription that hook opens **never starts**: its set stays empty, its readiness never settles, no engine update ever arrives, and the only trace is one `console.error`. An application sees "this scope holds nothing", indefinitely, which is the exact confusion the rest of the read surface is built to prevent.
The cause is upstream, in NextGraph. **Nothing in this package can work around it** — the failure is a name lookup two hops below our surface.
## The mechanism — one method name, kept in three places by hand
VERIFIED 2026-08-17 by reading `/home/sylvain/projects/nextgraph/nextgraph-rs` at `213338f6`.
A live ORM read is one call, and it is streamed: the engine pushes into a callback rather than answering once. Nothing in the transport infers that — each hop looks the method up in a **hand-maintained table** mapping method name to the index of its callback argument, and a method missing from the table takes the plain request/response branch instead.
1. `sdk/js/orm/src/connector/GraphOrmSubscription.ts` — the `OrmSubscription` constructor is the only place a live graph read begins: `ng.orm_start_graph(scope.graphs, scope.subjects, shapeType, session.session_id, this.onBackendMessage)`. Five arguments, callback last. `useShape` reaches it through `OrmSubscription.getOrCreate` (`sdk/js/orm/src/frontendAdapters/react/useShape.ts`).
2. `sdk/js/web/src/index.ts` — the application-side `ng` proxy. `streamed_api` must contain the method, or `rpc` posts the arguments verbatim to the parent window, callback included. A function is not structured-cloneable, so that post throws.
3. `sdk/js/api-web/main.ts` — the broker-side proxy, an independent copy of the same table. Same fallback into `myWorker.postMessage`, same throw.
The hops between the two tables carry no table of their own and cannot repair a mismatch: `infra/ngnet/auth/src/main.ts` forwards `{method, args, streamed, port}` unchanged, and `engine/broker/auth/src/App.svelte` re-appends a callback **only** when `streamed` is true.
**The name changed.** Both tables keyed the method as `orm_start` until commit `d5ecd0fd` (2026-01-16, "refactor discrete orm for yjs") renamed it to `orm_start_graph` in both files at once — while the ORM had been calling `orm_start_graph`. A build made before that commit therefore does not recognise the method a live read needs.
**The application side is already correct**, and that is what isolates the defect. This package pins `@ng-org/web@0.1.2-alpha.13` (`packages/polyfill/package.json`), which is the version at `213338f6` — the rename is in it. `@ng-org/api-web` is `private: true` and has never been version-bumped past `0.1.2`: it is **not published**, it ships only inside the broker build. Its table is whatever the operator last deployed, and no consumer can pin it.
**Why it fails in silence:** `orm_start_graph` is awaited inside a `try` whose `catch` is `console.error(e)`, and `resolveReady()` is called on one path only — the arrival of initial data. A rejected start leaves the readiness promise pending forever and the set permanently empty. This is the same upstream property already recorded in `docs/api-contract.md` (a failed read *is* an eternal pending upstream), reached here through a different door.
## What is affected, and what is not
- **Affected:** the published `useShape`, and only it.
- **Not affected:** `watchShape`, `subscribeDoc`, `subscribeDocs`, `readUnion`. These open one `doc_subscribe` per document, and `doc_subscribe: 2` has been in both tables continuously under a stable name. `watchShape` is built on that path, not on the ORM subscription — it is a working reactive read today.
That is why the library's own suites and the reference application do not show it: nothing in this package calls `useShape` internally.
## Not verified
The deployed broker's table was **not read**. `https://nextgraph.net/redir/` returns a page containing none of the protocol markers, so it is not the bundle that holds it, and probing further was out of mandate. What is established is the mechanism and its exact precondition — a broker built before `d5ecd0fd` — not that the running deployment sits before it. The symptom was reported by the consuming application, not reproduced here.
## What to do
- **Do not build on live `useShape`.** Use `watchShape` for a reactive read, `readUnion` for a one-shot one. This is stated in the contract's `## Non-guarantees` so a consumer meets it without reading this leaf.
- **The fix is not ours.** It is a NextGraph deployment: rebuild and redeploy the broker from a source at or after `d5ecd0fd`. Nothing to change in this repository, and nothing to open upstream from here — report it, do not patch around it.
- **To check whether it is closed:** open a live `useShape` against the deployment and watch for an initial payload. A permanently empty set with one `console.error` naming `orm_start_graph` means the table is still stale.
## The shape worth remembering
A method name duplicated across three independently-deployed artifacts, with a **silent** fallback when they disagree, is a defect that cannot be caught by any one repository's tests. Renaming it in the two tables in one commit fixed the source; it did not fix anything already running. Anything this package routes through a streamed method inherits the same exposure — that is the reason `subscribeDocs` composing per-document `doc_subscribe` is worth more than it looks.
@@ -0,0 +1,30 @@
---
type: knowledge
summary: The two fates a published symbol can have, and why the deletion list is now carried by a block and a test rather than by an import path
---
# What an application deletes at migration, and what it keeps
Every published symbol has exactly one of two fates, and knowing which is the whole point of this boundary.
**Replaced in place.** The build alias resolving `@ng-eventually/polyfill` is removed, the import resolves to the real SDK, and the application's code is unchanged. This is almost everything: `ng`, `useShape`, `watchShape`, `init`, `initNg`, `readUnion`, `subscribeDoc(s)`, `docs.*`, `inbox.*`, `storeRegistry.*`, and the types.
**Deleted.** The symbol has no counterpart at any level of the target, exists only because one shared wallet hosts every user, and goes with its call site. Today that is `configure` and its config type.
`ensureIdentity` sits between the two and is worth stating precisely: its **substance** is scaffolding — a barrier that hands out a shared wallet file and takes an identifier, a step that exists only because users share a wallet — while its **call site survives**, because an application still awaits a session before it renders. Its signature was designed for that: it takes no identifier, so the line does not change the day the wallet supplies the identity and the barrier stops appearing.
## Why the deletion list is not an import path any more
There were two entries until 2026-08-07, `.` and `./polyfill`, and the second one carried a signal worth naming: *what you import from that path is exactly what you will delete*. That was a mechanical guarantee — the compiler produced the list.
Merging them lost it. Nothing at an import line now distinguishes `configure`, which goes away, from `docs`, which is replaced. Three things carry it instead, and it is worth knowing that only the last two are enforced:
- the **`POLYFILL-ERA` block** in the package's entry module, which is the deletion list, kept short by construction;
- **`docs/api-contract.md`**, which rules on every symbol with an epistemic label (PASSTHROUGH / LEVEL-1 SHAPE / ASSUMPTION / NO COUNTERPART) and whose export inventory is pinned by a test — so it cannot go stale quietly, which a hand-kept list would;
- the **names themselves**, each built from the target's own vocabulary or carrying a marker saying why it exists only here, pinned by the same test.
The trade was deliberate and it is a real reduction in enforcement: a documentary signal where a mechanical one used to be. To verify the pinning is doing its job, look for the vocabulary test beside the package's other tests — it compares the contract's inventory against the entry's real exports, in both directions, and it caught five drifted sections the day it was extended to the rulings.
## The direction of travel
The polyfill-era list only shrinks. It has gone from four published calls to one, and each removal was a symbol an application should never have had: naming its own identity, awaiting the connection, wiring a second bootstrap, reaching a machinery accessor. A symbol added to that block is a promise to delete it later — so the question at every addition is whether the application genuinely needs it, or whether the library is passing on a problem of its own.
@@ -0,0 +1,47 @@
---
type: knowledge
summary: What NextGraph actually offers around inboxes — a document may be given one, nothing above the engine exposes them, and most message variants are reserved names carrying nothing
last_checked: 2026-08-17
---
# What an inbox is upstream
Read at the source in `nextgraph-rs`. Every claim here points at a symbol rather than a line, so it stays verifiable by `git grep` after the code moves.
## A document may have an inbox, and must be given one
`Repo` upstream **is** a document — not a store. Its `inbox` field is an `Option<PrivKey>`: at most one inbox per document, and a document has none until something creates it. The creating act is an **`AddInboxCap` commit**, verified and applied like any other — see `update_inbox_cap_v0` in the verifier and the `CommitVerifier for AddInboxCap` impl.
The automatic creation inside `complete_site_store` is narrower than it looks: it gives an inbox to the site's **non-private stores** only. Reading that as *"only stores can have inboxes"* is a mistake this project has now made twice — the site path is one caller of a general capability, not the rule.
So an inbox on an ordinary document is **aligned with the target**, not an emulation of something absent.
## Nothing above the engine exposes any of it
Run the cascade for *"post, observe, or process an inbox message"* and every level above the engine is silent. The ORM has no occurrence of `inbox` at all; neither the web API nor the wasm binding exposes one — the binding's single mention is an unused import — and there is no generic `app_request` escape hatch, since every command carries its own dedicated wrapper.
Anything built here therefore aligns on the **engine's model**, because levels 2 and 3 answer nothing.
## Most message variants are reserved names, not shapes
`InboxMsgContent` declares eight variants. `ContactDetails` and `SocialQuery` carry data and have processors. **`Link`, `Patch`, `ServiceRequest`, `ExtRequest`, `RemoteQuery` and `DialogRequest` carry nothing at all** — they are bare unit variants, so they cannot hold a reference, name a servicer, or describe an operation.
That distinction decides how much they bind us, and it is why the provenance taxonomy in [[rule_no-divergence-from-nextgraph]] separates a reserved name from a declared shape.
## Processing is closed, and so is posting
`process_inbox` is `pub(crate)`, its match ends in `NotImplemented`, and its only caller is `Verifier::inbox`. There is no trait, registry or hook: **nothing outside the engine can contribute a processor** for a message type.
Posting is no better wired. `AppRequestCommandV0::InboxPost` is declared with its constructor and has **no handler arm** — the identifier appears nowhere else in the tree.
## A message waits for its recipient, and there is no delegate
An inbox's readers are `UserId`s, and messages queue until that user comes online. The only *"someone else runs it"* notion upstream is `Headless(Credentials)` — a server-side verifier holding a **user's own** credentials, which is that user running elsewhere, not a service delegated over someone's inbox.
## What follows for us
The **container** is settled by the target: a document may be given an inbox, one at most. Build on that freely.
The **content** of a deposit and **who processes it, when** have no upstream answer — the variants that would carry them are empty, and the processor set admits nothing from outside. So these are ours to choose, and a choice here is a bet that must be named as one rather than presented as alignment.
The standing choice, deliberate and provisional: **a deposit is a reference to a document**. It is enough for what is built on it today, and it is expected to evolve — most plausibly toward whatever shape the target eventually gives its own variants.
@@ -0,0 +1,69 @@
---
type: knowledge
summary: What NextGraph's own IRI vocabulary is, what this package writes instead, and why scaffolding triples and durable application data must not share a namespace
last_checked: 2026-08-21
---
# What an IRI says upstream, and what ours says
Read at the source in `nextgraph-rs` (`213338f6`). Every claim points at a symbol or a module rather than a line, so `git grep` settles any doubt after the code moves. Two questions are answered here and they are not the same one: *what does the target's vocabulary look like?* and *what does a triple this package writes promise about its own future?*
## Upstream defines exactly one namespace, and it is the engine's
`NG_ONTOLOGY = "did:ng:x:ng#"` (`engine/verifier/src/types.rs`) is the only vocabulary namespace anything upstream declares as a constant. Three terms sit beside it — `NG_ONTOLOGY_ABOUT` (`#a`), `NG_ONTOLOGY_TITLE` (`#n`), `NG_ONTOLOGY_CLASS` (`#c`) — a document's description, its title, its class.
Those three are **not the whole namespace**. The same `did:ng:x:ng#` carries engine protocol state, written as inline string literals rather than named constants: `ng:site` / `ng:protected` and their `_inbox` counterparts, which `import_contact_from_qrcode` writes into a contact document (`engine/verifier/src/request_processor.rs`), and `ng:social_query_id` / `ng:social_query_forwarder` / `ng:social_query_started` / `ng:social_query_sparql`, which the social-query machinery reads and writes (`engine/verifier/src/inbox_processor.rs`). So the namespace is engine-owned in a broader sense than "document metadata": it is where the engine keeps what it needs to keep.
Everything else that looks like a NextGraph vocabulary lives **only in test fixtures**: `did:ng:x:contact#…`, `did:ng:x:core#…` and `did:ng:x:contact:class#…` in `sdk/rust/src/tests/big_contact_schema.json`, `contact_data.sparql`, `orm_create_patches.rs`, `orm_creation.rs`. No engine code writes them. There is no registry, no parser, no stability promise behind any of them.
## `x` is not an address — vocabulary and addresses share a scheme and nothing keeps them apart
The NURI grammar knows `o v b c d a g h j k l n s t u` and the bare `did:ng:i` — the regexes in `engine/net/src/types.rs` and every constructor of `NuriV0` (`engine/net/src/app_protocol.rs`). Neither `x` nor `z` nor `ex` is among them: a vocabulary IRI is simply a string that starts like an address and matches no rule.
The fixtures make the point sharper. They also use `did:ng:k:contact:tag#…` and `did:ng:k:contact:phoneNumber#…` for controlled values — and `k` **is** in the grammar, as the key half of a `:j:…:k:…` or `:c:…:k:…` pair. The letter is already spent; the vocabulary form matches nothing anyway. Nobody arbitrated that collision because nobody had to: no registry exists to arbitrate it in.
## `did:ng:x:core#value` is not "a field's value"
It is the payload slot of a **reified value node**, and reading it as a generic field accessor gets the model backwards. The shape, verifiable in `sdk/rust/src/tests/contact_data.sparql`:
- the entity carries `did:ng:x:contact#email` → a **value node** (its own IRI);
- the value node carries `did:ng:x:core#value` → the literal;
- the value node also carries the qualifiers: `core#source` (where this value came from), `core#type` (an IRI such as `did:ng:k:contact:phoneNumber#home`), `core#hidden`, `core#selected`.
**The field name lives in the parent's predicate**, never in the value node. And the payload slot splits by RDF term kind rather than by field: `core#value` holds a literal, `core#valueDateTime` / `core#valueDate` a typed date, `core#valueIRI` an IRI. `:class#` (as in `did:ng:x:contact:class#SocialContact`) is a convention observed in exactly one vocabulary, the contact fixture.
## There is no extension convention, and nothing upstream filters or reserves
No reserved segment, no procedure to claim one, no prohibition — nowhere in the tree. What the examples do instead is either `PREFIX ex: <did:ng:z:>` (`sdk/js/orm/src/tests/shapes/shex/testShape.shex`, the shex under `sdk/js/examples/`, `sdk/rust/src/tests/video_schema.json`) or plain web ontologies — vcard, foaf, schema.org, which the engine itself uses for a contact's name and email. One example spells `did:ng:n:g:x:social:contact#` (`sdk/js/examples/react-ldo/src/.shapes/contact.shex` and the generated files beside it), an older form matching neither the fixtures nor the grammar: the scheme is **not self-consistent across the tree**.
**Nothing upstream filters or reserves a namespace.** The engine stores and returns arbitrary IRIs. The single prefix-sensitive path is in `engine/verifier/src/commits/transaction.rs`, which matches `NG_ONTOLOGY_ABOUT` / `NG_ONTOLOGY_TITLE` among a commit's inserts and removes only to push a tab-info update when a title or description changed — and only for updates whose `branch_type.is_header()`. The triples stay in the store and stay queryable; nothing is hidden from a reader. **Separation upstream is by BRANCH, not by prefix** — a header branch is a different CRDT with its own topic, which is why upstream needs no reserved prefix at all.
The `urn:` strings that appear upstream are unit-test fixtures and nothing else: `urn:g`, `urn:child:dg`, `urn:child:sg` inside `#[cfg(test)] mod tests` in `engine/verifier/src/orm/graph/utils.rs`, and `urn:test:…` under `sdk/rust/src/tests/`. They are not vocabulary and imply nothing.
## What this package writes
**`urn:ng-eventually:``MACHINERY_NS`** (`packages/polyfill/src/emulated-verifier/machinery.ts`), in two families: `urn:ng-eventually:shim:…`, the compartments `shared-wallet/account-registry.ts` uses to emulate a repo's branches inside one document, and `urn:ng-eventually:inbox:…`, the deposit envelope and the deposit kinds `emulated-verifier/inbox-dispatch.ts` dispatches on.
**Triples under it are dropped on read.** `isMachinerySubject` is called from `readUnion` (`packages/polyfill/src/surface/read-model.ts`), which skips every row whose **subject** starts with the prefix before the caller ever sees it. By subject only — a machinery predicate on an ordinary subject comes straight through.
**That filtering has no upstream counterpart: it is an invention, and the source annotates it as one** (`kind=invention level=none`). It exists because this package has no branches, so a compartment upstream keeps in a separate CRDT has to live as a reserved subject in the same graph. Do not read our reservation as mirroring anything upstream — nothing there is filtered out of a document's own graph.
**The index arm writes another package's namespace.** `urn:ng-helpers:index:field` and `urn:ng-helpers:index:value` (`packages/polyfill/src/emulated-verifier/index-deposit.ts`) are the IRIs `@ng-helpers/indexing` already writes, kept letter for letter because renaming them would orphan every index already written. It is odd for this package to write a namespace it does not own. Recorded here as an oddity; it is not this leaf's to fix.
## Two kinds of triple, two fates — the distinction this leaf exists for
**Scaffolding** is what this package writes to emulate what the engine will do: the branch compartments, the deposit envelope. It is transitory. It disappears at migration together with the read filter that hides it, and being invisible to a caller is exactly right for it. `urn:ng-eventually:` is its home.
**Durable application data** is what an application means: an index entry written into a public document, read by third parties, which must **survive** migration. It is deliberately *not* under `urn:ng-eventually:` — which is precisely why the index IRIs pass the read filter and reach the caller.
Naming them alike would say the wrong thing about both: it would promise the scaffolding a future it does not have, and it would put the durable data one widened filter away from disappearing. The test at every new IRI: **does this triple die at migration, or does it have to outlive it?** Scaffolding goes under `urn:ng-eventually:`; anything an application or a third party reads must not. This is the triple-level twin of [[knowledge_what-an-app-deletes-at-migration]] — there, the two fates of a published symbol; here, the two fates of a written triple.
## What is settled, and what is a bet
**Settled, read at the source.** Nothing upstream reserves, filters or validates a namespace; the engine stores and returns arbitrary IRIs; there is no extension convention to comply with and no procedure for claiming a segment. **Nothing upstream binds our choice, and nothing upstream breaks if we keep a `urn:` scheme.**
**A bet.** That upstream will not later claim `urn:` or begin separating by prefix. Nothing says it will; nothing says it will not — and *the absence of an implementation says nothing about what the target will do* ([[rule_no-divergence-from-nextgraph]]). The exposure is small and it falls on the scaffolding side, which is the half designed to disappear.
**A bet, and the more expensive one.** Writing `urn:ng-helpers:` from here. Two packages now share one data format with no owner and no agreement about who may change it.
**Not a bet — a divergence, if it were ever done.** Putting our own terms under `did:ng:x:…`. That segment is the target's vocabulary space, `did:ng:x:ng#` is the engine's own, and `x` means nothing to the address grammar — so borrowing it would dress an invention in the target's vocabulary, which [[rule_no-divergence-from-nextgraph]] names as the most dangerous kind of all.
@@ -0,0 +1,54 @@
---
type: knowledge
summary: Who does what along a deposit's path — the application deposits, this package DISPATCHES on what a deposit declares itself to be and applies it arm by arm, and upstream none of it is reachable yet
last_checked: 2026-08-21
---
# Who processes an inbox
[[knowledge_what-an-inbox-is-upstream]] settles what the target *offers*: a document may be given an inbox, nothing above the engine exposes one, and the processor set admits nothing from outside. It says nothing about who does the work **here**. That is this leaf.
The question is hard to answer out loud — *"does the polyfill process the inbox automatically?"* took one agent a long exchange to fail at — because **processing means a different thing at each layer**. Naming the actor first makes it easy. Everything below is read off the code; symbols are cited so `git grep` settles any doubt.
## The application
It **deposits** by naming a document (`inbox.postToDocument`) or a person (`inbox.share`), **reads its own** with `inbox.readForDocument` / `readSyncedForDocument`, and may **watch** one with `inbox.watch`.
What it never does: **process an inbox so that a `ReadCap` sent to it arrives**. `inbox.processInbox` is published, but no application has to call it — connecting drains, and every arrival afterwards is applied. It never resolves someone else's inbox address (the only published call that returns one is `storeRegistry.openDocumentInbox`, on a document it owns), and it has no way to reach, hurry or disable the deferred stand-in below.
## `@ng-eventually/polyfill`
**At connection**`emulated-verifier/connect.ts`, `connectedUser`: restore every durable register, then `processInbox` on each inbox this identity holds, then start the continuous observation. Failing to apply one queue is reported and denies nobody their session; failing to *reach* the queues rejects.
**Continuously, while the identity is connected**`emulated-verifier/inbox-observer.ts`, `startObservingInboxes`, whose only caller is `connectedUser`: one subscription per inbox, and every push runs `processInbox` on it. *Which* inboxes exist is itself subscribed to, on two channels that do not overlap: the **register** (the User branch of the private store, where `openDocumentInbox` appends its record — so an inbox opened mid-session is picked up) and **what this identity holds** (`CapRegistry.onChange` — which is what makes a person's *first* session watched at all, since provisioning is lazy and they connect owning nothing). Push-driven throughout; it never polls. It stops on identity change, on disconnection, and on `configure` / `resetConfig` (`stopObservingInboxes`, called only from `shared-wallet/bootstrap`). Every step re-checks its holder, so a run that outlives its identity abandons rather than file into the wrong ring.
### Applying is a DISPATCH — one arm per declared type
`emulated-verifier/inbox-dispatch.ts`, `applyDeposit`. It is a `switch` over what a deposit DECLARES itself to be (`kindOf`), and the shape is the target's own: `Verifier::process_inbox(msg, content)` is literally `match content { … }`, one arm per `InboxMsgContent` variant, ending `_ => Err(VerifierError::NotImplemented)` (`engine/verifier/src/inbox_processor.rs`).
**What an arm owes**, read off upstream's clearest arm (`InboxMsgContent::ContactDetails`): **validate** the envelope — a missing `from_inbox` is `InvalidInboxPost`, a hard failure and not a skip; **build** its addresses from the typed content; **read** the recipient's own store to see whether the thing is already there (a SPARQL `ASK`); **decide idempotently**`ContactAlreadyExists` rather than a duplicate; and only then **write**. An arm is a real program, not a branch.
The two arms this package has:
- **A `ReadCap` delivery** (`inbox-dispatch.LINK_KIND`, whose name is upstream's own word for the variant) — held for the owner's ring and filed durably with `branch-registers.addLink`, the emulated `AddLink { read_cap }`. A payload declaring itself one of these and carrying nothing that reads is REFUSED and reported, which is the envelope validation above; it used to be handed back as consumer data.
- **A bare `reference`** — a deposit whose payload IS a `did:ng:` reference and nothing else: no operation, no claim, no copy of any value. `emulated-verifier/index-deposit.ts`, `applyIndexDeposit`, resolves it and LOOKS: which document the inbox belongs to (the owner's own `AddInboxCap` pairing, `readInboxCapPairs`), what that document declares it indexes by (`urn:ng-helpers:index:field`, on the document's own NURI as subject), whether the reference is already in, what the referenced object says for that field — and only then writes ONE entry (`urn:ng-helpers:index:value`, subject = the object's NURI). Since anyone may deposit, a payload carrying an operation would be a licence to rewrite someone else's document, and a payload carrying a value would put in the index something the object does not say. **An index only ever grows**: nothing there removes an entry and nothing there can express a removal, which is what makes an empty read harmless — absent, unreadable and failed all mean *not added this time*, and a later deposit adds it. An unresolved reference is reported and costs nothing; an already-indexed object is passed over; a document declaring no field, or several, refuses rather than write something wrong — and that refusal leaves READING its entries untouched.
*(Those two IRIs are `@ng-helpers/indexing`'s namespace, kept letter for letter because renaming them is a data-format change nobody decided. This package writing another package's namespace is odd, and it is recorded as odd rather than acted on.)*
**A kind with no arm says so.** A payload declaring itself in this package's reserved `urn:ng-eventually:inbox:` namespace for a variant nothing here applies is REPORTED — upstream's `NotImplemented`, which is an answer. Until 2026-08-21 it was read, counted, and produced nothing at all: no effect and no reported failure, the "failure disguised as an absence" this package has closed repeatedly elsewhere.
**Consumer data is not a variant.** A payload that declares no kind of this package's is claimed by no arm and nothing is reported about it: it is not an unapplied message, it is not a message. That is why the RESERVED NAMESPACE is the discriminator and the mere presence of a `kind` field is not — `{ kind: "join" }` is a consumer's word for a consumer's message, and it stays one.
**What still reaches the caller.** Only a `ReadCap` is withheld: an application must never handle one. Everything else `read` found is returned unchanged, including the payloads an arm applies — a `reference` is public information, so withholding it would buy nothing. Two senses of *applied* stay apart: `read` learns a `ReadCap` **in memory, for this session**; only `processInbox` makes it **durable** and runs the dispatch. `inbox.watch` re-reads on every push, so it absorbs and files nothing.
**After a deposit whose recipient is absent.** A deposit that landed arms a deferred stand-in for the session the owner does not have. What it achieves is the only part written down outside the package: **deposits converge** — a share becomes readable without the recipient re-opening the page. *How* is deliberately not documented here. The mechanism is unpublished by design: nothing lets a caller ask for it, name another user's inbox, or turn it off, and the confidentiality of the whole shared-wallet emulation rests on its being unreachable. Its limit is stated rather than hidden — it lives in a page, so if the page goes away the deposit waits for its owner's next connection, which is the real path and not a repair.
**And it applies the registers, not the documents.** Running for an owner who is not the one holding the page, it takes the arms that write the verifier's own bookkeeping and stops there: writing application data into an absent owner's documents from a stranger's session is a different act, and this package does not take it. So a `reference` deposit is left — traced, not reported, because waiting is not failing — and the owner's own next connection applies it. Nothing is lost: see below.
**Nothing is ever consumed.** No path retires an applied deposit: an inbox here is re-readable where upstream it is a queue. That is what makes every abandon, every reported refusal and every watch that could not be opened harmless — what was not applied is still there for the next push or the next connection. It is also what makes re-applying safe: every arm is idempotent, so seeing every deposit again on every run lands on the same result.
## NextGraph itself
The **broker** routes a sealed message by inbox key (`inboxes: PubKey → RepoId`, `engine/verifier/src/verifier.rs`) and queues it while the recipient is away. The **recipient's own verifier** applies it inline as it arrives — `LocalBrokerMessage::Inbox``session.verifier.inbox(&msg, from_queue)` in `sdk/rust/src/local_broker.rs`; `from_queue` distinguishes the backlog handed over at connection from the messages that follow, and **both go through the same door**, which is why the continuous regime above is the rule and the connection drain the special case.
And **none of it is reachable from JS**: the processor set is closed, the variants that would carry a payload carry nothing, and the post command has no handler arm — all in [[knowledge_what-an-inbox-is-upstream]]. Every actor above except the broker and that verifier is therefore ours, emulating one thing: a session applying its own inbox, continuously.
@@ -0,0 +1,211 @@
---
type: contract
summary: The API @ng-eventually/polyfill exposes to an application — signatures, guaranteed behaviour, and what it does not offer
---
# contract_polyfill-surface — `@ng-eventually/polyfill`
## Scope
This package is a polyfill of NextGraph's SDK.
This package covers placement (creating and listing an application's documents by scope), reading (a document's subjects, one-shot or reactive), sharing a document with a named user, and depositing into inboxes. It does not cover user management, display names, transport, or the operation of a deployment.
### Deployment requirements
An application using this package must:
- serve a wallet file (`.ngw`) from its own bundle, and pass its URL and password to `configure` as `sharedWallet: { fileUrl, password }`;
- call `init(…)` — this package's, not the one it passed to `configure` — and then await `ensureIdentity()`, in a browser context, before rendering its interface. `ensureIdentity()` resolves once a session is open, and a session arrives only through `init`: awaited before `init` has been called, it throws and names the call to make first;
- **declare this package exactly once**, so that everything in the application resolves to one copy of it — see the single-copy rule under `## Non-guarantees`, which is the one packaging mistake this surface cannot protect you from.
**Obtaining it.** This package is not published to a public registry, and it is not distributed as built output: its published entry point is TypeScript source, so whatever builds the application is what compiles it, and a toolchain that accepts only JavaScript cannot consume it as it stands. By which channel the source reaches a given application is agreed with that application rather than fixed here; what this contract fixes is the version you pin and the rules below.
## Surface
Full typed shape: the package's `types` entry, `@ng-eventually/polyfill`. A type is published only when a published signature uses it. The load-bearing signatures:
```ts
// ── bootstrap ────────────────────────────────────────────────────────────
export function configure(c: EventuallyConfig): void;
export interface EventuallyConfig {
ng: NgLike; // the `ng` object from @ng-org/web
useShape: UseShapeLike; // `useShape` from @ng-org/orm
sharedWallet?: SharedWalletConfig; // { fileUrl, password, importUrl? }
debugAccessLog?: boolean;
init?: (...args: any[]) => any;
initNg?: (...args: any[]) => any;
}
// ── identity — one await before the application renders ──────────────────
export async function ensureIdentity(): Promise<PrincipalId>; // returns who you are
// ── addressing ───────────────────────────────────────────────────────────
export type Nuri = `did:ng:${string}`;
export type NuriLike = Nuri | string;
export type Scope = "public" | "protected" | "private";
// ── placement: where an application's documents live ─────────────────────
export const storeRegistry: { // no identity parameter — a session is one user's
createEntityDoc(scope: Scope): Promise<Nuri>;
listMyEntityDocs(scope: Scope): Promise<Nuri[]>;
resolveScopeGraph(scope: Scope): Promise<Nuri>;
resolveWriteGraph(scope: Scope): Promise<Nuri>;
openDocumentInbox(doc: NuriLike): Promise<Nuri>;
};
// ── reading ──────────────────────────────────────────────────────────────
export async function readUnion(docs: NuriLike[]): Promise<UnionSubject[]>;
export interface UnionSubject { subject: string; graph: Nuri; props: Record<string, string[]> }
export function useShape(shapeType: unknown, scope: unknown): unknown; // read-filtered view
// TWO positional arguments — the same pair `useShape` takes. `ShapeQuery` is what you
// READ BACK (the snapshot), never what you pass in.
export function watchShape<T = UnionSubject>(shapeType: unknown, scope: Scope): ShapeObservable<T>;
export interface ShapeObservable<T = UnionSubject> {
getSnapshot(): ShapeQuery<T>; // stable reference until the state changes
subscribe(onChange: () => void): () => void; // returns its own unsubscribe
refetch(): void; // re-resolve and re-read now; never polls
}
export interface ShapeQuery<T = UnionSubject> {
data: T[]; isPending: boolean; isSuccess: boolean; isError: boolean; error: unknown;
}
export function subscribeDoc(nuri: NuriLike, onChange: (r: DocChange, t: DocChangeType) => void): Unsubscribe;
export function subscribeDocs(nuris: NuriLike[], onChange: (r: DocChange, t: DocChangeType) => void): Unsubscribe;
// ── low-level document / SPARQL primitives ───────────────────────────────
export const docs: {
// `sessionId` is `string | number` — upstream's own declared type (`Session.session_id`).
// It is RELAYED, never converted: the wasm side deserializes a `u64`, and stringifying it
// fails for real (`Deserialization error of session_id JsValue("1")`).
docCreate(sessionId: string | number, crdt: string, cls: string, dest: string, store?: unknown): Promise<Nuri>;
sparqlQuery(sessionId: string | number, query: string, base?: string, anchor?: NuriLike, label?: string): Promise<unknown>;
// Returns the commits the update produced, as upstream does (it typed this `void` until
// 2026-08-14 while already relaying the value). A caller that ignores it is unaffected.
sparqlUpdate(sessionId: string | number, query: string, anchor?: NuriLike, label?: string): Promise<unknown>;
};
// ── inbox: giving to read, and depositing ────────────────────────────────
export const inbox: {
share(doc: NuriLike, toUser: string): Promise<void>; // give a reader the key
post(targetInbox: NuriLike, opts: PostOptions): Promise<void>;
postToDocument(doc: NuriLike, opts: PostOptions): Promise<void>;
read(targetInbox: NuriLike): Promise<Deposit[]>; // only your own
readForDocument(doc: NuriLike): Promise<Deposit[]>;
readSynced(targetInbox: NuriLike): Promise<Deposit[]>;
readSyncedForDocument(doc: NuriLike): Promise<Deposit[]>;
processInbox(targetInbox: NuriLike): Promise<Deposit[]>;
watch(targetInbox: NuriLike, onDeposits: (d: Deposit[]) => void): () => void;
// `materialize` (a second published name for `read`) was REMOVED on 2026-08-14 —
// an alias with no call site, and no counterpart upstream. Use `read`.
};
export interface Deposit { from: PrincipalId | null; payload: unknown; ts: number }
// ── the wrapped SDK objects ──────────────────────────────────────────────
export const ng: NG; // call this instead of the `ng` passed to `configure`
// `NG` is upstream's own type (`@ng-org/web`), 88 typed
// members; it was `Record<string, any>` until 2026-08-14
export function init(...args: any[]): any; // likewise — not the `init` passed to `configure`
export function initNg(...args: any[]): any;
```
## Guarantees
Every entry accepts `NuriLike` and validates at the door; what it returns is a precise `Nuri`. No type guard is published.
A returned reference carries no key — not `createEntityDoc`, not `listMyEntityDocs`, not `UnionSubject.subject` / `.graph`. A reference found inside a document yields a name, not a key.
You read a document whose key you hold: you created it, it was shared with you, or it sits in a public store, which serves its read key to whoever asks. No call answers "may I read this?".
What was shared with you becomes readable after `ensureIdentity()`.
`readUnion` returns one entry per distinct subject present in a document. `subject` is that subject's IRI exactly as written, and is a `string`, because a subject may be any IRI; `graph` is the document reference you passed in, and is the `Nuri` to hand back to this surface. Properties of different subjects are never merged, and the same subject IRI found in two documents stays two entries, told apart by `graph`. Several objects in one document are allowed. Recommended placement is one document per business entity: access is granted per document.
`urn:ng-eventually:` is reserved, and the reservation has two faces. In a document: triples whose **subject** falls under that prefix are dropped on read and never returned by `readUnion`; every other IRI is returned. In a deposit: two payload shapes are this package's own and it acts on them — a payload that IS a reference (a bare `did:ng:…` string and nothing else), and an object whose `kind` property is a string starting with `urn:ng-eventually:inbox:`. **Every other payload is yours, and is delivered to you untouched.**
**Do not use either shape to carry your own data.** Depositing one is still accepted and it is still returned to you by `inbox.read` and its variants — nothing is withheld — but this package now also acts on it, and what you observe is one of two things: it writes an entry into the document the inbox belongs to, if that document is one it can index; otherwise it reports a refusal on its own log stream (`console.error`, ungated, as below). That refusal does not clear on its own the way an unreachable inbox does: a deposit is not consumed, so the same refusal is reported again at every arrival on that inbox and at every connection, for as long as the deposit sits there. Carry your own data in an object with a `kind` of your own — `{ kind: "join", … }` is yours and stays yours — and never as a bare reference on its own.
Only a document's owner writes to it. Holding its read key never grants a write.
`inbox.share(doc, toUser)` names the document and the person; the recipient calls nothing. It refuses a recipient nobody has signed in as, rather than creating them.
`inbox.post` refuses a target that is not an inbox; to reach a document's owner, use `inbox.postToDocument(doc, …)`. Anyone may deposit into an inbox; only its owner reads it.
**You never resolve an inbox address, on either side.** You deposit by naming a document (`inbox.postToDocument`), and you read what was left on your own by naming it too — `inbox.readForDocument(doc)` at any time, or `inbox.readSyncedForDocument(doc)` on a page that has just loaded. The second is the one to call when an empty answer has to MEAN empty: a session that has just loaded has synced neither the document nor its inbox, and an unsynced read of either comes back empty with no error — so the ungated form can answer `[]` for a document whose inbox holds messages. `readSyncedForDocument` waits for both before answering. `inbox.readSynced` is the same guarantee on an inbox you already hold the address of, which no application does: it takes an address, so nothing here hands you one.
`ensureIdentity()` settles the identity, completes the connection work it starts, and returns the identity. It takes no identifier, and no other call takes one.
It resolves **only once that work has actually completed**: if what was shared with you could not be restored, it throws instead of returning, and a rejected call must not be rendered past — the interface would show an empty account rather than an empty screen. A single queue that could not be drained is reported and does not reject: reaching your queues is infrastructure and must succeed, applying one deposit is data and must never cost you the session. So a resolved call means your own capabilities are restored; it does not promise that every deposit waiting for you has been applied, and those that were not stay in their queue.
`ensureIdentity()` mounts a full-screen barrier on every top-level load, and takes it down itself — past the broker round-trip it stays down, provided the identifier reached the other side. A person who comes back to the page from that round-trip finds the barrier live again, prefilled, and confirming it hands the page over a second time. The application's own page is never reloaded and nothing outside the barrier is touched.
**What decides which identity you get.** No call takes an identifier — not `configure`, not `init`, not `ensureIdentity` — so an application never chooses one, never keeps one, and never hands one over. It is settled once per page load, from the page itself, and the answer depends on which side of the broker round-trip is asking:
- **Before the round-trip, on your own top-level page — the barrier decides.** Whatever is already known fills the field, and the person may change it; the identity is the value they confirm. Confirming publishes that value into the address bar as `?ng-id=` and records it in this browser.
- **After the round-trip, on the page the broker loads back — `?ng-id=` decides**, and the barrier stays down. The parameter also wins over anything the browser remembers, on either side: it is read first, and reading it replaces what was remembered. Arrive with neither — a URL that dropped the parameter, and a partition that remembers nothing — and the barrier asks on that side too.
- **Failing both — whatever this browsing context last recorded.** This is the only path on which an identity is adopted with nobody confirming it. If the address bar lost `?ng-id=` while a *different* identifier was on record here, that different identity is adopted, and nothing is raised anywhere; if nothing was on record either, the barrier asks again on that side too.
**`?ng-id=` exists because it is the only thing that crosses.** Your page before the round-trip and your page after it sit in two separate storage partitions — nothing the browser remembers on one side is visible on the other, and the address bar is the sole channel between them. The package writes the parameter itself, without navigating. **An application that owns its URL must let it survive**: a router that drops query parameters it does not know, or a redirect that rebuilds the URL, sends the round-trip off without the identifier, and the consequence is the silent mis-identification above rather than an error. In a context that can neither write the address bar nor use storage, the barrier simply asks on both sides.
**Being remembered is a prefill, never a decision.** A top-level reload asks again every time, with the field already filled — one click, no typing. A remembered identifier is therefore not a signed-in state, and two tabs, two browsers or two devices do not share one: each keeps its own record, and only a URL carrying `?ng-id=` puts a second context under the same identity.
**The session is the package's, not yours.** You never build one, and no call takes one. Call this package's `init` (not the one you passed to `configure`): it captures the session the SDK delivers to `init`'s callback and keeps it, then calls your callback with that same event untouched — so an application that wants the `session_id` for the `docs` primitives reads it there, and one that does not may pass no callback at all. Identity normalisation is the package's too: `@Alice`, `alice ` and `ALICE` are one person.
Where a call must first find out whether something already exists — a document's record in its store, a user's inbox — it throws when it could not find out, instead of proceeding as though the answer were "nothing". So `createEntityDoc` throws if the document cannot be recorded in its store, and resolving an inbox throws rather than handing back a second one. **A rejection means "unknown", never "absent"** — retry it or surface it, but do not read it as an empty result.
**`storeRegistry.openDocumentInbox(doc)` is idempotent, including when calls overlap.** Asks for the same document that are in flight together are answered by one call, and every one of them gets the same inbox — you do not have to serialise them yourself, and firing one per component as they mount is a supported way to use it. This holds **within one page**; two pages doing it in the same moment is a non-guarantee below, and it is the only part of this you have to think about.
**A reactive read says "nothing" and "I could not find out" differently.** `watchShape` answers in three states and only two of them are answers about your data: `isPending` while the question is still open, `isSuccess` once it has been answered, `isError` when it could not be. An empty `data` under `isSuccess` means this scope holds no document of that shape — the distinction the surface exists for. Until 2026-08-17 a scope whose listing did not answer published that very snapshot, so an interface showed "you have created nothing" for "the store did not answer"; it now publishes `isError` carrying the error. And because an observable cannot take back a list a subscriber has already rendered, `data` under `isError` keeps the **last read that answered** rather than emptying — so an empty `data` is never handed to you as a failure's answer. Read the load state before `data`: **a rejection means "unknown", never "absent"** here too.
The same rule reaches what a call hands BACK, not only what it looked up first: **`listMyEntityDocs` returns a listing whose documents you can open, or it throws.** It reads which documents are in the store and what opens each, and it throws if either did not answer — including when the documents came back and their keys did not. Nothing about a keyless listing is visible to you: it is the same `Nuri[]`, and the difference would only appear at the next read, empty, long after the cause. An empty array therefore means this account created nothing.
**A deposit made while a person is looking at the page arrives while they are looking at it.** For as long as an identity is connected, every inbox it may read is watched and what lands in one is applied as it lands — its own inbox, and the inbox of every document it has opened one on, including a document whose inbox it opens later in the same session. So a `ReadCap` sent with `inbox.share` becomes usable in the recipient's live session with no reload and no call from the application, and a `watchShape` that was empty for want of that `ReadCap` re-reads and publishes the document it now opens. Until 2026-08-17 only the backlog waiting at connection was applied, and a deposit made in front of its recipient converged only when that person reloaded the page.
**The watching is in place by the time `ensureIdentity()` resolves**, whatever else that call made of its own work: a connection that could not restore something still rejects, and the identity it settled is watched all the same. It lasts exactly as long as that identity stays connected — changing identity or clearing it stops it, and whoever connects next is watched in their own right, so nothing of the previous one keeps applying.
**Failing to apply one inbox denies nothing.** It is reported on this package's own log stream (`console.error`, carrying this package's prefix) and never gated by `debugAccessLog` — a diagnostic may be opt-in, a failure may not. The deposit stays in its queue, so the next arrival on that inbox, or the next connection, applies it; the other inboxes were never involved, and nobody is refused anything.
**Any number of subscriptions on one document coexist.** Opening a document, watching an inbox and following a scope no longer silence one another. Until 2026-08-17 a second `subscribeDoc` on a document killed the first, silently — nothing rejected, the first caller's unsubscribe still appeared to work, and what an application saw was a view that stopped re-reading and an inbox that stopped notifying, with no trace anywhere near the cause. A subscriber that joins a document somebody else already opened is handed the initial `State` its own subscription would have pushed it, so joining late is not the same as never firing; and unsubscribing silences that caller and no other, including when it happens from inside a push.
## Non-guarantees
**No display name.** `ensureIdentity()` returns an opaque identifier: do not parse it, split it, or render it as a readable name.
**Naming an identity proves nothing about who named it.** Any visitor may type any identifier at the barrier, or arrive on a URL that already carries one, and act as that identity — the wallet and its password are handed out on the barrier itself. The identifier is a choice of space, never a proof: anyone who knows one can act as it, so do not treat `ensureIdentity()`'s answer as an authenticated subject.
**No live read through `useShape` against a deployed broker.** The reactive subscription it opens is dispatched by method name at two hops below this package, and a broker deployed before that name last changed does not recognise it: the read never starts, its set stays empty, its readiness never settles, and one console error is the only trace — indistinguishable from "this scope is empty". The cause is upstream in NextGraph and the remedy is a broker redeployment; nothing in this package can work around it. Use `watchShape` for a reactive read and `readUnion` for a one-shot one — neither goes through that path.
**No revocation.** `inbox.share` cannot be undone.
**Nothing per reader on a document in a public store.** No grant, no revocation, no audience list.
**No delegated writing.** A received key never grants a write, and no call adds a writer to a document.
**No mailbox model.** Do not build on the raw deposit list.
**No cross-broker reference.** A returned reference resolves for users of the same broker.
**`openDocumentInbox` does not coalesce across PAGES.** Two tabs — or two sessions of the same person — that open the same document's inbox in the same moment can each create one, and the document is left with two: its owner drains one while deposits arrive in the other. Nothing raises, nothing reports it, and neither page can detect it afterwards. It is not an oversight to be patched later: a branch MERGES records rather than refusing the second, so there is no "create only if absent" to build the guarantee on, and the address a depositor reads is a separate record from the one the owner resolves — so the two cannot even be made to agree on which of the pair won. Open a document's inbox from one place: the page that creates the document, or one call the rest of the interface waits on.
**TWO COPIES OF THIS PACKAGE IN ONE APPLICATION MISBEHAVE SILENTLY, and nothing here can detect it.** What this package remembers it keeps in the package itself, not in any handle you hold: which documents are subscribed and who is listening for them, which identity the session settled on, which documents are open. None of that is shared between two copies. An application that ends up with two therefore runs two of everything — a document subscribed through one copy is invisible to the other, so changes to it simply never arrive; and the identity settled in one is not the identity the other acts as, so the same call writes as one user or the other depending on which copy it reached. Nothing raises, nothing warns, and no call can report it: from every entry point a second copy is indistinguishable from the first, and the symptoms surface far from the cause as missing updates and writes attributed to the wrong person. This is a packaging property, not a behaviour to code around — **declare this package once, as one dependency of the application**, and let your own code and every library built on it resolve to that copy. A library that builds on this package should declare it a *peer* rather than a dependency for exactly this reason, so that the application remains the one place it is named.
**No unfiltered read through `useShape`.** Members that yield items are filtered and mutations pass through; anything else throws. A document reached through that view alone, read nowhere else first, does not appear.
**A watch on one inbox that could not be opened does not come back on its own.** Opening it can fail — a broker that does not answer in that moment — and the failure is reported rather than passed over, but what follows is event-driven and this package deliberately never polls. The watch is opened again at the next moment this identity comes to hold something it did not: it creates a document, it opens an inbox on a document, a `ReadCap` reaches it through an inbox still being watched, it reads a public-store document for the first time. A session that does none of those goes on without that inbox — deposits made into it are not applied, nothing raises, and they wait unconsumed for the next connection. The exposed case is the identity that only ever READS, since it produces none of those events, where an identity that goes on creating recovers as a by-product of its own work. Awaiting `ensureIdentity()` again applies what is waiting — it drains every one of this identity's queues before it resolves, and shows no barrier a second time — but the watching itself comes back only on a fresh page.
## Change policy
**Semver, and majors are the normal case.** This surface converges on a NextGraph that does not ship yet, so most steps toward the target remove or narrow something — the major number will move often, and that frequency is the honest signal about this package, not an apology. Refusing to version would not slow the churn down; it would only take away the one tool you have for managing it. Pin a version, upgrade deliberately, and re-pull this contract each time.
What each level means here, in this package's own terms:
- **major** — a published symbol is removed (`getSession`, `normalizeId`, `currentUser`, `RegistrySession` and `inbox.materialize` all left this way), **or** an existing call narrows: it now throws where it returned (`listMyEntityDocs` refuses a listing whose documents you could not open; `ensureIdentity` rejects rather than resolve on an incomplete restore), or it reports a state you did not have to handle before (`watchShape` publishing `isError` where it used to publish a synced-empty snapshot). A signature change a caller must react to counts; one that only accepts more than before does not.
- **minor** — a symbol is added and nothing existing moves (`inbox.readSyncedForDocument` arrived this way).
- **patch** — a fix that changes neither the published surface nor anything above under `## Guarantees`.
**A tag says where it comes from.** A release cut on `main` carries a **full version** (`1.0.0`), and the three rules above govern what changes between two full versions. Work still on a branch carries a **pre-release** of the version it is heading for (`1.0.0-dev.3`), which sorts *below* that version by construction — so you can pin what exists today while the tag itself tells you the surface has not been released and may still move before it is. Between two pre-releases of the same version nothing is promised: re-pull and read this leaf again. When the branch lands, the full version appears alongside; the pre-release keeps resolving, so no reference you pinned is ever withdrawn from under you.
**Tags carry the package name**, because this repository publishes more than one engagement and their versions move independently: `polyfill/v1.0.0-dev.3` is this package, `ng-e2e-helpers/v…` is the other one. A bare `v…` tag would say nothing about which surface it froze the day the two diverge — which is the day one of them takes a major and the other does not.
`1.0.0` is a baseline, not a claim of maturity: it is the number that makes your pin mean something. Nothing was released before it, so none of the changes named above is a bump from anything — but the next release very likely is a major. What exists today is `1.0.0-dev.3`, on a branch: pin that string exactly, and anchor your `usage_` leaf's `against:` on it — `against: @ng-eventually/polyfill@1.0.0-dev.3`, the string you pinned, never the version it is heading for. `1.0.0-dev.3` narrows an existing call rather than moving a signature: two payload shapes an application could carry freely are now this package's own, and one it cannot apply is reported — a state you did not have to handle before, which is a **major** by the rules above. It lands inside the pre-release line rather than on a new major number because `1.0.0` has not been cut, so there is nothing yet to break away from.
There is no changelog file and no deprecation window: **the sections above are the release note.** A removal or a narrowing lands in `## Surface` and `## Guarantees` in the same version that ships it, and a symbol is never left published-but-dead as a courtesy. Diff this leaf between two pulls — `## Guarantees` and `## Non-guarantees` before `## Surface`, because that is where a narrowing shows up first.
@@ -0,0 +1,52 @@
---
type: rule
summary: Never implement anything that diverges from NextGraph — ask "does the target do this?" first, and only then how badly a caller would be misled
---
# No divergence from NextGraph
**Rule:** Never implement anything that diverges from NextGraph. At every choice — a published signature as much as an internal mechanism — the **first** question is *does the target do this?* Verified in `nextgraph-rs`, or stated by its author. If the target does it differently, we do it their way, whatever that costs. Only where the target answers nothing at all do we invent, and then deliberately, documented, and named as a bet.
"Known" is narrow: read at the source, or stated by its author. Never inferred from what an npm package happens to expose, and **never inferred from an absent implementation***"the engine does not do X"* says nothing about whether the target will.
**A definition is a fact; its being unwired is an absence.** The two arrive together and the absence is the louder one, which is how it wins arguments it should lose. A type the target has declared but nothing constructs yet is the *strongest* thing to align on — implementing it is not inventing, it is realising a shape the target wrote down. Read the declaration and let the silence around it say nothing.
That distinction has its own place in the taxonomy below, because collapsing it into "invention" makes us believe we are further from the target than we are — and then a later reader, seeing "our own idea", feels free to change it.
**The second question measures gravity, not permission:** *would an application coding against this have to unlearn it?* Use it to rank what to fix first, and to judge an unavoidable divergence. Never use it to authorise one.
**Why the order matters — it is the failure this rule was rewritten for.** `readUnion` folded every triple of a document into one bag keyed by the document, so two entities written under two subjects came back merged and one written under another subject came back relabelled, silently. Asked the second question first, the answer was ambiguous: *one entity per document* is good placement practice anyway, so what exactly would a caller unlearn? The reasoning stalled there for hours. Asked the first question, it took one look: level 1 returns real subjects, and level 3's ORM carries `@id` **and** `@graph` on every object and generates the former when omitted — several objects per graph is the designed case. Divergence, done, no debate about perception.
Earlier instances, all of the same shape: *"every document has a native inbox"*, written from general reasoning, false, and already an implementation; and a per-document inbox pointed at its owner's inbox to absorb a measured cost, emulating a many-to-one relation the target cannot express.
**The pressure to deviate never announces itself as one.** It arrives as a cost, a latency, an ergonomic wrinkle — all real, all legitimate. That disguise is what makes it dangerous. When shape and cost conflict: keep the shape and attack the cost elsewhere (the lever is usually *who* pays and *when*). If the cost is genuinely unsolvable, say so rather than bending the model quietly.
**How to apply.** The tells, each of which has produced a real hole here:
- **A recommendation enforced by the code.** Ours may guide placement; it may not make the other arrangement invisible. A read path that silently normalises what it did not expect is the signature.
- **A symbol that makes the caller handle what the target will never hand it** — a document's key, an inbox address, a store id, its own identity.
- **A named exception stops being one the moment it is published.** A door documented as *"only this internal caller uses it"* is a door any application can open; the note is not a mechanism. Move it out of the published surface instead.
- **A symbol kept because it was already there is not a decision.** At every surface change, re-ask whether an application still needs each neighbouring symbol.
When a divergence is genuinely unavoidable it must be **deliberate, documented and invisible to the caller**. What is forbidden is the silent one, adopted because it was convenient.
## Every symbol says where it comes from
Answering *does the target do this?* once, in conversation, does not survive the week. So each symbol carries its own provenance, beside the code rather than in a document that drifts: the **level** that answers (1 the engine, 2 the binding, 3 the ORM), the upstream `path:symbol` it aligns on, and one of five kinds.
| kind | meaning |
|---|---|
| `passthrough` | upstream's own symbol, same name and shape |
| `aligned` | our name or ergonomics, upstream's semantics and model |
| `declared-not-wired` | upstream **defines** the shape; nothing constructs it yet; we implement it |
| `reserved-name` | upstream has declared a NAME with no shape behind it — it binds nothing, so building on it is a bet |
| `invention` | nothing at any level answers — a deliberate, documented bet |
| `divergent` | upstream does this, and we do it differently |
A test requires the annotation on every published symbol; it cannot check that the citation is *true*, and that is fine — a citation is refutable by a reader in seconds, prose is not. The classification also applies to unpublished code that **claims to resemble the target**: the mistake this section exists for was made on an internal function, and being unexported protected nobody.
**A reserved name is not a declared shape, and the gap between them is the whole point.** `declared-not-wired` is strong *because the shape constrains us*: implementing it is realising something the target wrote down. A name with nothing behind it constrains nothing, so what we build under it is our own invention wearing the target's vocabulary — the most dangerous kind, because the borrowed name makes it read as alignment.
The tell is mechanical: **open the declaration and look for a payload.** Several of `InboxMsgContent`'s variants — `Link`, `Patch`, `ServiceRequest`, `ExtRequest`, `RemoteQuery`, `DialogRequest` — are bare unit variants carrying nothing at all, while `ContactDetails` and `SocialQuery` carry data and have processors. Presenting the first group as *"the strongest thing to align on"* was an error made here, in the same breath as citing this rule; see [[knowledge_what-an-inbox-is-upstream]] for what those variants do and do not offer.
**Why this exists.** A maintainer read that `AddLinkV0` is defined upstream but constructed nowhere, and reported our implementation of it as our own invention — reasoning from the absence while quoting, in the same session, the rule against exactly that. The correct reading was `declared-not-wired`, which is the best alignment available. Prose said otherwise elsewhere too: `docs/api-contract.md` claimed `docs.*` was a 1:1 passthrough while two of its signatures diverged, and stated a member count that was simply wrong. Both errors are the same one — a claim about the target that nothing forced anyone to check.
@@ -0,0 +1,32 @@
---
type: overview
summary: What each end-to-end suite judges, the shortcut that kept two defects invisible, and the failure mode that is not ours to fix
triggers:
keywords: [e2e, harness, playwright, chromium, journey, suite, profile, broker page, notebook, wallet profile, ng-e2e-helpers]
paths:
- "packages/polyfill/e2e/**"
- "packages/ng-e2e-helpers/**"
vocabulary:
- term: journey
gloss: one named end-to-end story in the applicative suite, reported pass or fail as a whole
not: [scenario, test case, spec]
- term: batch
gloss: one run of a suite, which mints its own physical user and discards the previous one
not: [session, campaign]
---
# e2e-harness — the suites, and what they can and cannot tell you
Three suites drive real browsers against the real broker and the real wallet application. They are the only place the emulation meets what it emulates, so a green unit suite says nothing about them.
They are also expensive and, on a machine whose network moves, unreliable in ways that look exactly like product defects. Most of what this concept holds exists so an agent does not spend hours diagnosing a product bug that is a transport failure — or, worse, trust a green run that measured nothing.
The generic half of that machinery is a package of its own, `ng-e2e-helpers`, and this repo **publishes** it: a NextGraph application in any repository pulls `ng-e2e-helpers/contract_ng-e2e-helpers` and declares back what it consumes. Our own suites are one of those consumers, and declare it in the same folder — same pair, no boundary to cross.
## Read first
- `ng-e2e-helpers/contract_ng-e2e-helpers` — the engagement, written from the caller's point of view.
- `ng-e2e-helpers/usage_polyfill-e2e` — what our own suites actually call, and where the fit still costs them code.
- `knowledge_what-each-suite-judges` — which suite answers which question.
- `rule_never-shortcut-the-sign-in` — the shortcut that hid a 404 and a regression at once.
- `caveat_a-dropped-pipe-kills-a-run` — the failure mode that is not ours.
@@ -0,0 +1,27 @@
---
type: caveat
summary: Chromium's control pipe drops without Playwright emitting close or disconnected, so a run dies mid-flight — it looks like a product defect and is not one
last_checked: 2026-08-11
---
# A run can die from a dropped pipe, and it is not the product
Chromium's devtools pipe sometimes drops mid-run. It logs a terminated-pipe message and exits cleanly, and **Playwright emits neither `close` nor `disconnected`** — observed four times out of four. From the client's side the browser simply stops answering.
Before waits were bounded this was fatal in a specific way: the suite blocked in its own teardown, so it printed **neither its summary nor the failure already on its way out**. Hours went into diagnosing silence. Every wait is now bounded and names what it was waiting for, so a lost browser costs seconds and a report.
**How to recognize it.** The run dies without a coherent failure, or several unrelated interactions time out at once, or the summary is missing entirely. **Do not read a named deadline as a verdict on the transport.** A deadline says only that something did not happen in time; it never says why, and reaching for the environment is the comfortable answer because it absolves the code.
That reflex cost a full day here. Every actor sign-in was timing out, and it was blamed on the broker and on a churning host for hours. The real cause was one line of ours: the harness looked for the application frame with a substring match on the URL, and the broker's own login page carries the application address in its query string — so it matched the login page from the first instant, skipped the wallet click and the password as "already logged in", and handed back the wrong frame. What made it intermittent was a 2-second visibility probe on a button that painted in 1.0 to 1.6 seconds. All of it was readable in the code the whole time.
**It is not ours to fix.** It is not caused by how a child process is spawned, nor by a leftover holding the profile, nor by overlapping launches — all three were probed and ruled out. It looks like Playwright losing its file descriptors without telling its client. Worth reporting upstream.
## The host can be the cause too
A machine that reconfigures its network — a container in a crash-restart loop cycling its virtual interface, for instance — makes the applicative suite unreliable. The browser answers with a network-changed error, broker sockets fail, and every failure looks like a different product bug.
This has happened here: seven failures out of ten runs in one afternoon, all transport, none product. The check costs seconds — watch for repeated link events, and look for a container restarting.
But do not conclude the suite is unmeasurable: under that same churn it also ran green four times in a row. A red run under a moving network proves nothing, and neither does a green one. What decides is the SHAPE of the failure — a named deadline on a browser or broker operation points at the transport, a failed assertion carrying an unexpected value points at the code — and repetition: three consecutive green runs, or a failure that reproduces.
**The discipline that follows.** Read your own harness first, and only call it transport once you can name the mechanism. A suite that genuinely fails for transport reasons has measured nothing. Do not read it as a red baseline, do not chase it as a regression, and do not commit against it. Re-run it — and if the environment is known to be moving, say so alongside the result instead of letting a single run stand as the verdict.
@@ -0,0 +1,38 @@
---
type: knowledge
summary: Which suite answers which question, what a batch costs, and why two runs must never overlap
---
# What each suite judges
**The polyfill suite** exercises the published surface against the real broker: capabilities, reads, inboxes, reactivity. It is the one that judges whether the emulation behaves like the target.
**The applicative suite** drives the example application through a browser, as a person would — several identities, several pages, assertions on what is on screen rather than on what the library returns. It judges whether an application built on this package actually works, including the sign-in a person really performs.
**The reactivity suite** isolates document subscription.
A unit suite cannot replace any of them, and none of them replaces the unit suite: they are slow, they depend on live external services, and they cannot enumerate a case space.
## What a batch costs, and why
Every run mints its own physical user by driving the wallet application's real interface, then discards the previous one. That is deliberate — identities must not leak between runs — and it puts a floor under every run that no test filter can remove.
Consequence worth knowing before optimizing anything: the setup runs before any `journey`, so filtering journeys saves journey time only — the floor stays.
## Two runs may overlap — and that was not free
They once could not: the profile was a single shared directory, and a run starting while another was alive destroyed it, which then failed in a way that read as a product defect. Several measurements were lost to it.
Now each run gets its own profile directory and kills the browser holding it when it ends, so concurrent runs — including a consuming application's suite, from its own checkout — are independent by construction. A lock once guarded the shared directory; it treated the symptom, and removing the sharing removed the need. Prefer that shape whenever two runs contend: make the contended thing per-run rather than serialising access to it.
## A bound must be larger than the sum of what it encloses
An enclosing deadline shorter than its own steps can only ever fire first, so every failure underneath it reports the *enclosing* name and none of them can name a cause. The sign-in bound sat at 180 s over steps totalling 270 s, and for days every failure said the same four words while the real step stayed anonymous. Days went into looking for a cause the harness was structurally incapable of reporting.
So: compute an enclosing bound from its parts rather than picking a number, and size every leaf bound from a **measured** healthy duration recorded beside it. A bare figure teaches nothing and rots without anyone noticing; a figure with its measurement lets the next reader tell a generous bound from a tight one.
## What a run must report whatever happens
Declare the journeys and their checks before anything can fail, so a run reports the same number of rows every time. When the count itself moves with the failure — journeys dying and taking their unreported checks with them — two runs are no longer comparable, and a shrinking total reads like a smaller problem instead of a bigger one.
The related trap that made it self-perpetuating: discarding the profile was once conditioned on a marker written at the *end* of a batch, so a run killed before writing it left a profile the next run happily reused — and inherited its breakage. Discarding now keys on the profile itself.
@@ -0,0 +1,166 @@
---
type: contract
summary: The machinery ng-e2e-helpers offers a NextGraph application's end-to-end suite — wallets, the broker crossing, per-run profiles, bounds, and a bounded report
---
# contract_ng-e2e-helpers — `ng-e2e-helpers`
## Scope
This package is the end-to-end testing machinery a NextGraph application needs to get a real person into itself: minting a wallet by driving the wallet application, crossing the broker, and coming back inside the iframe the application runs in — plus per-run browser profiles, bounds that turn a hang into a named failure, and a run report whose size does not depend on what failed.
It covers **only what is generic to NextGraph**. It knows nothing about any one application, and nothing about any compatibility layer: an application calling the NextGraph SDK directly is its intended consumer. What is specific to your repository — the page that carries your application, how you build your bundle, which journeys you run — is yours to write, and this package deliberately offers no place to put it.
It is not a test runner, not an assertion library, and not a fixture system. You keep your own runner and your own `main()`.
### Deployment requirements
`playwright` and `@ng-org/web` are **peer dependencies**, and you own both versions: browser binaries have to match the driver, and the SDK the export page opens a session with must be the one your application and your broker agree on. Import the browser types from the helpers that return them rather than from `playwright` directly — a second resolution of the driver produces a structurally different `BrowserContext`, and a context you opened then cannot be handed back to the helper that opens contexts.
A machine running this needs a real Chromium, network reach to the wallet application and to the broker, and a writable temporary directory for the per-run profiles.
## Surface
Full typed shape: the package's `types` entry, `ng-e2e-helpers`. It also ships one executable, `ng-mint-wallet`, for provisioning a deployment's wallet file outside any run. `BrowserContext`, `Frame`, and `Page` — every Playwright type this surface mentions, no more — are re-exported as types from the same entry point, so a consumer can type its own helper functions against these signatures without a second, independently-versioned import of `playwright`. The load-bearing signatures:
```ts
// ── bounds: a hang becomes a named failure ───────────────────────────────────
export function within<T>(what: string, ms: number, task: () => Promise<T>): Promise<T>;
export class DeadlineExceeded extends Error {} // thrown by `within` — names `what`
export class BrowserGone extends Error {} // the browser died; not the task's fault
export function enclosingBound(steps: readonly number[], margin: number): number;
export function closeQuietly(what: string, close: () => Promise<unknown>): Promise<void>;
export function armSuiteDeadline(suite: string, ms: number, thenReport?: () => void): void;
export function browserLost(reason: string): void; // declare it, once
export function lossDeclared(): string | null; // has it been declared?
export function firstLine(e: unknown): string;
export const CLOSE_MS: number, CONTEXT_ACTION_MS: number, CONTEXT_NAVIGATION_MS: number;
// ── measurement: how a bound gets its number ─────────────────────────────────
export function measured<T>(what: string, bound: number, task: (ms: number) => Promise<T>): Promise<T>;
export function record(what: string, ms: number, ok: boolean, bound: number): void;
export function timingsWanted(): boolean; // true under E2E_TIMINGS=1
export function printTimings(): void;
// ── browser and profiles: one run owns its own ───────────────────────────────
export interface RunProfile { readonly dir: string; readonly purpose: string; discard(): void }
export function newRunProfile(purpose: string): RunProfile;
export function isAlive(pid: number): boolean;
export function launchWatchedContext(label: string, dir: string): Promise<BrowserContext>;
export function closeContext(label: string, ctx: BrowserContext): Promise<void>;
export function newPage(label: string, ctx: BrowserContext): Promise<Page>;
export const LAUNCH_MS: number, NEW_PAGE_MS: number;
// ── the wallet: minted, carried, imported ────────────────────────────────────
export interface WalletCredentials { readonly name: string; readonly password: string }
export const DEFAULT_WALLET_NAME: string; // the password never has a default
export function mintWalletProfile(purpose: string, c: WalletCredentials): Promise<RunProfile>;
export function mintWalletProfileKeepingContext(
purpose: string, c: WalletCredentials): Promise<{ ctx: BrowserContext; profile: RunProfile }>;
export function mintWalletBytes(password: string, name?: string): Promise<Uint8Array>;
export function createWalletInContext(ctx: BrowserContext, c: WalletCredentials): Promise<void>;
export function emptyProfileContext(
purpose: string): Promise<{ ctx: BrowserContext; profile: RunProfile }>;
export function exportWalletBytes(ctx: BrowserContext, walletPassword: string): Promise<Uint8Array>;
export function exportWalletFile(ctx: BrowserContext, ngwPath: string, pw: string): Promise<number>;
export function importWalletFile(page: Page, ngwPath: string, password: string): Promise<void>;
// ── the broker crossing ──────────────────────────────────────────────────────
export function setupBrokerPage(page: Page, appUrl: string, walletPassword: string): Promise<Frame>;
export function completeBrokerLogin(page: Page, appUrl: string, pw: string): Promise<Frame>;
export const BROKER_LOGIN_MS: number, BROKER_ROUND_TRIP_MS: number;
export function brokerRedirectFor(appUrl: string): string;
export const BROKER_SCREENS: readonly BrokerScreenSpec[]; // the screens, in test order
export const WALLET_APP, WALLET_CREATION, WALLET_IMPORT; // the wallet application's own pages
export type BrokerScreen = "choose-broker" | "login-offered" | "wallet-list"
| "password" | "working" | "error";
export type { BrokerScreenSpec, ScreenSignature, ScreenResponse, TextPattern };
// ── serving your application to the browser ──────────────────────────────────
export function serveOnEphemeralPort(
handler: (req: IncomingMessage, res: ServerResponse) => void,
): Promise<{ url: string; close: () => void }>;
// ── failures that are not the application's ──────────────────────────────────
export function browserTrouble(label: string, ctx: BrowserContext): Promise<string | null>;
export function frameTrouble(id: string, page: Page, frame: Frame, marker: string): Promise<string | null>;
export const BROWSER_PROBE_MS: number, FRAME_PROBE_MS: number;
// ── the report ───────────────────────────────────────────────────────────────
export interface JourneyDeclaration { readonly name: string; readonly checks: readonly string[] }
export type Prerequisite = () => Promise<string | null> | (string | null);
export interface JourneySpec {
readonly name: string; // must be a declared journey
readonly needs?: readonly Prerequisite[]; // each answers null, or why it cannot start
readonly run: () => Promise<void>;
}
export interface SuiteOptions {
readonly label: string;
readonly journeys: readonly JourneyDeclaration[];
readonly journeyBound: number;
readonly diagnose?: () => Promise<string | null>;
}
export interface SuiteReport {
check(name: string, ok: boolean, detail?: string): void; // throws on an undeclared name
journey(spec: JourneySpec): Promise<void>;
finish(fatal: string | null): never; // prints, then leaves
}
export function declareSuite(options: SuiteOptions): SuiteReport;
// ── playwright types this surface mentions, re-exported so a second import isn't needed ─────
export type { BrowserContext, Frame, Page };
```
## Guarantees
**A bound names what it was waiting for.** `within(what, ms, task)` either settles the task or throws a `DeadlineExceeded` carrying `what` verbatim — never a bare timeout with no subject. `armSuiteDeadline` does the same for a whole run, and reports before leaving. `enclosingBound(steps, margin)` returns a number no smaller than the sum of the bounds it encloses, so an enclosure can never fire before the step that actually hung.
**A run owns its profile and discards it.** `newRunProfile` returns a directory belonging to this run alone; `discard()` kills whatever still holds it and removes it, is idempotent, and also runs when the process leaves — including when the run is killed. Two runs never share local state, so nothing a previous run left can make this one pass or fail.
**A minted wallet is new.** `mintWalletProfile`/`mintWalletBytes` walk the wallet application to create a wallet that did not exist a moment ago. `mintWalletBytes` keeps only the bytes and discards the profile it minted in; `mintWalletProfile` keeps the profile for the length of the run. The password is always a parameter and never has a default; the name defaults to `DEFAULT_WALLET_NAME` because nothing in NextGraph keys off it.
**The crossing dispatches on the screen it can see**, not on a fixed sequence: `setupBrokerPage` reads the page against `BROKER_SCREENS` in order, answers it, and returns the application's `Frame` once the crossing has completed. It identifies the application by ORIGIN. A terminal screen ends the crossing as a failure rather than an action.
**An empty-profile context starts with nothing local.** `emptyProfileContext` gives a context whose profile holds no wallet and no repo cache, which is the reconnection cold start: the wallet's repos are on the broker and not in this profile. `importWalletFile` then puts the wallet in — but not the repos' cache — so the next session over it still hits that cold start.
**A closed server is closed.** `serveOnEphemeralPort` tracks its open sockets and destroys them on `close()`, so a connection nobody hung up cannot be blamed on whatever goes wrong next.
**A known failure mode is named instead of the innocent operation.** `browserTrouble`/`frameTrouble` answer a string when the browser or the application frame is the actual cause, and `null` when they are not. `SuiteOptions.diagnose` puts that answer in FRONT of a journey's own reason, never in place of it. A frame that is attached, on the right URL, and empty is reported as trouble — that is the state a naive check misses.
**The report's size does not depend on what failed.** Journeys and their checks are declared up front to `declareSuite`; `check` throws on a name the journey did not declare, `journey` runs one journey bounded and isolated so it cannot change the report's shape, and `finish` reports every declared check the run did not get to before printing and exiting. A journey whose prerequisite is provably dead is reported as such instead of being driven.
**Errors are values where a caller can act on them.** `browserTrouble`/`frameTrouble`/`Prerequisite` answer `string | null`, never throw for the condition they detect. `DeadlineExceeded` and `BrowserGone` are exported classes, so `instanceof` is a supported way to tell a hang from a dead browser.
## Non-guarantees
**No assertion library, no runner, no fixtures.** `SuiteReport.check` records a boolean you computed; nothing here decides what is true.
**`finish` never returns** — it exits the process. Do not put cleanup after it; put it in the `discard()`/`closeQuietly` path.
**No message text is stable.** The strings from `browserTrouble`, `frameTrouble`, `DeadlineExceeded` and the report are for a human reading a run. Do not parse them, match on them, or branch on them.
**No timing promise.** Every exported `*_MS` constant is a bound sized from a measurement on one machine, not a service level: they change when the measurements change, and a run slower than one of them is a failed run, not a broken guarantee. `E2E_TIMINGS=1` reprints the measurements so you can resize your own.
**Nothing survives a run.** Profiles are discarded, and no artifact, cache, or wallet is carried from one run to the next. The one exception is a file you write yourself with `exportWalletFile` or `ng-mint-wallet`.
**No concurrency contract.** The suite machinery assumes one run per process. Two suites sharing a process share `armSuiteDeadline`, the timings table and the loss declaration.
**No wallet-application version pinning.** The crossing is driven against the wallet application as deployed at the broker you point it at. When that application changes its screens, `BROKER_SCREENS` is what has to be updated — a crossing that fails there is not necessarily your application's fault, and is not this package's promise to hold across upstream redesigns.
## Change policy
**Semver, and majors are the normal case.** This machinery tracks a NextGraph and a wallet application that are both still moving, so a screen inventory, a bound, or a call shape changes whenever they do — the major number will move often, and that frequency is the honest signal about this package, not an apology. Refusing to version would not slow the churn down; it would only take away the one tool you have for managing it. Pin a version, upgrade deliberately, and re-pull this contract each time.
What each level means here, in this package's own terms:
- **major** — an exported symbol is removed or renamed, **or** an existing call narrows: it now throws where it returned, requires an argument that was optional, or returns something a caller must newly handle. A change to what a `SuiteReport` accepts (a check name, a journey shape) is a major, because your suite is written against it. A signature change a caller must react to counts; one that only accepts more than before does not.
- **minor** — a symbol is added and nothing existing moves: a new helper, a new screen in `BROKER_SCREENS`, a new optional field on `SuiteOptions`.
- **patch** — a fix that changes neither the exported surface nor anything above under `## Guarantees` — including a re-sized `*_MS` constant, which is explicitly disclaimed above.
**A tag says where it comes from.** A release cut on `main` carries a **full version** (`1.0.0`), and the three rules above govern what changes between two full versions. Work still on a branch carries a **pre-release** of the version it is heading for (`1.0.0-dev.3`), which sorts *below* that version by construction — so you can pin what exists today while the tag itself tells you the surface has not been released and may still move before it is. Between two pre-releases of the same version nothing is promised: re-pull and read this leaf again. When the branch lands, the full version appears alongside; the pre-release keeps resolving, so no reference you pinned is ever withdrawn from under you.
**Tags carry the package name**, because this repository publishes more than one engagement and their versions move independently: `ng-e2e-helpers/v1.0.0-dev.2` is this package, `polyfill/v…` is the other one. A bare `v…` tag would say nothing about which surface it froze the day the two diverge — which is the day one of them takes a major and the other does not.
`1.0.0` is a baseline, not a claim of maturity: it is the number that makes your pin mean something. Nothing was released before it. What exists today is `1.0.0-dev.2`, on a branch: pin that string exactly, and anchor your `usage_` leaf's `against:` on it — `against: ng-e2e-helpers@1.0.0-dev.2`, the string you pinned, never the version it is heading for.
There is no changelog file and no deprecation window: **the sections above are the release note.** A removal or a narrowing lands in `## Surface` and `## Guarantees` in the same version that ships it. Diff this leaf between two pulls — `## Guarantees` and `## Non-guarantees` before `## Surface`, because that is where a narrowing shows up first.
@@ -0,0 +1,55 @@
---
type: usage
summary: What the polyfill's own end-to-end suites call from ng-e2e-helpers, under which constraints, and where the fit still costs them code
against: ng-e2e-helpers@1.0.0-dev.2
---
# usage_polyfill-e2e — the polyfill's end-to-end suites
The consumer is the end-to-end suite of `@ng-eventually/polyfill`: four entry points under that package's `e2e/` — a batch of SDK checks against the broker, an applicative suite driving the example application, a reactivity probe, and a cold-start reproduction — plus the page module they share.
This consumer lives in the same repository as the provider. That changes nothing about what is declared here: the engagement is what these suites are written against, and anything they call that is NOT listed below is theirs to fix if it moves.
## Consumed surface
**Bounds**`within`, `armSuiteDeadline`, `closeQuietly`, `enclosingBound`, `firstLine`. `within` is the most-called entry of the whole package here: every bridge call into the application iframe goes through it.
**Measurement**`measured`. Nothing reads `record`, `timingsWanted` or `printTimings` directly; the `E2E_TIMINGS=1` output is consumed as printed output, not as a call.
**Browser and profiles**`launchWatchedContext`, `closeContext`, `newPage`, and the type `RunProfile`. `newRunProfile` and `isAlive` are never called: profiles are always obtained through a wallet call.
**Wallet**`mintWalletProfile` (the batch wallet, minted once per run), `mintWalletProfileKeepingContext` (the cold-start reproduction, which needs the very first application session over a never-used wallet), `emptyProfileContext` + `importWalletFile` (the reconnection journey), `exportWalletBytes` (serving the wallet from the application's own bundle), and the type `WalletCredentials`.
**Broker crossing**`setupBrokerPage` in all four suites, `completeBrokerLogin` in the applicative one.
**Serving**`serveOnEphemeralPort`, for both the SDK page and the example application's bundle.
**Known failure modes**`browserTrouble`, `frameTrouble`.
**Report**`declareSuite` and the types `JourneyDeclaration`, `Prerequisite`. Used by the applicative suite only.
**Constants**`BROKER_ROUND_TRIP_MS`, `NEW_PAGE_MS`, both as inputs to `enclosingBound` rather than as waits of their own.
Everything else the package exports is offered and NOT consumed here — the screen inventory (`BROKER_SCREENS`, `WALLET_APP`, `WALLET_CREATION`, `WALLET_IMPORT`, `brokerRedirectFor`, and the screen types), `createWalletInContext`, `exportWalletFile`, `mintWalletBytes`, `DEFAULT_WALLET_NAME`, `browserLost`, `lossDeclared`, the exported error classes, and the remaining `*_MS` constants. It is safely evolvable as far as these suites are concerned.
## Constraints
**The browser types come from the helpers.** `Frame`, `Page` and `BrowserContext` are taken from `ng-e2e-helpers`, which re-exports them, rather than from `playwright` directly — so these suites never depend on resolving the same copy of it, which is the position a consumer in another repository is in anyway.
**Every suite mints its own physical wallet, per run.** Nothing is carried between runs and no wallet is reused, so a run's result never depends on a previous one. The batch wallet's NAME is stable and its identity is not: two runs sharing the name share nothing else.
**The password is supplied, never defaulted.** These suites also read the barrier's displayed password back off its own screen and pass it to `importWalletFile`, which is the only way to prove that what the barrier shows is what opens the file. A defaulted password would make that check vacuous.
**An enclosing bound is at least the sum of what it encloses.** These suites compute every enclosure with `enclosingBound` rather than picking a round number, because an enclosure that fires first reports "the journey timed out" and never names the step that hung.
**A wait must not be entered unbounded.** Playwright's `frame.evaluate` waits forever, so no bridge call is made outside `within`.
## Frictions
**`frame.evaluate` has no bound, and three suites each wrote the same wrapper.** A `sdk(frame, method, …args)` helper — `within(…, BRIDGE_MS, () => frame.evaluate(…))` — is duplicated verbatim in three of the four entry points. The bound is the single most important one in each of them, and the package offers nothing to obtain it from. A bounded `evaluate` helper here would delete the duplication and stop each new suite re-deriving it.
**"Measured and bounded" is one intent and two calls.** Sizing a bound from its own measurement is the discipline this package prescribes, yet the applicative suite composes `measured(what, ms, (bound) => within(what, bound, task))` by hand — and the indexing layer's suite, in another repository, wrote the same line byte for byte. A helper that pairs them belongs on the engagement.
**`declareSuite` fits a journey suite and not a check batch.** Only the applicative suite uses it; the other three declare a local `record`/`check` pair and print their own summary, because their shape is a flat list of checks discovered as the run goes rather than named journeys declared up front. The up-front declaration is exactly what makes the report bounded, so this is not a request to relax it — but three of four suites falling outside it suggests the package covers one of two real shapes.
**The screen inventory is exported and unreachable in practice.** `BROKER_SCREENS` and the `WALLET_*` page descriptions are published, but a suite that crosses the broker with `setupBrokerPage` never needs them, and one that wanted to extend the crossing has no supported way to hand a modified inventory back in. As it stands they read as internals that happen to be exported.
@@ -0,0 +1,24 @@
---
type: rule
summary: An end-to-end journey must reach its starting state through the system, never by pre-seeding what the sign-in would have produced — the shortcut keeps whole features untested while staying green
---
# Never hand a journey the state the sign-in would have produced
**Rule:** An end-to-end `journey` must arrive at its starting state **through the system**. Never pre-seed the identifier in the URL, never pre-import a wallet into the profile under test, never set the acting identity directly. If reaching a state through the system is awkward, that awkwardness is the finding — not a reason to skip it.
**Why.** Every applicative `journey` used to append the identifier to the application URL before loading it. The identity then resolved from the URL, the `barrier` never appeared, and the suite was green for months. It was green over:
- a download link pointing at a file **nobody served** — the barrier handed out a 404, and no test had ever clicked it. Worse, the test server answered every unknown path with the application's own HTML, so a missing file could not fail even in principle;
- an ordering defect that made the barrier unreachable for any first-time user — the application handed the page to the broker before the barrier could appear, leaving a newcomer at a login page with no wallet and no way to get one;
- and, later, a regression in sharing.
The first journey that actually walked a newcomer's path — empty profile, no wallet, real download, real import — found all of it immediately. The coverage hole and the defects protected each other: the shortcut existed because the real path was tedious, and the real path stayed broken because nothing walked it.
**How to apply.** The tell is mechanical: a `journey` that *writes* a value the system is supposed to produce. Ask *"by which call would a real person obtain this?"* — no answer means the feature is incomplete, not that the test needs help.
This is not a blanket ban on fixtures. Shared setup — building the application, minting the `batch` user, serving files — is legitimate and belongs before the journeys. What is banned is seeding **the specific state under test**: a journey about sign-in may not start signed in.
Journeys that legitimately need an already-signed-in actor may use the shared sign-in helper. That is exactly why at least one journey must not: without it, the helper's own path is the untested one.
Read alongside the global rules on covering the case space and on each actor obtaining its inputs through the system — same failure shape, and it has now produced real defects here twice.
+31
View File
@@ -0,0 +1,31 @@
---
type: overview
summary: How a person gets from holding nothing to acting as an identity — the barrier, the broker hand-over, and the split between settling and connecting
triggers:
keywords: [sign-in, signin, identity, ensureIdentity, settleIdentity, barrier, gate, wallet, broker, redirect, ng-id, currentUser, session]
paths:
- "packages/polyfill/src/shared-wallet/**"
- "packages/polyfill/src/surface/lifecycle.ts"
- "packages/polyfill/src/emulated-verifier/connect.ts"
vocabulary:
- term: settle
gloss: decide which identity is acting, from the URL, storage, or the barrier — needs no session
not: [resolve identity, determine user, login]
see: knowledge_settling-is-not-connecting
- term: barrier
gloss: the full-screen overlay this package mounts to hand out the wallet and take an identifier
not: [modal, popup, login screen]
---
# sign-in — getting a person from nothing to acting
This concept covers everything between "someone opens the application" and "an identity is acting with a live session": the `barrier` this package mounts, the wallet a newcomer must obtain, the broker redirect, and the two distinct acts — `settle` and connect — that were once conflated and must stay apart.
None of it is app-facing. An application awaits one call and renders; it must never learn that a broker, an iframe or a redirect exist. What it may rely on lives in `app-contract`; what follows is how that promise is kept.
The whole thing is scaffolding: upstream, a person opens **their** wallet, it contains **their** site, and `session_start` takes an id that came from the wallet. There is nothing to name and nothing to choose. This concept exists because one shared wallet hosts several identities.
## Read first
- `knowledge_how-a-user-gets-in` — the flow end to end, and which layer owns each step.
- `knowledge_settling-is-not-connecting` — the split, and the cycle that forces it.
@@ -0,0 +1,45 @@
---
type: knowledge
summary: The sign-in flow end to end, and which layer owns each step — in particular that the broker redirect belongs to @ng-org/web, not to this package
---
# How a user gets in
## The steps, and who owns them
A newcomer holds nothing. To act they need a wallet imported once into the wallet application, and an identifier naming their virtual space. The `barrier` hands out the first three — the wallet file, its password, a link to the wallet application — and takes the fourth.
Then the page is handed to the broker, which opens the wallet and reloads the application **inside an iframe it hosts**. Outside that iframe there is no session.
**The redirect is not ours.** `@ng-org/web`'s `init()` performs it as its first statement — same host, same URL shape, same frame check. Verified in its shipped bundle: when `window.self === window.top` it assigns `window.location.href` from a hard-coded `redirect` constant. This package must not reimplement it; doing so would double a function of the layer below and diverge from it in the meantime (see `app-contract/rule_no-divergence-from-nextgraph`).
What is genuinely ours is the one thing `init()` cannot do: **put the identifier into the URL before `init()` reads it**. `init()` hands over `window.location.href`; it does not know the parameter exists.
## When the `barrier` shows, and why it is not a question of identity
**Top-level: always. Inside the broker iframe: never** — there the identifier arrives in the URL and the page stands aside.
The discriminator is the frame, not whether an identity is known, and the difference is not ergonomic. An identity is state we can observe; whether the person still has a wallet in this browser is state we cannot — it lives in another origin's storage. A screen that decides on the identity is therefore *guessing* at the state that matters, and when it guesses "already set up" while NextGraph's side is gone, it hides the only controls that could repair the situation.
What that costs is not a degraded experience but a dead end: sent to the broker without a wallet, the person lands on a static page carrying **no return path to the application**. The browser's Back button is the only way out, and it only helps if the `barrier` is there to catch them on arrival.
A known identifier therefore **prefills the field; it never skips the screen**. And nothing detects whether the wallet is already there: all steps are always shown, and whoever already has one ignores them. No checkbox, no "I already have it" — knowing whether you imported a wallet into this browser is not something a person can be asked.
Two facts, observed on the live sites, close the alternatives: clicking the wallet application's import control opens a file chooser **in place** — no navigation, no new tab; and our own page receives **no signal at all** when the import succeeds (no opener, no message, no storage or focus event, no reload). Detecting the return is impossible, not merely fragile.
## Why the identifier travels by URL
The flow runs in **two contexts with separate storage partitions** — the top-level page and the broker iframe — because browsers partition storage by top-level site. A value written top-level is not the value the iframe reads.
What crosses is the URL: the redirect embeds the full application URL, query included, and reloads it in the iframe. Hence the resolution order, which must not be "simplified":
1. the identifier in the URL — it wins whenever present, being the only thing that crosses;
2. otherwise storage — same-partition convenience, and prefill on reload.
The parameter must therefore be written on **every** path that settles an identity, not only when the `barrier` asked. An identity coming from storage without the parameter being written is the silent failure: the iframe reads an empty identity, provisions a second virtual space, and the returning user lands somewhere empty with no error.
Writing to storage and writing to the address bar are two independent attempts, deliberately: a storage that refuses writes must not cost the round-trip its parameter.
## What survives migration
The `barrier` evaporates whole — handing out a shared wallet and naming a space exist only because the wallet is shared. The redirect does **not**: upstream's real multi-wallet flow has the same shape, and the barrier becomes the real per-user sign-in. The call site survives too, which is why the published call takes no identifier.
@@ -0,0 +1,61 @@
---
type: knowledge
summary: Deciding which identity acts needs no session; connecting does — conflating them deadlocks one way and silently breaks sharing the other
---
# Settling is not connecting
Two acts of different nature hide behind "sign in":
- **`settle`** — decide which identity is acting, from the URL, from storage, or by asking at the `barrier`, then persist it. Pure DOM and storage. **No session required.**
- **connect** — put back in that identity's hands everything it can already open: what it OWNS as well as what was shared with it, drain its queues, and then keep applying what arrives in them for as long as it stays connected. **Requires a live session.**
The wallet keeps those two in different places, and connecting once replayed only the second — so an application that reloaded and went straight to a document it had made itself was refused its own document. Whatever else changes here, connecting must replay **every** durable register, not the one that happens to be read on the path being tested.
They must stay apart, and the reason is not tidiness.
## Why they cannot be one call
`init()` starts the broker redirect as its first statement when the page is top-level, so anything the barrier needs to show must already be in place. The `barrier` must therefore appear **before** `init()`.
But an identity cannot connect before `init()` either: connecting needs the session, which arrives only through `init()`'s callback. Await the whole of sign-in before `init()` and it deadlocks; call it after and the `barrier` never appears.
The way out is that only *half* of it has that dependency. Settling is awaited by this package's `init()` wrapper before it delegates; connecting stays in the published call, awaited where a session exists.
**The invariant is carried by the composition from `init()` onwards** — which is why an application must call this package's `init`, not the one it injected: calling it settles the identity whether or not the published call has run.
It does **not** cover calling the published sign-in strictly first and awaiting it: the connection work it adds waits for a session only `init()` resolves, so it deadlocks in silence. Half an invariant carried by a mechanism, half still owed to the caller — and the half still owed fails silently, which is the worst kind.
## The failure this cost
Settling once reached the session, because recording who is acting also fired the connection. At the time the session was assembled by the application: it built a promise around `init()`'s callback and handed the package a thunk reading it. The application called `init()` *from inside the executor building that promise*, so the thunk could not answer by construction. It threw, the account lookup answered null, and the connection run abandoned **without restoring or draining** — having already registered itself as in flight. The published call then joined that dead run and resolved having done nothing.
Symptom: a document shared with someone did not open for them. No error, just unreadable content.
Nothing had changed in the connection logic. What changed was *when* the identity was recorded. Before the split, nothing recorded an identity during module evaluation: a session existed, the run was healthy, and joining it was harmless.
Hence the shape of the fix — recording who acts and starting to connect are separate operations, and the session-free half only records. The session is now the package's own — its `init()` wrapper captures the event — so no application can wire this wrong again. To validate: nothing reachable from settling asks for a session.
## The lesson worth keeping
A "session-free" half that calls something which fires a session lookup is not session-free. When splitting on a dependency, check what the *side effects* of each remaining call reach, not only what the call itself does.
## Failing to reach a queue is not failing to apply one item in it
Making connection failures surface was right — a restore that did not happen makes shared documents silently invisible, and resolving as though it had is the defect family this project keeps finding. But applied to *every* step alike, that rule locked people out.
A deposit that cannot be applied is not consumed by failing. So a single unusable item denied the session, and denied it again at every future connection: not a missing share, a person who can never sign in. Strictly worse than the silence it replaced.
The distinction to keep: **reaching** the queues, and the restore itself, are infrastructure — if they fail, the session genuinely cannot proceed, and rejecting is right. **Applying one item** is data — it must be reported loudly, the remaining queues must still be drained, and the session must still be granted.
The general shape, worth carrying to any similar rule: when a blanket "every failure surfaces" is imposed, ask which failures are *retried by simply trying again later* and which are *permanent for the actor*. The permanent ones must never gate something the actor cannot otherwise obtain.
## Connecting does not end when the drain does
The drain is the backlog; being connected is a regime. An identity that stays connected has its inboxes watched throughout, so a deposit made in front of it is applied as it arrives rather than waiting for a reload, and an inbox opened later in the session joins the watched set.
That third step runs whatever became of the two before it, and the failure it was added for says why. A restore that rejected used to skip it, leaving an identity connected — recording who acts is synchronous and had already taken effect — with nothing watching its inboxes for the rest of the session. One broker hiccup while signing in then cost that person every deposit made from then on, in silence, long after the broker had recovered. What the caller is told has not moved: reaching the queues still rejects. What changed is that being connected now *means* being watched, whatever the connection made of its own work.
The counterpart is that the watching belongs to one identity and dies with it. Recording a different identity — or none, which is a disconnection and not merely "no new work" — stops it, because every step of it resolves the current holder as it runs: left going, it would read the previous identity's registers under the new one and file the previous one's `ReadCap`s into the new one's hands. Whoever connects next starts their own.
There is deliberately no timer anywhere in this: the regime is push-driven, and its known cost — a watch that failed to open is re-opened only by a later event, so a session that only reads never gets it back — is stated to applications under the contract's non-guarantees rather than papered over with polling.
+42
View File
@@ -0,0 +1,42 @@
# Inter-repo contracts. `publish:` is this project's engagement toward its consumers —
# listing a leaf here IS the act of publishing it; an unlisted `contract_` leaf is a draft.
#
# This project is a PROVIDER of two interfaces, each hosted in an interface folder inside
# its owning concept, with one `usage_<consumer>.md` per consumer beside the engagement:
#
# polyfill-surface (concepts/app-contract/polyfill-surface/)
# consumers: the Festipod application (its own repo), `ng-helpers`
# ng-e2e-helpers (concepts/e2e-harness/ng-e2e-helpers/)
# consumers: this repo's own polyfill e2e suites (same repo — declared in place,
# nothing to pull), `ng-helpers`, any NextGraph application
#
# `consume:` here is the PROVIDER side of the pair: we pull each consumer's own declaration
# back, so we can see what is actually relied on and read its Frictions. We consume no
# engagement — the NextGraph clone this library aligns on is a read-only reference we verify
# against, not a contract anyone stamps.
#
# `pullFrom:` values are CANONICAL remote identities, because this file travels with the
# branch. Per-developer local access lives in `.project/contracts.local.yaml`, which is
# gitignored and must never be committed.
publish:
# paths are relative to `.project/`
polyfill-surface: concepts/app-contract/polyfill-surface/contract_polyfill-surface.md
ng-e2e-helpers: concepts/e2e-harness/ng-e2e-helpers/contract_ng-e2e-helpers.md
consume:
# Festipod is NOT listed: it has not declared a usage leaf, and we do not author one for
# a consumer whose repo is not ours. An interface with no declared consumer degrades to
# the one-document mode, which is what `polyfill-surface` is for Festipod today.
- contract: ng-helpers-uses-polyfill-surface
type: git
pullFrom: git@gitea.reconnexion.apps.gueraud.net:Sylvain/ng-helpers.git/.project/concepts/indexing/polyfill-surface/usage_ng-helpers.md
ref: main
into: concepts/app-contract/polyfill-surface/
- contract: ng-helpers-uses-ng-e2e-helpers
type: git
pullFrom: git@gitea.reconnexion.apps.gueraud.net:Sylvain/ng-helpers.git/.project/concepts/indexing/ng-e2e-helpers/usage_ng-helpers.md
ref: main
into: concepts/e2e-harness/ng-e2e-helpers/
+3
View File
@@ -0,0 +1,3 @@
## Project vocabulary (always loaded)
@.project/VOCABULARY.md
+107 -28
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,30 +51,25 @@ 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 `shared-wallet/account-registry.ts` |
| Three native stores per identity | Places entities by scope `public` / `protected` / `private` | The identity's three real native stores hold the entity documents | Not-yet-implemented: `doc_create`/ORM can target only the private (and protected) native store today; a `public`/arbitrary `StoreRepo` is not JS-constructible | Three emulated scope-index documents per account — each "store" is an index doc listing its entity-doc NURIs; all physically live in the one shared private store, and scope is a logical label | | 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; `emulated-verifier/caps.ts` caches them for the session. A read filter (`emulated-verifier/read-filter.ts`) plus the boundary (`emulated-verifier/reach.ts`) keep only documents whose cap is held. The cap value is the stand-in `OK` — enforcement is P1b |
| Directed read sharing | Owns the relationship concept ("who is connected to whom") itself, and 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`) |
| Current identity | Sets the current identity id (established at wallet import) via the SDK's current-identity call | Opening one's own wallet at the broker gate establishes the session identity | Not-yet-implemented for the shared-wallet case: everyone shares one wallet, so the broker cannot distinguish identities | A relayed id (`accounts.ts` `IdentityStore` persists it); the read filter and inbox `from` read it | | Current identity | Sets the current identity id (established at wallet import) via the SDK's current-identity call | Opening one's own wallet at the broker gate establishes the session identity | Not-yet-implemented for the shared-wallet case: everyone shares one wallet, so the broker cannot distinguish identities | A relayed id (`shared-wallet/virtualUsers.ts` `IdentityStore` persists it); the read filter and inbox `from` read it |
| Write-guard | Writes refused without the write cap | The broker/verifier enforces the write cap natively | Partial: the guard fires only on the public proxy, but the real write paths call the injected `ng` directly (the `DataCloneError` constraint), so it is best-effort today | A `sparql_update` override (`ng-proxy.ts`) checking the emulated write cap | | Write-guard | Writes refused without the write cap | The broker/verifier enforces the write cap natively — writing is repo membership (`verify_permission`, reachable only from `Commit::verify`) | Not-yet-implemented for delegation: this library emulates no `AddMember`/`AddPermission`, so only a document's OWNER writes — a repo's upstream state until someone is added | An ownership check at the write door (`emulated-verifier/reach.ts` `assertMayWrite`, reading authorship from the Store branch). The older write-cap proxy (`surface/ng-proxy.ts`) is **inert**`grantWrite` has no production caller, so its policy set is always empty |
## Packages ## Packages
| Package | Role | | Package | Role |
|---|---| |---|---|
| `@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/polyfill` *(was `@ng-eventually/client` until 2026-08-07)* | The SDK-identical wrapper the app imports instead of `@ng-org/web` / `@ng-org/orm`. It adds the polyfills the broker/verifier will do natively (shared-wallet identity, capability enforcement, anticipated cap/inbox methods). As NextGraph matures, the app points back at the real SDK (build alias removed) and this package falls away. |
A global-index package is deferred. 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 SDK wrapper. 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,35 +77,120 @@ 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
of the recipient's own inbox processing. of the recipient's own inbox processing.
- Tests of the polyfill (against a real broker) live in this repo, so a consuming - Tests of the polyfill (against a real broker) live in this repo, in **two** suites,
app can test its features against a clean, mocked API. and the split is deliberate: `packages/polyfill/e2e/run.ts` (`test:e2e`) characterises the
primitives and the platform contracts, while `packages/polyfill/e2e/notebook.ts`
(`test:e2e:app`) drives the example application through the DOM, one browser page per
identity. Only the second can tell whether an application is *writable* — a harness
can pass a value between two identities through a variable, and an application cannot.
A consuming app can test its own features against a clean, mocked API.
## Status ## Status
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 — `shared-wallet/account-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 — `emulated-verifier/caps.ts` (`CapRegistry`, per-document, directed grants) +
read filter `read-filter.ts` (reactive-set `Proxy` view), applied by read filter `emulated-verifier/read-filter.ts` (reactive-set `Proxy` view), applied by
`use-shape.ts` only when a policy is declared. `surface/use-shape.ts` only once a cap exists (`caps.isEnforcing()`).
- Write guard — `ng-proxy.ts` (`sparql_update` override, emulated write cap). - Write guard — `surface/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 — `shared-wallet/virtualUsers.ts` (`IdentityStore`, injected storage).
- SPARQL hardening — `sparql.ts` (`escapeLiteral` / `escapeIri` / `assertNuri`). - SPARQL hardening — `sparql.ts` (`escapeLiteral` / `escapeIri` / `assertNuri`).
The remaining `TODO` markers are narrow: the shared-wallet credential passthrough The remaining `TODO` markers are narrow: the shared-wallet credential passthrough
+297
View File
@@ -0,0 +1,297 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "ng-eventually",
"devDependencies": {
"@types/bun": "latest",
"playwright": "^1.61.1",
"typescript": "^5.6.0",
},
},
"examples/notebook": {
"name": "@ng-eventually/example-notebook",
"dependencies": {
"@ng-eventually/polyfill": "workspace:*",
"@ng-org/web": "0.1.2-alpha.13",
},
},
"packages/ng-e2e-helpers": {
"name": "ng-e2e-helpers",
"version": "0.0.0",
"devDependencies": {
"@ng-org/web": "0.1.2-alpha.13",
"playwright": "^1.61.1",
},
"peerDependencies": {
"@ng-org/web": ">=0.1.2-alpha.13",
"playwright": ">=1.40.0",
},
},
"packages/polyfill": {
"name": "@ng-eventually/polyfill",
"version": "0.0.0",
"devDependencies": {
"@ng-org/alien-deepsignals": "0.1.2-alpha.11",
"@ng-org/shex-orm": "0.1.2-alpha.8",
"@ng-org/web": "0.1.2-alpha.13",
},
"peerDependencies": {
"@ng-org/alien-deepsignals": "*",
"@ng-org/orm": "*",
"@ng-org/shex-orm": "*",
"@ng-org/web": "*",
},
"optionalPeers": [
"@ng-org/alien-deepsignals",
"@ng-org/orm",
"@ng-org/shex-orm",
"@ng-org/web",
],
},
},
"packages": {
"@ldo/traverser-shexj": ["@ldo/traverser-shexj@1.0.0-alpha.28", "", { "dependencies": { "@ldo/type-traverser": "1.0.0-alpha.28" } }, "sha512-N06+LOWhv6//unPRLbFMd56MqPf5lO2ihZgle9hNLmxt6QJmNrZM3oXzHCL3TfDu4OT1/NUZp3kj2HmztQIZkg=="],
"@ldo/type-traverser": ["@ldo/type-traverser@1.0.0-alpha.28", "", { "dependencies": { "uuid": "8.3.2" } }, "sha512-pGMIVxLzoLjYVhADuVhg6r5ZDNleXZ9DcyIvLXo1/ADEocLnysg/Xjk9D/7l/Rw3WtDJrTFOOtBv8OnH+VPgKA=="],
"@ng-eventually/example-notebook": ["@ng-eventually/example-notebook@workspace:examples/notebook"],
"@ng-eventually/polyfill": ["@ng-eventually/polyfill@workspace:packages/polyfill"],
"@ng-org/alien-deepsignals": ["@ng-org/alien-deepsignals@0.1.2-alpha.11", "", { "dependencies": { "alien-signals": "2.0.8" } }, "sha512-nPgqOrheAda/pW5FHgSb45SrSZWuyMyEVqO683ijEsVPpD105bngfh92PPfcRoRnFzGSoKXa3CfuqUHi2+qVIQ=="],
"@ng-org/shex-orm": ["@ng-org/shex-orm@0.1.2-alpha.8", "", { "dependencies": { "@ldo/traverser-shexj": "1.0.0-alpha.28", "@ldo/type-traverser": "1.0.0-alpha.28", "@shexjs/parser": "1.0.0-alpha.28", "commander": "14.0.3", "dts-dom": "3.6.0", "ejs": "3.1.10", "fs-extra": "10.1.0", "jsonld2graphobject": "0.0.5", "loading-cli": "1.1.2", "prettier": "3.9.5" }, "peerDependencies": { "typescript": "5.9.3" }, "bin": { "rdf-orm": "dist/cli.js" } }, "sha512-jogxiLfUtI3WkuF5O3PPwRBRWylpapk/JlvElGUqZnEavXXlEMmEGbK3VwqMUsbp1pweNA0sEKIFLIk3Lzt2Gg=="],
"@ng-org/web": ["@ng-org/web@0.1.2-alpha.13", "", { "dependencies": { "async-proxy": "0.4.1" } }, "sha512-/xO0c+3NTphnws5Do2LDqgZWmAf+aNnYdChJKdU0dnp1U1iVSgi/y3yb8AYryf0v9sooj0aYJxt08B6DpirFMQ=="],
"@rdfjs/types": ["@rdfjs/types@1.1.2", "", { "dependencies": { "@types/node": "26.1.1" } }, "sha512-wqpOJK1QCbmsGNtyzYnojPU8gRDPid2JO0Q0kMtb4j65xhCK880cnKAfEOwC+dX85VJcCByQx5zOwyyfCjDJsg=="],
"@shexjs/parser": ["@shexjs/parser@1.0.0-alpha.28", "", { "dependencies": { "@shexjs/util": "1.0.0-alpha.28", "@ts-jison/parser": "0.4.1-alpha.1" } }, "sha512-eeVeHq/2JG9X+3h7y+7EmuBSWWl2EMj/EQBLk5CTRx4W4hWDdjWczsY8RWwKjkIzLwUS1+G0aiAI1u5LHCZ2Rw=="],
"@shexjs/term": ["@shexjs/term@1.0.0-alpha.27", "", { "dependencies": { "@types/shexj": "2.1.7", "rdf-data-factory": "1.1.3", "relativize-url": "0.1.0" } }, "sha512-+D7P7pglRPTZC2RkwaQuq+cgBZImx+61JZtcN77uEJVqcGaIscQK5hScsKhAPIo16/I+4jhIUCEFojXqw6otpg=="],
"@shexjs/util": ["@shexjs/util@1.0.0-alpha.28", "", { "dependencies": { "@shexjs/term": "1.0.0-alpha.27", "@shexjs/visitor": "1.0.0-alpha.27", "@types/shexj": "2.1.7", "hierarchy-closure": "1.2.2", "sync-request": "6.1.0" } }, "sha512-L8pBokTU/5eNRJPkC8R9SIgPw6/JDh/bHKdV5TZzf8/FkOMNJwKIy6UDHXM1I8FJ+c8u2gOOHp2MA+7b+md+0A=="],
"@shexjs/visitor": ["@shexjs/visitor@1.0.0-alpha.27", "", {}, "sha512-9s67A+f0ZZNw/SNxqoi1483CqUca8dbnHM6WDWsRH4+eXlQpQqwOZDxA8uKEaWeX4VcDrDwzWpr0WvK6EyDWIQ=="],
"@ts-jison/common": ["@ts-jison/common@0.4.1-alpha.1", "", {}, "sha512-SDbHzq+UMD+V3ciKVBHwCEgVqSeyQPTCjOsd/ZNTGySUVg4x3EauR9ZcEfdVFAsYRR38XWgDI+spq5LDY46KvQ=="],
"@ts-jison/lexer": ["@ts-jison/lexer@0.4.1-alpha.1", "", { "dependencies": { "@ts-jison/common": "0.4.1-alpha.1" } }, "sha512-5C1Wr+wixAzn2MOFtgy7KbT6N6j9mhmbjAtyvOqZKsikKtNOQj22MM5HxT+ooRexG2NbtxnDSXYdhHR1Lg58ow=="],
"@ts-jison/parser": ["@ts-jison/parser@0.4.1-alpha.1", "", { "dependencies": { "@ts-jison/common": "0.4.1-alpha.1", "@ts-jison/lexer": "0.4.1-alpha.1" } }, "sha512-xNj+qOez/7dju44LlYiTlCjxMzW5oek9EckUAElfln/GBK9vgMSk0swWcnacMr0TYbGjUQuXvL2wEgmDf5WajQ=="],
"@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
"@types/concat-stream": ["@types/concat-stream@1.6.1", "", { "dependencies": { "@types/node": "8.10.66" } }, "sha512-eHE4cQPoj6ngxBZMvVf6Hw7Mh4jMW4U9lpGmS5GBPB9RYxlFg+CHaVN7ErNY4W9XfLIEn20b4VDYaIrbq0q4uA=="],
"@types/form-data": ["@types/form-data@0.0.33", "", { "dependencies": { "@types/node": "8.10.66" } }, "sha512-8BSvG1kGm83cyJITQMZSulnl6QV8jqAGreJsc5tPu1Jq0vTSOiY/k24Wx82JRpWwZSqrala6sd5rWi6aNXvqcw=="],
"@types/http-link-header": ["@types/http-link-header@1.0.7", "", { "dependencies": { "@types/node": "18.19.130" } }, "sha512-snm5oLckop0K3cTDAiBnZDy6ncx9DJ3mCRDvs42C884MbVYPP74Tiq2hFsSDRTyjK6RyDYDIulPiW23ge+g5Lw=="],
"@types/jsonld": ["@types/jsonld@1.5.15", "", {}, "sha512-PlAFPZjL+AuGYmwlqwKEL0IMP8M8RexH0NIPGfCVWSQ041H2rR/8OlyZSD7KsCVoN8vCfWdtWDBxX8yBVP+xow=="],
"@types/node": ["@types/node@26.1.1", "", { "dependencies": { "undici-types": "8.3.0" } }, "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw=="],
"@types/qs": ["@types/qs@6.15.1", "", {}, "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw=="],
"@types/shexj": ["@types/shexj@2.1.7", "", {}, "sha512-pu/0vIZxFTMPVjTlo5MJKFkBL/EbAuFhtCXpmBB7ZdUiyNpc6pt8GxfyRPqdf6q2SsWu71a/vbhvGK2IZN2Eug=="],
"alien-signals": ["alien-signals@2.0.8", "", {}, "sha512-844G1VLkk0Pe2SJjY0J8vp8ADI73IM4KliNu2OGlYzWpO28NexEUvjHTcFjFX3VXoiUtwTbHxLNI9ImkcoBqzA=="],
"asap": ["asap@2.0.6", "", {}, "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA=="],
"async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="],
"async-proxy": ["async-proxy@0.4.1", "", { "dependencies": { "object-path-operator": "3.0.0" } }, "sha512-4e+zNtoGL4+cnqib8v169CnKcRfAsAubp2EsjBhAA5jyW7jjI3t36rVvuqLwmhtliwf8JvSnxinE4ecQN+DK4w=="],
"asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"brace-expansion": ["brace-expansion@2.1.2", "", { "dependencies": { "balanced-match": "1.0.2" } }, "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA=="],
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
"bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "26.1.1" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "1.3.0", "function-bind": "1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
"call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "1.0.2", "get-intrinsic": "1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
"caseless": ["caseless@0.12.0", "", {}, "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw=="],
"colors-cli": ["colors-cli@1.0.33", "", { "bin": { "colors": "bin/colors" } }, "sha512-PWGsmoJFdOB0t+BeHgmtuoRZUQucOLl5ii81NBzOOGVxlgE04muFNHlR5j8i8MKbOPELBl3243AI6lGBTj5ICQ=="],
"combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="],
"commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
"concat-stream": ["concat-stream@1.6.2", "", { "dependencies": { "buffer-from": "1.1.2", "inherits": "2.0.4", "readable-stream": "2.3.8", "typedarray": "0.0.6" } }, "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw=="],
"core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="],
"cross-fetch": ["cross-fetch@3.2.0", "", { "dependencies": { "node-fetch": "2.7.0" } }, "sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q=="],
"delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="],
"dts-dom": ["dts-dom@3.6.0", "", {}, "sha512-on5jxTgt+A6r0Zyyz6ZRHXaAO7J1VPnOd6+AmvI1vH440AlAZZNc5rUHzgPuTjGlrVr1rOWQYNl7ZJK6rDohbw=="],
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "1.0.2", "es-errors": "1.3.0", "gopd": "1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
"ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "10.9.4" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="],
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
"es-object-atoms": ["es-object-atoms@1.1.2", "", { "dependencies": { "es-errors": "1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="],
"es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "1.3.0", "get-intrinsic": "1.3.0", "has-tostringtag": "1.0.2", "hasown": "2.0.4" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="],
"filelist": ["filelist@1.0.6", "", { "dependencies": { "minimatch": "5.1.9" } }, "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA=="],
"form-data": ["form-data@2.5.6", "", { "dependencies": { "asynckit": "0.4.0", "combined-stream": "1.0.8", "es-set-tostringtag": "2.1.0", "hasown": "2.0.4", "mime-types": "2.1.35", "safe-buffer": "5.2.1" } }, "sha512-Ogz/E85h9tlfJzpI6TuFpGcHZFhLrb9Gw8wq9v40CxSCPnv7ahKr6Xgtkn0KYCDQJ8DNn5VoMO8EXr9V5PadyA=="],
"fs-extra": ["fs-extra@10.1.0", "", { "dependencies": { "graceful-fs": "4.2.11", "jsonfile": "6.2.1", "universalify": "2.0.1" } }, "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ=="],
"fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "1.0.2", "es-define-property": "1.0.1", "es-errors": "1.3.0", "es-object-atoms": "1.1.2", "function-bind": "1.1.2", "get-proto": "1.0.1", "gopd": "1.2.0", "has-symbols": "1.1.0", "hasown": "2.0.4", "math-intrinsics": "1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
"get-port": ["get-port@3.2.0", "", {}, "sha512-x5UJKlgeUiNT8nyo/AcnwLnZuZNcSjSw0kogRB+Whd1fjjFq4B1hySFxSFWWSn4mIBzg3sRNUDFYc4g5gjPoLg=="],
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "1.0.1", "es-object-atoms": "1.1.2" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
"has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "1.1.0" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="],
"hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="],
"hierarchy-closure": ["hierarchy-closure@1.2.2", "", {}, "sha512-ZqZvsA6HyMqrmm49D3llYA8x8hqdyDDEkaTXcqwyO+fGQlzxoeXws/5ze11M40s4EoTw7GFxdTKIwj5YDOicLQ=="],
"http-basic": ["http-basic@8.1.3", "", { "dependencies": { "caseless": "0.12.0", "concat-stream": "1.6.2", "http-response-object": "3.0.2", "parse-cache-control": "1.0.1" } }, "sha512-/EcDMwJZh3mABI2NhGfHOGOeOZITqfkEO4p/xK+l3NpyncIHUQBoMvCSF/b5GqvKtySC2srL/GGG3+EtlqlmCw=="],
"http-link-header": ["http-link-header@1.1.4", "", {}, "sha512-xT3GPW6/ZbGuw4UvwHqErSCEjNUlwbQJuZn9/q5U4WEKfp2kENVCAlousG1zLxHeaQ/ffOHUNpWamvkbBW0eNw=="],
"http-response-object": ["http-response-object@3.0.2", "", { "dependencies": { "@types/node": "10.17.60" } }, "sha512-bqX0XTF6fnXSQcEJ2Iuyr75yVakyjIDCqroJQ/aHfSdlM743Cwqoi2nDYMzLGWUcuTWGWy8AAvOKXTfiv6q9RA=="],
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
"isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="],
"jake": ["jake@10.9.4", "", { "dependencies": { "async": "3.2.6", "filelist": "1.0.6", "picocolors": "1.1.1" }, "bin": { "jake": "bin/cli.js" } }, "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA=="],
"jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "2.0.1" }, "optionalDependencies": { "graceful-fs": "4.2.11" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
"jsonld-context-parser": ["jsonld-context-parser@2.4.0", "", { "dependencies": { "@types/http-link-header": "1.0.7", "@types/node": "18.19.130", "cross-fetch": "3.2.0", "http-link-header": "1.1.4", "relative-to-absolute-iri": "1.0.8" }, "bin": { "jsonld-context-parse": "bin/jsonld-context-parse.js" } }, "sha512-ZYOfvh525SdPd9ReYY58dxB3E2RUEU4DJ6ZibO8AitcowPeBH4L5rCAitE2om5G1P+HMEgYEYEr4EZKbVN4tpA=="],
"jsonld2graphobject": ["jsonld2graphobject@0.0.5", "", { "dependencies": { "@rdfjs/types": "1.1.2", "@types/jsonld": "1.5.15", "jsonld-context-parser": "2.4.0", "uuid": "8.3.2" } }, "sha512-5BqfXOq96+OBjjiJNG8gQH66pYt6hW88z2SJxdvFJo4XNoVMvqAcUz+JSm/KEWS5NLRnebApEzFrYP3HUiUmYw=="],
"loading-cli": ["loading-cli@1.1.2", "", { "dependencies": { "colors-cli": "1.0.33" } }, "sha512-M1ntfXHpdGoQxfaqKBOQPwSrTr9EIoTgj664Q9UVSbSnJvAFdribo+Ij//1jvACgrGHaTvfKoD9PG3NOxGj44g=="],
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
"mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
"mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"minimatch": ["minimatch@5.1.9", "", { "dependencies": { "brace-expansion": "2.1.2" } }, "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw=="],
"ng-e2e-helpers": ["ng-e2e-helpers@workspace:packages/ng-e2e-helpers"],
"node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "5.0.0" } }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="],
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
"object-path-operator": ["object-path-operator@3.0.0", "", {}, "sha512-Z7dlPUeXqRU/lLfGerP24dPC66n7ehyXaTM81k71EFlsaaEjOHkf4/uq1WGicfGfiO7snYShneE1YZZUkyRiLQ=="],
"parse-cache-control": ["parse-cache-control@1.0.1", "", {}, "sha512-60zvsJReQPX5/QP0Kzfd/VrpjScIQ7SHBW6bFCYfEP+fp0Eppr1SHhIO5nd1PjZtvclzSzES9D/p5nFJurwfWg=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"playwright": ["playwright@1.61.1", "", { "dependencies": { "playwright-core": "1.61.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ=="],
"playwright-core": ["playwright-core@1.61.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg=="],
"prettier": ["prettier@3.9.5", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg=="],
"process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="],
"promise": ["promise@8.3.0", "", { "dependencies": { "asap": "2.0.6" } }, "sha512-rZPNPKTOYVNEEKFaq1HqTgOwZD+4/YHS5ukLzQCypkj+OkYx7iv0mA91lJlpPPZ8vMau3IIGj5Qlwrx+8iiSmg=="],
"qs": ["qs@6.15.3", "", { "dependencies": { "es-define-property": "1.0.1", "side-channel": "1.1.1" } }, "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A=="],
"rdf-data-factory": ["rdf-data-factory@1.1.3", "", { "dependencies": { "@rdfjs/types": "1.1.2" } }, "sha512-ny6CI7m2bq4lfQQmDYvcb2l1F9KtGwz9chipX4oWu2aAtVoXjb7k3d8J1EsgAsEbMXnBipB/iuRen5H2fwRWWQ=="],
"readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "1.0.3", "inherits": "2.0.4", "isarray": "1.0.0", "process-nextick-args": "2.0.1", "safe-buffer": "5.1.2", "string_decoder": "1.1.1", "util-deprecate": "1.0.2" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="],
"relative-to-absolute-iri": ["relative-to-absolute-iri@1.0.8", "", {}, "sha512-U1TmhrhCmXKkDL9mI8gBbF5TN6TKcuv28k5+H3gMCAjoz0TyyHAICHlaGDZsTEBSu2Y3HhDKc8e6X9n33qeIqA=="],
"relativize-url": ["relativize-url@0.1.0", "", {}, "sha512-YXet4a9wQP96Ru9MQSfoRUzsCaeboLPXj+rVG1ulH4t54zqFHiNmW6FPl7V2dTxk9uHlW3yb9+1jWO44AdWisw=="],
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
"side-channel": ["side-channel@1.1.1", "", { "dependencies": { "es-errors": "1.3.0", "object-inspect": "1.13.4", "side-channel-list": "1.0.1", "side-channel-map": "1.0.1", "side-channel-weakmap": "1.0.2" } }, "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ=="],
"side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "1.3.0", "object-inspect": "1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="],
"side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "1.0.4", "es-errors": "1.3.0", "get-intrinsic": "1.3.0", "object-inspect": "1.13.4" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="],
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "1.0.4", "es-errors": "1.3.0", "get-intrinsic": "1.3.0", "object-inspect": "1.13.4", "side-channel-map": "1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
"string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "5.1.2" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
"sync-request": ["sync-request@6.1.0", "", { "dependencies": { "http-response-object": "3.0.2", "sync-rpc": "1.3.7", "then-request": "6.0.2" } }, "sha512-8fjNkrNlNCrVc/av+Jn+xxqfCjYaBoHqCsDz6mt030UMxJGr+GSfCV1dQt2gRtlL63+VPidwDVLr7V2OcTSdRw=="],
"sync-rpc": ["sync-rpc@1.3.7", "", { "dependencies": { "get-port": "3.2.0" } }, "sha512-YHciI7TUxL8EPqz/bg01sZfwuzQA0odao1wf1Ywdtw7j5vl30aQ6s+bLRTvgPPgzr94cg+WMm6Bxi/P7BJOxgw=="],
"then-request": ["then-request@6.0.2", "", { "dependencies": { "@types/concat-stream": "1.6.1", "@types/form-data": "0.0.33", "@types/node": "8.10.66", "@types/qs": "6.15.1", "caseless": "0.12.0", "concat-stream": "1.6.2", "form-data": "2.5.6", "http-basic": "8.1.3", "http-response-object": "3.0.2", "promise": "8.3.0", "qs": "6.15.3" } }, "sha512-3ZBiG7JvP3wbDzA9iNY5zJQcHL4jn/0BWtXIkagfz7QgOL/LqjCEOBQuJNZfu0XYnv5JhKh+cDxCPM4ILrqruA=="],
"tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
"typedarray": ["typedarray@0.0.6", "", {}, "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="],
"universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="],
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
"uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="],
"webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
"whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "0.0.3", "webidl-conversions": "3.0.1" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
"@types/concat-stream/@types/node": ["@types/node@8.10.66", "", {}, "sha512-tktOkFUA4kXx2hhhrB8bIFb5TbwzS4uOhKEmwiD+NoiL0qtP2OQ9mFldbgD4dV1djrlBYP6eBuQZiWjuHUpqFw=="],
"@types/form-data/@types/node": ["@types/node@8.10.66", "", {}, "sha512-tktOkFUA4kXx2hhhrB8bIFb5TbwzS4uOhKEmwiD+NoiL0qtP2OQ9mFldbgD4dV1djrlBYP6eBuQZiWjuHUpqFw=="],
"@types/http-link-header/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "5.26.5" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="],
"http-response-object/@types/node": ["@types/node@10.17.60", "", {}, "sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw=="],
"jsonld-context-parser/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "5.26.5" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="],
"readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
"string_decoder/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
"then-request/@types/node": ["@types/node@8.10.66", "", {}, "sha512-tktOkFUA4kXx2hhhrB8bIFb5TbwzS4uOhKEmwiD+NoiL0qtP2OQ9mFldbgD4dV1djrlBYP6eBuQZiWjuHUpqFw=="],
"@types/http-link-header/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
"jsonld-context-parser/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
}
}
+742
View File
@@ -0,0 +1,742 @@
# API contract — what `@ng-eventually/polyfill` exposes today, and what the future SDK should expose per subject
> **Updated 2026-08-03, after the source layout was reorganised by migration fate** (`docs/source-layout-by-fate.md`). Paths, and three names, changed under this document: `readModel` became the directly-exported `readUnion`; `accounts` / `AccountRecord` / `AccountStorage` became `virtualUsers` / `VirtualUserRecord` / `VirtualUserStorage` (module `shared-wallet/virtual-users.ts`); `store-registry-api.ts` became `surface/placement.ts`. Two modules were created and are covered here: `emulated-verifier/branch-registers.ts` (the four durable registers, split out of the shim) and `shared-wallet/bootstrap.ts` (the injection store, split out of the `/polyfill` entry). The subject-by-subject rulings below are unaffected — what moved is where the code lives, not what it promises.
**Scope: the APP-FACING contract only.** Everything reachable from the published entry point, 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/polyfill` (verified against the `export` statements in `packages/polyfill/src/index.ts``package.json` maps exactly one entry point, `.`), 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.
### The per-symbol annotations are the authority; this document is the reasoning
Since 2026-08-16 every published symbol — and every internal one that claims to mirror the target — carries its provenance **in the source, beside the declaration**, pinned by `packages/polyfill/test/provenance.test.ts`:
```text
// @provenance <name> kind=<kind> level=<1|2|3|none> ref=<path:symbol|none> — <note>
```
It exists because this document could not stop drifting from the code: it claimed a "1:1 passthrough" for `docs.*` that was false in two ways, and `emulated-verifier/branch-registers.readLinks` was classified as this library's own invention when it implements `AddLinkV0` — a type the engine **defines** and constructs nowhere. That second mistake is the one the annotations are designed against: *nothing constructs this upstream* is an **absence**, and an absence is not evidence that a thing is ours.
The `ref` cites a **symbol**, never a line number — three citations in this document had already rotted (§ 2, § 4, § 12 below).
The prose labels above map onto the annotation's closed `kind` set, and where they disagree **the annotation wins**:
| label here | annotation `kind` | |
|---|---|---|
| PASSTHROUGH | `passthrough` | only when the name and shape are upstream's OWN, so the call site survives migration untouched |
| PASSTHROUGH | `aligned` | when the semantics are upstream's but the name or the ergonomics are ours (`docCreate` for `doc_create`, a `label` parameter that is not forwarded) — § 7, § 8 |
| LEVEL-1 SHAPE | `aligned` / `declared-not-wired` | the two are NOT the same claim: `aligned` means the engine does this and we follow it; `declared-not-wired` means the engine **defines** the type and constructs it nowhere |
| ASSUMPTION / NO COUNTERPART | `invention` | nothing at any level answers; `ref` is `none`, deliberately |
| *(no prose label)* | `divergent` | upstream does this and we do it differently — the inbox-address publication, the re-readable deposit list |
---
## 1. Bootstrap and configuration
### Today — `@ng-eventually/polyfill`: **one call**
```ts
// shared-wallet/bootstrap.ts
export interface EventuallyConfig {
ng: NgLike; // the REAL @ng-org/web ng
useShape: UseShapeLike; // the REAL @ng-org/orm useShape
pointerGuard?: { attempts?: number; baseMs?: number; maxStepMs?: number };
sharedWallet?: SharedWalletConfig; // the gate's, § 2bis
debugAccessLog?: boolean;
init?: (...args: any[]) => any;
initNg?: (...args: any[]) => any;
}
export function configure(c: EventuallyConfig): void;
```
**The count is the contract here.** The agreed target was two polyfill-era calls, or one; it had drifted to four, and each extra one was a reason the LIBRARY has rather than a need an application has. Four became one on 2026-08-07:
| Was published | Where it went |
|---|---|
| `configureStoreRegistry` + `StoreRegistryDeps` | folded into `configure` — two bootstrap calls existed because the library has two internals, which is not a reason a caller should pay |
| `setCurrentUser` | the access gate sets the identity (§ 2bis). An application naming its own identity is the gesture that INVERTS the model; it must not have a published call to reach for |
| `connectedUser` | `ensureIdentity` awaits it. Upstream, opening the session IS the connection — no application awaits a second call |
| `getConfig`, `getStoreRegistryDeps`, `resetConfig`, `resetStoreRegistry` | internal wiring and test resets, reached by their internal path (2026-08-07, with the entry merge) |
**And two FIELDS of that one call, on 2026-08-12.** The count was already one; what was left inside it still made an application build things the target never asks anyone to build:
| Was published | Where it went |
|---|---|
| `getSession` (and the `RegistrySession` type with it, § 12) | the package's. Upstream a session is **returned**`init()`'s callback delivers `{ status: "loggedin", session }` (`@ng-org/web` `dist/ngweb.js:124`, VERIFIED) and `session_start` hands one back; nowhere does an application ASSEMBLE one out of `session_id` / `private_store_id` / …. Every consumer wrapped `init()` in a promise and wrote the same unwrapping thunk, with nothing to migrate it to. The lib's `init` wrapper captures the event on its way through (§ 2) and holds the session (`shared-wallet/session.ts`) |
| `normalizeId` | the package's, as `normalizeIdentityId` — trim, strip a leading `@`, lowercase. The identities it keys are the shared wallet's own virtual users, so there was never a decision here for a consumer to make; and one rule in one place is what stops the barrier, the URL and storage keying onto three different spaces |
Both remain substitutable through `configureStoreRegistry` (`shared-wallet/bootstrap.ts`), which the published entry does not re-export: the unit suites have no browser and the e2e harness holds a session the broker handed it directly, and neither is an application.
So an application's whole bootstrap is `configure({ ng, useShape, init, sharedWallet })` plus `await ensureIdentity()` — and the second of those keeps its call site after migration.
### 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/polyfill`
```ts
// lifecycle.ts:11 — settles the identity, wraps the callback, then 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 — with one argument touched, deliberately.** `init`'s callback in position 0 is wrapped since 2026-08-12: the wrapper reads the event, keeps the session it carries (§ 1), and calls the caller's callback with that same event, unchanged. Everything else — the remaining arguments, the return value, what the callback observes — passes straight through, so an application's call site is what it would write against the real SDK. This is the only place the capture can sit: it is the only one that knows both what the caller asked and what the SDK will answer, and the alternative was every consumer re-implementing it (which is what it replaces). The real signatures forwarded 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:265-273 (source sdk/js/web/src/index.ts:15-22)
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.
---
## 2bis. The access gate — `ensureIdentity`
### Today — `@ng-eventually/polyfill`
```ts
export async function ensureIdentity(): Promise<PrincipalId>; // shared-wallet/access-gate.ts
export interface SharedWalletConfig { fileUrl: string; password: string; importUrl?: string }
```
One call, before the application renders. It resolves the identity from the URL (`?ng-id=`), failing that from browser storage — and top-level it shows the barrier anyway, with whatever it found already in the field: download the shared wallet, here is its password, import it once, and name your space. Knowing who someone is says nothing about whether their browser still holds the wallet, and the barrier is the only place it is handed out. Past the broker round-trip, inside the iframe, a known identifier stands it down.
It takes no timeout and needs none: the session it waits for arrives through this package's `init` and nowhere else, so awaited before that call it throws, naming the call to make first (`shared-wallet/access-gate.ts`, `refuseAWaitNothingCanEnd`).
### Target
**NO COUNTERPART in substance, and a surviving CALL SITE — this section exists because those two are not the same thing.**
The substance is pure scaffolding. Every step it performs exists only because one wallet hosts several identities: upstream a user opens THEIR wallet, it contains THEIR site (`SensitiveWalletV0.personal_identity()`, `engine/wallet/src/types.rs:576-579`), and `session_start(wallet_name, user_id)` takes an id that came FROM the wallet. There is nothing to name and nothing to choose. The step that takes an identifier is the one that inverts the model, and it is the reason the whole gate is scaffolding.
The call site is a different matter. An application still has to wait for a session before it renders, and that will still be one awaited call at the same place. So the signature was designed to survive: **it takes no identifier and RETURNS one**, deliberately. Naming an identity is the part that disappears, so it must not be a parameter; but knowing which identity you are is something an application legitimately has upstream — it passes `user_id` to `session_start(wallet_name, user_id)` (`index.d.ts:276`), having got it from the wallet it opened. Here the gate chooses it, so the gate hands it back. Without that, the example application had to read the gate's own private storage key.
What a consumer must NOT conclude:
- that it may pass an identity in (it cannot — that is the point);
- that the barrier is a product screen. It is a technical gate, rendered in plain DOM inside a shadow root so no application stylesheet reshapes it and its own leaks nowhere. It is deliberately not bound to a UI framework: a screen that is going away must not make every consumer adopt one.
- that `SharedWalletConfig` describes a user setting. It describes what a DEPLOYMENT hands out, and it disappears with the gate. The library reads no environment variable, ever — the application resolves these values at its own build and passes them.
Why it lives here and not in the consumer application: the first consumer had ~300 lines of it — a gate component, a screen, a wallet module, an identity context, three BDD features. That is code an application would have to delete, and worse, code that teaches its authors a model NextGraph does not have (*"I name my identity"*).
---
## 3. The `ng` object
### Today — `@ng-eventually/polyfill`
```ts
// index.ts:152
export const ng: NG;
// type re-export, index.ts:86
export type { NG } from "@ng-org/web";
```
`ng` is a `Proxy` (`ng-proxy.ts:26`) forwarding every property to the injected real `ng`, overriding exactly one thing: `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). `session_start` is intercepted but is a pure passthrough today, with a TODO for shared-wallet credentials. (`login` was named here until 2026-08-14 but has not existed since 2026-08-03: `@ng-org/web` exposes no such method, and the proxy was FABRICATING it.)
### Target
**PASSTHROUGH (level 2, VERIFIED).** `export declare const ng: NG` with `NG = typeof NGModule`, **88** exported members (`index.d.ts:136-231`; the namespace body is `:140-231`). The surface is identical by construction — the proxy adds no member and removes none — **and identical at the type level too since 2026-08-14**, when the published `ng` stopped being declared `Record<string, any>` and took `NG`, upstream's own type. That escape hatch silently dropped all 88 signatures: an application got no completion, and a misspelt member typechecked.
The 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 cap-enforcement.
---
## 4. Reactive typed reads — `useShape`
### Today — `@ng-eventually/polyfill`
```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)
// *(Corrected 2026-08-16: this said `sdk/js/orm/src/model/types.ts`, a path that does not
// exist in the clone. The type is at `sdk/js/orm/src/types.ts:25`. A citation nobody can
// follow reads as verified and is not — the reason the per-symbol annotations below cite a
// SYMBOL rather than a line.)*
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/polyfill`
```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>;
```
**A scope that did not answer is `isError`, never `isSuccess` with `data: []` — since 2026-08-17.** Step 1 of the pipeline asks `listMyEntityDocs` which documents are mine in this scope. That failure used to be caught and logged, and the empty set flowed on: a barrier over zero documents is trivially reached, so the surface published `{ data: [], isPending: false, isSuccess: true }` — byte for byte the synced-but-empty snapshot, which means the one distinction this module exists for was the one it destroyed. An application rendered "you have created nothing" for "the store did not answer". `listMyEntityDocs` had stopped handing out that reading the day before (see § 12); catching it here put it straight back one floor up. It now travels the LOAD-STATE channel, which is where "this is not an answer" already lives on this surface, and which an application must already read to tell pending from empty — so the third state costs it no new vocabulary.
**`data` survives an error rather than emptying.** A one-shot call rejects and is done; an observable has already handed a list to a subscriber that rendered it, and cannot un-emit. Collapsing `data` to `[]` on failure would put the empty answer back in the one field a view actually paints, for exactly the case that must never read as empty — so `data` keeps the last read that ANSWERED, `isSuccess` stays false so that array is never offered as a reply to the question that just failed, and the subscriber is notified so a view gating on the load state can say so. Before any answer there is nothing to keep and `data` is `[]`, published under `isError` and never under `isSuccess`.
### Target
**Partly ASSUMPTION — flagged deliberately.** `surface/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.
**But `readyPromise` answers two of those three questions, not three — VERIFIED 2026-08-17, and it bears on `isError` specifically.** It is constructed with `resolve` alone (`new Promise<void>((resolve) => { this.resolveReady = resolve; })`, `GraphOrmSubscription.ts` constructor), nothing anywhere rejects it, and `resolveReady()` is called on one path only — the arrival of initial data. The subscription that fails does not settle it: `orm_start_graph` is awaited inside a `try` whose `catch` is `console.error(e)`, so the promise stays pending forever and every `await this.readyPromise_` behind it hangs. Upstream's "I could not find out" IS its "still pending". So `isPending` and `isSuccess` map onto something real at level 3, while `isError` has **no counterpart at any level** — it is this library's third state, and the reason it exists is that swallowing a failed read into a plausible-looking result is the shape that took this project a week to close everywhere else. A consumer should keep reading it; if the future hook ships without one, the failure is the pending that never ends, which is a worse thing to have to render, not a reason to have learned less.
---
## 6. One-shot listing — the read-model
### Today — `@ng-eventually/polyfill`
```ts
// read-model.ts:60
export interface UnionSubject {
subject: string; // the subject IRI as the document carries it — any IRI, not a Nuri
graph: Nuri; // the document reference the caller passed, unchanged
props: Record<string, string[]>;
}
// read-model.ts:166
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.
**Grouping is per (document, subject), and subjects come back as written.** A document holding several subjects yields several entries — one each, properties never merged across subjects; the same subject IRI seen in two documents stays two entries, told apart by `graph`. This matches level 3, where an object carries `@id` and `@graph` as two distinct read-only properties and the ORM fabricates an `@id` when the writer leaves it empty (`sdk/js/orm/src/connector/GraphOrmSubscription.ts`, `":q:"`) — several objects per graph is the provided case, and `@id` is what distinguishes them inside a `@graph`. Only `graph` is a `Nuri`; `subject` is typed `string` because an RDF subject may be any IRI. One document per business entity remains the recommended placement (a key is per repo, so isolating an entity needs a repo of its own), but that is a recommendation about writing — the read reports what is there rather than making the other arrangement invisible.
### 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/polyfill` (namespace `docs`)
```ts
// docs.ts:53
export async function docCreate(
sessionId: string | number,
crdt: string,
cls: string,
dest: string,
store?: unknown,
): Promise<Nuri>;
// docs.ts:101
export async function sparqlUpdate(
sessionId: string | number,
query: string,
anchor?: Nuri,
label = "sparqlUpdate",
): Promise<unknown>;
// docs.ts:146
export async function sparqlQuery(
sessionId: string | number,
query: string,
base?: string,
anchor?: Nuri,
label = "sparqlQuery",
): Promise<unknown>;
// docs.ts:113 — machinery, see § 15
// NOT published since 2026-08-07 — moved to `emulated-verifier/register-write.ts`.
// It skips the boundary by design ("the one write that legitimately crosses"), and a door
// that skips a guard must not be one an application can open: holding nothing but a public
// document's bare reference, one could rewrite the inbox address posted on it and divert
// every deposit meant for its owner. Go through `inbox.post` / `inbox.share`.
```
### Target
**Annotated `aligned` (level 2, VERIFIED) — not `passthrough`, and the distinction is the point.** These forward to the real methods with the same arguments in the same order, but the names are ours (`docCreate` for `doc_create`) and they take a trailing `label` (a lib-internal access-log tag) the real signatures have no room for. So the *semantics* survive migration and the *call site* does not — which is exactly what `aligned` says and what a blanket "PASSTHROUGH" hid. *(This entry said PASSTHROUGH until 2026-08-16; it had already been caught claiming a 1:1 mirror, false in two ways, both fixed rather than documented as deltas:)*
- **The session id is `string | number`, upstream's own declared type for it** (`Session.session_id`, `sdk/js/web/src/index.ts:16` and the installed `index.d.ts:266`) — it used to be narrowed to `string` here, which made the value the SDK hands an application impossible to pass back into this library. It is RELAYED, never converted: the wasm side deserializes a `u64` (`sdk/js/lib-wasm/src/lib.rs:352-358` `sparql_query`, `:452-457` `sparql_update`, `:1575` `doc_create`), and stringifying it fails that deserialization for real — observed as `Deserialization error of session_id JsValue("1")`.
- **`sparqlUpdate` returns what the real method returns** — the commits the update produced (`lib.rs:481-483` serialises `AppResponseV0::Commits`; the installed `index.d.ts:297` types it `Promise<any>`). It was declared `Promise<void>` while already relaying the value at runtime, so the answer was thrown away for every caller. Typed `unknown` rather than `any`, exactly as `sparqlQuery` already renders the same upstream `Promise<any>`. A caller that ignores it is unaffected.
The real signatures forwarded to:
```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 — and it is no longer published (see the block above).
**Store targeting — finer than "not JS-constructible".** *(The other docs were corrected on 2026-08-03 to match this entry; they used to state the blanket form.)* Verified in the clone:
- 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/polyfill`
```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
**Annotated `aligned` (level 2, VERIFIED) — the deltas below are what makes it `aligned` rather than `passthrough`:**
```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/polyfill` (namespace `inbox`)
```ts
export interface Deposit {
from: PrincipalId | null;
payload: unknown;
ts: number;
}
export interface PostOptions {
from?: PrincipalId | null;
payload: unknown;
ts?: number;
}
export async function post(targetInbox: NuriLike, opts: PostOptions): Promise<void>;
export async function postToDocument(doc: NuriLike, opts: PostOptions): Promise<void>;
export async function share(doc: NuriLike, toUser: string): Promise<void>;
export async function read(targetInbox: NuriLike): Promise<Deposit[]>;
export async function readForDocument(doc: NuriLike): Promise<Deposit[]>;
// `materialize` — a second published name for `read` — was REMOVED on 2026-08-14. It was
// an alias and nothing else: no call site, and upstream has no such member, so it was a
// symbol an application could learn and would then have to unlearn. Use `read`.
export async function readSynced(targetInbox: NuriLike): Promise<Deposit[]>;
export async function readSyncedForDocument(doc: NuriLike): Promise<Deposit[]>;
export async function processInbox(targetInbox: NuriLike): Promise<Deposit[]>;
export function watch(
targetInbox: NuriLike,
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 address→repo association lives in `inboxes: PubKey → RepoId`, a table of the **verifier** — one per user, `verifier.rs:105`). *(Corrected 2026-08-10: this said the table was "session-local, rebuilt empty". It is initialized empty (`verifier.rs:520,2820`) and then repopulated at every load — `Verifier::load` → `add_repo_without_saving` → `add_repo_`, `verifier.rs:534-566,2871,2887` — with the inbox private key persisted per repo, `user_storage/repo.rs:61,171,207,362`. The property that matters is that it is **per verifier**, not that it is ephemeral.)* Documented in `docs/briefs/2026-08-03-document-inbox-addressing.md`.
- `share` — 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` / `readSynced` / `readSyncedForDocument` / `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.
- `readForDocument(doc)` — the owner's side of a document's inbox, named by the DOCUMENT. Same LEVEL-1 SHAPE ruling as `read`: it is the recipient's own processing, which has no consumer-facing JS surface upstream, and enumerating its deposits is emulation detail. It exists so an application never handles an inbox address. It is the WARM form (it delegates to `read`), so on a session that has just loaded it can answer `[]` for a document that has deposits.
- `readSyncedForDocument(doc)` — added 2026-08-17, the intersection the surface was missing: `readSynced`'s barrier on `readForDocument`'s address. **What an application no longer does: resolve an inbox address itself.** It had to, because materializing deposits needs both halves and only one call carried each — and resolving an address is the exact gesture § *Guarantees* says an application never performs. The document-addressed path crosses TWO repos and a cold session loses the answer at either: the address is read off the DOCUMENT's Header branch, so an unsynced document reads as "no inbox"; the deposits are read off the INBOX, which is what `readSynced` gates. This gates both, document first. Same LEVEL-1 SHAPE ruling as `read` and the same fate — it adds no divergent ACT, it composes two published ones so the caller does not have to hold an address to reach them (`packages/polyfill/test/cold-read-for-document.test.ts` pins the pair: on one cold state, `readForDocument` answers empty and this answers the message).
- `share(doc, toUser)` **refuses an unknown recipient** since 2026-08-10. It used to provision one: a mistyped name minted that name's stores and an inbox, and the cap landed where nobody looks. Upstream a deposit is sealed to an inbox pubkey that reached you through an inbound contact, so you cannot address a name you invented.
- `watch`'s `_opts?: { intervalMs?: number }` is accepted and **ignored** (kept for signature compatibility with a removed polling watcher) — dead surface, see § 15.
---
> **Known divergence, low impact today — `inbox.share` always deposits on the recipient's PROTECTED inbox.** Upstream the choice follows the profile through which the person was reached (`a_or_b = if details.profile.is_public() { "site" } else { "protected" }`, `engine/verifier/src/inbox_processor.rs:787`). This library has no notion of "which profile I know this person by", so it picks one. It flattens a distinction the model makes; it will be wrong the day an application shares with someone met through a public profile. Recorded rather than fixed, because the fix needs a notion nothing here has established — note that the `Identity` enum that would name it is entirely commented out upstream (`engine/repo/src/types.rs:586-595`), so there is no profile model to read yet.
## 10. Capabilities — possession, not ACL
### Today
```ts
// @ng-eventually/polyfill — model/types.ts. The published cap surface is now ONE type.
export type Nuri = `did:ng:${string}`;
export type NuriLike = Nuri | string;
// NOT published, each deliberately:
// ReadCap — `did:ng:${string}:r:${string}`. Unpublished 2026-08-10, when
// `export * from "./model/types"` became a named list. It remains the library's
// internal type for a cap-bearing reference, but NO published signature takes or
// returns one: within `surface/inbox.ts` only two private helpers use it
// (`capsSeenIn`, `capOfPayload`), plus the emulated registers. Publishing it named
// the one value the model says must never be handed over on request (§ 0 of
// `readcap-and-nuri-model.md`) — while leaving no published call able to produce
// one, since `linkTo` was removed and `mintCap` is unreachable (§ 11). A type whose
// only possible use by a consumer is a cast is worse than no type. See § 14.
// InboxScope — unpublished the same day, same rule: its only user is
// `account-registry.userInbox(id, scope)`, which is not published (§ 12).
// isNuri / hasReadCap — the type guards (`model/nuri.ts`). Unpublished since the
// permissive-in change: every entry takes `NuriLike` and validates at the door, so
// a consumer holding a plain string narrows nothing. Publishing a guard would
// invite the cast it exists to prevent.
// hasCap(doc) — removed 2026-08-06. It read like "may I read this?", and a
// document in a public store answers `false` until something asks for its cap.
// getCaps / CapRegistry / resetCaps — the emulation's engine room and its test reset.
// INTERNAL — `emulated-verifier/caps.ts` (class CapRegistry). Never published; listed for the maintainer.
constructor(holder?: () => PrincipalId | null);
mint(nuri: Nuri): ReadCap;
learn(cap: ReadCap): void;
capFor(nuri: Nuri): ReadCap | undefined;
learnFromPublicStore(cap: ReadCap): void; // a cap the public store SERVED — read only
isReadOnlyPublicCap(nuri: Nuri): boolean;
markInPublicStore(nuri: Nuri): void;
isInPublicStore(nuri: Nuri): boolean;
open(nuri: Nuri, scope: Scope): ReadCap;
isEnforcing(): boolean;
onChange(listener: () => void): () => void;
grantWrite(doc: Nuri, principal: PrincipalId): void; // decorative until cap-enforcement
governsWrite(doc: Nuri): boolean; // decorative until cap-enforcement
canWrite(doc: Nuri, principal: PrincipalId | null): boolean; // decorative until cap-enforcement
hasWritePolicy(): boolean; // decorative until cap-enforcement
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 `r:` segment and its encoding are upstream's**, reported by NextGraph's developer and verified in that function: id and key are serialized together into ONE opaque segment, unlike the `:k:` object/file/commit forms where they are two. The lib's `ReadCap` template-literal type uses that segment, with the stand-in constant `OK` in place of the key material.
- **"cap-enforcement swaps the value, not the shape" is a BET, and this section stated it as a fact until 2026-08-10.** What the source establishes is narrower, in three readings: (a) `readcap_nuri()` is produced as a **field value**, never concatenated onto a NURI — every call site fills `AppTabBranchInfo.readcap: Option<String>` (`engine/net/src/app_protocol.rs:1334`; `engine/verifier/src/verifier.rs:278,320`; `rocksdb_user_storage.rs:162,172`); (b) **no upstream parser accepts a repo NURI carrying `:r:`**`NuriV0::new_from` (`app_protocol.rs:643-737`) tries `did:ng:i`, `RE_REPO_O`, `RE_FILE_READ_CAP`, `RE_REPO` and `RE_BRANCH`, and none of the regexes at `engine/net/src/types.rs:48-80` has an `r:` form; (c) the slot the type *declares* for a repo read cap is a **field**`NuriV0.access: Vec<NgAccessV0>` with `NgAccessV0::ReadCap(ReadCap)` (`app_protocol.rs:54-62,192`) — itself constructed nowhere today (only `NgAccessV0::Key`, `:622`). Per the design principle none of that says the target will *not* parse a cap-bearing repo NURI; it says nothing parses one yet, so "the shape survives, only the value changes" is an assumption and not a passthrough. If the cap turns out to belong in a field, cap-enforcement moves it there instead of swapping a substring — a change the surface absorbs, because the value is opaque and nothing published parses it (§ 11).
- 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). It is not published at all: the consumer surface is the ACTS (creating a document, `inbox.share`, processing one's inbox — and, for a document in a public store, simply reading it), never a lookup; see § 15.
---
## 11. NURI and SPARQL string utilities
### Today — nothing. The entry publishes **no** string utility and **no** type guard.
```ts
// NOT published — internal, and each for a stated reason:
// surface/sparql.ts escapeLiteral, escapeIri, assertNuri
// model/nuri.ts isNuri, hasReadCap, targetOf, parseNuri, toNuri
// emulated-verifier/caps.ts mintCap (it lived in `model/nuri.ts` until the source
// layout was reorganised by migration fate; this list
// still said so until 2026-08-10)
```
Two decisions meet here, and both point the same way.
**No guard, because the doors validate.** Every public entry takes `NuriLike` (`Nuri | string`) and runs `toNuri` itself — permissive in, precise out. A consumer holding a string from storage, a URL or a form passes it straight in; publishing a guard would invite the cast the types exist to prevent, and would put validation in the caller's hands where the door already does it.
**No `mintCap`, ever.** Nothing on the surface may turn a bare reference into a cap — that is the model's central invariant (§ 0 of `readcap-and-nuri-model.md`), so the function that could is unreachable from outside.
The escaping helpers were published until the surface was narrowed. Their removal costs a consumer nothing it will miss: they are generic injection-safety utilities, and neither `@ng-org/web` nor the ORM exposes an equivalent (re-verified against `index.d.ts` and `sdk/js/orm/src` — the engine escapes ad hoc where it builds SPARQL, e.g. `update_header`, `engine/verifier/src/request_processor.rs:196-208`). An application that interpolates SPARQL writes its own two-line escaper, against this lib or the real SDK alike.
### Target
**NO COUNTERPART at any level, and none expected** — which is precisely why none of it is published: a symbol with no successor, on a surface that promises one, is the thing this document exists to catch.
---
## 12. Scope resolution, per-entity documents, and the store registry
### Today — `@ng-eventually/polyfill` (namespace `storeRegistry`) — plus `Scope` from `types.ts`
> **Narrowed twice.** 2026-08-03 the entry stopped re-exporting the whole `store-registry` module and kept an app-facing slice (`src/surface/placement.ts`). 2026-08-05 that slice lost its two inbox-ADDRESS functions as well: an application deposits with `inbox.postToDocument(doc, …)` and shares with `inbox.share(doc, toUser)` — always naming a document or a person, never an address, because upstream an address is resolved from a profile and never handled by a caller. **Five functions remain published**, listed first below; everything after them is kept for the record and is covered by `docs/internal-contract.md`.
```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
// VirtualUserRecord is INTERNAL (shape kept here for the ruling below).
interface VirtualUserRecord {
id: string;
docPublic: Nuri;
docProtected: Nuri;
docPrivate: Nuri;
}
// RegistrySession is INTERNAL since 2026-08-12 (shape kept here for the ruling below).
interface RegistrySession {
sessionId: string | number; // relayed untouched — upstream's own type (§ 7)
privateStoreId: string;
protectedStoreId?: string;
publicStoreId?: string;
}
// PUBLISHED — the whole `storeRegistry` namespace, and nothing else.
// NO identity parameter, since 2026-08-10: a session belongs to one user, and the
// target's own `doc_create(session_id, …)` carries no user at all. Passing one's own
// identity to every placement call was a gesture with no successor — and it forced an
// application to KNOW its identity, which it could only do by reading the access gate's
// private storage key. `ensureIdentity()` returns it now; these take it from the session.
export async function createEntityDoc(scope: Scope): Promise<Nuri>;
export async function listMyEntityDocs(scope: Scope): Promise<Nuri[]>;
export async function resolveScopeGraph(scope: Scope): Promise<Nuri>;
export async function resolveWriteGraph(scope: Scope): Promise<Nuri>;
export async function openDocumentInbox(doc: NuriLike): Promise<Nuri>;
// NOT published — internal, kept here because the target rulings below still cover them.
// userStoreDoc, userInbox, documentInboxAddress, isOwnInbox, myInboxes,
// addLink, readLinks, resolveAccount, ensureAccount, reservedAccount,
// resetRegistryCache, and the VirtualUserRecord type.
// `RegistrySession` joined them on 2026-08-12: it was published for ONE reason — a consumer
// typed the session thunk it injected with it — and that thunk is gone (§ 1). Upstream a
// session is RETURNED, never assembled, so no application has a session shape to declare.
```
**`listMyEntityDocs` answers a VERIFIED listing, or it rejects — since 2026-08-17.** It stands on two reads of the store document, and both propagate: the Main branch says which documents are in there, the Store branch says what opens each. Handing back the listing over a key read that never answered was tolerated until then, on the ground that the array was already in hand by that point — which is exactly what made it a half-truth rather than a shortcut, since nothing distinguishes a listing you can open from one you cannot. It is the same `Nuri[]`; the difference shows at the next read, empty, with the cause long gone. It is the ruling the connection path already runs on, applied to the last call that escaped it: **a rejection means "unknown", never "absent"**, and only "there was nothing to do" resolves quietly — so an empty array here means this account created no document in that scope, and never that the store went unread.
**`openDocumentInbox` coalesces concurrent asks — and one JS realm is the honest extent of it, since 2026-08-17.** The call reads the User branch to find out whether an inbox is already recorded and mints when the answer is no, with a dozen awaits between the two. Callers that arrive together therefore all read before any of them writes: each read ANSWERS, each answer is honestly "none", and each mints. Nothing fails, which is what sets this apart from the read-that-could-not-answer family swept through this document — no rejection-means-unknown ruling touches it, only coalescing does. Reported from an application: four simultaneous calls on one document registered three inboxes, after which the owner drained one while deposits arrived in another. Asks for the same `(holder, document)` now share one call.
**What that does NOT reach, and why it cannot be fixed here.** Two tabs share no in-flight map, so the durable fork survives — and it is not a matter of doing more work. Preventing it needs a conditional write ("record only if absent"), which no level of the target offers: a branch is an add-only CRDT, so two `AddInboxCap` records merge rather than one being refused. Reconciling it afterwards the way `canonicalDoc` reconciles a forked account pointer does not work either, because the two sides read different records — the owner resolves from `AddInboxCap` on its User branch, a depositor from the address published on the document's Header branch, and the latter is written DELETE-then-INSERT, so it is last-write-wins and need not name the same one. Making both sides agree would mean letting a document accumulate two addresses to pick a canonical one from, and that is a state the target model has no meaning for: `inboxes: PubKey → RepoId` is a function (`engine/verifier/src/verifier.rs:105`) and `repo.inbox` a single `Option<PrivKey>`. So the gap is stated in the app contract as a non-guarantee rather than papered over. At migration it closes on its own — upstream an inbox is a keypair created WITH the repo, not a document minted on demand, so there is no read-then-write to race.
### 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 is already gone from the published call (2026-08-10); expect `createEntityDoc(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`, `sdk/js/web/src/index.ts:Session`, installed `index.d.ts:265-273`). The store IS the container; the per-scope index document disappears.
- **`userInbox(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``new_repo_default``Store::create_repo_default``create_repo_with_keys`, which builds the `Repo` with `inbox: None``engine/verifier/src/verifier.rs:3004`, `engine/repo/src/store.rs:264,284,691`) 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()` → annotated `declared-not-wired` (level 1).** The emulated `AddLink { read_cap }` register (`engine/repo/src/types.rs:AddLinkV0`*"so that a user can share with all its device a new Link they received"*, external repos only). **Verified 2026-08-16, and this is the load-bearing fact:** the type is DEFINED, its `CommitVerifier::verify` is a no-op `Ok(())` (`engine/verifier/src/commits/mod.rs`), and **nothing in the engine constructs one** — the only other occurrences are the `CommitBodyV0::AddLink` enum variant and two match arms classifying body kinds. Its sibling `AddRepoV0` *is* constructed (`Verifier::send_add_repo_to_store`), which is why the Store-branch register next door is annotated `aligned` and this one is not. A maintainer read that absence and concluded the function was this library's own invention; it is not — an absent implementation says nothing about the target. 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` / `VirtualUserRecord` / `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
// PUBLISHED: nothing. Identity is established by `ensureIdentity()` (§ 2bis) and the
// connection is awaited inside it.
//
// NOT published, and each removal is a gesture an application no longer performs:
// setCurrentUser (2026-08-07) naming one's own identity — the step that inverts the
// model. The gate does it; the e2e harness, which plays
// several identities on one page, reaches it internally.
// connectedUser (2026-08-07) awaited inside `ensureIdentity`; upstream, opening the
// session IS the connection.
// getCurrentUser (2026-08-05) an application knows who it signed in.
// IdentityStore, browserIdentityStore, VirtualUserStorage, ACCOUNT_STORAGE_KEY
// (2026-08-05) persisting an identity is the application's job
// upstream too; the gate persists what IT needs.
```
### 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>;
```
- `IdentityStore` / `browserIdentityStore` (the persisted identity id) — **NO COUNTERPART**; they exist only because every virtual user shares one wallet, and they are no longer published at all. Removed at migration (`docs/migration-guide.md` § 5).
- `setCurrentUser`**NO COUNTERPART**; the relay of an identity the broker cannot see. Disappears with the shared wallet, and is no longer published: the gate is the only caller an application needs.
- `connectedUser()` (internal since 2026-08-07) — 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/polyfill` re-exports, type-only (erased at build, `src/index.ts`):
```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.
### The library's own model types — published by NAME since 2026-08-10
The entry used to say `export * from "./model/types"`, a blanket re-export publishing eight types in one gesture. It now names them, under one rule:
> **A type is published only if a PUBLISHED SIGNATURE uses it.**
```ts
export type { Nuri, NuriLike, Scope, PrincipalId, NgLike, UseShapeLike } from "./model/types";
```
Each one's warrant: `Nuri` is what every reference-returning call returns and `NuriLike` what every entry accepts (§ 10, § 11); `Scope` types `storeRegistry.*` and `watchShape` (§ 12, § 5); `PrincipalId` is `ensureIdentity`'s return and a field of `Deposit`, `PostOptions` and `EventuallyConfig` (§ 2bis, § 9, § 1); `NgLike` and `UseShapeLike` type the two injected objects in `EventuallyConfig` (§ 1).
Two types the blanket export published are now internal, each because **nothing published names it**:
- **`ReadCap`** — no published signature takes or returns one. Its users are two private helpers of `surface/inbox.ts` (`capsSeenIn`, `capOfPayload`) and the emulated registers. Publishing it advertised a value a consumer has no published call to obtain, and deliberately so: `linkTo` was removed precisely for handing one out (§ 0 of `readcap-and-nuri-model.md`), and `mintCap` is unreachable from outside (§ 11). The only use a consumer could make of it is a cast — which is what the surface's permissive-in / precise-out design exists to make unnecessary.
- **`InboxScope`** — used only by `account-registry.userInbox(id, scope)`, unpublished since 2026-08-05 (§ 12). An application never handles an inbox address, so it never names an inbox scope.
Both remain **defined** in `model/types.ts` and are used throughout the library; only their publication changed. Nothing about the target motivates either removal — this is a statement about *this* surface, and the same test that pins the appendix pins it.
---
## 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`**~~ **FIXED 2026-08-07.** It was published "only because `inbox.ts` lives in another module", with the note that a consumer must always go through `inbox.post`. That note is not a mechanism: an adversarial review drove through it — bare reference to a public document, rewrite its posted inbox address, divert its owner's deposits. It now lives in `emulated-verifier/register-write.ts`, which nothing exports.
- **`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 the acts that file caps: creating a document, `inbox.share` (grant), processing one's inbox, and reading a document a public store serves. `CapRegistry.grantWrite` / `governsWrite` / `canWrite` / `hasWritePolicy` are explicitly decorative until cap-enforcement — the guard they feed is bypassed by every internal writer.
- ~~**`storeRegistry.reservedAccount`, `resolveAccount`, `ensureAccount`, `VirtualUserRecord`, `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.
- ~~**`virtualUsers.*` 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` — 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 `virtualUsers` 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`, `readUnion`, `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".
- ~~**`share` is importable from both entries.**~~ **FIXED 2026-08-07** with the entry merge: there is one entry and one `share`, under `inbox`.
- **One entry means the import line no longer says what disappears.** Until 2026-08-07 a second import path (`/polyfill`) WAS the deletion list. It is now the `POLYFILL-ERA` block in `src/index.ts`, this appendix's note above, and the per-subject rulings in this document. That is a documentation-carried signal where it used to be a mechanical one — the appendix is pinned by a test, the grouping is not.
- **`inbox.read` 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)
*Generated from the `export` statements, and pinned by `packages/polyfill/test/vocabulary.test.ts` — if this list and the code disagree, that test fails. It went stale once, still listing `storeRegistry`'s shim internals after the entry had been narrowed, which is what a hand-maintained inventory does.*
### `@ng-eventually/polyfill` — `src/index.ts` (the only entry since 2026-08-07)
```text
direct: BaseType, DeepSignalSet, DocChange, DocChangeType, EventuallyConfig, NG, NgLike, Nuri, NuriLike, PrincipalId, Schema, Scope, ShapeObservable, ShapeQuery, ShapeType, SharedWalletConfig, UnionSubject, Unsubscribe, UseShapeLike, configure, docChangeType, ensureIdentity, init, initNg, ng, readUnion, subscribeDoc, subscribeDocs, useShape, watchShape
docs: docCreate, sparqlQuery, sparqlUpdate
inbox: Deposit, PostOptions, post, postToDocument, processInbox, read, readForDocument, readSynced, readSyncedForDocument, share, watch
storeRegistry: createEntityDoc, listMyEntityDocs, openDocumentInbox, resolveScopeGraph, resolveWriteGraph
```
**Of these, exactly ONE is polyfill-era with no target counterpart**`configure` (plus
the type `EventuallyConfig`; `RegistrySession` left the surface on 2026-08-12 with the
session thunk that was its only reason to be there). It is the deletion list, and
`src/index.ts` groups it under a heading that says so. `ensureIdentity` is a second in
substance — the shared-wallet gate — but its *call site* survives (§ 2bis).
Nine symbols published before 2026-08-07 are gone from the surface: `configureStoreRegistry`
and `StoreRegistryDeps` (folded into `configure`), `setCurrentUser` and `connectedUser`
(§ 1), `getConfig` / `getStoreRegistryDeps` (internal wiring), `resetConfig` /
`resetStoreRegistry` / `resetCaps` (test resets), and the direct `share` re-export —
`inbox.share` was always the same function, and publishing it twice blurred the boundary
it was meant to mark.
@@ -0,0 +1,145 @@
# Brief — align the caps emulation with the real NextGraph model
**Brief (incubation) — 2026-07-20.** See the reference `docs/readcap-and-nuri-model.md`.
## Problem
`emulated-verifier/caps.ts` emulates read rights as an **ACL** (`Map<Nuri, Set<PrincipalId>>`, `grantRead(doc, grantee)`) — **the inversion** of the real NextGraph model (key possession). Consequences: no notion of a **cap-less reference**, grant/revocation **instantaneous and total** (instead of durable sealing + re-key), and an API (`declareConnections`) that consumers have to **re-declare every session**. This divergence makes it impossible to properly build models that rest on the real semantics — in particular **anonymous presence** (naming/counting without reading).
## Objective: shape-fidelity, NOT security
The polyfill does **NOT match** the security of finished NextGraph, and does not try to. The shared wallet plus the absence of crypto make the emulation **deliberately insecure** (everything is in plaintext, any marker is forgeable) — a dev/staging vehicle, not a goal. **Sole objective**: expose the **RIGHT SHAPE** of the future primitives so that consumers (Festipod) are coded against the **correct mental model** and **do not have to be rewritten** when NextGraph is finished.
Corollary: **"no crypto" is not a problem**; what matters is being **in the same logic, with RIGOR**. A criticism of the form "an attacker reads the plaintext / forges a marker" is **correct but out of scope**. What is **unacceptable** = exposing the **wrong shape** (e.g. an ACL where the real thing is key possession) → the consumer codes against a model that will not exist. **The ACL inversion of ReadCaps IS that lack of rigor** — the central defect to fix.
## Enforcement mechanism: LIGHTWEIGHT crypto simulation (anti-ACL, anti-shortcut)
For the shape to be **really** key-possession (and not an ACL in disguise), a doc's data is **stored encrypted** (per-doc symmetric encryption, however lightweight) and the **ReadCap = the key**. Invariant (cf. `docs/vision.md`):
> a **bare `did` (without a ReadCap)** does **NOT** allow reading; a **NURI with a ReadCap** is **sufficient and required**.
This **prevents the shortcuts** the adversary pointed out (#4/#6: reading the plaintext, `sparqlQuery`/`inbox.read` bypassing the filter) and **forbids** falling back on an ACL — that is the heart of "same logic, with rigor".
**Target — NOT the current state**: every surface that returns data will have to go through decryption-with-key. **Today this is FALSE, and far more broadly than this brief first stated** — mapping of 2026-07-27, VERIFIED: **only 4 sites consult the caps** (`use-shape`, `read-filter`, `read-model.readUnion`, `discovery.submitToIndex`). Everything else returns data with no guard:
| Surface | State |
|---|---|
| `docs.sparqlQuery` / `sparqlUpdate` | **bypasses** — they call the injected `ng` **directly** (an accepted constraint, to avoid a double-Proxy `DataCloneError`). **The widest breach**: a session id + a NURI are enough to read everything. |
| `inbox` (`read` / `readSynced` / `materialize` / `watch`) | **bypasses** — no cap consulted; the drops go to whoever asks for them |
| `store-registry` (**zero** reference to caps in the whole file) | **bypasses** — the account→NURI root of trust is universally readable |
| `discovery.readIndex` | **bypasses** on read (caps checked on write only) |
| `subscribe`, `open-repo` | **bypass** — the subscription push carries the doc state with no check |
| `watch-shape` | deliberately delegates to `readUnion` (does not re-filter) |
**And the WRITE guard is already stillborn**: `ng-proxy` guards `sparql_update`, but `docs` bypasses the proxy **by design**, and **all** internal writers go through `docs`. So the guard only fires for an app calling `ng.sparql_update` on the exported `ng` — which Festipod does not do. `grantWrite` / `canWrite` are **decorative**. *(This finding reinforces §1 of the adversarial review: writing is not an axis "to be added", it is an axis we believed was covered and is not.)*
**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 `: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).
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).
5. **Revocation = re-key** emulated: invalidate the old token, re-deliver a new one to the remaining authorized holders; **non-retroactive**.
## ~~Widened scope: the WriteCap (= membership)~~ — DROPPED (2026-07-21)
**This section was wrong and is kept struck through as a guardrail.** It imported a notion of *membership* read from the **current state** of `nextgraph-rs` (`AddMember`, `PermissionV0`, `member_pubkey`) and promoted it into a **target shape**. But (a) those types are **inert scaffolding** at runtime — `verify_sig` / `verify_perm` are only called in unit tests, and `Repo`s are built with `members: HashMap::new()`; and (b) the target model **has no notion of membership at all**: only **keys and URLs**, symmetric and asymmetric. A shape in terms of `member`/`role`/`permission` is therefore exactly the **wrong shape** that this brief exists to prevent.
**The methodological lesson, which is worth more than the dropped section**: reading NextGraph's current state in order to **deduce** the target shape is a mistake — the current state contains unfinished work that must not be frozen into the polyfill. The source serves to verify an existing **mechanism**, never to infer an **intent**.
Erroneous content kept below as a record:
<details>
<summary>Dropped section</summary>
**Why this is here and not elsewhere.** The brief was at first ReadCap-centric; an adversarial finding showed that it **does not compose** with its consumer: the Festipod brief on "Set-based sign-ups" needs to **deduplicate** participations (one user = one participation per event), and the only non-application-level basis available is the commit's **author signature** — hence a **write** primitive, not a read one. A polyfill that only exposes the ReadCap shape leaves the consumer to invent its own application-level dedup → exactly the wrong shape.
**The real shape (VERIFIED, cf. `readcap-and-nuri-model.md` §1)** — and it is **asymmetric** with reading, which is the easiest point to miss:
- **Reading = possession of a key.** No ACL. Whoever holds, reads.
- **Writing = membership + permissions** (`AddMember`, `AddPermission` on the `RootBranch`). It really **is an authorization list** — not possession. Emulating writing "by token possession" would be just as wrong as the current read ACL, in mirror image.
- **Commits ARE signed** by a `UserId` (a **technical** key, distinct from the profile) — so a dedup identifier **exists** natively, with no application-level pseudonym.
- **But verifying a signature requires being a member of the repo** (access to the `member_pubkey`). A non-member third party sees a signed commit without being able to attribute it.
- **The inbox drop is NOT authenticated** (anonymous sealed box): a declared `from` is content, not proof.
**What that imposes on the polyfill.** Expose `membership` as a primitive **distinct** from cap possession, with at minimum: adding/removing a member of a repo, reading the members map **when one is a member**, and **verifying the author of a commit** (→ an author digest, **per-overlay hence per-store**). It is this last point that unblocks the dedup on the Festipod side.
**The shape consequence, to be documented explicitly** (otherwise the consumer picks the wrong model): the author digest being **per-store**, the choice of how stores are carved up **is** the choice of the correlation level. A **per-user stable** store gives an identifier traceable **across events**; a **per-event** store gives a pseudonym **local to the event** — dedup possible, correlation impossible. Festipod needs the second. So the polyfill must make this carving **expressible**, not freeze it.
**Still open**: "can the creator of an event be a member of the store that contains the participations, without holding its read key?" — that is, membership (writing/verification) and possession (reading) genuinely **orthogonal**. If NextGraph couples them, verified dedup and anonymity are mutually exclusive, and it is the Festipod brief that must settle what it sacrifices. **To be verified before shaping the API.**
</details>
*(Question now moot: there is no membership. The dedup does not go through signature verification — see the Festipod brief on "sign-ups".)*
## 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**.
- 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.
- 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)
**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?
- **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 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.
**Method** (cheap, decisive):
1. **Trace** in `nextgraph-rs` the broker/verifier **fetch authorization** path: who serves the blocks (`BlocksGet`/`TopicSync`/`OverlaySync`)? is a cap/membership checked, or is `id+overlay` enough? is the *outer* overlay public? is a deletion observable without the key?
2. *(Optional)* **decisive e2e test** (in the style of `e2e/reactivity-doc-subscribe.ts`): B holds the cap-less ref, attempts fetch/existence **without** the key, verifies that it **does not reach** the content. Empirical proof > source.
3. *(Or)* confirm with the NextGraph dev — the fastest.
**Deliverable**: YES/NO/PARTIAL per Q1/Q2/Q3 + the exact primitive (file:line) + the API shape to expose (if YES), or the finding that the counter changes (if NO).
**Gated decision**: YES → P1; NO → the sign-ups brief revisits the counter (not anonymous, or another primitive).
### Spike verdict (2026-07-21) — VERIFIED in `nextgraph-rs`
| | Answer | Evidence |
|---|---|---|
| **Q1 — existence/fetch without a cap** | **NO** *(corrected on 2026-07-27 — the initial "partial YES" verdict over-read the evidence)* | Read access control does indeed let you through (reads are not cap-gated) — **but addressing presupposes the cap**: no existence command at the SDK level; the only probe is internal to the crate, requires `BlockId`s **and** a loaded repo, and targets the **inner** overlay derived from the read secret. A cap-less reference has neither `BlockId` nor the required overlay. See `readcap-and-nuri-model.md`. |
| **Q2 — detect a deletion without the key** | **NO** | Append-only broker; a deletion is an **encrypted tombstone commit** (`RemoveRepo`), a no-op on the verifier side. Without the key one observes "some activity", never "a deletion". |
| **Q3 — confidentiality** | **YES** | Blocks stored as ciphertext; the key is `#[serde(skip)]` (`types.rs`), derived from the `ReadCapSecret`. Keyless **never** gives the content. |
**What that decides.**
- **P1 is unblocked**: `resolveCapLess(nuri) → { exists }` is the right shape — but **`{ exists | deleted }` is NOT**. Do not expose a `deleted` state; that would be inventing a capability the target will never have (precisely the failure mode this brief fights).
- **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.
## 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), which records what landed where.**
The ACL inversion — *the central defect this whole chantier exists to fix* — is gone: `emulated-verifier/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.
This brief remains the **overall effort**: P0 verdicts, P1b scope, P2P4 batches, and the adversarial reviews.
## Phase sketch
- ~~**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. **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.*
- **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`.
- **P4** — adapt the consumer API + `migration-guide.md`. *The adversarial review requalifies this batch: it is not an API swap but a **consumer re-architecture** (the grant moves to connection acceptance and becomes persistent; `declareConnections` disappears).*
## Adversarial review (2026-07-20) — to be integrated
An adversary refuted the brief (7 findings — the 7th marked *(Plausible)*). **To be read through the filter of the Objective above** (shape, not security). The purely **security** criticisms — readable plaintext content (#4), forgeable markers — are **ACCEPTED / out of scope**: the polyfill does not seek to prevent them. What remains are the real **SHAPE / rigor** defects (to be fixed), and a question of **future model** (#5):
1. **WriteCap forgotten, and "possession" is FALSE there.** Writing is **membership/permissions** (`AddMember`) — an **authorization list**, not key possession (ref. §1); `ng-proxy.ts:28-48` guards every `sparql_update`. → keep a **WriteCap = membership track**; **possession concerns ONLY reading**.
2. **P2 "possession without crypto" = the ACL renamed.** Without crypto, "who holds which token" = `Map<doc, Set<holder>>` = the current `readers`: **no observable delta**. The real deltas are **durability + cap-less + re-sharing by the holder** — THAT is the content of P2, not "inverting the ACL".
3. **Non-retroactive revocation NOT EMULABLE** without versioning: `read-model.ts:112-118` only reads the current state → "invalidate the old token" = total removal = the inverse of the real thing (the former holder decrypts the **prior** versions). → emulate only "no new reads after re-key" + **document non-retroactivity as non-emulable**.
4. **cap-less "without exposing the content" ILLUSORY in the emulation**: content in **plaintext** in the shared wallet; `sparqlQuery`/`inbox.read` **bypass** the filter; `read-filter.ts:30-35` is all-or-nothing. → cap-less anonymity requires either **real crypto** or a **masked read-model projection** (counting without reading). "Replacement not overhaul" is **overstated**.
5. **Keyless-fetch = INFERRED and load-bearing**: add a **P0 spike** that verifies it **before** P1 (otherwise the model — polyfill AND Festipod — is not constructible).
6. **Migration ≠ API swap.** `declareConnections` is replayed every session because the map is ephemeral; durable seals move the grant to **connection acceptance** + persist "already sealed" — no analogue of `protectedDocsOf` + the re-derivation loop. **Consumer re-architecture.**
7. *(Plausible)* delivering a cap through the async inbox **does not re-trigger** `watchShape` (which subscribes to data docs, not to caps) → unreadable views left **stale** until another change. → plan for a cap-mutation signal.
**Consequence**: add **P0 (keyless-fetch spike)** up front and a **distinct WriteCap track**; requalify P2 (the real content = durability + cap-less + re-sharing, not "inverting the ACL"); record that **without crypto, read privacy is not applicable** (choose: real crypto vs masked projection).
Links: `readcap-and-nuri-model.md`, `packages/sdk/src/emulated-verifier/caps.ts`. On the consumer side, the Festipod brief "realign the sign-ups" depends on this effort.
+329
View File
@@ -0,0 +1,329 @@
# Brief — P1a: the capability surface
**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/emulated-verifier/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/sdk/src/model/types.ts`, `src/model/nuri.ts` (internal parse/mint/derive) |
| Keyring, one per identity — `capFor` | `src/emulated-verifier/caps.ts` (`CapRegistry`), surfaced as `capFor` in `src/polyfill.ts` |
| Caps of my OWN documents (the emulated `AddRepo { read_cap }`) | `src/shared-wallet/account-registry.ts` `fileOwnCaps`, called from `createEntityDoc` and `listMyEntityDocs` |
| `shareCap(cap, toInbox)` + reception with no dedicated operation | `src/surface/inbox.ts` (`shareCap`, and the inline absorption in `read`) |
| `publishRepoLink` | `src/emulated-verifier/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/surface/read-model.ts` (`readUnion`), `src/emulated-verifier/read-filter.ts`, `src/surface/use-shape.ts` |
| Cap-mutation signal (a delivered cap re-triggers reads) | `CapRegistry.onChange``src/surface/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/sdk/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.userInbox`), 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/sdk/src/surface/docs.ts:73`, and deliberately NOT replicated in `shared-wallet/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` + `userInbox`, 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 `userInbox(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/sdk/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/sdk/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`).
## Why this lot exists
`emulated-verifier/caps.ts` currently models read rights as an **ACL** — a `Map<doc, Set<principal>>` plus `grantRead(doc, grantee)`. That is the **exact inversion** of the real model, where reading is **key possession**: whoever holds the key reads, and there is no authorization list anywhere.
This is not a security problem — the library is deliberately insecure and that is accepted (see `../vision.md`). It is a **shape** problem, and shape is the only thing this library exists to get right. A consumer coded against an ACL is coded against a model that will never exist, and will have to be rewritten.
## Scope: shape only, not enforcement
- **P1a (this brief)** — the surface consumers see.
- **P1b (separate)** — per-doc encryption and closing the read paths that bypass the guard.
Only P1a blocks the consumer, because the consumer must be written as if NextGraph were finished. P1b can follow.
> **After P1a the shape is right and the isolation is still fake.** Nothing may be claimed as "anonymous" or "private" until P1b lands. Say so in the README if it helps.
## Guiding constraint: stay close to NextGraph's concepts
Stated by the PO, and it is the acceptance criterion for the design as much as for the code:
> Stay as close as possible to NextGraph's concepts — and to its SDK's — to keep development simple and to keep the number of notions someone must discover small when they already know NextGraph and open this library.
Every invented name is **vocabulary debt**: the reader has to carry a translation table in their head. The first draft of this spec introduced eight new notions; adversarial review reduced it to two. Hold that line.
## The design
### 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 **`: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
type Nuri = string // did:ng:o:{doc}:v:{overlay} — names, does not read
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.
A parsed form `{ target, readCap? }` — a 1:1 mirror of `NuriV0 { target, access }` — may be used **inside** the library. It must not surface in the SDK-identical entry's signatures.
**Do not use branded types.** They were in the first draft and were dropped deliberately: the real SDK takes `nuri: String` and enforces at **runtime, through cryptography**. A compile-time guarantee is a concept NextGraph does not have, and a consumer who typed everything would have to *un-type* it when the real SDK arrives — the opposite of the goal. The cost was also measured: branded types force a cast at every ORM and SPARQL boundary.
### 2. The keyring — where caps come from
`doc_create` returns a **cap-less** NURI. So a rule like "no function ever goes from a bare reference to a cap" is wrong: it would leave a document's own creator unable to obtain that document's cap.
The real mechanism: on every document creation, an `AddRepo { read_cap }` is committed to a **branch of the store** (the store is itself a repo, with typed branches — "branch" here has nothing to do with git). That branch lists the store's documents, each with its read key. **It is the owner's keyring.** Upstream, the keyring is the **wallet**.
```ts
capFor(nuri: Nuri): ReadCap | undefined
```
The invariant, correctly stated:
> **You do not derive a cap from a bare reference. You look it up in your keyring — or you were given it.**
`capFor` absorbs `canRead(doc)` (`capFor(n) !== undefined`) and drops its ACL verb.
**The keyring is not the sharing mechanism.** Handing over a store cap would give away everything the store contains, present and future. That is not the gesture (see §3). This confusion is easy and expensive — it was made once already during design.
### 3. Sharing — one document, to one or more recipients
**The unit of sharing is the document**, consistent with the consumer's own doctrine ("the document is the unit of sharing and of rights").
```ts
shareCap(cap: ReadCap, toInbox: Nuri): Promise<void>
```
Recipients are addressed as **inboxes** — which `inbox.post(targetInbox: Nuri)` already does in this package. There is no `PrincipalId` here: that notion exists nowhere upstream, and the first draft removed `principal` from `canRead` (calling it the ACL inversion) only to reintroduce it here.
**Caps received need no dedicated operation.** They arrive as inbox deposits of kind `cap`, consumed by the **existing** `inbox.watch`. This also fixes a known gap: a cap delivered asynchronously now triggers a re-read naturally, instead of leaving stale views.
> **Upstream status: this is a GAP, not a disagreement.** The field exists (`ContactDetails.read_cap`, commented "*if user wants to share the content of profile*") 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. We emulate it meanwhile — filed as `orm-tests/INBOX/2026-07-27-inbox-cap-delivery-not-implemented.md`, including what to remove from this library once upstream lands it.
### 4. Key rotation — automatic redelivery, not loss of access
When a key is rotated, the new one is **sent to the inbox** of users who keep access, and that inbox is **processed automatically** as soon as one of the user's clients connects.
So access is not lost, it is **deferred** until the next connection — consistent with local-first. Consequences for the surface:
- **No subscription obligation to expose.** The consumer implements nothing to "keep" an access.
- Redelivery uses **the same channel** as the initial delivery, so §3 covers both with no special case.
- **Revocation** stays what it is: stop redelivering, non-retroactive.
> An earlier draft said the opposite ("whoever does not stay subscribed loses access"). That came from an upstream comment describing the **current state**, read as if it gave the **intention**. It does not. Source verifies a mechanism; it never states a direction.
### 5. Public content — readable by URL, and NOT recursive
> **An item in the public store is public: whoever has the URL reads the content.** But **not recursively** — public content may *reference* private content, and the reference does not grant access to what it references.
This is a **second mechanism** alongside key possession, not an exception to it. The non-recursiveness is what carries the value: it allows a public object that **points at** private identity without disclosing it — exactly the pattern the consumer needs.
*Implementation detail the shape must not depend on*: NextGraph is moving toward **not encrypting** public store content (data still signed). And if the public store does not behave as this principle describes, **this library adapts** — not the consumer.
### 6. What disappears or is renamed
| Today | Becomes |
|---|---|
| `grantRead(doc, grantee)` | `shareCap(cap, toInbox)` |
| `canRead(doc, principal)` | absorbed by `capFor(nuri)` — the `principal` parameter **was** the ACL inversion |
| `protectedDocsOf(owner)` | **removed** — the re-derivation loop disappears |
| `makePublic(doc)` | `publishRepoLink` — the shareable link has an upstream name (`RepoLinkV0`) |
| `grantWrite` / `canWrite` | deferred to P1b — currently **decorative** (the guard never fires) |
| `resetCaps()` on identity change | **switch** keyrings, do **not** wipe |
| `PrincipalId` in the cap surface | **removed** — recipients are inboxes |
`resetCaps()` is the trap that can make this lot look finished while it is not: if switching identity still wipes, durability is a lie and the per-session re-declaration comes back under another name.
### 7. Boundary: SDK-identical entry vs `/polyfill`
Caps live under `/polyfill` today; `index.ts` is the SDK-identical entry. Keep it that way, and keep `index.ts` signatures on plain strings — that **is** what the real SDK does. The discrimination lives in what you can **obtain** (the keyring), not in what the compiler permits.
### 8. Acceptance test — no cryptography required
`watch-shape` currently harvests **every** `did:ng:` string it finds in a discovery reference and folds those documents into the **read** set. A bare reference therefore grants **full read** today — the semantics exactly inverted.
After P1a: a harvested bare reference yields **nothing**, for want of a cap in the keyring — which is what real NextGraph does. The test holds without a line of encryption, which is what makes the P1a/P1b split honest rather than cosmetic.
## Consumer impact
`declareConnections` **disappears**. This is not an API swap: today it re-declares every grant on every session because the ACL is in-memory. With delivered caps, the grant moves to the moment a connection is **accepted**, and persists. Plan for consumer re-architecture, and update `../migration-guide.md`.
## What this lot does NOT do
Closing the read paths that bypass the guard — `docs.sparqlQuery`/`sparqlUpdate`, the whole inbox, `store-registry`, `discovery.readIndex`, `subscribe`, `open-repo`. Only four sites consult caps today. That inventory is P1b's scope and is listed in `2026-07-20-caps-emulation-alignment.md`.
@@ -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/sdk/src/surface/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(userInbox)` 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 `VirtualUserRecord` 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 `shared-wallet/virtualUsers.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`, `VirtualUserRecord` 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 |
| `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.userInbox(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
**`userInbox(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/emulated-verifier/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/shared-wallet/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/emulated-verifier/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.
@@ -0,0 +1,76 @@
# What a consumer's tests need from the contract, and cannot find in it
Raised by the first consumer (Festipod) on 2026-08-10, while migrating onto `@ng-eventually/sdk` against `contract_sdk-surface` @ `30f6263`. Three findings.
## 1. The contract does not say how an identity comes to be established
The first version of this brief asked for a test entry exposing identity switching, on the strength of the API contract's remark that *"the e2e harness plays several identities on one page and reaches it by its internal path, which is what a harness is allowed to do and an application is not"* — true of this library's own harness, and unreachable for a consumer's, since `packages/sdk/package.json` maps exactly one entry and the resolver refuses a deep import (verified: `Cannot find module '@ng-eventually/sdk/src/shared-wallet/access-gate'`).
**That request is withdrawn**, and the reason is worth recording because it is the library's own argument turned around. The consumer decided that its tests take no shortcut through the SDK and validate the application's behaviour rather than the SDK's. Under that rule, "two identities on one page" is not a capability to restore: it is not something a user does, it exists only because one wallet hosts several identities, and a test that used it would be testing the emulation. Multi-user behaviour gets tested the way it is lived — several browser contexts, each signing in as itself. So the surface is right as it stands, and the library should not add a testing entry on this consumer's account.
**A larger claim was drafted here and is retracted before it could mislead.** It said the contract offers no way to determine which identity comes up, and that a consumer therefore has no reproducible test isolation. That is wrong: `EventuallyConfig` publishes both `sharedWallet` and `currentUser`, so bootstrap is exactly where a caller says which wallet to open and, when it must, which identity to come up as. The consumer had simply not wired those two fields yet — its own migration was incomplete, and the library's error message said so precisely (*"no shared wallet configured. Pass `sharedWallet` to `configure()`"*). A good error message did the work the brief was trying to do.
What remains is small and is prose, not surface. `## Guarantees` describes `ensureIdentity()` as *"the whole of signing in… it resolves who you are"* without ever connecting that sentence to the two `EventuallyConfig` fields that determine the answer. The `## Surface` block carries them as bare lines in a config interface, where nothing marks them as the inputs to the one call a consumer must get right before anything renders. Naming that link — *these fields are what `ensureIdentity()` resolves from* — would have saved a wrong diagnosis here, and it is one sentence.
## 2. `watchShape`'s published signature contradicts its own types
`## Surface` publishes:
```ts
export function watchShape(query: ShapeQuery): ShapeObservable;
```
`ShapeQuery` is the **result** type (`{ data, isPending, isSuccess, isError, error }`, per `docs/api-contract.md` § 5), so as written the call takes its own return value. The signature the consumer has always called, and the one § 5 documents, is `watchShape<T>(shapeType, scope)` — two positional arguments. One of the two documents is wrong; the contract is the one consumers read.
## 3. There is no synced read for the per-document form
The inbox surface publishes `readSynced(targetInbox)` and `readForDocument(doc)`, but not their intersection. The consumer's materialization path depends on the **synced** guarantee specifically (`read` and `readSynced` differ by contract), and it addresses by document. Today it must therefore resolve an address itself to get the synced form — which is the exact gesture § *Guarantees* says an application never performs (*"an application never handles a key or an inbox address"*).
Either `readForDocument` carries the synced guarantee, or a `readSyncedForDocument(doc)` completes the pair. As it stands the document-addressed path is strictly weaker than the address-addressed one, and the contract does not say that is intentional.
## 4. Identity was taken out of the application's hands, but three published calls still demand one
This is the finding that actually cost the migration, and it is one incoherence seen from two sides.
`ensureIdentity()` returns `void`, and nothing else answers *"who am I"*`getCurrentUser` was removed on the sound argument that an application knows who it signed in. Under the previous surface that was true: the application named the identity, so it held the value. It no longer names it, and the gate that resolves it hands nothing back. So the premise the removal rested on has quietly stopped holding.
Meanwhile `storeRegistry.createEntityDoc(id, scope)`, `listMyEntityDocs(id, scope)` and `resolveWriteGraph(id, scope)` all take a mandatory `id: string`, and **the contract never says what it is**. Two readings were open. **The library has now answered, by refusing — and the answer is measured, not argued.**
The consumer bet on the harmless reading (`id` = a collection key inside the already-connected identity, which `listMy…` suggests) and routed all its call sites through one constant, because the other reading offered it no legal move at all. Running the broker-backed suite settles it in one line. Each scenario opens as its own identity — verified, the access log shows `test-…-2`, `-3`, `-4`, `-5`, `-6`, `-7` — and every one of them fails on **the same document**:
```
[test-…-2][polyfill] createEntityDoc cap append failed:
[ng-eventually] createEntityDoc:addRepo: refused — the connected user does not hold
this document's cap. Naming a document does not grant access to it.
"did:ng:o:TFYUlJQnOkYUlC4T9XtGOBoz70opW_IO21VJ_ouF_Y0A:v:…"
```
The first identity creates that document and owns it; every later identity names the same one and is refused. So **`id` is per-identity**, and a constant collapses every user onto one owner's document.
Two things follow, and the second is the blocker.
**The good news, worth saying plainly: the guarantee held.** The failure mode feared here was a silent merge — every user's documents in one collection, no error, isolation quietly gone. That is not what happened. *"Writing is ownership"* refused loudly and named the reason, and a consumer's wrong guess became a red test instead of a data leak. This is the surface doing exactly what it promises.
**The blocker: the three calls require the connected identity, and no published call returns it.** `ensureIdentity()` resolves it and returns `void`; `getCurrentUser` was removed on the argument that an application knows who it signed in — which was true while the application named the identity and stopped being true the moment it no longer does. So the consumer must pass a value it has no way to obtain. It currently works only where a deployment plants one (its test environment, through `configure({ currentUser })`); a real deployment plants nothing, and there the application is stuck.
The pair has to close, either way round: the three calls stop taking an `id` (the session IS the identity — what the API contract already predicts for the target), or the surface answers *"who am I"* again. Right now it does neither, and this is no longer a documentation nicety — it is the one thing standing between a consumer and a working multi-user deployment.
## 6. The barrier is the library's, and nothing can test that a first-time device gets in
The consumer has now deleted its own access screen and relies on the barrier `ensureIdentity()` shows — which is right, and is what the library asked for when it absorbed ~300 lines of gate from this same application.
Its one end-to-end check of a first-time device — a real browser with an empty profile, offered the wallet file and its password, importing it, and coming back signed in — drove that deleted screen's own DOM, so it went with the screen. The consumer still needs that coverage: a first-time device getting in is the single most consequential path a real user takes, and it is currently covered by nothing.
**The consumer is not asking for testids, and is rebuilding the check on its own side.** An end-to-end test should interact the way a person does — visible text and roles — rather than reach for identifiers planted inside someone else's component, so driving your barrier that way needs nothing published and is the more honest test besides.
**One question, because it decides whether that is possible at all**: is the barrier reachable by ordinary browser automation? A screen rendered inside a **shadow root**, or in its own iframe, is invisible to a locator that searches the light DOM, and no amount of "test it like a user" gets past that. If it is isolated, then either the isolation needs a documented way through for automation, or the flow is yours to cover and the contract should say so plainly — because right now each side can reasonably assume the other has it.
**Unrelated signature seen in the same run, reported without diagnosis** (78 occurrences, none fatal — the scenarios fail on the cap refusal above, not on this): `call_sdk Deserialization error of config Error: invalid type: JsValue(Function), expected any valid JSON value`. It appears only once `sharedWallet` is configured and `ensureIdentity()` actually opens a wallet — it was absent from every earlier run. The consumer passes `sharedWallet` as three plain strings, per `## Surface`, so whatever crosses that boundary as a function does not come from its call site.
A second, smaller consequence of the same hole: the consumer's `currentUserId` now has to be read out of its own profile document, so it is empty until that read lands, where it used to be available synchronously and invariant. An action taken in that window is silently dropped instead of written.
## 5. "Permissive in" is stated as a guarantee but is not uniform
`## Guarantees` opens with *"Every entry accepts `NuriLike` and validates at the door"*, and makes a point of it: a value read from storage, a URL or a form goes straight in, no guard to call, no cast to write. The signature block does not honour it uniformly — `storeRegistry.openDocumentInbox(doc: Nuri)` takes the precise type where its own deposit-side counterpart `inbox.readForDocument(doc: NuriLike)` takes the permissive one. Verified by the consumer's typechecker, not read from your source.
Either the guarantee is narrower than stated (say which entries it covers), or `openDocumentInbox` should widen. As written, a consumer that trusts the sentence gets a type error at exactly one call site and has no way to tell whether that is the rule or the exception.
+24 -3
View File
@@ -1,8 +1,29 @@
# 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; `shared-wallet/account-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
application's concern, not this lib's; only the mechanism is recorded here. application's concern, not this lib's; only the mechanism is recorded here.
@@ -63,7 +84,7 @@ later. This is why a global-index package is a deferred separate package in this
## Polyfill reality — the fan-out drift is now RESOLVED (special-account index) ## Polyfill reality — the fan-out drift is now RESOLVED (special-account index)
The shared-wallet polyfill originally shipped a cross-account fan-out over The shared-wallet polyfill originally shipped a cross-account fan-out over
every account's public documents (`store-registry.ts` `listEntityDocs('public')` every account's public documents (`shared-wallet/account-registry.ts` `listEntityDocs('public')`
/ `resolveReadGraphs`) — one account saw another's public entity without any / `resolveReadGraphs`) — one account saw another's public entity without any
relationship to its creator. This ADR classified that per-account fan-out as a drift relationship to its creator. This ADR classified that per-account fan-out as a drift
to be replaced by the single global index. to be replaced by the single global index.
+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/polyfill/src/emulated-verifier/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
+3 -3
View File
@@ -2,7 +2,7 @@
**Date:** 2026-06-15 · **Status:** Accepted (frozen). The rationale behind how **Date:** 2026-06-15 · **Status:** Accepted (frozen). The rationale behind how
the consumer application presents identity selection as a perceived login, and why the consumer application presents identity selection as a perceived login, and why
the lib's identity store (`accounts.ts`) must never touch NextGraph. The lib itself the lib's identity store (`shared-wallet/virtualUsers.ts`) must never touch NextGraph. The lib itself
no longer frames this as a login: it receives an identity id, set at wallet-import no longer frames this as a login: it receives an identity id, set at wallet-import
time; the perceived-login UX lives entirely in the consumer application. time; the perceived-login UX lives entirely in the consumer application.
@@ -63,8 +63,8 @@ barrier becomes the real per-user login — the flow shape does not change.
## How this lib realizes it ## How this lib realizes it
`accounts.ts` is an `IdentityStore`: `set(id)` / `clear()` / `get()` only read/write `shared-wallet/virtualUsers.ts` is an `IdentityStore`: `set(id)` / `clear()` / `get()` only read/write
the identity id in an injected `AccountStorage`; they never call NG. The id is set at the identity id in an injected `VirtualUserStorage`; they never call NG. The id is set at
wallet-import time and relayed via the lib's current-identity call; the perceived wallet-import time and relayed via the lib's current-identity call; the perceived
login is the consumer application's. See the identity store in login is the consumer application's. See the identity store in
[`../simulation.md`](../simulation.md). [`../simulation.md`](../simulation.md).
+84
View File
@@ -0,0 +1,84 @@
# Document links — what travels when an application shares a document
**Established 2026-08-06**, by direct reading of the `nextgraph-rs` clone (HEAD `213338f6`, 2026-05-16), the installed `@ng-org/web@0.1.2-alpha.13` declarations, and this repo's docs. Layer numbers per `README.md` § *The three references* (1 = engine, 2 = wasm binding / Rust SDK, 3 = ORM). `file:line` references are dated — navigate by symbol when they drift. Epistemic labels follow `docs/api-contract.md`.
**The question.** Alice writes a note; she wants Bob to open it. Bob is not necessarily known to her, and the sharing may cross a channel NextGraph knows nothing about — an email, a printed QR code, a web page. Does the model contain a value that travels, and if so, what does an application legitimately hold and pass around?
**The verdict.** Yes — and it is not a side feature: circulating a link is the model's only distribution mechanism ("you cannot discover, you can only follow links" — PO, 2026-07-30, `docs/readcap-and-nuri-model.md` §4ter-bis). The engine declares a complete link family (`NgLink`) whose doc comments describe out-of-band circulation as the normal case, and even names a durable "PermaLink" governed by a permission. But **nothing constructs any of it yet**, the repo-level *string* form an application would put in an email does not exist at any layer, and the value differs by scope: a link to a protected document carries the key, a link to a public document carries none.
---
## 1. Does a real application need this?
Yes, and not as a convenience. The two candidate paths in the framing collapse into one gesture at the model level: *publishing is two acts — place the data, then circulate the link* (§4ter-bis), and the inbox is merely one channel for that circulation ("post it into inboxes, **or** put it somewhere already reachable by the people concerned"). Directed delivery (path 1) is the same Link payload sealed to a known recipient's inbox; the email/QR case (path 2) is the same payload over a channel the recipient's identity never touches. Since there is no discovery — no index, no registry, nothing to find — a document that never has a link circulated for it has **no audience at all**. An application that lets users share anything therefore handles links; the only question is what the value is.
## 2. What the model actually offers
### Exists and is exercised
- **A self-contained share-URL for objects, commits and files.** Grammar: `did:ng[:o:{repo}]:v:{overlay}(:[cj]:{id}:k:{key})+[:s:{id}:k:{key}]:l:{locator}` (`RE_OBJECTS`, `engine/net/src/types.rs:63-65`); parser `NuriV0::new_for_readcaps` (`engine/net/src/app_protocol.rs:493-548`); one caller, `ngcli get` (`bin/ngcli/src/main.rs:430-460`), which connects **anonymously** to the broker named in the URL's `:l:` locator, fetches blocks with `ExtObjectGetV0`, and decrypts locally with the keys carried in the URL. Level 1 + CLI, working end to end: a stranger holding only the string reads the content. It addresses *frozen* content (objects, files, a commit) — not a live document.
- **Shareable values at level 2 — for profiles and wallets, not documents.** `get_qrcode_for_profile(session_id, _public, size)` and its receiving half `import_contact_from_qrcode` (`index.d.ts:96,106`), payload `NgQRCodeProfileSharingV0 { inbox, profile, name, email }` (`engine/net/src/types.rs:5227-5233`); `wallet_export_get_qrcode` (`index.d.ts:332`; `sdk/rust/src/local_broker.rs:2074`, with textcode and rendezvous variants). Precedent that "a value crosses an out-of-band channel" is SDK-real — no document instance of it exists.
- **NURIs are URL-shaped and the app opens them.** The app layer routes `/did:ng:*` to a `NURI` component (`app/ui-common/src/App.svelte:66`); the component is absent from this clone (app mid-rebuild), and its historical version (git `077ba626`, old `ng-app/src/routes/NURI.svelte`) deep-links only documents the **connected user already holds**. An in-app precedent for "a document reference is a URL", not for stranger-opening.
### Exists, unexercised — the declared model
- **The link family.** `NgLinkV0 = Repo | PublicRepo | Branch | Object` (`engine/net/src/types.rs:5206-5217`). `RepoLinkV0 { id, read_cap, overlay, peers }` (`:5062-5079`), whose comment states the flow: *"In most cases, the link is shared and then the recipient opens it and subscribes soon afterward"* (`:5059`). `PublicRepoLinkV0 { repo, branch?, heads, snapshot?, public_store, peers }`**no `read_cap` field** (`:5105-5127`). `ReadBranchLinkV0` (`:5142`), `ObjectLinkV0` (`:5176`). **Zero constructors and zero consumers in the entire workspace** (grep over `engine/`, `sdk/`, `bin/`, `app/`): the family is a declared model, not a feature.
- **The NURI type has exactly a link's slots — the string grammar does not.** `NuriV0 { target, overlay, access: Vec<NgAccessV0>, locator: Option<Locator> }` (`engine/net/src/app_protocol.rs:181-195`); `NgAccessV0::ReadCap(ReadCap)` (`:55`) has **zero consumers anywhere**. The `r:` segment (a repo-level ReadCap, `BlockRef::readcap_nuri`, `engine/repo/src/types.rs:518-521`) is *produced* for display (`engine/verifier/src/verifier.rs:278,320`) and **parsed by nothing**; `NuriV0::new_from` (`app_protocol.rs:643-737`) accepts only cap-less forms plus the file/commit `:k:` forms. The only parsed cap+locator grammar is the object form above.
- **The recipient's mechanics exist and work — short of the external case.** `Verifier::load_repo_from_read_cap` (`engine/verifier/src/verifier.rs:2237`, `pub(crate)`) is exercised for the user's own stores and the `AddRepo` replay (`engine/verifier/src/commits/mod.rs:660`); the one call site that would open an **external** repo from a received cap is commented out (`engine/verifier/src/inbox_processor.rs:469-481`, social-query forwarder). The durable filing register for a received link exists: `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`).
- **Serving the public store to strangers is broker-plumbed and client-disabled.** `PinRepoV0.expose_outer` exists through the broker's storage (`engine/net/src/types.rs:3123`; `engine/broker/src/server_storage/core/overlay.rs:103-127`), but the client hard-codes `expose_outer: false` in both `PinRepo` constructors (`engine/net/src/actors/client/pin_repo.rs:51,79`), and `ExtTopicSyncReq` — the anonymous branch-sync a `PublicRepoLink` needs — is declared and `unimplemented!()` (`engine/net/src/types.rs:4523,4533`). Only `ExtObjectGet` and `WalletGetExport` are servable ext requests today.
- **Durable links are named, not built.** `PermaCap` is a TODO (`engine/repo/src/types.rs:578`), and `PermissionV0::PermaShare`*"can create and answer to PermaCap (PermaLink)"* — already reserves the governance of creating one (`:1761`).
### Does not exist
- No function at level 2 or 3 produces or accepts a document link (all 77 `index.d.ts` exports checked; `sdk/rust/src/local_broker.rs` has only the wallet QR/textcode/rendezvous trio). `doc_fetch_repo_subscribe` (`index.d.ts:64`, `sdk/js/lib-wasm/src/lib.rs:1900`) opens documents the session **already holds** — an unknown repo errors `RepoNotFound` (`engine/verifier/src/request_processor.rs:155,163`) — it is not a stranger-open.
- No string serialization of `NgLink`, and no repo-level cap-bearing NURI that any parser accepts. Per the design principle, none of these absences says anything about the future SDK; the declared types above say a great deal.
### What the receiving side actually needs — a reference alone is NOT enough
A bare `did:ng:o:{id}`, or even `o:{id}:v:{overlay}`, gives a stranger nothing: no locator → no broker to ask; no cap → nothing decrypts, and addressing itself presupposes the cap (the outer overlay is never registered, and there is no existence probe — `docs/readcap-and-nuri-model.md` §4bis, the refuted-hypothesis block). What a stranger needs to resolve **and join** is precisely `RepoLinkV0`'s field list — id + read_cap + overlay + peers — or, public, `PublicRepoLinkV0`'s find-and-join set. The `peers`/locator half is not decoration; it is the join, and it is why the polyfill's single-broker world makes locators invisible today.
## 3. Public vs protected — the distinction is in the model
The two link structures differ on exactly one thing: **the key**. `RepoLinkV0` carries `read_cap`; `PublicRepoLinkV0` carries none — *"The latest ReadCap of the branch … will be downloaded from the outerOverlay, if the peer brokers listed below allow it"*, and *"This link is durable, because the public site are served differently by brokers"* (`engine/net/src/types.rs:5100-5104`). Public-ness is therefore a property of the **store and of how brokers serve it** (outer-overlay exposure), not a capability that circulates. This matches the official docs' store table (docs-intent, transcribed in `docs/nextgraph-current-state.md:44-58`): Public read = *"Everyone, no capability"*, *"equivalent to your website … without the need for special permissions"*; Protected = *"share … but they will need a special link and permission"*.
Two nuances against over-reading:
- It is not that no key *exists*. Today every branch — public included — has its ReadCap and its content is encrypted; the public link merely does not **carry** the key, because any holder of the link may fetch the current one from the outer overlay. The stated direction of not encrypting public-store content at all is PO-confirmed but explicitly not to be depended on by any surface (§4ter).
- The durability asymmetry follows from what the value contains: a public link *names* and stays valid across cap refresh; a protected link *carries* and goes stale when the cap is rotated (§4 below).
What follows for a public document, and is easy to get wrong: **there is nothing per-reader**. No grant, no per-reader revocation, no audience list — the only lever is coarse (unpublish/re-key the store's serving). An application UI that enumerates or revokes "the readers of my public document" is built on nothing.
### The polyfill's public emulation, read against this
**Rewritten 2026-08-06**, when the emulation changed. It used to refuse a cap-less reference for every scope, which was over-strict in the safe direction but left an application unable to express *"circulate widely, the reference is enough"* — the one act the model makes cheap. `emulated-verifier/public-store.ts` now emulates the declared mechanism: a document in a public store exposes its ReadCap, and any reader's first door fetches it. Possession still decides everything; what changed is that for a public document the cap is **obtainable** instead of having to be handed over.
**This aligns on a DECLARED model, not on current behaviour, and the difference is worth stating.** What is read in source: `PublicRepoLinkV0` carries no `read_cap` and its comment says *"The latest ReadCap of the branch will be downloaded from the outerOverlay, if the peer brokers listed below allow it […] the public site are served differently by brokers"* (`engine/net/src/types.rs:5098-5124`); the broker's `expose_outer` plumbing exists (`engine/broker/src/server_storage/core/overlay.rs:103-133`). What is *not* wired today, per the inventory above: the client hard-codes `expose_outer: false` in both `PinRepo` constructors, and `ExtTopicSyncReq` — the anonymous branch-sync such a link needs — is `unimplemented!()`. So the emulation follows the model the engine declares, in a place the engine does not yet serve. That is the intended posture for this library (an absent implementation says nothing about what the target will do), and it is a bet, named here as one.
Three things that remain true and must stay visible wherever the public emulation is documented:
1. **Per-reader semantics for public documents** must not be inferred from the cap-per-holder bookkeeping — upstream has none. No grant, no per-reader revocation, no audience list.
2. The `:r:` segment inside anything naming a *public* document is emulation detail: upstream's public link carries no key material, because the key is fetched. Harmless as long as the value stays opaque to the consumer — which is the contract to enforce.
3. **Reading is not writing.** The cap a public store serves grants reading only; `caps.learnFromPublicStore` files it apart and `docs.sparqlUpdate` refuses a write on it. Upstream a public store never makes a repo world-writable — writing needs the write cap, and `verify_permission` fires on WRITE only.
The surface consequence: the *act* — obtain a link, circulate it — is the same for both scopes upstream (`NgLinkV0` is one enum over both), so one producing function covering both is target-shaped; what differs is the semantics attached to the **value** (durability, revocability, the absence of per-reader anything), and that belongs in documentation, not in a second function.
## 4. Durability — what an application may store
- **Protected links go stale by design.** *"Those capabilities are not durable: They can be refreshed … and previously shared Caps will become obsolete/revoked"* (`engine/net/src/types.rs:5055-5058`). Rotation re-delivers the new cap **to the inboxes of those who keep access, at the rotating party's choice** (PO DIRECTION, `docs/readcap-and-nuri-model.md` §3) — never through the stored link. So a protected link in an email opened months later may no longer open; an application must treat open-from-link as fallible and fall back to a person-channel re-request. The declared future answer for stored-unopened links is `PermaCap`/PermaLink — a TODO (`engine/repo/src/types.rs:578,1761`).
- **Public links are durable** by construction (`:5103`): they carry nothing that rotation invalidates.
- **The polyfill emulates no revocation** (readcap doc §5, P3), so nothing goes stale today; consumers must not read permanence into that.
## 5. Recommendation for the polyfill
**SUPERSEDED on its first point, 2026-08-06 — `linkTo` was REMOVED, and the removal stands.** This section used to open: *"The surface already exists: `linkTo(doc: NuriLike): ReadCap` … `inbox.share(doc, toUser)` for directed delivery. **Keep `linkTo` — the act is the right one**"*. What that recommendation missed is the single thing § 0 of `docs/readcap-and-nuri-model.md` exists to hold: a call that returns a document's **key** where a caller asked for its **reference** converts *"whoever has the reference AND the key reads"* into *"whoever has the reference reads"* — for that document and for every document it mentions — so confidentiality can no longer be composed inside anything one circulates. The *act* (circulate a value that opens a document) may still be right; making it the answer to "give me the link to my document" was not. The reasoning is recorded where the function was, in the `No linkTo here` block of `packages/polyfill/src/surface/placement.ts`.
What an application does instead, today: it names a document with the bare reference it already holds — every published call returns one — and grants access with `inbox.share(doc, toUser)` (`packages/polyfill/src/surface/inbox.ts`). The four points below are kept and re-read against that: 1 and 4 stand as written for **any** future link-producing surface; 2 is void with the function; 3 is unchanged and still open.
1. **Label any such call LEVEL-1 SHAPE in `docs/api-contract.md`.** What supports it: the `NgLink` family and its stated sharing flow, the `PermaShare` permission, the exercised object-URL and profile-QR precedents, and the PO doctrine that circulation is the only distribution. The model's own stated flows are unusable without *some* produce-a-link affordance, which is as much confidence as an unbuilt feature allows. What cannot be promised: the SDK's name for it, sync vs async (upstream link-building needs overlay + peers from the session, so async is plausible — same adapter-sized delta class as `subscribeDoc`'s sync unsubscribe), and whether the value is a NURI string or a structured link. Therefore: **the returned value is opaque**; a consumer that stores it, transmits it, and hands it back unmodified learns nothing to unlearn; a consumer that parses it does.
2. ~~**Fix the comment-vs-code mismatch in `linkTo`.**~~ **VOID — the function is gone.** The observation it rested on survives and is worth keeping: `RepoLinkV0`-with-key IS the protected-document link, and circulating it out-of-band is the documented normal case (`:5059`), so a protected link carrying its key is not in itself a violation — with the § 4 durability caveat. What made `linkTo` wrong was not that the value carried a key; it was that a caller got one **by asking for a reference**. Handing over a key must be its own act, which is what `inbox.share` is.
3. **The recipient verb is missing.** Nothing exported ingests an out-of-band link: `learn` is reached only by inbox processing and the connection drain (`packages/polyfill/src/surface/inbox.ts:410`, `packages/polyfill/src/emulated-verifier/connect.ts:68`), and `getCaps()` is documented machinery (api-contract §15). The model names the recipient act precisely — open the link: load the repo from its read cap, file `AddLink` durably on the User branch, subscribe (`:5059`; `engine/repo/src/types.rs:1934-1950`; `verifier.rs:2237`). Suggested surface, same epistemic label as point 1: `openLink(link: string): Promise<Nuri>` — files the cap in the emulated registers and returns the cap-less target for use in reads. Without it, path 2 has a producer and no consumer, and the multi-actor test where Bob *obtains* the document through calls (never through a shared variable) cannot be written — the exact failure mode `rules/engineering/multi-actor-tests-obtain-not-receive.md` records.
4. **Do not add**: link options (expiry, audience, revoke-this-link), per-reader introspection for public documents, or any API that parses or inspects a link's insides — nothing upstream supports any of them, and each teaches a lever the model does not have.
## 6. The question for the NextGraph developer
What will an application hold and pass around to share **one document** out-of-band (email, QR) — a NURI string with `r:`/`l:` segments, a serialized `NgLink`, something else — and which SDK calls will produce it and open it? And for a *public* document, is the intended value the key-less `PublicRepoLink` form, i.e. should applications treat public sharing as never involving a per-reader capability?
@@ -0,0 +1,57 @@
# Write loss on socket death (`SerializationError`)
**Post-mortem — 2026-07-14 · Status: OPEN (not addressed).**
An entity written just before a period of inactivity can be **silently lost**: it is absent on reconnection. *(Whether the write never durably reached the broker, or reached it and is not read back on a cold reconnection, is **not settled** — see Epistemic caveat below. The wording here deliberately states only the observed symptom.)* The **account / identity survives** (no fork). Observed in real conditions (Festipod, Firefox) during a pause after login/creation.
## Symptom
1. The user logs in, the app creates an entity (a Festipod event).
2. A period of inactivity follows (idle, tab in the background…).
3. The broker socket dies spontaneously with `SOCKET IS CLOSED Some(Left(SerializationError))`.
4. On reconnection, the created entity has disappeared; the app reads back its own scope **empty**.
## Evidence (VERIFIED — live Firefox logs, verbatim)
```
… REPLAY TOPIC NOT FOUND <topic> IN OVERLAY <overlay>
… NEED REPLAY true
… SENDING EVENTS FROM OUTBOX RETURNED: Err(TopicNotFound)
[user1][polyfill] resolveAccount(user1) → 1 record ← the account SURVIVES (no fork)
[user1][polyfill] readScopeIndex(…) → 0 entities ← but the scope is EMPTY
… set reçu: 0 objets Event (public)
… SOCKET IS CLOSED Some(Left(SerializationError)) [51, 3, 223, …]
```
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)` + 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)
- 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).
- **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.
- **No write-durability confirmation API**: a caller cannot `await` the guarantee that a write has reached the broker.
## What the SDK exposes but does not consume
`disconnections_subscribe` **does fire** on this failure — but neither the polyfill (`@ng-eventually/polyfill`) nor the consumer app subscribes to it. The signal exists, nobody listens to it; on the app side, no mechanism retries or warns the user.
## Scope & not reproduced
- **Observed on Firefox only** to date. A manual test on another browser did not trigger the `SerializationError` nor its consequences.
- **@data reproduction (Chromium, real broker) — 2026-07-14, decisive.** The existing @data reconnection test (`reconnexion-meme-identite`) was a **false green**: it read A's repos back from the persistent profile's **local IndexedDB**, never from the broker. A **genuinely cold** reader (non-persistent `freshBrowser` context, the **same** wallet/account A, no local state — seeded from the wallet captured before the event) reads **0** events from A (`BARRIER timed-out (8000ms)`, `CONNECTION ESTABLISHED`). A **different** signature from the Firefox case (no socket death; the `OUTBOX empty` is the reader's, trivially empty) and it **does not settle** (i) vs (ii) — an empty barrier is compatible with both. Established on the other hand: **@data has never verified the broker durability of A's own reads**, and cold rehydration from the broker fails. Repro: `src/modules/event/features/reconnexion-froide-sans-local.feature` (Festipod).
- **To settle (i) vs (ii)**: independently verify that A's write reaches the broker — e.g. a *warm* reader / a second identity reads the event's public doc (the two-identity isolation scenario). If it sees it → the write is durable → the cold reader's 0 is a **(ii)** (rehydration). Otherwise → **(i)**.
## Fix leads (not arbitrated)
1. **Core** — fix the `SerializationError` **and** implement the reconnection TODO (re-establish the socket + re-flush the outbox).
2. **SDK / polyfill** — consume `disconnections_subscribe` → reconnection + outbox re-flush as a mitigation, independently of the core.
3. **Durability API** — expose a confirmation that a write has reached the broker, so that the caller can `await` it.
## Links
- `docs/nextgraph-current-state.md` — current state of the core (disconnection / reconnection to be cross-referenced here).
- Product impact + consumer-side caveat: Festipod concept `data-layer``caveat_write-durability-across-disconnect`.
+271
View File
@@ -0,0 +1,271 @@
# Internal contract — what `@ng-eventually/polyfill` keeps off its surface, and what NextGraph does or would do about each subject
> **Updated 2026-08-03, after the source layout was reorganised by migration fate** (`docs/source-layout-by-fate.md`). Paths, and three names, changed under this document: `readModel` became the directly-exported `readUnion`; `accounts` / `AccountRecord` / `AccountStorage` became `virtualUsers` / `VirtualUserRecord` / `VirtualUserStorage` (module `shared-wallet/virtual-users.ts`); `store-registry-api.ts` became `surface/placement.ts`. Two modules were created and are covered here: `emulated-verifier/branch-registers.ts` (the four durable registers, split out of the shim) and `shared-wallet/bootstrap.ts` (the injection store, split out of the `/polyfill` entry). The subject-by-subject rulings below are unaffected — what moved is where the code lives, not what it promises.
**Scope.** The complement of [`docs/api-contract.md`](./api-contract.md): every module export under `packages/polyfill/src/` that is NOT reachable from the published entry point (`package.json` maps exactly `.``src/index.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`, `surface/read-model.ts`, and by name everything `surface/use-shape.ts`, `surface/watch-shape.ts`, `lifecycle.ts`, `sparql.ts` export, and `subscribeDoc`/`subscribeDocs`/`docChangeType` (+ types) from `subscribe.ts`; its `storeRegistry` namespace is the **`surface/placement.ts` slice only** (`createEntityDoc`, `listMyEntityDocs`, `resolveScopeGraph`, `resolveWriteGraph`, `openDocumentInbox`). `model/nuri.ts` is now internal in full — the guards `isNuri`/`hasReadCap` stopped being published when the entries became permissive-in (`NuriLike` validated at the door). *(The second entry, `polyfill.ts`, was merged into `index.ts` on 2026-08-07, and the polyfill-era block was then cut to ONE published call, `configure`. `configureStoreRegistry`, `setCurrentUser` and `connectedUser` became internal the same day — folded, replaced by the gate, and awaited inside it respectively; the machinery accessors and test resets are internal too. All four are inventoried below.)* Everything else that carries `export` in a `src/` module is internal and inventoried here. Eight modules are internal in their entirety: `shared-wallet/access-log.ts`, `emulated-verifier/machinery.ts`, `surface/ng-proxy.ts`, `emulated-verifier/open-repo.ts`, `shared-wallet/outbox-log.ts`, `shared-wallet/physical.ts`, `emulated-verifier/reach.ts`, `emulated-verifier/read-filter.ts`. Four are internal in part: `nuri.ts`, `emulated-verifier/connect.ts`, `subscribe.ts`, `shared-wallet/account-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 — `surface/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`, plus the minting point
```ts
// model/nuri.ts
export function targetOf(nuri: Nuri): Nuri;
export function parseNuri(nuri: Nuri): { target: Nuri; readCap?: ReadCap };
// emulated-verifier/caps.ts — NOT `model/nuri.ts`; it moved when the source layout was
// reorganised by migration fate, and this block said `nuri.ts` until 2026-08-10.
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` (`STAND_IN_CAP`). Kept off the surface deliberately: nothing published turns a bare reference into a cap.
- `targetOf` / `parseNuri`**LEVEL-1 SHAPE, model VERIFIED**: they transcribe upstream's one-type-with-optional-access NURI, on the **two** fields this library uses. Not a "1:1 mirror" of `NuriV0`, as this line claimed until 2026-08-10: that type has TEN fields — `identity, target, entire_store, objects, signature, branch, overlay, access, topic, locator` (`engine/net/src/app_protocol.rs:181-194`) — of which `parseNuri` carries `target` and the cap half of `access`. The other eight have no counterpart here (the missing `locator` is a stated gap, `docs/readcap-and-nuri-model.md` § 4sexies). 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 cap-enforcement has not yet supplied real key material; the constant `OK` pretends nothing (`STAND_IN_CAP`, `emulated-verifier/caps.ts`). It has exactly two call sites (`shared-wallet/account-registry.ts` `createEntityDoc`; `emulated-verifier/caps.ts` internals) — the minting points of the emulation. With cap-enforcement the constant becomes a real key; at migration the function is deleted (the engine mints).
## 3. The reach boundary — `emulated-verifier/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 `shared-wallet/account-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 — `shared-wallet/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 `emulated-verifier/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 `shared-wallet/physical.ts`.
## 6. Bootstrap repo opening — `emulated-verifier/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`, `surface/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 — `emulated-verifier/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 `surface/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/model/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 `emulated-verifier/connect.ts`
```ts
// connect.ts:91
export function startConnect(): void;
```
Fire-and-forget wrapper over `connectedUser()` (internal since 2026-08-07, awaited inside `ensureIdentity`) (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 `shared-wallet/account-registry.ts`
The sharpest boundary case: `surface/placement.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 type `RegistrySession` is published by the entry (a consumer types its injected `getSession` with it) and covered by the surface contract; `VirtualUserRecord` is internal.
### 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<VirtualUserRecord | null>;
// store-registry.ts:631
export async function ensureAccount(id: string): Promise<VirtualUserRecord>;
```
`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()` (`shared-wallet/bootstrap.ts`), 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 `emulated-verifier/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 — `emulated-verifier/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 — `shared-wallet/access-log.ts` and `shared-wallet/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;
```
`shared-wallet/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`). `shared-wallet/outbox-log.ts` — a read-only count of the real SDK's offline write outbox at session bootstrap, warning when non-empty.
- `shared-wallet/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.
- `shared-wallet/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 — `surface/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 — `emulated-verifier/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 `surface/placement.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 `virtualUsers.*` on the SDK entry — since the split (`index.ts` routes through `surface/placement.ts`) those are internal. 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()` (`shared-wallet/bootstrap.ts`), 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, `emulated-verifier/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; `emulated-verifier/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: `shared-wallet/access-log.ts` (`AccessOp`, `setAccessLog`, `enabled`, `activeIdentity`, `accessLogPrefix`, `logStage`, `shortNuri`, `logAccess`); `emulated-verifier/machinery.ts` (`MACHINERY_NS`, `isMachinerySubject`); `surface/ng-proxy.ts` (`makeNg`); `emulated-verifier/open-repo.ts` (`SyncState`, `setOpenTimeoutForTests`, `resetOpenedRepos`, `getSyncState`, `ensureRepoOpen`, `ensurePhysicalRepoOpen`, `ensureReposOpen`); `shared-wallet/outbox-log.ts` (`inspectOutbox`); `shared-wallet/physical.ts` (`physicalCreate`, `physicalQuery`, `physicalUpdate`); `emulated-verifier/reach.ts` (`declareInfrastructure`, `isInfrastructure`, `resetInfrastructure`, `mayReach`, `assertMayReach`, `mustNotAttempt`); `emulated-verifier/read-filter.ts` (`filterReadable`, `makeReadFilteredView`).
Internal slices of partially-published modules: `model/nuri.ts` (`targetOf`, `parseNuri`) and `emulated-verifier/caps.ts` (`mintCap`); `emulated-verifier/connect.ts` (`startConnect`); `subscribe.ts` (`subscribePhysicalDoc`); `shared-wallet/account-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`, `surface/read-model.ts`, `shared-wallet/virtualUsers.ts`, `emulated-verifier/caps.ts`, `sparql.ts`, `lifecycle.ts`, `surface/use-shape.ts`, `surface/watch-shape.ts`, `surface/placement.ts`, and the entry point.
+58 -23
View File
@@ -14,13 +14,37 @@ 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 (the cap-surface work): 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 - `emulated-verifier/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.
- the stand-in cap value — the constant `OK` (`STAND_IN_CAP`,
`emulated-verifier/caps.ts`) — becomes the real
`r:{base64url(serde_bare(ObjectRef))}`. It is **one function** (`mintCap`, in that
same module since the source layout was reorganised — this said `nuri.ts` until
2026-08-10), because
every path now READS a stored cap instead of recomputing one. `hasReadCap` /
`targetOf` stay meaningful: the `r:` discriminant is upstream grammar, not ours.
- `inbox.share(doc, toUser)` 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.**
- `caps.markInPublicStore` and the whole of `emulated-verifier/public-store.ts` disappear: which store a document sits in stops being a fact we record, and serving a public store's repos becomes the broker's job (`expose_outer`, the ReadCap downloaded from the outer overlay — `PublicRepoLinkV0`, `engine/net/src/types.rs:5098`). Nothing an application calls changes: it circulates bare references now, and will still.
- `assertMayWrite` goes with it — refusing a write on a cap the public store served is a stand-in for the write cap this emulation does not have.
- The read filter (`emulated-verifier/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 (`surface/ng-proxy.ts` `sparql_update` override) is a separate axis and
is decorative today (every internal writer bypasses the proxy); it belongs to the
cap-enforcement 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
@@ -28,7 +52,7 @@ private store, and the `public|protected|private` scope is a logical label
in the shim (see the two-axes section in [`simulation.md`](./simulation.md)). in the shim (see the two-axes section in [`simulation.md`](./simulation.md)).
- `doc_create` cannot target a non-private native store today — verified: - `doc_create` cannot target a non-private native store today — verified:
`StoreRepo` is not JS-constructible from the SDK, so there is no way to pass `StoreRepo` is not constructible from the WEB build of the SDK, so there is no way to pass
a public/protected store as the create destination (`docCreate`'s trailing a public/protected store as the create destination (`docCreate`'s trailing
`store` arg is left `undefined` → private store). The private store works only `store` arg is left `undefined` → private store). The private store works only
because it opens without `RepoNotFound`. because it opens without `RepoNotFound`.
@@ -36,10 +60,10 @@ in the shim (see the two-axes section in [`simulation.md`](./simulation.md)).
`getNativeStore(scope)`-style resolver returning the real store to pass as the `getNativeStore(scope)`-style resolver returning the real store to pass as the
`docCreate` destination, so the logical scope label becomes a real store `docCreate` destination, so the logical scope label becomes a real store
placement. (No such helper exists yet — it is blocked on the SDK gap above.) placement. (No such helper exists yet — it is blocked on the SDK gap above.)
- At that point `store-registry.ts` maps `(account, scope)` to the user's real - At that point `shared-wallet/account-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
@@ -47,42 +71,53 @@ The `sharedWalletShim` (account → 3 scope-document NURIs, held in a subscribab
doc-shim reached via a write-once pointer in the store-root — see doc-shim reached via a write-once pointer in the store-root — see
[`nextgraph-current-state.md`](./nextgraph-current-state.md) § *The pointer → doc-shim [`nextgraph-current-state.md`](./nextgraph-current-state.md) § *The pointer → doc-shim
indirection*) has no target equivalent — the target has no central directory. Remove indirection*) has no target equivalent — the target has no central directory. Remove
it entirely: `store-registry.ts`, `configureStoreRegistry`, the pointer + doc-shim it entirely: `shared-wallet/account-registry.ts`, `configureStoreRegistry`, the pointer + doc-shim
resolution, and the `pointerGuard` dep. Cross-wallet reads replace the fan-out; resolution, and the `pointerGuard` dep. Cross-wallet reads replace the fan-out;
per-user wallets replace the shared one. 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 `shared-wallet/virtualUsers.ts` (the `IdentityStore` that persists the identity id in
`localStorage`) and the app-level "Connexion" screen. The technical broker gate `localStorage`) and the app-level "Connexion" screen. The technical broker gate
becomes the real per-user login 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
via a build alias during the polyfill period. Removing the alias makes those imports via a build alias during the polyfill period. Removing the alias makes those imports
resolve to the real SDK — the `ng`/`useShape`/`inbox` surface is SDK-identical, so resolve to the real SDK — the `ng`/`useShape`/`inbox` surface is SDK-identical, so
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. the POLYFILL-ERA block of `@ng-eventually/polyfill` — is deleted. The lib itself disappears.
## The one break already taken: `declareConnections`
The cap-surface work 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
`inbox.share(doc, toUser)` 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
+415 -43
View File
@@ -1,6 +1,6 @@
# Current-state NextGraph — what the SDK/broker do and do NOT expose # Current-state NextGraph — what the SDK/broker do and do NOT expose
**Owner:** this library. `@ng-eventually/client` exists because the *current* **Owner:** this library. `@ng-eventually/polyfill` exists because the *current*
NextGraph JS SDK is immature. This file is the authoritative reference on what NextGraph JS SDK is immature. This file is the authoritative reference on what
today's SDK/broker actually give us — the ground truth every polyfill in this today's SDK/broker actually give us — the ground truth every polyfill in this
lib compensates for. Read [`simulation.md`](./simulation.md) for how we emulate lib compensates for. Read [`simulation.md`](./simulation.md) for how we emulate
@@ -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 |
|---|---|---|---| |---|---|---|---|
@@ -76,15 +86,17 @@ users/quorum (write/permissions), **not** read-cap possession. (Repos of a
> Consequence for this lib's emulation (see [`simulation.md`](./simulation.md)): > Consequence for this lib's emulation (see [`simulation.md`](./simulation.md)):
> the read access unit is the repo = each item's `@graph` — a per-document > the read access unit is the repo = each item's `@graph` — a per-document
> filter, never per-store and never per-item. This is exactly what > filter, never per-store and never per-item. This is exactly what
> `caps.ts` (`CapRegistry`) and `read-filter.ts` model: no store-level > `emulated-verifier/caps.ts` (`CapRegistry`) and `emulated-verifier/read-filter.ts` model: no store-level
> inheritance, purely per-document caps. In a mono-store layout (all items in one > inheritance, purely per-document caps. In a mono-store layout (all items in one
> repo) the filter is therefore all-or-nothing on that document — which *is* the > repo) the filter is therefore all-or-nothing on that document — which *is* the
> native behaviour, and why fine-grained isolation requires one document per > native behaviour, and why fine-grained isolation requires one document per
> 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)
@@ -96,17 +108,78 @@ stores.
## Capability sharing / NURI ## Capability sharing / NURI
**What a NURI transports, and the two acts that cover every use of it, are in
[`readcap-and-nuri-model.md`](./readcap-and-nuri-model.md) §4sexies** — including why a
key-less reference is the ordinary case rather than a degenerate one, and why nothing is
checked at access time. The analysis of what exists vs what is merely declared is in
[`document-links.md`](./document-links.md).
Sharing transmits a **NURI** embedding the crypto capability (read and/or write). Sharing transmits a **NURI** embedding the crypto capability (read and/or write).
No central ACL: holding the NURI *is* the right. *"adding permissions can be done No central ACL: holding the NURI *is* the right. *"adding permissions can be done
offline"*; *"removing permissions … requires a SyncSignature"* (synchronous). 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` (`verifier.rs:3004`, called at `request_processor.rs:689`) →
`Store::create_repo_default` (`engine/repo/src/store.rs:264`) →
`create_repo_with_keys` (`store.rs:284`), which builds the `Repo` with `inbox: None`
(`store.rs:691`). 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.
*(Citation corrected 2026-08-10. This pointed at `engine/repo/src/repo.rs:574`, which is
inside `Repo::new_with_member` (`repo.rs:543`) — a constructor no production path
reaches: its callers are `Repo::new_with_perms`, gated `#[cfg(any(test, feature =
"testing"))]` (`repo.rs:186-192`), and `#[cfg(test)]` blocks in `branch.rs:387,490` and
`commit.rs:1659,1849,1919`. The claim itself was right; it was being proved by a test
fixture.)*
**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 +196,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).
@@ -137,6 +213,32 @@ A related exposed primitive: `social_query_start` (a federated query via inbox u
`degree` hops) exists but is limited to contacts — it does not cover an anonymous `degree` hops) exists but is limited to contacts — it does not cover an anonymous
notification to a non-connected host. notification to a non-connected host.
### Delivering a ReadCap through the inbox — the field exists, the path does NOT — VERIFIED
The `ContactDetails` inbox message carries `read_cap: Option<ReadCap>`, commented
*"optional readcap on the profile, if user wants to share the content of profile"*
(`engine/net/src/types.rs`). Nothing behind that field is implemented:
- **Building it panics.** `InboxPost::new_contact_details(…, with_readcap: bool, …)`
(`engine/net/src/types.rs`) fills `read_cap` with `unimplemented!()` when
`with_readcap` is true, and `None` otherwise. Asking for a cap in the message is a
panic, not a feature.
- **Nobody asks for one.** Its ONLY caller is the `QrCodeProfileImport` path in
`engine/verifier/src/request_processor.rs`
(`post_to_inbox(InboxPost::new_contact_details(…))`), which passes `with_readcap =
false`. No message ever carries a cap.
- **The receiver discards it.** The `InboxMsgContent::ContactDetails(details)` arm of
`engine/verifier/src/inbox_processor.rs` reads `details.profile`, `details.name` and
`details.email` to build a `social:contact` document — it **never reads
`details.read_cap`**. Even a hand-crafted message carrying a cap would be dropped.
**Consequence for this lib:** there is no native channel to HAND a key to somebody. The
inbox transports an identity/profile pointer, not a read capability. Combined with
§ *The inbox is not usable from the JS SDK* (no `InboxPost` arm in the request processor
at all), cap delivery must be emulated end to end: the polyfill's emulated inbox and its
`CapRegistry` are not a shortcut around an existing mechanism, they stand in for a
mechanism that does not exist.
## The query capability — ONE local store, named graphs, union queries ## The query capability — ONE local store, named graphs, union queries
The single fact that makes read-time *listing* possible on the shared wallet, and The single fact that makes read-time *listing* possible on the shared wallet, and
@@ -199,7 +301,7 @@ from JS today a repo becomes queryable ONLY by being `doc_create`d in this sessi
**Consequence for this lib's mono-wallet polyfill:** every account's documents are **Consequence for this lib's mono-wallet polyfill:** every account's documents are
`doc_create`d in the one shared wallet within the same session, so they are all `doc_create`d in the one shared wallet within the same session, so they are all
already in `self.repos`. `read-model.ts` reads the bounded, by-need set of docs already in `self.repos`. `surface/read-model.ts` reads the bounded, by-need set of docs
with one anchored `sparql_query` per doc (`SELECT ?s ?p ?o WHERE { ?s ?p ?o }`, with one anchored `sparql_query` per doc (`SELECT ?s ?p ?o WHERE { ?s ?p ?o }`,
anchor = the doc NURI): the anchor resolves that same-session repo directly (no anchor = the doc NURI): the anchor resolves that same-session repo directly (no
separate open needed) and restricts the query to its graph, so it is O(1) per doc, separate open needed) and restricts the query to its graph, so it is O(1) per doc,
@@ -213,7 +315,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).
@@ -230,11 +333,26 @@ has both**:
- **Subscribable with a sync BARRIER.** `doc_subscribe(nuri)` delivers `TabInfo` then - **Subscribable with a sync BARRIER.** `doc_subscribe(nuri)` delivers `TabInfo` then
an initial **`State`** (`verifier.rs:470`/`:476`); that first `State` is the sync an initial **`State`** (`verifier.rs:470`/`:476`); that first `State` is the sync
barrier — **after it, presence is guaranteed and absence is definitive** (pinned barrier — **after it, presence is guaranteed and absence is definitive** (pinned
empirically by CONTRACT 3 in `packages/client/e2e/`). But this barrier exists only empirically by CONTRACT 3 in `packages/polyfill/e2e/`). But this barrier exists only
for a repo `doc_subscribe` can open, i.e. a `did:ng:o:<RepoID>` repo. A **store-root for a repo `doc_subscribe` can open, i.e. a `did:ng:o:<RepoID>` repo. A **store-root
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
@@ -244,14 +362,14 @@ account map fell into (see next section).
### The pointer → doc-shim indirection (how the polyfill shim resolves accounts) ### The pointer → doc-shim indirection (how the polyfill shim resolves accounts)
`store-registry.ts` keeps a map `identifier → {docPublic, docProtected, docPrivate}` `shared-wallet/account-registry.ts` keeps a map `identifier → {docPublic, docProtected, docPrivate}`
(the "shim", the account→document trust root). It must be reachable by a fresh (the "shim", the account→document trust root). It must be reachable by a fresh
reconnecting session (findable) AND authoritative on a cold read (so a fresh page reconnecting session (findable) AND authoritative on a cold read (so a fresh page
does not mistake sync-lag for "account absent" and PROVISION a fork). Since no single does not mistake sync-lag for "account absent" and PROVISION a fork). Since no single
document is both (previous section), the shim uses an **indirection**: document is both (previous section), the shim uses an **indirection**:
1. **doc-shim** — a `doc_create`d graph document (`did:ng:o:...`, hence a first-`State` 1. **doc-shim** — a `doc_create`d graph document (`did:ng:o:...`, hence a first-`State`
barrier). **All `AccountRecord`s live inside it.** Because it is subscribable, an barrier). **All `VirtualUserRecord`s live inside it.** Because it is subscribable, an
anchored read behind its `ensureRepoOpen` barrier is **authoritative**: a cold 0 anchored read behind its `ensureRepoOpen` barrier is **authoritative**: a cold 0
means the account is genuinely absent. means the account is genuinely absent.
2. **pointer** — a single well-known, **write-once** triple in the store-root graph, 2. **pointer** — a single well-known, **write-once** triple in the store-root graph,
@@ -327,10 +445,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
@@ -406,30 +530,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
@@ -470,3 +601,244 @@ logout is exposed (`ng.session_stop()`, `ng.user_disconnect()`,
redirect afterwards. This lib's identity store sidesteps all of it — the identity redirect afterwards. This lib's identity store sidesteps all of it — the identity
id is set at wallet-import time and relayed to the lib, without a separate login; id is set at wallet-import time and relayed to the lib, without a separate login;
see the identity store in [`simulation.md`](./simulation.md). see the identity store in [`simulation.md`](./simulation.md).
## Authorship, existence, outer overlay, `Ext` (section added 2026-07-27)
Four capability facts about the current core, verified in `nextgraph-rs`. They bear on
what can be BUILT on top (can we deliver a key? can we tell whether a document exists?
can we attribute a write?) — they are not a security assessment. Each carries its
epistemic status; do not upgrade an INFERRED item without new evidence.
### Author-signature verification is never called at runtime — VERIFIED
`Commit::verify` (`engine/repo/src/commit.rs`) chains `verify_sig``verify_perm`
`verify_full_object_refs_of_branch_at_commit`. Its only callers in the whole tree are
inside `#[cfg(test)] mod test` blocks (`engine/repo/src/commit.rs`,
`engine/repo/src/branch.rs`); `verify_sig` and `verify_perm` have no other caller. The
verifier's commit path calls a **different** `verify`:
`CommitBodyV0::<Body>::verify(commit, self, branch_id, repo_id, store)` in
`engine/verifier/src/verifier.rs` — the `CommitVerifier` trait, which APPLIES a body
(mutating verifier state); it is not a signature check.
Even if it were called it could not succeed. `verify_sig` resolves the author through
`Repo::member_pubkey``Repo.members`, and every `Repo` the verifier builds at runtime
sets `members: HashMap::new()``engine/verifier/src/user_storage/repo.rs` (with a
literal `//TODO: members`) and `engine/verifier/src/commits/mod.rs`. Only
`Repo::new_with_member` ever populates a member, and it is called only from tests. An
empty table makes `member_pubkey` return `NotFound`
`CommitVerifyError::PermissionDenied`.
Reading authorship at all presupposes the read cap (VERIFIED): the author field is not a
UserId but `CommitContent::author_digest(user, overlay)`, a BLAKE3 keyed hash, and the
commit content sits in blocks ChaCha20-encrypted under `Object::convergence_key(store)`
(`engine/repo/src/object.rs`), whose key material is the store id **plus the
store-overlay-branch ReadCapSecret**. No read cap → the author field is not even
visible. *Nuance, VERIFIED:* the digest's own hashing key derives from
`overlay_id_for_read_purpose`, which for Public/Protected/Private/Group stores is
`OverlayId::outer(store_id)` — public. What is secret is the commit content, not the
hash key.
**Consequence for this lib:** "who wrote this triple" is unanswerable today — neither
cryptographically (nothing verifies) nor by identity (the digest is opaque without a
member table). Any authorship or provenance the polyfill needs must be carried in the
DATA it writes and re-read from there; an "authored by X" claim in the emulation has no
core check behind it.
### No existence probe at SDK level — addressing presupposes the cap — VERIFIED
`AppRequestCommandV0` (`engine/net/src/app_protocol.rs`) contains no existence command:
`Fetch`, `Pin`, `UnPin`, `Delete`, `Create`, `FileGet`, `FilePut`, `Header`, `InboxPost`,
`SocialQueryStart`, `SocialQueryCancel`, `QrCodeProfile`, `QrCodeProfileImport`,
`OrmStartGraph`, `OrmStartDiscrete`, `OrmGraphUpdate`, `OrmDiscreteUpdate`, `OrmStop`.
Nothing answers *"does document D exist?"*.
The single probe in the tree is internal and cannot answer it either:
`Verifier::has_blocks` (`engine/verifier/src/verifier.rs`) sends
`BlocksExist { blocks, overlay }`. It is `pub(crate)` (never reaches JS); it takes
**`BlockId`s** — content addresses you only hold if you already read the object; it takes
a **`&Repo` already loaded**; and it targets
`repo.store.overlay_for_read_on_client_protocol()` = the **inner** overlay
(`Store::inner_overlay``overlay_id_for_write_purpose(store_overlay_branch_readcap.key)`,
`engine/repo/src/store.rs`), derived from the read-cap secret.
**Consequence for this lib:** you cannot prove — nor disprove — the existence of a
document whose key you do not hold. **Addressing presupposes the cap.** Every "is it
there?" question therefore collapses into "can I read it?", which is why absence is only
ever established behind a sync barrier (see § *Findable-without-lookup vs subscribable*)
and never by probing.
### `expose_outer` is hard-coded to `false` — VERIFIED
Both constructors of `PinRepo``PinRepo::for_branch` and `PinRepo::from_repo`
(`engine/net/src/actors/client/pin_repo.rs`) — set `expose_outer: false`, and they are
the only two `PinRepoV0` constructions in the tree. No parameter carries the flag up:
`expose_outer` appears nowhere under `sdk/`. The broker side is fully wired
(`RepoInfo.expose_outer: HashSet<UserId>` in `engine/broker/src/server_broker.rs`, the
`if expose_outer` branch in `rocksdb_server_storage.rs`, the outer-overlay registration
in `server_storage/core/overlay.rs`), and the `PinRepo` responder even validates the flag
(refusing `expose_outer` from a peer that publishes no topic) — but no client ever sets
it.
**Consequence for this lib:** a store's **outer** overlay is never registered broker-side,
so there is no anonymous / capability-free read surface to build on. Everything is reached
through the inner overlay, i.e. through a read cap — the same cap-first addressing as
above. The "public store readable by everyone without permission" promise in the official
docs has no client-side switch today.
### The `Ext` protocol serves blocks with no control — VERIFIED
The `ExtObjectGetV0` responder (`engine/net/src/actors/ext/get.rs`) builds
`Store::new_from_overlay_id(&req.overlay, …)` from the OverlayId the **requester
declares**, then returns `Object::load_without_header(obj_id, None, &store)` blocks for
each requested id. No authentication, no verification that the requester belongs to that
overlay. The guards that were planned exist but are dead:
- `Authorization::ExtMessage` is matched in `Broker::authorize`
(`engine/net/src/broker.rs`) and returns `AccessDenied` — but **no caller ever passes
it**; the only `authorize` call sites pass `Discover`, `Admin` or `Client`. The
server-side `StartProtocol::Ext` arm in `engine/net/src/connection.rs` goes straight to
`StepReply::Responder`, never through `authorize`.
- the config flag whose comment reads *"are ExtRequest allowed on the server? this
requires the core to be ON."* — `allow_read` in `engine/net/src/types.rs` — is declared
and defaulted to `false`, and **read nowhere**.
- `ExtRequestContentV0::get_actor` handles `WalletGetExport` and `ExtObjectGet` and falls
through to `_ => unimplemented!()` for `ExtTopicSyncReq` — a **panic reachable from an
anonymous peer**. (The commented-out `// Self::ExtTopicSyncReq(a) => a.get_actor(),` on
that arm and the `// TODO inbox requests` in the enum are *direction hints*, labelled as
such — not current behaviour.)
**Consequence for this lib:** `Ext` is not a usable read path in either direction. Blocks
come back **encrypted**, and naming them requires ObjectIds you only have once you can
already read — so it grants no capability we could build on, and confirms the shape of
everything above: confidentiality lives entirely in the keys, and holding no key means
holding no partial access, just none.
## Known open issues (section added 2026-07-18)
Live limitations observed against the current core/SDK, each with its epistemic
status. **None is treated.** The status labels below are load-bearing — do not
upgrade an OPEN / UNDETERMINED / HYPOTHESIS item to "confirmed" or "fixed"
without new evidence.
### Write loss on socket death (`SerializationError`) — symptom VERIFIED, mechanism UNSETTLED, OPEN / untreated
A write made just before an idle period / spontaneous socket death
(`SOCKET IS CLOSED Some(Left(SerializationError))`) can be **silently lost**:
the entity is absent on reconnection while the account survives. Reconnection is
an unimplemented `// TODO` stub in the core (`broker.rs`, ≈ `1051-1076`);
`disconnections_subscribe` DOES fire on the failure but nothing — neither this
polyfill nor the consumer app — consumes it; and there is **no
write-durability-confirmation API** a caller could `await`. Full post-mortem
(logs, causal chain, correction leads, none arbitrated):
[`incidents/2026-07-14-write-loss-on-disconnect.md`](./incidents/2026-07-14-write-loss-on-disconnect.md).
### Cold-start read does not rehydrate the owner's own scope from the broker — symptom VERIFIED, root cause UNDETERMINED, OPEN / untreated
Decisive test (2026-07-14): a genuinely no-local cold reader — fresh
non-persistent browser context, SAME wallet + account — reads **0** of the
owner's own scope from the broker. The previously "passing" reconnect test was
FALSE-GREEN: it read the owner's repos from the persistent profile's LOCAL
IndexedDB, so it never proved broker durability. It is UNDETERMINED whether
**(i)** the write never durably reached the broker, or **(ii)** the write IS on
the broker but a fresh session cannot re-open the owner's own scope docs (a
cold-open / rehydration limitation) — both collapse to the same 0-read in this
setup. Next step (NOT done): disambiguate (i) vs (ii) with an independent warm /
second-identity read of the same doc. The same (i)/(ii) reserve is carried in
[`incidents/2026-07-14-write-loss-on-disconnect.md`](./incidents/2026-07-14-write-loss-on-disconnect.md)
*Portée & non-reproduit*), whose Firefox case leans (i) — this cold-reader
signature is distinct (no socket death) and does not settle it.
### Reactive subscription may not echo the writer's OWN local commit — HYPOTHESIS (high-confidence), confirmation in progress (2026-07-18), NOT confirmed, NOT fixed
When a client does a local `sparqlUpdate` on a doc it is itself subscribed to
(`subscribeDoc`/`doc_subscribe`), the subscription callback appears NOT to fire
for its own local commit in the same session, so the polyfill's reactive re-read
chain never runs and consumers keep a stale value until the next connection
delivers a fresh initial `State`. REMOTE commits DO push correctly (verified:
cross-browser reactive update works). Verdict pending a live instrumented run.
Full write-up (suspect link, instrumentation, planned polyfill-side fix):
[`../packages/polyfill/docs/polyfill-reference.md`](../packages/polyfill/docs/polyfill-reference.md)
§ *Current emulation status*.
### Cold-start anchored read returns 0 rows instead of an error — symptom VERIFIED, mechanism INFERRED, healed polyfill-side
On a FRESH session over the SAME persistent wallet (reconnect, new page, re-login), an
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
reader of the polyfill and healed identically in each (`ensureRepoOpen` before the read,
`packages/polyfill/src/emulated-verifier/open-repo.ts`): the user's own documents,
the user's store (`shared-wallet/account-registry.ts` `readUserStore`), the by-need doc batch
(`surface/read-model.ts` `readUnion`), and the store-root pointer read (`shared-wallet/account-registry.ts`
`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.
The circularity that made it self-inflicted (VERIFIED by the fix working): `doc_subscribe`
WOULD open the repo, but the reactive layer only subscribes AFTER a listing produced
NURIs, and the listing is itself an anchored read of a not-yet-open index repo → 0 rows →
nothing to subscribe → nothing ever opens.
**Mechanism INFERRED, not established.** `resolve_target_for_sparql(Repo(id))`
(`engine/verifier/src/request_processor.rs`) does
`self.repos.get(repo_id).ok_or(RepoNotFound)`, so a repo genuinely absent from
`self.repos` should ERROR, not return 0 rows. The most plausible reading of the silent 0
is that the repo IS in `self.repos` (loaded from local user storage at bootstrap) while
its named graph in `graph_dataset` is not yet populated — commits not applied/synced yet
— so the query legitimately matches nothing. Not traced end to end; the tension with the
`RepoNotFound` path described in § *A repo is only queryable once OPENED/synced into the
store* is unresolved.
**Consequence for this lib:** a cold anchored read is NOT authoritative on its own — 0
rows does not mean absent. This is what imposes the open-then-read discipline on every
cold reader, and it is why the account trust root had to move behind a first-`State`
barrier (see § *The pointer → doc-shim indirection*).
### Account fork on concurrent provision — symptom VERIFIED, guarded polyfill-side, residue persists in wallets
On a fresh page, several independent callers hit `ensureAccount(A)` near-simultaneously
(the public and protected `watchShape`, container subscriptions, the app's owned-events
effect). When the account is genuinely new, each caller sees 0 and each provisions its
own set of three scope documents — an **in-session account fork**. The persisted residue
is a single account subject carrying MULTIPLE values for one scope predicate (observed:
five `shim:docPublic`), after which a writer and a later reader can resolve DIFFERENT
scope docs and the reader's anchored read returns 0.
Two polyfill-side guards, both in `packages/polyfill/src/shared-wallet/account-registry.ts`: `ensureInFlight`
(a bounded promise map keyed by account, so concurrent `ensureAccount` calls share ONE
resolve-or-provision) prevents new forks; `canonicalDoc` (pick the lexicographically
smallest NURI among all distinct values for a scope predicate — NURIs are
content-addressed, so the order is total and session-independent) makes resolution
deterministic on wallets that already carry fork residue. The earlier account-level
`provisionRetry` / `resolveAccountReliably` loop is gone, replaced by the doc-shim
barrier.
**Consequence for this lib:** the underlying enabler is core-side — there is no atomic
create-if-absent, and no existence probe to settle "does this account already exist?"
(see § *No existence probe at SDK level*), so provisioning is a read-then-create race the
polyfill has to serialize itself. The guards are mitigation, not a fix: a wallet already
corrupted stays corrupted, and only `canonicalDoc` keeps it readable.
### Outbox replay aborts on an unknown topic (`REPLAY TOPIC NOT FOUND`) — VERIFIED in core, already documented as an incident
`Verifier::send_outbox` (`engine/verifier/src/verifier.rs`) walks the queued events and,
for each, looks up `self.topics.get(&(overlay, topic_id))`. On a miss it logs
`REPLAY TOPIC NOT FOUND <topic> IN OVERLAY <overlay>` and sets `need_replay`, calls
`load_from_credentials_and_outbox(&events_to_replay)`, then in the send loop does
`self.topics.get(…).ok_or(NgError::TopicNotFound)?` — the `?` **aborts the whole outbox
flush**, so the remaining queued events are not sent. There is no per-event isolation and
no signal to the caller.
Already covered — **not duplicated here**: this is the core-side mechanism behind the
symptom described in § *Write loss on socket death (`SerializationError`)* above, whose
full post-mortem (logs, causal chain, the unarbitrated (i)/(ii) reserve) is
[`incidents/2026-07-14-write-loss-on-disconnect.md`](./incidents/2026-07-14-write-loss-on-disconnect.md).
The spontaneous socket death (`SOCKET IS CLOSED Some(Left(SerializationError))`) is
likewise covered there and in that section — the only fact added here is the abort
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,
and one unknown topic can take the rest of the queue with it. The polyfill's own
`shared-wallet/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
is durable".
+30 -30
View File
@@ -5,7 +5,7 @@ shared wallet. This is a design decision, grounded entirely in the query
capability documented in capability documented in
[`nextgraph-current-state.md`](./nextgraph-current-state.md) § *The query [`nextgraph-current-state.md`](./nextgraph-current-state.md) § *The query
capability*. The consumer application never sees any of this: it asks capability*. The consumer application never sees any of this: it asks
`@ng-eventually/client` for its lists by need and trusts the answer — the whole `@ng-eventually/polyfill` for its lists by need and trusts the answer — the whole
read mechanism lives here, in the polyfill. read mechanism lives here, in the polyfill.
> The rule in one line: read each by-need doc with its own anchored > The rule in one line: read each by-need doc with its own anchored
@@ -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 virtualUsers. 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 (`inbox.share` — 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
@@ -104,14 +104,14 @@ There is no reactive union query. So reactivity is assembled:
- keep a lightweight reactive subscription — `doc_subscribe`, or the ORM on an - keep a lightweight reactive subscription — `doc_subscribe`, or the ORM on an
already-opened single store (never a per-entity fan-out) — on the synced docs; already-opened single store (never a per-entity fan-out) — on the synced docs;
- on its change signal, re-run the bounded set of per-doc anchored - on its change signal, re-run the bounded set of per-doc anchored
`sparql_query`s (`readModel.readUnion`) — never an anchorless union-scan. `sparql_query`s (`readUnion`) — never an anchorless union-scan.
Keep the reactive ORM strictly to already-opened single stores; it is a change Keep the reactive ORM strictly to already-opened single stores; it is a change
*signal* source here, not the list source. *signal* source here, not the list source.
## The boundary with the consumer application ## The boundary with the consumer application
The consumer application asks the SDK for its lists by need and trusts the returned The consumer application asks the polyfill for its lists by need and trusts the returned
set. It never constructs a NURI, never picks the union-vs-anchor mode, never touches set. It never constructs a NURI, never picks the union-vs-anchor mode, never touches
the ORM. The domain-shaped list helpers (e.g. "my meeting points", "events") live in the ORM. The domain-shaped list helpers (e.g. "my meeting points", "events") live in
the consumer application, not the lib; the lib exposes the generic by-need read. the consumer application, not the lib; the lib exposes the generic by-need read.
@@ -165,14 +165,14 @@ Step (4) has a nuance worth recording, and it is exactly why the read path uses
graph — so an explicit `GRAPH ?g` body spans every opened graph independently of graph — so an explicit `GRAPH ?g` body spans every opened graph independently of
the anchor. The anchor's "one repo" restriction is observable only for a body that the anchor. The anchor's "one repo" restriction is observable only for a body that
reads the **default graph** (no `GRAPH` wrapper). That is precisely why the per-doc reads the **default graph** (no `GRAPH` wrapper). That is precisely why the per-doc
read in `read-model.ts` uses the anchored default-graph body read in `surface/read-model.ts` uses the anchored default-graph body
`SELECT ?s ?p ?o WHERE { ?s ?p ?o }`: the anchor makes that one repo the default `SELECT ?s ?p ?o WHERE { ?s ?p ?o }`: the anchor makes that one repo the default
graph, so the read is bounded to it — O(1) per doc, independent of wallet size — graph, so the read is bounded to it — O(1) per doc, independent of wallet size —
and never iterates the other named graphs. (A repo absent from `self.repos` throws and never iterates the other named graphs. (A repo absent from `self.repos` throws
`RepoNotFound` and is skipped per-doc, see the VERIFIED note above — the read cannot `RepoNotFound` and is skipped per-doc, see the VERIFIED note above — the read cannot
sync an unknown repo.) sync an unknown repo.)
> **Re-confirmed by the standing e2e harness (`packages/client/e2e/`, broker > **Re-confirmed by the standing e2e harness (`packages/polyfill/e2e/`, broker
> `@ng-org/web 0.1.2-alpha.13`).** The `docRoundTrip` check measures all three shapes > `@ng-org/web 0.1.2-alpha.13`).** The `docRoundTrip` check measures all three shapes
> anchored to a doc D: (a) a no-GRAPH default-graph write round-trips; (b) an explicit > anchored to a doc D: (a) a no-GRAPH default-graph write round-trips; (b) an explicit
> `INSERT DATA { GRAPH <D> {…} }` — a **constant** plain NURI — **also** round-trips > `INSERT DATA { GRAPH <D> {…} }` — a **constant** plain NURI — **also** round-trips
@@ -185,9 +185,9 @@ sync an unknown repo.)
> is per-doc anchored, preserved above), **not** a **constant** `GRAPH <D>` write, > is per-doc anchored, preserved above), **not** a **constant** `GRAPH <D>` write,
> which lands in D's repo. Re-run that harness to re-verify against a newer broker. > which lands in D's repo. Re-run that harness to re-verify against a newer broker.
## Implementation — `read-model.ts` ## Implementation — `surface/read-model.ts`
`readModel.readUnion(docs)` implements this: for each requested doc NURI (the `readUnion(docs)` implements this: for each requested doc NURI (the
bounded by-need set), run — in parallel, tolerant per-doc (a doc that fails is bounded by-need set), run — in parallel, tolerant per-doc (a doc that fails is
skipped, never aborting the batch like the ORM fan-out would) — one anchored skipped, never aborting the batch like the ORM fan-out would) — one anchored
`SELECT ?s ?p ?o WHERE { ?s ?p ?o }` with `anchor = docNuri`. The anchor restricts `SELECT ?s ?p ?o WHERE { ?s ?p ?o }` with `anchor = docNuri`. The anchor restricts
+407
View File
@@ -0,0 +1,407 @@
# NextGraph's ReadCap & NURI model — and the polyfill's caps emulation
**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 `emulated-verifier/caps.ts` emulation (an ACL until the cap-surface work — 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.
> - **The access rule is "whoever has the reference AND the key reads" — never "whoever has the reference reads".** An agent forgets this with remarkable consistency, because client-server reflexes supply a missing half: somewhere a server checks. Here nobody checks (§0 below). Getting it wrong does not fail — it silently grants. Every time you hand out a value, ask which of the two you just handed.
> - **NextGraph's naming ALWAYS wins over any rule of ours — including the one that follows.** Where upstream has a word for something, that is the word, full stop. `publisher` is a case in point: it appears 126 times in the engine (`as_publisher`, `publisher_advert`) for a **pub/sub role on a topic**, and it must be used, unchanged, whenever that is what is meant.
> - **What is banned is OUR loose "publish a document"**, which has no upstream referent and covers three different acts: *placing a document in a public store*, *making it findable*, and (in this emulation only) *handing out a key*. Every design discussion that used it drifted between them. NextGraph knows only the first — a document IS IN a public store, and brokers serve it accordingly. Say which act you mean. If you catch yourself writing "publish a document", you have not yet decided which one. This never licenses renaming something upstream calls `publisher`.
> - **"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.
---
## 0. The security model, and why an agent keeps losing it
**This is the one thing to hold on to.** Every design mistake this file records — a cap minted where a reference sufficed, a link that granted what it was meant to merely name, an "is it allowed?" call that cannot exist — comes from letting go of it.
### The rule
> A document is readable by whoever holds **the reference AND the key**. Not by whoever holds the reference.
There is no third party in that sentence. No server, no ACL, no check at access time: the engine verifies a permission on WRITE only (`verify_permission``PermissionDenied` in `Commit::verify`), never on read. Content is encrypted; the key IS the right. That is what local-first means here — there is nobody to ask, so possession is the whole mechanism.
### Why this is easy to lose
Client-server reflexes supply the missing half without asking. In that world a link is an identifier, harmless on its own, and access is decided elsewhere — so handing out a URL feels safe, and "may Bob read this?" is a question with an answer. Both instincts are wrong here, and neither announces itself: code written on them **does not fail**, it silently grants. There is no error to notice, no test that goes red on its own.
### What the rule buys — composable confidentiality
Because a reference grants nothing, **a reference is not recursive**: a widely-circulated document may point at a restricted one. The reference says *it exists*; the key says *who reads it*.
That is what lets confidentiality be composed inside one document:
- a public summary pointing at restricted chapters,
- a public event pointing at its participant list,
- a profile pointing at data reserved for a circle.
The author circulates one document and still decides, per referenced document, who gets the key. **Remove non-recursiveness and this whole construction collapses**: everything a circulated document mentions becomes readable by all its readers, and no level of confidentiality can exist inside a document one shares.
`test/cross-user-access.test.ts` is this property's test, and it is the suite's centrepiece: Alice's document in a public store REFERENCES her protected one; Bob follows the reference, names the target, reads nothing of it. It writes the reference **bare** — which is exactly what an application must do.
### The failure mode to watch for
Any call that returns a key where a reference was asked for. It looks like a convenience ("give me the link to my document"), it type-checks, the tests stay green — and it converts "whoever has the reference and the key" into "whoever has the reference", for that document and every document it mentions. `linkTo` (added and removed 2026-08-06) was exactly this.
---
## 1. A ReadCap = possession of a key, NOT a per-identity ACL
A **ReadCap is fundamentally a cryptographic key that one holds**, not an ACL entry tied to a wallet. "Whoever holds the key can read."
- Structure: `ReadCap = ObjectRef = BlockRef { id: BlockId, key: SymKey }` (`engine/repo/src/types.rs:461, 463-471, 557, 565`).
- `id: BlockId` = **BLAKE3** digest (address of the encrypted object).
- `key: SymKey = ChaCha20Key([u8;32])` = the **decryption key**.
Holding the pair → the broker serves the encrypted blocks by `id`, and one decrypts **locally** with `key`.
- Granularity: per commit/object the `ObjectRef` **is** the cap; for a branch → its defining commit; for a repo → RootBranch; for a store → the root repo's cap (`types.rs:559-565`). `ReadCapSecret` = the key half (`:567-570`).
- **There is NO read-ACL.** A repo's membership/permissions (`RootBranch`, `AddMember`, `AddPermission`) govern **writing/admin**, not reading. Reading is guarded only by key possession.
## 2. Granting read access = sealing the key to the recipient
"Grant" = delivering the cap **sealed** (`crypto_box seal`, anonymous public-key encryption) to the recipient's **inbox pubkey** — only they can open it with their private key.
- Sealed inbox message: `InboxMsgBody.msg` = `crypto_box::seal(... to_inbox ...)`, opened with the inbox secret key (`engine/net/src/types.rs:4272, 4299, 4319`).
- The payload can carry a cap: `ContactDetails.read_cap: Option<ReadCap>` ("if user wants to share the content of profile") (`net/types.rs:4232-4233`) → **directed grant** (sealed to one recipient).
- **Undirected** variant: `RepoLinkV0.read_cap` = a shareable link that **whoever receives it** can open (`net/types.rs:5061-5078`).
So "wallet targeting" lives in the **sealing envelope**, not in the cap: the cap remains `{id, key}`, possession-based.
> **Current state (2026-07-27) — the path is a GAP, not a disagreement.** The `ContactDetails.read_cap` field exists, but the construction of the message is `unimplemented!()` (its only caller passes "without read_cap") and the receiver **discards** the cap it would receive. The *shape* is therefore the right one; the implementation is not there. The polyfill emulates it in the meantime — filed in the bug-inbox.
## 3. Revocation = re-key (coarse, non-retroactive)
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`).
- 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).
- **Durable** delivery of a cap = `PermaCap` — still **TODO** (`repo/types.rs:578`).
### DIRECTION — rotation does NOT cause access to be lost (confirmed by the PO, 2026-07-27)
**Do not read the comment above as the intent.** "*if they don't subscribe, they lose access after the refresh*" describes **the current state**, not the target. What NextGraph is aiming for:
> When a key is rotated, the new one is **sent to the inbox** of the users who retain the access right. That inbox is **processed automatically** as soon as one of the user's clients connects.
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 `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`.
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`:
- `did:ng:o:{repo_id}` (`:315`, `RE_REPO_O` types.rs:52)
- `did:ng:o:{repo_id}:v:{overlay_id}` (`:263`, `RE_REPO` types.rs:55)
- `did:ng:o:{repo_id}:v:{overlay_id}:b:{branch_id}` (`RE_BRANCH` types.rs:58)
- `did:ng:o:{repo_id}:c:{commit_id}` (`:355`)
- `did:ng:b:{branch}` / `h:{topic}` / `v:{overlay}` / `d:{inbox}` (`:327,323,319,359`)
**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:
| Segment | Shape | What it is |
|---|---|---|
| `: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.
## 4bis. The overlay is the network space of a STORE — never of a document
**The overlay is a store's unit of network addressing.** At the broker, blocks are filed under a `(overlay, block_id)` key, and peers synchronize *within* an overlay. Two forms per store:
| | Derivation | Who can compute it |
|---|---|---|
| **outer** | `OverlayId::outer(store_id)` = **public** BLAKE3 | everyone (the store_id is enough) |
| **inner** | `OverlayId::inner(store_id, readcap_secret)` = **keyed** BLAKE3 | only whoever holds the store's read key |
Consistent with the rest of the model: no role and no list, only "do you hold the key that lets you derive this identifier". `outer` = the store's public name, `inner` = its private name.
**The `:v:` of a DOCUMENT NURI carries the overlay of its STORE** (VERIFIED, chain read end to end): `NuriV0::repo_graph_name(repo_id, overlay_id)` formats `o:{repo_id}:v:{overlay_id}`; in `doc_create` the value injected is `store.outer_overlay()` — the **containing** store, never the `repo_id`. A `Repo` carries **no** overlay field (only `store: Arc<Store>`); it is `Store` that carries `overlay_id`. **Mechanical counter-proof**: in `Store`, `get`/`put`/`del`/`has` all pass `&self.overlay_id` to the block storage — every document of a store shares the same block namespace, so a per-document overlay is structurally impossible.
### The consequence to know about: the `:v:` is a stable pseudonym
**All of one person's documents in their protected store carry the SAME `:v:`** = `outer(protected_store_id)`. So a cap-less reference — precisely the one used to "name without granting read" — **exposes store membership**, that is to say a **stable and permanent pseudonymous identifier of the person**. The store_id itself does not leak (BLAKE3 is not invertible), so it does not say *who*; but it is a **constant handle**, the same everywhere and forever, correlatable by anyone who collects cap-less references.
**The coupling that results, and that constrains any anonymous-presence model**: that same `:v:` is *simultaneously* (a) what makes it possible to **deduplicate** references without reading them — two references with the same `:v:` come from the same person — and (b) what makes it possible to **track** that person from one context to another. **It is the same bit of information.** You cannot get the dedup without conceding the tracking, nor remove the tracking without losing the dedup — short of changing how the stores are carved up, which moves the cursor but does not remove the trade-off.
*Nuances.* The NURI's `:v:` is the **outer** overlay, whereas client↔broker traffic and local storage use the **inner** one — a different value, but derived from the store as well, so the property holds in both cases. A `Dialog` store returns an `Inner`, still store-scoped.
**CORRECTED on 2026-07-27 — this hypothesis was FALSE.** We had inferred, then believed we had verified, that a holder **without a key** could fetch the encrypted blocks and therefore prove a document's **existence**. An adversarial review showed that the reasoning stopped at *access control* without looking at **addressing**:
- There is **no existence command at the SDK level**.
- The only probe (`BlocksExist`) is **internal to the crate**, requires `BlockId`s **and** an already **loaded** repo, and addresses the **inner** overlay — which is derived from the **read secret**.
- A cap-less reference carries a RepoId and the **outer** overlay: no `BlockId` to probe. And the outer is never registered anyway (`expose_outer` hard-coded to `false`, with no SDK parameter).
- The primitive a non-member can reach (`ExtObjectGet`) requires the **ObjectIds**, which one only holds once one can already read. *(Corrected 2026-08-10 — this line used to say "the ObjectIds **and their keys**", and to call `ExtObjectGet` the **only** such primitive. Both are wrong at the source: `ExtObjectGetV0 { overlay, ids: Vec<ObjectId>, include_files }` has **no key field** at all (`engine/net/src/types.rs:4492-4501`), and `ExtRequestContentV0` has **three** variants — `WalletGetExport`, `ExtObjectGet`, `ExtTopicSyncReq` (`:4520-4526`), the last of which falls into `unimplemented!()` (`:4533`). The conclusion is unchanged and rests on addressing, not on keys: blocks come back **encrypted**, and naming them needs ObjectIds a non-holder does not have — the formulation `docs/nextgraph-current-state.md` § "The `Ext` protocol serves blocks with no control" already carried.)*
> **Addressing itself presupposes the cap.** Proving a document's existence without holding its key is not constructible today, and nothing indicates that it is planned.
Transferable lesson: verifying that an access guard **lets you through** does not prove that an operation is reachable — you still have to be able to **name** what you are asking for.
## 4ter. The public store: readable by URL, and NOT recursive
Target principle (confirmed by the PO, 2026-07-27):
> **An element of the public store is public: whoever has the URL reads the content.**
> But **not recursively** — public content can *reference* private content, and the reference does **not** give access to the referenced.
This is a **second mechanism**, alongside key possession (§1) — not a breach of it. And it is the **non-recursiveness** that carries the value: it allows a public object that **points** to private identity, without divulging it. That is exactly the pattern an anonymous-presence model needs.
*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.
## 4ter-bis. THERE IS NO DISCOVERY — you only ever follow links
**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:
> **You cannot discover. You can only follow links.**
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.
So **publishing is two acts, never one**:
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).
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".
**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 |
|---|---|---|
| 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. |
| 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. |
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, state column corrected 2026-08-10.** The delivery message, the register and the record all exist upstream under the same word, which is what a shape being real looks like. What none of them is, is *implemented* — the table said so of three rows, and re-reading the source refuted it:
| 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`) | **declared, stubbed** — the verifier arm is a no-op `Ok(())` (`engine/verifier/src/commits/mod.rs:681-693`), and nothing in the workspace constructs one |
| Withdrawing it | `RemoveLink`, ORset (`engine/repo/src/types.rs:1952`) | **declared, stubbed** — the same no-op `Ok(())` arm (`commits/mod.rs:695-707`) |
| What circulates | `RepoLinkV0 { read_cap, … }` (`engine/net/src/types.rs:5062-5078`) | **declared only** — zero constructors and zero consumers in the workspace (only the `RepoLink` wrapper and its two accessors, `:5082-5097`) |
**What makes "stub" the right word rather than a quibble**: the neighbouring arm in the same file does real work. `CommitVerifier for AddRepo` calls `load_repo_from_read_cap` then `add_doc` (`commits/mod.rs:644-664`); `AddLink` and `RemoveLink`, twenty lines below, return `Ok(())` with `#[allow(unused_variables)]` on every parameter. Same trait, same file, opposite states — so "there is a verifier arm" cannot be read as "the register works".
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** — which is not the same as having a behaviour. Per this document's own rule, none of that says what the target will do; it says the gesture is fully specified and none of it runs.
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 — a no-op `Ok(())` one — at `engine/verifier/src/commits/mod.rs:681-693`. 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 — the durable, cross-device record — is level 2': `AddLink` on the User branch of the private store.
**But "the register exists, only the delivery is missing" is FALSE, and this section said it until 2026-08-10.** Both ends are declared and stubbed, as the table above now records: nothing constructs an `AddLink` commit anywhere in the workspace, and the arm that would apply one returns `Ok(())`. So the corrected statement is: `AddLink` on the User branch is **where a received cap belongs in the model** — a placement the source states unambiguously and this library aligns on — and no part of the gesture runs today, neither the road nor the destination. Per the design principle that gap says nothing about the target; it forbids only calling the register "implemented".
The delivery half, in detail:
- `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 no cap reaches the User branch today.
- `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. (`AddRepo` runs; `AddLink` is declared and stubbed — see the table above. The *split by origin* is the model's, whatever each half's state.) 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.
## 4sexies. What a NURI TRANSPORTS — and the TWO acts that cover every use of it
**VERIFIED 2026-08-06** by reading `nextgraph-rs`, after a design discussion kept stalling on an assumed split between "a NURI" and "a link". There is no such split, and getting that wrong is what made this library ship a `linkTo` that was wrong three ways at once.
### A NURI and a Link are one thing in two shapes
`NuriV0` (`engine/net/src/app_protocol.rs:181-194`) carries exactly what a link carries:
```rust
NuriV0 { identity, target, entire_store, objects, signature,
branch, overlay, access: Vec<NgAccessV0>, topic, locator: Option<Locator> }
```
`NgLinkV0 = Repo | PublicRepo | Branch | Object` (`engine/net/src/types.rs:5206-5211`) is the structured form of the same information; the NURI is its URI form. So "should we hand out a NURI or a link?" is not a question — they are the same thing, and the real question is **what one puts in the `access` and `locator` slots**.
*Caveat, and it matters for anything printed on paper:* the **type** has all the slots, the **string grammar does not expose them all**. A self-contained share URL exists for objects, commits and files (`…:v:{overlay}(:[cj]:{id}:k:{key})+:l:{locator}`); the repo case is not exercised. See [`document-links.md`](./document-links.md).
### `access` is a LIST of access forms, not a key flag
```rust
NgAccessV0 = ReadCap(ReadCap) | Token(Digest) | ExtRequest(Vec<u8>)
| Key(BlockKey) | Inbox(PrivKey) | Topic(PrivKey)
```
(`app_protocol.rs:54-62`.) An **empty** `access` is therefore a legitimate, meaningful state: the reference NAMES its target and grants nothing. That is not a degenerate link — see below, it is the common case.
### There are TWO acts, and they cover everything
An earlier version of this section listed four "needs" (name, let read, let find, join). That was wrong: it confused what an act DOES with what its result depends on. A sender does exactly two things, and every use case is one of them:
| Act | What is transmitted | What the recipient can do |
|---|---|---|
| **Transmit the reference** | `target` (+ `overlay`/`locator`), `access` EMPTY | Name the document. **And read it, if it sits in a public store** — same value transmitted, different outcome, decided by where the document is and not by what was sent. |
| **Transmit the reference AND the key** | the above + `ReadCap` in `access` | Read it. Irreversible: nothing is checked later, and there is no revoking a key already handed out (only rotation, which also cuts off legitimate recipients — `PermaShare`/PermaCap, `engine/repo/src/types.rs:1761`, is the reserved answer, unimplemented). |
"Let someone find a public document" is therefore not a third act: it is the first one, applied to a document that happens to be in a public store. And "join" is not an act at all — the `locator` is about the reference being COMPLETE enough to be resolved by a stranger (no locator → no broker to ask → nothing opens, key or no key). It belongs to the recipient's side.
The consequence for anything this library exposes: **a call either hands over the key or it does not**, and that must be the caller's explicit intent — never a side effect of asking for a reference.
### Three confusions worth naming, because each one was made here
**"No key" does not mean "public".** A key-less reference to a PROTECTED document withholds access deliberately — the recipient can name it, not read it. A `PublicRepoLinkV0` (`types.rs:5105-5127`) carries no `read_cap` for an unrelated reason: the content is served publicly, so there is nothing to hand over. Same shape, opposite situations.
**Naming is not reading, and it is the DEFAULT act.** This is what `test/cross-user-access.test.ts` proves: Alice has a document in a public store that REFERENCES her protected one; Bob follows the public link, finds the reference, and can name the protected document while reading nothing of it. Publication is not recursive. Withholding the key is the ordinary, reversible gesture; including it is the exceptional, irreversible one.
**Nothing is checked at access time.** Reading IS possession — the engine verifies a permission on WRITE only (`verify_permission``PermissionDenied` in `Commit::verify`), never on read. So a reference cannot be "neutral about rights, resolved later": what you put in it IS the grant. There is no revoking a link you have circulated, only rotating the key (and `RepoLinkV0`'s own comment says shared caps go stale on refresh — `PermaShare` / PermaCap, `engine/repo/src/types.rs:1761`, is the reserved answer to that, unimplemented).
### Where this library stands, act by act
- **Transmit the reference** — covered, with no dedicated call: every reference the surface returns is bare (`createEntityDoc`, `docCreate`, `listMyEntityDocs`, `UnionSubject.subject`/`.graph`). An application cites what it already holds. Faithful.
- **Transmit the reference and the key**`inbox.share(doc, toUser)`. Names the document and the person; the key is looked up and sealed into a deposit, and the recipient applies it by connecting, with nothing to call. Faithful in shape. One recorded divergence: the deposit always goes to the recipient's PROTECTED inbox, where upstream the choice follows the profile the person was reached by (`engine/verifier/src/inbox_processor.rs:787`).
**And the property that makes the first act worth anything — a public store SERVES its documents' caps.** `PublicRepoLinkV0` (`engine/net/src/types.rs:5098-5124`) carries `repo`, `public_store` and `peers` and no `read_cap`, and its own comment says why: *"The latest ReadCap of the branch (or main branch) will be **downloaded from the outerOverlay**, if the peer brokers listed below allow it. […] This link is durable, because the public site are **served differently by brokers**."* The key is not something a sender hands over; it is something the network gives to whoever asks, because the broker pinned the outer overlay (`expose_outer`, `engine/broker/src/server_storage/core/overlay.rs:103-133`).
That is emulated, since 2026-08-06, in `emulated-verifier/public-store.ts` — and emulated **without touching the guard**. Possession remains the one criterion: a public document is readable not because `mayReach` makes an exception, but because its cap is *obtainable* — the library asks, files what it gets, and from there the ordinary path applies. Every read door asks first (`readUnion`, `docs.sparqlQuery`, `ensureRepoOpen`, `documentInboxAddress`).
Where the emulation shows its seams, stated rather than hidden:
- Upstream nothing is WRITTEN to make a repo public — the store is public and the broker serves it. Here one broker serves every virtual user identically, so the cap is recorded on the document's Header branch and read back through the machinery's unguarded door. Fetching, not enumerating: a reader asks the document it already names.
- A reader therefore learns a document is public by asking THAT document. One it has never heard of stays invisible, where upstream a broker would serve it just the same. That limits discovery, not access.
- The cap a public store serves is a READ grant, and this emulation says so: `caps.learnFromPublicStore` files it apart, and `docs.sparqlUpdate` refuses a write on it (`assertMayWrite`). Without that, a bare reference would buy a write, which upstream it never does — writing needs the write cap, and no store hands that out.
- `useShape` cannot ask (its signature is the real ORM's, with no await to spend), so a public document reached through it alone, read nowhere first, is filtered out. Recorded in `emulated-verifier/read-filter.ts`.
- **No `locator` anywhere**, and the emulation's topology is why it does not show. Upstream a reference must be complete enough for a stranger to resolve — without a `locator` there is no broker to ask, and nothing opens, key or no key (`NuriV0.locator`, `engine/net/src/app_protocol.rs:181-194`). Here every virtual user is on the same broker, so the question never arises and no reference this library produces carries one. An application must not conclude that a bare reference travels anywhere: **it travels between users of one deployment**. The day two deployments have to exchange one, the locator is what will be missing, and nothing in the emulation will have prepared it.
One thing a consumer must not conclude from the emulation: that a public document has anything per-reader. Upstream there is no grant, no revoke and no audience on it — there is nothing to build a UI around.
## 5. What the polyfill emulates (caps.ts) — and where it still diverges
**Realigned 2026-07-28 (the cap-surface batch).** `packages/polyfill/src/emulated-verifier/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 `shared-wallet/account-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-cap-surface) |
|---|---|---|
| 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 | `inbox.share(doc, toUser)` → 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 |
| Public store | the broker serves the outer overlay; the ReadCap is **downloaded** from it | `public-store.ts` — the cap is exposed on the document and fetched through the machinery's door, then held like any other. Filed apart (`learnFromPublicStore`) so it grants reading and **not** writing |
**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 (`emulated-verifier/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 **cap-enforcement** 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 (`inbox.share` once, per document), which is a consumer **re-architecture**, not an API swap.
## 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.
- **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 DONE for the surface (cap-surface, 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 (cap-enforcement) — a key-material step, not a reshape. See `migration-guide.md` §1.
## Caveats / gaps
- `file:line` references are dated (2026-07) — re-verify by symbol; the core moves.
- ~~INFERRED: keyless broker fetch (existence without a key)~~**RESOLVED and REFUTED, 2026-07-27**: not constructible. See the CORRECTED block in §4bis. Kept struck through because the hypothesis is intuitive and will otherwise be re-formed.
- Not traced: the full execution of `RootCapRefresh` on the verifier side (`verifier/src/commits/mod.rs:616`), wallet storage of `private_store_read_cap` (`repo/types.rs:945,976`).
+328 -191
View File
@@ -11,7 +11,7 @@
> file for *how* each emulation works; read those two for *what is fake* and *what > file for *how* each emulation works; read those two for *what is fake* and *what
> replaces it*. > replaces it*.
The consumer application writes against `@ng-eventually/client` as if NextGraph The consumer application writes against `@ng-eventually/polyfill` as if NextGraph
already shipped per-entity documents in public/protected/private stores, capabilities already shipped per-entity documents in public/protected/private stores, capabilities
and inboxes. It hasn't (see [`nextgraph-current-state.md`](./nextgraph-current-state.md)). and inboxes. It hasn't (see [`nextgraph-current-state.md`](./nextgraph-current-state.md)).
This file is the lib's own engineering doctrine on how it fabricates that mature This file is the lib's own engineering doctrine on how it fabricates that mature
@@ -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, `shared-wallet/account-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 `shared-wallet/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
`shared-wallet/physical.ts` all dissolve into native per-user reads.
## Two axes, never conflate them (store ≠ document) ## Two axes, never conflate them (store ≠ document)
@@ -75,7 +86,7 @@ terminology historically fused:
`docCreate(sessionId, "Graph", "data:graph", "store", undefined)` targets the shared `docCreate(sessionId, "Graph", "data:graph", "store", undefined)` targets the shared
wallet's private store. The trailing `store` arg left `undefined` targets the wallet's private store. The trailing `store` arg left `undefined` targets the
private store (this is what `store-registry.ts`'s `createDoc()` does). So every private store (this is what `shared-wallet/account-registry.ts`'s `createDoc()` does). So every
document the shim creates physically lives in one store (private), and the document the shim creates physically lives in one store (private), and the
`public|protected|private` scope is a logical label tracked in RDF by the `public|protected|private` scope is a logical label tracked in RDF by the
shim — not a NextGraph store. Therefore what a consumer application's "multi-store" shim — not a NextGraph store. Therefore what a consumer application's "multi-store"
@@ -84,12 +95,12 @@ multi-store. Do not read `Scope` (`types.ts`) as a physical store — it is the
logical label the registry attaches. logical label the registry attaches.
> Why `undefined` and not a real store? Because `doc_create` **cannot target a > Why `undefined` and not a real store? Because `doc_create` **cannot target a
> non-private native store** today: `StoreRepo` is not JS-constructible (verified > non-private native store** today from the WEB build: `StoreRepo` is not constructible there (verified
> — see the parked `getNativeStore` note in > — see the parked `getNativeStore` note in
> [`migration-guide.md`](./migration-guide.md)). The private store is reachable > [`migration-guide.md`](./migration-guide.md)). The private store is reachable
> because it opens without `RepoNotFound`. > because it opens without `RepoNotFound`.
## The shared-wallet shim (`store-registry.ts`) ## The shared-wallet shim (`shared-wallet/account-registry.ts`)
Emulates the target infrastructure — where each user owns their own Emulates the target infrastructure — where each user owns their own
public/protected/private stores — on top of one shared wallet. public/protected/private stores — on top of one shared wallet.
@@ -114,41 +125,40 @@ public/protected/private stores — on top of one shared wallet.
account→document trust root, which is why every untrusted value that reaches its account→document trust root, which is why every untrusted value that reaches its
SPARQL is escaped (see SPARQL hardening below). It makes identity resolution SPARQL is escaped (see SPARQL hardening below). It makes identity resolution
cross-device: another device opening the same wallet reads the same pointer → the cross-device: another device opening the same wallet reads the same pointer → the
same doc-shim → the same accounts. same doc-shim → the same virtualUsers.
- **Per-entity documents + per-scope index.** `createEntityDoc(id, scope)` - **Per-entity documents + per-scope index.** `createEntityDoc(id, scope)`
makes a dedicated document for one entity (mirrors the target, where each entity makes a dedicated document for one entity (mirrors the target, where each entity
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 `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
and injects the session + identity-id normalization via `configureStoreRegistry({ and injects the session + identity-id normalization through `configure({
getSession, normalizeId })` (`polyfill.ts`). getSession, normalizeId })` (`shared-wallet/bootstrap.ts`, published by the entry).
The `store≠document` two axes materialize here directly: the registry moves along The `store≠document` two axes materialize here directly: the registry moves along
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, … ]
@@ -156,28 +166,28 @@ Virtual wallet (id)
So the 3 native stores (public/protected/private) are present, but emulated: each So the 3 native stores (public/protected/private) are present, but emulated: each
"store" is an index document "store" is an index document
(`AccountRecord.{docPublic,docProtected,docPrivate}`) that lists the NURIs of the (`VirtualUserRecord.{docPublic,docProtected,docPrivate}`) that lists the NURIs of the
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)).
### SDK-shaped scope resolvers — the consumer application holds no store-id ### SDK-shaped scope resolvers — the consumer application holds no store-id
The consumer application must never construct a `did:ng:${store_id}` NURI itself: The consumer application must never construct a `did:ng:${store_id}` NURI itself:
physical placement is the lib's job (the whole point of the SDK boundary). Two physical placement is the lib's job (the whole point of the polyfill boundary). Two
resolvers turn a logical scope into an opaque graph NURI without exposing any resolvers turn a logical scope into an opaque graph NURI without exposing any
store-id: store-id:
@@ -190,21 +200,45 @@ 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 - **`userInbox(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 `emulated-verifier/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/emulated-verifier/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
`privateStoreId` anchor). The consumer application hands the whole session to the `privateStoreId` anchor). The consumer application hands the whole session to the
lib at the one injection point (`configureStoreRegistry({ getSession })`) — that is lib at the one injection point (`configure({ getSession })`) — that is
wiring, not placement logic; everything else in the consumer application speaks only wiring, not placement logic; everything else in the consumer application speaks only
in scopes. If the session omits `protectedStoreId`, the non-private scopes fall back in scopes. If the session omits `protectedStoreId`, the non-private scopes fall back
to the private store rather than emit a broken NURI. to the private store rather than emit a broken NURI.
@@ -231,7 +265,7 @@ is preserved in [`decisions/private-store-nuri-scope.md`](./decisions/private-st
A validated hard constraint, not a style choice: `docs.ts` calls the real A validated hard constraint, not a style choice: `docs.ts` calls the real
injected `ng` (`getConfig().ng`) directly, never the public `ng` proxy injected `ng` (`getConfig().ng`) directly, never the public `ng` proxy
(`makeNg` in `ng-proxy.ts`). (`makeNg` in `surface/ng-proxy.ts`).
`@ng-org/web`'s `ng` is already an iframe-RPC proxy (postMessage marshaling, `@ng-org/web`'s `ng` is already an iframe-RPC proxy (postMessage marshaling,
see [`nextgraph-current-state.md`](./nextgraph-current-state.md) § integration). see [`nextgraph-current-state.md`](./nextgraph-current-state.md) § integration).
@@ -249,87 +283,154 @@ verified: routing the shim's `doc_create`/SPARQL through the public proxy turned
`docs.ts` therefore imports **no** `@ng-org` package and must **not** import from `docs.ts` therefore imports **no** `@ng-org` package and must **not** import from
`./ng-proxy`. `./ng-proxy`.
## Emulated ReadCap — per document (`caps.ts` + `read-filter.ts`) ## Emulated ReadCap — per document (`emulated-verifier/caps.ts` + `emulated-verifier/read-filter.ts`)
In the target the broker only delivers documents the wallet holds a ReadCap 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` (`emulated-verifier/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
- **`read-filter.ts`** — `makeReadFilteredView` wraps the reactive set in a type would be a concept NextGraph does not have. The stand-in key value is the
`Proxy`: iteration / `size` / `forEach` are filtered by constant `OK` (see the module header): the only question the emulation answers is
`caps.canRead(item['@graph'], user)`; everything else (`add`, `delete`, `has`, *do I hold this cap or not*, so the value says exactly that and pretends nothing
`getById`…) forwards to the target, preserving writes and reactivity. An item more. Cap-enforcement, not cap-surface, is the batch that turns the shape into a protection.
with no `@graph`, or in a document under no cap policy, is kept (the filter only - **`emulated-verifier/read-filter.ts`** — `makeReadFilteredView` wraps the reactive set in a
restricts documents that *declare* a cap — no regression on ungoverned data). `Proxy`: iteration / `size` / `forEach` keep only items whose `@graph` the
`filterReadable` is the pure variant. current holder holds; everything else (`add`, `delete`, `has`, `getById`…) forwards to
- **`useShape` (`use-shape.ts`)** applies the view only if the target, preserving writes and reactivity. An item with no `@graph` is kept (it
`caps.hasReadPolicy()` — otherwise it passes the real set through unchanged names no document, so there is no cap to hold). `filterReadable` is the pure
(no regression when the consumer application declares no caps). variant. Note the absence of a `user` parameter — that absence *is* the model.
- **`useShape` (`surface/use-shape.ts`)** applies the view only once a cap exists at all
(`caps.isEnforcing()`) — before that it passes the real set through unchanged (no
regression for a consumer that never touches caps). Once ANY cap is issued the
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:
- **`setCurrentUser(id)` (`polyfill.ts`)** — the SDK's "current identity" call. > **You do not derive a cap from a bare reference. You look it up in what you hold —
`useShape`'s filtered view reads it lazily, so the delivered subset always > or you were given it.**
reflects the identity in effect at read time. Until it is set, the filter has no
principal and (per `canRead(doc, null)`) only public documents pass — which is
why isolation stays dormant until the consumer application makes this call.
- **`getCaps().open(doc, scope, owner)`** — declares a document's policy when the
consumer application creates it: `public` → world-readable; `protected`/`private`
→ owner reads, owner holds the write cap. `open` also remembers `(scope, owner)`
per document so `protectedDocsOf(owner)` can later enumerate the protected ones.
- **`grantRead(doc, granteeId)` (`caps.ts`, exposed via `getCaps()`)** — the one
relationship-shaped sharing act the lib exposes: a directed per-document read
grant issued to a specific identity. Public docs stay world-readable; private
docs stay owner-only; a protected doc becomes readable by `granteeId` once the
owner grants it. The consumer application passes a document NURI and a grantee id
— no store id.
The relationship concept — who is "connected" to whom, and therefore which of Three ways a cap arrives, and there are no others:
their protected docs to grant — is owned by the consumer application, not the lib.
A connection or friendship is not a NextGraph primitive; the only platform-mappable
primitive is the directed per-document read grant above. So the consumer application
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 - **Creation.** `createEntityDoc(id, scope)` writes the cap on the store's emulated
owner; protected → owner + whoever the owner has directly granted; public → all. Store branch (`shim:readCap`) and the creator holds it. The consumer declares
Proven in `test/isolation-active.test.ts`: an unconnected principal is denied a nothing, and the cap is minted exactly ONCE — the stored value is the held value,
protected document, granted it after the owner issues a directed `grantRead`, and which is what keeps this correct when cap-enforcement makes the key real.
reads the public document throughout. - **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.** `inbox.share(doc, toUser)` 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.
This discrimination is only observable because each entity is its own document **The caps a holder holds are not the sharing mechanism.** Handing over a *store* cap would give
(the consumer application creates per-entity docs via `createEntityDoc` and `open`s away everything the store contains, present and future. The unit of sharing is the
each) — in a mono-store layout the per-document ReadCap is all-or-nothing. 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)` (`shared-wallet/bootstrap.ts`, INTERNAL since 2026-08-07 — `ensureIdentity` is what an application calls)** — the SDK's "current identity" call.
It selects *whose* caps are consulted, lazily, so the delivered subset always
reflects the identity in effect at read time.
- **`inbox.share(doc, toUser)`** — the one sharing act the lib exposes. Recipients
are addressed as **inboxes**, which `inbox.post(targetInbox)` already does here;
there is no `PrincipalId` in this surface, because that notion exists nowhere
upstream. Reaching several recipients means calling it once per inbox, which is
what the real model does too (each delivery is sealed to one recipient).
- **A document created in the `public` scope** needs no sharing act at all. The store
serves its ReadCap to whoever asks (`emulated-verifier/public-store.ts`, emulating
*"the latest ReadCap will be downloaded from the outerOverlay"*`PublicRepoLinkV0`,
`engine/net/src/types.rs:5098`), so what an application circulates is the **bare
reference**, exactly as it will after migration. Never recursive: a public document
may reference private ones, and the reference grants nothing on what it references —
which is what lets a public object point at a private identity without disclosing it.
And never a write right: what the store serves is a read cap.
Upstream, directed delivery is a **gap, not a disagreement**: `ContactDetails.read_cap`
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, so this lib emulates it meanwhile.
**Key rotation needs nothing on this surface.** A rotated key is re-sent to the
inbox of whoever keeps access, and that inbox is processed automatically at the next
connection — so access is not lost, it is *deferred*, consistent with local-first.
Same channel as the initial delivery, so there is **no subscription obligation** to
expose and no special case to write. Revocation stays what it is: stop re-delivering,
non-retroactive.
The relationship concept — who is "connected" to whom, and therefore whose documents
to share — is owned by the consumer application, not the lib. A connection or
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 cap-surface (shape) / cap-enforcement (enforcement) split honest rather than
cosmetic. Proven in `test/cross-user-access.test.ts`.
> **After the cap-surface batch 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)
The emulated write guard (`ng-proxy.ts`, `sparql_update` override) enforces the The emulated write guard (`surface/ng-proxy.ts`, `sparql_update` override) enforces the
per-document write cap on the public `ng` proxy only. In practice the per-document write cap on the public `ng` proxy only. In practice the
consumer application's write paths (`docs.sparqlUpdate`, ORM `ngSet`) call the real consumer application's write paths (`docs.sparqlUpdate`, ORM `ngSet`) call the real
injected `ng` directly — never the public proxy — for the validated `DataCloneError` injected `ng` directly — never the public proxy — for the validated `DataCloneError`
@@ -341,17 +442,17 @@ 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 (`emulated-verifier/caps.ts` + `emulated-verifier/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, `inbox.share` delivers one, a public
`grantRead(doc, granteeId)` per identity it wants to share with. Because the consumer store serves one to whoever asks). 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 polyfill. 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 +466,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,71 +489,107 @@ 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.userInbox(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 the cap-surface batch 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 (`inbox.share`), 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 (`emulated-verifier/reach.ts` + `shared-wallet/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 (`register-write.depositInto`, internal): 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.
## Emulated write guard (`ng-proxy.ts`) The machinery lives in `shared-wallet/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 (`emulated-verifier/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 (`surface/ng-proxy.ts`)
The public `ng` proxy overrides `sparql_update` to enforce an emulated write The public `ng` proxy overrides `sparql_update` to enforce an emulated write
cap: a write is refused unless the current user holds the target document's cap: a write is refused unless the current user holds the target document's
@@ -461,7 +598,7 @@ specific document (the `anchor` arg) is governed by it — ungoverned docs (the
mono-store default, no cap declared) flow through unchanged. This mirrors the target mono-store default, no cap declared) flow through unchanged. This mirrors the target
broker/verifier, which refuses a write without the document's write cap. broker/verifier, which refuses a write without the document's write cap.
## Identity store (`accounts.ts`) ## Identity store (`shared-wallet/virtualUsers.ts`)
The real NextGraph login (redirect to the broker, opening the single shared The real NextGraph login (redirect to the broker, opening the single shared
wallet) is perceived as a technical access barrier (see the login wallet) is perceived as a technical access barrier (see the login
@@ -479,7 +616,7 @@ identity id the consumer application relays to it:
open underneath. The real logout lives elsewhere (hidden in the consumer open underneath. The real logout lives elsewhere (hidden in the consumer
application's settings/debug), because it forces a new redirect. application's settings/debug), because it forces a new redirect.
- Framework-agnostic: no React, no DOM beyond an optional injected - Framework-agnostic: no React, no DOM beyond an optional injected
`AccountStorage` (a `window.localStorage`, a test fake, or `null` for SSR). The `VirtualUserStorage` (a `window.localStorage`, a test fake, or `null` for SSR). The
React `Context`/`Provider` stays in the consumer application. `normalizeId` React `Context`/`Provider` stays in the consumer application. `normalizeId`
(case-insensitive, optional leading `@` stripped, trimmed) is the pure (case-insensitive, optional leading `@` stripped, trimmed) is the pure
normalizer, reusable as the shim key normalizer. normalizer, reusable as the shim key normalizer.
@@ -502,5 +639,5 @@ document trust root):
(`did:ng:...`): validates and throws on IRI-breaking chars rather than emitting (`did:ng:...`): validates and throws on IRI-breaking chars rather than emitting
a malformed/injected query. a malformed/injected query.
These are re-exported from `@ng-eventually/client` so the consumer application These are re-exported from `@ng-eventually/polyfill` so the consumer application
reuses the same escaping when it builds SPARQL. reuses the same escaping when it builds SPARQL.
+167
View File
@@ -0,0 +1,167 @@
# Source layout by migration fate — analysis and recommendation
**Status: ADOPTED.** The layout this document recommends is the one in the tree — `model/`, `surface/`, `emulated-verifier/`, `shared-wallet/`. Read the rest as the reasoning that produced it, not as a proposal. *(Two things have moved under it since: the package became `packages/polyfill` / `@ng-eventually/polyfill` on 2026-08-10 — it was `packages/client` / `@ng-eventually/client`, kept here as a search keyword — and `emulated-verifier/public-store.ts` was added on 2026-08-06. Paths below point at where the files are now.)*
Written 2026-08-04, when nothing had yet been moved, from the source of `src/` (25 modules), the two contracts (`docs/api-contract.md`, `docs/internal-contract.md`) used as the export-level inventory, and the read-only `nextgraph-rs` clone (HEAD `213338f6`); the upstream facts this analysis leans on were re-verified at the source and are cited with layer numbers per `README.md` § *The three references* (1 = engine, 2 = wasm binding, 3 = JS ORM).
**The question.** Today all 25 modules sit flat in `src/`, named mechanically. Three different fates coexist undistinguished: modules whose *shape* the consumer keeps (the surface the real SDK replaces), modules standing in for what the engine/verifier will do natively, and modules that exist only because the emulation runs on one shared wallet. The bet under evaluation: if the folder structure mirrors the target's own structure, divergence gets harder to commit and easier to spot.
---
## 1. Is layout-by-future-layer the right axis?
**Yes on the axis, no on the literal reading — and with a bounded claim about what it buys.**
### 1a. The bet as literally stated is a category error
"Mirror the target's own structure" cannot mean mirroring `nextgraph-rs`'s tree (`engine/`, `sdk/js/lib-wasm`, `sdk/js/orm`). Every line this library ships lives in the polyfill; the three references are layers we *align on*, never places we write (`README.md` § *The three references*: "REFERENCES, not places we write code"). A folder named `engine/` or `verifier/` inside `src/` would claim residency in a layer we only read — the exact conflation the README warns produces false certainty. The right axis is the target's **stack as seen from the polyfill** — what each piece of our code stands in for, and therefore what happens to it at migration — not the target's repo tree.
### 1b. The three fates are real, but they are not three piles of equal nature
The two that evaporate at migration differ in the only way that matters for divergence:
- **Stand-ins for native behaviour** (cap possession and filing, the reach boundary, read filtering, inbox processing, branch registers, repo opening) have a **model to diverge from** — the engine's, level 1. This is where the dangerous failure lives: an emulation that drifts from the model teaches the consumer something to unlearn. Both incidents in `README.md` § *Design principle* happened here.
- **Shared-wallet compensation** (the account directory, the physical user, the identity relay, the diagnostics) has **nothing to align on** — NO COUNTERPART at any layer. It cannot diverge from a model; its only law is invisibility from the surface. Its failure mode is *leaking*, not drifting.
And a fourth group the three-fate framing misses: the **target's model vocabulary** (the NURI grammar, the type guards, the branded types) — level-1-verified transcription that every layer consumes and that survives migration as knowledge rather than as code to delete.
### 1c. Alternatives, honestly
- **By feature** (`inbox/`, `caps/`, `read/`…): optimises "find everything about X", which the api-contract's by-subject sections already do better — and it actively hides the fate axis: `inbox.ts` would stay one folder while its sender half is target-shaped surface and its reader half is emulation detail a consumer must not code against (`docs/api-contract.md` § 9). Rejected: it organises along the axis that is already served and flattens the one that is not.
- **By dependency direction** (layered, low → high): the import graph refuses it. `docs.ts` (surface) calls `getCaps().mint` (emulation) because upstream `doc_create` itself commits `AddRepo` (level 1, `engine/verifier/src/request_processor.rs:698`, re-verified); `emulated-verifier/connect.ts` (verifier stand-in) calls `resolveAccount` (shim) because the emulation runs on the shim. These cross-fate imports are *target-faithful*, not accidents — a layout that forbids them would force artificial inversions or be violated on day one. Rejected: it encodes a property the semantics do not have.
- **Flat with naming conventions only** (`surface-docs.ts`, `shim-physical.ts`…): carries the same information at the same churn — a rename churns every importer exactly as a move does — with weaker affordances: no per-folder contract note, no one-glance grouping, and no folder-granular entry rule to grep or lint. Prefixes also rot silently in a way a misplaced file in a four-folder tree does not. Rejected as strictly dominated: same cost, less structure.
- **Do nothing — rely on the contracts:** the serious alternative. The contracts are finer-grained than any layout (per-claim epistemic labels, not per-module), and they were verified at the source. But they are read *after* the fact; the incident that motivates this analysis happened in a module whose own header states the right doctrine. A layout is confronted *during* the edit: a new module must be placed, and placing it forces the "which fate?" question at the moment the docs.ts-style mistake is made. Layout and contracts are complementary instruments — the layout is the cheap always-on prompt, the contracts remain the enforcement.
### 1d. What the layout actually buys — bounded claims
1. **The placement question fires at creation time.** A new module cannot be added without answering "surface, native stand-in, shared-wallet, or model?" — the question whose non-asking is the root of the flat layout's failure.
2. **The entry rule becomes folder-granular and mechanical.** Today `index.ts`'s purity is maintained per-symbol (the hand-built `surface/placement.ts` slice). With folders the rule is "`index.ts` re-exports only from `surface/` and `model/`" — one grep, lintable in CI, reviewable at a glance.
3. **Cross-fate imports become visible seams.** `import { … } from "../emulated-verifier/…"` inside `surface/docs.ts` is a reviewable event in a diff; the same call inside a flat sibling import is invisible. The seam does not *prevent* the docs.ts incident class — it makes it show up in review.
4. **The contracts map onto the tree.** `docs/api-contract.md``surface/` + `model/`; `docs/internal-contract.md``emulated-verifier/` + `shared-wallet/`. Drift between doc and code becomes a folder-membership diff instead of an inventory audit.
5. **It generalises a pattern this repo already proved.** `surface/placement.ts` (a hand-maintained surface slice), `shared-wallet/physical.ts` (privilege as *separate functions*, not exemption flags), and the 2026-08-03 entry-header fix are all the same idea implemented piecemeal at module granularity. The layout is the same discipline promoted to the tree.
**Verdict on the bet:** "easier to spot" — substantially yes (points 24). "Harder to commit" — only mildly: nothing in a folder tree stops a determined or oblivious edit, and the decisive question (*is this behaviour the target's?*) is answered by reading `nextgraph-rs`, not by any layout (§ 6). Worth doing, with the expectations of § 1d and the costs of § 5.
---
## 2. The recommended layout
Both entry files stay at `src/` root, so `package.json`'s `exports` map is untouched and the consumer application sees no change. *(The two doors were merged into one on 2026-08-07 — the `exports` map is now just `.`, and the polyfill-era symbols sit in a marked block of `index.ts`.)*
| Folder | What the name asserts | Alignment reference | Fate at migration |
|---|---|---|---|
| `src/` root (`index.ts`, `polyfill.ts`) | The two published doors, nothing else. `index.ts` may re-export only from `surface/` and `model/`; `polyfill.ts` may re-export by name from anywhere — it is the polyfill-era door and its imports *are* the list of what dies. *(Merged 2026-08-07: one door, `index.ts`, with the polyfill-era symbols in a marked block. The rule survives as a block boundary instead of a file boundary.)* | — | The SDK-shaped half is replaced by the real SDK via the build alias; the polyfill-era block is deleted. |
| `model/` | The target's addressing model, transcribed: pure vocabulary (types, NURI grammar, guards). No I/O, no state, no minting. Importable by every layer. | Level 1, verified (`NuriV0`, `readcap_nuri``engine/repo/src/types.rs:518-521`) | Survives as knowledge; the guards stay useful against the real SDK (which takes plain strings). |
| `surface/` | App-facing, and every symbol has a target counterpart — verified or a documented bet — in `docs/api-contract.md`. A consumer coding against this folder learns nothing to unlearn. | Levels 3/2 where they answer, level-1 shape where they do not (per subject, in the contract) | Deleted when the alias flips; the consumer's code is unchanged. |
| `emulated-verifier/` | Stand-ins for what the engine/verifier/broker do natively: possession, filing, boundary, non-delivery, inbox processing, branch registers, repo opening. Aligned on the level-1 model; each module names its native counterpart mechanism. **This is the folder where divergence from the model is possible, and its main risk.** | Level 1 (the model is the specification) | Deleted — the native side takes over. |
| `shared-wallet/` | Exists only because one wallet hosts every identity. NO COUNTERPART at any layer — nothing to align on; the only law is invisibility from `surface/` and from the consumer. | None (nothing upstream has an image of this) | Evaporates entirely, replaced by nothing. |
On the name `emulated-verifier/`: every module in it has its native counterpart running *in the verifier* (cap state and Store/User-branch replay, `Verifier::load_repo_from_read_cap` `engine/verifier/src/verifier.rs:2237`, level 1; non-delivery, `resolve_target_for_sparql``RepoNotFound` `engine/verifier/src/request_processor.rs:264,269`, level 1, re-verified; inbox processing, `Verifier::inbox` `verifier.rs:1674-1690`, level 1; session repos, `self.repos`). The `emulated-` prefix is load-bearing: it says *stands in for*, never *is* — the residency confusion § 1a rules out.
---
## 3. Module-by-module assignment
The 25 current modules, with the two splits' offspring shown where a module divides (§ 4 gives the criterion and the rulings). "Stays whole" means the file moves as-is.
| Current module | Destination | What the placement asserts / notes |
|---|---|---|
| `index.ts` | `src/index.ts` (unchanged path) | The SDK-entry manifest. New rule made checkable: imports only from `surface/` and `model/`. |
| `polyfill.ts` | `src/polyfill.ts` (path unchanged) + **new** `shared-wallet/bootstrap.ts` | Split: the entry keeps the re-exports; the config store (`configure`, `getConfig`, `registryDeps`, the current-user relay, the `CapRegistry` singleton wiring) moves to `shared-wallet/bootstrap.ts` — it is the injection machinery with NO COUNTERPART by design (`docs/api-contract.md` § 1). Side effect: removes the current entry↔internal import cycles (`polyfill.ts:16``connect.ts:37`; `polyfill.ts:228``inbox.ts:32`). |
| `types.ts` | `model/types.ts`, minus `NgLike` / `UseShapeLike``shared-wallet/bootstrap.ts` | `Nuri`/`ReadCap`/`Scope` are level-1-verified vocabulary; `PrincipalId` stays with a note (target: the wallet user; polyfill: a relayed id). `NgLike`/`UseShapeLike` describe the *injection*, not the target — they belong to the bootstrap. See § 5 for the published-type wrinkle this creates. |
| `nuri.ts` | `model/nuri.ts`, minus `mintCap``emulated-verifier/` | The guards and `targetOf`/`parseNuri` are the model transcription. `mintCap` is the emulation's minting point — upstream only the engine mints, at repo creation (level 1, `BlockRef::readcap_nuri`, `engine/repo/src/types.rs:518-521`) — and its presence in the model module contradicts the module's own header ("nothing on the surface turns a bare reference into a cap"). Cap-enforcement swaps its constant; migration deletes it. |
| `sparql.ts` | `surface/sparql.ts` | Published, generic injection-safety utilities with NO COUNTERPART and none expected (`docs/api-contract.md` § 11) — the one surface family that survives *any* migration unchanged. Placed with the surface because it is published and documented there; the folder note must carry this exception. |
| `docs.ts` | `surface/docs.ts` — stays whole | Level-2 passthroughs whose in-body cap filing and reach guard *mirror the target's own composition* (§ 4 ruling). The mint and the guard become named imports from `emulated-verifier/` — the visible seam. |
| `lifecycle.ts` | `surface/lifecycle.ts` | Pure forwarding to the injected level-2/3 calls (`docs/api-contract.md` § 2). |
| `surface/ng-proxy.ts` | `surface/ng-proxy.ts` — stays whole | Builds the published `ng`; its two overrides compose `emulated-verifier/` predicates (the write guard stands in for `verify_perm` inside `Commit::verify`, level 1, `engine/repo/src/commit.rs:892-899`, re-verified — noting `verify` has no runtime caller today, which says nothing about the target). The `login` arm is finding F1 of the internal contract: an unprovenanced fabricated member — its fix is deletion, not relocation. |
| `surface/use-shape.ts` | `surface/use-shape.ts` | Level-3 passthrough + the read-filter view imported from `emulated-verifier/` — mixture-by-import, already in the right shape. |
| `surface/watch-shape.ts` | `surface/watch-shape.ts` | Surface composition over `emulated-verifier/` and the placement calls; its "planned `useShape` upgrade" header claim remains an ASSUMPTION with no provenance (`docs/api-contract.md` § 5) — a layout cannot fix that (§ 6). |
| `subscribe.ts` | `surface/subscribe.ts`, minus `subscribePhysicalDoc``shared-wallet/physical.ts` | The guarded `subscribeDoc`/`subscribeDocs` and `docChangeType` are surface (level 2, `doc_subscribe`, `sdk/js/lib-wasm/src/lib.rs:1908`). The physical door moves to the machinery module (§ 4); the unguarded core is exported under its `Unguarded` name for that one importer. |
| `surface/read-model.ts` | `surface/read-model.ts` — stays whole | The anchored-read mechanics are level-1-verified and survive as composition (`docs/api-contract.md` § 6). Its possession gate and machinery filter mirror native behaviour (§ 4 ruling) and arrive via named `emulated-verifier/` imports. |
| `inbox.ts` | **split**: `surface/inbox.ts` (post, `postToDocument`, `shareCap`) + `emulated-verifier/inbox-processing.ts` (`read`/`materialize`/`readSynced`/`processInbox`/`watch`, `assertOwnInbox`, the deposit RDF vocabulary) | § 4 ruling. `surface/inbox.ts` re-exports the processing functions with a header saying exactly what `docs/api-contract.md` § 9 says — that enumerating deposits is emulation detail — so the published `inbox.*` namespace is unchanged and the warning sits at the one place the two halves meet. |
| `surface/placement.ts` | dissolved into `surface/placement.ts` | The hand-built slice becomes a real module: the app-facing placement/addressing calls (`createEntityDoc`, `listMyEntityDocs`, `resolveScopeGraph`, `resolveWriteGraph`, `userInbox`, `openDocumentInbox`, `documentInboxAddress`), composed from the two lower layers. Its existence today is the strongest in-repo evidence for the whole reorganisation: it does by hand what the folder boundary does structurally. |
| `shared-wallet/account-registry.ts` | **split three ways**: `surface/placement.ts` + `emulated-verifier/branch-registers.ts` + `shared-wallet/account-registry.ts` | The sharpest case — 1377 lines spanning all three fates. `branch-registers.ts` takes the durable compartments: the Store-branch cap register (`holdOwnCap`/`readStoreCaps` — emulating `AddRepo { read_cap }`, level 1, `engine/repo/src/types.rs:1890-1899`), the User-branch Link register (`addLink`/`readLinks``AddLink { read_cap }`, `types.rs:1939-1948`), the inbox-cap records (`AddInboxCapV0`, `types.rs:1969-1981`) and the Header-branch address records. `account-registry.ts` takes the pointer→doc-shim indirection, `resolveAccount`/`ensureAccount`, `reservedAccount`, the cache, `VirtualUserRecord`/`RegistrySession` — NO COUNTERPART, the shim proper. `placement.ts` (surface) keeps the app-facing calls listed above plus `userStoreDoc`/`isOwnInbox`/`myInboxes` staying internal on the register side per their contract entries. |
| `emulated-verifier/caps.ts` | `emulated-verifier/caps.ts` — stays whole (+ receives `mintCap`) | The in-memory record of what the connected holder holds — the verifier's cap state. The per-holder `heldByHolder` keying is its one shared-wallet dimension (one wallet, many holders); it stays, isolated behind the injected `holder()` and noted: at migration there is exactly one holder and the map collapses. |
| `emulated-verifier/reach.ts` | `emulated-verifier/reach.ts` | The emulated wallet boundary: stands in for "a repo you hold no cap for cannot even be addressed" (level 1, `resolve_target_for_sparql``RepoNotFound`, `request_processor.rs:264,269`, re-verified). The two-rules redundancy is a lib choice, documented. |
| `emulated-verifier/read-filter.ts` | `emulated-verifier/read-filter.ts` | Stands in for cryptographic non-delivery (same level-1 citations); deleted at migration with nothing to migrate to. |
| `emulated-verifier/connect.ts` | `emulated-verifier/connect.ts` — stays whole | The recipient-verifier moment (`Verifier::inbox``process_inbox`, level 1, `verifier.rs:1674-1690`). Its import of `resolveAccount` (shared-wallet) is a deliberate cross-fate *check* — connecting must not provision — and stays visible at the import line. |
| `emulated-verifier/open-repo.ts` | `emulated-verifier/open-repo.ts`, minus `ensurePhysicalRepoOpen``shared-wallet/physical.ts` | Stands in for the verifier bringing repos into `self.repos` (native at migration: open by cap at bootstrap). Flag kept from the internal contract: this module is a *current-state heal*, not a model emulation — its barrier ("TabInfo then first State") is empirical, pinned only by the e2e CONTRACT-3 probe, and its header's mechanism claim is finding F2 (contradicted at the source). The folder cannot fix either (§ 6). |
| `emulated-verifier/machinery.ts` | `emulated-verifier/machinery.ts` | The subject filter fabricates what is structurally impossible upstream: a content read cannot see Store/User/Header compartments because they are separate branches with no triples (level 1, `BranchCrdt::None`, `engine/repo/src/types.rs:1420`, re-verified). It sits beside `branch-registers.ts`, whose compartments it hides; note that `MACHINERY_NS` also covers the account-shim's vocabulary. |
| `shared-wallet/physical.ts` | `shared-wallet/physical.ts` — grows into the complete privileged door | The quintessential shared-wallet module. It absorbs `subscribePhysicalDoc` and `ensurePhysicalRepoOpen`, so **one module is the machinery's entire unguarded API** — completing its own doctrine ("separate functions, never exemptions", `physical.ts:19-27`) at the tree level. Cost: the two unguarded cores get exported (under `Unguarded` names) from their mechanism modules; acceptable because neither entry ever re-exports them. |
| `shared-wallet/virtualUsers.ts` | `shared-wallet/virtualUsers.ts` | Identity persistence for the shared wallet; NO COUNTERPART (`docs/api-contract.md` § 13); already correctly published via `/polyfill` only. |
| `shared-wallet/access-log.ts` | `shared-wallet/access-log.ts` | Diagnoses the shared-wallet isolation leak; the identity it prefixes is the relayed virtual id. Deleted at migration. |
| `shared-wallet/outbox-log.ts` | `shared-wallet/outbox-log.ts` | Polyfill-era trace probe over the injected SDK's private persistence (level-2 facts verified in the internal contract § 11). Deleted at migration. |
Resulting tree: 2 entry files + `model/` (2) + `surface/` (10) + `emulated-verifier/` (8) + `shared-wallet/` (6).
---
## 4. The mixed modules — mechanical detection, one criterion, and rulings
### Detection method
Fate labels exist per *export* in the two contracts; a module is mixed when its exports (or its internal effects) span fates. Three code signals find the internal effects mechanically, without trusting headers:
- **Signal A — wire call × emulation-state write:** the module calls the injected `ng` (`getConfig().ng`) *and* mutates emulation state (`getCaps().mint/learn/open`, `addLink`, `declareInfrastructure`). Grep hits: `docs.ts:73` (mint), `inbox.ts:385` (learn), `store-registry.ts:875,893-895,903,1169,1371` (learn/open), `connect.ts:67` (learn).
- **Signal B — guarded/unguarded twin exports:** the `*Physical*` / `*Unguarded` pairs. Hits: `subscribe.ts:104/118`, `open-repo.ts:167/184`.
- **Signal C — act vs stand-in-processing under one namespace:** exports of the same module carrying different fate labels in the contracts. Hits: `inbox.ts` (§ 9: sender acts are target-shaped; deposit enumeration is emulation detail), `shared-wallet/account-registry.ts` (§ 12: labels range from level-2 VERIFIED to NO COUNTERPART), `types.ts` (model types vs injection types, § 1 vs § 10), `nuri.ts` (guards vs `mintCap`, internal contract § 2), `polyfill.ts` (entry vs config store).
Full mixed list: `docs.ts`, `surface/read-model.ts`, `inbox.ts`, `subscribe.ts`, `emulated-verifier/open-repo.ts`, `shared-wallet/account-registry.ts`, `nuri.ts`, `types.ts`, `polyfill.ts`, `surface/ng-proxy.ts`, `emulated-verifier/caps.ts`, `emulated-verifier/connect.ts`. (`surface/use-shape.ts` and `surface/watch-shape.ts` cross fates only through imports — already the desired end state.)
### The criterion
**Split when the halves have different fates AND different callers. Keep whole when the mixture reproduces a composition the target itself performs atomically — and then express the emulated half as a named import from the other folder, so the seam is on the import line.** Corollary: an unguarded twin of a guarded operation always lives with the machinery that calls it, never beside its guarded sibling.
### Rulings
- **`docs.ts` — KEEP WHOLE.** The cap filing inside `docCreate` mirrors the target's own `doc_create`, which commits `AddRepo` to the Store branch and `ldp:contains` to the Main branch *in the same native call* (level 1, `engine/verifier/src/request_processor.rs:697-710`, re-verified). Splitting the mint into a separate caller-visible step would create a two-step creation surface the target does not have — the split itself would be the divergence. Same for the reach guard: the refusal is native (`RepoNotFound`). The fix is visibility, not surgery: both effects become named imports from `emulated-verifier/`.
- **`surface/read-model.ts` — KEEP WHOLE.** Its possession gate mirrors native non-delivery, and its machinery-subject drop mirrors the structural invisibility of non-content branches (`BranchCrdt::None`, `types.rs:1420`, level 1). Both are the target's own composition of "read a document".
- **`inbox.ts` — SPLIT.** Different fates (api-contract § 9: the acts are target-shaped level-1 inventions; the deposit-list surface "may never have this shape") *and* different callers (apps post/share/watch; `emulated-verifier/connect.ts` processes). The deposit RDF vocabulary — pure emulation transport (upstream a deposit is a sealed message, `InboxMsgBody`, `engine/net/src/types.rs:4265`, level 1, carrying no target document) — lives once, on the emulated side.
- **`subscribe.ts` / `emulated-verifier/open-repo.ts` — SPLIT the physical doors out** (criterion's corollary): different caller (machinery only), different fate (the guarded/unguarded pair collapses to one call when the wallet is the boundary). They regroup in `shared-wallet/physical.ts`.
- **`shared-wallet/account-registry.ts` — SPLIT three ways** (§ 3). It is the module the flat layout hides most: signal C fires on nearly every export group, and the repo already voted for the split by hand-building `surface/placement.ts`.
- **`nuri.ts` — SPLIT `mintCap` out.** Different fate (model vocabulary survives; the minting point is deleted when the engine mints) and the module's own stated invariant argues for it.
- **`types.ts` — SPLIT the injection types out** (small; see § 5 for the published-type consequence, which must be decided, not slipped).
- **`polyfill.ts` — SPLIT entry from config store.** Different fates (a published door vs internal state) and it removes real import cycles.
- **`surface/ng-proxy.ts` — KEEP WHOLE.** 59 lines; the overrides *are* "what the native side takes over" and already compose `emulated-verifier/` predicates; the proxy artifact itself is the published surface. F1 (the fabricated `login` member) is fixed by deletion wherever the file lives.
- **`emulated-verifier/caps.ts` — KEEP WHOLE.** The per-holder keying is shared-wallet-flavoured, but splitting holder-resolution from the possession model would fragment one coherent level-1 model for no boundary gain; the injected `holder()` already isolates the dimension that collapses at migration.
- **`emulated-verifier/connect.ts` — KEEP WHOLE.** Its shim import is a deliberate cross-fate check (must-not-provision), which is exactly what the seam should show.
---
## 5. Cost and risk
**What does not change: the published surface.** Both entries keep their `src/` paths *(they were merged into one on 2026-08-07, and `package.json`'s `exports` map now maps only `.`)*; the `inbox.*` and `storeRegistry.*` namespaces are re-assembled at the entry with identical contents. A consumer application importing the entry sees nothing — with one deliberate exception below.
**Import churn — the inventory:**
- All 23 non-entry `src/` modules import each other relatively; every moved file churns its importers' paths (mechanical, type-checked).
- 19 of 19 unit-test files deep-import `../src/*` — 66 static import lines (heaviest: `store-registry` ×16, `polyfill` ×14) **plus 3 dynamic `await import("../src/…")` sites** (`test/isolation-active.test.ts:359`, `test/reach.test.ts:201-202`) that a naive static-import codemod will miss and that fail only at runtime.
- The e2e harness deep-imports twice (`e2e/polyfill-entry.ts:41-42`: `../src/store-registry`, `../src/accounts`); its package-name imports resolve through the exports map and are immune. `e2e/tsconfig.json` includes `"."` only — path-agnostic.
- Decision to make alongside: whether `test/` mirrors the new folders (keeps the module↔spec correspondence at more churn) or stays flat.
**Silent-breakage candidates — the ones tests may not catch:**
1. **Module-evaluation order.** `polyfill.ts` is today both entry and config store and sits inside import cycles (`polyfill ↔ connect`, `polyfill ↔ inbox`) that work through ES-module hoisting; the `CapRegistry` singleton is constructed at module scope (`polyfill.ts:97`). Re-cutting the graph changes which module evaluates first; a cycle that works today can break — or, worse, *change initialization order without breaking*. Mitigation: extract `shared-wallet/bootstrap.ts` as its own first step with the full unit + e2e suite run before any other move (baseline discipline), since the e2e suite is what actually exercises load order against a real broker.
2. **`export * from "./types"`.** After the types split, `NgLike`/`UseShapeLike` silently vanish from the `.` entry's type surface — erased types, so nothing in this repo's runtime tests notices; only the consumer's typecheck would. This is a real (if arguably desirable) published-surface change and must be an explicit decision: either re-export them deliberately from `/polyfill` (where `EventuallyConfig`, which references them, already lives) with a documented deprecation on `.`, or accept the narrowing and record it in the api-contract.
3. **The contracts' citations — the largest single cost.** `docs/api-contract.md` and `docs/internal-contract.md` (plus several briefs) carry hundreds of `file:line` references into `src/`; every moved or split module stales them wholesale. These two documents are the library's enforcement instrument — letting their citations rot would undercut the very discipline the reorganisation serves. A citation-refresh pass over both contracts is part of the change, not a follow-up.
4. **`git blame` archaeology.** Moves (and especially the three-way `store-registry` split) break naive blame; `--follow` works per-file but split hunks lose lineage. One-time tax; worth staging the splits as move-then-edit commits so content moves stay detectable.
**Staging that contains the risk:** (1) extract `shared-wallet/bootstrap.ts`, full suite green; (2) pure moves into the four folders, no content edits, full suite green; (3) the splits (`inbox`, `store-registry`, `nuri`, `types`), one per commit, each behind its baseline; (4) the citation-refresh pass on both contracts. Each stage leaves the published surface byte-identical (stage-3 exception 2 above being the one flagged decision).
---
## 6. What the layout will NOT fix
Being specific, because overclaiming here would recreate the false-certainty problem the layout is meant to reduce:
- **It cannot decide whether a behaviour is the target's.** The decisive act remains reading `nextgraph-rs`. Both README § *Design principle* incidents would have type-checked and folder-checked: "every document has a native inbox" was a *belief* error, and the owner-inbox pointer was a *model* error inside code that belongs exactly where it was. A correctly named folder holds wrong code without complaint.
- **It cannot fix wrong claims inside correctly placed modules.** Finding F2 (`emulated-verifier/open-repo.ts`'s header asserts a silent-0-rows mechanism the source contradicts — upstream errors `RepoNotFound`, `request_processor.rs:264,269`, level 1) and `surface/watch-shape.ts`'s unprovenanced "planned upgrade" survive any tree untouched. Header claims are policed by source-verification passes, not placement.
- **It is coarser than the epistemic labels.** PASSTHROUGH vs LEVEL-1 SHAPE vs ASSUMPTION vary per *claim* within one module (`inbox.post`'s act is level-1-shaped, its transport is pure emulation, arbitrary payloads are an ASSUMPTION — all in one function's orbit). A folder carries one label; the contracts remain the finer instrument and the layout must not be read as replacing them.
- **It cannot stop in-module shape drift.** `Deposit` growing a target-document field — the exact divergence class of the reverted owner-inbox episode, since upstream a message carries no document (`InboxMsgBody`, `engine/net/src/types.rs:4265`, level 1) — is one line in a correctly placed file.
- **It does not police the empirical bets.** The sync barrier's push ordering and "a held subscription keeps the repo open" are pinned by the e2e CONTRACT-3 probe alone; no layout substitutes for that tripwire.
- **It does not remove dead or decorative surface** (`inbox.watch`'s ignored `intervalMs`, the decorative write caps) — inventory work, already tracked in the contracts.
- **A wrong placement is worse than no placement.** Folders assert; a mis-filed module borrows the folder's authority (a shim-flavoured helper landing in `surface/` would *look* migration-safe). The contracts' per-subject verification remains the check on the layout — never the reverse.
---
*Cross-references: `README.md` § Design principle and § The three references (the doctrine this layout serializes into the tree); `docs/api-contract.md` (the would-be `surface/`+`model/` inventory); `docs/internal-contract.md` (the would-be `emulated-verifier/`+`shared-wallet/` inventory, findings F1F5).*
+34
View File
@@ -0,0 +1,34 @@
# Vision & principles of `@ng-eventually/polyfill`
## Purpose
A **stand-in faithful in SHAPE** to NextGraph's future primitives. **Single** objective: that consumers (Festipod) be **coded against the CORRECT mental model** — the one of finished NextGraph — and have **NOTHING to rewrite** when NextGraph provides the real primitives.
## What the polyfill is NOT
A **security** layer. The **shared wallet** (everyone shares the same keys) plus the absence of real crypto make the emulation **infinitely less secure** than a wallet-per-user — it is a **dev/staging vehicle**, not a goal. **Insecurity is ACCEPTED.** An attacker who bypasses the emulation is not our problem.
## The only criterion: shape-fidelity, with RIGOR
The **exposed surfaces** must match the **exact SHAPE** of the future primitives, **even where enforcement is simulated**. The **failure mode to avoid**: exposing the **wrong shape** → the consumer codes against a model that will not exist → rewrite. The **ACL** inversion of ReadCaps was exactly that defect (an ACL where the real thing is **key possession**) — a lack of rigor.
## Simulating crypto to PREVENT shortcuts
Without a minimum of crypto simulation, damaging shortcuts get taken (reading the plaintext, falling back on ACLs). The polyfill therefore **simulates** the final mechanism, enough to hold this **invariant**:
> **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, **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 the **cap-enforcement** work, and it is one function (`mintCap`, in `emulated-verifier/caps.ts` — this said `nuri.ts` until 2026-08-10). Until it lands, nothing this library does may be described as anonymous or private.
## Shape consequences (to respect everywhere)
- **Everything is keys and URLs.** There is **no** notion of membership, role, or authorization list in the model: only symmetric and asymmetric cryptography, URIs, and who holds which key. Any exposed shape that looks like an ACL, a `member`, a `role`, or a `permission` is a **wrong shape**, whatever scaffolding one may otherwise read in the current state of NextGraph.
- **Reading = possession of the read key** (ReadCap = `{id, key}`). A bare id (a `did` without a ReadCap) does not read.
- **Writing = possession of the write key** — a key **distinct** from the read key, hence a distinct axis, but **possession too**.
- **Sharing a cap = sealing it to a recipient** (**durable** delivery, at share time — NOT an ACL re-declared every session).
- **Revocation = re-key** (new key; former holders keep the old state). Non-retroactive.
- **Cap-less reference** (naming/pointing without reading) **distinct** from the cap-bearing reference.
See `readcap-and-nuri-model.md` (the real model, verified in `nextgraph-rs`) and `briefs/2026-07-20-caps-emulation-alignment.md` (the alignment effort).
+30
View File
@@ -0,0 +1,30 @@
# Notebook — the library's example application
A minimal application written against `@ng-eventually/polyfill`, in plain DOM.
It exists for two reasons, and the second is the one that matters.
**It shows how to use the library.** Every call in `app.ts` is what a real consumer writes. There is no test scaffolding, no privileged import, no reaching into the library's internals — it resolves `@ng-eventually/polyfill` as an external consumer does. If something reads awkwardly here, it reads awkwardly for everyone.
**It is what the applicative e2e suite drives** (`packages/polyfill/e2e/notebook.ts`, `bun run test:e2e:app`). The other suite talks to a bag of methods on `window.__sdk`, which proves the functions run but never that an application can be written with them — and that gap shipped a real defect once: a document's inbox was green in tests and unusable in practice, because the harness handed an address across an identity boundary through a variable, something no application can do. Here each identity is its own browser page, and the only values that cross between them are the ones that cross in life: a note's reference, copied off one screen, and an identifier typed into a field.
It has already paid for itself: writing it surfaced that `UnionSubject` returned `string` where the values are always document references (so a consumer had to cast whatever it had just read before passing it back), and that the access gate normalized what a user typed but not what the URL carried.
## What it exercises
Signing in, writing notes by scope, listing one's own, reading a note from its bare reference, handing a reader the key to a protected note, opening a note for messages, leaving a message on someone else's note, and reacting to changes.
The four journeys the suite runs, and what each proves:
| Journey | What it proves |
|---|---|
| Bob reads Alice's PUBLIC note from its reference alone | A public store serves its notes' keys — a bare reference is enough, and no key ever crosses |
| Alice's PROTECTED note stays shut until she shares it | The same gesture, the opposite outcome, decided by where the note sits and not by what was sent |
| Bob leaves a message on Alice's note, and only Alice reads it | A depositor FINDS the address from the note itself; depositing grants no reading |
| Each actor's list holds their own notes | The boundary, seen from the only place that matters: the screen |
It has also found three defects of its own, each one the harness could not see. The first turned out to be a LIBRARY defect rather than an application one: the connection work had to be awaited at sign-in, or a note just shared with you read as unreadable — so `ensureIdentity` now awaits it, and `connectedUser` left the published surface. The other two were the application's: a stale answer stayed on screen beside a fresh question, and changing the scope did not refresh the list.
## Running it
`cd packages/polyfill && bun run test:e2e:app` builds it, serves it, and drives it against the real broker. To open it by hand you need a wallet: serve the folder with a bundled `app.js` and a `/shared-wallet.ngw`, and set `__NOTEBOOK_WALLET_PASSWORD__`.
+274
View File
@@ -0,0 +1,274 @@
/**
* Notebook a minimal application written against `@ng-eventually/polyfill`.
*
* It exists for two reasons, and the second is the one that matters:
*
* 1. **It shows how to use the library.** Every call here is what a real consumer
* writes; there is no test scaffolding, no privileged import, no reaching into the
* library's internals. If something is awkward here, it is awkward for everyone.
*
* 2. **It is what the applicative e2e suite drives** (`packages/polyfill/e2e/notebook.ts`).
* The other suite talks to a bag of methods on `window.__sdk`, which proves the
* functions run but never that an application could be written with them and that
* gap shipped a real defect: a document's inbox was green in tests and unusable in
* practice, because the harness handed the address across an identity boundary
* through a variable. No application can do that. This app can only do what an
* application can do, so a test that passes here means the surface is usable, not
* merely callable.
*
* The domain is deliberately thin
* Notes. Each user writes their own, may publish one, may hand a reader the key to a
* private one, and may leave a message on someone else's note. That is enough to
* exercise placement by scope, capability possession, directed sharing, per-document
* inboxes and reactive reads without inventing a product.
*
* Plain DOM, on purpose
* The library imposes no framework, so its example must not adopt one: a consumer
* reading this should see the SDK calls, not a component tree. The UI here is the
* shortest thing that makes each act reachable.
*/
import {
// SDK-shaped — these survive migration, the real SDK replaces them in place.
docs,
ensureIdentity,
inbox,
init,
readUnion,
storeRegistry,
subscribeDoc,
type Nuri,
type Scope,
// Polyfill-era — ONE call, and it is the whole of what goes away.
configure,
} from "@ng-eventually/polyfill";
import { ng as realNg, init as realInit } from "@ng-org/web";
// --- the domain, such as it is ---------------------------------------------
const TITLE = "urn:notebook:title";
const BODY = "urn:notebook:body";
interface Note {
doc: Nuri;
title: string;
body: string;
}
// --- bootstrap: ONE polyfill-era call --------------------------------------
//
// Everything else an application calls is SDK surface, preserved at migration. This one
// is the scaffolding, and at migration it goes: the app imports the real SDK, and the
// identity comes from the wallet instead of a barrier.
configure({
ng: realNg,
useShape: (() => {}) as never, // this example reads through `readUnion`, not the ORM
init: realInit,
sharedWallet: {
fileUrl: "/shared-wallet.ngw",
password: (globalThis as { __NOTEBOOK_WALLET_PASSWORD__?: string }).__NOTEBOOK_WALLET_PASSWORD__ ?? "",
},
});
// The library's `init`, not the injected one — and this line is SDK-shaped, kept at
// migration. It settles the identity before handing the page to the broker, so the
// round-trip leaves with the identifier in the URL it carries. `realInit` called here
// would navigate away first, and the barrier would never show.
//
// The callback is this application's own business, and only its own: it keeps the session
// because the SPARQL primitives below take a `session_id`, exactly as the real SDK's do.
// It used to ALSO hand the library a thunk unwrapping this event into a session shape —
// plumbing every consumer wrote identically, and this one wrote wrong twice. The library
// catches the same event on its way through `init` now (2026-08-12), so what is left here
// is only what this app itself reads.
const sessionReady = new Promise<{ session_id: string }>((resolve) => {
init((event: { status: string; session?: { session_id: string } }) => {
if (event.status === "loggedin" && event.session) resolve(event.session);
}, true, []);
});
// --- the acts ---------------------------------------------------------------
/** Write a new note in `scope`. The document is created, then filled. */
async function writeNote(scope: Scope, title: string, body: string): Promise<Nuri> {
const doc = await storeRegistry.createEntityDoc(scope);
const s = await sessionReady;
await docs.sparqlUpdate(
s.session_id,
`INSERT DATA { <${doc}> <${TITLE}> "${escape(title)}" ; <${BODY}> "${escape(body)}" }`,
doc,
);
return doc;
}
/** My notes in `scope`, read the way the library intends: list, then read. */
async function myNotes(scope: Scope): Promise<Note[]> {
const docsOfScope = await storeRegistry.listMyEntityDocs(scope);
const subjects = await readUnion(docsOfScope);
return subjects.map((s) => ({
doc: s.graph,
title: s.props[TITLE]?.[0] ?? "(sans titre)",
body: s.props[BODY]?.[0] ?? "",
}));
}
/**
* Read someone else's note from its REFERENCE.
*
* A reference is what circulates you do not discover a note, someone gives you its
* reference (a message, a URL, a QR code). It carries no key, and that is the point:
* if the note is in a PUBLIC store the store hands its key to whoever asks, so the
* reference is enough; if it is protected, the reference names the note and opens
* nothing, until its owner shares it (see `shareNote`).
*
* It arrives as a plain string, from a field or a URL, and goes straight in: the
* library validates it. Nothing to narrow, nothing to cast, and nothing that will have
* to change when the real SDK takes that same string.
*/
async function readSharedNote(reference: string): Promise<Note | null> {
const [note] = await readUnion([reference]);
if (!note) return null;
return {
doc: note.graph,
title: note.props[TITLE]?.[0] ?? "(sans titre)",
body: note.props[BODY]?.[0] ?? "",
};
}
/**
* Hand a reader access to one of my notes.
*
* Names the NOTE and the PERSON the two things this app has. Neither the key nor the
* recipient's inbox appears: an application will handle neither once this is native
* (upstream the verifier fills `ContactDetails.read_cap` itself), so it handles neither
* now. Refuses if the note is not mine to share.
*/
async function shareNote(doc: Nuri, withUser: string): Promise<void> {
await inbox.share(doc, withUser);
}
/** Open a note for messages — only its owner can, and only they will read them. */
async function openNoteForMessages(doc: Nuri): Promise<void> {
await storeRegistry.openDocumentInbox(doc);
}
/** Leave a message on someone else's note. One call, naming the NOTE. */
async function leaveMessage(doc: Nuri, text: string): Promise<void> {
await inbox.postToDocument(doc, { payload: { text } });
}
/** The messages left on one of my notes — named by the note, like leaving one. */
async function messagesOn(doc: Nuri): Promise<string[]> {
const deposits = await inbox.readForDocument(doc);
return deposits.map((d) => String((d.payload as { text?: string })?.text ?? ""));
}
/** Re-render whenever a note changes — locally or from a peer. */
function watchNote(doc: Nuri, onChange: () => void): () => void {
return subscribeDoc(doc, onChange);
}
// --- identity ---------------------------------------------------------------
let identity = "";
/**
* Sign in, and learn who you are.
*
* One await, and it covers everything: the library settles the identity, waits for the
* connection work it fires (restoring what others shared with you, draining your
* inboxes), and **returns the identity**. The application keeps it only to display it
* no call takes it, because a session belongs to one user and the target's own
* `doc_create` carries no user at all.
*
* This used to read the library's private storage key to find out who it was, which is a
* boundary no consumer should be able to see. Writing this application is what made that
* visible.
*/
async function signIn(): Promise<void> {
identity = await ensureIdentity();
await sessionReady;
}
function escape(s: string): string {
return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n");
}
// --- the thinnest UI that makes each act reachable --------------------------
const el = (id: string): HTMLElement => document.getElementById(id)!;
const val = (id: string): string => (el(id) as HTMLInputElement).value.trim();
async function refresh(): Promise<void> {
const scope = (el("scope") as HTMLSelectElement).value as Scope;
const notes = await myNotes(scope);
el("notes").innerHTML = notes
.map(
(n) => `<li data-doc="${n.doc}">
<b class="t">${n.title}</b> <span class="b">${n.body}</span>
<button class="share" data-doc="${n.doc}">partager</button>
<button class="open" data-doc="${n.doc}">ouvrir aux messages</button>
<button class="msgs" data-doc="${n.doc}">messages</button>
<div><code class="ref" data-testid="ref">${n.doc}</code></div>
</li>`,
)
.join("");
// What `ensureIdentity()` returns is an OPAQUE identifier, not a display name: the SDK
// publishes none. So it is shown verbatim and marked as an identifier — never parsed,
// never split, never dressed up as a person's name.
const idTag = document.createElement("code");
idTag.textContent = identity;
el("who").replaceChildren("id ", idTag);
}
function wire(): void {
// Changing the scope changes which notes are listed — without this the list keeps
// showing the previous scope's notes, which reads as "my note disappeared".
el("scope").addEventListener("change", () => void refresh());
el("write").addEventListener("click", async () => {
await writeNote((el("scope") as HTMLSelectElement).value as Scope, val("title"), val("body"));
await refresh();
});
el("openRef").addEventListener("click", async () => {
el("shared").textContent = ""; // never show a previous answer beside a new question
const note = await readSharedNote(val("reference"));
el("shared").textContent = note ? `${note.title}${note.body}` : "(illisible)";
});
el("leave").addEventListener("click", async () => {
el("left").textContent = "";
await leaveMessage(val("onNote") as Nuri, val("message"));
el("left").textContent = "déposé";
});
el("notes").addEventListener("click", async (e) => {
const target = e.target as HTMLElement;
const doc = target.dataset.doc as Nuri | undefined;
if (!doc) return;
el("shareResult").textContent = "";
el("messages").textContent = "";
if (target.classList.contains("share")) {
await shareNote(doc, val("shareWith"));
el("shareResult").textContent = "partagé";
} else if (target.classList.contains("open")) {
await openNoteForMessages(doc);
el("shareResult").textContent = "ouverte aux messages";
} else if (target.classList.contains("msgs")) {
el("messages").textContent = (await messagesOn(doc)).join(" | ") || "(aucun)";
}
});
}
async function main(): Promise<void> {
wire();
await signIn();
await refresh();
}
void main();
// The e2e suite drives this app through the DOM. It exposes nothing else: a test that
// needed a back door would be testing something an application cannot do. `watchNote`
// is here because reactivity has no visible surface in this UI yet — not as an escape
// hatch, and it takes no identity: switching user means reloading with another `?ng-id=`,
// exactly as switching upstream means opening another wallet.
(globalThis as { __notebook?: unknown }).__notebook = { watchNote };
+59
View File
@@ -0,0 +1,59 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8" />
<title>Notebook — exemple ng-eventually</title>
<style>
body { font: 15px/1.5 system-ui, sans-serif; max-width: 640px; margin: 24px auto; padding: 0 16px; color: #222; }
fieldset { border: 1px solid #ddd; border-radius: 8px; margin: 0 0 16px; }
legend { padding: 0 6px; color: #666; font-size: 13px; }
input, select, button { font: inherit; padding: 6px 8px; }
input { border: 1px solid #bbb; border-radius: 5px; }
button { cursor: pointer; border: 1px solid #bbb; border-radius: 5px; background: #f6f6f6; }
ul { list-style: none; padding: 0; }
li { padding: 6px 0; border-bottom: 1px solid #eee; }
code.ref { font-size: 11px; color: #888; user-select: all; }
.out { color: #555; font-size: 13px; min-height: 1.2em; }
</style>
</head>
<body>
<p>Connecté : <b id="who" data-testid="who"></b></p>
<fieldset>
<legend>Écrire une note</legend>
<input id="title" data-testid="title" placeholder="titre" />
<input id="body" data-testid="body" placeholder="contenu" />
<select id="scope" data-testid="scope">
<option value="protected">protégée</option>
<option value="public">publique</option>
<option value="private">privée</option>
</select>
<button id="write" data-testid="write">écrire</button>
</fieldset>
<fieldset>
<legend>Mes notes</legend>
<input id="shareWith" data-testid="share-with" placeholder="partager avec (identifiant)" />
<ul id="notes" data-testid="notes"></ul>
<p class="out" id="shareResult" data-testid="share-result"></p>
<p class="out" id="messages" data-testid="messages"></p>
</fieldset>
<fieldset>
<legend>Ouvrir une note reçue</legend>
<input id="reference" data-testid="reference" placeholder="référence de la note" size="46" />
<button id="openRef" data-testid="open-reference">ouvrir</button>
<p class="out" id="shared" data-testid="shared"></p>
</fieldset>
<fieldset>
<legend>Laisser un message sur la note d'un autre</legend>
<input id="onNote" data-testid="on-note" placeholder="note visée" size="46" />
<input id="message" data-testid="message" placeholder="message" />
<button id="leave" data-testid="leave">déposer</button>
<p class="out" id="left" data-testid="left"></p>
</fieldset>
<script type="module" src="/app.js"></script>
</body>
</html>
+10
View File
@@ -0,0 +1,10 @@
{
"name": "@ng-eventually/example-notebook",
"private": true,
"type": "module",
"description": "A minimal application written against @ng-eventually/polyfill \u2014 the library's usage example, and what the e2e suite drives.",
"dependencies": {
"@ng-eventually/polyfill": "workspace:*",
"@ng-org/web": "0.1.2-alpha.13"
}
}
+21
View File
@@ -0,0 +1,21 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"types": [
"bun"
],
"noEmit": true,
"lib": [
"ESNext",
"DOM"
],
"paths": {
"@ng-eventually/polyfill": [
"../../packages/polyfill/src/index.ts"
]
}
},
"include": [
"."
]
}
+5 -2
View File
@@ -3,10 +3,13 @@
"private": true, "private": true,
"type": "module", "type": "module",
"description": "Generic polyfill layer over the NextGraph JS SDK — makes a single broker behave like the (not-yet-shipped) multi-user NextGraph: emulated capabilities and inbox. Drop-in SDK-shaped client; remove at migration. (A global-index curator package is deferred — see README.)", "description": "Generic polyfill layer over the NextGraph JS SDK — makes a single broker behave like the (not-yet-shipped) multi-user NextGraph: emulated capabilities and inbox. Drop-in SDK-shaped client; remove at migration. (A global-index curator package is deferred — see README.)",
"workspaces": ["packages/*"], "workspaces": [
"packages/*",
"examples/*"
],
"scripts": { "scripts": {
"test": "bun test", "test": "bun test",
"typecheck": "bunx tsc --noEmit -p packages/client/tsconfig.json" "typecheck": "bunx tsc --noEmit -p packages/polyfill/tsconfig.json"
}, },
"devDependencies": { "devDependencies": {
"@types/bun": "latest", "@types/bun": "latest",
-48
View File
@@ -1,48 +0,0 @@
# @ng-eventually/client
Two entry points — the data-plane is SDK-identical, the polyfill bootstrap is
separate:
| Import | Surface |
|---|---|
| `@ng-eventually/client` | The same signature as the SDK — `ng`, `useShape`, `inbox` (+ types). A drop-in for `@ng-org/web` / `@ng-org/orm`; as NextGraph matures it resolves to the real SDK (build alias removed) with no code change. |
| `@ng-eventually/client/polyfill` | The only non-SDK surface — `configure`, `setCurrentUser`, and capability helpers (`getCaps`, `grantRead`, `canRead`/`canWrite`). It falls away as NextGraph matures. |
```ts
// bootstrap (the only non-SDK call) — inject the real SDK
import { configure } from "@ng-eventually/client/polyfill";
configure({ ng: realNg, useShape: realUseShape, sharedWallet, currentUser });
// from here on, a pure SDK surface:
import { ng, useShape, inbox } from "@ng-eventually/client";
await ng.doc_create(/* … */);
const set = useShape(MyShape, scope); // filtered to what the identity may read
await inbox.post(targetInbox, ref); // deposit (anticipated SDK API)
```
## Principle — the polyfill compensates, it never extends
**The polyfill's ONLY reason to exist is to bridge a NextGraph implementation gap
or a bug.** Every non-SDK surface must map to a capability NextGraph will provide
natively, and must fall away at that point. The polyfill MUST NOT add functionality
of its own — no bespoke features, no observability/tooling, no convenience API that
isn't strictly "NextGraph will do this natively later." The test for any proposed
addition: *does it compensate a real, exhibited NextGraph gap or bug?* If not, it
does not belong here — build it in the consumer application, not in the polyfill.
Corollary: a compensation whose gap is not actually exhibited on the target broker
is dead weight, not defensive code — it should be removed, not kept "just in case."
What the polyfill adds on top of the real SDK (each emulated for now, native as
NextGraph matures):
- Shared-wallet identity (one wallet for everyone; the current identity id is
relayed to the SDK).
- Capability enforcement — a read filter + write guard over emulated grants
attached to documents; the app declares a document's read policy and issues
directed read grants.
- Anticipated methods (inbox `post`, capability ops) with their future-SDK shapes,
emulated for now.
Generic: no application domain. The consumer application injects its shapes and
performs the acts of granting access. The relationship concept ("who is connected
to whom") is the consumer application's own — the client exposes only directed
per-document read grants.
-309
View File
@@ -1,309 +0,0 @@
/**
* Real-broker plumbing for the SDK e2e harness a DEDICATED test wallet for
* `@ng-eventually/client`, fully separate from any consumer app's profile.
*
* Adapted from the Festipod app's `src/shared/support/hooks.ts` (the reference
* real-broker Playwright flow): headless wallet CREATION on nextgraph.eu, broker
* redirect via nextgraph.net, iframe handling. Here it authenticates a wallet
* created FOR THIS LIB (distinct name + distinct profile dir), and loads the
* minimal SDK page (sdk-entry.ts) inside the broker iframe.
*/
import { chromium, type BrowserContext, type Page, type Frame } from "playwright";
import { execSync } from "node:child_process";
import * as http from "node:http";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// ── Dedicated, gitignored profile + wallet (NOT the app's .playwright-profile) ──
export const PROFILE_DIR = path.resolve(__dirname, ".playwright-profile-lib");
const WALLET_READY_MARKER = path.join(PROFILE_DIR, ".wallet-ready");
export const WALLET_NAME = "ng-eventually-e2e";
export const WALLET_PASSWORD = "ng-eventually-e2e";
const ENTRY = path.resolve(__dirname, "sdk-entry.ts");
const BUNDLE_OUT = path.resolve(__dirname, ".dist", "sdk-entry.js");
const LAUNCH_ARGS = [
"--disable-features=PrivateNetworkAccessRespectPreflightResults,BlockInsecurePrivateNetworkRequests,PrivateNetworkAccessForWorkers,PrivateNetworkAccessForNavigations",
"--allow-insecure-localhost",
"--disable-web-security",
];
function resolveChromePath(): string | undefined {
const p = chromium
.executablePath()
.replace("chrome-headless-shell", "chrome")
.replace("chromium_headless_shell", "chromium");
return p.includes("headless") ? undefined : p;
}
export function buildBundle(): void {
fs.mkdirSync(path.dirname(BUNDLE_OUT), { recursive: true });
execSync(`bun build ${ENTRY} --outfile ${BUNDLE_OUT} --bundle --format=esm`, {
stdio: "pipe",
cwd: path.resolve(__dirname, ".."),
});
}
export function serveHarness(): Promise<{ url: string; close: () => void }> {
const bundle = fs.readFileSync(BUNDLE_OUT, "utf-8");
const html = `<!DOCTYPE html><html><head><meta charset="utf-8"><title>ng-eventually sdk e2e</title></head><body><div id="root"></div><script type="module" src="/sdk-entry.js"></script></body></html>`;
const server = http.createServer((req, res) => {
if (req.url === "/sdk-entry.js") {
res.writeHead(200, { "Content-Type": "application/javascript; charset=utf-8" });
res.end(bundle);
} else {
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
res.end(html);
}
});
return new Promise((resolve) => {
server.listen(0, "127.0.0.1", () => {
const port = (server.address() as { port: number }).port;
resolve({ url: `http://127.0.0.1:${port}`, close: () => server.close() });
});
});
}
/**
* Create the dedicated lib wallet once (headless UI flow on nextgraph.eu),
* persisted in PROFILE_DIR. Mirrors Festipod's ensureAuth but with the lib's own
* wallet name + profile. Idempotent via the ready marker.
*/
export async function ensureWallet(): Promise<void> {
if (fs.existsSync(WALLET_READY_MARKER)) {
console.log("[e2e] dedicated lib wallet present — skipping creation");
return;
}
console.log("[e2e] creating dedicated lib wallet on nextgraph.eu...");
fs.mkdirSync(PROFILE_DIR, { recursive: true });
const ctx = await chromium.launchPersistentContext(PROFILE_DIR, {
headless: true,
executablePath: resolveChromePath(),
args: LAUNCH_ARGS,
});
const page = ctx.pages()[0] || (await ctx.newPage());
page.on("pageerror", () => {});
try {
await page.goto("https://nextgraph.eu/", { waitUntil: "domcontentloaded", timeout: 30000 });
const createButton = page.getByText("Create Wallet", { exact: true });
await createButton.waitFor({ state: "visible", timeout: 15000 });
await createButton.click();
await page.waitForURL("**/account*", { timeout: 15000 }).catch(() => {});
const acceptButton = page.getByText("I accept", { exact: true });
await acceptButton.waitFor({ state: "visible", timeout: 15000 });
await acceptButton.click();
const usernameInput = page.locator("#username-input");
await usernameInput.waitFor({ state: "visible", timeout: 30000 });
await usernameInput.fill(WALLET_NAME);
const passwordInput = page.locator("#password-input");
await passwordInput.waitFor({ state: "visible", timeout: 5000 });
await passwordInput.fill(WALLET_PASSWORD);
const submitButton = page.getByText("create my wallet", { exact: false });
await submitButton.waitFor({ state: "visible", timeout: 5000 });
await submitButton.click();
await page.waitForURL("**/#/wallet/login", { timeout: 30000 });
await page.waitForTimeout(2000);
// First login → bootstrap the verifier repos from the broker.
const walletLink = page.getByText("Click here to login with your wallet");
if (await walletLink.isVisible({ timeout: 5000 }).catch(() => false)) {
await walletLink.click();
await page.waitForTimeout(1000);
}
const loginPassword = page.locator('input[type="password"]');
await loginPassword.waitFor({ state: "visible", timeout: 10000 });
await loginPassword.fill(WALLET_PASSWORD);
await loginPassword.press("Enter");
await page.waitForTimeout(10000);
console.log("[e2e] dedicated lib wallet created + bootstrapped");
} finally {
await ctx.close();
}
fs.writeFileSync(WALLET_READY_MARKER, new Date().toISOString());
}
export async function launchWalletContext(): Promise<BrowserContext> {
return chromium.launchPersistentContext(PROFILE_DIR, {
headless: true,
executablePath: resolveChromePath(),
args: LAUNCH_ARGS,
});
}
/**
* Create a BRAND-NEW wallet in a BRAND-NEW profile dir and RETURN the launched
* context, without a `.wallet-ready` marker and WITHOUT tearing the context down.
* Unlike {@link ensureWallet} (which reuses one persistent dedicated wallet across
* runs so it is always "hot"), this mints a genuinely FRESH wallet each call so
* the cold-start (private-store repo not yet in `self.repos`) can be exercised.
*
* Same headless nextgraph.eu creation + first-login-bootstrap flow as ensureWallet,
* but the context stays OPEN and is returned (with its dir) so the caller can then
* open the SDK page in the SAME profile i.e. the very first app session over a
* wallet that has never run the app. Caller cleans up ctx + dir.
*/
export async function createFreshWalletContext(): Promise<{
ctx: BrowserContext;
dir: string;
name: string;
}> {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ng-eventually-fresh-"));
const name = "ng-fresh-" + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
const ctx = await chromium.launchPersistentContext(dir, {
headless: true,
executablePath: resolveChromePath(),
args: LAUNCH_ARGS,
});
const page = ctx.pages()[0] || (await ctx.newPage());
page.on("pageerror", () => {});
await page.goto("https://nextgraph.eu/", { waitUntil: "domcontentloaded", timeout: 30000 });
const createButton = page.getByText("Create Wallet", { exact: true });
await createButton.waitFor({ state: "visible", timeout: 15000 });
await createButton.click();
await page.waitForURL("**/account*", { timeout: 15000 }).catch(() => {});
const acceptButton = page.getByText("I accept", { exact: true });
await acceptButton.waitFor({ state: "visible", timeout: 15000 });
await acceptButton.click();
const usernameInput = page.locator("#username-input");
await usernameInput.waitFor({ state: "visible", timeout: 30000 });
await usernameInput.fill(name);
const passwordInput = page.locator("#password-input");
await passwordInput.waitFor({ state: "visible", timeout: 5000 });
await passwordInput.fill(WALLET_PASSWORD);
const submitButton = page.getByText("create my wallet", { exact: false });
await submitButton.waitFor({ state: "visible", timeout: 5000 });
await submitButton.click();
await page.waitForURL("**/#/wallet/login", { timeout: 30000 });
await page.waitForTimeout(2000);
// First login → bootstrap the verifier repos from the broker (this is what a
// brand-new wallet does on its very first unlock).
const walletLink = page.getByText("Click here to login with your wallet");
if (await walletLink.isVisible({ timeout: 5000 }).catch(() => false)) {
await walletLink.click();
await page.waitForTimeout(1000);
}
const loginPassword = page.locator('input[type="password"]');
await loginPassword.waitFor({ state: "visible", timeout: 10000 });
await loginPassword.fill(WALLET_PASSWORD);
await loginPassword.press("Enter");
await page.waitForTimeout(10000);
await page.close().catch(() => {});
return { ctx, dir, name };
}
/**
* Launch a persistent context on a FRESH, EMPTY profile dir (its own userDataDir).
* Empty local storage empty verifier repo cache the reconnection cold-start:
* the same wallet's repos are on the broker but NOT in this profile's IndexedDB, so
* a session over it starts with an empty `self.repos`. Caller must import the wallet
* (see {@link importWalletViaFile}) before opening the SDK page. Returns the context
* and the dir so the caller can clean it up.
*/
export async function launchCleanProfileContext(): Promise<{ ctx: BrowserContext; dir: string }> {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ng-eventually-clean-"));
const ctx = await chromium.launchPersistentContext(dir, {
headless: true,
executablePath: resolveChromePath(),
args: LAUNCH_ARGS,
});
return { ctx, dir };
}
/**
* Import a `.ngw` wallet FILE into the current (clean) profile via the standalone
* nextgraph.eu "Import a Wallet File" flow, then unlock it with the password. After
* this the profile holds the wallet but NOT the repos' local cache so the next
* SDK session over it hits the broker-only cold-start. Adapted from the Festipod
* app's `importWalletViaFile` (the proven real-broker wallet-file import).
*/
export async function importWalletViaFile(page: Page, ngwPath: string): Promise<void> {
await page.goto("https://nextgraph.eu/#/wallet/login", { waitUntil: "domcontentloaded" });
// Let the SPA render + attach the file input (uploading too early → EncryptionError).
await page.waitForTimeout(3000);
await page.locator('input[type=file]').waitFor({ state: "attached", timeout: 15000 });
await page.setInputFiles('input[type=file]', ngwPath);
const passwordInput = page.locator('input[type=password]').first();
await passwordInput.waitFor({ state: "visible", timeout: 15000 });
await passwordInput.fill(WALLET_PASSWORD);
await passwordInput.press("Enter");
const confirm = page.getByRole("button", { name: /Confirm/i });
if (await confirm.isVisible({ timeout: 2000 }).catch(() => false)) await confirm.click().catch(() => {});
await page.waitForTimeout(8000); // unlock + verifier bootstrap from the broker
}
/**
* Navigate through the broker (nextgraph.net redirect) to load `appUrl` in the
* broker iframe; unlock the dedicated wallet if a login is shown; return the app
* iframe Frame. Adapted from Festipod setupBrokerPage + completeBrokerLogin.
*/
export async function setupBrokerPage(page: Page, appUrl: string): Promise<Frame> {
const brokerRedirect = `https://nextgraph.net/redir/#/?o=${encodeURIComponent(appUrl)}`;
await page.goto(brokerRedirect, { waitUntil: "domcontentloaded" });
const loginButton = page.getByText("Login", { exact: true });
if (await loginButton.isVisible({ timeout: 2000 }).catch(() => false)) {
await loginButton.click();
await page.waitForURL("**/wallet/login", { timeout: 5000 }).catch(() => {});
}
const hasAppFrame = () => page.frames().some((f) => f.url().includes("127.0.0.1"));
const walletLink = page.getByText("Click here to login with your wallet", { exact: false });
const loginDeadline = Date.now() + 25000;
while (Date.now() < loginDeadline && !hasAppFrame() && !(await walletLink.isVisible().catch(() => false))) {
await page.waitForTimeout(500);
}
if (!hasAppFrame() && (await walletLink.isVisible().catch(() => false))) {
await walletLink.click();
await page.waitForTimeout(1000);
const passwordInput = page.locator('input[type="password"]');
if (await passwordInput.isVisible({ timeout: 8000 }).catch(() => false)) {
await passwordInput.fill(WALLET_PASSWORD);
await passwordInput.press("Enter");
await page.waitForTimeout(3000);
}
}
let appFrame: Frame | null = null;
const deadline = Date.now() + 30000;
while (Date.now() < deadline) {
for (const f of page.frames()) {
if (f.url().startsWith(appUrl) || f.url().includes("127.0.0.1")) {
appFrame = f;
break;
}
}
if (appFrame) break;
for (const iframe of await page.locator("iframe").all()) {
const src = await iframe.getAttribute("src");
if (src && src.includes("127.0.0.1")) {
const el = await iframe.elementHandle();
appFrame = (await el?.contentFrame()) ?? null;
if (appFrame) break;
}
}
if (appFrame) break;
await page.waitForTimeout(500);
}
if (!appFrame) {
const frames = page.frames().map((f) => f.url());
throw new Error(`SDK iframe not found after 30s. Frames: ${JSON.stringify(frames)}`);
}
return appFrame;
}
-33
View File
@@ -1,33 +0,0 @@
{
"name": "@ng-eventually/client",
"version": "0.0.0",
"type": "module",
"description": "SDK-identical client wrapper over @ng-org/web + @ng-org/orm with emulated capabilities and inbox. Drop-in; remove at migration.",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts",
"./polyfill": "./src/polyfill.ts"
},
"peerDependencies": {
"@ng-org/web": "*",
"@ng-org/orm": "*",
"@ng-org/shex-orm": "*",
"@ng-org/alien-deepsignals": "*"
},
"peerDependenciesMeta": {
"@ng-org/web": { "optional": true },
"@ng-org/orm": { "optional": true },
"@ng-org/shex-orm": { "optional": true },
"@ng-org/alien-deepsignals": { "optional": true }
},
"devDependencies": {
"@ng-org/web": "0.1.2-alpha.13",
"@ng-org/shex-orm": "0.1.2-alpha.8",
"@ng-org/alien-deepsignals": "0.1.2-alpha.11"
},
"scripts": {
"test": "bun test",
"test:e2e": "bun run e2e/run.ts"
}
}
-134
View File
@@ -1,134 +0,0 @@
/**
* Capability emulation generic, with no domain rules. It models NextGraph
* ReadCaps (and write caps) as a data layer can.
*
* In NextGraph a ReadCap is possession of a document's (repo's) read key: the
* broker only delivers documents the wallet holds a cap for. The access unit is
* therefore the document = repo, identified here by its NURI the `@graph` an
* item lives in, rather than the item. (A store is just a container repo, and
* holding a store's cap does not grant the repos it references each document
* 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
* document (`grantRead(doc, granteeId)`). Whether two identities are "connected"
* and therefore whether such a grant should be issued is an application
* concept the consumer owns; this layer only records the resulting per-document
* grants. At migration this whole layer disappears: the broker/verifier enforces
* the real caps and `useShape` returns only authorized documents.
*/
import type { Nuri, PrincipalId, Scope } from "./types";
/**
* 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 {
/** doc NURI → principals holding its READ cap. */
private readers = new Map<Nuri, Set<PrincipalId>>();
/** doc NURI → principals holding its WRITE cap. */
private writers = new Map<Nuri, Set<PrincipalId>>();
/** doc NURIs readable by everyone (public_store repos — no cap needed). */
private publicDocs = new Set<Nuri>();
/** doc NURI its declared (scope, owner), as recorded at {@link open}. Lets
* the consumer re-derive which documents are `protected` and who owns them
* (see {@link protectedDocsOf}) so it can issue directed grants, without
* 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 {
add(this.readers, doc, grantee);
}
/** Grant `principal` the WRITE cap of document `doc`. */
grantWrite(doc: Nuri, principal: PrincipalId): void {
add(this.writers, doc, principal);
}
/** Mark `doc` public (readable without a cap — a public_store repo). */
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? */
governsWrite(doc: Nuri): boolean {
return this.writers.has(doc);
}
/** Does `principal` hold a WRITE cap for `doc`? */
canWrite(doc: Nuri, principal: PrincipalId | null): boolean {
if (principal === null) return false;
return this.writers.get(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). */
hasWritePolicy(): boolean {
return this.writers.size > 0;
}
clear(): void {
this.readers.clear();
this.writers.clear();
this.publicDocs.clear();
this.policy.clear();
}
}
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);
}
-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;
}
};
}
-103
View File
@@ -1,103 +0,0 @@
/**
* Low-level document + SPARQL primitives.
*
* These call the real injected `ng` (`getConfig().ng`) directly never the
* public `ng` proxy (`makeNg`). This is a validated hard constraint, not a style
* choice: the public `ng` is a JS `Proxy` over `@ng-org/web`'s iframe-RPC proxy,
* and layering our Proxy on top breaks `doc_create`'s `postMessage` marshaling
* with **`DataCloneError: function ... could not be cloned`** the footgun this
* rule exists to prevent. Reaching the real `ng` held in the config avoids the
* double-proxy. Do not import from `./ng-proxy`.
*
* Signatures mirror the real `@ng-org/web` `ng` surface (verified against the
* app's storeRegistry usage), so this is a drop-in for those raw calls.
*/
import { getConfig } from "./polyfill";
import { logAccess, enabled as accessLogEnabled } from "./access-log";
import type { Nuri } from "./types";
// The low common point for ALL document access: every read in the SDK routes
// through `sparqlQuery`, every write through `sparqlUpdate` (+ container creation
// through `docCreate`) — each ultimately calling the real injected `ng` here. The
// access log is therefore instrumented HERE so no access path escapes it. Callers
// pass a semantic `label` (readDoc|readUnion|listMyEntityDocs|writeEntity|deposit
// |…); it is a lib-internal probe param, NOT forwarded to the real `ng` (the docs
// primitives forward the exact SDK signature — see test/docs.test.ts). When the
// log is OFF (default) the extra param is inert and costs one boolean read.
/** Count rows in a raw SPARQL SELECT result, tolerant of the possible shapes. */
function rowCount(result: unknown): number {
if (!result) return 0;
if (Array.isArray(result)) return result.length;
const anyRes = result as { results?: { bindings?: unknown[] } };
return anyRes.results?.bindings?.length ?? 0;
}
/**
* Create one document its NURI.
*
* Mirrors `ng.doc_create(session_id, crdt, cls, dest, store_repo?)`. For a graph
* document in the (shared) private store: `docCreate(sid, "Graph", "data:graph",
* "store")` (store_repo left undefined → private store).
*/
export async function docCreate(
sessionId: string,
crdt: string,
cls: string,
dest: string,
store?: unknown,
): Promise<Nuri> {
const { ng } = getConfig();
const nuri = await ng.doc_create(sessionId, crdt, cls, dest, store);
// A container creation is a WRITE; the NURI only exists after the call.
logAccess("WRITE", nuri, "docCreate");
return nuri;
}
/**
* Run a SPARQL UPDATE (INSERT/DELETE DATA, etc.).
*
* Mirrors `ng.sparql_update(session_id, query, anchor?)`, where `anchor` is the
* document NURI the update is scoped/base'd to (optional).
*/
export async function sparqlUpdate(
sessionId: string,
query: string,
anchor?: Nuri,
label = "sparqlUpdate",
): Promise<void> {
const { ng } = getConfig();
// `label` is a lib-internal access-log tag, NOT forwarded to `ng`.
logAccess("WRITE", anchor ?? "(no anchor)", label);
return ng.sparql_update(sessionId, query, anchor);
}
/**
* Run a SPARQL SELECT/CONSTRUCT/ASK query the raw SDK result.
*
* Mirrors `ng.sparql_query(session_id, query, base?, anchor?)`. `base` is the
* query base IRI (usually `undefined`); `anchor` is the document NURI to query.
*/
export async function sparqlQuery(
sessionId: string,
query: string,
base?: string,
anchor?: Nuri,
label = "sparqlQuery",
): Promise<unknown> {
const { ng } = getConfig();
// `label` is a lib-internal access-log tag, NOT forwarded to `ng`.
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
// rows under an identity that should see nothing) can be appended. Skip the
// rowCount work entirely when the log is off.
if (accessLogEnabled()) {
// `rows` here are raw RDF triple bindings (the SPARQL `?s ?p ?o` result), NOT
// domain objects — one document's entity is spread across several triple rows.
// Spell that out so the log isn't mistaken for an object count (the app-level
// object/shape count is logged separately by useShapeQuery → dataStats).
logAccess("READ", anchor ?? "(no anchor)", label, " → " + rowCount(result) + " triple-rows");
}
return result;
}
-270
View File
@@ -1,270 +0,0 @@
/**
* Inbox a generic deposit + read/materialize mechanism the consumer reuses for
* its own purposes (same `inbox.post` API, same watcher see the discovery-model
* decision). The mechanism itself knows no application domain: the consumer
* supplies the inbox document NURI and interprets the `payload`. (An example
* consumer mapping, purely illustrative: a consumer might use one inbox for a
* registration deposit and another for submitting a reference to an index.)
*
* Real target vs this emulation
* 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
* 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
* inbox from the sender side; it is not exposed yet.
*
* 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
* document (in the shared wallet) via the `docs.sparqlUpdate` primitive;
* - `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
* until the sealed-inbox path (`inbox_post_link`) is available.
*
* All NextGraph I/O routes through the `docs` primitives (the real injected `ng`,
* never `makeNg`), so this module imports no `@ng-org` package.
*/
import { sparqlUpdate, sparqlQuery } from "./docs";
import { subscribeDoc } from "./subscribe";
import { ensureRepoOpen } from "./open-repo";
import { getCurrentUser, getStoreRegistryDeps } from "./polyfill";
import { escapeLiteral } from "./sparql";
import { accessLogPrefix } from "./access-log";
import type { Nuri, PrincipalId } from "./types";
// --- deposit model --------------------------------------------------------
/** One deposit as materialized from an inbox document. */
export interface Deposit {
/** The sender, if identified; `null` when the deposit was anonymous. */
from: PrincipalId | null;
/** The consumer-defined payload (opaque here — JSON-serialized in storage). */
payload: unknown;
/** Deposit timestamp (ms epoch). Caller may pass one for determinism. */
ts: number;
}
/** Options for {@link post}. `from` and `ts` are both optional. */
export interface PostOptions {
/**
* Who is depositing. Omit (or pass `null`) for an ANONYMOUS deposit; pass a
* principal id to identify the sender. Defaults to the current polyfill user
* ({@link getCurrentUser}) when the property is entirely absent, so callers
* that want anonymity must pass `from: null` explicitly.
*/
from?: PrincipalId | null;
/** The payload to deposit (interpreted only by the consumer). */
payload: unknown;
/** Optional deposit timestamp (ms epoch). Omitted `Date.now()`. Passing it
* keeps tests deterministic. */
ts?: number;
}
const SHIM = "urn:ng-eventually:inbox";
const P = {
type: `${SHIM}:Deposit`,
from: `${SHIM}:from`,
payload: `${SHIM}:payload`,
ts: `${SHIM}:ts`,
} as const;
// --- session access (shared with the storeRegistry) -----------------------
/** The inbox documents live in the shared wallet, so we reuse the registry's
* injected session provider for the sessionId. Disappears at migration. */
async function sessionId(): Promise<string> {
return (await getStoreRegistryDeps().getSession()).sessionId;
}
// --- SPARQL result helpers ------------------------------------------------
/** Tolerant extraction of SPARQL SELECT bindings across possible shapes. */
function readBindings(result: unknown): Array<Record<string, { value: string }>> {
if (!result) return [];
if (Array.isArray(result)) return result as Array<Record<string, { value: string }>>;
const anyRes = result as {
results?: { bindings?: Array<Record<string, { value: string }>> };
};
return anyRes.results?.bindings ?? [];
}
// --- deposit (client side) ------------------------------------------------
/**
* Deposit a payload into `targetInbox`.
*
* Appends `{ from, payload, ts }` into the inbox document via `docs.sparqlUpdate`
* (the real injected `ng`). Each deposit is a fresh RDF subject in the inbox
* graph, so concurrent deposits don't collide.
*
* `from` is bound to the current identity it is authenticated, not
* caller-supplied. Omit it to stamp the current identity; pass `null` to deposit
* anonymously (a legitimate choice identified if known, anonymous otherwise).
* A `from` naming another identity is rejected as a spoof: in the target the
* broker seals the sender from the wallet's own key, so a client cannot forge
* another's identity. This check is redundant once the seal enforces it, but
* until then it closes the spoof the shared wallet would otherwise allow.
*/
export async function post(targetInbox: Nuri, opts: PostOptions): Promise<void> {
const current = getCurrentUser();
let from: PrincipalId | null;
if (opts.from === undefined) {
from = current; // default: stamp the current identity
} else if (opts.from === null) {
from = null; // explicit anonymous deposit
} else if (opts.from === current) {
from = opts.from; // identifying as self — allowed
} else {
throw new Error(
"[ng-eventually] inbox.post: `from` must be the current identity or null " +
"(anonymous) — depositing as another principal is a spoof.",
);
}
const ts = opts.ts ?? Date.now();
const sid = await sessionId();
// A unique subject per deposit (in the inbox graph) — no collisions.
const subject = `${SHIM}:deposit:${ts}:${Math.random().toString(36).slice(2)}`;
const payloadLiteral = escapeLiteral(JSON.stringify(opts.payload ?? null));
const fromTriple =
from == null ? "" : ` ;\n <${P.from}> "${escapeLiteral(from)}"`;
// NO explicit `GRAPH <…>` wrapper — write the anchored DEFAULT graph:
// `sparqlUpdate(sid, update, targetInbox)` scopes the write to that repo's
// default graph (same shape as read-model.ts readDoc/readUnion). This is the
// CANONICAL, always-safe shape and the one the anchored default-graph read
// queries. (Not a round-trip necessity on the current broker: the e2e harness
// `packages/client/e2e/` verified that an anchored `GRAPH <plainNuri>` write
// ALSO round-trips here — it resolves to the same repo graph, no phantom graph.
// The no-GRAPH form is kept as a simplicity/safety convention; re-verify with
// that harness if the broker version changes.)
const update = `
INSERT DATA {
<${subject}> a <${P.type}> ;
<${P.payload}> "${payloadLiteral}" ;
<${P.ts}> "${ts}"${fromTriple} .
}`;
await sparqlUpdate(sid, update, targetInbox, "deposit");
}
// --- read --------------------------------------------------------------
/**
* Read every deposit currently in `targetInbox`, sorted by `ts` ascending. In
* real NextGraph the recipient's own verifier applies queued messages inline as
* it processes the inbox; here this read stands in for that until the
* sealed-inbox path is available. The consumer interprets each deposit's
* `payload`.
*/
export async function read(targetInbox: Nuri): Promise<Deposit[]> {
const sid = await sessionId();
// NOTE: cold-start repo opening is done by the COLD DIRECT readers that need it
// (e.g. `discovery.readIndex` → `ensureInboxRepoOpen`), NOT here — `inbox.watch`
// 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
// race the watch's own initial-`State` delivery. Keeping `read` a pure anchored
// read leaves both callers correct: the watch path stays event-driven, and the
// cold direct-read path opens the repo explicitly before calling `read`.
// NO explicit `GRAPH <…>` clause — read the anchored DEFAULT graph (see the
// note in `post`). The anchor (`targetInbox`) scopes the query to that repo's
// default graph, exactly where `post` writes.
const query = `
SELECT ?payload ?ts ?from WHERE {
?d a <${P.type}> ;
<${P.payload}> ?payload ;
<${P.ts}> ?ts .
OPTIONAL { ?d <${P.from}> ?from }
}`;
const result = await sparqlQuery(sid, query, undefined, targetInbox, "inboxRead");
const deposits: Deposit[] = [];
for (const row of readBindings(result)) {
const rawPayload = row.payload?.value ?? "null";
let payload: unknown;
try {
payload = JSON.parse(rawPayload);
} catch {
payload = rawPayload; // tolerate a non-JSON literal
}
const tsRaw = row.ts?.value ?? "0";
const ts = Number.parseInt(tsRaw, 10) || 0;
const fromValue = row.from?.value;
deposits.push({ from: fromValue ? fromValue : null, payload, ts });
}
deposits.sort((a, b) => a.ts - b.ts);
return deposits;
}
/** Alias for {@link read} — the name that reads as "process the inbox now". */
export const materialize = read;
/**
* COLD, BARRIER-GATED read of `targetInbox` the reliable "process the inbox at
* (re)connection" read. Opens/subscribes the inbox repo and AWAITS its first
* `State` (the deterministic sync barrier after it, presence is guaranteed and
* absence definitive, {@link ensureRepoOpen}) BEFORE the anchored {@link read}.
*
* Why this over a plain {@link read}: on a FRESH session over the persistent
* wallet (a (re)connection / new page), the inbox repo is not yet in the verifier's
* `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
* 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.
*
* 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
* re-read would be redundant and could race the watch's own initial-`State`
* delivery. Use this from a COLD reader (materialize-at-connection), like
* `discovery.readIndex` does. Idempotent per session (no polling); a no-op open on
* the unit fake-ng path (no `doc_subscribe`) so `bun test` is unaffected.
*/
export async function readSynced(targetInbox: Nuri): Promise<Deposit[]> {
await ensureRepoOpen(targetInbox);
return read(targetInbox);
}
/**
* Subscription over an inbox **event-driven, not polled**. Subscribes to the
* inbox document via {@link subscribeDoc} (the platform's `doc_subscribe` push):
* `onDeposits` fires once on the initial state push and again on every subsequent
* change to the inbox document a local deposit OR a broker-synced remote one.
* Returns an unsubscribe function.
*
* On each push it re-reads the full deposit list ({@link read}) and invokes
* `onDeposits` only when the deposit count changed (grew), keeping the same
* "fires on change" contract the polling watcher had same callback signature
* and same behaviour, just event-driven instead of `setInterval`.
*
* The `intervalMs` option is accepted for signature compatibility but IGNORED:
* there is no polling. (The inbox document is a single doc, so this is immune to
* the ORM fan-out hang see {@link subscribeDoc}.)
*/
export function watch(
targetInbox: Nuri,
onDeposits: (deposits: Deposit[]) => void,
_opts?: { intervalMs?: number },
): () => void {
let stopped = false;
let lastCount = -1;
// Re-read on every push; fire onDeposits only when the set changed (grew).
const refresh = async (): Promise<void> => {
if (stopped) return;
try {
const deposits = await read(targetInbox);
if (!stopped && deposits.length !== lastCount) {
lastCount = deposits.length;
onDeposits(deposits);
}
} catch (error) {
console.error(accessLogPrefix() + " watch read failed:", error);
}
};
// 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.
const unsubscribe = subscribeDoc(targetInbox, () => void refresh());
return () => {
stopped = true;
unsubscribe();
};
}
-48
View File
@@ -1,48 +0,0 @@
/**
* @ng-eventually/client **SDK-identical** surface.
*
* This entry exposes ONLY what `@ng-org/web` / `@ng-org/orm` expose (current +
* anticipated: `inbox`). Import `ng` / `useShape` from here instead of the SDK
* during the polyfill period; at migration the build alias is removed and these
* resolve to the real SDK with **no code change**.
*
* The one non-SDK piece the polyfill bootstrap (`configure`, capability
* helpers, current user) lives at `@ng-eventually/client/polyfill`, and is the
* only thing removed at migration.
*/
export * from "./types";
export { useShape } from "./use-shape";
export { watchShape } from "./watch-shape";
export type { ShapeQuery, ShapeObservable } from "./watch-shape";
export { init, initNg } from "./lifecycle";
export * as inbox from "./inbox";
export * as discovery from "./discovery";
export type { IndexEntry, SubmitOptions } from "./discovery";
export * as docs from "./docs";
export { subscribeDoc, subscribeDocs, docChangeType } from "./subscribe";
export type { DocChange, DocChangeType, Unsubscribe } from "./subscribe";
export * as readModel from "./read-model";
export type { UnionSubject } from "./read-model";
export * as storeRegistry from "./store-registry";
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 /
// validation when it builds SPARQL by interpolation. `escapeLiteral` for string
// literals, `escapeIri` to embed untrusted values in an IRI, `assertNuri` to
// validate trusted-shaped NURIs before embedding them in an IRI.
export { escapeLiteral, escapeIri, assertNuri } from "./sparql";
// 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
// @ng-org import to the lib (no risk of a duplicate SDK copy in the bundle).
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";
import { makeNg } from "./ng-proxy";
/** SDK-identical `ng` (wrapped). Drop-in replacement for `@ng-org/web`'s `ng`. */
export const ng: Record<string, any> = makeNg();
-22
View File
@@ -1,22 +0,0 @@
/**
* Lifecycle re-exports SDK-shaped forwarders so the app imports `init` /
* `initNg` from `@ng-eventually/client` rather than from `@ng-org/*`. They
* delegate to the REAL functions injected at `configure()`. Passthrough today;
* a hook point later (e.g. opening the shared wallet on `init`).
*/
import { getConfig } from "./polyfill";
/** Forwards to the real `@ng-org/web` `init`. */
export function init(...args: any[]): any {
const f = getConfig().init;
if (!f) throw new Error("[ng-eventually] init() not injected — pass it to configure()");
return f(...args);
}
/** Forwards to the real `@ng-org/orm` `initNg` (ORM signals). */
export function initNg(...args: any[]): any {
const f = getConfig().initNg;
if (!f) throw new Error("[ng-eventually] initNg() not injected — pass it to configure()");
return f(...args);
}
-59
View File
@@ -1,59 +0,0 @@
/**
* The wrapped `ng`: a Proxy that forwards every method to the real SDK and
* overrides only what the broker/verifier will do natively at migration. The
* surface stays identical to `@ng-org/web`'s `ng`.
*/
import { getConfig, getCaps, getCurrentUser } from "./polyfill";
import type { Nuri } from "./types";
export function makeNg(): Record<string, any> {
return new Proxy({} as Record<string, any>, {
get(_target, prop: string) {
const { ng } = getConfig();
// login / session_start → open the SHARED wallet invisibly.
if (prop === "login" || prop === "session_start") {
return (...args: any[]) => {
// TODO(polyfill): supply shared-wallet credentials so no wallet UI
// is shown. For now, passthrough.
return ng[prop]!(...args);
};
}
// sparql_update → write guard (emulated write-cap check).
// Mirrors the target broker/verifier: a write is refused unless the wallet
// holds the document's WRITE cap. Emulated per-document via CapRegistry.
// args = (session_id, query, anchor?) — `anchor` is the target doc NURI.
if (prop === "sparql_update") {
return (...args: any[]) => {
const anchor = args[2] as Nuri | undefined;
const caps = getCaps();
// Passthrough (no regression) unless a WRITE policy exists AND this
// specific document is governed by it. Ungoverned docs (mono-store
// default, no cap declared) flow through exactly as before.
if (
typeof anchor === "string" &&
caps.hasWritePolicy() &&
caps.governsWrite(anchor) &&
!caps.canWrite(anchor, getCurrentUser())
) {
return Promise.reject(
new Error(
`[ng-eventually] write denied: current user lacks the write cap for ${anchor}`,
),
);
}
return ng.sparql_update!(...args);
};
}
// TODO(anticipated API): inbox_post_link + capability operations — expose
// here with their anticipated signatures, emulated for now.
// Everything else: passthrough to the real SDK, unchanged.
const real = ng[prop];
return typeof real === "function" ? real.bind(ng) : real;
},
});
}
-171
View File
@@ -1,171 +0,0 @@
/**
* The polyfill bootstrap the ONLY non-SDK surface of the client.
*
* It injects the REAL SDK and the polyfill settings; afterwards the SDK-shaped
* exports (`ng`, `useShape`, `inbox`) behave as drop-ins. This is exposed at the
* subpath `@ng-eventually/client/polyfill` so the main entry
* (`@ng-eventually/client`) stays a **pure, SDK-identical** surface. Everything
* here is removed at migration.
*/
import type { NgLike, UseShapeLike, PrincipalId } from "./types";
import type { RegistrySession } from "./store-registry";
import { CapRegistry } from "./caps";
import { setAccessLog } from "./access-log";
import { inspectOutbox } from "./outbox-log";
/**
* Consumer-injected dependencies of the storeRegistry (polyfill-era). The
* registry itself is generic (it knows only native scopes); the consumer wires
* up how to reach the shared-wallet session and how to normalize an identity id
* used as the shim key. Removed at migration along with the whole shim.
*/
export interface StoreRegistryDeps {
/** Resolve the current shared-wallet session (id + private-store anchor). */
getSession: () => Promise<RegistrySession>;
/** Normalize an identity id for shim keying. Default: trim (identity-ish). */
normalizeId?: (id: string) => string;
/**
* POINTER micro-guard budget. The account records now live in a subscribable
* doc-shim (`did:ng:o:...`) reached through a well-known write-once POINTER triple
* in the store-root graph. The doc-shim read is barrier-AUTHORITATIVE, so accounts
* need NO retry (this replaces the deleted account-level `provisionRetry`). The
* ONLY residual sync-lag window is the store-root pointer read itself one
* write-once triple. This bounded guard re-reads JUST that pointer a few times if a
* fresh cold read misses it; it can never provision or fork an account (worst case:
* a couple extra reads before an existing pointer is seen). Enable it where the REAL
* broker is used (app + e2e). Left UNSET (the default) `attempts: 1` = single
* read, keeping the synchronous unit fakes fast and unchanged.
*/
pointerGuard?: { attempts?: number; baseMs?: number; maxStepMs?: number };
}
export interface EventuallyConfig {
/** The REAL `@ng-org/web` `ng` (injected to avoid a hard import / alias loop). */
ng: NgLike;
/** The REAL `@ng-org/orm` `useShape`. */
useShape: UseShapeLike;
/** Shared-wallet credentials — polyfill only (one wallet for everyone). */
sharedWallet?: { name: string; secret: string };
/** Initial current user; may also be set later via {@link setCurrentUser}. */
currentUser?: PrincipalId;
/**
* Turn on the OFF-by-default document access log (see {@link ./access-log}):
* every real read/write is printed, prefixed by the active identity, to
* diagnose the shared-wallet isolation leak. Also enablable without a code
* change via the env var `NG_EVENTUALLY_ACCESS_LOG=1`. Default: false.
*/
debugAccessLog?: boolean;
/** REAL `@ng-org/web` `init` (lifecycle) — forwarded by the lib's `init()`. */
init?: (...args: any[]) => any;
/** REAL `@ng-org/orm` `initNg` (ORM signals) — forwarded by the lib's `initNg()`. */
initNg?: (...args: any[]) => any;
}
let cfg: EventuallyConfig | null = null;
let currentUser: PrincipalId | null = null;
/** Required fields of StoreRegistryDeps after defaults are applied. `pointerGuard`
* defaults to `{ attempts: 1 }` (single read) when the consumer leaves it unset. */
type ResolvedRegistryDeps = Required<
Pick<StoreRegistryDeps, "getSession" | "normalizeId" | "pointerGuard">
>;
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). */
let caps = new CapRegistry();
export function configure(c: EventuallyConfig): void {
cfg = c;
currentUser = c.currentUser ?? null;
setAccessLog(c.debugAccessLog ?? false);
}
/** @internal — used by the SDK-shaped wrappers to reach the injected real SDK. */
export function getConfig(): EventuallyConfig {
if (!cfg) throw new Error("[ng-eventually] configure() must be called before use");
return cfg;
}
/** Reset the injected config back to un-configured (mainly for tests, so a
* suite that calls configure() can restore the not-configured guard state). */
export function resetConfig(): void {
cfg = null;
currentUser = null;
}
/**
* Wire the storeRegistry's consumer-injected dependencies (session + identity-id
* normalization). Must be called before any storeRegistry.* use. Separate from
* {@link configure} because it's storeRegistry-specific and, like the shim,
* disappears at migration.
*/
export function configureStoreRegistry(deps: StoreRegistryDeps): void {
// Fire the outbox inspection (Volet 3 of the low-level data-path trace) once,
// on the FIRST successful `getSession()` resolution — the most reliable
// "a session is established" signal available: every low-level reader/writer
// (store-registry, open-repo, read-model, subscribe, inbox) reaches its
// session through this SAME injected `getSession`, so wrapping it HERE catches
// the first success from whichever caller happens to run first, instead of
// tying the probe to one particular call site. Only on SUCCESS (an error
// propagates untouched, exactly as before) and only ONCE per
// `configureStoreRegistry()` call (a fresh session config → a fresh check).
let outboxInspected = false;
const getSession = async (): Promise<RegistrySession> => {
const session = await deps.getSession();
if (!outboxInspected) {
outboxInspected = true;
inspectOutbox();
}
return session;
};
registryDeps = {
getSession,
normalizeId: deps.normalizeId ?? ((id: string) => id.trim()),
// Default: single read (no re-read). Only the real-broker consumers (app + e2e)
// opt into the bounded pointer micro-guard; unit fakes stay synchronous.
pointerGuard: deps.pointerGuard ?? { attempts: 1 },
};
}
/** @internal — used by the storeRegistry to reach its injected dependencies. */
export function getStoreRegistryDeps(): ResolvedRegistryDeps {
if (!registryDeps) {
throw new Error("[ng-eventually] configureStoreRegistry() must be called before use");
}
return registryDeps;
}
/** Reset storeRegistry deps (mainly for tests). */
export function resetStoreRegistry(): void {
registryDeps = null;
}
/**
* Set the current identity id who the SDK is reading/writing as. In the target
* this is the wallet user established at wallet-import time; here the consumer
* relays that id through this call so the read filter and the inbox `from` know
* who is acting. Passing `null` clears it (no identity yet, e.g. during startup).
*/
export function setCurrentUser(id: PrincipalId | null): void {
currentUser = id;
}
export function getCurrentUser(): PrincipalId | null {
return currentUser;
}
/** The emulated cap registry the app opens a document's read policy and issues
* directed read grants on it (as it will via real cap operations in the target).
* The read filter consults it. */
export function getCaps(): CapRegistry {
return caps;
}
/** Reset all emulated caps (mainly for tests / fresh sessions). */
export function resetCaps(): void {
caps = new CapRegistry();
}
// Cap surface — polyfill-era (caps are emulated now; native at migration).
// Re-exported here so the whole polyfill API lives under /polyfill.
export { CapRegistry } from "./caps";
-82
View File
@@ -1,82 +0,0 @@
/**
* Read filter the polyfill of capability-based read access.
*
* In the target, the broker only delivers documents the user holds a **ReadCap**
* for, so `useShape` already returns an authorized subset. Here (single shared
* wallet, everything readable) we reproduce that with a read-filtered VIEW over
* 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}.
*
* 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-
* nothing on that document which is exactly the native behavior, and why
* fine-grained isolation requires one document per entity. Removed at migration.
*/
import type { CapRegistry } from "./caps";
import type { PrincipalId } from "./types";
/** The document (repo NURI) an item lives in — its `@graph`. */
function docOf(item: unknown): string | null {
const g = (item as Record<string, unknown> | null)?.["@graph"];
return typeof g === "string" ? g : null;
}
/**
* May `user` read this item? An item with no `@graph`, or in a document under no
* cap policy, is KEPT (the filter only restricts documents that DECLARE a cap
* mirrors the prior behavior and keeps ungoverned data flowing).
*/
function readable(item: unknown, caps: CapRegistry, user: PrincipalId | null): boolean {
const doc = docOf(item);
if (doc === null) return true;
if (!caps.governsRead(doc)) return true;
return caps.canRead(doc, user);
}
/** Pure: keep only the items the user may read. */
export function filterReadable<T>(
items: Iterable<T>,
caps: CapRegistry,
user: PrincipalId | null,
): T[] {
const out: T[] = [];
for (const item of items) if (readable(item, caps, user)) out.push(item);
return out;
}
/**
* A read-filtered VIEW over a reactive set (a `DeepSignalSet`, or any Set-like).
* Iteration / `size` / `forEach` yield only readable items; everything else
* (`add`, `delete`, `has`, `getById`, ) forwards to the target, so writes and
* the underlying reactivity are preserved. The current user is read lazily (via
* `getUser`) so the view reflects the user in effect at read time.
*/
export function makeReadFilteredView<S extends object>(
set: S,
caps: CapRegistry,
getUser: () => PrincipalId | null,
): S {
const keep = (item: unknown): boolean => readable(item, caps, getUser());
return new Proxy(set, {
get(target, prop, receiver) {
if (prop === Symbol.iterator) {
return function* () {
for (const item of target as Iterable<unknown>) if (keep(item)) yield item;
};
}
if (prop === "size") {
let n = 0;
for (const item of target as Iterable<unknown>) if (keep(item)) n++;
return n;
}
if (prop === "forEach") {
return (cb: (v: unknown, v2: unknown, s: unknown) => void) => {
for (const item of target as Iterable<unknown>) if (keep(item)) cb(item, item, receiver);
};
}
const v = Reflect.get(target, prop, target);
return typeof v === "function" ? v.bind(target) : v;
},
}) as S;
}
-839
View File
@@ -1,839 +0,0 @@
/**
* storeRegistry resolves (account, scope) document NURI.
*
* Stopgap / polyfill-era. Emulates the target infrastructure where each
* user owns their own public/protected/private stores on top of one shared
* wallet. It creates one document per (account × scope) inside that shared
* wallet (via the `docs.docCreate` primitive), so the `scope`
* (`public|protected|private`) is a logical attribute tracked here, not a
* physical NextGraph store. Isolation is enforced by the app layer + the
* emulated cap registry, not by crypto.
*
* The mapping (account its 3 document NURIs) is the **sharedWalletShim**. It
* is persisted as RDF, but NOT in the store-root graph anymore see the
* indirection below. That makes login cross-device: another device opening the
* same wallet reads the same shim and finds the same accounts.
*
* The indirection: pointer (store-root) doc-shim (subscribable)
* On the real NextGraph platform "findable-without-lookup" and "subscribable"
* are DISJOINT (verified at the source, see docs/nextgraph-current-state.md
* § *Findable vs subscribable*):
* - the ONLY NURI a fresh session can name WITHOUT a lookup is the store-root
* `did:ng:${privateStoreId}` but a store-root has NO first-`State` sync
* BARRIER, so a cold "0 rows" on it is AMBIGUOUS (could be sync-lag, could
* be truly empty);
* - the ONLY thing that DOES have a first-`State` barrier is a `did:ng:o:<RepoID>`
* doc from `doc_create` but its RepoID is RANDOM, so a fresh session
* cannot GUESS it; it must be looked up.
* So a purely-barrier shim resolution is impossible: you cannot have a doc that
* is both guessable and authoritative on a cold read. The indirection bridges
* this: a well-known, write-ONCE **pointer** triple in the store-root names a
* **doc-shim** (`did:ng:o:...`) that holds all account records and IS
* subscribable. Resolution reads the pointer (a single oldest write-once triple,
* near-always synced), then opens the doc-shim through its `ensureRepoOpen`
* BARRIER and reads the account AUTHORITATIVELY (0 = genuinely absent).
*
* A pointer FORK (two devices writing the pointer before either synced) is
* reconciled to a canonical doc-shim (lexicographically-smallest NURI) so every
* device converges on the SAME doc-shim. This is why the OLD account-level retry
* (`resolveAccountReliably` / `provisionRetry`) is GONE: the account read is now
* barrier-authoritative, so it never needs to be retried to distinguish sync-lag
* from absence. A micro-guard remains ONLY on the pointer read (one write-once
* triple) see resolvePointer.
*
* Generic by construction
* This module knows only the three native scopes; it knows no application
* entity kind. The consumer maps its entities to a scope and calls
* `createEntityDoc(scope)` / `listEntityDocs(scope)` with the resulting native
* scope. No application domain here.
*
* What disappears at migration
* At the real multi-store migration the shim vanishes entirely: `(account,
* scope)` maps to the user's REAL store NURI instead of a document in the
* shared wallet, `docCreate` targets the real per-user store, and the
* per-scope index document (the store-container emulation) is replaced by the
* store itself. The consumer-facing surface (`createEntityDoc`,
* `listEntityDocs`, resolvers) is designed to survive that swap unchanged.
*
* All NextGraph I/O routes through the T01.a `docs` primitive (real injected
* `ng`), so this module imports **no** `@ng-org` package.
*/
import { docCreate, sparqlUpdate, sparqlQuery } from "./docs";
import { getStoreRegistryDeps } from "./polyfill";
import { ensureRepoOpen } from "./open-repo";
import { escapeLiteral, escapeIri, assertNuri } from "./sparql";
import { accessLogPrefix, logStage, shortNuri } from "./access-log";
import type { Nuri, Scope } from "./types";
// --- sharedWalletShim model ----------------------------------------------
/** One account's three scope-document NURIs, as recorded in the shim. */
export interface AccountRecord {
id: string;
docPublic: Nuri;
docProtected: Nuri;
docPrivate: Nuri;
}
const SHIM = "urn:ng-eventually:shim";
const P = {
type: `${SHIM}:Account`,
id: `${SHIM}:id`,
docPublic: `${SHIM}:docPublic`,
docProtected: `${SHIM}:docProtected`,
docPrivate: `${SHIM}:docPrivate`,
contains: `${SHIM}:contains`, // scope-index → entity document NURI
} as const;
// Fixed subject of the per-(account×scope) index document. The index doc plays
// the role of the future store-container: it lists the NURIs of the entity
// documents (one per entity) that live "in" that scope.
const INDEX_SUBJECT = `${SHIM}:index`;
// --- pointer (store-root → doc-shim indirection) --------------------------
//
// The pointer is a SINGLE well-known triple written ONCE into the store-root
// graph on the very first login, then IMMUTABLE. Its object is the NURI of the
// doc-shim (a `did:ng:o:...` repo) where all AccountRecords actually live. The
// store-root is NOT subscribable (no first-`State` barrier), but the pointer is
// the OLDEST triple in that graph and is write-once, so it is near-always synced
// on a cold read — and even a transient miss is bounded by a small guard
// (resolvePointer), NOT by an account-level retry.
const POINTER_SUBJECT = `${SHIM}:root`;
const POINTER_PRED = `${SHIM}:shimDoc`;
function accountSubject(id: string): string {
// The id is UNTRUSTED and lands in an IRI position. Percent-encode it
// (escapeIri) so no `>` / `"` / whitespace / control char can break out of
// the `<...>` and inject triples into the shim graph (the account→doc trust
// root). accountKey() runs first so the subject stays stable per shim key.
return `${SHIM}:account:${escapeIri(accountKey(id))}`;
}
// --- reserved accounts -----------------------------------------------------
//
// Some accounts are internal to the lib (e.g. the discovery index owner) and
// must NOT collide with any user-chosen id. A reserved account is created
// via {@link reservedAccount}, which marks the name with a sentinel PREFIX that
// `normalizeId` (consumer-injected) can never produce: it strips a leading
// `@`, trims, and lowercases, so a NUL prefix is unreachable. Reserved
// keys therefore live in a disjoint namespace from every normalized id —
// a real user named "index"/"@index" can never resolve to the reserved
// `reservedAccount("index")` account.
const RESERVED_PREFIX = "\u0000reserved:";
/**
* Wrap an internal account name so it occupies a key that no user input can
* produce (see {@link RESERVED_PREFIX}). Pass the result to {@link ensureAccount}
* (and the other registry calls) instead of a bare id.
*/
export function reservedAccount(name: string): string {
return `${RESERVED_PREFIX}${name}`;
}
/** Whether a name is a reserved-account sentinel (from {@link reservedAccount}). */
function isReserved(id: string): boolean {
return id.startsWith(RESERVED_PREFIX);
}
/**
* The shim/cache key for an account. Reserved accounts bypass `normalizeId`
* entirely and key on their sentinel-prefixed name, so they cannot collide with
* a normalized id; everyone else normalizes as usual.
*/
function accountKey(id: string): string {
return isReserved(id) ? id : normalize(id);
}
// --- session / normalization access (injected by the consumer) ------------
/** Minimal session shape the registry needs — provided by the consumer. */
export interface RegistrySession {
sessionId: string;
/** The shared wallet's private store id — the pointer anchor. */
privateStoreId: string;
/** The shared wallet's protected store id (native store). Optional: only the
* scope resolvers need it; the shim only needs the private anchor. */
protectedStoreId?: string;
/** The shared wallet's public store id (native store). Optional. */
publicStoreId?: string;
}
function normalize(id: string): string {
return getStoreRegistryDeps().normalizeId(id);
}
async function session(): Promise<RegistrySession> {
return getStoreRegistryDeps().getSession();
}
/** The pointer lives in the shared wallet's private STORE-ROOT graph (the only
* always-known-without-lookup anchor). NOT subscribable hence the pointer is
* a write-once triple, and the actual account records live in the doc-shim it
* names (see rootNuri vs the doc-shim). */
async function rootNuri(): Promise<Nuri> {
const s = await session();
return `did:ng:${s.privateStoreId}`;
}
// --- cache ----------------------------------------------------------------
// In-memory cache of the FULL shim (all accounts), keyed by account key. Set
// only once loadShim() has read every account — used by the all-accounts paths.
let cache: Map<string, AccountRecord> | null = null;
// Per-account cache, keyed by account key. Populated by the TARGETED resolver
// (resolveAccount) and by loadShim(). Independent of `cache` so a single
// targeted resolve never forces a full shim scan. Both are cleared together.
const accountCache = new Map<string, AccountRecord>();
// The resolved doc-shim NURI for the current session (cached: the pointer read +
// barrier open happen once, then every account read reuses this doc). Cleared on
// resetRegistryCache / wallet switch.
let shimDocNuri: Nuri | null = null;
// De-dupe concurrent pointer-resolutions so a fresh page firing many parallel
// ensureAccount/resolveAccount calls opens the doc-shim exactly once.
let shimDocInFlight: Promise<Nuri> | null = null;
/** Reset cache (e.g. after switching the shared wallet). Mostly for tests. */
export function resetRegistryCache(): void {
cache = null;
accountCache.clear();
shimDocNuri = null;
shimDocInFlight = null;
}
// --- SPARQL result helpers ------------------------------------------------
/** Tolerant extraction of SPARQL SELECT bindings across possible shapes. */
function readBindings(result: unknown): Array<Record<string, { value: string }>> {
if (!result) return [];
const anyRes = result as {
results?: { bindings?: Array<Record<string, { value: string }>> };
};
if (Array.isArray(result)) return result as Array<Record<string, { value: string }>>;
if (anyRes.results?.bindings) return anyRes.results.bindings;
return [];
}
function bindingValue(row: Record<string, { value: string }>, key: string): string {
return row[key]?.value ?? "";
}
/**
* DETERMINISTIC resolution of an account's scope docs from a set of SPARQL
* bindings (all bindings for ONE account subject).
*
* Why this is load-bearing (residual fork residue)
* A corrupted shim can carry the SAME account subject with MULTIPLE values for a
* scope predicate (e.g. 5 `shim:docPublic`) the residue of past account FORKS
* (each stray provision appended another doc NURI). A query then returns several
* bindings (the cross-product of the duplicate values). Picking `rows[0]` is
* NON-DETERMINISTIC (binding order is not stable across sessions), so the session
* that WROTE an entity into one docPublic and a later fresh page that RESOLVED a
* DIFFERENT docPublic would disagree the anchored `readScopeIndex` returns 0
* the home reads empty. When both happen to pick the same doc, it "works".
*
* The fix: for each scope field, collect EVERY distinct value across the bindings
* and choose the SAME one every time the lexicographically-smallest NURI. NURIs
* are content-addressed and stable, so lexicographic order is a total, stable,
* session-independent order: writer and reader converge on the SAME canonical doc
* even on a wallet already corrupted by duplicates. (No creation timestamp is
* recorded in the shim, so lexicographic-min is the available deterministic key.)
*
* With the barrier-authoritative doc-shim read, account FORKS no longer occur (a
* fresh page reads the doc-shim through its first-`State` barrier, so a cold 0 is
* definitive and never triggers a fork-provision). `canonicalDoc` is RETAINED to
* stay robust against the residue of PAST forks already persisted in a wallet, and
* to reconcile a benign pointer fork the same content-addressed way.
*/
function canonicalDoc(rows: Array<Record<string, { value: string }>>, key: string): Nuri {
let chosen = "";
const distinct = new Set<string>();
for (const row of rows) {
const v = bindingValue(row, key);
if (!v) continue;
distinct.add(v);
if (chosen === "" || v < chosen) chosen = v;
}
// Stage trace: which doc got picked, and out of how many DISTINCT candidate
// values — >1 flags residual fork residue (see the module doc above) even
// when resolution still converges correctly on the canonical (smallest) one.
logStage(
"canonicalDoc(" + key + ") → " + (chosen ? shortNuri(chosen) : "none") +
" (" + distinct.size + (distinct.size === 1 ? " candidate)" : " candidates)"),
);
return chosen;
}
/** Build an AccountRecord by picking the canonical (lexicographically-smallest)
* doc NURI per scope across all bindings for one account. See {@link canonicalDoc}. */
function recordFromRows(
rows: Array<Record<string, { value: string }>>,
fallbackId: string,
): AccountRecord {
let id = "";
for (const row of rows) {
const v = bindingValue(row, "id");
if (v) { id = v; break; }
}
return {
id: id || fallbackId,
docPublic: canonicalDoc(rows, "docPublic"),
docProtected: canonicalDoc(rows, "docProtected"),
docPrivate: canonicalDoc(rows, "docPrivate"),
};
}
// --- pointer resolution + doc-shim bootstrap ------------------------------
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
/**
* Read the pointer(s) from the store-root graph the canonical doc-shim NURI, or
* `""` if no pointer exists yet.
*
* The store-root is NOT subscribable, so this read has no first-`State` barrier:
* a cold 0 is ambiguous. But the pointer is ONE write-once triple (the OLDEST in
* that graph), so it is near-always synced. The ONLY residual guard is a small
* bounded re-read here (NOT an account retry): a handful of quick re-reads of that
* single triple. It is bounded, benign, and crucially it can never re-provision
* an account or fork data; the worst it can do is take a couple extra reads to see a
* pointer that is still landing. The account records themselves are read
* authoritatively through the doc-shim barrier, never through this guard.
*
* If MULTIPLE pointers exist (a pointer fork: two devices each wrote a pointer to
* their own freshly-created doc-shim before either synced), reconcile to the
* canonical (lexicographically-smallest) doc-shim NURI content-addressed and
* stable, so every device converges on the SAME doc-shim.
*/
async function resolvePointer(): Promise<Nuri> {
const s = await session();
const root = await rootNuri();
// COLD-START heal: open the store-root repo before the anchored read, so a fresh
// wallet whose store-root isn't yet in `self.repos` resolves instead of throwing
// `RepoNotFound`. Idempotent; a no-op with the unit fake ng. The store-root has no
// barrier, so this open cannot make the read authoritative — the guard below does.
await ensureRepoOpen(root);
const query = `
SELECT ?shimDoc WHERE {
GRAPH <${assertNuri(root)}> {
<${POINTER_SUBJECT}> <${POINTER_PRED}> ?shimDoc .
}
}`;
// Micro-guard (POINTER only): a small bounded re-read to bridge the store-root
// sync-lag window on the ONE write-once pointer triple. Bounded, and it can only
// ever DELAY seeing an existing pointer — never provision, never fork. Uses the
// injected pointerGuard budget (defaults to a single read when unset, so unit
// fakes stay synchronous). NB this is NOT the deleted account-level provisionRetry.
const budget = getStoreRegistryDeps().pointerGuard;
const attempts = Math.max(1, budget.attempts ?? 1);
const baseMs = budget.baseMs ?? 150;
const maxStepMs = budget.maxStepMs ?? 2000;
let step = baseMs;
for (let i = 0; i < attempts; i++) {
try {
const result = await sparqlQuery(s.sessionId, query, undefined, root, "resolvePointer");
const doc = canonicalDoc(readBindings(result), "shimDoc");
if (doc) {
logStage("resolvePointer → 1 target: " + shortNuri(doc));
return doc;
}
} catch (error) {
console.error(accessLogPrefix() + " resolvePointer failed:", error);
}
if (i < attempts - 1) {
await sleep(step);
step = Math.min(step * 2, maxStepMs);
}
}
logStage("resolvePointer → 0 targets");
return "";
}
/** Write the pointer (store-root doc-shim), once, at first login. Idempotent in
* practice (only called when no pointer was found); a concurrent double-write is
* reconciled by canonicalDoc on read. */
async function writePointer(doc: Nuri): Promise<void> {
const s = await session();
const root = await rootNuri();
await ensureRepoOpen(root);
const update = `
INSERT DATA {
GRAPH <${assertNuri(root)}> {
<${POINTER_SUBJECT}> <${POINTER_PRED}> <${assertNuri(doc)}> .
}
}`;
try {
await sparqlUpdate(s.sessionId, update, root, "writePointer");
} catch (error) {
console.error(accessLogPrefix() + " writePointer failed:", error);
}
}
/** Create one graph document in the shared wallet's private store (→ a NURI). */
async function createDoc(): Promise<Nuri> {
const s = await session();
// crdt="Graph" (RDF/SPARQL/ORM), class="data:graph", destination="store",
// store_repo=undefined → shared wallet's private store.
return docCreate(s.sessionId, "Graph", "data:graph", "store", undefined);
}
/**
* Resolve (or on first login, create) the doc-shim NURI for this session the
* `did:ng:o:...` repo that holds every AccountRecord and IS subscribable.
*
* Steps (cached; runs at most once per session, concurrent callers share one):
* 1. Read the pointer from the store-root (resolvePointer). If present that is
* the doc-shim; open it through its first-`State` BARRIER so subsequent account
* reads are AUTHORITATIVE.
* 2. No pointer FIRST login:
* a. create a fresh doc-shim (`doc_create`), which bootstraps the repo into the
* session (`self.repos`) so it is already open/synced in-session;
* b. publish the pointer (store-root the new doc-shim), once;
* c. open it (barrier trivially satisfied for a just-created in-session repo).
* The BARRIER matters on RECONNECT (step 1, reading an EXISTING remote doc-shim);
* on first-login creation it is a no-op, so the pointer is published first.
*/
async function resolveShimDoc(): Promise<Nuri> {
if (shimDocNuri) return shimDocNuri;
if (shimDocInFlight) return shimDocInFlight;
const p = (async (): Promise<Nuri> => {
const existing = await resolvePointer();
if (existing) {
// Open the doc-shim through its first-`State` barrier BEFORE any account read,
// so a cold 0 on the doc-shim is authoritative (genuinely absent), not sync-lag.
await ensureRepoOpen(existing);
shimDocNuri = existing;
logStage("resolveShimDoc → " + shortNuri(existing));
return existing;
}
// FIRST login (no pointer): create the doc-shim (bootstrapped in-session), publish
// the pointer, then open (no-op barrier for a just-created repo).
const doc = await createDoc();
await writePointer(doc);
await ensureRepoOpen(doc);
shimDocNuri = doc;
logStage("resolveShimDoc → " + shortNuri(doc));
return doc;
})();
shimDocInFlight = p;
try {
return await p;
} finally {
shimDocInFlight = null;
}
}
// --- shim load / account bootstrap ----------------------------------------
/** Load all accounts from the shim (the doc-shim) into the cache. */
export async function loadShim(): Promise<Map<string, AccountRecord>> {
if (cache) return cache;
const s = await session();
const doc = await resolveShimDoc();
const query = `
SELECT ?id ?docPublic ?docProtected ?docPrivate WHERE {
?acc a <${P.type}> ;
<${P.id}> ?id ;
<${P.docPublic}> ?docPublic ;
<${P.docProtected}> ?docProtected ;
<${P.docPrivate}> ?docPrivate .
}`;
const map = new Map<string, AccountRecord>();
// The doc-shim is opened (first-`State` barrier) by resolveShimDoc, so this read is
// authoritative.
await ensureRepoOpen(doc);
try {
const result = await sparqlQuery(s.sessionId, query, undefined, doc, "loadShim");
// Group ALL bindings by account key first, then pick the CANONICAL doc per
// scope (see recordFromRows / canonicalDoc). A single account subject may carry
// duplicate scope-doc values (fork residue) → several bindings; grouping +
// canonical selection makes loadShim resolve the SAME doc the targeted
// resolveAccount does, so full-scan and hot-path readers never disagree.
const byKey = new Map<string, Array<Record<string, { value: string }>>>();
for (const row of readBindings(result)) {
const id = bindingValue(row, "id");
if (!id) continue;
const key = accountKey(id);
const bucket = byKey.get(key) ?? [];
bucket.push(row);
byKey.set(key, bucket);
}
for (const [key, rows] of byKey) {
const record = recordFromRows(rows, rows[0] ? bindingValue(rows[0], "id") : key);
map.set(key, record);
// Feed the per-account cache too, so a subsequent targeted resolve is free.
accountCache.set(key, record);
}
} catch (error) {
console.error(accessLogPrefix() + " loadShim failed:", error);
}
cache = map;
return map;
}
/**
* Resolve ONE account by its shim key with a BOUNDED query O(1), independent
* of the number of accounts in the shim. This is the HOT-PATH lookup: it hits
* the account record at its known subject (`accountSubject(id)`) directly in the
* doc-shim, instead of scanning EVERY account like {@link loadShim}. Returns the
* account's record or `null` if it does not exist yet.
*
* Barrier-AUTHORITATIVE (the reconnection fix)
* The read targets the DOC-SHIM (`did:ng:o:...`), which resolveShimDoc opened
* through its first-`State` barrier. So a cold 0 rows here is DEFINITIVE ("account
* genuinely absent"), not ambiguous sync-lag no account-level retry is needed or
* used. This is what replaced the old `resolveAccountReliably` / `provisionRetry`
* loop: the store-root ambiguity that forced the retry is gone once the read moves
* behind the doc-shim barrier.
*
* Cached per account (in `accountCache`); a hit skips the query entirely, so
* repeated resolves of the same account are free. `resetRegistryCache` clears it.
*/
export async function resolveAccount(id: string): Promise<AccountRecord | null> {
const key = accountKey(id);
const cached = accountCache.get(key);
if (cached) return cached;
const s = await session();
const doc = await resolveShimDoc();
// `subj` is already IRI-safe (accountSubject → escapeIri). The read is anchored to
// the doc-shim's default graph (opened through its barrier by resolveShimDoc), so
// it is authoritative. The query is bounded to this one subject.
const subj = accountSubject(id);
const query = `
SELECT ?id ?docPublic ?docProtected ?docPrivate WHERE {
<${subj}> a <${P.type}> ;
<${P.id}> ?id ;
<${P.docPublic}> ?docPublic ;
<${P.docProtected}> ?docProtected ;
<${P.docPrivate}> ?docPrivate .
}`;
try {
const result = await sparqlQuery(s.sessionId, query, undefined, doc, "resolveAccount");
const rows = readBindings(result);
if (rows.length === 0) {
logStage("resolveAccount(" + key + ") → null");
return null;
}
// DETERMINISTIC: a corrupted shim may return SEVERAL bindings for this one
// account subject (duplicate scope-doc values from past forks). Pick the
// canonical (lexicographically-smallest) doc per scope so writer and reader
// always resolve the SAME docPublic (robustness against PAST fork residue).
const record = recordFromRows(rows, id);
accountCache.set(key, record);
logStage("resolveAccount(" + key + ") → 1 record");
return record;
} catch (error) {
console.error(accessLogPrefix() + " resolveAccount failed:", error);
return null;
}
}
/** All known accounts (from the shim). */
export async function allAccounts(): Promise<AccountRecord[]> {
return [...(await loadShim()).values()];
}
/** Persist one AccountRecord into the doc-shim (anchored default-graph write, the
* canonical always-safe shape same convention as createEntityDoc). */
async function writeRecord(doc: Nuri, record: AccountRecord): Promise<void> {
const s = await session();
const subj = `${SHIM}:account:${escapeIri(accountKey(record.id))}`;
// `subj` is IRI-safe (escapeIri). `id` is UNTRUSTED text in a LITERAL position →
// escapeLiteral. The doc NURIs come from `ng` but are stored as literals here, so
// they are escaped as literals too (defence in depth). NO explicit `GRAPH <…>`
// wrapper: write the anchored DEFAULT graph (the `doc` anchor scopes it) — the
// canonical, always-safe shape the anchored default-graph read queries match.
const update = `
INSERT DATA {
<${subj}> a <${P.type}> ;
<${P.id}> "${escapeLiteral(record.id)}" ;
<${P.docPublic}> "${escapeLiteral(record.docPublic)}" ;
<${P.docProtected}> "${escapeLiteral(record.docProtected)}" ;
<${P.docPrivate}> "${escapeLiteral(record.docPrivate)}" .
}`;
try {
await sparqlUpdate(s.sessionId, update, doc, "writeRecord");
} catch (error) {
console.error(accessLogPrefix() + " writeRecord persist failed:", error);
}
}
/**
* In-flight `ensureAccount` promises, keyed by account key so CONCURRENT
* `ensureAccount(id)` calls for the SAME account share ONE resolve-or-provision.
*
* Why this is load-bearing (concurrent-provision de-dup)
* On a fresh page over the persistent wallet, SEVERAL independent callers hit
* `ensureAccount(A)` near-simultaneously (watchShape public/protected, the container
* subscriptions, the app's owned-events effect). With the barrier-authoritative
* resolveAccount a fresh page NO LONGER mistakes sync-lag for absence but if the
* account is GENUINELY new, N concurrent callers would still each see 0 and each
* provision a set of scope docs (an in-session fork). De-duping concurrent provisions
* collapses those N into ONE: the first caller resolves-or-provisions; every
* concurrent caller awaits the SAME promise and gets the SAME record. Not polling: a
* bounded in-memory promise map (mirrors open-repo.ts `inFlight`), cleared the instant
* it settles.
*/
const ensureInFlight = new Map<string, Promise<AccountRecord>>();
/**
* Ensure an account exists in the shim, creating its 3 scope documents on
* first sight. Idempotent returns the existing record if already present.
* Concurrency-safe: concurrent calls for the same account share one provision
* (see {@link ensureInFlight}) so a fresh page never FORKS the account.
*/
export async function ensureAccount(id: string): Promise<AccountRecord> {
const key = accountKey(id);
// A completed provision/resolve is cached → no query, no fork risk.
const cached = accountCache.get(key);
if (cached) return cached;
// A concurrent provision for the SAME account is already running → await it,
// instead of racing a second (forking) provision. This is the anti-fork guard.
const pending = ensureInFlight.get(key);
if (pending) return pending;
const p = (async (): Promise<AccountRecord> => {
// HOT PATH: targeted O(1) lookup — does THIS account already exist? — instead
// of a full-shim scan (loadShim). Off the read/write hot path entirely.
//
// Barrier-AUTHORITATIVE: resolveAccount reads the doc-shim behind its first-`State`
// barrier (opened by resolveShimDoc), so a 0 here means the account is GENUINELY
// absent — not sync-lag. No account-level retry: the store-root ambiguity that
// forced the old provisionRetry loop is gone once the read moves behind the barrier.
const existing = await resolveAccount(id);
if (existing) return existing;
const doc = await resolveShimDoc();
const [docPublic, docProtected, docPrivate] = await Promise.all([
createDoc(),
createDoc(),
createDoc(),
]);
const record: AccountRecord = { id, docPublic, docProtected, docPrivate };
// Persist the record INTO the doc-shim (not the store-root anymore).
await writeRecord(doc, record);
// Feed the per-account cache, and the full-shim cache if it is already loaded
// (so allAccounts / the fan-out see the freshly-created account too).
accountCache.set(key, record);
cache?.set(key, record);
return record;
})();
ensureInFlight.set(key, p);
try {
return await p;
} finally {
ensureInFlight.delete(key);
}
}
// --- resolvers ------------------------------------------------------------
/** The index document NURI of an account for a scope (the store-container). */
function indexDocOf(record: AccountRecord, scope: Scope): Nuri {
return scope === "public"
? record.docPublic
: scope === "protected"
? record.docProtected
: record.docPrivate;
}
/**
* NURI of the document where `id` writes GROUPED entities of `scope` (a single
* per-scope index document, for entities that need no per-entity document / no
* inbox). For per-entity scopes use {@link createEntityDoc} instead.
*/
export async function resolveWriteGraph(id: string, scope: Scope): Promise<Nuri> {
const record = await ensureAccount(id);
return indexDocOf(record, scope);
}
/** NURIs of every account's document for `scope` (read fan-out). */
export async function resolveReadGraphs(scope: Scope): Promise<Nuri[]> {
const accounts = await allAccounts();
return accounts.map((a) => indexDocOf(a, scope));
}
// --- SDK-shaped scope resolvers (no store-id ever leaves the lib) ----------
//
// The consumer asks by SCOPE ("give me the graph to write/read entities of
// scope X", "give me the inbox anchor") and NEVER constructs a `did:ng:${…}`
// store NURI itself. The lib owns the physical placement — which is the whole
// point of the SDK boundary. In THIS polyfill the placement is the shared
// wallet's native stores (Axis A, per the two-axes doctrine in
// docs/simulation.md): a scope maps to a native store NURI resolved from the
// injected session. `public` currently co-locates with `protected` because
// `doc_create`/ORM cannot target a non-private/protected native store today
// (the SDK blocker recorded in migration-guide.md); at migration each scope
// resolves to the user's REAL per-scope store and this mapping changes here,
// in the lib, with no consumer change.
/** The native store NURI backing `scope`, resolved from the injected session.
* Requires `protectedStoreId` on the session for the non-private scopes. */
async function scopeStoreNuri(scope: Scope): Promise<Nuri> {
const s = await session();
if (scope === "private") return `did:ng:${s.privateStoreId}`;
// public + protected → the protected native store (see note above). Falls
// back to the private store if the session didn't carry a protected id.
const store = s.protectedStoreId ?? s.privateStoreId;
return `did:ng:${store}`;
}
/**
* The graph NURI where the current session WRITES entities of `scope`, and
* whose repo `useShape` must subscribe to read them back. SDK-shaped: the
* consumer passes a logical scope and gets an opaque graph NURI it holds no
* store-id and builds no NURI. Use the returned value as both the read scope
* (`useShape(shape, nuri)`) and the `@graph` write target.
*/
export async function resolveScopeGraph(scope: Scope): Promise<Nuri> {
return scopeStoreNuri(scope);
}
/**
* The reserved account that OWNS the shared registration-inbox document. Like the
* discovery index's special account, it lives in the reserved namespace (no user
* can produce this key) and only HOSTS a document its `public` scope document is
* the inbox anchor. Disappears at migration (native per-document inboxes).
*/
const INBOX_ANCHOR_ACCOUNT = reservedAccount("inbox");
/**
* The inbox anchor NURI for the current session (where emulated inbox deposits
* physically land). SDK-shaped: the consumer never resolves a store itself.
*
* This is a DEDICATED inbox DOCUMENT (a reserved account's public scope document
* a real repo NURI from `docCreate`, stable across clients via the shim), NOT the
* shared wallet's private-store root. Reason (perf + hygiene): the shim (the
* accountdocument trust root) is scanned on every `loadShim`; routing every inbox
* deposit into that SAME graph bloats it without bound (thousands of deposit triples
* across sessions). A separate inbox document keeps the shim graph small and the
* deposits isolated. At migration this becomes the host's native per-document inbox
* and the resolution moves here.
*/
export async function resolveInboxAnchor(): Promise<Nuri> {
const record = await ensureAccount(INBOX_ANCHOR_ACCOUNT);
return record.docPublic;
}
// --- per-entity documents + per-scope index -------------------------------
/**
* Create a dedicated document for ONE entity mirrors the target, where each
* such entity is its own document/repo (addressable, future inbox). The new
* document's NURI is appended to the account's scope index document (the
* store-container). Returns the entity document NURI (use it as `@graph`).
*/
export async function createEntityDoc(id: string, scope: Scope): Promise<Nuri> {
const record = await ensureAccount(id);
const indexDoc = indexDocOf(record, scope);
const entityNuri = await createDoc();
const s = await session();
try {
await sparqlUpdate(
s.sessionId,
// NO explicit `GRAPH <…>` wrapper: write the anchored DEFAULT graph (the
// `indexDoc` anchor scopes it) — the CANONICAL, always-safe shape the
// anchored default-graph read queries (readScopeIndex below, same as
// read-model.ts). Not a round-trip necessity on the current broker: the e2e
// harness (`packages/client/e2e/`) verified an anchored `GRAPH <plainNuri>`
// write ALSO round-trips here (same repo graph, no phantom graph); no-GRAPH
// is kept as a simplicity/safety convention. entityNuri is a NURI stored as
// a literal → escapeLiteral.
`INSERT DATA { <${INDEX_SUBJECT}> <${P.contains}> "${escapeLiteral(entityNuri)}" }`,
indexDoc,
"createEntityDoc",
);
} catch (error) {
console.error(accessLogPrefix() + " createEntityDoc index append failed:", error);
}
return entityNuri;
}
/** Read the entity-document NURIs contained in ONE scope index document. */
async function readScopeIndex(indexDoc: Nuri): Promise<Nuri[]> {
const s = await session();
const out: Nuri[] = [];
// COLD-START heal (polyfill-era): on a fresh session over a persistent wallet the
// scope-index repo (public OR protected — the protected one carries participations
// and is the one that most often reads empty) is not yet in `self.repos`, so this
// anchored read would return 0 NURIs → nothing gets listed → nothing gets
// subscribed (the self-inflicted circularity). Open/subscribe the index repo ONCE
// before reading it. Idempotent per session; no-op with the unit fake ng. See
// open-repo.ts.
await ensureRepoOpen(indexDoc);
try {
const res = await sparqlQuery(
s.sessionId,
// NO explicit `GRAPH <…>` clause — read the anchored DEFAULT graph (see
// the note in createEntityDoc). The `indexDoc` anchor scopes the query.
`SELECT ?e WHERE { <${INDEX_SUBJECT}> <${P.contains}> ?e }`,
undefined,
indexDoc,
"readScopeIndex",
);
for (const row of readBindings(res)) {
const v = bindingValue(row, "e");
if (v) out.push(v);
}
} catch (error) {
console.error(accessLogPrefix() + " readScopeIndex failed:", error);
}
logStage("readScopeIndex(" + shortNuri(indexDoc) + ") → " + out.length + " entities");
return out;
}
/**
* Every entity document NURI of `scope`, across all accounts the read
* fan-out for per-entity scopes. Reads each account's scope index document and
* unions the contained NURIs. Use as `useShape(shape, { graphs })`.
*
* NOTE (read-by-need): this ALL-ACCOUNTS fan-out contradicts the read-by-need
* model (docs/read-model.md) it opens/syncs other accounts' possibly-unsynced
* docs, which HANGS. Prefer {@link listMyEntityDocs} (my own account's scope
* docs) for "my entities", and the discovery index for "all public events".
* Retained for callers that legitimately need every account (tests).
*/
export async function listEntityDocs(scope: Scope): Promise<Nuri[]> {
const accounts = await allAccounts();
const out: Nuri[] = [];
for (const a of accounts) {
out.push(...(await readScopeIndex(indexDocOf(a, scope))));
}
return out;
}
/**
* The scope-INDEX document NURI of ONE account (`id`) for `scope` the
* store-container document that LISTS the account's per-entity document NURIs
* (what {@link listMyEntityDocs} reads). Exposed so a reactive reader
* ({@link watchShape}) can SUBSCRIBE to this index document and re-resolve the
* entity-doc set when the index changes (a new entity created appends a NURI
* here). Idempotent via `ensureAccount`'s cache. At migration this becomes the
* user's real per-scope store NURI (the container the store itself provides).
*/
export async function scopeIndexDoc(id: string, scope: Scope): Promise<Nuri> {
const record = await ensureAccount(id);
return indexDocOf(record, scope);
}
/**
* The entity-document NURIs of `scope` belonging to ONE account (`id`)
* the read-by-need path for one account's own entities. Bounded to a SINGLE
* account: it resolves only that account's scope index doc (via `ensureAccount`)
* and reads the contained NURIs NO cross-account fan-out, so it never touches
* another account's unsynced docs. This is the helper a consumer application uses
* for its own my-entities path, instead of the all-accounts `listEntityDocs`.
*/
export async function listMyEntityDocs(id: string, scope: Scope): Promise<Nuri[]> {
const record = await ensureAccount(id);
return readScopeIndex(indexDocOf(record, scope));
}
-187
View File
@@ -1,187 +0,0 @@
/**
* Reactive single-document subscription the polyfill's typed wrapper over the
* platform's `doc_subscribe` primitive. This is the canonical NextGraph reactive
* read at the document granularity: subscribe once, get the initial state pushed,
* then a push on every subsequent commit to that document whether the write was
* local (this session) or a broker-synced remote change. NO POLLING.
*
* Why call the REAL injected `ng` directly (never `makeNg`)
* Same hard constraint as `docs.ts`: the public `ng` is a JS `Proxy` over
* `@ng-org/web`'s iframe-RPC proxy. `doc_subscribe` is a STREAMED method the
* `@ng-org/web` RPC strips the callback (by positional index) BEFORE it posts to
* the iframe and drives it locally via a `MessageChannel` port (the function is
* never structured-cloned, so no `DataCloneError`). Layering our own Proxy on top
* risks re-wrapping that surface; reaching the real `ng` held in the config avoids
* the double-proxy exactly as the raw `docs` primitives do. Do not import from
* `./ng-proxy`.
*
* The primitive shape (verified against nextgraph-rs)
* `ng.doc_subscribe(repo_o: string, session_id, callback)`
* (`sdk/js/lib-wasm/src/lib.rs:1907`) is **per-document** one repo NURI, one
* callback. It is `async`, resolving to a JS **unsubscribe function**. The
* callback is invoked `callback(appResponse)` with a serialized `AppResponse`:
* `{ V0: { State | Patch | TabInfo | ... } }`. It pushes an initial `State`
* (plus a `TabInfo`) on subscribe, then a `Patch` per verified commit on the
* branch. Returning `true` from the callback also cancels; we cancel by calling
* the returned unsubscribe fn.
*
* Why per-document, never `orm_start_graph(graphs:[…])`
* A single not-yet-synced repo in an ORM graph fan-out makes `RepoNotFound` abort
* the WHOLE subscription (`initialize.rs:125-128`), so the readyPromise never
* resolves the ~75s hang. `doc_subscribe` is per-branch/per-doc and has no
* fan-out: an absent doc breaks only its own subscription. {@link subscribeDocs}
* builds a set of these with per-doc error isolation to preserve that property.
*/
import { getConfig, getStoreRegistryDeps } from "./polyfill";
import type { Nuri } from "./types";
/**
* A push from the platform to a document subscriber. Loosely typed: the raw
* serialized `AppResponse` (`{ V0: { State | Patch | TabInfo | ... } }`). The
* consumer typically ignores the payload and uses the push purely as a
* change SIGNAL (re-query on change the read-model pattern), so this stays
* permissive rather than modelling every AppResponse variant.
*/
export type DocChange = unknown;
/**
* The discriminant of a {@link DocChange} the single variant key of the raw
* `AppResponse` payload (`{ V0: { State | Patch | TabInfo | … } }`). It is NOT a
* closed enum: the platform may push other variants, so this is a bare `string`
* (e.g. `"State"`, `"Patch"`, `"TabInfo"`), or `undefined` when the shape can't
* be read. Verified against the CONTRACT-3 e2e probe (`e2e/sdk-entry.ts`): the
* variant is `Object.keys(resp.V0)[0]`. Exposed so a caller that needs the SYNC
* BARRIER (the first `State`, per CONTRACT 3) can distinguish it from the earlier
* `TabInfo`/`Patch` pushes see `open-repo.ts`. Most callers ignore it and use
* any push as a plain change signal.
*/
export type DocChangeType = string | undefined;
/**
* Extract the variant key from a raw {@link DocChange}. Reads `resp.V0` (case-
* tolerant to `v0`) and returns its first key the AppResponse variant name
* (`"State"` / `"Patch"` / `"TabInfo"` / ). Returns `undefined` if the payload
* is not a recognisable `{ V0: { <Variant>: … } }` object. Inspects the variant
* proplerly (no `any`-cast to force it) so a `State` push is identifiable.
*/
export function docChangeType(resp: DocChange): DocChangeType {
if (!resp || typeof resp !== "object") return undefined;
const outer = resp as { V0?: unknown; v0?: unknown };
const v0 = outer.V0 ?? outer.v0;
if (!v0 || typeof v0 !== "object") return undefined;
const keys = Object.keys(v0 as Record<string, unknown>);
return keys.length > 0 ? keys[0] : undefined;
}
/** An unsubscribe function — idempotent (calling it twice is a no-op). */
export type Unsubscribe = () => void;
async function sessionId(): Promise<string> {
return (await getStoreRegistryDeps().getSession()).sessionId;
}
/**
* Subscribe to ONE document. `onChange` fires on the initial state push and on
* every subsequent change to that doc (local write OR broker-synced remote
* change). Returns an unsubscribe function.
*
* The wrapper is synchronous-returning (an unsubscribe fn) even though the
* underlying `ng.doc_subscribe` is async: the real unsubscribe is captured when
* the promise resolves; if the caller unsubscribes before setup completes, the
* cancellation is honoured as soon as the real unsubscribe is available (and no
* further `onChange` fires after unsubscribe).
*
* `onChange` receives the raw payload AND its variant type ({@link docChangeType},
* e.g. `"State"`). The type is a NON-BREAKING second argument: existing callers
* that ignore it (the change-signal pattern `discovery.ts`, `inbox.ts`) are
* unaffected; a caller that needs the sync barrier (`open-repo.ts`) reads it to
* act only on the first `State`.
*
* Calls the REAL injected `ng.doc_subscribe` directly (never `makeNg`).
*/
export function subscribeDoc(
nuri: Nuri,
onChange: (r: DocChange, type: DocChangeType) => void,
): Unsubscribe {
const { ng } = getConfig();
let stopped = false;
let realUnsub: (() => void) | null = null;
const cb = (resp: DocChange): void => {
if (stopped) return;
try {
onChange(resp, docChangeType(resp));
} catch (error) {
console.error("[subscribe] onChange handler threw for", nuri, error);
}
};
// Kick off the async subscription. Errors are isolated to this doc (they never
// reject a shared batch — see subscribeDocs). If setup fails, this doc simply
// never fires; the caller's unsubscribe stays a safe no-op.
void (async () => {
try {
const sid = await sessionId();
const unsub = (await ng.doc_subscribe(nuri, sid, cb)) as (() => void) | undefined;
if (stopped) {
// Unsubscribed before setup resolved — cancel immediately.
if (typeof unsub === "function") unsub();
return;
}
realUnsub = typeof unsub === "function" ? unsub : null;
} catch (error) {
console.error("[subscribe] doc_subscribe failed for", nuri, error);
}
})();
return () => {
if (stopped) return;
stopped = true;
if (realUnsub) {
try {
realUnsub();
} catch (error) {
console.error("[subscribe] unsubscribe failed for", nuri, error);
}
realUnsub = null;
}
};
}
/**
* Subscribe to a SET of documents, one {@link subscribeDoc} per NURI, with
* PER-DOC error isolation. `onChange(nuri, r)` fires for whichever doc changed.
* Returns a single unsubscribe that tears down all of them.
*
* The per-doc isolation is the point: a bad / not-yet-synced doc breaks only its
* own subscription and NEVER aborts the others (this is precisely what avoids the
* ORM fan-out hang do NOT replace this with `orm_start_graph(graphs:[…])`). The
* set is deduplicated; an empty set returns a no-op unsubscribe.
*/
export function subscribeDocs(
nuris: Nuri[],
onChange: (nuri: Nuri, r: DocChange, type: DocChangeType) => void,
): Unsubscribe {
const unique = [...new Set(nuris.filter(Boolean))];
const unsubs = unique.map((nuri) => {
// Each subscription is independent: subscribeDoc already isolates its own
// async setup failure (logged, never thrown), so one bad doc cannot abort the
// construction of the others here.
try {
return subscribeDoc(nuri, (r, type) => onChange(nuri, r, type));
} catch (error) {
console.error("[subscribe] subscribeDocs: failed to subscribe", nuri, error);
return () => {};
}
});
return () => {
for (const u of unsubs) {
try {
u();
} catch (error) {
console.error("[subscribe] subscribeDocs: unsubscribe failed", error);
}
}
};
}
-26
View File
@@ -1,26 +0,0 @@
/**
* Generic, NextGraph-shaped types. ZERO application domain.
*/
/** A NextGraph URI (document / store / inbox). */
export type Nuri = string;
/** NextGraph-native store scopes. The *mapping* of entities to scopes is the
* consumer's concern; this layer only knows the three scopes exist. */
export type Scope = "public" | "protected" | "private";
/** The current identity id. Target: the wallet user (`session.user`). Polyfill:
* a chosen id, because everyone shares one wallet. */
export type PrincipalId = string;
/**
* Loose shape of the real `@ng-org/web` `ng` object that we wrap. Injected by
* the consumer at {@link configure} we never hard-import the SDK, which keeps
* the build-alias safe (the app's `@ng-org/web` import can resolve to us) and
* makes the wrapper testable with a fake. Permissive on purpose: the real `ng`
* carries non-function members too, so we accept any property bag.
*/
export type NgLike = Record<string, any>;
/** Loose shape of `@ng-org/orm`'s `useShape` (a generic hook). */
export type UseShapeLike = (...args: any[]) => any;
-17
View File
@@ -1,17 +0,0 @@
/**
* Wrapped `useShape`: same signature as `@ng-org/orm`. When a read-cap policy is
* declared, 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
* unchanged. At migration the filtering disappears the broker only delivers
* authorized documents.
*/
import { getConfig, getCurrentUser, getCaps } from "./polyfill";
import { makeReadFilteredView } from "./read-filter";
export function useShape(shapeType: unknown, scope: unknown): unknown {
const set = getConfig().useShape(shapeType, scope) as object;
const caps = getCaps();
if (!caps.hasReadPolicy()) return set; // no policy configured → passthrough
return makeReadFilteredView(set, caps, getCurrentUser);
}
-83
View File
@@ -1,83 +0,0 @@
import { test, expect } from "bun:test";
import { CapRegistry } from "../src/caps";
test("public documents are readable by anyone, even anonymous", () => {
const caps = new CapRegistry();
caps.open("did:ng:o:pub", "public", "alice");
expect(caps.canRead("did:ng:o:pub", null)).toBe(true);
expect(caps.canRead("did:ng:o:pub", "bob")).toBe(true);
});
test("protected documents: owner + explicitly granted principals only", () => {
const caps = new CapRegistry();
caps.open("did:ng:o:prot", "protected", "alice");
expect(caps.canRead("did:ng:o:prot", "alice")).toBe(true);
expect(caps.canRead("did:ng:o:prot", "bob")).toBe(false);
caps.grantRead("did:ng:o:prot", "bob"); // a directed grant issues bob the read cap
expect(caps.canRead("did:ng:o:prot", "bob")).toBe(true);
});
test("private documents: owner only", () => {
const caps = new CapRegistry();
caps.open("did:ng:o:priv", "private", "alice");
expect(caps.canRead("did:ng:o:priv", "alice")).toBe(true);
expect(caps.canRead("did:ng:o:priv", "bob")).toBe(false);
expect(caps.canRead("did:ng:o:priv", null)).toBe(false);
});
test("protectedDocsOf surfaces an owner's protected documents for directed grants", () => {
const caps = new CapRegistry();
caps.open("did:ng:o:prot1", "protected", "alice");
caps.open("did:ng:o:prot2", "protected", "alice");
caps.open("did:ng:o:pub", "public", "alice"); // not protected → excluded
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", () => {
const caps = new CapRegistry();
caps.open("did:ng:o:pub", "public", "alice");
expect(caps.canWrite("did:ng:o:pub", "alice")).toBe(true);
expect(caps.canWrite("did:ng:o:pub", "bob")).toBe(false);
expect(caps.canWrite("did:ng:o:pub", null)).toBe(false);
});
test("holding a document's cap does NOT grant another document (no inheritance)", () => {
const caps = new CapRegistry();
caps.grantRead("did:ng:o:doc1", "alice");
expect(caps.canRead("did:ng:o:doc1", "alice")).toBe(true);
expect(caps.canRead("did:ng:o:doc2", "alice")).toBe(false); // separate repo, separate cap
});
test("governsRead / hasReadPolicy distinguish governed from ungoverned documents", () => {
const caps = new CapRegistry();
expect(caps.hasReadPolicy()).toBe(false);
caps.grantRead("did:ng:o:doc1", "alice");
expect(caps.hasReadPolicy()).toBe(true);
expect(caps.governsRead("did:ng:o:doc1")).toBe(true);
expect(caps.governsRead("did:ng:o:unknown")).toBe(false); // not declared → not enforced
});
test("governsWrite / hasWritePolicy distinguish governed from ungoverned documents", () => {
const caps = new CapRegistry();
expect(caps.hasWritePolicy()).toBe(false);
caps.open("did:ng:o:doc1", "private", "alice"); // owner gets the write cap
expect(caps.hasWritePolicy()).toBe(true);
expect(caps.governsWrite("did:ng:o:doc1")).toBe(true);
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.
const pub = new CapRegistry();
pub.open("did:ng:o:pub", "public", "alice");
expect(pub.hasWritePolicy()).toBe(true);
expect(pub.governsWrite("did:ng:o:pub")).toBe(true);
});
-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);
});
@@ -1,128 +0,0 @@
/**
* ReadCap ACTIVE end-to-end proof that the emulated SDK enforces per-DOCUMENT
* isolation, driven by per-entity documents + DIRECTED read grants.
*
* Mirrors what the app does: create an entity document through the REAL registry
* (`createEntityDoc`), declare its cap policy via `getCaps().open(doc, scope,
* owner)`, set the current identity, and — when the app decides two identities
* are related issue a DIRECTED read grant on each of the owner's protected
* documents (`getCaps().grantRead(doc, granteeId)`). Whether identities are
* "connected" is the application's own concept: this test plays that role
* directly. The read filter then discriminates:
* (a) an ungranted principal is denied a PROTECTED doc; granted once the owner
* issues a directed grant; PUBLIC readable throughout via the ACTIVE
* ReadCap.
* (b) no grant no protected read (a reader cannot grant itself).
*/
import { test, expect, mock, afterAll } from "bun:test";
import { createEntityDoc, resetRegistryCache } from "../src/store-registry";
import type { RegistrySession } from "../src/store-registry";
import {
configure,
configureStoreRegistry,
resetStoreRegistry,
resetConfig,
getCaps,
resetCaps,
setCurrentUser,
} from "../src/polyfill";
import { filterReadable } from "../src/read-filter";
afterAll(() => {
resetConfig();
resetStoreRegistry();
resetCaps();
setCurrentUser(null);
});
const SESSION: RegistrySession = { sessionId: "sid", privateStoreId: "PRIV" };
function inject() {
let n = 0;
const ng = {
doc_create: mock(async () => `did:ng:o:doc${++n}`),
sparql_update: mock(async () => undefined),
sparql_query: mock(async () => ({ results: { bindings: [] } })),
};
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: (id) => id.trim() });
resetRegistryCache();
resetCaps();
setCurrentUser(null);
return ng;
}
/** The app's relationship concept, played inline: grant `reader` the read cap of
* every protected document owned by `owner`. */
function grantOwnerProtectedTo(owner: string, reader: string) {
for (const doc of getCaps().protectedDocsOf(owner)) getCaps().grantRead(doc, reader);
}
test("ReadCap active: a private entity doc created via the real registry is hidden from another principal", async () => {
inject();
const aliceDoc = await createEntityDoc("alice", "private");
getCaps().open(aliceDoc, "private", "alice");
const bobDoc = await createEntityDoc("bob", "public");
getCaps().open(bobDoc, "public", "bob");
const items = [
{ "@graph": aliceDoc, "@id": "a1", label: "alice-private" },
{ "@graph": bobDoc, "@id": "b1", label: "bob-public" },
];
expect(filterReadable(items, getCaps(), "bob").map((i) => i["@id"])).toEqual(["b1"]);
expect(filterReadable(items, getCaps(), "alice").map((i) => i["@id"]).sort()).toEqual(["a1", "b1"]);
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
// readable regardless — all through the ACTIVE ReadCap.
test("(a) PROTECTED doc: hidden ungranted, revealed after a DIRECTED grant, PUBLIC always readable", async () => {
inject();
const aliceProtected = await createEntityDoc("alice", "protected");
getCaps().open(aliceProtected, "protected", "alice");
const alicePublic = await createEntityDoc("alice", "public");
getCaps().open(alicePublic, "public", "alice");
const items = [
{ "@graph": aliceProtected, "@id": "p1" },
{ "@graph": alicePublic, "@id": "u1" },
];
const view = (u: string) => filterReadable(items, getCaps(), u).map((i) => i["@id"]).sort();
// BEFORE any grant: bob sees only the public item.
expect(view("bob")).toEqual(["u1"]);
expect(view("alice")).toEqual(["p1", "u1"]);
// The app decides alice↔bob are related and grants bob the read cap of alice's
// protected documents.
grantOwnerProtectedTo("alice", "bob");
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
// reader cannot grant itself.
test("(b) no directed grant → no protected read", async () => {
inject();
const aliceProtected = await createEntityDoc("alice", "protected");
getCaps().open(aliceProtected, "protected", "alice");
const items = [{ "@graph": aliceProtected, "@id": "p1" }];
const view = (u: string) => filterReadable(items, getCaps(), u).map((i) => i["@id"]);
// mallory holds no grant on alice's protected doc → denied.
expect(view("mallory")).toEqual([]);
// Granting bob (a different, legitimate reader) leaves mallory denied.
grantOwnerProtectedTo("alice", "bob");
expect(view("mallory")).toEqual([]);
expect(view("bob")).toEqual(["p1"]);
});
-59
View File
@@ -1,59 +0,0 @@
import { test, expect } from "bun:test";
import { filterReadable, makeReadFilteredView } from "../src/read-filter";
import { CapRegistry } from "../src/caps";
// 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.
interface Item { id: string; "@graph"?: string }
const PRIV: Item = { id: "a", "@graph": "did:ng:o:alice" }; // alice's doc
const PUB: Item = { id: "p", "@graph": "did:ng:o:public" }; // public doc
const UNGOV: Item = { id: "n", "@graph": "did:ng:o:other" }; // doc under no policy
const NOGRAPH: Item = { id: "x" }; // no document → kept
function caps(): CapRegistry {
const c = new CapRegistry();
c.grantRead("did:ng:o:alice", "alice");
c.makePublic("did:ng:o:public");
return c;
}
test("filterReadable keeps public, cap-held, ungoverned and graphless items", () => {
const items = [PRIV, PUB, UNGOV, NOGRAPH];
expect(filterReadable(items, caps(), "alice").map(i => (i as Item).id)).toEqual(["a", "p", "n", "x"]);
expect(filterReadable(items, caps(), "bob").map(i => (i as Item).id)).toEqual(["p", "n", "x"]);
expect(filterReadable(items, caps(), null).map(i => (i as Item).id)).toEqual(["p", "n", "x"]);
});
test("makeReadFilteredView filters iteration/size, reflects the current user", () => {
const set = new Set<Item>([PRIV, PUB, UNGOV, NOGRAPH]);
let user: string | null = "bob";
const view = makeReadFilteredView(set, caps(), () => user);
expect([...view].map(i => i.id)).toEqual(["p", "n", "x"]);
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", () => {
const set = new Set<Item>([PUB]);
const view = makeReadFilteredView(set, caps(), () => "bob");
const C: Item = { id: "c", "@graph": "did:ng:o:public" };
view.add(C);
expect(set.has(C)).toBe(true); // mutation reached the real set
expect([...view].map(i => i.id)).toEqual(["p", "c"]);
view.delete(C);
expect(set.has(C)).toBe(false);
});
test("forEach is filtered too", () => {
const set = new Set<Item>([PRIV, PUB]);
const seen: string[] = [];
makeReadFilteredView(set, caps(), () => "bob").forEach((i) => seen.push((i as Item).id));
expect(seen).toEqual(["p"]);
});
-105
View File
@@ -1,105 +0,0 @@
import { test, expect, mock } from "bun:test";
import { readUnion } from "../src/read-model";
import { configure, configureStoreRegistry } from "../src/polyfill";
// 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
// NO anchorless union scan: each doc is read independently by its own anchor. Each
// entity subject IRI IS its own document NURI (writeEntity convention), so the
// fixture keys triples by the doc NURI and returns them for the matching anchor.
function fakeNgWith(triplesByDoc: Record<string, Array<[string, string]>>) {
return {
doc_create: mock(async () => "did:ng:o:new"),
sparql_update: mock(async () => undefined),
sparql_query: mock(async (_sid: string, _query: string, _base: unknown, anchor: unknown) => {
// Every read is ANCHORED to one doc NURI — never anchorless.
if (anchor === undefined) {
throw new Error("read-model must NEVER run an anchorless (union) query");
}
const doc = anchor as string;
const triples = triplesByDoc[doc];
if (!triples) return { results: { bindings: [] } };
const bindings = triples.map(([p, o]) => ({
s: { value: doc },
p: { value: p },
o: { value: o },
}));
return { results: { bindings } };
}),
};
}
function inject(triplesByDoc: Record<string, Array<[string, string]>>) {
const ng = fakeNgWith(triplesByDoc);
configure({ ng: ng as any, useShape: (() => {}) as any });
configureStoreRegistry({
getSession: async () => ({ sessionId: "sid-rm", privateStoreId: "priv" }),
normalizeId: (u: string) => u,
});
return ng;
}
const TYPE = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type";
const FP = "http://festipod.org/";
test("readUnion reads each doc with its OWN anchored query (never anchorless)", async () => {
const ng = inject({
"did:ng:o:a": [[TYPE, `${FP}Event`], [`${FP}title`, "A"]],
"did:ng:o:b": [[TYPE, `${FP}Event`], [`${FP}title`, "B"]],
});
const subjects = await readUnion(["did:ng:o:a", "did:ng:o:b"]);
// One anchored query per doc = 2 sparql_query calls, each anchored (c[3] set).
expect(ng.sparql_query).toHaveBeenCalledTimes(2);
const anchored = ng.sparql_query.mock.calls.filter((c: unknown[]) => c[3] !== undefined);
expect(anchored.length).toBe(2);
// The anchors are exactly the requested doc NURIs.
expect(new Set(anchored.map((c: unknown[]) => c[3]))).toEqual(
new Set(["did:ng:o:a", "did:ng:o:b"]),
);
expect(subjects.length).toBe(2);
const a = subjects.find((s) => s.subject === "did:ng:o:a")!;
expect(a.props[`${FP}title`]).toEqual(["A"]);
expect(a.graph).toBe("did:ng:o:a");
});
test("readUnion groups predicates per subject", async () => {
inject({
"did:ng:o:p": [
[TYPE, `${FP}Participation`],
[`${FP}event`, "did:ng:o:e"],
[`${FP}user`, "urn:festipod:user:x"],
],
});
const s = (await readUnion(["did:ng:o:p"]))[0]!;
expect(s.subject).toBe("did:ng:o:p");
expect(s.props[`${FP}event`]).toEqual(["did:ng:o:e"]);
expect(s.props[`${FP}user`]).toEqual(["urn:festipod:user:x"]);
});
test("readUnion returns [] for an empty doc set (no query)", async () => {
const ng = inject({});
const subjects = await readUnion([]);
expect(subjects).toEqual([]);
expect(ng.sparql_query).toHaveBeenCalledTimes(0);
});
test("a doc that fails to read is skipped, not aborting the batch", async () => {
const ng = fakeNgWith({ "did:ng:o:ok": [[TYPE, `${FP}Event`], [`${FP}title`, "ok"]] });
const orig = ng.sparql_query;
// Make the anchored read throw for the bad doc only.
ng.sparql_query = mock(async (sid: string, query: string, base: unknown, anchor: unknown) => {
if (anchor === "did:ng:o:bad") throw new Error("RepoNotFound");
return orig(sid, query, base, anchor);
}) as any;
configure({ ng: ng as any, useShape: (() => {}) as any });
configureStoreRegistry({
getSession: async () => ({ sessionId: "sid-rm", privateStoreId: "priv" }),
normalizeId: (u: string) => u,
});
const subjects = await readUnion(["did:ng:o:ok", "did:ng:o:bad"]);
// The bad doc failed its read but the good one still lists.
expect(subjects.map((s) => s.subject)).toEqual(["did:ng:o:ok"]);
});
-148
View File
@@ -1,148 +0,0 @@
import { test, expect, mock, afterAll } from "bun:test";
import { subscribeDoc, subscribeDocs } from "../src/subscribe";
import {
configure,
configureStoreRegistry,
resetConfig,
resetStoreRegistry,
} from "../src/polyfill";
import type { RegistrySession } from "../src/store-registry";
// subscribeDoc/subscribeDocs wrap the REAL injected `ng.doc_subscribe`. This
// suite injects a fake `ng` whose `doc_subscribe` records the callback per doc
// and hands back an unsubscribe, so we can assert routing + isolation without a
// broker. Restore the un-configured state at the end.
afterAll(() => {
resetConfig();
resetStoreRegistry();
});
const SESSION: RegistrySession = { sessionId: "sid-1", privateStoreId: "PRIV" };
/**
* A fake reactive `ng`: `doc_subscribe(nuri, sid, cb)` registers `cb` for `nuri`,
* fires it once (initial State push), and returns an unsubscribe. `push(nuri)`
* drives a later change to that doc's subscribers. A per-doc `failFor` set makes
* `doc_subscribe` reject for chosen NURIs (a not-yet-synced doc).
*/
function makeFakeNg(failFor: Set<string> = new Set()) {
const subs = new Map<string, Set<(r: unknown) => void>>();
const doc_subscribe = mock(async (nuri: string, _sid: unknown, cb: (r: unknown) => void) => {
if (failFor.has(nuri)) throw new Error(`RepoNotFound: ${nuri}`);
let set = subs.get(nuri);
if (!set) {
set = new Set();
subs.set(nuri, set);
}
set.add(cb);
// Initial State push, delivered async (as the real RPC does).
queueMicrotask(() => cb({ V0: { State: { doc: nuri } } }));
return () => set!.delete(cb);
});
const push = (nuri: string): void => {
for (const cb of subs.get(nuri) ?? []) cb({ V0: { Patch: { doc: nuri } } });
};
const isSubscribed = (nuri: string): boolean => (subs.get(nuri)?.size ?? 0) > 0;
return { doc_subscribe, push, isSubscribed, _subs: subs };
}
function inject(failFor?: Set<string>) {
const ng = makeFakeNg(failFor);
configure({ ng: ng as any, useShape: (() => {}) as any });
// Synchronous fake store → no sync lag; disable the anti-fork retry backoff.
configureStoreRegistry({ getSession: async () => SESSION });
return ng;
}
const A = "did:ng:o:docA";
const B = "did:ng:o:docB";
const tick = () => new Promise((r) => setTimeout(r, 5));
test("subscribeDoc calls ng.doc_subscribe with (nuri, sessionId, callback)", async () => {
const ng = inject();
const onChange = mock(() => {});
subscribeDoc(A, onChange);
await tick();
expect(ng.doc_subscribe).toHaveBeenCalledTimes(1);
const call = ng.doc_subscribe.mock.calls[0]!;
expect(call[0]).toBe(A);
expect(call[1]).toBe("sid-1"); // sessionId from the injected session
expect(typeof call[2]).toBe("function"); // the callback
});
test("subscribeDoc routes the initial State push and every later change", async () => {
const ng = inject();
const seen: unknown[] = [];
subscribeDoc(A, (r) => seen.push(r));
await tick();
expect(seen).toHaveLength(1); // initial State push
ng.push(A);
ng.push(A);
expect(seen).toHaveLength(3); // + 2 patches
});
test("subscribeDoc unsubscribe stops further callbacks", async () => {
const ng = inject();
const seen: unknown[] = [];
const stop = subscribeDoc(A, (r) => seen.push(r));
await tick();
expect(seen).toHaveLength(1);
stop();
expect(ng.isSubscribed(A)).toBe(false); // real unsubscribe was invoked
ng.push(A); // ignored — no subscriber
expect(seen).toHaveLength(1);
});
test("subscribeDoc unsubscribe BEFORE async setup resolves cancels cleanly", async () => {
const ng = inject();
const seen: unknown[] = [];
const stop = subscribeDoc(A, (r) => seen.push(r));
stop(); // before the microtask/promise setup resolved
await tick();
// The subscription was cancelled the moment setup resolved: no callbacks, and
// no lingering subscriber.
expect(seen).toHaveLength(0);
expect(ng.isSubscribed(A)).toBe(false);
});
test("subscribeDocs fans out one subscription per doc and reports the source nuri", async () => {
const ng = inject();
const seen: Array<[string, unknown]> = [];
subscribeDocs([A, B], (nuri, r) => seen.push([nuri, r]));
await tick();
// Two initial pushes, one per doc.
expect(seen.map((s) => s[0]).sort()).toEqual([A, B]);
ng.push(B);
expect(seen.filter((s) => s[0] === B)).toHaveLength(2); // initial + patch
expect(seen.filter((s) => s[0] === A)).toHaveLength(1); // isolated: A didn't fire
});
test("subscribeDocs isolates a failing doc — the others still fire", async () => {
const ng = inject(new Set([A])); // A's subscription throws (RepoNotFound)
const seen: Array<[string, unknown]> = [];
subscribeDocs([A, B], (nuri, r) => seen.push([nuri, r]));
await tick();
// A failed to subscribe (logged, not thrown); B is unaffected and fired.
expect(seen.map((s) => s[0])).toEqual([B]);
ng.push(B);
expect(seen.filter((s) => s[0] === B)).toHaveLength(2);
});
test("subscribeDocs unsubscribe tears down all subscriptions", async () => {
const ng = inject();
const stop = subscribeDocs([A, B], () => {});
await tick();
expect(ng.isSubscribed(A)).toBe(true);
expect(ng.isSubscribed(B)).toBe(true);
stop();
expect(ng.isSubscribed(A)).toBe(false);
expect(ng.isSubscribed(B)).toBe(false);
});
test("subscribeDocs deduplicates repeated NURIs", async () => {
const ng = inject();
subscribeDocs([A, A, A], () => {});
await tick();
expect(ng.doc_subscribe).toHaveBeenCalledTimes(1);
});
+106
View File
@@ -0,0 +1,106 @@
#!/usr/bin/env bun
/**
* Mint a NextGraph wallet and write it as a `.ngw` the one-off a person runs to provision a
* deployment.
*
* Why this exists next to the library function
* An application that hands a wallet out serves a `.ngw` at the URL it passes to
* `configure({ sharedWallet: { fileUrl, password } })`, and nothing produces that file: minting
* one means driving the wallet application in a browser, which is exactly what
* `mintWalletBytes` already does for the suites. So this is not a second implementation it is
* that call, a `writeFileSync`, and the two lines a human needs to fill the configuration in.
*
* What it does NOT do
* It does not invent a password. The password is what opens the wallet for everyone the
* deployment lets in; one chosen here would be a secret the tool knows and the operator does
* not, printed to a terminal and never chosen by anybody. It is a required argument.
*
* It also refuses to overwrite an existing file unless told to. A `.ngw` is an identity, and
* the identities it holds exist nowhere else a clobbered one is not recoverable from the
* broker or anywhere else.
*
* Usage:
* bun run packages/ng-e2e-helpers/bin/mint-wallet.ts --password <password> [--out <path.ngw>] [--name <wallet name>] [--force]
*/
import * as fs from "node:fs";
import * as path from "node:path";
import { DEFAULT_WALLET_NAME, mintWalletBytes } from "../src/wallet";
const USAGE =
"usage: mint-wallet --password <password> [--out <path.ngw>] [--name <wallet name>] [--force]";
interface Options {
readonly password: string;
readonly name: string;
readonly out: string;
readonly force: boolean;
}
/** `--k v` and `--k=v` both, because a person types whichever one they learnt first. */
function parseArguments(argv: readonly string[]): Options {
const values = new Map<string, string>();
let force = false;
for (let i = 0; i < argv.length; i++) {
const arg = argv[i]!;
if (arg === "--force") {
force = true;
continue;
}
if (!arg.startsWith("--")) throw new Error(`unexpected argument ${arg}\n${USAGE}`);
const equals = arg.indexOf("=");
const key = equals === -1 ? arg.slice(2) : arg.slice(2, equals);
let value: string | undefined;
if (equals !== -1) {
value = arg.slice(equals + 1);
} else {
value = argv[++i];
}
if (value === undefined) throw new Error(`--${key} needs a value\n${USAGE}`);
if (!["password", "out", "name"].includes(key)) {
throw new Error(`unknown option --${key}\n${USAGE}`);
}
values.set(key, value);
}
const password = values.get("password");
if (password === undefined || password === "") {
throw new Error(`--password is required — this tool does not invent one\n${USAGE}`);
}
const name = values.get("name") ?? DEFAULT_WALLET_NAME;
// Relative to where the person is standing, which is the only path they can predict. Any
// default landing inside a checkout is covered by the repository-wide `*.ngw` ignore.
const out = path.resolve(process.cwd(), values.get("out") ?? `${name}.ngw`);
return { password, name, out, force };
}
async function main(): Promise<void> {
const options = parseArguments(process.argv.slice(2));
if (!options.force && fs.existsSync(options.out)) {
throw new Error(
`${options.out} already exists — a .ngw is an identity, so this refuses to overwrite one.\n` +
"Pass --force if that file is genuinely disposable.",
);
}
console.log(`[mint-wallet] minting the wallet "${options.name}" (this drives a real browser)...`);
const bytes = await mintWalletBytes(options.password, options.name);
fs.mkdirSync(path.dirname(options.out), { recursive: true });
fs.writeFileSync(options.out, bytes);
console.log("");
console.log(`[mint-wallet] wrote ${bytes.length} bytes`);
console.log(` file: ${options.out}`);
console.log(` password: ${options.password}`);
console.log("");
console.log("Serve that file, and give the application its URL and this password:");
console.log(" configure({ sharedWallet: { fileUrl: <where you serve it>, password: <above> } })");
}
main().then(
() => process.exit(0),
(e: unknown) => {
console.error(`[mint-wallet] ${e instanceof Error ? e.message : String(e)}`);
process.exit(1);
},
);
+26
View File
@@ -0,0 +1,26 @@
{
"name": "ng-e2e-helpers",
"version": "1.0.0-dev.2",
"type": "module",
"description": "End-to-end testing machinery for a NextGraph application: mint and carry a wallet, cross the broker, per-run browser profiles, bounded waits that name what they were waiting for, and a run report whose size does not depend on what failed.",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts"
},
"bin": {
"ng-mint-wallet": "./bin/mint-wallet.ts"
},
"peerDependencies": {
"playwright": ">=1.40.0",
"@ng-org/web": ">=0.1.2-alpha.13"
},
"devDependencies": {
"@ng-org/web": "0.1.2-alpha.13",
"playwright": "^1.61.1"
},
"scripts": {
"typecheck": "bunx tsc --noEmit -p tsconfig.json",
"mint-wallet": "bun run bin/mint-wallet.ts"
}
}
+423
View File
@@ -0,0 +1,423 @@
/**
* The broker crossing `nextgraph.net/redir` the broker's auth page the wallet list
* (sometimes) the password nested iframes the application's own frame.
*
* The defect this replaces, and why it looked like the network
* An earlier version decided where it was in the flow by TIMING: a 2-second probe for the
* "Login" button, a 500 ms poll loop, a 1-second settle, an 8-second window for the password
* prompt, a 3-second hope after submitting. Under it sat a predicate that was simply wrong
* an "application frame" was any frame whose URL CONTAINED `127.0.0.1`.
*
* VERIFIED 2026-08-14 by driving the real pages: the broker's own auth page carries the
* application's address in its query string, so its MAIN frame's URL is
* `https://nextgraph.eu/auth/#/?o=http%3A%2F%2F127.0.0.1%3A39975` which contains
* `127.0.0.1`. The predicate therefore matched the AUTH PAGE ITSELF, from the first instant,
* before any login had happened. Everything downstream then followed: the poll loop exited at
* once, the wallet click and the password were SKIPPED as "already logged in", and the
* function returned `page.mainFrame()` the broker's login screen — as the application's
* frame. The caller then waited its full minute for an element that page does not have, and
* reported a timeout naming nothing.
*
* The only thing that ever saved a run was the very first branch: clicking "Login" moves the
* URL to `#/wallet/login`, which carries no `o=` parameter and so no `127.0.0.1` after which
* the broken predicate happens to behave. That click was guarded by a 2-second visibility
* probe, and the button paints at 1.01.6 s (VERIFIED, three consecutive sign-ins). A 2-second
* bound on a 1.01.6-second event is a coin toss, and which side it lands on is decided by how
* loaded the machine is which is exactly why this read as "the broker" or "the host
* network", and why it hit the SECOND actor most: it signs in while the first one's tab is
* busy with its own broker traffic.
*
* So nothing here waits for a DURATION. It waits for whichever screen appears, dispatches on
* it, and stops when a frame is on the application's ORIGIN — an origin the broker's pages can
* never be on, whatever they carry in their query string.
*
* The screens themselves, their signatures and their answers are DESCRIPTION and live in
* `nextgraph-ui.ts`; this file is the driving.
*/
import type { Frame, Locator, Page } from "playwright";
import { CONTEXT_NAVIGATION_MS, within } from "./deadline";
import { browserTrouble } from "./known-failures";
import {
BROKER_SCREENS,
brokerRedirectFor,
type BrokerScreen,
type BrokerScreenSpec,
} from "./nextgraph-ui";
/**
* One bound for the whole ceremony the screens, the clicks, and the application's frame
* attaching. Measured 1.32.8 s for an actor and 1.7 s on a cold profile's barrier passage
* (2026-08-16, `E2E_TIMINGS=1`). Bounded at 45 s 16x the slowest measured: enough that a
* busy host does not manufacture a false diagnosis, little enough that the rich failure below
* the screen, the trail, the frames, the page's own text arrives in under a minute instead
* of after two.
*
* Exported for the same reason as `NEW_PAGE_MS`: this function's failure message is the most
* informative one in the harness, and an enclosing bound set below it would replace that
* message with "the round-trip timed out" and lose every fact in it.
*/
export const BROKER_LOGIN_MS = 45_000;
/**
* What {@link setupBrokerPage} costs at worst: its navigation plus the ceremony. A caller that
* wants to MEASURE the round-trip should hand this to `measured` rather than invent a bound of
* its own an enclosure below this number fires before the ceremony can explain itself.
*/
export const BROKER_ROUND_TRIP_MS = CONTEXT_NAVIGATION_MS + BROKER_LOGIN_MS;
/** How often the browser re-reads the screen. Not a sleep: it is the interval of a condition
* check that runs INSIDE the page, the same mechanism `isVisible` uses. */
const SCREEN_POLL_MS = 200;
/** A screen answered this many times without the flow moving on is a livelock, not a slow
* page say so instead of clicking for ever. */
const SCREEN_REVISITS_ALLOWED = 3;
/** A click or a fill that has not landed in 15 s is not going to; the ceremony's own bound is
* eight times that, so failing here leaves room to say so rather than to hang. */
const CLICK_MS = 15_000;
/** Reading the text of a page for a failure message is a round-trip like any other, and
* `evaluate` carries no bound of its own a diagnosis must not become the new hang. */
const TEXT_MS = 15_000;
/**
* Navigate through the broker's redirect to load `appUrl` in the broker iframe, unlock the
* wallet if a login is shown, and return the application's frame.
*/
export async function setupBrokerPage(page: Page, appUrl: string, walletPassword: string): Promise<Frame> {
await page.goto(brokerRedirectFor(appUrl), { waitUntil: "domcontentloaded" });
return completeBrokerLogin(page, appUrl, walletPassword);
}
/**
* Which screen the page is on evaluated INSIDE the page, returning `false` while it is still
* the one the caller already knows about, so it doubles as the change detector.
*
* Self-contained on purpose: Playwright ships this function's source into the browser, so it
* may close over nothing at all. The screen inventory is therefore an ARGUMENT, not an import
* which is also what lets the description live in one file and the driving in another. It is
* passed both to `waitForFunction` (wait for a DIFFERENT screen) and to `evaluate` (read the
* current one): one definition, so the name in a failure message is always the name the
* machine dispatched on.
*/
function readBrokerScreen(input: {
previous: string | null;
screens: readonly BrokerScreenSpec[];
}): string | false {
const shown = (el: Element | null): boolean => {
if (el === null) return false;
const box = el.getBoundingClientRect();
return box.width > 0 && box.height > 0;
};
let kind: string | null = null;
for (const spec of input.screens) {
const signature = spec.signature;
if (signature.kind === "rendered") {
if (signature.selectors.some((selector) => shown(document.querySelector(selector)))) {
kind = spec.screen;
}
} else if (signature.kind === "rendered-control") {
const pattern = new RegExp(signature.matches.source, signature.matches.flags);
const control = Array.from(document.querySelectorAll("button, a")).find((el) =>
pattern.test((el.textContent ?? "").trim()),
);
if (shown(control ?? null)) kind = spec.screen;
} else if (signature.kind === "page-text") {
// `innerText`, not `textContent`, and only here: it is the one test that has to read
// prose rather than a selector, and only what is RENDERED counts — the hidden login UI
// would otherwise answer for a page that is not showing it.
const pattern = new RegExp(signature.matches.source, signature.matches.flags);
const visible = document.body === null ? "" : document.body.innerText;
if (pattern.test(visible)) kind = spec.screen;
} else {
kind = spec.screen;
}
if (kind !== null) break;
}
if (kind === null) return false;
return kind === input.previous ? false : kind;
}
/**
* The application's frame: a SUB-frame whose URL is on the application's own origin.
*
* Both halves of that sentence are load-bearing
* `startsWith(origin)` rather than `includes(host)` is what stops the broker's own pages from
* answering. THE BROKER'S AUTH PAGE CARRIES THE APPLICATION'S ADDRESS IN ITS QUERY STRING, so
* a substring match returns the LOGIN PAGE and the whole crossing then fails silently: the
* wallet click and the password are skipped as "already logged in", and the caller is handed a
* frame that will never render the application. That bug cost days, and it was readable in one
* line the whole time. Never match a host, a port or a path here match the origin, from the
* start of the URL.
*
* Excluding the MAIN frame is the second half, and it is not redundant: a journey that lets the
* application hand the page over ITSELF calls this while the page is still ON the application,
* top-level, and a main-frame match there would hand back a frame that is about to navigate
* away.
*
* Event-driven rather than polled `frameattached`/`framenavigated` is exactly the signal, so
* there is nothing to sleep between.
*/
interface AppFrameWatcher {
/** The frame now, or null. */
found(): Frame | null;
/** Resolves once one appears. The same promise every time, so racing it costs no listener. */
whenFound(): Promise<Frame>;
stop(): void;
}
function watchForAppFrame(page: Page, appOrigin: string): AppFrameWatcher {
const pick = (): Frame | null => {
for (const f of page.frames()) {
if (f === page.mainFrame()) continue;
if (f.url().startsWith(appOrigin)) return f;
}
return null;
};
let settle: ((f: Frame) => void) | null = null;
const appeared = new Promise<Frame>((resolve) => {
settle = resolve;
});
const check = (): void => {
const f = pick();
if (f !== null && settle !== null) {
settle(f);
settle = null;
}
};
page.on("frameattached", check);
page.on("framenavigated", check);
check();
return {
found: pick,
whenFound: () => appeared,
stop: () => {
page.off("frameattached", check);
page.off("framenavigated", check);
},
};
}
type BrokerEvent =
| { kind: "frame"; frame: Frame }
| { kind: "screen"; screen: BrokerScreen }
/** Nothing happened before the bound. `because` is set when the watch itself failed (a
* closed browser, say) rather than simply running out of time reporting the two the same
* way is how "the screen never changed" gets blamed for a dead browser. */
| { kind: "stalled"; because: string | null };
/**
* Whichever comes first: a screen that is not `previous`, or the application's frame.
*
* The race is not an optimisation. The successful end of this flow leaves the page on a screen
* that never changes again ("Wallet opened for …", which reads as `working`), so a wait for a
* screen CHANGE alone would sit there until its deadline with the frame it wanted already
* attached.
*/
async function nextBrokerEvent(
page: Page,
watcher: AppFrameWatcher,
previous: BrokerScreen | null,
ms: number,
): Promise<BrokerEvent> {
const onScreen = page
.waitForFunction(readBrokerScreen, { previous, screens: BROKER_SCREENS }, { timeout: ms, polling: SCREEN_POLL_MS })
.then(async (handle): Promise<BrokerEvent> => {
const value = await handle.jsonValue();
return typeof value === "string"
? { kind: "screen", screen: value as BrokerScreen }
: { kind: "stalled", because: null };
});
// Absorbed, so the loser of the race cannot surface as an unhandled rejection minutes after
// the winner has already been acted on — the same hazard `deadline.ts` documents. A plain
// expiry is NOT an error worth quoting; anything else is, and is quoted.
const settled = onScreen.catch((e: unknown): BrokerEvent => {
const message = String((e as Error)?.message ?? e).split("\n")[0] ?? "";
const expired = (e as Error)?.name === "TimeoutError" || /Timeout .* exceeded/i.test(message);
return { kind: "stalled", because: expired ? null : message };
});
const onFrame = watcher.whenFound().then((frame): BrokerEvent => ({ kind: "frame", frame }));
return Promise.race([settled, onFrame]);
}
/**
* Answer a screen, as its description says to. Returns what it did, for the failure message,
* or null if there was nothing to do but wait.
*
* A click that cannot land is REPORTED, not thrown: the loop sees the screen again and the
* revisit cap turns a stuck click into a named failure which says far more than the click's
* own timeout would.
*/
async function answerBrokerScreen(
page: Page,
spec: BrokerScreenSpec,
walletPassword: string,
): Promise<string | null> {
const click = async (what: string, locator: Locator): Promise<string> => {
try {
await locator.first().click({ timeout: CLICK_MS });
return `clicked ${what}`;
} catch (e) {
return `could NOT click ${what}: ${String((e as Error)?.message ?? e).split("\n")[0]}`;
}
};
const answer = spec.answer;
switch (answer.kind) {
case "click":
return click(answer.what, page.locator(answer.selector));
case "click-text":
return click(answer.what, page.getByText(answer.text, { exact: true }));
case "submit-password": {
const field = page.locator(answer.selector).first();
try {
await field.fill(walletPassword, { timeout: CLICK_MS });
await field.press("Enter", { timeout: CLICK_MS });
return `filled ${answer.what} and submitted it`;
} catch (e) {
return `could NOT submit ${answer.what}: ${String((e as Error)?.message ?? e).split("\n")[0]}`;
}
}
case "wait":
return null;
}
}
/**
* Why the crossing did not get where it was going with enough on it to skip the guessing.
*
* An earlier version said "SDK iframe not found after 30s" plus a list of frame URLs, and a
* whole day went into attributing that to the network. So this names the screen the machine
* last recognised, the screens it walked through and what it did on each, the origin it was
* waiting for, every frame, and the text the page was actually showing which is the one
* thing that distinguishes a broker error page from a page that is simply still working.
*
* And, when it applies, the browser's own condition FIRST: a crossing that failed because the
* browser stopped answering must not be reported as a broker problem.
*/
async function brokerLoginFailure(
page: Page,
appOrigin: string,
screen: BrokerScreen | null,
trail: string[],
startedAt: number,
why: string,
): Promise<Error> {
const elapsed = ((Date.now() - startedAt) / 1000).toFixed(1);
const trouble = await browserTrouble("crossing", page.context());
let shown: string;
try {
const text = await within("the failed broker page's own text", TEXT_MS, () =>
page.evaluate(() => (document.body === null ? "" : document.body.innerText)),
);
const compact = text.replace(/[ \t]+/g, " ").replace(/\n{2,}/g, "\n").trim();
shown = compact === "" ? "(the page showed nothing at all)" : compact.slice(0, 800);
} catch (e) {
shown = `(could not be read: ${String((e as Error)?.message ?? e).split("\n")[0]})`;
}
const frames = page
.frames()
.map((f) => ` ${f === page.mainFrame() ? "top" : "sub"} ${f.url() === "" ? "(blank)" : f.url()}`);
return new Error(
`[e2e broker login] gave up after ${elapsed}s — ${why}.\n` +
(trouble === null ? "" : ` BUT FIRST: ${trouble}\n`) +
` last screen it recognised: ${screen ?? "(none)"}\n` +
` it was waiting for: a sub-frame whose URL starts with ${appOrigin}\n` +
` how it got here:\n${trail.length === 0 ? " (nothing happened)" : trail.map((s) => ` ${s}`).join("\n")}\n` +
` frames on the page (${frames.length}):\n${frames.join("\n")}\n` +
` what the page was showing:\n${shown
.split("\n")
.map((l) => ` | ${l}`)
.join("\n")}`,
);
}
/**
* The half of {@link setupBrokerPage} that does NOT navigate: walk the crossing from whatever
* screen the page is on, and return the application's frame.
*
* Split out because there are two ways to arrive at the broker, and only one of them is the
* suite's. A test signs the round-trip off itself (`setupBrokerPage`); an APPLICATION hands the
* page over on its own, once the identity is settled and a journey that walks a first-time
* user through an access barrier has to LET it, because that hand-over IS what it is checking.
* Calling `setupBrokerPage` there would re-navigate and throw away the URL the application had
* just built, its query parameters included i.e. it would quietly substitute the suite's path
* for the one under test.
*
* A password may simply never be asked for
* The second actor is a BRANCH, not a fallthrough: the wallet is broadcast between the broker
* origin's tabs, so a later actor's selection logs straight in. The machine below neither
* expects nor requires the password screen it answers what is on screen. An earlier version
* treated the password as the normal case and gave the no-password path an 8-second window to
* prove itself innocent.
*/
export async function completeBrokerLogin(page: Page, appUrl: string, walletPassword: string): Promise<Frame> {
const appOrigin = new URL(appUrl).origin;
const startedAt = Date.now();
const deadline = startedAt + BROKER_LOGIN_MS;
const trail: string[] = [];
const actedOn = new Map<BrokerScreen, number>();
let screen: BrokerScreen | null = null;
const watcher = watchForAppFrame(page, appOrigin);
try {
for (;;) {
const already = watcher.found();
if (already !== null) return already;
const left = deadline - Date.now();
if (left <= 0) {
throw await brokerLoginFailure(page, appOrigin, screen, trail, startedAt, "its overall deadline expired");
}
const next = await nextBrokerEvent(page, watcher, screen, left);
if (next.kind === "frame") return next.frame;
if (next.kind === "stalled") {
throw await brokerLoginFailure(
page,
appOrigin,
screen,
trail,
startedAt,
next.because !== null
? `watching the page stopped working: ${next.because}`
: screen === null
? "no screen it recognises ever appeared"
: `the ${screen} screen never changed and no application frame ever appeared`,
);
}
screen = next.screen;
trail.push(`+${((Date.now() - startedAt) / 1000).toFixed(1)}s ${screen}`);
const spec = BROKER_SCREENS.find((s) => s.screen === screen);
if (spec === undefined) {
throw await brokerLoginFailure(
page,
appOrigin,
screen,
trail,
startedAt,
`the page reported a screen the inventory does not describe (${screen})`,
);
}
if (spec.terminal === true) {
throw await brokerLoginFailure(page, appOrigin, screen, trail, startedAt, "the broker showed an error page");
}
const seenBefore = (actedOn.get(screen) ?? 0) + 1;
actedOn.set(screen, seenBefore);
if (seenBefore > SCREEN_REVISITS_ALLOWED) {
throw await brokerLoginFailure(
page,
appOrigin,
screen,
trail,
startedAt,
`the ${screen} screen came back ${seenBefore} times — the click it answers is not moving the flow on`,
);
}
const did = await answerBrokerScreen(page, spec, walletPassword);
if (did !== null) trail.push(` ${did}`);
}
} finally {
watcher.stop();
}
}
+113
View File
@@ -0,0 +1,113 @@
/**
* Every browser this harness opens, launched and watched the same way.
*/
import { chromium, type BrowserContext, type Page } from "playwright";
import {
CONTEXT_ACTION_MS,
CONTEXT_NAVIGATION_MS,
browserLost,
closeQuietly,
within,
} from "./deadline";
/** Launching a browser is local — 30 s is Playwright's own default, doubled. */
export const LAUNCH_MS = 60_000;
/**
* Opening a page in a live browser is instant measured 0.00.1 s over a run. Bounded at
* 10 s, which is a hundred times the measurement and still fails while a reader is watching.
* Exported because a caller that wraps `newPage` in a TIGHTER bound of its own would fire
* first and report its own name instead of this one.
*/
export const NEW_PAGE_MS = 10_000;
/**
* What this harness needs Chromium to allow: the application under test is served from
* `127.0.0.1` and loaded inside a broker iframe on a public origin, which is a private-network
* request and a cross-origin one at once.
*/
const LAUNCH_ARGS = [
"--disable-features=PrivateNetworkAccessRespectPreflightResults,BlockInsecurePrivateNetworkRequests,PrivateNetworkAccessForWorkers,PrivateNetworkAccessForNavigations",
"--allow-insecure-localhost",
"--disable-web-security",
];
/**
* A real Chromium rather than the headless shell: the shell has no support for the extensions
* of a full browser, and the wallet application's flows have been observed only on the full
* build. Falls back to Playwright's own choice when no full build is installed beside it.
*/
function resolveChromePath(): string | undefined {
const p = chromium
.executablePath()
.replace("chrome-headless-shell", "chrome")
.replace("chromium_headless_shell", "chromium");
return p.includes("headless") ? undefined : p;
}
/** Contexts we are closing ON PURPOSE — so their `close` event is not read as a loss. */
const closingOnPurpose = new WeakSet<BrowserContext>();
/**
* Launch a persistent context on `dir`, bounded, with the harness's own timeouts applied and
* its disappearance turned into an immediate, named failure.
*
* The watch is the load-bearing part. VERIFIED 2026-08-11: a browser can exit mid-run the
* devtools pipe between the runner and Chromium is terminated and Chromium shuts down
* (exitCode=0) and Playwright does NOT reject the calls already waiting on it. A bounded
* wait then burns its whole timeout; an unbounded one (`newPage`, `evaluate`, and
* `context.close()` in a `finally`) waits for ever. That is how a 60-second failure became
* three runs killed at 50 and 68 minutes having printed nothing.
*
* So the context's own `close` event is listened to, and anything it was not asked to do is
* declared a loss once, loudly, for every wait at once.
*/
export async function launchWatchedContext(label: string, dir: string): Promise<BrowserContext> {
const ctx = await within(`the ${label} browser to launch`, LAUNCH_MS, () =>
chromium.launchPersistentContext(dir, {
headless: true,
executablePath: resolveChromePath(),
args: LAUNCH_ARGS,
timeout: LAUNCH_MS,
}),
);
ctx.setDefaultTimeout(CONTEXT_ACTION_MS);
ctx.setDefaultNavigationTimeout(CONTEXT_NAVIGATION_MS);
const gone = (how: string): void => {
if (closingOnPurpose.has(ctx)) return;
browserLost(
`the ${label} browser went away mid-run — ${how}. Every wait on it is now ` +
"unanswerable, so the run stops here instead of waiting on a browser that " +
"no longer exists",
);
};
// Both signals, and NEITHER of them covers the loss that hurts most — which is the whole
// reason the deadlines are not optional.
//
// VERIFIED 2026-08-11: on a normal teardown both `close` and `disconnected` fire. On the
// failure this harness actually suffers — Chromium logging "Connection terminated while
// reading from pipe" and exiting — Playwright fires NEITHER, four times out of four. Its
// client never learns the pipe is gone, so every call already in flight simply waits, and
// every call after it waits too. That is why a browser dying used to cost an hour of
// silence, and why no event-based guard can be the protection here: only a deadline can
// (and `known-failures.ts` is what turns the deadline back into the right name).
//
// They are wired anyway because they DO catch the losses they can see (a context closed by
// something nobody asked), and those are free to catch immediately rather than at the end
// of a bound.
ctx.on("close", () => gone("its context closed and nobody asked it to"));
ctx.browser()?.on("disconnected", () => gone("its devtools connection dropped"));
return ctx;
}
/** Close a context we own, bounded, without its `close` event being read as a loss. */
export async function closeContext(label: string, ctx: BrowserContext): Promise<void> {
closingOnPurpose.add(ctx);
await closeQuietly(`the ${label} context`, () => ctx.close());
}
/** Open a page under a bound: `context.newPage()` carries no timeout of its own. */
export function newPage(label: string, ctx: BrowserContext): Promise<Page> {
return within(`a new page for ${label}`, NEW_PAGE_MS, () => ctx.newPage());
}
+255
View File
@@ -0,0 +1,255 @@
/**
* Deadlines so a wait that cannot end FAILS, named, instead of hanging.
*
* Why this module exists
* A harness that hangs is worse than one that fails. A failure names a suspect and costs a
* minute; a hang costs an hour and leaves every measurement of the session undecidable
* was the suite slow, was the broker slow, or was it stuck? Two of the waits these suites
* lean on have NO bound at all: `frame.evaluate()` (which is what every bridge call into a
* page is) and `context.newPage()`. Playwright applies no timeout to either.
*
* And the worst one is in the teardown. VERIFIED 2026-08-11: when the browser goes away
* mid-run, `BrowserContext.close()` in a `finally` never resolves so the suite dies
* INSIDE its own cleanup, after its last journey, without ever printing its summary or its
* failures. That is the "prints the setup lines, then nothing for 68 minutes" the harness
* was killed for, three times.
*
* So: every wait that can block gets a deadline, and on expiry an error that says WHAT it
* was waiting for and WHERE the chain of journeys and steps it sits inside (see
* {@link enclosing}) because a bound whose message is "Timeout" only moves the guessing
* from "which wait" to "which of these thirty-two".
*
* Bounds are generous on purpose
* The numbers are sized from OBSERVED healthy timings with a wide margin (see each
* caller). The goal is to catch a hang, never to make a healthy-but-slow run flaky: a
* bound that fires on a slow broker manufactures exactly the false diagnosis it exists to
* prevent.
*/
/** Thrown when a bounded wait outlives its deadline. */
export class DeadlineExceeded extends Error {
constructor(what: string, ms: number, where: string) {
super(`[e2e deadline] gave up after ${fmtMs(ms)} waiting for: ${what}\n ${where}`);
this.name = "DeadlineExceeded";
}
}
/**
* Thrown at every wait in flight when the browser they all depend on has gone away.
*
* Without it, a dead browser is discovered one 60-second timeout at a time or never, on
* the waits Playwright does not bound. The suite has nothing left to measure at that
* point, so the useful thing is to say so once, immediately, and name the loss.
*/
export class BrowserGone extends Error {
constructor(reason: string, what: string, where: string) {
super(`[e2e] ${reason}\n it was waiting for: ${what}\n ${where}`);
this.name = "BrowserGone";
}
}
function fmtMs(ms: number): string {
return ms >= 60000 ? `${(ms / 60000).toFixed(1)} min` : `${Math.round(ms / 1000)}s`;
}
interface Pending {
what: string;
where: string;
ms: number;
startedAt: number;
abandon: (e: Error) => void;
}
/** Everything currently being waited on, so a loss can name every casualty at once. */
const pending = new Set<Pending>();
/** Set once the run has lost the thing every wait depends on. */
let lost: string | null = null;
/**
* Where a wait sits, as the chain of waits enclosing it `journey X alice to sign in`.
*
* Deliberately NOT a file:line read off a stack. The runner is Bun, and Bun elides frames
* across `await` boundaries: measured 2026-08-11, a `within` called from an async function
* reports `moduleEvaluation (native:1:11)` and nothing else, so a stack-derived call site
* is silently wrong exactly when it is needed. The enclosing chain is better anyway a
* reader wants "which journey, which step" far more than a line number, and journeys and
* steps are themselves bounded waits, so the chain is already there to be read.
*
* These suites are strictly sequential, which is what makes "everything else in flight" the
* same thing as "everything enclosing this". A concurrent harness would need real context
* propagation.
*/
function enclosing(): string {
const chain = [...pending].map((p) => p.what);
return chain.length === 0 ? "(the suite's top level)" : `while: ${chain.join(" ")}`;
}
/**
* Run `task` under a deadline. On expiry or the moment {@link browserLost} is declared
* reject with an error naming what was being waited for and where.
*
* The losing task is NOT cancelled; nothing here can cancel a browser round-trip. Its
* eventual rejection is absorbed instead, because a race loser surfacing as an unhandled
* rejection would crash the process minutes after the real failure was already reported.
*/
export function within<T>(what: string, ms: number, task: () => Promise<T>): Promise<T> {
if (lost !== null) return Promise.reject(new BrowserGone(lost, what, enclosing()));
return bounded(what, ms, enclosing(), task);
}
/** The race itself, shared by {@link within} and the teardown path that outlives a loss. */
function bounded<T>(what: string, ms: number, where: string, task: () => Promise<T>): Promise<T> {
let timer: ReturnType<typeof setTimeout> | undefined;
let entry!: Pending;
const interrupted = new Promise<never>((_, reject) => {
entry = { what, where, ms, startedAt: Date.now(), abandon: reject };
timer = setTimeout(() => reject(new DeadlineExceeded(what, ms, where)), ms);
});
pending.add(entry);
const running = task();
running.catch(() => {}); // absorbed: the race's loser must not become an unhandled rejection
return Promise.race([running, interrupted]).finally(() => {
if (timer !== undefined) clearTimeout(timer);
pending.delete(entry);
});
}
/**
* Declare that the browser every wait depends on has gone, and abandon them all now.
*
* Idempotent, and one-way: once a run has lost its browser there is nothing further to
* measure, so later waits are refused rather than left to time out one by one.
*/
export function browserLost(reason: string): void {
if (lost !== null) return;
lost = reason;
console.error(`\n[e2e] ${reason}`);
if (pending.size > 0) {
console.error(` ${pending.size} wait(s) were in flight and are abandoned:`);
for (const p of pending) console.error(` - ${p.what} [${p.where}]`);
}
for (const p of [...pending]) p.abandon(new BrowserGone(reason, p.what, p.where));
}
/**
* The loss already declared, or `null` while the run still has a browser.
*
* Read by the failure-mode recognition (`known-failures.ts`) so that a diagnosis asked for
* AFTER a loss answers instantly with the loss, instead of spending a probe's bound
* re-discovering what is already known.
*/
export function lossDeclared(): string | null {
return lost;
}
/**
* An ENCLOSING bound, computed from the bounds it encloses rather than picked.
*
* Why this is an addition and not a comment
* An enclosing deadline shorter than its own steps can only ever fire FIRST, so every
* failure underneath it is reported as "the enclosure timed out" and the step that actually
* hung is never named. Observed at length: a sign-in bounded at 3 min sat over steps
* totalling 4.5 min, and for days every sign-in failure said the same four words while the
* real step stayed anonymous. Days went into looking for a cause the harness was
* structurally incapable of reporting.
*
* A comment saying "keep this above the sum" is a discipline; an addition is a mechanism,
* and the mechanism is what survives the next edit shrink a step's bound and the
* enclosure shrinks with it. That is the lever: the steps, never the enclosure.
*
* `margin` is for the enclosure's OWN overhead (the code between the steps), not for
* comfort an enclosure sized "generously" above its steps just delays every report.
*/
export function enclosingBound(steps: readonly number[], margin: number): number {
return steps.reduce((sum, ms) => sum + ms, 0) + margin;
}
/** Teardown bound: a close that has not returned in 30s is not going to. */
export const CLOSE_MS = 30_000;
/**
* Close a page/context/server under a deadline, reporting rather than throwing.
*
* Teardown is where a bound matters most and an exception matters least: the verdict is
* already decided, so a close that never returns must not be what the run dies of. This is
* the exact shape of the observed hang `BrowserContext.close()` on a browser that had
* already exited, inside a `finally`, swallowing the summary that was on its way out.
*
* Deliberately NOT refused after a loss, unlike {@link within}: a lost browser is when
* closing matters most. Skipping it there would leave the Chromium processes of a failed
* run alive, and the next run would inherit them.
*/
export async function closeQuietly(what: string, close: () => Promise<unknown>): Promise<void> {
try {
await bounded(`${what} to close`, CLOSE_MS, enclosing(), async () => {
await close();
});
} catch (e) {
console.warn(` [warn] ${what} did not close cleanly: ${String((e as Error)?.message ?? e)}`);
}
}
/**
* Arm the suite's own wall clock. On expiry, name every wait still in flight and exit.
*
* The last resort behind the per-wait deadlines: it catches the wait nobody wrapped. It
* reports before it dies, because "the run was killed" is the uninformative message that
* cost the hours this module exists to stop spending.
*
* `unref`ed, so a healthy run is never held open by its own watchdog.
*
* `thenReport` lets a suite print its own summary before the process goes without it a run
* that trips this watchdog reports its waits and then vanishes, so its check count is zero
* and comparable with nothing. (A suite built on `report.ts` passes its `finish`.)
*/
export function armSuiteDeadline(suite: string, ms: number, thenReport?: () => void): void {
const startedAt = Date.now();
const timer = setTimeout(() => {
console.error(
`\n[e2e deadline] ${suite} exceeded its wall clock of ${fmtMs(ms)} — aborting.\n` +
" This is a HANG, not a verdict.",
);
if (pending.size === 0) {
console.error(
" Nothing was inside a bounded wait, so the block is in unbounded code: " +
"wrap the step it stopped at with `within(...)`.",
);
} else {
console.error(` Waits still in flight (${pending.size}):`);
for (const p of pending) {
console.error(
` - ${p.what}${fmtMs(Date.now() - p.startedAt)} of ${fmtMs(p.ms)}\n at ${p.where}`,
);
}
}
console.error(` Total elapsed: ${fmtMs(Date.now() - startedAt)}`);
// A suite that can still say what it did and did not verify must be allowed to say it —
// otherwise the watchdog, whose whole purpose is to replace a silent kill with a report,
// produces its own silent kill. `thenReport` is expected to exit; the line below is the
// fallback for a caller that has nothing to report.
if (thenReport !== undefined) thenReport();
process.exit(1);
}, ms);
timer.unref?.();
}
/**
* Playwright's per-context defaults, set explicitly so the bound on every locator action and
* navigation is a decision in this file rather than a library default nobody looked up.
*
* The value is Playwright's own 30s, deliberately: raising it to 120s was tried on
* 2026-08-11 and made things WORSE, because a bound is not only a hang-catcher it is also
* how fast a genuine failure is reported. The wallet-creation flow on nextgraph.eu can
* re-render under a click ("element was detached from the DOM, retrying"), and at 120s that
* flake took two minutes to surface instead of thirty seconds. Every action and navigation
* here already had a bound; the waits that had NONE are the ones this module wraps
* (`evaluate`, `newPage`, `close`), and the slow broker calls pass their own timeout.
*/
export const CONTEXT_ACTION_MS = 30_000;
export const CONTEXT_NAVIGATION_MS = 30_000;
/** The first line of whatever was thrown — the form a report carries. */
export function firstLine(e: unknown): string {
return String((e as Error)?.message ?? e).split("\n")[0] ?? "(no message)";
}
+101
View File
@@ -0,0 +1,101 @@
/**
* `ng-e2e-helpers` what any NextGraph application needs to test itself end to end, against
* the real broker and the real wallet application.
*
* What it is for
* Testing a NextGraph application end to end means getting a real person into it: minting a
* wallet by driving the wallet application, crossing the broker, and coming back inside the
* iframe the application actually runs in. None of that is about any one application, and all
* of it is expensive to get right the crossing alone has cost days of misdiagnosis, twice,
* for reasons recorded in `broker.ts` and `known-failures.ts`.
*
* It knows nothing about any compatibility layer and never will: an application that uses the
* NextGraph SDK directly is its intended consumer.
*
* The five things it gives you
* - a WALLET: minted for this run, exported as bytes an application can serve, imported into
* a profile (`wallet.ts`) and, for the one-off that provisions a deployment rather than a
* run, the same minting behind an executable (`bin/mint-wallet.ts`);
* - the BROKER CROSSING, which dispatches on the screen it can see and identifies the
* application by ORIGIN (`broker.ts`, `nextgraph-ui.ts`);
* - PROFILES that belong to one run and are cleaned up after it (`profiles.ts`, `browser.ts`);
* - BOUNDS that turn a hang into a named failure (`deadline.ts`, `measure.ts`);
* - a REPORT whose size does not depend on what failed (`report.ts`), and the recognition of
* the failure modes that are not the application's fault (`known-failures.ts`).
*
* Playwright and `@ng-org/web` are peer dependencies: the consumer owns both versions the
* first because browser binaries have to match the driver, the second because the SDK the
* export page opens a session with must be the one the application and the broker agree on.
*/
export {
BrowserGone,
CLOSE_MS,
CONTEXT_ACTION_MS,
CONTEXT_NAVIGATION_MS,
DeadlineExceeded,
armSuiteDeadline,
browserLost,
closeQuietly,
enclosingBound,
firstLine,
lossDeclared,
within,
} from "./deadline";
export { measured, printTimings, record, timingsWanted } from "./measure";
export { LAUNCH_MS, NEW_PAGE_MS, closeContext, launchWatchedContext, newPage } from "./browser";
export { isAlive, newRunProfile, type RunProfile } from "./profiles";
export { serveOnEphemeralPort } from "./serve";
export { BROKER_LOGIN_MS, BROKER_ROUND_TRIP_MS, completeBrokerLogin, setupBrokerPage } from "./broker";
export {
DEFAULT_WALLET_NAME,
createWalletInContext,
emptyProfileContext,
exportWalletBytes,
exportWalletFile,
importWalletFile,
mintWalletBytes,
mintWalletProfile,
mintWalletProfileKeepingContext,
type WalletCredentials,
} from "./wallet";
export {
BROWSER_PROBE_MS,
FRAME_PROBE_MS,
browserTrouble,
frameTrouble,
} from "./known-failures";
export {
declareSuite,
type JourneyDeclaration,
type JourneySpec,
type Prerequisite,
type SuiteOptions,
type SuiteReport,
} from "./report";
export {
BROKER_SCREENS,
WALLET_APP,
WALLET_CREATION,
WALLET_IMPORT,
brokerRedirectFor,
type BrokerScreen,
type BrokerScreenSpec,
type ScreenResponse,
type ScreenSignature,
type TextPattern,
} from "./nextgraph-ui";
// Re-exported so a consumer can type its own helpers against our signatures without a second,
// independently-versioned import of `playwright` — exactly the set our own exported functions
// mention (`launchWatchedContext`, `setupBrokerPage`, `frameTrouble`, and their neighbors).
export type { BrowserContext, Frame, Page } from "playwright";
@@ -0,0 +1,106 @@
/**
* The failure modes this harness cannot fix, and must therefore NAME.
*
* Why naming is the whole of the job
* Both modes below present as a bounded wait expiring on whatever operation happened to be in
* flight a `fill`, a `selectOption`, a `click`. Reported that way they read as product
* defects, and they have been diagnosed as such more than once: a run whose browser had
* stopped answering reported three timeouts on three different innocent selectors, none of
* them naming the browser. A whole day went into one of those.
*
* So when a wait fails, the honest question is asked before the verdict is written: does the
* browser still answer AT ALL? A trivial round-trip settles it in milliseconds when things are
* healthy, so asking costs a healthy run nothing.
*
* The two modes
* **A dropped devtools pipe.** Chromium's control pipe drops mid-run: it logs a terminated-pipe
* message and exits cleanly, and Playwright emits NEITHER `close` NOR `disconnected` observed
* four times out of four. From the client's side the browser simply stops answering, so every
* wait on it burns its bound and the unbounded ones wait for ever. It is not caused by how the
* child process is spawned, nor by a leftover holding the profile, nor by overlapping launches
* all three were probed and ruled out. It looks like Playwright losing its file descriptors
* without telling its client.
*
* **A context that stops answering.** The same shape at frame level: a frame that is attached,
* on the right URL, and holds NOTHING what a RELOADED iframe looks like from the outside.
* VERIFIED 2026-08-16, one actor's frame reached it mid-run and the next three journeys each
* reported a 30 s timeout on a different innocent selector.
*
* What a named deadline does NOT prove
* That the transport is at fault. A deadline says only that something did not happen in time;
* reaching for the environment is the comfortable answer because it absolves the code. The
* worst instance of that reflex here was a one-line harness bug an application frame matched
* by SUBSTRING blamed on the broker and on the host network for a day. Read your own harness
* first, and call it transport only once you can name the mechanism.
*/
import type { BrowserContext, Frame, Page } from "playwright";
import { DeadlineExceeded, firstLine, lossDeclared, within } from "./deadline";
/** Asking a live browser something trivial: it answers in milliseconds, or it is gone. */
export const BROWSER_PROBE_MS = 5_000;
/**
* Asking a live frame whether it still holds the application. A `count()` is one round-trip
* and does not wait for the element, so it answers in milliseconds or the frame is gone the
* probe cannot itself become the hang it exists to name.
*/
export const FRAME_PROBE_MS = 10_000;
/**
* Whether the browser has stopped answering as a sentence naming the mode, or `null` when it
* answers normally and the operation that failed is the real suspect.
*
* Consult this on a failure path, never in the success path: it exists to REPLACE a misleading
* verdict, not to add a check.
*/
export async function browserTrouble(label: string, ctx: BrowserContext): Promise<string | null> {
// Already established, by the `close`/`disconnected` listeners that can see their losses.
// Answering from it costs nothing and says the same thing.
const declared = lossDeclared();
if (declared !== null) return declared;
const live = ctx.pages().filter((p) => !p.isClosed());
if (live.length === 0) return null; // nothing to ask — no verdict, rather than a wrong one
const page = live[0]!;
try {
await within(`the ${label} browser to answer a trivial question`, BROWSER_PROBE_MS, () =>
page.evaluate(() => 1),
);
return null;
} catch (e) {
if (e instanceof DeadlineExceeded) {
return (
`the ${label} browser STOPPED ANSWERING — a trivial round-trip did not come back in ` +
`${BROWSER_PROBE_MS / 1000}s. This is the dropped devtools pipe (Chromium exits and ` +
"Playwright emits neither `close` nor `disconnected`), so whatever operation was in " +
"flight is a casualty and not the cause. It is not ours to fix — re-run, and do not " +
"read this as a verdict on the code under test"
);
}
return `the ${label} browser refused a trivial question: ${firstLine(e)}`;
}
}
/**
* Why `frame` cannot be driven, or `null` when it can.
*
* `marker` is the selector that proves the application is still in the frame the caller's,
* because only the application knows what its own presence looks like. The THIRD state is the
* one that actually happens and the one no naive check catches: attached, on the right URL,
* and empty.
*/
export async function frameTrouble(
id: string,
page: Page,
frame: Frame,
marker: string,
): Promise<string | null> {
if (page.isClosed()) return `${id}'s page has been closed`;
if (frame.isDetached()) return `${id}'s application frame is detached`;
const shell = await within(`${id}'s frame to answer`, FRAME_PROBE_MS, () =>
frame.locator(marker).count(),
).catch((e: unknown) => firstLine(e));
if (typeof shell === "string") return `${id}'s frame did not answer (${shell})`;
if (shell === 0) return `${id}'s frame no longer holds the application — it reloaded`;
return null;
}
+113
View File
@@ -0,0 +1,113 @@
/**
* What each bounded operation ACTUALLY takes the measurement every bound is sized from.
*
* Why a bound needs its measurement kept beside it
* A bare number teaches nothing and rots in silence. "180 seconds" cannot be judged: is it
* ten times the normal duration, or a hundred? Only one of those is a bound; the other is a
* hang dressed up as one. The suite this module serves had a sign-in bounded at 3 minutes
* for an operation that measures 1.4s, so its single job turning a hang into a named
* failure fast was done fifty times too slowly to be worth anything.
*
* So every bound in these harnesses is written as `measured normal → bound → margin`, and
* this module is how the "measured normal" half is obtained and re-obtained. Run any suite
* with `E2E_TIMINGS=1` and it prints, at the end, what each named operation took and how
* much headroom its bound still has. A future reader who suspects a number has gone stale
* does not have to believe this file's comments: they can re-run the measurement.
*
* Passive by default a `Date.now()` per wait, and nothing printed unless asked.
*/
/** One observation of one named operation. */
interface Sample {
readonly ms: number;
/** Sizing a bound from a FAILED attempt would size it from the bound itself. */
readonly ok: boolean;
/** The bound in force, so the report can show the headroom rather than make one guess it. */
readonly bound: number;
}
const samples = new Map<string, Sample[]>();
export function record(what: string, ms: number, ok: boolean, bound: number): void {
const seen = samples.get(what);
if (seen === undefined) samples.set(what, [{ ms, ok, bound }]);
else seen.push({ ms, ok, bound });
}
/**
* Run a wait under `bound`, recording what it took under the stable name `what`.
*
* The bound is handed TO the task rather than raced against it, deliberately: Playwright's
* own timeout reports the call log ("waiting for locator(…)"), and a race would replace that
* with a message naming only the enclosure. What this adds is the measurement and a stable
* name not a second, competing deadline.
*
* The name must be stable across runs (no identifiers, no ports) or the table fragments into
* one row per run and measures nothing.
*/
export async function measured<T>(what: string, bound: number, task: (ms: number) => Promise<T>): Promise<T> {
const startedAt = Date.now();
let ok = false;
try {
const out = await task(bound);
ok = true;
return out;
} finally {
record(what, Date.now() - startedAt, ok, bound);
}
}
/** Whether the caller asked for the table. */
export function timingsWanted(): boolean {
return (process.env.E2E_TIMINGS ?? "") !== "";
}
function fmt(ms: number): string {
return ms >= 10_000 ? `${(ms / 1000).toFixed(0)}s` : `${(ms / 1000).toFixed(1)}s`;
}
/**
* Print what was measured: per operation, the healthy observations and the headroom its
* bound has over the SLOWEST of them.
*
* Failed attempts are counted but excluded from the statistics, because an operation that
* hit its bound measures the bound and not the operation feeding that back into the sizing
* is how a bound ratchets upward for ever, one bad run at a time.
*/
export function printTimings(): void {
if (samples.size === 0) {
console.log("\n── measured durations ── nothing was recorded.");
return;
}
const rows = [...samples.entries()].map(([what, all]) => {
const good = all.filter((s) => s.ok).map((s) => s.ms).sort((a, b) => a - b);
const bound = all[all.length - 1]!.bound;
const failed = all.length - good.length;
return {
what,
n: good.length,
min: good.length === 0 ? null : good[0]!,
max: good.length === 0 ? null : good[good.length - 1]!,
bound,
failed,
};
});
const width = Math.max(...rows.map((r) => r.what.length), 9);
console.log("\n── measured durations (E2E_TIMINGS) ─────────────────────────────────────────");
console.log(
` ${"operation".padEnd(width)} ${"n".padStart(3)} ${"min".padStart(6)} ${"max".padStart(6)}` +
` ${"bound".padStart(6)} headroom failed`,
);
for (const r of rows) {
const headroom = r.max === null || r.max === 0 ? "—" : `${(r.bound / r.max).toFixed(0)}x`;
console.log(
` ${r.what.padEnd(width)} ${String(r.n).padStart(3)} ` +
`${(r.min === null ? "—" : fmt(r.min)).padStart(6)} ${(r.max === null ? "—" : fmt(r.max)).padStart(6)} ` +
`${fmt(r.bound).padStart(6)} ${headroom.padStart(8)} ${r.failed === 0 ? "" : String(r.failed)}`,
);
}
console.log(
" (statistics are over SUCCESSFUL attempts only: a wait that hit its bound measures\n" +
" the bound, and sizing the next bound from it ratchets upward for ever.)",
);
}
+186
View File
@@ -0,0 +1,186 @@
/**
* What NextGraph's own pages LOOK like addresses, selectors, and the inventory of screens
* the sign-in walks through. Description only: nothing here drives a browser.
*
* Why it is a separate file, and why it is data
* Two kinds of knowledge live in this package and they age at completely different rates.
* How to cross a broker dispatch on the screen you can see, never on elapsed time; identify
* the application by its origin, never by a substring is a *method*, and it has survived
* every change upstream. WHICH selector shows a wallet list is a *fact about today's markup*,
* and it changes whenever the wallet application is restyled.
*
* Keeping the second kind as plain data has two consequences worth the split. Upstream
* changes a selector: you edit a string in this file and no control flow moves. And the
* driving code below (`broker.ts`, `wallet.ts`) reads this inventory rather than embedding
* it, so a harness built on some other browser driver would reuse this file whole and
* rewrite only the driving. That adapter is NOT built here the point is only that
* building it would not be a rewrite.
*
* The screen inventory is deliberately SERIALIZABLE: it is handed to the browser as an
* argument (see `readBrokerScreen` in `broker.ts`), so the same description that names a
* screen in a failure message is the one the recognition dispatched on. That rules out
* regular expressions as values, hence {@link TextPattern}.
*
* VERIFIED 2026-08-14 against the live pages unless noted; the upstream source is
* `nextgraph-rs` (`infra/ngnet/redir`, `engine/broker/auth`, `app/ui-common`), read but
* never modified.
*/
// ── the wallet application (nextgraph.eu) ───────────────────────────────────
/**
* Where a wallet is created and where one is imported. The wallet application is a real
* application like any other this harness drives its actual interface rather than
* reaching behind it, because a wallet obtained any other way is not the one a person has.
*/
export const WALLET_APP = {
home: "https://nextgraph.eu/",
/** The standalone import/unlock route, reachable without going through the broker. */
login: "https://nextgraph.eu/#/wallet/login",
} as const;
/** The creation flow, screen by screen, as labels and selectors. */
export const WALLET_CREATION = {
/** Step 1 — the home page's entry point. */
createWallet: "Create Wallet",
/** Step 2 — the terms screen, reached on the `/account` route. */
acceptTerms: "I accept",
/** The URL glob that route is awaited by. */
termsRoute: "**/account*",
/** Step 3 — the credentials form. */
username: "#username-input",
password: "#password-input",
/** Matched loosely: the button's caption is not stable in case. */
submit: "create my wallet",
/** Step 4 — creation lands here, and the first unlock happens from it. */
landsOn: "**/#/wallet/login",
/** Offered on the login route when a wallet is already on the device. */
loginWithThisWallet: "Click here to login with your wallet",
passwordField: 'input[type="password"]',
} as const;
/** The import-a-wallet-file flow on the same login route. */
export const WALLET_IMPORT = {
fileInput: "input[type=file]",
passwordField: "input[type=password]",
/** Shown by some builds after the password; absent in others, so it is probed, not awaited. */
confirm: /Confirm/i,
} as const;
// ── the broker crossing (nextgraph.net/redir → the broker's auth page) ──────
/** The redirect that hands an application's address to the broker. */
export function brokerRedirectFor(appUrl: string): string {
return `https://nextgraph.net/redir/#/?o=${encodeURIComponent(appUrl)}`;
}
/**
* The distinct screens the crossing can be on.
*
* - `choose-broker` the redirect page with MORE than one broker to pick from. Not observed
* on hosts that resolve to a single broker (which auto-selects), so it is described from
* the upstream source rather than from observation.
* - `login-offered` "We could not find a wallet on this device… Login". The entry screen of
* every sign-in observed, first actor and later ones alike.
* - `wallet-list` "Select a wallet to login with", one box per wallet.
* - `password` "Enter your password". Reached by the FIRST actor only: the wallet is
* broadcast between the broker origin's tabs over a `BroadcastChannel` named `ng_wallet`,
* so a later actor's wallet is already in `opened_wallets` and selecting it logs straight
* in (`ui-common/src/routes/WalletLogin.svelte`, the `$opened_wallets[selected]` path).
* VERIFIED 2026-08-14, three consecutive sign-ins in one browser context.
* - `working` a splash, "Opening your wallet…", "Wallet opened for …". Nothing to do but
* wait for it to become something else. Note that SUCCESS is one of these: the final screen
* never stops being `working`, which is why the application's frame is watched separately
* rather than inferred from the screen.
* - `error` the broker said no ("An error occurred", "Invalid request"). Terminal.
*/
export type BrokerScreen =
| "choose-broker"
| "login-offered"
| "wallet-list"
| "password"
| "working"
| "error";
/**
* A regular expression as data, because the inventory crosses into the browser and a
* `RegExp` does not survive that trip. Rebuilt on the far side with `new RegExp(...)`.
*/
export interface TextPattern {
readonly source: string;
readonly flags: string;
}
/** How a screen is told apart from the ones described BEFORE it. */
export type ScreenSignature =
/** Any of these selectors matches an element with a non-zero box. */
| { readonly kind: "rendered"; readonly selectors: readonly string[] }
/** A rendered `<button>`/`<a>` whose trimmed text matches. */
| { readonly kind: "rendered-control"; readonly matches: TextPattern }
/** The page's RENDERED prose matches — the one test that has to read words. */
| { readonly kind: "page-text"; readonly matches: TextPattern }
/** Whatever is left. Must be the last entry, and there must be one. */
| { readonly kind: "otherwise" };
/** What moves the flow on from a screen. */
export type ScreenResponse =
| { readonly kind: "click"; readonly what: string; readonly selector: string }
| { readonly kind: "click-text"; readonly what: string; readonly text: string }
/** Fill the run's wallet password and submit it. The password is never described here
* it belongs to the run, not to the pages. */
| { readonly kind: "submit-password"; readonly what: string; readonly selector: string }
/** Nothing to do but let it become something else. */
| { readonly kind: "wait" };
export interface BrokerScreenSpec {
readonly screen: BrokerScreen;
readonly signature: ScreenSignature;
readonly answer: ScreenResponse;
/** Terminal: reaching it ends the crossing with a failure rather than an action. */
readonly terminal?: true;
}
/**
* The inventory, IN THE ORDER IT IS TESTED and the order is load-bearing, not cosmetic.
*
* Each screen is identified by the signature that the screens BEFORE it do not have.
* Visibility is checked by measured box rather than by presence, because the auth
* application HIDES its whole login UI (`#app` gets `display:none`) instead of removing it
* once the wallet is open a presence test would keep reporting `wallet-list` on a page
* that has already logged in.
*/
export const BROKER_SCREENS: readonly BrokerScreenSpec[] = [
{
screen: "password",
signature: { kind: "rendered", selectors: ["#password-input", 'input[type="password"]'] },
answer: { kind: "submit-password", what: "the password", selector: "#password-input, input[type='password']" },
},
{
screen: "wallet-list",
signature: { kind: "rendered", selectors: [".wallet-box"] },
// The BOX, not its caption: the caption only renders for a wallet that carries a
// password, and the box is the thing with `role="button"` either way.
answer: { kind: "click", what: "this run's wallet", selector: ".wallet-box" },
},
{
screen: "choose-broker",
signature: { kind: "rendered", selectors: ['[role="menuitem"]'] },
answer: { kind: "click", what: "the first broker in the list", selector: '[role="menuitem"]' },
},
{
screen: "login-offered",
signature: { kind: "rendered-control", matches: { source: "^(login|anmelden)$", flags: "i" } },
answer: { kind: "click-text", what: 'the "Login" button', text: "Login" },
},
{
screen: "error",
signature: { kind: "page-text", matches: { source: "An error occurred|Invalid request", flags: "i" } },
answer: { kind: "wait" },
terminal: true,
},
{
screen: "working",
signature: { kind: "otherwise" },
answer: { kind: "wait" },
},
];
+140
View File
@@ -0,0 +1,140 @@
/**
* Browser profiles one per run, never shared, never inherited.
*
* Why a run owns its profile instead of borrowing a shared one
* A run mints its own physical NextGraph user and must not inherit the previous run's. That
* discipline is not an optimisation: a wallet reused across runs ACCUMULATES every run
* leaves behind the identities and documents it created, nothing removes them, and a cold
* resynchronisation is O(the user's size). A wallet kept for a month took 286 s on a single
* sync step against 250 s a week earlier, and the drift was invisible because it was never
* measured against a stable baseline. A fresh user per run makes that duration comparable
* from one run to the next instead of a number that only ever grows.
*
* The obvious way to get a fresh user is to WIPE a profile at a fixed path which is what
* this harness used to do, and it is why it needed a lock. A wipe destroys a profile that
* another run may be using, so runs had to be serialised, and a suite belonging to a
* consuming application run from its own checkout, against the same broker collided with
* ours exactly as two of ours would, invisibly to both. The lock could never have fixed that:
* it guarded one repository's idea of a path.
*
* A directory of its own removes the problem rather than exporting it. There is nothing to
* serialise, concurrent runs are independent by construction, and "one physical user per run,
* never reused" stops being a rule anyone can forget a directory that did not exist a
* moment ago cannot hold a previous run's user.
*
* What the profile still IS, and must remain: persistent for the WHOLE run. A run opens
* several browser contexts over it in sequence (a reconnection is exactly that), and the
* contracts about reconnecting faithfully and not forking an account are checks on that
* persistence.
*/
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
/** A profile directory this run owns and will remove. */
export interface RunProfile {
/** The directory to launch a persistent context on. */
readonly dir: string;
/** What it is for, as it appears in logs. */
readonly purpose: string;
/**
* Kill whatever still holds it, then remove it. Idempotent, and also run automatically when
* the process leaves (see below), so a killed run cleans up after itself.
*/
discard(): void;
}
const live = new Set<RunProfile>();
let leavingHandlersInstalled = false;
/** Is that process still alive? Signal 0 tests for existence without touching it. */
export function isAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch (e) {
// EPERM means it exists and is someone else's — still alive, still holding it.
return (e as NodeJS.ErrnoException).code === "EPERM";
}
}
/**
* The Chromium still holding `dir`, if any. Chromium names the holder itself: `SingletonLock`
* is a symlink to `<host>-<pid>`.
*/
function holderOf(dir: string): number | null {
let target: string;
try {
target = fs.readlinkSync(path.join(dir, "SingletonLock"));
} catch {
return null; // no lock, nothing holding it
}
const pid = Number(target.slice(target.lastIndexOf("-") + 1));
return Number.isInteger(pid) && pid > 0 && isAlive(pid) ? pid : null;
}
/**
* A profile directory of this run's own, under the system temp dir.
*
* Under the TEMP dir and not the repository, deliberately: a profile holds a wallet, a wallet
* is an identity, and an identity must never end up committed. It also means two checkouts of
* the same suite cannot land on the same path.
*/
export function newRunProfile(purpose: string): RunProfile {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ng-e2e-profile-"));
let discarded = false;
const profile: RunProfile = {
dir,
purpose,
discard: () => {
if (discarded) return;
discarded = true;
live.delete(profile);
// A run that fails or is killed leaves its Chromium ALIVE: `BrowserContext.close()` in
// the teardown gives up after its bound, and a `kill -9` on the runner never gets there
// at all. Nothing else will ever want this directory, so the orphan cannot poison a
// later run the way it used to — but it would sit on the host's memory for ever, and a
// loaded host is how this suite manufactures its own flakiness. Each run therefore
// clears its OWN leftovers, which is the one moment where it is certainly safe.
const holder = holderOf(dir);
if (holder !== null) {
try {
process.kill(holder, "SIGKILL");
} catch {
/* gone between the check and the signal */
}
}
try {
fs.rmSync(dir, { recursive: true, force: true });
} catch {
/* a temp dir the OS will collect anyway */
}
},
};
live.add(profile);
installLeavingHandlers();
return profile;
}
/**
* Discard every profile still live when the process leaves including the ways out nobody
* plans for.
*
* `exit` covers the normal end and `process.exit()`, which is how these suites finish; the
* signal handlers cover Ctrl-C and `kill`, which is how a hung run ends. Everything here is
* synchronous, because an `exit` handler is the only thing that runs at that point.
*/
function installLeavingHandlers(): void {
if (leavingHandlersInstalled) return;
leavingHandlersInstalled = true;
process.on("exit", () => {
for (const profile of [...live]) profile.discard();
});
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"] as const) {
process.on(signal, () => {
for (const profile of [...live]) profile.discard();
process.exit(130);
});
}
}
+221
View File
@@ -0,0 +1,221 @@
/**
* What a run REPORTS a constant number of rows, whatever fails.
*
* The arithmetic is the point
* A suite whose check TOTAL is a function of how far it got cannot be compared with itself. A
* journey that dies halfway takes its unreported checks with it and simply never mentions
* them: three runs of the same suite reported 24, 26 and 27 checks (VERIFIED 2026-08-16), and
* a moving total compares nothing. Worse, the checks that vanish are the ones nobody looks
* for silence reads as absence, not as failure. A shrinking total even reads like a
* SMALLER problem instead of a bigger one.
*
* So every check is DECLARED before anything can fail. Read off the declaration, the
* arithmetic survives any death: every journey contributes exactly `checks.length + 1` rows
* whatever happens to it, including journeys that never ran because the setup died first. A
* difference between two runs is then always a real difference.
*
* The declaration doubles as the suite's table of contents, which is the other reason to keep
* it whole and in execution order.
*
* The related trap that made this self-perpetuating once: the checks were declared inside each
* journey, so a run that died in the SETUP before any journey printed `fatal:` and left,
* with no summary and nothing a previous run could be compared to.
*/
import { firstLine, within } from "./deadline";
import { printTimings, timingsWanted } from "./measure";
/** One journey and every check it reports. Declared up front; never assembled at run time. */
export interface JourneyDeclaration {
readonly name: string;
readonly checks: readonly string[];
}
/** Why a journey cannot start, or `null` when it can. */
export type Prerequisite = () => Promise<string | null> | (string | null);
export interface JourneySpec {
/** Must name a declared journey, which is where its checks are declared. */
readonly name: string;
/**
* What this journey needs from the ones before it. A prerequisite that is provably dead is
* reported as such INSTEAD of being driven not to spare the journey, but because driving
* a closed page answers with "Target page, context or browser has been closed", a verdict
* that names the innocent operation and hides the journey that actually broke.
*/
readonly needs?: readonly Prerequisite[];
readonly run: () => Promise<void>;
}
export interface SuiteOptions {
/** Names the suite in its summary line, e.g. "Application e2e". */
readonly label: string;
/** Every journey, in execution order, with its checks. */
readonly journeys: readonly JourneyDeclaration[];
/** The bound on ONE journey — what catches a journey that never returns. */
readonly journeyBound: number;
/**
* Asked on a journey's failure: is there a KNOWN failure mode to name instead of the
* operation that happened to be in flight? Typically `() => browserTrouble(label, ctx)`.
* Its answer is put in front of the journey's reason, never in place of it.
*/
readonly diagnose?: () => Promise<string | null>;
}
export interface SuiteReport {
/** Report a declared check. Throws if the name is not one the journey declared. */
check(name: string, ok: boolean, detail?: string): void;
/** Run one journey: bounded, isolated, unable to change the shape of the report. */
journey(spec: JourneySpec): Promise<void>;
/** Report everything this run did not get to, print the summary, and leave. */
finish(fatal: string | null): never;
}
interface Check {
name: string;
ok: boolean;
detail?: string;
}
/**
* Build the reporting for a suite from its declaration.
*
* The returned functions do not use `this`, so a caller may destructure them
* (`const { check, journey, finish } = declareSuite(...)`) and read like a test file.
*/
export function declareSuite(options: SuiteOptions): SuiteReport {
const results: Check[] = [];
/** The journeys already reported, so `finish` knows what is missing. */
const reported = new Set<string>();
/** The checks the journey in flight has DECLARED and not yet reported `null` between
* journeys, which is what makes a stray report detectable. */
let outstanding: Set<string> | null = null;
const startedAt = Date.now();
const record = (name: string, ok: boolean, detail?: string): void => {
results.push({ name, ok, detail });
console.log(` [${ok ? "PASS" : "FAIL"}] ${name}${detail === undefined ? "" : " — " + detail}`);
};
/**
* Both rules the name must be declared, and each may be reported once are enforced by
* throwing rather than by tolerating, because either violation silently breaks the
* arithmetic the declaration exists to fix. An undeclared name adds a row no other run has;
* a repeated one consumes a row that then reads as "not reached". A throw here fails the
* journey it happens in and says exactly what is wrong with it, which is a harness bug
* reported the same way as any other failure.
*/
const check = (name: string, ok: boolean, detail?: string): void => {
if (outstanding === null) {
throw new Error(`[${options.label}] the check ${JSON.stringify(name)} was reported outside any journey`);
}
if (!outstanding.delete(name)) {
throw new Error(
`[${options.label}] the check ${JSON.stringify(name)} was reported but its journey does not ` +
"declare it (or declares it once and reports it twice) — fix the journey's `checks` list",
);
}
record(name, ok, detail);
};
/**
* What "isolated" buys, and what it does not
* It does NOT mean a failure is absorbed a contained failure is still a failure and is
* still counted, here as every one of the journey's declared checks plus the "ran to the
* end" row. What it means is that the journey's failure cannot take the FOLLOWING journeys'
* checks off the report, cannot leave them reporting a timeout that names the wrong suspect,
* and cannot end the run before its summary.
*
* The bound is what makes the catch honest: catching everything and recording a FAIL is
* right for a journey that fails, but a journey that never RETURNS is caught by nothing
* and that is what three killed runs looked like from the outside.
*
* The last row, `ran to the end`, is not decoration either. Without it a journey that throws
* AFTER reporting its last check would report no failure at all, since there would be no
* unreached check left to carry the reason.
*/
const journey = async (spec: JourneySpec): Promise<void> => {
console.log(`\n── ${spec.name} ──`);
const planned = options.journeys.find((j) => j.name === spec.name);
if (planned === undefined) {
throw new Error(
`[${options.label}] the journey ${JSON.stringify(spec.name)} is not declared — add it, or fix the name`,
);
}
const declared = new Set(planned.checks);
if (declared.size !== planned.checks.length) {
throw new Error(`[${options.label}] the same check is declared twice under "${spec.name}"`);
}
reported.add(spec.name);
const journeyStartedAt = Date.now();
let why: string | null = null;
const blocked = (await Promise.all((spec.needs ?? []).map(async (needed) => needed()))).filter(
(r): r is string => r !== null,
);
if (blocked.length > 0) {
why = `it could not start: ${blocked.join("; ")}`;
console.error(` [blocked] ${why}`);
} else {
outstanding = declared;
try {
await within(`the journey "${spec.name}"`, options.journeyBound, spec.run);
} catch (e) {
why = firstLine(e);
// In full, and to stderr: the one-liner above is what the report carries, and it is
// never the whole of a driver's call log or a broker crossing's trail.
console.error(` [threw] ${String((e as Error)?.stack ?? e)}`);
// A known failure mode goes IN FRONT of the reason, never in place of it: the
// operation in flight is still worth having, it is just not the cause.
if (options.diagnose !== undefined) {
const known = await options.diagnose().catch(() => null);
if (known !== null) why = `${known} — the operation it died on: ${why}`;
}
} finally {
outstanding = null;
}
}
for (const name of declared) {
record(name, false, why === null ? "the journey ended without reporting it" : `not reached — ${why}`);
}
record(
`the journey "${spec.name}" ran to the end`,
why === null,
why ?? `${((Date.now() - journeyStartedAt) / 1000).toFixed(1)}s`,
);
};
/**
* The journeys that never ran are read off the declaration, so a run that died in its setup
* reports exactly the same number of checks as one that finished all of them failed, and
* each saying why. "24 checks" and "27 checks" are not two results of the same suite; they
* are two different suites, and comparing them quietly compares nothing.
*/
const finish = (fatal: string | null): never => {
for (const planned of options.journeys) {
if (reported.has(planned.name)) continue;
const why = fatal === null ? "the suite ended before this journey ran" : `the suite died first: ${fatal}`;
for (const name of planned.checks) record(name, false, `not reached — ${why}`);
record(`the journey "${planned.name}" ran to the end`, false, why);
}
// The measurement every bound is sized from, on request. Printed BEFORE the summary so the
// summary stays the last line — which is what a reader and a `tail` look at.
if (timingsWanted()) printTimings();
const failed = results.filter((r) => !r.ok);
if (failed.length > 0) {
console.log("\n── what failed ──");
for (const r of failed) console.log(` ${r.name}${r.detail === undefined ? "" : " — " + r.detail}`);
}
const minutes = ((Date.now() - startedAt) / 60000).toFixed(1);
console.log(
`\n══ ${options.label} summary: ${results.length - failed.length} passed, ${failed.length} failed, ` +
`${results.length} total — ${minutes} min ══`,
);
process.exit(failed.length === 0 ? 0 : 1);
};
return { check, journey, finish };
}
+41
View File
@@ -0,0 +1,41 @@
/**
* Serving an application (or a fixture page) to the browser under test, the way a deployment
* would.
*/
import * as http from "node:http";
import type { Socket } from "node:net";
/**
* Serve `handler` on an ephemeral port, and hand back a close that CLOSES.
*
* `server.close()` alone stops the listener and then waits for every keep-alive connection
* to drain on its own a browser that is still attached keeps the server half-alive long
* after the harness believes it gone. These suites close a server while a browser is still
* pointed at it (the wallet export does exactly that), so the sockets are tracked and
* destroyed: "closed" has to mean closed, or the next thing to go wrong gets blamed on the
* suite instead of on the connection nobody hung up.
*/
export function serveOnEphemeralPort(
handler: (req: http.IncomingMessage, res: http.ServerResponse) => void,
): Promise<{ url: string; close: () => void }> {
const server = http.createServer(handler);
const open = new Set<Socket>();
server.on("connection", (socket) => {
open.add(socket);
socket.on("close", () => open.delete(socket));
});
return new Promise((resolve) => {
server.listen(0, "127.0.0.1", () => {
const port = (server.address() as { port: number }).port;
resolve({
url: `http://127.0.0.1:${port}`,
close: () => {
server.close();
for (const socket of open) socket.destroy();
open.clear();
},
});
});
});
}
@@ -0,0 +1,61 @@
/**
* The page that fetches a wallet's bytes bundled and served by `exportWalletBytes`.
*
* Why a page has to do this at all
* A wallet's bytes exist only inside the broker iframe: `wallet_get_file()` is an RPC to the
* wallet the broker holds, so nothing in Node can produce one. This page is the smallest thing
* that can ask it opens a NextGraph session the way any application does, and exposes one
* function.
*
* It talks to `@ng-org/web` and to nothing else, deliberately: the machinery around it must
* stay usable by an application that has never heard of any particular compatibility layer.
* `init(callback, true, [])` is the shape an application writes; the broker (which loaded this
* page in its iframe) drives the connection and calls back with the session.
*/
import { ng, init } from "@ng-org/web";
/** What crosses back to Node: base64, because a `Uint8Array` does not survive `evaluate`. */
export interface ExportedWallet {
readonly walletName: string;
readonly b64: string;
readonly len: number;
}
/**
* The two calls this page needs, named. A narrow local shape rather than the SDK's own types:
* the wallet functions are not in its published surface at this version, and asserting the two
* signatures we actually use says more than widening everything.
*/
interface WalletFunctions {
get_wallets(): Promise<Record<string, unknown> | null | undefined>;
wallet_get_file(name: string): Promise<Uint8Array | ArrayLike<number>>;
}
const wallet = ng as unknown as WalletFunctions;
const state: { status: string } = { status: "connecting" };
void (async () => {
try {
await init(() => {
state.status = "connected";
}, true, []);
} catch (e) {
state.status = `error: ${e instanceof Error ? e.message : String(e)}`;
}
})();
(globalThis as unknown as { __ngWalletExport: unknown }).__ngWalletExport = {
status: (): string => state.status,
async file(): Promise<ExportedWallet> {
const wallets = await wallet.get_wallets();
const walletName = Object.keys(wallets ?? {})[0];
if (walletName === undefined) throw new Error("no wallet is open in this session");
const file = await wallet.wallet_get_file(walletName);
const bytes = file instanceof Uint8Array ? file : new Uint8Array(Array.from(file));
let binary = "";
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]!);
return { walletName, b64: btoa(binary), len: bytes.length };
},
};
+328
View File
@@ -0,0 +1,328 @@
/**
* The wallet lifecycle: mint one by driving the wallet application's real interface, get its
* bytes out (`.ngw` contents, file optional), and put a `.ngw` file into a browser profile.
*
* Why the real interface and not a shortcut
* A wallet obtained any other way is not the one a person has. The wallet application is an
* application like any other, so this drives it: click for click, field for field. That is
* also what makes the harness notice when the flow upstream changes, instead of testing
* against a fixture that quietly stopped resembling it.
*
* The addresses and selectors are DESCRIPTION and live in `nextgraph-ui.ts`.
*
* On the fixed waits in these flows
* The creation and import flows below contain a handful of `waitForTimeout` calls, each on a
* step where the wallet application offers NO observable signal that the work is finished
* (unlocking a wallet bootstraps the verifier's repos from the broker and paints nothing).
* They are inherited as-is, with their measured durations, and they are the only fixed waits
* in this package everything else waits for a condition. They are the first thing to replace
* if the wallet application ever grows a marker to wait on.
*/
import type { BrowserContext, Page } from "playwright";
import { execSync } from "node:child_process";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { fileURLToPath } from "node:url";
import { closeQuietly, within } from "./deadline";
import { closeContext, launchWatchedContext, newPage } from "./browser";
import { newRunProfile, type RunProfile } from "./profiles";
import { serveOnEphemeralPort } from "./serve";
import { setupBrokerPage } from "./broker";
import { WALLET_APP, WALLET_CREATION, WALLET_IMPORT } from "./nextgraph-ui";
import type { ExportedWallet } from "./wallet-export-page";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
/** `bun build` is a local bundle; a minute is already ten times what it takes. */
const BUILD_MS = 60_000;
/**
* The whole wallet export measures ~7 s against a real broker. Bounded at 60 s 8x.
*
* It was two minutes once, and that cost a run twice over: the export hung, and the suite
* spent two full minutes reaching a verdict it could have reached in one before dying
* without a summary, because an export runs in the SETUP, ahead of every journey.
*/
const EXPORT_MS = 60_000;
/** The export page appearing, then its session connecting. Both against a live broker. */
const EXPORT_PAGE_MS = 30_000;
const EXPORT_CONNECT_MS = 60_000;
/** A wallet's name and the password that opens it. */
export interface WalletCredentials {
readonly name: string;
readonly password: string;
}
/**
* The name a wallet gets when the caller has no opinion about it.
*
* A wallet's name is what a person types to pick one among several, and nothing in NextGraph
* keys off it so a caller minting the only wallet it will ever hold has nothing to decide
* here. The PASSWORD never gets a default: it is the only thing between the file and whoever
* finds it, and a defaulted one would be a published secret.
*/
export const DEFAULT_WALLET_NAME = "ng-wallet";
/**
* Create a wallet in `ctx`'s profile by walking the wallet application, then unlock it once.
*
* The first unlock is not decoration: it is what bootstraps the verifier's repos from the
* broker, and a wallet that has never been unlocked is not usable by an application.
*/
export async function createWalletInContext(ctx: BrowserContext, credentials: WalletCredentials): Promise<void> {
const page = ctx.pages()[0] ?? (await newPage("the wallet creation flow", ctx));
page.on("pageerror", () => {});
await page.goto(WALLET_APP.home, { waitUntil: "domcontentloaded", timeout: 30000 });
const createButton = page.getByText(WALLET_CREATION.createWallet, { exact: true });
await createButton.waitFor({ state: "visible", timeout: 15000 });
await createButton.click();
await page.waitForURL(WALLET_CREATION.termsRoute, { timeout: 15000 }).catch(() => {});
const acceptButton = page.getByText(WALLET_CREATION.acceptTerms, { exact: true });
await acceptButton.waitFor({ state: "visible", timeout: 15000 });
await acceptButton.click();
const usernameInput = page.locator(WALLET_CREATION.username);
await usernameInput.waitFor({ state: "visible", timeout: 30000 });
await usernameInput.fill(credentials.name);
const passwordInput = page.locator(WALLET_CREATION.password);
await passwordInput.waitFor({ state: "visible", timeout: 5000 });
await passwordInput.fill(credentials.password);
const submitButton = page.getByText(WALLET_CREATION.submit, { exact: false });
await submitButton.waitFor({ state: "visible", timeout: 5000 });
await submitButton.click();
await page.waitForURL(WALLET_CREATION.landsOn, { timeout: 30000 });
await page.waitForTimeout(2000);
// First login → bootstrap the verifier repos from the broker. This is what a brand-new
// wallet does on its very first unlock.
const walletLink = page.getByText(WALLET_CREATION.loginWithThisWallet);
if (await walletLink.isVisible({ timeout: 5000 }).catch(() => false)) {
await walletLink.click();
await page.waitForTimeout(1000);
}
const loginPassword = page.locator(WALLET_CREATION.passwordField);
await loginPassword.waitFor({ state: "visible", timeout: 10000 });
await loginPassword.fill(credentials.password);
await loginPassword.press("Enter");
await page.waitForTimeout(10000);
}
/**
* This run's physical user: a profile of its own, and a wallet minted into it. The creation
* context is closed the caller opens its own contexts over `profile.dir`, one at a time.
*
* One per run, never inherited from a previous one: see `profiles.ts` for why that is a
* property of the directory rather than a rule anyone has to remember.
*/
export async function mintWalletProfile(purpose: string, credentials: WalletCredentials): Promise<RunProfile> {
const profile = newRunProfile(purpose);
const ctx = await launchWatchedContext("wallet-creation", profile.dir);
try {
await createWalletInContext(ctx, credentials);
} finally {
const { closeContext } = await import("./browser");
await closeContext("wallet-creation", ctx);
}
return profile;
}
/**
* The same, with the context left OPEN.
*
* For the cold-start case: the caller then opens its application in the SAME profile, i.e. the
* very first application session over a wallet that has never run one. Closing and relaunching
* would not be the same thing.
*/
export async function mintWalletProfileKeepingContext(
purpose: string,
credentials: WalletCredentials,
): Promise<{ ctx: BrowserContext; profile: RunProfile }> {
const profile = newRunProfile(purpose);
const ctx = await launchWatchedContext("fresh-wallet", profile.dir);
await createWalletInContext(ctx, credentials);
const first = ctx.pages()[0];
if (first !== undefined) await first.close().catch(() => {});
return { ctx, profile };
}
/**
* A context on an EMPTY profile: no wallet, no local repo cache.
*
* Empty local storage empty verifier repo cache the reconnection cold-start: a wallet's
* repos are on the broker but NOT in this profile, so a session over it starts with nothing
* local. The caller imports a wallet (see {@link importWalletFile}) before opening the
* application.
*/
export async function emptyProfileContext(
purpose: string,
): Promise<{ ctx: BrowserContext; profile: RunProfile }> {
const profile = newRunProfile(purpose);
const ctx = await launchWatchedContext("clean-profile", profile.dir);
return { ctx, profile };
}
/**
* Import a `.ngw` wallet FILE into the profile `page` belongs to, then unlock it.
*
* After this the profile holds the wallet but NOT the repos' local cache so the next
* application session over it hits the broker-only cold-start.
*
* The password is a PARAMETER and has no default. An access barrier that DISPLAYS a password
* can then be tested by reading it off its own screen and passing it here, which is the only
* way to tell that what the barrier shows is what actually opens the file. A default would
* make that step untestable: the import would succeed on a barrier showing anything at all,
* including nothing.
*/
export async function importWalletFile(page: Page, ngwPath: string, password: string): Promise<void> {
await page.goto(WALLET_APP.login, { waitUntil: "domcontentloaded" });
// Let the application render and attach the file input (uploading too early → EncryptionError).
await page.waitForTimeout(3000);
await page.locator(WALLET_IMPORT.fileInput).waitFor({ state: "attached", timeout: 15000 });
await page.setInputFiles(WALLET_IMPORT.fileInput, ngwPath);
const passwordInput = page.locator(WALLET_IMPORT.passwordField).first();
await passwordInput.waitFor({ state: "visible", timeout: 15000 });
await passwordInput.fill(password);
await passwordInput.press("Enter");
const confirm = page.getByRole("button", { name: WALLET_IMPORT.confirm });
if (await confirm.isVisible({ timeout: 2000 }).catch(() => false)) await confirm.click().catch(() => {});
await page.waitForTimeout(8000); // unlock + verifier bootstrap from the broker
}
/** The export page, bundled once per run. */
let exportBundle: string | null = null;
function buildExportBundle(): string {
if (exportBundle !== null) return exportBundle;
const out = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "ng-e2e-export-")), "wallet-export-page.js");
const entry = path.join(__dirname, "wallet-export-page.ts");
execSync(`bun build ${entry} --outfile ${out} --bundle --format=esm`, {
stdio: "pipe",
cwd: __dirname,
timeout: BUILD_MS,
});
exportBundle = fs.readFileSync(out, "utf-8");
return exportBundle;
}
/**
* The bytes of the wallet held by `ctx`'s profile the contents of a `.ngw` file, without a
* `.ngw` file.
*
* Why an application's own suite needs this: a deployment that hands a wallet out an access
* barrier with a download link, say must be tested against a REAL wallet. Serving a
* placeholder there makes the download step a decoration: importing it cannot let anybody in,
* so the check that the link works cannot fail for the right reason.
*
* Why BYTES are the primitive and the file the convenience
* Every caller here already has the bytes in hand they arrive from the broker iframe and
* only some of them want a file. This used to write one unconditionally, so a suite that
* serves a wallet from memory had to name a temp path, write it, read it straight back and
* remember to remove it: three steps and a cleanup to get back what the function had. A caller
* that genuinely wants a file writes these bytes (or calls {@link exportWalletFile}), which is
* one step in the direction nobody has to undo.
*/
export async function exportWalletBytes(
ctx: BrowserContext,
walletPassword: string,
): Promise<Uint8Array> {
const bundle = buildExportBundle();
const html =
'<!DOCTYPE html><html><head><meta charset="utf-8"><title>wallet export</title></head>' +
'<body><script type="module" src="/wallet-export-page.js"></script></body></html>';
const { url, close } = await serveOnEphemeralPort((req, res) => {
if (req.url === "/wallet-export-page.js") {
res.writeHead(200, { "Content-Type": "application/javascript; charset=utf-8" });
res.end(bundle);
} else {
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
res.end(html);
}
});
const page = await newPage("the wallet export", ctx);
page.on("pageerror", () => {});
try {
const frame = await setupBrokerPage(page, url, walletPassword);
await frame.waitForFunction(
() => (window as unknown as { __ngWalletExport?: unknown }).__ngWalletExport !== undefined,
{ timeout: EXPORT_PAGE_MS },
);
await frame.waitForFunction(
() => (window as unknown as { __ngWalletExport: { status(): string } }).__ngWalletExport.status() === "connected",
{ timeout: EXPORT_CONNECT_MS },
);
// `frame.evaluate` has NO timeout of its own — a bridge call that never settles is one of
// the two ways this harness used to hang for ever.
const exported = await within("the wallet bytes from the broker iframe", EXPORT_MS, () =>
frame.evaluate(
() =>
(
window as unknown as { __ngWalletExport: { file(): Promise<ExportedWallet> } }
).__ngWalletExport.file(),
),
);
return new Uint8Array(Buffer.from(exported.b64, "base64"));
} finally {
await closeQuietly("the wallet export page", () => page.close());
close();
}
}
/**
* The same wallet, written to `ngwPath`, returning its size in bytes.
*
* For the callers that want a FILE a browser's file input takes a path, and a person
* provisioning a deployment has to put the wallet somewhere. Everyone else takes the bytes.
*/
export async function exportWalletFile(
ctx: BrowserContext,
ngwPath: string,
walletPassword: string,
): Promise<number> {
const bytes = await exportWalletBytes(ctx, walletPassword);
fs.writeFileSync(ngwPath, bytes);
return bytes.length;
}
/**
* A wallet that did not exist a moment ago, as the bytes of a `.ngw`: mint one by walking the
* wallet application, take its bytes out, and drop everything else.
*
* The one call behind all three callers who need a fresh wallet this package's own suite, a
* consuming application's suite, and a person provisioning a deployment (see `bin/mint-wallet.ts`,
* which is this function plus a `writeFileSync` and two `console.log`s).
*
* What it deliberately does NOT keep
* The browser profile the wallet was minted in is discarded here. That is not a shortcut: the
* wallet's repos live on the broker and the bytes returned are what opens them, so the profile
* is scaffolding in every one of the three cases including the durable one, where what is
* kept is the FILE the caller writes, never a browser directory.
*
* A suite that needs the profile to survive because its actors sign in THROUGH it rather than
* importing the file composes the two halves itself: {@link mintWalletProfile} for a profile
* that lasts the run, then {@link exportWalletBytes} over it. Same minting, same export,
* different lifetime, which is the only thing the two paths disagree about.
*/
export async function mintWalletBytes(
password: string,
name: string = DEFAULT_WALLET_NAME,
): Promise<Uint8Array> {
const profile = await mintWalletProfile(`minting the wallet "${name}"`, { name, password });
try {
// A context of its own over the profile, opened after the creation one closed — the order
// `mintWalletProfile` already imposes, and the one every export in this repository uses.
const ctx = await launchWatchedContext("wallet-export", profile.dir);
try {
return await exportWalletBytes(ctx, password);
} finally {
await closeContext("wallet-export", ctx);
}
} finally {
profile.discard();
}
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"types": ["bun"],
"noEmit": true
},
"include": ["src", "bin"]
}
+124
View File
@@ -0,0 +1,124 @@
# @ng-eventually/polyfill
One entry point. Most of what it publishes has the same signature as the future SDK —
`ng`, `useShape`, `watchShape`, `docs`, `inbox`, `storeRegistry`, `readUnion` (+ types) —
and is a drop-in for `@ng-org/web` / `@ng-org/orm`: as NextGraph matures it resolves to
the real SDK (build alias removed) with no code change.
**One call does not, and it is the whole of what you will delete:** `configure`. It
exists because one shared wallet hosts every user; upstream, an application imports the
SDK and each user opens their own wallet. `src/index.ts` groups it under a heading that
says so. (`ensureIdentity` is a second in substance — the shared-wallet gate — but its
call site survives: an application still awaits a session before it renders.)
*(There were two entry points until 2026-08-07, `.` and `./polyfill`, and the second one
WAS that list. One door is easier to import from and says less — hence the grouping, and
hence `docs/api-contract.md`, whose export inventory a test keeps honest.)*
Per-symbol, with the target signature and an epistemic label on every claim:
[`docs/api-contract.md`](../../docs/api-contract.md).
> **Reading is key possession, and the isolation here is still fake.** The cap surface
> has the shape of the real model — you hold a document's `ReadCap` or you do not read
> it, and there is no authorization list anywhere — but nothing is encrypted yet and
> the stand-in key is a constant. Nothing this library does may be described as
> "anonymous" or "private" until cap-enforcement lands per-document encryption.
```ts
import {
// SDK-shaped — the real SDK replaces these in place.
ensureIdentity, storeRegistry, inbox, readUnion, docs,
// Polyfill-era — one call, and it is the whole of what goes away.
configure,
} from "@ng-eventually/polyfill";
configure({ ng: realNg, useShape: realUseShape, getSession, sharedWallet });
await ensureIdentity(); // who I am (returned), connection work awaited
const doc = await storeRegistry.createEntityDoc("protected");
await docs.sparqlUpdate(sid, `INSERT DATA { … }`, doc);
const subjects = await readUnion(await storeRegistry.listMyEntityDocs("protected"));
```
## Principle — the polyfill compensates, it never extends
**Its only reason to exist is to bridge a NextGraph implementation gap.** Every
non-SDK surface must map to something NextGraph will provide natively, and must fall
away at that point — no bespoke features, no observability, no convenience API that
isn't strictly *"NextGraph will do this later"*. The test for any proposed addition:
*does it compensate a real, exhibited gap?* If not, it belongs in the consumer
application. And a compensation whose gap is not actually exhibited on the target
broker is dead weight, not defensive code.
Both halves are binding — **the surface AND the implementation** stay as close as
possible to what NextGraph plans. The question to ask at every choice: *would this
make a caller learn something it has to UNLEARN at migration?* If yes, it is a
deviation, whatever it buys.
What the polyfill adds, each emulated now and native later:
- **Shared-wallet identity** — one wallet hosts every user, so the library fabricates
*virtual users* and confines every access to the connected one
(`emulated-verifier/reach.ts`). Upstream, each user opens their own wallet.
- **Capability emulation** — per-identity cap possession plus a read filter over it:
you read the documents whose cap you hold. There is no authorization list, because
the real model has none.
- **Inbox**`post`, `postToDocument`, `share`, and the recipient's processing.
The model is verified (an inbox is a keypair on one repo); no JS surface exists yet.
Generic by construction: no application domain here. See
[`examples/notebook`](../../examples/notebook) for an application written against it,
which the e2e suite drives.
## How a document is reached — the three acts, and no others
```ts
import { storeRegistry, inbox, readUnion } from "@ng-eventually/polyfill";
// 1. CREATE — you hold its cap, with nothing to declare. No identity parameter: a
// session belongs to one user, exactly as the target's own `doc_create` assumes.
const doc = await storeRegistry.createEntityDoc("protected");
// 2. GIVE TO READ — name the document and the person. The key is looked up and
// sealed into a deposit; the recipient applies it by connecting, with nothing
// to call. Irreversible: there is no revoking a key already handed out.
await inbox.share(doc, "bob");
// 3. CIRCULATE THE REFERENCE — no call at all. Every reference this surface returns
// is BARE: it names the document and grants nothing. If the document sits in a
// PUBLIC store, the store serves its read cap to whoever asks, so the bare
// reference is enough to read it — and if it does not, the reference still names
// it and opens nothing.
const publicDoc = await storeRegistry.createEntityDoc("public");
// …put `publicDoc` in a QR code, a message, another document. Nothing else to do.
await readUnion([publicDoc]); // a stranger holding only this reads it
```
**The invariant behind all three: you never derive a cap from a bare reference.** You
look it up in what you hold, you were given it, or a public store served it. A
`did:ng:o:…` without `:r:` names a document and opens nothing — which is what makes
confidentiality composable: a widely circulated document may point at a restricted
one, and following the reference gets you a name, not a key. See
[`docs/readcap-and-nuri-model.md`](../../docs/readcap-and-nuri-model.md) § 0.
## 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.
**Permissive in, precise out.** Public entries take `NuriLike` (`Nuri | string`) and
validate at the door, so a value coming from storage, a URL or a form needs no
narrowing and no cast on your side; what they *return* is a precise `Nuri`. The
runtime checks stay regardless — a JavaScript caller never meets the compiler.
```ts
const saved = localStorage.getItem("doc"); // string | null
if (saved) await readUnion([saved]); // ✓ validated at the door
```
@@ -1,10 +1,10 @@
# SDK reference — reading data with `@ng-eventually/client` # Polyfill reference — reading data with `@ng-eventually/polyfill`
**Audience:** anyone using `@ng-eventually/client` (the app that consumes it, and **Audience:** anyone using `@ng-eventually/polyfill` (the app that consumes it, and
the lib itself when honoring the contract). This is the reference on the SDK's the lib itself when honoring the contract). This is the reference on the polyfill's
**read/reactivity surface** — how you read data and how a read stays live. **read/reactivity surface** — how you read data and how a read stays live.
`@ng-eventually/client` is written and consumed as if NextGraph were a **finished, `@ng-eventually/polyfill` is written and consumed as if NextGraph were a **finished,
mature SDK**: documents per entity placed by scope, capabilities, inboxes, and a mature SDK**: documents per entity placed by scope, capabilities, inboxes, and a
**reactive ORM**. This file documents that finished-SDK contract. Where today's **reactive ORM**. This file documents that finished-SDK contract. Where today's
emulation does not yet deliver it, that is called out in one clearly-separated emulation does not yet deliver it, that is called out in one clearly-separated
@@ -27,7 +27,7 @@ cited by `file:symbol` throughout so a future agent can re-verify cheaply.
> reads are the exception, not the rule.** > reads are the exception, not the rule.**
```ts ```ts
import { useShape } from "@ng-eventually/client"; import { useShape } from "@ng-eventually/polyfill";
import { EventShapeType } from "…/shapes/orm/…"; import { EventShapeType } from "…/shapes/orm/…";
function EventList() { function EventList() {
@@ -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.
@@ -105,9 +108,9 @@ and every subsequent patch to a `DeepSignalSet`
`useDeepSignal` (`@ng-org/alien-deepsignals/react`). Vue and Svelte adapters exist `useDeepSignal` (`@ng-org/alien-deepsignals/react`). Vue and Svelte adapters exist
alongside the React one (`sdk/js/orm/src/frontendAdapters/{vue,svelte}/`). alongside the React one (`sdk/js/orm/src/frontendAdapters/{vue,svelte}/`).
`@ng-eventually/client` re-exports `useShape` from `@ng-eventually/polyfill` re-exports `useShape` from
[`../src/use-shape.ts`](../src/use-shape.ts); import it from the SDK [`../src/surface/use-shape.ts`](../src/surface/use-shape.ts); import it from the SDK
(`@ng-eventually/client`), never from `@ng-org/orm` directly. (`@ng-eventually/polyfill`), never from `@ng-org/orm` directly.
### What you get, in order ### What you get, in order
@@ -158,14 +161,14 @@ computes a result and returns once (`sparql_query`,
`sdk/js/lib-wasm/src/lib.rs:352`/`553`; no "subscribe to a query" exists — `sdk/js/lib-wasm/src/lib.rs:352`/`553`; no "subscribe to a query" exists —
`sparql_query` is not reactive). `sparql_query` is not reactive).
In `@ng-eventually/client` the one-shot read is exposed as: In `@ng-eventually/polyfill` the one-shot read is exposed as:
- **`docs.sparqlQuery(sid, query, base?, anchor?)`** — a raw anchored SPARQL query - **`docs.sparqlQuery(sid, query, base?, anchor?)`** — a raw anchored SPARQL query
([`../src/docs.ts`](../src/docs.ts)). `anchor` = the document NURI to read; the ([`../src/surface/docs.ts`](../src/surface/docs.ts)). `anchor` = the document NURI to read; the
anchor restricts the query to that one repo's graph. anchor restricts the query to that one repo's graph.
- **`readModel.readUnion(docs)`** — read a **bounded, by-need set** of document NURIs, - **`readUnion(docs)`** — read a **bounded, by-need set** of document NURIs,
each with its own anchored query, grouped per subject each with its own anchored query, grouped per subject
([`../src/read-model.ts`](../src/read-model.ts)). This is the polyfill's listing ([`../src/surface/read-model.ts`](../src/surface/read-model.ts)). This is the polyfill's listing
primitive (see [§ Current emulation status](#current-emulation-status) and primitive (see [§ Current emulation status](#current-emulation-status) and
[`read-model.md`](../../../docs/read-model.md)). [`read-model.md`](../../../docs/read-model.md)).
@@ -182,7 +185,7 @@ You do not need the write internals to read, but reads and writes share the same
document model, so briefly: document model, so briefly:
- **Create a document:** `docs.docCreate(sid, crdt, cls, dest, store?)` - **Create a document:** `docs.docCreate(sid, crdt, cls, dest, store?)`
([`../src/docs.ts`](../src/docs.ts)) — mirrors `ng.doc_create`. **One document = one ([`../src/surface/docs.ts`](../src/surface/docs.ts)) — mirrors `ng.doc_create`. **One document = one
repo** (`did:ng:o:<RepoID>`); there is no separate `Document` type. repo** (`did:ng:o:<RepoID>`); there is no separate `Document` type.
- **Write into it:** `docs.sparqlUpdate(sid, query, anchor)` — a SPARQL - **Write into it:** `docs.sparqlUpdate(sid, query, anchor)` — a SPARQL
`INSERT/DELETE` scoped to the anchor document's graph. Or, at the ORM layer, the ORM `INSERT/DELETE` scoped to the anchor document's graph. Or, at the ORM layer, the ORM
@@ -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
@@ -244,7 +273,7 @@ helpers live in the consumer app; the SDK exposes the generic reactive/by-need r
> [`read-model.md`](../../../docs/read-model.md), > [`read-model.md`](../../../docs/read-model.md),
> [`simulation.md`](../../../docs/simulation.md). > [`simulation.md`](../../../docs/simulation.md).
Today, on a single shared wallet emulating the mature platform, three gaps diverge Today, on a single shared wallet emulating the mature platform, four gaps diverge
from the reactive contract: from the reactive contract:
1. **Entity-list reads are one-shot, not reactive.** The reactive ORM cannot be used 1. **Entity-list reads are one-shot, not reactive.** The reactive ORM cannot be used
@@ -255,7 +284,7 @@ from the reactive contract:
`engine/verifier/src/request_processor.rs` `resolve_target` `engine/verifier/src/request_processor.rs` `resolve_target`
`self.repos.get(...).ok_or(RepoNotFound)`; see `self.repos.get(...).ok_or(RepoNotFound)`; see
[`nextgraph-current-state.md`](../../../docs/nextgraph-current-state.md) § *The ORM [`nextgraph-current-state.md`](../../../docs/nextgraph-current-state.md) § *The ORM
fan-out hang*). So the lib reads entity lists with **`readModel.readUnion`** — a fan-out hang*). So the lib reads entity lists with **`readUnion`** — a
bounded set of one-shot anchored `sparql_query`s bounded set of one-shot anchored `sparql_query`s
([`read-model.md`](../../../docs/read-model.md)) — and reassembles reactivity by ([`read-model.md`](../../../docs/read-model.md)) — and reassembles reactivity by
**re-querying on a change signal** (a lightweight `doc_subscribe` / single-store ORM **re-querying on a change signal** (a lightweight `doc_subscribe` / single-store ORM
@@ -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/surface/inbox.ts`](../src/surface/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)`,
@@ -279,5 +307,38 @@ from the reactive contract:
queryable. At the multi-store migration, opening a repo by cap becomes a native queryable. At the multi-store migration, opening a repo by cap becomes a native
broker sync and the anchored read is unchanged. broker sync and the anchored read is unchanged.
4. **The subscription may not echo the writer's OWN local commit — HYPOTHESIS
(high-confidence), confirmation in progress (2026-07-18); NOT confirmed, NOT
fixed.** Unlike gaps 13 (designed emulation stopgaps), this is a suspected
defect in the polyfill's own reactive assembly. When a client does a local
`sparqlUpdate` on a doc it is itself subscribed to (`subscribeDoc` /
`ng.doc_subscribe`), the subscription callback appears NOT to fire for its OWN
local commit in the same session — so the reactive re-read chain
([`../src/surface/watch-shape.ts`](../src/surface/watch-shape.ts) `watchShape``reread`
[`../src/surface/read-model.ts`](../src/surface/read-model.ts) `readUnion`) never runs, and
consumers keep the STALE value until the next connection delivers a fresh
initial `State`. **Remote** commits DO push correctly (verified: cross-browser
reactive update works). A code review verified the consumer wiring is correct,
the doc IS in the subscribed set, and a triggered re-read WOULD return the new
value — leaving the self-commit echo as the only suspect link. That link is
**INFERRED**, not observed: the real `ng.doc_subscribe` runtime is not readable
from source, and [`../src/surface/subscribe.ts`](../src/surface/subscribe.ts)'s own doc-comment
CLAIMS local writes push a `Patch` — contradicted by the observation. (This
also sits in tension with § *The reactivity model* above, which documents the
target contract — one commit, every subscriber pushed, local or remote.) The
requirement at stake is multi-user: a value change (e.g. a participant count)
must propagate reactively to ALL viewers — other viewers (remote push, which
works) AND the writer's own view (this suspect link). **Treatment (PLANNED,
not done):** confirm first via the temporary instrumentation just added
([`../src/surface/subscribe.ts`](../src/surface/subscribe.ts) ≈`:119` logs
`doc_subscribe FIRE <nuri> (State|Patch)`;
[`../src/surface/watch-shape.ts`](../src/surface/watch-shape.ts) ≈`:341` logs
`reread TRIGGER by <nuri>` — line numbers volatile, grep the log strings);
then, IF confirmed, fix **polyfill-side** — a
local commit should notify the doc's active `subscribeDoc` callbacks.
Consumers must not compensate. Short entry:
[`nextgraph-current-state.md`](../../../docs/nextgraph-current-state.md) §
*Known open issues*.
When these gaps close, the read path collapses to the reference above: `useShape` When these gaps close, the read path collapses to the reference above: `useShape`
everywhere, push everywhere, no polling and no re-query-on-signal assembly. everywhere, push everywhere, no polling and no re-query-on-signal assembly.
+88
View File
@@ -0,0 +1,88 @@
/**
* What is POLYFILL-SPECIFIC in these suites' setup: the harness page and the wallet this
* repository's runs use.
*
* Everything generic the wallet lifecycle, the broker crossing, profiles, bounds, the report
* shape, the recognition of the known failure modes lives in `ng-e2e-helpers`, which knows
* nothing about this package and must keep knowing nothing about it: the polyfill is designed
* to DISAPPEAR at migration, and that machinery talks about NextGraph itself, so it outlives
* it. What is left here is the two things that genuinely belong to the polyfill: the page that
* exposes its surface to a browser (`polyfill-entry.ts`), and the name of the wallet its runs
* mint.
*/
import { execSync } from "node:child_process";
import * as fs from "node:fs";
import * as path from "node:path";
import { fileURLToPath } from "node:url";
import {
mintWalletProfile,
serveOnEphemeralPort,
type RunProfile,
type WalletCredentials,
} from "ng-e2e-helpers";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
/**
* The throwaway credentials every suite in this package mints its own run's wallet with.
*
* A NAME, not an identity that survives: each run gets a profile of its own and mints this
* wallet into it, so two runs sharing the name share nothing else. See `ng-e2e-helpers`'
* `profiles.ts` for why one physical user per run is the rule, and why it is now a property of
* the directory rather than something a lock had to enforce.
*
* Which is also why the password can sit in a source file in the clear: it opens a wallet that
* exists for the length of one run and is deleted with the profile that holds it. A wallet
* meant to LAST the one a deployment serves must never be minted with these; it gets its
* own, chosen by whoever provisions it (`ng-e2e-helpers`' `bin/mint-wallet.ts`).
*/
export const WALLET: WalletCredentials = {
name: "ng-eventually-e2e",
password: "ng-eventually-e2e",
};
/**
* This run's physical user, in a profile of its own.
*
* The credentials are a PARAMETER defaulting to this package's throwaway pair, not a constant
* baked into the call: minting is the same work whoever wants it, and a caller that needs its
* own anyone provisioning a wallet that outlives a run must not have to reach for a
* different function to get it.
*/
export function mintBatchWallet(
suite: string,
credentials: WalletCredentials = WALLET,
): Promise<RunProfile> {
return mintWalletProfile(suite, credentials);
}
/** `bun build` is a local bundle; a minute is already ten times what it takes. */
const BUILD_MS = 60_000;
const ENTRY = path.resolve(__dirname, "polyfill-entry.ts");
const BUNDLE_OUT = path.resolve(__dirname, ".dist", "polyfill-entry.js");
export function buildBundle(): void {
fs.mkdirSync(path.dirname(BUNDLE_OUT), { recursive: true });
execSync(`bun build ${ENTRY} --outfile ${BUNDLE_OUT} --bundle --format=esm`, {
stdio: "pipe",
cwd: path.resolve(__dirname, ".."),
timeout: BUILD_MS,
});
}
/** Serve the harness page — the polyfill's surface, reachable from Playwright as `window.__sdk`. */
export function serveHarness(): Promise<{ url: string; close: () => void }> {
const bundle = fs.readFileSync(BUNDLE_OUT, "utf-8");
const html = `<!DOCTYPE html><html><head><meta charset="utf-8"><title>ng-eventually polyfill e2e</title></head><body><div id="root"></div><script type="module" src="/polyfill-entry.js"></script></body></html>`;
return serveOnEphemeralPort((req, res) => {
if (req.url === "/polyfill-entry.js") {
res.writeHead(200, { "Content-Type": "application/javascript; charset=utf-8" });
res.end(bundle);
} else {
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
res.end(html);
}
});
}
File diff suppressed because it is too large Load Diff
@@ -1,7 +1,7 @@
/** /**
* SDK e2e harness entry the MINIMAL page loaded inside the broker iframe. * Polyfill e2e harness entry the MINIMAL page loaded inside the broker iframe.
* *
* It imports the REAL `@ng-org/web` `ng` + this package (`@ng-eventually/client`), * It imports the REAL `@ng-org/web` `ng` + this package (`@ng-eventually/polyfill`),
* configures the polyfill session injection exactly the way a consumer does * configures the polyfill session injection exactly the way a consumer does
* (`configure` + `configureStoreRegistry`), waits for the real broker to hand back * (`configure` + `configureStoreRegistry`), waits for the real broker to hand back
* a session, then exposes `window.__sdk`: a flat bag of async methods the * a session, then exposes `window.__sdk`: a flat bag of async methods the
@@ -15,22 +15,59 @@
*/ */
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 { import {
configure,
docs, docs,
init,
subscribeDoc, subscribeDoc,
subscribeDocs, subscribeDocs,
readModel, readUnion,
inbox, inbox,
discovery,
storeRegistry, storeRegistry,
useShape as libUseShape, useShape as libUseShape,
watchShape, watchShape,
accounts, } from "@ng-eventually/polyfill";
} from "@ng-eventually/client"; // The harness tests the LIBRARY, so it legitimately reaches machinery a consumer
import type { ShapeObservable, ShapeQuery } from "@ng-eventually/client"; // 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/shared-wallet/account-registry";
// The harness plays SEVERAL identities on one page — something no application does, and
// the reason `setCurrentUser` / `configureStoreRegistry` are no longer published. It
// reaches them by their internal path, like the rest of its machinery.
import {
adoptCurrentUser,
configureStoreRegistry,
setCurrentUser,
getCaps,
getCurrentUser,
resetCaps,
} from "../src/shared-wallet/bootstrap";
import { connectedUser } from "../src/emulated-verifier/connect";
import * as virtualUsers from "../src/shared-wallet/virtual-users";
import { ensureIdentity } from "@ng-eventually/polyfill";
// The harness narrows for its OWN assertions; a consumer never has to (the entries take
// plain strings and validate inside). Internal path, like the rest of its machinery.
import { isNuri } from "../src/model/nuri";
import type { Nuri, ShapeObservable, ShapeQuery } from "@ng-eventually/polyfill";
const { IdentityStore } = accounts; const { IdentityStore } = virtualUsers;
/**
* 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 {
@@ -66,10 +103,39 @@ configure({
ng: realNg, ng: realNg,
useShape: fakeUseShape, useShape: fakeUseShape,
init: realInit, init: realInit,
// The harness already holds an open wallet (the suite imports it itself), so it never
// needs the gate's assisted import. These values exist only so the gate has something
// to render when the access-gate steps exercise it — they are never used to import.
sharedWallet: { fileUrl: "/harness-not-used.ngw", password: "harness" },
}); });
/**
* Who this page opens as and why it has to open as somebody.
*
* The boot below goes through the POLYFILL's `init`, not the injected one, so that the
* 42 checks run over the ordering an application actually gets: settle the identity, THEN
* hand the page to the broker (`surface/lifecycle.ts`). A page with no identity would
* raise the barrier instead and never hand over, so the harness supplies one.
*
* Recorded with `adoptCurrentUser`, which names the identity and stops there the
* session-free half, the same one the access gate settles with (`bootstrap.ts`). Naming it
* through `setCurrentUser` would FIRE the connection work: it costs the batch an account
* lookup and a broker round-trip for a boot identity nothing reads, and it registers a
* connection in flight for a user that does not exist. It used to fire nothing here for an
* incidental reason the registry was still unwired at this line and `configure` wires
* it now, so the intent is stated by the call instead of by the ordering. Every check that
* cares about identity sets its own anyway; this one is only what the page opened as.
*/
const BOOT_IDENTITY = "e2e-harness";
adoptCurrentUser(BOOT_IDENTITY);
// The harness keeps its OWN route to the session, substituted through the internal wiring
// path AFTER `configure` has pointed the registry at the package's. Not redundancy: the
// checks below tear a session down and start another (`session_stop` + `session_start`, the
// reconnection cold-start), and only this page knows about the second one — the package's
// holder is fed by `init()`'s callback, which the broker fires once per page.
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
@@ -101,7 +167,7 @@ configureStoreRegistry({
const state: { status: string; error?: string } = { status: "connecting" }; const state: { status: string; error?: string } = { status: "connecting" };
// Identity store over the iframe's localStorage (the real AccountStorage). // Identity store over the iframe's localStorage (the real VirtualUserStorage).
const identity = new IdentityStore( const identity = new IdentityStore(
typeof window !== "undefined" && window.localStorage ? window.localStorage : null, typeof window !== "undefined" && window.localStorage ? window.localStorage : null,
); );
@@ -137,6 +203,65 @@ const identity = new IdentityStore(
return { walletName, b64: btoa(bin), len: bytes.length }; return { walletName, b64: btoa(bin), len: bytes.length };
}, },
/**
* THE ACCESS GATE, in a real browser ported from the consumer's
* `barriere-acces-identifiant` feature, which the library took over with the flow.
*
* Unit tests pin the resolution ORDER (`test/access-gate.test.ts`); only a real DOM can
* pin the barrier itself: that it appears on a first access, that entering a value
* settles the identity normalized, and that it stands aside once the identity is known.
*
* That last one holds HERE because this page runs inside the broker iframe, and only
* there. Top-level the barrier shows on every load a known identifier prefills the
* field instead of skipping the screen, since knowing who someone is says nothing about
* whether their browser still holds the wallet, and the broker's answer for someone who
* does not is a dead end with no way back. That side is the applicative suite's to walk
* (`e2e/notebook.ts`), with a real page and a real Back button.
*/
async accessGateFirstVisit(raw: string) {
setCurrentUser(null);
try { window.localStorage.removeItem("ng-eventually:identity"); } catch {}
// A first visit has no `?ng-id=` either, and the URL is the branch the gate consults
// FIRST — so clearing storage alone stopped describing a first visit the moment the
// boot started settling an identity (which writes the parameter, as every settling
// path must). Leaving it there would make this check pass for the wrong reason on a
// gate that had stopped asking at all.
try {
const withoutIdentity = new URL(window.location.href);
withoutIdentity.searchParams.delete("ng-id");
window.history.replaceState(null, "", withoutIdentity.toString());
} catch {}
const done = ensureIdentity();
const gate = document.querySelector('[data-ng-eventually="access-gate"]');
const root = gate?.shadowRoot ?? null;
const input = root?.querySelector("input") as HTMLInputElement | null;
const button = root?.querySelector("button.go") as HTMLButtonElement | null;
const shown = input !== null && button !== null;
const disabledWhenEmpty = button?.disabled ?? null;
if (input && button) {
input.value = raw;
input.dispatchEvent(new Event("input"));
button.click();
}
await done;
return {
shown,
disabledWhenEmpty,
identity: getCurrentUser(),
stillMounted: document.querySelector('[data-ng-eventually="access-gate"]') !== null,
};
},
/** Inside the iframe, the barrier must stay away once an identity is known. */
async accessGateIdentityAlreadyKnown(known: string) {
setCurrentUser(known);
await ensureIdentity();
return {
shown: document.querySelector('[data-ng-eventually="access-gate"]') !== null,
identity: getCurrentUser(),
};
},
// ── docs primitives ────────────────────────────────────────────────────── // ── docs primitives ──────────────────────────────────────────────────────
async docCreate() { async docCreate() {
const s = await sessionReady; const s = await sessionReady;
@@ -144,11 +269,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 +377,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,24 +387,44 @@ const identity = new IdentityStore(
); );
docNuris.push(d); docNuris.push(d);
} }
const toRead = includeBad ? [...docNuris, "did:ng:o:definitely-not-a-real-doc-xyz"] : docNuris; // Two SUBJECTS in the LAST document, so the probe can tell "one entry per document"
const subjects = await readModel.readUnion(toRead); // from "one entry per subject". Until 2026-08-10 `readUnion` folded every triple of a
return { docNuris, subjectCount: subjects.length, subjects }; // document into one bag keyed by the document, and this step stayed green throughout
// because it only ever counted entries and never looked at what they were.
if (n > 0) {
await docs.sparqlUpdate(
s.session_id,
`INSERT DATA { <urn:e2e:rm:extra> <urn:e2e:idx> "extra" }`,
docNuris[n - 1]!,
);
}
const toRead: Nuri[] = includeBad ? [...docNuris, "did:ng:o:definitely-not-a-real-doc-xyz"] : docNuris;
const subjects = await readUnion(toRead);
return {
docNuris,
subjectCount: subjects.length,
// What each entry actually IS — the assertion the count could not make.
subjectIris: subjects.map((x) => x.subject),
graphs: subjects.map((x) => x.graph),
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("someone-else");
const asStranger = await readModel.readUnion([doc]);
setCurrentUser("owner-O"); setCurrentUser("owner-O");
const asOwner = await readModel.readUnion([doc]); getCaps().open(doc, "protected");
setCurrentUser("someone-else");
const asStranger = await readUnion([doc]);
setCurrentUser("owner-O");
const asOwner = await readUnion([doc]);
resetCaps(); resetCaps();
setCurrentUser(null); setCurrentUser(null);
return { strangerCount: asStranger.length, ownerCount: asOwner.length }; return { strangerCount: asStranger.length, ownerCount: asOwner.length };
@@ -308,7 +453,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 +486,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 `userInbox` resolve (and file) that user's inbox.
setCurrentUser(id);
const target = await registryInternals.userInbox(id, "protected");
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 +506,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 registryInternals.userInbox(id, "protected");
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 +522,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,12 +530,16 @@ 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() {
const s = await sessionReady; // A REAL inbox, obtained from the system. It used to be a plain `docs.docCreate`
const target = await docs.docCreate(s.session_id, "Graph", "data:graph", "store", undefined); // document — a state the library never produces, and `inbox.post` now refuses it
// (a deposit is addressed to an inbox, never to a document). The step is about the
// `from` spoof guard; it should not also assert something the model forbids.
setCurrentUser("alice"); setCurrentUser("alice");
const target = await registryInternals.userInbox("alice", "protected");
let threw = false; let threw = false;
try { try {
await inbox.post(target, { payload: { x: 1 }, from: "bob" }); await inbox.post(target, { payload: { x: 1 }, from: "bob" });
@@ -396,53 +554,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 +570,25 @@ const identity = new IdentityStore(
}; };
}, },
async entityDocsBounded(idA: string, idB: string) { async entityDocsBounded(idA: string, idB: string) {
storeRegistry.resetRegistryCache(); registryInternals.resetRegistryCache();
const dA1 = await storeRegistry.createEntityDoc(idA, "public"); // Each user creates its OWN documents: you act as one virtual user at a time,
const dA2 = await storeRegistry.createEntityDoc(idA, "public"); // and the caps of what you create are filed under the identity you were acting
const dB1 = await storeRegistry.createEntityDoc(idB, "public"); // as. Creating B's document while connected as A is not a thing the model has.
setCurrentUser(idA);
const dA1 = await storeRegistry.createEntityDoc("public");
const dA2 = await storeRegistry.createEntityDoc("public");
setCurrentUser(idB);
const dB1 = await storeRegistry.createEntityDoc("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("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,9 +610,13 @@ 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;
const entityNuri = await storeRegistry.createEntityDoc(id, scope); // 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(scope);
const marker = "recon-" + Date.now(); const marker = "recon-" + Date.now();
await docs.sparqlUpdate( await docs.sparqlUpdate(
s.session_id, s.session_id,
@@ -500,8 +628,8 @@ 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(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 +638,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 +647,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(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 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 +683,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 +699,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 +726,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 +741,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 +762,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,10 +788,10 @@ 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("protected");
const s = await sessionReady; const s = await sessionReady;
// Seed the entity doc with the shape's type + a title (anchored default graph). // Seed the entity doc with the shape's type + a title (anchored default graph).
await docs.sparqlUpdate( await docs.sparqlUpdate(
@@ -663,8 +801,8 @@ 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("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 +859,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,40 +892,40 @@ 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
// cap-surface 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().open("did:ng:o:pubdoc", "public");
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, and this VIEW asks nobody: it is pure possession, with
// no round-trip to spend (see `read-filter.ts`). That a public store would serve
// the cap is proven on the read paths, not here.
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() {
resetCaps();
injectedSetItems = [{ "@graph": "did:ng:o:sharedoc", "@id": "1", v: "shared-item" }];
getCaps().open("did:ng:o:sharedoc", "protected", "owner-O");
setCurrentUser("friend");
const before = [...(libUseShape(null, null) as Iterable<any>)].length;
getCaps().grantRead("did:ng:o:sharedoc", "friend");
const after = [...(libUseShape(null, null) as Iterable<any>)].length;
resetCaps();
injectedSetItems = [];
setCurrentUser(null);
return { before, after };
}, },
// MOVED to the applicative suite, `e2e/notebook.ts` (2026-08-07):
// - `documentInboxDeposit` → "Bob leaves a message on Alice's note, and only Alice
// reads it". This one is WHY that suite exists: it was green here while the
// feature was unusable, because a harness can hand an inbox address across an
// identity boundary through a variable and an application cannot.
// - `capsShareCap` → "Alice's protected note stays shut until she gives Bob the key".
//
// What stays here is what an application does not do: primitives, characterisation,
// and the cold-start regressions.
// ── accounts (IdentityStore) ───────────────────────────────────────────── // ── accounts (IdentityStore) ─────────────────────────────────────────────
identitySet(id: string) { return identity.set(id); }, identitySet(id: string) { return identity.set(id); },
@@ -849,7 +987,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";
@@ -932,19 +1070,26 @@ const identity = new IdentityStore(
// ── Connect to the real broker ───────────────────────────────────────────── // ── Connect to the real broker ─────────────────────────────────────────────
// Mirrors ngSession.ts: register the init callback; the broker (this iframe is // Mirrors ngSession.ts: register the init callback; the broker (this iframe is
// loaded by it) drives the connection and calls back with the session. // loaded by it) drives the connection and calls back with the session.
//
// Through the POLYFILL's `init`, not the injected `realInit` — even though `realInit` is
// what ends up being called (it is what `configure` injects, above). Calling it directly
// skipped the forwarder that settles the identity before delegating, so the 42 checks ran
// over an ordering no application has, and the defect that ordering exists to prevent —
// the hand-over happening before the identity reaches the address bar — could not have
// been caught here. What an application writes is this line.
(async () => { (async () => {
try { try {
await (realInit as any)( await init(
(event: any) => { (event: { session: BrokerSession }) => {
session = event.session as BrokerSession; session = event.session;
state.status = "connected"; state.status = "connected";
sessionResolve(session); sessionResolve(session);
}, },
true, true,
[], [],
); );
} catch (e: any) { } catch (e) {
state.status = "error"; state.status = "error";
state.error = String(e?.message ?? e); state.error = e instanceof Error ? e.message : String(e);
} }
})(); })();
@@ -0,0 +1,275 @@
/**
* DECISIVE real-broker determination: does `doc_subscribe` actually PUSH when a
* subscribed document is written?
*
* This is the reactive-layer coverage whose ABSENCE let a reactivity bug ship: the
* app's whole read-model reactivity rests on `subscribeDoc(nuri, cb)` (the polyfill
* wrapper over `ng.doc_subscribe`, `src/subscribe.ts`) firing `cb` again on every
* commit to the doc. Two pushes are load-bearing in production and were reported as
* NOT firing:
* (SELF) a session's own `sparqlUpdate` to a doc it subscribes to.
* (CROSS) another session writes to a doc the first session subscribes to.
*
* This runner exercises BOTH against the REAL broker, through the SAME public
* surface the app uses `subscribeDoc` (via the harness's `stateProbe*` bridge,
* which passes the raw `AppResponse` straight through the polyfill wrapper),
* `docs.docCreate`, and `docs.sparqlUpdate` (`writeTo`). It records EVERY push as a
* typed event (`{ typeKey: "State" | "Patch" | "TabInfo" | …, elapsedMs }`) so the
* verdict is the ground truth "did the subscription callback fire again", not a
* re-read of the document. Each wait is a single event-driven promise+timeout on the
* push (NO re-read loop) a timeout is a DEFINITE "did-not-fire", not a flaky miss.
*
* Standalone (NOT `bun test`). Run:
* bun run e2e/reactivity-doc-subscribe.ts
* (or `bun run test:e2e:reactivity` from packages/polyfill)
*
* It reuses the exact real-broker plumbing of run.ts (`ng-e2e-helpers`): the dedicated lib
* wallet, the broker iframe, `window.__sdk`. The CROSS case opens a SECOND page on
* the SAME persistent wallet context a second concurrent verifier session on one
* shared wallet (as faithfulReconnect does) and writes from it.
*/
import { launchWatchedContext, setupBrokerPage, type RunProfile, type Frame, type Page, type BrowserContext } from "ng-e2e-helpers";
import { WALLET, buildBundle, mintBatchWallet, serveHarness } from "./harness-page";
type Check = { name: string; ok: boolean; detail?: string };
const results: Check[] = [];
function record(name: string, ok: boolean, detail?: string): void {
results.push({ name, ok, detail });
console.log(` [${ok ? "PASS" : "FAIL"}] ${name}${detail ? " — " + detail : ""}`);
}
type Event = { typeKey: string; elapsedMs: number };
// Call a bridge method inside a given iframe.
function sdk<T>(frame: Frame, method: string, ...args: unknown[]): Promise<T> {
return frame.evaluate(
([m, a]) => (window as any).__sdk[m as string](...(a as unknown[])),
[method, args] as const,
) as Promise<T>;
}
/**
* The decisive wait: resolve TRUE as soon as the probe's recorded push count grows
* past `base` (the subscription callback fired again), or FALSE on timeout. This is
* a promise+timeout on the PUSH itself it polls only the in-memory event counter
* the `subscribeDoc` callback writes, NEVER re-reads the document. A FALSE here is a
* definite non-delivery within the window, not a missed re-read.
*/
async function waitForPush(frame: Frame, base: number, timeoutMs: number): Promise<boolean> {
try {
await frame.waitForFunction(
(b) => (window as any).__sdk.stateProbeEvents().length > (b as number),
base,
{ timeout: timeoutMs },
);
return true;
} catch {
return false; // timed out → the callback did NOT fire again within the window
}
}
const seq = (events: Event[]): string =>
events.length ? events.map((e) => `${e.typeKey}@${e.elapsedMs}ms`).join(" → ") : "(none)";
async function openSession(
ctx: BrowserContext,
url: string,
tag: string,
): Promise<{ page: Page; frame: Frame; sessionId: string }> {
const page = await ctx.newPage();
page.on("pageerror", (e) => console.error(`[iframe error:${tag}]`, e.message));
page.on("console", (m) => {
const t = m.text();
// Surface the polyfill's own "doc_subscribe FIRE" diagnostic (subscribe.ts) if
// access logging happens to be on — an independent confirmation of a push.
if (m.type() === "error") console.error(`[iframe console:${tag}]`, t);
else if (t.includes("doc_subscribe FIRE")) console.log(`[${tag}] ${t}`);
});
const frame = await setupBrokerPage(page, url, WALLET.password);
await frame.waitForFunction(() => (window as any).__sdk !== undefined, { timeout: 30000 });
await frame.waitForFunction(() => (window as any).__sdk.status() === "connected", {
timeout: 60000,
});
const info = await sdk<{ session_id: string } | null>(frame, "sessionInfo");
const sessionId = info?.session_id ?? "(none)";
console.log(`[session:${tag}] connected — session_id=${sessionId}`);
return { page, frame, sessionId };
}
const SELF_TIMEOUT_MS = 10000;
const CROSS_TIMEOUT_MS = 15000;
const STATE_TIMEOUT_MS = 20000;
async function main(): Promise<void> {
console.log("[reactivity] building SDK page bundle...");
buildBundle();
console.log("[reactivity] minting this run's wallet...");
const wallet: RunProfile = await mintBatchWallet("the reactivity suite (e2e/reactivity-doc-subscribe.ts)");
const { url, close: closeServer } = await serveHarness();
console.log(`[reactivity] harness served at ${url}`);
let ctx: BrowserContext | null = null;
try {
ctx = await launchWatchedContext("reactivity", wallet.dir);
// ── Session A (the subscriber for both cases) ────────────────────────────
const A = await openSession(ctx, url, "A");
// ════════════════════════════════════════════════════════════════════════
// CASE 1 — SELF: A subscribes to D, then A itself writes to D.
// ════════════════════════════════════════════════════════════════════════
console.log("\n── CASE 1: SELF (single session — own write to own subscribed doc) ──");
{
const doc = await sdk<string>(A.frame, "docCreate");
console.log(` [SELF] created doc D = ${doc}`);
await sdk(A.frame, "stateProbeSubscribe", doc);
// Wait for the initial State (the sync barrier). TabInfo may precede it.
const gotState = await (async () => {
try {
await A.frame.waitForFunction(
() => (window as any).__sdk.stateProbeStateCount() >= 1,
{ timeout: STATE_TIMEOUT_MS },
);
return true;
} catch {
return false;
}
})();
const afterSubscribe = await sdk<Event[]>(A.frame, "stateProbeEvents");
console.log(` [SELF] pushes after subscribe: ${seq(afterSubscribe)}`);
record(
"SELF: initial State push arrives on subscribe (baseline sanity)",
gotState && afterSubscribe.some((e) => e.typeKey === "State"),
`sequence=${seq(afterSubscribe)}`,
);
// Now the decisive write: A's OWN sparqlUpdate to D.
const preWrite = afterSubscribe.length;
console.log(` [SELF] A writes to D (own sparqlUpdate); waiting ≤${SELF_TIMEOUT_MS}ms for a push…`);
await sdk(A.frame, "writeTo", doc, "self-1");
const fired = await waitForPush(A.frame, preWrite, SELF_TIMEOUT_MS);
const afterWrite = await sdk<Event[]>(A.frame, "stateProbeEvents");
const newEvents = afterWrite.slice(preWrite);
console.log(` [SELF] pushes AFTER own write: ${seq(newEvents)}`);
console.log(` [SELF] VERDICT: callback ${fired ? "FIRED" : "did NOT fire"} within ${SELF_TIMEOUT_MS}ms`);
record(
`SELF: subscription callback fires on the session's OWN write (≤${SELF_TIMEOUT_MS}ms)`,
fired,
`newPushes=${seq(newEvents)}`,
);
await sdk(A.frame, "stateProbeStop");
}
// ════════════════════════════════════════════════════════════════════════
// CASE 2 — CROSS-SESSION: A subscribes to D2; a SECOND session B (same shared
// wallet, own concurrent verifier session) writes to D2.
// ════════════════════════════════════════════════════════════════════════
console.log("\n── CASE 2: CROSS-SESSION (session B writes to a doc session A subscribes to) ──");
let B: { page: Page; frame: Frame; sessionId: string } | null = null;
try {
B = await openSession(ctx, url, "B");
} catch (e: any) {
console.log(` [CROSS] COULD-NOT-TEST: second concurrent session on the shared wallet failed to open: ${String(e?.message ?? e)}`);
record(
"CROSS: second concurrent session opened on the shared wallet",
false,
`open failed: ${String(e?.message ?? e)} — see Festipod multibrowser harness as the alternative venue`,
);
}
if (B) {
// NB: `session_id` is a PER-PAGE local verifier counter (each fresh iframe
// numbers its first session "1"), so it is NOT a global identifier and cannot
// be used to prove distinctness. The REAL proof that A and B are two separate
// verifier sessions is behavioural: B's write reaches A only after a broker
// round-trip (a delayed Patch), not as an instant same-session echo.
console.log(
` [CROSS] both pages connected — A.session=${A.sessionId} B.session=${B.sessionId} (per-page local counter; distinctness shown by the cross-broker propagation below)`,
);
record(
"CROSS: a second concurrent page/session is open on the same shared wallet",
true,
`A=${A.sessionId} B=${B.sessionId} (session_id is a per-page counter, not a global id)`,
);
// A creates D2 and subscribes.
const doc2 = await sdk<string>(A.frame, "docCreate");
console.log(` [CROSS] A created doc D2 = ${doc2}`);
await sdk(A.frame, "stateProbeSubscribe", doc2);
const gotState2 = await (async () => {
try {
await A.frame.waitForFunction(
() => (window as any).__sdk.stateProbeStateCount() >= 1,
{ timeout: STATE_TIMEOUT_MS },
);
return true;
} catch {
return false;
}
})();
const afterSub2 = await sdk<Event[]>(A.frame, "stateProbeEvents");
console.log(` [CROSS] A pushes after subscribe: ${seq(afterSub2)}`);
record(
"CROSS: A receives its initial State on D2 (baseline sanity)",
gotState2 && afterSub2.some((e) => e.typeKey === "State"),
`sequence=${seq(afterSub2)}`,
);
// B writes to D2. Capture a write failure (e.g. RepoNotFound) explicitly —
// it would mean B cannot reach A's doc, which is itself a determination.
const preCross = afterSub2.length;
let writeThrew: string | null = null;
// Cross-session writes to a doc created by ANOTHER session can be slow: B must
// sync/open D2's repo before it can commit. Time it separately so the push
// latency is reported relative to when B's write actually LANDED, not to
// subscribe time.
console.log(` [CROSS] B writes to D2 from its own session…`);
const tWriteStart = Date.now();
try {
await sdk(B.frame, "writeTo", doc2, "cross-1");
} catch (e: any) {
writeThrew = String(e?.message ?? e);
console.log(` [CROSS] B's write THREW: ${writeThrew}`);
}
const writeMs = Date.now() - tWriteStart;
record("CROSS: session B's write to D2 did not throw", writeThrew === null, writeThrew ? writeThrew : `landed in ${writeMs}ms`);
console.log(` [CROSS] B's write returned in ${writeMs}ms; now waiting ≤${CROSS_TIMEOUT_MS}ms for A's push…`);
const tWaitStart = Date.now();
const crossFired = writeThrew ? false : await waitForPush(A.frame, preCross, CROSS_TIMEOUT_MS);
const pushAfterWriteMs = Date.now() - tWaitStart;
const afterCross = await sdk<Event[]>(A.frame, "stateProbeEvents");
const crossNew = afterCross.slice(preCross);
console.log(` [CROSS] A pushes AFTER B's write: ${seq(crossNew)}`);
console.log(
` [CROSS] VERDICT: A's callback ${crossFired ? `FIRED (${pushAfterWriteMs}ms after B's write landed)` : "did NOT fire"} within ${CROSS_TIMEOUT_MS}ms${writeThrew ? " (B's write threw first)" : ""}`,
);
record(
`CROSS: A's subscription callback fires on B's write (≤${CROSS_TIMEOUT_MS}ms after B's write landed)`,
crossFired,
`newPushes=${seq(crossNew)} (B write took ${writeMs}ms; push ${crossFired ? pushAfterWriteMs + "ms after" : "not seen"})${writeThrew ? ` — B write threw: ${writeThrew}` : ""}`,
);
await sdk(A.frame, "stateProbeStop");
}
} finally {
try { if (ctx) await ctx.close(); } catch { /* ignore */ }
closeServer();
wallet.discard();
}
// ── Determination summary (not a pass/fail gate — this is a probe) ──────────
console.log("\n══ doc_subscribe delivery determination ══");
for (const r of results) console.log(` [${r.ok ? "PASS" : "FAIL"}] ${r.name}${r.detail ? " — " + r.detail : ""}`);
const self = results.find((r) => r.name.startsWith("SELF: subscription callback fires"));
const cross = results.find((r) => r.name.startsWith("CROSS: A's subscription callback fires"));
console.log("\n SELF →", self ? (self.ok ? "FIRES" : "DOES-NOT-FIRE") : "could-not-test");
console.log(" CROSS →", cross ? (cross.ok ? "FIRES" : "DOES-NOT-FIRE") : "could-not-test");
}
main().catch((e) => {
console.error("[reactivity] fatal:", e);
process.exit(1);
});
@@ -21,9 +21,8 @@
* Run: `bun run e2e/repro-fresh-wallet.ts`. * Run: `bun run e2e/repro-fresh-wallet.ts`.
*/ */
import * as fs from "node:fs"; import { mintWalletProfileKeepingContext, setupBrokerPage, type RunProfile, type Frame, type Page, type BrowserContext } from "ng-e2e-helpers";
import type { Frame, Page, BrowserContext } from "playwright"; import { WALLET, buildBundle, serveHarness } from "./harness-page";
import { buildBundle, serveHarness, createFreshWalletContext, setupBrokerPage } from "./broker";
type Check = { name: string; ok: boolean; detail?: string }; type Check = { name: string; ok: boolean; detail?: string };
const results: Check[] = []; const results: Check[] = [];
@@ -47,13 +46,20 @@ async function main(): Promise<void> {
console.log("[repro] creating a BRAND-NEW wallet (fresh profile)..."); console.log("[repro] creating a BRAND-NEW wallet (fresh profile)...");
let ctx: BrowserContext | null = null; let ctx: BrowserContext | null = null;
let dir: string | null = null; let profile: RunProfile | null = null;
let page: Page | null = null; let page: Page | null = null;
try { try {
const fresh = await createFreshWalletContext(); // A name of its own, not the batch wallet's: what this reproduction needs is a wallet
// whose private-store repo has never been opened by an application, and reusing a name
// would not give one.
const credentials = {
name: `ng-fresh-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`,
password: WALLET.password,
};
const fresh = await mintWalletProfileKeepingContext("the cold-start reproduction", credentials);
ctx = fresh.ctx; ctx = fresh.ctx;
dir = fresh.dir; profile = fresh.profile;
console.log(`[repro] fresh wallet: ${fresh.name}`); console.log(`[repro] fresh wallet: ${credentials.name}`);
page = await ctx.newPage(); page = await ctx.newPage();
page.on("pageerror", (e) => console.error("[iframe error]", e.message)); page.on("pageerror", (e) => console.error("[iframe error]", e.message));
@@ -62,7 +68,7 @@ async function main(): Promise<void> {
}); });
console.log("[repro] opening SDK page over the FRESH wallet (first-ever app session)..."); console.log("[repro] opening SDK page over the FRESH wallet (first-ever app session)...");
const frame = await setupBrokerPage(page, url); const frame = await setupBrokerPage(page, url, WALLET.password);
await frame.waitForFunction(() => (window as any).__sdk !== undefined, { timeout: 30000 }); await frame.waitForFunction(() => (window as any).__sdk !== undefined, { timeout: 30000 });
await frame.waitForFunction(() => (window as any).__sdk.status() === "connected", { await frame.waitForFunction(() => (window as any).__sdk.status() === "connected", {
timeout: 60000, timeout: 60000,
@@ -102,7 +108,7 @@ async function main(): Promise<void> {
} finally { } finally {
try { if (page) await page.close(); } catch { /* ignore */ } try { if (page) await page.close(); } catch { /* ignore */ }
try { if (ctx) await ctx.close(); } catch { /* ignore */ } try { if (ctx) await ctx.close(); } catch { /* ignore */ }
try { if (dir) fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ } profile?.discard();
closeServer(); closeServer();
} }
@@ -1,9 +1,9 @@
/** /**
* Real-broker e2e runner for `@ng-eventually/client` the polyfill's OWN suite, * Real-broker e2e runner for `@ng-eventually/polyfill` the polyfill's OWN suite,
* in the SDK domain (no application concepts), with a DEDICATED wallet. * in the SDK domain (no application concepts), with a DEDICATED wallet.
* *
* Standalone (NOT `bun test`), so it never mixes into the fake-ng unit suite. * Standalone (NOT `bun test`), so it never mixes into the fake-ng unit suite.
* Run: `bun run e2e/run.ts` (or `bun run test:e2e` from packages/client). * Run: `bun run e2e/run.ts` (or `bun run test:e2e` from packages/polyfill).
* *
* It: builds the SDK page bundle, creates/reuses the dedicated lib wallet, opens * It: builds the SDK page bundle, creates/reuses the dedicated lib wallet, opens
* the broker iframe on the real broker with that wallet, waits for `window.__sdk` * the broker iframe on the real broker with that wallet, waits for `window.__sdk`
@@ -15,16 +15,22 @@
import * as fs from "node:fs"; import * as fs from "node:fs";
import * as os from "node:os"; import * as os from "node:os";
import * as path from "node:path"; import * as path from "node:path";
import type { Frame, Page, BrowserContext } from "playwright";
import { import {
buildBundle, armSuiteDeadline,
serveHarness, closeContext,
ensureWallet, closeQuietly,
launchWalletContext, emptyProfileContext,
launchCleanProfileContext, importWalletFile,
importWalletViaFile, launchWatchedContext,
newPage,
setupBrokerPage, setupBrokerPage,
} from "./broker"; within,
type RunProfile,
type Frame,
type Page,
type BrowserContext,
} from "ng-e2e-helpers";
import { WALLET, buildBundle, mintBatchWallet, serveHarness } from "./harness-page";
type Check = { name: string; ok: boolean; detail?: string }; type Check = { name: string; ok: boolean; detail?: string };
const results: Check[] = []; const results: Check[] = [];
@@ -36,27 +42,42 @@ function record(name: string, ok: boolean, detail?: string): void {
function check(name: string, cond: boolean, detail?: string): void { function check(name: string, cond: boolean, detail?: string): void {
record(name, !!cond, detail); record(name, !!cond, detail);
} }
/**
* One step of the batch, under its own deadline.
*
* The bound is what makes the catch honest: recording a FAIL is the right answer for a step
* that fails, but a step that never RETURNS is caught by nothing and from the outside
* that is indistinguishable from a machine that has stopped.
*/
async function step(name: string, fn: () => Promise<void>): Promise<void> { async function step(name: string, fn: () => Promise<void>): Promise<void> {
try { try {
await fn(); await within(`the step "${name}"`, STEP_MS, fn);
} catch (e: any) { } catch (e: any) {
record(name, false, "threw: " + String(e?.message ?? e)); record(name, false, "threw: " + String(e?.message ?? e));
} }
} }
// A short helper: call a bridge method inside the iframe. /**
* Call a bridge method inside the iframe under a deadline, because `frame.evaluate()`
* has none.
*
* This is the single most important bound in the file: every one of the thirty-odd
* `sdk(...)` calls below is an `evaluate`, and Playwright will wait on one for ever. A
* bridge method that never settles a broker round-trip that gets no answer used to
* stop the batch dead with nothing printed and no way to tell which call it was. The name
* carried into the error is the method's own, so the report says which.
*/
function sdk<T>(frame: Frame, method: string, ...args: unknown[]): Promise<T> { function sdk<T>(frame: Frame, method: string, ...args: unknown[]): Promise<T> {
return frame.evaluate( return within(`__sdk.${method}() in the broker iframe`, BRIDGE_MS, () =>
([m, a]) => (window as any).__sdk[m as string](...(a as unknown[])), frame.evaluate(
[method, args] as const, ([m, a]) => (window as any).__sdk[m as string](...(a as unknown[])),
[method, args] as const,
),
) as Promise<T>; ) as Promise<T>;
} }
function sdkGet<T>(frame: Frame, method: string, ...args: unknown[]): Promise<T> { function sdkGet<T>(frame: Frame, method: string, ...args: unknown[]): Promise<T> {
// Same as sdk() but for synchronous getters (no await inside the bridge). // Same as sdk() but for synchronous getters (no await inside the bridge).
return frame.evaluate( return sdk<T>(frame, method, ...args);
([m, a]) => (window as any).__sdk[m as string](...(a as unknown[])),
[method, args] as const,
) as Promise<T>;
} }
/** /**
@@ -80,37 +101,90 @@ async function faithfulReconnect(
ctx: BrowserContext, ctx: BrowserContext,
url: string, url: string,
): Promise<{ page: Page; frame: Frame }> { ): Promise<{ page: Page; frame: Frame }> {
const p = await ctx.newPage(); const p = await newPage("the faithful reconnect", ctx);
p.on("pageerror", (e) => console.error("[iframe error:reconnect]", e.message)); p.on("pageerror", (e) => console.error("[iframe error:reconnect]", e.message));
p.on("console", (m) => { p.on("console", (m) => {
if (m.type() === "error") console.error("[iframe console:reconnect]", m.text()); if (m.type() === "error") console.error("[iframe console:reconnect]", m.text());
}); });
const frame = await setupBrokerPage(p, url); const frame = await setupBrokerPage(p, url, WALLET.password);
await frame.waitForFunction(() => (window as any).__sdk !== undefined, { timeout: 30000 }); await frame.waitForFunction(() => (window as any).__sdk !== undefined, { timeout: 30000 });
await frame.waitForFunction(() => (window as any).__sdk.status() === "connected", { timeout: 60000 }); await frame.waitForFunction(() => (window as any).__sdk.status() === "connected", { timeout: 60000 });
return { page: p, frame }; return { page: p, frame };
} }
/**
* The batch's own budget, and the measurement that explains an overrun.
*
* Not a `timeout` wrapped around the command from outside: when that fired it killed the
* browser, and the suite reported `Target page, context or browser has been closed`
* which reads as an application bug and was twice diagnosed as one. A budget belongs to
* the thing that knows what it is spending it on, and it must say so when it runs out.
*
* ENFORCED WHILE IT RUNS, not merely checked at the end. Declared as 45 min and only ever
* asserted after the last step, it could not fire on the one case that matters a batch
* that never reaches its last step. 25 min is the number now, against a healthy batch of
* ~3.3 min: the drift the old figure was sized against (a physical user growing across
* batches, so an O(size) cold resync) is gone since each batch mints its own user, and a
* budget that cannot interrupt anything is not a budget.
*/
const BATCH_BUDGET_MS = 25 * 60 * 1000;
/**
* One `__sdk` bridge call. The slowest measured on this broker is a reconnect read at
* ~90-105s (open-repo heal + anti-fork retry + anchored read, all round-tripping); four
* minutes is well past that and still names a stuck call in minutes rather than never.
*/
const BRIDGE_MS = 4 * 60 * 1000;
/**
* One step. The longest are the reconnect contracts, which poll for up to 120s per scope
* on top of a fresh broker login a few minutes when healthy, ten before we call it stuck.
*/
const STEP_MS = 10 * 60 * 1000;
const batchStart = Date.now();
/**
* The slowest cold resynchronisation of the batch the number that drifted from 250s to
* 286s over a month without anyone looking, because it only ever appeared inside one
* step's detail line. It is the health indicator of the physical user, so it is reported
* with the summary.
*/
let coldSyncMs = 0;
/** Fail with the cause named, rather than letting a killed browser look like a defect. */
function assertWithinBudget(): void {
const spent = Date.now() - batchStart;
if (spent > BATCH_BUDGET_MS) {
throw new Error(
`[e2e] batch budget exceeded (${Math.round(spent / 60000)} min > ` +
`${BATCH_BUDGET_MS / 60000} min). This is almost always the physical user having ` +
"grown: a cold resync is O(its size). Check the cold-sync figure printed above — " +
"it should be stable from batch to batch now that each gets a fresh wallet.",
);
}
}
async function main(): Promise<void> { async function main(): Promise<void> {
armSuiteDeadline("the polyfill suite", BATCH_BUDGET_MS);
console.log("[e2e] building SDK page bundle..."); console.log("[e2e] building SDK page bundle...");
buildBundle(); buildBundle();
console.log("[e2e] ensuring dedicated lib wallet..."); // This batch's own physical user, in a profile directory of its own. Nothing to serialise
await ensureWallet(); // against another run: there is no shared directory left for two runs to fight over, so a
// suite from a consuming application can drive the same broker at the same time.
console.log("[e2e] minting this batch's wallet...");
const wallet: RunProfile = await mintBatchWallet("the polyfill suite (e2e/run.ts)");
const { url, close: closeServer } = await serveHarness(); const { url, close: closeServer } = await serveHarness();
console.log(`[e2e] harness served at ${url}`); console.log(`[e2e] harness served at ${url}`);
let ctx: BrowserContext | null = null; let ctx: BrowserContext | null = null;
let page: Page | null = null; let page: Page | null = null;
try { try {
ctx = await launchWalletContext(); ctx = await launchWatchedContext("sdk-harness", wallet.dir);
page = await ctx.newPage(); page = await newPage("the SDK harness", ctx);
page.on("pageerror", (e) => console.error("[iframe error]", e.message)); page.on("pageerror", (e) => console.error("[iframe error]", e.message));
page.on("console", (m) => { page.on("console", (m) => {
if (m.type() === "error") console.error("[iframe console]", m.text()); if (m.type() === "error") console.error("[iframe console]", m.text());
}); });
console.log("[e2e] loading SDK page in broker iframe..."); console.log("[e2e] loading SDK page in broker iframe...");
const frame = await setupBrokerPage(page, url); const frame = await setupBrokerPage(page, url, WALLET.password);
// Wait for the bridge to exist + the broker session to connect. // Wait for the bridge to exist + the broker session to connect.
await frame.waitForFunction(() => (window as any).__sdk !== undefined, { timeout: 30000 }); await frame.waitForFunction(() => (window as any).__sdk !== undefined, { timeout: 30000 });
@@ -120,6 +194,25 @@ async function main(): Promise<void> {
const info = await sdkGet<any>(frame, "sessionInfo"); const info = await sdkGet<any>(frame, "sessionInfo");
check("broker session connected", info?.session_id !== undefined && info?.session_id !== null, `session=${JSON.stringify(info)}`); check("broker session connected", info?.session_id !== undefined && info?.session_id !== null, `session=${JSON.stringify(info)}`);
// ── access gate ─────────────────────────────────────────────────────────
console.log("\n── access gate ──");
await step("the gate asks on a first access, and settles the identity normalized", async () => {
const r = await sdk<any>(frame, "accessGateFirstVisit", "@Erin");
check(
"barrier shown, Entrer disabled while empty, identity normalized, barrier removed",
r.shown === true && r.disabledWhenEmpty === true && r.identity === "erin" && r.stillMounted === false,
`shown=${r.shown} disabledWhenEmpty=${r.disabledWhenEmpty} identity=${r.identity} stillMounted=${r.stillMounted}`,
);
});
// This harness page runs INSIDE the broker iframe — which is the whole of what makes
// the check below true. Top-level the barrier shows on every load, known identity or
// not, because a top-level page is one redirect away from a dead end for anyone whose
// browser has no wallet. The applicative suite walks that side (`e2e/notebook.ts`).
await step("past the round-trip, a known identity stands the barrier down", async () => {
const r = await sdk<any>(frame, "accessGateIdentityAlreadyKnown", "erin");
check("no barrier inside the broker iframe once the identity is known", r.shown === false && r.identity === "erin", `shown=${r.shown}`);
});
// ── docs primitives ───────────────────────────────────────────────────── // ── docs primitives ─────────────────────────────────────────────────────
console.log("\n── docs primitives ──"); console.log("\n── docs primitives ──");
await step("docCreate returns a usable NURI", async () => { await step("docCreate returns a usable NURI", async () => {
@@ -168,13 +261,32 @@ async function main(): Promise<void> {
// ── read-model ────────────────────────────────────────────────────────── // ── read-model ──────────────────────────────────────────────────────────
console.log("\n── read-model ──"); console.log("\n── read-model ──");
await step("readUnion over N docs → per-doc subjects", async () => { await step("readUnion returns one entry per SUBJECT, with the subject it was written under", async () => {
// 3 documents, and the last carries TWO subjects → 4 entries, not 3. Counting alone
// could not distinguish grouping-by-document from grouping-by-subject, which is why
// this step stayed green while `readUnion` conflated them (fixed 2026-08-10).
const r = await sdk<any>(frame, "readUnionOverDocs", 3, false); const r = await sdk<any>(frame, "readUnionOverDocs", 3, false);
check("readUnion returns one subject per doc", r.subjectCount === 3, `subjects=${r.subjectCount}/3`); const iris: string[] = r.subjectIris ?? [];
check(
"one entry per subject, not per document",
r.subjectCount === 4 && iris.includes("urn:e2e:rm:extra"),
`entries=${r.subjectCount}/4 subjects=${JSON.stringify(iris)}`,
);
check(
"each entry carries the subject it was written under, not the document",
iris.every((s) => s.startsWith("urn:e2e:rm:")),
JSON.stringify(iris),
);
check(
"…and its `graph` is the document reference",
(r.graphs ?? []).every((g: string) => g.startsWith("did:ng:")),
JSON.stringify(r.graphs),
);
}); });
await step("readUnion per-doc tolerance (bad NURI skipped)", async () => { await step("readUnion per-doc tolerance (bad NURI skipped)", async () => {
const r = await sdk<any>(frame, "readUnionOverDocs", 2, true); const r = await sdk<any>(frame, "readUnionOverDocs", 2, true);
check("bad NURI does not abort the batch", r.subjectCount === 2, `subjects=${r.subjectCount}/2 (+1 bad)`); // 2 documents, the last carrying two subjects → 3 entries.
check("bad NURI does not abort the batch", r.subjectCount === 3, `entries=${r.subjectCount}/3 (+1 bad NURI)`);
}); });
await step("readUnion cap gate", async () => { await step("readUnion cap gate", async () => {
const r = await sdk<any>(frame, "readUnionCapGate"); const r = await sdk<any>(frame, "readUnionCapGate");
@@ -224,7 +336,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 +347,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 +360,16 @@ 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");
}); });
// MOVED to the applicative suite (`e2e/notebook.ts`, "Bob leaves a message on Alice's
// note, and only Alice reads it"). This is the step that motivated that suite: it was
// green here while the feature was unusable, because the harness could hand the inbox
// address across an identity boundary through a variable — a channel no application
// has. Driven through two screens, the address has to be FOUND or the journey fails.
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,18 +429,27 @@ 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.
await step("read-filter: directed grant reveals the doc", async () => { const strangerReadsNothing = r.strangerView.length === 0;
const r = await sdk<any>(frame, "capsDirectedGrant"); // …until the repo link of the PUBLISHED document reaches them.
check("grantRead reveals the protected doc to the grantee", r.before === 0 && r.after === 1, `before=${r.before} after=${r.after}`); const linkOpensPublic =
r.strangerWithLinkView.length === 1 && r.strangerWithLinkView.includes("public-item");
check(
"the read-filtered view decides on possession alone: owner sees what he holds, a stranger nothing, and a filed cap opens it",
ownerReadsHeld && ownerMissesUnheld && strangerReadsNothing && linkOpensPublic,
`owner=${JSON.stringify(r.ownerView)} stranger=${JSON.stringify(r.strangerView)} withCap=${JSON.stringify(r.strangerWithLinkView)}`,
);
}); });
// MOVED to the applicative suite (`e2e/notebook.ts`, "Alice's protected note stays
// shut until she gives Bob the key"): sharing is a journey, and it is worth more
// driven through two screens than through two calls on one page.
// ── accounts (IdentityStore) ──────────────────────────────────────────── // ── accounts (IdentityStore) ────────────────────────────────────────────
console.log("\n── accounts (IdentityStore) ──"); console.log("\n── accounts (IdentityStore) ──");
@@ -396,20 +497,20 @@ async function main(): Promise<void> {
fs.writeFileSync(ngwPath, Buffer.from(exp.b64, "base64")); fs.writeFileSync(ngwPath, Buffer.from(exp.b64, "base64"));
let cleanCtx: BrowserContext | null = null; let cleanCtx: BrowserContext | null = null;
let cleanDir: string | null = null; let cleanProfile: RunProfile | null = null;
let cleanPage: Page | null = null; let cleanPage: Page | null = null;
try { try {
const launched = await launchCleanProfileContext(); const launched = await emptyProfileContext("the clean-profile cold read");
cleanCtx = launched.ctx; cleanCtx = launched.ctx;
cleanDir = launched.dir; cleanProfile = launched.profile;
cleanPage = await cleanCtx.newPage(); cleanPage = await newPage("the clean-profile session", cleanCtx);
cleanPage.on("pageerror", (e) => console.error("[iframe error:clean]", e.message)); cleanPage.on("pageerror", (e) => console.error("[iframe error:clean]", e.message));
cleanPage.on("console", (m) => { if (m.type() === "error") console.error("[iframe console:clean]", m.text()); }); cleanPage.on("console", (m) => { if (m.type() === "error") console.error("[iframe console:clean]", m.text()); });
// Import the SAME wallet into the empty profile (broker-only repos), then open // Import the SAME wallet into the empty profile (broker-only repos), then open
// the SDK page in a fresh broker session over it. // the SDK page in a fresh broker session over it.
await importWalletViaFile(cleanPage, ngwPath); await importWalletFile(cleanPage, ngwPath, WALLET.password);
const cleanFrame = await setupBrokerPage(cleanPage, url); const cleanFrame = await setupBrokerPage(cleanPage, url, WALLET.password);
await cleanFrame.waitForFunction(() => (window as any).__sdk !== undefined, { timeout: 30000 }); await cleanFrame.waitForFunction(() => (window as any).__sdk !== undefined, { timeout: 30000 });
await cleanFrame.waitForFunction(() => (window as any).__sdk.status() === "connected", { timeout: 60000 }); await cleanFrame.waitForFunction(() => (window as any).__sdk.status() === "connected", { timeout: 60000 });
const cleanInfo = await sdkGet<any>(cleanFrame, "sessionInfo"); const cleanInfo = await sdkGet<any>(cleanFrame, "sessionInfo");
@@ -426,9 +527,9 @@ async function main(): Promise<void> {
`rawAnchoredNoOpen=${r.rawRowCount} listed=${r.listedCount} foundEntity=${r.foundEntity} subjects=${r.subjectCount} markerPresent=${r.markerPresent}`, `rawAnchoredNoOpen=${r.rawRowCount} listed=${r.listedCount} foundEntity=${r.foundEntity} subjects=${r.subjectCount} markerPresent=${r.markerPresent}`,
); );
} finally { } finally {
try { if (cleanPage) await cleanPage.close(); } catch { /* ignore */ } if (cleanPage) await closeQuietly("the clean-profile page", () => cleanPage!.close());
try { if (cleanCtx) await cleanCtx.close(); } catch { /* ignore */ } if (cleanCtx) await closeContext("clean-profile", cleanCtx);
try { if (cleanDir) fs.rmSync(cleanDir, { recursive: true, force: true }); } catch { /* ignore */ } cleanProfile?.discard();
try { fs.rmSync(ngwPath, { force: true }); } catch { /* ignore */ } try { fs.rmSync(ngwPath, { force: true }); } catch { /* ignore */ }
} }
}); });
@@ -506,12 +607,13 @@ async function main(): Promise<void> {
`[SYNC] reconnect re-reads its OWN persisted ${scope} marker (cold-read)`, `[SYNC] reconnect re-reads its OWN persisted ${scope} marker (cold-read)`,
found, found,
found found
? `synced in ${syncMs}ms (reconnect-login ${loginMs}ms) — ${lastDetail}` ? ((coldSyncMs = Math.max(coldSyncMs, syncMs)),
`synced in ${syncMs}ms (reconnect-login ${loginMs}ms) — ${lastDetail}`)
: `NEVER synced within 120s (reconnect-login ${loginMs}ms) — ${lastDetail}`, : `NEVER synced within 120s (reconnect-login ${loginMs}ms) — ${lastDetail}`,
); );
} }
} finally { } finally {
try { if (rp) await rp.close(); } catch { /* ignore */ } if (rp) await closeQuietly("the reconnect page", () => rp!.close());
} }
}); });
@@ -568,7 +670,7 @@ async function main(): Promise<void> {
: `FORKED — orig pub=${String(orig.docPublic).slice(0, 20)}… got pub=${String(last?.docPublic).slice(0, 20)}… (differs)`, : `FORKED — orig pub=${String(orig.docPublic).slice(0, 20)}… got pub=${String(last?.docPublic).slice(0, 20)}… (differs)`,
); );
} finally { } finally {
try { if (rp) await rp.close(); } catch { /* ignore */ } if (rp) await closeQuietly("the reconnect page", () => rp!.close());
} }
}); });
@@ -720,15 +822,28 @@ async function main(): Promise<void> {
await sdk(frame, "stateProbeStop"); await sdk(frame, "stateProbeStop");
}); });
} finally { } finally {
try { if (page) await page.close(); } catch { /* ignore */ } // Bounded, and it has to be: `BrowserContext.close()` on a browser that has already
try { if (ctx) await ctx.close(); } catch { /* ignore */ } // gone never resolves, and this `finally` is where that hang swallowed the summary.
if (page) await closeQuietly("the SDK harness page", () => page!.close());
if (ctx) await closeContext("sdk-harness", ctx);
closeServer(); closeServer();
// This run's physical user goes with it. Explicit here and also registered on the way
// out, so a run that is killed mid-batch still takes its profile — and the Chromium
// holding it — with it, instead of leaving both for a host that has to keep running.
wallet.discard();
} }
// ── Summary ─────────────────────────────────────────────────────────────── // ── Summary ───────────────────────────────────────────────────────────────
const passed = results.filter((r) => r.ok).length; const passed = results.filter((r) => r.ok).length;
const failed = results.length - passed; const failed = results.length - passed;
console.log(`\n══ SDK e2e summary: ${passed} passed, ${failed} failed, ${results.length} total ══`); const batchMin = ((Date.now() - batchStart) / 60000).toFixed(1);
console.log(
`\n══ Polyfill e2e summary: ${passed} passed, ${failed} failed, ${results.length} total ` +
`— batch ${batchMin} min, slowest cold sync ${Math.round(coldSyncMs / 1000)}s ══`,
);
// A fresh wallet per batch is what should keep the cold sync flat; if it climbs from
// one batch to the next, the per-batch wallet is not being discarded.
assertWithinBudget();
if (failed > 0) { if (failed > 0) {
console.log("Failures:"); console.log("Failures:");
for (const r of results.filter((x) => !x.ok)) console.log(` - ${r.name}: ${r.detail ?? ""}`); for (const r of results.filter((x) => !x.ok)) console.log(` - ${r.name}: ${r.detail ?? ""}`);
+42
View File
@@ -0,0 +1,42 @@
{
"name": "@ng-eventually/polyfill",
"version": "1.0.0-dev.3",
"type": "module",
"description": "Polyfill of the NextGraph JS SDK over @ng-org/web + @ng-org/orm, with emulated capabilities and inbox. Drop-in; remove at migration.",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts"
},
"peerDependencies": {
"@ng-org/web": "*",
"@ng-org/orm": "*",
"@ng-org/shex-orm": "*",
"@ng-org/alien-deepsignals": "*"
},
"peerDependenciesMeta": {
"@ng-org/web": {
"optional": true
},
"@ng-org/orm": {
"optional": true
},
"@ng-org/shex-orm": {
"optional": true
},
"@ng-org/alien-deepsignals": {
"optional": true
}
},
"devDependencies": {
"@ng-org/web": "0.1.2-alpha.13",
"@ng-org/shex-orm": "0.1.2-alpha.8",
"@ng-org/alien-deepsignals": "0.1.2-alpha.11"
},
"scripts": {
"test": "bun test",
"test:e2e": "bun run e2e/run.ts",
"test:e2e:app": "bun run e2e/notebook.ts",
"test:e2e:reactivity": "bun run e2e/reactivity-doc-subscribe.ts"
}
}
@@ -0,0 +1,885 @@
/**
* The durable cap/inbox registers this library's stand-in for the compartments the
* verifier maintains on a repo's own branches.
*
* Upstream these are not RDF at all: they are streams of service commits on branches
* whose CRDT is `BranchCrdt::None` (`engine/repo/src/types.rs:1420`). Each register here
* names its native counterpart:
*
* - **Store branch** `AddRepo { read_cap }` (`engine/repo/src/types.rs:1890-1899`):
* the cap of a document you CREATED, filed beside the store that holds it. Replaying
* it is what reloads a store's documents with their keys (`AddRepo::verify` ->
* `Verifier::load_repo_from_read_cap`, `engine/verifier/src/verifier.rs:2237`).
* - **User branch, links** `AddLink { read_cap }` (`types.rs:1939-1948`), *"so that a
* user can share with all its device a new Link they received"*, external repos only.
* - **User branch, inbox caps** `AddInboxCap { repo_id, overlay, priv_key }`
* (`types.rs:1969-1981`): which inboxes you may READ. Keyed by `repo_id`, hence valid
* for ANY repo `update_inbox_cap_v0` applies it with no `is_store` check
* (`engine/verifier/src/verifier.rs:1920`).
* - **Header branch** a document's deposit ADDRESS, readable by any holder of it.
* The one register with NO native counterpart: upstream an address is TRANSMITTED
* (a message, a profile QR code), never published, and `inboxes: PubKey -> RepoId` is
* a per-session local table (`verifier.rs:105`). Publishing is our divergence, taken
* because an emulation has no message channel see
* `docs/briefs/2026-08-03-document-inbox-addressing.md`.
*
* Why separate from the shim next door: these emulate the VERIFIER's bookkeeping and
* survive conceptually at migration the native side keeps them, only our RDF
* representation goes. `shared-wallet/account-registry.ts` has no counterpart at all and
* evaporates. One file until 2026-08-03, two fates.
*
* The imports back into `shared-wallet/` are deliberate, visible cross-fate edges: a
* register needs the shim to know WHOSE it is, and where its store document lives. Every
* use sits inside a function body, so the module cycle is inert at evaluation time.
*/
import { sparqlQuery } from "../surface/docs";
import { readForHolder, registerUpdate } from "./register-write";
import { getCaps, getCurrentUser } from "../shared-wallet/bootstrap";
import { escapeLiteral } from "../surface/sparql";
import { hasReadCap, isNuri, targetOf, toNuri } from "../model/nuri";
import { mustNotAttempt } from "./reach";
import { fetchReadCap } from "./public-store";
import { ensureRepoOpen } from "./open-repo";
import { accessLogPrefix } from "../shared-wallet/access-log";
import {
P,
USER_BRANCH_SUBJECT,
STORE_BRANCH_SUBJECT,
HEADER_BRANCH_SUBJECT,
accountKey,
session,
readBindings,
bindingValue,
lookupAccount,
resolveAccount,
storeOf,
readUserStore,
userInbox,
createDoc,
ensureAccount,
recordInbox,
type VirtualUserRecord,
} from "../shared-wallet/account-registry";
import type { InboxScope, Nuri, NuriLike, PrincipalId, ReadCap, Scope } from "../model/types";
/**
* Does `nuri` belong to the CURRENT wallet as one of its inboxes? The predicate the
* inbox read guard consults (`inbox.ts`). Anonymous holds no inbox, so it is false
* for everyone until an identity is set.
*/
// @provenance isOwnInbox kind=aligned level=1 ref=engine/repo/src/types.rs:AddInboxCapV0 — 'which inboxes may I read' is answered by the User branch's records
export async function isOwnInbox(nuri: Nuri): Promise<boolean> {
const holder = getCurrentUser();
if (holder === null) return false;
// Either of the user's two inboxes counts as its own.
for (const scope of ["public", "protected"] as const) {
if ((await userInbox(holder, scope)) === nuri) return true;
}
// …and the inbox of any document this user opened one on (the emulated
// `AddInboxCap` records on its User branch).
return (await readInboxCapPairs()).some((p) => p.inbox === nuri);
}
// --- the cap side of a user's store ----------------------------------
/**
* File the caps of documents the CURRENT holder owns into what they hold the
* emulated `AddRepo { read_cap }`.
*
* Upstream, creating a document commits an `AddRepo { read_cap }` into a typed
* branch of the store, and that branch listing the store's documents, each with
* its read key carries the owner's caps. Here the per-(account × scope) index
* document plays the store-container role, so it carries the caps too: a
* document appended to it on creation, or read back from it on a later session,
* puts its cap in the owner's hands with nothing for the consumer to do. That is
* what makes the invariant hold both ways you never derive a cap from a bare
* reference, and yet a document's own creator is never locked out of it.
*
* Scoped to the current holder ON PURPOSE: another account's documents are listed
* by the cross-account fan-out (`listEntityDocs`), and those caps are emphatically
* not ours to hold. `id` is compared through the shim key, so it matches however
* the consumer spells the identity.
*/
// @provenance holdOwnCap kind=aligned level=1 ref=engine/repo/src/types.rs:AddRepoV0 — the cap of a document you created, filed on the store's Store branch. WIRED upstream — `send_add_repo_to_store` constructs it
export function holdOwnCap(id: string, scope: Scope, doc: Nuri, cap: ReadCap): void {
const holder = getCurrentUser();
if (holder === null || accountKey(holder) !== accountKey(id)) return;
const caps = getCaps();
// `learn(cap)`, not `open(doc, scope)` — the cap must be the SAME value that was
// written to the Store branch, not a second one minted from the NURI. They agree
// today only because the stand-in value is a constant; with a real key (cap-enforcement) a
// second mint would produce a DIFFERENT key and the document would be unreadable
// by the very session that created it. Mint once, store it, hold that one.
caps.learn(cap);
// Which store the document sits in is a registry fact, applied separately — and a
// MARK only, for the same reason the cap above is learned rather than re-minted.
if (scope === "public") caps.markInPublicStore(doc);
}
/**
* File the caps of the documents a virtual user owns BY BEING one: its three
* stores, and its inbox. They are as much its documents as any entity it creates,
* and without them it cannot even list its own content the boundary would lock a
* user out of itself.
*
* Scoped to the current holder, like {@link holdOwnCap}: another user's stores are
* emphatically not ours to hold.
*/
// @provenance fileOwnStructure kind=aligned level=1 ref=engine/verifier/src/site.rs:create_individual_ — a user's three stores are its own repos; it holds their caps by BEING a user
export function fileOwnStructure(id: string, record: VirtualUserRecord): void {
const holder = getCurrentUser();
if (holder === null || accountKey(holder) !== accountKey(id)) return;
const caps = getCaps();
if (record.docPublic) caps.open(record.docPublic, "public");
if (record.docProtected) caps.open(record.docProtected, "protected");
if (record.docPrivate) caps.open(record.docPrivate, "private");
}
/** Same, for the user's own inbox it is its document, and it must be able to
* read it. Depositing into someone else's needs no cap (see `register-write.depositInto`). */
// @provenance fileOwnInbox kind=aligned level=1 ref=engine/repo/src/types.rs:AddInboxCapV0 — upstream the record IS the key (`priv_key`); here the cap is minted for the holder the record belongs to
export function fileOwnInbox(id: string, inbox: Nuri): void {
const holder = getCurrentUser();
if (holder === null || accountKey(holder) !== accountKey(id)) return;
getCaps().open(inbox, "private");
}
// --- per-entity documents + per-scope index -------------------------------
/**
* Publish WHERE to deposit for `doc`, on its Header branch the compartment any
* holder of the document can read.
*
* Replacement, not addition: a document has exactly ONE inbox upstream (the verifier's
* `inboxes: PubKey → RepoId` is a function, and `repo.inbox` a single `Option<PrivKey>`),
* so two addresses on one document is a state the model has no meaning for and a
* depositor picking the stale one writes where nobody reads.
*
* **Propagates a failed write.** Publishing is the ONLY way a third party learns where to
* deposit for a document (we publish because an emulation has no message channel see the
* module header), and `openDocumentInbox` short-circuits on the inbox it already recorded,
* so nothing ever tries again. Swallowing therefore produced an inbox its owner drains
* forever while no one can reach it and worse when the `DELETE` landed and the `INSERT`
* did not: the address that WAS published is gone, and every future deposit on that
* document is refused as "this document has no inbox".
*/
// @provenance publishInboxAddress kind=divergent level=1 ref=engine/net/src/types.rs:ContactDetails — upstream an inbox address is TRANSMITTED (a message, a profile QR) and lands in the verifier's own per-user `inboxes` table; PUBLISHING it on the document is this library's deliberate divergence
export async function publishInboxAddress(doc: Nuri, inbox: Nuri): Promise<void> {
const s = await session();
try {
// Two separate updates, not one compound statement: `DELETE WHERE { … }` is the
// form verified against the real broker (see
// `docs/decisions/sparql-delete-for-orm-objects.md`), whereas a `;`-joined update
// is not exercised anywhere in this lib.
await registerUpdate(
s.sessionId,
`DELETE WHERE { <${HEADER_BRANCH_SUBJECT}> <${P.inboxAddress}> ?a }`,
doc,
"publishInboxAddress:clear",
);
await registerUpdate(
s.sessionId,
`INSERT DATA { <${HEADER_BRANCH_SUBJECT}> <${P.inboxAddress}> "${escapeLiteral(inbox)}" }`,
doc,
"publishInboxAddress",
);
} catch (error) {
console.error(accessLogPrefix() + " publishInboxAddress failed:", error);
throw error;
}
}
/**
* The ReadCaps recorded on a store's Store branch its documents, each with its
* key. The emulated replay of `AddRepo`, and the reason a fresh session recovers
* what it owns without recomputing anything.
*
* **Propagates a failed read**, like {@link readLinks} beside it and for the same reason:
* empty and unreadable are the same value here and could not mean more different things
* "I own nothing in this store" against "every document I own is invisible and nothing
* said so". {@link restoreOwnCaps} is the caller that cannot survive the confusion, since
* its whole contract is that the connection either did the restore or says it did not.
* `listMyEntityDocs` used to be the exception; it stopped being one on 2026-08-17, because
* a listing handed back without its keys is the same confusion one layer up (see it).
*/
// @provenance readStoreCaps kind=aligned level=1 ref=engine/repo/src/types.rs:AddRepoV0 — the replay of the Store branch — what reloads a store's documents with their keys
export async function readStoreCaps(storeDoc: Nuri): Promise<ReadCap[]> {
const s = await session();
const out: ReadCap[] = [];
const res = await sparqlQuery(
s.sessionId,
`SELECT ?c WHERE { <${STORE_BRANCH_SUBJECT}> <${P.readCap}> ?c }`,
undefined,
storeDoc,
"readStoreCaps",
);
for (const row of readBindings(res)) {
const v = bindingValue(row, "c");
if (v && hasReadCap(v)) out.push(v);
}
return out;
}
/**
* Put back in the connected user's hands the key of EVERY document it owns the three
* Store branches read back, which is the emulated replay of `AddRepo { read_cap }`
* (`AddRepo::verify` `Verifier::load_repo_from_read_cap`,
* `engine/verifier/src/verifier.rs:2237`).
*
* Why connecting has to do this, and why the store record is not enough
* A document's cap is written in exactly one durable place the Store branch of the
* store it was created in ({@link holdOwnCap}) and until 2026-08-16 exactly one path
* read it back: `listMyEntityDocs`. So a creator that reloaded the page and went straight
* to the document it had just made, without listing that scope first, held nothing for it:
* the read was refused, the batch read (`readUnion`) dropped it silently, and the write
* was refused. Its own document, denied to its own author, on the first refresh.
*
* Restoring what a user HOLDS is already what connecting means here that is what the
* User-branch Links step does for caps RECEIVED from others. Its own documents are the
* other half of the same act, and the more fundamental one: a received cap is a bonus, a
* created document is the user's own content.
*
* The scope stores are filed first ({@link fileOwnStructure}), because the Store branch
* lives INSIDE the store document and reading it is itself a guarded read. Not left to a
* side effect of whatever ran before: the order is carried by this composition rather than
* by where the call happens to sit.
*
* `holderKey` is the ring these land in captured by the caller when it decided whose
* connection this is, and handed back here, exactly as `readLinks`' caller does. See
* `caps.holderKey`.
*
* Nothing to restore is not a failure: an identity with no account yet owns no store to
* read. A failed READ is one, and it propagates the caller is `connect.connectedUser`,
* whose contract is that it did the work or said it did not.
*/
// @provenance restoreOwnCaps kind=aligned level=1 ref=engine/verifier/src/verifier.rs:load_repo_from_read_cap — `AddRepo::verify` loads each repo from its read cap at replay; this is that replay, at connection
export async function restoreOwnCaps(holderKey: string): Promise<void> {
const holder = getCurrentUser();
if (holder === null) return;
// `lookupAccount`, not `resolveAccount`: a read that could not ANSWER must not arrive
// here as "this identity has no account", which is the one absence entitled to silence.
// Its caller resolved the same account a moment ago, so this is a cache hit.
const record = await lookupAccount(holder);
if (record === null) return;
// The store documents themselves, or the reads below would be refused for want of the
// very structure this user owns by BEING a user.
fileOwnStructure(holder, record);
const caps = getCaps();
for (const scope of ["public", "protected", "private"] as const) {
const store = storeOf(record, scope);
if (!store) continue;
// COLD-START heal, and it is the whole point of this function running at CONNECTION
// time: on a fresh session the store repo is not yet in the verifier's `self.repos`,
// and an anchored read of a repo that is not open returns 0 rows — it does not fail.
// Without this the restore would answer "you own nothing" on exactly the session that
// needs it, silently, which is the failure-as-absence this whole path exists against.
// Same guard `readUserStore` and `readLinks` already apply to the same documents.
await ensureRepoOpen(store);
for (const cap of await readStoreCaps(store)) {
// `learnFor`, never a fresh mint: the cap must be the value the Store branch
// carries — see the note in `holdOwnCap` on why a second mint locks the owner out
// the day the stand-in value becomes a real key.
caps.learnFor(holderKey, cap);
// Which store a document sits in is a registry fact, not one recorded beside the
// caps, so it is re-applied here — marking only, like `listMyEntityDocs` does.
if (scope === "public") caps.markInPublicStore(targetOf(cap));
}
}
}
/**
* WHERE to deposit for `doc` its inbox address, or `undefined` if its owner never
* opened one. The deposit-side counterpart of {@link openDocumentInbox}, and the
* function an app calls before `inbox.post`.
*
* Readable by whoever can read the document, because it lives on its Header branch
* an address is public by nature (upstream a depositor needs only the inbox PUBLIC
* key). Conversely someone who cannot read the document learns nothing, which is
* faithful too: upstream the inbox pubkey is not derivable from a RepoId, it has to
* reach you.
*
* **Never creates.** Asking where to deposit must not bring an inbox into existence
* only its owner opens one, and only on its own document.
*/
// @provenance documentInboxAddress kind=divergent level=1 ref=engine/net/src/types.rs:ContactDetails — the deposit-side half of the same divergence: upstream you are TOLD an address, you never read one off a document
export async function documentInboxAddress(doc: Nuri): Promise<Nuri | undefined> {
// RULE 2 — do not even attempt. Not holding the document, we have no address to
// find: upstream the inbox pubkey travels WITH what you can read, so "where do I
// deposit for a document I cannot read" is not a refused question, it is a question
// with no referent. Answering `undefined` here keeps the caller's shape (an address
// or none) instead of turning the boundary into an exception it must catch.
// …but ask the (emulated) network first: a document in a public store serves its cap
// to whoever asks (public-store.ts), and "where do I deposit for this public
// document" is exactly the question a third party arrives with, holding nothing but
// the reference.
await fetchReadCap(doc);
if (mustNotAttempt(doc)) return undefined;
const s = await session();
try {
const res = await sparqlQuery(
s.sessionId,
`SELECT ?a WHERE { <${HEADER_BRANCH_SUBJECT}> <${P.inboxAddress}> ?a }`,
undefined,
doc,
"documentInboxAddress",
);
for (const row of readBindings(res)) {
const a = bindingValue(row, "a");
if (a && isNuri(a)) return a;
}
} catch (error) {
// Unreadable document (no cap) or not synced → no address to give. Refusing to
// read is the boundary doing its job, not an error to propagate here.
console.error(accessLogPrefix() + " documentInboxAddress failed:", error);
}
return undefined;
}
/**
* Does the connected user own `doc`? Answered from its **Store branches** the
* register of the documents it created across the three scopes, which is the only
* place that records authorship. Holding a cap is NOT ownership: a cap can be
* received, and a recipient must not be able to open an inbox on what it merely reads.
*
* The ONE caller that catches `readUserStore` rather than propagating it, deliberately.
* Every answer this function feeds is a REFUSAL the write guard (`reach.mustNotAttempt`)
* and `openDocumentInbox` both only ever ask it for permission so a store that did not
* answer closes the door, which is the safe side and writes nothing. That is the ruling
* made when this family was swept (commit `e32b6d0`): the sites that fail CLOSED were
* left, the ones that answer a caller with a fabricated value were not. The refusal names
* the wrong reason ("not yours" for "could not tell"), which is the price, and it is
* bounded nobody acts on this answer except by being denied.
*/
// @provenance ownsDocument kind=aligned level=1 ref=engine/repo/src/types.rs:AddRepoV0 — authorship is what the Store branch records; holding a cap is NOT ownership, since a cap can be received
export async function ownsDocument(doc: Nuri): Promise<boolean> {
const holder = getCurrentUser();
if (holder === null) return false;
const record = await resolveAccount(holder);
if (record === null) return false;
for (const scope of ["public", "protected", "private"] as const) {
const store = storeOf(record, scope);
if (!store) continue;
try {
if ((await readUserStore(store)).includes(doc)) return true;
} catch (error) {
console.error(accessLogPrefix() + " ownsDocument: store unreadable:", error);
}
}
return false;
}
/** The `(document, inbox)` pairs recorded on this user's User branch. */
/**
* Encode the `(document, inbox)` pair of an emulated `AddInboxCap` record.
*
* Upstream this is a TYPED structure `AddInboxCapV0 { repo_id, overlay, priv_key }`
* (`engine/repo/src/types.rs:1973`) carried by a service commit, not a string. Ours is
* one RDF literal because our User branch is a subject in a document, so the pairing has
* to live inside a value. That is the emulation's shape, and it is what migration
* replaces: the fields become fields again.
*
* The separator is a space, which is safe for a reason worth stating rather than
* assuming: a NURI is `did:ng:` followed by base64url and `:`-separated segments
* (`NuriV0`, `engine/net/src/app_protocol.rs`), an alphabet that contains no space. The
* assertion below turns that from an implicit property into a checked one a silently
* mis-split pair would file an inbox under a truncated document and lose deposits with
* no error, which is exactly the failure class this whole path already paid for once.
*/
function encodeInboxCap(doc: Nuri, inbox: Nuri): string {
if (doc.includes(" ") || inbox.includes(" ")) {
throw new Error(
"[ng-eventually] branch-registers: a NURI containing a space cannot be paired in " +
`an inbox-cap record — the separator would be ambiguous: ${JSON.stringify([doc, inbox])}`,
);
}
return `${doc} ${inbox}`;
}
/**
* The (document, inbox) pairs this user may read the emulated `AddInboxCap` records.
*
* **Propagates a failed read**, for the reason spelled out on {@link readLinks}: this is
* half of the drain list `connect.connectedUser` works from, and an empty answer would
* make a document's queue silently un-drained a share that was delivered and never
* applied, with nothing to see anywhere.
*
* **Barrier-AUTHORITATIVE, since 2026-08-17.** This was the only reader of a user's store
* in this file with no {@link ensureRepoOpen} of its own `readLinks` next door,
* `restoreOwnCaps` and `readUserStore` all carry one and on a fresh page over the same
* persistent wallet an anchored read of a not-yet-synced repo returns no rows, no error
* (`open-repo.ts`). What made that survive was caller ORDER: connecting opens the three
* stores ({@link restoreOwnCaps}) before anything asks. Order is not a guarantee, and the
* one caller that decides on the answer proved it {@link openDocumentInbox} MINTS when
* this reads empty, so on a page that had settled an identity without connecting it yet,
* one call, no race, gave a note a SECOND inbox: two `AddInboxCap` records, and the
* address published on the document replaced by the new one, so later deposits land where
* none of the earlier ones are. Same ruling as the family around it (`e32b6d0`): only a
* VERIFIED absence may mint, and a read that could not answer is not one.
*
* Costs nothing on the paths that already connected the open registry is per-session and
* a repo already open is a map hit (`open-repo.ts`).
*/
// @provenance readInboxCapPairs kind=declared-not-wired level=1 ref=engine/repo/src/types.rs:AddInboxCapV0 — the record is keyed by `repo_id` and `update_inbox_cap_v0` applies it with no is-store check — but the engine only ever commits one for the two STORE repos, never for a plain document
export async function readInboxCapPairs(): Promise<Array<{ doc: Nuri; inbox: Nuri }>> {
const holder = getCurrentUser();
if (holder === null) return [];
const record = await resolveAccount(holder);
const store = record?.docPrivate;
if (!store) return [];
const s = await session();
const out: Array<{ doc: Nuri; inbox: Nuri }> = [];
// The sync barrier, before the read that decides — see the note above.
await ensureRepoOpen(store);
try {
const res = await sparqlQuery(
s.sessionId,
`SELECT ?c WHERE { <${USER_BRANCH_SUBJECT}> <${P.inboxCap}> ?c }`,
undefined,
store,
"readInboxCaps",
);
for (const row of readBindings(res)) {
// See `encodeInboxCap` for why a space is a safe separator here, and why this
// pairing exists at all.
const [doc, inbox] = bindingValue(row, "c").split(" ");
if (doc && inbox && isNuri(doc) && isNuri(inbox)) out.push({ doc, inbox });
}
} catch (error) {
console.error(accessLogPrefix() + " readInboxCaps failed:", error);
throw error;
}
return out;
}
/** The inbox recorded for one document, if this user opened one. */
// @provenance readInboxCapsFor kind=declared-not-wired level=1 ref=engine/repo/src/types.rs:AddInboxCapV0 — the per-document lookup over the same unwired record
export async function readInboxCapsFor(doc: Nuri): Promise<Nuri | undefined> {
return (await readInboxCapPairs()).find((p) => p.doc === doc)?.inbox;
}
/**
* Every inbox this user may READ: its own, plus one per document it opened an
* inbox on. What `connect.connectedUser` drains, and what `isOwnInbox` answers from.
*
* Listing an inbox PUTS IT IN YOUR HANDS, both levels alike
* An `AddInboxCap` record is not a note that an inbox exists upstream it IS the key:
* `AddInboxCapV0 { repo_id, overlay, priv_key }` (`engine/repo/src/types.rs:1973`), which
* `update_inbox_cap_v0` applies straight into the repo (`verifier.rs:1920`). So a session
* that can enumerate an inbox from that record can read it, by construction.
*
* Here the record carries the pair and not the key, so the entitlement it expresses is
* honoured the same way {@link fileOwnInbox} already honours it for a user's OWN two
* inboxes, one line above: the cap is minted from the NURI for the holder the record
* belongs to. Same emulation, same stand-in, and at migration both are replaced by the
* `priv_key` the record carries.
*
* Left out until 2026-08-16 for the DOCUMENT inboxes, and the consequence was not a
* missing feature: connecting enumerated an inbox it then had no key to read, the guarded
* read refused it, and the refusal came back out of `ensureIdentity()` a rejected
* sign-in, on a document the application had never named, repeating at every connection
* because the record is durable. Reported live, three sign-ins out of three.
*/
// @provenance myInboxes kind=aligned level=1 ref=engine/repo/src/types.rs:AddInboxCapV0 — the User branch answers 'which inboxes may I read'. The document-inbox half of this list is declared-not-wired — see `readInboxCapPairs`
export async function myInboxes(): Promise<Nuri[]> {
const { inboxes, incomplete } = await enumerateMyInboxes();
// The whole list or the failure that stopped it — the contract this function has always
// had, and the one `connect.connectedUser` is built on: not knowing which queues exist is
// the session failing to establish, and a short list would silently leave a delivered
// share un-drained. The original error is re-thrown, not wrapped: its caller reads it.
if (incomplete !== null) throw incomplete.error;
return inboxes;
}
/**
* What could be listed of {@link myInboxes}, and whether that is ALL of it.
*
* `incomplete` is `null` when the list is whole, and otherwise carries what stopped the rest
* from being listed. Not an error CODE and not a flag on the array: the point is that a
* caller cannot read this answer without meeting the question "was there a failure", which
* is exactly what a short array on its own let everybody skip.
*/
export interface InboxEnumeration {
/** Every inbox this holder may read that COULD be listed — possibly not all of them. */
inboxes: Nuri[];
/**
* What stopped the rest from being listed, or `null` when nothing did and WHICH of the
* two registers stopped it, because the two shortfalls are not the same condition.
*
* `"account-record"` means the user's OWN two inboxes are missing, which is where a share
* addressed to a PERSON lands: the identity has nothing watched that a stranger can reach,
* and the document half was never even attempted (the account record is what it reads
* through). `"user-branch"` means those two are in hand and only the per-document inboxes
* are missing. Told apart because a caller that reports one of them has to be able to say
* that the OTHER one has now happened `error` alone cannot, and the observation
* de-duplicating on "already said something" swallowed exactly that.
*/
incomplete: { error: unknown; register: InboxRegister } | null;
}
/**
* WHICH of the two registers behind {@link enumerateMyInboxes} could not be read.
*
* Named after the register, not after the failure, because that is what a reader has to go
* and look at: the account record in the doc-shim, and the User branch of the private store.
*/
export type InboxRegister = "account-record" | "user-branch";
/**
* {@link myInboxes}, for the caller that can use a PARTIAL answer the live observation.
*
* The list is built from two independent registers: the account record, which names the
* user's own two store inboxes, and the User branch, which names one per document it opened
* an inbox on. Built in one `try`, one unreachable register discarded BOTH halves the two
* user inboxes were already in hand when the second read threw, and the throw dropped them
* on the floor. A broker hiccup spanning sign-in therefore left the identity connected with
* ZERO inboxes watched, not with the one register it could not reach missing: every deposit,
* including the ones addressed to the person by name, waited for the next connection.
*
* So each half answers for itself, and what came back is returned WITH the failure rather
* than instead of it. That is the line this package draws everywhere: reaching a register is
* infrastructure and may fail, but a failure must never come back looking like an absence
* hence {@link InboxEnumeration.incomplete}, which the caller has to look at.
*/
export async function enumerateMyInboxes(): Promise<InboxEnumeration> {
const holder = getCurrentUser();
if (holder === null) return { inboxes: [], incomplete: null };
const out: Nuri[] = [];
try {
// BOTH of the user's inboxes — public and protected — since upstream a site carries
// one on each of those two store repos (`engine/verifier/src/site.rs:127-152`).
if ((await resolveAccount(holder)) !== null) {
for (const scope of ["public", "protected"] as const) {
out.push(await userInbox(holder, scope));
}
}
} catch (error) {
// The account record is what the other half reads THROUGH (`readInboxCapPairs` resolves
// the same record to find the private store), so there is no second half to attempt.
return { inboxes: out, incomplete: { error, register: "account-record" } };
}
try {
for (const { inbox } of await readInboxCapPairs()) {
// The record entitles this holder to read it — see the note above.
fileOwnInbox(holder, inbox);
out.push(inbox);
}
} catch (error) {
return { inboxes: out, incomplete: { error, register: "user-branch" } };
}
return { inboxes: out, incomplete: null };
}
/**
* File a cap received for someone ELSE's document the emulated
* `AddLink { read_cap }` on the User branch of the current user's private store.
*
* This is what makes a received cap DURABLE. Before it, a shared document survived
* only by re-reading the inbox every session, which uses a queue as a database:
* upstream an inbox is consumed, and processing a message *applies* it. Applying a
* Link means writing it here.
*
* Idempotent re-applying the same Link is a no-op, so re-processing an inbox
* (a second tab, a reconnect) costs nothing.
*
* `forHolder` names WHOSE User branch this lands on when it is not the connected
* identity's the emulated inbox processor
* (`emulated-verifier/inbox-processor.ts`) applying a Link for an inbox's owner during
* someone else's session. Omitted, it is the connected identity, unchanged. Getting this
* wrong is not a near-miss: a Link filed under the wrong holder gives one user another's
* capability and leaves the real recipient with nothing.
*/
// @provenance addLink kind=declared-not-wired level=1 ref=engine/repo/src/types.rs:AddLinkV0 — the type is DEFINED and its `CommitVerifier::verify` is a no-op `Ok(())`; nothing in the engine constructs one
export async function addLink(cap: ReadCap, forHolder?: PrincipalId): Promise<void> {
const holder = forHolder ?? getCurrentUser();
if (holder === null) return;
const record = await ensureAccount(holder);
const store = record.docPrivate;
if (!store) return;
if ((await readLinks(forHolder)).includes(cap)) return;
const s = await session();
try {
await registerUpdate(
s.sessionId,
`INSERT DATA { <${USER_BRANCH_SUBJECT}> <${P.link}> "${escapeLiteral(cap)}" }`,
store,
"addLink",
holderRing(forHolder),
);
} catch (error) {
console.error(accessLogPrefix() + " addLink failed:", error);
}
}
/**
* The cap-registry key of a NAMED holder, or `undefined` for "whoever is connected".
*
* One rule decides both, and it has to: the registry keys a holder's caps by
* `normalizeId` (`shared-wallet/bootstrap.ts` `capsHolder`), and the shim keys its accounts
* the same way ({@link accountKey}). A path that named a holder any other way would file
* into a ring the owner's own session never looks at.
*/
function holderRing(forHolder: PrincipalId | undefined): string | undefined {
return forHolder === undefined ? undefined : accountKey(forHolder);
}
/**
* The caps this user has received and applied the User branch read back. Called
* at connection to restore what was shared with them, without touching any inbox.
*
* **Propagates a failed read** rather than answering `[]`. Empty and unreadable are the
* same value here and could not be more different: "nobody has shared anything with me"
* against "everything shared with me is invisible and nothing said so". The caller that
* matters is `connect.connectedUser`, whose whole contract is that it either did the
* restore or says it did not (2026-08-13) an empty answer would let it report success
* over a restore that never happened. Same reason `lookupAccount` exists beside
* `resolveAccount`.
*
* `forHolder` names whose User branch is read when it is not the connected identity's
* see {@link addLink}, whose idempotence check is the caller that needs it.
*/
// @provenance readLinks kind=declared-not-wired level=1 ref=engine/repo/src/types.rs:AddLinkV0 — same: defined, verifier is a no-op, constructed nowhere. NOT this library's invention — an absent implementation says nothing about the target
export async function readLinks(forHolder?: PrincipalId): Promise<ReadCap[]> {
const holder = forHolder ?? getCurrentUser();
if (holder === null) return [];
const record = await ensureAccount(holder);
const store = record.docPrivate;
if (!store) return [];
const s = await session();
const out: ReadCap[] = [];
const ring = holderRing(forHolder);
await ensureRepoOpen(store, ring);
const query = `SELECT ?c WHERE { <${USER_BRANCH_SUBJECT}> <${P.link}> ?c }`;
try {
// Two doors, one question. The connected identity's own register goes through the
// ordinary guarded read; a NAMED holder's goes through the processor's door, which
// asks the boundary about THAT holder's possession instead of the session's — see
// `register-write.readForHolder`.
const res =
ring === undefined
? await sparqlQuery(s.sessionId, query, undefined, store, "readLinks")
: await readForHolder(s.sessionId, query, store, ring, "readLinks");
for (const row of readBindings(res)) {
const v = bindingValue(row, "c");
if (v && hasReadCap(v)) out.push(v);
}
} catch (error) {
console.error(accessLogPrefix() + " readLinks failed:", error);
throw error;
}
return out;
}
/**
* The `openDocumentInbox` calls currently in flight, keyed by `(holder, document)`
* mirrors `account-registry.userInbox`'s `inboxInFlight`, which had the same read-then-
* mint shape and was made concurrency-safe first. This one was not, and the gap was
* reported from an application: four simultaneous calls on ONE document minted three
* inboxes, after which the owner drained one while deposits arrived in another.
*
* Note what did NOT happen: nothing failed. Every caller read the register, every read
* ANSWERED, and every answer was honestly "no inbox recorded" because none of the writes
* had landed yet. So this is not the "a failure resolved like a success" family the rest of
* this file guards against; it is a read-then-write with no coalescing, and only the
* coalescing closes it.
*
* Keyed by the HOLDER as well as the document, because the answer is the holder's: the
* register lives on their User branch, and a non-owner asking gets a refusal, never an
* inbox. Keyed on `accountKey` so `@Alice` and `alice ` one person share one entry,
* and joined with `\u0000` for the reason `userInbox` uses it: a separator no identifier
* can contain is the only one that cannot make two different pairs share a key.
*
* Deliberately holds no RESOLVED value, unlike the `inboxCache` beside its counterpart:
* entries are dropped the instant the call settles, whether it answered or threw, so the
* next ask re-reads the durable register rather than trusting a memo and a refusal
* never lingers as one. That is also why this map needs no reset hook: nothing in it can
* go stale, because nothing in it has finished.
*
* **Its reach is one JS realm, and that is the whole of what it promises.** Two browser
* tabs, or two sessions, share no map: each reads the register, each finds nothing, and
* each mints the durable fork this cannot prevent. Preventing it needs a conditional
* write ("insert only if absent") that no layer of the target offers: a branch is an
* add-only CRDT, so two `AddInboxCap` records simply merge. Nor can it be reconciled
* after the fact the way `canonicalDoc` reconciles a forked account pointer: the owner
* resolves from the User branch and a depositor from the document's published address,
* two different records, and the second is last-write-wins by construction upstream
* `inboxes: PubKey → RepoId` is a function and `repo.inbox` a single `Option`, so
* accumulating two addresses to pick a canonical one is a state the model has no meaning
* for. The contract says one realm; see `contract_polyfill-surface.md`.
*/
const openInboxInFlight = new Map<string, Promise<Nuri>>();
/**
* The inbox of a document this user owns resolved, and created on first ask.
*
* Upstream a repo carries `inbox: Option<PrivKey>` (`engine/repo/src/repo.rs:126`):
* an inbox is a keypair on the repo, whose PRIVATE half its owner holds. That half is
* recorded with `AddInboxCap { repo_id, overlay, priv_key }` *"into the user branch,
* so that a user can share with all its device"* (`engine/repo/src/types.rs:1973`), the
* same branch that carries `AddLink`. So "which inboxes may I read" is answered by the
* User branch, and that is what this emulates.
*
* **The engine SUPPORTS this; nothing exercises it automatically.** Those are two
* different statements, and conflating them is what made an earlier version of this
* comment call the feature an "anticipation". It is not. `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`); and
* `update_inbox_cap_v0` applies it with `self.repos.get_mut(repo_id)` and **no
* `is_store` check of any kind** (`engine/verifier/src/verifier.rs:1920`). Generic by
* construction, and at any time (see the User-branch note above).
*
* What is true is narrower: no code path CREATES one for a document `new_store_default`
* attaches one only `if !private` (`verifier.rs:2994`), `doc_create` leaves `inbox: None`,
* and the only two `AddInboxCap` commits in the engine are for the
* public and protected STORE repos (`engine/verifier/src/site.rs:128,149`). So the
* capability exists and is simply unexposed above level 1: this function is aligned on
* the engine's model, it does not bet past it.
*
* *(The `inbox: None` claim is true; its citation was wrong until 2026-08-10. It pointed
* at `repo.rs:574`, inside `Repo::new_with_member` (`engine/repo/src/repo.rs:543`)
* a constructor reached only from `Repo::new_with_perms`, itself gated
* `#[cfg(any(test, feature = "testing"))]` (`repo.rs:186-192`), and from `#[cfg(test)]`
* blocks (`branch.rs:387,490`; `commit.rs:1659,1849,1919`). The PRODUCTION path is
* `doc_create` `Verifier::new_repo_default` (`engine/verifier/src/verifier.rs:3004`,
* called at `request_processor.rs:689`) `Store::create_repo_default`
* (`engine/repo/src/store.rs:264`) `create_repo_with_keys` (`store.rs:284`), which
* builds the `Repo` with `inbox: None` at `store.rs:691`.)*
*
* Lazy on purpose, for the same reason: creating an inbox document for every entity up
* front would double every `createEntityDoc` for inboxes most documents never receive
* anything in. Upstream the keypair is cheap; here an inbox is a document, so it is
* minted when first asked for.
*
* *(Not covered: ROTATING an inbox key the engine's "update" case with a new
* `priv_key`. This function is idempotent and returns the existing inbox instead. A
* known limit, not an oversight.)*
*
* Only for a document this user OWNS see {@link ownsDocument}. Opening an inbox on
* someone else's document would be usurpation, not a courtesy: the opener keeps the
* reading half, so it would silently divert to itself the deposits meant for the
* owner. To deposit into someone else's document, resolve
* {@link documentInboxAddress} and `inbox.post` into it.
*
* **Idempotent, including under concurrency within ONE JS realm.** Simultaneous asks
* for the same document by the same holder are coalesced onto a single call
* ({@link openInboxInFlight}), which is what stops N callers each reading "no inbox" and
* each minting one. Two TABS still fork, and cannot be stopped from here read the note
* on that map before assuming otherwise.
*/
// @provenance storeRegistry.openDocumentInbox kind=declared-not-wired level=1 ref=engine/repo/src/types.rs:AddInboxCapV0 — every `Repo` carries `inbox: Option<PrivKey>` and the record is keyed by any `repo_id`, but `new_store_default` attaches one only to non-private STORES and `doc_create` leaves `inbox: None`. PUBLISHING the address is a separate, divergent act — see `publishInboxAddress`
export async function openDocumentInbox(docLike: NuriLike): Promise<Nuri> {
// Permissive in, precise out — see `model/nuri.ts`. Published through
// `surface/placement.ts`, so it is a door an application types against.
const doc = toNuri(docLike, "openDocumentInbox");
const holder = getCurrentUser();
if (holder === null) throw new Error("[ng-eventually] openDocumentInbox: no identity is set");
// Everything below is one read-then-write: it asks the register whether an inbox is
// already recorded, and mints when the answer is no. A dozen awaits separate the two,
// so callers that arrive together all read before any of them writes — each finds
// nothing, each mints, and the document ends up with several. Coalescing them onto ONE
// call is the whole fix, and it is where the guarantee is enforced rather than merely
// hoped for: a second caller never runs the body at all, it awaits the first.
const key = `${accountKey(holder)}\u0000${doc}`;
const pending = openInboxInFlight.get(key);
if (pending) return pending;
const p = resolveOrMintDocumentInbox(doc, holder);
openInboxInFlight.set(key, p);
try {
return await p;
} finally {
// Dropped whether it resolved or threw. A refusal (not the owner) or a failed persist
// must not linger as an answer: the next ask has to look again, exactly as it would
// have if it had arrived a moment later.
openInboxInFlight.delete(key);
}
}
/**
* The body of {@link openDocumentInbox}, minus the coalescing a separate function so
* the "one call per (holder, document)" invariant is carried by the composition rather
* than by where a check sits inside a long block. Never call it directly: it is the
* un-coalesced path, and reaching it twice concurrently is the bug.
*/
async function resolveOrMintDocumentInbox(doc: Nuri, holder: PrincipalId): Promise<Nuri> {
const known = (await readInboxCapsFor(doc)) ?? null;
if (known) return known;
// OWNERSHIP is the criterion, and holding a cap is NOT ownership — a cap can be
// received. Opening an inbox is what PUBLISHES this document's address, so a
// non-owner doing it would route the owner's deposits to itself, silently, on a
// document it merely reads.
//
// **This guard compensates OUR design, not an upstream constraint** — an earlier
// comment here claimed "upstream only the owner can commit `AddInboxCap`", which is
// false: that commit lands on the committer's OWN User branch, so anyone may write
// one naming anyone's repo. What protects upstream is that an inbox address is never
// PUBLISHED — it is TRANSMITTED (in a `ContactDetails` message, or a profile QR
// code), and `inboxes: PubKey → RepoId` is a table of the VERIFIER
// (`engine/verifier/src/verifier.rs:105`) — one per user. A forged pair reaches
// nobody because it only ever lands in the forger's OWN table; nobody else was told.
//
// The motive matters, and it was wrong here until 2026-08-10: this comment said the
// table is "rebuilt empty each session", which is not what the source does. It is
// initialized empty at construction (`:520`, `:2820`) and then REPOPULATED at every
// load — `Verifier::load` (`:534-566`) → `add_repo_without_saving` (`:2871`) →
// `add_repo_` (`:2887`), which re-inserts `repo.inbox.to_pub() → repo.id` for each
// repo it reloads — and the inbox private key itself is persisted per repo
// (`INBOX_CAP`, `engine/verifier/src/user_storage/repo.rs:61,171,207,362`). So the
// knowledge is durable; what it is not is SHARED. Per-verifier, not ephemeral.
//
// We publish instead of transmitting — the only way a third party can find the
// address at all here — which creates a vector upstream does not have: whoever can
// write the document can redirect its deposits. Hence this guard. It is a real
// divergence, deliberately taken; see `docs/briefs/2026-08-03-document-inbox-addressing.md`.
if (!(await ownsDocument(doc))) {
throw new Error(
"[ng-eventually] openDocumentInbox: refused — you may only open an inbox on a document " +
"you own. To reach its owner, name the DOCUMENT: `inbox.postToDocument(doc, …)`, " +
`which resolves the address itself: ${JSON.stringify(doc)}`,
);
}
const inbox = await createDoc();
const s = await session();
const record = await ensureAccount(holder);
const store = record.docPrivate;
getCaps().open(inbox, "private"); // its owner holds it, like any document of theirs
// …and the shim records that it IS an inbox, so a depositor can find that out without
// holding anything of it. See `recordInbox`: upstream a deposit cannot address a plain
// document at all, and this is what stands in for that impossibility — and WHOSE it is,
// which is the other half of the same routing fact. The owner is `holder`: this branch is
// past the ownership guard, so the opener owns the document the inbox belongs to.
await recordInbox(inbox, holder);
if (store) {
try {
await registerUpdate(
s.sessionId,
`INSERT DATA { <${USER_BRANCH_SUBJECT}> <${P.inboxCap}> "${escapeLiteral(encodeInboxCap(doc, inbox))}" }`,
store,
"openDocumentInbox",
);
} catch (error) {
// This record is what makes the inbox DRAINABLE: `myInboxes` builds the connection's
// drain list from it, and `readInboxCapsFor` answers "have I already opened one"
// from it. Swallowing here went straight on to PUBLISH the address, so depositors
// were invited to write into a queue its own owner never enumerates — every message
// delivered and none ever applied, permanently. Failing before the address is
// published is the honest state: nobody is told where to deposit, and asking again
// opens a fresh inbox.
console.error(accessLogPrefix() + " openDocumentInbox persist failed:", error);
throw error;
}
}
// …and the PUBLIC half, in the document itself, so a depositor can find it at all.
// Without this the inbox is reachable only by its owner — the opposite of what an
// inbox is for, and the bug this path shipped with.
await publishInboxAddress(doc, inbox);
return inbox;
}
@@ -0,0 +1,425 @@
/**
* Capability emulation key POSSESSION, not an authorization list.
*
* In NextGraph a ReadCap **is** the document's read key: whoever holds it reads,
* and there is no read-ACL anywhere. This module emulates that shape (see
* `docs/briefs/2026-07-27-p1a-cap-surface.md`), which means it answers exactly one
* question *do I hold this document's cap?* and cannot answer "may principal P
* read document D", because the real model cannot either.
*
* Where caps come from and why this is NOT "a keyring"
* There is no keyring object in NextGraph, and calling this one invited a wrong
* mental model: that some single place holds every key. It does not. Upstream the
* caps of a user are in **two** places, by origin (see
* `docs/readcap-and-nuri-model.md` §4quater/§4quinquies):
*
* - 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.share(doc, toUser)` 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.
*
* And for a document in a PUBLIC store there is no sharing act at all: the store hands
* its cap to whoever asks (`public-store.ts`), so what circulates is the bare
* reference. Filed apart (`learnFromPublicStore`) because it grants reading only.
*
* What this module does NOT do
* Enforce. The shape is right, delivered by cap-surface; the isolation is still
* fake. Per-document encryption and closing the read paths that bypass the guard
* (an ANCHORLESS `docs.sparqlQuery`, the inbox, `store-registry`, `subscribe`,
* `open-repo`) are cap-enforcement's job. Nothing may be claimed "anonymous" or
* "private" until then.
*
* The write caps below (`grantWrite`, `governsWrite`, `canWrite`, `hasWritePolicy`) are
* **inert, not partial** a distinction the docs got wrong until 2026-08-07, when an
* adversarial review measured it. `grantWrite` has NO production caller, so
* `hasWritePolicy()` is permanently false and the `ng-proxy` guard they feed never fires
* at all. Writing is governed instead by OWNERSHIP, at the write door (`reach.ts`
* `assertMayWrite`) which is what upstream's `verify_permission` actually checks. These
* four are dead surface kept for cap-enforcement; do not read them as a working policy.
*/
import { CAP_SEGMENT, hasReadCap, targetOf } from "../model/nuri";
import type { Nuri, PrincipalId, ReadCap, Scope } from "../model/types";
/**
* The stand-in cap value, and the minting point moved here from `model/nuri.ts`
* on 2026-08-03 because it did not belong to the model.
*
* `model/` transcribes the target's addressing vocabulary; minting is not part of
* that vocabulary. Upstream nothing on the surface turns a bare reference into a cap:
* the engine mints at repo creation and you afterwards look a cap up in what you hold,
* or you were given it. Keeping `mintCap` in the model module contradicted that module's
* own header, and put the emulation's one invented value in the file that claims to hold
* only verified target vocabulary.
*
* cap-enforcement replaces this single constant with a real key; migration deletes both.
*/
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. INTERNAL to the emulated verifier.
*/
// @provenance mintCap kind=divergent level=1 ref=engine/repo/src/types.rs:readcap_nuri — upstream a cap is SERIALIZED from the ObjectRef at repo creation; here it is DERIVED from a NURI with a stand-in key. Unreachable from outside on purpose
export function mintCap(nuri: Nuri): ReadCap {
return `${targetOf(nuri)}${CAP_SEGMENT}${STAND_IN_CAP}`;
}
/** The map key of the anonymous holder (no identity established yet). */
const ANONYMOUS = "";
// @provenance CapRegistry kind=aligned level=1 ref=engine/repo/src/repo.rs:Repo — the session's record of which repos it holds a `read_cap` for; upstream that record is the verifier's opened repos, never a queryable registry
export class CapRegistry {
/** holder → the caps they hold, indexed by the cap-less NURI. */
private heldByHolder = new Map<string, Map<Nuri, ReadCap>>();
/**
* holder the documents they CREATED in this session, through {@link mint}.
*
* Authorship, for the one path that records it nowhere else. `storeRegistry`'s
* documents are recorded durably on a Store branch (the emulated `AddRepo`, which is
* what upstream's `doc_create` commits), so `ownsDocument` finds them on a later
* session. The raw `docs.docCreate` has no store to record into so nothing about
* such a document survives its session, and an in-session note of who made it is
* exactly as durable as the thing it describes.
*
* Consulted by the write guard before it pays for a Store-branch read. Without it the
* guard refused a caller a write to a document it had just created caught by the
* live-broker e2e, seven steps red, after the unit suite stayed green.
*/
private mintedByHolder = new Map<string, Set<Nuri>>();
/**
* Documents this session knows to sit in a PUBLIC store a fact about each
* DOCUMENT, so global rather than per-holder, unlike everything else here.
*
* It is not itself a right. What being in a public store buys is that the document's
* cap can be DOWNLOADED by anyone who asks (`emulated-verifier/public-store.ts`,
* emulating `PublicRepoLinkV0`'s *"downloaded from the outerOverlay"*); once it has
* been, the holder holds it like any other and this set records only how it got there.
*/
private inPublicStore = new Set<Nuri>();
/** doc NURI → principals holding its WRITE cap. Decorative until cap-enforcement. */
private writers = new Map<Nuri, Set<PrincipalId>>();
/** Fired whenever a holder gains a cap a cap delivered asynchronously must
* re-trigger the reads that were empty for want of it. */
private listeners = new Set<() => void>();
/** Has any cap been issued at all? Gates the whole emulation (see {@link isEnforcing}). */
private issued = false;
/**
* @param holder resolves WHO is holding the current identity. Looked up through it on every
* 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 ----------------------------------------------
/**
* The key of the holder currently connected capture it when you DECIDE that a cap is
* someone's, and hand it back to {@link learnFor} when you file.
*
* **A hazard closed, not a leak observed** the distinction matters and I got it wrong
* once while writing this. Filing resolves the holder at the moment it runs, and three
* paths file several `await`s after the check that authorised them (connecting, reading
* an inbox, listing one's own documents). So an application switching identity in the
* gap COULD have the first identity's caps filed into the second one's ring. That is
* structural and visible by reading. What was NOT established is that it happens: the
* reproduction that seemed to show it turned out to be a broken test fake, and once the
* fake was corrected the leak did not reproduce.
*
* The pairing stays because it costs one argument and removes the hazard by
* construction, where a re-check at each of three sites is a discipline. It is not
* evidence of a bug that was found.
*/
holderKey(): string {
return this.holder() ?? ANONYMOUS;
}
/** What the current holder holds, created on first use. */
private heldCaps(): Map<Nuri, ReadCap> {
return this.ringFor(this.holderKey());
}
private ringFor(key: string): Map<Nuri, ReadCap> {
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, key: string = this.holderKey()): 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.ringFor(key);
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);
const key = this.holder() ?? ANONYMOUS;
let made = this.mintedByHolder.get(key);
if (!made) this.mintedByHolder.set(key, (made = new Set()));
made.add(targetOf(nuri));
return cap;
}
/**
* Did the current holder CREATE this document in this session? Authorship, and
* therefore the right to write see {@link mintedByHolder}.
*/
mintedHere(nuri: Nuri): boolean {
return this.mintedByHolder.get(this.holder() ?? ANONYMOUS)?.has(targetOf(nuri)) ?? false;
}
/**
* 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);
}
/**
* File a cap for a NAMED holder the one the caller decided for, not whoever happens
* to be connected when the `await` resumes. See {@link holderKey}.
*/
learnFor(key: string, cap: ReadCap): void {
this.file(cap, key);
}
/**
* File a cap a PUBLIC STORE served me `emulated-verifier/public-store.ts`, the
* emulated *"downloaded from the outerOverlay"*. Held like any other cap, so reading
* needs no special case anywhere; recorded apart because of what it is NOT.
*
* It is a READ grant and nothing else. Upstream a public store makes its repos
* world-readable, never world-writable writing needs the write cap, and
* `verify_permission` fires on WRITE only. Here the write guard still consults the
* read cap (write caps are decorative until cap-enforcement, see the module header), so without
* this distinction a bare reference to a public document would buy a WRITE a
* consumer would build on it, and have to unlearn it at migration.
*
* A stronger claim on the same document erases the mark: {@link mint} (I created it)
* and {@link learn} (it was deposited for me) both go through {@link file}, which
* clears it. So a public document of my own is never read-only to me.
*/
learnFromPublicStore(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));
}
/**
* Does the NAMED holder hold `nuri`'s cap? The reading counterpart of {@link learnFor},
* and it exists for the same caller: work decided for one holder that runs while ANOTHER
* one is connected the emulated inbox processor
* (`emulated-verifier/inbox-processor.ts`), which drains an inbox on behalf of its owner
* during someone else's session. Asking `capFor` there would consult the connected
* identity's ring, which is not the ring the question is about.
*
* Reads without creating a ring, unlike {@link heldCaps}: asking about a holder must not
* file one. Still no principal parameter in the model's sense the question is "does
* THIS ring hold the key", never "may principal P read D".
*/
capForHolder(key: string, nuri: Nuri): ReadCap | undefined {
return this.heldByHolder.get(key)?.get(targetOf(nuri));
}
// --- publication (the public store) -------------------------------------
/**
* Record that `nuri` sits in a PUBLIC store. A fact about the DOCUMENT, not a right
* of anyone hence a global set rather than a per-holder one, and hence no minting
* here: what sitting in a public store buys is that the cap is **obtainable** by
* whoever asks (`emulated-verifier/public-store.ts`), which is a separate act from
* this one holding it.
*
* Marking and minting were one method (`recordInPublicStore`) until they were split:
* the fetch path files the cap it DOWNLOADED, and minting a second one beside it
* would produce a different key the day the stand-in constant becomes a real one
* the same trap `holdOwnCap` already documents.
*
* Upstream nothing corresponds to this call: the store IS public, and the broker
* exposes its outer overlay (`expose_outer`,
* `engine/broker/src/server_storage/core/overlay.rs:103-133`). We record it because
* one broker here serves every virtual user identically.
*
* NOT recursive: a document in a public store may REFERENCE private ones, and the
* reference grants nothing on what it references. That non-recursiveness is what lets
* a public object point at private content without disclosing it.
*/
markInPublicStore(nuri: Nuri): void {
this.inPublicStore.add(targetOf(nuri));
}
/** Is `nuri` recorded as sitting in a public store? A fact about the document. */
isInPublicStore(nuri: Nuri): boolean {
return this.inPublicStore.has(targetOf(nuri));
}
/**
* Record a document the current holder owns in `scope`: its cap lands among what
* they hold, and a `public` one is additionally marked as sitting in a public store.
* Returns the cap. Idempotent the 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 cap-enforcement, and
* arming their guard here would be enforcement this batch does not do.
*/
open(nuri: Nuri, scope: Scope): ReadCap {
// `file`, NOT `mint` — and the difference is a hole that was open for one commit.
//
// Every caller of this method files a STRUCTURAL document: one of the holder's three
// store documents, or an inbox. Those are not authored content, they are registers —
// written only through `emulated-verifier/register-write.ts`. Minting them marked
// them "created by me", which let the write guard through, which let a holder append
// `contains "<anyone's document>"` to their own store index through the PUBLISHED
// `docs.sparqlUpdate` and forge ownership of it. `ownsDocument` reads that very
// index, so the guard was fully bypassable from the surface.
//
// Found by re-running the adversary on the fix (2026-08-07). Filing without minting
// closes it at the source: a structural document is owned by nobody in the authorship
// sense, so both halves of `assertMayWrite` say no, which is correct.
const cap = mintCap(nuri);
this.file(cap);
if (scope === "public") this.markInPublicStore(nuri);
return cap;
}
// --- 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 cap-enforcement) ----------------------------------
/** Grant `principal` the WRITE cap of document `doc`. */
grantWrite(doc: Nuri, principal: PrincipalId): void {
const target = targetOf(doc);
let s = this.writers.get(target);
if (!s) this.writers.set(target, (s = new Set()));
s.add(principal);
}
/** Is `doc` under any WRITE-cap policy? */
governsWrite(doc: Nuri): boolean {
return this.writers.has(targetOf(doc));
}
/** Does `principal` hold a WRITE cap for `doc`? */
canWrite(doc: Nuri, principal: PrincipalId | null): boolean {
if (principal === null) return false;
return this.writers.get(targetOf(doc))?.has(principal) ?? false;
}
/** No WRITE policy declared → the write guard stays inert (passthrough). */
hasWritePolicy(): boolean {
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 {
this.heldByHolder.clear();
this.mintedByHolder.clear();
this.inPublicStore.clear();
this.writers.clear();
this.issued = false;
this.notify();
}
}

Some files were not shown because too many files have changed in this diff Show More