refactor(vocabulary): les noms publiés parlent la langue de la cible, et un test le tient

La correction de nomenclature du 2026-07-30 — en amont un *wallet* n'est qu'un
trousseau, ce qui possède des stores est un **user** (un *site*) — s'était faite
à la main. `walletInbox` y a échappé et a vécu des semaines, en faisant des
dégâts : le nom rendait « une inbox par wallet » évident, masquant qu'un user en
a **deux** en amont (repos de store public et protected, les deux seuls
`AddInboxCap` du moteur). Une discipline appliquée à la main en oublie un ; un
test non.

D'où `test/vocabulary.test.ts` : tout nom publié est bâti sur des mots que la
CIBLE emploie — vérifiés dans `nextgraph-rs` — ou porte un marqueur disant
POURQUOI il n'existe qu'ici (`virtual`, `physical`, `shim`, `emulated`,
`polyfill`), ce qui dit aussi quand il disparaît. Un échec n'est pas « renommer
pour faire passer le test », c'est une question : la cible a-t-elle un mot pour
ça ? la chose n'existe-t-elle qu'ici ? le mot est-il vraiment de la glue ?

Ce que le test a trouvé, et les réponses :

- `walletInbox` → `userInbox`, avec l'écart de cardinalité écrit noir sur blanc
  plutôt que caché par le nom.
- `accounts` / `AccountRecord` / `AccountStorage` → `virtualUsers` /
  `VirtualUserRecord` / `VirtualUserStorage`, module `accounts.ts` →
  `virtual-users.ts`. « account » n'est pas de la cible : c'est notre mot pour
  l'utilisateur virtuel, et le marqueur le dit désormais.
- `readModel` → la fonction `readUnion`, exposée directement. « model » n'était
  ni de la cible ni de la glue, et le namespace ne tenait qu'une fonction.
- Le reste était du vocabulaire légitime à déclarer (`subject`, `base`,
  `schema`, `connected`, le modèle réactif de l'ORM).

Corrigé au passage, sur signalement du contrat interne : l'en-tête d'`open-repo`
justifiait son correctif par un mécanisme que le source contredit. Un repo absent
de `self.repos` lève bien `RepoNotFound`
(`engine/verifier/src/request_processor.rs:264,269`). Les 0 lignes observées
viennent d'ailleurs — `Verifier::load` repeuple `self.repos` depuis le stockage
sur un profil persistant (`verifier.rs:535-560`), et notre propre `readDoc`
attrape toute erreur et rend `[]`. Le correctif est bon, le diagnostic écrit à
côté ne l'était pas.

159 tests unitaires, typecheck src/test/e2e vert, e2e 40/40 contre le broker.
This commit is contained in:
Sylvain Duchesne
2026-08-04 14:35:01 +02:00
parent e01a8dbab1
commit 107f9d1633
28 changed files with 297 additions and 135 deletions
@@ -52,9 +52,9 @@ import {
resolveAccount,
storeOf,
readUserStore,
walletInbox,
userInbox,
ensureAccount,
type AccountRecord,
type VirtualUserRecord,
} from "../shared-wallet/account-registry";
import type { Nuri, ReadCap, Scope } from "../model/types";
@@ -66,7 +66,7 @@ import type { Nuri, ReadCap, Scope } from "../model/types";
export async function isOwnInbox(nuri: Nuri): Promise<boolean> {
const holder = getCurrentUser();
if (holder === null) return false;
if ((await walletInbox(holder)) === nuri) return true;
if ((await userInbox(holder)) === nuri) return true;
// …and the inbox of any document this user opened one on (the emulated
// `AddInboxCap` records on its User branch).
return (await readInboxCapPairs()).some((p) => p.inbox === nuri);
@@ -115,7 +115,7 @@ export function holdOwnCap(id: string, scope: Scope, doc: Nuri, cap: ReadCap): v
* Scoped to the current holder, like {@link holdOwnCap}: another user's stores are
* emphatically not ours to hold.
*/
export function fileOwnStructure(id: string, record: AccountRecord): void {
export function fileOwnStructure(id: string, record: VirtualUserRecord): void {
const holder = getCurrentUser();
if (holder === null || accountKey(holder) !== accountKey(id)) return;
const caps = getCaps();
@@ -294,7 +294,7 @@ export async function myInboxes(): Promise<Nuri[]> {
const holder = getCurrentUser();
if (holder === null) return [];
const out: Nuri[] = [];
if ((await resolveAccount(holder)) !== null) out.push(await walletInbox(holder));
if ((await resolveAccount(holder)) !== null) out.push(await userInbox(holder));
for (const { inbox } of await readInboxCapPairs()) out.push(inbox);
return out;
}
@@ -2,14 +2,26 @@
* open-repo — cold-start repo opening for the ANCHORED read path (polyfill-era).
*
* ── The cold-start defect this heals ──────────────────────────────────────
* The anchored read path (`read-model.ts` `readDoc`, `store-registry.ts`
* `readUserStore`) assumes the target repo is already in the verifier's
* `self.repos` — true within the session that CREATED the doc (every `doc_create`
* opens it), but FALSE on a FRESH session over the same persistent wallet
* (reconnection / new page / re-login). On that fresh session nothing has opened
* the user's scope-index or entity repos yet, so an anchored `sparql_query`
* resolves a repo absent from `self.repos` and silently returns 0 rows (never a
* `RepoNotFound`) — persisted documents read as empty.
* The anchored read path (`surface/read-model.ts` `readDoc`,
* `shared-wallet/account-registry.ts` `readUserStore`) assumes the target repo is
* already usable by the verifier — true within the session that CREATED the doc
* (every `doc_create` opens it), but FALSE on a FRESH session over the same
* persistent wallet (reconnection / new page / re-login): the repos are on the broker
* and in the profile's cache, but this session has not synced them, so a persisted
* document reads as empty.
*
* **The mechanism, corrected 2026-08-03.** This comment used to say the verifier
* "silently returns 0 rows (never a `RepoNotFound`)" for a repo absent from
* `self.repos`. That is FALSE at the source: `resolve_target_for_sparql` does
* `self.repos.get(repo_id).ok_or(NgError::RepoNotFound)?`
* (`engine/verifier/src/request_processor.rs:264,269`), which surfaces as a rejected
* promise. Two things produce the 0 rows actually observed, and neither is silence in
* the verifier: on a persistent profile `Verifier::load` repopulates `self.repos` from
* user storage at construction (`engine/verifier/src/verifier.rs:535-560`), so the repo
* is PRESENT but unsynced and the anchored query legitimately matches nothing; and this
* library's own `readDoc` catches every error and returns `[]`
* (`surface/read-model.ts:122`), so anything that did throw would reach the caller as
* emptiness anyway. The fix below is right; the diagnosis written beside it was not.
*
* The circularity that made this self-inflicted: `doc_subscribe` WOULD open the
* repo, but the reactive layer only subscribes AFTER the listing produced NURIs —
+4 -1
View File
@@ -29,7 +29,10 @@ export * as inbox from "./surface/inbox";
export * as docs from "./surface/docs";
export { subscribeDoc, subscribeDocs, docChangeType } from "./surface/subscribe";
export type { DocChange, DocChangeType, Unsubscribe } from "./surface/subscribe";
export * as readModel from "./surface/read-model";
// `readUnion` is exposed as a function, not under a `readModel` namespace: "model" is
// neither the target's vocabulary nor neutral glue, and the namespace bought nothing —
// it held one published function. Renamed 2026-08-03 by the vocabulary check.
export { readUnion } from "./surface/read-model";
export type { UnionSubject } from "./surface/read-model";
export * as storeRegistry from "./surface/placement";
+3 -3
View File
@@ -44,7 +44,7 @@ export { connectedUser } from "./emulated-verifier/connect";
// one shared wallet hosts several identities. The real SDK has no counterpart: there
// each user opens their own wallet, and "who am I" is the session. Shipping it from
// the SDK entry advertised as durable something that disappears at migration.
export * as accounts from "./shared-wallet/accounts";
export type { AccountStorage } from "./shared-wallet/accounts";
export * as virtualUsers from "./shared-wallet/virtual-users";
export type { VirtualUserStorage } from "./shared-wallet/virtual-users";
// Config-shaped types the bootstrap needs; both describe the shim, not the SDK.
export type { AccountRecord, RegistrySession } from "./shared-wallet/account-registry";
export type { VirtualUserRecord, RegistrySession } from "./shared-wallet/account-registry";
@@ -14,7 +14,7 @@
* The mapping (account → its 3 document NURIs) is the **sharedWalletShim**. It
* is persisted as RDF, but NOT in the store-root graph anymore — see the
* indirection below. That makes login cross-device: another device opening the
* same wallet reads the same shim and finds the same accounts.
* same wallet reads the same shim and finds the same virtualUsers.
*
* ── The indirection: pointer (store-root) → doc-shim (subscribable) ─────────
* On the real NextGraph platform "findable-without-lookup" and "subscribable"
@@ -93,7 +93,7 @@ import type { Nuri, ReadCap, Scope } from "../model/types";
* The empty case is NOT new — `canonicalDoc` has always returned `""` for a missing
* field, and callers have always had to test for it — but with {@link Nuri} typed it
* stops hiding inside a `string`. It is kept confined to the shim-reading functions
* below: `AccountRecord` still promises real NURIs, because a record with an empty
* below: `VirtualUserRecord` still promises real NURIs, because a record with an empty
* scope document is a corrupt record, not a valid state to spread through the API.
* Tightening that (reject the record rather than let it flow) is a change of
* behaviour and belongs to its own lot — see `recordFromRows`.
@@ -101,7 +101,7 @@ import type { Nuri, ReadCap, Scope } from "../model/types";
type MaybeNuri = Nuri | "";
/** One account's three scope-document NURIs, as recorded in the shim. */
export interface AccountRecord {
export interface VirtualUserRecord {
id: string;
docPublic: Nuri;
docProtected: Nuri;
@@ -278,7 +278,7 @@ async function rootNuri(): Promise<Nuri> {
// Per-account cache, keyed by account key. Populated by the TARGETED resolver
// (resolveAccount) and by loadShim(). Independent of `cache` so a single
// targeted resolve never forces a full shim scan. Both are cleared together.
const accountCache = new Map<string, AccountRecord>();
const accountCache = new Map<string, VirtualUserRecord>();
// The resolved doc-shim NURI for the current session (cached: the pointer read +
// barrier open happen once, then every account read reuses this doc). Cleared on
@@ -363,18 +363,18 @@ function canonicalDoc(rows: Array<Record<string, { value: string }>>, key: strin
return chosen;
}
/** Build an AccountRecord by picking the canonical (lexicographically-smallest)
/** Build an VirtualUserRecord by picking the canonical (lexicographically-smallest)
* doc NURI per scope across all bindings for one account. See {@link canonicalDoc}. */
function recordFromRows(
rows: Array<Record<string, { value: string }>>,
fallbackId: string,
): AccountRecord {
): VirtualUserRecord {
let id = "";
for (const row of rows) {
const v = bindingValue(row, "id");
if (v) { id = v; break; }
}
// The ONE place the `""`-for-corrupt case is absorbed. `AccountRecord` promises
// The ONE place the `""`-for-corrupt case is absorbed. `VirtualUserRecord` promises
// real NURIs; a shim missing a scope document yields `""` here, exactly as it
// always has, and the cast records that this is a KNOWN gap rather than a proven
// invariant. Callers already test for the empty value (e.g. `watchShape` skips a
@@ -486,7 +486,7 @@ async function createDoc(): Promise<Nuri> {
/**
* Resolve (or on first login, create) the doc-shim NURI for this session — the
* `did:ng:o:...` repo that holds every AccountRecord and IS subscribable.
* `did:ng:o:...` repo that holds every VirtualUserRecord and IS subscribable.
*
* Steps (cached; runs at most once per session, concurrent callers share one):
* 1. Read the pointer from the store-root (resolvePointer). If present → that is
@@ -553,7 +553,7 @@ async function resolveShimDoc(): Promise<Nuri> {
* Cached per account (in `accountCache`); a hit skips the query entirely, so
* repeated resolves of the same account are free. `resetRegistryCache` clears it.
*/
export async function resolveAccount(id: string): Promise<AccountRecord | null> {
export async function resolveAccount(id: string): Promise<VirtualUserRecord | null> {
const key = accountKey(id);
const cached = accountCache.get(key);
if (cached) return cached;
@@ -593,9 +593,9 @@ export async function resolveAccount(id: string): Promise<AccountRecord | null>
}
}
/** Persist one AccountRecord into the doc-shim (anchored default-graph write, the
/** Persist one VirtualUserRecord into the doc-shim (anchored default-graph write, the
* canonical always-safe shape — same convention as createEntityDoc). */
async function writeRecord(doc: Nuri, record: AccountRecord): Promise<void> {
async function writeRecord(doc: Nuri, record: VirtualUserRecord): Promise<void> {
const s = await session();
const subj = `${SHIM}:account:${escapeIri(accountKey(record.id))}`;
// `subj` is IRI-safe (escapeIri). `id` is UNTRUSTED text in a LITERAL position →
@@ -634,7 +634,7 @@ async function writeRecord(doc: Nuri, record: AccountRecord): Promise<void> {
* bounded in-memory promise map (mirrors open-repo.ts `inFlight`), cleared the instant
* it settles.
*/
const ensureInFlight = new Map<string, Promise<AccountRecord>>();
const ensureInFlight = new Map<string, Promise<VirtualUserRecord>>();
/**
* Ensure an account exists in the shim, creating its 3 scope documents on
@@ -642,7 +642,7 @@ const ensureInFlight = new Map<string, Promise<AccountRecord>>();
* Concurrency-safe: concurrent calls for the same account share one provision
* (see {@link ensureInFlight}) so a fresh page never FORKS the account.
*/
export async function ensureAccount(id: string): Promise<AccountRecord> {
export async function ensureAccount(id: string): Promise<VirtualUserRecord> {
const key = accountKey(id);
// A completed provision/resolve is cached → no query, no fork risk.
const cached = accountCache.get(key);
@@ -655,7 +655,7 @@ export async function ensureAccount(id: string): Promise<AccountRecord> {
const pending = ensureInFlight.get(key);
if (pending) return pending;
const p = (async (): Promise<AccountRecord> => {
const p = (async (): Promise<VirtualUserRecord> => {
// HOT PATH: targeted O(1) lookup — does THIS account already exist? — instead
// of a full-shim scan (loadShim). Off the read/write hot path entirely.
//
@@ -675,7 +675,7 @@ export async function ensureAccount(id: string): Promise<AccountRecord> {
createDoc(),
createDoc(),
]);
const record: AccountRecord = { id, docPublic, docProtected, docPrivate };
const record: VirtualUserRecord = { id, docPublic, docProtected, docPrivate };
// Persist the record INTO the doc-shim (not the store-root anymore).
await writeRecord(doc, record);
// Feed the per-account cache, and the full-shim cache if it is already loaded
@@ -696,7 +696,7 @@ export async function ensureAccount(id: string): Promise<AccountRecord> {
// --- resolvers ------------------------------------------------------------
/** The index document NURI of an account for a scope (the store-container). */
export function storeOf(record: AccountRecord, scope: Scope): Nuri {
export function storeOf(record: VirtualUserRecord, scope: Scope): Nuri {
return scope === "public"
? record.docPublic
: scope === "protected"
@@ -751,7 +751,7 @@ export async function resolveScopeGraph(scope: Scope): Promise<Nuri> {
}
/**
* In-flight `walletInbox` resolutions, keyed by account key — so concurrent callers
* In-flight `userInbox` resolutions, keyed by account key — so concurrent callers
* for the SAME wallet share ONE resolve-or-create instead of racing two documents
* into existence (mirrors {@link ensureInFlight}).
*/
@@ -760,6 +760,26 @@ const inboxInFlight = new Map<string, Promise<Nuri>>();
const inboxCache = new Map<string, Nuri>();
/**
* The inbox of a virtual user — where caps and messages addressed to THEM arrive.
*
* **Renamed from `walletInbox` on 2026-08-03, and the old name was wrong twice.**
* A *wallet* upstream is only a keyring; what owns stores — and therefore what an inbox
* belongs to — is a **user** (a *site*): `SensitiveWalletV0.sites: HashMap<String, SiteV0>`
* (`engine/wallet/src/types.rs:456`), `SiteV0` carrying `public`/`protected`/`private`
* (`engine/verifier/src/site.rs:31-37`). The library had corrected that vocabulary
* everywhere else and this name survived the pass — and it did cost: reasoning about
* "the inbox per wallet" hid that upstream a user has **two**.
*
* **Known divergence, deliberate.** Upstream a user has TWO inboxes, one on its public
* store repo and one on its protected store repo — the only two `AddInboxCap` commits in
* the engine (`engine/verifier/src/site.rs:128,149`; `new_store_default` attaches one
* only `if !private`, `engine/verifier/src/verifier.rs:2994`). They are distinguished
* right down to the predicates a contact record uses (`ng:site_inbox` vs
* `ng:protected_inbox`, `engine/verifier/src/inbox_processor.rs:374-375`). This function
* exposes ONE. Collapsing them is a simplification this library has not yet had a reason
* to undo; the day a caller needs to address a user's public inbox distinctly from its
* protected one, this is the seam that has to split in two.
*
* The NURI of a virtual user's OWN inbox — where deposits addressed to that
* identity land, ReadCaps among them.
*
@@ -783,7 +803,7 @@ const inboxCache = new Map<string, Nuri>();
* At migration this becomes the identity's native inbox and the resolution moves
* here — the consumer-facing act (deposit to an inbox, process my own) is unchanged.
*/
export async function walletInbox(id: string): Promise<Nuri> {
export async function userInbox(id: string): Promise<Nuri> {
const key = accountKey(id);
const cached = inboxCache.get(key);
if (cached) {
@@ -806,7 +826,7 @@ export async function walletInbox(id: string): Promise<Nuri> {
`SELECT ?d WHERE { <${subj}> <${P.docInbox}> ?d }`,
undefined,
shimDoc,
"walletInbox",
"userInbox",
);
const existing = canonicalDoc(readBindings(res), "d");
if (existing) {
@@ -815,7 +835,7 @@ export async function walletInbox(id: string): Promise<Nuri> {
return existing;
}
} catch (error) {
console.error(accessLogPrefix() + " walletInbox read failed:", error);
console.error(accessLogPrefix() + " userInbox read failed:", error);
}
const doc = await createDoc();
@@ -825,13 +845,13 @@ export async function walletInbox(id: string): Promise<Nuri> {
s.sessionId,
`INSERT DATA { <${subj}> <${P.docInbox}> "${escapeLiteral(doc)}" }`,
shimDoc,
"walletInbox",
"userInbox",
);
} catch (error) {
console.error(accessLogPrefix() + " walletInbox persist failed:", error);
console.error(accessLogPrefix() + " userInbox persist failed:", error);
}
inboxCache.set(key, doc);
logStage("walletInbox(" + key + ") → " + shortNuri(doc));
logStage("userInbox(" + key + ") → " + shortNuri(doc));
return doc;
})();
@@ -23,7 +23,7 @@ export const ACCOUNT_STORAGE_KEY = "ng-eventually.account.id";
* so this stays framework/DOM-agnostic. When none is available (SSR, no
* `window`), pass `null` and the store degrades to in-memory-null (no persist).
*/
export interface AccountStorage {
export interface VirtualUserStorage {
getItem(key: string): string | null;
setItem(key: string, value: string): void;
removeItem(key: string): void;
@@ -31,14 +31,14 @@ export interface AccountStorage {
/**
* The persisted current identity id. A tiny store around an injected
* {@link AccountStorage}. It holds no framework state; the consumer's Provider
* {@link VirtualUserStorage}. It holds no framework state; the consumer's Provider
* mirrors `get()` into framework state and re-reads after `set`/`clear`.
*/
export class IdentityStore {
private readonly storage: AccountStorage | null;
private readonly storage: VirtualUserStorage | null;
private readonly key: string;
constructor(storage: AccountStorage | null, key: string = ACCOUNT_STORAGE_KEY) {
constructor(storage: VirtualUserStorage | null, key: string = ACCOUNT_STORAGE_KEY) {
this.storage = storage;
this.key = key;
}
@@ -89,8 +89,8 @@ export class IdentityStore {
export function browserIdentityStore(key: string = ACCOUNT_STORAGE_KEY): IdentityStore {
const ls =
typeof globalThis !== "undefined" &&
(globalThis as { localStorage?: AccountStorage }).localStorage
? (globalThis as { localStorage: AccountStorage }).localStorage
(globalThis as { localStorage?: VirtualUserStorage }).localStorage
? (globalThis as { localStorage: VirtualUserStorage }).localStorage
: null;
return new IdentityStore(ls, key);
}
+1 -1
View File
@@ -29,7 +29,7 @@ export {
/** The NURI where GROUPED entities of `scope` are written (no per-entity document). */
resolveWriteGraph,
/** A user's own inbox — where caps and messages addressed to THEM arrive. */
walletInbox,
userInbox,
/** Open an inbox on a document you OWN, so others can deposit into it. */
openDocumentInbox,
/** WHERE to deposit for a document — readable by any holder of it. `undefined` if none. */
+2 -2
View File
@@ -21,7 +21,7 @@
* you can only follow links** (see docs/readcap-and-nuri-model.md §4ter-bis),
* and a link reaches you through an inbox or through a document you already
* hold. A document whose cap you were given is read by NAMING it
* (`readModel.readUnion`), not by turning up in a scope you never put it in.
* (`readUnion`), not by turning up in a scope you never put it in.
* 2. Open the docs (`ensureReposOpen`) — this AWAITS the sync BARRIER (first
* `State` per doc, `getSyncState` → `synced`, or `timed-out` on the bounded
* fallback). `isPending` holds until the barrier is reached for the current
@@ -218,7 +218,7 @@ export function watchShape<T = UnionSubject>(
* There is no "everything public" to fold in. You cannot discover; you can only
* follow links, and a link reaches you through an inbox or through a document
* you already hold — never through a shared index. A document someone gave you
* the cap for is read by naming it (`readModel.readUnion`), not by appearing in
* the cap for is read by naming it (`readUnion`), not by appearing in
* a scope you did not put it in. */
async function resolveDocs(): Promise<Nuri[]> {
const user = getCurrentUser();