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
15 KiB
NextGraph's ReadCap & NURI model — and the polyfill's caps emulation
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.
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".
1. A ReadCap = possession of a key, NOT a per-identity ACL
A ReadCap is fundamentally a cryptographic key that one holds, not an ACL entry tied to a wallet. "Whoever holds the key can read."
- Structure:
ReadCap = ObjectRef = BlockRef { id: BlockId, key: SymKey }(engine/repo/src/types.rs:461, 463-471, 557, 565).id: BlockId= BLAKE3 digest (address of the encrypted object).key: SymKey = ChaCha20Key([u8;32])= the decryption key. Holding the pair → the broker serves the encrypted blocks byid, and one decrypts locally withkey.
- Granularity: per commit/object the
ObjectRefis 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). - 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.
2. Granting read access = sealing the key to the recipient
"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.
- Sealed inbox message:
InboxMsgBody.msg=crypto_box::seal(... to_inbox ...), opened with the inbox secret key (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). - Undirected variant:
RepoLinkV0.read_cap= a shareable link that whoever receives it can open (net/types.rs:5061-5078).
So "wallet targeting" lives in the sealing envelope, not in the cap: the cap remains {id, key}, possession-based.
Current state (2026-07-27) — the path is a GAP, not a disagreement. The
ContactDetails.read_capfield exists, but the construction of the message isunimplemented!()(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.
3. Revocation = re-key (coarse, non-retroactive)
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.
- "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; permstypes.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).
DIRECTION — rotation does NOT cause access to be lost (confirmed by the PO, 2026-07-27)
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:
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_Otypes.rs:52)did:ng:o:{repo_id}:v:{overlay_id}(:263,RE_REPOtypes.rs:55)did:ng:o:{repo_id}:v:{overlay_id}:b:{branch_id}(RE_BRANCHtypes.rs:58)did:ng:o:{repo_id}:c:{commit_id}(:355)did:ng:b:{branch}/h:{topic}/v:{overlay}/d:{inbox}(:327,323,319,359)
Cap-bearing (embeds the key):
did:ng:j:{id}:k:{key}— object/file read cap (repo/types.rs:511,RE_FILE_READ_CAPtypes.rs:49)did:ng:o:{repo}:c:{commit}:k:{key}(RE_COMMITtypes.rs:73)- list
RE_OBJECTS…:[cj]:{id}:k:{key}…:l:{locator}(types.rs:64)
The :v: segment is the overlay, which has its own section below — it is the point with the heaviest consequences for anonymous-presence models.
4bis. The overlay is the network space of a STORE — never of a document
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:
| Derivation | Who can compute it | |
|---|---|---|
| outer | OverlayId::outer(store_id) = public BLAKE3 |
everyone (the store_id is enough) |
| inner | OverlayId::inner(store_id, readcap_secret) = keyed BLAKE3 |
only whoever holds the store's read key |
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.
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.
The consequence to know about: the :v: is a stable pseudonym
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.
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.
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.
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:
- There is no existence command at the SDK level.
- The only probe (
BlocksExist) is internal to the crate, requiresBlockIds 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
BlockIdto probe. And the outer is never registered anyway (expose_outerhard-coded tofalse, with no SDK parameter). - The only primitive accessible to a non-member (
ExtObjectGet) requires the ObjectIds and their keys.
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.
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 of a key | ACL (set of principals) |
| Grant | seal the key (crypto_box) to the inbox | add a principal to the set |
| Durability | durable (key delivered once) | ephemeral (Map empty every session → re-declared) |
| Revocation | coarse re-key, non-retroactive | removal from the set: instantaneous and total |
| Granularity | repo / branch / commit / object | one cap per doc-NURI |
| Ref. without rights | cap-less NURI (no :k:) |
no such notion (the ACL says who may) |
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).
6. Implications for consumers (e.g. Festipod)
- "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.
- 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".)
- 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.
Caveats / gaps
file:linereferences are dated (2026-07) — re-verify by symbol; the core moves.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.- Not traced: the full execution of
RootCapRefreshon the verifier side (verifier/src/commits/mod.rs:616), wallet storage ofprivate_store_read_cap(repo/types.rs:945,976).