fix(client): résolution de compte DÉTERMINISTE + anti-fork restauré

Cause racine du bug de reconnexion (probe contrôlé répété) : le shim d'un compte
accumule des `docPublic`/`docProtected` EN DOUBLE (forks passés), et
resolveAccount/indexDocOf les choisissaient de façon NON-DÉTERMINISTE → l'écrivain
et le lecteur (page fraîche) ancraient sur des docs d'index DIFFÉRENTS → lecture 0.

Fix :
- `canonicalDoc()`/`recordFromRows()` : parmi plusieurs valeurs d'un scope, choisir
  le NURI lexicographiquement le plus petit (les NURIs sont content-addressed →
  ordre total stable). Écrivain et lecteur résolvent TOUJOURS le même doc, même sur
  un shim corrompu par des doublons.
- `resolveAccountReliably` RESTAURÉ (retry borné avant provision sur read shim 0 à
  froid) : j'avais retiré l'anti-fork à tort (`38b1521`) — le gap EST exhibé (fork
  non-déterministe au cold-read), et la barrière `user_connect` n'est PAS accessible
  côté JS → un retry borné est la compensation légitime (pas la barrière-store
  cassée de `45dbd9a`). Budget injecté `provisionRetry` ; défaut attempts:1 (fakes
  synchrones), app/e2e attempts:8.

anti-fork.test.ts réécrit : docPublic dupliqué → même canonique ; retry sur lag →
réutilise ; neuf → provision 1×.

gate : tsc 0 ; bun test 122 ; test:e2e 42/42 (CONTRACT 2 non-fork vert).

Portée : corrige la couche docPublic de la reconnexion. Une couche PROTECTED
distincte (watchShape protected ne converge pas à froid) reste — diagnostic en cours.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sylvain Duchesne
2026-07-10 10:24:58 +02:00
parent 7c233df5c0
commit bd48b16e31
4 changed files with 350 additions and 119 deletions
+17 -5
View File
@@ -25,9 +25,15 @@ export interface StoreRegistryDeps {
/** Normalize an identity id for shim keying. Default: trim (identity-ish). */
normalizeId?: (id: string) => string;
/**
* @deprecated Removed. The anti-fork retry/barrier mechanism has been dropped
* (the private store is synchronised at login; the barrier was both unnecessary
* and broken for STORE NURIs). This field is accepted but silently ignored.
* 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.
*/
provisionRetry?: { attempts?: number; baseMs?: number; maxStepMs?: number };
}
@@ -56,8 +62,11 @@ export interface EventuallyConfig {
let cfg: EventuallyConfig | null = null;
let currentUser: PrincipalId | null = null;
/** Required fields of StoreRegistryDeps after defaults are applied (excludes deprecated provisionRetry). */
type ResolvedRegistryDeps = Required<Pick<StoreRegistryDeps, "getSession" | "normalizeId">>;
/** Required fields of StoreRegistryDeps after defaults are applied. `provisionRetry`
* defaults to `{ attempts: 1 }` (no retry) when the consumer leaves it unset. */
type ResolvedRegistryDeps = Required<
Pick<StoreRegistryDeps, "getSession" | "normalizeId" | "provisionRetry">
>;
let registryDeps: ResolvedRegistryDeps | null = null;
/** The emulated ReadCap/WriteCap registry. Empty until the app declares caps;
* while it has no read policy the read filter passes through (no regression). */
@@ -92,6 +101,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 },
};
}