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:
@@ -28,7 +28,7 @@ import { depositInto, sparqlQuery } from "./docs";
|
||||
import { subscribeDoc } from "./subscribe";
|
||||
import { ensureRepoOpen } from "./open-repo";
|
||||
import { getCaps, getCurrentUser, getStoreRegistryDeps } from "./polyfill";
|
||||
import { addLink, isOwnInbox } from "./store-registry";
|
||||
import { addLink, documentInboxAddress, isOwnInbox } from "./store-registry";
|
||||
import { escapeLiteral } from "./sparql";
|
||||
import { hasReadCap } from "./nuri";
|
||||
import {
|
||||
@@ -189,6 +189,40 @@ export async function post(targetInbox: Nuri, opts: PostOptions): Promise<void>
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deposit into the inbox of a DOCUMENT — resolve where, then deposit there.
|
||||
*
|
||||
* The call an app makes to reach a document's owner: it needs the document (which it
|
||||
* must be able to read) and nothing else. Where the inbox is, and whether the owner
|
||||
* ever opened one, are the library's business.
|
||||
*
|
||||
* **No target-document field on the deposit, deliberately.** Upstream an inbox belongs
|
||||
* to exactly one repo — the verifier routes by `inboxes: PubKey → RepoId` and unseals
|
||||
* with that repo's key (`engine/verifier/src/verifier.rs:1677`) — and `InboxMsgBody`
|
||||
* carries no document (`engine/net/src/types.rs:4265`), because the address already
|
||||
* identifies it. Tagging deposits with their document would be an invention consumers
|
||||
* would have to unlearn at migration, so this resolves the address and stops there.
|
||||
*
|
||||
* @throws if the document has no inbox — its owner never opened one, so there is
|
||||
* nowhere for this to go. Throwing rather than returning quietly is the whole lesson of
|
||||
* this path: a deposit that vanishes without an error is worse than a refusal, and it
|
||||
* is exactly the bug per-document inboxes shipped with
|
||||
* (`docs/briefs/2026-08-03-document-inbox-addressing.md`). Call
|
||||
* `storeRegistry.documentInboxAddress(doc)` first when "no inbox" is an expected case.
|
||||
*/
|
||||
export async function postToDocument(doc: Nuri, opts: PostOptions): Promise<void> {
|
||||
const target = await documentInboxAddress(doc);
|
||||
if (target === undefined) {
|
||||
throw new Error(
|
||||
"[ng-eventually] inbox.postToDocument: this document has no inbox — either its owner " +
|
||||
"never opened one, or you cannot read the document (the address rides on it): " +
|
||||
JSON.stringify(doc),
|
||||
);
|
||||
}
|
||||
return post(target, opts);
|
||||
}
|
||||
|
||||
|
||||
// --- cap delivery ---------------------------------------------------------
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 }`,
|
||||
|
||||
Reference in New Issue
Block a user