feat(inbox): l'inbox d'un document est adressable par tout détenteur

Répond au brief 2026-08-03 remonté depuis le consommateur. `documentInbox(doc)`
répondait « quelle inbox est-ce que MOI je connais pour ce document » et en
créait une quand la réponse était « aucune » : un tiers n'atteignait jamais
l'inbox du propriétaire, il en obtenait une à lui, que personne ne lit, et son
dépôt disparaissait sans erreur. C'est l'acte central du consommateur —
s'inscrire à l'événement d'un autre — qui était silencieusement perdu.

Lire une inbox et savoir où y déposer sont deux actes opposés, avec des publics
opposés. Ils sont désormais deux fonctions :

- `openDocumentInbox(doc)` — le PROPRIÉTAIRE ouvre une inbox dédiée. Refuse sur
  la PROPRIÉTÉ (lue depuis les branches Store), pas sur la possession du cap :
  un cap se reçoit, et un destinataire ne doit pas pouvoir rediriger vers lui
  les dépôts destinés au propriétaire.
- `documentInboxAddress(doc)` — n'importe quel détenteur trouve où déposer. Ne
  crée jamais rien.

L'adresse est publiée dès la CRÉATION, sur la branche Header émulée du document
— un sujet réservé à l'intérieur du document, donc lisible par qui détient le
document. Publier seulement le jour où le propriétaire ouvre une inbox dédiée
laisserait une fenêtre pendant laquelle un tiers lit le document, ne trouve
aucune adresse, et ne peut pas joindre le propriétaire du tout.

Sur le coût mesuré par le brief (9m37 → 21m30) : il venait de la création d'un
DOCUMENT supplémentaire par document. L'adresse publiée pointe vers l'inbox
propre du propriétaire, qui existe déjà et s'amortit sur tous ses documents ;
la création grandit d'un triple, pas d'un document. Le dépôt porte le document
concerné, donc le propriétaire matérialise toujours par document. La forme
« dérivable » du brief n'était pas disponible : notre inbox est un document, et
un NURI dérivé nommerait un repo que `doc_create` n'a jamais créé.

Le tout reflète la séparation d'amont : un déposant scelle avec la clé PUBLIQUE
de l'inbox et n'a besoin de rien d'autre, seul le propriétaire détient la
moitié privée — une adresse est donc publique par nature.

`src/machinery.ts` : l'espace de noms `urn:ng-eventually:` que la bibliothèque
se réserve, et le prédicat que le chemin de lecture utilise. La branche Header
est le premier compartiment logé dans un document que le consommateur lit ;
`read-model` écarte désormais tout sujet de cet espace, par SUJET et non par
prédicat — ce qui couvre toutes les branches émulées, présentes et futures.

Question ouverte du brief, tranchée : « une inbox de document adressable par
tout détenteur » est une invention de cette bibliothèque, pas de l'amont — aucun
document n'y a d'inbox, ni le store privé. Ce qui EST vérifié, c'est la forme
qui rend l'anticipation défendable : `AddInboxCapV0` est clé par `repo_id`.

Tests : le test qui validait « n'importe qui dépose » passait le NURI d'inbox au
déposant par une variable du test — chemin qu'aucune app n'a. Réécrit avec les
deux acteurs cloisonnés : le déposant reçoit le lien du document, qui est la
seule chose qui circule dans ce modèle, et doit trouver l'adresse lui-même. Le
fake `ng` gagne le SELECT de la branche Header et le `DELETE WHERE` (sans quoi
un remplacement devenait une accumulation, précisément le bug qu'il évite).

157 tests unitaires, e2e 40/40 contre le broker en ligne.
This commit is contained in:
Sylvain Duchesne
2026-08-03 16:02:11 +02:00
parent e24a20cc46
commit 8a382f29f8
11 changed files with 487 additions and 26 deletions
@@ -186,7 +186,9 @@ So the store-root pointer, the doc-shim and the account records go through the m
7. ~~**Per-document inboxes**~~**DONE 2026-08-03.** Upstream a repo carries `inbox: Option<PrivKey>` (`engine/repo/src/repo.rs:126`): an inbox is a keypair on the document whose PRIVATE half the owner holds, recorded with `AddInboxCap { repo_id, overlay, priv_key }` on the **User branch** — the same branch as `AddLink`, and with the same stated purpose (*"so that a user can share with all its device"*). So "which inboxes may I read" has exactly one answer, and it is the one place to look.
`storeRegistry.documentInbox(doc)` resolves — creating on first ask — the inbox of a document this user owns, recording the pair on its User branch. **Lazy**: minting an inbox document for every entity up front would double every `createEntityDoc` for inboxes most documents never receive anything in. `myInboxes()` enumerates both levels, `isOwnInbox` answers from the same record, and `connect.connectedUser` drains them all in one call.
*(Renamed and split on 2026-08-03 — `documentInbox` became `openDocumentInbox` (own the inbox) + `documentInboxAddress` (find where to deposit). Conflating the two made per-document inboxes unusable by anyone but their owner; see [`2026-08-03-document-inbox-addressing.md`](2026-08-03-document-inbox-addressing.md).)*
`storeRegistry.openDocumentInbox(doc)` resolves — creating on first ask — the inbox of a document this user owns, recording the pair on its User branch. **Lazy**: minting an inbox document for every entity up front would double every `createEntityDoc` for inboxes most documents never receive anything in. `myInboxes()` enumerates both levels, `isOwnInbox` answers from the same record, and `connect.connectedUser` drains them all in one call.
The asymmetry holds at both levels, and a test walks it: **anyone deposits** into a document's inbox (that is how a third party reaches its owner at all), **only the owner reads** it.
@@ -2,6 +2,20 @@
**Raised 2026-08-03, from the consumer side (Festipod), after an attempt to solve it in the app proved it does not belong there.**
> ## IMPLEMENTED 2026-08-03 — shape 2 (the library publishes), with the cost objection taken as binding
>
> `storeRegistry.documentInboxAddress(doc)` answers *"where do I deposit for this document"* for **any holder**, and `inbox.post` into it. The address is published **at creation**, in a compartment the library owns — so it never enters a consumer shape.
>
> **On the cost.** The measured regression (9m37 → 21m30) came from creating a second **DOCUMENT** per document. The published address points at the owner's **own inbox**, which already exists and is amortized over every document that owner creates: creation grows by triples, not by a document. The deposit carries the document it concerns, so the owner still materializes per document. Shape 1 (derivation) was **not available**: our inbox is a document, and a derived NURI would name a repo `doc_create` never created — upstream can derive because an inbox there is a keypair on the repo, not a document.
>
> **Why at creation and not at first open.** Publishing the day the owner opens a dedicated inbox leaves a window where a third party reads the document, finds no address, and cannot reach the owner at all — which is exactly the consumer's central act (signing up to someone else's document, before that owner ever touched an inbox). `openDocumentInbox(doc)` remains, for an owner who wants one document's deposits kept apart; it **replaces** the published address rather than adding to it.
>
> **Where the address lives.** On the document's emulated **Header branch** (`urn:ng-eventually:shim:headerBranch`), beside the content rather than in it — the same subject-as-compartment shape already used for the Store and User branches. `read-model` now drops every subject under the reserved `urn:ng-eventually:` namespace (`src/machinery.ts`), so the address cannot surface as one of the entity's properties. That filter is by SUBJECT, so it covers every emulated compartment present and future.
>
> **The open question, answered.** *"Is 'a document has an inbox addressable by any holder' upstream, or this library's invention?"* — **the library's**, and it is now written down as such. Verified: no document has an inbox upstream, and neither does the private store; the only two `AddInboxCap` commits in the engine are for the public and protected STORE repos (`engine/verifier/src/site.rs:128,149`), `new_store_default` attaches one only `if !private` (`engine/verifier/src/verifier.rs:2994`), and `doc_create` leaves `inbox: None` (`engine/repo/src/repo.rs:574`). What IS upstream is the shape that makes this a defensible anticipation rather than a fiction: `AddInboxCapV0` is keyed by `repo_id` (`engine/repo/src/types.rs:1973`), so the record accommodates an inbox on any repo. The half-split is upstream's too — a depositor seals with the inbox PUBLIC key (`engine/net/src/types.rs:4299`) and only the owner holds the private half — which is why an address is public by nature and belongs on the document, not on the owner's User branch.
>
> **Also fixed, and it was the root of the reported symptom.** `openDocumentInbox` (formerly `documentInbox`) called by a non-owner used to mint a parallel inbox and record it for the caller — no error, deposits lost. It now refuses, on OWNERSHIP (read from the Store branches), not on cap possession: a cap can be received, and a recipient must not be able to redirect the owner's deposits to itself.
## The problem, in one sentence
`documentInbox(doc)` answers *"which inbox do **I** know for this document?"* — and mints a fresh one when the answer is none. So a third party never reaches the owner's inbox: they get one of their own, which the owner never reads, and their deposit vanishes without an error.
+27 -10
View File
@@ -200,16 +200,33 @@ store-id:
blocker, [`migration-guide.md`](./migration-guide.md)). At migration each scope
resolves to the user's real per-scope store — the change is in this function,
and the consumer application is unchanged.
- **`walletInbox(id)` / `documentInbox(doc)`** — an inbox BELONGS to someone. The
first is a virtual user's own inbox (where Links arrive), the second the inbox of
one of its documents, created on first ask. Both are dedicated documents (real
repo NURIs from `docCreate`), never the private-store root: routing deposits into
the shim graph would bloat the account→document trust root without bound.
`myInboxes()` enumerates both levels — what `connect.ts` drains at connection —
and `isOwnInbox` answers from the same record. *(The former `resolveInboxAnchor`,
a single inbox COMMON to every user, was removed on 2026-07-30: nothing may be
common but the mechanisms that make the virtual users work.)* At migration these
become native per-document inboxes.
- **`walletInbox(id)` / `openDocumentInbox(doc)`** — an inbox BELONGS to someone. The
first is a user's own inbox (where Links arrive), the second a DEDICATED inbox for
one of its documents, opened on demand by its **owner only** (ownership read from the
Store branches — a received cap is not ownership, and a recipient must not be able to
redirect the owner's deposits to itself). Both are dedicated documents (real repo
NURIs from `docCreate`), never the private-store root: routing deposits into the shim
graph would bloat the account→document trust root without bound. `myInboxes()`
enumerates both levels — what `connect.ts` drains at connection — and `isOwnInbox`
answers from the same record. *(The former `resolveInboxAnchor`, a single inbox COMMON
to every user, was removed on 2026-07-30: nothing may be common but the mechanisms
that make the virtual users work.)*
- **`documentInboxAddress(doc)` — the DEPOSIT side, and the one a third party uses.**
Reading an inbox and finding where to deposit into it are opposite acts with opposite
audiences, and conflating them is what made per-document inboxes unusable at first:
resolution answered *"which inbox do I know for this document"*, so a depositor got
one of their own and their deposit vanished silently
([`briefs/2026-08-03-document-inbox-addressing.md`](./briefs/2026-08-03-document-inbox-addressing.md)).
Now every document carries its address **from creation**, on its emulated **Header
branch** — a reserved subject inside the document, so any holder of the document
reads it, and `read-model` filters the whole `urn:ng-eventually:` namespace out of
consumer data (`src/machinery.ts`). It points at the owner's own inbox by default —
one inbox per user, amortized, NOT one document per document — and
`openDocumentInbox` replaces it when an owner wants a document's deposits kept apart.
This mirrors upstream's split: a depositor seals with the inbox PUBLIC key and needs
nothing else, only the owner holds the private half. At migration the address becomes
the repo's native inbox pubkey and the resolution moves; the consumer-facing act
(resolve, then `inbox.post`) is unchanged.
Both resolve the native store ids from the injected session
(`RegistrySession.protectedStoreId` / `publicStoreId`, alongside the existing