7 Commits

Author SHA1 Message Date
Sylvain Duchesne 6f0d0586e2 docs(brief): passer le brief caps en anglais et solder trois incohérences
Dernier document du dossier docs/ encore en français. Traduction fidèle : mêmes
sections, mêmes tableaux, mêmes items, mêmes balises. Glossaire repris tel quel
du passage précédent pour que le vocabulaire soit cohérent d'un fichier à
l'autre.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014GbGgNEHRejVKoREvFuDFg
2026-07-27 14:48:48 +02:00
6 changed files with 601 additions and 388 deletions
+100 -171
View File
@@ -1,216 +1,145 @@
# Brief — aligner l'émulation des caps sur le vrai modèle NextGraph # Brief — align the caps emulation with the real NextGraph model
**Brief (incubation) — 2026-07-20.** Voir la référence `docs/readcap-and-nuri-model.md`. **Brief (incubation) — 2026-07-20.** See the reference `docs/readcap-and-nuri-model.md`.
## Problème ## Problem
`caps.ts` émule les droits de lecture comme une **ACL** (`Map<Nuri, Set<PrincipalId>>`, `caps.ts` emulates read rights as an **ACL** (`Map<Nuri, Set<PrincipalId>>`, `grantRead(doc, grantee)`) — **the inversion** of the real NextGraph model (key possession). Consequences: no notion of a **cap-less reference**, grant/revocation **instantaneous and total** (instead of durable sealing + re-key), and an API (`declareConnections`) that consumers have to **re-declare every session**. This divergence makes it impossible to properly build models that rest on the real semantics — in particular **anonymous presence** (naming/counting without reading).
`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é ## Objective: shape-fidelity, NOT security
Le polyfill **n'égale PAS** la sécurité de NextGraph fini, et n'essaie pas. Le The polyfill does **NOT match** the security of finished NextGraph, and does not try to. The shared wallet plus the absence of crypto make the emulation **deliberately insecure** (everything is in plaintext, any marker is forgeable) — a dev/staging vehicle, not a goal. **Sole objective**: expose the **RIGHT SHAPE** of the future primitives so that consumers (Festipod) are coded against the **correct mental model** and **do not have to be rewritten** when NextGraph is finished.
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 Corollary: **"no crypto" is not a problem**; what matters is being **in the same logic, with RIGOR**. A criticism of the form "an attacker reads the plaintext / forges a marker" is **correct but out of scope**. What is **unacceptable** = exposing the **wrong shape** (e.g. an ACL where the real thing is key possession) → the consumer codes against a model that will not exist. **The ACL inversion of ReadCaps IS that lack of rigor** — the central defect to fix.
**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) ## Enforcement mechanism: LIGHTWEIGHT crypto simulation (anti-ACL, anti-shortcut)
Pour que la forme soit **réellement** possession-de-clé (et pas une ACL déguisée), For the shape to be **really** key-possession (and not an ACL in disguise), a doc's data is **stored encrypted** (per-doc symmetric encryption, however lightweight) and the **ReadCap = the key**. Invariant (cf. `docs/vision.md`):
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 > a **bare `did` (without a ReadCap)** does **NOT** allow reading; a **NURI with a ReadCap** is **sufficient and required**.
> ReadCap** est **suffisant et requis**.
Ça **empêche les raccourcis** que l'adversaire a relevés (#4/#6 : lire le clair, This **prevents the shortcuts** the adversary pointed out (#4/#6: reading the plaintext, `sparqlQuery`/`inbox.read` bypassing the filter) and **forbids** falling back on an ACL — that is the heart of "same logic, with rigor".
`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`, **Target — NOT the current state**: every surface that returns data will have to go through decryption-with-key. **Today this is FALSE, and far more broadly than this brief first stated** — mapping of 2026-07-27, VERIFIED: **only 4 sites consult the caps** (`use-shape`, `read-filter`, `read-model.readUnion`, `discovery.submitToIndex`). Everything else returns data with no guard:
`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 — | Surface | State |
aligné sur le NURI sans `:k:`) vs cap-porteur (id + clé/token). Aujourd'hui |---|---|
absent. | `docs.sparqlQuery` / `sparqlUpdate` | **bypasses** — they call the injected `ng` **directly** (an accepted constraint, to avoid a double-Proxy `DataCloneError`). **The widest breach**: a session id + a NURI are enough to read everything. |
2. **Grant = livrer un cap-token à un destinataire** (émuler le scellage : | `inbox` (`read` / `readSynced` / `materialize` / `watch`) | **bypasses** — no cap consulted; the drops go to whoever asks for them |
le destinataire *reçoit* le token dans son inbox ; c'est la **possession** du | `store-registry` (**zero** reference to caps in the whole file) | **bypasses** — the account→NURI root of trust is universally readable |
token qui autorise la lecture — pas une ligne d'ACL vérifiée par principal). | `discovery.readIndex` | **bypasses** on read (caps checked on write only) |
3. **Enforcement par possession** : les lecteurs (`read-filter`, `use-shape`) ne | `subscribe`, `open-repo` | **bypass** — the subscription push carries the doc state with no check |
voient que ce dont ils **détiennent le token**, pas « ce dont ils sont dans le | `watch-shape` | deliberately delegates to `readUnion` (does not re-filter) |
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) **And the WRITE guard is already stillborn**: `ng-proxy` guards `sparql_update`, but `docs` bypasses the proxy **by design**, and **all** internal writers go through `docs`. So the guard only fires for an app calling `ng.sparql_update` on the exported `ng` — which Festipod does not do. `grantWrite` / `canWrite` are **decorative**. *(This finding reinforces §1 of the adversarial review: writing is not an axis "to be added", it is an axis we believed was covered and is not.)*
**Cette section était fausse et est conservée barrée comme garde-fou.** Elle **This inventory IS the scope of P1b.** The only existing guard (`caps.canRead`) is moreover a **set-membership ACL** — the very inversion the vision forbids.
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 1. **Two distinct reference shapes**: cap-less (names/locates without reading — aligned with the NURI without `:k:`) vs cap-bearing (id + key/token). Absent today.
courant de NextGraph pour en **déduire** la forme cible est une erreur — l'état 2. **Grant = delivering a cap-token to a recipient** (emulating the sealing: the recipient *receives* the token in their inbox; it is **possession** of the token that authorizes reading — not an ACL row checked per principal).
courant contient de l'inachevé qu'il ne faut pas figer dans le polyfill. Le source 3. **Enforcement by possession**: readers (`read-filter`, `use-shape`) only see what they **hold the token for**, not "what they are in the readers set for".
sert à vérifier un **mécanisme** existant, jamais à inférer une **intention**. 4. **Resolving a cap-less** = naming / proving existence / counting, **without** exposing the content (support for anonymous presence).
5. **Revocation = re-key** emulated: invalidate the old token, re-deliver a new one to the remaining authorized holders; **non-retroactive**.
Contenu erroné conservé ci-dessous à titre de trace : ## ~~Widened scope: the WriteCap (= membership)~~ — DROPPED (2026-07-21)
**This section was wrong and is kept struck through as a guardrail.** It imported a notion of *membership* read from the **current state** of `nextgraph-rs` (`AddMember`, `PermissionV0`, `member_pubkey`) and promoted it into a **target shape**. But (a) those types are **inert scaffolding** at runtime — `verify_sig` / `verify_perm` are only called in unit tests, and `Repo`s are built with `members: HashMap::new()`; and (b) the target model **has no notion of membership at all**: only **keys and URLs**, symmetric and asymmetric. A shape in terms of `member`/`role`/`permission` is therefore exactly the **wrong shape** that this brief exists to prevent.
**The methodological lesson, which is worth more than the dropped section**: reading NextGraph's current state in order to **deduce** the target shape is a mistake — the current state contains unfinished work that must not be frozen into the polyfill. The source serves to verify an existing **mechanism**, never to infer an **intent**.
Erroneous content kept below as a record:
<details> <details>
<summary>Section retirée</summary> <summary>Dropped section</summary>
**Pourquoi c'est ici et pas ailleurs.** Le brief était d'abord centré ReadCap ; un **Why this is here and not elsewhere.** The brief was at first ReadCap-centric; an adversarial finding showed that it **does not compose** with its consumer: the Festipod brief on "Set-based sign-ups" needs to **deduplicate** participations (one user = one participation per event), and the only non-application-level basis available is the commit's **author signature** — hence a **write** primitive, not a read one. A polyfill that only exposes the ReadCap shape leaves the consumer to invent its own application-level dedup → exactly the wrong shape.
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 **The real shape (VERIFIED, cf. `readcap-and-nuri-model.md` §1)**and it is **asymmetric** with reading, which is the easiest point to miss:
**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. - **Reading = possession of a key.** No ACL. Whoever holds, reads.
- **Écriture = membership + permissions** (`AddMember`, `AddPermission` sur le - **Writing = membership + permissions** (`AddMember`, `AddPermission` on the `RootBranch`). It really **is an authorization list** — not possession. Emulating writing "by token possession" would be just as wrong as the current read ACL, in mirror image.
`RootBranch`). C'est **bel et bien une liste d'autorisation** — pas de la - **Commits ARE signed** by a `UserId` (a **technical** key, distinct from the profile) — so a dedup identifier **exists** natively, with no application-level pseudonym.
possession. Émuler l'écriture « par possession de token » serait aussi faux que - **But verifying a signature requires being a member of the repo** (access to the `member_pubkey`). A non-member third party sees a signed commit without being able to attribute it.
l'ACL de lecture actuelle, en miroir. - **The inbox drop is NOT authenticated** (anonymous sealed box): a declared `from` is content, not proof.
- **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** **What that imposes on the polyfill.** Expose `membership` as a primitive **distinct** from cap possession, with at minimum: adding/removing a member of a repo, reading the members map **when one is a member**, and **verifying the author of a commit** (→ an author digest, **per-overlay hence per-store**). It is this last point that unblocks the dedup on the Festipod side.
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 **The shape consequence, to be documented explicitly** (otherwise the consumer picks the wrong model): the author digest being **per-store**, the choice of how stores are carved up **is** the choice of the correlation level. A **per-user stable** store gives an identifier traceable **across events**; a **per-event** store gives a pseudonym **local to the event** — dedup possible, correlation impossible. Festipod needs the second. So the polyfill must make this carving **expressible**, not freeze it.
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 **Still open**: "can the creator of an event be a member of the store that contains the participations, without holding its read key?" — that is, membership (writing/verification) and possession (reading) genuinely **orthogonal**. If NextGraph couples them, verified dedup and anonymity are mutually exclusive, and it is the Festipod brief that must settle what it sacrifices. **To be verified before shaping the API.**
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> </details>
*(Question devenue sans objet : il n'y a pas de membership. La dédup ne passe pas *(Question now moot: there is no membership. The dedup does not go through signature verification — see the Festipod brief on "sign-ups".)*
par une vérification de signature — voir le brief Festipod « inscriptions ».)*
## Questions ouvertes ## Open questions
- **TRANCHÉ (directive PO, 2026-07-21)** : on **simule la crypto** (donnée chiffrée - **SETTLED (PO directive, 2026-07-21)**: we **simulate the crypto** (per-doc encrypted data, cap = key). "Semantics only" (a token registry) is **discarded** — it turns back into an ACL and lets the plaintext be read. What remains to settle is the **level** of simulation (real lightweight encryption vs masked read-model projection), **before P1**.
par-doc, cap = clé). La « sémantique seule » (registre de tokens) est **écartée** - NURI representation, cap-less vs cap-bearing, in the emulation (mirror `:k:`).
elle redevient une ACL et laisse lire le clair. Reste à trancher le **niveau** de - ~~Should **keyless fetch** be allowed (resolving a cap-less into existence/count without the content)~~ — **SETTLED, and negatively (2026-07-27)**: not constructible. Addressing itself presupposes the cap, so there is nothing to expose. See the corrected Q1 verdict below. Kept struck through rather than deleted: the hypothesis is intuitive and will otherwise be re-formed.
simulation (chiffrement réel léger vs projection read-model masquée), **avant P1**. - API migration: `declareConnections`/`grantRead``seal(cap, recipient)` + `inbox → received caps`. Breaks consumers (the app-side `declareConnections` disappears).
- 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) ## P0 — "keyless-resolve" spike (the blocker, BEFORE any 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** : **Load-bearing question**: can a holder of a **cap-less reference** (`did:ng:o:{id}:v:{overlay}`, without `:k:`), **without ever reading the content**:
- **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 ? - **Q1 — Existence / fetch**: prove/retrieve the presence of the (encrypted) blocks from the broker? Or does the broker require a ReadCap/membership in order to serve the blocks?
- **Q2 — 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.)* - **Q2 — Deletion**: distinguish "exists" from "deleted"? *(The FRAGILE point: NextGraph is an append-only CRDT — a withdrawal = a **tombstone commit** that one would have to **read** in order to know about → potentially **the key is required**. And the **decrement on leave** depends on it.)*
- **Q3 — Confidentialité** : la clé (`:k:`) reste-t-elle **requise** pour déchiffrer (le keyless ne donne jamais le contenu) ? - **Q3 — Confidentiality**: does the key (`:k:`) remain **required** in order to decrypt (keyless never gives the content)?
**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. **Why this is the blocker**: the whole **anonymous counter** (counting/validating cap-less refs without reading) AND the **decrement on leave** depend on it. **If NO**"anonymous counter via cap-less ref" is **not constructible in the target** → Festipod must **not** code that shape (guaranteed rewrite). **If YES** → P1 exposes `resolveCapLess(nuri) → {exists|deleted}` (never any content), and the emulation simulates it faithfully.
**Méthode** (bon marché, décisif) : **Method** (cheap, decisive):
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é ? 1. **Trace** in `nextgraph-rs` the broker/verifier **fetch authorization** path: who serves the blocks (`BlocksGet`/`TopicSync`/`OverlaySync`)? is a cap/membership checked, or is `id+overlay` enough? is the *outer* overlay public? is a deletion observable without the key?
2. *(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. 2. *(Optional)* **decisive e2e test** (in the style of `e2e/reactivity-doc-subscribe.ts`): B holds the cap-less ref, attempts fetch/existence **without** the key, verifies that it **does not reach** the content. Empirical proof > source.
3. *(Ou)* confirmer avec le dev NextGraph — le plus rapide. 3. *(Or)* confirm with the NextGraph dev — the fastest.
**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). **Deliverable**: YES/NO/PARTIAL per Q1/Q2/Q3 + the exact primitive (file:line) + the API shape to expose (if YES), or the finding that the counter changes (if NO).
**Décision gatée** : OUI → P1 ; NONle brief inscriptions revoit le compteur (pas anonyme, ou autre primitif). **Gated decision**: YES → P1; NO → the sign-ups brief revisits the counter (not anonymous, or another primitive).
### Verdict du spike (2026-07-21) — VÉRIFIÉ dans `nextgraph-rs` ### Spike verdict (2026-07-21) — VERIFIED in `nextgraph-rs`
| | Réponse | Preuve | | | Answer | Evidence |
|---|---|---| |---|---|---|
| **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**. | | **Q1 — existence/fetch without a cap** | **NO** *(corrected on 2026-07-27 — the initial "partial YES" verdict over-read the evidence)* | Read access control does indeed let you through (reads are not cap-gated) — **but addressing presupposes the cap**: no existence command at the SDK level; the only probe is internal to the crate, requires `BlockId`s **and** a loaded repo, and targets the **inner** overlay derived from the read secret. A cap-less reference has neither `BlockId` nor the required overlay. See `readcap-and-nuri-model.md`. |
| **Q2 — 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 ». | | **Q2 — detect a deletion without the key** | **NO** | Append-only broker; a deletion is an **encrypted tombstone commit** (`RemoveRepo`), a no-op on the verifier side. Without the key one observes "some activity", never "a deletion". |
| **Q3 — 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. | | **Q3 — confidentiality** | **YES** | Blocks stored as ciphertext; the key is `#[serde(skip)]` (`types.rs`), derived from the `ReadCapSecret`. Keyless **never** gives the content. |
**Ce que ça décide.** **What that decides.**
- **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). - **P1 is unblocked**: `resolveCapLess(nuri) → { exists }` is the right shape — but **`{ exists | deleted }` is NOT**. Do not expose a `deleted` state; that would be inventing a capability the target will never have (precisely the failure mode this brief fights).
- **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. - **Withdrawal has to be a message, not an observation.** On the consumer side: an explicit *nudge*. The polyfill has **nothing** to emulate for that — it just must not pretend otherwise.
- **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. - **Settled by the Q1 correction**: the anonymous counter can**not** rest on an existence validation — that is not constructible. So it rests on something **declarative**, which is acceptable (outside the security scope) as long as the **exposed shape does not lie**: do not expose an existence primitive that the target will not offer.
## Esquisse de phases ## P1a — the surface
- **P1** — distinction cap-less / cap-porteur dans les NURI + le read-model **Extracted into its own brief: [`2026-07-27-p1a-cap-surface.md`](2026-07-27-p1a-cap-surface.md).**
(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 P1P3 ; **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 This batch is **specified and ready to implement**; it has its own note so that one can code from it without wading through the retracted material of this document.
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) : In two lines: a single new type (`ReadCap`), a keyring (`capFor`), a per-document share to an inbox (`shareCap`) — and nothing else. The branded types, `resolveCapLess`, `receivedCaps`, `refOf`, `parseNuri` and `PrincipalId` were **discarded** after a double adversarial review; the reasons are in that note.
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**. This brief remains the **overall effort**: P0 verdicts, P1b scope, P2P4 batches, and the adversarial reviews.
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). ## Phase sketch
Liens : `readcap-and-nuri-model.md`, `packages/client/src/caps.ts`. Côté consommateur, - **P1a** — **the surface**: one new type (`ReadCap`), a keyring (`capFor`), per-document sharing to an inbox (`shareCap`). **The only batch that blocks Festipod.** Specified in its own note: [`2026-07-27-p1a-cap-surface.md`](2026-07-27-p1a-cap-surface.md). *(An earlier draft listed `DocRef`/`DocCap` branded types, `resolveCapLess` and a durable `sealCapTo` here — all three were **dropped** after adversarial review; the note says why.)*
le brief Festipod « réaligner les inscriptions » dépend de ce chantier. - **P1b** — **the enforcement**: per-doc encryption (cap = key) and closing out the inventory of bypasses. Without it the shape is right but the isolation remains false — so nothing "anonymous" can be claimed.
- **P2** — replace the ACL with a **token possession** model (grant = deliver to a recipient; enforcement = possession). *Requalified by the adversarial review: the real content of P2 is **durability + cap-less + re-sharing by the holder**, not "inverting the ACL" — without crypto, inverting produces no observable delta.*
- **P3** — revocation by re-key (invalidation + re-delivery, non-retroactive).
- ~~**PW** — WriteCap = membership~~ **DROPPED (2026-07-27)**. This batch rested on a notion of membership that **does not exist** in the model (everything is keys and URLs); see the struck-through section above. It was moreover justified by a need for **dedup via signature verification** that the consumer turns out not to have: its dedup rests on the overlay, which is store-scoped. *For the record, two facts verified along the way, not to be re-discovered*: author signature verification **is not called at runtime**; and the author digest is **not** keyed under the read secret — it is keyed by the **outer** overlay, which is public *(it is the commit's **content** that is encrypted, hence the fact that verifying still presupposes being able to read)*. Detail in `nextgraph-current-state.md`.
- **P4** — adapt the consumer API + `migration-guide.md`. *The adversarial review requalifies this batch: it is not an API swap but a **consumer re-architecture** (the grant moves to connection acceptance and becomes persistent; `declareConnections` disappears).*
## Adversarial review (2026-07-20) — to be integrated
An adversary refuted the brief (7 findings — the 7th marked *(Plausible)*). **To be read through the filter of the Objective above** (shape, not security). The purely **security** criticisms — readable plaintext content (#4), forgeable markers — are **ACCEPTED / out of scope**: the polyfill does not seek to prevent them. What remains are the real **SHAPE / rigor** defects (to be fixed), and a question of **future model** (#5):
1. **WriteCap forgotten, and "possession" is FALSE there.** Writing is **membership/permissions** (`AddMember`) — an **authorization list**, not key possession (ref. §1); `ng-proxy.ts:28-48` guards every `sparql_update`. → keep a **WriteCap = membership track**; **possession concerns ONLY reading**.
2. **P2 "possession without crypto" = the ACL renamed.** Without crypto, "who holds which token" = `Map<doc, Set<holder>>` = the current `readers`: **no observable delta**. The real deltas are **durability + cap-less + re-sharing by the holder** — THAT is the content of P2, not "inverting the ACL".
3. **Non-retroactive revocation NOT EMULABLE** without versioning: `read-model.ts:112-118` only reads the current state → "invalidate the old token" = total removal = the inverse of the real thing (the former holder decrypts the **prior** versions). → emulate only "no new reads after re-key" + **document non-retroactivity as non-emulable**.
4. **cap-less "without exposing the content" ILLUSORY in the emulation**: content in **plaintext** in the shared wallet; `sparqlQuery`/`inbox.read` **bypass** the filter; `read-filter.ts:30-35` is all-or-nothing. → cap-less anonymity requires either **real crypto** or a **masked read-model projection** (counting without reading). "Replacement not overhaul" is **overstated**.
5. **Keyless-fetch = INFERRED and load-bearing**: add a **P0 spike** that verifies it **before** P1 (otherwise the model — polyfill AND Festipod — is not constructible).
6. **Migration ≠ API swap.** `declareConnections` is replayed every session because the map is ephemeral; durable seals move the grant to **connection acceptance** + persist "already sealed" — no analogue of `protectedDocsOf` + the re-derivation loop. **Consumer re-architecture.**
7. *(Plausible)* delivering a cap through the async inbox **does not re-trigger** `watchShape` (which subscribes to data docs, not to caps) → unreadable views left **stale** until another change. → plan for a cap-mutation signal.
**Consequence**: add **P0 (keyless-fetch spike)** up front and a **distinct WriteCap track**; requalify P2 (the real content = durability + cap-less + re-sharing, not "inverting the ACL"); record that **without crypto, read privacy is not applicable** (choose: real crypto vs masked projection).
Links: `readcap-and-nuri-model.md`, `packages/client/src/caps.ts`. On the consumer side, the Festipod brief "realign the sign-ups" depends on this effort.
+129
View File
@@ -0,0 +1,129 @@
# Brief — P1a: the capability surface
**Status: specified, ready to implement.** Extracted from `2026-07-20-caps-emulation-alignment.md` (which remains the wider chantier: P0 findings, P1b enforcement, P2P4, and the adversarial reviews). This file is the actionable lot; read it alone to implement.
Written 2026-07-27, after two adversarial reviews and three corrections from the PO. Background: `../vision.md` (why this library exists), `../readcap-and-nuri-model.md` (the target model, verified against `nextgraph-rs`).
## Why this lot exists
`caps.ts` currently models read rights as an **ACL** — a `Map<doc, Set<principal>>` plus `grantRead(doc, grantee)`. That is the **exact inversion** of the real model, where reading is **key possession**: whoever holds the key reads, and there is no authorization list anywhere.
This is not a security problem — the library is deliberately insecure and that is accepted (see `../vision.md`). It is a **shape** problem, and shape is the only thing this library exists to get right. A consumer coded against an ACL is coded against a model that will never exist, and will have to be rewritten.
## Scope: shape only, not enforcement
- **P1a (this brief)** — the surface consumers see.
- **P1b (separate)** — per-doc encryption and closing the read paths that bypass the guard.
Only P1a blocks the consumer, because the consumer must be written as if NextGraph were finished. P1b can follow.
> **After P1a the shape is right and the isolation is still fake.** Nothing may be claimed as "anonymous" or "private" until P1b lands. Say so in the README if it helps.
## Guiding constraint: stay close to NextGraph's concepts
Stated by the PO, and it is the acceptance criterion for the design as much as for the code:
> Stay as close as possible to NextGraph's concepts — and to its SDK's — to keep development simple and to keep the number of notions someone must discover small when they already know NextGraph and open this library.
Every invented name is **vocabulary debt**: the reader has to carry a translation table in their head. The first draft of this spec introduced eight new notions; adversarial review reduced it to two. Hold that line.
## The design
### 1. Types — one new name
A NURI is **one object**, with or without the key inside — upstream, `NuriV0 { target, access }`, where a cap-less NURI simply has an empty `access`. `did:ng:` is the **URI scheme prefix**, present on inboxes, branches and overlays alike; it does not mean "without cap". The discriminant is the **`:k:` segment**.
```ts
type Nuri = string // did:ng:o:{doc}:v:{overlay} — names, does not read
type ReadCap = string // …:k:{key} — names AND reads
```
`Nuri` **keeps its current meaning** in this package (~90 call sites, untouched): the cap-less form. `ReadCap` is the upstream name — do not invent another.
A parsed form `{ target, readCap? }` — a 1:1 mirror of `NuriV0 { target, access }` — may be used **inside** the library. It must not surface in the SDK-identical entry's signatures.
**Do not use branded types.** They were in the first draft and were dropped deliberately: the real SDK takes `nuri: String` and enforces at **runtime, through cryptography**. A compile-time guarantee is a concept NextGraph does not have, and a consumer who typed everything would have to *un-type* it when the real SDK arrives — the opposite of the goal. The cost was also measured: branded types force a cast at every ORM and SPARQL boundary.
### 2. The keyring — where caps come from
`doc_create` returns a **cap-less** NURI. So a rule like "no function ever goes from a bare reference to a cap" is wrong: it would leave a document's own creator unable to obtain that document's cap.
The real mechanism: on every document creation, an `AddRepo { read_cap }` is committed to a **branch of the store** (the store is itself a repo, with typed branches — "branch" here has nothing to do with git). That branch lists the store's documents, each with its read key. **It is the owner's keyring.** Upstream, the keyring is the **wallet**.
```ts
capFor(nuri: Nuri): ReadCap | undefined
```
The invariant, correctly stated:
> **You do not derive a cap from a bare reference. You look it up in your keyring — or you were given it.**
`capFor` absorbs `canRead(doc)` (`capFor(n) !== undefined`) and drops its ACL verb.
**The keyring is not the sharing mechanism.** Handing over a store cap would give away everything the store contains, present and future. That is not the gesture (see §3). This confusion is easy and expensive — it was made once already during design.
### 3. Sharing — one document, to one or more recipients
**The unit of sharing is the document**, consistent with the consumer's own doctrine ("the document is the unit of sharing and of rights").
```ts
shareCap(cap: ReadCap, toInbox: Nuri): Promise<void>
```
Recipients are addressed as **inboxes** — which `inbox.post(targetInbox: Nuri)` already does in this package. There is no `PrincipalId` here: that notion exists nowhere upstream, and the first draft removed `principal` from `canRead` (calling it the ACL inversion) only to reintroduce it here.
**Caps received need no dedicated operation.** They arrive as inbox deposits of kind `cap`, consumed by the **existing** `inbox.watch`. This also fixes a known gap: a cap delivered asynchronously now triggers a re-read naturally, instead of leaving stale views.
> **Upstream status: this is a GAP, not a disagreement.** The field exists (`ContactDetails.read_cap`, commented "*if user wants to share the content of profile*") but the message construction is `unimplemented!()`, its only caller passes "without read_cap", and the receiver **discards** the cap. The shape is right; the implementation is absent. We emulate it meanwhile — filed as `orm-tests/INBOX/2026-07-27-inbox-cap-delivery-not-implemented.md`, including what to remove from this library once upstream lands it.
### 4. Key rotation — automatic redelivery, not loss of access
When a key is rotated, the new one is **sent to the inbox** of users who keep access, and that inbox is **processed automatically** as soon as one of the user's clients connects.
So access is not lost, it is **deferred** until the next connection — consistent with local-first. Consequences for the surface:
- **No subscription obligation to expose.** The consumer implements nothing to "keep" an access.
- Redelivery uses **the same channel** as the initial delivery, so §3 covers both with no special case.
- **Revocation** stays what it is: stop redelivering, non-retroactive.
> An earlier draft said the opposite ("whoever does not stay subscribed loses access"). That came from an upstream comment describing the **current state**, read as if it gave the **intention**. It does not. Source verifies a mechanism; it never states a direction.
### 5. Public content — readable by URL, and NOT recursive
> **An item in the public store is public: whoever has the URL reads the content.** But **not recursively** — public content may *reference* private content, and the reference does not grant access to what it references.
This is a **second mechanism** alongside key possession, not an exception to it. The non-recursiveness is what carries the value: it allows a public object that **points at** private identity without disclosing it — exactly the pattern the consumer needs.
*Implementation detail the shape must not depend on*: NextGraph is moving toward **not encrypting** public store content (data still signed). And if the public store does not behave as this principle describes, **this library adapts** — not the consumer.
### 6. What disappears or is renamed
| Today | Becomes |
|---|---|
| `grantRead(doc, grantee)` | `shareCap(cap, toInbox)` |
| `canRead(doc, principal)` | absorbed by `capFor(nuri)` — the `principal` parameter **was** the ACL inversion |
| `protectedDocsOf(owner)` | **removed** — the re-derivation loop disappears |
| `makePublic(doc)` | `publishRepoLink` — the shareable link has an upstream name (`RepoLinkV0`) |
| `grantWrite` / `canWrite` | deferred to P1b — currently **decorative** (the guard never fires) |
| `resetCaps()` on identity change | **switch** keyrings, do **not** wipe |
| `PrincipalId` in the cap surface | **removed** — recipients are inboxes |
`resetCaps()` is the trap that can make this lot look finished while it is not: if switching identity still wipes, durability is a lie and the per-session re-declaration comes back under another name.
### 7. Boundary: SDK-identical entry vs `/polyfill`
Caps live under `/polyfill` today; `index.ts` is the SDK-identical entry. Keep it that way, and keep `index.ts` signatures on plain strings — that **is** what the real SDK does. The discrimination lives in what you can **obtain** (the keyring), not in what the compiler permits.
### 8. Acceptance test — no cryptography required
`watch-shape` currently harvests **every** `did:ng:` string it finds in a discovery reference and folds those documents into the **read** set. A bare reference therefore grants **full read** today — the semantics exactly inverted.
After P1a: a harvested bare reference yields **nothing**, for want of a cap in the keyring — which is what real NextGraph does. The test holds without a line of encryption, which is what makes the P1a/P1b split honest rather than cosmetic.
## Consumer impact
`declareConnections` **disappears**. This is not an API swap: today it re-declares every grant on every session because the ACL is in-memory. With delivered caps, the grant moves to the moment a connection is **accepted**, and persists. Plan for consumer re-architecture, and update `../migration-guide.md`.
## What this lot does NOT do
Closing the read paths that bypass the guard — `docs.sparqlQuery`/`sparqlUpdate`, the whole inbox, `store-registry`, `discovery.readIndex`, `subscribe`, `open-repo`. Only four sites consult caps today. That inventory is P1b's scope and is listed in `2026-07-20-caps-emulation-alignment.md`.
@@ -1,57 +1,57 @@
# Perte d'écriture lors d'une mort de socket (`SerializationError`) # Write loss on socket death (`SerializationError`)
**Post-mortem — 2026-07-14 · Statut : OUVERT (non traité).** **Post-mortem — 2026-07-14 · Status: OPEN (not addressed).**
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. An entity written just before a period of inactivity can be **silently lost**: it is absent on reconnection. *(Whether the write never durably reached the broker, or reached it and is not read back on a cold reconnection, is **not settled** — see Epistemic caveat below. The wording here deliberately states only the observed symptom.)* The **account / identity survives** (no fork). Observed in real conditions (Festipod, Firefox) during a pause after login/creation.
## Symptôme ## Symptom
1. L'utilisateur se connecte, l'app crée une entité (un événement Festipod). 1. The user logs in, the app creates an entity (a Festipod event).
2. Une période d'inactivité suit (idle, onglet en arrière-plan…). 2. A period of inactivity follows (idle, tab in the background…).
3. Le socket broker meurt spontanément avec `SOCKET IS CLOSED Some(Left(SerializationError))`. 3. The broker socket dies spontaneously with `SOCKET IS CLOSED Some(Left(SerializationError))`.
4. À la reconnexion, l'entité créée a disparu ; l'app relit son propre scope **vide**. 4. On reconnection, the created entity has disappeared; the app reads back its own scope **empty**.
## Preuves (VÉRIFIÉ — logs Firefox en direct, verbatim) ## Evidence (VERIFIED — live Firefox logs, verbatim)
``` ```
… REPLAY TOPIC NOT FOUND <topic> IN OVERLAY <overlay> … REPLAY TOPIC NOT FOUND <topic> IN OVERLAY <overlay>
… NEED REPLAY true … NEED REPLAY true
… SENDING EVENTS FROM OUTBOX RETURNED: Err(TopicNotFound) … SENDING EVENTS FROM OUTBOX RETURNED: Err(TopicNotFound)
[user1][polyfill] resolveAccount(user1) → 1 record ← le compte SURVIT (pas de fork) [user1][polyfill] resolveAccount(user1) → 1 record ← the account SURVIVES (no fork)
[user1][polyfill] readScopeIndex(…) → 0 entities ← mais le scope est VIDE [user1][polyfill] readScopeIndex(…) → 0 entities ← but the scope is EMPTY
… set reçu: 0 objets Event (public) … set reçu: 0 objets Event (public)
… SOCKET IS CLOSED Some(Left(SerializationError)) [51, 3, 223, …] … 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é. Interpretation (**plausible mechanism, not settled**): the write was pushed into the local **outbox**, but the socket died before it was **durably flushed** into the broker topic; on reconnection, the outbox replay fails (`Err(TopicNotFound)`) because the topic was **never created on the broker side**the event is abandoned. The account, for its part, had already been durably resolved (`resolveAccount → 1 record`): it is neither lost nor forked.
> **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). > **Epistemic caveat.** The evidence establishes the *symptom* (loss + `Err(TopicNotFound)` + `readScopeIndex → 0`). The exact *mechanism* is not settled between **(i) loss at write time** (the write never durably reaches the broker) and **(ii) cold-rehydration failure** (the write *is* on the broker but a fresh session does not reopen its own scope). The `Err(TopicNotFound)` on the outbox replay leans toward **(i) in this Firefox case**. See the @data repro below, which exhibits a neighboring symptom but **does not settle** (i) vs (ii).
## Chaîne causale (TRACÉlecture du core NextGraph, à re-vérifier) ## Causal chain (TRACEDreading of the NextGraph core, to be re-verified)
- La `SerializationError` ferme le socket. Le core émet la déconnexion : `broker.rs``LocalBrokerMessage::Disconnected``disconnections_sender.send(...)` (≈ `broker.rs:1051`, à re-vérifiernuméro volatil, se repérer par le symbole). - The `SerializationError` closes the socket. The core emits the disconnection: `broker.rs``LocalBrokerMessage::Disconnected``disconnections_sender.send(...)` (≈ `broker.rs:1051`, to be re-verifiedvolatile number, navigate by symbol).
- Cette déconnexion est **poussée** aux abonnés via `disconnections_subscribe(cb)` (flux PUSH). - This disconnection is **pushed** to subscribers via `disconnections_subscribe(cb)` (PUSH stream).
- **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. - **NextGraph reconnection is an unimplemented `// TODO`** (≈ `broker.rs:1051-1076`): nothing re-establishes the socket nor re-flushes the 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. - `user_connect` returns a **snapshot** `{ server_id, server_ip, error, since }` at call time — not a stream, unusable for detecting a later drop.
- **Aucune API de confirmation de durabilité d'écriture** : un appelant ne peut pas `await` la garantie qu'une écriture a atteint le broker. - **No write-durability confirmation API**: a caller cannot `await` the guarantee that a write has reached the broker.
## Ce que le SDK expose mais ne consomme pas ## What the SDK exposes but does not consume
`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. `disconnections_subscribe` **does fire** on this failure — but neither the polyfill (`@ng-eventually/client`) nor the consumer app subscribes to it. The signal exists, nobody listens to it; on the app side, no mechanism retries or warns the user.
## Portée & non-reproduit ## Scope & not reproduced
- **Observé Firefox uniquement** à ce jour. Un test manuel sur un autre navigateur n'a pas déclenché la `SerializationError` ni ses conséquences. - **Observed on Firefox only** to date. A manual test on another browser did not trigger the `SerializationError` nor its consequences.
- **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`, **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). - **@data reproduction (Chromium, real broker) — 2026-07-14, decisive.** The existing @data reconnection test (`reconnexion-meme-identite`) was a **false green**: it read A's repos back from the persistent profile's **local IndexedDB**, never from the broker. A **genuinely cold** reader (non-persistent `freshBrowser` context, the **same** wallet/account A, no local state — seeded from the wallet captured before the event) reads **0** events from A (`BARRIER timed-out (8000ms)`, `CONNECTION ESTABLISHED`). A **different** signature from the Firefox case (no socket death; the `OUTBOX empty` is the reader's, trivially empty) and it **does not settle** (i) vs (ii) — an empty barrier is compatible with both. Established on the other hand: **@data has never verified the broker durability of A's own reads**, and cold rehydration from the broker fails. Repro: `src/modules/event/features/reconnexion-froide-sans-local.feature` (Festipod).
- **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)**. - **To settle (i) vs (ii)**: independently verify that A's write reaches the broker — e.g. a *warm* reader / a second identity reads the event's public doc (the two-identity isolation scenario). If it sees it → the write is durable → the cold reader's 0 is a **(ii)** (rehydration). Otherwise**(i)**.
## Pistes de correction (non arbitré) ## Fix leads (not arbitrated)
1. **Core**corriger la `SerializationError` **et** implémenter le TODO de reconnexion (rétablir le socket + re-flusher l'outbox). 1. **Core**fix the `SerializationError` **and** implement the reconnection TODO (re-establish the socket + re-flush the outbox).
2. **SDK / polyfill** — consommer `disconnections_subscribe` → reconnexion + re-flush outbox comme mitigation, indépendamment du core. 2. **SDK / polyfill** — consume `disconnections_subscribe` → reconnection + outbox re-flush as a mitigation, independently of the core.
3. **API de durabilité** — exposer une confirmation qu'une écriture a atteint le broker, pour que l'appelant puisse l'`await`. 3. **Durability API** — expose a confirmation that a write has reached the broker, so that the caller can `await` it.
## Liens ## Links
- `docs/nextgraph-current-state.md`état courant du core (déconnexion / reconnexion à cross-référencer ici). - `docs/nextgraph-current-state.md`current state of the core (disconnection / reconnection to be cross-referenced here).
- Impact produit + caveat côté consommateur : concept Festipod `data-layer``caveat_write-durability-across-disconnect`. - Product impact + consumer-side caveat: Festipod concept `data-layer``caveat_write-durability-across-disconnect`.
+218
View File
@@ -137,6 +137,32 @@ A related exposed primitive: `social_query_start` (a federated query via inbox u
`degree` hops) exists but is limited to contacts — it does not cover an anonymous `degree` hops) exists but is limited to contacts — it does not cover an anonymous
notification to a non-connected host. notification to a non-connected host.
### Delivering a ReadCap through the inbox — the field exists, the path does NOT — VERIFIED
The `ContactDetails` inbox message carries `read_cap: Option<ReadCap>`, commented
*"optional readcap on the profile, if user wants to share the content of profile"*
(`engine/net/src/types.rs`). Nothing behind that field is implemented:
- **Building it panics.** `InboxPost::new_contact_details(…, with_readcap: bool, …)`
(`engine/net/src/types.rs`) fills `read_cap` with `unimplemented!()` when
`with_readcap` is true, and `None` otherwise. Asking for a cap in the message is a
panic, not a feature.
- **Nobody asks for one.** Its ONLY caller is the `QrCodeProfileImport` path in
`engine/verifier/src/request_processor.rs`
(`post_to_inbox(InboxPost::new_contact_details(…))`), which passes `with_readcap =
false`. No message ever carries a cap.
- **The receiver discards it.** The `InboxMsgContent::ContactDetails(details)` arm of
`engine/verifier/src/inbox_processor.rs` reads `details.profile`, `details.name` and
`details.email` to build a `social:contact` document — it **never reads
`details.read_cap`**. Even a hand-crafted message carrying a cap would be dropped.
**Consequence for this lib:** there is no native channel to HAND a key to somebody. The
inbox transports an identity/profile pointer, not a read capability. Combined with
§ *The inbox is not usable from the JS SDK* (no `InboxPost` arm in the request processor
at all), cap delivery must be emulated end to end: the polyfill's emulated inbox and its
`CapRegistry` are not a shortcut around an existing mechanism, they stand in for a
mechanism that does not exist.
## The query capability — ONE local store, named graphs, union queries ## The query capability — ONE local store, named graphs, union queries
The single fact that makes read-time *listing* possible on the shared wallet, and The single fact that makes read-time *listing* possible on the shared wallet, and
@@ -471,6 +497,117 @@ redirect afterwards. This lib's identity store sidesteps all of it — the ident
id is set at wallet-import time and relayed to the lib, without a separate login; id is set at wallet-import time and relayed to the lib, without a separate login;
see the identity store in [`simulation.md`](./simulation.md). see the identity store in [`simulation.md`](./simulation.md).
## Authorship, existence, outer overlay, `Ext` (section added 2026-07-27)
Four capability facts about the current core, verified in `nextgraph-rs`. They bear on
what can be BUILT on top (can we deliver a key? can we tell whether a document exists?
can we attribute a write?) — they are not a security assessment. Each carries its
epistemic status; do not upgrade an INFERRED item without new evidence.
### Author-signature verification is never called at runtime — VERIFIED
`Commit::verify` (`engine/repo/src/commit.rs`) chains `verify_sig``verify_perm`
`verify_full_object_refs_of_branch_at_commit`. Its only callers in the whole tree are
inside `#[cfg(test)] mod test` blocks (`engine/repo/src/commit.rs`,
`engine/repo/src/branch.rs`); `verify_sig` and `verify_perm` have no other caller. The
verifier's commit path calls a **different** `verify`:
`CommitBodyV0::<Body>::verify(commit, self, branch_id, repo_id, store)` in
`engine/verifier/src/verifier.rs` — the `CommitVerifier` trait, which APPLIES a body
(mutating verifier state); it is not a signature check.
Even if it were called it could not succeed. `verify_sig` resolves the author through
`Repo::member_pubkey``Repo.members`, and every `Repo` the verifier builds at runtime
sets `members: HashMap::new()``engine/verifier/src/user_storage/repo.rs` (with a
literal `//TODO: members`) and `engine/verifier/src/commits/mod.rs`. Only
`Repo::new_with_member` ever populates a member, and it is called only from tests. An
empty table makes `member_pubkey` return `NotFound`
`CommitVerifyError::PermissionDenied`.
Reading authorship at all presupposes the read cap (VERIFIED): the author field is not a
UserId but `CommitContent::author_digest(user, overlay)`, a BLAKE3 keyed hash, and the
commit content sits in blocks ChaCha20-encrypted under `Object::convergence_key(store)`
(`engine/repo/src/object.rs`), whose key material is the store id **plus the
store-overlay-branch ReadCapSecret**. No read cap → the author field is not even
visible. *Nuance, VERIFIED:* the digest's own hashing key derives from
`overlay_id_for_read_purpose`, which for Public/Protected/Private/Group stores is
`OverlayId::outer(store_id)` — public. What is secret is the commit content, not the
hash key.
**Consequence for this lib:** "who wrote this triple" is unanswerable today — neither
cryptographically (nothing verifies) nor by identity (the digest is opaque without a
member table). Any authorship or provenance the polyfill needs must be carried in the
DATA it writes and re-read from there; an "authored by X" claim in the emulation has no
core check behind it.
### No existence probe at SDK level — addressing presupposes the cap — VERIFIED
`AppRequestCommandV0` (`engine/net/src/app_protocol.rs`) contains no existence command:
`Fetch`, `Pin`, `UnPin`, `Delete`, `Create`, `FileGet`, `FilePut`, `Header`, `InboxPost`,
`SocialQueryStart`, `SocialQueryCancel`, `QrCodeProfile`, `QrCodeProfileImport`,
`OrmStartGraph`, `OrmStartDiscrete`, `OrmGraphUpdate`, `OrmDiscreteUpdate`, `OrmStop`.
Nothing answers *"does document D exist?"*.
The single probe in the tree is internal and cannot answer it either:
`Verifier::has_blocks` (`engine/verifier/src/verifier.rs`) sends
`BlocksExist { blocks, overlay }`. It is `pub(crate)` (never reaches JS); it takes
**`BlockId`s** — content addresses you only hold if you already read the object; it takes
a **`&Repo` already loaded**; and it targets
`repo.store.overlay_for_read_on_client_protocol()` = the **inner** overlay
(`Store::inner_overlay``overlay_id_for_write_purpose(store_overlay_branch_readcap.key)`,
`engine/repo/src/store.rs`), derived from the read-cap secret.
**Consequence for this lib:** you cannot prove — nor disprove — the existence of a
document whose key you do not hold. **Addressing presupposes the cap.** Every "is it
there?" question therefore collapses into "can I read it?", which is why absence is only
ever established behind a sync barrier (see § *Findable-without-lookup vs subscribable*)
and never by probing.
### `expose_outer` is hard-coded to `false` — VERIFIED
Both constructors of `PinRepo``PinRepo::for_branch` and `PinRepo::from_repo`
(`engine/net/src/actors/client/pin_repo.rs`) — set `expose_outer: false`, and they are
the only two `PinRepoV0` constructions in the tree. No parameter carries the flag up:
`expose_outer` appears nowhere under `sdk/`. The broker side is fully wired
(`RepoInfo.expose_outer: HashSet<UserId>` in `engine/broker/src/server_broker.rs`, the
`if expose_outer` branch in `rocksdb_server_storage.rs`, the outer-overlay registration
in `server_storage/core/overlay.rs`), and the `PinRepo` responder even validates the flag
(refusing `expose_outer` from a peer that publishes no topic) — but no client ever sets
it.
**Consequence for this lib:** a store's **outer** overlay is never registered broker-side,
so there is no anonymous / capability-free read surface to build on. Everything is reached
through the inner overlay, i.e. through a read cap — the same cap-first addressing as
above. The "public store readable by everyone without permission" promise in the official
docs has no client-side switch today.
### The `Ext` protocol serves blocks with no control — VERIFIED
The `ExtObjectGetV0` responder (`engine/net/src/actors/ext/get.rs`) builds
`Store::new_from_overlay_id(&req.overlay, …)` from the OverlayId the **requester
declares**, then returns `Object::load_without_header(obj_id, None, &store)` blocks for
each requested id. No authentication, no verification that the requester belongs to that
overlay. The guards that were planned exist but are dead:
- `Authorization::ExtMessage` is matched in `Broker::authorize`
(`engine/net/src/broker.rs`) and returns `AccessDenied` — but **no caller ever passes
it**; the only `authorize` call sites pass `Discover`, `Admin` or `Client`. The
server-side `StartProtocol::Ext` arm in `engine/net/src/connection.rs` goes straight to
`StepReply::Responder`, never through `authorize`.
- the config flag whose comment reads *"are ExtRequest allowed on the server? this
requires the core to be ON."* — `allow_read` in `engine/net/src/types.rs` — is declared
and defaulted to `false`, and **read nowhere**.
- `ExtRequestContentV0::get_actor` handles `WalletGetExport` and `ExtObjectGet` and falls
through to `_ => unimplemented!()` for `ExtTopicSyncReq` — a **panic reachable from an
anonymous peer**. (The commented-out `// Self::ExtTopicSyncReq(a) => a.get_actor(),` on
that arm and the `// TODO inbox requests` in the enum are *direction hints*, labelled as
such — not current behaviour.)
**Consequence for this lib:** `Ext` is not a usable read path in either direction. Blocks
come back **encrypted**, and naming them requires ObjectIds you only have once you can
already read — so it grants no capability we could build on, and confirms the shape of
everything above: confidentiality lives entirely in the keys, and holding no key means
holding no partial access, just none.
## Known open issues (section added 2026-07-18) ## Known open issues (section added 2026-07-18)
Live limitations observed against the current core/SDK, each with its epistemic Live limitations observed against the current core/SDK, each with its epistemic
@@ -517,3 +654,84 @@ cross-browser reactive update works). Verdict pending a live instrumented run.
Full write-up (suspect link, instrumentation, planned polyfill-side fix): Full write-up (suspect link, instrumentation, planned polyfill-side fix):
[`../packages/client/docs/sdk-reference.md`](../packages/client/docs/sdk-reference.md) [`../packages/client/docs/sdk-reference.md`](../packages/client/docs/sdk-reference.md)
§ *Current emulation status*. § *Current emulation status*.
### Cold-start anchored read returns 0 rows instead of an error — symptom VERIFIED, mechanism INFERRED, healed polyfill-side
On a FRESH session over the SAME persistent wallet (reconnect, new page, re-login), an
anchored `sparql_query` against a document written in an earlier session comes back with
**0 rows and no error** — persisted documents read as empty. Observed on every anchored
reader of the polyfill and healed identically in each (`ensureRepoOpen` before the read,
`packages/client/src/open-repo.ts`): the discovery index (`discovery.ts` `readIndex`),
the per-scope index (`store-registry.ts` `readScopeIndex`), the by-need doc batch
(`read-model.ts` `readUnion`), and the store-root pointer read (`store-registry.ts`
`resolvePointer`). The heal is `doc_subscribe(nuri)` → await the first `State` (the sync
barrier) → THEN the anchored read, and it is verified to return the data.
The circularity that made it self-inflicted (VERIFIED by the fix working): `doc_subscribe`
WOULD open the repo, but the reactive layer only subscribes AFTER a listing produced
NURIs, and the listing is itself an anchored read of a not-yet-open index repo → 0 rows →
nothing to subscribe → nothing ever opens.
**Mechanism INFERRED, not established.** `resolve_target_for_sparql(Repo(id))`
(`engine/verifier/src/request_processor.rs`) does
`self.repos.get(repo_id).ok_or(RepoNotFound)`, so a repo genuinely absent from
`self.repos` should ERROR, not return 0 rows. The most plausible reading of the silent 0
is that the repo IS in `self.repos` (loaded from local user storage at bootstrap) while
its named graph in `graph_dataset` is not yet populated — commits not applied/synced yet
— so the query legitimately matches nothing. Not traced end to end; the tension with the
`RepoNotFound` path described in § *A repo is only queryable once OPENED/synced into the
store* is unresolved.
**Consequence for this lib:** a cold anchored read is NOT authoritative on its own — 0
rows does not mean absent. This is what imposes the open-then-read discipline on every
cold reader, and it is why the account trust root had to move behind a first-`State`
barrier (see § *The pointer → doc-shim indirection*).
### Account fork on concurrent provision — symptom VERIFIED, guarded polyfill-side, residue persists in wallets
On a fresh page, several independent callers hit `ensureAccount(A)` near-simultaneously
(the public and protected `watchShape`, container subscriptions, the app's owned-events
effect). When the account is genuinely new, each caller sees 0 and each provisions its
own set of three scope documents — an **in-session account fork**. The persisted residue
is a single account subject carrying MULTIPLE values for one scope predicate (observed:
five `shim:docPublic`), after which a writer and a later reader can resolve DIFFERENT
scope docs and the reader's anchored read returns 0.
Two polyfill-side guards, both in `packages/client/src/store-registry.ts`: `ensureInFlight`
(a bounded promise map keyed by account, so concurrent `ensureAccount` calls share ONE
resolve-or-provision) prevents new forks; `canonicalDoc` (pick the lexicographically
smallest NURI among all distinct values for a scope predicate — NURIs are
content-addressed, so the order is total and session-independent) makes resolution
deterministic on wallets that already carry fork residue. The earlier account-level
`provisionRetry` / `resolveAccountReliably` loop is gone, replaced by the doc-shim
barrier.
**Consequence for this lib:** the underlying enabler is core-side — there is no atomic
create-if-absent, and no existence probe to settle "does this account already exist?"
(see § *No existence probe at SDK level*), so provisioning is a read-then-create race the
polyfill has to serialize itself. The guards are mitigation, not a fix: a wallet already
corrupted stays corrupted, and only `canonicalDoc` keeps it readable.
### Outbox replay aborts on an unknown topic (`REPLAY TOPIC NOT FOUND`) — VERIFIED in core, already documented as an incident
`Verifier::send_outbox` (`engine/verifier/src/verifier.rs`) walks the queued events and,
for each, looks up `self.topics.get(&(overlay, topic_id))`. On a miss it logs
`REPLAY TOPIC NOT FOUND <topic> IN OVERLAY <overlay>` and sets `need_replay`, calls
`load_from_credentials_and_outbox(&events_to_replay)`, then in the send loop does
`self.topics.get(…).ok_or(NgError::TopicNotFound)?` — the `?` **aborts the whole outbox
flush**, so the remaining queued events are not sent. There is no per-event isolation and
no signal to the caller.
Already covered — **not duplicated here**: this is the core-side mechanism behind the
symptom described in § *Write loss on socket death (`SerializationError`)* above, whose
full post-mortem (logs, causal chain, the unarbitrated (i)/(ii) reserve) is
[`incidents/2026-07-14-write-loss-on-disconnect.md`](./incidents/2026-07-14-write-loss-on-disconnect.md).
The spontaneous socket death (`SOCKET IS CLOSED Some(Left(SerializationError))`) is
likewise covered there and in that section — the only fact added here is the abort
semantics of the replay path itself (VERIFIED by reading `send_outbox`).
**Consequence for this lib:** a queued write can be dropped without any observable error,
and one unknown topic can take the rest of the queue with it. The polyfill's own
`outbox-log.ts` records write intents but cannot replay them into the core, and no
write-durability confirmation exists to await — so "the write returned" is not "the write
is durable".
+103 -137
View File
@@ -1,181 +1,147 @@
# Modèle ReadCap & NURI de NextGraph — et l'émulation caps du polyfill # NextGraph's ReadCap & NURI model — and the polyfill's caps emulation
**Établi 2026-07-20**, VÉRIFIÉ par lecture directe du cœur Rust `nextgraph-rs` **Established 2026-07-20**, VERIFIED by direct reading of the `nextgraph-rs` Rust core (except for points marked INFERRED). The `file:line` references are dated — line numbers are volatile, navigate by symbol/regex.
(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 Purpose: to give the ground truth of NextGraph's access-rights model, in order to align the polyfill's `caps.ts` emulation (today an ACL — the inverse of the real model). This is the basis for the item "align ReadCap/WriteCap with NextGraph".
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é ## 1. A ReadCap = possession of a key, NOT a per-identity ACL
Un **ReadCap est fondamentalement une clé cryptographique que l'on détient**, pas A **ReadCap is fundamentally a cryptographic key that one holds**, not an ACL entry tied to a wallet. "Whoever holds the key can read."
une entrée d'ACL liée à un wallet. « Qui détient la clé peut lire. »
- Structure : `ReadCap = ObjectRef = BlockRef { id: BlockId, key: SymKey }` - Structure: `ReadCap = ObjectRef = BlockRef { id: BlockId, key: SymKey }` (`engine/repo/src/types.rs:461, 463-471, 557, 565`).
(`engine/repo/src/types.rs:461, 463-471, 557, 565`). - `id: BlockId` = **BLAKE3** digest (address of the encrypted object).
- `id: BlockId` = digest **BLAKE3** (adresse de l'objet chiffré). - `key: SymKey = ChaCha20Key([u8;32])` = the **decryption key**.
- `key: SymKey = ChaCha20Key([u8;32])` = la **clé de déchiffrement**. Holding the pair → the broker serves the encrypted blocks by `id`, and one decrypts **locally** with `key`.
Détenir le couple → le broker sert les blocs chiffrés par `id`, on déchiffre - Granularity: per commit/object the `ObjectRef` **is** the cap; for a branch → its defining commit; for a repo → RootBranch; for a store → the root repo's cap (`types.rs:559-565`). `ReadCapSecret` = the key half (`:567-570`).
**localement** avec `key`. - **There is NO read-ACL.** A repo's membership/permissions (`RootBranch`, `AddMember`, `AddPermission`) govern **writing/admin**, not reading. Reading is guarded only by key possession.
- 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 ## 2. Granting read access = sealing the key to the recipient
« Grant » = livrer le cap **scellé** (`crypto_box seal`, chiffrement à clé "Grant" = delivering the cap **sealed** (`crypto_box seal`, anonymous public-key encryption) to the recipient's **inbox pubkey** — only they can open it with their private key.
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 ...)`, - Sealed inbox message: `InboxMsgBody.msg` = `crypto_box::seal(... to_inbox ...)`, opened with the inbox secret key (`engine/net/src/types.rs:4272, 4299, 4319`).
ouvert avec la clé secrète d'inbox (`engine/net/src/types.rs:4272, 4299, 4319`). - The payload can carry a cap: `ContactDetails.read_cap: Option<ReadCap>` ("if user wants to share the content of profile") (`net/types.rs:4232-4233`) → **directed grant** (sealed to one recipient).
- Le payload peut porter un cap : `ContactDetails.read_cap: Option<ReadCap>` - **Undirected** variant: `RepoLinkV0.read_cap` = a shareable link that **whoever receives it** can open (`net/types.rs:5061-5078`).
(« 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 : So "wallet targeting" lives in the **sealing envelope**, not in the cap: the cap remains `{id, key}`, possession-based.
le cap reste `{id, clé}`, possession-based.
## 3. Révocation = re-key (grossier, non-rétroactif) > **Current state (2026-07-27) — the path is a GAP, not a disagreement.** The `ContactDetails.read_cap` field exists, but the construction of the message is `unimplemented!()` (its only caller passes "without read_cap") and the receiver **discards** the cap it would receive. The *shape* is therefore the right one; the implementation is not there. The polyfill emulates it in the meantime — filed in the bug-inbox.
On ne « reprend » pas une clé livrée. Révoquer = **re-chiffrer** avec une nouvelle ## 3. Revocation = re-key (coarse, non-retroactive)
clé et ne la re-sceller qu'aux autorisés restants.
- « Capabilities are not durable: they can be refreshed by members and previously A delivered key is not "taken back". To revoke = **re-encrypt** with a new key and re-seal it only to the remaining authorized holders.
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:`) - "Capabilities are not durable: they can be refreshed by members and previously shared Caps become obsolete/revoked… if [a member] doesn't subscribe, they lose access after the refresh" (`net/types.rs:5055-5058`).
- Mechanism: `RootCapRefresh` / `BranchCapRefresh` (`repo/src/commit.rs:616,630`; perms `types.rs:1748-1749`).
- Consequences: **coarse** (repo/branch scale), **non-retroactive** (what was read before remains known to the former holder; they only decrypt the versions **prior to** the refresh).
- **Durable** delivery of a cap = `PermaCap` — still **TODO** (`repo/types.rs:578`).
Le discriminant est le segment **`:k:{clé}`** : présent = cap-porteur ; **absent = ### DIRECTION — rotation does NOT cause access to be lost (confirmed by the PO, 2026-07-27)
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`, **Do not read the comment above as the intent.** "*if they don't subscribe, they lose access after the refresh*" describes **the current state**, not the target. What NextGraph is aiming for:
regexes `net/types.rs` :
> When a key is rotated, the new one is **sent to the inbox** of the users who retain the access right. That inbox is **processed automatically** as soon as one of the user's clients connects.
So access is **not lost**, it is **deferred** until the next connection — consistent with local-first. Shape consequences: **no subscription obligation** to expose to the consumer; a re-delivery takes **the same channel** as the initial delivery, so the sharing mechanism covers both with no special case. **Revocation** remains "stop re-delivering", non-retroactive.
## 4. NURI grammar: cap-less vs cap-bearing (the `:k:` segment)
**Clearing up the confusion first**: `did:ng:` is **not** a "cap-less" marker, it is the **URI scheme prefix** — present everywhere (inbox `did:ng:d:…`, branch `did:ng:b:…`, overlay `did:ng:v:…`, document `did:ng:o:…`). A NURI **is** a `did:ng:…`. So there is no "the did" on one side and "the NURI" on the other: it is **a single object**, with or without the key inside it — a single type upstream, `NuriV0 { target, access }`, where a cap-less NURI simply has an empty `access`.
The discriminant is the **`:k:{key}`** segment: present = cap-bearing; **absent = cap-less** (names/locates **without** granting the right to read). This is **first-class** in the type: `NuriV0.target` (ids) and `access`/`objects` (the cap) are **separate fields** — an id-only NURI parses with `access: vec![]` (`engine/net/src/app_protocol.rs:53-62, 99-118, 181-195, 659-677`).
**Cap-less** (id + optional overlay, no key) — formatters in `app_protocol.rs`, regexes in `net/types.rs`:
- `did:ng:o:{repo_id}` (`:315`, `RE_REPO_O` types.rs:52) - `did:ng:o:{repo_id}` (`: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}` (`: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}:v:{overlay_id}:b:{branch_id}` (`RE_BRANCH` types.rs:58)
- `did:ng:o:{repo_id}:c:{commit_id}` (`:355`) - `did:ng:o:{repo_id}:c:{commit_id}` (`:355`)
- `did:ng:b:{branch}` / `h:{topic}` / `v:{overlay}` / `d:{inbox}` (`:327,323,319,359`) - `did:ng:b:{branch}` / `h:{topic}` / `v:{overlay}` / `d:{inbox}` (`:327,323,319,359`)
**Cap-porteur** (embarque la clé) : **Cap-bearing** (embeds the key):
- `did:ng:j:{id}:k:{clé}`read cap d'objet/fichier (`repo/types.rs:511`, - `did:ng:j:{id}:k:{key}` — object/file read cap (`repo/types.rs:511`, `RE_FILE_READ_CAP` types.rs:49)
`RE_FILE_READ_CAP` types.rs:49) - `did:ng:o:{repo}:c:{commit}:k:{key}` (`RE_COMMIT` types.rs:73)
- `did:ng:o:{repo}:c:{commit}:k:{clé}` (`RE_COMMIT` types.rs:73) - list `RE_OBJECTS` `…:[cj]:{id}:k:{key}…:l:{locator}` (types.rs:64)
- 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 The `:v:` segment is the **overlay**, which has its own section below — it is the point with the heaviest consequences for anonymous-presence models.
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 ## 4bis. The overlay is the network space of a STORE — never of a document
**L'overlay est l'unité d'adressage réseau d'un store.** Chez le broker, les blocs **The overlay is a store's unit of network addressing.** At the broker, blocks are filed under a `(overlay, block_id)` key, and peers synchronize *within* an overlay. Two forms per store:
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 | | | Derivation | Who can compute it |
|---|---|---| |---|---|---|
| **outer** | `OverlayId::outer(store_id)` = BLAKE3 **public** | tout le monde (le store_id suffit) | | **outer** | `OverlayId::outer(store_id)` = **public** BLAKE3 | everyone (the store_id is enough) |
| **inner** | `OverlayId::inner(store_id, readcap_secret)` = BLAKE3 **keyed** | seulement qui détient la clé de lecture du store | | **inner** | `OverlayId::inner(store_id, readcap_secret)` = **keyed** BLAKE3 | only whoever holds the store's read key |
Corent avec le reste du modèle : pas de rôle ni de liste, seulement « détiens-tu Consistent with the rest of the model: no role and no list, only "do you hold the key that lets you derive this identifier". `outer` = the store's public name, `inner` = its private name.
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 **The `:v:` of a DOCUMENT NURI carries the overlay of its STORE** (VERIFIED, chain read end to end): `NuriV0::repo_graph_name(repo_id, overlay_id)` formats `o:{repo_id}:v:{overlay_id}`; in `doc_create` the value injected is `store.outer_overlay()` — the **containing** store, never the `repo_id`. A `Repo` carries **no** overlay field (only `store: Arc<Store>`); it is `Store` that carries `overlay_id`. **Mechanical counter-proof**: in `Store`, `get`/`put`/`del`/`has` all pass `&self.overlay_id` to the block storage — every document of a store shares the same block namespace, so a per-document overlay is structurally impossible.
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 ### The consequence to know about: the `:v:` is a stable pseudonym
**Tous les documents d'une même personne dans son store protected portent le MÊME **All of one person's documents in their protected store carry the SAME `:v:`** = `outer(protected_store_id)`. So a cap-less reference — precisely the one used to "name without granting read" — **exposes store membership**, that is to say a **stable and permanent pseudonymous identifier of the person**. The store_id itself does not leak (BLAKE3 is not invertible), so it does not say *who*; but it is a **constant handle**, the same everywhere and forever, correlatable by anyone who collects cap-less references.
`: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** : **The coupling that results, and that constrains any anonymous-presence model**: that same `:v:` is *simultaneously* (a) what makes it possible to **deduplicate** references without reading them — two references with the same `:v:` come from the same person — and (b) what makes it possible to **track** that person from one context to another. **It is the same bit of information.** You cannot get the dedup without conceding the tracking, nor remove the tracking without losing the dedup — short of changing how the stores are carved up, which moves the cursor but does not remove the trade-off.
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 *Nuances.* The NURI's `:v:` is the **outer** overlay, whereas client↔broker traffic and local storage use the **inner** one — a different value, but derived from the store as well, so the property holds in both cases. A `Dialog` store returns an `Inner`, still store-scoped.
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 **CORRECTED on 2026-07-27 — this hypothesis was FALSE.** We had inferred, then believed we had verified, that a holder **without a key** could fetch the encrypted blocks and therefore prove a document's **existence**. An adversarial review showed that the reasoning stopped at *access control* without looking at **addressing**:
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 - There is **no existence command at the SDK level**.
- The only probe (`BlocksExist`) is **internal to the crate**, requires `BlockId`s **and** an already **loaded** repo, and addresses the **inner** overlay — which is derived from the **read secret**.
- A cap-less reference carries a RepoId and the **outer** overlay: no `BlockId` to probe. And the outer is never registered anyway (`expose_outer` hard-coded to `false`, with no SDK parameter).
- The only primitive accessible to a non-member (`ExtObjectGet`) requires the ObjectIds **and their keys**.
`packages/client/src/caps.ts` modélise `readers: Map<Nuri, Set<PrincipalId>>` + > **Addressing itself presupposes the cap.** Proving a document's existence without holding its key is not constructible today, and nothing indicates that it is planned.
`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 | Transferable lesson: verifying that an access guard **lets you through** does not prove that an operation is reachable — you still have to be able to **name** what you are asking for.
## 4ter. The public store: readable by URL, and NOT recursive
Target principle (confirmed by the PO, 2026-07-27):
> **An element of the public store is public: whoever has the URL reads the content.**
> But **not recursively** — public content can *reference* private content, and the reference does **not** give access to the referenced.
This is a **second mechanism**, alongside key possession (§1) — not a breach of it. And it is the **non-recursiveness** that carries the value: it allows a public object that **points** to private identity, without divulging it. That is exactly the pattern an anonymous-presence model needs.
*Implementation detail, NOT to be carried by the shape*: NextGraph is moving toward **not encrypting** the content of the public store (the data remaining **signed**). A surface must not depend on it. And if the public store does not behave the way this principle describes, it is **the polyfill** that adapts, not the consumer.
## 4quater. The keyring: where the owner gets the caps for THEIR OWN documents
On every document creation, an `AddRepo { read_cap }` is committed to a **store branch** — the store being itself a repo, endowed with **typed** branches (the word "branch" has nothing to do with git: it is a compartment with a defined role). That branch lists **the store's documents, each with its read key**.
So it **is** the **owner's keyring**: the mechanism by which they find the caps of their own documents. Upstream of that, the keyring is the **wallet**.
**This is NOT the sharing mechanism.** An easy and costly confusion: concluding "we share at the store level" is wrong — delivering a store cap would give access to **all** of its content, present and future. **The unit of sharing is the document** (§2). The keyring is a private index, not an act of sharing.
*(VERIFIED for the `AddRepo { read_cap }` mechanism; the **exact name** of the branches and the enumeration of their types have not been re-traced — to be confirmed if this point becomes load-bearing.)*
## 5. What the polyfill emulates (caps.ts) — and where it diverges
`packages/client/src/caps.ts` models `readers: Map<Nuri, Set<PrincipalId>>` + `grantRead(doc, grantee)` (`:29-30, 41-42`) — **a per-document ACL of principals, that is the exact INVERSION of the real model** (key). Divergences:
| | Real NextGraph | caps.ts emulation |
|---|---|---| |---|---|---|
| Nature | possession de **clé** | **ACL** (set de principals) | | Nature | possession of a **key** | **ACL** (set of principals) |
| Grant | sceller la clé (crypto_box) à l'inbox | ajouter un principal au set | | Grant | seal the key (crypto_box) to the inbox | add a principal to the set |
| Durabilité | **durable** (clé livrée une fois) | **éphémère** (Map vide à chaque session → re-déclarée) | | Durability | **durable** (key delivered once) | **ephemeral** (Map empty every session → re-declared) |
| Révocation | **re-key** grossier, non-rétroactif | retrait du set : **instantané et total** | | Revocation | coarse **re-key**, non-retroactive | removal from the set: **instantaneous and total** |
| Granularité | repo / branche / commit / objet | **un cap par doc-NURI** | | Granularity | repo / branch / commit / object | **one cap per doc-NURI** |
| Réf. sans droit | **NURI cap-less** (sans `:k:`) | pas de notion (l'ACL dit qui peut) | | Ref. without rights | **cap-less NURI** (no `:k:`) | no such notion (the ACL says who may) |
**Face app** : `declareConnections` (côté consommateur) qui re-déclare « mes **App-facing**: `declareConnections` (on the consumer side), which re-declares "my connections read my protected entities" **every session**, is an **artifact of this ephemeral ACL** — moot in the real model (there the seals are durable; one seals per-doc at share time, not per-session).
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) ## 6. Implications for consumers (e.g. Festipod)
- « **scope protected = mon réseau peut lire** » n'est **pas** une ACL vérifiée - "**protected scope = my network can read**" is **not** an ACL checked by the broker: it is "I have **sealed my read key** to each of my connections". The "scope = ACL" mental model is wrong at the NextGraph level.
par le broker : c'est « j'ai **scellé ma read key** à chacune de mes - **Anonymous references are possible**: putting a **cap-less NURI** in a third party's collection lets that third party **name/count** without **reading the identity**; the cap-bearing one is sealed separately to the authorized parties only. (Basis for a presence model of the form "self-owned participation + curated cap-less Set + cap sealed to the connections".)
connexions ». Le modèle mental « scope = ACL » est faux au niveau NextGraph. - **Alignment to do**: when the real cap operations become available, replace the emulated ACL with durable per-doc key sealing, and `declareConnections`-as-a-re-declared-ACL disappears.
- **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 ## Caveats / gaps
- `file:line` datés (2026-07) — re-vérifier par symbole ; le core bouge. - `file:line` references are dated (2026-07) — re-verify by symbol; the core moves.
- INFÉRÉ : fetch broker keyless (existence sans clé) — non tracé au runtime. - ~~INFERRED: keyless broker fetch (existence without a key)~~ — **RESOLVED and REFUTED, 2026-07-27**: not constructible. See the CORRECTED block in §4bis. Kept struck through because the hypothesis is intuitive and will otherwise be re-formed.
- Non tracé : exécution complète de `RootCapRefresh` côté verifier - Not traced: the full execution of `RootCapRefresh` on the verifier side (`verifier/src/commits/mod.rs:616`), wallet storage of `private_store_read_cap` (`repo/types.rs:945,976`).
(`verifier/src/commits/mod.rs:616`), stockage wallet de `private_store_read_cap`
(`repo/types.rs:945,976`).
+19 -48
View File
@@ -1,61 +1,32 @@
# Vision & principes du polyfill `@ng-eventually/client` # Vision & principles of the `@ng-eventually/client` polyfill
## Raison d'être ## Purpose
Un **stand-in fidèle en FORME** des primitives futures de NextGraph. Objectif A **stand-in faithful in SHAPE** to NextGraph's future primitives. **Single** objective: that consumers (Festipod) be **coded against the CORRECT mental model** — the one of finished NextGraph — and have **NOTHING to rewrite** when NextGraph provides the real primitives.
**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 ## What the polyfill is NOT
Une couche de **sécurité**. Le **wallet partagé** (tout le monde partage les mêmes A **security** layer. The **shared wallet** (everyone shares the same keys) plus the absence of real crypto make the emulation **infinitely less secure** than a wallet-per-user — it is a **dev/staging vehicle**, not a goal. **Insecurity is ACCEPTED.** An attacker who bypasses the emulation is not our problem.
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 ## The only criterion: shape-fidelity, with RIGOR
Les **surfaces exposées** doivent matcher **exactement la FORME** des primitives The **exposed surfaces** must match the **exact SHAPE** of the future primitives, **even where enforcement is simulated**. The **failure mode to avoid**: exposing the **wrong shape** → the consumer codes against a model that will not exist → rewrite. The **ACL** inversion of ReadCaps was exactly that defect (an ACL where the real thing is **key possession**) — a lack of rigor.
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 ## Simulating crypto to PREVENT shortcuts
Sans un minimum de simulation crypto, des raccourcis préjudiciables sont pris (on Without a minimum of crypto simulation, damaging shortcuts get taken (reading the plaintext, falling back on ACLs). The polyfill therefore **simulates** the final mechanism, enough to hold this **invariant**:
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 > **A `did` (bare id, WITHOUT a ReadCap) and a NURI (WITH a ReadCap) are treated GENUINELY differently: the former does NOT allow reading the data; the latter is SUFFICIENT and REQUIRED.**
> 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 Concretely: a document's data is **stored encrypted** (per-doc symmetric encryption, however lightweight); the **ReadCap = the key**; without it, **decrypting/reading is impossible**. No ACL, no plaintext accessible "on the side". Obtaining read access = **holding the key**, exactly as in the target model.
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) ## Shape consequences (to respect everywhere)
- **Tout est clés et URLs.** Il n'y a **pas** de notion d'appartenance, de rôle ni - **Everything is keys and URLs.** There is **no** notion of membership, role, or authorization list in the model: only symmetric and asymmetric cryptography, URIs, and who holds which key. Any exposed shape that looks like an ACL, a `member`, a `role`, or a `permission` is a **wrong shape**, whatever scaffolding one may otherwise read in the current state of NextGraph.
de liste d'autorisation dans le modèle : uniquement de la cryptographie - **Reading = possession of the read key** (ReadCap = `{id, key}`). A bare id (a `did` without a ReadCap) does not read.
symétrique et asymétrique, des URIs, et qui détient quelle clé. Toute forme - **Writing = possession of the write key** — a key **distinct** from the read key, hence a distinct axis, but **possession too**.
exposée qui ressemble à une ACL, un `member`, un `role` ou une `permission` est - **Sharing a cap = sealing it to a recipient** (**durable** delivery, at share time — NOT an ACL re-declared every session).
une **mauvaise forme**, quel que soit l'échafaudage qu'on peut lire par ailleurs - **Revocation = re-key** (new key; former holders keep the old state). Non-retroactive.
dans l'état courant de NextGraph. - **Cap-less reference** (naming/pointing without reading) **distinct** from the cap-bearing reference.
- **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 See `readcap-and-nuri-model.md` (the real model, verified in `nextgraph-rs`) and `briefs/2026-07-20-caps-emulation-alignment.md` (the alignment effort).
`briefs/2026-07-20-caps-emulation-alignment.md` (le chantier d'alignement).