94173fd3d3cd567698eb9dc225759c620a3a41a4
23 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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. |
||
|
|
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).
|
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
88914f50ae |
refactor(layout): ranger les modules par destin à la migration
Les 25 modules étaient à plat, nommés d'après ce qu'ils font mécaniquement (`store-registry`, `read-model`, `reach`, `caps`). Rien dans l'arborescence ne disait lesquels DEVIENDRONT le vrai SDK, lesquels tiennent lieu du travail que le verifier fera nativement, et lesquels n'existent que parce qu'un wallet est partagé — trois destins sans rapport. Quatre dossiers, les deux fichiers d'entrée restant à la racine pour que l'`exports` du paquet et le code du consommateur ne bougent pas : - `model/` — le modèle d'adressage de la cible, transcrit : vocabulaire pur, pas d'I/O. Survit comme connaissance. - `surface/` — ce que l'app touche, chaque symbole ayant un pendant cible documenté. Supprimé quand l'alias bascule ; le code de l'app est inchangé. - `emulated-verifier/` — les doublures de ce que le verifier fait nativement : possession, dépôt des caps, frontière, non-livraison, traitement des inbox, registres de branche, ouverture de repo. **C'est le dossier où diverger du modèle est possible.** Le préfixe `emulated-` porte le sens : tient lieu de, jamais est — cette bibliothèque ne réside dans aucune couche de la cible, elle les référence. - `shared-wallet/` — n'existe que parce qu'un wallet héberge toutes les identités. Aucun pendant, rien sur quoi s'aligner ; sa seule loi est de rester invisible depuis `surface/`. S'évapore, remplacé par rien. `store-registry-api.ts` devient `surface/placement.ts` : il faisait déjà à la main ce que la frontière de dossier fait structurellement — c'est la meilleure preuve interne du bien-fondé de ce rangement. Ce commit ne fait que déplacer et recâbler les imports (src, test, e2e). Les scissions des modules à cheval suivent. 157 tests unitaires, typecheck src/test/e2e vert. |
||
|
|
d07b3642aa |
refactor(api): séparer la surface de l'app et la machinerie
L'entrée SDK déversait la machinerie par deux fuites : - `export * as storeRegistry from "./store-registry"` exportait TOUT le module — `ensureAccount`, `addLink`, `readLinks`, `resolveAccount`, `reservedAccount`, `resetRegistryCache`, `isOwnInbox`, `myInboxes`, `userStoreDoc`. Remplacé par `store-registry-api.ts`, qui ne ré-expose que les sept appels destinés à l'app : createEntityDoc, listMyEntityDocs, resolveScopeGraph, resolveWriteGraph, walletInbox, openDocumentInbox, documentInboxAddress. - `accounts.*` — persistance d'identité navigateur, sans aucun pendant SDK — passe sur `/polyfill`, où sa disparition à la migration se lit sur la ligne d'import. L'en-tête d'`index.ts` affirmait n'exposer « que ce que @ng-org/web et @ng-org/orm exposent ». C'était faux et enseignait une frontière fausse : un consommateur en déduisait que tout ce qui s'importe de l'entrée survit à la migration, ce qui ne valait ni pour `accounts` ni pour l'essentiel de `storeRegistry`. Il énonce désormais ce que l'entrée promet vraiment : tout symbole y a un pendant dans le futur SDK, vérifié ou assumé, et rien n'y est de la machinerie. La frontière mord : le typecheck e2e a échoué aussitôt, le harnais atteignant `ensureAccount` et `resetRegistryCache` par l'entrée publique. Il passe désormais par le chemin interne, comme les tests unitaires — légitime, il teste la bibliothèque. Deux documents plutôt qu'un, mêmes exigences, publics différents : `docs/api-contract.md` (la surface de l'app, avec pour chaque sujet la signature que le futur SDK devrait exposer, et l'étiquette qui distingue le vérifié de l'assumé) et `docs/internal-contract.md` (le complément exact). 157 tests unitaires, e2e 40/40 contre le broker en ligne. |
||
|
|
5a7009bd75 |
fix(inbox): une inbox appartient à un document, jamais à plusieurs
Retour sur l'adresse par défaut livrée en
|
||
|
|
8a382f29f8 |
feat(inbox): l'inbox d'un document est adressable par tout détenteur
Répond au brief 2026-08-03 remonté depuis le consommateur. `documentInbox(doc)` répondait « quelle inbox est-ce que MOI je connais pour ce document » et en créait une quand la réponse était « aucune » : un tiers n'atteignait jamais l'inbox du propriétaire, il en obtenait une à lui, que personne ne lit, et son dépôt disparaissait sans erreur. C'est l'acte central du consommateur — s'inscrire à l'événement d'un autre — qui était silencieusement perdu. Lire une inbox et savoir où y déposer sont deux actes opposés, avec des publics opposés. Ils sont désormais deux fonctions : - `openDocumentInbox(doc)` — le PROPRIÉTAIRE ouvre une inbox dédiée. Refuse sur la PROPRIÉTÉ (lue depuis les branches Store), pas sur la possession du cap : un cap se reçoit, et un destinataire ne doit pas pouvoir rediriger vers lui les dépôts destinés au propriétaire. - `documentInboxAddress(doc)` — n'importe quel détenteur trouve où déposer. Ne crée jamais rien. L'adresse est publiée dès la CRÉATION, sur la branche Header émulée du document — un sujet réservé à l'intérieur du document, donc lisible par qui détient le document. Publier seulement le jour où le propriétaire ouvre une inbox dédiée laisserait une fenêtre pendant laquelle un tiers lit le document, ne trouve aucune adresse, et ne peut pas joindre le propriétaire du tout. Sur le coût mesuré par le brief (9m37 → 21m30) : il venait de la création d'un DOCUMENT supplémentaire par document. L'adresse publiée pointe vers l'inbox propre du propriétaire, qui existe déjà et s'amortit sur tous ses documents ; la création grandit d'un triple, pas d'un document. Le dépôt porte le document concerné, donc le propriétaire matérialise toujours par document. La forme « dérivable » du brief n'était pas disponible : notre inbox est un document, et un NURI dérivé nommerait un repo que `doc_create` n'a jamais créé. Le tout reflète la séparation d'amont : un déposant scelle avec la clé PUBLIQUE de l'inbox et n'a besoin de rien d'autre, seul le propriétaire détient la moitié privée — une adresse est donc publique par nature. `src/machinery.ts` : l'espace de noms `urn:ng-eventually:` que la bibliothèque se réserve, et le prédicat que le chemin de lecture utilise. La branche Header est le premier compartiment logé dans un document que le consommateur lit ; `read-model` écarte désormais tout sujet de cet espace, par SUJET et non par prédicat — ce qui couvre toutes les branches émulées, présentes et futures. Question ouverte du brief, tranchée : « une inbox de document adressable par tout détenteur » est une invention de cette bibliothèque, pas de l'amont — aucun document n'y a d'inbox, ni le store privé. Ce qui EST vérifié, c'est la forme qui rend l'anticipation défendable : `AddInboxCapV0` est clé par `repo_id`. Tests : le test qui validait « n'importe qui dépose » passait le NURI d'inbox au déposant par une variable du test — chemin qu'aucune app n'a. Réécrit avec les deux acteurs cloisonnés : le déposant reçoit le lien du document, qui est la seule chose qui circule dans ce modèle, et doit trouver l'adresse lui-même. Le fake `ng` gagne le SELECT de la branche Header et le `DELETE WHERE` (sans quoi un remplacement devenait une accumulation, précisément le bug qu'il évite). 157 tests unitaires, e2e 40/40 contre le broker en ligne. |
||
|
|
88f396a7ac |
fix(caps): créer un document en donne le cap, + corriger 9 faits NextGraph
Le trou trouvé par l'e2e contre le broker en ligne : `docs.docCreate` ne
déposait aucun cap pour le créateur, donc un consommateur pouvait créer un
document par la primitive publique puis se voir refuser sa lecture et son
écriture. En amont c'est impossible — `doc_create` commite
`AddRepo { read_cap }` sur la branche Store du store, et le créateur le détient
dès le premier instant. Délibérément non répliqué dans `physical.ts` : les
documents du shim n'appartiennent à aucun utilisateur virtuel, et
`store-registry` classe leurs caps là où il sait à qui ils sont.
e2e : 22 passés / 8 échoués → 39 / 0. Les autres échecs venaient du harnais,
qui agissait comme une seconde identité sans l'établir, ou lisait un document
quelconque comme une inbox. Un run e2e contre un wallet persistant exige une
identité FRAÎCHE par run : `walletInbox(id)` rend l'inbox stable pour son
propriétaire — c'est son intérêt — donc un id fixe accumule les dépôts des runs
précédents (vert au 2e run, rouge au 3e, à code inchangé).
Revue adverse de la documentation, 9 défauts, tous vérifiés à la source avant
correction :
- « chaque document a une inbox native » est FAUX. Seuls les repos de store
public et protected en ont une (`site.rs:128,149`) ; `new_store_default` n'en
pose que `if !private` et `doc_create` laisse `inbox: None`. Le store privé
n'en a pas non plus. Ce que le code fait est donc une ANTICIPATION — assumée
et notée comme telle dans `documentInbox`, le brief et l'ADR discovery. Ce qui
est vérifié, c'est la FORME : `AddInboxCapV0` est clé par `repo_id`.
- `InboxMsgContent::Link` est une variante unit sans charge utile : l'inbox ne
transporte aucun ReadCap. `shareCap` était juste et le reste ; ses citations
sont complétées aux deux bouts (émetteur `unimplemented!()`, récepteur qui
ignore `details.read_cap`).
- les 3 stores appartiennent au user (`SiteV0`), pas au wallet ;
- le TODO `OpenRepo` ne concerne pas la lecture cross-wallet — il est dans
`open_branch_`, après `RepoNotFound` ; charger par cap, c'est
`load_repo_from_read_cap` ;
- la liste des méthodes JS était un sous-ensemble présenté comme la surface
(77 exportées) ;
- `outbox-log.ts` n'enregistre rien : il inspecte l'outbox du SDK ;
- l'ADR private-store-nuri-scope citait `orm_start_graph` au présent, remplacé
par `ensureRepoOpen` ;
- l'incident write-loss plaçait `disconnections_sender.send` dans `broker.rs` ;
- la section « Apps & services » n'a aucune citation et rien ne lui correspond
dans le moteur : marquée à re-confirmer, pas à citer comme vérifiée.
Aussi : `fileOwnCaps` n'existe plus (`holdOwnCap` / `readStoreCaps` /
`fileOwnStructure`) — pointeur mort corrigé dans `caps.ts`.
|
||
|
|
ae9c32e271 |
Align the cap emulation on NextGraph's model, and confine it to a virtual user
Two batches, verified against nextgraph-rs throughout. P1a — the capability surface. Reading was an ACL (Map<doc, Set<principal>>), the exact inversion of key possession. It is now possession: `capFor(nuri)` is the only question, there is no principal parameter anywhere, and nothing turns a bare reference into a cap. Sharing is `shareCap(cap, toInbox)`, a Link deposit; receiving needs no operation. `Nuri` and `ReadCap` are template literal types, so passing a bare reference where a cap belongs is a compile error, with runtime guards behind it for JavaScript callers. The virtual user boundary. Every access function is now confined to the connected user, through two rules on one criterion (possession), implemented in two places so a lapse in either is caught by the other: authorization at the passage points, and "do not even attempt" at the callers. The polyfill's own machinery moved to physical.ts — unguarded, never exported — which replaced an exemption list: the machinery no longer gets waved through the guard, it calls something the guard never saw. Removed, as emulating capabilities the target does not have: - discovery.ts and its global index. There is no discovery in NextGraph; you follow links. It also pooled user data across wallets. - the cross-account fan-out (listEntityDocs, resolveReadGraphs, allAccounts, loadShim), which was cross-user enumeration by construction. - resolveInboxAnchor, a single inbox common to every user. Caps are now stored where NextGraph stores them, and read back rather than recomputed: AddRepo on the store's Store branch for documents a user creates, AddLink on its User branch for caps received. Inboxes belong to someone — the user's own, plus one per document — and connecting a user drains them all; that is the library's job, not the app's. Corrections worth recording: a ReadCap is `r:`, not `:k:` (reported by NextGraph's developer, verified in BlockRef::readcap_nuri); received caps DO have a register (AddLink), contrary to what this repo's notes claimed; and "wallet" upstream means keyring — what owns three stores is a user, so the vocabulary follows. The cap value is the constant OK: the only question the emulation answers is whether a cap is held. P1b replaces that one constant with a real key. After this the shape is right and the isolation is still fake. Nothing here may be described as anonymous or private. |
||
|
|
5e91771da6 |
fix(client): résolution de compte barrière-autoritative — fin du fork à la reconnexion
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> |
||
|
|
3046ead08f |
fix(client): durcir le cold-start du shim — ensureRepoOpen(anchor) avant lecture/écriture
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> |
||
|
|
bd48b16e31 |
fix(client): résolution de compte DÉTERMINISTE + anti-fork restauré
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> |
||
|
|
7c233df5c0 |
feat(client): watchShape — lecture réactive à la forme TanStack useQuery
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>
|
||
|
|
956a2228cc |
test(client/e2e): CONTRAT — le 1er State d'un abonnement = barrière de sync
Épingle empiriquement (broker réel) le contrat implicite sur lequel open-repo repose : après le 1er événement `State` d'un `doc_subscribe`, la PRÉSENCE d'une donnée est GARANTIE (le triple écrit est déjà dans ce State, sans attente supplémentaire) et l'ABSENCE est DÉFINITIVE (doc vide reste vide, +5s de grâce). Si ce contrat casse (changement de version broker), ce test le détecte. Constats mesurés : l'abonnement pousse `TabInfo` (~1-3ms) PUIS `State` (~2-3ms) — le State est le 2e événement, pas le 1er ; latence 1er State ~2-3ms sur profil frais. IMPORTANT (à corriger phase 2) : open-repo résout son attente sur le 1er push (= TabInfo), PAS sur le State → il rend la main avant la vraie barrière ; le commentaire « resolves on the FIRST push (the initial State) » est faux. gate : test:e2e 39 passed ; bun test 117 ; tsc propre. src/ non touché. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
d98777e6e3 |
test(client/e2e): contrats broker RÉEL — reconnexion cold-read + non-fork de compte
Les tests du polyfill doivent PROUVER son contrat contre le VRAI broker (pas un fake). Ajout de deux tests e2e broker réel (wallet dédié), avec reconnexion FIDÈLE : page fraîche sur le MÊME profil persistant + nouveau login broker (helper `faithfulReconnect`), PAS export/réimport dans un profil vide (qui resynchronise et masque le cold-open). - Contrat reconnexion/cold-read : session 1 crée un doc (public + protected) ; session fraîche fidèle ; relit → données présentes, en POLLANT une borne généreuse (l'attente de récupération après souscription est le fonctionnement normal). - Contrat non-fork : re-résoudre le même identifiant après reconnexion rend les MÊMES NURIs de docs de scope, pas un second provisioning. Temps de sync réels observés (le SIGNAL) : protected relu en ~195ms (repo déjà ouvert) ; PUBLIC relu en ~105s (ouverture repo + attente push + union ancrée à l'échelle de la donnée publique) ; login de reconnexion ~2.5s. Le ~105s public est un signal de PERFORMANCE À INVESTIGUER (probable scale/bloat de l'anchorless union scan, cf. suivi bloat) — le contrat est REMPLI mais lent côté public. Honnêteté : le « échoue-sans-le-fix » n'a PAS pu être montré via ce chemin de login (le bootstrap du login broker ouvre déjà les repos → firstRawNoOpen=1). Contrat prouvé REMPLI avec les fix ; nécessité non isolable via cette voie. gate : test:e2e 33 passed (baseline 27 vert) ; bun test 117 ; tsc propre. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
1825d4d72f |
WIP: open/subscribe repos before anchored cold-start reads (branche, non mergée)
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> |
||
|
|
dd1313258f |
chore(client): scope a tsconfig to e2e/ so the SDK harness typechecks cleanly
The e2e harness files (broker.ts/run.ts/sdk-entry.ts) live outside the client tsconfig include (src/test), so an editor's broad tsc flagged process/node:*/ playwright as unresolved. Add packages/client/e2e/tsconfig.json (extends base, types: bun) — tsc -p e2e is exit 0; the client tsconfig stays unchanged/clean. Drop an unused getCurrentUser import in sdk-entry.ts. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
4a2569e243 |
docs+test(client): correct the phantom-graph claim, verify graph behavior in e2e
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> |
||
|
|
1c475d8c9f |
test(client): real-broker e2e harness for the SDK (own domain, dedicated wallet)
The polyfill had only fake-ng unit tests; real-broker behaviour (SPARQL graph
round-trip, doc_subscribe marshaling, inbox/index) was only ever exercised by
borrowing Festipod's harness. Add packages/client/e2e/ — a standalone Playwright
harness in the SDK's own domain: a minimal SDK page (configure() + the real
@ng-org/web ng, a window.__sdk bridge, zero Festipod domain) loaded in the broker
iframe on the real broker, authenticating with a DEDICATED wallet (name
ng-eventually-e2e, profile e2e/.playwright-profile-lib — gitignored, separate from
Festipod's). Runner: bun run e2e/run.ts (script test:e2e); not mixed into bun test.
Covers, against the real broker (23 checks, all green): docCreate; sparqlUpdate/
Query anchored default-graph round-trip; readUnion N-doc + per-doc tolerance + cap
gate; doc_subscribe initial+write+unsub and subscribeDocs per-doc isolation; inbox
post/read/watch + spoof guard; discovery submit/read/watchIndex + reserved-account
isolation; store-registry idempotency + bounded listMyEntityDocs + scope resolvers;
caps read-filter (in-memory, honestly scoped); IdentityStore.
Finding (reported, not a failure): on @ng-org/web 0.1.2-alpha.13 an anchored
INSERT DATA { GRAPH <plainNuri> {…} } DOES round-trip (no phantom graph) — several
lib comments assert the opposite; stale, to reconcile. Behaviour is unaffected (the
lib writes the always-safe no-GRAPH default-graph shape). bun test still 91 pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|