Files
ng-eventually/docs/briefs/2026-08-03-document-inbox-addressing.md
Sylvain Duchesne 0b37d17c2f docs: reprendre les citations après le rangement, et retirer le membre login fabriqué
Les deux contrats et la doctrine portent des centaines de citations `file:line`
vers `src/`. Le rangement par destin les périmait en bloc — 208 reprises (45
chemins `src/x.ts`, 163 mentions nues). Sans ça la réorganisation pourrissait
l'instrument même qui tient la discipline qu'elle sert.

Et un défaut relevé par le contrat interne, vérifié : `ng-proxy` fabriquait un
membre `login`. `@ng-org/web` n'expose aucune méthode de ce nom — zéro
occurrence dans les déclarations installées comme dans `sdk/js/lib-wasm/src/lib.rs`
— mais le proxy répondait une fonction au lieu d'`undefined`, laquelle plantait
à l'appel. C'était le seul endroit où ce wrapper ajoutait à la surface du SDK,
contre son propre en-tête.

157 tests unitaires, typecheck src/test/e2e vert.
2026-08-04 12:56:02 +02:00

8.3 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, 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 — 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 → RepoId and unseals it with THAT repo's private half (engine/verifier/src/verifier.rs:1677,1928), and InboxMsgBody carries 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. createEntityDoc publishes nothing; an app calls openDocumentInbox(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_create never 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-model now drops every subject under the reserved urn:ng-eventually: namespace (src/emulated-verifier/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?"upstream, as a capability; ours only as a JS surface. The engine lets any repo have an inbox: inbox: Option<PrivKey> is a field of every Repo (engine/repo/src/repo.rs:126), AddInboxCapV0 is keyed by repo_id (engine/repo/src/types.rs:1973), and update_inbox_cap_v0 applies it with no is_store check (engine/verifier/src/verifier.rs:1920), at any time (User-branch commit, commit.rs:1043-1050, whose type documents the update case). What no code path does is CREATE one for a document: new_store_default attaches one only if !private (verifier.rs:2994), doc_create leaves inbox: None (repo.rs:574), and the engine's only two AddInboxCap commits are for the public and protected STORE repos (site.rs:128,149). "Does not" is not "cannot" — an earlier version of this note called the feature an anticipation on exactly that confusion. 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.

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 Event shape starts carrying a technical address, and the domain model encodes a temporary state of the library.
  • It costs. documentInbox creates 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 @data run 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.