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
This commit is contained in:
Sylvain Duchesne
2026-07-27 17:42:14 +02:00
parent 8764daff4f
commit b2cb774124
3 changed files with 298 additions and 5 deletions
+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
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 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::<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)
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 <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".