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:
@@ -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".
|
||||
|
||||
Reference in New Issue
Block a user