refactor(api): séparer la surface de l'app et la machinerie
L'entrée SDK déversait la machinerie par deux fuites : - `export * as storeRegistry from "./store-registry"` exportait TOUT le module — `ensureAccount`, `addLink`, `readLinks`, `resolveAccount`, `reservedAccount`, `resetRegistryCache`, `isOwnInbox`, `myInboxes`, `userStoreDoc`. Remplacé par `store-registry-api.ts`, qui ne ré-expose que les sept appels destinés à l'app : createEntityDoc, listMyEntityDocs, resolveScopeGraph, resolveWriteGraph, walletInbox, openDocumentInbox, documentInboxAddress. - `accounts.*` — persistance d'identité navigateur, sans aucun pendant SDK — passe sur `/polyfill`, où sa disparition à la migration se lit sur la ligne d'import. L'en-tête d'`index.ts` affirmait n'exposer « que ce que @ng-org/web et @ng-org/orm exposent ». C'était faux et enseignait une frontière fausse : un consommateur en déduisait que tout ce qui s'importe de l'entrée survit à la migration, ce qui ne valait ni pour `accounts` ni pour l'essentiel de `storeRegistry`. Il énonce désormais ce que l'entrée promet vraiment : tout symbole y a un pendant dans le futur SDK, vérifié ou assumé, et rien n'y est de la machinerie. La frontière mord : le typecheck e2e a échoué aussitôt, le harnais atteignant `ensureAccount` et `resetRegistryCache` par l'entrée publique. Il passe désormais par le chemin interne, comme les tests unitaires — légitime, il teste la bibliothèque. Deux documents plutôt qu'un, mêmes exigences, publics différents : `docs/api-contract.md` (la surface de l'app, avec pour chaque sujet la signature que le futur SDK devrait exposer, et l'étiquette qui distingue le vérifié de l'assumé) et `docs/internal-contract.md` (le complément exact). 157 tests unitaires, e2e 40/40 contre le broker en ligne.
This commit is contained in:
@@ -0,0 +1,612 @@
|
||||
# API contract — what `@ng-eventually/client` exposes today, and what the future SDK should expose per subject
|
||||
|
||||
**Scope: the APP-FACING contract only.** Everything reachable from the two published entry points, and nothing else. The library's internal modules — the shim machinery, the read paths, the boundary guards — are held to the same standard (as close as possible to what NextGraph does or plans) but have their own document, `docs/internal-contract.md`: a consumer never reads that one, a maintainer does. This split was made on 2026-08-03, together with the export change described in § 15.
|
||||
|
||||
**Scope.** The real exported surface of `@ng-eventually/client` (verified against the `export` statements in `packages/client/src/index.ts` and `packages/client/src/polyfill.ts` — `package.json` maps exactly two entry points, `.` and `./polyfill`), and, for each subject, the target signature the future NextGraph JS SDK is expected to expose. Written 2026-08-03, verified against the `nextgraph-rs` clone (HEAD `213338f6`, 2026-05-16) and the installed `@ng-org/web@0.1.2-alpha.13` type 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 to read the epistemic labels.** Every target-side claim carries one of:
|
||||
|
||||
- **PASSTHROUGH (level 3 / level 2, VERIFIED)** — the target function exists today; the lib forwards to it. Citation into `nextgraph-rs` or the installed `.d.ts`. Level numbers per `README.md` § *The three references*: 3 = JS ORM (`sdk/js/orm`), 2 = wasm binding / `@ng-org/web` (`sdk/js/lib-wasm`, `sdk/js/web`), 1 = Rust engine (`engine/`).
|
||||
- **LEVEL-1 SHAPE (model VERIFIED, JS surface ASSUMED)** — the engine's model constrains the shape and is cited, but **no JS surface exists at any level**, so the signature offered here is this library's invention. The future SDK's name and parameter order for it are unknown.
|
||||
- **ASSUMPTION** — nothing at any layer constrains this; the bet and what bounds it are stated.
|
||||
- **NO COUNTERPART** — the subject has no image in the target at any layer, usually because it is shared-wallet machinery that disappears at migration. That is a finding about the emulation, not a gap in the target.
|
||||
|
||||
Per the design principle (`README.md` § *Design principle*): an absent implementation is never treated as evidence about the future — "the engine does not do X" and "the SDK will not offer X" are kept apart throughout.
|
||||
|
||||
---
|
||||
|
||||
## 1. Bootstrap and configuration
|
||||
|
||||
### Today — `@ng-eventually/client/polyfill` (everything here is removed at migration)
|
||||
|
||||
```ts
|
||||
// polyfill.ts:44
|
||||
export interface EventuallyConfig {
|
||||
ng: NgLike;
|
||||
useShape: UseShapeLike;
|
||||
sharedWallet?: { name: string; secret: string };
|
||||
currentUser?: PrincipalId;
|
||||
debugAccessLog?: boolean;
|
||||
init?: (...args: any[]) => any;
|
||||
initNg?: (...args: any[]) => any;
|
||||
}
|
||||
// polyfill.ts:99
|
||||
export function configure(c: EventuallyConfig): void;
|
||||
// polyfill.ts:113 — tests only
|
||||
export function resetConfig(): void;
|
||||
|
||||
// polyfill.ts:24
|
||||
export interface StoreRegistryDeps {
|
||||
getSession: () => Promise<RegistrySession>;
|
||||
normalizeId?: (id: string) => string;
|
||||
pointerGuard?: { attempts?: number; baseMs?: number; maxStepMs?: number };
|
||||
}
|
||||
// polyfill.ts:124
|
||||
export function configureStoreRegistry(deps: StoreRegistryDeps): void;
|
||||
// polyfill.ts:161 — tests only
|
||||
export function resetStoreRegistry(): void;
|
||||
|
||||
// polyfill.ts:106 / :153 — both tagged @internal, exported so the SDK-shaped wrappers can reach the injected SDK
|
||||
export function getConfig(): EventuallyConfig;
|
||||
export function getStoreRegistryDeps(): ResolvedRegistryDeps;
|
||||
```
|
||||
|
||||
### Target
|
||||
|
||||
**NO COUNTERPART, by design.** The whole subject is the polyfill bootstrap: it exists to inject the real SDK without a hard import (build-alias safety). At migration the consumer initializes the real SDK directly, with the two calls in § 2, and `configure` / `configureStoreRegistry` are deleted (`docs/migration-guide.md` § 7). Nothing in the target takes an "injected `ng`".
|
||||
|
||||
---
|
||||
|
||||
## 2. Lifecycle
|
||||
|
||||
### Today — `@ng-eventually/client`
|
||||
|
||||
```ts
|
||||
// lifecycle.ts:11 — forwards to the real @ng-org/web init injected at configure()
|
||||
export function init(...args: any[]): any;
|
||||
// lifecycle.ts:18 — forwards to the real @ng-org/orm initNg injected at configure()
|
||||
export function initNg(...args: any[]): any;
|
||||
```
|
||||
|
||||
### Target
|
||||
|
||||
**PASSTHROUGH, VERIFIED at both levels.** The wrapper's `...args: any[]` is deliberately shapeless; the real signatures it forwards to are:
|
||||
|
||||
```ts
|
||||
// level 2 — @ng-org/web: index.d.ts:108, source sdk/js/web/src/index.ts:51
|
||||
export declare const init: (callback: Function | null, singleton: boolean, access_requests: any) => Promise<void>;
|
||||
|
||||
// level 3 — @ng-org/orm: sdk/js/orm/src/connector/initNg.ts:51 (exported as initNg from core.ts)
|
||||
export function initNgSignals(ngImpl: NG, session: Session): void;
|
||||
|
||||
// level 2 — the Session initNg consumes: index.d.ts:264-272
|
||||
export declare type Session = {
|
||||
session_id: string | number;
|
||||
protected_store_id: string;
|
||||
private_store_id: string;
|
||||
public_store_id: string;
|
||||
ng: typeof NGModule;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
```
|
||||
|
||||
Divergence: none in behaviour (pure forwarding), but the wrapper erases the parameter types. A consumer typing calls against the wrapper learns nothing it must unlearn — it just gets no compile-time help the real SDK would give.
|
||||
|
||||
---
|
||||
|
||||
## 3. The `ng` object
|
||||
|
||||
### Today — `@ng-eventually/client`
|
||||
|
||||
```ts
|
||||
// index.ts:55
|
||||
export const ng: Record<string, any>;
|
||||
// type re-export, index.ts:50
|
||||
export type { NG } from "@ng-org/web";
|
||||
```
|
||||
|
||||
`ng` is a `Proxy` (`ng-proxy.ts:10`) forwarding every property to the injected real `ng`, overriding exactly two things: `login` / `session_start` (currently a passthrough with a TODO for shared-wallet credentials) and `sparql_update` (the emulated write-cap guard, rejecting a write when a write policy governs the anchored document and the current user lacks the cap).
|
||||
|
||||
### Target
|
||||
|
||||
**PASSTHROUGH (level 2, VERIFIED).** `export declare const ng: NG` with `NG = typeof NGModule`, 77 exported members (`index.d.ts:136-231`). The surface is identical by construction — the proxy adds no member and removes none.
|
||||
|
||||
The two overrides:
|
||||
|
||||
- `session_start(wallet_name: string, user_id: any): Promise<any>` (`index.d.ts:276`) — target signature unchanged; only the emulated credential injection disappears.
|
||||
- `sparql_update(session_id: any, sparql: string, nuri: any): Promise<any>` (`index.d.ts:297`) — target signature unchanged. The native enforcement the guard stands in for is the engine's permission model (`verify_perm`, `engine/repo/src/commit.rs:897`), which today is **called only from tests** (its enclosing `Commit::verify` has no runtime caller — see `docs/nextgraph-current-state.md` § *Author-signature verification*). That absence says nothing about the target: write permissions are the engine's declared model, so the guard's *behaviour* (a refused write) is target-shaped even though its *mechanism* (a JS-side check) is emulation. Known limit, documented in `README.md`: the guard fires only on this proxy, and the lib's own writers call the injected `ng` directly, so it is best-effort until P1b.
|
||||
|
||||
---
|
||||
|
||||
## 4. Reactive typed reads — `useShape`
|
||||
|
||||
### Today — `@ng-eventually/client`
|
||||
|
||||
```ts
|
||||
// use-shape.ts:12
|
||||
export function useShape(shapeType: unknown, scope: unknown): unknown;
|
||||
// type re-exports, index.ts:48-49
|
||||
export type { ShapeType, BaseType, Schema } from "@ng-org/shex-orm";
|
||||
export type { DeepSignalSet } from "@ng-org/alien-deepsignals";
|
||||
```
|
||||
|
||||
Behaviour: forwards to the injected real `useShape`; once any emulated cap exists (`caps.isEnforcing()`), the returned set is wrapped in a read-filtered view keeping only items whose document cap the current holder has.
|
||||
|
||||
### Target
|
||||
|
||||
**PASSTHROUGH (level 3, VERIFIED), with a signature the wrapper widens.** The real hook:
|
||||
|
||||
```ts
|
||||
// level 3 — @ng-org/orm/react: sdk/js/orm/src/frontendAdapters/react/useShape.ts:86-124
|
||||
const useShape = <T extends BaseType>(
|
||||
shape: ShapeType<T>,
|
||||
scope: Scope | string | undefined
|
||||
) => DeepSignalSet<T>;
|
||||
|
||||
// its Scope — sdk/js/orm/src/types.ts:25-38 (NOT this lib's Scope, see § 12)
|
||||
export type Scope = {
|
||||
graphs?: string[] | string;
|
||||
subjects?: string[];
|
||||
};
|
||||
```
|
||||
|
||||
The read filter disappears at migration: in the target, isolation is cryptographic — a repo whose cap the wallet does not hold is never decrypted, a union read over it yields nothing, and a targeted read errors `RepoNotFound` (`engine/verifier/src/request_processor.rs:155,163` via `resolve_target`). VERIFIED at level 1; the *consumer-visible* result (you only see what you hold) is the same, which is the point of the emulation.
|
||||
|
||||
Divergence to note: the wrapper types everything `unknown`, losing the generic `T`. A consumer wanting typed sets today must cast; at migration the real generic signature gives it back. Nothing to unlearn, only ergonomics deferred.
|
||||
|
||||
---
|
||||
|
||||
## 5. Reactive typed reads with load state — `watchShape`
|
||||
|
||||
### Today — `@ng-eventually/client`
|
||||
|
||||
```ts
|
||||
// watch-shape.ts:73
|
||||
export interface ShapeQuery<T = UnionSubject> {
|
||||
data: T[];
|
||||
isPending: boolean;
|
||||
isSuccess: boolean;
|
||||
isError: boolean;
|
||||
error: unknown;
|
||||
}
|
||||
// watch-shape.ts:90
|
||||
export interface ShapeObservable<T = UnionSubject> {
|
||||
getSnapshot(): ShapeQuery<T>;
|
||||
subscribe(onChange: () => void): () => void;
|
||||
refetch(): void;
|
||||
}
|
||||
// watch-shape.ts:166
|
||||
export function watchShape<T = UnionSubject>(
|
||||
shapeType: unknown,
|
||||
scope: Scope,
|
||||
): ShapeObservable<T>;
|
||||
```
|
||||
|
||||
### Target
|
||||
|
||||
**Partly ASSUMPTION — flagged deliberately.** `watch-shape.ts`'s header says it "anticipates NextGraph's planned `useShape(shape, scope)` upgrade, which will natively distinguish 'sync in progress' from 'synced but empty'". **No provenance for that plan exists in this repo's docs or in the `nextgraph-rs` clone** — treat the "planned upgrade" as an assumption, not a stated NextGraph direction. What IS verified at level 3 is that the distinction is *expressible* today, just not through the hook:
|
||||
|
||||
```ts
|
||||
// level 3, VERIFIED — sdk/js/orm/src/connector/GraphOrmSubscription.ts:228,260,274
|
||||
OrmSubscription.getOrCreate<T extends BaseType>(shape: ShapeType<T>, scope: NormalizedScope): OrmSubscription<T>;
|
||||
get readyPromise(): Promise<void>; // resolves when the subscription is synced — the native "no longer pending" signal
|
||||
public close(): void;
|
||||
```
|
||||
|
||||
So the constraint on the bet: the target can already answer "synced?" (`readyPromise`), and `useShape` today returns "an empty set, if still loading" (its own doc comment, `useShape.ts:29-31`) — indistinguishable from synced-empty. `watchShape` surfaces the distinction with a TanStack-`useQuery`-minimal vocabulary (`isPending`/`isSuccess`/`isError`), which is a **shape of this library's choosing**. If the future hook exposes load state under different names, the consumer's binding code changes; the underlying distinction it teaches (pending ≠ empty) is target-expressible and safe to learn.
|
||||
|
||||
---
|
||||
|
||||
## 6. One-shot listing — the read-model
|
||||
|
||||
### Today — `@ng-eventually/client`
|
||||
|
||||
```ts
|
||||
// read-model.ts:59
|
||||
export interface UnionSubject {
|
||||
subject: string;
|
||||
graph: string;
|
||||
props: Record<string, string[]>;
|
||||
}
|
||||
// read-model.ts:140
|
||||
export async function readUnion(docs: Nuri[]): Promise<UnionSubject[]>;
|
||||
```
|
||||
|
||||
Behaviour: one anchored `sparql_query` per doc (default-graph body, no `GRAPH` wrapper), parallel, per-doc failure tolerance, cap filter applied inside, machinery subjects dropped.
|
||||
|
||||
### Target
|
||||
|
||||
Two verified counterparts, one per level; neither returns `UnionSubject` — that grouping is lib-invented:
|
||||
|
||||
```ts
|
||||
// level 2, VERIFIED — the primitive readUnion composes: index.d.ts:295, source sdk/js/lib-wasm/src/lib.rs:352 (nodejs) / :555 (web)
|
||||
declare function sparql_query(session_id: any, sparql: string, base: any, nuri: any): Promise<any>;
|
||||
|
||||
// level 3, VERIFIED — the one-shot typed read: sdk/js/orm/src/connector/getObjects.ts:23
|
||||
export async function getObjects<T extends BaseType>(
|
||||
shapeType: ShapeType<T>,
|
||||
scope: Scope | string
|
||||
); // returns a deep-cloned Set of matching objects
|
||||
```
|
||||
|
||||
The anchored-read mechanics are level-1 VERIFIED: an anchor restricts the query to that repo's graph as default graph (`resolve_target_for_sparql`, `engine/verifier/src/request_processor.rs:256-285`), an anchorless query unions every named graph in the session store (same function, `UserSite → None` → `set_default_graph_as_union`). At migration `readUnion` survives as composition (the anchored per-doc read is native); a consumer that wants typed results should be on `useShape`/`getObjects`, not on `UnionSubject` — the property-bag shape is a polyfill artifact, kept generic precisely so the consumer maps it into its own types and can drop it later.
|
||||
|
||||
---
|
||||
|
||||
## 7. Raw document / SPARQL primitives — `docs.*`
|
||||
|
||||
### Today — `@ng-eventually/client` (namespace `docs`)
|
||||
|
||||
```ts
|
||||
// docs.ts:46
|
||||
export async function docCreate(
|
||||
sessionId: string,
|
||||
crdt: string,
|
||||
cls: string,
|
||||
dest: string,
|
||||
store?: unknown,
|
||||
): Promise<Nuri>;
|
||||
// docs.ts:85
|
||||
export async function sparqlUpdate(
|
||||
sessionId: string,
|
||||
query: string,
|
||||
anchor?: Nuri,
|
||||
label = "sparqlUpdate",
|
||||
): Promise<void>;
|
||||
// docs.ts:130
|
||||
export async function sparqlQuery(
|
||||
sessionId: string,
|
||||
query: string,
|
||||
base?: string,
|
||||
anchor?: Nuri,
|
||||
label = "sparqlQuery",
|
||||
): Promise<unknown>;
|
||||
// docs.ts:113 — machinery, see § 15
|
||||
export async function depositInto(
|
||||
sessionId: string,
|
||||
query: string,
|
||||
targetInbox: Nuri,
|
||||
label = "deposit",
|
||||
): Promise<void>;
|
||||
```
|
||||
|
||||
### Target
|
||||
|
||||
**PASSTHROUGH (level 2, VERIFIED)** — these mirror the real methods 1:1 minus the trailing `label` (a lib-internal access-log tag, never forwarded):
|
||||
|
||||
```ts
|
||||
// index.d.ts:60 — the installed web SDK's doc_create
|
||||
declare function doc_create(session_id: any, crdt: string, class_name: string, destination: string, store_repo: any): Promise<any>;
|
||||
// index.d.ts:297
|
||||
declare function sparql_update(session_id: any, sparql: string, nuri: any): Promise<any>;
|
||||
// index.d.ts:295
|
||||
declare function sparql_query(session_id: any, sparql: string, base: any, nuri: any): Promise<any>;
|
||||
```
|
||||
|
||||
`depositInto` has **NO COUNTERPART as a SPARQL write**: upstream a deposit is a sealed message, not an update into the recipient's graph (§ 9). It exists only because the emulated inbox is an RDF document.
|
||||
|
||||
**Store targeting — a nuance this repo's docs understate.** `docs/nextgraph-current-state.md` and `docs/migration-guide.md` say a public/arbitrary `StoreRepo` "is not JS-constructible". Verified in the clone, the picture is finer:
|
||||
|
||||
- The **web** wasm variant (`sdk/js/lib-wasm/src/lib.rs:1575`, `#[cfg(not(wasmpack_target = "nodejs"))]`) deserializes its 5th argument as `Option<StoreRepo>` via serde — so a value CAN be passed, but no JS helper exists to build the serde form, which keeps it out of practical reach. The published `.d.ts` documents this 5-arg form.
|
||||
- The **nodejs** variant (`lib.rs:1618`, 6 args) takes `store_type: Option<String>` + `store_repo: Option<String>` and builds the store via `StoreRepo::from_type_and_repo(store_type, repo_id_str)` with `store_type ∈ "public" | "protected" | "private" | "group"` (`sdk/rust/src/local_broker.rs:2969-2987`, `engine/repo/src/types.rs:819-828`).
|
||||
|
||||
So the target's direction for scope placement is **already visible in the source** (level 2, VERIFIED, nodejs SDK): name the store by type + repo id strings. The migration-guide's anticipated `getNativeStore(scope)`-style resolver should expect to produce exactly that pair (or the serde `StoreRepo` once a web helper lands) — not a new concept.
|
||||
|
||||
---
|
||||
|
||||
## 8. Per-document subscription — `subscribeDoc`
|
||||
|
||||
### Today — `@ng-eventually/client`
|
||||
|
||||
```ts
|
||||
// subscribe.ts:47,60,79
|
||||
export type DocChange = unknown;
|
||||
export type DocChangeType = string | undefined;
|
||||
export type Unsubscribe = () => void;
|
||||
// subscribe.ts:69
|
||||
export function docChangeType(resp: DocChange): DocChangeType;
|
||||
// subscribe.ts:104
|
||||
export function subscribeDoc(
|
||||
nuri: Nuri,
|
||||
onChange: (r: DocChange, type: DocChangeType) => void,
|
||||
): Unsubscribe;
|
||||
// subscribe.ts:184
|
||||
export function subscribeDocs(
|
||||
nuris: Nuri[],
|
||||
onChange: (nuri: Nuri, r: DocChange, type: DocChangeType) => void,
|
||||
): Unsubscribe;
|
||||
```
|
||||
|
||||
### Target
|
||||
|
||||
**PASSTHROUGH (level 2, VERIFIED) with two deliberate ergonomic deltas:**
|
||||
|
||||
```ts
|
||||
// index.d.ts:66, source sdk/js/lib-wasm/src/lib.rs:1908
|
||||
declare function doc_subscribe(repo_o: string, session_id: any, callback: Function): Promise<any>;
|
||||
```
|
||||
|
||||
- The real call is `async` and resolves to an unsubscribe function; the wrapper returns the unsubscribe **synchronously** and honours an early cancel when the promise settles. A consumer coding against the sync return will keep working against the real SDK only through an adapter — a small, known unlearn, traded for not forcing `await` on every subscription site.
|
||||
- The real callback receives one argument, the serialized `AppResponse` (`{ V0: { State | Patch | TabInfo | … } }`); the wrapper adds a second, pre-extracted `type`. `docChangeType` is a convenience over the verified payload shape (pinned by the e2e CONTRACT-3 probe), not an upstream API.
|
||||
- `subscribeDocs` has **NO COUNTERPART and needs none**: it is client-side composition (a set of `doc_subscribe` with per-doc error isolation). The upstream fan-out primitive that looks like it (`orm_start_graph(graph_scope, …)`, `index.d.ts:243`) aborts wholesale on one `RepoNotFound` (`sdk/js/orm` → `engine/verifier/src/request_processor.rs:53-66`) — the reason this composition exists.
|
||||
|
||||
---
|
||||
|
||||
## 9. Inbox — deposits, and cap delivery
|
||||
|
||||
### Today — `@ng-eventually/client` (namespace `inbox`; `shareCap` also re-exported from `/polyfill`)
|
||||
|
||||
```ts
|
||||
// inbox.ts:48,58
|
||||
export interface Deposit {
|
||||
from: PrincipalId | null;
|
||||
payload: unknown;
|
||||
ts: number;
|
||||
}
|
||||
export interface PostOptions {
|
||||
from?: PrincipalId | null;
|
||||
payload: unknown;
|
||||
ts?: number;
|
||||
}
|
||||
// inbox.ts:140
|
||||
export async function post(targetInbox: Nuri, opts: PostOptions): Promise<void>;
|
||||
// inbox.ts:215
|
||||
export async function postToDocument(doc: Nuri, opts: PostOptions): Promise<void>;
|
||||
// inbox.ts:282
|
||||
export async function shareCap(cap: ReadCap, toInbox: Nuri): Promise<void>;
|
||||
// inbox.ts:339
|
||||
export async function read(targetInbox: Nuri): Promise<Deposit[]>;
|
||||
// inbox.ts:419
|
||||
export const materialize = read;
|
||||
// inbox.ts:441
|
||||
export async function readSynced(targetInbox: Nuri): Promise<Deposit[]>;
|
||||
// inbox.ts:466
|
||||
export async function processInbox(targetInbox: Nuri): Promise<Deposit[]>;
|
||||
// inbox.ts:492
|
||||
export function watch(
|
||||
targetInbox: Nuri,
|
||||
onDeposits: (deposits: Deposit[]) => void,
|
||||
_opts?: { intervalMs?: number },
|
||||
): () => void;
|
||||
```
|
||||
|
||||
### Target
|
||||
|
||||
**LEVEL-1 SHAPE throughout — the model is VERIFIED, every JS signature here is this library's invention.** There is no inbox method in `@ng-org/web` (none in the 77 `index.d.ts` exports, re-verified), and the verifier's dispatch has no `InboxPost` arm (arms actually handled listed at `engine/verifier/src/request_processor.rs:53-1444`, re-verified). The engine model that constrains the shape:
|
||||
|
||||
- An inbox is a keypair on **exactly one repo**: `pub inbox: Option<PrivKey>` (`engine/repo/src/repo.rs:126`); routing is `inboxes: HashMap<PubKey, RepoId>` on the verifier (`engine/verifier/src/verifier.rs:105`, looked up at `:1677`, inserted at `:1928`).
|
||||
- A message is sealed to the inbox pubkey and carries **no target document** — `InboxMsgBody { to_overlay, to_inbox: PubKey, from_overlay: Option<OverlayId>, from_inbox: Option<PubKey>, … }` (`engine/net/src/types.rs:4265`). The address identifies the recipient repo; nothing else is needed. This is why `Deposit` has no document field and why `post` takes only the inbox NURI.
|
||||
- `from` optional upstream (`from_inbox: Option<PubKey>`) — the "identified if known, anonymous otherwise" behaviour `PostOptions.from` mirrors, including the `null`-means-anonymous case.
|
||||
- The recipient's own verifier unseals and **applies** queued messages when it processes its inbox (`engine/verifier/src/verifier.rs:1674-1690` → `process_inbox`); an inbox is a consumed queue, not a store you re-read.
|
||||
|
||||
Consequences per function:
|
||||
|
||||
- `post` / `postToDocument` — the sender-side act exists in the model (the broker routes `InboxPost` natively, `engine/net/src/server_broker.rs`); its JS surface does not. **The future SDK's name and signature are unknown** — `docs/nextgraph-current-state.md:187` records that nothing is announced. `postToDocument`'s resolution step (find the document's inbox address) rides on a **deliberate divergence**: this lib PUBLISHES the address on the document (Header-branch emulation), whereas upstream an address is only ever TRANSMITTED (`ContactDetails` carries `ng:site_inbox`/`ng:protected_inbox`, `engine/verifier/src/inbox_processor.rs:778-830`; the verifier's `inboxes` table is session-local, rebuilt empty — `verifier.rs:520,2820`). Documented in `docs/briefs/2026-08-03-document-inbox-addressing.md`.
|
||||
- `shareCap` — a **gap upstream, not a disagreement**, verified at both ends: `ContactDetails.read_cap: Option<ReadCap>` exists (`engine/net/src/types.rs:4233`) but building a message with it is `unimplemented!()` (`types.rs:3786`), its only caller passes `with_readcap: false`, and the receiving arm never reads the field (`inbox_processor.rs:778-830`). `InboxMsgContent::Link` is a **unit variant carrying nothing** (`types.rs:4252`) — do not read it as the delivery channel. The recipient-side filing the lib emulates is real: `AddLink { read_cap }` on the User branch (`engine/repo/src/types.rs:1939-1948`). The consumer's *act* (share one document's cap to one inbox) is target-shaped; only the transport is emulated.
|
||||
- `read` / `materialize` / `readSynced` / `processInbox` / `watch` — **stand-ins for the recipient's own verifier processing**, which has no consumer-facing JS surface upstream and may never have this list-of-deposits shape. A consumer should treat "my inbox gets processed when I connect, and applied caps just appear in what I hold" as the durable contract (that is what `connectedUser` automates, § 13); code that leans on enumerating raw deposits as a mailbox UI is coding against emulation detail it may have to unlearn. The consumer-payload case (`Deposit.payload` as app data) maps to `InboxMsgContent` variants upstream (`types.rs:4249-4260`), of which only `ContactDetails` and `SocialQuery` are more than unit variants today — arbitrary app payloads through the inbox are an **ASSUMPTION**, constrained by the model only in that messages are sealed, per-recipient, and applied by the recipient.
|
||||
- `watch`'s `_opts?: { intervalMs?: number }` is accepted and **ignored** (kept for signature compatibility with a removed polling watcher) — dead surface, see § 15.
|
||||
|
||||
---
|
||||
|
||||
## 10. Capabilities — possession, not ACL
|
||||
|
||||
### Today
|
||||
|
||||
```ts
|
||||
// @ng-eventually/client — nuri.ts:50,60 (type guards; the only doors from string to typed)
|
||||
export function isNuri(s: string): s is Nuri;
|
||||
export function hasReadCap(s: string): s is ReadCap;
|
||||
|
||||
// @ng-eventually/client — types.ts:11,34
|
||||
export type Nuri = `did:ng:${string}`;
|
||||
export type ReadCap = `did:ng:${string}:r:${string}`;
|
||||
|
||||
// @ng-eventually/client/polyfill — polyfill.ts:205
|
||||
export function capFor(nuri: Nuri): ReadCap | undefined;
|
||||
// polyfill.ts:193 — hands out the registry itself
|
||||
export function getCaps(): CapRegistry;
|
||||
// polyfill.ts:215 — tests / fresh wallet only
|
||||
export function resetCaps(): void;
|
||||
|
||||
// @ng-eventually/client/polyfill — caps.ts:59 (class CapRegistry)
|
||||
constructor(holder?: () => PrincipalId | null);
|
||||
mint(nuri: Nuri): ReadCap;
|
||||
learn(cap: ReadCap): void;
|
||||
capFor(nuri: Nuri): ReadCap | undefined;
|
||||
publishRepoLink(nuri: Nuri): ReadCap;
|
||||
isPublished(nuri: Nuri): boolean;
|
||||
open(nuri: Nuri, scope: Scope): ReadCap;
|
||||
isEnforcing(): boolean;
|
||||
onChange(listener: () => void): () => void;
|
||||
grantWrite(doc: Nuri, principal: PrincipalId): void; // decorative until P1b
|
||||
governsWrite(doc: Nuri): boolean; // decorative until P1b
|
||||
canWrite(doc: Nuri, principal: PrincipalId | null): boolean; // decorative until P1b
|
||||
hasWritePolicy(): boolean; // decorative until P1b
|
||||
clear(): void;
|
||||
```
|
||||
|
||||
### Target
|
||||
|
||||
**LEVEL-1 SHAPE.** There is no capability API at level 2 or 3 (no cap method in `index.d.ts`, none in the ORM), and there is **nothing to introspect upstream**: reading is key possession. The model, VERIFIED:
|
||||
|
||||
- A ReadCap is the serialized `ObjectRef` — `format!("r:{}", base64_url::encode(&ser))` (`BlockRef::readcap_nuri`, `engine/repo/src/types.rs:518-521`). The lib's `ReadCap` template-literal grammar (`…:r:{cap}`) is upstream's, with the stand-in constant `OK` in place of the key material (P1b swaps the value, not the shape).
|
||||
- Caps live in two durable registers by origin: created documents → `AddRepo { read_cap }` on the store's Store branch (`engine/repo/src/types.rs:1890-1899`, committed by `doc_create` via `send_add_repo_to_store`, `engine/verifier/src/request_processor.rs:698`); received caps → `AddLink { read_cap }` on the private store's User branch (`types.rs:1939-1948`).
|
||||
- The one path that loads a repo from a cap is `pub(crate)` — `Verifier::load_repo_from_read_cap` (`engine/verifier/src/verifier.rs:2237`) — unexposed to JS.
|
||||
|
||||
`capFor(nuri)` asks the only question the model admits — "do I hold this document's key?" — and returning `undefined` is the whole possible answer. There is no "may principal P read D?" anywhere, and the future SDK cannot offer one without inventing an ACL the engine does not have. That absence is a **finding about the target's model**, not a missing feature: a consumer should never expect a cap-introspection API.
|
||||
|
||||
The `CapRegistry` class itself is machinery (the in-memory record of what the connected holder holds — upstream's local user storage). The consumer-facing surface is `capFor` + the acts (`shareCap`, creating a document, processing one's inbox); see § 15.
|
||||
|
||||
---
|
||||
|
||||
## 11. NURI and SPARQL string utilities
|
||||
|
||||
### Today — `@ng-eventually/client`
|
||||
|
||||
```ts
|
||||
// sparql.ts:32,66,95
|
||||
export function escapeLiteral(value: string): string;
|
||||
export function escapeIri(value: string): string;
|
||||
export function assertNuri<T extends string>(nuri: T): T;
|
||||
```
|
||||
|
||||
(`isNuri` / `hasReadCap` are in § 10; `targetOf`, `parseNuri`, `mintCap` exist in `nuri.ts` but are **not** exported from either entry point — deliberately: nothing on the surface turns a bare reference into a cap.)
|
||||
|
||||
### Target
|
||||
|
||||
**NO COUNTERPART at any level, and none expected.** Neither `@ng-org/web` nor the ORM exposes SPARQL escaping helpers (re-verified against `index.d.ts` and `sdk/js/orm/src`); the engine does its own ad-hoc literal escaping internally where it builds SPARQL (e.g. `update_header`, `engine/verifier/src/request_processor.rs:196-208`). These are generic injection-safety utilities, not SDK anticipation: they stay useful to any app that builds SPARQL by interpolation, against this lib or the real SDK. Nothing to unlearn; also nothing that migration replaces.
|
||||
|
||||
---
|
||||
|
||||
## 12. Scope resolution, per-entity documents, and the store registry
|
||||
|
||||
### 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/store-registry-api.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.
|
||||
|
||||
```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 {
|
||||
id: string;
|
||||
docPublic: Nuri;
|
||||
docProtected: Nuri;
|
||||
docPrivate: Nuri;
|
||||
}
|
||||
export interface RegistrySession {
|
||||
sessionId: string;
|
||||
privateStoreId: string;
|
||||
protectedStoreId?: string;
|
||||
publicStoreId?: string;
|
||||
}
|
||||
|
||||
// consumer-facing, designed to survive migration (store-registry.ts:917, 1358, 1079, 735, 698)
|
||||
export async function createEntityDoc(id: string, scope: Scope): Promise<Nuri>;
|
||||
export async function listMyEntityDocs(id: string, scope: Scope): Promise<Nuri[]>;
|
||||
export async function userStoreDoc(id: string, scope: Scope): Promise<Nuri>;
|
||||
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 isOwnInbox(nuri: Nuri): Promise<boolean>;
|
||||
export async function openDocumentInbox(doc: Nuri): Promise<Nuri>;
|
||||
export async function documentInboxAddress(doc: Nuri): Promise<Nuri | undefined>;
|
||||
export async function myInboxes(): Promise<Nuri[]>;
|
||||
|
||||
// User-branch registers (store-registry.ts:1307, 1331)
|
||||
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 function reservedAccount(name: string): string;
|
||||
export function resetRegistryCache(): void;
|
||||
```
|
||||
|
||||
### Target — split by what each piece maps to
|
||||
|
||||
- **`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.
|
||||
- **`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.
|
||||
- **`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.
|
||||
|
||||
---
|
||||
|
||||
## 13. Identity and connection
|
||||
|
||||
### Today
|
||||
|
||||
```ts
|
||||
// @ng-eventually/client — accounts.ts (namespace accounts)
|
||||
export const ACCOUNT_STORAGE_KEY = "ng-eventually.account.id"; // :18
|
||||
export interface AccountStorage { // :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);
|
||||
get(): string | null;
|
||||
set(id: string): string | null;
|
||||
clear(): void;
|
||||
}
|
||||
export function browserIdentityStore(key?: string): IdentityStore; // :89
|
||||
|
||||
// @ng-eventually/client/polyfill
|
||||
export function setCurrentUser(id: PrincipalId | null): void; // polyfill.ts:171
|
||||
export function getCurrentUser(): PrincipalId | null; // polyfill.ts:187
|
||||
export async function connectedUser(): Promise<void>; // connect.ts:52
|
||||
```
|
||||
|
||||
### Target
|
||||
|
||||
**PASSTHROUGH-to-be at level 2, VERIFIED signatures.** In the target the identity is established by opening one's own wallet and starting a per-user session — there is no "set the current user" call because the session IS the user:
|
||||
|
||||
```ts
|
||||
// index.d.ts:276, 280, 313, 315
|
||||
declare function session_start(wallet_name: string, user_id: any): Promise<any>;
|
||||
declare function session_stop(user_id: string): Promise<void>;
|
||||
declare function user_connect(client_info: any, user_id: string, location?: string | null): Promise<any>;
|
||||
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).
|
||||
- `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.
|
||||
|
||||
---
|
||||
|
||||
## 14. Type re-exports
|
||||
|
||||
`@ng-eventually/client` re-exports, type-only (erased at build, `index.ts:48-50`):
|
||||
|
||||
```ts
|
||||
export type { ShapeType, BaseType, Schema } from "@ng-org/shex-orm";
|
||||
export type { DeepSignalSet } from "@ng-org/alien-deepsignals";
|
||||
export type { NG } from "@ng-org/web";
|
||||
```
|
||||
|
||||
**PASSTHROUGH (levels 2/3, VERIFIED)** — `ShapeType`/`BaseType` at `@ng-org/shex-orm` `dist/types.d.ts:5,12` (installed 0.1.2-alpha.8); `NG` at `index.d.ts:136`. At migration these imports point at the same packages directly; nothing changes for the consumer.
|
||||
|
||||
---
|
||||
|
||||
## 15. Machinery on the surface — what a consumer should NOT code against
|
||||
|
||||
Exported, but not SDK surface. Coding against these builds knowledge that migration deletes:
|
||||
|
||||
- **`docs.depositInto`** — the named boundary-crossing write `inbox.post` uses. It is exported only because `inbox.ts` lives in another module; a consumer must always go through `inbox.post` / `inbox.shareCap`. Upstream a deposit is a sealed message, not a SPARQL update — this function's very signature is emulation.
|
||||
- **`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.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.
|
||||
- **`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.
|
||||
|
||||
### Places the current surface teaches something to unlearn
|
||||
|
||||
- ~~**The SDK entry is not as pure as its header claims.**~~ **FIXED 2026-08-03.** The header claimed the entry "exposes ONLY what `@ng-org/web` / `@ng-org/orm` expose" while also shipping `accounts` and the whole `store-registry` module. Both are gone from it, and the header now states what the entry actually promises: *every symbol here has a target-SDK counterpart, verified or assumed, listed in this document*. It still exports `docs`, `readModel`, `watchShape`, `subscribeDoc(s)`, the SPARQL helpers and the NURI guards — justified inventions, documented per subject above — so the promise is no longer "@ng-org surface only", which was never true, but "nothing here is machinery".
|
||||
- **`shareCap` is importable from both entries** (`inbox.shareCap` on the SDK entry via `export * as inbox`, and a named re-export on `/polyfill`). The polyfill re-export exists "so the cap vocabulary stays on the polyfill side" — but the namespace export undoes that. Harmless functionally; blurs the same boundary.
|
||||
- **`inbox.read`/`materialize` as a mailbox** — enumerating raw deposits is emulation detail (§ 9); the durable contract is deposit-and-it-gets-applied. An app building UI on the deposit list should expect that surface to change shape entirely.
|
||||
- **`watchShape`'s "planned `useShape` upgrade"** — stated in the module header with no provenance in this repo or the clone (§ 5). The load-state *distinction* is safe; the claim that NextGraph plans this exact hook shape is an assumption and must not be cited as an announced API.
|
||||
- **`UnionSubject` property bags** — polyfill read-model shape, not a target type; map them into app types at the boundary (which `watchShape`'s design already assumes).
|
||||
- **The sync-returning `subscribeDoc` unsubscribe** vs the target's promise-resolved one (§ 8) — a deliberate, documented ergonomic delta; an adapter is one line at migration, but it is a delta.
|
||||
|
||||
---
|
||||
|
||||
## 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/polyfill` (from `polyfill.ts`): types `StoreRegistryDeps`, `EventuallyConfig`; values `configure`, `getConfig`, `resetConfig`, `configureStoreRegistry`, `getStoreRegistryDeps`, `resetStoreRegistry`, `setCurrentUser`, `getCurrentUser`, `getCaps`, `capFor`, `resetCaps`, `CapRegistry`, `shareCap`, `connectedUser`.
|
||||
|
||||
Not exported from either entry (internal, listed to preempt "why isn't X documented"): `nuri.targetOf` / `parseNuri` / `mintCap`, `subscribePhysicalDoc`, `machinery.*`, `open-repo.*`, `read-filter.*`, `reach.*`, `physical.*`, `access-log.*`, `outbox-log.*`, `connect.startConnect`.
|
||||
@@ -0,0 +1,269 @@
|
||||
# Internal contract — what `@ng-eventually/client` keeps off its surface, and what NextGraph does or would do about each subject
|
||||
|
||||
**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`, `read-model.ts`, and by name everything `use-shape.ts`, `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 **`store-registry-api.ts` slice only** (7 functions: `createEntityDoc`, `listMyEntityDocs`, `resolveScopeGraph`, `resolveWriteGraph`, `walletInbox`, `openDocumentInbox`, `documentInboxAddress`). `polyfill.ts` re-exports `CapRegistry` from `caps.ts`, `shareCap` from `inbox.ts`, `connectedUser` from `connect.ts`, `* as accounts` from `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: `access-log.ts`, `machinery.ts`, `ng-proxy.ts`, `open-repo.ts`, `outbox-log.ts`, `physical.ts`, `reach.ts`, `read-filter.ts`. Four are internal in part: `nuri.ts`, `connect.ts`, `subscribe.ts`, `store-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.
|
||||
|
||||
---
|
||||
|
||||
## 1. The wrapped `ng` factory — `ng-proxy.ts`
|
||||
|
||||
```ts
|
||||
// ng-proxy.ts:10
|
||||
export function makeNg(): Record<string, any>;
|
||||
```
|
||||
|
||||
Builds the published `ng` Proxy (consumed once, `index.ts:61`): forwards every property to the injected real `ng`, overriding `login`/`session_start` (passthrough with a shared-wallet-credentials TODO) and `sparql_update` (the emulated write-cap guard).
|
||||
|
||||
- The factory itself is **NO COUNTERPART, by design** — the target has no "wrap the SDK" step; at migration `ng` IS `@ng-org/web`'s and `makeNg` is deleted.
|
||||
- The `sparql_update` guard stands in for the engine's write-permission model (`verify_perm` inside `Commit::verify`, `engine/repo/src/commit.rs:892-899`) — same analysis as `docs/api-contract.md` § 3.
|
||||
- **Defect — the `login` arm fabricates a member (see Findings F1).** `@ng-org/web` has no `login`: none among the exports of `index.d.ts` (re-verified), and no `fn login` in `sdk/js/lib-wasm/src/lib.rs`. The proxy nevertheless returns a function for `prop === "login"` (`ng-proxy.ts:16-22`), so `typeof ng.login === "function"` on the wrapper while the real SDK yields `undefined` — the one place the proxy adds a member, contradicting its own header and the surface contract's "adds no member and removes none" (§ 3). Calling it throws at runtime (`ng[prop]` is undefined). **ASSUMPTION with no provenance** — no target layer names a `login`.
|
||||
- Disappears at migration (the whole module).
|
||||
|
||||
## 2. NURI internals — the unexported slice of `nuri.ts`
|
||||
|
||||
```ts
|
||||
// nuri.ts:73
|
||||
export function targetOf(nuri: Nuri): Nuri;
|
||||
// nuri.ts:82
|
||||
export function parseNuri(nuri: Nuri): { target: Nuri; readCap?: ReadCap };
|
||||
// nuri.ts:116
|
||||
export function mintCap(nuri: Nuri): ReadCap;
|
||||
```
|
||||
|
||||
`targetOf` strips a `:r:` cap segment to the naming form; `parseNuri` is the parsed pair; `mintCap` builds the cap-bearing form with the stand-in value `OK`. Kept off the surface deliberately: nothing published turns a bare reference into a cap.
|
||||
|
||||
- `targetOf` / `parseNuri` — **LEVEL-1 SHAPE, model VERIFIED**: a 1:1 mirror of upstream's one-type-with-optional-access NURI. The ReadCap encoding they discriminate on is `r:{base64url(serde_bare(ObjectRef))}` (`BlockRef::readcap_nuri`, `engine/repo/src/types.rs:518-521`), distinct from the `:k:` object/commit forms (`object_nuri`/`commit_nuri`, `types.rs:510-514`). No JS surface parses NURIs at level 2 or 3 — the real SDK takes plain strings — so these helpers never surface in signatures and survive only as internals.
|
||||
- `mintCap` — **NO COUNTERPART as an operation, and that is the point**: upstream a ReadCap is produced by the engine when a repo is created, never derived from a bare reference by a caller. `mintCap` exists solely because the emulation needs a cap VALUE at creation time and P1b has not yet supplied real key material; the constant `OK` pretends nothing (`nuri.ts:87-103`). It has exactly two call sites (`store-registry.ts` `createEntityDoc`; `caps.ts` internals) — the minting points of the emulation. At P1b the constant becomes a real key; at migration the function is deleted (the engine mints).
|
||||
|
||||
## 3. The reach boundary — `reach.ts`
|
||||
|
||||
```ts
|
||||
// reach.ts:66
|
||||
export function declareInfrastructure(nuri: Nuri): void;
|
||||
// reach.ts:71
|
||||
export function isInfrastructure(nuri: Nuri): boolean;
|
||||
// reach.ts:76
|
||||
export function resetInfrastructure(): void;
|
||||
// reach.ts:93
|
||||
export function mayReach(nuri: Nuri): boolean;
|
||||
// reach.ts:110
|
||||
export function assertMayReach(nuri: Nuri, op: string): void;
|
||||
// reach.ts:131
|
||||
export function mustNotAttempt(nuri: Nuri): boolean;
|
||||
```
|
||||
|
||||
The single predicate deciding whether the CONNECTED virtual user may touch a document at all: cap possession, or explicitly-declared infrastructure (the store-root and doc-shim). `assertMayReach` guards the passage points (rule 1, throw on refusal); `mustNotAttempt` guards the callers (rule 2, do not even issue the operation). Inert until the first cap exists (`caps.isEnforcing()`).
|
||||
|
||||
- **NO COUNTERPART, shared-wallet machinery — the emulated stand-in for the wallet boundary itself.** In the target the boundary is cryptographic, not a predicate: a repo whose cap the wallet does not hold is never decrypted, a targeted read of it errors `RepoNotFound` (`resolve_target_for_sparql`, `engine/verifier/src/request_processor.rs:264,269`), and the only path that loads a repo from a cap is `pub(crate)` (`Verifier::load_repo_from_read_cap`, `engine/verifier/src/verifier.rs:2237`). The two-rules split (refuse at the gate AND do not attempt) is redundancy this lib chose; upstream only "cannot" exists — there is nothing to refuse because the request cannot be formed.
|
||||
- The infrastructure exemption (`declareInfrastructure`, registered by `store-registry.ts` for the store-root and doc-shim only) has **no image in the target**: there is no shim to exempt. Registration-not-pattern-matching is a lib-internal safety choice.
|
||||
- Everything here disappears at migration; the durable lesson it protects (naming a document does not grant access) is the target's own model.
|
||||
|
||||
## 4. The physical user's primitives — `physical.ts`
|
||||
|
||||
```ts
|
||||
// physical.ts:54
|
||||
export async function physicalCreate(sessionId: string, crdt = "Graph", cls = "data:graph", dest = "store", store?: unknown): Promise<Nuri>;
|
||||
// physical.ts:80
|
||||
export async function physicalQuery(sessionId: string, query: string, base: string | undefined, anchor: Nuri, label = "physicalQuery"): Promise<unknown>;
|
||||
// physical.ts:94
|
||||
export async function physicalUpdate(sessionId: string, query: string, anchor: Nuri, label = "physicalUpdate"): Promise<void>;
|
||||
```
|
||||
|
||||
The unguarded counterparts of `docs.docCreate` / `sparqlQuery` / `sparqlUpdate`, callable only by the library's own machinery on the shim's documents (store-root pointer, doc-shim, provisioning). Separated as FUNCTIONS rather than as an exemption list so machinery never gets "waved through" a guard (module header, `physical.ts:19-27`).
|
||||
|
||||
- As wire calls: **PASSTHROUGH (level 2, VERIFIED)** — the same `doc_create` / `sparql_query` / `sparql_update` the published `docs.*` forwards to (`index.d.ts:60,295,297`; sources `sdk/js/lib-wasm/src/lib.rs:1575` web / `:1618` nodejs, `:352`/`:555`), minus the lib-internal `label`.
|
||||
- As a CONCEPT: **NO COUNTERPART, shared-wallet machinery.** The physical/virtual user split exists only because one wallet hosts many identities; the target has exactly one user per wallet and no privileged "machinery caller". The module disappears with the shim.
|
||||
|
||||
## 5. Physical subscription — the unexported slice of `subscribe.ts`
|
||||
|
||||
```ts
|
||||
// subscribe.ts:118
|
||||
export function subscribePhysicalDoc(nuri: Nuri, onChange: (r: DocChange, type: DocChangeType) => void): Unsubscribe;
|
||||
```
|
||||
|
||||
`subscribeDoc` minus the reach guard — the machinery's door to `doc_subscribe`, used by `open-repo.ts` to hold shim repos open. Same wire behaviour as the published `subscribeDoc` (analysed in `docs/api-contract.md` § 8, target `doc_subscribe`, `index.d.ts:66`, `sdk/js/lib-wasm/src/lib.rs:1908`).
|
||||
|
||||
- **NO COUNTERPART, shared-wallet machinery** — the guarded/unguarded pair collapses to one call when the wallet is the boundary. Disappears with `physical.ts`.
|
||||
|
||||
## 6. Bootstrap repo opening — `open-repo.ts`
|
||||
|
||||
```ts
|
||||
// open-repo.ts:75
|
||||
export type SyncState = "syncing" | "synced" | "timed-out";
|
||||
// open-repo.ts:104 — TEST-ONLY
|
||||
export function setOpenTimeoutForTests(ms: number): void;
|
||||
// open-repo.ts:110
|
||||
export function resetOpenedRepos(): void;
|
||||
// open-repo.ts:135
|
||||
export function getSyncState(nuri: Nuri): SyncState | "unknown";
|
||||
// open-repo.ts:167
|
||||
export async function ensureRepoOpen(nuri: Nuri): Promise<void>;
|
||||
// open-repo.ts:184
|
||||
export async function ensurePhysicalRepoOpen(nuri: Nuri): Promise<void>;
|
||||
// open-repo.ts:259
|
||||
export async function ensureReposOpen(nuris: Nuri[]): Promise<void>;
|
||||
```
|
||||
|
||||
Heals the cold-start defect of the anchored read path: on a fresh session a not-yet-open repo reads empty, so before an anchored read the repo is opened by subscribing (`subscribePhysicalDoc`) and awaiting the first `State` push — the sync barrier — with a bounded timeout. The subscription is held for the session; per-nuri `SyncState` keeps `synced` and `timed-out` apart.
|
||||
|
||||
- The opening mechanism is **level 2, VERIFIED as a composition**: `doc_subscribe` exists (`sdk/js/lib-wasm/src/lib.rs:1908`), and the push variants `TabInfo`/`State`/`Patch` are the engine's `AppResponseV0` (`engine/net/src/app_protocol.rs:1354-1358`). The ORDER (TabInfo first, then the initial State) and "first State = presence guaranteed, absence definitive" are **empirical, pinned by the in-repo e2e CONTRACT-3 probe — an ASSUMPTION about ordering as far as upstream is concerned**: no upstream statement fixes the push order, so a future reordering upstream would silently break the barrier. Bound: the e2e probe fails loudly if the order changes.
|
||||
- "Hold a live subscription to keep the repo open" — **ASSUMPTION** (nothing upstream documents subscription lifetime as what retains a repo in `self.repos`); observed to work, bounded by the same probe.
|
||||
- **Defect — the header's mechanism claim is contradicted at the source (see Findings F2).** `open-repo.ts:10-12` says an anchored `sparql_query` on a repo absent from `self.repos` "silently returns 0 rows (never a `RepoNotFound`)". Verified upstream: absence from `self.repos` yields `Err(NgError::RepoNotFound)` (`request_processor.rs:264,269`), the ReadQuery arm converts it into `AppResponse::error` (`:1293-1296`), and the web binding REJECTS the JS promise with it (`sdk/js/lib-wasm/src/lib.rs:606`). The observed 0-rows-no-error behaviour has two candidate explanations that the source does support: a persistent verifier reloads every known repo into `self.repos` at `Verifier::load` (`engine/verifier/src/verifier.rs:535-560`) so the repo is present-but-unsynced (a genuine 0-row read), and/or the lib's own per-doc tolerance (`readUserStore`, `read-model.ts` per-doc catch) converts a rejection into an empty result. The healed symptom is real and the fix correct; the stated mechanism is not established, and a maintainer reasoning from it would mispredict behaviour whenever the verifier is not persistent.
|
||||
- `SyncState` and `getSyncState` are lib-invented vocabulary — **NO COUNTERPART** (upstream has no consumer-facing "sync state of a repo" API at any level; `OrmSubscription.readyPromise`, `sdk/js/orm/src/connector/GraphOrmSubscription.ts:260`, is the closest level-3 signal, per-subscription not per-repo).
|
||||
- At migration the whole module becomes "open the store by cap at bootstrap" (native) and is removed with the shim.
|
||||
|
||||
## 7. The read filter — `read-filter.ts`
|
||||
|
||||
```ts
|
||||
// read-filter.ts:47
|
||||
export function filterReadable<T>(items: Iterable<T>, caps: CapRegistry): T[];
|
||||
// read-filter.ts:60
|
||||
export function makeReadFilteredView<S extends object>(set: S, caps: CapRegistry): S;
|
||||
```
|
||||
|
||||
The polyfill of capability-based read access: a Proxy view over the reactive set keeping only items whose `@graph` document the current holder holds; applied by `use-shape.ts` once `caps.isEnforcing()`.
|
||||
|
||||
- **NO COUNTERPART, by design — it stands in for cryptographic non-delivery.** In the target the broker/verifier simply never yields what the wallet holds no cap for (targeted read errors, `request_processor.rs:264,269`; union read yields nothing for undecrypted repos — the § 4 analysis of `docs/api-contract.md`). There is no post-hoc filter to migrate to; the module is deleted.
|
||||
- The `@graph` key it filters on is **level 3, VERIFIED**: the ORM annotates every object with its graph NURI (`sdk/js/orm/src/frontendAdapters/react/useShape.ts:41`, `sdk/js/orm/src/types.ts:19`). Items with no `@graph` are kept — a lib policy choice (they name no document), not an upstream rule.
|
||||
- Access unit = the DOCUMENT, not the item — faithful to the model (a ReadCap opens a repo, `types.rs:518-521`), and the reason the filter is all-or-nothing per document.
|
||||
|
||||
## 8. Connection trigger — the unexported slice of `connect.ts`
|
||||
|
||||
```ts
|
||||
// connect.ts:91
|
||||
export function startConnect(): void;
|
||||
```
|
||||
|
||||
Fire-and-forget wrapper over the published `connectedUser()` (restore Links, then drain every inbox), called by `setCurrentUser` so inbox processing is the library's job, not the app's.
|
||||
|
||||
- **LEVEL-1 SHAPE for the timing, VERIFIED**: upstream the recipient's verifier processes inbox messages as they arrive, with no consumer call (`Verifier::inbox` → `process_inbox`, `engine/verifier/src/verifier.rs:1674-1690`); firing on connection is the emulation's equivalent moment. The restore-before-drain order is a lib choice; upstream "restore" does not exist as a step (applied caps are already in the User branch replay).
|
||||
- `startConnect` itself disappears at migration; the automatic-processing behaviour it fabricates is native.
|
||||
|
||||
## 9. The shim registry — the unexported slice of `store-registry.ts`
|
||||
|
||||
The sharpest boundary case: `store-registry-api.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.
|
||||
|
||||
### 9a. Account shim — provision, resolve, reserved names, cache
|
||||
|
||||
```ts
|
||||
// store-registry.ts:213
|
||||
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>;
|
||||
// store-registry.ts:631
|
||||
export async function ensureAccount(id: string): Promise<AccountRecord>;
|
||||
```
|
||||
|
||||
`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.
|
||||
|
||||
- **NO COUNTERPART, shared-wallet machinery — the whole group.** The target has no directory of identities to resolve or provision: a user's site (three stores + their inboxes) is created once at wallet creation (`engine/verifier/src/site.rs` — the site-creation flow committing the stores and the two store-inbox `AddInboxCap`s at `:128,149`), and "which user" is the session. `ensureAccount`'s provision-on-first-sight has no target analogue and is exactly what `connectedUser` refuses to trigger (`connect.ts:60-65`). All of it disappears with the shim (`docs/migration-guide.md` § 3).
|
||||
- `reservedAccount`'s collision-safety rests on an **ASSUMPTION about a consumer-injected function**: the comment (`store-registry.ts:200-206`) asserts the injected `normalizeId` can never produce a U+0000-prefixed key, but `normalizeId` is injected by the consumer and the lib's own default is a bare `trim()` (`polyfill.ts:145`), which does not strip U+0000. Bound: a consumer id would have to begin with a literal NUL to collide — implausible from any UI, but the property is the consumer's to keep, not the lib's (see Findings F5).
|
||||
|
||||
### 9b. Scope-index resolution
|
||||
|
||||
```ts
|
||||
// store-registry.ts:1079
|
||||
export async function userStoreDoc(id: string, scope: Scope): Promise<Nuri>;
|
||||
```
|
||||
|
||||
The scope-INDEX document of one account (what `watchShape` subscribes to for container changes). Same resolution as the published `resolveWriteGraph`, without doubling as a write target.
|
||||
|
||||
- **Level 2, VERIFIED counterpart with the indirection removed**: in the target the container IS the store, and its id is on the session (`Session.private_store_id | protected_store_id | public_store_id`, `index.d.ts:264-272`); the listing a subscriber watches is the store's `ldp:contains` graph, written natively by `doc_create` (`request_processor.rs:706-708`). The function's question survives; the per-account parameter and the index-document indirection disappear.
|
||||
|
||||
### 9c. Inbox ownership predicates
|
||||
|
||||
```ts
|
||||
// store-registry.ts:837
|
||||
export async function isOwnInbox(nuri: Nuri): Promise<boolean>;
|
||||
// store-registry.ts:1286
|
||||
export async function myInboxes(): Promise<Nuri[]>;
|
||||
```
|
||||
|
||||
`isOwnInbox` — the read guard's question (may the current identity READ this inbox); `myInboxes` — the drain list for `connectedUser` (own wallet inbox + one per document this user opened an inbox on).
|
||||
|
||||
- **LEVEL-1 SHAPE for the underlying record, VERIFIED; NO COUNTERPART as API.** Upstream "which inboxes may I read" is answered inside the verifier by the User branch's `AddInboxCap` records (`AddInboxCapV0 { repo_id, overlay, priv_key }`, `engine/repo/src/types.rs:1969-1981`, applied at `verifier.rs:1916-1932`) and consulted implicitly when a message arrives (`verifier.rs:1674-1690`); nothing suggests a JS API for the question at any level. Both functions exist only because the emulation must ANSWER it in JS (the read guard, the drain); they disappear with the emulated inbox read side.
|
||||
|
||||
### 9d. The durable Link register
|
||||
|
||||
```ts
|
||||
// store-registry.ts:1307
|
||||
export async function addLink(cap: ReadCap): Promise<void>;
|
||||
// store-registry.ts:1331
|
||||
export async function readLinks(): Promise<ReadCap[]>;
|
||||
```
|
||||
|
||||
File / read back a received cap on the emulated User branch of the private store — what makes a received cap durable across sessions.
|
||||
|
||||
- **LEVEL-1 SHAPE, model VERIFIED; no JS surface anywhere.** The record emulated is `AddLink { read_cap }` on the User branch — *"so that a user can share with all its device a new Link they received"*, external repos only (`engine/repo/src/types.rs:1934-1950`). Upstream the filing happens inside the verifier while processing the inbox; the future SDK most likely never exposes these as calls (the surface contract's § 12 finding, restated here because these are now internal-only: the one caller is `connect.ts` / `inbox.ts`, which is exactly where upstream's verifier sits).
|
||||
- At migration both are deleted; the verifier files and replays.
|
||||
|
||||
## 10. The machinery namespace — `machinery.ts`
|
||||
|
||||
```ts
|
||||
// machinery.ts:32
|
||||
export const MACHINERY_NS = "urn:ng-eventually:";
|
||||
// machinery.ts:40
|
||||
export function isMachinerySubject(subject: string | undefined): boolean;
|
||||
```
|
||||
|
||||
The URN prefix every triple the lib writes for itself lives under, and the one predicate read paths use to keep machinery out of consumer data.
|
||||
|
||||
- **NO COUNTERPART, by design — the seam where the emulation pays for having no branches.** Upstream the separation is structural: a compartment is a different BRANCH with its own CRDT and topic (`BranchType`, `engine/repo/src/types.rs:1536-1551`; the Store/User/Overlay branches carry no triples at all, `BranchCrdt::None`, `types.rs:1420`), so machinery cannot appear in a content read and no subject filter exists to write. The namespace, the filter, and the four `shim:*` compartment subjects it protects all disappear at migration.
|
||||
|
||||
## 11. Diagnostics — `access-log.ts` and `outbox-log.ts`
|
||||
|
||||
```ts
|
||||
// access-log.ts:25,45,50,63,76,87,99,116
|
||||
export type AccessOp = "READ" | "WRITE";
|
||||
export function setAccessLog(on: boolean): void;
|
||||
export function enabled(): boolean;
|
||||
export function activeIdentity(): string;
|
||||
export function accessLogPrefix(): string;
|
||||
export function logStage(line: string): void;
|
||||
export function shortNuri(nuri: string): string;
|
||||
export function logAccess(op: AccessOp, nuri: string, label: string, extra?: string): void;
|
||||
|
||||
// outbox-log.ts:62
|
||||
export function inspectOutbox(): void;
|
||||
```
|
||||
|
||||
`access-log.ts` — the off-by-default per-identity access trace for the shared-wallet isolation leak (toggled by `configure({ debugAccessLog })` or `NG_EVENTUALLY_ACCESS_LOG=1`). `outbox-log.ts` — a read-only count of the real SDK's offline write outbox at session bootstrap, warning when non-empty.
|
||||
|
||||
- `access-log.ts` — **NO COUNTERPART, shared-wallet machinery**: the leak it makes visible cannot exist in the target (isolation is per-wallet), and the "active identity" it prefixes is the relayed virtual id that disappears with `setCurrentUser`. Deleted at migration.
|
||||
- `outbox-log.ts` — **NO COUNTERPART as API, but every fact it relies on is level-2 VERIFIED** in the clone: the outbox is persisted through `JsStorageConfig` (`sdk/rust/src/local_broker.rs:89-100`), keyed `ng_peer_last_seq@<peerId>` (`:119,141`) and `ng_outboxes@<peerId>@start` / `@<idx>` with zero-padded `{:05}` indexes (`:163-213`, pad at `:183,210`); the real `outbox_read_function` DRAINS on read (`session_del` per key plus the start key, `:218-224`) — which is why the probe only counts and never touches; and the storage callbacks land in browser `sessionStorage` (`sdk/js/api-web/main.ts:47,57,66`), whose access-denied error string is the one `convert_error` handles (`main.ts:18-22`). The probe reads a private persistence format of the injected SDK — acknowledged in its header as out-of-contract, hence count-only. Deleted with the rest of the trace instrumentation at migration.
|
||||
|
||||
---
|
||||
|
||||
## Findings — defects and migration risks
|
||||
|
||||
**F1 — `ng-proxy.ts` fabricates a `login` member the real SDK does not have.** `ng-proxy.ts:16-22` intercepts `prop === "login"`, but `@ng-org/web` exports no `login` (none in `index.d.ts`, re-verified against the full `declare function` list; no `fn login` in `sdk/js/lib-wasm/src/lib.rs`). On the wrapper `ng.login` is a function; on the real SDK it is `undefined`; calling it throws. This contradicts the module's own "surface stays identical" header and `docs/api-contract.md` § 3's "the proxy adds no member and removes none". No target layer names a `login` — the arm is an unprovenanced assumption. Cheap fix: drop the `login` case (keep `session_start`), or gate it on `typeof ng.login === "function"`.
|
||||
|
||||
**F2 — `open-repo.ts`'s stated mechanism is contradicted at the source.** The header (`open-repo.ts:10-12`) asserts an anchored `sparql_query` on a repo absent from `self.repos` "silently returns 0 rows (never a `RepoNotFound`)". Upstream, absence from `self.repos` errors `RepoNotFound` (`engine/verifier/src/request_processor.rs:264,269`), the ReadQuery arm wraps it as `AppResponse::error` (`:1293-1296`), and the web binding rejects the JS promise (`sdk/js/lib-wasm/src/lib.rs:606`). The observed behaviour is real but its cause is one (or both) of: the repo WAS in `self.repos` (a persistent verifier reloads all known repos at `Verifier::load`, `engine/verifier/src/verifier.rs:535-560`) and read 0 rows because unsynced; or the lib's own catch-and-continue layers absorbed the rejection. The fix (open before reading) is correct either way; the diagnosis in the header should not be relied on, and mispredicts non-persistent-verifier behaviour.
|
||||
|
||||
**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 `store-registry-api.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 `store-registry-api.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`.
|
||||
|
||||
**Migration-risk flags (shapes that will not travel):**
|
||||
|
||||
- **The `(document, inbox)` pair is persisted as a space-joined string literal** (`"${doc} ${inbox}"`, written `store-registry.ts:1174`, parsed by `split(" ")` at `:1268`). Upstream the record is the typed `AddInboxCapV0 { repo_id, overlay, priv_key }` (`engine/repo/src/types.rs:1969-1981`). Internal-only and replaced wholesale at migration, but it is the one shim record with an ad hoc micro-format a future reader must know to parse.
|
||||
- **`isOwnInbox` / `myInboxes`** encode questions the target answers only inside the verifier (§ 9c) — any new internal caller added to them deepens a dependency that has no successor API; keep callers to the read guard and the connection drain.
|
||||
- **The sync barrier is empirical** (§ 6): "TabInfo before the first State" and "held subscription keeps the repo open" are pinned by the in-repo e2e probe, not by any upstream statement. If upstream changes push ordering or repo retention, `open-repo.ts` is the module that breaks first; the probe is the tripwire.
|
||||
- **`ensureAccount`'s provision-on-first-sight** (§ 9a) is a behaviour with no target image; `connect.ts` already refuses to trigger it. Any future internal path that provisions as a side effect of resolving would be teaching the emulation something the target contradicts (creation is an explicit act at wallet/site creation, `engine/verifier/src/site.rs`).
|
||||
|
||||
---
|
||||
|
||||
## Appendix — full internal export inventory (for diffing)
|
||||
|
||||
Fully internal modules: `access-log.ts` (`AccessOp`, `setAccessLog`, `enabled`, `activeIdentity`, `accessLogPrefix`, `logStage`, `shortNuri`, `logAccess`); `machinery.ts` (`MACHINERY_NS`, `isMachinerySubject`); `ng-proxy.ts` (`makeNg`); `open-repo.ts` (`SyncState`, `setOpenTimeoutForTests`, `resetOpenedRepos`, `getSyncState`, `ensureRepoOpen`, `ensurePhysicalRepoOpen`, `ensureReposOpen`); `outbox-log.ts` (`inspectOutbox`); `physical.ts` (`physicalCreate`, `physicalQuery`, `physicalUpdate`); `reach.ts` (`declareInfrastructure`, `isInfrastructure`, `resetInfrastructure`, `mayReach`, `assertMayReach`, `mustNotAttempt`); `read-filter.ts` (`filterReadable`, `makeReadFilteredView`).
|
||||
|
||||
Internal slices of partially-published modules: `nuri.ts` (`targetOf`, `parseNuri`, `mintCap`); `connect.ts` (`startConnect`); `subscribe.ts` (`subscribePhysicalDoc`); `store-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`, `read-model.ts`, `accounts.ts`, `caps.ts`, `sparql.ts`, `lifecycle.ts`, `use-shape.ts`, `watch-shape.ts`, `store-registry-api.ts`, and the two entry points.
|
||||
Reference in New Issue
Block a user