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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sylvain Duchesne
2026-07-06 14:02:16 +02:00
parent d39b12885a
commit 63ecfeeff8
31 changed files with 1059 additions and 1396 deletions
+49 -46
View File
@@ -1,91 +1,94 @@
# ADR — Discovery mechanism (inbox-fed index, curator, fan-out)
# ADR — Discovery mechanism (inbox-fed index, fan-out)
**Date:** 2026-06-16 · **Status:** mechanism accepted; target owner undecided.
Ported here for the **discovery MECHANISM** it defines — the piece this lib
Ported here for the discovery mechanism it defines — the piece this lib
realizes (`inbox.ts` post/materialize/watch; `store-registry.ts` fan-out). The
product intent (what a consumer *should* surface) is the consumer's concern, not
this lib's; only the mechanism is recorded here.
product intent (what a consumer application *should* surface) is the consumer
application's concern, not this lib's; only the mechanism is recorded here.
## Access discovery
## Access is not discovery
- **Access**: may I read this document if I hold it? A public entity is
world-readable with its NURI.
- **Discovery**: how do I learn it exists, in order to read it? this ADR.
- **Discovery**: how do I learn it exists, in order to read it? This is the ADR's topic.
## The mechanism
1. **A single global index**, **fed via ITS inbox**. The creator does **not** edit
the index directly: it **deposits a reference into the index's inbox**. The
index is an **owned document** (public read), **materialized from its inbox** (a
watcher ingests deposits adds entries).
2. **Primary discovery = that global index.**
3. **Relational = secondary axis**, overlaid: a connection's participations,
markers on the global list. Rests on existing per-item data (protected scope)
no new primitive.
1. A single global index, fed via its inbox. The creator does not edit
the index directly: it deposits a reference into the index's inbox. The
index is an owned document (public read), built up from its inbox (a
watcher ingests deposits and adds entries).
2. Primary discovery is that global index.
3. Relational is a secondary axis, overlaid: a peer's participations,
markers on the global list. It rests on existing per-item data (protected scope),
with no new primitive.
## The 3-stage frame
`discovery → synchronization → query`
1. **Discovery**: the index gives the NURIs of the entity documents.
2. **Synchronization**: subscribe to those documents they **replicate locally**
2. **Synchronization**: subscribe to those documents so they replicate locally
(verifier: `self.repos` + oxigraph dataset).
3. **Query**: query what is **now local** (sort, limit, reactivity). **SPARQL/ORM
run on the local set only** (`resolve_target_for_sparql` searches `self.repos`)
you cannot query what is not loaded.
3. **Query**: query what is now local (sort, limit, reactivity). SPARQL/ORM
run on the local set only (`resolve_target_for_sparql` searches `self.repos`)
you cannot query what is not loaded.
**Corollary:** a reactive query does not replace the index — it runs at stage 3 on
the local union that stages 1-2 built. You don't sync what you didn't discover.
## Why one reused mechanism
- **No Group store.** The index is **not** open-write: it is an **owned document**
(public read) **+ native inbox** (a primitive present on every document). Nobody
writes the index but its owner (by materializing inbox deposits). So the model
- **No Group store.** The index is not open-write: it is an owned document
(public read) plus a native inbox (a primitive present on every document). Nobody
writes the index but its owner (by ingesting inbox deposits). So the model
stays "3 stores + Dialog + inboxes, no Group store."
- **One mechanism, reused.** The **inbox + materialization watcher** serve BOTH
submitting an entity to the index AND registering to a meeting-point — same
`inbox.post` API, same handling. This is exactly `inbox.ts` in this lib (`post`
/ `read` / `materialize` / `watch`).
- **Natural dedup / moderation point:** materialization (inbox → index) is where
- **One mechanism, reused.** The inbox + ingest watcher serve both
submitting an entity to the index and a registration/deposit in one consumer
app — same `inbox.post` API, same handling. This is exactly `inbox.ts` in this
lib (`post` / `read` / `materialize` / `watch`).
- **Natural dedup / moderation point:** the inbox → index ingest is where
duplicates are detected / moderated before insertion.
## Index owner — target model undecided
The "dedicated service with its own wallet sharing a freely-readable index" was
**incorrect**: NextGraph apps and services are **mono-user with no global data**
NextGraph apps and services are mono-user with no global data
(see [`../nextgraph-current-state.md`](../nextgraph-current-state.md) § Apps &
services). The only path glimpsed for a global document is a **singleton app**
bound to the developer-user — **not implemented, uncertain**, to explore later.
This is why a global-index curator is a **deferred separate package** in this lib
services), so a dedicated service with its own wallet sharing a freely-readable
index is not a NextGraph shape. The only path glimpsed for a global document is a
singleton app bound to the developer-user — not implemented, uncertain, to explore
later. This is why a global-index package is a deferred separate package in this lib
(see the top-level README).
## Polyfill reality — the fan-out drift is now RESOLVED (special-account index)
The shared-wallet polyfill originally shipped a **cross-account fan-out over
every account's public documents** (`store-registry.ts` `listEntityDocs('public')`
/ `resolveReadGraphs`) — one account saw another's public entity **without a
connection**. This ADR classified that per-account fan-out as a **drift** to be
replaced by the single global index.
The shared-wallet polyfill originally shipped a cross-account fan-out over
every account's public documents (`store-registry.ts` `listEntityDocs('public')`
/ `resolveReadGraphs`) — one account saw another's public entity without any
relationship to its creator. This ADR classified that per-account fan-out as a drift
to be replaced by the single global index.
**That drift is now resolved in the polyfill.** The inbox-fed global index of
this ADR is implemented on top of a **RESERVED SPECIAL ACCOUNT** in the shim
(`discovery.ts`, `INDEX_ACCOUNT = "@index"`) that owns the index document while
That drift is now resolved in the polyfill. The inbox-fed global index of
this ADR is implemented on top of a reserved special account in the shim
(`discovery.ts`, `INDEX_ACCOUNT = reservedAccount("index")` — a sentinel-prefixed
key in the shim's reserved namespace that `normalizeId` can never produce, so it is
disjoint from any normalized user id, not the literal `"@index"`) that owns the
index document while
the target owner stays undecided: `submitToIndex(ref)` deposits into the index
document's inbox; `readIndex()` materializes (dedup) the entries. The app-facing
discovery path is now **read the index**, exactly as this ADR prescribes — NOT
the fan-out. The cross-account fan-out survives only as an **internal lib
fallback** (it still powers per-scope listing like `resolveReadGraphs`), never
document's inbox; `readIndex()` ingests (dedups) the entries. The app-facing
discovery path is now "read the index", exactly as this ADR prescribes — not
the fan-out. The cross-account fan-out survives only as an internal lib
fallback (it still powers per-scope listing like `resolveReadGraphs`), never
the discovery route. The special account is the provisional owner; at migration
it disappears and ownership moves to the decided global-index owner (see
[`../migration-guide.md`](../migration-guide.md)) with the consumer surface
(`submitToIndex` / `readIndex`) unchanged.
[`../migration-guide.md`](../migration-guide.md)) with the consumer application's
surface (`submitToIndex` / `readIndex`) unchanged.
## Alternatives rejected (mechanism)
- **Open-write index** (creator writes the index directly): required a
collaborative document (Group store, SDK-blocked) and exposed the index to
corruption. Replaced by inbox deposit + owner materialization.
corruption. Replaced by inbox deposit + owner-side ingest.
- **Purely relational discovery** (`social_query`): rejected as *primary* (a
global list is wanted); kept as a secondary axis.
- **No index, direct reactive query**: impossible — SPARQL is local-only (stage 3).
+39 -33
View File
@@ -1,64 +1,70 @@
# ADR — Shared-wallet login/logout flow
# ADR — Shared-wallet identity flow (perceived login)
**Date:** 2026-06-15 · **Status:** Accepted (frozen). The rationale behind this
lib's **faux login** (`accounts.ts`) and why it must never touch NextGraph.
**Date:** 2026-06-15 · **Status:** Accepted (frozen). The rationale behind how
the consumer application presents identity selection as a perceived login, and why
the lib's identity store (`accounts.ts`) must never touch NextGraph. The lib itself
no longer frames this as a login: it receives an identity id, set at wallet-import
time; the perceived-login UX lives entirely in the consumer application.
## Starting constraint
NextGraph login **is not programmable**: it is a **web redirect** to the broker
NextGraph login is not programmable: it is a web redirect to the broker
page (`nextgraph.net`). The shared wallet cannot be opened silently — at least one
broker-redirect pass is required per device. The question is therefore not "how to
avoid the redirect" but "how to order and present it" so the UX stays coherent.
## Decision — technical gate first, application "Connexion" second
Two distinct auth layers, presented in this order:
Two distinct layers, presented in this order:
1. **Real layer (technical, NOT perceived as login).** The broker redirect appears
**immediately, before any app render**. Because it precedes the app, the user
reads it as a **technical access barrier to the test environment** (a beta
wall), **not** an application login. Same shared credentials for everyone
1. **Real layer (technical, not perceived as login).** The broker redirect appears
immediately, before any app render. Because it precedes the app, the user
reads it as a technical access barrier to the test environment (a beta
wall), not an application login. Same shared credentials for everyone
(given in the invitation, "access code" style). Once per device, then
persistent. **Never labelled "login."**
2. **Application layer (perceived as THE login).** A **"Connexion"** screen =
**username only** (→ `localStorage`, the current principal). This is the login
*in the user's perception*. **No password** → declarative connection (anyone
takes any username — coherent with zero-security / friends). **"Déconnexion"**
clears **only** the username and returns to "Connexion"; it **calls no NG
function**.
persistent. It is not labelled "login."
2. **Application layer (perceived as the login).** A "Connexion" screen where the
user picks an identity id (relayed to the lib's identity store, persisted in
`localStorage`, the current principal). This is the login *in the user's
perception*, presented by the consumer application. No password — declarative
selection (anyone takes any id — coherent with zero-security / friends). In
practice the id is often a human-friendly handle. "Déconnexion" clears only
the stored id and returns to "Connexion"; it calls no NG function.
The **real logout** (`ng.session_stop` / `user_disconnect` / `wallet_close`) stays
**hidden** (settings/debug), because it forces a new redirect.
The real logout (`ng.session_stop` / `user_disconnect` / `wallet_close`) stays
hidden (settings/debug), because it forces a new redirect.
## Why (vs the rejected option)
**Rejected**faux login first, then a warning page "enter this username/password",
then a *Continue* button triggering the redirect. Rejected: strange workflow,
dissonant double-login, a warning page that **looks like a scam**, and the
redirect **resurfacing mid-use** on every session expiry.
**Rejected**a perceived login first, then a warning page "enter this
id/password", then a *Continue* button triggering the redirect. Rejected: strange
workflow, dissonant double-login, a warning page that looks like a scam, and the
redirect resurfacing mid-use on every session expiry.
**Chosen** because: the mental model stays coherent (the technical barrier not
being perceived as login, the app-level Connexion/Déconnexion pair is complete and
self-consistent); graceful degradation (a re-gate after a browser restart reads as
"reconnecting to the environment", not a bug); and **similarity to the target
infra** — the "broker redirect → app" shape is exactly the real multi-wallet flow.
At migration you **remove the username "Connexion" screen** and the **technical
barrier becomes the real per-user login** — the flow shape does not change.
"reconnecting to the environment", not a bug); and similarity to the target
infra — the "broker redirect → app" shape is exactly the real multi-wallet flow.
At migration you remove the id "Connexion" screen and the technical
barrier becomes the real per-user login — the flow shape does not change.
## Verified technical facts (`nextgraph-rs`, 2026-06-15)
- **Session persistence: YES.** Wallet remembered iframe-side (`localStorage`
- **Session persistence: yes.** Wallet remembered iframe-side (`localStorage`
long-term + `sessionStorage` for the active session); on reload `init()`
recovers the session **without** re-triggering the redirect while the broker
recovers the session without re-triggering the redirect while the broker
session exists (`sdk/js/web/src/index.ts`, `sdk/js/api-web/main.ts`). A full
browser restart (losing `sessionStorage`) can re-trigger the gate.
- **Real logout exposed: YES.** `ng.session_stop()`, `ng.user_disconnect()`,
- **Real logout exposed: yes.** `ng.session_stop()`, `ng.user_disconnect()`,
`ng.wallet_close()` (`sdk/js/lib-wasm/src/lib.rs`); they stop the session /
clear the wallet and **force a new redirect** afterwards — hence: do NOT call
them in the app-level "Déconnexion," and hide the real logout.
clear the wallet and force a new redirect afterwards — hence the app-level
"Déconnexion" does not call them, and the real logout stays hidden.
## How this lib realizes it
`accounts.ts` `AccountStore.login()`/`logout()` only read/write the username in an
injected `AccountStorage`; they **never** call NG. See the faux login in
`accounts.ts` is an `IdentityStore`: `set(id)` / `clear()` / `get()` only read/write
the identity id in an injected `AccountStorage`; they never call NG. The id is set at
wallet-import time and relayed via the lib's current-identity call; the perceived
login is the consumer application's. See the identity store in
[`../simulation.md`](../simulation.md).