docs: own the current-NextGraph-state knowledge + boundary (lib side)

This library presents a mature-NextGraph SDK face to consumers while
compensating for the current SDK's gaps via a shared-wallet simulation. It
therefore OWNS all current-state + simulation knowledge — moved here out of the
Festipod app repo, which must treat this library as a finished SDK.

New docs/:
- nextgraph-current-state.md — what the current SDK/broker do and don't expose
  (5 store types, document=repo, per-document ReadCap, inbox not exposed, iframe
  RPC proxy, mono-user/no-global-data, wallet import constraint). Keeps the
  nextgraph-rs source pointers.
- simulation.md — how the lib emulates the mature behaviour on one shared wallet
  (shim, store!=document two axes, docCreate→private store, RepoNotFound scope
  rule, @ng-org double-proxy DataCloneError, emulated ReadCap/inbox/curator).
- decisions/ — the current-SDK ADRs (private-store-nuri-scope, sparql-delete,
  shared-wallet-login, discovery mechanism).
- fork-inbox-fallback.md — the Rust-patch/self-host route not taken.
- migration-guide.md — the checklist for when real NextGraph matures.

README: boundary framing from the lib's side + docs/ index; replaced the stale
"scaffold/stubbed" status with the actually-implemented mechanisms per source.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sylvain Duchesne
2026-07-03 23:23:23 +02:00
parent d804a436d7
commit bea9f51d91
9 changed files with 1021 additions and 4 deletions
+81
View File
@@ -0,0 +1,81 @@
# ADR — Discovery mechanism (inbox-fed index, curator, fan-out)
**Date:** 2026-06-16 · **Status:** mechanism accepted; target owner undecided.
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.
## Access ≠ 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.
## 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.
## 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**
(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.
**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
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
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**
(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
(see the top-level README).
## Polyfill reality (fan-out) vs target (global index)
What ships in the shared-wallet polyfill today is the **cross-account fan-out over
every account's public documents** (`store-registry.ts` `listEntityDocs('public')`
/ `resolveReadGraphs`) — one account sees another's public entity **without a
connection**. This ADR classified per-account fan-out as a **drift** to be
replaced by the single global index; the target (inbox-fed global index) remains
valid but the fan-out is the mechanism the shared-wallet staging actually runs on
until the global-index owner is decided. Recorded here as mechanism history — the
resolution belongs to [`../migration-guide.md`](../migration-guide.md).
## 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.
- **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).
@@ -0,0 +1,51 @@
# ADR — Use a store NURI as the `useShape` scope AND `@graph`
**Date:** 2026-03-17 · **Status:** Accepted (partially superseded — see below).
Historical decision, ported into this lib because the *insight* still governs how
the shim opens repos. Original context: the consuming app.
> **Partially superseded (2026-07-03).** The private-store-only scope was replaced
> for shareable domain entities: they are now scoped AND written to the
> **protected** store (`did:ng:${protected_store_id}`), verified to open without
> `RepoNotFound`. **The central insight of this ADR still holds** and now applies
> to **both** stores: you must open the repo via the store's NURI
> (`orm_start_graph`) or you get `RepoNotFound`.
## Context
Loading test data updated the in-memory ORM signals (immediate UI) but produced
`RepoNotFound` on `doc_create` and `orm_frontend_update`. Data vanished on reload
because the SPARQL writes never reached the broker: the verifier's `self.repos`
HashMap did not contain the store's repo → `resolve_target()` failed.
## Options considered
### A — `did:ng:i` scope + `doc_create` for `@graph`
`did:ng:i` is documented as a subscription scope; `doc_create` returns a real
NURI. **Against:** `did:ng:i` goes through `NuriTargetV0::UserSite`, which does
NOT open individual repos; `doc_create` calls `resolve_target(PrivateStore)`,
which requires the repo already in `self.repos` → fails; needs complex retry/timing.
### B — the store NURI as scope AND `@graph` (chosen)
Exact copy of the working `expense-tracker-rdf` example: `orm_start_graph` with
the store's NURI opens the repo in `self.repos`; subsequent `orm_frontend_update`
finds it. Simple, no retry. **Against:** slightly less flexible than `did:ng:i`
(scoped to one store); requires passing the session down to the ORM hook.
### C — `did:ng:i` scope + reuse an existing entity's `@graph`
Works for users who already have data. **Against:** fails for empty wallets (no
entity to reuse) → falls back to `doc_create` and the same `RepoNotFound`.
## Decision
**Option B**: use the store NURI as both the `useShape` scope AND the write
`@graph`, exactly like `expense-tracker-rdf`. This is why this lib's shim opens a
store repo via `orm_start_graph` before writing, and why **`did:ng:i` must never
be used as a scope** (it breaks writes with `RepoNotFound`). See the
`orm_start_graph` scope rule in [`../simulation.md`](../simulation.md).
## Consequences
- **Positive:** immediate writes after connect (no retry); persistence across
reload; aligned with the official examples.
- **Risk:** if NextGraph changes the store's open behaviour, this breaks.
@@ -0,0 +1,64 @@
# ADR — Shared-wallet login/logout flow
**Date:** 2026-06-15 · **Status:** Accepted (frozen). The rationale behind this
lib's **faux login** (`accounts.ts`) and why it must never touch NextGraph.
## Starting constraint
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:
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**.
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.
**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.
## Verified technical facts (`nextgraph-rs`, 2026-06-15)
- **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
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()`,
`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.
## 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
[`../simulation.md`](../simulation.md).
@@ -0,0 +1,61 @@
# ADR — Use SPARQL DELETE (not ORM `ngSet.delete()`) to remove objects
**Date:** 2026-03-17 · **Status:** Accepted → Superseded (2026-06-15). Historical
decision, ported for the current-SDK behaviour it records. Original context: the
consuming app.
> **Superseded (2026-06-15).** The `ngSet.delete()` non-persistence bug that
> motivated this decision was largely fixed upstream in `@ng-org/orm`; deletion
> code went back to `ngSet.delete()`. Kept as arbitration memory — the CRDT
> conflict rule ("don't combine the two") **still holds**.
## Context
Removing an object from the NextGraph store: `DeepSignalSet.delete()` updates the
local reactive state (immediate UI) but **does not persist** to the broker — after
refresh the object reappears.
## Options considered
### A — ORM `ngSet.delete(item)`
Official API, instant local reactive update. **Against:** did not persist in
practice (`delete()` returned `true`, local set updated, object back after
refresh); `graph_orm_update` seemed to mishandle "remove" patches for top-level
set objects (likely engine bug); failed silently.
### B — `ng.sparql_update()` with SPARQL DELETE
`DELETE WHERE { GRAPH <graph> { <subject> ?p ?o } }` removes all the RDF triples.
**For:** persists (survives refresh); the broker confirms via a `GraphOrmUpdate`
`op: "remove"` that reactively removes the item from the ORM set; direct control.
**Against:** not instant (~50ms SPARQL round-trip + broker callback); must NOT be
combined with `ngSet.delete()`.
### C — both together
**Does not work:** the ORM `.delete()` patch and the SPARQL DELETE conflict at the
CRDT level → neither UI nor persistence.
## Decision
**Option B — SPARQL DELETE alone.** The broker returns a `GraphOrmUpdate`
`op: "remove"` that reactively removes the item from the ORM set (UI updates, just
not synchronously). **Do NOT** call `ngSet.delete()` alongside.
```ts
await ng.sparql_update(
session_id,
`DELETE WHERE { GRAPH <${partGraph}> { <${partId}> ?p ?o } }`,
partGraph,
);
```
This is the authoritative-delete pattern this lib's emulation relies on for inbox
deposits and shim graphs (an interpolated NURI/subject must pass through
`assertNuri`/`escapeLiteral` first — see SPARQL hardening in
[`../simulation.md`](../simulation.md)).
## Consequences
- **Positive:** deletion persists; single source of truth (broker → ORM → UI).
- **Negative:** slight UI delay (~50ms); diverges from the ORM README examples.
- **Risk:** if `ng.sparql_update` changes, this breaks; revisit as `ngSet.delete()`
matures upstream.