feat(client): OFF-by-default document access log, prefixed by active identity

Observability probe for the shared-wallet isolation footgun: on one physical
wallet several virtual identities coexist, and a read must never surface a doc
scoped to another identity. When it does (B reading A's doc), the leak is
invisible in the data — it looks like a normal read. This makes it VISIBLE.

Every real read/write is logged, prefixed by the ACTIVE virtual identity
(getCurrentUser → the account the op is scoped under, NOT the constant shared
physical wallet id). Reads append the row count — a strong leak signal:

  [urn:festipod:user:bob] READ did:ng:o:docA (readDoc) → 3 rows

Instrumented at the LOW common point in docs.ts: every read routes through
sparqlQuery, every write through sparqlUpdate, container creation through
docCreate. Callers pass a semantic label (readDoc|readUnion|listMyEntityDocs|
writeEntity|deposit|…) that is a lib-internal probe param, NOT forwarded to the
real `ng` (preserves docs.test.ts exact-forwarding assertions).

OFF by default → one boolean read on the hot path, zero output. On via
configure({ debugAccessLog: true }) or env NG_EVENTUALLY_ACCESS_LOG=1 (no code
change). Polyfill-era; removed at the real multi-store migration.

tsc --noEmit: 0 errors. bun test: 91 pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sylvain Duchesne
2026-07-07 21:20:47 +02:00
parent dd1313258f
commit d8c36bac3b
6 changed files with 133 additions and 7 deletions
+5 -3
View File
@@ -185,7 +185,7 @@ export async function loadShim(): Promise<Map<string, AccountRecord>> {
}`;
const map = new Map<string, AccountRecord>();
try {
const result = await sparqlQuery(s.sessionId, query, undefined, anchor);
const result = await sparqlQuery(s.sessionId, query, undefined, anchor, "loadShim");
for (const row of readBindings(result)) {
const id = bindingValue(row, "id");
if (!id) continue;
@@ -238,7 +238,7 @@ export async function resolveAccount(id: string): Promise<AccountRecord | null>
}
}`;
try {
const result = await sparqlQuery(s.sessionId, query, undefined, anchor);
const result = await sparqlQuery(s.sessionId, query, undefined, anchor, "resolveAccount");
const rows = readBindings(result);
if (rows.length === 0) return null;
const row = rows[0]!;
@@ -305,7 +305,7 @@ export async function ensureAccount(id: string): Promise<AccountRecord> {
}
}`;
try {
await sparqlUpdate(s.sessionId, update, anchor);
await sparqlUpdate(s.sessionId, update, anchor, "ensureAccount");
} catch (error) {
console.error("[storeRegistry] ensureAccount persist failed:", error);
}
@@ -432,6 +432,7 @@ export async function createEntityDoc(id: string, scope: Scope): Promise<Nuri> {
// a literal → escapeLiteral.
`INSERT DATA { <${INDEX_SUBJECT}> <${P.contains}> "${escapeLiteral(entityNuri)}" }`,
indexDoc,
"createEntityDoc",
);
} catch (error) {
console.error("[storeRegistry] createEntityDoc index append failed:", error);
@@ -451,6 +452,7 @@ async function readScopeIndex(indexDoc: Nuri): Promise<Nuri[]> {
`SELECT ?e WHERE { <${INDEX_SUBJECT}> <${P.contains}> ?e }`,
undefined,
indexDoc,
"readScopeIndex",
);
for (const row of readBindings(res)) {
const v = bindingValue(row, "e");