docs+refactor(client): fidelity pass — id identity, drop connections, no faux-login, accurate NextGraph framing

Align the polyfill's surface and docs with the verified NextGraph reality and
remove application-level concepts:

- Identity is an ID, not a username: AccountRecord.id, shim predicate shim:id,
  normalizeId; accounts core becomes IdentityStore (set/clear/get) — the faux
  login/logout framing is gone (identity is set at wallet-import time).
- Relationship/connection is an application concept, not a platform primitive
  (NextGraph has no bilateral-connection primitive: grantee is unpersisted
  scaffolding, cap-send is unimplemented). Remove connections.ts; caps exposes
  only a directed grantRead(doc, granteeId) + a read-only protectedDocsOf(owner).
  Delete the now-dead isolation.ts social-visibility axis.
- Inbox docs: NextGraph has no separate curator — the recipient's own verifier
  unseals and applies each queued sealed message inline (process_inbox);
  inbox_post_link is a proposed/future API. Stop attributing the emulated
  curator to the platform.
- Read isolation reframed around the outcome: no cap -> empty union read;
  targeted read of an unheld repo -> RepoNotFound; cap introspection
  (canRead/governsRead) is emulation-only with no NextGraph API behind it.
- read-model.md corrected: the listing path is per-doc ANCHORED default-graph
  queries, never the anchorless GRAPH ?g union (that is O(wallet)); the probe
  section no longer claims the opposite.
- README recap table restructured (target | current NextGraph status | current
  emulation); INDEX_ACCOUNT documented as reservedAccount("index") in the
  sentinel namespace; de-domained generic-layer comments; softened tone.

Consumer application (Festipod) rewired separately to own the relationship
concept and feed the lib an id. Lib gates: bun test 83 pass / 0 fail, tsc clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sylvain Duchesne
2026-07-06 14:02:16 +02:00
parent d39b12885a
commit 63ecfeeff8
31 changed files with 1059 additions and 1396 deletions
+49 -46
View File
@@ -1,91 +1,94 @@
# ADR — Discovery mechanism (inbox-fed index, curator, fan-out)
# ADR — Discovery mechanism (inbox-fed index, fan-out)
**Date:** 2026-06-16 · **Status:** mechanism accepted; target owner undecided.
Ported here for the **discovery MECHANISM** it defines — the piece this lib
Ported here for the discovery mechanism it defines — the piece this lib
realizes (`inbox.ts` post/materialize/watch; `store-registry.ts` fan-out). The
product intent (what a consumer *should* surface) is the consumer's concern, not
this lib's; only the mechanism is recorded here.
product intent (what a consumer application *should* surface) is the consumer
application's concern, not this lib's; only the mechanism is recorded here.
## Access discovery
## Access is not discovery
- **Access**: may I read this document if I hold it? A public entity is
world-readable with its NURI.
- **Discovery**: how do I learn it exists, in order to read it? this ADR.
- **Discovery**: how do I learn it exists, in order to read it? This is the ADR's topic.
## The mechanism
1. **A single global index**, **fed via ITS inbox**. The creator does **not** edit
the index directly: it **deposits a reference into the index's inbox**. The
index is an **owned document** (public read), **materialized from its inbox** (a
watcher ingests deposits adds entries).
2. **Primary discovery = that global index.**
3. **Relational = secondary axis**, overlaid: a connection's participations,
markers on the global list. Rests on existing per-item data (protected scope)
no new primitive.
1. A single global index, fed via its inbox. The creator does not edit
the index directly: it deposits a reference into the index's inbox. The
index is an owned document (public read), built up from its inbox (a
watcher ingests deposits and adds entries).
2. Primary discovery is that global index.
3. Relational is a secondary axis, overlaid: a peer's participations,
markers on the global list. It rests on existing per-item data (protected scope),
with no new primitive.
## The 3-stage frame
`discovery → synchronization → query`
1. **Discovery**: the index gives the NURIs of the entity documents.
2. **Synchronization**: subscribe to those documents they **replicate locally**
2. **Synchronization**: subscribe to those documents so they replicate locally
(verifier: `self.repos` + oxigraph dataset).
3. **Query**: query what is **now local** (sort, limit, reactivity). **SPARQL/ORM
run on the local set only** (`resolve_target_for_sparql` searches `self.repos`)
you cannot query what is not loaded.
3. **Query**: query what is now local (sort, limit, reactivity). SPARQL/ORM
run on the local set only (`resolve_target_for_sparql` searches `self.repos`)
you cannot query what is not loaded.
**Corollary:** a reactive query does not replace the index — it runs at stage 3 on
the local union that stages 1-2 built. You don't sync what you didn't discover.
## Why one reused mechanism
- **No Group store.** The index is **not** open-write: it is an **owned document**
(public read) **+ native inbox** (a primitive present on every document). Nobody
writes the index but its owner (by materializing inbox deposits). So the model
- **No Group store.** The index is not open-write: it is an owned document
(public read) plus a native inbox (a primitive present on every document). Nobody
writes the index but its owner (by ingesting inbox deposits). So the model
stays "3 stores + Dialog + inboxes, no Group store."
- **One mechanism, reused.** The **inbox + materialization watcher** serve BOTH
submitting an entity to the index AND registering to a meeting-point — same
`inbox.post` API, same handling. This is exactly `inbox.ts` in this lib (`post`
/ `read` / `materialize` / `watch`).
- **Natural dedup / moderation point:** materialization (inbox → index) is where
- **One mechanism, reused.** The inbox + ingest watcher serve both
submitting an entity to the index and a registration/deposit in one consumer
app — same `inbox.post` API, same handling. This is exactly `inbox.ts` in this
lib (`post` / `read` / `materialize` / `watch`).
- **Natural dedup / moderation point:** the inbox → index ingest is where
duplicates are detected / moderated before insertion.
## Index owner — target model undecided
The "dedicated service with its own wallet sharing a freely-readable index" was
**incorrect**: NextGraph apps and services are **mono-user with no global data**
NextGraph apps and services are mono-user with no global data
(see [`../nextgraph-current-state.md`](../nextgraph-current-state.md) § Apps &
services). The only path glimpsed for a global document is a **singleton app**
bound to the developer-user — **not implemented, uncertain**, to explore later.
This is why a global-index curator is a **deferred separate package** in this lib
services), so a dedicated service with its own wallet sharing a freely-readable
index is not a NextGraph shape. The only path glimpsed for a global document is a
singleton app bound to the developer-user — not implemented, uncertain, to explore
later. This is why a global-index package is a deferred separate package in this lib
(see the top-level README).
## Polyfill reality — the fan-out drift is now RESOLVED (special-account index)
The shared-wallet polyfill originally shipped a **cross-account fan-out over
every account's public documents** (`store-registry.ts` `listEntityDocs('public')`
/ `resolveReadGraphs`) — one account saw another's public entity **without a
connection**. This ADR classified that per-account fan-out as a **drift** to be
replaced by the single global index.
The shared-wallet polyfill originally shipped a cross-account fan-out over
every account's public documents (`store-registry.ts` `listEntityDocs('public')`
/ `resolveReadGraphs`) — one account saw another's public entity without any
relationship to its creator. This ADR classified that per-account fan-out as a drift
to be replaced by the single global index.
**That drift is now resolved in the polyfill.** The inbox-fed global index of
this ADR is implemented on top of a **RESERVED SPECIAL ACCOUNT** in the shim
(`discovery.ts`, `INDEX_ACCOUNT = "@index"`) that owns the index document while
That drift is now resolved in the polyfill. The inbox-fed global index of
this ADR is implemented on top of a reserved special account in the shim
(`discovery.ts`, `INDEX_ACCOUNT = reservedAccount("index")` — a sentinel-prefixed
key in the shim's reserved namespace that `normalizeId` can never produce, so it is
disjoint from any normalized user id, not the literal `"@index"`) that owns the
index document while
the target owner stays undecided: `submitToIndex(ref)` deposits into the index
document's inbox; `readIndex()` materializes (dedup) the entries. The app-facing
discovery path is now **read the index**, exactly as this ADR prescribes — NOT
the fan-out. The cross-account fan-out survives only as an **internal lib
fallback** (it still powers per-scope listing like `resolveReadGraphs`), never
document's inbox; `readIndex()` ingests (dedups) the entries. The app-facing
discovery path is now "read the index", exactly as this ADR prescribes — not
the fan-out. The cross-account fan-out survives only as an internal lib
fallback (it still powers per-scope listing like `resolveReadGraphs`), never
the discovery route. The special account is the provisional owner; at migration
it disappears and ownership moves to the decided global-index owner (see
[`../migration-guide.md`](../migration-guide.md)) with the consumer surface
(`submitToIndex` / `readIndex`) unchanged.
[`../migration-guide.md`](../migration-guide.md)) with the consumer application's
surface (`submitToIndex` / `readIndex`) unchanged.
## Alternatives rejected (mechanism)
- **Open-write index** (creator writes the index directly): required a
collaborative document (Group store, SDK-blocked) and exposed the index to
corruption. Replaced by inbox deposit + owner materialization.
corruption. Replaced by inbox deposit + owner-side ingest.
- **Purely relational discovery** (`social_query`): rejected as *primary* (a
global list is wanted); kept as a secondary axis.
- **No index, direct reactive query**: impossible — SPARQL is local-only (stage 3).
+39 -33
View File
@@ -1,64 +1,70 @@
# ADR — Shared-wallet login/logout flow
# ADR — Shared-wallet identity flow (perceived login)
**Date:** 2026-06-15 · **Status:** Accepted (frozen). The rationale behind this
lib's **faux login** (`accounts.ts`) and why it must never touch NextGraph.
**Date:** 2026-06-15 · **Status:** Accepted (frozen). The rationale behind how
the consumer application presents identity selection as a perceived login, and why
the lib's identity store (`accounts.ts`) must never touch NextGraph. The lib itself
no longer frames this as a login: it receives an identity id, set at wallet-import
time; the perceived-login UX lives entirely in the consumer application.
## Starting constraint
NextGraph login **is not programmable**: it is a **web redirect** to the broker
NextGraph login is not programmable: it is a web redirect to the broker
page (`nextgraph.net`). The shared wallet cannot be opened silently — at least one
broker-redirect pass is required per device. The question is therefore not "how to
avoid the redirect" but "how to order and present it" so the UX stays coherent.
## Decision — technical gate first, application "Connexion" second
Two distinct auth layers, presented in this order:
Two distinct layers, presented in this order:
1. **Real layer (technical, NOT perceived as login).** The broker redirect appears
**immediately, before any app render**. Because it precedes the app, the user
reads it as a **technical access barrier to the test environment** (a beta
wall), **not** an application login. Same shared credentials for everyone
1. **Real layer (technical, not perceived as login).** The broker redirect appears
immediately, before any app render. Because it precedes the app, the user
reads it as a technical access barrier to the test environment (a beta
wall), not an application login. Same shared credentials for everyone
(given in the invitation, "access code" style). Once per device, then
persistent. **Never labelled "login."**
2. **Application layer (perceived as THE login).** A **"Connexion"** screen =
**username only** (→ `localStorage`, the current principal). This is the login
*in the user's perception*. **No password** → declarative connection (anyone
takes any username — coherent with zero-security / friends). **"Déconnexion"**
clears **only** the username and returns to "Connexion"; it **calls no NG
function**.
persistent. It is not labelled "login."
2. **Application layer (perceived as the login).** A "Connexion" screen where the
user picks an identity id (relayed to the lib's identity store, persisted in
`localStorage`, the current principal). This is the login *in the user's
perception*, presented by the consumer application. No password — declarative
selection (anyone takes any id — coherent with zero-security / friends). In
practice the id is often a human-friendly handle. "Déconnexion" clears only
the stored id and returns to "Connexion"; it calls no NG function.
The **real logout** (`ng.session_stop` / `user_disconnect` / `wallet_close`) stays
**hidden** (settings/debug), because it forces a new redirect.
The real logout (`ng.session_stop` / `user_disconnect` / `wallet_close`) stays
hidden (settings/debug), because it forces a new redirect.
## Why (vs the rejected option)
**Rejected**faux login first, then a warning page "enter this username/password",
then a *Continue* button triggering the redirect. Rejected: strange workflow,
dissonant double-login, a warning page that **looks like a scam**, and the
redirect **resurfacing mid-use** on every session expiry.
**Rejected**a perceived login first, then a warning page "enter this
id/password", then a *Continue* button triggering the redirect. Rejected: strange
workflow, dissonant double-login, a warning page that looks like a scam, and the
redirect resurfacing mid-use on every session expiry.
**Chosen** because: the mental model stays coherent (the technical barrier not
being perceived as login, the app-level Connexion/Déconnexion pair is complete and
self-consistent); graceful degradation (a re-gate after a browser restart reads as
"reconnecting to the environment", not a bug); and **similarity to the target
infra** — the "broker redirect → app" shape is exactly the real multi-wallet flow.
At migration you **remove the username "Connexion" screen** and the **technical
barrier becomes the real per-user login** — the flow shape does not change.
"reconnecting to the environment", not a bug); and similarity to the target
infra — the "broker redirect → app" shape is exactly the real multi-wallet flow.
At migration you remove the id "Connexion" screen and the technical
barrier becomes the real per-user login — the flow shape does not change.
## Verified technical facts (`nextgraph-rs`, 2026-06-15)
- **Session persistence: YES.** Wallet remembered iframe-side (`localStorage`
- **Session persistence: yes.** Wallet remembered iframe-side (`localStorage`
long-term + `sessionStorage` for the active session); on reload `init()`
recovers the session **without** re-triggering the redirect while the broker
recovers the session without re-triggering the redirect while the broker
session exists (`sdk/js/web/src/index.ts`, `sdk/js/api-web/main.ts`). A full
browser restart (losing `sessionStorage`) can re-trigger the gate.
- **Real logout exposed: YES.** `ng.session_stop()`, `ng.user_disconnect()`,
- **Real logout exposed: yes.** `ng.session_stop()`, `ng.user_disconnect()`,
`ng.wallet_close()` (`sdk/js/lib-wasm/src/lib.rs`); they stop the session /
clear the wallet and **force a new redirect** afterwards — hence: do NOT call
them in the app-level "Déconnexion," and hide the real logout.
clear the wallet and force a new redirect afterwards — hence the app-level
"Déconnexion" does not call them, and the real logout stays hidden.
## How this lib realizes it
`accounts.ts` `AccountStore.login()`/`logout()` only read/write the username in an
injected `AccountStorage`; they **never** call NG. See the faux login in
`accounts.ts` is an `IdentityStore`: `set(id)` / `clear()` / `get()` only read/write
the identity id in an injected `AccountStorage`; they never call NG. The id is set at
wallet-import time and relayed via the lib's current-identity call; the perceived
login is the consumer application's. See the identity store in
[`../simulation.md`](../simulation.md).
+20 -20
View File
@@ -1,15 +1,15 @@
# Fallback — forking NextGraph to expose the inbox (path NOT taken)
# Fallback — forking NextGraph to expose the inbox (path not taken)
**Status:** NOT taken — short-circuited by this lib's **emulated inbox**
**Status:** not taken — short-circuited by this lib's emulated inbox
(`inbox.ts`, see [`simulation.md`](./simulation.md)). Kept as the fallback plan if
a **native** broker inbox ever becomes necessary — chiefly for the **crypto
anonymity** the emulation does not provide (native `from = None` sealed deposit).
a native broker inbox ever becomes necessary — chiefly for the crypto
anonymity the emulation does not provide (native `from = None` sealed deposit).
Current NextGraph does not expose the inbox to the JS SDK: the verifier has no
`InboxPost` arm and no wasm helper seals a deposit (see
[`nextgraph-current-state.md`](./nextgraph-current-state.md) § Inbox). Two ways to
get a real inbox: **emulate it** (what this lib does) or **fork the engine** (this
document). The emulation won; this is the archived alternative.
get a real inbox: emulate it (what this lib does) or fork the engine (this
document). The emulation was chosen; this is the archived alternative.
## Strategic posture
@@ -33,13 +33,13 @@ a full stack for a feature the upstream will likely ship differently.
session_id, to_inbox_nuri, to_profile_nuri, link, anonymous)`, modeled on
`social_query_start`.
4. **`engine/verifier/src/inbox_processor.rs`** (`process_inbox`) — a receive arm
that **materializes** the message into a document in the owner's store (model on
the `ContactDetails` handler). The app then reads via ORM/SPARQL — no new
inbox-read API.
where the owner's own verifier unseals the queued message and applies it inline
into the owner's store (model on the `ContactDetails` handler). The app then
reads via ORM/SPARQL — no new inbox-read API.
**Identity resolution** (known/anonymous): free via app-side SPARQL (JOIN the
sender inbox NURI against `social:contact` docs). **Discovering the owner's
inbox**: embed the owner's `public_store` inbox NURI in the entity document or
Identity resolution (known/anonymous): free via app-side SPARQL (JOIN the
sender inbox NURI against `social:contact` docs). Discovering the owner's
inbox: embed the owner's `public_store` inbox NURI in the entity document or
public profile (the QR profile-share flow already carries it).
## Layer 2 — deployment (from the fork)
@@ -76,18 +76,18 @@ Maintain patched client packages, not just the wasm. The generic forwarding
sides. For write-only (request/response) — unneeded.
- **`@ng-org/orm`** — only if inbox writes join the ORM flow. Otherwise unneeded.
## Layer 3 — consumer integration
## Layer 3 — consumer application integration
Exposing the method is not enough; the consumer must model the entity + its inbox
NURI, write the registration, deposit into the host inbox, and read/resolve
notifications. Several of these are **already done** in the shared-wallet emulation
Exposing the method is not enough; the consumer application must model the entity +
its inbox NURI, write the registration, deposit into the host inbox, and read/resolve
notifications. Several of these are already done in the shared-wallet emulation
(registration wired on the emulated `inbox.post`), which is precisely why this fork
was not needed.
## Why this fallback still matters
The emulated inbox stores `from = null` as *absence of a triple*; it does not seal
deposits, so it does not provide the target's **crypto** anonymity. If a consumer
needs true anonymous-but-verifiable deposits to a non-connected host, only a native
inbox (`from = None` sealed) delivers it — and this fork is the route. Until then,
the emulation is sufficient.
deposits, so it does not provide the target's crypto anonymity. If a consumer
application needs true anonymous-but-verifiable deposits to a non-connected host,
only a native inbox (`from = None` sealed) delivers it — and this fork is the route.
Until then, the emulation is sufficient.
+37 -34
View File
@@ -1,9 +1,8 @@
# Migration guide — when real NextGraph matures
The whole point of this library: the consumer already writes SDK-shaped code, so
when NextGraph ships cross-wallet reads, capabilities and inboxes, **only this lib
changes**. The consumer's application code does **not** change. This is the
checklist.
The whole point of this library: the consumer application already writes SDK-shaped
code, so when NextGraph ships cross-wallet reads, capabilities and inboxes, only this
lib changes. The consumer application's code does not change. This is the checklist.
## Guiding invariant
@@ -17,18 +16,19 @@ has no clear target image, that is a drift signal (see
### 1. Emulated ReadCaps → real capabilities
Translate the per-document `CapRegistry` (`caps.ts`) into real NextGraph caps: the
broker/verifier enforces them, and `useShape` already returns only authorized
documents. The read filter (`read-filter.ts`) and the write guard (`ng-proxy.ts`
`sparql_update` override) are then **dead code** — remove them. The access unit is
already the document (`@graph`), matching the native per-repo cap model, so this is
a data step, not a reshape.
documents. The directed `grantRead(doc, granteeId)` maps to a native per-document
ReadCap issued to that identity. The read filter (`read-filter.ts`) and the write
guard (`ng-proxy.ts` `sparql_update` override) are then dead code — remove them. The
access unit is already the document (`@graph`), matching the native per-repo cap
model, so this is a data step, not a reshape.
### 2. Place documents in real native stores
Today `docCreate(..., undefined)` writes every document into the shared wallet's
**private** store, and the `public|protected|private` scope is a **logical label**
private store, and the `public|protected|private` scope is a logical label
in the shim (see the two-axes section in [`simulation.md`](./simulation.md)).
- **`doc_create` cannot target a non-private native store today** — verified:
`StoreRepo` is **not JS-constructible** from the SDK, so there is no way to pass
- `doc_create` cannot target a non-private native store today — verified:
`StoreRepo` is not JS-constructible from the SDK, so there is no way to pass
a public/protected store as the create destination (`docCreate`'s trailing
`store` arg is left `undefined` → private store). The private store works only
because it opens without `RepoNotFound`.
@@ -36,48 +36,51 @@ in the shim (see the two-axes section in [`simulation.md`](./simulation.md)).
`getNativeStore(scope)`-style resolver returning the real store to pass as the
`docCreate` destination, so the logical scope label becomes a real store
placement. (No such helper exists yet — it is blocked on the SDK gap above.)
- At that point `store-registry.ts` maps `(account, scope)` to the user's **real
store NURI** instead of a document in the shared wallet; the per-scope index
- At that point `store-registry.ts` maps `(account, scope)` to the user's real
store NURI instead of a document in the shared wallet; the per-scope index
document (the store-container emulation) is replaced by the store itself. The
consumer-facing surface (`createEntityDoc`, `listEntityDocs`, resolvers) is
designed to survive that swap unchanged.
surface facing the consumer application (`createEntityDoc`, `listEntityDocs`,
resolvers) is designed to survive that swap unchanged.
### 3. Drop the resolver / shim
The `sharedWalletShim` (account → 3 scope-document NURIs, RDF in the private store)
has **no target equivalent** — the target has no central directory. Remove it:
has no target equivalent — the target has no central directory. Remove it:
`store-registry.ts`, `configureStoreRegistry`, the shim SPARQL. Cross-wallet reads
replace the fan-out; per-user wallets replace the shared one.
### 4. Real inbox → drop the emulated curator
### 4. Real inbox → drop the in-lib read emulation
Replace the emulated `inbox.ts` deposit (`docs.sparqlUpdate` into a shared-wallet
document) with the native `inbox_post_link`, and move `read`/`materialize`/`watch`
to a **separate curator package** (the deferred global-index curator — see the
top-level README and [`decisions/discovery-model.md`](./decisions/discovery-model.md)).
The in-client read side goes away. The single global index replaces the
cross-account fan-out.
document) with the native `inbox_post_link` (proposed/future). On the read side the
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
emulation simply goes away (see the deferred global-index note in the top-level
README and [`decisions/discovery-model.md`](./decisions/discovery-model.md)). The
single global index replaces the cross-account fan-out.
### 5. Faux login → real per-user login
Remove `accounts.ts` (the username `localStorage` faux login) and the app-level
"Connexion" screen. The technical broker gate **becomes** the real per-user login
### 5. Retire the identity store → real per-user login
Remove `accounts.ts` (the `IdentityStore` that persists the identity id in
`localStorage`) and the app-level "Connexion" screen. The technical broker gate
becomes the real per-user login
(see [`decisions/shared-wallet-login-flow.md`](./decisions/shared-wallet-login-flow.md)).
The flow shape ("broker redirect → app") does not change.
### 6. Drop the isolation scaffold
`isolation.ts` (application social-visibility filter) disappears against a
different piece of infra than the caps: real per-account wallets + a real
per-account social graph. Distinct axis from ReadCaps — remove independently.
`isolation.ts` (application-visibility scaffold) disappears against a
different piece of infra than the caps: real per-account wallets, and the
relationship concept the consumer application owns. Distinct axis from ReadCaps —
remove independently.
### 7. Remove the build alias — the client becomes the real SDK
The consumer imports `@ng-org/web` / `@ng-org/orm` resolved to this lib via a
**build alias** during the polyfill period. Removing the alias makes those imports
The consumer application imports `@ng-org/web` / `@ng-org/orm` resolved to this lib
via a build alias during the polyfill period. Removing the alias makes those imports
resolve to the real SDK — the `ng`/`useShape`/`inbox` surface is SDK-identical, so
**no consumer code changes**. The one non-SDK call — `configure(...)` /
no consumer code changes. The one non-SDK call — `configure(...)` /
`@ng-eventually/client/polyfill` — is deleted. The lib itself disappears.
## What does NOT change
## What does not change
**The consumer's application code.** Shapes, screens, the *acts* of granting
access, entity→scope mapping, the connection graph — all injected, all untouched.
The consumer application's code. Shapes, screens, the *acts* of granting
access, entity→scope mapping, the relationship graph — all injected, all untouched.
Migration is entirely inside this library plus removing the alias + the bootstrap
call. That asymmetry — a mature SDK face outward, all compensation inward — is the
library's reason to exist.
+60 -51
View File
@@ -73,14 +73,18 @@ grant the repos it contains — **you need each repo's own ReadCap**. The option
users/quorum (write/permissions), **not** read-cap possession. (Repos of a
`private_store` inherit implicitly.)
> **Consequence for this lib's emulation (see [`simulation.md`](./simulation.md)):**
> the read access UNIT is the **repo = each item's `@graph`** — a per-DOCUMENT
> Consequence for this lib's emulation (see [`simulation.md`](./simulation.md)):
> the read access unit is the repo = each item's `@graph` — a per-document
> filter, never per-store and never per-item. This is exactly what
> `caps.ts` (`CapRegistry`) and `read-filter.ts` model: no store-level
> inheritance, purely per-document caps. In a mono-store layout (all items in one
> repo) the filter is therefore all-or-nothing on that document — which *is* the
> native behaviour, and why fine-grained isolation requires one document per
> entity.
> entity. Read isolation is cryptographic in the target: with no cap for a repo, a
> union / reactive read returns empty (the repo is never decrypted), while a
> targeted read of an unheld repo returns `RepoNotFound`. There is no
> cap-introspection API — the polyfill's `canRead` / `governsRead` are
> emulation-only, with no NextGraph API behind them.
### Store ↔ document confusion (recurring)
@@ -98,36 +102,40 @@ offline"*; *"removing permissions … requires a SyncSignature"* (synchronous).
## Inbox
**Every document has a native inbox.** A non-editor can **deposit a link (DID
cap)** into it without being invited as an editor; the owner **moderates**. NURI:
Every document has a native inbox. A non-editor can deposit a link (DID
cap) into it without being invited as an editor; the owner moderates. NURI:
`did:ng:d:<inbox_id>`. Content: the `InboxMsgContent` enum (`ContactDetails`,
`DialogRequest`, **`Link`**, `Patch`, `ServiceRequest`, `ExtRequest`,
`RemoteQuery`, `SocialQuery`…). Messages are **sealed** (`crypto_box::seal`) to
the inbox pubkey only the owner decrypts. The `from` field is **optional** an
**anonymous** sender is possible. This is the "identified if known, anonymous
`DialogRequest`, `Link`, `Patch`, `ServiceRequest`, `ExtRequest`,
`RemoteQuery`, `SocialQuery`…). Messages are sealed (`crypto_box::seal`) to
the inbox pubkey, so only the owner decrypts. The `from` field is optional, so an
anonymous sender is possible. This is the "identified if known, anonymous
otherwise" behaviour native to the protocol.
### The inbox is NOT usable from the JS SDK
The recipient's own verifier unseals each queued message and applies it inline when
it processes its inbox — there is no separate curator or materialization service.
### The inbox is not usable from the JS SDK
- `app_request(request)` is exposed, and `AppRequestCommandV0::InboxPost` +
`AppRequest::inbox_post()` exist. **BUT** the verifier's `request_processor`
has **no `InboxPost` arm** (arms actually handled: `OrmStart(Discrete)`,
`AppRequest::inbox_post()` exist, but the verifier's `request_processor`
has no `InboxPost` arm (arms actually handled: `OrmStart(Discrete)`,
`Fetch`, `FileGet`, `OrmUpdate`, `OrmDiscreteUpdate`, `SocialQueryStart`,
`QrCodeProfile(Import)`, `Header`, `Create`, `FilePut`). Sending an `InboxPost`
triggers nothing.
- Building an `InboxPost` requires crypto sealing on the Rust side; **no wasm
helper** exposes it.
- Inbox deposit is only triggered **internally** by `QrCodeProfileImport`
- 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
yet present.
- Inbox deposit is only triggered internally by `QrCodeProfileImport`
(`post_to_inbox(new_contact_details)`) and `social_query_start` (contact
propagation via inbox).
**Consequence:** there is no clean way to "drop a Link" into an arbitrary
document's inbox from the JS SDK today. This lib emulates the inbox instead of
patching the broker — see [`simulation.md`](./simulation.md) (emulated inbox +
curator) and [`fork-inbox-fallback.md`](./fork-inbox-fallback.md) (the Rust-patch
path NOT taken). A related exposed primitive: `social_query_start` (a federated
query via inbox up to `degree` hops) exists but is limited to **contacts** — it
does not cover an anonymous notification to a non-connected host.
patching the broker — see [`simulation.md`](./simulation.md) (emulated inbox) and
[`fork-inbox-fallback.md`](./fork-inbox-fallback.md) (the Rust-patch path not taken).
A related exposed primitive: `social_query_start` (a federated query via inbox up to
`degree` hops) exists but is limited to contacts — it does not cover an anonymous
notification to a non-connected host.
## The query capability — ONE local store, named graphs, union queries
@@ -190,36 +198,36 @@ from JS today a repo becomes queryable ONLY by being `doc_create`d in this sessi
(own docs) or synced by an internal path — never on demand by NURI+ReadCap.
**Consequence for this lib's mono-wallet polyfill:** every account's documents are
`doc_create`d in the ONE shared wallet within the SAME session, so they are ALL
already in `self.repos`. `read-model.ts` reads the **bounded, by-need** set of docs
with ONE **anchored** `sparql_query` per doc (`SELECT ?s ?p ?o WHERE { ?s ?p ?o }`,
`doc_create`d in the one shared wallet within the same session, so they are all
already in `self.repos`. `read-model.ts` reads the bounded, by-need set of docs
with one anchored `sparql_query` per doc (`SELECT ?s ?p ?o WHERE { ?s ?p ?o }`,
anchor = the doc NURI): the anchor resolves that same-session repo directly (no
separate open needed) and restricts the query to its graph O(1) per doc,
separate open needed) and restricts the query to its graph, so it is O(1) per doc,
independent of the store's size. An absent repo throws `RepoNotFound` on its own
read and is skipped, never aborting the batch.
**Do NOT anchorless-union-scan on the read path.** An anchorless
`SELECT … WHERE { GRAPH ?g { ?s ?p ?o } }` spans EVERY named graph in the store —
O(wallet size). On a **shared / bloated** wallet that accumulates docs across runs
that was O(wallet) and timed out (~90s observed on `readUnion` / probe reads). The
per-doc anchored read makes a non-empty wallet irrelevant. At the real multi-store
The read path avoids an anchorless union-scan. An anchorless
`SELECT … WHERE { GRAPH ?g { ?s ?p ?o } }` spans every named graph in the store —
O(wallet size). On a shared wallet that accumulates docs across runs that cost grows
with the whole wallet, which is why the read path is per-doc anchored: the anchored
read makes a non-empty wallet irrelevant. At the real multi-store
migration this is unchanged (the anchored read is native); only bringing a repo into
the session changes: opening a real per-user store repo by cap becomes a native
broker sync (the `OpenRepo` TODO at `verifier.rs:1423`). Opening still requires the
repo's **NURI + ReadCap** — there is **no store-level read inheritance** (see
repo's NURI + ReadCap — there is no store-level read inheritance (see
§ Capability / ReadCap granularity).
### The union is READ-ONLY — writes must target one document
### The union is read-only — writes must target one document
`resolve_target_for_sparql(update=true)` returns `InvalidTarget` for `UserSite` /
`None` (`request_processor.rs:275-282`). So `sparql_update` cannot write "to the
union": every write must name **one** document's `@graph` — exactly what the
union": every write must name one document's `@graph` — exactly what the
polyfill's `docs.sparqlUpdate` already does.
### No reactive SPARQL — `sparql_query` is one-shot
`sparql_query` is non-streamed: it computes a `QueryResults` and returns once
(`lib-wasm/src/lib.rs:352-405` / `553-610`). There is **no** "subscribe to a union
(`lib-wasm/src/lib.rs:352-405` / `553-610`). There is no "subscribe to a union
query". The only reactive primitives are the streamed ones: `orm_start_graph`,
`orm_start_discrete`, `doc_subscribe`, `app_request_stream`.
@@ -228,18 +236,18 @@ query". The only reactive primitives are the streamed ones: `orm_start_graph`,
The reactive ORM is structurally unfit for a fan-out of per-entity / not-yet-synced
graphs, and this is *why* subscribing such a fan-out hangs:
- `OrmStartGraph` first loops over EVERY graph in the requested scope and calls
- `OrmStartGraph` first loops over every graph in the requested scope and calls
`open_for_target(&nuri.target, /*publisher*/ true)` on each
(`request_processor.rs:53-66`), and `orm/graph/initialize.rs` does the same
fan-out again for the graphs the ORM discovers (~`125-128`).
- `open_for_target``resolve_target``self.repos.get(repo_id).ok_or(RepoNotFound)`
(`request_processor.rs:286-294` calling `resolve_target` at `:147`, the
`RepoNotFound` at `:155/:163`).
- A **freshly-created per-entity doc**, or any **not-yet-synced other-account doc**,
is absent from `self.repos` `RepoNotFound` propagates through the `?` and
**aborts the whole `orm_start_graph`**. The subscription never emits its initial
the ORM `readyPromise` never resolves the multi-second hang observed (≈75s)
when subscribing a fan-out of per-entity graphs.
- A freshly-created per-entity doc, or any not-yet-synced other-account doc,
is absent from `self.repos`, so `RepoNotFound` propagates through the `?` and
aborts the whole `orm_start_graph`. The subscription never emits its initial, so
the ORM `readyPromise` never resolves and the subscription hangs when a fan-out of
per-entity graphs is passed in.
**Consequence:** passing per-entity / unsynced graphs to the reactive ORM is broken.
Listing must go through a one-shot union `sparql_query` instead — see
@@ -347,12 +355,12 @@ the idea of "a service with its own wallet sharing global data".
that user.
**Consequence for a "global document" (e.g. a discovery index):** the only path
glimpsed is a **singleton app** whose global document is administered by the
developer-user — **but this is not implemented and not guaranteed** (simpler
paths may exist; to explore later). The **incorrect** model to avoid: "a
dedicated service with its own wallet sharing a freely-readable index" — that
does not exist in NextGraph (a service is mono-user, no global data). This is why
a global-index curator package is **deferred** in this lib (see the top-level
glimpsed is a singleton app whose global document is administered by the
developer-user — though this is not implemented and not guaranteed (simpler
paths may exist; to explore later). The model that does exist is this
singleton-app one; a dedicated service with its own wallet sharing a
freely-readable index is not a NextGraph shape (a service is mono-user, no global
data). This is why a global-index package is deferred in this lib (see the top-level
README).
## Third-party wallet auto-import constraint
@@ -382,14 +390,15 @@ real way to eliminate the cross-origin round-trip is to self-host/fork the ng-ap
## Login is not programmable
NextGraph login is a **web redirect** to the broker page (`nextgraph.net`). There
NextGraph login is a web redirect to the broker page (`nextgraph.net`). There
is no way to open a wallet silently — at least one broker-redirect pass per device
is required. Session persistence: the wallet is remembered iframe-side
(`localStorage` long-term + `sessionStorage` for the active session); on reload,
`init()` recovers the session **without re-triggering the redirect** while the
`init()` recovers the session without re-triggering the redirect while the
broker session exists (`sdk/js/web/src/index.ts`, `sdk/js/api-web/main.ts`). A
full browser restart (losing `sessionStorage`) can re-trigger the gate. A real
logout IS exposed (`ng.session_stop()`, `ng.user_disconnect()`,
`ng.wallet_close()` in `sdk/js/lib-wasm/src/lib.rs`) but **forces a new
redirect** afterwards. This lib's faux login sidesteps all of it — see the faux
login in [`simulation.md`](./simulation.md).
logout is exposed (`ng.session_stop()`, `ng.user_disconnect()`,
`ng.wallet_close()` in `sdk/js/lib-wasm/src/lib.rs`) but forces a new
redirect afterwards. This lib's identity store sidesteps all of it — the identity
id is set at wallet-import time and relayed to the lib, without a separate login;
see the identity store in [`simulation.md`](./simulation.md).
+95 -81
View File
@@ -1,40 +1,40 @@
# The READ MODEL the polyfill implements
# The read model the polyfill implements
How the polyfill turns "give me my lists" into concrete NextGraph reads on the
shared wallet. This is a **design decision**, grounded entirely in the query
shared wallet. This is a design decision, grounded entirely in the query
capability documented in
[`nextgraph-current-state.md`](./nextgraph-current-state.md) § *The query
capability*. The consumer (Festipod) never sees any of this: it asks
`@ng-eventually/client` for its lists **by need** and trusts the answer — the whole
capability*. The consumer application never sees any of this: it asks
`@ng-eventually/client` for its lists by need and trusts the answer — the whole
read mechanism lives here, in the polyfill.
> **The rule in one line:** read each by-need doc with its OWN anchored
> `sparql_query`; NEVER run an anchorless union-scan over all graphs. An anchorless
> union spans **every** named graph in the session store — O(wallet size) — and on a
> shared/bloated wallet that accumulates across runs it produced ~90s timeouts. The
> per-doc anchored read is O(1) per doc, INDEPENDENT of wallet size, so a non-empty
> wallet no longer matters.
> The rule in one line: read each by-need doc with its own anchored
> `sparql_query`; never run an anchorless union-scan over all graphs. An anchorless
> union spans every named graph in the session store — O(wallet size) — which is why
> the read path is per-doc anchored on a shared wallet that accumulates across runs.
> The per-doc anchored read is O(1) per doc, independent of wallet size, so a
> non-empty wallet does not matter.
The governing constraints (all verified in `nextgraph-rs`, cited there):
- One local oxigraph store per session; every opened repo is a **named graph**.
- `sparql_query` with **no anchor** → the **LOCAL UNION** of all opened graphs
(O(wallet), NOT used on the read path); with a string anchor → restricted to
**one** repo (that repo becomes the query's DEFAULT graph). Union is **read-only**.
- The anchor's one-repo restriction applies only to a **default-graph** body (no
- One local oxigraph store per session; every opened repo is a named graph.
- `sparql_query` with no anchor → the local union of all opened graphs
(O(wallet), not used on the read path); with a string anchor → restricted to
one repo (that repo becomes the query's default graph). Union is read-only.
- The anchor's one-repo restriction applies only to a default-graph body (no
`GRAPH` wrapper); an explicit `GRAPH ?g { … }` body iterates the named graphs
regardless of the anchor (see § probe step 4). The read path therefore uses an
anchored `SELECT ?s ?p ?o WHERE { ?s ?p ?o }` (default-graph body) per doc.
- A repo is queryable **only after it is opened/synced** (needs its NURI + ReadCap;
no store-level read inheritance). **VERIFIED (T03.k):** the current JS SDK exposes
**no primitive that syncs an *unknown* repo**`sparql_query`/`doc_subscribe`/
- A repo is queryable only after it is opened/synced (needs its NURI + ReadCap;
no store-level read inheritance). Verified (T03.k): the current JS SDK exposes
no primitive that syncs an *unknown* repo`sparql_query`/`doc_subscribe`/
`orm_start_graph` all resolve via `self.repos.get().ok_or(RepoNotFound)` and only
touch a repo already present; the real loader `load_repo_from_read_cap` is
`pub(crate)`, unexposed. In THIS mono-wallet polyfill that is fine: every account's
docs are `doc_create`d in the SAME session, so they are all already in `self.repos`
and the anchorless union spans them with no per-doc open needed. The open step
becomes a real broker sync only at the multi-store migration.
- **No reactive union query**, and the reactive ORM **hangs** if handed a per-entity
`pub(crate)`, unexposed. In this mono-wallet polyfill that is fine: every account's
docs are `doc_create`d in the same session, so they are all already in `self.repos`
and the per-doc anchored read resolves each one directly with no per-doc open
needed. The open step becomes a real broker sync only at the multi-store migration.
- No reactive union query, and the reactive ORM hangs if handed a per-entity
/ unsynced graph fan-out (`RepoNotFound` aborts `orm_start_graph`).
## Two read regimes — enumerate vs follow
@@ -42,71 +42,80 @@ The governing constraints (all verified in `nextgraph-rs`, cited there):
There is **no cross-wallet read** in current NextGraph, so nothing is globally
enumerable "for free". The polyfill splits every list into one of two regimes:
### Events (all public) = the GLOBAL INDEX — the ONE enumeration hack
### Events (all public) = the global index — the one enumeration hack
Public events are the only thing enumerated across accounts, via the emulated
discovery index (`discovery.readIndex`, see
[`simulation.md`](./simulation.md) § *Emulated discovery index*). This is the ONE
[`simulation.md`](./simulation.md) § *Emulated discovery index*). This is the one
"hack", and it is justified precisely because P2P has no cross-wallet read: without
a shared index a client could never learn that another account's public event-doc
**exists**. `readIndex` yields the event-doc **NURIs** to open/sync; those repos
exists. `readIndex` yields the event-doc NURIs to open/sync; those repos
then enter the local union and become union-queryable.
### Everything else = FOLLOW a graph, never enumerate across accounts
### Everything else = follow a graph, never enumerate across accounts
My participations / my profile, a connection's shared protected data, my
notifications — **none** of these is enumerated across accounts. Each is reached by
**what is already reachable to me**:
My participations / my profile, protected data an owner has granted me, my
notifications — none of these is enumerated across accounts. Each is reached by
what is already reachable to me:
- **my own docs** (always in `self.repos`);
- docs reachable via a **connection's shared cap** (a bilateral connection surfaces
the peer's protected NURIs — see the bilateral connection registry in
- my own docs (always in `self.repos`);
- docs an owner has granted me via a directed per-document read grant
(`grantRead(doc, granteeId)` — see the per-document ReadCap in
[`simulation.md`](./simulation.md));
- my **inbox** (deposits addressed to me).
- my inbox (deposits addressed to me).
The rule of thumb: **Access discovery.** You only union-query over graphs you were
already entitled to open.
The rule of thumb: access is not discovery. You only union-query over graphs you
were already entitled to open.
## Listing = a bounded set of per-doc ANCHORED reads (never a union-scan, never the ORM fan-out)
Accessing a document without read rights yields an empty result: a reactive / union
read never decrypts a repo you hold no cap for, so it simply returns nothing (this
matches NextGraph's union read). A targeted read of a repo you do not hold diverges
in one way — it raises `RepoNotFound` rather than returning empty — and the read
path tolerates that per-doc (a doc that throws is skipped). The cap-introspection
used here (`canRead` / `governsRead`) is emulation-only; there is no NextGraph API
behind it, so it has no migration target.
To produce a list, take the **bounded, by-need** set of doc NURIs (the index-yielded
event NURIs, my own docs, a connection's shared NURIs) and read **each one with its
OWN anchored `sparql_query`** (`SELECT ?s ?p ?o WHERE { ?s ?p ?o }`, anchor = that
## Listing = a bounded set of per-doc anchored reads (never a union-scan, never the ORM fan-out)
To produce a list, take the bounded, by-need set of doc NURIs (the index-yielded
event NURIs, my own docs, the NURIs an owner has granted me) and read each one with its
own anchored `sparql_query` (`SELECT ?s ?p ?o WHERE { ?s ?p ?o }`, anchor = that
doc NURI, in parallel and tolerant per-doc). The anchor restricts the query to that
one repo's graph, so each read is O(1) in the doc's own size and INDEPENDENT of how
one repo's graph, so each read is O(1) in the doc's own size and independent of how
many other graphs the (possibly bloated / shared) session store holds.
Do **NOT** run an **anchorless union-scan** (`SELECT … WHERE { GRAPH ?g { ?s ?p ?o } }`,
no anchor) over the local union: it iterates **every** named graph in the session
store — O(wallet size) — so on a shared wallet that accumulates across runs it times
out (~90s observed). The read-set is already bounded and known; read exactly those
docs, anchored, and never scan the wallet.
Do not run an anchorless union-scan (`SELECT … WHERE { GRAPH ?g { ?s ?p ?o } }`,
no anchor) over the local union: it iterates every named graph in the session
store — O(wallet size) — so on a shared wallet that accumulates across runs its cost
grows with the whole wallet. The read-set is already bounded and known; read exactly
those docs, anchored, and never scan the wallet.
Do **NOT** drive listing through the reactive ORM's per-document fan-out
Do not drive listing through the reactive ORM's per-document fan-out
(`orm_start_graph` over many graphs): a freshly-created or not-yet-synced graph in
the fan-out makes `RepoNotFound` abort the whole subscription the readyPromise
never resolves the ~75s hang (root cause verified in
the fan-out makes `RepoNotFound` abort the whole subscription, so the readyPromise
never resolves and the subscription hangs (root cause verified in
[`nextgraph-current-state.md`](./nextgraph-current-state.md) § *The ORM fan-out
hang*).
## Reactivity = re-query on a change signal (no reactive union)
There is **no reactive union query**. So reactivity is assembled:
There is no reactive union query. So reactivity is assembled:
- keep a lightweight reactive subscription — `doc_subscribe`, or the ORM on an
**already-opened single store** (never a per-entity fan-out) — on the synced docs;
- on its change signal, **re-run** the bounded set of per-doc anchored
already-opened single store (never a per-entity fan-out) — on the synced docs;
- on its change signal, re-run the bounded set of per-doc anchored
`sparql_query`s (`readModel.readUnion`) — never an anchorless union-scan.
Keep the reactive ORM strictly to already-opened single stores; it is a change
*signal* source here, not the list source.
## The boundary with the consumer
## The boundary with the consumer application
Festipod asks the SDK for its lists by need (`listMyMeetingPoints()`,
`listEvents()`, …) and trusts the returned set. It never constructs a NURI, never
picks the union-vs-anchor mode, never touches the ORM. Open/sync + union-query +
re-query-on-signal all live in the polyfill.
The consumer application asks the SDK for its lists by need and trusts the returned
set. It never constructs a NURI, never picks the union-vs-anchor mode, never touches
the ORM. The domain-shaped list helpers (e.g. "my meeting points", "events") live in
the consumer application, not the lib; the lib exposes the generic by-need read.
Open/sync + union-query + re-query-on-signal all live in the polyfill.
## Minimal broker probe (confirms the union behaviour)
@@ -127,17 +136,19 @@ The one experiment that pins down union vs anchor, to run against a real broker:
// → rows from BOTH A's and B's graphs
```
4. **Anchor = A** — expect only A:
4. **Anchor = A, default-graph body** (the form the read path actually uses) —
expect only A:
```
sparql_query(sid, "SELECT ?g ?s ?p ?o WHERE { GRAPH ?g { ?s ?p ?o } }",
undefined, A /* string NURI → one repo */)
sparql_query(sid, "SELECT ?s ?p ?o WHERE { ?s ?p ?o }",
undefined, A /* string NURI → one repo becomes the default graph */)
// → rows from A's graph only
```
If (3) returns both and (4) returns only A, the union read model above holds as
If (3) returns both and (4) returns only A, the read model above holds as
implemented in `resolve_target_for_sparql` /
`set_default_graph_as_union`.
`set_default_graph_as_union`: the anchor turns A's repo into the query's default
graph, and a default-graph body reads exactly that graph.
### Verified against the real broker (T03.k)
@@ -146,33 +157,36 @@ Step (3) — **the load-bearing one** — is CONFIRMED: an anchorless
(the local union of the opened graphs). That is the entire premise the listing
path relies on.
Step (4) has a nuance worth recording: with an **explicit `GRAPH ?g { … }` body**,
passing `anchor = A` did **not** restrict the result to A (B still appeared). The
reason: the anchor sets the query's **default graph**, but a `GRAPH ?g` pattern
iterates over the **named graphs** regardless of the default graph — so an
explicit `GRAPH ?g` body spans every opened graph independently of the anchor.
The anchor's "one repo" restriction is observable only for a body that reads the
**default graph** (no `GRAPH` wrapper). The read model never needs the anchored
form for listing — it uses the anchorless `GRAPH ?g` union — so this does not
affect it. (The per-doc "open" step in `read-model.ts` uses an anchored `ASK`
only to CONFIRM presence — it cannot sync an unknown repo, see the VERIFIED note
above; a repo absent from `self.repos` throws `RepoNotFound` and is skipped.)
Step (4) has a nuance worth recording, and it is exactly why the read path uses a
**default-graph body**, not an explicit `GRAPH ?g` one: with an explicit
`GRAPH ?g { … }` body, passing `anchor = A` would **not** restrict the result to A
(B still appears). The reason: the anchor sets the query's **default graph**, but a
`GRAPH ?g` pattern iterates over the **named graphs** regardless of the default
graph — so an explicit `GRAPH ?g` body spans every opened graph independently of
the anchor. The anchor's "one repo" restriction is observable only for a body that
reads the **default graph** (no `GRAPH` wrapper). That is precisely why the per-doc
read in `read-model.ts` uses the anchored default-graph body
`SELECT ?s ?p ?o WHERE { ?s ?p ?o }`: the anchor makes that one repo the default
graph, so the read is bounded to it — O(1) per doc, independent of wallet size —
and never iterates the other named graphs. (A repo absent from `self.repos` throws
`RepoNotFound` and is skipped per-doc, see the VERIFIED note above — the read cannot
sync an unknown repo.)
## Implementation — `read-model.ts`
`readModel.readUnion(docs)` implements this: for each requested doc NURI (the
bounded by-need set), run — in parallel, tolerant per-doc (a doc that fails is
skipped, never aborting the batch like the ORM fan-out would) — ONE **anchored**
skipped, never aborting the batch like the ORM fan-out would) — one anchored
`SELECT ?s ?p ?o WHERE { ?s ?p ?o }` with `anchor = docNuri`. The anchor restricts
the query to that doc's graph (default graph), so it returns ONLY that doc's
triples, O(1) per doc, independent of wallet size. There is **NO** anchorless
union-scan. Each entity's subject IRI IS its own document NURI, so the subject is
the query to that doc's graph (default graph), so it returns only that doc's
triples, O(1) per doc, independent of wallet size. There is no anchorless
union-scan. Each entity's subject IRI is its own document NURI, so the subject is
the anchor doc NURI; the result is grouped per subject (keeping the `UnionSubject[]`
shape: `subject`, `graph`, `props`). A ReadCap gate drops any doc the current user
may not read (defence-in-depth). The consumer maps the result to its types (e.g.
Festipod's `readEntities`). Reactivity = the consumer re-calls `readUnion` on its
change signal (no reactive union query exists).
may not read (defence-in-depth). The consumer application maps the result to its
types (e.g. its own `readEntities`). Reactivity = the consumer application re-calls
`readUnion` on its change signal (no reactive union query exists).
> The name `readUnion` / `UnionSubject` is historical (it once ran a union query).
> The read is now **per-doc anchored**, bounded to the read-set — the "union" is only
> The read is now per-doc anchored, bounded to the read-set — the "union" is only
> the logical concatenation of the per-doc results, never an anchorless graph scan.
+242 -229
View File
@@ -1,61 +1,61 @@
# How this library emulates mature NextGraph on ONE shared wallet
# How this library emulates mature NextGraph on one shared wallet
> **EVERYTHING in this file is EMULATION.** Not one behaviour described here is a
> real NextGraph feature: each is a **stopgap** the lib fabricates on top of the
> *current, immature* NextGraph (the exact gaps it compensates for are in
> Everything in this file is emulation. None of the behaviours described here is a
> real NextGraph feature: each is a stopgap the lib fabricates on top of the
> current, immature NextGraph (the exact gaps it compensates for are in
> [`nextgraph-current-state.md`](./nextgraph-current-state.md)). Every piece has a
> **real target** and goes away when NextGraph matures — the swap is **lib-only**,
> the consumer's code is unchanged. The per-behaviour recap table lives in the
> real target and goes away when NextGraph matures — the swap is lib-only, and the
> consumer application's code is unchanged. The per-behaviour recap table lives in the
> top-level [`README.md`](../README.md) (*What is emulated (and how it goes away)*);
> the removal checklist is [`migration-guide.md`](./migration-guide.md). Read this
> file for *how* each emulation works; read those two for *what is fake* and *what
> replaces it*.
The consumer writes against `@ng-eventually/client` **as if** NextGraph already
shipped per-entity documents in public/protected/private stores, capabilities and
inboxes. It hasn't (see [`nextgraph-current-state.md`](./nextgraph-current-state.md)).
The consumer application writes against `@ng-eventually/client` as if NextGraph
already shipped per-entity documents in public/protected/private stores, capabilities
and inboxes. It hasn't (see [`nextgraph-current-state.md`](./nextgraph-current-state.md)).
This file is the lib's own engineering doctrine on how it fabricates that mature
face on top of **one single shared wallet / broker**. Everything here is
face on top of one single shared wallet / broker. Everything here is
polyfill-era and disappears at migration ([`migration-guide.md`](./migration-guide.md)).
## The premise: one shared wallet, everything readable
Current NextGraph has **no cross-wallet read** (`OpenRepo` is a TODO at
Current NextGraph has no cross-wallet read (`OpenRepo` is a TODO at
`engine/verifier/src/verifier.rs:1423`; a foreign NURI raises `RepoNotFound`; a
session only holds its own 3 stores in `self.repos`). So "each user their own
wallet" is blocked at the root — no data ever crosses the boundary between two
wallets.
The lib's answer: **everyone opens the same wallet**. NextGraph sees a single
identity**everything is physically readable**. "Multi-user" becomes an
The lib's answer: everyone opens the same wallet. NextGraph sees a single
identity, so everything is physically readable. "Multi-user" becomes an
application fiction the lib maintains. On top of that one wallet the lib rebuilds,
by emulation, the per-user stores + capabilities + inbox the consumer codes
against.
by emulation, the per-user stores + capabilities + inbox the consumer application
codes against.
## Physical wallet vs virtual wallet — never enumerate the physical one
Because the emulation runs on ONE shared wallet, distinguish two levels:
- **Physical wallet** — the real NextGraph wallet everyone opens. Its local store
holds **every account's documents PLUS the lib's own internals** (the shim index,
the inbox docs, the discovery index) as named graphs. It **accumulates without
bound** across sessions/runs. **Listing / scanning "all documents" of the physical
wallet is meaningless AND O(size)** — it mixes every user's data with lib internals,
and it is exactly what a `sparql_query` with **no anchor** (`GRAPH ?g { … }`) does
(it spans every synced graph). **Never do it.** The physical wallet is a substrate,
holds every account's documents plus the lib's own internals (the shim index,
the inbox docs, the discovery index) as named graphs. It accumulates without
bound across sessions/runs. Listing or scanning "all documents" of the physical
wallet is meaningless and O(size) — it mixes every user's data with lib internals,
and it is exactly what a `sparql_query` with no anchor (`GRAPH ?g { … }`) does
(it spans every synced graph). The physical wallet is a substrate,
not something to enumerate.
- **Virtual wallet** — the lib's emulation of **one user's** wallet: the set of
- **Virtual wallet** — the lib's emulation of one user's wallet: the set of
documents the shim attributes to that account (its per-scope index in
`store-registry.ts`). This is what "the user owns". Over a *virtual* wallet,
"**list my documents**" is meaningful and **bounded** (only that account's docs).
"list my documents" is meaningful and bounded (only that account's docs).
**Consequence for reads (see `read-model.md`):** to list a user's entities you
enumerate the **virtual** wallet — the account's scope index (bounded, O(my docs)),
NOT the physical union — then read those specific documents with a **per-doc anchored**
enumerate the *virtual* wallet — the account's scope index (bounded, O(my docs)),
not the physical union — then read those specific documents with a per-doc anchored
`sparql_query`. A non-empty / bloated physical wallet then costs nothing, because the
physical union is never scanned. Discovery (all public events) is the one bounded
enumeration hack and goes through the discovery **index**, not a physical scan.
enumeration hack and goes through the discovery index, not a physical scan.
At migration each virtual wallet becomes a real per-user wallet; the physical/virtual
distinction — and the "never enumerate the physical wallet" rule — dissolves into
@@ -66,20 +66,20 @@ native per-wallet reads.
The single most load-bearing distinction. Two **orthogonal** axes the
terminology historically fused:
- **Axis A — which native STORE?** A wallet has 3: `private_store_id`,
- **Axis A — which native store?** A wallet has 3: `private_store_id`,
`protected_store_id`, `public_store_id`. Historic origin of "mono-store /
multi-store" (use 1 store vs the 3).
- **Axis B — how many DOCUMENTS in a store?** A store contains documents; the
**document (= repo = `@graph`) is the sharing + rights boundary**. The ReadCap
hence **isolation** — is **PER-DOCUMENT**.
- **Axis B — how many documents in a store?** A store contains documents; the
document (= repo = `@graph`) is the sharing + rights boundary. The ReadCap
hence isolation — is per-document.
**`docCreate(sessionId, "Graph", "data:graph", "store", undefined)` the shared
wallet's PRIVATE store.** The trailing `store` arg left `undefined` targets the
`docCreate(sessionId, "Graph", "data:graph", "store", undefined)` targets the shared
wallet's private store. The trailing `store` arg left `undefined` targets the
private store (this is what `store-registry.ts`'s `createDoc()` does). So every
document the shim creates physically lives in ONE store (private), and the
`public|protected|private` scope is a **LOGICAL LABEL** tracked in RDF by the
shim — **not** a NextGraph store. Therefore what a consumer's "multi-store" flag
switches on is really **multi-DOCUMENT with logical scope labels**, never
document the shim creates physically lives in one store (private), and the
`public|protected|private` scope is a logical label tracked in RDF by the
shim — not a NextGraph store. Therefore what a consumer application's "multi-store"
flag switches on is really multi-document with logical scope labels, never
multi-store. Do not read `Scope` (`types.ts`) as a physical store — it is the
logical label the registry attaches.
@@ -98,32 +98,31 @@ public/protected/private stores — on top of one shared wallet.
`docs.docCreate` primitive. The `scope` (`public|protected|private`) is a
logical attribute tracked here, not a physical store.
- **The `sharedWalletShim`** is the mapping `account → its 3 scope-document
NURIs`, persisted as RDF in the shared wallet's **private store** (the anchor,
NURIs`, persisted as RDF in the shared wallet's private store (the anchor,
always known from the session: `RegistrySession.privateStoreId`). That makes
login **cross-device**: another device opening the same wallet reads the same
shim and finds the same accounts. It is the account→document **trust root** —
identity resolution cross-device: another device opening the same wallet reads
the same shim and finds the same accounts. It is the account→document trust root,
which is why every untrusted value that reaches its SPARQL is escaped (see
SPARQL hardening below).
- **Per-entity documents + per-scope index.** `createEntityDoc(username, scope)`
makes a dedicated document for ONE entity (mirrors the target, where each entity
- **Per-entity documents + per-scope index.** `createEntityDoc(id, scope)`
makes a dedicated document for one entity (mirrors the target, where each entity
is its own document/repo with a future inbox) and appends its NURI to the
account's **scope index document** — the index doc plays the role of the future
**store-container** (it lists the entity-document NURIs "in" that scope).
account's scope index document — the index doc plays the role of the future
store-container (it lists the entity-document NURIs "in" that scope).
`listEntityDocs(scope)` unions the contained NURIs across all accounts. This is a
**fallback / test-only** path, NOT the read path: enumerating every account and
fallback / test-only path, not the read path: enumerating every account and
handing the NURIs to `useShape({ graphs })` opens/syncs other accounts' possibly-
unsynced docs and HANGS (the ORM fan-out, ~75s — see
[`read-model.md`](./read-model.md)). The real READ path is
`readModel.readUnion(docs)`, which reads the by-need doc set with **one PER-DOC
ANCHORED `sparql_query`** — **never an anchorless union-scan** of the physical
wallet (that is O(wallet size) and timed out ~90s; see
[`read-model.md`](./read-model.md)). The app resolves the by-need doc set from the
discovery index (public events) and `listMyEntityDocs(username, scope)` (my own
account, bounded — no cross-account fan-out).
- **GENERIC by construction.** The registry knows only the three native scopes,
**zero** application entity kind. The consumer maps its entities to a scope and
injects the session + username normalization via `configureStoreRegistry({
getSession, normalizeUser })` (`polyfill.ts`).
unsynced docs and hangs (the ORM fan-out — see
[`read-model.md`](./read-model.md)). The real read path is
`readModel.readUnion(docs)`, which reads the by-need doc set with one per-doc
anchored `sparql_query`, never an anchorless union-scan of the physical
wallet (see [`read-model.md`](./read-model.md)). The consumer application resolves
the by-need doc set from the discovery index (public events) and
`listMyEntityDocs(id, scope)` (its own account, bounded — no cross-account fan-out).
- **Generic by construction.** The registry knows only the three native scopes,
zero application entity kind. The consumer application maps its entities to a scope
and injects the session + identity-id normalization via `configureStoreRegistry({
getSession, normalizeId })` (`polyfill.ts`).
The `store≠document` two axes materialize here directly: the registry moves along
axis B (more documents = more isolation), never axis A (it always writes into the
@@ -131,30 +130,31 @@ one private store via `docCreate(..., undefined)`).
### A virtual wallet's structure — the three emulated stores
A **virtual wallet** = one account in the shim, keyed by its **virtual-wallet id**
(the technical identifier the consumer sets when the physical wallet is opened; it
identifies *which* virtual wallet, and is NOT the consumer's application username).
Its structure mirrors the target "1 user = 1 wallet with 3 native stores":
A *virtual wallet* = one account in the shim, keyed by its virtual-wallet id
(the technical identifier the consumer application sets when the physical wallet is
opened; it identifies *which* virtual wallet, and is an id rather than a
human-friendly handle). Its structure mirrors the target "1 user = 1 wallet with 3
native stores":
```
Virtual wallet (id)
├── public store = docPublic index → [ event doc NURI, PdR doc NURI, … ]
├── protected store = docProtected index → [ profile doc NURI, participation doc NURI, … ]
└── private store = docPrivate index → [ settings doc NURI, … ]
├── public store = docPublic index → [ entity doc NURI, entity doc NURI, … ]
├── protected store = docProtected index → [ record doc NURI, record doc NURI, … ]
└── private store = docPrivate index → [ record doc NURI, … ]
```
So **yes, the 3 native stores (public/protected/private) are present** — but
**emulated**: each "store" is an **index document**
(`AccountRecord.{docPublic,docProtected,docPrivate}`) that LISTS the NURIs of the
So the 3 native stores (public/protected/private) are present, but emulated: each
"store" is an index document
(`AccountRecord.{docPublic,docProtected,docPrivate}`) that lists the NURIs of the
per-entity documents in that scope. It is not a physical native store.
Everything is physical in ONE place: the 3 index documents, every per-entity
document, and the shim anchor itself all live in the shared **physical** wallet's
Everything is physical in one place: the 3 index documents, every per-entity
document, and the shim anchor itself all live in the shared physical wallet's
private store (`docCreate(..., undefined)`). The 3-store structure is the per-account
**logical** layer the lib maintains on top.
logical layer the lib maintains on top.
```
Physical wallet (shared, ONE) → private_store (physical) holds EVERYTHING:
Physical wallet (shared, one) → private_store (physical) holds everything:
• the shim anchor: virtual-wallet-id → { docPublic, docProtected, docPrivate }
• every account's 3 scope-index docs + all per-entity docs + inbox + discovery index
```
@@ -163,11 +163,11 @@ At migration each virtual wallet's 3 index documents become the user's 3 **real*
native stores, the entity documents move into them physically, and the
virtual/physical distinction dissolves (see [`migration-guide.md`](./migration-guide.md)).
### SDK-shaped scope resolvers — the consumer holds NO store-id
### SDK-shaped scope resolvers — the consumer application holds no store-id
The consumer must never construct a `did:ng:${store_id}` NURI itself: physical
placement is the lib's job (the whole point of the SDK boundary). Two resolvers
turn a **logical scope** into an **opaque graph NURI** without exposing any
The consumer application must never construct a `did:ng:${store_id}` NURI itself:
physical placement is the lib's job (the whole point of the SDK boundary). Two
resolvers turn a logical scope into an opaque graph NURI without exposing any
store-id:
- **`resolveScopeGraph(scope)`** — the graph where the current session writes
@@ -177,26 +177,26 @@ store-id:
native store; `public` + `protected` → the **protected** native store, because
`doc_create`/ORM cannot target a non-private/protected native store today (SDK
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,
the consumer is unchanged.
resolves to the user's real per-scope store — the change is in this function,
and the consumer application is unchanged.
- **`resolveInboxAnchor()`** — the anchor where emulated inbox deposits land: a
**DEDICATED inbox document** (a reserved account's public scope document, from
`docCreate` — a real repo NURI, stable across clients), **not** the shared
dedicated inbox document (a reserved account's public scope document, from
`docCreate` — a real repo NURI, stable across clients), not the shared
wallet's private-store root. Why dedicated: the shim (the account→document trust
root) lives in the private-store graph and is scanned on every `loadShim`;
routing every inbox deposit into that SAME graph bloats it without bound
routing every inbox deposit into that same graph bloats it without bound
(thousands of deposit triples across sessions), turning `loadShim` into a
multi-second full-graph scan. A separate inbox document keeps the shim graph
small and the deposits isolated. At migration it becomes the host's native
inbox NURI.
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
`privateStoreId` anchor). The consumer hands the whole session to the lib at the
ONE injection point (`configureStoreRegistry({ getSession })`) — that is wiring,
not placement logic; everything else in the consumer speaks only in scopes. If
the session omits `protectedStoreId`, the non-private scopes fall back to the
private store rather than emit a broken NURI.
`privateStoreId` anchor). The consumer application hands the whole session to the
lib at the one injection point (`configureStoreRegistry({ getSession })`) — that is
wiring, not placement logic; everything else in the consumer application speaks only
in scopes. If the session omits `protectedStoreId`, the non-private scopes fall back
to the private store rather than emit a broken NURI.
## `RepoNotFound` and the `orm_start_graph` scope rule
@@ -206,11 +206,11 @@ the ORM, the store's repo must be **explicitly opened** in the verifier's
without it, `orm_frontend_update` fails with `RepoNotFound`.
- **Scope** for `useShape`: the store NURI, e.g. `did:ng:${privateStoreId}` (or,
in the consumer, a per-user store once that migration happens).
in the consumer application, a per-user store once that migration happens).
- **`@graph`** (write target): the same store NURI.
- **Never use `did:ng:i` as a scope.** It subscribes to the user's whole site via
a special code path (`NuriTargetV0::UserSite`) that **does not open individual
repos** → breaks every write with `RepoNotFound`.
- Never use `did:ng:i` as a scope: it subscribes to the user's whole site via
a special code path (`NuriTargetV0::UserSite`) that does not open individual
repos, breaking every write with `RepoNotFound`.
Both the private and the protected native stores were verified to open the same
way for ORM+SPARQL (round-trip probe, no `RepoNotFound`). The original arbitration
@@ -218,17 +218,17 @@ is preserved in [`decisions/private-store-nuri-scope.md`](./decisions/private-st
## The `@ng-org` double-proxy `DataCloneError` constraint
**Validated hard constraint, not a style choice.** `docs.ts` calls the **real
injected `ng`** (`getConfig().ng`) DIRECTLY — never the public `ng` proxy
A validated hard constraint, not a style choice: `docs.ts` calls the real
injected `ng` (`getConfig().ng`) directly, never the public `ng` proxy
(`makeNg` in `ng-proxy.ts`).
`@ng-org/web`'s `ng` is already an **iframe-RPC proxy** (postMessage marshaling,
`@ng-org/web`'s `ng` is already an iframe-RPC proxy (postMessage marshaling,
see [`nextgraph-current-state.md`](./nextgraph-current-state.md) § integration).
Wrapping it in the lib's own JS `Proxy` (double proxy) breaks `doc_create`'s
postMessage marshaling `DataCloneError: function ... could not be cloned`.
postMessage marshaling with `DataCloneError: function ... could not be cloned`.
Reaching the real `ng` held in the config avoids the double-proxy. This was
verified: routing the shim's `doc_create`/SPARQL through the public proxy turned
4 multistore scenarios red; it was reverted. The integration boundary is:
4 multistore scenarios red, so it was reverted. The integration boundary is:
- **Through the lib's public proxy** (validated): `useShape` (ORM + ReadCap
filter), `init`/`initNg`, `login`.
@@ -240,223 +240,236 @@ verified: routing the shim's `doc_create`/SPARQL through the public proxy turned
## Emulated ReadCap — per document (`caps.ts` + `read-filter.ts`)
In the target the broker only delivers documents the wallet holds a **ReadCap**
In the target the broker only delivers documents the wallet holds a ReadCap
for, so `useShape` already returns an authorized subset. Here (single shared
wallet, everything readable) the lib reproduces that with a read-filtered VIEW:
wallet, everything readable) the lib reproduces that with a read-filtered view:
- **`CapRegistry` (`caps.ts`)** models ReadCaps as faithfully as a data layer
can. The access UNIT is the **document = repo NURI** (an item's `@graph`),
**never the item** — because in `nextgraph-rs` a store is just a container repo
and holding its cap does NOT grant the repos it references (no store-level read
inheritance; verified). So the registry is **purely per-document**:
`grantRead` / `grantWrite` / `makePublic` / `open(doc, scope, owner)` /
`canRead` / `canWrite` / `governsRead` / `hasReadPolicy`. The consumer performs
the *acts* of granting (create-public, grant-to-a-connection…) exactly as it
can. The access unit is the document = repo NURI (an item's `@graph`),
never the item — because in `nextgraph-rs` a store is just a container repo
and holding its cap does not grant the repos it references (no store-level read
inheritance; verified). So the registry is purely per-document:
`grantRead(doc, granteeId)` issues a directed read grant to one identity,
alongside `grantWrite` / `makePublic` / `open(doc, scope, owner)` /
`canRead` / `canWrite` / `governsRead` / `hasReadPolicy`, plus the read-only
accessor `protectedDocsOf(owner)` the consumer application uses to pick which
protected docs to grant. The consumer application performs the *acts* of granting
(create-public, grant a specific doc to a specific identity…) exactly as it
will in the target; the lib injects no policy.
- **`read-filter.ts`** — `makeReadFilteredView` wraps the reactive set in a
`Proxy`: iteration / `size` / `forEach` are filtered by
`caps.canRead(item['@graph'], user)`; everything else (`add`, `delete`, `has`,
`getById`…) forwards to the target, preserving writes and reactivity. An item
with no `@graph`, or in a document under no cap policy, is KEPT (the filter only
with no `@graph`, or in a document under no cap policy, is kept (the filter only
restricts documents that *declare* a cap — no regression on ungoverned data).
`filterReadable` is the pure variant.
- **`useShape` (`use-shape.ts`)** applies the view **only if
`caps.hasReadPolicy()`** — otherwise it passes the real set through unchanged
(no regression when the consumer declares no caps).
- **`useShape` (`use-shape.ts`)** applies the view only if
`caps.hasReadPolicy()` — otherwise it passes the real set through unchanged
(no regression when the consumer application declares no caps).
In a mono-store layout (every item in one repo) this is all-or-nothing on that
document — exactly the native behaviour, and why fine-grained isolation requires
one document per entity (axis B).
### Making the ReadCap ACTIVE — current user + connection-driven grants
### Making the ReadCap active — current identity + directed grants
The filter only discriminates once the consumer (a) tells the SDK **who is
reading** and (b) declares the access policy on the documents. Both are plain SDK
calls; the consumer never touches the registry internals:
The filter only discriminates once the consumer application (a) tells the SDK who is
reading and (b) declares the access policy on the documents. Both are plain SDK
calls; the consumer application never touches the registry internals:
- **`setCurrentUser(id)` (`polyfill.ts`)** — the SDK's "current identity" call.
`useShape`'s filtered view reads it lazily, so the delivered subset always
reflects the identity in effect at read time. Until it is set, the filter has no
principal and (per `canRead(doc, null)`) only public documents pass — which is
why isolation stayed **dormant** while the consumer never made this call.
why isolation stays dormant until the consumer application makes this call.
- **`getCaps().open(doc, scope, owner)`** — declares a document's policy when the
consumer creates it: `public` → world-readable; `protected`/`private` → owner
reads, owner holds the write cap. `open` now also **remembers** `(scope, owner)`
per document so a later connection-driven grant can find the protected ones.
- **`declareConnections(peers, as?)` (`polyfill.ts`)** — the SDK-shaped
**protected sharing act**, now **AUTHENTICATED / BILATERAL** (`connections.ts`).
Each call declares the CURRENT identity's OWN peers (`as` defaults to
`getCurrentUser()`); the lib records that as a **directed assertion authored by
the current identity** — a session can only ever assert its own side. A protected
read cap is issued between two principals only when **both have asserted the
other** (a materialized two-sided link, `ConnectionRegistry.neighbors` →
`CapRegistry.grantReadToConnections`). Public docs stay world-readable; private
docs stay owner-only. Re-callable; additive + idempotent. The consumer passes
only principals — no document NURI, no store id.
consumer application creates it: `public` → world-readable; `protected`/`private`
→ owner reads, owner holds the write cap. `open` also remembers `(scope, owner)`
per document so `protectedDocsOf(owner)` can later enumerate the protected ones.
- **`grantRead(doc, granteeId)` (`caps.ts`, exposed via `getCaps()`)** — the one
relationship-shaped sharing act the lib exposes: a directed per-document read
grant issued to a specific identity. Public docs stay world-readable; private
docs stay owner-only; a protected doc becomes readable by `granteeId` once the
owner grants it. The consumer application passes a document NURI and a grantee id
— no store id.
**Why bilateral (adversarial finding).** If a single directed assertion granted
access, any reader could read any owner's protected documents by unilaterally
self-declaring a connection. The two-sided requirement is the emulation of the
target's mutual capability exchange: only a reciprocated link grants the cap. A
unilateral / self-declared connection grants **nothing** (proven in
`test/connections.test.ts` and `test/isolation-active.test.ts` case (b)).
The relationship concept — who is "connected" to whom, and therefore which of
their protected docs to grant — is owned by the consumer application, not the lib.
A connection or friendship is not a NextGraph primitive; the only platform-mappable
primitive is the directed per-document read grant above. So the consumer application
decides a relationship exists and, for each protected doc it wants to share, calls
`grantRead(doc, granteeId)` — typically iterating `protectedDocsOf(owner)` to pick
the owner's protected docs. The intended target of such a directed grant is a native
per-document ReadCap issued to that identity — but that target is itself
scaffolding-only in nextgraph-rs today, not merely unexposed in JS: `AccessGrantV0
{grantee}` is unpersisted and cap-send is `unimplemented!()`, so directing a grant
to another identity is not-yet-built at the platform level. There is no bilateral
capability exchange to mirror, only (eventually) individual directed grants.
The result is the target's discrimination reproduced end-to-end: **private**
owner; **protected** → owner + BILATERAL connections; **public** → all. Proven in
`test/isolation-active.test.ts`: (a) an unconnected principal is denied a protected
document, granted it after a two-sided `declareConnections`, and reads the public
document throughout; (b) a unilateral/self-declared connection is denied.
The result is the target's discrimination reproduced end-to-end: private →
owner; protected → owner + whoever the owner has directly granted; public → all.
Proven in `test/isolation-active.test.ts`: an unconnected principal is denied a
protected document, granted it after the owner issues a directed `grantRead`, and
reads the public document throughout.
This discrimination is only observable because each entity is **its own document**
(the consumer creates per-entity docs via `createEntityDoc` and `open`s each) — in
a mono-store layout the per-document ReadCap is all-or-nothing.
This discrimination is only observable because each entity is its own document
(the consumer application creates per-entity docs via `createEntityDoc` and `open`s
each) — in a mono-store layout the per-document ReadCap is all-or-nothing.
### Write-guard coverage (honest scope)
The emulated write guard (`ng-proxy.ts`, `sparql_update` override) enforces the
per-document write cap **on the public `ng` proxy only**. In practice the
consumer's write paths (`docs.sparqlUpdate`, ORM `ngSet`) call the **real injected
`ng` directly** — never the public proxy — for the validated `DataCloneError`
reason above. So the guard is **best-effort**: it fires for any write routed
through the public proxy, but the consumer's real write paths bypass it and are
**not** guarded today. This is a deliberate, recorded limitation of the emulation
per-document write cap on the public `ng` proxy only. In practice the
consumer application's write paths (`docs.sparqlUpdate`, ORM `ngSet`) call the real
injected `ng` directly — never the public proxy — for the validated `DataCloneError`
reason above. So the guard is best-effort: it fires for any write routed
through the public proxy, but the consumer application's real write paths bypass it
and are not guarded today. This is a deliberate, recorded limitation of the emulation
(the write guard becomes effective only when the broker/verifier enforces caps
natively at migration); the READ side is what makes isolation observably active.
natively at migration); the read side is what makes isolation observably active.
### The per-document ReadCap is now THE isolation path (item-level filter retired)
### The per-document ReadCap is the isolation path (item-level filter retired)
Isolation is enforced by the **per-document ReadCap** (`caps.ts` + `read-filter.ts`)
alone: the access unit is the DOCUMENT (`@graph` = repo), grants are explicit
(`open` / `grantRead` / `makePublic`) and, for `protected`, driven by the
**bilateral connection registry** (`connections.ts`). Because the consumer now
writes **one document per entity** (`createEntityDoc` + `open` per entity), the
per-document cap discriminates at entity granularity — the target's behaviour.
Isolation is enforced by the per-document ReadCap (`caps.ts` + `read-filter.ts`)
alone: the access unit is the document (`@graph` = repo), and grants are explicit
(`open` / `grantRead` / `makePublic`) for `protected`, the owner issues a directed
`grantRead(doc, granteeId)` per identity it wants to share with. Because the consumer
application now writes one document per entity (`createEntityDoc` + `open` per entity),
the per-document cap discriminates at entity granularity — the target's behaviour.
The old **item-level application-visibility filter** (`isolation.ts`
`applyIsolation`, a `Set`-of-records filter keyed on owner+scope) is **retired**
from the consumer path: the app carries **no** access logic — it declares its
identity and its bilateral connections and trusts the SDK. `isolation.ts` survives
only as the home of the generic `Connections` interface (consumed by
`connections.ts` / `caps.grantReadToConnections`) plus its own unit tests; its
matrix functions are dead scaffolding kept for reference and removed at migration.
There is no longer a second, coexisting app-layer filter to reconcile — the single
axis is the per-document cap, exactly as in the target.
The old item-level application-visibility filter (`isolation.ts`
`applyIsolation`, a `Set`-of-records filter keyed on owner+scope) is retired
from the consumer path: the application carries no access logic — it declares its
identity and issues directed grants, and trusts the SDK. Its matrix functions are
dead scaffolding kept for reference and removed at migration. There is no longer a
second, coexisting app-layer filter to reconcile — the single axis is the
per-document cap, exactly as in the target.
## Emulated inbox + curator (`inbox.ts`)
## Emulated inbox (`inbox.ts`)
Current NextGraph does not expose the inbox to the JS SDK (verifier has no
`InboxPost` arm; no wasm sealing helper — see
[`nextgraph-current-state.md`](./nextgraph-current-state.md) § Inbox). Rather than
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.** Target: `post` seals a reference into the owner's native
inbox (`ng.inbox_post_link(...)`) and a **separate curator** materializes
deposits into the owned document. Here, everything is readable, so both sides are
emulated in-lib.
- **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
own verifier unseals each queued message and applies it inline when it processes
its inbox — there is no separate curator or materialization process. Here,
everything is readable, so the lib emulates the read side in-lib.
- **`post(targetInbox, opts)`** appends a deposit `{ from, payload, ts }` as RDF
into the inbox DOCUMENT (in the shared wallet) via `docs.sparqlUpdate`. Each
deposit is a unique RDF subject concurrent deposits don't collide. **`from` is
BOUND to the current identity** (`getCurrentUser`) — it is authenticated, not
into the inbox document (in the shared wallet) via `docs.sparqlUpdate`. Each
deposit is a unique RDF subject, so concurrent deposits don't collide. `from` is
bound to the current identity (`getCurrentUser`) — it is authenticated, not
caller-supplied: omit it to stamp the current user, pass `null` to deposit
ANONYMOUSLY, and a `from` naming ANOTHER principal is **rejected as a spoof**.
This reproduces the protocol's "identified if known, anonymous otherwise" AND
anonymously, and a `from` naming another principal is rejected as a spoof.
This reproduces the protocol's "identified if known, anonymous otherwise" and
the target's guarantee that a client cannot forge another's sender identity (in
the target the broker seals `from` from the wallet's own key; here the check
closes the spoof the shared wallet would otherwise allow). The emulation stores
`from = null` as *absence of a triple*, so it does not provide the target's
**crypto** anonymity (`from = None` sealed), which only a native inbox would.
crypto anonymity (`from = None` sealed), which only a native inbox would.
Proven in `test/inbox.test.ts` case (c).
- **`read` / `materialize` (alias)** play the **emulated CURATOR**: they read the
- **`read` / `materialize` (alias)** emulate the recipient-side read: they read the
deposits back via `docs.sparqlQuery`, JSON-parse each payload, sort by `ts`.
- **`watch(targetInbox, onDeposits, { intervalMs })`** is the emulated watcher: it
polls `read` and fires when the deposit count changes (the polyfill has no
reactive inbox subscription). Fires once immediately; returns an unsubscribe.
GENERIC: the module knows no domain — the consumer 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
`inbox_post_link` and the read side moves to a **separate curator package** (see
the deferred global-index note in the top-level README and
[`decisions/discovery-model.md`](./decisions/discovery-model.md)). The inbox +
watcher is the ONE deposit/materialization mechanism reused for BOTH meeting-point
registration AND submission to a discovery index — same `post` API, same watcher.
`inbox_post_link` (proposed/future) and the read side is served by the recipient's
own verifier unsealing queued messages inline (see the deferred global-index note in
the top-level README and [`decisions/discovery-model.md`](./decisions/discovery-model.md)).
The inbox + watcher is the one deposit/read mechanism a consumer reuses for its own
purposes — e.g. a registration/deposit in one consumer app and submission to a
discovery index — same `post` API, same watcher.
## Emulated discovery index + special account (`discovery.ts`)
Discovery is a **surface on top of the inbox**, not a new primitive. **Access
discovery**: a public entity is world-readable *with its NURI*; the discovery
index is how a client learns that NURI **exists** without holding a connection
Discovery is a surface on top of the inbox, not a new primitive. Access is not the
same as discovery: a public entity is world-readable *with its NURI*; the discovery
index is how a client learns that NURI exists without holding a relationship
to its creator (see [`decisions/discovery-model.md`](./decisions/discovery-model.md)).
The model is: ONE global index = an **owned document** (public read), **fed via
ITS inbox**, **materialized by a curator**. Nobody writes the index directly — a
creator DEPOSITS a reference into the index's inbox; the curator ingests deposits
into entries. Materialization is the natural **dedup / moderation point**.
The model is: one global index = an owned document (public read), fed via
its inbox. Nobody writes the index directly — a creator deposits a reference into
the index's inbox, and the index is built up from those deposits. That build-up
step is the natural dedup / moderation point.
- **The special account (polyfill owner).** "Who owns the global index" is
undecided in the target (NextGraph is mono-user with no global data — a
singleton app is the only glimpsed path). So the polyfill parks ownership on a
**RESERVED SPECIAL ACCOUNT** in the shim — `INDEX_ACCOUNT = "@index"`. It is a
reserved special account in the shim — `INDEX_ACCOUNT = reservedAccount("index")`.
This is NOT the key `"index"` / `"@index"`: `reservedAccount` mints a
sentinel-prefixed key in the shim's reserved namespace (e.g. `" reserved:index"`)
that `normalizeId` can never produce, so no user id — not even one typed as
"index" or "@index", which normalizes to the disjoint key "index" — can collide
with or hijack the index account (asserted in `discovery.test.ts`). It is a
normal shim account (so its 3 scope documents are created on first sight like
any other), but never a real user; it only HOSTS the index document. Its
`public` scope document IS the index document, and its inbox receives the
deposits — a **stable NURI**: every client opening the same shared wallet
resolves the same account same document, so all clients read/write ONE
any other), but never a real user; it only hosts the index document. Its
`public` scope document is the index document, and its inbox receives the
deposits — a stable NURI: every client opening the same shared wallet
resolves the same account, hence the same document, so all clients read/write one
shared index.
- **`submitToIndex(ref, opts?)`** — the SDK act "make this discoverable".
Deposits `ref` into the index document's inbox via `inbox.post`. `from` follows
the inbox convention (bound to the current identity; anonymous when `null`).
`ref` is **opaque** here — the consumer serializes whatever locates the entity
(e.g. an entity document NURI + discovery metadata). **PUBLIC-ONLY guard:** when
`ref` is opaque here — the consumer application serializes whatever locates the
entity (e.g. an entity document NURI + discovery metadata). Public-only guard: when
`opts.doc` names the document being surfaced, a document under a non-public
(protected/private) read policy is **REFUSED** (`caps.governsRead(doc) &&
(protected/private) read policy is refused (`caps.governsRead(doc) &&
!caps.canRead(doc, null)`) — the global index is world-readable, so admitting a
governed doc's NURI would leak it past its scope. Proven in
`test/discovery.test.ts` case (d).
- **`readIndex()`** — the EMULATED CURATOR. Reads every submission, **dedups by
serialized `ref`** (the moderation point: a duplicate submission surfaces
- **`readIndex()`** — the emulated read side. Reads every submission, dedups by
serialized `ref` (the moderation point: a duplicate submission surfaces
once), returns entries sorted by `ts`. `watchIndex(onEntries, opts?)` is the
emulated watcher (polls `readIndex`).
**This REPLACES the cross-account fan-out** (`store-registry.ts`
This replaces the cross-account fan-out (`store-registry.ts`
`listEntityDocs('public')` / `resolveReadGraphs`) as the app-facing discovery
path: the app submits public entities to the index and reads the index, instead
of fanning out over every account's public documents. The fan-out survives only
as an **internal lib fallback** — kept for the per-scope listing it also powers
(e.g. `resolveReadGraphs`), never the app's discovery route.
path: the consumer application submits public entities to the index and reads the
index, instead of fanning out over every account's public documents. The fan-out
survives only as an internal lib fallback — kept for the per-scope listing it also
powers (e.g. `resolveReadGraphs`), never the app's discovery route.
GENERIC: `discovery.ts` knows no application domain — the consumer defines the
`discovery.ts` knows no application domain — the consumer application defines the
`ref` shape and its meaning. At migration the special account disappears:
ownership moves to the decided global-index owner, `submitToIndex` becomes the
native `inbox_post_link` on the index's inbox, and `readIndex` queries the real
materialized index document. The consumer surface (`submitToIndex` / `readIndex`)
native `inbox_post_link` (proposed/future) on the index's inbox, and `readIndex`
queries the real index document. The consumer surface (`submitToIndex` / `readIndex`)
is designed to survive that swap unchanged.
## Emulated write guard (`ng-proxy.ts`)
The public `ng` proxy overrides `sparql_update` to enforce an emulated **write
cap**: a write is refused unless the current user holds the target document's
WRITE cap. Passthrough (no regression) unless a WRITE policy exists AND that
The public `ng` proxy overrides `sparql_update` to enforce an emulated write
cap: a write is refused unless the current user holds the target document's
write cap. It passes through (no regression) unless a write policy exists and that
specific document (the `anchor` arg) is governed by it — ungoverned docs (the
mono-store default, no cap declared) flow through unchanged. Mirrors the target
mono-store default, no cap declared) flow through unchanged. This mirrors the target
broker/verifier, which refuses a write without the document's write cap.
## Faux login (`accounts.ts`)
## Identity store (`accounts.ts`)
The real NextGraph login (redirect to the broker, opening the single SHARED
wallet) is perceived as a **technical access barrier**, not a login (see login
The real NextGraph login (redirect to the broker, opening the single shared
wallet) is perceived as a technical access barrier (see the login
flow in [`decisions/shared-wallet-login-flow.md`](./decisions/shared-wallet-login-flow.md)).
THIS layer is the **perceived** login:
This layer is not a login: it is an `IdentityStore` that holds the current
identity id the consumer application relays to it:
- The user picks a **username** (no password — declarative), persisted in
`localStorage` so the "session" survives reloads and lands on the same account
when the shared wallet re-opens.
- `login()` / `logout()` are **FAUX**: they only read/write the username in
storage. They must **NEVER** call NextGraph (no `session_stop` / `wallet_close`)
— the shared wallet stays open underneath. The real logout lives elsewhere
(hidden in the consumer's settings/debug), because it forces a new redirect.
- **Framework-agnostic**: no React, no DOM beyond an optional injected
- The identity id is set at wallet-import time by the consumer application and
relayed to the lib via its current-identity call. It is persisted in
`localStorage` so the id survives reloads and lands on the same account
when the shared wallet re-opens. In practice the id is often a human-friendly
handle the consumer application chose, but the lib's surface speaks only of an id.
- `set(id)` / `clear()` / `get()` only read/write the id in storage. They never
call NextGraph (no `session_stop` / `wallet_close`) — the shared wallet stays
open underneath. The real logout lives elsewhere (hidden in the consumer
application's settings/debug), because it forces a new redirect.
- Framework-agnostic: no React, no DOM beyond an optional injected
`AccountStorage` (a `window.localStorage`, a test fake, or `null` for SSR). The
React `Context`/`Provider` stays in the consumer. `normalizeUsername`
React `Context`/`Provider` stays in the consumer application. `normalizeId`
(case-insensitive, optional leading `@` stripped, trimmed) is the pure
normalizer, reusable as the shim key normalizer.
@@ -470,13 +483,13 @@ document trust root):
- **`escapeLiteral`** — for LITERAL position (`"..."`): escapes backslash,
double-quote, C0 whitespace. Lossless (literals legitimately carry arbitrary
text — JSON payloads, display names).
- **`escapeIri`** — for UNTRUSTED values embedded into an IRI (`<PREFIX:${…}>`,
e.g. a username minted into an account-subject IRI): percent-encodes every
IRI-hostile character so any username (spaces, unicode, punctuation) stays
- **`escapeIri`** — for untrusted values embedded into an IRI (`<PREFIX:${…}>`,
e.g. an identity id minted into an account-subject IRI): percent-encodes every
IRI-hostile character so any id (spaces, unicode, punctuation) stays
usable while breakout is impossible.
- **`assertNuri`** — for trusted-SHAPED NURIs coming back from `ng`
- **`assertNuri`** — for trusted-shaped NURIs coming back from `ng`
(`did:ng:...`): validates and throws on IRI-breaking chars rather than emitting
a malformed/injected query.
These are re-exported from `@ng-eventually/client` so the consumer reuses the same
escaping when it builds SPARQL.
These are re-exported from `@ng-eventually/client` so the consumer application
reuses the same escaping when it builds SPARQL.