feat(client): discovery via a global index (special @index account)

Add a generic discovery-index surface: submitToIndex(ref) deposits a reference
into the index document's inbox; readIndex() returns the materialized entries. A
reserved special account (@index) owns the index document; deposits flow through
the emulated inbox and are materialized by the emulated curator (the dedup/
moderation point). This replaces cross-account fan-out as the discovery path and
is more faithful to the target (a single owned index fed via its inbox). Generic
(the consumer supplies the reference to index). 79 tests pass; tsc rc=0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sylvain Duchesne
2026-07-04 09:33:42 +02:00
parent 7db5eef33f
commit 9951cd5223
5 changed files with 461 additions and 8 deletions
+18 -8
View File
@@ -60,16 +60,26 @@ 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)
## Polyfill reality — the fan-out drift is now RESOLVED (special-account index)
What ships in the shared-wallet polyfill today is the **cross-account fan-out over
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 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).
/ `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.
**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
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
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.
## Alternatives rejected (mechanism)
+45
View File
@@ -274,6 +274,51 @@ the deferred global-index note in the top-level README and
watcher is the ONE deposit/materialization mechanism reused for BOTH meeting-point
registration AND submission to a discovery index — same `post` API, same watcher.
## Emulated discovery index + special account (`discovery.ts`)
Discovery is a **surface on top of the inbox**, not a new primitive. **Access ≠
discovery**: a public entity is world-readable *with its NURI*; the discovery
index is how a client learns that NURI **exists** without holding a connection
to its creator (see [`decisions/discovery-model.md`](./decisions/discovery-model.md)).
The model is: ONE global index = an **owned document** (public read), **fed via
ITS inbox**, **materialized by a curator**. Nobody writes the index directly — a
creator DEPOSITS a reference into the index's inbox; the curator ingests deposits
into entries. Materialization is the natural **dedup / moderation point**.
- **The special account (polyfill owner).** "Who owns the global index" is
undecided in the target (NextGraph is mono-user with no global data — a
singleton app is the only glimpsed path). So the polyfill parks ownership on a
**RESERVED SPECIAL ACCOUNT** in the shim — `INDEX_ACCOUNT = "@index"`. It is a
normal shim account (so its 3 scope documents are created on first sight like
any other), but never a real user; it only HOSTS the index document. Its
`public` scope document IS the index document, and its inbox receives the
deposits — a **stable NURI**: every client opening the same shared wallet
resolves the same account → same document, so all clients read/write ONE
shared index.
- **`submitToIndex(ref, opts?)`** — the SDK act "make this discoverable".
Deposits `ref` into the index document's inbox via `inbox.post`. `from` follows
the inbox convention (anonymous when `null`). `ref` is **opaque** here — the
consumer serializes whatever locates the entity (e.g. an entity document NURI +
discovery metadata).
- **`readIndex()`** — the EMULATED CURATOR. Reads every submission, **dedups by
serialized `ref`** (the moderation point: a duplicate submission surfaces
once), returns entries sorted by `ts`. `watchIndex(onEntries, opts?)` is the
emulated watcher (polls `readIndex`).
**This REPLACES the cross-account fan-out** (`store-registry.ts`
`listEntityDocs('public')` / `resolveReadGraphs`) as the app-facing discovery
path: the app submits public entities to the index and reads the index, instead
of fanning out over every account's public documents. The fan-out survives only
as an **internal lib fallback** — kept for the per-scope listing it also powers
(e.g. `resolveReadGraphs`), never the app's discovery route.
GENERIC: `discovery.ts` knows no application domain — the consumer defines the
`ref` shape and its meaning. At migration the special account disappears:
ownership moves to the decided global-index owner, `submitToIndex` becomes the
native `inbox_post_link` on the index's inbox, and `readIndex` queries the real
materialized index document. The consumer surface (`submitToIndex` / `readIndex`)
is designed to survive that swap unchanged.
## Emulated write guard (`ng-proxy.ts`)
The public `ng` proxy overrides `sparql_update` to enforce an emulated **write