refactor(client): retirer anti-fork — gap non exhibé, resolveAccount simple

Preuve e2e (wallet frais, broker RÉEL rapide : 1er State 1-2 ms) : le private
store est synchronisé au login, la lecture du shim réussit à froid — le « fork sur
lag » que anti-fork compensait n'est PAS exhibé. Par le principe du polyfill
(compenser un gap RÉEL, jamais du poids mort), et par la règle no-polling :
- la version retry = polling (bannie) ;
- la version barrière `ensureRepoOpen(privateStore)` = CASSÉE (un store n'émet pas
  de `State`, la barrière timeout systématiquement → CONTRAT 2 e2e échouait) ;
- le gap = non exhibé.
→ `ensureAccount` fait un `resolveAccount(id)` SIMPLE (une lecture, provision si 0).
`resolveAccountReliably`, `_forceOpenedSyncState` retirés ; `provisionRetry` gardé
optionnel @deprecated (ignoré) pour ne pas casser les 8 tests qui le passent.
`ensureRepoOpen`/`getSyncState` inchangés (chemin de lecture open-repo).

gate : tsc 0 ; bun test 116 ; test:e2e 39 passed, CONTRAT 2 VERT (« same account,
no second provisioning »). Le ~10s du re-resolve public est du scaling anchorless,
pas de la lenteur broker (broker mesuré à 1-2 ms).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sylvain Duchesne
2026-07-09 13:47:07 +02:00
parent 45dbd9a33a
commit 38b152136b
4 changed files with 25 additions and 153 deletions
+15 -40
View File
@@ -1,18 +1,16 @@
/**
* anti-fork.test.ts — behavioral tests for the ANTI-FORK guard in ensureAccount /
* resolveAccountReliably (src/store-registry.ts).
* ensureAccount-idempotent.test.ts — behavioral tests for the NO-FORK guarantee
* of ensureAccount (src/store-registry.ts).
*
* The guard is: BEFORE reading the shim, open the private-store repo and await
* the first `State` push (the deterministic sync barrier, CONTRACT 3). After the
* barrier, 0 rows is DEFINITIVE (account genuinely absent) → provision exactly
* once; rows present → reuse (NO-FORK). No retry loop, no polling.
* The guarantee: ensureAccount always resolves via a SIMPLE resolveAccount read
* (one targeted SPARQL query, no barrier, no retry). The private store is
* synchronised at login, so 0 rows is DEFINITIVE (account genuinely absent) →
* provision exactly once; rows present → reuse (NO-FORK). Idempotent within
* and across sessions.
*
* With the unit fake `ng` (no `doc_subscribe`), `ensureRepoOpen` is a no-op
* (getSyncState → "unknown") and the single shim read is immediate — no lag to
* wait out, synchronous behaviour preserved.
*
* Timed-out barrier: if the barrier expires without a `State`, we refuse to
* provision (throwing a clear error) rather than risk a fork.
* (getSyncState → "unknown") and the single shim read is immediate —
* synchronous behaviour preserved, no lag to wait out.
*/
import { describe, it, expect, mock, beforeEach, afterAll } from "bun:test";
@@ -27,7 +25,7 @@ import {
resetStoreRegistry,
resetConfig,
} from "../src/polyfill";
import { resetOpenedRepos, _forceOpenedSyncState } from "../src/open-repo";
import { resetOpenedRepos } from "../src/open-repo";
afterAll(() => {
resetConfig();
@@ -42,8 +40,7 @@ const SESSION: RegistrySession = { sessionId: "sid-af", privateStoreId: "PRIV-AF
// Fake ng — simple in-memory store (no doc_subscribe → fake-ng no-op path)
//
// Queries return data from the `quads` array immediately. No lag simulation:
// the barrier mechanism (ensureRepoOpen) is a no-op in the fake-ng path, so
// the single shim read after "the barrier" is already authoritative.
// the single shim read is already authoritative (private store is synced at login).
// ---------------------------------------------------------------------------
interface Quad { g: string; s: string; p: string; o: string }
@@ -150,8 +147,6 @@ function inject(fakeNg: ReturnType<typeof makeFakeNg>) {
configureStoreRegistry({
getSession: async () => SESSION,
normalizeId: (u) => u.trim().toLowerCase(),
// provisionRetry is now unused by the barrier mechanism; kept for API compat.
provisionRetry: { attempts: 1 },
});
resetRegistryCache();
resetOpenedRepos();
@@ -161,7 +156,7 @@ function inject(fakeNg: ReturnType<typeof makeFakeNg>) {
// Tests
// ---------------------------------------------------------------------------
describe("anti-fork: barrier-first resolveAccountReliably / ensureAccount", () => {
describe("ensureAccount: no-fork + idempotence", () => {
beforeEach(() => {
resetRegistryCache();
resetOpenedRepos();
@@ -175,7 +170,7 @@ describe("anti-fork: barrier-first resolveAccountReliably / ensureAccount", () =
expect(fakeNg.doc_create).toHaveBeenCalledTimes(3);
// Simulate a fresh session: clear caches but keep quads intact (same fakeNg).
// In the barrier model the single post-barrier read finds the account immediately.
// The single resolveAccount read finds the account immediately.
resetRegistryCache();
resetOpenedRepos();
@@ -215,9 +210,9 @@ describe("anti-fork: barrier-first resolveAccountReliably / ensureAccount", () =
expect(fakeNg.doc_create).toHaveBeenCalledTimes(3);
});
it("(d) fake-ng no-op barrier path: single shim read, no doc_subscribe calls", async () => {
it("(d) fake-ng no-op path: single shim read per call, no doc_subscribe calls", async () => {
// The fake ng has no doc_subscribe → ensureRepoOpen is a no-op (getSyncState → "unknown").
// resolveAccountReliably must proceed to the single read without waiting or throwing.
// ensureAccount must proceed to the single resolveAccount read without waiting or throwing.
const fakeNg = makeFakeNg(); // no doc_subscribe
inject(fakeNg);
@@ -234,24 +229,4 @@ describe("anti-fork: barrier-first resolveAccountReliably / ensureAccount", () =
// The account query was called exactly twice (once per ensureAccount, no retries)
expect(fakeNg.getAccountQueryCount()).toBe(2);
});
it("(e) timed-out barrier: resolveAccountReliably throws rather than provisioning", async () => {
// Simulate: the private store nuri is already in the `opened` set but with
// sync state "timed-out" (forced via _forceOpenedSyncState so the test
// does not have to wait 8s for the real OPEN_TIMEOUT_MS to fire).
const fakeNg = makeFakeNg(); // empty quads → 0 rows
inject(fakeNg);
// Force the private-store nuri ("did:ng:PRIV-AF") into timed-out state.
// resolveAccountReliably calls anchorNuri() → `did:ng:${privateStoreId}`.
_forceOpenedSyncState("did:ng:PRIV-AF", "timed-out");
// ensureAccount must throw (conservative anti-fork guard: do not provision blind)
await expect(ensureAccount("TimedOutUser")).rejects.toThrow(
/sync barrier timed out/,
);
// Must NOT have created any scope docs (refusing to provision)
expect(fakeNg.doc_create).toHaveBeenCalledTimes(0);
});
});