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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sylvain Duchesne
2026-07-06 14:02:16 +02:00
parent d39b12885a
commit 63ecfeeff8
31 changed files with 1059 additions and 1396 deletions
+60 -51
View File
@@ -73,14 +73,18 @@ grant the repos it contains — **you need each repo's own ReadCap**. The option
users/quorum (write/permissions), **not** read-cap possession. (Repos of a
`private_store` inherit implicitly.)
> **Consequence for this lib's emulation (see [`simulation.md`](./simulation.md)):**
> the read access UNIT is the **repo = each item's `@graph`** — a per-DOCUMENT
> Consequence for this lib's emulation (see [`simulation.md`](./simulation.md)):
> the read access unit is the repo = each item's `@graph` — a per-document
> filter, never per-store and never per-item. This is exactly what
> `caps.ts` (`CapRegistry`) and `read-filter.ts` model: no store-level
> inheritance, purely per-document caps. In a mono-store layout (all items in one
> repo) the filter is therefore all-or-nothing on that document — which *is* the
> native behaviour, and why fine-grained isolation requires one document per
> entity.
> entity. Read isolation is cryptographic in the target: with no cap for a repo, a
> union / reactive read returns empty (the repo is never decrypted), while a
> targeted read of an unheld repo returns `RepoNotFound`. There is no
> cap-introspection API — the polyfill's `canRead` / `governsRead` are
> emulation-only, with no NextGraph API behind them.
### Store ↔ document confusion (recurring)
@@ -98,36 +102,40 @@ offline"*; *"removing permissions … requires a SyncSignature"* (synchronous).
## Inbox
**Every document has a native inbox.** A non-editor can **deposit a link (DID
cap)** into it without being invited as an editor; the owner **moderates**. NURI:
Every document has a native inbox. A non-editor can deposit a link (DID
cap) into it without being invited as an editor; the owner moderates. NURI:
`did:ng:d:<inbox_id>`. Content: the `InboxMsgContent` enum (`ContactDetails`,
`DialogRequest`, **`Link`**, `Patch`, `ServiceRequest`, `ExtRequest`,
`RemoteQuery`, `SocialQuery`…). Messages are **sealed** (`crypto_box::seal`) to
the inbox pubkey → only the owner decrypts. The `from` field is **optional** → an
**anonymous** sender is possible. This is the "identified if known, anonymous
`DialogRequest`, `Link`, `Patch`, `ServiceRequest`, `ExtRequest`,
`RemoteQuery`, `SocialQuery`…). Messages are sealed (`crypto_box::seal`) to
the inbox pubkey, so only the owner decrypts. The `from` field is optional, so an
anonymous sender is possible. This is the "identified if known, anonymous
otherwise" behaviour native to the protocol.
### The inbox is NOT usable from the JS SDK
The recipient's own verifier unseals each queued message and applies it inline when
it processes its inbox — there is no separate curator or materialization service.
### The inbox is not usable from the JS SDK
- `app_request(request)` is exposed, and `AppRequestCommandV0::InboxPost` +
`AppRequest::inbox_post()` exist. **BUT** the verifier's `request_processor`
has **no `InboxPost` arm** (arms actually handled: `OrmStart(Discrete)`,
`AppRequest::inbox_post()` exist, but the verifier's `request_processor`
has no `InboxPost` arm (arms actually handled: `OrmStart(Discrete)`,
`Fetch`, `FileGet`, `OrmUpdate`, `OrmDiscreteUpdate`, `SocialQueryStart`,
`QrCodeProfile(Import)`, `Header`, `Create`, `FilePut`). Sending an `InboxPost`
triggers nothing.
- Building an `InboxPost` requires crypto sealing on the Rust side; **no wasm
helper** exposes it.
- Inbox deposit is only triggered **internally** by `QrCodeProfileImport`
- Building an `InboxPost` requires crypto sealing on the Rust side; no wasm
helper exposes it. A high-level `inbox_post_link` is a proposed/future API, not
yet present.
- Inbox deposit is only triggered internally by `QrCodeProfileImport`
(`post_to_inbox(new_contact_details)`) and `social_query_start` (contact
propagation via inbox).
**Consequence:** there is no clean way to "drop a Link" into an arbitrary
document's inbox from the JS SDK today. This lib emulates the inbox instead of
patching the broker — see [`simulation.md`](./simulation.md) (emulated inbox +
curator) and [`fork-inbox-fallback.md`](./fork-inbox-fallback.md) (the Rust-patch
path NOT taken). A related exposed primitive: `social_query_start` (a federated
query via inbox up to `degree` hops) exists but is limited to **contacts** — it
does not cover an anonymous notification to a non-connected host.
patching the broker — see [`simulation.md`](./simulation.md) (emulated inbox) and
[`fork-inbox-fallback.md`](./fork-inbox-fallback.md) (the Rust-patch path not taken).
A related exposed primitive: `social_query_start` (a federated query via inbox up to
`degree` hops) exists but is limited to contacts — it does not cover an anonymous
notification to a non-connected host.
## The query capability — ONE local store, named graphs, union queries
@@ -190,36 +198,36 @@ from JS today a repo becomes queryable ONLY by being `doc_create`d in this sessi
(own docs) or synced by an internal path — never on demand by NURI+ReadCap.
**Consequence for this lib's mono-wallet polyfill:** every account's documents are
`doc_create`d in the ONE shared wallet within the SAME session, so they are ALL
already in `self.repos`. `read-model.ts` reads the **bounded, by-need** set of docs
with ONE **anchored** `sparql_query` per doc (`SELECT ?s ?p ?o WHERE { ?s ?p ?o }`,
`doc_create`d in the one shared wallet within the same session, so they are all
already in `self.repos`. `read-model.ts` reads the bounded, by-need set of docs
with one anchored `sparql_query` per doc (`SELECT ?s ?p ?o WHERE { ?s ?p ?o }`,
anchor = the doc NURI): the anchor resolves that same-session repo directly (no
separate open needed) and restricts the query to its graph → O(1) per doc,
separate open needed) and restricts the query to its graph, so it is O(1) per doc,
independent of the store's size. An absent repo throws `RepoNotFound` on its own
read and is skipped, never aborting the batch.
**Do NOT anchorless-union-scan on the read path.** An anchorless
`SELECT … WHERE { GRAPH ?g { ?s ?p ?o } }` spans EVERY named graph in the store —
O(wallet size). On a **shared / bloated** wallet that accumulates docs across runs
that was O(wallet) and timed out (~90s observed on `readUnion` / probe reads). The
per-doc anchored read makes a non-empty wallet irrelevant. At the real multi-store
The read path avoids an anchorless union-scan. An anchorless
`SELECT … WHERE { GRAPH ?g { ?s ?p ?o } }` spans every named graph in the store —
O(wallet size). On a shared wallet that accumulates docs across runs that cost grows
with the whole wallet, which is why the read path is per-doc anchored: the anchored
read makes a non-empty wallet irrelevant. At the real multi-store
migration this is unchanged (the anchored read is native); only bringing a repo into
the session changes: opening a real per-user store repo by cap becomes a native
broker sync (the `OpenRepo` TODO at `verifier.rs:1423`). Opening still requires the
repo's **NURI + ReadCap** — there is **no store-level read inheritance** (see
repo's NURI + ReadCap — there is no store-level read inheritance (see
§ Capability / ReadCap granularity).
### The union is READ-ONLY — writes must target one document
### The union is read-only — writes must target one document
`resolve_target_for_sparql(update=true)` returns `InvalidTarget` for `UserSite` /
`None` (`request_processor.rs:275-282`). So `sparql_update` cannot write "to the
union": every write must name **one** document's `@graph` — exactly what the
union": every write must name one document's `@graph` — exactly what the
polyfill's `docs.sparqlUpdate` already does.
### No reactive SPARQL — `sparql_query` is one-shot
`sparql_query` is non-streamed: it computes a `QueryResults` and returns once
(`lib-wasm/src/lib.rs:352-405` / `553-610`). There is **no** "subscribe to a union
(`lib-wasm/src/lib.rs:352-405` / `553-610`). There is no "subscribe to a union
query". The only reactive primitives are the streamed ones: `orm_start_graph`,
`orm_start_discrete`, `doc_subscribe`, `app_request_stream`.
@@ -228,18 +236,18 @@ query". The only reactive primitives are the streamed ones: `orm_start_graph`,
The reactive ORM is structurally unfit for a fan-out of per-entity / not-yet-synced
graphs, and this is *why* subscribing such a fan-out hangs:
- `OrmStartGraph` first loops over EVERY graph in the requested scope and calls
- `OrmStartGraph` first loops over every graph in the requested scope and calls
`open_for_target(&nuri.target, /*publisher*/ true)` on each
(`request_processor.rs:53-66`), and `orm/graph/initialize.rs` does the same
fan-out again for the graphs the ORM discovers (~`125-128`).
- `open_for_target` → `resolve_target` → `self.repos.get(repo_id).ok_or(RepoNotFound)`
(`request_processor.rs:286-294` calling `resolve_target` at `:147`, the
`RepoNotFound` at `:155/:163`).
- A **freshly-created per-entity doc**, or any **not-yet-synced other-account doc**,
is absent from `self.repos` → `RepoNotFound` propagates through the `?` and
**aborts the whole `orm_start_graph`**. The subscription never emits its initial →
the ORM `readyPromise` never resolves → the multi-second hang observed (≈75s)
when subscribing a fan-out of per-entity graphs.
- A freshly-created per-entity doc, or any not-yet-synced other-account doc,
is absent from `self.repos`, so `RepoNotFound` propagates through the `?` and
aborts the whole `orm_start_graph`. The subscription never emits its initial, so
the ORM `readyPromise` never resolves and the subscription hangs when a fan-out of
per-entity graphs is passed in.
**Consequence:** passing per-entity / unsynced graphs to the reactive ORM is broken.
Listing must go through a one-shot union `sparql_query` instead — see
@@ -347,12 +355,12 @@ the idea of "a service with its own wallet sharing global data".
that user.
**Consequence for a "global document" (e.g. a discovery index):** the only path
glimpsed is a **singleton app** whose global document is administered by the
developer-user — **but this is not implemented and not guaranteed** (simpler
paths may exist; to explore later). The **incorrect** model to avoid: "a
dedicated service with its own wallet sharing a freely-readable index" — that
does not exist in NextGraph (a service is mono-user, no global data). This is why
a global-index curator package is **deferred** in this lib (see the top-level
glimpsed is a singleton app whose global document is administered by the
developer-user — though this is not implemented and not guaranteed (simpler
paths may exist; to explore later). The model that does exist is this
singleton-app one; a dedicated service with its own wallet sharing a
freely-readable index is not a NextGraph shape (a service is mono-user, no global
data). This is why a global-index package is deferred in this lib (see the top-level
README).
## Third-party wallet auto-import constraint
@@ -382,14 +390,15 @@ real way to eliminate the cross-origin round-trip is to self-host/fork the ng-ap
## Login is not programmable
NextGraph login is a **web redirect** to the broker page (`nextgraph.net`). There
NextGraph login is a web redirect to the broker page (`nextgraph.net`). There
is no way to open a wallet silently — at least one broker-redirect pass per device
is required. Session persistence: the wallet is remembered iframe-side
(`localStorage` long-term + `sessionStorage` for the active session); on reload,
`init()` recovers the session **without re-triggering the redirect** while the
`init()` recovers the session without re-triggering the redirect while the
broker session exists (`sdk/js/web/src/index.ts`, `sdk/js/api-web/main.ts`). A
full browser restart (losing `sessionStorage`) can re-trigger the gate. A real
logout IS exposed (`ng.session_stop()`, `ng.user_disconnect()`,
`ng.wallet_close()` in `sdk/js/lib-wasm/src/lib.rs`) but **forces a new
redirect** afterwards. This lib's faux login sidesteps all of it — see the faux
login in [`simulation.md`](./simulation.md).
logout is exposed (`ng.session_stop()`, `ng.user_disconnect()`,
`ng.wallet_close()` in `sdk/js/lib-wasm/src/lib.rs`) but forces a new
redirect afterwards. This lib's identity store sidesteps all of it — the identity
id is set at wallet-import time and relayed to the lib, without a separate login;
see the identity store in [`simulation.md`](./simulation.md).