feat(client): SDK-shaped scope resolvers (resolveScopeGraph/resolveInboxAnchor)

Expose a clean scope-based surface so consumers work by scope (public/protected/
private) and never see a physical store id — the library resolves placement and
performs the shared-wallet simulation internally. RegistrySession gains optional
protected/public store ids, supplied at the single injection point
(configureStoreRegistry). Zero domain knowledge. docs/simulation.md updated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sylvain Duchesne
2026-07-03 23:41:34 +02:00
parent bea9f51d91
commit e0d88b5076
3 changed files with 110 additions and 0 deletions
+29
View File
@@ -80,6 +80,35 @@ The `store≠document` two axes materialize here directly: the registry moves al
axis B (more documents = more isolation), never axis A (it always writes into the axis B (more documents = more isolation), never axis A (it always writes into the
one private store via `docCreate(..., undefined)`). one private store via `docCreate(..., undefined)`).
### SDK-shaped scope resolvers — the consumer holds NO store-id
The consumer must never construct a `did:ng:${store_id}` NURI itself: physical
placement is the lib's job (the whole point of the SDK boundary). Two resolvers
turn a **logical scope** into an **opaque graph NURI** without exposing any
store-id:
- **`resolveScopeGraph(scope)`** — the graph where the current session writes
entities of `scope`, and whose repo `useShape` subscribes to read them back.
Use the returned value as BOTH the read scope (`useShape(shape, nuri)`) and the
`@graph` write target. Placement lives HERE (Axis A): `private` → the private
native store; `public` + `protected` → the **protected** native store, because
`doc_create`/ORM cannot target a non-private/protected native store today (SDK
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,
the consumer is unchanged.
- **`resolveInboxAnchor()`** — the anchor where emulated inbox deposits land
(today the shared wallet's private store — a real repo NURI, required because
the broker rejects a `urn:` anchor). At migration it becomes the host's native
inbox NURI.
Both resolve the native store ids from the **injected session**
(`RegistrySession.protectedStoreId` / `publicStoreId`, alongside the existing
`privateStoreId` anchor). The consumer hands the whole session to the lib at the
ONE injection point (`configureStoreRegistry({ getSession })`) — that is wiring,
not placement logic; everything else in the consumer speaks only in scopes. If
the session omits `protectedStoreId`, the non-private scopes fall back to the
private store rather than emit a broken NURI.
## `RepoNotFound` and the `orm_start_graph` scope rule ## `RepoNotFound` and the `orm_start_graph` scope rule
A hard constraint inherited from the SDK: to read **and** write entities through A hard constraint inherited from the SDK: to read **and** write entities through
+52
View File
@@ -76,6 +76,11 @@ export interface RegistrySession {
sessionId: string; sessionId: string;
/** The shared wallet's private store id — the shim anchor. */ /** The shared wallet's private store id — the shim anchor. */
privateStoreId: string; privateStoreId: string;
/** The shared wallet's protected store id (native store). Optional: only the
* scope resolvers need it; the shim only needs the private anchor. */
protectedStoreId?: string;
/** The shared wallet's public store id (native store). Optional. */
publicStoreId?: string;
} }
function normalize(username: string): string { function normalize(username: string): string {
@@ -239,6 +244,53 @@ export async function resolveReadGraphs(scope: Scope): Promise<Nuri[]> {
return accounts.map((a) => indexDocOf(a, scope)); return accounts.map((a) => indexDocOf(a, scope));
} }
// --- SDK-shaped scope resolvers (no store-id ever leaves the lib) ----------
//
// The consumer asks by SCOPE ("give me the graph to write/read entities of
// scope X", "give me the inbox anchor") and NEVER constructs a `did:ng:${…}`
// store NURI itself. The lib owns the physical placement — which is the whole
// point of the SDK boundary. In THIS polyfill the placement is the shared
// wallet's native stores (Axis A, per the two-axes doctrine in
// docs/simulation.md): a scope maps to a native store NURI resolved from the
// injected session. `public` currently co-locates with `protected` because
// `doc_create`/ORM cannot target a non-private/protected native store today
// (the SDK blocker recorded in migration-guide.md); at migration each scope
// resolves to the user's REAL per-scope store and this mapping changes here,
// in the lib, with no consumer change.
/** The native store NURI backing `scope`, resolved from the injected session.
* Requires `protectedStoreId` on the session for the non-private scopes. */
async function scopeStoreNuri(scope: Scope): Promise<Nuri> {
const s = await session();
if (scope === "private") return `did:ng:${s.privateStoreId}`;
// public + protected → the protected native store (see note above). Falls
// back to the private store if the session didn't carry a protected id.
const store = s.protectedStoreId ?? s.privateStoreId;
return `did:ng:${store}`;
}
/**
* The graph NURI where the current session WRITES entities of `scope`, and
* whose repo `useShape` must subscribe to read them back. SDK-shaped: the
* consumer passes a logical scope and gets an opaque graph NURI — it holds no
* store-id and builds no NURI. Use the returned value as both the read scope
* (`useShape(shape, nuri)`) and the `@graph` write target.
*/
export async function resolveScopeGraph(scope: Scope): Promise<Nuri> {
return scopeStoreNuri(scope);
}
/**
* The inbox anchor NURI for the current session (where emulated inbox deposits
* physically land). SDK-shaped: the consumer never resolves the private store
* 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
* migration it becomes the host's native inbox and this resolution moves here.
*/
export async function resolveInboxAnchor(): Promise<Nuri> {
return scopeStoreNuri("private");
}
// --- per-entity documents + per-scope index ------------------------------- // --- per-entity documents + per-scope index -------------------------------
/** /**
@@ -5,6 +5,8 @@ import {
loadShim, loadShim,
resolveWriteGraph, resolveWriteGraph,
resolveReadGraphs, resolveReadGraphs,
resolveScopeGraph,
resolveInboxAnchor,
createEntityDoc, createEntityDoc,
listEntityDocs, listEntityDocs,
resetRegistryCache, resetRegistryCache,
@@ -177,6 +179,33 @@ test("resolveWriteGraph returns the per-scope index doc; resolveReadGraphs fans
expect(await resolveReadGraphs("public")).toEqual([rec.docPublic]); expect(await resolveReadGraphs("public")).toEqual([rec.docPublic]);
}); });
test("resolveScopeGraph maps scopes to native store NURIs (no store-id leaks to the caller)", async () => {
// Session with all three store ids: private → private store; public+protected
// co-locate on the protected native store (the polyfill's Axis-A placement).
const ng = makeFakeNg();
configure({ ng: ng as any, useShape: (() => {}) as any });
configureStoreRegistry({
getSession: async () => ({
sessionId: "sid-2",
privateStoreId: "PRIV",
protectedStoreId: "PROT",
publicStoreId: "PUB",
}),
});
resetRegistryCache();
expect(await resolveScopeGraph("private")).toBe("did:ng:PRIV");
expect(await resolveScopeGraph("protected")).toBe("did:ng:PROT");
expect(await resolveScopeGraph("public")).toBe("did:ng:PROT"); // co-located
expect(await resolveInboxAnchor()).toBe("did:ng:PRIV");
});
test("resolveScopeGraph falls back to the private store when no protected id is injected", async () => {
// The default SESSION carries only privateStoreId — non-private scopes fall
// back to the private store rather than emitting a broken NURI.
expect(await resolveScopeGraph("protected")).toBe("did:ng:PRIV");
expect(await resolveScopeGraph("public")).toBe("did:ng:PRIV");
});
test("createEntityDoc + listEntityDocs round-trip via the per-scope index", async () => { test("createEntityDoc + listEntityDocs round-trip via the per-scope index", async () => {
const rec = await ensureAccount("Dave"); const rec = await ensureAccount("Dave");
const e1 = await createEntityDoc("dave", "public"); const e1 = await createEntityDoc("dave", "public");