From b2cb77412493263e99fb9caf628cba5e4bf5af2b Mon Sep 17 00:00:00 2001 From: Sylvain Duchesne Date: Mon, 27 Jul 2026 17:42:14 +0200 Subject: [PATCH] =?UTF-8?q?docs:=20=C3=A9tat=20courant=20NextGraph=20enric?= =?UTF-8?q?hi=20+=20mod=C3=A8le=20cible=20align=C3=A9=20+=20retrait=20du?= =?UTF-8?q?=20lot=20PW?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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:ng:` 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 Claude-Session: https://claude.ai/code/session_014GbGgNEHRejVKoREvFuDFg --- .../2026-07-20-caps-emulation-alignment.md | 16 +- docs/nextgraph-current-state.md | 218 ++++++++++++++++++ docs/readcap-and-nuri-model.md | 69 ++++++ 3 files changed, 298 insertions(+), 5 deletions(-) diff --git a/docs/briefs/2026-07-20-caps-emulation-alignment.md b/docs/briefs/2026-07-20-caps-emulation-alignment.md index 47a465f..24861dc 100644 --- a/docs/briefs/2026-07-20-caps-emulation-alignment.md +++ b/docs/briefs/2026-07-20-caps-emulation-alignment.md @@ -364,11 +364,17 @@ rend le découpage P1a/P1b honnête plutôt que cosmétique. le vrai contenu de P2 est **durabilité + cap-less + re-partage par le détenteur**, pas « inverser l'ACL » — sans crypto, inverser ne produit aucun delta observable.* - **P3** — révocation par re-key (invalidation + re-livraison, non-rétroactive). -- **PW** — **WriteCap = membership** : primitif distinct (add/remove member, - members map lisible par un membre, **vérification d'auteur de commit** → digest - par-store), et découpage en stores **exprimable** par le consommateur. - *Indépendant de P1–P3 ; **bloquant pour la dédup Festipod**, donc à ordonnancer - tôt si c'est ce besoin-là qui presse.* +- ~~**PW** — WriteCap = membership~~ **RETIRÉ (2026-07-27)**. Ce lot reposait sur + une notion d'appartenance qui **n'existe pas** dans le modèle (tout est clés et + URLs) ; voir la section barrée plus haut. Il était en outre justifié par un + besoin de **dédup par vérification de signature** que le consommateur n'a + finalement pas : sa dédup s'appuie sur l'overlay, store-scopé. + *Pour mémoire, deux faits vérifiés au passage, à ne pas re-découvrir* : la + vérification de signature d'auteur **n'est pas appelée au runtime** ; et 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é, + d'où le fait que vérifier suppose quand même de pouvoir lire)*. Détail dans + `nextgraph-current-state.md`. - **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 diff --git a/docs/nextgraph-current-state.md b/docs/nextgraph-current-state.md index 256399f..1b13260 100644 --- a/docs/nextgraph-current-state.md +++ b/docs/nextgraph-current-state.md @@ -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 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`, 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 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; 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::::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` 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) 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): [`../packages/client/docs/sdk-reference.md`](../packages/client/docs/sdk-reference.md) § *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 IN 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". diff --git a/docs/readcap-and-nuri-model.md b/docs/readcap-and-nuri-model.md index 1b89d16..09a464a 100644 --- a/docs/readcap-and-nuri-model.md +++ b/docs/readcap-and-nuri-model.md @@ -45,6 +45,12 @@ sa clé privée. Le « ciblage wallet » vit donc dans **l'enveloppe de scellage**, pas dans le cap : le cap reste `{id, clé}`, possession-based. +> **État courant (2026-07-27) — le chemin est un MANQUE, pas un désaccord.** Le +> champ `ContactDetails.read_cap` existe, mais la construction du message est +> `unimplemented!()` (son unique appelant passe « sans read_cap ») et le récepteur +> **jette** le cap qu'il recevrait. La *forme* est donc la bonne ; l'implémentation +> n'est pas là. Le polyfill l'émule en attendant — fiche dans le bug-inbox. + ## 3. Révocation = re-key (grossier, non-rétroactif) On ne « reprend » pas une clé livrée. Révoquer = **re-chiffrer** avec une nouvelle @@ -60,8 +66,32 @@ clé et ne la re-sceller qu'aux autorisés restants. **antérieures** au refresh). - Livraison **durable** d'un cap = `PermaCap` — encore **TODO** (`repo/types.rs:578`). +### DIRECTION — la rotation ne fait PAS perdre l'accès (confirmé PO, 2026-07-27) + +**Ne pas lire le commentaire ci-dessus comme l'intention.** « *if they don't +subscribe, they lose access after the refresh* » décrit **l'état courant**, pas la +cible. Ce que NextGraph vise : + +> Quand une clé tourne, la nouvelle est **envoyée dans l'inbox** des utilisateurs +> qui conservent le droit d'accès. Cette inbox est **traitée automatiquement** dès +> qu'un client de l'utilisateur se connecte. + +Donc l'accès n'est **pas perdu**, il est **différé** jusqu'à la prochaine connexion +— cohérent avec le local-first. Conséquences de forme : **aucune obligation +d'abonnement** à exposer au consommateur ; une re-livraison emprunte **le même +canal** que la livraison initiale, donc le mécanisme de partage couvre les deux +sans cas particulier. La **révocation** reste « cesser de re-livrer », non +rétroactive. + ## 4. Grammaire NURI : cap-less vs cap-porteur (le segment `:k:`) +**Lever la confusion d'abord** : `did:ng:` n'est **pas** un marqueur de « sans +cap », c'est le **préfixe de schéma d'URI** — présent partout (inbox `did:ng:d:…`, +branche `did:ng:b:…`, overlay `did:ng:v:…`, document `did:ng:o:…`). Un NURI **est** +un `did:ng:…`. Il n'y a donc pas « le did » d'un côté et « le NURI » de l'autre : +c'est **un seul objet**, avec ou sans la clé dedans — un seul type en amont, +`NuriV0 { target, access }`, où un NURI cap-less a simplement `access` vide. + Le discriminant est le segment **`:k:{clé}`** : présent = cap-porteur ; **absent = cap-less** (nomme/localise **sans** donner le droit de lire). C'est de **première classe** dans le type : `NuriV0.target` (des ids) et `access`/`objects` (le cap) @@ -156,6 +186,45 @@ Leçon transposable : vérifier qu'une garde d'accès **laisse passer** ne prouv qu'une opération est atteignable — encore faut-il pouvoir **nommer** ce qu'on demande. +## 4ter. Le store public : lisible par l'URL, et NON récursif + +Principe cible (confirmé PO, 2026-07-27) : + +> **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é, et la référence ne donne **pas** accès au référencé. + +C'est un **second mécanisme**, à côté de la possession de clé (§1) — pas une +entorse. Et c'est la **non-récursivité** qui porte la valeur : elle autorise un +objet public qui **pointe** vers de l'identité privée, sans la divulguer. C'est +exactement le motif dont un modèle de présence anonyme a besoin. + +*Détail d'implémentation, à ne PAS faire porter par la forme* : NextGraph s'oriente +vers un **non-chiffrement** du contenu du store public (les données restant +**signées**). Une surface ne doit pas en dépendre. Et si le store public ne se +comporte pas comme ce principe le décrit, c'est **le polyfill** qui s'adapte, pas +le consommateur. + +## 4quater. Le trousseau : d'où le propriétaire tire les caps de SES documents + +À chaque création de document, un `AddRepo { read_cap }` est commité sur une +**branche du store** — le store étant lui-même un repo, doté de branches **typées** +(le mot « branche » n'a rien de git : c'est un compartiment à rôle défini). Cette +branche liste **les documents du store, chacun avec sa clé de lecture**. + +Elle **est** donc le **trousseau du propriétaire** : le mécanisme par lequel il +retrouve les caps de ses propres documents. En amont, le trousseau, c'est le +**wallet**. + +**Ce n'est PAS le mécanisme de partage.** Confusion facile et coûteuse : en déduire +« on partage au niveau du store » est faux — livrer un cap de store donnerait accès +à **tout** son contenu, présent et futur. **L'unité de partage est le document** +(§2). Le trousseau est un index privé, pas un geste de partage. + +*(VÉRIFIÉ pour le mécanisme `AddRepo { read_cap }` ; le **nom exact** des branches +et l'énumération de leurs types n'ont pas été re-tracés — à confirmer si ce point +devient porteur.)* + ## 5. Ce que le polyfill émule (caps.ts) — et où ça diverge `packages/client/src/caps.ts` modélise `readers: Map>` +