fix(client): per-entity write round-trip + dedicated inbox anchor

Real-broker validation of the per-document model surfaced round-trip breaks the
fake-ng unit tests missed:
- The inbox anchor was the shim graph itself, making loadShim ~60s; give the
  inbox its own document so anchor resolution is fast and isolated.
- store-registry adjustments so per-entity documents created via the SDK are
  indexed and readable back through the scope fan-out.
docs/simulation.md updated. 89 tests pass; tsc rc=0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sylvain Duchesne
2026-07-04 17:26:31 +02:00
parent bf753770b8
commit b1d06b68b9
3 changed files with 38 additions and 9 deletions
+9 -3
View File
@@ -96,9 +96,15 @@ 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,
the consumer is unchanged. the consumer is unchanged.
- **`resolveInboxAnchor()`** — the anchor where emulated inbox deposits land - **`resolveInboxAnchor()`** — the anchor where emulated inbox deposits land: a
(today the shared wallet's private store — a real repo NURI, required because **DEDICATED inbox document** (a reserved account's public scope document, from
the broker rejects a `urn:` anchor). At migration it becomes the host's native `docCreate` — a real repo NURI, stable across clients), **not** the shared
wallet's private-store root. Why dedicated: the shim (the account→document trust
root) lives in the private-store graph and is scanned on every `loadShim`;
routing every inbox deposit into that SAME graph bloats it without bound
(thousands of deposit triples across sessions), turning `loadShim` into a
multi-second full-graph scan. A separate inbox document keeps the shim graph
small and the deposits isolated. At migration it becomes the host's native
inbox NURI. inbox NURI.
Both resolve the native store ids from the **injected session** Both resolve the native store ids from the **injected session**
+21 -5
View File
@@ -315,15 +315,31 @@ export async function resolveScopeGraph(scope: Scope): Promise<Nuri> {
return scopeStoreNuri(scope); return scopeStoreNuri(scope);
} }
/**
* The reserved account that OWNS the shared registration-inbox document. Like the
* discovery index's special account, it lives in the reserved namespace (no user
* can produce this key) and only HOSTS a document — its `public` scope document is
* the inbox anchor. Disappears at migration (native per-document inboxes).
*/
const INBOX_ANCHOR_ACCOUNT = reservedAccount("inbox");
/** /**
* The inbox anchor NURI for the current session (where emulated inbox deposits * The inbox anchor NURI for the current session (where emulated inbox deposits
* physically land). SDK-shaped: the consumer never resolves the private store * physically land). SDK-shaped: the consumer never resolves a store itself.
* itself. In the polyfill this is the shared wallet's private store (a real *
* repo NURI — required because the broker rejects a `urn:` anchor); at * This is a DEDICATED inbox DOCUMENT (a reserved account's public scope document —
* migration it becomes the host's native inbox and this resolution moves here. * a real repo NURI from `docCreate`, stable across clients via the shim), NOT the
* shared wallet's private-store root. Reason (perf + hygiene): the shim (the
* account→document trust root) lives in the private-store graph and is scanned on
* every `loadShim`; routing every inbox deposit into that SAME graph bloats it
* without bound (thousands of deposit triples across sessions), turning `loadShim`
* into a multi-second full-graph scan. A separate inbox document keeps the shim
* graph small and the deposits isolated. At migration this becomes the host's
* native per-document inbox and the resolution moves here.
*/ */
export async function resolveInboxAnchor(): Promise<Nuri> { export async function resolveInboxAnchor(): Promise<Nuri> {
return scopeStoreNuri("private"); const record = await ensureAccount(INBOX_ANCHOR_ACCOUNT);
return record.docPublic;
} }
// --- per-entity documents + per-scope index ------------------------------- // --- per-entity documents + per-scope index -------------------------------
+8 -1
View File
@@ -196,7 +196,14 @@ test("resolveScopeGraph maps scopes to native store NURIs (no store-id leaks to
expect(await resolveScopeGraph("private")).toBe("did:ng:PRIV"); expect(await resolveScopeGraph("private")).toBe("did:ng:PRIV");
expect(await resolveScopeGraph("protected")).toBe("did:ng:PROT"); expect(await resolveScopeGraph("protected")).toBe("did:ng:PROT");
expect(await resolveScopeGraph("public")).toBe("did:ng:PROT"); // co-located expect(await resolveScopeGraph("public")).toBe("did:ng:PROT"); // co-located
expect(await resolveInboxAnchor()).toBe("did:ng:PRIV"); // The inbox anchor is now a DEDICATED inbox DOCUMENT (a reserved account's
// public scope doc, from docCreate) — NOT the private-store root — so inbox
// deposits don't bloat the shim graph. It is a real repo NURI and STABLE
// across calls (same reserved account → same document).
const anchor = await resolveInboxAnchor();
expect(anchor).toMatch(/^did:ng:o:doc/);
expect(anchor).not.toBe("did:ng:PRIV");
expect(await resolveInboxAnchor()).toBe(anchor); // stable
}); });
test("resolveScopeGraph falls back to the private store when no protected id is injected", async () => { test("resolveScopeGraph falls back to the private store when no protected id is injected", async () => {