fix(caps): créer un document en donne le cap, + corriger 9 faits NextGraph

Le trou trouvé par l'e2e contre le broker en ligne : `docs.docCreate` ne
déposait aucun cap pour le créateur, donc un consommateur pouvait créer un
document par la primitive publique puis se voir refuser sa lecture et son
écriture. En amont c'est impossible — `doc_create` commite
`AddRepo { read_cap }` sur la branche Store du store, et le créateur le détient
dès le premier instant. Délibérément non répliqué dans `physical.ts` : les
documents du shim n'appartiennent à aucun utilisateur virtuel, et
`store-registry` classe leurs caps là où il sait à qui ils sont.

e2e : 22 passés / 8 échoués → 39 / 0. Les autres échecs venaient du harnais,
qui agissait comme une seconde identité sans l'établir, ou lisait un document
quelconque comme une inbox. Un run e2e contre un wallet persistant exige une
identité FRAÎCHE par run : `walletInbox(id)` rend l'inbox stable pour son
propriétaire — c'est son intérêt — donc un id fixe accumule les dépôts des runs
précédents (vert au 2e run, rouge au 3e, à code inchangé).

Revue adverse de la documentation, 9 défauts, tous vérifiés à la source avant
correction :

- « chaque document a une inbox native » est FAUX. Seuls les repos de store
  public et protected en ont une (`site.rs:128,149`) ; `new_store_default` n'en
  pose que `if !private` et `doc_create` laisse `inbox: None`. Le store privé
  n'en a pas non plus. Ce que le code fait est donc une ANTICIPATION — assumée
  et notée comme telle dans `documentInbox`, le brief et l'ADR discovery. Ce qui
  est vérifié, c'est la FORME : `AddInboxCapV0` est clé par `repo_id`.
- `InboxMsgContent::Link` est une variante unit sans charge utile : l'inbox ne
  transporte aucun ReadCap. `shareCap` était juste et le reste ; ses citations
  sont complétées aux deux bouts (émetteur `unimplemented!()`, récepteur qui
  ignore `details.read_cap`).
- les 3 stores appartiennent au user (`SiteV0`), pas au wallet ;
- le TODO `OpenRepo` ne concerne pas la lecture cross-wallet — il est dans
  `open_branch_`, après `RepoNotFound` ; charger par cap, c'est
  `load_repo_from_read_cap` ;
- la liste des méthodes JS était un sous-ensemble présenté comme la surface
  (77 exportées) ;
- `outbox-log.ts` n'enregistre rien : il inspecte l'outbox du SDK ;
- l'ADR private-store-nuri-scope citait `orm_start_graph` au présent, remplacé
  par `ensureRepoOpen` ;
- l'incident write-loss plaçait `disconnections_sender.send` dans `broker.rs` ;
- la section « Apps & services » n'a aucune citation et rien ne lui correspond
  dans le moteur : marquée à re-confirmer, pas à citer comme vérifiée.

Aussi : `fileOwnCaps` n'existe plus (`holdOwnCap` / `readStoreCaps` /
`fileOwnStructure`) — pointeur mort corrigé dans `caps.ts`.
This commit is contained in:
Sylvain Duchesne
2026-08-03 12:17:40 +02:00
parent 9d3e2d2bfe
commit 88f396a7ac
13 changed files with 198 additions and 67 deletions
+57 -13
View File
@@ -22,14 +22,24 @@ Where the ground truth lives, so future re-verification is cheap:
dispatch (the truth on what is actually *processed*).
- `engine/net/src/types.rs` — inbox types (`InboxPost`, `InboxMsg`, `InboxMsgContent`).
- `engine/verifier/src/inbox_processor.rs` — inbox message handling.
- `engine/verifier/src/verifier.rs:1423`the `OpenRepo` TODO (cross-wallet read).
- `engine/verifier/src/verifier.rs:2237``load_repo_from_read_cap`, the one path that
brings a repo in FROM a cap (`pub(crate)`, see § *Capability / ReadCap granularity*).
- `engine/verifier/src/verifier.rs:1423` — the `OpenRepo` TODO. It is **not** about
loading an unheld repo: it sits inside `open_branch_`, past
`self.repos.get_mut(repo_id).ok_or(RepoNotFound)?` (`:1331`), so the repo is already
held by the time that line runs. What is missing is the broker-side `OpenRepo`
request, worked around with a pin.
- `engine/repo/src/types.rs``RootBranchV0.store: StoreOverlay` (repo → its store).
## The 5 store types
Every wallet has the **3 default stores** out of the box (session fields
`private_store_id`, `protected_store_id`, `public_store_id`). Group and Dialog
are created on demand.
The **3 default stores** belong to a **user**, not to the wallet. A wallet holds
`sites: HashMap<String, SiteV0>` (`engine/wallet/src/types.rs:456`), and it is `SiteV0`
that carries `public` / `protected` / `private` (`engine/verifier/src/site.rs:31-37`) —
one wallet can hold several, which is exactly why "wallet" is the wrong unit to reason
in (see `docs/readcap-and-nuri-model.md` §4quinquies, *Nomenclature first*). A session exposes the three as
`private_store_id`, `protected_store_id`, `public_store_id` — those are the connected
USER's. Group and Dialog are created on demand.
| Store | Read | Write | Creation |
|---|---|---|---|
@@ -104,11 +114,27 @@ offline"*; *"removing permissions … requires a SyncSignature"* (synchronous).
## Inbox
Every document has a native inbox. A non-editor can deposit a link (DID
cap) into it without being invited as an editor; the owner moderates. NURI:
`did:ng:d:<inbox_id>`. Content: the `InboxMsgContent` enum (`ContactDetails`,
**Only two repos have an inbox today: a user's public and protected STORES.** Not
documents, and not the private store. `new_store_default` attaches one solely `if
!private` (`engine/verifier/src/verifier.rs:2994`), and `doc_create` goes through
`new_repo_default`, which leaves `inbox: None` (`engine/repo/src/repo.rs:574`). The only
`AddInboxCap` commits in the whole engine are the two in `engine/verifier/src/site.rs:128,149`
— one for the public store repo, one for the protected one.
The *register* is nonetheless per-repo: `AddInboxCapV0 { repo_id, overlay, priv_key }`
(`engine/repo/src/types.rs:1973`) records **which repo** an inbox is opened for, so the
shape accommodates an inbox on any repo. Nothing creates one, which is a different
statement from the shape forbidding it — and it is why this lib's per-document inbox is
an ANTICIPATION of that shape, not an emulation of something upstream already does.
A non-editor can deposit into an inbox without being invited as an editor; the owner
moderates. NURI: `did:ng:d:<inbox_id>`. Content: the `InboxMsgContent` enum (`ContactDetails`,
`DialogRequest`, `Link`, `Patch`, `ServiceRequest`, `ExtRequest`,
`RemoteQuery`, `SocialQuery`). Messages are sealed (`crypto_box::seal`) to
`RemoteQuery`, `SocialQuery`, `engine/net/src/types.rs:4249-4261`). Note what `Link`
is: a **unit variant, carrying nothing** — not a link, not a cap, just a discriminant.
Reading it as "the inbox can deliver a read capability" is the trap this file exists to
prevent; see the *Consequence for this lib* below, which says the same thing from the
other end. Messages are sealed (`crypto_box::seal`) to
the inbox pubkey, so only the owner decrypts. The `from` field is optional, so an
anonymous sender is possible. This is the "identified if known, anonymous
otherwise" behaviour native to the protocol.
@@ -241,7 +267,8 @@ with the whole wallet, which is why the read path is per-doc anchored: the ancho
read makes a non-empty wallet irrelevant. At the real multi-store
migration this is unchanged (the anchored read is native); only bringing a repo into
the session changes: opening a real per-user store repo by cap becomes a native
broker sync (the `OpenRepo` TODO at `verifier.rs:1423`). Opening still requires the
broker sync, through `load_repo_from_read_cap` (`verifier.rs:2237`) — not through the
`OpenRepo` TODO at `:1423`, which concerns a repo already held. Opening still requires the
repo's NURI + ReadCap — there is no store-level read inheritance (see
§ Capability / ReadCap granularity).
@@ -355,10 +382,16 @@ Listing must go through a one-shot union `sparql_query` instead — see
installed version) **does NOT expose**: Group/Dialog store creation; capability
sharing (a NURI with rights); permission manipulation; inbox deposit/read.
Available JS methods: `doc_create`, `doc_subscribe`, `sparql_query`,
The JS methods this lib USES: `doc_create`, `doc_subscribe`, `sparql_query`,
`sparql_update`, `orm_start_graph`, `orm_start_discrete`, `graph_orm_update`,
`discrete_orm_update`, `file_get`, `app_request_stream`. The docs announce *"An
API will be provided for permission manipulation"* (no date).
`discrete_orm_update`, `file_get`, `app_request_stream`. That is a working subset,
**not** the surface: `NGModule` exports **77** (`@ng-org/web@0.1.2-alpha.13`,
`dist/index.d.ts:140-268`), including `app_request`, `session_stop`,
`disconnections_subscribe`, `social_query_start`, `upload_start`/`upload_chunk`/
`upload_done`, and the whole `wallet_*` family. Read "not in the list above" as "we do
not call it", never as "it does not exist" — several sections of this very file discuss
methods absent from that subset. The docs announce *"An API will be provided for
permission manipulation"* (no date).
## Integration & deployment model
@@ -439,6 +472,15 @@ methods: `doc_subscribe`, `orm_start_graph`, `orm_start_discrete`, `file_get`,
NextGraph's app/service execution model — important because it **invalidates**
the idea of "a service with its own wallet sharing global data".
> **Provenance: NOT verified against `nextgraph-rs`.** Every other section of this file
> cites the engine; this one cites nothing, and nothing in the local clone corresponds to
> it — no app/service runtime, no settings-document type, no singleton notion. It arrived
> with `bea9f51`, moved wholesale from the consumer app's repo, and its own source (an
> exchange with the PO, the official docs, or an inference) was not recorded. The
> conclusion below carries real weight — it is what deferred a global index in this lib —
> so treat it as **a claim to re-confirm with the PO**, not as an engine fact. Do not
> extend it, and do not cite it as verified.
- **Apps AND services are mono-user.** They see only **what the user makes
available** to them. There is **no global data** natively, and no central
service holding shared data.
@@ -734,6 +776,8 @@ 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
`outbox-log.ts` does not record anything: it exports a single `inspectOutbox()` that
READS the SDK's own `sessionStorage` outbox and logs how many peers still have queued
writes. It observes the symptom; it holds nothing it could replay, and no
write-durability confirmation exists to await — so "the write returned" is not "the write
is durable".