fix(inbox): une inbox appartient à un document, jamais à plusieurs

Retour sur l'adresse par défaut livrée en 8a382f2, qui faisait pointer tout
document vers l'inbox de son propriétaire. C'était acheter le coût au prix de
la forme — le mauvais arbitrage pour cette bibliothèque.

Vérifié en amont : le verifier route un message entrant par
`inboxes: PubKey → RepoId` (`engine/verifier/src/verifier.rs:1677,1928`) et le
déchiffre avec la moitié privée de CE repo. Et `InboxMsgBody`
(`engine/net/src/types.rs:4265`) ne porte aucun document cible — il n'en a pas
besoin : l'adresse EST l'identification. Une inbox appartient donc à exactement
un repo, et faire tenir plusieurs documents derrière une inbox émule une
relation que le modèle ne peut pas exprimer.

Conséquences :

- `createEntityDoc` ne publie plus rien. Un document neuf n'a pas d'inbox et
  `documentInboxAddress` rend `undefined`.
- Une inbox s'ouvre par `openDocumentInbox(doc)`, sur décision du propriétaire.
  C'est aussi ce qui règle le coût sans toucher à la forme : seuls les
  documents destinés à RECEVOIR en paient une — l'app le sait, la bibliothèque
  non.
- `inbox.postToDocument(doc, { payload })` : l'app nomme le DOCUMENT, jamais une
  inbox. Lève quand le document n'en a pas, au lieu de rendre la main
  silencieusement — un dépôt qui disparaît sans erreur est exactement le bug que
  ce chemin traînait.
- Pas de champ « document cible » sur un dépôt. Ce serait une invention que les
  apps devraient désapprendre à la migration.

README, principe de conception : les deux moitiés sont contraignantes, et c'est
la seconde qu'on brade. La surface doit être au plus près du futur SDK, mais
l'IMPLÉMENTATION aussi doit être au plus près de ce que NextGraph prévoit, sans
exception. Ce qui est connu vaut spécification. La pression à dévier ne se
présente jamais comme une déviation : elle arrive comme un coût, une latence,
une gêne d'ergonomie — bien réels. Deux cas déjà rencontrés sont consignés, avec
le signal commun : un choix qui ferait apprendre au consommateur quelque chose
qu'il devra DÉSAPPRENDRE.

157 tests unitaires, e2e 40/40 contre le broker en ligne.
This commit is contained in:
Sylvain Duchesne
2026-08-03 16:45:28 +02:00
parent 8a382f29f8
commit 5a7009bd75
9 changed files with 137 additions and 58 deletions
+19 -23
View File
@@ -957,32 +957,30 @@ export async function createEntityDoc(id: string, scope: Scope): Promise<Nuri> {
}
// …and the creator holds THAT cap for this session.
holdOwnCap(id, scope, entityNuri, cap);
// The THIRD write: WHERE to deposit for this document, on its Header branch, so any
// holder can find it. Done at creation because the alternative — publishing it the
// day the owner opens a dedicated inbox — leaves a window in which a third party
// reads the document, finds no address, and cannot reach its owner at all. That
// window is precisely the consumer's central act (signing up to someone else's
// document), so it cannot be left open.
// NO inbox here, and NOT the owner's own inbox published as this document's address.
// Upstream an inbox belongs to exactly ONE repo: the verifier routes an incoming
// message by `inboxes: PubKey → RepoId` (`engine/verifier/src/verifier.rs:1677,1928`)
// and unseals it with THAT repo's private half, while `InboxMsgBody` carries no
// target document at all (`engine/net/src/types.rs:4265`) — because it needs none,
// the address IS the identification. Pointing several documents at one inbox would
// emulate a many-to-one relation the model cannot express, and would teach consumers
// to tag deposits with their document, a habit that has to be unlearned at migration.
//
// It points at the owner's OWN inbox, which already exists: one inbox per user,
// amortized over every document they create — NOT one document per document. That
// distinction is the whole cost question (see
// `docs/briefs/2026-08-03-document-inbox-addressing.md`: publishing at creation was
// measured at 9m37 → 21m30 on the consumer's suite, because it created a second
// DOCUMENT each time). Here creation grows by one triple, and the deposit carries
// the document it concerns, so the owner still materializes per document.
//
// `openDocumentInbox` later REPLACES this address with a dedicated inbox for owners
// who want one document's deposits kept apart; the resolution is the same either way.
await publishInboxAddress(entityNuri, await walletInbox(id));
// So a document gets an inbox only when its owner opens one
// ({@link openDocumentInbox}), which is also what keeps the cost proportional: only
// documents meant to RECEIVE pay for one (see
// `docs/briefs/2026-08-03-document-inbox-addressing.md`).
return entityNuri;
}
/**
* Publish WHERE to deposit for `doc`, on its Header branch — the compartment any
* holder of the document can read. Idempotent by replacement: a document has exactly
* one address, and re-publishing (when a dedicated inbox is opened) must not leave the
* previous one behind for a depositor to pick.
* holder of the document can read.
*
* Replacement, not addition: a document has exactly ONE inbox upstream (the verifier's
* `inboxes: PubKey → RepoId` is a function, and `repo.inbox` a single `Option<PrivKey>`),
* so two addresses on one document is a state the model has no meaning for — and a
* depositor picking the stale one writes where nobody reads.
*/
async function publishInboxAddress(doc: Nuri, inbox: Nuri): Promise<void> {
const s = await session();
@@ -990,9 +988,7 @@ async function publishInboxAddress(doc: Nuri, inbox: Nuri): Promise<void> {
// Two separate updates, not one compound statement: `DELETE WHERE { … }` is the
// form verified against the real broker (see
// `docs/decisions/sparql-delete-for-orm-objects.md`), whereas a `;`-joined update
// is not exercised anywhere in this lib. Deleting first is what makes this a
// replacement — a document has ONE address, and a stale one left beside the new
// one is a depositor writing where nobody reads.
// is not exercised anywhere in this lib.
await sparqlUpdate(
s.sessionId,
`DELETE WHERE { <${HEADER_BRANCH_SUBJECT}> <${P.inboxAddress}> ?a }`,