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).