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