feat(inbox): l'inbox d'un document est adressable par tout détenteur

Répond au brief 2026-08-03 remonté depuis le consommateur. `documentInbox(doc)`
répondait « quelle inbox est-ce que MOI je connais pour ce document » et en
créait une quand la réponse était « aucune » : un tiers n'atteignait jamais
l'inbox du propriétaire, il en obtenait une à lui, que personne ne lit, et son
dépôt disparaissait sans erreur. C'est l'acte central du consommateur —
s'inscrire à l'événement d'un autre — qui était silencieusement perdu.

Lire une inbox et savoir où y déposer sont deux actes opposés, avec des publics
opposés. Ils sont désormais deux fonctions :

- `openDocumentInbox(doc)` — le PROPRIÉTAIRE ouvre une inbox dédiée. Refuse sur
  la PROPRIÉTÉ (lue depuis les branches Store), pas sur la possession du cap :
  un cap se reçoit, et un destinataire ne doit pas pouvoir rediriger vers lui
  les dépôts destinés au propriétaire.
- `documentInboxAddress(doc)` — n'importe quel détenteur trouve où déposer. Ne
  crée jamais rien.

L'adresse est publiée dès la CRÉATION, sur la branche Header émulée du document
— un sujet réservé à l'intérieur du document, donc lisible par qui détient le
document. Publier seulement le jour où le propriétaire ouvre une inbox dédiée
laisserait une fenêtre pendant laquelle un tiers lit le document, ne trouve
aucune adresse, et ne peut pas joindre le propriétaire du tout.

Sur le coût mesuré par le brief (9m37 → 21m30) : il venait de la création d'un
DOCUMENT supplémentaire par document. L'adresse publiée pointe vers l'inbox
propre du propriétaire, qui existe déjà et s'amortit sur tous ses documents ;
la création grandit d'un triple, pas d'un document. Le dépôt porte le document
concerné, donc le propriétaire matérialise toujours par document. La forme
« dérivable » du brief n'était pas disponible : notre inbox est un document, et
un NURI dérivé nommerait un repo que `doc_create` n'a jamais créé.

Le tout reflète la séparation d'amont : un déposant scelle avec la clé PUBLIQUE
de l'inbox et n'a besoin de rien d'autre, seul le propriétaire détient la
moitié privée — une adresse est donc publique par nature.

`src/machinery.ts` : l'espace de noms `urn:ng-eventually:` que la bibliothèque
se réserve, et le prédicat que le chemin de lecture utilise. La branche Header
est le premier compartiment logé dans un document que le consommateur lit ;
`read-model` écarte désormais tout sujet de cet espace, par SUJET et non par
prédicat — ce qui couvre toutes les branches émulées, présentes et futures.

Question ouverte du brief, tranchée : « une inbox de document adressable par
tout détenteur » est une invention de cette bibliothèque, pas de l'amont — aucun
document n'y a d'inbox, ni le store privé. Ce qui EST vérifié, c'est la forme
qui rend l'anticipation défendable : `AddInboxCapV0` est clé par `repo_id`.

Tests : le test qui validait « n'importe qui dépose » passait le NURI d'inbox au
déposant par une variable du test — chemin qu'aucune app n'a. Réécrit avec les
deux acteurs cloisonnés : le déposant reçoit le lien du document, qui est la
seule chose qui circule dans ce modèle, et doit trouver l'adresse lui-même. Le
fake `ng` gagne le SELECT de la branche Header et le `DELETE WHERE` (sans quoi
un remplacement devenait une accumulation, précisément le bug qu'il évite).

157 tests unitaires, e2e 40/40 contre le broker en ligne.
This commit is contained in:
Sylvain Duchesne
2026-08-03 16:02:11 +02:00
parent e24a20cc46
commit 8a382f29f8
11 changed files with 487 additions and 26 deletions
@@ -186,7 +186,9 @@ So the store-root pointer, the doc-shim and the account records go through the m
7. ~~**Per-document inboxes**~~**DONE 2026-08-03.** Upstream a repo carries `inbox: Option<PrivKey>` (`engine/repo/src/repo.rs:126`): an inbox is a keypair on the document whose PRIVATE half the owner holds, recorded with `AddInboxCap { repo_id, overlay, priv_key }` on the **User branch** — the same branch as `AddLink`, and with the same stated purpose (*"so that a user can share with all its device"*). So "which inboxes may I read" has exactly one answer, and it is the one place to look. 7. ~~**Per-document inboxes**~~**DONE 2026-08-03.** Upstream a repo carries `inbox: Option<PrivKey>` (`engine/repo/src/repo.rs:126`): an inbox is a keypair on the document whose PRIVATE half the owner holds, recorded with `AddInboxCap { repo_id, overlay, priv_key }` on the **User branch** — the same branch as `AddLink`, and with the same stated purpose (*"so that a user can share with all its device"*). So "which inboxes may I read" has exactly one answer, and it is the one place to look.
`storeRegistry.documentInbox(doc)` resolves — creating on first ask — the inbox of a document this user owns, recording the pair on its User branch. **Lazy**: minting an inbox document for every entity up front would double every `createEntityDoc` for inboxes most documents never receive anything in. `myInboxes()` enumerates both levels, `isOwnInbox` answers from the same record, and `connect.connectedUser` drains them all in one call. *(Renamed and split on 2026-08-03 — `documentInbox` became `openDocumentInbox` (own the inbox) + `documentInboxAddress` (find where to deposit). Conflating the two made per-document inboxes unusable by anyone but their owner; see [`2026-08-03-document-inbox-addressing.md`](2026-08-03-document-inbox-addressing.md).)*
`storeRegistry.openDocumentInbox(doc)` resolves — creating on first ask — the inbox of a document this user owns, recording the pair on its User branch. **Lazy**: minting an inbox document for every entity up front would double every `createEntityDoc` for inboxes most documents never receive anything in. `myInboxes()` enumerates both levels, `isOwnInbox` answers from the same record, and `connect.connectedUser` drains them all in one call.
The asymmetry holds at both levels, and a test walks it: **anyone deposits** into a document's inbox (that is how a third party reaches its owner at all), **only the owner reads** it. The asymmetry holds at both levels, and a test walks it: **anyone deposits** into a document's inbox (that is how a third party reaches its owner at all), **only the owner reads** it.
@@ -2,6 +2,20 @@
**Raised 2026-08-03, from the consumer side (Festipod), after an attempt to solve it in the app proved it does not belong there.** **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.** 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.
>
> **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.
>
> **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.
>
> **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 `AddInboxCap` commits in the engine are for the public and protected STORE repos (`engine/verifier/src/site.rs:128,149`), `new_store_default` attaches one only `if !private` (`engine/verifier/src/verifier.rs:2994`), and `doc_create` leaves `inbox: None` (`engine/repo/src/repo.rs:574`). What IS upstream is the shape that makes this a defensible anticipation rather than a fiction: `AddInboxCapV0` is keyed by `repo_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` (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 ## 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. `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.
+27 -10
View File
@@ -200,16 +200,33 @@ store-id:
blocker, [`migration-guide.md`](./migration-guide.md)). At migration each scope blocker, [`migration-guide.md`](./migration-guide.md)). At migration each scope
resolves to the user's real per-scope store — the change is in this function, resolves to the user's real per-scope store — the change is in this function,
and the consumer application is unchanged. and the consumer application is unchanged.
- **`walletInbox(id)` / `documentInbox(doc)`** — an inbox BELONGS to someone. The - **`walletInbox(id)` / `openDocumentInbox(doc)`** — an inbox BELONGS to someone. The
first is a virtual user's own inbox (where Links arrive), the second the inbox of first is a user's own inbox (where Links arrive), the second a DEDICATED inbox for
one of its documents, created on first ask. Both are dedicated documents (real one of its documents, opened on demand by its **owner only** (ownership read from the
repo NURIs from `docCreate`), never the private-store root: routing deposits into Store branches — a received cap is not ownership, and a recipient must not be able to
the shim graph would bloat the account→document trust root without bound. redirect the owner's deposits to itself). Both are dedicated documents (real repo
`myInboxes()` enumerates both levels — what `connect.ts` drains at connection — NURIs from `docCreate`), never the private-store root: routing deposits into the shim
and `isOwnInbox` answers from the same record. *(The former `resolveInboxAnchor`, graph would bloat the account→document trust root without bound. `myInboxes()`
a single inbox COMMON to every user, was removed on 2026-07-30: nothing may be enumerates both levels — what `connect.ts` drains at connection — and `isOwnInbox`
common but the mechanisms that make the virtual users work.)* At migration these answers from the same record. *(The former `resolveInboxAnchor`, a single inbox COMMON
become native per-document inboxes. to every user, was removed on 2026-07-30: nothing may be common but the mechanisms
that make the virtual users work.)*
- **`documentInboxAddress(doc)` — the DEPOSIT side, and the one a third party uses.**
Reading an inbox and finding where to deposit into it are opposite acts with opposite
audiences, and conflating them is what made per-document inboxes unusable at first:
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.
Both resolve the native store ids from the injected session Both resolve the native store ids from the injected session
(`RegistrySession.protectedStoreId` / `publicStoreId`, alongside the existing (`RegistrySession.protectedStoreId` / `publicStoreId`, alongside the existing
+13
View File
@@ -239,6 +239,19 @@ Consequences a consumer must internalize:
are this library's, not the engine's: upstream only the public and protected store 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`).* 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:
```ts
const where = await storeRegistry.documentInboxAddress(doc); // Nuri | undefined
if (where) await inbox.post(where, { 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).
The consumer asks the SDK for what it needs and trusts the result; it does not 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 construct NURIs, pick union-vs-anchor, or reason about caps. The domain-shaped list
helpers live in the consumer app; the SDK exposes the generic reactive/by-need read. helpers live in the consumer app; the SDK exposes the generic reactive/by-need read.
+12
View File
@@ -248,6 +248,18 @@ async function main(): Promise<void> {
check("watch fires when a deposit lands", after.fires > base.fires && after.lastLen >= 1, `fires=${after.fires} lastLen=${after.lastLen}`); check("watch fires when a deposit lands", after.fires > base.fires && after.lastLen >= 1, `fires=${after.fires} lastLen=${after.lastLen}`);
await sdk(frame, "inboxWatchStop"); await sdk(frame, "inboxWatchStop");
}); });
await step("a document's inbox: owner opens, a third party resolves and deposits", async () => {
const t = Date.now();
const r = await sdk<any>(frame, "documentInboxDeposit", "@owner-" + t, "@depositor-" + t);
check(
"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 }]) &&
!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)}`,
);
});
await step("inbox spoof guard", async () => { await step("inbox spoof guard", async () => {
const r = await sdk<any>(frame, "inboxSpoofGuard"); const r = await sdk<any>(frame, "inboxSpoofGuard");
check("post as another principal is rejected; self + anon allowed", r.spoofRejected && r.selfOk && r.anonOk, `spoof=${r.spoofRejected} self=${r.selfOk} anon=${r.anonOk}`); check("post as another principal is rejected; self + anon allowed", r.spoofRejected && r.selfOk && r.anonOk, `spoof=${r.spoofRejected} self=${r.selfOk} anon=${r.anonOk}`);
+42
View File
@@ -802,6 +802,48 @@ const identity = new IdentityStore(
* "receive" operation exists, and no principal is ever named to the registry. * "receive" operation exists, and no principal is ever named to the registry.
* Runs against the REAL broker inbox document, so it exercises the whole path. * Runs against the REAL broker inbox document, so it exercises the whole path.
*/ */
/**
* The DEPOSIT side of a document's inbox, end to end against the real broker: the
* owner opens it, a third party RESOLVES its address from the document itself and
* deposits, the owner reads it back.
*
* The point of the step is the resolution: nothing hands `depositorId` the address.
* It gets the document's link (which is what circulates in this model) and must find
* where to deposit on its own — which is exactly what a consumer app has to do, and
* what a unit test passing the NURI through a variable cannot prove.
*/
async documentInboxDeposit(ownerId: string, depositorId: string) {
storeRegistry.resetRegistryCache();
setCurrentUser(ownerId);
const doc = await storeRegistry.createEntityDoc(ownerId, "public");
const ownerInbox = await storeRegistry.openDocumentInbox(doc);
const link = capFor(doc)!; // the repo link the owner circulates
setCurrentUser(depositorId);
getCaps().learn(link);
const resolved = await storeRegistry.documentInboxAddress(doc);
// Opening one on someone else's document must be refused, not silently forked.
let openRefused = false;
try {
await storeRegistry.openDocumentInbox(doc);
} catch {
openRefused = true;
}
if (resolved) await inbox.post(resolved, { payload: { joining: true }, ts: 1000 });
setCurrentUser(ownerId);
const deposits = await inbox.read(ownerInbox);
// The address is machinery: it must not surface among the document's properties.
const subjects = await readModel.readUnion([doc]);
const props = Object.keys(subjects[0]?.props ?? {});
setCurrentUser(null);
return {
sameInbox: resolved === ownerInbox,
openRefused,
deposits: deposits.map((d) => d.payload),
props,
};
},
async capsShareCap(friendId: string) { async capsShareCap(friendId: string) {
const s = await sessionReady; const s = await sessionReady;
resetCaps(); resetCaps();
+42
View File
@@ -0,0 +1,42 @@
/**
* The namespace this library reserves for its OWN triples, and the one predicate a
* read path needs about it: *is this subject machinery, or is it the consumer's data?*
*
* ── Why this exists ────────────────────────────────────────────────────────
* The polyfill has no branches, so it emulates each of a repo's compartments with a
* distinct SUBJECT inside a document (`shim:index` for the store's Main branch,
* `shim:storeBranch`, `shim:userBranch`, `shim:headerBranch` — see `store-registry.ts`).
* That was invisible as long as those subjects only ever appeared in documents the
* consumer never reads through the data path — store documents and the doc-shim.
*
* The Header branch broke that: it lives in an ENTITY document, the one the consumer
* reads with `SELECT ?s ?p ?o`. Without a filter, the address of a document's inbox
* would surface as one of that entity's properties — machinery leaking into domain
* data. Filtering by SUBJECT rather than by predicate is what makes this hold for
* every compartment, present and future: a new emulated branch needs no new filter.
*
* Upstream this problem does not exist, because there the separation is real — a
* branch is a different CRDT with its own topic, not a subject in the same graph. This
* module is the seam where our emulation pays for that.
*/
/**
* The URN namespace every triple this library writes for itself lives under —
* `urn:ng-eventually:shim:…` (store-registry's compartments) and
* `urn:ng-eventually:inbox:…` (inbox deposits).
*
* A consumer that writes its own data under this prefix would have it filtered out of
* its reads. That is a deliberate reservation, not a hazard to guard against: the
* namespace names this library.
*/
export const MACHINERY_NS = "urn:ng-eventually:";
/**
* Is `subject` one of this library's own, rather than consumer data?
*
* Tolerant of `undefined` so a read path can hand it a possibly-absent binding
* without a preliminary check — an absent subject is not machinery.
*/
export function isMachinerySubject(subject: string | undefined): boolean {
return subject !== undefined && subject.startsWith(MACHINERY_NS);
}
+6
View File
@@ -46,6 +46,7 @@ import { getCaps, getStoreRegistryDeps } from "./polyfill";
import { mustNotAttempt } from "./reach"; import { mustNotAttempt } from "./reach";
import { ensureReposOpen } from "./open-repo"; import { ensureReposOpen } from "./open-repo";
import { assertNuri } from "./sparql"; import { assertNuri } from "./sparql";
import { isMachinerySubject } from "./machinery";
import type { Nuri } from "./types"; import type { Nuri } from "./types";
// Keep the primitives referenced so tree-shaking never drops the import used by // Keep the primitives referenced so tree-shaking never drops the import used by
@@ -172,6 +173,11 @@ export async function readUnion(docs: Nuri[]): Promise<UnionSubject[]> {
// (writeEntity invariant). Pin subject/graph to the doc NURI (the anchor), which // (writeEntity invariant). Pin subject/graph to the doc NURI (the anchor), which
// is stable regardless of the repo_graph_name overlay suffix the store carries. // is stable regardless of the repo_graph_name overlay suffix the store carries.
for (const row of rows) { for (const row of rows) {
// The polyfill's own compartments live as reserved SUBJECTS inside the very
// documents the consumer reads (the Header branch carrying a document's inbox
// address is the first). They are machinery, not this entity's properties —
// drop them here, once, for every compartment present and future.
if (isMachinerySubject(row.s?.value)) continue;
const p = row.p?.value; const p = row.p?.value;
const o = row.o?.value; const o = row.o?.value;
if (!p || o === undefined) continue; if (!p || o === undefined) continue;
+167 -6
View File
@@ -67,6 +67,7 @@ import { getCaps, getCurrentUser, getStoreRegistryDeps } from "./polyfill";
import { ensureRepoOpen, ensurePhysicalRepoOpen } from "./open-repo"; import { ensureRepoOpen, ensurePhysicalRepoOpen } from "./open-repo";
import { escapeLiteral, escapeIri, assertNuri } from "./sparql"; import { escapeLiteral, escapeIri, assertNuri } from "./sparql";
import { hasReadCap, isNuri, mintCap } from "./nuri"; import { hasReadCap, isNuri, mintCap } from "./nuri";
import { mustNotAttempt } from "./reach";
import { accessLogPrefix, logStage, shortNuri } from "./access-log"; import { accessLogPrefix, logStage, shortNuri } from "./access-log";
import type { Nuri, ReadCap, Scope } from "./types"; import type { Nuri, ReadCap, Scope } from "./types";
@@ -105,6 +106,7 @@ const P = {
link: `${SHIM}:link`, // user branch → a ReadCap received for an EXTERNAL document link: `${SHIM}:link`, // user branch → a ReadCap received for an EXTERNAL document
readCap: `${SHIM}:readCap`, // store branch → the ReadCap of a document IN this store readCap: `${SHIM}:readCap`, // store branch → the ReadCap of a document IN this store
inboxCap: `${SHIM}:inboxCap`, // user branch → an inbox this user may READ inboxCap: `${SHIM}:inboxCap`, // user branch → an inbox this user may READ
inboxAddress: `${SHIM}:inboxAddress`, // header branch → WHERE to deposit for this document
} as const; } as const;
// Fixed subject of the per-(account×scope) index document. The index doc plays // Fixed subject of the per-(account×scope) index document. The index doc plays
// the role of the future store-container: it lists the NURIs of the entity // the role of the future store-container: it lists the NURIs of the entity
@@ -142,6 +144,34 @@ const USER_BRANCH_SUBJECT = `${SHIM}:userBranch`;
* keys stay separate. * keys stay separate.
*/ */
const STORE_BRANCH_SUBJECT = `${SHIM}:storeBranch`; const STORE_BRANCH_SUBJECT = `${SHIM}:storeBranch`;
/**
* Fixed subject of the **Header branch** emulation, inside an ENTITY document — the
* first compartment we put in a document the consumer also reads, hence the filter in
* `read-model.ts` (every `${SHIM}:` subject is machinery and never surfaces as data).
*
* It carries what must be readable by *whoever can read the document*, as opposed to
* what belongs to its owner alone. Today that is one thing: the ADDRESS of the
* document's inbox.
*
* Why the address must live here and not on the owner's User branch. Upstream an inbox
* is a KEYPAIR (`repo.inbox: Option<PrivKey>`, `engine/repo/src/repo.rs:126`) and the
* two halves have opposite audiences: a depositor seals with the PUBLIC key
* (`InboxMsg::new` → `crypto_box::seal(&to_inbox.to_dh_slice(), …)`,
* `engine/net/src/types.rs:4299`) and needs nothing else; only the owner holds the
* private half (`AddInboxCap`, on the User branch). An address is therefore public by
* nature — upstream it travels with the profile (`ContactDetails` carries
* `ng:site_inbox` / `ng:protected_inbox`, `engine/verifier/src/inbox_processor.rs:823`).
* Keeping it only on the owner's User branch, as this lib first did, made the deposit
* side unreachable: a third party had no way to learn where to deposit.
*
* **Not `BranchType::Header` upstream.** That branch exists (`engine/repo/src/types.rs:1551`)
* but is CLOSED: `update_header` writes only `title`/`about`
* (`engine/verifier/src/request_processor.rs:173-211`) and `fetch_header` reads back
* only `title`/`about`/`class` (`:1240-1284`). It cannot carry an inbox address. The
* name is borrowed for the shape — a compartment of the document that is not its
* content — not for the upstream branch's contract.
*/
const HEADER_BRANCH_SUBJECT = `${SHIM}:headerBranch`;
// --- pointer (store-root → doc-shim indirection) -------------------------- // --- pointer (store-root → doc-shim indirection) --------------------------
// //
@@ -927,9 +957,59 @@ export async function createEntityDoc(id: string, scope: Scope): Promise<Nuri> {
} }
// …and the creator holds THAT cap for this session. // …and the creator holds THAT cap for this session.
holdOwnCap(id, scope, entityNuri, cap); 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.
//
// 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));
return entityNuri; 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.
*/
async function publishInboxAddress(doc: Nuri, inbox: Nuri): Promise<void> {
const s = await session();
try {
// 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.
await sparqlUpdate(
s.sessionId,
`DELETE WHERE { <${HEADER_BRANCH_SUBJECT}> <${P.inboxAddress}> ?a }`,
doc,
"publishInboxAddress:clear",
);
await sparqlUpdate(
s.sessionId,
`INSERT DATA { <${HEADER_BRANCH_SUBJECT}> <${P.inboxAddress}> "${escapeLiteral(inbox)}" }`,
doc,
"publishInboxAddress",
);
} catch (error) {
console.error(accessLogPrefix() + " publishInboxAddress failed:", error);
}
}
/** /**
* The ReadCaps recorded on a store's Store branch — its documents, each with its * The ReadCaps recorded on a store's Store branch — its documents, each with its
* key. The emulated replay of `AddRepo`, and the reason a fresh session recovers * key. The emulated replay of `AddRepo`, and the reason a fresh session recovers
@@ -1038,15 +1118,31 @@ export async function userStoreDoc(id: string, scope: Scope): Promise<Nuri> {
* in. Upstream the keypair is cheap; here an inbox is a document, so it is minted * in. Upstream the keypair is cheap; here an inbox is a document, so it is minted
* when first asked for. * when first asked for.
* *
* Only for documents this user holds — you cannot open an inbox on someone else's * Only for a document this user OWNS — see {@link ownsDocument}. Opening an inbox on
* document, you can only deposit into it. * someone else's document would be usurpation, not a courtesy: the opener keeps the
* reading half, so it would silently divert to itself the deposits meant for the
* owner. To deposit into someone else's document, resolve
* {@link documentInboxAddress} and `inbox.post` into it.
*/ */
export async function documentInbox(doc: Nuri): Promise<Nuri> { export async function openDocumentInbox(doc: Nuri): Promise<Nuri> {
const holder = getCurrentUser(); const holder = getCurrentUser();
if (holder === null) throw new Error("[ng-eventually] documentInbox: no identity is set"); if (holder === null) throw new Error("[ng-eventually] openDocumentInbox: no identity is set");
const known = (await readInboxCapsFor(doc)) ?? null; const known = (await readInboxCapsFor(doc)) ?? null;
if (known) return known; if (known) return known;
// OWNERSHIP is the criterion — not "is there an address yet", since every document
// carries one from creation (its owner's inbox). Opening a dedicated inbox REPLACES
// that address, so letting a non-owner do it would redirect the owner's deposits to
// the caller: usurpation, silent, and on a document the caller merely reads.
// Holding a cap is not ownership; a cap can be received.
if (!(await ownsDocument(doc))) {
throw new Error(
"[ng-eventually] openDocumentInbox: refused — you may only open an inbox on a document " +
`you own. Deposit into its published address instead (storeRegistry.documentInboxAddress ` +
`then inbox.post): ${JSON.stringify(doc)}`,
);
}
const inbox = await createDoc(); const inbox = await createDoc();
const s = await session(); const s = await session();
const record = await ensureAccount(holder); const record = await ensureAccount(holder);
@@ -1058,15 +1154,80 @@ export async function documentInbox(doc: Nuri): Promise<Nuri> {
s.sessionId, s.sessionId,
`INSERT DATA { <${USER_BRANCH_SUBJECT}> <${P.inboxCap}> "${escapeLiteral(doc + " " + inbox)}" }`, `INSERT DATA { <${USER_BRANCH_SUBJECT}> <${P.inboxCap}> "${escapeLiteral(doc + " " + inbox)}" }`,
store, store,
"documentInbox", "openDocumentInbox",
); );
} catch (error) { } catch (error) {
console.error(accessLogPrefix() + " documentInbox persist failed:", error); console.error(accessLogPrefix() + " openDocumentInbox persist failed:", error);
} }
} }
// …and the PUBLIC half, in the document itself, so a depositor finds THIS inbox
// instead of the owner's general one that `createEntityDoc` published. Replacement,
// not addition: one document, one address.
await publishInboxAddress(doc, inbox);
return inbox; return inbox;
} }
/**
* WHERE to deposit for `doc` — its inbox address, or `undefined` if its owner never
* opened one. The deposit-side counterpart of {@link openDocumentInbox}, and the
* function an app calls before `inbox.post`.
*
* Readable by whoever can read the document, because it lives on its Header branch —
* an address is public by nature (upstream a depositor needs only the inbox PUBLIC
* key). Conversely someone who cannot read the document learns nothing, which is
* faithful too: upstream the inbox pubkey is not derivable from a RepoId, it has to
* reach you.
*
* **Never creates.** Asking where to deposit must not bring an inbox into existence —
* only its owner opens one, and only on its own document.
*/
export async function documentInboxAddress(doc: Nuri): Promise<Nuri | undefined> {
// RULE 2 — do not even attempt. Not holding the document, we have no address to
// find: upstream the inbox pubkey travels WITH what you can read, so "where do I
// deposit for a document I cannot read" is not a refused question, it is a question
// with no referent. Answering `undefined` here keeps the caller's shape (an address
// or none) instead of turning the boundary into an exception it must catch.
if (mustNotAttempt(doc)) return undefined;
const s = await session();
try {
const res = await sparqlQuery(
s.sessionId,
`SELECT ?a WHERE { <${HEADER_BRANCH_SUBJECT}> <${P.inboxAddress}> ?a }`,
undefined,
doc,
"documentInboxAddress",
);
for (const row of readBindings(res)) {
const a = bindingValue(row, "a");
if (a && isNuri(a)) return a;
}
} catch (error) {
// Unreadable document (no cap) or not synced → no address to give. Refusing to
// read is the boundary doing its job, not an error to propagate here.
console.error(accessLogPrefix() + " documentInboxAddress failed:", error);
}
return undefined;
}
/**
* Does the connected user own `doc`? Answered from its **Store branches** — the
* register of the documents it created — across the three scopes, which is the only
* place that records authorship. Holding a cap is NOT ownership: a cap can be
* received, and a recipient must not be able to open an inbox on what it merely reads.
*/
async function ownsDocument(doc: Nuri): Promise<boolean> {
const holder = getCurrentUser();
if (holder === null) return false;
const record = await resolveAccount(holder);
if (record === null) return false;
for (const scope of ["public", "protected", "private"] as const) {
const store = storeOf(record, scope);
if (!store) continue;
if ((await readUserStore(store)).includes(doc)) return true;
}
return false;
}
/** The `(document, inbox)` pairs recorded on this user's User branch. */ /** The `(document, inbox)` pairs recorded on this user's User branch. */
async function readInboxCapPairs(): Promise<Array<{ doc: Nuri; inbox: Nuri }>> { async function readInboxCapPairs(): Promise<Array<{ doc: Nuri; inbox: Nuri }>> {
const holder = getCurrentUser(); const holder = getCurrentUser();
+110 -9
View File
@@ -18,7 +18,13 @@
* no authorization list anywhere, and nobody was named to the registry. * no authorization list anywhere, and nobody was named to the registry.
*/ */
import { test, expect, mock, afterAll } from "bun:test"; import { test, expect, mock, afterAll } from "bun:test";
import { createEntityDoc, documentInbox, resetRegistryCache, walletInbox } from "../src/store-registry"; import {
createEntityDoc,
documentInboxAddress,
openDocumentInbox,
resetRegistryCache,
walletInbox,
} from "../src/store-registry";
import type { RegistrySession } from "../src/store-registry"; import type { RegistrySession } from "../src/store-registry";
import { import {
configure, configure,
@@ -76,6 +82,19 @@ function makeFakeNg() {
const query = a[1] as string; const query = a[1] as string;
const anchor = a[2] as string | undefined; const anchor = a[2] as string | undefined;
if (!anchor) return undefined; if (!anchor) return undefined;
// `DELETE WHERE { <s> <p> ?var }` — the form the lib uses to REPLACE a value
// (see docs/decisions/sparql-delete-for-orm-objects.md). Without this arm the
// fake would treat the delete as an insert and the replacement would silently
// become an accumulation — the exact bug a replacement exists to prevent.
const del = query.match(/^\s*DELETE\s+WHERE\s*\{\s*<([^>]+)>\s+<([^>]+)>\s+\?/);
if (del) {
const [s0, p0] = [del[1]!, del[2]!];
for (let i = quads.length - 1; i >= 0; i--) {
const q = quads[i]!;
if (q.g === anchor && q.s === s0 && q.p === p0) quads.splice(i, 1);
}
return undefined;
}
const body = query.replace(/^\s*INSERT DATA\s*\{/, "").replace(/\}\s*$/, ""); const body = query.replace(/^\s*INSERT DATA\s*\{/, "").replace(/\}\s*$/, "");
const sm = body.match(/<([^>]+)>/); const sm = body.match(/<([^>]+)>/);
if (!sm) return undefined; if (!sm) return undefined;
@@ -149,6 +168,10 @@ function makeFakeNg() {
if (query.includes(`<${SHIM}:inboxCap>`)) { if (query.includes(`<${SHIM}:inboxCap>`)) {
return { results: { bindings: quads.filter((q) => q.g === anchor && q.p === `${SHIM}:inboxCap`).map((q) => ({ c: { value: q.o } })) } }; return { results: { bindings: quads.filter((q) => q.g === anchor && q.p === `${SHIM}:inboxCap`).map((q) => ({ c: { value: q.o } })) } };
} }
// Header-branch `inboxAddress` SELECT (where to deposit for this document).
if (query.includes(`<${SHIM}:inboxAddress>`)) {
return { results: { bindings: quads.filter((q) => q.g === anchor && q.p === `${SHIM}:inboxAddress`).map((q) => ({ a: { value: q.o } })) } };
}
// Store-branch `readCap` SELECT (the emulated AddRepo records). // Store-branch `readCap` SELECT (the emulated AddRepo records).
if (query.includes(`<${SHIM}:readCap>`)) { if (query.includes(`<${SHIM}:readCap>`)) {
return { results: { bindings: quads.filter((q) => q.g === anchor && q.p === `${SHIM}:readCap`).map((q) => ({ c: { value: q.o } })) } }; return { results: { bindings: quads.filter((q) => q.g === anchor && q.p === `${SHIM}:readCap`).map((q) => ({ c: { value: q.o } })) } };
@@ -383,28 +406,96 @@ test("a document has its own inbox: anyone deposits, only the owner reads", asyn
inject(); inject();
setCurrentUser("alice"); setCurrentUser("alice");
const doc = await createEntityDoc("alice", "public"); const doc = await createEntityDoc("alice", "public");
const docInbox = await documentInbox(doc); const aliceInbox = await openDocumentInbox(doc);
expect(docInbox).not.toBe(await walletInbox("alice")); expect(aliceInbox).not.toBe(await walletInbox("alice"));
const link = capFor(doc)!; // the repo link alice circulates — links DO travel
// Bob deposits into the document's inbox — the cross-user act, open to all. // Bob RESOLVES the address himself, from the document. The only thing he is handed
// is the link, which is the one thing the model says circulates. The address is not
// passed to him — if it had to be, there would be no way for an app to get it.
setCurrentUser("bob"); setCurrentUser("bob");
await post(docInbox, { payload: { joining: true }, ts: 1 }); getCaps().learn(link);
const bobTarget = await documentInboxAddress(doc);
expect(bobTarget).toBe(aliceInbox); // …and it is the SAME inbox alice reads
await post(bobTarget!, { payload: { joining: true }, ts: 1 });
// …and cannot read it back: depositing grants nothing. // …and he cannot read it back: depositing grants nothing.
await expect(readInbox(docInbox)).rejects.toThrow(/does not belong to the connected wallet/i); await expect(readInbox(bobTarget!)).rejects.toThrow(/does not belong to the connected wallet/i);
// Alice reads her document's inbox, because she opened it. // Alice reads her document's inbox, because she opened it.
setCurrentUser("alice"); setCurrentUser("alice");
const deposits = await readInbox(docInbox); const deposits = await readInbox(aliceInbox);
expect(deposits.map((d) => d.payload)).toEqual([{ joining: true }]); expect(deposits.map((d) => d.payload)).toEqual([{ joining: true }]);
}); });
test("opening an inbox on someone else's document is refused, not silently forked", async () => {
inject();
setCurrentUser("alice");
const doc = await createEntityDoc("alice", "public");
const aliceInbox = await openDocumentInbox(doc);
const link = capFor(doc)!;
// Bob holds the document — that is a READ right, and it is not ownership.
setCurrentUser("bob");
getCaps().learn(link);
await expect(openDocumentInbox(doc)).rejects.toThrow(/already has an inbox|you may only open an inbox/i);
// The address he resolves is still alice's, so his deposits reach her.
expect(await documentInboxAddress(doc)).toBe(aliceInbox);
});
test("a document is addressable from creation — its owner's inbox, no second document", 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.
setCurrentUser("bob");
getCaps().learn(link);
expect(await documentInboxAddress(doc)).toBe(aliceInbox);
});
test("opening a dedicated inbox REPLACES the published address — it never accumulates", 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);
// 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);
});
test("the inbox address is machinery: it never surfaces as the document's data", async () => {
inject();
setCurrentUser("alice");
const doc = await createEntityDoc("alice", "public");
await write(doc, SECRET, "s1");
await openDocumentInbox(doc);
// The consumer read returns the entity's properties and nothing of the compartment
// that carries the address — the Header branch is beside the content, not in it.
const subjects = await readUnion([doc]);
const props = subjects[0]?.props ?? {};
expect(Object.keys(props)).toEqual([SECRET]);
});
test("connecting drains BOTH levels: the user's inbox and its documents'", async () => { test("connecting drains BOTH levels: the user's inbox and its documents'", async () => {
inject(); inject();
setCurrentUser("alice"); setCurrentUser("alice");
const protDoc = await createEntityDoc("alice", "protected"); const protDoc = await createEntityDoc("alice", "protected");
const pubDoc = await createEntityDoc("alice", "public"); const pubDoc = await createEntityDoc("alice", "public");
const docInbox = await documentInbox(pubDoc); const docInbox = await openDocumentInbox(pubDoc);
const aliceInbox = await walletInbox("alice"); const aliceInbox = await walletInbox("alice");
// Two deposits, one at each level, both made by someone else. // Two deposits, one at each level, both made by someone else.
@@ -422,3 +513,13 @@ test("connecting drains BOTH levels: the user's inbox and its documents'", async
const left = await readInbox(docInbox); const left = await readInbox(docInbox);
expect(left.map((d) => d.payload)).toEqual([{ onTheDocument: true }]); // consumer data stays expect(left.map((d) => d.payload)).toEqual([{ onTheDocument: true }]); // consumer data stays
}); });
// The same resolution property one level up: a user's own inbox.
test("a third party resolves another user's inbox (the wallet level)", async () => {
inject();
setCurrentUser("alice");
const aliceView = await walletInbox("alice");
setCurrentUser("bob");
const bobView = await walletInbox("alice");
expect(bobView).toBe(aliceView);
});
+51
View File
@@ -0,0 +1,51 @@
/**
* The reserved-namespace predicate, in isolation.
*
* It is one `startsWith`, but it is the seam that keeps the polyfill's emulated
* branches out of the consumer's data (see `machinery.ts`), so its edges are worth
* pinning: get it wrong in one direction and machinery leaks into domain properties;
* wrong in the other and real data silently disappears from reads.
*/
import { test, expect } from "bun:test";
import { MACHINERY_NS, isMachinerySubject } from "../src/machinery";
test("the emulated branch subjects are all machinery", () => {
// The four compartments store-registry emulates, verbatim.
for (const s of [
"urn:ng-eventually:shim:index",
"urn:ng-eventually:shim:storeBranch",
"urn:ng-eventually:shim:userBranch",
"urn:ng-eventually:shim:headerBranch",
]) {
expect(isMachinerySubject(s)).toBe(true);
}
});
test("inbox deposits are machinery too — a second prefix under the same namespace", () => {
expect(isMachinerySubject("urn:ng-eventually:inbox:deposit:1700:abc")).toBe(true);
});
test("consumer subjects are not machinery — including a NURI, which is what entities use", () => {
expect(isMachinerySubject("did:ng:o:doc1")).toBe(false);
expect(isMachinerySubject("urn:e2e:secret")).toBe(false);
expect(isMachinerySubject("http://example.org/thing")).toBe(false);
});
test("a look-alike prefix is NOT machinery — the boundary is exact, not fuzzy", () => {
// Anything that merely resembles the namespace must fall on the data side, or a
// consumer's own vocabulary could vanish from its reads.
expect(isMachinerySubject("urn:ng-eventuallyX:thing")).toBe(false);
expect(isMachinerySubject("urn:ng-event:thing")).toBe(false);
expect(isMachinerySubject("x-urn:ng-eventually:shim:index")).toBe(false);
});
test("an absent subject is not machinery — read paths hand bindings straight in", () => {
expect(isMachinerySubject(undefined)).toBe(false);
expect(isMachinerySubject("")).toBe(false);
});
test("the namespace is the prefix both writers actually use", () => {
// Guards against the constant drifting away from store-registry/inbox.
expect("urn:ng-eventually:shim".startsWith(MACHINERY_NS)).toBe(true);
expect("urn:ng-eventually:inbox".startsWith(MACHINERY_NS)).toBe(true);
});