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.
8.1 KiB
Brief — a document's inbox has to be addressable by anyone holding the document
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, andinbox.postinto it. The address is published at creation, in a compartment the library owns — so it never enters a consumer shape.On the cost — and the wrong answer that was tried first. The measured regression (9m37 → 21m30) came from creating a second DOCUMENT per document. The first fix pointed every document's published address at the owner's own inbox: no second document, cost amortized. It was reverted the same day, because it emulates a relation upstream cannot express — the verifier routes an incoming message by
inboxes: PubKey → RepoIdand unseals it with THAT repo's private half (engine/verifier/src/verifier.rs:1677,1928), andInboxMsgBodycarries no target document (engine/net/src/types.rs:4265) because the address already identifies it. Many documents behind one inbox would have forced consumers to tag deposits with their document — a habit to unlearn at migration, which is precisely what this library exists to prevent.The cost, actually answered: only documents meant to RECEIVE open an inbox, and their owner is who knows.
createEntityDocpublishes nothing; an app callsopenDocumentInbox(doc)for the documents that need one (in the consumer's case: events, not every entity). Cost becomes proportional to the need, with the shape intact.Shape 1 of this brief (derivation) was not available: our inbox is a document, and a derived NURI would name a repo
doc_createnever created — upstream can derive because an inbox there is a keypair on the repo, not a document.
inbox.postToDocument(doc, { payload })is the one call an app makes: it names the DOCUMENT, never an inbox, and throws when the document has no inbox rather than returning quietly — a deposit that vanishes without an error is the bug this whole path exists to close. There is deliberately no target-document field on a deposit, for the reason above.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-modelnow drops every subject under the reservedurn: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
AddInboxCapcommits in the engine are for the public and protected STORE repos (engine/verifier/src/site.rs:128,149),new_store_defaultattaches one onlyif !private(engine/verifier/src/verifier.rs:2994), anddoc_createleavesinbox: None(engine/repo/src/repo.rs:574). What IS upstream is the shape that makes this a defensible anticipation rather than a fiction:AddInboxCapV0is keyed byrepo_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(formerlydocumentInbox) 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.
Why that breaks the consumer
Festipod's central act is signing up to someone else's event. The participant deposits into the event's inbox; the owner drains it at their next connection and materializes the count. With addressing scoped to the caller, only the owner can deposit into their own event — every other sign-up is silently lost.
The same wall stands in front of the directory document the app needs to rebuild discovery: creators must deposit a reference into a document they do not own.
What was tried in the app, and why it was wrong
The consumer published the address on the document itself: resolve documentInbox(eventDoc) once at creation, write the NURI into the public event, and have participants read it from there instead of deriving it.
It works. It is still the wrong place, for three reasons — and the third is the one that settles it.
- It puts infrastructure in the domain. The
Eventshape starts carrying a technical address, and the domain model encodes a temporary state of the library. - It costs.
documentInboxcreates a document. Publishing the address at creation takes every event creation from one document to two — and document creation is a round-trip that does not overlap. Measured on the test suite: the@datarun went from 9m37 to 21m30, with sign-up scenarios timing out on their setup step (4 failures out of 7 on a fresh profile, so not test-wallet bloat). That cost belongs to whoever can amortize or defer it. The app can do neither, because it does not own the mechanism. - The library already claims this job. The commit that introduced per-document inboxes says: "Inboxes belong to someone — the user's own, plus one per document — and connecting a user drains them all; that is the library's job, not the app's." Draining was taken as the library's job. Addressing was left to the caller. Those two halves belong together.
The consumer's own doctrine says the same thing from the other side: when something does not work, the question is never how to work around it in the app, but what the library has to compensate. The app-side change has been reverted.
What is being asked
Given a document, any holder of that document should be able to name the inbox its owner reads — without owning it, and without being handed the address out of band.
How is the library's call. Two shapes come to mind, neither prescriptive:
- Make the address derivable from the document, so
documentInbox(doc)returns the same NURI for everyone, and only the owner can read it — the read guard already enforces that, and it is where the asymmetry belongs. - Or have the library publish the address itself, at document creation, somewhere it controls — so it stays out of the consumer's shapes and the library keeps the freedom to make it lazy.
The second keeps the current create-time cost unless it is deferred; the first has none, and matches how the rest of the model works — an overlay is derived, a keyring is looked up, nothing is handed over out of band.
Open question worth settling first
Is "a document has an inbox addressable by any holder" part of NextGraph's target model, or an invention of this library?
If it is upstream, this is emulation to align. If it is the library's own, it is a design decision to take deliberately — and the answer decides whether the note about it belongs in the shared NextGraph inbox as a gap, or here.
Consumer state meanwhile
Sign-ups across identities do not converge, and the app does not pretend otherwise. Nothing in Festipod works around it.