Compare commits
6 Commits
1f0bae461e
...
ead5aececf
| Author | SHA1 | Date | |
|---|---|---|---|
| ead5aececf | |||
| f2c5b30527 | |||
| 1791c31f42 | |||
| 127ca3159e | |||
| 138d37c02f | |||
| cf9500f0cf |
@@ -0,0 +1,216 @@
|
||||
# Brief — aligner l'émulation des caps sur le vrai modèle NextGraph
|
||||
|
||||
**Brief (incubation) — 2026-07-20.** Voir la référence `docs/readcap-and-nuri-model.md`.
|
||||
|
||||
## Problème
|
||||
|
||||
`caps.ts` émule les droits de lecture comme une **ACL** (`Map<Nuri, Set<PrincipalId>>`,
|
||||
`grantRead(doc, grantee)`) — **l'inversion** du vrai modèle NextGraph (possession
|
||||
de clé). Conséquences : pas de notion de **référence cap-less**, grant/révocation
|
||||
**instantanés et totaux** (au lieu de scellage durable + re-key), et une API
|
||||
(`declareConnections`) que les consommateurs doivent **re-déclarer à chaque
|
||||
session**. Cet écart empêche de bâtir correctement des modèles qui reposent sur la
|
||||
vraie sémantique — notamment la **présence anonyme** (nommer/compter sans lire).
|
||||
|
||||
## Objectif : shape-fidelity, PAS sécurité
|
||||
|
||||
Le polyfill **n'égale PAS** la sécurité de NextGraph fini, et n'essaie pas. Le
|
||||
wallet partagé + l'absence de crypto rendent l'émulation **volontairement
|
||||
non-sécurisée** (tout est en clair, tout marqueur est forgeable) — véhicule de
|
||||
dev/staging, pas un but. **Seul objectif** : exposer la **BONNE FORME** des
|
||||
primitives futures pour que les consommateurs (Festipod) soient codés contre le
|
||||
**modèle mental correct** et **n'aient pas à être réécrits** quand NextGraph sera fini.
|
||||
|
||||
Corollaire : **« pas de crypto » n'est pas un problème** ; ce qui compte est d'être
|
||||
**dans la même logique, avec RIGUEUR**. Une critique « un attaquant lit le clair /
|
||||
forge un marqueur » est **exacte mais hors-scope**. Ce qui est **inacceptable** =
|
||||
exposer la **mauvaise forme** (ex. une ACL là où le réel est possession de clé) →
|
||||
le consommateur code contre un modèle qui n'existera pas. **L'inversion ACL des
|
||||
ReadCaps EST ce manque de rigueur** — le défaut central à corriger.
|
||||
|
||||
## Mécanisme d'enforcement : simulation crypto LÉGÈRE (anti-ACL, anti-raccourci)
|
||||
|
||||
Pour que la forme soit **réellement** possession-de-clé (et pas une ACL déguisée),
|
||||
la donnée d'un doc est **stockée chiffrée** (chiffrement symétrique par-doc, même
|
||||
léger) et le **ReadCap = la clé**. Invariant (cf. `docs/vision.md`) :
|
||||
|
||||
> un **`did` nu (sans ReadCap)** ne permet **PAS** de lire ; un **NURI avec
|
||||
> ReadCap** est **suffisant et requis**.
|
||||
|
||||
Ça **empêche les raccourcis** que l'adversaire a relevés (#4/#6 : lire le clair,
|
||||
`sparqlQuery`/`inbox.read` qui bypass le filtre) et **interdit** de retomber sur
|
||||
une ACL — c'est le cœur de « même logique, avec rigueur ».
|
||||
|
||||
**Cible — PAS l'état actuel** : toutes les surfaces de lecture (`read-model`,
|
||||
`read-filter`, `sparqlQuery`, `inbox.read`) devront passer par le
|
||||
déchiffrement-avec-clé. **Aujourd'hui c'est FAUX** : `inbox.read` (`inbox.ts:198`)
|
||||
et `sparqlQuery` brut (`docs.ts:82`) **bypass** le filtre, et le seul garde
|
||||
(`caps.canRead`, `caps.ts:95`) est une **ACL set-membership** — l'inversion même
|
||||
que la vision interdit. **Fermer ces voies = le cœur de P1.**
|
||||
|
||||
1. **Deux formes de référence distinctes** : cap-less (nomme/localise sans lire —
|
||||
aligné sur le NURI sans `:k:`) vs cap-porteur (id + clé/token). Aujourd'hui
|
||||
absent.
|
||||
2. **Grant = livrer un cap-token à un destinataire** (émuler le scellage :
|
||||
le destinataire *reçoit* le token dans son inbox ; c'est la **possession** du
|
||||
token qui autorise la lecture — pas une ligne d'ACL vérifiée par principal).
|
||||
3. **Enforcement par possession** : les lecteurs (`read-filter`, `use-shape`) ne
|
||||
voient que ce dont ils **détiennent le token**, pas « ce dont ils sont dans le
|
||||
set de readers ».
|
||||
4. **Résoudre un cap-less** = nommer / prouver l'existence / compter, **sans**
|
||||
exposer le contenu (support de la présence anonyme).
|
||||
5. **Révocation = re-key** émulé : invalider l'ancien token, re-livrer un nouveau
|
||||
aux autorisés restants ; **non-rétroactif**.
|
||||
|
||||
## ~~Périmètre élargi : le WriteCap (= membership)~~ — RETIRÉ (2026-07-21)
|
||||
|
||||
**Cette section était fausse et est conservée barrée comme garde-fou.** Elle
|
||||
importait une notion de *membership* lue dans l'**état courant** de `nextgraph-rs`
|
||||
(`AddMember`, `PermissionV0`, `member_pubkey`) et la promouvait en **forme cible**.
|
||||
Or (a) ces types sont un **échafaudage inerte** au runtime — `verify_sig` /
|
||||
`verify_perm` ne sont appelés que dans des tests unitaires, les `Repo` sont
|
||||
construits avec `members: HashMap::new()` ; et (b) le modèle cible **n'a pas de
|
||||
notion d'appartenance du tout** : uniquement des **clés et des URLs**, symétrique
|
||||
et asymétrique. Une forme en `member`/`role`/`permission` est donc exactement la
|
||||
**mauvaise forme** que ce brief existe pour empêcher.
|
||||
|
||||
**La leçon de méthode, qui vaut plus que la section retirée** : lire l'état
|
||||
courant de NextGraph pour en **déduire** la forme cible est une erreur — l'état
|
||||
courant contient de l'inachevé qu'il ne faut pas figer dans le polyfill. Le source
|
||||
sert à vérifier un **mécanisme** existant, jamais à inférer une **intention**.
|
||||
|
||||
Contenu erroné conservé ci-dessous à titre de trace :
|
||||
|
||||
<details>
|
||||
<summary>Section retirée</summary>
|
||||
|
||||
**Pourquoi c'est ici et pas ailleurs.** Le brief était d'abord centré ReadCap ; un
|
||||
constat adverse a montré qu'il **ne compose pas** avec son consommateur : le brief
|
||||
Festipod « inscriptions par Set » a besoin de **dédupliquer** les participations
|
||||
(un user = une participation par événement), et la seule base non-applicative
|
||||
disponible est la **signature d'auteur du commit** — donc un primitif d'**écriture**,
|
||||
pas de lecture. Un polyfill qui n'expose que la forme du ReadCap laisse le
|
||||
consommateur inventer sa propre dédup applicative → exactement la mauvaise forme.
|
||||
|
||||
**La forme réelle (VÉRIFIÉE, cf. `readcap-and-nuri-model.md` §1)** — et elle est
|
||||
**asymétrique** de la lecture, ce qui est le point le plus facile à rater :
|
||||
|
||||
- **Lecture = possession d'une clé.** Pas d'ACL. Qui détient, lit.
|
||||
- **Écriture = membership + permissions** (`AddMember`, `AddPermission` sur le
|
||||
`RootBranch`). C'est **bel et bien une liste d'autorisation** — pas de la
|
||||
possession. Émuler l'écriture « par possession de token » serait aussi faux que
|
||||
l'ACL de lecture actuelle, en miroir.
|
||||
- **Les commits SONT signés** par un `UserId` (clé **technique**, distincte du
|
||||
profil) — donc un identifiant de dédup **existe** nativement, sans pseudonyme
|
||||
applicatif.
|
||||
- **Mais vérifier une signature exige d'être membre du repo** (accès au
|
||||
`member_pubkey`). Un tiers non-membre voit un commit signé sans pouvoir
|
||||
l'attribuer.
|
||||
- **Le dépôt d'inbox n'est PAS authentifié** (sealed box anonyme) : un `from`
|
||||
déclaré est du contenu, pas une preuve.
|
||||
|
||||
**Ce que ça impose au polyfill.** Exposer `membership` comme primitif **distinct**
|
||||
de la possession de cap, avec au minimum : ajouter/retirer un membre d'un repo,
|
||||
lire la members map **quand on est membre**, et **vérifier l'auteur d'un commit**
|
||||
(→ un digest d'auteur, **par-overlay donc par-store**). C'est ce dernier point qui
|
||||
débloque la dédup côté Festipod.
|
||||
|
||||
**La conséquence de forme, à documenter explicitement** (sinon le consommateur se
|
||||
trompe de modèle) : le digest d'auteur étant **par-store**, le choix du découpage
|
||||
en stores **est** le choix du niveau de corrélation. Un store **stable par-user**
|
||||
donne un identifiant traçable **cross-événement** ; un store **par-événement**
|
||||
donne un pseudonyme **local à l'événement** — dédup possible, corrélation
|
||||
impossible. Festipod a besoin du second. Le polyfill doit donc rendre ce découpage
|
||||
**exprimable**, pas le figer.
|
||||
|
||||
**Reste ouvert** : « le créateur d'un événement peut-il être membre du store qui
|
||||
contient les participations, sans pour autant en détenir la clé de lecture ? » —
|
||||
c'est-à-dire membership (écriture/vérification) et possession (lecture)
|
||||
réellement **orthogonales**. Si NextGraph les couple, la dédup vérifiée et
|
||||
l'anonymat s'excluent, et c'est le brief Festipod qui doit trancher ce qu'il
|
||||
sacrifie. **À vérifier avant de façonner l'API.**
|
||||
|
||||
</details>
|
||||
|
||||
*(Question devenue sans objet : il n'y a pas de membership. La dédup ne passe pas
|
||||
par une vérification de signature — voir le brief Festipod « inscriptions ».)*
|
||||
|
||||
## Questions ouvertes
|
||||
|
||||
- **TRANCHÉ (directive PO, 2026-07-21)** : on **simule la crypto** (donnée chiffrée
|
||||
par-doc, cap = clé). La « sémantique seule » (registre de tokens) est **écartée** —
|
||||
elle redevient une ACL et laisse lire le clair. Reste à trancher le **niveau** de
|
||||
simulation (chiffrement réel léger vs projection read-model masquée), **avant P1**.
|
||||
- Représentation NURI cap-less vs cap-porteur dans l'émulation (calquer `:k:`).
|
||||
- Faut-il permettre le **fetch keyless** (résoudre un cap-less en existence/compte
|
||||
sans le contenu) — dépend de ce que le vrai broker autorise (INFÉRÉ, non tracé).
|
||||
- Migration d'API : `declareConnections`/`grantRead` → `seal(cap, recipient)` +
|
||||
`inbox → caps reçus`. Casse les consommateurs (`declareConnections` app disparaît).
|
||||
|
||||
## P0 — Spike « keyless-resolve » (le verrou, AVANT tout P1)
|
||||
|
||||
**Question porteuse** : un détenteur d'une **référence cap-less** (`did:ng:o:{id}:v:{overlay}`, sans `:k:`) peut-il, **sans jamais lire le contenu** :
|
||||
- **Q1 — Existence / fetch** : prouver/récupérer la présence des blocs (chiffrés) auprès du broker ? Ou le broker exige-t-il un ReadCap/membership pour servir les blocs ?
|
||||
- **Q2 — Suppression** : distinguer « existe » de « supprimé » ? *(Le point FRAGILE : NextGraph est append-only CRDT — une désinscription = un **commit tombstone** qu'il faudrait **lire** pour connaître → potentiellement **la clé est requise**. Or le **décrément au leave** en dépend.)*
|
||||
- **Q3 — Confidentialité** : la clé (`:k:`) reste-t-elle **requise** pour déchiffrer (le keyless ne donne jamais le contenu) ?
|
||||
|
||||
**Pourquoi c'est le verrou** : tout le **compteur anonyme** (compter/valider des réfs cap-less sans lire) ET le **décrément au leave** en dépendent. **Si NON** → « compteur anonyme par réf cap-less » est **inconstruisible en cible** → Festipod ne doit **pas** coder cette forme (réécriture garantie). **Si OUI** → P1 expose `resolveCapLess(nuri) → {exists|deleted}` (jamais de contenu), et l'émulation la simule fidèlement.
|
||||
|
||||
**Méthode** (bon marché, décisif) :
|
||||
1. **Tracer** dans `nextgraph-rs` le chemin d'**autorisation de fetch** du broker/verifier : qui sert les blocs (`BlocksGet`/`TopicSync`/`OverlaySync`) ? un cap/membership est-il vérifié, ou `id+overlay` suffit ? l'overlay *outer* est-il public ? une suppression est-elle observable sans clé ?
|
||||
2. *(Optionnel)* **test e2e décisif** (façon `e2e/reactivity-doc-subscribe.ts`) : B détient la réf cap-less, tente fetch/existence **sans** la clé, vérifie qu'il **n'accède pas** au contenu. Preuve empirique > source.
|
||||
3. *(Ou)* confirmer avec le dev NextGraph — le plus rapide.
|
||||
|
||||
**Livrable** : YES/NO/PARTIAL par Q1/Q2/Q3 + le primitif exact (file:line) + la forme d'API à exposer (si OUI), ou le constat que le compteur change (si NON).
|
||||
**Décision gatée** : OUI → P1 ; NON → le brief inscriptions revoit le compteur (pas anonyme, ou autre primitif).
|
||||
|
||||
### Verdict du spike (2026-07-21) — VÉRIFIÉ dans `nextgraph-rs`
|
||||
|
||||
| | Réponse | Preuve |
|
||||
|---|---|---|
|
||||
| **Q1 — existence/fetch sans cap** | **OUI, partiel** | Les lectures ne sont pas cap-gatées : `blocks_get.rs`, `blocks_exist.rs`, `topic_sync_req.rs` servent les blocs sans exiger ReadCap ni membership. **Seul garde : l'overlay**. Nuance non levée : overlay *inner* vs *outer* (`expose_outer`, défaut `false`) — **sonde en cours**. |
|
||||
| **Q2 — détecter une suppression sans clé** | **NON** | Broker append-only ; une suppression est un **commit tombstone chiffré** (`RemoveRepo`), no-op côté verifier. Sans clé on observe « une activité », jamais « une suppression ». |
|
||||
| **Q3 — confidentialité** | **OUI** | Blocs stockés en ciphertext ; la clé est `#[serde(skip)]` (`types.rs`), dérivée du `ReadCapSecret`. Le keyless ne donne **jamais** le contenu. |
|
||||
|
||||
**Ce que ça décide.**
|
||||
- **P1 est débloqué** : `resolveCapLess(nuri) → { exists }` est la bonne forme — mais **`{ exists | deleted }` ne l'est PAS**. Ne pas exposer d'état `deleted`, ce serait inventer une capacité que la cible n'aura jamais (précisément le mode d'échec que ce brief combat).
|
||||
- **Le retrait doit être un message, pas une observation.** Côté consommateur : un *nudge* explicite. Le polyfill n'a **rien** à émuler pour ça — juste à ne pas prétendre le contraire.
|
||||
- **Reste gaté** : la nuance d'overlay (Q1). Si un non-membre ne peut pas rejoindre l'overlay d'un store *protected* tiers, le fetch keyless est inatteignable **en pratique** malgré un chemin d'autorisation ouvert — et le compteur anonyme retombe sur du déclaratif.
|
||||
|
||||
## Esquisse de phases
|
||||
|
||||
- **P1** — distinction cap-less / cap-porteur dans les NURI + le read-model
|
||||
(résoudre un cap-less = nommer/compter/prouver l'existence, **pas** lire, **pas**
|
||||
de `deleted`). Inclut la **fermeture des bypass** (`inbox.read`, `sparqlQuery`)
|
||||
sans laquelle la distinction n'est pas tenue.
|
||||
- **P2** — remplacer l'ACL par un modèle de **possession de token** (grant = livrer
|
||||
à un destinataire ; enforcement = possession). *Requalifié par la revue adverse :
|
||||
le vrai contenu de P2 est **durabilité + cap-less + re-partage par le détenteur**,
|
||||
pas « inverser l'ACL » — sans crypto, inverser ne produit aucun delta observable.*
|
||||
- **P3** — révocation par re-key (invalidation + re-livraison, non-rétroactive).
|
||||
- **PW** — **WriteCap = membership** : primitif distinct (add/remove member,
|
||||
members map lisible par un membre, **vérification d'auteur de commit** → digest
|
||||
par-store), et découpage en stores **exprimable** par le consommateur.
|
||||
*Indépendant de P1–P3 ; **bloquant pour la dédup Festipod**, donc à ordonnancer
|
||||
tôt si c'est ce besoin-là qui presse.*
|
||||
- **P4** — adapter l'API consommateur + `migration-guide.md`. *La revue adverse
|
||||
requalifie ce lot : ce n'est pas un swap d'API mais une **re-architecture
|
||||
consommateur** (le grant se déplace vers l'acceptation de connexion et devient
|
||||
persistant ; `declareConnections` disparaît).*
|
||||
|
||||
## Revue adverse (2026-07-20) — à intégrer
|
||||
|
||||
Un adversaire a réfuté le brief (6 constats). **À lire au filtre de l'Objectif ci-dessus** (forme, pas sécurité). Les critiques purement **sécurité** — contenu en clair lisible (#4), marqueurs forgeables — sont **ACCEPTÉES / hors-scope** : le polyfill ne cherche pas à les empêcher. Restent les vrais défauts de **FORME / rigueur** (à corriger), et une question de **modèle futur** (#5) :
|
||||
|
||||
1. **WriteCap oublié, et « possession » y est FAUX.** L'écriture est **membership/permissions** (`AddMember`) — une **liste d'autorisation**, pas de la possession de clé (réf §1) ; `ng-proxy.ts:28-48` garde chaque `sparql_update`. → garder une **piste WriteCap = membership** ; la **possession ne concerne QUE la lecture**.
|
||||
2. **P2 « possession sans crypto » = l'ACL renommée.** Sans crypto, « qui détient quel token » = `Map<doc, Set<holder>>` = le `readers` actuel : **aucun delta observable**. Les vrais deltas sont **durabilité + cap-less + re-partage par le détenteur** — c'est ÇA le contenu de P2, pas « inverser l'ACL ».
|
||||
3. **Révocation non-rétroactive INÉMULABLE** sans versioning : `read-model.ts:112-118` ne lit que l'état courant → « invalider l'ancien token » = retrait total = l'inverse du réel (l'ex-détenteur déchiffre les versions **antérieures**). → n'émuler que « plus de nouvelles lectures après re-key » + **documenter la non-rétroactivité comme non-émulable**.
|
||||
4. **cap-less « sans exposer le contenu » ILLUSOIRE dans l'émulation** : contenu en **clair** dans le wallet partagé ; `sparqlQuery`/`inbox.read` **bypass** le filtre ; `read-filter.ts:30-35` est tout-ou-rien. → l'anonymat cap-less exige soit du **vrai crypto**, soit une **projection read-model masquée** (compter sans lire). « Remplacement pas refonte » est **surévalué**.
|
||||
5. **Keyless-fetch = INFÉRÉ et load-bearing** : ajouter un **spike P0** qui le vérifie **avant** P1 (sinon le modèle — polyfill ET Festipod — est inconstruisible).
|
||||
6. **Migration ≠ swap d'API.** `declareConnections` se re-joue chaque session parce que la map est éphémère ; des scellages durables déplacent le grant à l'**acceptation de connexion** + persistent « déjà scellé » — pas d'analogue de `protectedDocsOf` + la boucle de re-dérivation. **Re-architecture consommateur.**
|
||||
7. *(Plausible)* livrer un cap par inbox async **ne re-déclenche pas** `watchShape` (souscrit aux docs de données, pas aux caps) → vues illisibles **périmées** jusqu'à un autre changement. → prévoir un signal de mutation de caps.
|
||||
|
||||
**Conséquence** : ajouter en tête **P0 (spike keyless-fetch)** et une **piste WriteCap distincte** ; requalifier P2 (le vrai contenu = durabilité + cap-less + re-partage, pas « inverser l'ACL ») ; acter que **sans crypto, la privacy de lecture n'est pas applicable** (choisir : vrai crypto vs projection masquée).
|
||||
|
||||
Liens : `readcap-and-nuri-model.md`, `packages/client/src/caps.ts`. Côté consommateur,
|
||||
le brief Festipod « réaligner les inscriptions » dépend de ce chantier.
|
||||
@@ -0,0 +1,57 @@
|
||||
# Perte d'écriture lors d'une mort de socket (`SerializationError`)
|
||||
|
||||
**Post-mortem — 2026-07-14 · Statut : OUVERT (non traité).**
|
||||
|
||||
Une entité écrite juste avant une période d'inactivité peut être **perdue silencieusement** : l'écriture n'atteint jamais durablement le broker, et l'entité est absente à la reconnexion. Le **compte / l'identité survit** (pas de fork). Observé en conditions réelles (Festipod, Firefox) lors d'une pause après login/création.
|
||||
|
||||
## Symptôme
|
||||
|
||||
1. L'utilisateur se connecte, l'app crée une entité (un événement Festipod).
|
||||
2. Une période d'inactivité suit (idle, onglet en arrière-plan…).
|
||||
3. Le socket broker meurt spontanément avec `SOCKET IS CLOSED Some(Left(SerializationError))`.
|
||||
4. À la reconnexion, l'entité créée a disparu ; l'app relit son propre scope **vide**.
|
||||
|
||||
## Preuves (VÉRIFIÉ — logs Firefox en direct, verbatim)
|
||||
|
||||
```
|
||||
… REPLAY TOPIC NOT FOUND <topic> IN OVERLAY <overlay>
|
||||
… NEED REPLAY true
|
||||
… SENDING EVENTS FROM OUTBOX RETURNED: Err(TopicNotFound)
|
||||
[user1][polyfill] resolveAccount(user1) → 1 record ← le compte SURVIT (pas de fork)
|
||||
[user1][polyfill] readScopeIndex(…) → 0 entities ← mais le scope est VIDE
|
||||
… set reçu: 0 objets Event (public)
|
||||
… SOCKET IS CLOSED Some(Left(SerializationError)) [51, 3, 223, …]
|
||||
```
|
||||
|
||||
Lecture (**mécanisme plausible, non tranché**) : l'écriture a été poussée dans l'**outbox** local, mais le socket est mort avant qu'elle ne soit **flushée durablement** dans le topic broker ; à la reconnexion, le replay de l'outbox échoue (`Err(TopicNotFound)`) parce que le topic n'a **jamais été créé côté broker** → l'événement est abandonné. Le compte, lui, avait déjà été résolu durablement (`resolveAccount → 1 record`) : il n'est ni perdu ni forké.
|
||||
|
||||
> **Réserve épistémique.** Les preuves établissent le *symptôme* (perte + `Err(TopicNotFound)` + `readScopeIndex → 0`). Le *mécanisme* exact n'est pas tranché entre **(i) perte à l'écriture** (l'écriture n'atteint jamais durablement le broker) et **(ii) échec de réhydratation à froid** (l'écriture *est* sur le broker mais une session fraîche ne rouvre pas le scope propre). Le `Err(TopicNotFound)` sur le replay outbox penche pour **(i) dans ce cas Firefox**. Voir la repro @data ci-dessous, qui expose un symptôme voisin mais **ne tranche pas** (i) vs (ii).
|
||||
|
||||
## Chaîne causale (TRACÉ — lecture du core NextGraph, à re-vérifier)
|
||||
|
||||
- La `SerializationError` ferme le socket. Le core émet la déconnexion : `broker.rs` → `LocalBrokerMessage::Disconnected` → `disconnections_sender.send(...)` (≈ `broker.rs:1051`, à re-vérifier — numéro volatil, se repérer par le symbole).
|
||||
- Cette déconnexion est **poussée** aux abonnés via `disconnections_subscribe(cb)` (flux PUSH).
|
||||
- **La reconnexion NextGraph est un `// TODO` non implémenté** (≈ `broker.rs:1051-1076`) : rien ne rétablit le socket ni ne re-flushe l'outbox.
|
||||
- `user_connect` renvoie un **instantané** `{ server_id, server_ip, error, since }` au moment de l'appel — pas un flux, inutilisable pour détecter une chute ultérieure.
|
||||
- **Aucune API de confirmation de durabilité d'écriture** : un appelant ne peut pas `await` la garantie qu'une écriture a atteint le broker.
|
||||
|
||||
## Ce que le SDK expose mais ne consomme pas
|
||||
|
||||
`disconnections_subscribe` **se déclenche** sur cette panne — mais ni le polyfill (`@ng-eventually/client`) ni l'app consommateur ne s'y abonnent. Le signal existe, personne ne l'écoute ; côté app, aucun mécanisme ne re-tente ni n'avertit l'utilisateur.
|
||||
|
||||
## Portée & non-reproduit
|
||||
|
||||
- **Observé Firefox uniquement** à ce jour. Un test manuel sur un autre navigateur n'a pas déclenché la `SerializationError` ni ses conséquences.
|
||||
- **Reproduction @data (Chromium, broker réel) — 2026-07-14, décisive.** Le test de reconnexion @data existant (`reconnexion-meme-identite`) était **faux-vert** : il relisait les repos de A depuis l'**IndexedDB local** du profil persistant, jamais depuis le broker. Un lecteur **réellement à froid** (contexte non-persistant `freshBrowser`, **même** wallet/compte A, aucun état local — seedé du wallet capturé avant l'événement) lit **0** événement de A (`BARRIER timed-out (8000ms)`, `CONNECTION ESTABLISHED`). Signature **différente** du cas Firefox (pas de mort de socket ; l'`OUTBOX empty` est celui du lecteur, trivialement vide) et **ne tranche pas** (i) vs (ii) — un barrier vide est compatible avec les deux. Établi en revanche : **@data n'a jamais vérifié la durabilité broker des lectures propres de A**, et la réhydratation à froid depuis le broker échoue. Repro : `src/modules/event/features/reconnexion-froide-sans-local.feature` (Festipod).
|
||||
- **Pour trancher (i) vs (ii)** : vérifier indépendamment que l'écriture de A atteint le broker — p.ex. un lecteur *chaud* / une seconde identité lit le doc public de l'événement (le scénario d'isolation deux-identités). S'il le voit → l'écriture est durable → le 0 du lecteur à froid est un **(ii)** (réhydratation). Sinon → **(i)**.
|
||||
|
||||
## Pistes de correction (non arbitré)
|
||||
|
||||
1. **Core** — corriger la `SerializationError` **et** implémenter le TODO de reconnexion (rétablir le socket + re-flusher l'outbox).
|
||||
2. **SDK / polyfill** — consommer `disconnections_subscribe` → reconnexion + re-flush outbox comme mitigation, indépendamment du core.
|
||||
3. **API de durabilité** — exposer une confirmation qu'une écriture a atteint le broker, pour que l'appelant puisse l'`await`.
|
||||
|
||||
## Liens
|
||||
|
||||
- `docs/nextgraph-current-state.md` — état courant du core (déconnexion / reconnexion à cross-référencer ici).
|
||||
- Impact produit + caveat côté consommateur : concept Festipod `data-layer` → `caveat_write-durability-across-disconnect`.
|
||||
@@ -470,3 +470,50 @@ logout is exposed (`ng.session_stop()`, `ng.user_disconnect()`,
|
||||
redirect afterwards. This lib's identity store sidesteps all of it — the identity
|
||||
id is set at wallet-import time and relayed to the lib, without a separate login;
|
||||
see the identity store in [`simulation.md`](./simulation.md).
|
||||
|
||||
## Known open issues (section added 2026-07-18)
|
||||
|
||||
Live limitations observed against the current core/SDK, each with its epistemic
|
||||
status. **None is treated.** The status labels below are load-bearing — do not
|
||||
upgrade an OPEN / UNDETERMINED / HYPOTHESIS item to "confirmed" or "fixed"
|
||||
without new evidence.
|
||||
|
||||
### Write loss on socket death (`SerializationError`) — symptom VERIFIED, mechanism UNSETTLED, OPEN / untreated
|
||||
|
||||
A write made just before an idle period / spontaneous socket death
|
||||
(`SOCKET IS CLOSED Some(Left(SerializationError))`) can be **silently lost**:
|
||||
the entity is absent on reconnection while the account survives. Reconnection is
|
||||
an unimplemented `// TODO` stub in the core (`broker.rs`, ≈ `1051-1076`);
|
||||
`disconnections_subscribe` DOES fire on the failure but nothing — neither this
|
||||
polyfill nor the consumer app — consumes it; and there is **no
|
||||
write-durability-confirmation API** a caller could `await`. Full post-mortem
|
||||
(logs, causal chain, correction leads, none arbitrated):
|
||||
[`incidents/2026-07-14-write-loss-on-disconnect.md`](./incidents/2026-07-14-write-loss-on-disconnect.md).
|
||||
|
||||
### Cold-start read does not rehydrate the owner's own scope from the broker — symptom VERIFIED, root cause UNDETERMINED, OPEN / untreated
|
||||
|
||||
Decisive test (2026-07-14): a genuinely no-local cold reader — fresh
|
||||
non-persistent browser context, SAME wallet + account — reads **0** of the
|
||||
owner's own scope from the broker. The previously "passing" reconnect test was
|
||||
FALSE-GREEN: it read the owner's repos from the persistent profile's LOCAL
|
||||
IndexedDB, so it never proved broker durability. It is UNDETERMINED whether
|
||||
**(i)** the write never durably reached the broker, or **(ii)** the write IS on
|
||||
the broker but a fresh session cannot re-open the owner's own scope docs (a
|
||||
cold-open / rehydration limitation) — both collapse to the same 0-read in this
|
||||
setup. Next step (NOT done): disambiguate (i) vs (ii) with an independent warm /
|
||||
second-identity read of the same doc. The same (i)/(ii) reserve is carried in
|
||||
[`incidents/2026-07-14-write-loss-on-disconnect.md`](./incidents/2026-07-14-write-loss-on-disconnect.md)
|
||||
(§ *Portée & non-reproduit*), whose Firefox case leans (i) — this cold-reader
|
||||
signature is distinct (no socket death) and does not settle it.
|
||||
|
||||
### Reactive subscription may not echo the writer's OWN local commit — HYPOTHESIS (high-confidence), confirmation in progress (2026-07-18), NOT confirmed, NOT fixed
|
||||
|
||||
When a client does a local `sparqlUpdate` on a doc it is itself subscribed to
|
||||
(`subscribeDoc`/`doc_subscribe`), the subscription callback appears NOT to fire
|
||||
for its own local commit in the same session, so the polyfill's reactive re-read
|
||||
chain never runs and consumers keep a stale value until the next connection
|
||||
delivers a fresh initial `State`. REMOTE commits DO push correctly (verified:
|
||||
cross-browser reactive update works). Verdict pending a live instrumented run.
|
||||
Full write-up (suspect link, instrumentation, planned polyfill-side fix):
|
||||
[`../packages/client/docs/sdk-reference.md`](../packages/client/docs/sdk-reference.md)
|
||||
§ *Current emulation status*.
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
# Modèle ReadCap & NURI de NextGraph — et l'émulation caps du polyfill
|
||||
|
||||
**Établi 2026-07-20**, VÉRIFIÉ par lecture directe du cœur Rust `nextgraph-rs`
|
||||
(sauf points marqués INFÉRÉ). Les `file:line` sont datés — les numéros de ligne
|
||||
sont volatils, se repérer par symbole/regex.
|
||||
|
||||
But : donner la vérité-terrain du modèle de droits d'accès NextGraph, pour
|
||||
aligner l'émulation `caps.ts` du polyfill (aujourd'hui une ACL — l'inverse du
|
||||
modèle réel). C'est la base de l'item « aligner ReadCap/WriteCap avec NextGraph ».
|
||||
|
||||
---
|
||||
|
||||
## 1. Un ReadCap = possession d'une clé, PAS une ACL par-identité
|
||||
|
||||
Un **ReadCap est fondamentalement une clé cryptographique que l'on détient**, pas
|
||||
une entrée d'ACL liée à un wallet. « Qui détient la clé peut lire. »
|
||||
|
||||
- Structure : `ReadCap = ObjectRef = BlockRef { id: BlockId, key: SymKey }`
|
||||
(`engine/repo/src/types.rs:461, 463-471, 557, 565`).
|
||||
- `id: BlockId` = digest **BLAKE3** (adresse de l'objet chiffré).
|
||||
- `key: SymKey = ChaCha20Key([u8;32])` = la **clé de déchiffrement**.
|
||||
Détenir le couple → le broker sert les blocs chiffrés par `id`, on déchiffre
|
||||
**localement** avec `key`.
|
||||
- Granularité : par commit/objet l'`ObjectRef` **est** le cap ; pour une branche
|
||||
→ commit de définition ; pour un repo → RootBranch ; pour un store → cap du
|
||||
repo racine (`types.rs:559-565`). `ReadCapSecret` = la moitié clé (`:567-570`).
|
||||
- **Il n'y a PAS de read-ACL.** L'appartenance/permissions d'un repo
|
||||
(`RootBranch`, `AddMember`, `AddPermission`) gouvernent l'**écriture/admin**,
|
||||
pas la lecture. La lecture n'est gardée que par la possession de la clé.
|
||||
|
||||
## 2. Accorder la lecture = sceller la clé au destinataire
|
||||
|
||||
« Grant » = livrer le cap **scellé** (`crypto_box seal`, chiffrement à clé
|
||||
publique anonyme) à la **pubkey d'inbox** du destinataire — seul lui l'ouvre avec
|
||||
sa clé privée.
|
||||
|
||||
- Message d'inbox scellé : `InboxMsgBody.msg` = `crypto_box::seal(... to_inbox ...)`,
|
||||
ouvert avec la clé secrète d'inbox (`engine/net/src/types.rs:4272, 4299, 4319`).
|
||||
- Le payload peut porter un cap : `ContactDetails.read_cap: Option<ReadCap>`
|
||||
(« if user wants to share the content of profile ») (`net/types.rs:4232-4233`)
|
||||
→ **grant dirigé** (scellé à un destinataire).
|
||||
- Variante **non-dirigée** : `RepoLinkV0.read_cap` = un lien partageable que
|
||||
**quiconque le reçoit** peut ouvrir (`net/types.rs:5061-5078`).
|
||||
|
||||
Le « ciblage wallet » vit donc dans **l'enveloppe de scellage**, pas dans le cap :
|
||||
le cap reste `{id, clé}`, possession-based.
|
||||
|
||||
## 3. Révocation = re-key (grossier, non-rétroactif)
|
||||
|
||||
On ne « reprend » pas une clé livrée. Révoquer = **re-chiffrer** avec une nouvelle
|
||||
clé et ne la re-sceller qu'aux autorisés restants.
|
||||
|
||||
- « Capabilities are not durable: they can be refreshed by members and previously
|
||||
shared Caps become obsolete/revoked… if [a member] doesn't subscribe, they lose
|
||||
access after the refresh » (`net/types.rs:5055-5058`).
|
||||
- Mécanisme : `RootCapRefresh` / `BranchCapRefresh` (`repo/src/commit.rs:616,630`;
|
||||
perms `types.rs:1748-1749`).
|
||||
- Conséquences : **grossier** (échelle repo/branche), **non-rétroactif** (ce qui a
|
||||
été lu avant reste connu de l'ex-détenteur ; il ne déchiffre que les versions
|
||||
**antérieures** au refresh).
|
||||
- Livraison **durable** d'un cap = `PermaCap` — encore **TODO** (`repo/types.rs:578`).
|
||||
|
||||
## 4. Grammaire NURI : cap-less vs cap-porteur (le segment `:k:`)
|
||||
|
||||
Le discriminant est le segment **`:k:{clé}`** : présent = cap-porteur ; **absent =
|
||||
cap-less** (nomme/localise **sans** donner le droit de lire). C'est de **première
|
||||
classe** dans le type : `NuriV0.target` (des ids) et `access`/`objects` (le cap)
|
||||
sont des **champs séparés** — un NURI d'id parse avec `access: vec![]`
|
||||
(`engine/net/src/app_protocol.rs:53-62, 99-118, 181-195, 659-677`).
|
||||
|
||||
**Cap-less** (id + overlay éventuel, pas de clé) — formatters `app_protocol.rs`,
|
||||
regexes `net/types.rs` :
|
||||
- `did:ng:o:{repo_id}` (`:315`, `RE_REPO_O` types.rs:52)
|
||||
- `did:ng:o:{repo_id}:v:{overlay_id}` (`:263`, `RE_REPO` types.rs:55)
|
||||
- `did:ng:o:{repo_id}:v:{overlay_id}:b:{branch_id}` (`RE_BRANCH` types.rs:58)
|
||||
- `did:ng:o:{repo_id}:c:{commit_id}` (`:355`)
|
||||
- `did:ng:b:{branch}` / `h:{topic}` / `v:{overlay}` / `d:{inbox}` (`:327,323,319,359`)
|
||||
|
||||
**Cap-porteur** (embarque la clé) :
|
||||
- `did:ng:j:{id}:k:{clé}` — read cap d'objet/fichier (`repo/types.rs:511`,
|
||||
`RE_FILE_READ_CAP` types.rs:49)
|
||||
- `did:ng:o:{repo}:c:{commit}:k:{clé}` (`RE_COMMIT` types.rs:73)
|
||||
- liste `RE_OBJECTS` `…:[cj]:{id}:k:{clé}…:l:{locator}` (types.rs:64)
|
||||
|
||||
Le segment `:v:` est l'**overlay**, qui a sa propre section ci-dessous — c'est le
|
||||
point le plus lourd de conséquences pour les modèles de présence anonyme.
|
||||
|
||||
## 4bis. L'overlay est l'espace réseau d'un STORE — jamais d'un document
|
||||
|
||||
**L'overlay est l'unité d'adressage réseau d'un store.** Chez le broker, les blocs
|
||||
sont rangés sous une clé `(overlay, block_id)`, et les pairs se synchronisent
|
||||
*dans* un overlay. Deux formes par store :
|
||||
|
||||
| | Dérivation | Qui peut le calculer |
|
||||
|---|---|---|
|
||||
| **outer** | `OverlayId::outer(store_id)` = BLAKE3 **public** | tout le monde (le store_id suffit) |
|
||||
| **inner** | `OverlayId::inner(store_id, readcap_secret)` = BLAKE3 **keyed** | seulement qui détient la clé de lecture du store |
|
||||
|
||||
Cohérent avec le reste du modèle : pas de rôle ni de liste, seulement « détiens-tu
|
||||
la clé qui permet de dériver cet identifiant ». `outer` = le nom public du store,
|
||||
`inner` = son nom privé.
|
||||
|
||||
**Le `:v:` d'un NURI de DOCUMENT porte l'overlay de son STORE** (VÉRIFIÉ, chaîne
|
||||
lue de bout en bout) : `NuriV0::repo_graph_name(repo_id, overlay_id)` formate
|
||||
`o:{repo_id}:v:{overlay_id}` ; dans `doc_create` la valeur injectée est
|
||||
`store.outer_overlay()` — le store **contenant**, jamais le `repo_id`. Un `Repo` ne
|
||||
porte **aucun** champ overlay (seulement `store: Arc<Store>`) ; c'est `Store` qui
|
||||
porte `overlay_id`. **Contre-preuve mécanique** : dans `Store`, `get`/`put`/`del`/`has`
|
||||
passent tous `&self.overlay_id` au block storage — tous les documents d'un store
|
||||
partagent le namespace de blocs, donc un overlay par-document est structurellement
|
||||
impossible.
|
||||
|
||||
### La conséquence à connaître : le `:v:` est un pseudonyme stable
|
||||
|
||||
**Tous les documents d'une même personne dans son store protected portent le MÊME
|
||||
`:v:`** = `outer(protected_store_id)`. Donc une référence cap-less — précisément
|
||||
celle qu'on utilise pour « nommer sans donner à lire » — **expose l'appartenance
|
||||
au store**, c'est-à-dire un **identifiant pseudonyme stable et permanent de la
|
||||
personne**. Le store_id lui-même ne fuit pas (BLAKE3 non inversible), donc ça ne
|
||||
dit pas *qui* ; mais c'est un **handle constant**, le même partout et pour
|
||||
toujours, corrélable par quiconque collecte des références cap-less.
|
||||
|
||||
**Le couplage qui en résulte, et qui contraint tout modèle de présence anonyme** :
|
||||
ce même `:v:` est *simultanément* (a) ce qui permet de **dédupliquer** des
|
||||
références sans les lire — deux références de même `:v:` viennent de la même
|
||||
personne — et (b) ce qui permet de **tracer** cette personne d'un contexte à
|
||||
l'autre. **C'est le même bit d'information.** On ne peut pas obtenir la dédup sans
|
||||
concéder le traçage, ni supprimer le traçage sans perdre la dédup — sauf à changer
|
||||
le découpage en stores, ce qui déplace le curseur mais ne supprime pas l'arbitrage.
|
||||
|
||||
*Nuances.* Le `:v:` du NURI est l'overlay **outer**, alors que le trafic
|
||||
client↔broker et le stockage local utilisent l'**inner** — valeur différente, mais
|
||||
tirée du store elle aussi, donc la propriété tient dans les deux cas. Un store
|
||||
`Dialog` renvoie un `Inner`, toujours store-scopé.
|
||||
|
||||
INFÉRÉ : un détenteur **sans clé** peut vraisemblablement **récupérer les blocs
|
||||
chiffrés** (avec l'overlay, toujours cap-less) → vérifier l'**existence** d'un doc
|
||||
sans lire son **contenu**. Le chemin d'autorisation de fetch broker pour un
|
||||
détenteur sans clé n'a **pas** été tracé — à confirmer avant de s'en servir.
|
||||
|
||||
## 5. Ce que le polyfill émule (caps.ts) — et où ça diverge
|
||||
|
||||
`packages/client/src/caps.ts` modélise `readers: Map<Nuri, Set<PrincipalId>>` +
|
||||
`grantRead(doc, grantee)` (`:29-30, 41-42`) — **une ACL de principals par
|
||||
document, soit l'INVERSION exacte du modèle réel** (clé). Divergences :
|
||||
|
||||
| | Réel NextGraph | Émulation caps.ts |
|
||||
|---|---|---|
|
||||
| Nature | possession de **clé** | **ACL** (set de principals) |
|
||||
| Grant | sceller la clé (crypto_box) à l'inbox | ajouter un principal au set |
|
||||
| Durabilité | **durable** (clé livrée une fois) | **éphémère** (Map vide à chaque session → re-déclarée) |
|
||||
| Révocation | **re-key** grossier, non-rétroactif | retrait du set : **instantané et total** |
|
||||
| Granularité | repo / branche / commit / objet | **un cap par doc-NURI** |
|
||||
| Réf. sans droit | **NURI cap-less** (sans `:k:`) | pas de notion (l'ACL dit qui peut) |
|
||||
|
||||
**Face app** : `declareConnections` (côté consommateur) qui re-déclare « mes
|
||||
connexions lisent mes entités protected » **à chaque session** est un **artefact
|
||||
de cette ACL éphémère** — sans objet dans le modèle réel (les scellages y sont
|
||||
durables ; on scelle par-doc au partage, pas par-session).
|
||||
|
||||
## 6. Implications pour les consommateurs (ex. Festipod)
|
||||
|
||||
- « **scope protected = mon réseau peut lire** » n'est **pas** une ACL vérifiée
|
||||
par le broker : c'est « j'ai **scellé ma read key** à chacune de mes
|
||||
connexions ». Le modèle mental « scope = ACL » est faux au niveau NextGraph.
|
||||
- **Références anonymes possibles** : mettre un **NURI cap-less** dans une
|
||||
collection tierce laisse le tiers **nommer/compter** sans **lire l'identité** ;
|
||||
sceller le cap-porteur séparément aux seuls autorisés. (Base d'un modèle de
|
||||
présence « participation auto-possédée + Set curé cap-less + cap scellé aux
|
||||
connexions ».)
|
||||
- **Alignement à faire** : quand les vraies opérations de cap seront disponibles,
|
||||
remplacer l'ACL émulée par du scellage de clé durable par-doc, et
|
||||
`declareConnections`-comme-ACL-ré-déclarée disparaît.
|
||||
|
||||
## Réserves / lacunes
|
||||
|
||||
- `file:line` datés (2026-07) — re-vérifier par symbole ; le core bouge.
|
||||
- INFÉRÉ : fetch broker keyless (existence sans clé) — non tracé au runtime.
|
||||
- Non tracé : exécution complète de `RootCapRefresh` côté verifier
|
||||
(`verifier/src/commits/mod.rs:616`), stockage wallet de `private_store_read_cap`
|
||||
(`repo/types.rs:945,976`).
|
||||
@@ -0,0 +1,61 @@
|
||||
# Vision & principes du polyfill `@ng-eventually/client`
|
||||
|
||||
## Raison d'être
|
||||
|
||||
Un **stand-in fidèle en FORME** des primitives futures de NextGraph. Objectif
|
||||
**unique** : que les consommateurs (Festipod) soient **codés contre le modèle
|
||||
mental CORRECT** — celui de NextGraph fini — et **n'aient RIEN à réécrire** quand
|
||||
NextGraph fournira les vraies primitives.
|
||||
|
||||
## Ce que le polyfill n'est PAS
|
||||
|
||||
Une couche de **sécurité**. Le **wallet partagé** (tout le monde partage les mêmes
|
||||
clés) + l'absence de vraie crypto rendent l'émulation **infiniment moins
|
||||
sécurisée** qu'un wallet-par-utilisateur — c'est un **véhicule de dev/staging**,
|
||||
pas un but. **L'insécurité est ACCEPTÉE.** Un attaquant qui contourne l'émulation
|
||||
n'est pas notre problème.
|
||||
|
||||
## Le seul critère : shape-fidelity, avec RIGUEUR
|
||||
|
||||
Les **surfaces exposées** doivent matcher **exactement la FORME** des primitives
|
||||
futures, **même là où l'enforcement est simulé**. Le **mode d'échec à éviter** :
|
||||
exposer la **mauvaise forme** → le consommateur code contre un modèle qui
|
||||
n'existera pas → réécriture. L'inversion **ACL** des ReadCaps était exactement ce
|
||||
défaut (une ACL là où le réel est **possession de clé**) — un manque de rigueur.
|
||||
|
||||
## Simuler la crypto pour EMPÊCHER les raccourcis
|
||||
|
||||
Sans un minimum de simulation crypto, des raccourcis préjudiciables sont pris (on
|
||||
lit le clair, on retombe sur des ACLs). Le polyfill **simule** donc le mécanisme
|
||||
final, assez pour tenir cet **invariant** :
|
||||
|
||||
> **Un `did` (id nu, SANS ReadCap) et un NURI (AVEC ReadCap) sont traités
|
||||
> VRAIMENT différemment : le premier ne permet PAS de lire la donnée ; le second
|
||||
> est SUFFISANT et REQUIS.**
|
||||
|
||||
Concrètement : la donnée d'un document est **stockée chiffrée** (chiffrement
|
||||
symétrique par-doc, même léger) ; le **ReadCap = la clé** ; sans elle, **impossible
|
||||
de déchiffrer/lire**. Pas d'ACL, pas de clair accessible « à côté ». Obtenir la
|
||||
lecture = **détenir la clé**, exactement comme en cible.
|
||||
|
||||
## Conséquences de forme (à respecter partout)
|
||||
|
||||
- **Tout est clés et URLs.** Il n'y a **pas** de notion d'appartenance, de rôle ni
|
||||
de liste d'autorisation dans le modèle : uniquement de la cryptographie
|
||||
symétrique et asymétrique, des URIs, et qui détient quelle clé. Toute forme
|
||||
exposée qui ressemble à une ACL, un `member`, un `role` ou une `permission` est
|
||||
une **mauvaise forme**, quel que soit l'échafaudage qu'on peut lire par ailleurs
|
||||
dans l'état courant de NextGraph.
|
||||
- **Lecture = possession de la clé de lecture** (ReadCap = `{id, clé}`). Un id nu
|
||||
(un `did` sans ReadCap) ne lit pas.
|
||||
- **Écriture = possession de la clé d'écriture** — une clé **distincte** de celle
|
||||
de lecture, donc un axe distinct, mais **de la possession elle aussi**.
|
||||
- **Partage d'un cap = le sceller à un destinataire** (livraison **durable**, au
|
||||
moment du partage — PAS une ACL re-déclarée à chaque session).
|
||||
- **Révocation = re-key** (nouvelle clé ; les anciens détenteurs gardent l'ancien
|
||||
état). Non-rétroactif.
|
||||
- **Référence cap-less** (nommer/pointer sans lire) **distincte** de la référence
|
||||
cap-porteuse.
|
||||
|
||||
Voir `readcap-and-nuri-model.md` (le vrai modèle, vérifié dans `nextgraph-rs`) et
|
||||
`briefs/2026-07-20-caps-emulation-alignment.md` (le chantier d'alignement).
|
||||
@@ -244,7 +244,7 @@ helpers live in the consumer app; the SDK exposes the generic reactive/by-need r
|
||||
> [`read-model.md`](../../../docs/read-model.md),
|
||||
> [`simulation.md`](../../../docs/simulation.md).
|
||||
|
||||
Today, on a single shared wallet emulating the mature platform, three gaps diverge
|
||||
Today, on a single shared wallet emulating the mature platform, four gaps diverge
|
||||
from the reactive contract:
|
||||
|
||||
1. **Entity-list reads are one-shot, not reactive.** The reactive ORM cannot be used
|
||||
@@ -279,5 +279,38 @@ from the reactive contract:
|
||||
queryable. At the multi-store migration, opening a repo by cap becomes a native
|
||||
broker sync and the anchored read is unchanged.
|
||||
|
||||
4. **The subscription may not echo the writer's OWN local commit — HYPOTHESIS
|
||||
(high-confidence), confirmation in progress (2026-07-18); NOT confirmed, NOT
|
||||
fixed.** Unlike gaps 1–3 (designed emulation stopgaps), this is a suspected
|
||||
defect in the polyfill's own reactive assembly. When a client does a local
|
||||
`sparqlUpdate` on a doc it is itself subscribed to (`subscribeDoc` /
|
||||
`ng.doc_subscribe`), the subscription callback appears NOT to fire for its OWN
|
||||
local commit in the same session — so the reactive re-read chain
|
||||
([`../src/watch-shape.ts`](../src/watch-shape.ts) `watchShape` → `reread` →
|
||||
[`../src/read-model.ts`](../src/read-model.ts) `readUnion`) never runs, and
|
||||
consumers keep the STALE value until the next connection delivers a fresh
|
||||
initial `State`. **Remote** commits DO push correctly (verified: cross-browser
|
||||
reactive update works). A code review verified the consumer wiring is correct,
|
||||
the doc IS in the subscribed set, and a triggered re-read WOULD return the new
|
||||
value — leaving the self-commit echo as the only suspect link. That link is
|
||||
**INFERRED**, not observed: the real `ng.doc_subscribe` runtime is not readable
|
||||
from source, and [`../src/subscribe.ts`](../src/subscribe.ts)'s own doc-comment
|
||||
CLAIMS local writes push a `Patch` — contradicted by the observation. (This
|
||||
also sits in tension with § *The reactivity model* above, which documents the
|
||||
target contract — one commit, every subscriber pushed, local or remote.) The
|
||||
requirement at stake is multi-user: a value change (e.g. a participant count)
|
||||
must propagate reactively to ALL viewers — other viewers (remote push, which
|
||||
works) AND the writer's own view (this suspect link). **Treatment (PLANNED,
|
||||
not done):** confirm first via the temporary instrumentation just added
|
||||
([`../src/subscribe.ts`](../src/subscribe.ts) ≈`:119` logs
|
||||
`doc_subscribe FIRE <nuri> (State|Patch)`;
|
||||
[`../src/watch-shape.ts`](../src/watch-shape.ts) ≈`:341` logs
|
||||
`reread TRIGGER by <nuri>` — line numbers volatile, grep the log strings);
|
||||
then, IF confirmed, fix **polyfill-side** — a
|
||||
local commit should notify the doc's active `subscribeDoc` callbacks.
|
||||
Consumers must not compensate. Short entry:
|
||||
[`nextgraph-current-state.md`](../../../docs/nextgraph-current-state.md) §
|
||||
*Known open issues*.
|
||||
|
||||
When these gaps close, the read path collapses to the reference above: `useShape`
|
||||
everywhere, push everywhere, no polling and no re-query-on-signal assembly.
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
/**
|
||||
* DECISIVE real-broker determination: does `doc_subscribe` actually PUSH when a
|
||||
* subscribed document is written?
|
||||
*
|
||||
* This is the reactive-layer coverage whose ABSENCE let a reactivity bug ship: the
|
||||
* app's whole read-model reactivity rests on `subscribeDoc(nuri, cb)` (the polyfill
|
||||
* wrapper over `ng.doc_subscribe`, `src/subscribe.ts`) firing `cb` again on every
|
||||
* commit to the doc. Two pushes are load-bearing in production and were reported as
|
||||
* NOT firing:
|
||||
* (SELF) a session's own `sparqlUpdate` to a doc it subscribes to.
|
||||
* (CROSS) another session writes to a doc the first session subscribes to.
|
||||
*
|
||||
* This runner exercises BOTH against the REAL broker, through the SAME public
|
||||
* surface the app uses — `subscribeDoc` (via the harness's `stateProbe*` bridge,
|
||||
* which passes the raw `AppResponse` straight through the polyfill wrapper),
|
||||
* `docs.docCreate`, and `docs.sparqlUpdate` (`writeTo`). It records EVERY push as a
|
||||
* typed event (`{ typeKey: "State" | "Patch" | "TabInfo" | …, elapsedMs }`) so the
|
||||
* verdict is the ground truth "did the subscription callback fire again", not a
|
||||
* re-read of the document. Each wait is a single event-driven promise+timeout on the
|
||||
* push (NO re-read loop) — a timeout is a DEFINITE "did-not-fire", not a flaky miss.
|
||||
*
|
||||
* Standalone (NOT `bun test`). Run:
|
||||
* bun run e2e/reactivity-doc-subscribe.ts
|
||||
* (or `bun run test:e2e:reactivity` from packages/client)
|
||||
*
|
||||
* It reuses the exact real-broker plumbing of run.ts / broker.ts: the dedicated lib
|
||||
* wallet, the broker iframe, `window.__sdk`. The CROSS case opens a SECOND page on
|
||||
* the SAME persistent wallet context — a second concurrent verifier session on one
|
||||
* shared wallet (as faithfulReconnect does) — and writes from it.
|
||||
*/
|
||||
|
||||
import type { Frame, Page, BrowserContext } from "playwright";
|
||||
import {
|
||||
buildBundle,
|
||||
serveHarness,
|
||||
ensureWallet,
|
||||
launchWalletContext,
|
||||
setupBrokerPage,
|
||||
} from "./broker";
|
||||
|
||||
type Check = { name: string; ok: boolean; detail?: string };
|
||||
const results: Check[] = [];
|
||||
function record(name: string, ok: boolean, detail?: string): void {
|
||||
results.push({ name, ok, detail });
|
||||
console.log(` [${ok ? "PASS" : "FAIL"}] ${name}${detail ? " — " + detail : ""}`);
|
||||
}
|
||||
|
||||
type Event = { typeKey: string; elapsedMs: number };
|
||||
|
||||
// Call a bridge method inside a given iframe.
|
||||
function sdk<T>(frame: Frame, method: string, ...args: unknown[]): Promise<T> {
|
||||
return frame.evaluate(
|
||||
([m, a]) => (window as any).__sdk[m as string](...(a as unknown[])),
|
||||
[method, args] as const,
|
||||
) as Promise<T>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The decisive wait: resolve TRUE as soon as the probe's recorded push count grows
|
||||
* past `base` (the subscription callback fired again), or FALSE on timeout. This is
|
||||
* a promise+timeout on the PUSH itself — it polls only the in-memory event counter
|
||||
* the `subscribeDoc` callback writes, NEVER re-reads the document. A FALSE here is a
|
||||
* definite non-delivery within the window, not a missed re-read.
|
||||
*/
|
||||
async function waitForPush(frame: Frame, base: number, timeoutMs: number): Promise<boolean> {
|
||||
try {
|
||||
await frame.waitForFunction(
|
||||
(b) => (window as any).__sdk.stateProbeEvents().length > (b as number),
|
||||
base,
|
||||
{ timeout: timeoutMs },
|
||||
);
|
||||
return true;
|
||||
} catch {
|
||||
return false; // timed out → the callback did NOT fire again within the window
|
||||
}
|
||||
}
|
||||
|
||||
const seq = (events: Event[]): string =>
|
||||
events.length ? events.map((e) => `${e.typeKey}@${e.elapsedMs}ms`).join(" → ") : "(none)";
|
||||
|
||||
async function openSession(
|
||||
ctx: BrowserContext,
|
||||
url: string,
|
||||
tag: string,
|
||||
): Promise<{ page: Page; frame: Frame; sessionId: string }> {
|
||||
const page = await ctx.newPage();
|
||||
page.on("pageerror", (e) => console.error(`[iframe error:${tag}]`, e.message));
|
||||
page.on("console", (m) => {
|
||||
const t = m.text();
|
||||
// Surface the polyfill's own "doc_subscribe FIRE" diagnostic (subscribe.ts) if
|
||||
// access logging happens to be on — an independent confirmation of a push.
|
||||
if (m.type() === "error") console.error(`[iframe console:${tag}]`, t);
|
||||
else if (t.includes("doc_subscribe FIRE")) console.log(`[${tag}] ${t}`);
|
||||
});
|
||||
const frame = await setupBrokerPage(page, url);
|
||||
await frame.waitForFunction(() => (window as any).__sdk !== undefined, { timeout: 30000 });
|
||||
await frame.waitForFunction(() => (window as any).__sdk.status() === "connected", {
|
||||
timeout: 60000,
|
||||
});
|
||||
const info = await sdk<{ session_id: string } | null>(frame, "sessionInfo");
|
||||
const sessionId = info?.session_id ?? "(none)";
|
||||
console.log(`[session:${tag}] connected — session_id=${sessionId}`);
|
||||
return { page, frame, sessionId };
|
||||
}
|
||||
|
||||
const SELF_TIMEOUT_MS = 10000;
|
||||
const CROSS_TIMEOUT_MS = 15000;
|
||||
const STATE_TIMEOUT_MS = 20000;
|
||||
|
||||
async function main(): Promise<void> {
|
||||
console.log("[reactivity] building SDK page bundle...");
|
||||
buildBundle();
|
||||
console.log("[reactivity] ensuring dedicated lib wallet...");
|
||||
await ensureWallet();
|
||||
const { url, close: closeServer } = await serveHarness();
|
||||
console.log(`[reactivity] harness served at ${url}`);
|
||||
|
||||
let ctx: BrowserContext | null = null;
|
||||
try {
|
||||
ctx = await launchWalletContext();
|
||||
|
||||
// ── Session A (the subscriber for both cases) ────────────────────────────
|
||||
const A = await openSession(ctx, url, "A");
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
// CASE 1 — SELF: A subscribes to D, then A itself writes to D.
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
console.log("\n── CASE 1: SELF (single session — own write to own subscribed doc) ──");
|
||||
{
|
||||
const doc = await sdk<string>(A.frame, "docCreate");
|
||||
console.log(` [SELF] created doc D = ${doc}`);
|
||||
await sdk(A.frame, "stateProbeSubscribe", doc);
|
||||
|
||||
// Wait for the initial State (the sync barrier). TabInfo may precede it.
|
||||
const gotState = await (async () => {
|
||||
try {
|
||||
await A.frame.waitForFunction(
|
||||
() => (window as any).__sdk.stateProbeStateCount() >= 1,
|
||||
{ timeout: STATE_TIMEOUT_MS },
|
||||
);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
const afterSubscribe = await sdk<Event[]>(A.frame, "stateProbeEvents");
|
||||
console.log(` [SELF] pushes after subscribe: ${seq(afterSubscribe)}`);
|
||||
record(
|
||||
"SELF: initial State push arrives on subscribe (baseline sanity)",
|
||||
gotState && afterSubscribe.some((e) => e.typeKey === "State"),
|
||||
`sequence=${seq(afterSubscribe)}`,
|
||||
);
|
||||
|
||||
// Now the decisive write: A's OWN sparqlUpdate to D.
|
||||
const preWrite = afterSubscribe.length;
|
||||
console.log(` [SELF] A writes to D (own sparqlUpdate); waiting ≤${SELF_TIMEOUT_MS}ms for a push…`);
|
||||
await sdk(A.frame, "writeTo", doc, "self-1");
|
||||
const fired = await waitForPush(A.frame, preWrite, SELF_TIMEOUT_MS);
|
||||
|
||||
const afterWrite = await sdk<Event[]>(A.frame, "stateProbeEvents");
|
||||
const newEvents = afterWrite.slice(preWrite);
|
||||
console.log(` [SELF] pushes AFTER own write: ${seq(newEvents)}`);
|
||||
console.log(` [SELF] VERDICT: callback ${fired ? "FIRED" : "did NOT fire"} within ${SELF_TIMEOUT_MS}ms`);
|
||||
record(
|
||||
`SELF: subscription callback fires on the session's OWN write (≤${SELF_TIMEOUT_MS}ms)`,
|
||||
fired,
|
||||
`newPushes=${seq(newEvents)}`,
|
||||
);
|
||||
await sdk(A.frame, "stateProbeStop");
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
// CASE 2 — CROSS-SESSION: A subscribes to D2; a SECOND session B (same shared
|
||||
// wallet, own concurrent verifier session) writes to D2.
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
console.log("\n── CASE 2: CROSS-SESSION (session B writes to a doc session A subscribes to) ──");
|
||||
let B: { page: Page; frame: Frame; sessionId: string } | null = null;
|
||||
try {
|
||||
B = await openSession(ctx, url, "B");
|
||||
} catch (e: any) {
|
||||
console.log(` [CROSS] COULD-NOT-TEST: second concurrent session on the shared wallet failed to open: ${String(e?.message ?? e)}`);
|
||||
record(
|
||||
"CROSS: second concurrent session opened on the shared wallet",
|
||||
false,
|
||||
`open failed: ${String(e?.message ?? e)} — see Festipod multibrowser harness as the alternative venue`,
|
||||
);
|
||||
}
|
||||
|
||||
if (B) {
|
||||
// NB: `session_id` is a PER-PAGE local verifier counter (each fresh iframe
|
||||
// numbers its first session "1"), so it is NOT a global identifier and cannot
|
||||
// be used to prove distinctness. The REAL proof that A and B are two separate
|
||||
// verifier sessions is behavioural: B's write reaches A only after a broker
|
||||
// round-trip (a delayed Patch), not as an instant same-session echo.
|
||||
console.log(
|
||||
` [CROSS] both pages connected — A.session=${A.sessionId} B.session=${B.sessionId} (per-page local counter; distinctness shown by the cross-broker propagation below)`,
|
||||
);
|
||||
record(
|
||||
"CROSS: a second concurrent page/session is open on the same shared wallet",
|
||||
true,
|
||||
`A=${A.sessionId} B=${B.sessionId} (session_id is a per-page counter, not a global id)`,
|
||||
);
|
||||
|
||||
// A creates D2 and subscribes.
|
||||
const doc2 = await sdk<string>(A.frame, "docCreate");
|
||||
console.log(` [CROSS] A created doc D2 = ${doc2}`);
|
||||
await sdk(A.frame, "stateProbeSubscribe", doc2);
|
||||
const gotState2 = await (async () => {
|
||||
try {
|
||||
await A.frame.waitForFunction(
|
||||
() => (window as any).__sdk.stateProbeStateCount() >= 1,
|
||||
{ timeout: STATE_TIMEOUT_MS },
|
||||
);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
const afterSub2 = await sdk<Event[]>(A.frame, "stateProbeEvents");
|
||||
console.log(` [CROSS] A pushes after subscribe: ${seq(afterSub2)}`);
|
||||
record(
|
||||
"CROSS: A receives its initial State on D2 (baseline sanity)",
|
||||
gotState2 && afterSub2.some((e) => e.typeKey === "State"),
|
||||
`sequence=${seq(afterSub2)}`,
|
||||
);
|
||||
|
||||
// B writes to D2. Capture a write failure (e.g. RepoNotFound) explicitly —
|
||||
// it would mean B cannot reach A's doc, which is itself a determination.
|
||||
const preCross = afterSub2.length;
|
||||
let writeThrew: string | null = null;
|
||||
// Cross-session writes to a doc created by ANOTHER session can be slow: B must
|
||||
// sync/open D2's repo before it can commit. Time it separately so the push
|
||||
// latency is reported relative to when B's write actually LANDED, not to
|
||||
// subscribe time.
|
||||
console.log(` [CROSS] B writes to D2 from its own session…`);
|
||||
const tWriteStart = Date.now();
|
||||
try {
|
||||
await sdk(B.frame, "writeTo", doc2, "cross-1");
|
||||
} catch (e: any) {
|
||||
writeThrew = String(e?.message ?? e);
|
||||
console.log(` [CROSS] B's write THREW: ${writeThrew}`);
|
||||
}
|
||||
const writeMs = Date.now() - tWriteStart;
|
||||
record("CROSS: session B's write to D2 did not throw", writeThrew === null, writeThrew ? writeThrew : `landed in ${writeMs}ms`);
|
||||
|
||||
console.log(` [CROSS] B's write returned in ${writeMs}ms; now waiting ≤${CROSS_TIMEOUT_MS}ms for A's push…`);
|
||||
const tWaitStart = Date.now();
|
||||
const crossFired = writeThrew ? false : await waitForPush(A.frame, preCross, CROSS_TIMEOUT_MS);
|
||||
const pushAfterWriteMs = Date.now() - tWaitStart;
|
||||
const afterCross = await sdk<Event[]>(A.frame, "stateProbeEvents");
|
||||
const crossNew = afterCross.slice(preCross);
|
||||
console.log(` [CROSS] A pushes AFTER B's write: ${seq(crossNew)}`);
|
||||
console.log(
|
||||
` [CROSS] VERDICT: A's callback ${crossFired ? `FIRED (${pushAfterWriteMs}ms after B's write landed)` : "did NOT fire"} within ${CROSS_TIMEOUT_MS}ms${writeThrew ? " (B's write threw first)" : ""}`,
|
||||
);
|
||||
record(
|
||||
`CROSS: A's subscription callback fires on B's write (≤${CROSS_TIMEOUT_MS}ms after B's write landed)`,
|
||||
crossFired,
|
||||
`newPushes=${seq(crossNew)} (B write took ${writeMs}ms; push ${crossFired ? pushAfterWriteMs + "ms after" : "not seen"})${writeThrew ? ` — B write threw: ${writeThrew}` : ""}`,
|
||||
);
|
||||
await sdk(A.frame, "stateProbeStop");
|
||||
}
|
||||
} finally {
|
||||
try { if (ctx) await ctx.close(); } catch { /* ignore */ }
|
||||
closeServer();
|
||||
}
|
||||
|
||||
// ── Determination summary (not a pass/fail gate — this is a probe) ──────────
|
||||
console.log("\n══ doc_subscribe delivery determination ══");
|
||||
for (const r of results) console.log(` [${r.ok ? "PASS" : "FAIL"}] ${r.name}${r.detail ? " — " + r.detail : ""}`);
|
||||
const self = results.find((r) => r.name.startsWith("SELF: subscription callback fires"));
|
||||
const cross = results.find((r) => r.name.startsWith("CROSS: A's subscription callback fires"));
|
||||
console.log("\n SELF →", self ? (self.ok ? "FIRES" : "DOES-NOT-FIRE") : "could-not-test");
|
||||
console.log(" CROSS →", cross ? (cross.ok ? "FIRES" : "DOES-NOT-FIRE") : "could-not-test");
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error("[reactivity] fatal:", e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -28,6 +28,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"test": "bun test",
|
||||
"test:e2e": "bun run e2e/run.ts"
|
||||
"test:e2e": "bun run e2e/run.ts",
|
||||
"test:e2e:reactivity": "bun run e2e/reactivity-doc-subscribe.ts"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,13 @@ import { subscribeDoc } from "./subscribe";
|
||||
import { ensureRepoOpen } from "./open-repo";
|
||||
import { getCurrentUser, getStoreRegistryDeps } from "./polyfill";
|
||||
import { escapeLiteral } from "./sparql";
|
||||
import { accessLogPrefix } from "./access-log";
|
||||
import {
|
||||
accessLogPrefix,
|
||||
enabled as accessLogEnabled,
|
||||
logAccess,
|
||||
logStage,
|
||||
shortNuri,
|
||||
} from "./access-log";
|
||||
import type { Nuri, PrincipalId } from "./types";
|
||||
|
||||
// --- deposit model --------------------------------------------------------
|
||||
@@ -76,6 +82,28 @@ async function sessionId(): Promise<string> {
|
||||
return (await getStoreRegistryDeps().getSession()).sessionId;
|
||||
}
|
||||
|
||||
// --- diagnostic logging helper ---------------------------------------------
|
||||
|
||||
/**
|
||||
* Best-effort, length-capped JSON rendering of a deposit payload for the
|
||||
* inbox diagnostic log (see {@link enabled}/{@link logAccess}). This module
|
||||
* stays domain-agnostic (see module header) — it never interprets payload
|
||||
* fields, it only dumps them verbatim so the consumer's own shape (e.g. a
|
||||
* Festipod participation: `{ participantId, eventId, … }`) is visible in the
|
||||
* log without this module knowing that shape. Capped so one oversized payload
|
||||
* can't blow up a log line; a payload that fails to stringify (e.g. a
|
||||
* circular structure a caller mistakenly passed) falls back to `String()`.
|
||||
*/
|
||||
function summarizePayload(payload: unknown): string {
|
||||
let s: string;
|
||||
try {
|
||||
s = JSON.stringify(payload) ?? String(payload);
|
||||
} catch {
|
||||
s = String(payload);
|
||||
}
|
||||
return s.length > 200 ? s.slice(0, 200) + "…" : s;
|
||||
}
|
||||
|
||||
// --- SPARQL result helpers ------------------------------------------------
|
||||
|
||||
/** Tolerant extraction of SPARQL SELECT bindings across possible shapes. */
|
||||
@@ -145,6 +173,17 @@ export async function post(targetInbox: Nuri, opts: PostOptions): Promise<void>
|
||||
<${P.ts}> "${ts}"${fromTriple} .
|
||||
}`;
|
||||
await sparqlUpdate(sid, update, targetInbox, "deposit");
|
||||
// Domain-level diagnostic (on top of docs.ts's generic access-path WRITE log):
|
||||
// who deposited WHAT into which inbox — the decoded payload, not just the
|
||||
// triple-write. Gated by the same access-log flag; skip the JSON work when off.
|
||||
if (accessLogEnabled()) {
|
||||
logAccess(
|
||||
"WRITE",
|
||||
targetInbox,
|
||||
"inbox deposit",
|
||||
" from=" + (from ?? "anonymous") + " payload=" + summarizePayload(opts.payload ?? null),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// --- read --------------------------------------------------------------
|
||||
@@ -191,6 +230,26 @@ export async function read(targetInbox: Nuri): Promise<Deposit[]> {
|
||||
deposits.push({ from: fromValue ? fromValue : null, payload, ts });
|
||||
}
|
||||
deposits.sort((a, b) => a.ts - b.ts);
|
||||
// Domain-level diagnostic (on top of docs.ts's generic access-path READ log
|
||||
// of raw triple-rows): how many DEPOSITS were found, and the decoded data of
|
||||
// each — the exact visibility needed to trace materialization at the owner
|
||||
// side. Gated by the same access-log flag; skip the JSON work when off.
|
||||
if (accessLogEnabled()) {
|
||||
logAccess(
|
||||
"READ",
|
||||
targetInbox,
|
||||
"inbox materialize",
|
||||
" → " + deposits.length + " message(s)",
|
||||
);
|
||||
for (const d of deposits) {
|
||||
logAccess(
|
||||
"READ",
|
||||
targetInbox,
|
||||
"inbox message",
|
||||
" ts=" + d.ts + " from=" + (d.from ?? "anonymous") + " payload=" + summarizePayload(d.payload),
|
||||
);
|
||||
}
|
||||
}
|
||||
return deposits;
|
||||
}
|
||||
|
||||
@@ -218,6 +277,11 @@ export const materialize = read;
|
||||
* the unit fake-ng path (no `doc_subscribe`) so `bun test` is unaffected.
|
||||
*/
|
||||
export async function readSynced(targetInbox: Nuri): Promise<Deposit[]> {
|
||||
// Marks the cold, connection-triggered entry point in the trace — the BARRIER
|
||||
// line (open-repo.ts) and the "inbox materialize"/"inbox message" lines below
|
||||
// (from the read() this wraps) follow right after, so a live session shows
|
||||
// the whole owner-reconnect sequence together.
|
||||
logStage("READSYNCED " + shortNuri(targetInbox) + " (cold, barrier-gated)");
|
||||
await ensureRepoOpen(targetInbox);
|
||||
return read(targetInbox);
|
||||
}
|
||||
@@ -251,7 +315,22 @@ export function watch(
|
||||
if (stopped) return;
|
||||
try {
|
||||
const deposits = await read(targetInbox);
|
||||
if (!stopped && deposits.length !== lastCount) {
|
||||
const changed = deposits.length !== lastCount;
|
||||
// Owner-side processing decision: did this push actually grow the
|
||||
// deposit set (→ onDeposits fires, the polyfill's stand-in for
|
||||
// materialization) or was it a no-op push (→ skipped)? This is the
|
||||
// exact line to check for the "must reconnect an extra time" symptom:
|
||||
// a push whose read still sees the OLD count means the barrier/read
|
||||
// raced the write, not that watch itself failed to fire.
|
||||
if (accessLogEnabled()) {
|
||||
logAccess(
|
||||
"READ",
|
||||
targetInbox,
|
||||
"inbox watch",
|
||||
" → " + deposits.length + " message(s)" + (changed ? " (materializing)" : " (unchanged, skip)"),
|
||||
);
|
||||
}
|
||||
if (!stopped && changed) {
|
||||
lastCount = deposits.length;
|
||||
onDeposits(deposits);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user