fix(client): résolution de compte barrière-autoritative — fin du fork à la reconnexion
Bug: à la reconnexion, resolveAccount lisait le shim depuis le store-root (did🆖${privateStoreId}), NON abonnable → pas de barrière first-State → un "0 rows" à froid est ambigu → le retry (resolveAccountReliably/provisionRetry) échoue → nouveau compte provisionné → FORK → données du compte invisibles. Cause NextGraph (vérifiée nextgraph-rs): "trouvable-sans-lookup" (store-root) et "abonnable" (did:ng:o:<RepoID aléatoire>) sont DISJOINTS — pas de doc à la fois devinable et attendable → une résolution shim purement barrière est impossible. Fix (indirection pointeur → doc-shim abonnable): - Les AccountRecord migrent dans un doc-shim doc_create'd (did:ng:o:..., a une barrière). - Un pointeur écrit-une-fois dans le store-root (<shim:root> <shim:shimDoc> <docShim>) le nomme. resolveShimDoc lit le pointeur → ensureRepoOpen(docShim) [barrière] → lecture de compte AUTORITATIVE (cold 0 = absent pour de vrai). Retry de compte SUPPRIMÉ. - Micro-garde résiduel (pointerGuard, ex-provisionRetry) sur le SEUL triple pointeur écrit-une-fois; ne peut jamais forker un compte; fork de pointeur réconcilié au doc-shim canonique (lexicographiquement-min), sans perte. - Migration: migrateLegacyRecords copie (pas déplace) les comptes de l'ancien store-root vers le doc-shim avant toute conclusion "absent"; idempotent; wallet neuf → no-op. Tests: unit 128/128, e2e réel 42/42 (CONTRACT 2 = non-fork du compte à la reconnexion), red-before/green-after prouvé. Docs: nextgraph-current-state (antagonisme + indirection), simulation, migration-guide. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -43,10 +43,13 @@ in the shim (see the two-axes section in [`simulation.md`](./simulation.md)).
|
||||
resolvers) is designed to survive that swap unchanged.
|
||||
|
||||
### 3. Drop the resolver / shim
|
||||
The `sharedWalletShim` (account → 3 scope-document NURIs, RDF in the private store)
|
||||
has no target equivalent — the target has no central directory. Remove it:
|
||||
`store-registry.ts`, `configureStoreRegistry`, the shim SPARQL. Cross-wallet reads
|
||||
replace the fan-out; per-user wallets replace the shared one.
|
||||
The `sharedWalletShim` (account → 3 scope-document NURIs, held in a subscribable
|
||||
doc-shim reached via a write-once pointer in the store-root — see
|
||||
[`nextgraph-current-state.md`](./nextgraph-current-state.md) § *The pointer → doc-shim
|
||||
indirection*) has no target equivalent — the target has no central directory. Remove
|
||||
it entirely: `store-registry.ts`, `configureStoreRegistry`, the pointer + doc-shim +
|
||||
migration SPARQL, and the `pointerGuard` dep. Cross-wallet reads replace the fan-out;
|
||||
per-user wallets replace the shared one.
|
||||
|
||||
### 4. Real inbox → drop the in-lib read emulation
|
||||
Replace the emulated `inbox.ts` deposit (`docs.sparqlUpdate` into a shared-wallet
|
||||
|
||||
@@ -217,6 +217,75 @@ broker sync (the `OpenRepo` TODO at `verifier.rs:1423`). Opening still requires
|
||||
repo's NURI + ReadCap — there is no store-level read inheritance (see
|
||||
§ Capability / ReadCap granularity).
|
||||
|
||||
### Findable-without-lookup vs subscribable (first-`State` barrier) — DISJOINT
|
||||
|
||||
Two properties a fresh session might want from a document, and **no single document
|
||||
has both**:
|
||||
|
||||
- **Findable without a lookup.** The ONLY NURI a fresh session can NAME with nothing
|
||||
but the session in hand is the store-root, `did:ng:${privateStoreId}` (from
|
||||
`session.private_store_id`). Any per-document repo is `did:ng:o:<RepoID>` with a
|
||||
**random** RepoID minted by `doc_create` — **not derivable**, so it must be looked
|
||||
up somewhere first.
|
||||
- **Subscribable with a sync BARRIER.** `doc_subscribe(nuri)` delivers `TabInfo` then
|
||||
an initial **`State`** (`verifier.rs:470`/`:476`); that first `State` is the sync
|
||||
barrier — **after it, presence is guaranteed and absence is definitive** (pinned
|
||||
empirically by CONTRACT 3 in `packages/client/e2e/`). But this barrier exists only
|
||||
for a repo `doc_subscribe` can open, i.e. a `did:ng:o:<RepoID>` repo. A **store-root
|
||||
has no first-`State` barrier**: an anchored read on it can return 0 rows during
|
||||
sync-lag with no signal distinguishing "still syncing" from "genuinely empty".
|
||||
|
||||
These two are **mutually exclusive**: the guessable target (store-root) is not
|
||||
barrier-authoritative, and the barrier-authoritative target (`o:` repo) is not
|
||||
guessable. **Consequence:** you cannot build a lookup table that is BOTH reachable
|
||||
cold (findable) AND authoritative on a cold read (barrier). A cold "0 rows" read of a
|
||||
store-root graph is therefore fundamentally ambiguous — which is the trap the shim's
|
||||
account map fell into (see next section).
|
||||
|
||||
### The pointer → doc-shim indirection (how the polyfill shim resolves accounts)
|
||||
|
||||
`store-registry.ts` keeps a map `identifier → {docPublic, docProtected, docPrivate}`
|
||||
(the "shim", the account→document trust root). It must be reachable by a fresh
|
||||
reconnecting session (findable) AND authoritative on a cold read (so a fresh page
|
||||
does not mistake sync-lag for "account absent" and PROVISION a fork). Since no single
|
||||
document is both (previous section), the shim uses an **indirection**:
|
||||
|
||||
1. **doc-shim** — a `doc_create`d graph document (`did:ng:o:...`, hence a first-`State`
|
||||
barrier). **All `AccountRecord`s live inside it.** Because it is subscribable, an
|
||||
anchored read behind its `ensureRepoOpen` barrier is **authoritative**: a cold 0
|
||||
means the account is genuinely absent.
|
||||
2. **pointer** — a single well-known, **write-once** triple in the store-root graph,
|
||||
`<urn:ng-eventually:shim:root> <urn:ng-eventually:shim:shimDoc> <docShimNuri>`.
|
||||
The store-root is findable-without-lookup, so a fresh session can always read it;
|
||||
the pointer being the OLDEST, write-once triple in that graph, it is near-always
|
||||
already synced on a cold read.
|
||||
|
||||
**Resolution** (`resolveShimDoc`): read the pointer from the store-root → open the
|
||||
named doc-shim through its barrier (`ensureRepoOpen`) → read the account
|
||||
AUTHORITATIVELY. First login (no pointer): `doc_create` the doc-shim, publish the
|
||||
pointer, done. A **pointer fork** (two devices each writing a pointer before either
|
||||
synced) is **benign**: reconcile to the lexicographically-smallest doc-shim NURI
|
||||
(content-addressed, so every device converges), and no account data is lost — a
|
||||
non-canonical doc-shim's records are migrated forward on the next resolve.
|
||||
|
||||
**The account-level retry is GONE.** Before this indirection the shim lived directly
|
||||
in the store-root graph, so an account read had no barrier and a cold 0 was ambiguous;
|
||||
the lib compensated with a **bounded account-level retry** (`provisionRetry` /
|
||||
`resolveAccountReliably`) that re-read the account several times before concluding
|
||||
"new". Moving the records behind the doc-shim barrier makes the account read
|
||||
authoritative on the FIRST read, so **that retry was removed** — a barrier is
|
||||
deterministic where a retry only guessed. The only residual bounded guard is a small
|
||||
re-read of the **pointer** itself (`pointerGuard`, one write-once triple): it can
|
||||
NEVER re-provision or fork an account — at worst it takes a couple extra reads to see
|
||||
a pointer that is still landing.
|
||||
|
||||
**Migration (no existing account lost).** A wallet whose accounts were written under
|
||||
the OLD scheme has its `AccountRecord`s in the store-root graph, not a doc-shim. On
|
||||
resolution, `migrateLegacyRecords` reads that old location best-effort and RE-INSERTS
|
||||
each legacy record into the doc-shim (copy, not move — the store-root copy is left
|
||||
inert), BEFORE any "account absent" conclusion. So a legacy account is READ (and made
|
||||
barrier-authoritative going forward), never re-provisioned into a fork.
|
||||
|
||||
### The union is read-only — writes must target one document
|
||||
|
||||
`resolve_target_for_sparql(update=true)` returns `InvalidTarget` for `UserSite` /
|
||||
|
||||
+16
-6
@@ -98,12 +98,22 @@ public/protected/private stores — on top of one shared wallet.
|
||||
`docs.docCreate` primitive. The `scope` (`public|protected|private`) is a
|
||||
logical attribute tracked here, not a physical store.
|
||||
- **The `sharedWalletShim`** is the mapping `account → its 3 scope-document
|
||||
NURIs`, persisted as RDF in the shared wallet's private store (the anchor,
|
||||
always known from the session: `RegistrySession.privateStoreId`). That makes
|
||||
identity resolution cross-device: another device opening the same wallet reads
|
||||
the same shim and finds the same accounts. It is the account→document trust root,
|
||||
which is why every untrusted value that reaches its SPARQL is escaped (see
|
||||
SPARQL hardening below).
|
||||
NURIs`. It is persisted as RDF, but **not directly in the store-root graph** — it
|
||||
lives in a subscribable **doc-shim** reached through a write-once **pointer** in the
|
||||
store-root, an indirection forced by a NextGraph fact: "findable-without-lookup"
|
||||
(store-root) and "subscribable / cold-read-authoritative" (`did:ng:o:` repo with a
|
||||
first-`State` barrier) are DISJOINT. The pointer (findable) names the doc-shim
|
||||
(authoritative); resolution reads the pointer from the store-root, opens the doc-shim
|
||||
through its barrier, and reads the account authoritatively — so a fresh reconnecting
|
||||
session never mistakes sync-lag for "account absent" (which would provision a FORK).
|
||||
Full rationale — including why the old account-level retry (`provisionRetry`) is
|
||||
removed and how legacy store-root records are migrated forward — is in
|
||||
[`nextgraph-current-state.md`](./nextgraph-current-state.md) §§ *Findable vs
|
||||
subscribable* / *The pointer → doc-shim indirection*. This map is the
|
||||
account→document trust root, which is why every untrusted value that reaches its
|
||||
SPARQL is escaped (see SPARQL hardening below). It makes identity resolution
|
||||
cross-device: another device opening the same wallet reads the same pointer → the
|
||||
same doc-shim → the same accounts.
|
||||
- **Per-entity documents + per-scope index.** `createEntityDoc(id, scope)`
|
||||
makes a dedicated document for one entity (mirrors the target, where each entity
|
||||
is its own document/repo with a future inbox) and appends its NURI to the
|
||||
|
||||
Reference in New Issue
Block a user