Commit Graph

109 Commits

Author SHA1 Message Date
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 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 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 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 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 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 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 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 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 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 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 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 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 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