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
+2 -2
View File
@@ -60,7 +60,7 @@ is needed), and how this lib emulates it today.
| Reads / listing | Lists the documents it needs, by scope, and reads them | Native per-wallet reads over the real per-identity stores | Bug/perf: an anchorless union query spans every named graph in the session store, which on a shared / accumulating wallet is O(wallet size) and stalls | A bounded, by-need set of per-doc anchored `sparql_query`s (each anchored to one repo's default graph), independent of wallet size |
| Reactivity | Lists update on change | Native reactive reads | Not-yet-implemented: there is no reactive union query across graphs | Re-query the bounded per-doc anchored set on a lightweight change signal (`doc_subscribe` / ORM on an already-opened single store) |
| Writes | Writes an entity to its scope | Writes land in the entity's real store via native primitives | Not-yet-implemented: `doc_create` can target only the private/protected store today (`StoreRepo` not JS-constructible) | Per-entity documents via direct SPARQL (`docs.sparqlUpdate` on the real injected `ng`) |
| Current identity | Sets the current identity id (established at wallet import) via the SDK's current-identity call | Opening one's own wallet at the broker gate establishes the session identity | Not-yet-implemented for the shared-wallet case: everyone shares one wallet, so the broker cannot distinguish identities | A relayed id (`shared-wallet/accounts.ts` `IdentityStore` persists it); the read filter and inbox `from` read it |
| Current identity | Sets the current identity id (established at wallet import) via the SDK's current-identity call | Opening one's own wallet at the broker gate establishes the session identity | Not-yet-implemented for the shared-wallet case: everyone shares one wallet, so the broker cannot distinguish identities | A relayed id (`shared-wallet/virtualUsers.ts` `IdentityStore` persists it); the read filter and inbox `from` read it |
| Write-guard | Writes refused without the write cap | The broker/verifier enforces the write cap natively | Partial: the guard fires only on the public proxy, but the real write paths call the injected `ng` directly (the `DataCloneError` constraint), so it is best-effort today | A `sparql_update` override (`surface/ng-proxy.ts`) checking the emulated write cap |
## Packages
@@ -185,7 +185,7 @@ Implemented. The polyfill mechanisms are wired against a real broker, not stubbe
`surface/use-shape.ts` only once a cap exists (`caps.isEnforcing()`).
- Write guard — `surface/ng-proxy.ts` (`sparql_update` override, emulated write cap).
- Inbox — `inbox.ts` (`post` / `read` / `materialize` / `watch`).
- Identity — `shared-wallet/accounts.ts` (`IdentityStore`, injected storage).
- Identity — `shared-wallet/virtualUsers.ts` (`IdentityStore`, injected storage).
- SPARQL hardening — `sparql.ts` (`escapeLiteral` / `escapeIri` / `assertNuri`).
The remaining `TODO` markers are narrow: the shared-wallet credential passthrough
+14 -14
View File
@@ -462,14 +462,14 @@ export function assertNuri<T extends string>(nuri: T): T;
### Today — `@ng-eventually/client` (namespace `storeRegistry`) — plus `Scope` from `types.ts`
> **Narrowed 2026-08-03.** The entry used to re-export the WHOLE `store-registry` module. It now re-exports an app-facing slice (`src/surface/placement.ts`): `createEntityDoc`, `listMyEntityDocs`, `resolveScopeGraph`, `resolveWriteGraph`, `walletInbox`, `openDocumentInbox`, `documentInboxAddress`. The rest — `userStoreDoc`, `isOwnInbox`, `myInboxes`, `addLink`, `readLinks`, `resolveAccount`, `ensureAccount`, `reservedAccount`, `resetRegistryCache`, and the `AccountRecord` / `RegistrySession` types — is **no longer importable from `@ng-eventually/client`** and is covered by `docs/internal-contract.md`. The signatures below are kept for the record, marked accordingly.
> **Narrowed 2026-08-03.** The entry used to re-export the WHOLE `store-registry` module. It now re-exports an app-facing slice (`src/surface/placement.ts`): `createEntityDoc`, `listMyEntityDocs`, `resolveScopeGraph`, `resolveWriteGraph`, `userInbox`, `openDocumentInbox`, `documentInboxAddress`. The rest — `userStoreDoc`, `isOwnInbox`, `myInboxes`, `addLink`, `readLinks`, `resolveAccount`, `ensureAccount`, `reservedAccount`, `resetRegistryCache`, and the `VirtualUserRecord` / `RegistrySession` types — is **no longer importable from `@ng-eventually/client`** and is covered by `docs/internal-contract.md`. The signatures below are kept for the record, marked accordingly.
```ts
// types.ts:38 — NB: NOT the ORM's Scope (a graphs/subjects filter); this is the store scope
export type Scope = "public" | "protected" | "private";
// store-registry.ts:90,234
export interface AccountRecord {
export interface VirtualUserRecord {
id: string;
docPublic: Nuri;
docProtected: Nuri;
@@ -490,7 +490,7 @@ export async function resolveScopeGraph(scope: Scope): Promise<Nuri>;
export async function resolveWriteGraph(id: string, scope: Scope): Promise<Nuri>;
// inbox-side (store-registry.ts:772, 837, 1133, 1203, 1286)
export async function walletInbox(id: string): Promise<Nuri>;
export async function userInbox(id: string): Promise<Nuri>;
export async function isOwnInbox(nuri: Nuri): Promise<boolean>;
export async function openDocumentInbox(doc: Nuri): Promise<Nuri>;
export async function documentInboxAddress(doc: Nuri): Promise<Nuri | undefined>;
@@ -501,8 +501,8 @@ export async function addLink(cap: ReadCap): Promise<void>;
export async function readLinks(): Promise<ReadCap[]>;
// shim machinery (store-registry.ts:542, 631, 213, 278)
export async function resolveAccount(id: string): Promise<AccountRecord | null>;
export async function ensureAccount(id: string): Promise<AccountRecord>;
export async function resolveAccount(id: string): Promise<VirtualUserRecord | null>;
export async function ensureAccount(id: string): Promise<VirtualUserRecord>;
export function reservedAccount(name: string): string;
export function resetRegistryCache(): void;
```
@@ -512,10 +512,10 @@ export function resetRegistryCache(): void;
- **`createEntityDoc(id, scope)` → level 2, VERIFIED direction.** Target: `doc_create(session_id, crdt, class_name, destination, store_repo)` aimed at the identity's real per-scope store (see § 7 for the store-targeting nuance — the nodejs SDK already takes `store_type`/`store_repo` strings). The two writes the lib performs by hand are **native side effects** of `doc_create` upstream: the `ldp:contains` listing on the store's Main branch and the `AddRepo { read_cap }` on its Store branch (`engine/verifier/src/request_processor.rs:697-710`). The `id` parameter disappears (the session IS the identity); expect `createEntityDoc(id, scope)` to become `doc_create(sid, …, storeOf(scope))` with no listing/cap bookkeeping.
- **`listMyEntityDocs(id, scope)` → level 1/2, VERIFIED mechanism.** Upstream the listing is the store's `ldp:contains` graph (written at `request_processor.rs:706-708`), readable with an anchored `sparql_query` on the store; the caps come back by replaying the Store branch (`AddRepo::verify``load_repo_from_read_cap`). The function's shape (give me my per-scope doc NURIs) survives; its implementation becomes one native read.
- **`userStoreDoc(id, scope)` / `resolveScopeGraph(scope)` / `resolveWriteGraph(id, scope)` → level 2, VERIFIED.** The target answers these from the session: `did:ng:` + `session.private_store_id | protected_store_id | public_store_id` (`Session`, `index.d.ts:264-272`). The store IS the container; the per-scope index document disappears.
- **`walletInbox(id)` → level 1, VERIFIED counterpart with a different granularity.** Upstream a user's inboxes are their public and protected STORE repos' inboxes — the only two `AddInboxCap` commits in the engine (`engine/verifier/src/site.rs:128,149`). An identity-level "my inbox" therefore maps to a store inbox; the resolution moves into the lib/SDK and the consumer's act (deposit to an address, process my own) is unchanged.
- **`userInbox(id)` → level 1, VERIFIED counterpart with a different granularity.** Upstream a user's inboxes are their public and protected STORE repos' inboxes — the only two `AddInboxCap` commits in the engine (`engine/verifier/src/site.rs:128,149`). An identity-level "my inbox" therefore maps to a store inbox; the resolution moves into the lib/SDK and the consumer's act (deposit to an address, process my own) is unchanged.
- **`openDocumentInbox(doc)` / `documentInboxAddress(doc)` → level 1, VERIFIED support, no exerciser.** Every `Repo` carries `inbox: Option<PrivKey>` (`engine/repo/src/repo.rs:126`); `AddInboxCapV0` is keyed by `repo_id` with no is-store restriction (`engine/repo/src/types.rs:1973`; applied at `engine/verifier/src/verifier.rs:1920-1928`); but no code path creates one for a plain document (`doc_create` leaves `inbox: None`, `repo.rs:574`) and no level-2/3 API exposes any of it. So: the *capability* is engine-verified; the *functions* are invented surface; and the **address publication is a real, deliberate divergence** (upstream transmits addresses, never publishes them — § 9), with the ownership guard compensating our design, not mirroring an upstream rule.
- **`addLink(cap)` / `readLinks()` → level 1, VERIFIED model, no JS surface.** The emulated `AddLink { read_cap }` register (`engine/repo/src/types.rs:1939-1948`*"so that a user can share with all its device a new Link they received"*, external repos only). Upstream this filing happens inside the verifier when it processes the inbox; the future SDK most likely never exposes these as calls, so consumers should not code against them (§ 15).
- **`resolveAccount` / `ensureAccount` / `AccountRecord` / `RegistrySession` / `reservedAccount` / `resetRegistryCache` → NO COUNTERPART.** The shared-wallet shim (accounts directory, pointer → doc-shim indirection) has no image in the target — the target has no central directory of identities (`docs/migration-guide.md` § 3). The whole group disappears with the shim.
- **`resolveAccount` / `ensureAccount` / `VirtualUserRecord` / `RegistrySession` / `reservedAccount` / `resetRegistryCache` → NO COUNTERPART.** The shared-wallet shim (accounts directory, pointer → doc-shim indirection) has no image in the target — the target has no central directory of identities (`docs/migration-guide.md` § 3). The whole group disappears with the shim.
- **`isOwnInbox` / `myInboxes` → NO COUNTERPART as API.** Upstream the question "which inboxes may I read" is answered inside the verifier by the User branch's `AddInboxCap` records; nothing suggests a JS API for it. These exist for the emulated read guard and the connection drain.
---
@@ -525,15 +525,15 @@ export function resetRegistryCache(): void;
### Today
```ts
// @ng-eventually/client — accounts.ts (namespace accounts)
// @ng-eventually/client — virtualUsers.ts (namespace accounts)
export const ACCOUNT_STORAGE_KEY = "ng-eventually.account.id"; // :18
export interface AccountStorage { // :26
export interface VirtualUserStorage { // :26
getItem(key: string): string | null;
setItem(key: string, value: string): void;
removeItem(key: string): void;
}
export class IdentityStore { // :37
constructor(storage: AccountStorage | null, key?: string);
constructor(storage: VirtualUserStorage | null, key?: string);
get(): string | null;
set(id: string): string | null;
clear(): void;
@@ -558,7 +558,7 @@ declare function user_connect(client_info: any, user_id: string, location?: stri
declare function user_disconnect(user_id: string): Promise<void>;
```
- `accounts.*` (the persisted identity id) — **NO COUNTERPART**; removed at migration (`docs/migration-guide.md` § 5). It exists only because every virtual user shares one wallet. It is exported from the SDK entry, which is a placement wart (§ 15).
- `virtualUsers.*` (the persisted identity id) — **NO COUNTERPART**; removed at migration (`docs/migration-guide.md` § 5). It exists only because every virtual user shares one wallet. It is exported from the SDK entry, which is a placement wart (§ 15).
- `setCurrentUser` / `getCurrentUser`**NO COUNTERPART**; the relay of an identity the broker cannot see. Disappears with the shared wallet.
- `connectedUser()` — the awaitable form of what the target does **automatically**: the recipient's verifier processes its inbox as messages arrive/at connection (`Verifier::inbox`, `engine/verifier/src/verifier.rs:1674`). VERIFIED at level 1 that no consumer call is needed upstream; the polyfill fires it from `setCurrentUser` for the same reason. A consumer should treat it as "await a deterministic start" (tests), not as an operation the future SDK will name.
@@ -586,9 +586,9 @@ Exported, but not SDK surface. Coding against these builds knowledge that migrat
- **`getConfig` / `getStoreRegistryDeps`** — tagged `@internal` in source, exported for the lib's own wrappers.
- **`resetConfig` / `resetStoreRegistry` / `resetCaps` / `storeRegistry.resetRegistryCache`** — test/reset machinery. In particular `resetCaps` wipes EVERY holder's caps, which no product flow should ever do.
- **`getCaps()` and the `CapRegistry` class** — the registry is the emulation's engine room. The consumer surface is `capFor` (possession lookup), `inbox.shareCap` (grant), and the acts that file caps implicitly (creating a document, processing one's inbox). `CapRegistry.grantWrite` / `governsWrite` / `canWrite` / `hasWritePolicy` are explicitly decorative until P1b — the guard they feed is bypassed by every internal writer.
- ~~**`storeRegistry.reservedAccount`, `resolveAccount`, `ensureAccount`, `AccountRecord`, `RegistrySession`**~~ — **RESOLVED 2026-08-03**: no longer exported. Shim internals, now in `docs/internal-contract.md`. The consumer's legitimate touchpoint is `configureStoreRegistry` (bootstrap) plus the scope/entity resolvers.
- ~~**`storeRegistry.reservedAccount`, `resolveAccount`, `ensureAccount`, `VirtualUserRecord`, `RegistrySession`**~~ — **RESOLVED 2026-08-03**: no longer exported. Shim internals, now in `docs/internal-contract.md`. The consumer's legitimate touchpoint is `configureStoreRegistry` (bootstrap) plus the scope/entity resolvers.
- ~~**`storeRegistry.addLink` / `readLinks`**~~ — **RESOLVED 2026-08-03**: no longer exported. Consumers receive caps by processing their inbox (automated at connection); calling these directly baked in a register the verifier owns upstream.
- ~~**`accounts.*` on the SDK entry**~~ — **RESOLVED 2026-08-03**: moved to `/polyfill`, where its disappearance at migration is visible at the import line.
- ~~**`virtualUsers.*` on the SDK entry**~~ — **RESOLVED 2026-08-03**: moved to `/polyfill`, where its disappearance at migration is visible at the import line.
- **`inbox.watch`'s `_opts?: { intervalMs?: number }`** — accepted and ignored (no polling exists). Dead compatibility surface; do not pass it.
- **The `label` parameters** on `docs.sparqlUpdate` / `docs.sparqlQuery` / `docs.depositInto` — lib-internal access-log tags, never forwarded to `ng`. The real signatures have no such parameter.
@@ -605,7 +605,7 @@ Exported, but not SDK surface. Coding against these builds knowledge that migrat
## Appendix — full export inventory (for diffing)
`@ng-eventually/client` (from `index.ts`): types `Nuri`, `ReadCap`, `Scope`, `PrincipalId`, `NgLike`, `UseShapeLike`, `ShapeQuery`, `ShapeObservable`, `DocChange`, `DocChangeType`, `Unsubscribe`, `UnionSubject`, `AccountRecord`, `RegistrySession`, `AccountStorage`, `Deposit`, `PostOptions` (via namespaces), re-exported `ShapeType`, `BaseType`, `Schema`, `DeepSignalSet`, `NG`; values `ng`, `useShape`, `watchShape`, `init`, `initNg`, `subscribeDoc`, `subscribeDocs`, `docChangeType`, `escapeLiteral`, `escapeIri`, `assertNuri`, `isNuri`, `hasReadCap`; namespaces `inbox` (`post`, `postToDocument`, `shareCap`, `read`, `materialize`, `readSynced`, `processInbox`, `watch`), `docs` (`docCreate`, `sparqlUpdate`, `sparqlQuery`, `depositInto`), `readModel` (`readUnion`), `storeRegistry` (`reservedAccount`, `resetRegistryCache`, `resolveAccount`, `ensureAccount`, `resolveWriteGraph`, `resolveScopeGraph`, `walletInbox`, `isOwnInbox`, `createEntityDoc`, `userStoreDoc`, `openDocumentInbox`, `documentInboxAddress`, `myInboxes`, `addLink`, `readLinks`, `listMyEntityDocs`), `accounts` (`ACCOUNT_STORAGE_KEY`, `IdentityStore`, `browserIdentityStore`).
`@ng-eventually/client` (from `index.ts`): types `Nuri`, `ReadCap`, `Scope`, `PrincipalId`, `NgLike`, `UseShapeLike`, `ShapeQuery`, `ShapeObservable`, `DocChange`, `DocChangeType`, `Unsubscribe`, `UnionSubject`, `VirtualUserRecord`, `RegistrySession`, `VirtualUserStorage`, `Deposit`, `PostOptions` (via namespaces), re-exported `ShapeType`, `BaseType`, `Schema`, `DeepSignalSet`, `NG`; values `ng`, `useShape`, `watchShape`, `init`, `initNg`, `subscribeDoc`, `subscribeDocs`, `docChangeType`, `escapeLiteral`, `escapeIri`, `assertNuri`, `isNuri`, `hasReadCap`; namespaces `inbox` (`post`, `postToDocument`, `shareCap`, `read`, `materialize`, `readSynced`, `processInbox`, `watch`), `docs` (`docCreate`, `sparqlUpdate`, `sparqlQuery`, `depositInto`), `readModel` (`readUnion`), `storeRegistry` (`reservedAccount`, `resetRegistryCache`, `resolveAccount`, `ensureAccount`, `resolveWriteGraph`, `resolveScopeGraph`, `userInbox`, `isOwnInbox`, `createEntityDoc`, `userStoreDoc`, `openDocumentInbox`, `documentInboxAddress`, `myInboxes`, `addLink`, `readLinks`, `listMyEntityDocs`), `accounts` (`ACCOUNT_STORAGE_KEY`, `IdentityStore`, `browserIdentityStore`).
`@ng-eventually/client/polyfill` (from `polyfill.ts`): types `StoreRegistryDeps`, `EventuallyConfig`; values `configure`, `getConfig`, `resetConfig`, `configureStoreRegistry`, `getStoreRegistryDeps`, `resetStoreRegistry`, `setCurrentUser`, `getCurrentUser`, `getCaps`, `capFor`, `resetCaps`, `CapRegistry`, `shareCap`, `connectedUser`.
+3 -3
View File
@@ -163,7 +163,7 @@ This is the point where the emulation is furthest from the eventual target, wher
`inbox.read` had no guard and **absorbs caps into the reader's keyring**, so `inbox.read(someoneElsesInbox)` pocketed the caps addressed to them and directed sharing was defeatable by anyone who knew an inbox NURI. The inbox was never guarded before either, but before P1a it carried nothing that granted access.
Fixed in step 2 of [`2026-07-30-virtual-wallet-boundary.md`](2026-07-30-virtual-wallet-boundary.md): an inbox now BELONGS to a virtual user (`storeRegistry.walletInbox`), and only its owner may read it. Depositing into anyone's inbox stays open — that is the one legitimate cross-wallet act, and the only way a link crosses between wallets at all.
Fixed in step 2 of [`2026-07-30-virtual-wallet-boundary.md`](2026-07-30-virtual-wallet-boundary.md): an inbox now BELONGS to a virtual user (`storeRegistry.userInbox`), and only its owner may read it. Depositing into anyone's inbox stays open — that is the one legitimate cross-wallet act, and the only way a link crosses between wallets at all.
## Follow-up decided by the PO — to plan, NOT in this lot
@@ -192,8 +192,8 @@ Not started. It changes the consumer contract in the right direction (one less o
- **Unit suite green — 146 tests**, typecheck clean on `src`, `test` and the e2e harness.
- The typing was verified from a **consumer's** point of view, not just the library's: a synthetic app compiled against the entry points shows the two real mistakes (`shareCap(bareNuri, …)` and passing a raw `string` from storage) as compile errors, while every correct path — `capFor(doc)``shareCap(cap, inbox)`, and narrowing with the exported guards — needs no cast.
- The acceptance test was **mutation-checked**: reverting both gardes (the discovery fold and the `readUnion` possession gate) makes `watch-shape.test.ts` (e) fail with the bare-referenced document reappearing. The test has teeth.
- **The e2e ran against the live broker (`nextgraph.eu`) on 2026-08-03 — 39 passed, 0 failed.** The first run was 22/8, and the eight refusals were not test noise: they exposed a **real hole in the surface**. `docs.docCreate` filed no cap for the creator, so a consumer could create a document through the public primitive and then be refused reading or writing it. Upstream that cannot happen — `doc_create` commits `AddRepo { read_cap }` to the store's Store branch, so the creator holds it from the first instant. Fixed at `packages/client/src/surface/docs.ts:73`, and deliberately NOT replicated in `shared-wallet/physical.ts`: the shim's own documents belong to no user, and `store-registry` files their caps where it knows whose they are. The remaining failures were the harness acting as a second identity without establishing it (`createEntityDoc(id, …)` with someone else connected) or reading an arbitrary document as an inbox; both are now `setCurrentUser` + `walletInbox`, which is what a consumer must do too.
- **An e2e run against a persistent wallet must use a FRESH identity per run.** The second run was green and the third was not, on unchanged code: moving the inbox tests onto `walletInbox(id)` made the inbox *stable for its owner* — which is the point of an inbox — so a fixed id accumulates every past run's deposits and `deposits.length === 2` drifts to 4. Green-then-red on identical code is the tell. The disposable thing is the **user**, not the inbox: `run.ts` now stamps `@inbox-user-`/`@watcher-`/`@friend-` with `Date.now()`, as it already did for `@alice-`. Any future step that resolves a durable per-user document (inbox, stores, Links) inherits this constraint.
- **The e2e ran against the live broker (`nextgraph.eu`) on 2026-08-03 — 39 passed, 0 failed.** The first run was 22/8, and the eight refusals were not test noise: they exposed a **real hole in the surface**. `docs.docCreate` filed no cap for the creator, so a consumer could create a document through the public primitive and then be refused reading or writing it. Upstream that cannot happen — `doc_create` commits `AddRepo { read_cap }` to the store's Store branch, so the creator holds it from the first instant. Fixed at `packages/client/src/surface/docs.ts:73`, and deliberately NOT replicated in `shared-wallet/physical.ts`: the shim's own documents belong to no user, and `store-registry` files their caps where it knows whose they are. The remaining failures were the harness acting as a second identity without establishing it (`createEntityDoc(id, …)` with someone else connected) or reading an arbitrary document as an inbox; both are now `setCurrentUser` + `userInbox`, which is what a consumer must do too.
- **An e2e run against a persistent wallet must use a FRESH identity per run.** The second run was green and the third was not, on unchanged code: moving the inbox tests onto `userInbox(id)` made the inbox *stable for its owner* — which is the point of an inbox — so a fixed id accumulates every past run's deposits and `deposits.length === 2` drifts to 4. Green-then-red on identical code is the tell. The disposable thing is the **user**, not the inbox: `run.ts` now stamps `@inbox-user-`/`@watcher-`/`@friend-` with `Date.now()`, as it already did for `@alice-`. Any future step that resolves a durable per-user document (inbox, stores, Links) inherits this constraint.
- **The cap registry is process-wide and `bun test` shares modules across files**, so suites that read without declaring caps now reset explicitly (`read-model.test.ts`, `watch-shape.test.ts`). Worth knowing before adding a suite.
## Documentation state
@@ -11,7 +11,7 @@
> And four more, all confirmed:
>
> 4. **D4 would delete a working recovery path.** Inbox deposits are never removed (`packages/client/src/surface/inbox.ts`), so a second device/tab recovers its caps by re-reading. localStorage-without-re-reading loses them permanently, and contradicts P1a's delivered doctrine that per-process rebuild "is correct".
> 5. **D3 is false outside entity documents.** `capFor(scopeIndexDoc)` and `capFor(walletInbox)` are undefined before *and after* `listMyEntityDocs` — their caps can only ever be derived. Yet the boundary brief requires them reachable. Upstream that root comes from the wallet plus `AddSignerCap` on the User branch — a level the fact table omitted entirely.
> 5. **D3 is false outside entity documents.** `capFor(scopeIndexDoc)` and `capFor(userInbox)` are undefined before *and after* `listMyEntityDocs` — their caps can only ever be derived. Yet the boundary brief requires them reachable. Upstream that root comes from the wallet plus `AddSignerCap` on the User branch — a level the fact table omitted entirely.
> 6. **`doc_create` writes four times, not two** (+ the class quad on the Header branch, + `AddSignerCap` on the User branch).
> 7. **Ordering defect: D2 before the boundary guard opens cap harvesting.** Once caps are triples in `scopeIndexDoc(bob,…)`, and both `scopeIndexDoc` and `docs.sparqlQuery` are exported, `setCurrentUser("mallory")` reads Bob's caps. Today `mintCap` is unexported, so a NURI yields nothing. **The guard must land before the caps become triples.**
>
@@ -69,7 +69,7 @@ All read in `nextgraph-rs` (`git 213338f6`) on 2026-07-30, recorded in full in [
| scope index / scope container (`scopeIndexDoc`, `readScopeIndex`, `indexDocOf`, `INDEX_SUBJECT`) | **store** (`storeDoc`, `readStore`, …) | the thing that lists a user's documents IS a store |
| `shim:contains` | `ldp:contains` | NextGraph's own predicate for exactly this |
`docPublic` / `docProtected` / `docPrivate` on `AccountRecord` already read as stores; keep them, or rename to `publicStore` / `protectedStore` / `privateStore` for symmetry.
`docPublic` / `docProtected` / `docPrivate` on `VirtualUserRecord` already read as stores; keep them, or rename to `publicStore` / `protectedStore` / `privateStore` for symmetry.
### D2 — Emulate the Store branch as a distinct SUBJECT, not a distinct graph or document
@@ -93,7 +93,7 @@ The in-memory `CapRegistry` then stops being "the keyring" and becomes what it a
Verified: there is no received-caps register upstream, and inventing one would expose a shape the target does not have. What upstream does is persist the `read_cap` of every **opened** repo in local user storage.
So the emulation is a **local, per-virtual-user store** — the same nature as `shared-wallet/accounts.ts`'s existing `IdentityStore` (localStorage). This ends "re-read the inbox every session to recover caps", which the PO identified as the wrong model: an inbox is a queue you consume, not a store you re-read.
So the emulation is a **local, per-virtual-user store** — the same nature as `shared-wallet/virtualUsers.ts`'s existing `IdentityStore` (localStorage). This ends "re-read the inbox every session to recover caps", which the PO identified as the wrong model: an inbox is a queue you consume, not a store you re-read.
*Open*: whether to do D4 in this lot or after the boundary lot. It is the piece with the most design risk, and it is not needed for D1D3 to be correct.
@@ -106,7 +106,7 @@ So the emulation is a **local, per-virtual-user store** — the same nature as `
## What this breaks
`storeRegistry`'s exported names change (`scopeIndexDoc`, `listEntityDocs`, `AccountRecord` fields). `shim:contains` becomes `ldp:contains`, so **existing dev wallets stop resolving their documents** — acceptable for dev data, and consistent with how the pointer/doc-shim migration was handled before, but it must be stated rather than discovered.
`storeRegistry`'s exported names change (`scopeIndexDoc`, `listEntityDocs`, `VirtualUserRecord` fields). `shim:contains` becomes `ldp:contains`, so **existing dev wallets stop resolving their documents** — acceptable for dev data, and consistent with how the pointer/doc-shim migration was handled before, but it must be stated rather than discovered.
## Risks I want challenged
@@ -113,13 +113,13 @@ Applies to every exported surface, including ones added later: **if it is expose
## Order of work
1. ~~**Remove `discovery.***~~**DONE 2026-07-30.** `src/discovery.ts` and `test/discovery.test.ts` deleted; `INDEX_ACCOUNT`, `watchShape`'s public-scope fold and its discovery-index container subscription, `nurisFromRef`, the `submitToIndex` guard, and the e2e discovery block all removed. P1a's acceptance test did not need re-basing: `test/cross-user-access.test.ts` already proves the same property (a bare reference reads nothing, the link reads the document) on the model's own terms — following a link — so `watch-shape.test.ts` (e), which proved it on the discovery fold, was dropped. Docs realigned: the ADR is marked superseded, `read-model.md` now describes ONE regime (follow, never enumerate), and the root README's capability row records the removal.
2. ~~**"My inbox" + the inbox read guard**~~**DONE 2026-07-30.** `storeRegistry.walletInbox(id)` gives every virtual user its own inbox document, created on first sight and recorded in the doc-shim under its own predicate (`shim:docInbox`), read by its OWN query so an account record written before this existed still resolves — the fixed account SELECT did not grow a fourth required field. `isOwnInbox(nuri)` is the predicate; `inbox.read` / `readSynced` (hence `watch`, which reads through it) refuse an inbox that is not the connected wallet's, and refuse outright when no identity is set. **Depositing stays open**`post` / `shareCap` are untouched, because that is the one legitimate cross-wallet act. The shared `resolveInboxAnchor` (a reserved account's document, an inbox COMMON to every wallet) was removed: it was unused by the library and violated *nothing common*. Locked by `test/isolation-active.test.ts` *an inbox may be DEPOSITED into by anyone, and READ only by its owner*, which walks the exact breach — Alice deposits, cannot read back; Mallory knowing the NURI absorbs nothing; anonymous is refused; Bob reads his own and only then does the cap land.
2. ~~**"My inbox" + the inbox read guard**~~**DONE 2026-07-30.** `storeRegistry.userInbox(id)` gives every virtual user its own inbox document, created on first sight and recorded in the doc-shim under its own predicate (`shim:docInbox`), read by its OWN query so an account record written before this existed still resolves — the fixed account SELECT did not grow a fourth required field. `isOwnInbox(nuri)` is the predicate; `inbox.read` / `readSynced` (hence `watch`, which reads through it) refuse an inbox that is not the connected wallet's, and refuse outright when no identity is set. **Depositing stays open**`post` / `shareCap` are untouched, because that is the one legitimate cross-wallet act. The shared `resolveInboxAnchor` (a reserved account's document, an inbox COMMON to every wallet) was removed: it was unused by the library and violated *nothing common*. Locked by `test/isolation-active.test.ts` *an inbox may be DEPOSITED into by anyone, and READ only by its owner*, which walks the exact breach — Alice deposits, cannot read back; Mallory knowing the NURI absorbs nothing; anonymous is refused; Bob reads his own and only then does the cap land.
*Not done, and deliberately*: per-DOCUMENT inboxes. ~~Upstream every document has one~~**false, corrected 2026-08-03**: no document has an inbox upstream, and neither does the private store (see step 7's correction). Here only the wallet does. **The PO has ruled they must come** (2026-07-30) — *"it can come in a second step, but it must come"* — so this is a commitment, not an option. The guard predicate (`isOwnInbox`) is where they plug in: it answers "is this inbox mine?", which extends to "…one of my documents' inboxes" without changing a single caller.
### Two defects this step surfaced — the first still open, the second closed by steps 56
**`walletInbox(id)` is a directory, and directories do not exist.** It resolves ANY wallet's inbox from its identity id, and it is exported (`storeRegistry.*` is re-exported from the SDK entry). But you cannot look someone up in NextGraph — you cannot discover, you can only follow links. Their inbox NURI reaches you because *they gave it to you*, not because you resolved it from a name. Resolving **my own** inbox is legitimate plumbing; resolving **anyone's** is the same shape as the discovery index just removed. Fix: the public surface becomes "my inbox" (no argument), and reaching someone else's requires a NURI you were given. Resolution-by-id stays internal, for the shim and the tests.
**`userInbox(id)` is a directory, and directories do not exist.** It resolves ANY wallet's inbox from its identity id, and it is exported (`storeRegistry.*` is re-exported from the SDK entry). But you cannot look someone up in NextGraph — you cannot discover, you can only follow links. Their inbox NURI reaches you because *they gave it to you*, not because you resolved it from a name. Resolving **my own** inbox is legitimate plumbing; resolving **anyone's** is the same shape as the discovery index just removed. Fix: the public surface becomes "my inbox" (no argument), and reaching someone else's requires a NURI you were given. Resolution-by-id stays internal, for the shim and the tests.
**The keyring is not stored anywhere, and the shape is wrong — fix it now, not at P1b.** It is an in-memory `Map<accountKey, Map<Nuri, ReadCap>>`, rebuilt from scratch each session. Nothing persists a cap *as a cap*. PO directive, 2026-07-30:
+3 -3
View File
@@ -2,7 +2,7 @@
**Date:** 2026-06-15 · **Status:** Accepted (frozen). The rationale behind how
the consumer application presents identity selection as a perceived login, and why
the lib's identity store (`shared-wallet/accounts.ts`) must never touch NextGraph. The lib itself
the lib's identity store (`shared-wallet/virtualUsers.ts`) must never touch NextGraph. The lib itself
no longer frames this as a login: it receives an identity id, set at wallet-import
time; the perceived-login UX lives entirely in the consumer application.
@@ -63,8 +63,8 @@ barrier becomes the real per-user login — the flow shape does not change.
## How this lib realizes it
`shared-wallet/accounts.ts` is an `IdentityStore`: `set(id)` / `clear()` / `get()` only read/write
the identity id in an injected `AccountStorage`; they never call NG. The id is set at
`shared-wallet/virtualUsers.ts` is an `IdentityStore`: `set(id)` / `clear()` / `get()` only read/write
the identity id in an injected `VirtualUserStorage`; they never call NG. The id is set at
wallet-import time and relayed via the lib's current-identity call; the perceived
login is the consumer application's. See the identity store in
[`../simulation.md`](../simulation.md).
+6 -6
View File
@@ -2,7 +2,7 @@
**Scope.** The complement of [`docs/api-contract.md`](./api-contract.md): every module export under `packages/client/src/` that is NOT reachable from the two published entry points (`package.json` maps exactly `.``src/index.ts` and `./polyfill``src/polyfill.ts`). A consumer never reads this document; a maintainer does. The internal code is held to the same standard as the surface — as close as possible to what NextGraph does or plans — so every subject below carries the same target-side analysis. Written 2026-08-04, verified against the `nextgraph-rs` clone (HEAD `213338f6`) and the installed `@ng-org/web@0.1.2-alpha.13` declarations (`node_modules/.bun/@ng-org+web@0.1.2-alpha.13/node_modules/@ng-org/web/dist/index.d.ts`, hereafter `index.d.ts`).
**How the boundary was computed — mechanically, from the `export` statements.** `index.ts` re-exports wholesale (`export *` / `export * as ns`) from `types.ts`, `inbox.ts`, `docs.ts`, `surface/read-model.ts`, and by name everything `surface/use-shape.ts`, `surface/watch-shape.ts`, `lifecycle.ts`, `sparql.ts` export, plus `isNuri`/`hasReadCap` from `nuri.ts` and `subscribeDoc`/`subscribeDocs`/`docChangeType` (+ types) from `subscribe.ts`; its `storeRegistry` namespace is the **`surface/placement.ts` slice only** (7 functions: `createEntityDoc`, `listMyEntityDocs`, `resolveScopeGraph`, `resolveWriteGraph`, `walletInbox`, `openDocumentInbox`, `documentInboxAddress`). `polyfill.ts` re-exports `CapRegistry` from `emulated-verifier/caps.ts`, `shareCap` from `inbox.ts`, `connectedUser` from `emulated-verifier/connect.ts`, `* as accounts` from `shared-wallet/accounts.ts`, and the types `AccountStorage`, `AccountRecord`, `RegistrySession`. Everything else that carries `export` in a `src/` module is internal and inventoried here. Eight modules are internal in their entirety: `shared-wallet/access-log.ts`, `emulated-verifier/machinery.ts`, `surface/ng-proxy.ts`, `emulated-verifier/open-repo.ts`, `shared-wallet/outbox-log.ts`, `shared-wallet/physical.ts`, `emulated-verifier/reach.ts`, `emulated-verifier/read-filter.ts`. Four are internal in part: `nuri.ts`, `emulated-verifier/connect.ts`, `subscribe.ts`, `shared-wallet/account-registry.ts`.
**How the boundary was computed — mechanically, from the `export` statements.** `index.ts` re-exports wholesale (`export *` / `export * as ns`) from `types.ts`, `inbox.ts`, `docs.ts`, `surface/read-model.ts`, and by name everything `surface/use-shape.ts`, `surface/watch-shape.ts`, `lifecycle.ts`, `sparql.ts` export, plus `isNuri`/`hasReadCap` from `nuri.ts` and `subscribeDoc`/`subscribeDocs`/`docChangeType` (+ types) from `subscribe.ts`; its `storeRegistry` namespace is the **`surface/placement.ts` slice only** (7 functions: `createEntityDoc`, `listMyEntityDocs`, `resolveScopeGraph`, `resolveWriteGraph`, `userInbox`, `openDocumentInbox`, `documentInboxAddress`). `polyfill.ts` re-exports `CapRegistry` from `emulated-verifier/caps.ts`, `shareCap` from `inbox.ts`, `connectedUser` from `emulated-verifier/connect.ts`, `* as accounts` from `shared-wallet/virtualUsers.ts`, and the types `VirtualUserStorage`, `VirtualUserRecord`, `RegistrySession`. Everything else that carries `export` in a `src/` module is internal and inventoried here. Eight modules are internal in their entirety: `shared-wallet/access-log.ts`, `emulated-verifier/machinery.ts`, `surface/ng-proxy.ts`, `emulated-verifier/open-repo.ts`, `shared-wallet/outbox-log.ts`, `shared-wallet/physical.ts`, `emulated-verifier/reach.ts`, `emulated-verifier/read-filter.ts`. Four are internal in part: `nuri.ts`, `emulated-verifier/connect.ts`, `subscribe.ts`, `shared-wallet/account-registry.ts`.
**Labels** are those of `docs/api-contract.md`: **PASSTHROUGH (level 3/2, VERIFIED)**, **LEVEL-1 SHAPE (model VERIFIED, JS surface ASSUMED)**, **ASSUMPTION**, **NO COUNTERPART**. Level numbers per `README.md` § *The three references*: 3 = JS ORM, 2 = wasm binding (`@ng-org/web`), 1 = Rust engine. One label recurs here that the surface contract rarely needs: **NO COUNTERPART, shared-wallet machinery** — the code below the emulation's floor, which the target has no image of because the target has no shared wallet. Per the design principle, an absent implementation is never treated as evidence about the future.
@@ -144,7 +144,7 @@ Fire-and-forget wrapper over the published `connectedUser()` (restore Links, the
## 9. The shim registry — the unexported slice of `shared-wallet/account-registry.ts`
The sharpest boundary case: `surface/placement.ts` publishes the 7 app-facing calls; the 9 exports below stay internal (importable by the lib's modules, unit tests and the e2e harness, not by an application through the package entries). The types `AccountRecord` (`store-registry.ts:90`) and `RegistrySession` (`:234`) are published via `/polyfill` and covered by the surface contract.
The sharpest boundary case: `surface/placement.ts` publishes the 7 app-facing calls; the 9 exports below stay internal (importable by the lib's modules, unit tests and the e2e harness, not by an application through the package entries). The types `VirtualUserRecord` (`store-registry.ts:90`) and `RegistrySession` (`:234`) are published via `/polyfill` and covered by the surface contract.
### 9a. Account shim — provision, resolve, reserved names, cache
@@ -154,9 +154,9 @@ export function reservedAccount(name: string): string;
// store-registry.ts:278
export function resetRegistryCache(): void;
// store-registry.ts:542
export async function resolveAccount(id: string): Promise<AccountRecord | null>;
export async function resolveAccount(id: string): Promise<VirtualUserRecord | null>;
// store-registry.ts:631
export async function ensureAccount(id: string): Promise<AccountRecord>;
export async function ensureAccount(id: string): Promise<VirtualUserRecord>;
```
`resolveAccount` — barrier-authoritative O(1) lookup of one account's record in the doc-shim; `ensureAccount` — resolve-or-provision (creates the three scope docs on first sight, concurrency-deduped); `reservedAccount` — NUL-prefixed sentinel namespace for lib-internal accounts; `resetRegistryCache` — test/wallet-switch reset.
@@ -247,7 +247,7 @@ export function inspectOutbox(): void;
**F3 — incomplete citation in `subscribe.ts`.** `subscribe.ts:31` cites the ORM fan-out abort as "`initialize.rs:125-128`" with no path. The file is `engine/verifier/src/orm/graph/initialize.rs`; lines 125-128 are the graph loop calling `self.open_for_target(&nuri.target, true).await?` — verified, the `?` propagates `RepoNotFound` and aborts the whole subscription. Substance correct; the bare filename is unfindable without this note.
**F4 — `docs/api-contract.md` lags the `surface/placement.ts` split.** Its § 12 and appendix still list `resolveAccount`, `ensureAccount`, `userStoreDoc`, `isOwnInbox`, `myInboxes`, `addLink`, `readLinks`, `reservedAccount`, `resetRegistryCache` as the SDK entry's `storeRegistry` namespace, and § 13/§ 15 place `accounts.*` on the SDK entry — since the split (`index.ts:34` routes through `surface/placement.ts`; `polyfill.ts:238` carries `accounts`) those are internal or `/polyfill`. That file is being edited concurrently; noted here, deliberately not fixed by this document.
**F4 — `docs/api-contract.md` lags the `surface/placement.ts` split.** Its § 12 and appendix still list `resolveAccount`, `ensureAccount`, `userStoreDoc`, `isOwnInbox`, `myInboxes`, `addLink`, `readLinks`, `reservedAccount`, `resetRegistryCache` as the SDK entry's `storeRegistry` namespace, and § 13/§ 15 place `virtualUsers.*` on the SDK entry — since the split (`index.ts:34` routes through `surface/placement.ts`; `polyfill.ts:238` carries `accounts`) those are internal or `/polyfill`. That file is being edited concurrently; noted here, deliberately not fixed by this document.
**F5 — `reservedAccount`'s collision guarantee is asserted about code the lib does not own.** `store-registry.ts:200-206` states the injected `normalizeId` "strips a leading `@`, trims, and lowercases, so a NUL prefix is unreachable" — that describes ONE consumer's normalizer, not a contract; the lib's own default is `id.trim()` (`polyfill.ts:145`), which passes U+0000 through. The reserved namespace is disjoint only if every consumer's normalizer keeps it so. Either document the requirement on `StoreRegistryDeps.normalizeId`, or reject NUL-prefixed raw ids at `accountKey`.
@@ -266,4 +266,4 @@ Fully internal modules: `shared-wallet/access-log.ts` (`AccessOp`, `setAccessLog
Internal slices of partially-published modules: `nuri.ts` (`targetOf`, `parseNuri`, `mintCap`); `emulated-verifier/connect.ts` (`startConnect`); `subscribe.ts` (`subscribePhysicalDoc`); `shared-wallet/account-registry.ts` (`reservedAccount`, `resetRegistryCache`, `resolveAccount`, `ensureAccount`, `userStoreDoc`, `isOwnInbox`, `myInboxes`, `addLink`, `readLinks`).
Modules with no internal exports (everything they export is published): `types.ts`, `docs.ts`, `inbox.ts`, `surface/read-model.ts`, `shared-wallet/accounts.ts`, `emulated-verifier/caps.ts`, `sparql.ts`, `lifecycle.ts`, `surface/use-shape.ts`, `surface/watch-shape.ts`, `surface/placement.ts`, and the two entry points.
Modules with no internal exports (everything they export is published): `types.ts`, `docs.ts`, `inbox.ts`, `surface/read-model.ts`, `shared-wallet/virtualUsers.ts`, `emulated-verifier/caps.ts`, `sparql.ts`, `lifecycle.ts`, `surface/use-shape.ts`, `surface/watch-shape.ts`, `surface/placement.ts`, and the two entry points.
+1 -1
View File
@@ -79,7 +79,7 @@ when it processes its inbox — there is no separate curator to build; the in-li
emulation simply goes away. *(There is no global index to replace the cross-account fan-out: both were removed on 2026-07-30 — you cannot discover in NextGraph, you follow links.)*
### 5. Retire the identity store → real per-user login
Remove `shared-wallet/accounts.ts` (the `IdentityStore` that persists the identity id in
Remove `shared-wallet/virtualUsers.ts` (the `IdentityStore` that persists the identity id in
`localStorage`) and the app-level "Connexion" screen. The technical broker gate
becomes the real per-user login
(see [`decisions/shared-wallet-login-flow.md`](./decisions/shared-wallet-login-flow.md)).
+1 -1
View File
@@ -353,7 +353,7 @@ does not mistake sync-lag for "account absent" and PROVISION a fork). Since no s
document is both (previous section), the shim uses an **indirection**:
1. **doc-shim** — a `doc_create`d graph document (`did:ng:o:...`, hence a first-`State`
barrier). **All `AccountRecord`s live inside it.** Because it is subscribable, an
barrier). **All `VirtualUserRecord`s live inside it.** Because it is subscribable, an
anchored read behind its `ensureRepoOpen` barrier is **authoritative**: a cold 0
means the account is genuinely absent.
2. **pointer** — a single well-known, **write-once** triple in the store-root graph,
+1 -1
View File
@@ -54,7 +54,7 @@ globally enumerable, and nothing is meant to be.
### Everything = follow a graph, never enumerate across accounts
My participations / my profile, protected data an owner has granted me, my
notifications — none of these is enumerated across accounts. Each is reached by
notifications — none of these is enumerated across virtualUsers. Each is reached by
what is already reachable to me:
- my own docs (always in `self.repos`, and whose caps I hold);
+6 -6
View File
@@ -125,7 +125,7 @@ public/protected/private stores — on top of one shared wallet.
account→document trust root, which is why every untrusted value that reaches its
SPARQL is escaped (see SPARQL hardening below). It makes identity resolution
cross-device: another device opening the same wallet reads the same pointer → the
same doc-shim → the same accounts.
same doc-shim → the same virtualUsers.
- **Per-entity documents + per-scope index.** `createEntityDoc(id, scope)`
makes a dedicated document for one entity (mirrors the target, where each entity
is its own document/repo with a future inbox) and appends its NURI to the
@@ -166,7 +166,7 @@ Virtual user (id)
So the 3 native stores (public/protected/private) are present, but emulated: each
"store" is an index document
(`AccountRecord.{docPublic,docProtected,docPrivate}`) that lists the NURIs of the
(`VirtualUserRecord.{docPublic,docProtected,docPrivate}`) that lists the NURIs of the
per-entity documents in that scope. It is not a physical native store.
Everything is physical in one place: the 3 index documents, every per-entity
@@ -200,7 +200,7 @@ store-id:
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,
and the consumer application is unchanged.
- **`walletInbox(id)` / `openDocumentInbox(doc)`** — an inbox BELONGS to someone. The
- **`userInbox(id)` / `openDocumentInbox(doc)`** — an inbox BELONGS to someone. The
first is a user's own inbox (where Links arrive), the second a DEDICATED inbox for
one of its documents, opened on demand by its **owner only** (ownership read from the
Store branches — a received cap is not ownership, and a recipient must not be able to
@@ -489,7 +489,7 @@ emulates the inbox on the shared wallet:
### An inbox BELONGS to a virtual user (2026-07-30)
`storeRegistry.walletInbox(id)` resolves — creating on first sight — the inbox
`storeRegistry.userInbox(id)` resolves — creating on first sight — the inbox
document of one virtual user, recorded in the doc-shim under `shim:docInbox` and
read by its own query (so an account written before this existed still resolves).
The asymmetry that matters:
@@ -596,7 +596,7 @@ specific document (the `anchor` arg) is governed by it — ungoverned docs (the
mono-store default, no cap declared) flow through unchanged. This mirrors the target
broker/verifier, which refuses a write without the document's write cap.
## Identity store (`shared-wallet/accounts.ts`)
## Identity store (`shared-wallet/virtualUsers.ts`)
The real NextGraph login (redirect to the broker, opening the single shared
wallet) is perceived as a technical access barrier (see the login
@@ -614,7 +614,7 @@ identity id the consumer application relays to it:
open underneath. The real logout lives elsewhere (hidden in the consumer
application's settings/debug), because it forces a new redirect.
- Framework-agnostic: no React, no DOM beyond an optional injected
`AccountStorage` (a `window.localStorage`, a test fake, or `null` for SSR). The
`VirtualUserStorage` (a `window.localStorage`, a test fake, or `null` for SSR). The
React `Context`/`Provider` stays in the consumer application. `normalizeId`
(case-insensitive, optional leading `@` stripped, trimmed) is the pure
normalizer, reusable as the shim key normalizer.
+3 -3
View File
@@ -77,8 +77,8 @@ The 25 current modules, with the two splits' offspring shown where a module divi
| `subscribe.ts` | `surface/subscribe.ts`, minus `subscribePhysicalDoc``shared-wallet/physical.ts` | The guarded `subscribeDoc`/`subscribeDocs` and `docChangeType` are surface (level 2, `doc_subscribe`, `sdk/js/lib-wasm/src/lib.rs:1908`). The physical door moves to the machinery module (§ 4); the unguarded core is exported under its `Unguarded` name for that one importer. |
| `surface/read-model.ts` | `surface/read-model.ts` — stays whole | The anchored-read mechanics are level-1-verified and survive as composition (`docs/api-contract.md` § 6). Its possession gate and machinery filter mirror native behaviour (§ 4 ruling) and arrive via named `emulated-verifier/` imports. |
| `inbox.ts` | **split**: `surface/inbox.ts` (post, `postToDocument`, `shareCap`) + `emulated-verifier/inbox-processing.ts` (`read`/`materialize`/`readSynced`/`processInbox`/`watch`, `assertOwnInbox`, the deposit RDF vocabulary) | § 4 ruling. `surface/inbox.ts` re-exports the processing functions with a header saying exactly what `docs/api-contract.md` § 9 says — that enumerating deposits is emulation detail — so the published `inbox.*` namespace is unchanged and the warning sits at the one place the two halves meet. |
| `surface/placement.ts` | dissolved into `surface/placement.ts` | The hand-built slice becomes a real module: the app-facing placement/addressing calls (`createEntityDoc`, `listMyEntityDocs`, `resolveScopeGraph`, `resolveWriteGraph`, `walletInbox`, `openDocumentInbox`, `documentInboxAddress`), composed from the two lower layers. Its existence today is the strongest in-repo evidence for the whole reorganisation: it does by hand what the folder boundary does structurally. |
| `shared-wallet/account-registry.ts` | **split three ways**: `surface/placement.ts` + `emulated-verifier/branch-registers.ts` + `shared-wallet/account-registry.ts` | The sharpest case — 1377 lines spanning all three fates. `branch-registers.ts` takes the durable compartments: the Store-branch cap register (`holdOwnCap`/`readStoreCaps` — emulating `AddRepo { read_cap }`, level 1, `engine/repo/src/types.rs:1890-1899`), the User-branch Link register (`addLink`/`readLinks``AddLink { read_cap }`, `types.rs:1939-1948`), the inbox-cap records (`AddInboxCapV0`, `types.rs:1969-1981`) and the Header-branch address records. `account-registry.ts` takes the pointer→doc-shim indirection, `resolveAccount`/`ensureAccount`, `reservedAccount`, the cache, `AccountRecord`/`RegistrySession` — NO COUNTERPART, the shim proper. `placement.ts` (surface) keeps the app-facing calls listed above plus `userStoreDoc`/`isOwnInbox`/`myInboxes` staying internal on the register side per their contract entries. |
| `surface/placement.ts` | dissolved into `surface/placement.ts` | The hand-built slice becomes a real module: the app-facing placement/addressing calls (`createEntityDoc`, `listMyEntityDocs`, `resolveScopeGraph`, `resolveWriteGraph`, `userInbox`, `openDocumentInbox`, `documentInboxAddress`), composed from the two lower layers. Its existence today is the strongest in-repo evidence for the whole reorganisation: it does by hand what the folder boundary does structurally. |
| `shared-wallet/account-registry.ts` | **split three ways**: `surface/placement.ts` + `emulated-verifier/branch-registers.ts` + `shared-wallet/account-registry.ts` | The sharpest case — 1377 lines spanning all three fates. `branch-registers.ts` takes the durable compartments: the Store-branch cap register (`holdOwnCap`/`readStoreCaps` — emulating `AddRepo { read_cap }`, level 1, `engine/repo/src/types.rs:1890-1899`), the User-branch Link register (`addLink`/`readLinks``AddLink { read_cap }`, `types.rs:1939-1948`), the inbox-cap records (`AddInboxCapV0`, `types.rs:1969-1981`) and the Header-branch address records. `account-registry.ts` takes the pointer→doc-shim indirection, `resolveAccount`/`ensureAccount`, `reservedAccount`, the cache, `VirtualUserRecord`/`RegistrySession` — NO COUNTERPART, the shim proper. `placement.ts` (surface) keeps the app-facing calls listed above plus `userStoreDoc`/`isOwnInbox`/`myInboxes` staying internal on the register side per their contract entries. |
| `emulated-verifier/caps.ts` | `emulated-verifier/caps.ts` — stays whole (+ receives `mintCap`) | The in-memory record of what the connected holder holds — the verifier's cap state. The per-holder `heldByHolder` keying is its one shared-wallet dimension (one wallet, many holders); it stays, isolated behind the injected `holder()` and noted: at migration there is exactly one holder and the map collapses. |
| `emulated-verifier/reach.ts` | `emulated-verifier/reach.ts` | The emulated wallet boundary: stands in for "a repo you hold no cap for cannot even be addressed" (level 1, `resolve_target_for_sparql``RepoNotFound`, `request_processor.rs:264,269`, re-verified). The two-rules redundancy is a lib choice, documented. |
| `emulated-verifier/read-filter.ts` | `emulated-verifier/read-filter.ts` | Stands in for cryptographic non-delivery (same level-1 citations); deleted at migration with nothing to migrate to. |
@@ -86,7 +86,7 @@ The 25 current modules, with the two splits' offspring shown where a module divi
| `emulated-verifier/open-repo.ts` | `emulated-verifier/open-repo.ts`, minus `ensurePhysicalRepoOpen``shared-wallet/physical.ts` | Stands in for the verifier bringing repos into `self.repos` (native at migration: open by cap at bootstrap). Flag kept from the internal contract: this module is a *current-state heal*, not a model emulation — its barrier ("TabInfo then first State") is empirical, pinned only by the e2e CONTRACT-3 probe, and its header's mechanism claim is finding F2 (contradicted at the source). The folder cannot fix either (§ 6). |
| `emulated-verifier/machinery.ts` | `emulated-verifier/machinery.ts` | The subject filter fabricates what is structurally impossible upstream: a content read cannot see Store/User/Header compartments because they are separate branches with no triples (level 1, `BranchCrdt::None`, `engine/repo/src/types.rs:1420`, re-verified). It sits beside `branch-registers.ts`, whose compartments it hides; note that `MACHINERY_NS` also covers the account-shim's vocabulary. |
| `shared-wallet/physical.ts` | `shared-wallet/physical.ts` — grows into the complete privileged door | The quintessential shared-wallet module. It absorbs `subscribePhysicalDoc` and `ensurePhysicalRepoOpen`, so **one module is the machinery's entire unguarded API** — completing its own doctrine ("separate functions, never exemptions", `physical.ts:19-27`) at the tree level. Cost: the two unguarded cores get exported (under `Unguarded` names) from their mechanism modules; acceptable because neither entry ever re-exports them. |
| `shared-wallet/accounts.ts` | `shared-wallet/accounts.ts` | Identity persistence for the shared wallet; NO COUNTERPART (`docs/api-contract.md` § 13); already correctly published via `/polyfill` only. |
| `shared-wallet/virtualUsers.ts` | `shared-wallet/virtualUsers.ts` | Identity persistence for the shared wallet; NO COUNTERPART (`docs/api-contract.md` § 13); already correctly published via `/polyfill` only. |
| `shared-wallet/access-log.ts` | `shared-wallet/access-log.ts` | Diagnoses the shared-wallet isolation leak; the identity it prefixes is the relayed virtual id. Deleted at migration. |
| `shared-wallet/outbox-log.ts` | `shared-wallet/outbox-log.ts` | Polyfill-era trace probe over the injected SDK's private persistence (level-2 facts verified in the internal contract § 11). Deleted at migration. |
+13 -13
View File
@@ -29,7 +29,7 @@ import {
docs,
subscribeDoc,
subscribeDocs,
readModel,
readUnion,
inbox,
storeRegistry,
useShape as libUseShape,
@@ -39,11 +39,11 @@ import {
// application must not — but through the internal path, never the published entry.
// `storeRegistry` above is the app-facing slice; these are the shim internals.
import * as registryInternals from "../src/shared-wallet/account-registry";
import * as accounts from "../src/shared-wallet/accounts";
import * as virtualUsers from "../src/shared-wallet/virtual-users";
import { isNuri } from "@ng-eventually/client";
import type { Nuri, ShapeObservable, ShapeQuery } from "@ng-eventually/client";
const { IdentityStore } = accounts;
const { IdentityStore } = virtualUsers;
/**
* The Playwright boundary. Every NURI reaching this harness crosses the bridge as
@@ -131,7 +131,7 @@ configureStoreRegistry({
const state: { status: string; error?: string } = { status: "connecting" };
// Identity store over the iframe's localStorage (the real AccountStorage).
// Identity store over the iframe's localStorage (the real VirtualUserStorage).
const identity = new IdentityStore(
typeof window !== "undefined" && window.localStorage ? window.localStorage : null,
);
@@ -293,7 +293,7 @@ const identity = new IdentityStore(
docNuris.push(d);
}
const toRead: Nuri[] = includeBad ? [...docNuris, "did:ng:o:definitely-not-a-real-doc-xyz"] : docNuris;
const subjects = await readModel.readUnion(toRead);
const subjects = await readUnion(toRead);
return { docNuris, subjectCount: subjects.length, subjects };
},
/**
@@ -309,9 +309,9 @@ const identity = new IdentityStore(
setCurrentUser("owner-O");
getCaps().open(doc, "protected");
setCurrentUser("someone-else");
const asStranger = await readModel.readUnion([doc]);
const asStranger = await readUnion([doc]);
setCurrentUser("owner-O");
const asOwner = await readModel.readUnion([doc]);
const asOwner = await readUnion([doc]);
resetCaps();
setCurrentUser(null);
return { strangerCount: asStranger.length, ownerCount: asOwner.length };
@@ -382,9 +382,9 @@ const identity = new IdentityStore(
async inboxPostRead(id: string, payloadA: unknown, payloadB: unknown) {
// The target must be that user's OWN inbox, not an arbitrary document: you may
// deposit into anyone's, you may only read your own. Establishing the identity
// FIRST is what makes `walletInbox` resolve (and file) that user's inbox.
// FIRST is what makes `userInbox` resolve (and file) that user's inbox.
setCurrentUser(id);
const target = await storeRegistry.walletInbox(id);
const target = await storeRegistry.userInbox(id);
await inbox.post(target, { payload: payloadA, from: null, ts: 1000 });
await inbox.post(target, { payload: payloadB, from: null, ts: 2000 });
const deposits = await inbox.read(target);
@@ -398,7 +398,7 @@ const identity = new IdentityStore(
// Watching an inbox is READING it continuously, so the watcher stays connected
// for the whole probe — including across `inboxWatchDeposit`.
setCurrentUser(id);
const target = await storeRegistry.walletInbox(id);
const target = await storeRegistry.userInbox(id);
const rec = { fires: 0, lastLen: -1, unsub: () => {}, target };
(window as any).__sdk._inboxWatch = rec;
rec.unsub = inbox.watch(target, (deposits) => {
@@ -556,7 +556,7 @@ const identity = new IdentityStore(
} catch (e: any) {
rawRowCount = -2; // threw (e.g. RepoNotFound / InvalidNuri)
}
const subjects = await readModel.readUnion(listed.length ? listed : [asNuri(entityNuri)]);
const subjects = await readUnion(listed.length ? listed : [asNuri(entityNuri)]);
const markers: string[] = [];
for (const subj of subjects) {
for (const vals of Object.values(subj.props)) {
@@ -840,7 +840,7 @@ const identity = new IdentityStore(
setCurrentUser(ownerId);
const deposits = await inbox.read(ownerInbox);
// The address is machinery: it must not surface among the document's properties.
const subjects = await readModel.readUnion([doc]);
const subjects = await readUnion([doc]);
const props = Object.keys(subjects[0]?.props ?? {});
setCurrentUser(null);
return {
@@ -859,7 +859,7 @@ const identity = new IdentityStore(
// the recipient's durable Links would grow run after run on a persistent wallet,
// making every later `connectedUser()` re-apply a longer and longer history.
setCurrentUser(friendId);
const friendInbox = await storeRegistry.walletInbox(friendId);
const friendInbox = await storeRegistry.userInbox(friendId);
setCurrentUser("owner-O");
const doc = await docs.docCreate(s.session_id, "Graph", "data:graph", "store", undefined);
@@ -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();
+4 -4
View File
@@ -3,11 +3,11 @@ import {
IdentityStore,
browserIdentityStore,
ACCOUNT_STORAGE_KEY,
type AccountStorage,
} from "../src/shared-wallet/accounts";
type VirtualUserStorage,
} from "../src/shared-wallet/virtual-users";
// In-memory fake of the Storage subset — keeps this framework/DOM-agnostic.
function fakeStorage(): AccountStorage & { map: Map<string, string> } {
function fakeStorage(): VirtualUserStorage & { map: Map<string, string> } {
const map = new Map<string, string>();
return {
map,
@@ -50,7 +50,7 @@ test("IdentityStore: null storage degrades to non-persisting (SSR-safe)", () =>
});
test("IdentityStore: swallows storage errors on read and write", () => {
const throwing: AccountStorage = {
const throwing: VirtualUserStorage = {
getItem: () => {
throw new Error("boom");
},
@@ -22,7 +22,7 @@ import {
createEntityDoc,
openDocumentInbox,
resetRegistryCache,
walletInbox,
userInbox,
} from "../src/shared-wallet/account-registry";
import { documentInboxAddress } from "../src/emulated-verifier/branch-registers";
import type { RegistrySession } from "../src/shared-wallet/account-registry";
@@ -263,7 +263,7 @@ test("Bob: reads the public document, sees the reference, and cannot read throug
test("Charlie: same public document, same reference — and he reads through it", async () => {
inject();
const { protDoc, pubDoc, pubLink, protCap } = await aliceSetsUpHerDocuments();
const CHARLIE_INBOX = await walletInbox("charlie");
const CHARLIE_INBOX = await userInbox("charlie");
// Alice decides Charlie may read that ONE document, and delivers its cap to his
// inbox. She names no principal to the registry; she addresses an inbox.
@@ -283,7 +283,7 @@ test("Charlie: same public document, same reference — and he reads through it"
test("the ONLY difference between Bob and Charlie is each of them holds", async () => {
inject();
const { protDoc, pubLink, protCap } = await aliceSetsUpHerDocuments();
const CHARLIE_INBOX = await walletInbox("charlie");
const CHARLIE_INBOX = await userInbox("charlie");
setCurrentUser("alice");
await shareCap(protCap, CHARLIE_INBOX);
@@ -306,7 +306,7 @@ test("the ONLY difference between Bob and Charlie is each of them holds", async
test("dynamic: a cap delivered to Bob's inbox makes the refused document readable, and signals it", async () => {
inject();
const { pubDoc, pubLink, protCap } = await aliceSetsUpHerDocuments();
const BOB_INBOX = await walletInbox("bob");
const BOB_INBOX = await userInbox("bob");
setCurrentUser("bob");
getCaps().learn(pubLink);
@@ -362,7 +362,7 @@ test("a bare reference to the PUBLIC document is not enough either — the link
test("a Link is APPLIED durably: the cap survives with the inbox emptied", async () => {
const ng = inject();
const { protDoc, protCap } = await aliceSetsUpHerDocuments();
const bobInbox = await walletInbox("bob");
const bobInbox = await userInbox("bob");
setCurrentUser("alice");
await shareCap(protCap, bobInbox);
@@ -407,7 +407,7 @@ test("a document has its own inbox: anyone deposits, only the owner reads", asyn
setCurrentUser("alice");
const doc = await createEntityDoc("alice", "public");
const aliceInbox = await openDocumentInbox(doc);
expect(aliceInbox).not.toBe(await walletInbox("alice"));
expect(aliceInbox).not.toBe(await userInbox("alice"));
const link = capFor(doc)!; // the repo link alice circulates — links DO travel
// Bob RESOLVES the address himself, from the document. The only thing he is handed
@@ -498,7 +498,7 @@ test("connecting drains BOTH levels: the user's inbox and its documents'", async
const protDoc = await createEntityDoc("alice", "protected");
const pubDoc = await createEntityDoc("alice", "public");
const docInbox = await openDocumentInbox(pubDoc);
const aliceInbox = await walletInbox("alice");
const aliceInbox = await userInbox("alice");
// Two deposits, one at each level, both made by someone else.
setCurrentUser("carol");
@@ -520,8 +520,8 @@ test("connecting drains BOTH levels: the user's inbox and its documents'", async
test("a third party resolves another user's inbox (the wallet level)", async () => {
inject();
setCurrentUser("alice");
const aliceView = await walletInbox("alice");
const aliceView = await userInbox("alice");
setCurrentUser("bob");
const bobView = await walletInbox("alice");
const bobView = await userInbox("alice");
expect(bobView).toBe(aliceView);
});
+2 -2
View File
@@ -1,6 +1,6 @@
import { test, expect, mock, beforeEach, afterAll } from "bun:test";
import { post, read, materialize, watch } from "../src/surface/inbox";
import { walletInbox, resetRegistryCache } from "../src/shared-wallet/account-registry";
import { userInbox, resetRegistryCache } from "../src/shared-wallet/account-registry";
import type { Deposit } from "../src/surface/inbox";
import {
configure,
@@ -165,7 +165,7 @@ beforeEach(async () => {
fake = inject();
resetRegistryCache();
setCurrentUser("alice");
TARGET = await walletInbox("alice");
TARGET = await userInbox("alice");
});
test("post writes via the real injected ng.sparql_update (not makeNg), scoped to the inbox", async () => {
@@ -16,7 +16,7 @@
* (c) switching identity SWITCHES heldByHolder it never wipes one.
*/
import { test, expect, mock, afterAll } from "bun:test";
import { createEntityDoc, resetRegistryCache, walletInbox, listMyEntityDocs } from "../src/shared-wallet/account-registry";
import { createEntityDoc, resetRegistryCache, userInbox, listMyEntityDocs } from "../src/shared-wallet/account-registry";
import type { RegistrySession } from "../src/shared-wallet/account-registry";
import type { ReadCap } from "../src/model/types";
import {
@@ -228,7 +228,7 @@ test("(a) sharing one document's cap to ONE inbox reveals it there, and only the
// The app decides alice↔bob are related: alice shares ONE document's cap into
// bob's OWN inbox — the only cross-wallet act there is.
const bobInbox = await walletInbox("bob");
const bobInbox = await userInbox("bob");
setCurrentUser("alice");
await shareCap(capFor(shared)!, bobInbox);
@@ -239,7 +239,7 @@ test("(a) sharing one document's cap to ONE inbox reveals it there, and only the
// carol, who was not shared with, still reads nothing.
setCurrentUser("carol");
await readInbox(await walletInbox("carol"));
await readInbox(await userInbox("carol"));
expect(view(items)).toEqual([]);
});
@@ -247,7 +247,7 @@ test("a cap deposit is absorbed, not surfaced as a consumer deposit", async () =
inject();
setCurrentUser("alice");
const doc = await createEntityDoc("alice", "protected");
const bobInbox = await walletInbox("bob");
const bobInbox = await userInbox("bob");
await shareCap(capFor(doc)!, bobInbox);
setCurrentUser("bob");
@@ -320,7 +320,7 @@ test("an inbox may be DEPOSITED into by anyone, and READ only by its owner", asy
inject();
setCurrentUser("alice");
const secret = await createEntityDoc("alice", "protected");
const bobInbox = await walletInbox("bob");
const bobInbox = await userInbox("bob");
// Alice deposits into bob's inbox — allowed, and it grants her nothing back.
await shareCap(capFor(secret)!, bobInbox);
+3 -3
View File
@@ -12,7 +12,7 @@
*/
import { test, expect, mock, afterAll } from "bun:test";
import { sparqlQuery, sparqlUpdate, depositInto } from "../src/surface/docs";
import { createEntityDoc, resetRegistryCache, walletInbox } from "../src/shared-wallet/account-registry";
import { createEntityDoc, resetRegistryCache, userInbox } from "../src/shared-wallet/account-registry";
import type { RegistrySession } from "../src/shared-wallet/account-registry";
import {
configure,
@@ -103,7 +103,7 @@ test("a user reaches its OWN stores and inbox — the boundary must not lock it
inject();
setCurrentUser("alice");
await createEntityDoc("alice", "protected"); // provisions alice's account
const inbox = await walletInbox("alice");
const inbox = await userInbox("alice");
expect(mayReach(inbox)).toBe(true);
await sparqlQuery(SESSION.sessionId, READ, undefined, inbox);
@@ -116,7 +116,7 @@ test("a user reaches its OWN stores and inbox — the boundary must not lock it
test("DEPOSITING into another user's inbox crosses the boundary, and gives nothing back", async () => {
const { ng } = inject();
setCurrentUser("bob");
const bobInbox = await walletInbox("bob");
const bobInbox = await userInbox("bob");
setCurrentUser("alice");
await createEntityDoc("alice", "private"); // alice now holds caps → guard is armed
+4 -4
View File
@@ -5,7 +5,7 @@ import {
resolveAccount,
listMyEntityDocs,
resolveScopeGraph,
walletInbox,
userInbox,
createEntityDoc,
resetRegistryCache,
} from "../src/shared-wallet/account-registry";
@@ -248,11 +248,11 @@ test("resolveScopeGraph maps scopes to native store NURIs (no store-id leaks to
// docCreate), not the private-store root, so deposits never bloat the shim graph.
// Stable per wallet, and DISJOINT between wallets: reading someone else's inbox
// would collect the caps addressed to them (see inbox.ts's read guard).
const mine = await walletInbox("@alice");
const mine = await userInbox("@alice");
expect(mine).toMatch(/^did:ng:o:doc/);
expect(mine).not.toBe("did:ng:PRIV");
expect(await walletInbox("@alice")).toBe(mine); // stable
expect(await walletInbox("@bob")).not.toBe(mine); // another wallet, another inbox
expect(await userInbox("@alice")).toBe(mine); // stable
expect(await userInbox("@bob")).not.toBe(mine); // another wallet, another inbox
});
test("resolveScopeGraph falls back to the private store when no protected id is injected", async () => {
+127
View File
@@ -0,0 +1,127 @@
/**
* The published names may only use words the TARGET uses, or a marker that says why
* they exist here.
*
* Why this is a test and not a rule
* The library corrected its vocabulary on 2026-07-30 upstream a *wallet* is only a
* keyring, and what owns stores is a **user** (a *site*) by a manual pass over the
* code and docs. `walletInbox` survived that pass and lived on for weeks, and it did
* damage: the name made "one inbox per wallet" sound obvious, hiding that a user
* upstream has **two** (public store repo and protected store repo the only two
* `AddInboxCap` commits in the engine, `engine/verifier/src/site.rs:128,149`). A
* discipline applied by hand misses one; a test does not.
*
* So this pins the naming half of the design principle (`README.md`): a name either
* belongs to the target's vocabulary in which case it needs no translation and
* survives migration or it carries a marker saying WHY it exists only here, which
* also says when it disappears.
*
* What it checks, and what it deliberately does not
* Only the PUBLISHED names, the ones a consumer application types. Internal names are
* held to the same intent but not mechanically: the folder they live in already states
* their fate, and pinning every internal identifier would fight refactoring for little.
*/
import { test, expect } from "bun:test";
import * as fs from "node:fs";
import * as path from "node:path";
/**
* Words the TARGET itself uses, verified in `nextgraph-rs`. A published name built
* from these needs no translation at migration.
*/
const TARGET_WORDS = new Set([
// addressing and objects
"nuri", "doc", "docs", "document", "repo", "store", "stores", "branch", "graph",
"overlay", "cap", "caps", "read", "write", "link", "links", "shape", "shapes",
// actors and containers
"user", "users", "session", "wallet", "inbox", "inboxes", "site", "principal",
// scopes (upstream store types, `StoreRepo::from_type_and_repo`)
"public", "protected", "private", "group", "dialog", "scope",
// acts the target performs
"create", "subscribe", "unsubscribe", "query", "update", "post", "share", "open",
"fetch", "init", "watch", "sparql", "ng", "orm", "type", "types",
// RDF / SPARQL terms the engine's own query paths use
"subject", "base", "schema", "connected",
// the reactive model the ORM exposes (`OrmSubscription`, `DeepSignalSet`)
"observable", "deep", "signal", "set",
]);
/**
* Markers that name WHY something exists only in this library. Each says when it
* disappears, which a bare `fake`/`tmp` would not.
*/
const EMULATION_MARKERS = new Set(["virtual", "physical", "shim", "emulated", "polyfill"]);
/** Glue with no domain meaning — never the load-bearing part of a name. */
const NEUTRAL = new Set([
"get", "set", "is", "has", "to", "for", "of", "my", "own", "all", "by", "with",
"current", "reset", "configure", "config", "deps", "id", "ids", "address", "entity",
"list", "resolve", "assert", "escape", "literal", "iri", "record", "registry",
"change", "changed", "state", "value", "data", "info", "count", "the", "a", "an",
"options", "opts", "result", "error", "signal", "filter", "placement", "and", "or",
"make", "use", "on", "off", "from", "into", "at", "in", "out", "up", "down",
// `union` is OURS — the bounded multi-document read — but it names an operation,
// not a domain notion a consumer would have to unlearn. `eventually` is the
// library's own name.
"union", "eventually",
]);
/** `documentInboxAddress` → ["document","inbox","address"] ; `NG` → ["ng"]. */
function words(name: string): string[] {
return name
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2")
.split(/[\s_]+/)
.map((w) => w.toLowerCase())
.filter(Boolean);
}
const SRC = path.join(import.meta.dir, "..", "src");
/** Every identifier the two entry points publish, read from the `export` statements. */
function publishedNames(): string[] {
const out = new Set<string>();
for (const entry of ["index.ts", "polyfill.ts"]) {
const text = fs.readFileSync(path.join(SRC, entry), "utf8");
// `export * as ns from "…"`
for (const m of text.matchAll(/export \* as (\w+) from/g)) out.add(m[1]!);
// `export { a, b as c }` / `export type { … }`, single- and multi-line
for (const m of text.matchAll(/export (?:type )?\{([^}]*)\}/g)) {
for (const raw of m[1]!.split(",")) {
const name = raw.trim().replace(/^type /, "").split(/\s+as\s+/).pop()?.trim();
if (name) out.add(name);
}
}
// `export const x` / `export function x` / `export interface x`
for (const m of text.matchAll(/export (?:declare )?(?:const|function|class|interface|type) (\w+)/g)) {
out.add(m[1]!);
}
}
return [...out];
}
test("every published name is built from the target's vocabulary, or carries an emulation marker", () => {
const offenders: string[] = [];
for (const name of publishedNames()) {
const ws = words(name);
// A marker anywhere in the name licenses the whole name: it declares the thing
// as ours and says when it goes.
if (ws.some((w) => EMULATION_MARKERS.has(w))) continue;
const unknown = ws.filter((w) => !TARGET_WORDS.has(w) && !NEUTRAL.has(w));
if (unknown.length > 0) offenders.push(`${name}${unknown.join(", ")}`);
}
// A failure here is not "rename to satisfy the test": it is a question. Does the
// target have a word for this? Use it. Does the thing exist only here? Say so with a
// marker. Is the word genuinely neutral glue? Add it to NEUTRAL, deliberately.
expect(offenders).toEqual([]);
});
test("no published name says `wallet` where the target says `user`", () => {
// The specific regression that motivated this file. `wallet` is a legitimate target
// word (a keyring IS a wallet upstream), so the generic check above cannot catch it —
// what is wrong is using it for the thing that owns stores and inboxes.
const wrong = publishedNames().filter((n) =>
/wallet/i.test(n) && /(inbox|store|doc|cap)/i.test(n),
);
expect(wrong).toEqual([]);
});