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:
@@ -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).
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user