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