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:
@@ -25,17 +25,18 @@ export interface StoreRegistryDeps {
|
||||
/** Normalize an identity id for shim keying. Default: trim (identity-ish). */
|
||||
normalizeId?: (id: string) => string;
|
||||
/**
|
||||
* ANTI-FORK bounded retry budget. On a FRESH page over a persistent wallet the
|
||||
* shim (in the private store's graph) may not be synced when the first read
|
||||
* fires → 0 rows; treating that transient 0 as "account absent" makes the
|
||||
* registry PROVISION a new set of scope docs — an account FORK. Since the
|
||||
* deterministic Rust barrier (`user_connect`) is not reachable from JS, a
|
||||
* BOUNDED retry (capped backoff) is the compensation: wait out the sync-lag
|
||||
* window before concluding "genuinely new". Enable it where the REAL broker is
|
||||
* used (app + e2e). Left UNSET (the default) → `attempts: 1` = NO retry (a
|
||||
* single read), which keeps the synchronous unit fakes fast and unchanged.
|
||||
* POINTER micro-guard budget. The account records now live in a subscribable
|
||||
* doc-shim (`did:ng:o:...`) reached through a well-known write-once POINTER triple
|
||||
* in the store-root graph. The doc-shim read is barrier-AUTHORITATIVE, so accounts
|
||||
* need NO retry (this replaces the deleted account-level `provisionRetry`). The
|
||||
* ONLY residual sync-lag window is the store-root pointer read itself — one
|
||||
* write-once triple. This bounded guard re-reads JUST that pointer a few times if a
|
||||
* fresh cold read misses it; it can never provision or fork an account (worst case:
|
||||
* a couple extra reads before an existing pointer is seen). Enable it where the REAL
|
||||
* broker is used (app + e2e). Left UNSET (the default) → `attempts: 1` = single
|
||||
* read, keeping the synchronous unit fakes fast and unchanged.
|
||||
*/
|
||||
provisionRetry?: { attempts?: number; baseMs?: number; maxStepMs?: number };
|
||||
pointerGuard?: { attempts?: number; baseMs?: number; maxStepMs?: number };
|
||||
}
|
||||
|
||||
export interface EventuallyConfig {
|
||||
@@ -62,10 +63,10 @@ export interface EventuallyConfig {
|
||||
|
||||
let cfg: EventuallyConfig | null = null;
|
||||
let currentUser: PrincipalId | null = null;
|
||||
/** Required fields of StoreRegistryDeps after defaults are applied. `provisionRetry`
|
||||
* defaults to `{ attempts: 1 }` (no retry) when the consumer leaves it unset. */
|
||||
/** Required fields of StoreRegistryDeps after defaults are applied. `pointerGuard`
|
||||
* defaults to `{ attempts: 1 }` (single read) when the consumer leaves it unset. */
|
||||
type ResolvedRegistryDeps = Required<
|
||||
Pick<StoreRegistryDeps, "getSession" | "normalizeId" | "provisionRetry">
|
||||
Pick<StoreRegistryDeps, "getSession" | "normalizeId" | "pointerGuard">
|
||||
>;
|
||||
let registryDeps: ResolvedRegistryDeps | null = null;
|
||||
/** The emulated ReadCap/WriteCap registry. Empty until the app declares caps;
|
||||
@@ -101,9 +102,9 @@ export function configureStoreRegistry(deps: StoreRegistryDeps): void {
|
||||
registryDeps = {
|
||||
getSession: deps.getSession,
|
||||
normalizeId: deps.normalizeId ?? ((id: string) => id.trim()),
|
||||
// Default: no retry (single read). Only the real-broker consumers (app + e2e)
|
||||
// opt into the bounded anti-fork retry; unit fakes stay synchronous.
|
||||
provisionRetry: deps.provisionRetry ?? { attempts: 1 },
|
||||
// Default: single read (no re-read). Only the real-broker consumers (app + e2e)
|
||||
// opt into the bounded pointer micro-guard; unit fakes stay synchronous.
|
||||
pointerGuard: deps.pointerGuard ?? { attempts: 1 },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user