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
+29
View File
@@ -77,6 +77,35 @@ The application code is written as if the target NextGraph existed. All
compensation lives here, beside the app. As NextGraph matures, this layer falls
away; the app code (SDK-shaped) is unchanged.
**Both halves are binding, and the second is the one that gets traded away.** The
SURFACE must be as close as possible to the future SDK — that much is obvious, it is
what the consumer codes against. But the IMPLEMENTATION must be as close as possible to
what NextGraph actually plans, and there is no exception to that. Where upstream's
behaviour is known, it is a specification, not a reference: **when it is known, hold to
it**. What "known" means here is narrow — read in `nextgraph-rs` or stated by the
NextGraph developer, never inferred from what an npm package happens to expose, and
never inferred from an absent implementation ("the engine has no X" says nothing about
whether the target will).
The pressure to deviate never announces itself as a deviation. It shows up as a cost, a
latency, an ergonomic wrinkle — a real one. Two instances, both caught only by asking
the question:
- *Every document has a native inbox* was written into the docs from general
reasoning. It is false, and it had already become an implementation.
- A per-document inbox was made to point at **the owner's** inbox, to avoid a measured
cost (9m37 → 21m30 on the consumer's suite). It emulates a many-to-one relation
upstream cannot express: the verifier routes by `inboxes: PubKey → RepoId` and unseals
with that one repo's key (`engine/verifier/src/verifier.rs:1677,1928`), and a message
carries no target document because it needs none. Reverted. The cost was then solved
without touching the shape — only documents meant to receive open an inbox.
The tell in both: an implementation choice that would make the consumer learn something
it must **unlearn** at migration. That is the thing this library exists to prevent, so
it outranks cost, latency and convenience. When the shape and the cost conflict, keep
the shape and attack the cost elsewhere — and if it truly cannot be solved, say so
rather than bend the model quietly.
- SDK-identical surface: the client wraps the real `ng` (a Proxy that forwards
everything and overrides only what must be emulated) and `useShape`. The real
SDK is injected via `configure()` (no hard import → build-alias safe and
@@ -6,9 +6,13 @@
>
> `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.
> **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.
>
> **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.
> **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/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.
>
+17 -10
View File
@@ -217,16 +217,23 @@ store-id:
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.
A document that has an inbox carries its address 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`). This mirrors upstream's split: a depositor seals with the inbox
PUBLIC key and needs nothing else, only the owner holds the private half.
**One inbox belongs to one document** — never several documents behind one inbox, a
relation upstream cannot express (the verifier routes by `inboxes: PubKey → RepoId`
and unseals with that repo's key, `engine/verifier/src/verifier.rs:1677,1928`), which
is also why a deposit carries no target document: the address identifies it. A fresh
document therefore has NO inbox and `documentInboxAddress` returns `undefined` — its
owner opens one when the document is meant to receive, which is what keeps the cost
proportional. At migration the address becomes the repo's native inbox pubkey and the
resolution moves; the consumer-facing act is unchanged.
- **`inbox.postToDocument(doc, { payload })`** — the one call an app makes to reach a
document's owner: it names the DOCUMENT, never an inbox. **Throws** when the document
has no inbox, rather than returning quietly: a deposit that vanishes without an error
is the exact bug this path shipped with.
Both resolve the native store ids from the injected session
(`RegistrySession.protectedStoreId` / `publicStoreId`, alongside the existing
+13 -8
View File
@@ -239,18 +239,23 @@ Consequences a consumer must internalize:
are this library's, not the engine's: upstream only the public and protected store
repos carry one (`engine/verifier/src/site.rs:128,149`).*
Depositing into a document you do not own is two calls, and the first is the one that
makes it possible at all:
Depositing into a document you do not own is **one** call, and it names the document:
```ts
const where = await storeRegistry.documentInboxAddress(doc); // Nuri | undefined
if (where) await inbox.post(where, { payload: { signingUp: true } });
await inbox.postToDocument(doc, { payload: { signingUp: true } });
```
You need the **document** (its cap), nothing else — the address rides on it and is
published from creation. `undefined` means you cannot read the document, not that the
owner is unreachable. Reading that inbox is a different right, and it stays the
owner's (`inbox.read` refuses otherwise).
You need the **document** (its cap), nothing else — the address rides on it. It
**throws** if the document has no inbox: its owner opens one with
`storeRegistry.openDocumentInbox(doc)` for documents meant to receive, so a fresh
document has none. When "no inbox" is an expected case, check first with
`storeRegistry.documentInboxAddress(doc)` (→ `Nuri | undefined`).
A deposit carries no target document, deliberately — one inbox belongs to one
document, so the address already identifies it, exactly as upstream (`inboxes:
PubKey → RepoId`). Do not encode the document in your payload; you would have to
unlearn it. Reading that inbox is a different right, and it stays the owner's
(`inbox.read` refuses otherwise).
The consumer asks the SDK for what it needs and trusts the result; it does not
construct NURIs, pick union-vs-anchor, or reason about caps. The domain-shaped list
+1 -1
View File
@@ -255,7 +255,7 @@ async function main(): Promise<void> {
"the depositor RESOLVES the same inbox from the document, deposits into it, and the address stays out of the data",
r.sameInbox === true &&
r.openRefused === true &&
JSON.stringify(r.deposits) === JSON.stringify([{ joining: true }]) &&
JSON.stringify(r.deposits) === JSON.stringify([{ viaPostToDocument: true }, { joining: true }]) &&
!r.props.some((p: string) => p.startsWith("urn:ng-eventually:")),
`sameInbox=${r.sameInbox} openRefused=${r.openRefused} deposits=${JSON.stringify(r.deposits)} props=${JSON.stringify(r.props)}`,
);
+2
View File
@@ -822,6 +822,8 @@ const identity = new IdentityStore(
setCurrentUser(depositorId);
getCaps().learn(link);
const resolved = await storeRegistry.documentInboxAddress(doc);
// The one-call form an app actually uses: it names the DOCUMENT, never an inbox.
await inbox.postToDocument(doc, { payload: { viaPostToDocument: true }, ts: 900 });
// Opening one on someone else's document must be refused, not silently forked.
let openRefused = false;
try {
+35 -1
View File
@@ -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 ---------------------------------------------------------
/**
+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 }`,
+15 -13
View File
@@ -38,7 +38,7 @@ import {
shareCap,
connectedUser,
} from "../src/polyfill";
import { post, read as readInbox } from "../src/inbox";
import { post, postToDocument, read as readInbox } from "../src/inbox";
import { readUnion } from "../src/read-model";
import { sparqlUpdate } from "../src/docs";
import type { Nuri } from "../src/types";
@@ -444,36 +444,38 @@ test("opening an inbox on someone else's document is refused, not silently forke
expect(await documentInboxAddress(doc)).toBe(aliceInbox);
});
test("a document is addressable from creation — its owner's inbox, no second document", async () => {
test("a fresh document has NO inbox — one belongs to one document, and only its owner opens it", async () => {
inject();
setCurrentUser("alice");
const aliceInbox = await walletInbox("alice");
const doc = await createEntityDoc("alice", "public");
const link = capFor(doc)!;
// No `openDocumentInbox` anywhere: a depositor must not have to wait for the owner
// to open one. This is the window the consumer's central act falls into — signing up
// to someone else's document, before that owner ever touched an inbox.
// Not "the owner's inbox by default": upstream an inbox belongs to exactly ONE repo
// (the verifier routes by `inboxes: PubKey → RepoId`), so pointing several documents
// at one inbox is a relation the model cannot express.
setCurrentUser("bob");
getCaps().learn(link);
expect(await documentInboxAddress(doc)).toBe(aliceInbox);
expect(await documentInboxAddress(doc)).toBeUndefined();
// …and depositing THROWS rather than vanishing — a lost deposit is the bug this
// whole path exists to close.
await expect(postToDocument(doc, { payload: { x: 1 } })).rejects.toThrow(/has no inbox/i);
});
test("opening a dedicated inbox REPLACES the published address — it never accumulates", async () => {
test("opening an inbox publishes ONE address, and re-opening does not accumulate", async () => {
inject();
setCurrentUser("alice");
const aliceInbox = await walletInbox("alice");
const doc = await createEntityDoc("alice", "public");
expect(await documentInboxAddress(doc)).toBe(aliceInbox); // the general one, first
const dedicated = await openDocumentInbox(doc);
expect(dedicated).not.toBe(aliceInbox);
expect(await openDocumentInbox(doc)).toBe(dedicated); // idempotent
// A depositor resolving now must reach the DEDICATED one, and only it: a stale
// address left beside the new one is a deposit written where nobody reads.
const link = capFor(doc)!;
setCurrentUser("bob");
getCaps().learn(link);
expect(await documentInboxAddress(doc)).toBe(dedicated);
// The deposit reaches the owner, addressed by the document alone.
await postToDocument(doc, { payload: { signingUp: true } });
setCurrentUser("alice");
expect((await readInbox(dedicated)).map((d) => d.payload)).toEqual([{ signingUp: true }]);
});
test("the inbox address is machinery: it never surfaces as the document's data", async () => {