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.
« 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.
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.
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.
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.
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`.
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.
Chemin données bas-niveau du polyfill rendu lisible pour diagnostiquer en session live :
- Format identité-en-premier : `[<identity>][polyfill] OP shortNuri (label)` ;
console.error épars (store-registry, inbox) unifiés au même préfixe.
- Trace (derrière le flag debug) : issue de la barrière ensureRepoOpen
(synced|timed-out + durée) et résultat sémantique de chaque étage de résolution
(resolvePointer/canonicalDoc/resolveAccount/resolveShimDoc/readScopeIndex).
- outbox-log.ts (nouveau) : inspection read-only de l'outbox hors-ligne au
démarrage de session ; console.warn si non vide (anomalie, toujours visible),
sous flag si vide. Le comptage seul est fiable (payloads BARE opaques côté JS).
Pas de changement fonctionnel. bun test 126 pass ; tsc 0 erreur.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014GbGgNEHRejVKoREvFuDFg
- Migration des comptes legacy supprimée (migrateLegacyRecords + garde migratedInto
+ call-sites). Un wallet pré-fix (records store-root, pas de pointeur) provisionne
simplement un doc-shim frais; contenu legacy ignoré (voulu, données = dev). La
résolution barrière-autoritative + anti-fork (resolvePointer/ensureRepoOpen/
canonicalDoc/ensureInFlight/pointerGuard) est inchangée.
- Logs d'accès SDK préfixés [polyfill] + NURI tronqué via shortNuri() (retire
did:ng:o: et :v:…, garde 8 chars) → moins verbeux.
Ex: [polyfill] [user1] READ vDlwbZio… (resolvePointer) → 1 triple-rows
Tests: bun test unit 126/0. Docs (nextgraph-current-state/simulation/migration-guide)
mis à jour (migration legacy retirée du modèle décrit).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bug: à la reconnexion, resolveAccount lisait le shim depuis le store-root
(did🆖${privateStoreId}), NON abonnable → pas de barrière first-State → un "0 rows"
à froid est ambigu → le retry (resolveAccountReliably/provisionRetry) échoue → nouveau
compte provisionné → FORK → données du compte invisibles.
Cause NextGraph (vérifiée nextgraph-rs): "trouvable-sans-lookup" (store-root) et
"abonnable" (did:ng:o:<RepoID aléatoire>) sont DISJOINTS — pas de doc à la fois
devinable et attendable → une résolution shim purement barrière est impossible.
Fix (indirection pointeur → doc-shim abonnable):
- Les AccountRecord migrent dans un doc-shim doc_create'd (did:ng:o:..., a une barrière).
- Un pointeur écrit-une-fois dans le store-root (<shim:root> <shim:shimDoc> <docShim>)
le nomme. resolveShimDoc lit le pointeur → ensureRepoOpen(docShim) [barrière] → lecture
de compte AUTORITATIVE (cold 0 = absent pour de vrai). Retry de compte SUPPRIMÉ.
- Micro-garde résiduel (pointerGuard, ex-provisionRetry) sur le SEUL triple pointeur
écrit-une-fois; ne peut jamais forker un compte; fork de pointeur réconcilié au
doc-shim canonique (lexicographiquement-min), sans perte.
- Migration: migrateLegacyRecords copie (pas déplace) les comptes de l'ancien store-root
vers le doc-shim avant toute conclusion "absent"; idempotent; wallet neuf → no-op.
Tests: unit 128/128, e2e réel 42/42 (CONTRACT 2 = non-fork du compte à la reconnexion),
red-before/green-after prouvé. Docs: nextgraph-current-state (antagonisme + indirection),
simulation, migration-guide.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Défensif : loadShim/resolveAccount/ensureAccount ouvrent l'anchor (private-store-root)
avant de lire/écrire le compte shim, comme le fait déjà readScopeIndex. Robustesse
same-session si l'anchor est là-mais-pas-encore-souscrit.
NB (vérifié nextgraph-rs) : sur le login broker normal, le bootstrap charge le
private-store dans self.repos AVANT de rendre la session à JS → un wallet FRAIS
retourne 0 rows (pas RepoNotFound) et provisionne. Il n'existe AUCUN primitif JS
pour ouvrir un repo *inconnu* : ce heal n'est pas un remède à un store non
bootstrappé (limite NextGraph), juste une robustesse d'ouverture same-session.
Tests : cold-start-anchor.test.ts (rouge-avant/vert-après unit) ; harness e2e
repro-fresh-wallet (mint un wallet neuf par run — comble le trou "aucun test de
démarrage à froid sur wallet vierge"). Fakes anti-fork/watch-shape honorent
désormais la barrière first-State dont dépend le heal. e2e réel 42/42.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Dernière couche du bug de reconnexion : au cold-start, `watchShape` public +
protected + l'effet owned-events appellent `ensureAccount(A)` quasi-simultanément
AVANT la sync du shim → chacun lit 0 → chacun provisionne un nouveau jeu de docs
(fork par-appelant) → la résolution déterministe canonique fait alors diverger
lecteur et écrivain sur le docProtected → `readScopeIndex` vide.
Fix : `ensureInFlight` (map de promesses) dé-duplique les provisions concurrentes
en UNE seule ; `discovery.readIndex` ouvre son repo au cold-start (`ensureRepoOpen`).
Avec la résolution canonique déjà committée, écrivain et lecteur convergent.
Mesuré (levier isSuccess) : la participation protected converge `isSuccess=true,
data=1` sur la page fraîche (plus « vide à 30s »).
gate : tsc 0 ; bun test 123 ; test:e2e 42/42.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Cause racine du bug de reconnexion (probe contrôlé répété) : le shim d'un compte
accumule des `docPublic`/`docProtected` EN DOUBLE (forks passés), et
resolveAccount/indexDocOf les choisissaient de façon NON-DÉTERMINISTE → l'écrivain
et le lecteur (page fraîche) ancraient sur des docs d'index DIFFÉRENTS → lecture 0.
Fix :
- `canonicalDoc()`/`recordFromRows()` : parmi plusieurs valeurs d'un scope, choisir
le NURI lexicographiquement le plus petit (les NURIs sont content-addressed →
ordre total stable). Écrivain et lecteur résolvent TOUJOURS le même doc, même sur
un shim corrompu par des doublons.
- `resolveAccountReliably` RESTAURÉ (retry borné avant provision sur read shim 0 à
froid) : j'avais retiré l'anti-fork à tort (`38b1521`) — le gap EST exhibé (fork
non-déterministe au cold-read), et la barrière `user_connect` n'est PAS accessible
côté JS → un retry borné est la compensation légitime (pas la barrière-store
cassée de `45dbd9a`). Budget injecté `provisionRetry` ; défaut attempts:1 (fakes
synchrones), app/e2e attempts:8.
anti-fork.test.ts réécrit : docPublic dupliqué → même canonique ; retry sur lag →
réutilise ; neuf → provision 1×.
gate : tsc 0 ; bun test 122 ; test:e2e 42/42 (CONTRACT 2 non-fork vert).
Portée : corrige la couche docPublic de la reconnexion. Une couche PROTECTED
distincte (watchShape protected ne converge pas à froid) reste — diagnostic en cours.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phase A du refactor des lectures. Surface la barrière de sync interne
(open-repo `getSyncState`) dans une API useQuery-shaped, en anticipation de la
mise à jour prévue de useShape par NextGraph — distingue nativement « sync en
cours » de « synchronisé mais vide ».
`watchShape<T>(shapeType, scope): { getSnapshot(): ShapeQuery<T>, subscribe(cb),
refetch() }` avec `ShapeQuery = { data, isPending, isSuccess, isError, error }`.
OBSERVABLE (pas de dépendance React — l'app câblera useSyncExternalStore en
phase B) ; getSnapshot rend une référence stable.
- Scope LOGIQUE (public/protected/private) résolu au wallet virtuel :
listMyEntityDocs(getCurrentUser, scope) + découverte foldée pour public.
- isPending tant que la barrière n'est pas atteinte / 1er readUnion non rendu ;
isSuccess après ; timed-out → isSuccess (best-effort, pas isError).
- Réactif SANS polling : subscribeDoc sur les docs + le doc d'index de scope
(+ index découverte) → re-read/re-résolution au push ; souscriptions idempotentes.
- Générique : aucune logique domaine Festipod dans le lib ; filtre par la shape
SHEX (rdf:type). Pas de double filtre cap.
gate : tsc 0 ; bun test 120 (+4 : pending→success, synced-vide, réactif, timed-out) ;
test:e2e 42 passed (+scénario broker réel : isPending au 1er snapshot → isSuccess
avec données, scope vide → isSuccess data:[]).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Preuve e2e (wallet frais, broker RÉEL rapide : 1er State 1-2 ms) : le private
store est synchronisé au login, la lecture du shim réussit à froid — le « fork sur
lag » que anti-fork compensait n'est PAS exhibé. Par le principe du polyfill
(compenser un gap RÉEL, jamais du poids mort), et par la règle no-polling :
- la version retry = polling (bannie) ;
- la version barrière `ensureRepoOpen(privateStore)` = CASSÉE (un store n'émet pas
de `State`, la barrière timeout systématiquement → CONTRAT 2 e2e échouait) ;
- le gap = non exhibé.
→ `ensureAccount` fait un `resolveAccount(id)` SIMPLE (une lecture, provision si 0).
`resolveAccountReliably`, `_forceOpenedSyncState` retirés ; `provisionRetry` gardé
optionnel @deprecated (ignoré) pour ne pas casser les 8 tests qui le passent.
`ensureRepoOpen`/`getSyncState` inchangés (chemin de lecture open-repo).
gate : tsc 0 ; bun test 116 ; test:e2e 39 passed, CONTRAT 2 VERT (« same account,
no second provisioning »). Le ~10s du re-resolve public est du scaling anchorless,
pas de la lenteur broker (broker mesuré à 1-2 ms).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Le polling est un anti-pattern NextGraph (par abonnement). La résolution de compte
retentait ×8 la lecture du shim tant qu'elle rendait 0 (lag de sync) — c'est du
polling. Remplacé par la BARRIÈRE d'abonnement, déjà le mécanisme de open-repo :
- `resolveAccountReliably` : `await ensureRepoOpen(did🆖${privateStoreId})`
(subscribe + attendre le 1er State — le shim vit dans le graphe du private store),
PUIS lecture UNIQUE. Après la barrière, 0 ligne = compte réellement inexistant →
provision 1×, lignes présentes = réutilisé (garantie NO-FORK préservée). Plus de
boucle de re-lecture.
- timed-out (barrière expirée) : throw explicite, NE provisionne PAS (un provision
sur sync incomplète re-forkerait). Le « trop long » est un signal, pas un feu vert.
- fake ng sans doc_subscribe : ensureRepoOpen no-op → lecture immédiate (unit intact).
- `_forceOpenedSyncState` : helper test-only (underscore, non ré-exporté).
anti-fork.test.ts réécrit (5 tests : no-fork, neuf→1 provision, idempotence, fake
no-op, timed-out→throw) ; plus aucun test de comptage de retry.
gate : tsc propre ; bun test 117. e2e À RE-VALIDER quand le broker répond (dégradé
ce jour : crash Chromium post-connexion) — la barrière ensureRepoOpen est déjà
validée e2e (CONTRAT 3 + reconnexion) en broker sain. provisionRetry devient un
champ mort de StoreRegistryDeps (nettoyage ultérieur).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Cause racine mesurée (broker réel, access-log) : à la première resolveAccount
d'une session, le record de compte tout juste persisté (ou d'une session
antérieure) peut lire 0 lignes à cause du LAG DE SYNC broker. ensureAccount
interprétait ce 0 comme « compte inexistant » et RE-PROVISIONNAIT un second jeu
de docs de scope (docPublic/docProtected forkés) → les lectures d'une session
tombaient sur un jeu, celles d'une autre (ou après drop de cache) sur l'autre
jeu vide → données « perdues » à la reconnexion.
Fix : `resolveAccountReliably` (store-registry.ts) — retry borné (ouverture du
repo d'ancre shim + backoff plafonné, défaut 8 tentatives / ≲8.5s) AVANT que
ensureAccount ne décide qu'un compte est neuf. Provisionne seulement si, après
le budget, la lecture rend toujours 0 (compte réellement neuf). Budget injecté
via StoreRegistryDeps.provisionRetry (polyfill.ts), ON en prod ; tests unitaires
à provisionRetry synchrone (attempts:1, fake sans lag). Idempotence de session
préservée par accountCache (hit court-circuite, déterministe).
Portée : corrige le déterminisme de provisioning. NE suffit PAS à réparer la
reconnexion (le read public à 0 same-session subsiste, cause distincte encore à
mesurer de façon déterministe — le lag broker rend les mesures non-reproductibles).
Complémentaire du commit open-repo précédent, pas redondant.
gate : tsc --noEmit propre ; bun test 91 pass ; auth @data (vide/distinctes) verts.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Défaut visé : sur une session verifier fraîche (reconnexion), la lecture ancrée
tape des repos pas encore ouverts dans self.repos → 0 ligne. Nouveau module
`open-repo.ts` : `ensureReposOpen`/`ensureRepoOpen` ouvrent/souscrivent un repo
via la primitive existante `subscribeDoc` (doc_subscribe) et attendent le push
d'état initial (borné, sans polling) AVANT la lecture ancrée. Câblé en amont de
`readUnion` (read-model) et `readScopeIndex` (store-registry). Idempotent par
session (Set des NURI ouverts + Map in-flight ; ré-ouverture si la session
injectée change). No-op si le `ng` injecté n'a pas doc_subscribe (fake unitaire).
État — NON MERGÉ, incomplet :
- AIDE le PROTECTED : la participation remonte au cold-start (mesuré app, timing
un peu bruité).
- N'ADRESSE PAS l'accueil PUBLIC : `readScopeIndex` de l'index public rend 0 même
côté écrivain même-session — le fix ouvre le repo mais l'index reste vide. Le
code d'index lib est prouvé scope-symétrique, donc la cause du public est
ailleurs (broker/store ou chemin app), À MESURER SOUS BROKER (actuellement
injoignable). Nécessité du fix elle-même non prouvée en e2e lib (broker down).
gate broker-indépendant : tsc --noEmit propre ; bun test 91 pass (worker).
Inclut le scaffolding e2e de repro reconnexion (broker/run/sdk-entry).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Observability probe for the shared-wallet isolation footgun: on one physical
wallet several virtual identities coexist, and a read must never surface a doc
scoped to another identity. When it does (B reading A's doc), the leak is
invisible in the data — it looks like a normal read. This makes it VISIBLE.
Every real read/write is logged, prefixed by the ACTIVE virtual identity
(getCurrentUser → the account the op is scoped under, NOT the constant shared
physical wallet id). Reads append the row count — a strong leak signal:
[urn:festipod:user:bob] READ did:ng:o:docA (readDoc) → 3 rows
Instrumented at the LOW common point in docs.ts: every read routes through
sparqlQuery, every write through sparqlUpdate, container creation through
docCreate. Callers pass a semantic label (readDoc|readUnion|listMyEntityDocs|
writeEntity|deposit|…) that is a lib-internal probe param, NOT forwarded to the
real `ng` (preserves docs.test.ts exact-forwarding assertions).
OFF by default → one boolean read on the hot path, zero output. On via
configure({ debugAccessLog: true }) or env NG_EVENTUALLY_ACCESS_LOG=1 (no code
change). Polyfill-era; removed at the real multi-store migration.
tsc --noEmit: 0 errors. bun test: 91 pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The lib e2e harness now characterizes all three SPARQL graph shapes against the real
broker (24/24): (a) no-GRAPH anchored write round-trips; (b) an explicit anchored
GRAPH <plainNuri> ALSO resolves to the same repo (no phantom graph) — the earlier
'targets a phantom graph, does NOT round-trip' claim is FALSE on @ng-org/web
0.1.2-alpha.13; (c) an anchorless GRAPH ?g scan spans every named graph (O(wallet)
union, 32 graphs) — TRUE, re-verified.
Reconcile the comments accordingly: inbox.ts / store-registry.ts drop the false
phantom-graph justification (no-GRAPH stays as the canonical, always-safe shape — a
simplicity choice, not a round-trip necessity; re-verify via e2e if the broker
version changes). read-model.md keeps the anchorless-O(wallet) rationale (the real
reason reads are per-doc anchored) and appends a note distinguishing the variable
GRAPH ?g SCAN from a constant GRAPH <D> WRITE. No new absolute introduced.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Align the polyfill's surface and docs with the verified NextGraph reality and
remove application-level concepts:
- Identity is an ID, not a username: AccountRecord.id, shim predicate shim:id,
normalizeId; accounts core becomes IdentityStore (set/clear/get) — the faux
login/logout framing is gone (identity is set at wallet-import time).
- Relationship/connection is an application concept, not a platform primitive
(NextGraph has no bilateral-connection primitive: grantee is unpersisted
scaffolding, cap-send is unimplemented). Remove connections.ts; caps exposes
only a directed grantRead(doc, granteeId) + a read-only protectedDocsOf(owner).
Delete the now-dead isolation.ts social-visibility axis.
- Inbox docs: NextGraph has no separate curator — the recipient's own verifier
unseals and applies each queued sealed message inline (process_inbox);
inbox_post_link is a proposed/future API. Stop attributing the emulated
curator to the platform.
- Read isolation reframed around the outcome: no cap -> empty union read;
targeted read of an unheld repo -> RepoNotFound; cap introspection
(canRead/governsRead) is emulation-only with no NextGraph API behind it.
- read-model.md corrected: the listing path is per-doc ANCHORED default-graph
queries, never the anchorless GRAPH ?g union (that is O(wallet)); the probe
section no longer claims the opposite.
- README recap table restructured (target | current NextGraph status | current
emulation); INDEX_ACCOUNT documented as reservedAccount("index") in the
sentinel namespace; de-domained generic-layer comments; softened tone.
Consumer application (Festipod) rewired separately to own the relationship
concept and feed the lib an id. Lib gates: bun test 83 pass / 0 fail, tsc clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Inbox deposits and the per-(account,scope) index append were written into an
explicit GRAPH <plainNuri> named graph, which the real broker stores separately
from the repo's default graph (repo_graph_name with overlay suffix) — so an
anchored default-graph read (read-model.readDoc) never saw them and deposits
did not round-trip. Drop the GRAPH wrapper: the anchor scopes the write to the
repo's default graph, matching the read. Mocks updated accordingly.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
loadShim() read EVERY account record in the shim (SELECT over the whole anchor
graph). On a shim that accumulates accounts (the shared wallet grows), that is
O(accounts) and hangs the hot path (~90s at ensureAccount → loadShim). Same
principle as per-doc reads: never scan a shared structure.
Add resolveAccount(username): a BOUNDED SELECT anchored on the single subject
accountSubject(username) → O(1), independent of account count. Cache in a
per-account Map (cleared by resetRegistryCache). Hot paths now use it:
ensureAccount (existence check), indexInboxNuri/@index (discovery), resolveInbox
Anchor, resolveWriteGraph, createEntityDoc, listMyEntityDocs. loadShim kept only
for genuine all-accounts needs (allAccounts, the listEntityDocs fan-out fallback).
The 90s ensureAccount/loadShim hang is gone. 93 tests pass; tsc rc=0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- read-model.ts `readUnion` now applies the emulated ReadCap gate (drops a
subject when its doc is governsRead && !canRead for the current identity), so
per-scope isolation holds by construction AND by filter.
- store-registry: `listMyEntityDocs(username, scope)` (current account only) vs
the all-accounts `listEntityDocs` fallback (documented as the enumeration to
avoid on the read path).
- docs: nextgraph-current-state / read-model — corrected to the SOURCE-VERIFIED
reality that the JS SDK exposes NO open/sync-by-cap primitive
(load_repo_from_read_cap is pub(crate)); in the mono-wallet all repos are
already local (same session), so the anchorless union spans them with no open
step. simulation.md: listEntityDocs+useShape({graphs}) is a fallback, not the
read path.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Real-broker validation of the per-document model surfaced round-trip breaks the
fake-ng unit tests missed:
- The inbox anchor was the shim graph itself, making loadShim ~60s; give the
inbox its own document so anchor resolution is fast and isolated.
- store-registry adjustments so per-entity documents created via the SDK are
indexed and readable back through the scope fan-out.
docs/simulation.md updated. 89 tests pass; tsc rc=0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The special discovery-index account was keyed as "@index", which the consumer's
normalizeUsername collapses to "index" — colliding with a real user named
"index" (who could then hijack/tamper the global index document). Introduce a
reserved-account namespace (a sentinel key unreachable by any typed username) so
the index account can never collide with user input. Test proves a user named
"index"/"@index" resolves to a DIFFERENT document than the reserved index
account. 80 tests pass; tsc rc=0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Expose a clean scope-based surface so consumers work by scope (public/protected/
private) and never see a physical store id — the library resolves placement and
performs the shared-wallet simulation internally. RegistrySession gains optional
protected/public store ids, supplied at the single injection point
(configureStoreRegistry). Zero domain knowledge. docs/simulation.md updated.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Polyfill capabilities landed for Festipod's T02 features (all generic,
zero-domain — the consumer injects the domain).
- inbox: implement the previously-stubbed namespace. post(target,{from?,
payload,ts?}) deposits {from,payload,ts} as RDF via docs.sparqlUpdate (the
real injected ng, never makeNg); read/materialize + watch emulate the curator
in-lib (deposits read via docs.sparqlQuery). `from` optional = anonymity.
- write-guard: caps.hasWritePolicy() + ng-proxy.sparql_update rejects when the
target doc is under a write policy and the current user lacks its write cap;
passthrough otherwise (no regression). Read-cap registry unchanged.
- sparql.ts (new): escapeLiteral / escapeIri / assertNuri, exported from index.
store-registry now escapes every literal and validates/encodes every IRI-
position value — closes a SPARQL-injection hole where an untrusted username
could inject triples into the shim (the account→doc-NURI trust root).
Tests: inbox, sparql (incl. injection), ng-proxy write-guard, isolation-active.
68 tests pass; tsc --noEmit rc=0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Port the shared-wallet shim mechanics from the Festipod app into the
generic @ng-eventually/client library — zero app-specific knowledge, the
consumer injects the domain (entity->scope mapping, connections, storage).
New namespaces exposed from src/index.ts:
- docs docCreate/sparqlUpdate/sparqlQuery via the REAL injected `ng`
(getConfig().ng), never the public makeNg proxy — the JS-over-
iframe double proxy breaks doc_create postMessage marshaling
(DataCloneError). Validated hard constraint.
- storeRegistry generic (account,scope)->NURI resolver, createEntityDoc/
listEntityDocs + per-scope index, sharedWalletShim in the
private_store, cache. Consumer wiring injected via
configureStoreRegistry({ getSession, normalizeUser }).
- isolation pure applyIsolation (public=all / protected=owner+connections
/ private=owner); accessors + connection graph injected.
- accounts AccountStore (localStorage-backed faux login, storage injected)
+ normalizeUsername. React wrapper intentionally NOT ported.
polyfill.ts gains configureStoreRegistry/getStoreRegistryDeps + resetConfig.
36/36 bun test, tsc --noEmit rc=0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>