Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d07b3642aa | |||
| e5779ca850 | |||
| 2406afec8b | |||
| 88efe4fea4 | |||
| 07312cd0a2 | |||
| 6a3bd9c3a9 | |||
| 1de9be9167 | |||
| 4f5c3ed03b | |||
| fba0128a7e | |||
| 5a7009bd75 | |||
| 8a382f29f8 | |||
| e24a20cc46 |
@@ -55,7 +55,7 @@ is needed), and how this lib emulates it today.
|
|||||||
| Three native stores per identity | Places entities by scope `public` / `protected` / `private` | The identity's three real native stores hold the entity documents | Not-yet-implemented: `doc_create`/ORM can target only the private (and protected) native store today; a `public`/arbitrary `StoreRepo` is not JS-constructible | Three emulated scope-index documents per account — each "store" is an index doc listing its entity-doc NURIs; all physically live in the one shared private store, and scope is a logical label |
|
| Three native stores per identity | Places entities by scope `public` / `protected` / `private` | The identity's three real native stores hold the entity documents | Not-yet-implemented: `doc_create`/ORM can target only the private (and protected) native store today; a `public`/arbitrary `StoreRepo` is not JS-constructible | Three emulated scope-index documents per account — each "store" is an index doc listing its entity-doc NURIs; all physically live in the one shared private store, and scope is a logical label |
|
||||||
| Per-document read isolation | Nothing to declare: creating a document records its cap on its store, and its creator holds it. Reading is `capFor(doc)` — you hold the key or you do not read | The broker/verifier delivers only documents the wallet holds a ReadCap for; accessing a document without the cap yields an empty result in a union read (a targeted read of an unheld repo errors with `RepoNotFound`) | The model itself is the point: reading is key possession, and there is no read-ACL to introspect — a client cannot ask "may this identity read this doc?" because that question does not exist upstream | Caps recorded per identity: `AddRepo` on the store's emulated Store branch for documents it creates, `AddLink` on its User branch for caps received; `caps.ts` caches them for the session. A read filter (`read-filter.ts`) plus the boundary (`reach.ts`) keep only documents whose cap is held. The cap value is the stand-in `OK` — enforcement is P1b |
|
| Per-document read isolation | Nothing to declare: creating a document records its cap on its store, and its creator holds it. Reading is `capFor(doc)` — you hold the key or you do not read | The broker/verifier delivers only documents the wallet holds a ReadCap for; accessing a document without the cap yields an empty result in a union read (a targeted read of an unheld repo errors with `RepoNotFound`) | The model itself is the point: reading is key possession, and there is no read-ACL to introspect — a client cannot ask "may this identity read this doc?" because that question does not exist upstream | Caps recorded per identity: `AddRepo` on the store's emulated Store branch for documents it creates, `AddLink` on its User branch for caps received; `caps.ts` caches them for the session. A read filter (`read-filter.ts`) plus the boundary (`reach.ts`) keep only documents whose cap is held. The cap value is the stand-in `OK` — enforcement is P1b |
|
||||||
| Directed read sharing | Owns the relationship concept ("who is connected to whom") itself, and on acceptance shares one document's cap to the other's inbox (`shareCap(cap, theirInbox)`) | The cap sealed to the recipient's inbox key (`ContactDetails.read_cap`), opened by their own verifier while processing the inbox | Not-yet-implemented — a **gap, not a disagreement**: the field exists but the message construction is `unimplemented!()`, its only caller passes "without read_cap", and the receiver discards the cap. The shape is right; the implementation is absent | `shareCap` deposits the cap into the recipient's inbox document; the recipient's existing `inbox.watch` absorbs it into what they hold. No "receive" operation, and no principal is ever named to the registry |
|
| Directed read sharing | Owns the relationship concept ("who is connected to whom") itself, and on acceptance shares one document's cap to the other's inbox (`shareCap(cap, theirInbox)`) | The cap sealed to the recipient's inbox key (`ContactDetails.read_cap`), opened by their own verifier while processing the inbox | Not-yet-implemented — a **gap, not a disagreement**: the field exists but the message construction is `unimplemented!()`, its only caller passes "without read_cap", and the receiver discards the cap. The shape is right; the implementation is absent | `shareCap` deposits the cap into the recipient's inbox document; the recipient's existing `inbox.watch` absorbs it into what they hold. No "receive" operation, and no principal is ever named to the registry |
|
||||||
| Inbox (registration notifications) | `inbox.post` / `read` / `watch` | A message is sealed to the recipient's key and queued in their inbox; the recipient's own verifier unseals and applies each queued message inline while processing the inbox | Not-yet-implemented: the sender-side seal-into-inbox call (`inbox_post_link`) is proposed/future, not exposed in the JS SDK | Deposits written as RDF into an inbox document via SPARQL; `read`/`watch` read the deposits back — an in-lib stand-in for the recipient's own inbox processing |
|
| Inbox (registration notifications) | `inbox.post` / `read` / `watch` | A message is sealed to the recipient's key and queued in their inbox; the recipient's own verifier unseals and applies each queued message inline while processing the inbox | Not reachable from JS: the verifier has no `InboxPost` arm, and no `inbox` method exists in `@ng-org/web`. (`inbox_post_link` is OUR proposed name from [`docs/fork-inbox-fallback.md`](docs/fork-inbox-fallback.md), not an announced NextGraph API — no such symbol exists in `nextgraph-rs`.) | Deposits written as RDF into an inbox document via SPARQL; `read`/`watch` read the deposits back — an in-lib stand-in for the recipient's own inbox processing |
|
||||||
| ~~Discovery of all public events~~ **REMOVED 2026-07-30** | Circulates the link itself — into inboxes, or into a document the reader already holds | **There is no discovery.** You cannot discover, you can only follow links: publishing = place the data in your public store **and** circulate the link, seen only by those who received it (a foundation of local-first) | Not a gap to be filled — a global index is not a NextGraph shape, and it would pool data across wallets | Nothing. `discovery.ts` and its global index were removed: they emulated a capability the target will never have. See [`docs/readcap-and-nuri-model.md`](docs/readcap-and-nuri-model.md) §4ter-bis |
|
| ~~Discovery of all public events~~ **REMOVED 2026-07-30** | Circulates the link itself — into inboxes, or into a document the reader already holds | **There is no discovery.** You cannot discover, you can only follow links: publishing = place the data in your public store **and** circulate the link, seen only by those who received it (a foundation of local-first) | Not a gap to be filled — a global index is not a NextGraph shape, and it would pool data across wallets | Nothing. `discovery.ts` and its global index were removed: they emulated a capability the target will never have. See [`docs/readcap-and-nuri-model.md`](docs/readcap-and-nuri-model.md) §4ter-bis |
|
||||||
| 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 |
|
| 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) |
|
| 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) |
|
||||||
@@ -69,12 +69,7 @@ is needed), and how this lib emulates it today.
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `@ng-eventually/client` | The SDK-identical wrapper the app imports instead of `@ng-org/web` / `@ng-org/orm`. It adds the polyfills the broker/verifier will do natively (shared-wallet identity, capability enforcement, anticipated cap/inbox methods). As NextGraph matures, the app points back at the real SDK (build alias removed) and this package falls away. |
|
| `@ng-eventually/client` | The SDK-identical wrapper the app imports instead of `@ng-org/web` / `@ng-org/orm`. It adds the polyfills the broker/verifier will do natively (shared-wallet identity, capability enforcement, anticipated cap/inbox methods). As NextGraph matures, the app points back at the real SDK (build alias removed) and this package falls away. |
|
||||||
|
|
||||||
A global-index package is deferred. In NextGraph an identity's apps and services
|
A global-index package is deferred. Data common to all of an application's users comes from a **singleton app**: a document or store shared by all users and hardcoded in the app, write-owned by the developer and delegable — but never to all users, so user contributions reach it **through an inbox** (nothing in NextGraph is freely writable by everyone). That is the direction the NextGraph developer has named; it is **not implemented**, and several points are still open (what exactly is hardcoded, how delegation travels, who materializes the inbox). So there is no second package for now — it will be introduced once the mechanism exists, and it will be separate from the client. See [`docs/nextgraph-current-state.md`](docs/nextgraph-current-state.md) § Apps & services.
|
||||||
see only what it shares, so there is no multi-identity backend. A global index
|
|
||||||
would come from a singleton app (a global document administered by the developer),
|
|
||||||
which is not implemented and undecided; simpler paths may exist. So there is no
|
|
||||||
second package for now — it will be introduced once the global-index mechanism is
|
|
||||||
decided, and it will be separate from the client.
|
|
||||||
|
|
||||||
## Design principle
|
## Design principle
|
||||||
|
|
||||||
@@ -82,6 +77,86 @@ The application code is written as if the target NextGraph existed. All
|
|||||||
compensation lives here, beside the app. As NextGraph matures, this layer falls
|
compensation lives here, beside the app. As NextGraph matures, this layer falls
|
||||||
away; the app code (SDK-shaped) is unchanged.
|
away; the app code (SDK-shaped) is unchanged.
|
||||||
|
|
||||||
|
**Both halves are binding, and the second is the one that gets traded away.** The
|
||||||
|
SURFACE must be as close as possible to the future SDK — that much is obvious, it is
|
||||||
|
what the consumer codes against. But the IMPLEMENTATION must be as close as possible to
|
||||||
|
what NextGraph actually plans, and there is no exception to that. Where upstream's
|
||||||
|
behaviour is known, it is a specification, not a reference: **when it is known, hold to
|
||||||
|
it**. What "known" means here is narrow — read in `nextgraph-rs` or stated by the
|
||||||
|
NextGraph developer, never inferred from what an npm package happens to expose, and
|
||||||
|
never inferred from an absent implementation ("the engine has no X" says nothing about
|
||||||
|
whether the target will).
|
||||||
|
|
||||||
|
The pressure to deviate never announces itself as a deviation. It shows up as a cost, a
|
||||||
|
latency, an ergonomic wrinkle — a real one. Two instances, both caught only by asking
|
||||||
|
the question:
|
||||||
|
|
||||||
|
- *Every document has a native inbox* was written into the docs from general
|
||||||
|
reasoning. It is false, and it had already become an implementation.
|
||||||
|
- A per-document inbox was made to point at **the owner's** inbox, to avoid a measured
|
||||||
|
cost (9m37 → 21m30 on the consumer's suite). It emulates a many-to-one relation
|
||||||
|
upstream cannot express: the verifier routes by `inboxes: PubKey → RepoId` and unseals
|
||||||
|
with that one repo's key (`engine/verifier/src/verifier.rs:1677,1928`), and a message
|
||||||
|
carries no target document because it needs none. Reverted. The cost was then solved
|
||||||
|
without touching the shape — only documents meant to receive open an inbox.
|
||||||
|
|
||||||
|
The tell in both: an implementation choice that would make the consumer learn something
|
||||||
|
it must **unlearn** at migration. That is the thing this library exists to prevent, so
|
||||||
|
it outranks cost, latency and convenience. When the shape and the cost conflict, keep
|
||||||
|
the shape and attack the cost elsewhere — and if it truly cannot be solved, say so
|
||||||
|
rather than bend the model quietly.
|
||||||
|
|
||||||
|
### The three references, numbered bottom-up
|
||||||
|
|
||||||
|
"NextGraph" is not one layer, and conflating them is how a fact about one gets asserted
|
||||||
|
about another. They are **stacked**, each built on the one below, so they are numbered
|
||||||
|
from the bottom:
|
||||||
|
|
||||||
|
| # | Layer | Where |
|
||||||
|
|---|---|---|
|
||||||
|
| **3** | **JS SDK / ORM** | `@ng-org/orm`, `@ng-org/shex-orm` — source in `sdk/js/orm` (TypeScript) |
|
||||||
|
| **2** | **wasm binding** | `@ng-org/web` — source in `sdk/js/lib-wasm` (77 exported methods) |
|
||||||
|
| **1** | **Rust engine** | `engine/` — `repo`, `verifier`, `net`, `broker`, `wallet` |
|
||||||
|
|
||||||
|
**These are REFERENCES, not places we write code.** Every line this library ships lives
|
||||||
|
in the polyfill; none of these three layers is ours to touch, and `nextgraph-rs` is a
|
||||||
|
read-only source of truth. Saying "level 1" about a piece of our code means *"it is
|
||||||
|
aligned on the engine's model"* — never *"it lives in the engine"*.
|
||||||
|
|
||||||
|
**Which reference to align on: take the HIGHEST one that answers, and go down only when
|
||||||
|
it does not.**
|
||||||
|
|
||||||
|
- **Level 3 answers fully → do not implement it here.** Pass through. Compensation code
|
||||||
|
that doubles a working SDK function is code to delete later, and it diverges meanwhile.
|
||||||
|
- **Level 3 is absent or unsatisfactory → align on the level-2 call** that does the job.
|
||||||
|
Ergonomics are lost, semantics are kept — and migrating later means moving up one
|
||||||
|
step, not rewriting.
|
||||||
|
- **Nothing at level 2 either → align on the level-1 MODEL**: cardinalities, addressing
|
||||||
|
units, what a structure can and cannot express.
|
||||||
|
|
||||||
|
Level 1's facts are the hardest-won, but aligning there means **inventing a JS surface**,
|
||||||
|
since none exists yet. So always **say which level a choice came from**. A level-3
|
||||||
|
passthrough is a fact; a level-1 shape is a bet constrained by the engine. Presenting
|
||||||
|
them alike is what manufactures false certainty — `inbox_post_link` was cited across
|
||||||
|
eight files as a planned NextGraph API when it was only a name proposed in
|
||||||
|
[`docs/fork-inbox-fallback.md`](./docs/fork-inbox-fallback.md).
|
||||||
|
|
||||||
|
Concretely for the inbox: level 3 has nothing, level 2 has **no `inbox` method at all**
|
||||||
|
(and the verifier has no `InboxPost` arm), so `inbox.*` is aligned on level 1 — the
|
||||||
|
engine's model (one inbox ↔ one repo, addressed by `(overlay, pubkey)`, no target
|
||||||
|
document in the message) with a JS surface of our own making.
|
||||||
|
|
||||||
|
This cascade answers *"we need X — what do we align on?"*. It is **not** a checklist to
|
||||||
|
run over what the target exposes: an unused binding method is not a debt, and "it was in
|
||||||
|
the unused list" is not a reason to investigate it.
|
||||||
|
|
||||||
|
> **Do not confuse these levels with the other "three levels" in this repo.**
|
||||||
|
> [`docs/readcap-and-nuri-model.md`](./docs/readcap-and-nuri-model.md) §4quinquies numbers
|
||||||
|
> *where a cap is stored* (wallet root key → the Store/User branch registers → the local
|
||||||
|
> in-memory cache). Same word, unrelated axis: these three are **layers of NextGraph to
|
||||||
|
> align on**, those three are **places a key lives**. When it is not obvious from the
|
||||||
|
> sentence, say "reference level" or "storage level".
|
||||||
|
|
||||||
- SDK-identical surface: the client wraps the real `ng` (a Proxy that forwards
|
- SDK-identical surface: the client wraps the real `ng` (a Proxy that forwards
|
||||||
everything and overrides only what must be emulated) and `useShape`. The real
|
everything and overrides only what must be emulated) and `useShape`. The real
|
||||||
SDK is injected via `configure()` (no hard import → build-alias safe and
|
SDK is injected via `configure()` (no hard import → build-alias safe and
|
||||||
|
|||||||
@@ -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`.
|
||||||
@@ -182,7 +182,7 @@ Not started. It changes the consumer contract in the right direction (one less o
|
|||||||
|
|
||||||
- **`open(nuri, scope)` was kept** (it is in neither the §6 table nor the removals) as the single "this document is mine, in this scope" act — `mint` for protected/private, `publishRepoLink` for public. It no longer touches write caps: arming that guard would be enforcement this batch does not do.
|
- **`open(nuri, scope)` was kept** (it is in neither the §6 table nor the removals) as the single "this document is mine, in this scope" act — `mint` for protected/private, `publishRepoLink` for public. It no longer touches write caps: arming that guard would be enforcement this batch does not do.
|
||||||
- **`grantWrite` / `canWrite` were left exactly as they were** (an authorization list, decorative, guard bypassed by every internal writer) and now have to be called explicitly — `open` used to set them as a side effect. They belong to P1b.
|
- **`grantWrite` / `canWrite` were left exactly as they were** (an authorization list, decorative, guard bypassed by every internal writer) and now have to be called explicitly — `open` used to set them as a side effect. They belong to P1b.
|
||||||
- **`shareCap` is implemented in `inbox.ts`** and re-exported from `/polyfill`, so it is reachable both as `inbox.shareCap` (SDK-identical entry, via `export * as inbox`) and from the polyfill surface. Deliberate: sharing a cap **is** an inbox deposit upstream, and at migration this call becomes `inbox_post_link` — a real SDK method — so hiding it from the SDK entry would have been the less faithful choice. §7's boundary holds where it matters: the registry, `capFor` and `CapRegistry` stay on the polyfill side, and every signature is a plain string.
|
- **`shareCap` is implemented in `inbox.ts`** and re-exported from `/polyfill`, so it is reachable both as `inbox.shareCap` (SDK-identical entry, via `export * as inbox`) and from the polyfill surface. Deliberate: sharing a cap **is** an inbox deposit upstream, and at migration this call becomes the native sealed deposit — a real SDK method, whatever it ends up being called (`inbox_post_link` was our own proposed name, not an announced API) — so hiding it from the SDK entry would have been the less faithful choice. §7's boundary holds where it matters: the registry, `capFor` and `CapRegistry` stay on the polyfill side, and every signature is a plain string.
|
||||||
- **The stand-in key is the constant `OK`** (`nuri.ts`; it was a derived FNV-1a digest until the PO simplified it on 2026-07-30). The only question the emulation answers is *do I hold this cap or not*, so the value says that and nothing more — a digest merely looked like a key. Possession is a shape here, not a protection; P1b replaces the constant with a real key.
|
- **The stand-in key is the constant `OK`** (`nuri.ts`; it was a derived FNV-1a digest until the PO simplified it on 2026-07-30). The only question the emulation answers is *do I hold this cap or not*, so the value says that and nothing more — a digest merely looked like a key. Possession is a shape here, not a protection; P1b replaces the constant with a real key.
|
||||||
- **`resetCaps()` clears in place** rather than rebuilding the registry, so a `watchShape` subscribed to the change signal does not end up holding a listener on an orphaned instance.
|
- **`resetCaps()` clears in place** rather than rebuilding the registry, so a `watchShape` subscribed to the change signal does not end up holding a listener on an orphaned instance.
|
||||||
- **The scope-index feed is holder-scoped** (`fileOwnCaps` compares through the shim key): the cross-account fan-out `listEntityDocs` files nothing, because other accounts' caps are emphatically not ours to hold.
|
- **The scope-index feed is holder-scoped** (`fileOwnCaps` compares through the shim key): the cross-account fan-out `listEntityDocs` files nothing, because other accounts' caps are emphatically not ours to hold.
|
||||||
|
|||||||
@@ -186,11 +186,13 @@ So the store-root pointer, the doc-shim and the account records go through the m
|
|||||||
|
|
||||||
7. ~~**Per-document inboxes**~~ — **DONE 2026-08-03.** Upstream a repo carries `inbox: Option<PrivKey>` (`engine/repo/src/repo.rs:126`): an inbox is a keypair on the document whose PRIVATE half the owner holds, recorded with `AddInboxCap { repo_id, overlay, priv_key }` on the **User branch** — the same branch as `AddLink`, and with the same stated purpose (*"so that a user can share with all its device"*). So "which inboxes may I read" has exactly one answer, and it is the one place to look.
|
7. ~~**Per-document inboxes**~~ — **DONE 2026-08-03.** Upstream a repo carries `inbox: Option<PrivKey>` (`engine/repo/src/repo.rs:126`): an inbox is a keypair on the document whose PRIVATE half the owner holds, recorded with `AddInboxCap { repo_id, overlay, priv_key }` on the **User branch** — the same branch as `AddLink`, and with the same stated purpose (*"so that a user can share with all its device"*). So "which inboxes may I read" has exactly one answer, and it is the one place to look.
|
||||||
|
|
||||||
`storeRegistry.documentInbox(doc)` resolves — creating on first ask — the inbox of a document this user owns, recording the pair on its User branch. **Lazy**: minting an inbox document for every entity up front would double every `createEntityDoc` for inboxes most documents never receive anything in. `myInboxes()` enumerates both levels, `isOwnInbox` answers from the same record, and `connect.connectedUser` drains them all in one call.
|
*(Renamed and split on 2026-08-03 — `documentInbox` became `openDocumentInbox` (own the inbox) + `documentInboxAddress` (find where to deposit). Conflating the two made per-document inboxes unusable by anyone but their owner; see [`2026-08-03-document-inbox-addressing.md`](2026-08-03-document-inbox-addressing.md).)*
|
||||||
|
|
||||||
|
`storeRegistry.openDocumentInbox(doc)` resolves — creating on first ask — the inbox of a document this user owns, recording the pair on its User branch. **Lazy**: minting an inbox document for every entity up front would double every `createEntityDoc` for inboxes most documents never receive anything in. `myInboxes()` enumerates both levels, `isOwnInbox` answers from the same record, and `connect.connectedUser` drains them all in one call.
|
||||||
|
|
||||||
The asymmetry holds at both levels, and a test walks it: **anyone deposits** into a document's inbox (that is how a third party reaches its owner at all), **only the owner reads** it.
|
The asymmetry holds at both levels, and a test walks it: **anyone deposits** into a document's inbox (that is how a third party reaches its owner at all), **only the owner reads** it.
|
||||||
|
|
||||||
**Correction 2026-08-03 — this step ANTICIPATES, it does not mirror.** The paragraph above overstated the upstream fact, and an adversarial doc review caught it. Verified: `new_store_default` attaches an inbox only `if !private` (`engine/verifier/src/verifier.rs:2994`), and `doc_create` goes through `new_repo_default`, which leaves `inbox: None` (`engine/repo/src/repo.rs:574`). The **only** two `AddInboxCap` commits in the engine are for the public and protected STORE repos (`engine/verifier/src/site.rs:128,149`). So today **no document has an inbox, and neither does the private store**. What is genuinely verified is the *shape*: `AddInboxCapV0` is keyed by `repo_id` (`engine/repo/src/types.rs:1973`), so the record accommodates an inbox on any repo. The step stands as delivered — a per-document inbox is what the consumer needs and what the record's shape supports — but it must be read as a bet on where upstream goes, not as an emulation of what it already does.
|
**Correction 2026-08-03, twice.** *First:* the paragraph above overstated the upstream fact — an adversarial doc review caught it. *Then the correction itself overshot*, calling the step an ANTICIPATION: the engine **supports** an inbox on any repo (`inbox` is a field of every `Repo`, `AddInboxCapV0` is keyed by `repo_id`, and `update_inbox_cap_v0` applies it with no `is_store` check — `engine/verifier/src/verifier.rs:1920`). What no code path does is CREATE one for a document. Verified: `new_store_default` attaches an inbox only `if !private` (`engine/verifier/src/verifier.rs:2994`), and `doc_create` goes through `new_repo_default`, which leaves `inbox: None` (`engine/repo/src/repo.rs:574`). The **only** two `AddInboxCap` commits in the engine are for the public and protected STORE repos (`engine/verifier/src/site.rs:128,149`). So today **no document is GIVEN an inbox, and neither is the private store** — which says nothing about what may have one. The step stands as delivered, and it is aligned on the engine's model rather than betting past it.
|
||||||
|
|
||||||
## Relation to P1b
|
## Relation to P1b
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,24 @@
|
|||||||
|
|
||||||
**Raised 2026-08-03, from the consumer side (Festipod), after an attempt to solve it in the app proved it does not belong there.**
|
**Raised 2026-08-03, from the consumer side (Festipod), after an attempt to solve it in the app proved it does not belong there.**
|
||||||
|
|
||||||
|
> ## IMPLEMENTED 2026-08-03 — shape 2 (the library publishes), with the cost objection taken as binding
|
||||||
|
>
|
||||||
|
> `storeRegistry.documentInboxAddress(doc)` answers *"where do I deposit for this document"* for **any holder**, and `inbox.post` into it. The address is published **at creation**, in a compartment the library owns — so it never enters a consumer shape.
|
||||||
|
>
|
||||||
|
> **On the cost — and the wrong answer that was tried first.** The measured regression (9m37 → 21m30) came from creating a second **DOCUMENT** per document. The first fix pointed every document's published address at the owner's **own** inbox: no second document, cost amortized. **It was reverted the same day**, because it emulates a relation upstream cannot express — the verifier routes an incoming message by `inboxes: PubKey → RepoId` and unseals it with THAT repo's private half (`engine/verifier/src/verifier.rs:1677,1928`), and `InboxMsgBody` carries no target document (`engine/net/src/types.rs:4265`) because the address already identifies it. Many documents behind one inbox would have forced consumers to tag deposits with their document — a habit to unlearn at migration, which is precisely what this library exists to prevent.
|
||||||
|
>
|
||||||
|
> **The cost, actually answered:** only documents meant to RECEIVE open an inbox, and their owner is who knows. `createEntityDoc` publishes nothing; an app calls `openDocumentInbox(doc)` for the documents that need one (in the consumer's case: events, not every entity). Cost becomes proportional to the need, with the shape intact.
|
||||||
|
>
|
||||||
|
> Shape 1 of this brief (derivation) was **not available**: our inbox is a document, and a derived NURI would name a repo `doc_create` never created — upstream can derive because an inbox there is a keypair on the repo, not a document.
|
||||||
|
>
|
||||||
|
> **`inbox.postToDocument(doc, { payload })`** is the one call an app makes: it names the DOCUMENT, never an inbox, and **throws** when the document has no inbox rather than returning quietly — a deposit that vanishes without an error is the bug this whole path exists to close. There is deliberately **no target-document field on a deposit**, for the reason above.
|
||||||
|
>
|
||||||
|
> **Where the address lives.** On the document's emulated **Header branch** (`urn:ng-eventually:shim:headerBranch`), beside the content rather than in it — the same subject-as-compartment shape already used for the Store and User branches. `read-model` now drops every subject under the reserved `urn:ng-eventually:` namespace (`src/machinery.ts`), so the address cannot surface as one of the entity's properties. That filter is by SUBJECT, so it covers every emulated compartment present and future.
|
||||||
|
>
|
||||||
|
> **The open question, answered.** *"Is 'a document has an inbox addressable by any holder' upstream, or this library's invention?"* — **upstream, as a capability**; ours only as a JS surface. The engine lets any repo have an inbox: `inbox: Option<PrivKey>` is a field of every `Repo` (`engine/repo/src/repo.rs:126`), `AddInboxCapV0` is keyed by `repo_id` (`engine/repo/src/types.rs:1973`), and `update_inbox_cap_v0` applies it with **no `is_store` check** (`engine/verifier/src/verifier.rs:1920`), at any time (User-branch commit, `commit.rs:1043-1050`, whose type documents the update case). What no code path does is CREATE one for a document: `new_store_default` attaches one only `if !private` (`verifier.rs:2994`), `doc_create` leaves `inbox: None` (`repo.rs:574`), and the engine's only two `AddInboxCap` commits are for the public and protected STORE repos (`site.rs:128,149`). "Does not" is not "cannot" — an earlier version of this note called the feature an anticipation on exactly that confusion. The half-split is upstream's too — a depositor seals with the inbox PUBLIC key (`engine/net/src/types.rs:4299`) and only the owner holds the private half — which is why an address is public by nature and belongs on the document, not on the owner's User branch.
|
||||||
|
>
|
||||||
|
> **Also fixed, and it was the root of the reported symptom.** `openDocumentInbox` (formerly `documentInbox`) called by a non-owner used to mint a parallel inbox and record it for the caller — no error, deposits lost. It now refuses, on OWNERSHIP (read from the Store branches), not on cap possession: a cap can be received, and a recipient must not be able to redirect the owner's deposits to itself.
|
||||||
|
|
||||||
## The problem, in one sentence
|
## The problem, in one sentence
|
||||||
|
|
||||||
`documentInbox(doc)` answers *"which inbox do **I** know for this document?"* — and mints a fresh one when the answer is none. So a third party never reaches the owner's inbox: they get one of their own, which the owner never reads, and their deposit vanishes without an error.
|
`documentInbox(doc)` answers *"which inbox do **I** know for this document?"* — and mints a fresh one when the answer is none. So a third party never reaches the owner's inbox: they get one of their own, which the owner never reads, and their deposit vanishes without an error.
|
||||||
|
|||||||
@@ -11,6 +11,8 @@
|
|||||||
>
|
>
|
||||||
> This ADR already recorded the first half of that verdict — *"a dedicated service with its own wallet sharing a freely-readable index is not a NextGraph shape"*, resting on a singleton-app path *"not implemented, uncertain"*. That reservation is now the conclusion.
|
> This ADR already recorded the first half of that verdict — *"a dedicated service with its own wallet sharing a freely-readable index is not a NextGraph shape"*, resting on a singleton-app path *"not implemented, uncertain"*. That reservation is now the conclusion.
|
||||||
>
|
>
|
||||||
|
> **Amended 2026-08-03 — the singleton-app half must be re-put, not cited as closed.** The NextGraph developer has since named the singleton app as *the* way an application holds data common to all its users: a document or store shared by all users, hardcoded in the app, write-owned by the developer and delegable — never delegated to all users, so user contributions arrive **through an inbox**. Still unimplemented, but no longer uncertain in direction. This does **not** reinstate discovery — the "you cannot discover, you can only follow links" verdict stands on its own footing, and count 2 above is about pooling users' data across wallets, not about an app's own data. What is void is treating "the singleton path is uncertain" as a settled argument. See [`../nextgraph-current-state.md`](../nextgraph-current-state.md) § Apps & services.
|
||||||
|
>
|
||||||
> **`discovery.ts` and its tests were removed on 2026-07-30**, along with `watchShape`'s public-scope fold and `INDEX_ACCOUNT`. See [`../briefs/2026-07-30-virtual-wallet-boundary.md`](../briefs/2026-07-30-virtual-wallet-boundary.md).
|
> **`discovery.ts` and its tests were removed on 2026-07-30**, along with `watchShape`'s public-scope fold and `INDEX_ACCOUNT`. See [`../briefs/2026-07-30-virtual-wallet-boundary.md`](../briefs/2026-07-30-virtual-wallet-boundary.md).
|
||||||
>
|
>
|
||||||
> One factual error below is worth naming so it is not carried into a future design: *"a native inbox (a primitive present on every document)"* is **false**. No document has an inbox upstream — only the public and protected STORE repos do (`engine/verifier/src/site.rs:128,149`; `doc_create` leaves `inbox: None`, `engine/repo/src/repo.rs:574`). See [`../nextgraph-current-state.md`](../nextgraph-current-state.md) § Inbox.
|
> One factual error below is worth naming so it is not carried into a future design: *"a native inbox (a primitive present on every document)"* is **false**. No document has an inbox upstream — only the public and protected STORE repos do (`engine/verifier/src/site.rs:128,149`; `doc_create` leaves `inbox: None`, `engine/repo/src/repo.rs:574`). See [`../nextgraph-current-state.md`](../nextgraph-current-state.md) § Inbox.
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -27,7 +27,7 @@ this step swaps the *emulated* key for the real one, not the model:
|
|||||||
`r:{base64url(serde_bare(ObjectRef))}`. It is **one function** (`mintCap`), because
|
`r:{base64url(serde_bare(ObjectRef))}`. It is **one function** (`mintCap`), because
|
||||||
every path now READS a stored cap instead of recomputing one. `hasReadCap` /
|
every path now READS a stored cap instead of recomputing one. `hasReadCap` /
|
||||||
`targetOf` stay meaningful: the `r:` discriminant is upstream grammar, not ours.
|
`targetOf` stay meaningful: the `r:` discriminant is upstream grammar, not ours.
|
||||||
- `shareCap(cap, toInbox)` becomes the native sealed delivery (`inbox_post_link`
|
- `shareCap(cap, toInbox)` becomes the native sealed delivery (whatever the SDK ends up naming it — see the note below
|
||||||
and `ContactDetails.read_cap`), and `inbox.read`'s inline absorption becomes the
|
and `ContactDetails.read_cap`), and `inbox.read`'s inline absorption becomes the
|
||||||
recipient's own verifier applying queued messages. **The consumer's call does not
|
recipient's own verifier applying queued messages. **The consumer's call does not
|
||||||
change.**
|
change.**
|
||||||
@@ -73,7 +73,7 @@ per-user wallets replace the shared one.
|
|||||||
|
|
||||||
### 4. Real inbox → drop the in-lib read emulation
|
### 4. Real inbox → drop the in-lib read emulation
|
||||||
Replace the emulated `inbox.ts` deposit (`docs.sparqlUpdate` into a shared-wallet
|
Replace the emulated `inbox.ts` deposit (`docs.sparqlUpdate` into a shared-wallet
|
||||||
document) with the native `inbox_post_link` (proposed/future). On the read side the
|
document) with the native sealed deposit, once one is exposed to JS. **Its name and shape are NOT known**: no inbox method exists in `@ng-org/web`, the verifier has no `InboxPost` arm, and `inbox_post_link` is OUR proposed name (`fork-inbox-fallback.md`), not an announced API. On the read side the
|
||||||
recipient's own verifier unseals each queued sealed message and applies it inline
|
recipient's own verifier unseals each queued sealed message and applies it inline
|
||||||
when it processes its inbox — there is no separate curator to build; the in-lib read
|
when it processes its inbox — there is no separate curator to build; the in-lib read
|
||||||
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.)*
|
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.)*
|
||||||
|
|||||||
@@ -121,11 +121,40 @@ documents, and not the private store. `new_store_default` attaches one solely `i
|
|||||||
`AddInboxCap` commits in the whole engine are the two in `engine/verifier/src/site.rs:128,149`
|
`AddInboxCap` commits in the whole engine are the two in `engine/verifier/src/site.rs:128,149`
|
||||||
— one for the public store repo, one for the protected one.
|
— one for the public store repo, one for the protected one.
|
||||||
|
|
||||||
The *register* is nonetheless per-repo: `AddInboxCapV0 { repo_id, overlay, priv_key }`
|
**But the engine SUPPORTS an inbox on any repo — "does not" and "cannot" are different
|
||||||
(`engine/repo/src/types.rs:1973`) records **which repo** an inbox is opened for, so the
|
statements.** `inbox: Option<PrivKey>` is a field of EVERY `Repo`
|
||||||
shape accommodates an inbox on any repo. Nothing creates one, which is a different
|
(`engine/repo/src/repo.rs:126`), not of a store structure. `AddInboxCapV0` is keyed by
|
||||||
statement from the shape forbidding it — and it is why this lib's per-document inbox is
|
`repo_id` (`engine/repo/src/types.rs:1973`) — *"Repo the Inbox is opened for"*. And
|
||||||
an ANTICIPATION of that shape, not an emulation of something upstream already does.
|
`update_inbox_cap_v0` applies it via `self.repos.get_mut(repo_id)` with **no `is_store`
|
||||||
|
check of any kind** (`engine/verifier/src/verifier.rs:1920`). It is generic by
|
||||||
|
construction, and available at any time: `AddInboxCap` is a User-branch commit
|
||||||
|
(`engine/repo/src/commit.rs:1043-1050`) whose type documents the late case — *"DEPS to
|
||||||
|
the previous AddInboxCap commit(s) if it is an update"*.
|
||||||
|
|
||||||
|
So a per-document inbox is **not an anticipation**: it is an engine capability that no
|
||||||
|
code path exercises automatically and that no level-2 or level-3 API exposes. This lib
|
||||||
|
implements it aligned on the engine's model.
|
||||||
|
|
||||||
|
**An inbox address is TRANSMITTED, never published — and nothing in the engine says who
|
||||||
|
may open one.** Two facts that decide more than they look:
|
||||||
|
|
||||||
|
- `inboxes: HashMap<PubKey, RepoId>` is a field of the **Verifier**
|
||||||
|
(`engine/verifier/src/verifier.rs:105`), rebuilt empty on each construction (`:520`,
|
||||||
|
`:2820`). The inbox → repo association is **local to a session**, not a published
|
||||||
|
fact. A depositor learns a pubkey because it was **sent** to them — in a
|
||||||
|
`ContactDetails` message (`contact.inbox`) or through a profile QR code; the reply
|
||||||
|
path reads its own `repo.inbox` to include it (`request_processor.rs:736-750`).
|
||||||
|
- There is therefore **no engine guard on who opens an inbox for a repo**.
|
||||||
|
`AddInboxCap` lands on the committer's OWN User branch, so anyone may write one naming
|
||||||
|
anyone's repo. It simply reaches nobody: no one was told that pubkey means that
|
||||||
|
document.
|
||||||
|
|
||||||
|
*Consequence for this lib, and it is a real divergence:* we **publish** the address on
|
||||||
|
the document (its Header branch) because that is the only way a third party can find it
|
||||||
|
in an emulation with no message channel. That creates a vector the engine does not have
|
||||||
|
— whoever can write the document can redirect its deposits — so `openDocumentInbox`
|
||||||
|
guards on ownership. That guard compensates OUR design; it does not mirror an upstream
|
||||||
|
rule. Do not cite it as one.
|
||||||
|
|
||||||
A non-editor can deposit into an inbox without being invited as an editor; the owner
|
A non-editor can deposit into an inbox without being invited as an editor; the owner
|
||||||
moderates. NURI: `did:ng:d:<inbox_id>`. Content: the `InboxMsgContent` enum (`ContactDetails`,
|
moderates. NURI: `did:ng:d:<inbox_id>`. Content: the `InboxMsgContent` enum (`ContactDetails`,
|
||||||
@@ -151,8 +180,11 @@ it processes its inbox — there is no separate curator or materialization servi
|
|||||||
`QrCodeProfile(Import)`, `Header`, `Create`, `FilePut`). Sending an `InboxPost`
|
`QrCodeProfile(Import)`, `Header`, `Create`, `FilePut`). Sending an `InboxPost`
|
||||||
triggers nothing.
|
triggers nothing.
|
||||||
- Building an `InboxPost` requires crypto sealing on the Rust side; no wasm
|
- Building an `InboxPost` requires crypto sealing on the Rust side; no wasm
|
||||||
helper exposes it. A high-level `inbox_post_link` is a proposed/future API, not
|
helper exposes it, and **no `inbox` method exists in `@ng-org/web` at all**.
|
||||||
yet present.
|
*(`inbox_post_link`, named across this repo's docs, is OUR proposed name from
|
||||||
|
[`fork-inbox-fallback.md`](./fork-inbox-fallback.md) — grep `nextgraph-rs` and it
|
||||||
|
is nowhere. Nothing is announced about the eventual JS surface for inboxes: its
|
||||||
|
name and shape are unknown, not merely unimplemented.)*
|
||||||
- Inbox deposit is only triggered internally by `QrCodeProfileImport`
|
- Inbox deposit is only triggered internally by `QrCodeProfileImport`
|
||||||
(`post_to_inbox(new_contact_details)`) and `social_query_start` (contact
|
(`post_to_inbox(new_contact_details)`) and `social_query_start` (contact
|
||||||
propagation via inbox).
|
propagation via inbox).
|
||||||
@@ -290,6 +322,21 @@ has both**:
|
|||||||
has no first-`State` barrier**: an anchored read on it can return 0 rows during
|
has no first-`State` barrier**: an anchored read on it can return 0 rows during
|
||||||
sync-lag with no signal distinguishing "still syncing" from "genuinely empty".
|
sync-lag with no signal distinguishing "still syncing" from "genuinely empty".
|
||||||
|
|
||||||
|
> **`doc_fetch_repo_subscribe` / `doc_fetch_private_subscribe` are NOT alternatives to
|
||||||
|
> `doc_subscribe`** — checked 2026-08-03, because they look like ready-made
|
||||||
|
> "open a repo" calls and they are not. Neither performs any I/O: each **builds an
|
||||||
|
> `AppRequest` and returns it serialized** (`sdk/js/lib-wasm/src/lib.rs:1890`, `:1900`),
|
||||||
|
> with no `session_id` and no callback. `doc_subscribe` builds the *same* request
|
||||||
|
> (`AppRequest::doc_fetch_repo_subscribe`, `engine/net/src/app_protocol.rs:930` →
|
||||||
|
> `Fetch(Subscribe)`), then adds the session id and runs it through
|
||||||
|
> `app_request_stream_` (`lib.rs:1921-1923`). They exist for a caller that wants to
|
||||||
|
> construct the request and dispatch it itself. So `ensureRepoOpen`'s
|
||||||
|
> `doc_subscribe` + wait-for-first-`State` is not duplicating an available call — using
|
||||||
|
> them instead would mean re-implementing what `doc_subscribe` already does. The
|
||||||
|
> difference in `doc_fetch_private_subscribe` is only its target
|
||||||
|
> (`NuriV0::new_private_store_target()`, the private store-root), which changes nothing
|
||||||
|
> about the barrier: a store-root still has none.
|
||||||
|
|
||||||
These two are **mutually exclusive**: the guessable target (store-root) is not
|
These two are **mutually exclusive**: the guessable target (store-root) is not
|
||||||
barrier-authoritative, and the barrier-authoritative target (`o:` repo) is not
|
barrier-authoritative, and the barrier-authoritative target (`o:` repo) is not
|
||||||
guessable. **Consequence:** you cannot build a lookup table that is BOTH reachable
|
guessable. **Consequence:** you cannot build a lookup table that is BOTH reachable
|
||||||
@@ -467,39 +514,37 @@ methods: `doc_subscribe`, `orm_start_graph`, `orm_start_discrete`, `file_get`,
|
|||||||
(`pnpm webfilebuild`). First boot is **interactive** (admin-wallet invitation
|
(`pnpm webfilebuild`). First boot is **interactive** (admin-wallet invitation
|
||||||
link). Official Dockerfiles are **broken**.
|
link). Official Dockerfiles are **broken**.
|
||||||
|
|
||||||
## Apps & services: mono-user, no global data
|
## Apps & services: shared app data goes through a hardcoded app store (section rewritten 2026-08-03)
|
||||||
|
|
||||||
NextGraph's app/service execution model — important because it **invalidates**
|
NextGraph's app/service execution model, and the answer to "can an application hold data common to all its users".
|
||||||
the idea of "a service with its own wallet sharing global data".
|
|
||||||
|
|
||||||
> **Provenance: NOT verified against `nextgraph-rs`.** Every other section of this file
|
> **Provenance — two layers, do not mix them.** What the **engine** contains is verified below and is vocabulary only. What the **model will be** was stated by the NextGraph developer on 2026-08-03 and is **not implemented**: treat it as the target's declared direction, not as something the clone can confirm. Per [`../README.md`](../README.md)'s design principle, never infer the target's shape from the source's current state — an absent implementation says nothing about the intended one. The previous version of this section drew exactly that inference and concluded the opposite of what the developer states; it has been replaced.
|
||||||
> cites the engine; this one cites nothing, and nothing in the local clone corresponds to
|
|
||||||
> it — no app/service runtime, no settings-document type, no singleton notion. It arrived
|
|
||||||
> with `bea9f51`, moved wholesale from the consumer app's repo, and its own source (an
|
|
||||||
> exchange with the PO, the official docs, or an inference) was not recorded. The
|
|
||||||
> conclusion below carries real weight — it is what deferred a global index in this lib —
|
|
||||||
> so treat it as **a claim to re-confirm with the PO**, not as an engine fact. Do not
|
|
||||||
> extend it, and do not cite it as verified.
|
|
||||||
|
|
||||||
- **Apps AND services are mono-user.** They see only **what the user makes
|
**What the engine actually has — types, no behaviour (verified 2026-08-03):**
|
||||||
available** to them. There is **no global data** natively, and no central
|
|
||||||
service holding shared data.
|
|
||||||
- **Local settings document.** Every app — even a singleton — and every service
|
|
||||||
has a **settings document** the user configures it through.
|
|
||||||
- **Multi-instance apps.** A **non-singleton** app can be **instantiated several
|
|
||||||
times** (e.g. a text editor, once per open file).
|
|
||||||
- **Singleton apps.** Also **mono-user**, but **bound to a particular user (the
|
|
||||||
developer)**. A singleton app **can hold a global document**, administered by
|
|
||||||
that user.
|
|
||||||
|
|
||||||
**Consequence for a "global document" (e.g. a discovery index):** the only path
|
- `AppManifestV0` (`engine/wallet/src/permissions.rs:113`) carries `nuri`, `origin` (webapps), `singleton: bool`, `access_requests`, `installs` (Viewer / Editor / ReadService / WriteService / Model, keyed by PrimaryClass), `dependencies`, and presentation fields.
|
||||||
glimpsed is a singleton app whose global document is administered by the
|
- The JS surface exists: `init(callback, singleton, access_requests)` (`sdk/js/web/src/index.ts:51`) relays `singleton` to the wallet origin by `postMessage`. Every example passes `true`.
|
||||||
developer-user — though this is not implemented and not guaranteed (simpler
|
- **Nothing consumes it.** The `permissions` module is declared by `engine/wallet/src/lib.rs:19` and imported by no other crate; `AppManifest` is constructed nowhere; no code reads `singleton`; the surrounding `AccessRequest` / `AccessGrant` machinery is in the same state. There is no app runtime, no app store, no global-document type.
|
||||||
paths may exist; to explore later). The model that does exist is this
|
- The field's doc comment reads `/// cannot create Documents?` — with the question mark, in the source. It is an open design note, and the developer's statement below settles it the other way. Do not treat it as the semantics.
|
||||||
singleton-app one; a dedicated service with its own wallet sharing a
|
- **A second, published gloss of the same flag disagrees with that doc comment, and it is the one that matches the developer's statement.** `sdk/js/web/README.md:90,108` annotates the argument as *"will your app create many docs in the system, or should it be launched as a unique instance"* — i.e. `singleton` is about **instance multiplicity**, not about being forbidden to create documents. That is consistent with "a singleton app can also manage ordinary per-user documents" below, and it is the reading to carry. Both README examples pass `true`.
|
||||||
freely-readable index is not a NextGraph shape (a service is mono-user, no global
|
- Unrelated homonyms, so a grep does not mislead: singleton *commits* (`engine/repo/`), the broker singleton (`engine/net/src/broker.rs`), Oxigraph's `empty_singleton` (SPARQL optimiser).
|
||||||
data). This is why a global-index package is deferred in this lib (see the top-level
|
|
||||||
README).
|
**The declared model (NextGraph developer, 2026-08-03 — not implemented):**
|
||||||
|
|
||||||
|
- A singleton app can **also** manage ordinary per-user documents, i.e. cover what a non-singleton app does. When both modes are needed, declaring one app as singleton is enough — there is no need for two apps.
|
||||||
|
- Centralized data for an application takes the form of a **document, or a store, shared by all its users and hardcoded in the app**.
|
||||||
|
- The **app's developer holds the write rights** on the app's documents and stores, and can **delegate** them.
|
||||||
|
- Delegation is **never to all users**. User contributions reach app-owned data **through an inbox** — this is NextGraph's general model, in which nothing is freely writable by everyone.
|
||||||
|
|
||||||
|
**Reading of the last point, since it decides the shape of any index:** an app-owned index is not a place users write to. It is a document they can read (its NURI being hardcoded) and **deposit into**, with an authority derived from the developer materializing the deposits — the same deposit-then-materialize shape the inbox already imposes elsewhere, moved up to the app level.
|
||||||
|
|
||||||
|
**Open questions to put to the developer before designing on this:**
|
||||||
|
|
||||||
|
- What exactly is hardcoded — the store's or document's NURI, and does that carry the read capability?
|
||||||
|
- How is write delegation transmitted, and is it revocable?
|
||||||
|
- Who processes the app store's inbox: an instance running with the developer's rights, a service, something else? A deposit nobody materializes is not an index.
|
||||||
|
|
||||||
|
**Bearing on [`decisions/discovery-model.md`](decisions/discovery-model.md):** that ADR's superseding block leans in part on the singleton-app path being "not implemented, uncertain". The path is still not implemented, but it is no longer uncertain in direction — the developer names it as *the* way to hold data common to all users. That does not reinstate discovery: the "you cannot discover, you can only follow links" verdict rests on its own footing (the PO, 2026-07-30). It does mean the *singleton-app* half of that reasoning must be re-put rather than cited as closed.
|
||||||
|
|
||||||
## Third-party wallet auto-import constraint
|
## Third-party wallet auto-import constraint
|
||||||
|
|
||||||
|
|||||||
+39
-14
@@ -200,16 +200,40 @@ store-id:
|
|||||||
blocker, [`migration-guide.md`](./migration-guide.md)). At migration each scope
|
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,
|
resolves to the user's real per-scope store — the change is in this function,
|
||||||
and the consumer application is unchanged.
|
and the consumer application is unchanged.
|
||||||
- **`walletInbox(id)` / `documentInbox(doc)`** — an inbox BELONGS to someone. The
|
- **`walletInbox(id)` / `openDocumentInbox(doc)`** — an inbox BELONGS to someone. The
|
||||||
first is a virtual user's own inbox (where Links arrive), the second the inbox of
|
first is a user's own inbox (where Links arrive), the second a DEDICATED inbox for
|
||||||
one of its documents, created on first ask. Both are dedicated documents (real
|
one of its documents, opened on demand by its **owner only** (ownership read from the
|
||||||
repo NURIs from `docCreate`), never the private-store root: routing deposits into
|
Store branches — a received cap is not ownership, and a recipient must not be able to
|
||||||
the shim graph would bloat the account→document trust root without bound.
|
redirect the owner's deposits to itself). Both are dedicated documents (real repo
|
||||||
`myInboxes()` enumerates both levels — what `connect.ts` drains at connection —
|
NURIs from `docCreate`), never the private-store root: routing deposits into the shim
|
||||||
and `isOwnInbox` answers from the same record. *(The former `resolveInboxAnchor`,
|
graph would bloat the account→document trust root without bound. `myInboxes()`
|
||||||
a single inbox COMMON to every user, was removed on 2026-07-30: nothing may be
|
enumerates both levels — what `connect.ts` drains at connection — and `isOwnInbox`
|
||||||
common but the mechanisms that make the virtual users work.)* At migration these
|
answers from the same record. *(The former `resolveInboxAnchor`, a single inbox COMMON
|
||||||
become native per-document inboxes.
|
to every user, was removed on 2026-07-30: nothing may be common but the mechanisms
|
||||||
|
that make the virtual users work.)*
|
||||||
|
- **`documentInboxAddress(doc)` — the DEPOSIT side, and the one a third party uses.**
|
||||||
|
Reading an inbox and finding where to deposit into it are opposite acts with opposite
|
||||||
|
audiences, and conflating them is what made per-document inboxes unusable at first:
|
||||||
|
resolution answered *"which inbox do I know for this document"*, so a depositor got
|
||||||
|
one of their own and their deposit vanished silently
|
||||||
|
([`briefs/2026-08-03-document-inbox-addressing.md`](./briefs/2026-08-03-document-inbox-addressing.md)).
|
||||||
|
A document that has an inbox carries its address on its emulated **Header branch** —
|
||||||
|
a reserved subject inside the document, so any holder of the document reads it, and
|
||||||
|
`read-model` filters the whole `urn:ng-eventually:` namespace out of consumer data
|
||||||
|
(`src/machinery.ts`). This mirrors upstream's split: a depositor seals with the inbox
|
||||||
|
PUBLIC key and needs nothing else, only the owner holds the private half.
|
||||||
|
**One inbox belongs to one document** — never several documents behind one inbox, a
|
||||||
|
relation upstream cannot express (the verifier routes by `inboxes: PubKey → RepoId`
|
||||||
|
and unseals with that repo's key, `engine/verifier/src/verifier.rs:1677,1928`), which
|
||||||
|
is also why a deposit carries no target document: the address identifies it. A fresh
|
||||||
|
document therefore has NO inbox and `documentInboxAddress` returns `undefined` — its
|
||||||
|
owner opens one when the document is meant to receive, which is what keeps the cost
|
||||||
|
proportional. At migration the address becomes the repo's native inbox pubkey and the
|
||||||
|
resolution moves; the consumer-facing act is unchanged.
|
||||||
|
- **`inbox.postToDocument(doc, { payload })`** — the one call an app makes to reach a
|
||||||
|
document's owner: it names the DOCUMENT, never an inbox. **Throws** when the document
|
||||||
|
has no inbox, rather than returning quietly: a deposit that vanishes without an error
|
||||||
|
is the exact bug this path shipped with.
|
||||||
|
|
||||||
Both resolve the native store ids from the injected session
|
Both resolve the native store ids from the injected session
|
||||||
(`RegistrySession.protectedStoreId` / `publicStoreId`, alongside the existing
|
(`RegistrySession.protectedStoreId` / `publicStoreId`, alongside the existing
|
||||||
@@ -440,7 +464,7 @@ fork the broker ([`fork-inbox-fallback.md`](./fork-inbox-fallback.md)), the lib
|
|||||||
emulates the inbox on the shared wallet:
|
emulates the inbox on the shared wallet:
|
||||||
|
|
||||||
- **Target vs polyfill.** In the target, `post` seals a reference into the owner's
|
- **Target vs polyfill.** In the target, `post` seals a reference into the owner's
|
||||||
native inbox (`inbox_post_link(...)`, a proposed/future API) and the recipient's
|
native inbox — through a JS call that **does not exist and is not announced** — and the recipient's
|
||||||
own verifier unseals each queued message and applies it inline when it processes
|
own verifier unseals each queued message and applies it inline when it processes
|
||||||
its inbox — there is no separate curator or materialization process. Here,
|
its inbox — there is no separate curator or materialization process. Here,
|
||||||
everything is readable, so the lib emulates the read side in-lib.
|
everything is readable, so the lib emulates the read side in-lib.
|
||||||
@@ -483,9 +507,10 @@ At migration this guard disappears into cryptography: an inbox is sealed to its
|
|||||||
owner's key.
|
owner's key.
|
||||||
|
|
||||||
The module knows no domain — the consumer application supplies the inbox document
|
The module knows no domain — the consumer application supplies the inbox document
|
||||||
NURI and interprets `payload`. At migration `post` becomes the native
|
NURI and interprets `payload`. At migration `post` becomes the native sealed
|
||||||
`inbox_post_link` (proposed/future) and the read side is served by the recipient's
|
deposit — **whose JS name and signature are not known**, since none is exposed or
|
||||||
own verifier unsealing queued messages inline.
|
announced — and the read side is served by the recipient's own verifier unsealing
|
||||||
|
queued messages inline.
|
||||||
The inbox + watcher is the one deposit/read mechanism a consumer reuses for its own
|
The inbox + watcher is the one deposit/read mechanism a consumer reuses for its own
|
||||||
purposes — a registration/deposit, a cap delivery (`shareCap`), a link handed to
|
purposes — a registration/deposit, a cap delivery (`shareCap`), a link handed to
|
||||||
someone — same `post` API, same watcher.
|
someone — same `post` API, same watcher.
|
||||||
|
|||||||
@@ -239,6 +239,24 @@ Consequences a consumer must internalize:
|
|||||||
are this library's, not the engine's: upstream only the public and protected store
|
are this library's, not the engine's: upstream only the public and protected store
|
||||||
repos carry one (`engine/verifier/src/site.rs:128,149`).*
|
repos carry one (`engine/verifier/src/site.rs:128,149`).*
|
||||||
|
|
||||||
|
Depositing into a document you do not own is **one** call, and it names the document:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
await inbox.postToDocument(doc, { payload: { signingUp: true } });
|
||||||
|
```
|
||||||
|
|
||||||
|
You need the **document** (its cap), nothing else — the address rides on it. It
|
||||||
|
**throws** if the document has no inbox: its owner opens one with
|
||||||
|
`storeRegistry.openDocumentInbox(doc)` for documents meant to receive, so a fresh
|
||||||
|
document has none. When "no inbox" is an expected case, check first with
|
||||||
|
`storeRegistry.documentInboxAddress(doc)` (→ `Nuri | undefined`).
|
||||||
|
|
||||||
|
A deposit carries no target document, deliberately — one inbox belongs to one
|
||||||
|
document, so the address already identifies it, exactly as upstream (`inboxes:
|
||||||
|
PubKey → RepoId`). Do not encode the document in your payload; you would have to
|
||||||
|
unlearn it. Reading that inbox is a different right, and it stays the owner's
|
||||||
|
(`inbox.read` refuses otherwise).
|
||||||
|
|
||||||
The consumer asks the SDK for what it needs and trusts the result; it does not
|
The consumer asks the SDK for what it needs and trusts the result; it does not
|
||||||
construct NURIs, pick union-vs-anchor, or reason about caps. The domain-shaped list
|
construct NURIs, pick union-vs-anchor, or reason about caps. The domain-shaped list
|
||||||
helpers live in the consumer app; the SDK exposes the generic reactive/by-need read.
|
helpers live in the consumer app; the SDK exposes the generic reactive/by-need read.
|
||||||
@@ -279,7 +297,7 @@ from the reactive contract:
|
|||||||
deposit), so `inbox.watch` ([`../src/inbox.ts`](../src/inbox.ts)) **polls** via
|
deposit), so `inbox.watch` ([`../src/inbox.ts`](../src/inbox.ts)) **polls** via
|
||||||
`setInterval` (default 1s) instead of subscribing. The finished contract is push
|
`setInterval` (default 1s) instead of subscribing. The finished contract is push
|
||||||
(the broker already routes the inbox natively); these become subscriptions when the
|
(the broker already routes the inbox natively); these become subscriptions when the
|
||||||
sealed-inbox path (`inbox_post_link`) lands.
|
sealed-inbox path is exposed to JS (no such method exists today).
|
||||||
|
|
||||||
3. **No cross-wallet / on-demand repo open.** There is no JS primitive to sync an
|
3. **No cross-wallet / on-demand repo open.** There is no JS primitive to sync an
|
||||||
*unknown* repo by NURI+ReadCap today (`load_repo_from_read_cap` is `pub(crate)`,
|
*unknown* repo by NURI+ReadCap today (`load_repo_from_read_cap` is `pub(crate)`,
|
||||||
|
|||||||
@@ -248,6 +248,18 @@ async function main(): Promise<void> {
|
|||||||
check("watch fires when a deposit lands", after.fires > base.fires && after.lastLen >= 1, `fires=${after.fires} lastLen=${after.lastLen}`);
|
check("watch fires when a deposit lands", after.fires > base.fires && after.lastLen >= 1, `fires=${after.fires} lastLen=${after.lastLen}`);
|
||||||
await sdk(frame, "inboxWatchStop");
|
await sdk(frame, "inboxWatchStop");
|
||||||
});
|
});
|
||||||
|
await step("a document's inbox: owner opens, a third party resolves and deposits", async () => {
|
||||||
|
const t = Date.now();
|
||||||
|
const r = await sdk<any>(frame, "documentInboxDeposit", "@owner-" + t, "@depositor-" + t);
|
||||||
|
check(
|
||||||
|
"the depositor RESOLVES the same inbox from the document, deposits into it, and the address stays out of the data",
|
||||||
|
r.sameInbox === true &&
|
||||||
|
r.openRefused === true &&
|
||||||
|
JSON.stringify(r.deposits) === JSON.stringify([{ viaPostToDocument: true }, { joining: true }]) &&
|
||||||
|
!r.props.some((p: string) => p.startsWith("urn:ng-eventually:")),
|
||||||
|
`sameInbox=${r.sameInbox} openRefused=${r.openRefused} deposits=${JSON.stringify(r.deposits)} props=${JSON.stringify(r.props)}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
await step("inbox spoof guard", async () => {
|
await step("inbox spoof guard", async () => {
|
||||||
const r = await sdk<any>(frame, "inboxSpoofGuard");
|
const r = await sdk<any>(frame, "inboxSpoofGuard");
|
||||||
check("post as another principal is rejected; self + anon allowed", r.spoofRejected && r.selfOk && r.anonOk, `spoof=${r.spoofRejected} self=${r.selfOk} anon=${r.anonOk}`);
|
check("post as another principal is rejected; self + anon allowed", r.spoofRejected && r.selfOk && r.anonOk, `spoof=${r.spoofRejected} self=${r.selfOk} anon=${r.anonOk}`);
|
||||||
|
|||||||
@@ -34,8 +34,12 @@ import {
|
|||||||
storeRegistry,
|
storeRegistry,
|
||||||
useShape as libUseShape,
|
useShape as libUseShape,
|
||||||
watchShape,
|
watchShape,
|
||||||
accounts,
|
|
||||||
} from "@ng-eventually/client";
|
} from "@ng-eventually/client";
|
||||||
|
// The harness tests the LIBRARY, so it legitimately reaches machinery a consumer
|
||||||
|
// 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/store-registry";
|
||||||
|
import * as accounts from "../src/accounts";
|
||||||
import { isNuri } from "@ng-eventually/client";
|
import { isNuri } from "@ng-eventually/client";
|
||||||
import type { Nuri, ShapeObservable, ShapeQuery } from "@ng-eventually/client";
|
import type { Nuri, ShapeObservable, ShapeQuery } from "@ng-eventually/client";
|
||||||
|
|
||||||
@@ -436,10 +440,10 @@ const identity = new IdentityStore(
|
|||||||
|
|
||||||
// ── store-registry ───────────────────────────────────────────────────────
|
// ── store-registry ───────────────────────────────────────────────────────
|
||||||
async ensureAccountIdempotent(id: string) {
|
async ensureAccountIdempotent(id: string) {
|
||||||
storeRegistry.resetRegistryCache();
|
registryInternals.resetRegistryCache();
|
||||||
const first = await storeRegistry.ensureAccount(id);
|
const first = await registryInternals.ensureAccount(id);
|
||||||
storeRegistry.resetRegistryCache();
|
registryInternals.resetRegistryCache();
|
||||||
const second = await storeRegistry.ensureAccount(id);
|
const second = await registryInternals.ensureAccount(id);
|
||||||
return {
|
return {
|
||||||
firstDocs: [first.docPublic, first.docProtected, first.docPrivate],
|
firstDocs: [first.docPublic, first.docProtected, first.docPrivate],
|
||||||
secondDocs: [second.docPublic, second.docProtected, second.docPrivate],
|
secondDocs: [second.docPublic, second.docProtected, second.docPrivate],
|
||||||
@@ -450,7 +454,7 @@ const identity = new IdentityStore(
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
async entityDocsBounded(idA: string, idB: string) {
|
async entityDocsBounded(idA: string, idB: string) {
|
||||||
storeRegistry.resetRegistryCache();
|
registryInternals.resetRegistryCache();
|
||||||
// Each user creates its OWN documents: you act as one virtual user at a time,
|
// Each user creates its OWN documents: you act as one virtual user at a time,
|
||||||
// and the caps of what you create are filed under the identity you were acting
|
// and the caps of what you create are filed under the identity you were acting
|
||||||
// as. Creating B's document while connected as A is not a thing the model has.
|
// as. Creating B's document while connected as A is not a thing the model has.
|
||||||
@@ -463,7 +467,7 @@ const identity = new IdentityStore(
|
|||||||
setCurrentUser(idA);
|
setCurrentUser(idA);
|
||||||
let listA: string[] = [];
|
let listA: string[] = [];
|
||||||
for (let i = 0; i < 12; i++) {
|
for (let i = 0; i < 12; i++) {
|
||||||
storeRegistry.resetRegistryCache();
|
registryInternals.resetRegistryCache();
|
||||||
listA = await storeRegistry.listMyEntityDocs(idA, "public");
|
listA = await storeRegistry.listMyEntityDocs(idA, "public");
|
||||||
if (listA.includes(dA1) && listA.includes(dA2)) break;
|
if (listA.includes(dA1) && listA.includes(dA2)) break;
|
||||||
await new Promise((r) => setTimeout(r, 1000));
|
await new Promise((r) => setTimeout(r, 1000));
|
||||||
@@ -490,7 +494,7 @@ const identity = new IdentityStore(
|
|||||||
* expected values to assert against.
|
* expected values to assert against.
|
||||||
*/
|
*/
|
||||||
async reconnectSeed(id: string, scope: "public" | "protected" | "private") {
|
async reconnectSeed(id: string, scope: "public" | "protected" | "private") {
|
||||||
storeRegistry.resetRegistryCache();
|
registryInternals.resetRegistryCache();
|
||||||
const s = await sessionReady;
|
const s = await sessionReady;
|
||||||
// Seed AS the user whose document this is — otherwise the cap of the created
|
// Seed AS the user whose document this is — otherwise the cap of the created
|
||||||
// document is filed under nobody and the very session that created it is
|
// document is filed under nobody and the very session that created it is
|
||||||
@@ -508,7 +512,7 @@ const identity = new IdentityStore(
|
|||||||
// data is persisted before the fresh session tries to read it back.
|
// data is persisted before the fresh session tries to read it back.
|
||||||
let listed: string[] = [];
|
let listed: string[] = [];
|
||||||
for (let i = 0; i < 15; i++) {
|
for (let i = 0; i < 15; i++) {
|
||||||
storeRegistry.resetRegistryCache();
|
registryInternals.resetRegistryCache();
|
||||||
listed = await storeRegistry.listMyEntityDocs(id, scope);
|
listed = await storeRegistry.listMyEntityDocs(id, scope);
|
||||||
if (listed.includes(entityNuri)) break;
|
if (listed.includes(entityNuri)) break;
|
||||||
await new Promise((r) => setTimeout(r, 1000));
|
await new Promise((r) => setTimeout(r, 1000));
|
||||||
@@ -534,7 +538,7 @@ const identity = new IdentityStore(
|
|||||||
setCurrentUser(id);
|
setCurrentUser(id);
|
||||||
await connectedUser();
|
await connectedUser();
|
||||||
|
|
||||||
storeRegistry.resetRegistryCache();
|
registryInternals.resetRegistryCache();
|
||||||
const listed = await storeRegistry.listMyEntityDocs(id, scope);
|
const listed = await storeRegistry.listMyEntityDocs(id, scope);
|
||||||
// DIAGNOSTIC: a RAW anchored read of the entity doc with NO open — reports how
|
// DIAGNOSTIC: a RAW anchored read of the entity doc with NO open — reports how
|
||||||
// many rows the bare anchored query resolves for a not-yet-opened repo (the
|
// many rows the bare anchored query resolves for a not-yet-opened repo (the
|
||||||
@@ -579,8 +583,8 @@ const identity = new IdentityStore(
|
|||||||
* first so the resolve goes to the shim, not a same-session in-memory hit.
|
* first so the resolve goes to the shim, not a same-session in-memory hit.
|
||||||
*/
|
*/
|
||||||
async accountDocs(id: string) {
|
async accountDocs(id: string) {
|
||||||
storeRegistry.resetRegistryCache();
|
registryInternals.resetRegistryCache();
|
||||||
const rec = await storeRegistry.ensureAccount(id);
|
const rec = await registryInternals.ensureAccount(id);
|
||||||
return { docPublic: rec.docPublic, docProtected: rec.docProtected, docPrivate: rec.docPrivate };
|
return { docPublic: rec.docPublic, docProtected: rec.docProtected, docPrivate: rec.docPrivate };
|
||||||
},
|
},
|
||||||
async scopeResolvers() {
|
async scopeResolvers() {
|
||||||
@@ -621,9 +625,9 @@ const identity = new IdentityStore(
|
|||||||
* truthy iff provisioning succeeded) so the runner can gate on real persistence.
|
* truthy iff provisioning succeeded) so the runner can gate on real persistence.
|
||||||
*/
|
*/
|
||||||
async coldEnsureAccount(id: string) {
|
async coldEnsureAccount(id: string) {
|
||||||
storeRegistry.resetRegistryCache();
|
registryInternals.resetRegistryCache();
|
||||||
try {
|
try {
|
||||||
const rec = await storeRegistry.ensureAccount(id);
|
const rec = await registryInternals.ensureAccount(id);
|
||||||
return {
|
return {
|
||||||
threw: false,
|
threw: false,
|
||||||
error: null,
|
error: null,
|
||||||
@@ -642,9 +646,9 @@ const identity = new IdentityStore(
|
|||||||
* docs coldEnsureAccount minted (real persistence, no RepoNotFound).
|
* docs coldEnsureAccount minted (real persistence, no RepoNotFound).
|
||||||
*/
|
*/
|
||||||
async verifyShimPersisted(id: string) {
|
async verifyShimPersisted(id: string) {
|
||||||
storeRegistry.resetRegistryCache();
|
registryInternals.resetRegistryCache();
|
||||||
try {
|
try {
|
||||||
const rec = await storeRegistry.ensureAccount(id);
|
const rec = await registryInternals.ensureAccount(id);
|
||||||
return { threw: false, error: null, docPublic: rec.docPublic, docProtected: rec.docProtected, docPrivate: rec.docPrivate };
|
return { threw: false, error: null, docPublic: rec.docPublic, docProtected: rec.docProtected, docPrivate: rec.docPrivate };
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
return { threw: true, error: String(e?.message ?? e), docPublic: "", docProtected: "", docPrivate: "" };
|
return { threw: true, error: String(e?.message ?? e), docPublic: "", docProtected: "", docPrivate: "" };
|
||||||
@@ -668,7 +672,7 @@ const identity = new IdentityStore(
|
|||||||
* doc/type so the runner can assert the data landed.
|
* doc/type so the runner can assert the data landed.
|
||||||
*/
|
*/
|
||||||
async watchShapeSeedAndSubscribe(handle: string, cls: string) {
|
async watchShapeSeedAndSubscribe(handle: string, cls: string) {
|
||||||
storeRegistry.resetRegistryCache();
|
registryInternals.resetRegistryCache();
|
||||||
const id = "@ws-" + handle;
|
const id = "@ws-" + handle;
|
||||||
setCurrentUser(id);
|
setCurrentUser(id);
|
||||||
const doc = await storeRegistry.createEntityDoc(id, "protected");
|
const doc = await storeRegistry.createEntityDoc(id, "protected");
|
||||||
@@ -681,7 +685,7 @@ const identity = new IdentityStore(
|
|||||||
);
|
);
|
||||||
// Wait until this session sees the index append (data persisted on the broker).
|
// Wait until this session sees the index append (data persisted on the broker).
|
||||||
for (let i = 0; i < 15; i++) {
|
for (let i = 0; i < 15; i++) {
|
||||||
storeRegistry.resetRegistryCache();
|
registryInternals.resetRegistryCache();
|
||||||
const listed = await storeRegistry.listMyEntityDocs(id, "protected");
|
const listed = await storeRegistry.listMyEntityDocs(id, "protected");
|
||||||
if (listed.includes(doc)) break;
|
if (listed.includes(doc)) break;
|
||||||
await new Promise((r) => setTimeout(r, 1000));
|
await new Promise((r) => setTimeout(r, 1000));
|
||||||
@@ -739,7 +743,7 @@ const identity = new IdentityStore(
|
|||||||
* the handle; poll watchShapeSnapshot for the transition.
|
* the handle; poll watchShapeSnapshot for the transition.
|
||||||
*/
|
*/
|
||||||
watchShapeEmptyStart(handle: string, cls: string) {
|
watchShapeEmptyStart(handle: string, cls: string) {
|
||||||
storeRegistry.resetRegistryCache();
|
registryInternals.resetRegistryCache();
|
||||||
const id = "@ws-empty-" + handle;
|
const id = "@ws-empty-" + handle;
|
||||||
setCurrentUser(id);
|
setCurrentUser(id);
|
||||||
const shape = {
|
const shape = {
|
||||||
@@ -802,6 +806,50 @@ const identity = new IdentityStore(
|
|||||||
* "receive" operation exists, and no principal is ever named to the registry.
|
* "receive" operation exists, and no principal is ever named to the registry.
|
||||||
* Runs against the REAL broker inbox document, so it exercises the whole path.
|
* Runs against the REAL broker inbox document, so it exercises the whole path.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* The DEPOSIT side of a document's inbox, end to end against the real broker: the
|
||||||
|
* owner opens it, a third party RESOLVES its address from the document itself and
|
||||||
|
* deposits, the owner reads it back.
|
||||||
|
*
|
||||||
|
* The point of the step is the resolution: nothing hands `depositorId` the address.
|
||||||
|
* It gets the document's link (which is what circulates in this model) and must find
|
||||||
|
* where to deposit on its own — which is exactly what a consumer app has to do, and
|
||||||
|
* what a unit test passing the NURI through a variable cannot prove.
|
||||||
|
*/
|
||||||
|
async documentInboxDeposit(ownerId: string, depositorId: string) {
|
||||||
|
registryInternals.resetRegistryCache();
|
||||||
|
setCurrentUser(ownerId);
|
||||||
|
const doc = await storeRegistry.createEntityDoc(ownerId, "public");
|
||||||
|
const ownerInbox = await storeRegistry.openDocumentInbox(doc);
|
||||||
|
const link = capFor(doc)!; // the repo link the owner circulates
|
||||||
|
|
||||||
|
setCurrentUser(depositorId);
|
||||||
|
getCaps().learn(link);
|
||||||
|
const resolved = await storeRegistry.documentInboxAddress(doc);
|
||||||
|
// The one-call form an app actually uses: it names the DOCUMENT, never an inbox.
|
||||||
|
await inbox.postToDocument(doc, { payload: { viaPostToDocument: true }, ts: 900 });
|
||||||
|
// Opening one on someone else's document must be refused, not silently forked.
|
||||||
|
let openRefused = false;
|
||||||
|
try {
|
||||||
|
await storeRegistry.openDocumentInbox(doc);
|
||||||
|
} catch {
|
||||||
|
openRefused = true;
|
||||||
|
}
|
||||||
|
if (resolved) await inbox.post(resolved, { payload: { joining: true }, ts: 1000 });
|
||||||
|
|
||||||
|
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 props = Object.keys(subjects[0]?.props ?? {});
|
||||||
|
setCurrentUser(null);
|
||||||
|
return {
|
||||||
|
sameInbox: resolved === ownerInbox,
|
||||||
|
openRefused,
|
||||||
|
deposits: deposits.map((d) => d.payload),
|
||||||
|
props,
|
||||||
|
};
|
||||||
|
},
|
||||||
async capsShareCap(friendId: string) {
|
async capsShareCap(friendId: string) {
|
||||||
const s = await sessionReady;
|
const s = await sessionReady;
|
||||||
resetCaps();
|
resetCaps();
|
||||||
|
|||||||
@@ -10,15 +10,17 @@
|
|||||||
* In real NextGraph, a message is sealed to the recipient's key and queued into
|
* In real NextGraph, a message is sealed to the recipient's key and queued into
|
||||||
* their inbox; the recipient's own verifier unseals each queued message and
|
* their inbox; the recipient's own verifier unseals each queued message and
|
||||||
* applies it inline as it processes the inbox — there is no separate curator
|
* applies it inline as it processes the inbox — there is no separate curator
|
||||||
* process. A future `inbox_post_link` is the intended way to seal a link into an
|
* process. There is NO sender-side JS call for this today: the verifier has no
|
||||||
* inbox from the sender side; it is not exposed yet.
|
* `InboxPost` arm and `@ng-org/web` exposes no inbox method at all. (`inbox_post_link`,
|
||||||
|
* named elsewhere in these docs, is OUR proposal from `docs/fork-inbox-fallback.md` —
|
||||||
|
* no such symbol exists in `nextgraph-rs`. Do not cite it as a planned API.)
|
||||||
*
|
*
|
||||||
* Here, on one shared wallet where everything is readable, both sides run in-lib:
|
* Here, on one shared wallet where everything is readable, both sides run in-lib:
|
||||||
* - `post` appends a deposit `{ from, payload, ts }` as RDF into the inbox
|
* - `post` appends a deposit `{ from, payload, ts }` as RDF into the inbox
|
||||||
* document (in the shared wallet) via the `docs.sparqlUpdate` primitive;
|
* document (in the shared wallet) via the `docs.sparqlUpdate` primitive;
|
||||||
* - `read` / `watch` read the deposits back via `docs.sparqlQuery` and expose
|
* - `read` / `watch` read the deposits back via `docs.sparqlQuery` and expose
|
||||||
* them. This in-lib read stands in for the recipient's own inbox processing
|
* them. This in-lib read stands in for the recipient's own inbox processing
|
||||||
* until the sealed-inbox path (`inbox_post_link`) is available.
|
* until a sealed-inbox path is exposed to JS.
|
||||||
*
|
*
|
||||||
* All NextGraph I/O routes through the `docs` primitives (the real injected `ng`,
|
* All NextGraph I/O routes through the `docs` primitives (the real injected `ng`,
|
||||||
* never `makeNg`), so this module imports no `@ng-org` package.
|
* never `makeNg`), so this module imports no `@ng-org` package.
|
||||||
@@ -28,7 +30,7 @@ import { depositInto, sparqlQuery } from "./docs";
|
|||||||
import { subscribeDoc } from "./subscribe";
|
import { subscribeDoc } from "./subscribe";
|
||||||
import { ensureRepoOpen } from "./open-repo";
|
import { ensureRepoOpen } from "./open-repo";
|
||||||
import { getCaps, getCurrentUser, getStoreRegistryDeps } from "./polyfill";
|
import { getCaps, getCurrentUser, getStoreRegistryDeps } from "./polyfill";
|
||||||
import { addLink, isOwnInbox } from "./store-registry";
|
import { addLink, documentInboxAddress, isOwnInbox } from "./store-registry";
|
||||||
import { escapeLiteral } from "./sparql";
|
import { escapeLiteral } from "./sparql";
|
||||||
import { hasReadCap } from "./nuri";
|
import { hasReadCap } from "./nuri";
|
||||||
import {
|
import {
|
||||||
@@ -189,6 +191,40 @@ export async function post(targetInbox: Nuri, opts: PostOptions): Promise<void>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deposit into the inbox of a DOCUMENT — resolve where, then deposit there.
|
||||||
|
*
|
||||||
|
* The call an app makes to reach a document's owner: it needs the document (which it
|
||||||
|
* must be able to read) and nothing else. Where the inbox is, and whether the owner
|
||||||
|
* ever opened one, are the library's business.
|
||||||
|
*
|
||||||
|
* **No target-document field on the deposit, deliberately.** Upstream an inbox belongs
|
||||||
|
* to exactly one repo — the verifier routes by `inboxes: PubKey → RepoId` and unseals
|
||||||
|
* with that repo's key (`engine/verifier/src/verifier.rs:1677`) — and `InboxMsgBody`
|
||||||
|
* carries no document (`engine/net/src/types.rs:4265`), because the address already
|
||||||
|
* identifies it. Tagging deposits with their document would be an invention consumers
|
||||||
|
* would have to unlearn at migration, so this resolves the address and stops there.
|
||||||
|
*
|
||||||
|
* @throws if the document has no inbox — its owner never opened one, so there is
|
||||||
|
* nowhere for this to go. Throwing rather than returning quietly is the whole lesson of
|
||||||
|
* this path: a deposit that vanishes without an error is worse than a refusal, and it
|
||||||
|
* is exactly the bug per-document inboxes shipped with
|
||||||
|
* (`docs/briefs/2026-08-03-document-inbox-addressing.md`). Call
|
||||||
|
* `storeRegistry.documentInboxAddress(doc)` first when "no inbox" is an expected case.
|
||||||
|
*/
|
||||||
|
export async function postToDocument(doc: Nuri, opts: PostOptions): Promise<void> {
|
||||||
|
const target = await documentInboxAddress(doc);
|
||||||
|
if (target === undefined) {
|
||||||
|
throw new Error(
|
||||||
|
"[ng-eventually] inbox.postToDocument: this document has no inbox — either its owner " +
|
||||||
|
"never opened one, or you cannot read the document (the address rides on it): " +
|
||||||
|
JSON.stringify(doc),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return post(target, opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// --- cap delivery ---------------------------------------------------------
|
// --- cap delivery ---------------------------------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,14 +1,23 @@
|
|||||||
/**
|
/**
|
||||||
* @ng-eventually/client — **SDK-identical** surface.
|
* @ng-eventually/client — the surface a consumer application codes against.
|
||||||
*
|
*
|
||||||
* This entry exposes ONLY what `@ng-org/web` / `@ng-org/orm` expose (current +
|
* Everything here has a target-SDK counterpart, verified or assumed, listed in
|
||||||
* anticipated: `inbox`). Import `ng` / `useShape` from here instead of the SDK
|
* `docs/api-contract.md`. Import `ng` / `useShape` from here rather than from the
|
||||||
* during the polyfill period; at migration the build alias is removed and these
|
* SDK during the polyfill period; at migration the build alias is removed and
|
||||||
* resolve to the real SDK with **no code change**.
|
* these resolve to the real SDK.
|
||||||
*
|
*
|
||||||
* The one non-SDK piece — the polyfill bootstrap (`configure`, capability
|
* **This entry carries no machinery.** The earlier header claimed it exposed "ONLY
|
||||||
* helpers, current user) — lives at `@ng-eventually/client/polyfill`, and is the
|
* what `@ng-org/web` / `@ng-org/orm` expose", which was false as written: it also
|
||||||
* only thing removed at migration.
|
* shipped the whole `store-registry` module (account resolution, cap registers,
|
||||||
|
* cache resets) and `accounts` (browser identity persistence, polyfill-era with no
|
||||||
|
* SDK counterpart). Both leaked machinery onto the entry whose promise is that it
|
||||||
|
* survives migration. `storeRegistry` is now the app-facing slice only
|
||||||
|
* (`store-registry-api.ts`); `accounts` moved to `/polyfill`.
|
||||||
|
*
|
||||||
|
* The polyfill bootstrap — `configure`, the capability helpers, the current user,
|
||||||
|
* identity persistence — lives at `@ng-eventually/client/polyfill`: everything an
|
||||||
|
* application needs TODAY that will not exist tomorrow, kept apart so what goes
|
||||||
|
* away is visible at the import line.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export * from "./types";
|
export * from "./types";
|
||||||
@@ -22,10 +31,7 @@ export { subscribeDoc, subscribeDocs, docChangeType } from "./subscribe";
|
|||||||
export type { DocChange, DocChangeType, Unsubscribe } from "./subscribe";
|
export type { DocChange, DocChangeType, Unsubscribe } from "./subscribe";
|
||||||
export * as readModel from "./read-model";
|
export * as readModel from "./read-model";
|
||||||
export type { UnionSubject } from "./read-model";
|
export type { UnionSubject } from "./read-model";
|
||||||
export * as storeRegistry from "./store-registry";
|
export * as storeRegistry from "./store-registry-api";
|
||||||
export type { AccountRecord, RegistrySession } from "./store-registry";
|
|
||||||
export * as accounts from "./accounts";
|
|
||||||
export type { AccountStorage } from "./accounts";
|
|
||||||
|
|
||||||
// SPARQL injection-safety helpers — so the app can reuse the same escaping /
|
// SPARQL injection-safety helpers — so the app can reuse the same escaping /
|
||||||
// validation when it builds SPARQL by interpolation. `escapeLiteral` for string
|
// validation when it builds SPARQL by interpolation. `escapeLiteral` for string
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
/**
|
||||||
|
* The namespace this library reserves for its OWN triples, and the one predicate a
|
||||||
|
* read path needs about it: *is this subject machinery, or is it the consumer's data?*
|
||||||
|
*
|
||||||
|
* ── Why this exists ────────────────────────────────────────────────────────
|
||||||
|
* The polyfill has no branches, so it emulates each of a repo's compartments with a
|
||||||
|
* distinct SUBJECT inside a document (`shim:index` for the store's Main branch,
|
||||||
|
* `shim:storeBranch`, `shim:userBranch`, `shim:headerBranch` — see `store-registry.ts`).
|
||||||
|
* That was invisible as long as those subjects only ever appeared in documents the
|
||||||
|
* consumer never reads through the data path — store documents and the doc-shim.
|
||||||
|
*
|
||||||
|
* The Header branch broke that: it lives in an ENTITY document, the one the consumer
|
||||||
|
* reads with `SELECT ?s ?p ?o`. Without a filter, the address of a document's inbox
|
||||||
|
* would surface as one of that entity's properties — machinery leaking into domain
|
||||||
|
* data. Filtering by SUBJECT rather than by predicate is what makes this hold for
|
||||||
|
* every compartment, present and future: a new emulated branch needs no new filter.
|
||||||
|
*
|
||||||
|
* Upstream this problem does not exist, because there the separation is real — a
|
||||||
|
* branch is a different CRDT with its own topic, not a subject in the same graph. This
|
||||||
|
* module is the seam where our emulation pays for that.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The URN namespace every triple this library writes for itself lives under —
|
||||||
|
* `urn:ng-eventually:shim:…` (store-registry's compartments) and
|
||||||
|
* `urn:ng-eventually:inbox:…` (inbox deposits).
|
||||||
|
*
|
||||||
|
* A consumer that writes its own data under this prefix would have it filtered out of
|
||||||
|
* its reads. That is a deliberate reservation, not a hazard to guard against: the
|
||||||
|
* namespace names this library.
|
||||||
|
*/
|
||||||
|
export const MACHINERY_NS = "urn:ng-eventually:";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Is `subject` one of this library's own, rather than consumer data?
|
||||||
|
*
|
||||||
|
* Tolerant of `undefined` so a read path can hand it a possibly-absent binding
|
||||||
|
* without a preliminary check — an absent subject is not machinery.
|
||||||
|
*/
|
||||||
|
export function isMachinerySubject(subject: string | undefined): boolean {
|
||||||
|
return subject !== undefined && subject.startsWith(MACHINERY_NS);
|
||||||
|
}
|
||||||
@@ -48,7 +48,7 @@ export function makeNg(): Record<string, any> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO(anticipated API): inbox_post_link + capability operations — expose
|
// TODO(anticipated API): a sealed inbox deposit + capability operations — expose
|
||||||
// here with their anticipated signatures, emulated for now.
|
// here with their anticipated signatures, emulated for now.
|
||||||
|
|
||||||
// Everything else: passthrough to the real SDK, unchanged.
|
// Everything else: passthrough to the real SDK, unchanged.
|
||||||
|
|||||||
@@ -227,3 +227,15 @@ export function resetCaps(): void {
|
|||||||
export { CapRegistry } from "./caps";
|
export { CapRegistry } from "./caps";
|
||||||
export { shareCap } from "./inbox";
|
export { shareCap } from "./inbox";
|
||||||
export { connectedUser } from "./connect";
|
export { connectedUser } from "./connect";
|
||||||
|
|
||||||
|
// --- identity persistence (polyfill-era, no SDK counterpart) ----------------
|
||||||
|
//
|
||||||
|
// Moved here from the SDK-identical entry on 2026-08-03. `accounts` persists WHICH
|
||||||
|
// virtual user is connected, in browser storage — a notion that exists only because
|
||||||
|
// 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 "./accounts";
|
||||||
|
export type { AccountStorage } from "./accounts";
|
||||||
|
// Config-shaped types the bootstrap needs; both describe the shim, not the SDK.
|
||||||
|
export type { AccountRecord, RegistrySession } from "./store-registry";
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ import { getCaps, getStoreRegistryDeps } from "./polyfill";
|
|||||||
import { mustNotAttempt } from "./reach";
|
import { mustNotAttempt } from "./reach";
|
||||||
import { ensureReposOpen } from "./open-repo";
|
import { ensureReposOpen } from "./open-repo";
|
||||||
import { assertNuri } from "./sparql";
|
import { assertNuri } from "./sparql";
|
||||||
|
import { isMachinerySubject } from "./machinery";
|
||||||
import type { Nuri } from "./types";
|
import type { Nuri } from "./types";
|
||||||
|
|
||||||
// Keep the primitives referenced so tree-shaking never drops the import used by
|
// Keep the primitives referenced so tree-shaking never drops the import used by
|
||||||
@@ -172,6 +173,11 @@ export async function readUnion(docs: Nuri[]): Promise<UnionSubject[]> {
|
|||||||
// (writeEntity invariant). Pin subject/graph to the doc NURI (the anchor), which
|
// (writeEntity invariant). Pin subject/graph to the doc NURI (the anchor), which
|
||||||
// is stable regardless of the repo_graph_name overlay suffix the store carries.
|
// is stable regardless of the repo_graph_name overlay suffix the store carries.
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
|
// The polyfill's own compartments live as reserved SUBJECTS inside the very
|
||||||
|
// documents the consumer reads (the Header branch carrying a document's inbox
|
||||||
|
// address is the first). They are machinery, not this entity's properties —
|
||||||
|
// drop them here, once, for every compartment present and future.
|
||||||
|
if (isMachinerySubject(row.s?.value)) continue;
|
||||||
const p = row.p?.value;
|
const p = row.p?.value;
|
||||||
const o = row.o?.value;
|
const o = row.o?.value;
|
||||||
if (!p || o === undefined) continue;
|
if (!p || o === undefined) continue;
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
/**
|
||||||
|
* The app-facing slice of `store-registry` — and the reason it exists as a file.
|
||||||
|
*
|
||||||
|
* `store-registry.ts` holds two things that must not be exported together: the
|
||||||
|
* placement/addressing calls a consumer application legitimately makes, and the
|
||||||
|
* shim machinery that makes virtual users work at all (account resolution, the
|
||||||
|
* durable cap registers, the inbox-ownership predicate, cache resets). Until now
|
||||||
|
* `index.ts` did `export * as storeRegistry from "./store-registry"` and shipped
|
||||||
|
* both, so an application could reach `ensureAccount`, `addLink` or
|
||||||
|
* `resetRegistryCache` from the SDK-identical entry — machinery it must never call,
|
||||||
|
* on the entry whose whole promise is "this survives migration unchanged".
|
||||||
|
*
|
||||||
|
* What is re-exported here is only what an application needs to do its own work,
|
||||||
|
* and each has a target-SDK counterpart (see `docs/api-contract.md`). Everything
|
||||||
|
* else stays reachable at `./store-registry` for the library's own modules, the
|
||||||
|
* unit tests and the e2e harness — an internal path, not a published one.
|
||||||
|
*
|
||||||
|
* At migration this file disappears: placement becomes the user's real per-scope
|
||||||
|
* stores and the calls below become native SDK ones.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export {
|
||||||
|
/** Create a document for ONE entity in `scope`, and record it in that scope's store. */
|
||||||
|
createEntityDoc,
|
||||||
|
/** The entity documents this user owns in `scope` — with their caps recovered. */
|
||||||
|
listMyEntityDocs,
|
||||||
|
/** The NURI to use as a READ scope for `scope` (what `useShape` is pointed at). */
|
||||||
|
resolveScopeGraph,
|
||||||
|
/** 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,
|
||||||
|
/** 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. */
|
||||||
|
documentInboxAddress,
|
||||||
|
} from "./store-registry";
|
||||||
@@ -67,6 +67,7 @@ import { getCaps, getCurrentUser, getStoreRegistryDeps } from "./polyfill";
|
|||||||
import { ensureRepoOpen, ensurePhysicalRepoOpen } from "./open-repo";
|
import { ensureRepoOpen, ensurePhysicalRepoOpen } from "./open-repo";
|
||||||
import { escapeLiteral, escapeIri, assertNuri } from "./sparql";
|
import { escapeLiteral, escapeIri, assertNuri } from "./sparql";
|
||||||
import { hasReadCap, isNuri, mintCap } from "./nuri";
|
import { hasReadCap, isNuri, mintCap } from "./nuri";
|
||||||
|
import { mustNotAttempt } from "./reach";
|
||||||
import { accessLogPrefix, logStage, shortNuri } from "./access-log";
|
import { accessLogPrefix, logStage, shortNuri } from "./access-log";
|
||||||
import type { Nuri, ReadCap, Scope } from "./types";
|
import type { Nuri, ReadCap, Scope } from "./types";
|
||||||
|
|
||||||
@@ -105,6 +106,7 @@ const P = {
|
|||||||
link: `${SHIM}:link`, // user branch → a ReadCap received for an EXTERNAL document
|
link: `${SHIM}:link`, // user branch → a ReadCap received for an EXTERNAL document
|
||||||
readCap: `${SHIM}:readCap`, // store branch → the ReadCap of a document IN this store
|
readCap: `${SHIM}:readCap`, // store branch → the ReadCap of a document IN this store
|
||||||
inboxCap: `${SHIM}:inboxCap`, // user branch → an inbox this user may READ
|
inboxCap: `${SHIM}:inboxCap`, // user branch → an inbox this user may READ
|
||||||
|
inboxAddress: `${SHIM}:inboxAddress`, // header branch → WHERE to deposit for this document
|
||||||
} as const;
|
} as const;
|
||||||
// Fixed subject of the per-(account×scope) index document. The index doc plays
|
// Fixed subject of the per-(account×scope) index document. The index doc plays
|
||||||
// the role of the future store-container: it lists the NURIs of the entity
|
// the role of the future store-container: it lists the NURIs of the entity
|
||||||
@@ -142,6 +144,34 @@ const USER_BRANCH_SUBJECT = `${SHIM}:userBranch`;
|
|||||||
* keys stay separate.
|
* keys stay separate.
|
||||||
*/
|
*/
|
||||||
const STORE_BRANCH_SUBJECT = `${SHIM}:storeBranch`;
|
const STORE_BRANCH_SUBJECT = `${SHIM}:storeBranch`;
|
||||||
|
/**
|
||||||
|
* Fixed subject of the **Header branch** emulation, inside an ENTITY document — the
|
||||||
|
* first compartment we put in a document the consumer also reads, hence the filter in
|
||||||
|
* `read-model.ts` (every `${SHIM}:` subject is machinery and never surfaces as data).
|
||||||
|
*
|
||||||
|
* It carries what must be readable by *whoever can read the document*, as opposed to
|
||||||
|
* what belongs to its owner alone. Today that is one thing: the ADDRESS of the
|
||||||
|
* document's inbox.
|
||||||
|
*
|
||||||
|
* Why the address must live here and not on the owner's User branch. Upstream an inbox
|
||||||
|
* is a KEYPAIR (`repo.inbox: Option<PrivKey>`, `engine/repo/src/repo.rs:126`) and the
|
||||||
|
* two halves have opposite audiences: a depositor seals with the PUBLIC key
|
||||||
|
* (`InboxMsg::new` → `crypto_box::seal(&to_inbox.to_dh_slice(), …)`,
|
||||||
|
* `engine/net/src/types.rs:4299`) and needs nothing else; only the owner holds the
|
||||||
|
* private half (`AddInboxCap`, on the User branch). An address is therefore public by
|
||||||
|
* nature — upstream it travels with the profile (`ContactDetails` carries
|
||||||
|
* `ng:site_inbox` / `ng:protected_inbox`, `engine/verifier/src/inbox_processor.rs:823`).
|
||||||
|
* Keeping it only on the owner's User branch, as this lib first did, made the deposit
|
||||||
|
* side unreachable: a third party had no way to learn where to deposit.
|
||||||
|
*
|
||||||
|
* **Not `BranchType::Header` upstream.** That branch exists (`engine/repo/src/types.rs:1551`)
|
||||||
|
* but is CLOSED: `update_header` writes only `title`/`about`
|
||||||
|
* (`engine/verifier/src/request_processor.rs:173-211`) and `fetch_header` reads back
|
||||||
|
* only `title`/`about`/`class` (`:1240-1284`). It cannot carry an inbox address. The
|
||||||
|
* name is borrowed for the shape — a compartment of the document that is not its
|
||||||
|
* content — not for the upstream branch's contract.
|
||||||
|
*/
|
||||||
|
const HEADER_BRANCH_SUBJECT = `${SHIM}:headerBranch`;
|
||||||
|
|
||||||
// --- pointer (store-root → doc-shim indirection) --------------------------
|
// --- pointer (store-root → doc-shim indirection) --------------------------
|
||||||
//
|
//
|
||||||
@@ -927,9 +957,55 @@ export async function createEntityDoc(id: string, scope: Scope): Promise<Nuri> {
|
|||||||
}
|
}
|
||||||
// …and the creator holds THAT cap for this session.
|
// …and the creator holds THAT cap for this session.
|
||||||
holdOwnCap(id, scope, entityNuri, cap);
|
holdOwnCap(id, scope, entityNuri, cap);
|
||||||
|
// NO inbox here, and NOT the owner's own inbox published as this document's address.
|
||||||
|
// Upstream an inbox belongs to exactly ONE repo: the verifier routes an incoming
|
||||||
|
// message by `inboxes: PubKey → RepoId` (`engine/verifier/src/verifier.rs:1677,1928`)
|
||||||
|
// and unseals it with THAT repo's private half, while `InboxMsgBody` carries no
|
||||||
|
// target document at all (`engine/net/src/types.rs:4265`) — because it needs none,
|
||||||
|
// the address IS the identification. Pointing several documents at one inbox would
|
||||||
|
// emulate a many-to-one relation the model cannot express, and would teach consumers
|
||||||
|
// to tag deposits with their document, a habit that has to be unlearned at migration.
|
||||||
|
//
|
||||||
|
// So a document gets an inbox only when its owner opens one
|
||||||
|
// ({@link openDocumentInbox}), which is also what keeps the cost proportional: only
|
||||||
|
// documents meant to RECEIVE pay for one (see
|
||||||
|
// `docs/briefs/2026-08-03-document-inbox-addressing.md`).
|
||||||
return entityNuri;
|
return entityNuri;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Publish WHERE to deposit for `doc`, on its Header branch — the compartment any
|
||||||
|
* holder of the document can read.
|
||||||
|
*
|
||||||
|
* Replacement, not addition: a document has exactly ONE inbox upstream (the verifier's
|
||||||
|
* `inboxes: PubKey → RepoId` is a function, and `repo.inbox` a single `Option<PrivKey>`),
|
||||||
|
* so two addresses on one document is a state the model has no meaning for — and a
|
||||||
|
* depositor picking the stale one writes where nobody reads.
|
||||||
|
*/
|
||||||
|
async function publishInboxAddress(doc: Nuri, inbox: Nuri): Promise<void> {
|
||||||
|
const s = await session();
|
||||||
|
try {
|
||||||
|
// Two separate updates, not one compound statement: `DELETE WHERE { … }` is the
|
||||||
|
// form verified against the real broker (see
|
||||||
|
// `docs/decisions/sparql-delete-for-orm-objects.md`), whereas a `;`-joined update
|
||||||
|
// is not exercised anywhere in this lib.
|
||||||
|
await sparqlUpdate(
|
||||||
|
s.sessionId,
|
||||||
|
`DELETE WHERE { <${HEADER_BRANCH_SUBJECT}> <${P.inboxAddress}> ?a }`,
|
||||||
|
doc,
|
||||||
|
"publishInboxAddress:clear",
|
||||||
|
);
|
||||||
|
await sparqlUpdate(
|
||||||
|
s.sessionId,
|
||||||
|
`INSERT DATA { <${HEADER_BRANCH_SUBJECT}> <${P.inboxAddress}> "${escapeLiteral(inbox)}" }`,
|
||||||
|
doc,
|
||||||
|
"publishInboxAddress",
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(accessLogPrefix() + " publishInboxAddress failed:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The ReadCaps recorded on a store's Store branch — its documents, each with its
|
* The ReadCaps recorded on a store's Store branch — its documents, each with its
|
||||||
* key. The emulated replay of `AddRepo`, and the reason a fresh session recovers
|
* key. The emulated replay of `AddRepo`, and the reason a fresh session recovers
|
||||||
@@ -1023,30 +1099,69 @@ export async function userStoreDoc(id: string, scope: Scope): Promise<Nuri> {
|
|||||||
* same branch that carries `AddLink`. So "which inboxes may I read" is answered by the
|
* same branch that carries `AddLink`. So "which inboxes may I read" is answered by the
|
||||||
* User branch, and that is what this emulates.
|
* User branch, and that is what this emulates.
|
||||||
*
|
*
|
||||||
* **This ANTICIPATES: no document has an inbox upstream today.** `new_store_default`
|
* **The engine SUPPORTS this; nothing exercises it automatically.** Those are two
|
||||||
* attaches one only `if !private` (`engine/verifier/src/verifier.rs:2994`), and
|
* different statements, and conflating them is what made an earlier version of this
|
||||||
* `doc_create` goes through `new_repo_default`, which leaves `inbox: None`
|
* comment call the feature an "anticipation". It is not. `inbox: Option<PrivKey>` is a
|
||||||
* (`engine/repo/src/repo.rs:574`) — the only two `AddInboxCap` commits in the engine
|
* field of EVERY `Repo` (`engine/repo/src/repo.rs:126`), not of a store structure;
|
||||||
* are for the public and protected STORE repos (`engine/verifier/src/site.rs:128,149`).
|
* `AddInboxCapV0` is keyed by `repo_id` (`engine/repo/src/types.rs:1973`); and
|
||||||
* What is verified is the SHAPE: the record is keyed by `repo_id`, so it accommodates
|
* `update_inbox_cap_v0` applies it with `self.repos.get_mut(repo_id)` and **no
|
||||||
* an inbox on any repo. What is not verified is that anything upstream will create one
|
* `is_store` check of any kind** (`engine/verifier/src/verifier.rs:1920`). Generic by
|
||||||
* per document. At migration this either becomes native or stays emulated here; either
|
* construction, and at any time (see the User-branch note above).
|
||||||
* way the consumer-facing act is unchanged.
|
|
||||||
*
|
*
|
||||||
* Lazy on purpose: creating an inbox document for every entity up front would
|
* What is true is narrower: no code path CREATES one for a document — `new_store_default`
|
||||||
* double every `createEntityDoc` for inboxes most documents never receive anything
|
* attaches one only `if !private` (`verifier.rs:2994`), `doc_create` leaves `inbox: None`
|
||||||
* in. Upstream the keypair is cheap; here an inbox is a document, so it is minted
|
* (`repo.rs:574`), and the only two `AddInboxCap` commits in the engine are for the
|
||||||
* when first asked for.
|
* public and protected STORE repos (`engine/verifier/src/site.rs:128,149`). So the
|
||||||
|
* capability exists and is simply unexposed above level 1: this function is aligned on
|
||||||
|
* the engine's model, it does not bet past it.
|
||||||
*
|
*
|
||||||
* Only for documents this user holds — you cannot open an inbox on someone else's
|
* Lazy on purpose, for the same reason: creating an inbox document for every entity up
|
||||||
* document, you can only deposit into it.
|
* front would double every `createEntityDoc` for inboxes most documents never receive
|
||||||
|
* anything in. Upstream the keypair is cheap; here an inbox is a document, so it is
|
||||||
|
* minted when first asked for.
|
||||||
|
*
|
||||||
|
* *(Not covered: ROTATING an inbox key — the engine's "update" case with a new
|
||||||
|
* `priv_key`. This function is idempotent and returns the existing inbox instead. A
|
||||||
|
* known limit, not an oversight.)*
|
||||||
|
*
|
||||||
|
* Only for a document this user OWNS — see {@link ownsDocument}. Opening an inbox on
|
||||||
|
* someone else's document would be usurpation, not a courtesy: the opener keeps the
|
||||||
|
* reading half, so it would silently divert to itself the deposits meant for the
|
||||||
|
* owner. To deposit into someone else's document, resolve
|
||||||
|
* {@link documentInboxAddress} and `inbox.post` into it.
|
||||||
*/
|
*/
|
||||||
export async function documentInbox(doc: Nuri): Promise<Nuri> {
|
export async function openDocumentInbox(doc: Nuri): Promise<Nuri> {
|
||||||
const holder = getCurrentUser();
|
const holder = getCurrentUser();
|
||||||
if (holder === null) throw new Error("[ng-eventually] documentInbox: no identity is set");
|
if (holder === null) throw new Error("[ng-eventually] openDocumentInbox: no identity is set");
|
||||||
const known = (await readInboxCapsFor(doc)) ?? null;
|
const known = (await readInboxCapsFor(doc)) ?? null;
|
||||||
if (known) return known;
|
if (known) return known;
|
||||||
|
|
||||||
|
// OWNERSHIP is the criterion, and holding a cap is NOT ownership — a cap can be
|
||||||
|
// received. Opening an inbox is what PUBLISHES this document's address, so a
|
||||||
|
// non-owner doing it would route the owner's deposits to itself, silently, on a
|
||||||
|
// document it merely reads.
|
||||||
|
//
|
||||||
|
// **This guard compensates OUR design, not an upstream constraint** — an earlier
|
||||||
|
// comment here claimed "upstream only the owner can commit `AddInboxCap`", which is
|
||||||
|
// false: that commit lands on the committer's OWN User branch, so anyone may write
|
||||||
|
// one naming anyone's repo. What protects upstream is that an inbox address is never
|
||||||
|
// PUBLISHED — it is TRANSMITTED (in a `ContactDetails` message, or a profile QR
|
||||||
|
// code), and `inboxes: PubKey → RepoId` is a per-verifier local table
|
||||||
|
// (`engine/verifier/src/verifier.rs:105`, rebuilt empty each session). A forged pair
|
||||||
|
// reaches nobody, because nobody was told about it.
|
||||||
|
//
|
||||||
|
// We publish instead of transmitting — the only way a third party can find the
|
||||||
|
// address at all here — which creates a vector upstream does not have: whoever can
|
||||||
|
// write the document can redirect its deposits. Hence this guard. It is a real
|
||||||
|
// divergence, deliberately taken; see `docs/briefs/2026-08-03-document-inbox-addressing.md`.
|
||||||
|
if (!(await ownsDocument(doc))) {
|
||||||
|
throw new Error(
|
||||||
|
"[ng-eventually] openDocumentInbox: refused — you may only open an inbox on a document " +
|
||||||
|
`you own. Deposit into its published address instead (storeRegistry.documentInboxAddress ` +
|
||||||
|
`then inbox.post): ${JSON.stringify(doc)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const inbox = await createDoc();
|
const inbox = await createDoc();
|
||||||
const s = await session();
|
const s = await session();
|
||||||
const record = await ensureAccount(holder);
|
const record = await ensureAccount(holder);
|
||||||
@@ -1058,15 +1173,80 @@ export async function documentInbox(doc: Nuri): Promise<Nuri> {
|
|||||||
s.sessionId,
|
s.sessionId,
|
||||||
`INSERT DATA { <${USER_BRANCH_SUBJECT}> <${P.inboxCap}> "${escapeLiteral(doc + " " + inbox)}" }`,
|
`INSERT DATA { <${USER_BRANCH_SUBJECT}> <${P.inboxCap}> "${escapeLiteral(doc + " " + inbox)}" }`,
|
||||||
store,
|
store,
|
||||||
"documentInbox",
|
"openDocumentInbox",
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(accessLogPrefix() + " documentInbox persist failed:", error);
|
console.error(accessLogPrefix() + " openDocumentInbox persist failed:", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// …and the PUBLIC half, in the document itself, so a depositor can find it at all.
|
||||||
|
// Without this the inbox is reachable only by its owner — the opposite of what an
|
||||||
|
// inbox is for, and the bug this path shipped with.
|
||||||
|
await publishInboxAddress(doc, inbox);
|
||||||
return inbox;
|
return inbox;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WHERE to deposit for `doc` — its inbox address, or `undefined` if its owner never
|
||||||
|
* opened one. The deposit-side counterpart of {@link openDocumentInbox}, and the
|
||||||
|
* function an app calls before `inbox.post`.
|
||||||
|
*
|
||||||
|
* Readable by whoever can read the document, because it lives on its Header branch —
|
||||||
|
* an address is public by nature (upstream a depositor needs only the inbox PUBLIC
|
||||||
|
* key). Conversely someone who cannot read the document learns nothing, which is
|
||||||
|
* faithful too: upstream the inbox pubkey is not derivable from a RepoId, it has to
|
||||||
|
* reach you.
|
||||||
|
*
|
||||||
|
* **Never creates.** Asking where to deposit must not bring an inbox into existence —
|
||||||
|
* only its owner opens one, and only on its own document.
|
||||||
|
*/
|
||||||
|
export async function documentInboxAddress(doc: Nuri): Promise<Nuri | undefined> {
|
||||||
|
// RULE 2 — do not even attempt. Not holding the document, we have no address to
|
||||||
|
// find: upstream the inbox pubkey travels WITH what you can read, so "where do I
|
||||||
|
// deposit for a document I cannot read" is not a refused question, it is a question
|
||||||
|
// with no referent. Answering `undefined` here keeps the caller's shape (an address
|
||||||
|
// or none) instead of turning the boundary into an exception it must catch.
|
||||||
|
if (mustNotAttempt(doc)) return undefined;
|
||||||
|
const s = await session();
|
||||||
|
try {
|
||||||
|
const res = await sparqlQuery(
|
||||||
|
s.sessionId,
|
||||||
|
`SELECT ?a WHERE { <${HEADER_BRANCH_SUBJECT}> <${P.inboxAddress}> ?a }`,
|
||||||
|
undefined,
|
||||||
|
doc,
|
||||||
|
"documentInboxAddress",
|
||||||
|
);
|
||||||
|
for (const row of readBindings(res)) {
|
||||||
|
const a = bindingValue(row, "a");
|
||||||
|
if (a && isNuri(a)) return a;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Unreadable document (no cap) or not synced → no address to give. Refusing to
|
||||||
|
// read is the boundary doing its job, not an error to propagate here.
|
||||||
|
console.error(accessLogPrefix() + " documentInboxAddress failed:", error);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Does the connected user own `doc`? Answered from its **Store branches** — the
|
||||||
|
* register of the documents it created — across the three scopes, which is the only
|
||||||
|
* place that records authorship. Holding a cap is NOT ownership: a cap can be
|
||||||
|
* received, and a recipient must not be able to open an inbox on what it merely reads.
|
||||||
|
*/
|
||||||
|
async function ownsDocument(doc: Nuri): Promise<boolean> {
|
||||||
|
const holder = getCurrentUser();
|
||||||
|
if (holder === null) return false;
|
||||||
|
const record = await resolveAccount(holder);
|
||||||
|
if (record === null) return false;
|
||||||
|
for (const scope of ["public", "protected", "private"] as const) {
|
||||||
|
const store = storeOf(record, scope);
|
||||||
|
if (!store) continue;
|
||||||
|
if ((await readUserStore(store)).includes(doc)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/** The `(document, inbox)` pairs recorded on this user's User branch. */
|
/** The `(document, inbox)` pairs recorded on this user's User branch. */
|
||||||
async function readInboxCapPairs(): Promise<Array<{ doc: Nuri; inbox: Nuri }>> {
|
async function readInboxCapPairs(): Promise<Array<{ doc: Nuri; inbox: Nuri }>> {
|
||||||
const holder = getCurrentUser();
|
const holder = getCurrentUser();
|
||||||
|
|||||||
@@ -18,7 +18,13 @@
|
|||||||
* no authorization list anywhere, and nobody was named to the registry.
|
* no authorization list anywhere, and nobody was named to the registry.
|
||||||
*/
|
*/
|
||||||
import { test, expect, mock, afterAll } from "bun:test";
|
import { test, expect, mock, afterAll } from "bun:test";
|
||||||
import { createEntityDoc, documentInbox, resetRegistryCache, walletInbox } from "../src/store-registry";
|
import {
|
||||||
|
createEntityDoc,
|
||||||
|
documentInboxAddress,
|
||||||
|
openDocumentInbox,
|
||||||
|
resetRegistryCache,
|
||||||
|
walletInbox,
|
||||||
|
} from "../src/store-registry";
|
||||||
import type { RegistrySession } from "../src/store-registry";
|
import type { RegistrySession } from "../src/store-registry";
|
||||||
import {
|
import {
|
||||||
configure,
|
configure,
|
||||||
@@ -32,7 +38,7 @@ import {
|
|||||||
shareCap,
|
shareCap,
|
||||||
connectedUser,
|
connectedUser,
|
||||||
} from "../src/polyfill";
|
} from "../src/polyfill";
|
||||||
import { post, read as readInbox } from "../src/inbox";
|
import { post, postToDocument, read as readInbox } from "../src/inbox";
|
||||||
import { readUnion } from "../src/read-model";
|
import { readUnion } from "../src/read-model";
|
||||||
import { sparqlUpdate } from "../src/docs";
|
import { sparqlUpdate } from "../src/docs";
|
||||||
import type { Nuri } from "../src/types";
|
import type { Nuri } from "../src/types";
|
||||||
@@ -76,6 +82,19 @@ function makeFakeNg() {
|
|||||||
const query = a[1] as string;
|
const query = a[1] as string;
|
||||||
const anchor = a[2] as string | undefined;
|
const anchor = a[2] as string | undefined;
|
||||||
if (!anchor) return undefined;
|
if (!anchor) return undefined;
|
||||||
|
// `DELETE WHERE { <s> <p> ?var }` — the form the lib uses to REPLACE a value
|
||||||
|
// (see docs/decisions/sparql-delete-for-orm-objects.md). Without this arm the
|
||||||
|
// fake would treat the delete as an insert and the replacement would silently
|
||||||
|
// become an accumulation — the exact bug a replacement exists to prevent.
|
||||||
|
const del = query.match(/^\s*DELETE\s+WHERE\s*\{\s*<([^>]+)>\s+<([^>]+)>\s+\?/);
|
||||||
|
if (del) {
|
||||||
|
const [s0, p0] = [del[1]!, del[2]!];
|
||||||
|
for (let i = quads.length - 1; i >= 0; i--) {
|
||||||
|
const q = quads[i]!;
|
||||||
|
if (q.g === anchor && q.s === s0 && q.p === p0) quads.splice(i, 1);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
const body = query.replace(/^\s*INSERT DATA\s*\{/, "").replace(/\}\s*$/, "");
|
const body = query.replace(/^\s*INSERT DATA\s*\{/, "").replace(/\}\s*$/, "");
|
||||||
const sm = body.match(/<([^>]+)>/);
|
const sm = body.match(/<([^>]+)>/);
|
||||||
if (!sm) return undefined;
|
if (!sm) return undefined;
|
||||||
@@ -149,6 +168,10 @@ function makeFakeNg() {
|
|||||||
if (query.includes(`<${SHIM}:inboxCap>`)) {
|
if (query.includes(`<${SHIM}:inboxCap>`)) {
|
||||||
return { results: { bindings: quads.filter((q) => q.g === anchor && q.p === `${SHIM}:inboxCap`).map((q) => ({ c: { value: q.o } })) } };
|
return { results: { bindings: quads.filter((q) => q.g === anchor && q.p === `${SHIM}:inboxCap`).map((q) => ({ c: { value: q.o } })) } };
|
||||||
}
|
}
|
||||||
|
// Header-branch `inboxAddress` SELECT (where to deposit for this document).
|
||||||
|
if (query.includes(`<${SHIM}:inboxAddress>`)) {
|
||||||
|
return { results: { bindings: quads.filter((q) => q.g === anchor && q.p === `${SHIM}:inboxAddress`).map((q) => ({ a: { value: q.o } })) } };
|
||||||
|
}
|
||||||
// Store-branch `readCap` SELECT (the emulated AddRepo records).
|
// Store-branch `readCap` SELECT (the emulated AddRepo records).
|
||||||
if (query.includes(`<${SHIM}:readCap>`)) {
|
if (query.includes(`<${SHIM}:readCap>`)) {
|
||||||
return { results: { bindings: quads.filter((q) => q.g === anchor && q.p === `${SHIM}:readCap`).map((q) => ({ c: { value: q.o } })) } };
|
return { results: { bindings: quads.filter((q) => q.g === anchor && q.p === `${SHIM}:readCap`).map((q) => ({ c: { value: q.o } })) } };
|
||||||
@@ -383,28 +406,98 @@ test("a document has its own inbox: anyone deposits, only the owner reads", asyn
|
|||||||
inject();
|
inject();
|
||||||
setCurrentUser("alice");
|
setCurrentUser("alice");
|
||||||
const doc = await createEntityDoc("alice", "public");
|
const doc = await createEntityDoc("alice", "public");
|
||||||
const docInbox = await documentInbox(doc);
|
const aliceInbox = await openDocumentInbox(doc);
|
||||||
expect(docInbox).not.toBe(await walletInbox("alice"));
|
expect(aliceInbox).not.toBe(await walletInbox("alice"));
|
||||||
|
const link = capFor(doc)!; // the repo link alice circulates — links DO travel
|
||||||
|
|
||||||
// Bob deposits into the document's inbox — the cross-user act, open to all.
|
// Bob RESOLVES the address himself, from the document. The only thing he is handed
|
||||||
|
// is the link, which is the one thing the model says circulates. The address is not
|
||||||
|
// passed to him — if it had to be, there would be no way for an app to get it.
|
||||||
setCurrentUser("bob");
|
setCurrentUser("bob");
|
||||||
await post(docInbox, { payload: { joining: true }, ts: 1 });
|
getCaps().learn(link);
|
||||||
|
const bobTarget = await documentInboxAddress(doc);
|
||||||
|
expect(bobTarget).toBe(aliceInbox); // …and it is the SAME inbox alice reads
|
||||||
|
await post(bobTarget!, { payload: { joining: true }, ts: 1 });
|
||||||
|
|
||||||
// …and cannot read it back: depositing grants nothing.
|
// …and he cannot read it back: depositing grants nothing.
|
||||||
await expect(readInbox(docInbox)).rejects.toThrow(/does not belong to the connected wallet/i);
|
await expect(readInbox(bobTarget!)).rejects.toThrow(/does not belong to the connected wallet/i);
|
||||||
|
|
||||||
// Alice reads her document's inbox, because she opened it.
|
// Alice reads her document's inbox, because she opened it.
|
||||||
setCurrentUser("alice");
|
setCurrentUser("alice");
|
||||||
const deposits = await readInbox(docInbox);
|
const deposits = await readInbox(aliceInbox);
|
||||||
expect(deposits.map((d) => d.payload)).toEqual([{ joining: true }]);
|
expect(deposits.map((d) => d.payload)).toEqual([{ joining: true }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("opening an inbox on someone else's document is refused, not silently forked", async () => {
|
||||||
|
inject();
|
||||||
|
setCurrentUser("alice");
|
||||||
|
const doc = await createEntityDoc("alice", "public");
|
||||||
|
const aliceInbox = await openDocumentInbox(doc);
|
||||||
|
|
||||||
|
const link = capFor(doc)!;
|
||||||
|
|
||||||
|
// Bob holds the document — that is a READ right, and it is not ownership.
|
||||||
|
setCurrentUser("bob");
|
||||||
|
getCaps().learn(link);
|
||||||
|
await expect(openDocumentInbox(doc)).rejects.toThrow(/already has an inbox|you may only open an inbox/i);
|
||||||
|
// The address he resolves is still alice's, so his deposits reach her.
|
||||||
|
expect(await documentInboxAddress(doc)).toBe(aliceInbox);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a fresh document has NO inbox — one belongs to one document, and only its owner opens it", async () => {
|
||||||
|
inject();
|
||||||
|
setCurrentUser("alice");
|
||||||
|
const doc = await createEntityDoc("alice", "public");
|
||||||
|
const link = capFor(doc)!;
|
||||||
|
|
||||||
|
// Not "the owner's inbox by default": upstream an inbox belongs to exactly ONE repo
|
||||||
|
// (the verifier routes by `inboxes: PubKey → RepoId`), so pointing several documents
|
||||||
|
// at one inbox is a relation the model cannot express.
|
||||||
|
setCurrentUser("bob");
|
||||||
|
getCaps().learn(link);
|
||||||
|
expect(await documentInboxAddress(doc)).toBeUndefined();
|
||||||
|
// …and depositing THROWS rather than vanishing — a lost deposit is the bug this
|
||||||
|
// whole path exists to close.
|
||||||
|
await expect(postToDocument(doc, { payload: { x: 1 } })).rejects.toThrow(/has no inbox/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("opening an inbox publishes ONE address, and re-opening does not accumulate", async () => {
|
||||||
|
inject();
|
||||||
|
setCurrentUser("alice");
|
||||||
|
const doc = await createEntityDoc("alice", "public");
|
||||||
|
const dedicated = await openDocumentInbox(doc);
|
||||||
|
expect(await openDocumentInbox(doc)).toBe(dedicated); // idempotent
|
||||||
|
|
||||||
|
const link = capFor(doc)!;
|
||||||
|
setCurrentUser("bob");
|
||||||
|
getCaps().learn(link);
|
||||||
|
expect(await documentInboxAddress(doc)).toBe(dedicated);
|
||||||
|
// The deposit reaches the owner, addressed by the document alone.
|
||||||
|
await postToDocument(doc, { payload: { signingUp: true } });
|
||||||
|
setCurrentUser("alice");
|
||||||
|
expect((await readInbox(dedicated)).map((d) => d.payload)).toEqual([{ signingUp: true }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the inbox address is machinery: it never surfaces as the document's data", async () => {
|
||||||
|
inject();
|
||||||
|
setCurrentUser("alice");
|
||||||
|
const doc = await createEntityDoc("alice", "public");
|
||||||
|
await write(doc, SECRET, "s1");
|
||||||
|
await openDocumentInbox(doc);
|
||||||
|
|
||||||
|
// The consumer read returns the entity's properties and nothing of the compartment
|
||||||
|
// that carries the address — the Header branch is beside the content, not in it.
|
||||||
|
const subjects = await readUnion([doc]);
|
||||||
|
const props = subjects[0]?.props ?? {};
|
||||||
|
expect(Object.keys(props)).toEqual([SECRET]);
|
||||||
|
});
|
||||||
|
|
||||||
test("connecting drains BOTH levels: the user's inbox and its documents'", async () => {
|
test("connecting drains BOTH levels: the user's inbox and its documents'", async () => {
|
||||||
inject();
|
inject();
|
||||||
setCurrentUser("alice");
|
setCurrentUser("alice");
|
||||||
const protDoc = await createEntityDoc("alice", "protected");
|
const protDoc = await createEntityDoc("alice", "protected");
|
||||||
const pubDoc = await createEntityDoc("alice", "public");
|
const pubDoc = await createEntityDoc("alice", "public");
|
||||||
const docInbox = await documentInbox(pubDoc);
|
const docInbox = await openDocumentInbox(pubDoc);
|
||||||
const aliceInbox = await walletInbox("alice");
|
const aliceInbox = await walletInbox("alice");
|
||||||
|
|
||||||
// Two deposits, one at each level, both made by someone else.
|
// Two deposits, one at each level, both made by someone else.
|
||||||
@@ -422,3 +515,13 @@ test("connecting drains BOTH levels: the user's inbox and its documents'", async
|
|||||||
const left = await readInbox(docInbox);
|
const left = await readInbox(docInbox);
|
||||||
expect(left.map((d) => d.payload)).toEqual([{ onTheDocument: true }]); // consumer data stays
|
expect(left.map((d) => d.payload)).toEqual([{ onTheDocument: true }]); // consumer data stays
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The same resolution property one level up: a user's own inbox.
|
||||||
|
test("a third party resolves another user's inbox (the wallet level)", async () => {
|
||||||
|
inject();
|
||||||
|
setCurrentUser("alice");
|
||||||
|
const aliceView = await walletInbox("alice");
|
||||||
|
setCurrentUser("bob");
|
||||||
|
const bobView = await walletInbox("alice");
|
||||||
|
expect(bobView).toBe(aliceView);
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
/**
|
||||||
|
* The reserved-namespace predicate, in isolation.
|
||||||
|
*
|
||||||
|
* It is one `startsWith`, but it is the seam that keeps the polyfill's emulated
|
||||||
|
* branches out of the consumer's data (see `machinery.ts`), so its edges are worth
|
||||||
|
* pinning: get it wrong in one direction and machinery leaks into domain properties;
|
||||||
|
* wrong in the other and real data silently disappears from reads.
|
||||||
|
*/
|
||||||
|
import { test, expect } from "bun:test";
|
||||||
|
import { MACHINERY_NS, isMachinerySubject } from "../src/machinery";
|
||||||
|
|
||||||
|
test("the emulated branch subjects are all machinery", () => {
|
||||||
|
// The four compartments store-registry emulates, verbatim.
|
||||||
|
for (const s of [
|
||||||
|
"urn:ng-eventually:shim:index",
|
||||||
|
"urn:ng-eventually:shim:storeBranch",
|
||||||
|
"urn:ng-eventually:shim:userBranch",
|
||||||
|
"urn:ng-eventually:shim:headerBranch",
|
||||||
|
]) {
|
||||||
|
expect(isMachinerySubject(s)).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("inbox deposits are machinery too — a second prefix under the same namespace", () => {
|
||||||
|
expect(isMachinerySubject("urn:ng-eventually:inbox:deposit:1700:abc")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("consumer subjects are not machinery — including a NURI, which is what entities use", () => {
|
||||||
|
expect(isMachinerySubject("did:ng:o:doc1")).toBe(false);
|
||||||
|
expect(isMachinerySubject("urn:e2e:secret")).toBe(false);
|
||||||
|
expect(isMachinerySubject("http://example.org/thing")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a look-alike prefix is NOT machinery — the boundary is exact, not fuzzy", () => {
|
||||||
|
// Anything that merely resembles the namespace must fall on the data side, or a
|
||||||
|
// consumer's own vocabulary could vanish from its reads.
|
||||||
|
expect(isMachinerySubject("urn:ng-eventuallyX:thing")).toBe(false);
|
||||||
|
expect(isMachinerySubject("urn:ng-event:thing")).toBe(false);
|
||||||
|
expect(isMachinerySubject("x-urn:ng-eventually:shim:index")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an absent subject is not machinery — read paths hand bindings straight in", () => {
|
||||||
|
expect(isMachinerySubject(undefined)).toBe(false);
|
||||||
|
expect(isMachinerySubject("")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the namespace is the prefix both writers actually use", () => {
|
||||||
|
// Guards against the constant drifting away from store-registry/inbox.
|
||||||
|
expect("urn:ng-eventually:shim".startsWith(MACHINERY_NS)).toBe(true);
|
||||||
|
expect("urn:ng-eventually:inbox".startsWith(MACHINERY_NS)).toBe(true);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user