diff --git a/README.md b/README.md index 67a00a7..0368572 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/briefs/2026-08-03-document-inbox-addressing.md b/docs/briefs/2026-08-03-document-inbox-addressing.md index bcae1e3..c380e11 100644 --- a/docs/briefs/2026-08-03-document-inbox-addressing.md +++ b/docs/briefs/2026-08-03-document-inbox-addressing.md @@ -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. > diff --git a/docs/simulation.md b/docs/simulation.md index aa451ad..2a94a2b 100644 --- a/docs/simulation.md +++ b/docs/simulation.md @@ -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 diff --git a/packages/client/docs/sdk-reference.md b/packages/client/docs/sdk-reference.md index 716bc25..53ab113 100644 --- a/packages/client/docs/sdk-reference.md +++ b/packages/client/docs/sdk-reference.md @@ -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 diff --git a/packages/client/e2e/run.ts b/packages/client/e2e/run.ts index d3613b1..e2b6786 100644 --- a/packages/client/e2e/run.ts +++ b/packages/client/e2e/run.ts @@ -255,7 +255,7 @@ async function main(): Promise { "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)}`, ); diff --git a/packages/client/e2e/sdk-entry.ts b/packages/client/e2e/sdk-entry.ts index 27122ca..9183403 100644 --- a/packages/client/e2e/sdk-entry.ts +++ b/packages/client/e2e/sdk-entry.ts @@ -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 { diff --git a/packages/client/src/inbox.ts b/packages/client/src/inbox.ts index a8fe16c..13bbd5c 100644 --- a/packages/client/src/inbox.ts +++ b/packages/client/src/inbox.ts @@ -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 } } +/** + * 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 { + 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 --------------------------------------------------------- /** diff --git a/packages/client/src/store-registry.ts b/packages/client/src/store-registry.ts index efdc910..4862077 100644 --- a/packages/client/src/store-registry.ts +++ b/packages/client/src/store-registry.ts @@ -957,32 +957,30 @@ export async function createEntityDoc(id: string, scope: Scope): Promise { } // …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`), + * 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 { const s = await session(); @@ -990,9 +988,7 @@ async function publishInboxAddress(doc: Nuri, inbox: Nuri): Promise { // 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 }`, diff --git a/packages/client/test/cross-user-access.test.ts b/packages/client/test/cross-user-access.test.ts index db27dfc..3f10327 100644 --- a/packages/client/test/cross-user-access.test.ts +++ b/packages/client/test/cross-user-access.test.ts @@ -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 () => {