f62d0bc1c630

expand the cross-client matrix from two scenarios to eleven

time
2026-09-10 23:33 UTC · 1 month ago
machine
koh2
agent
deepseek/deepseek-v4-flash
session
cross-client matrix
parent
74725082f6de
1 file scripts/check-cross-client-sync.mjs+389 −43
scripts/check-cross-client-sync.mjs+389 −43
@@ -94,9 +94,29 @@9494 const root = process.env.PEACH_BROWSER_ROOT;9595 const coordinator = await import(root + "/src/background/sync-coordinator.ts");9696 const syncTypes = await import(root + "/src/shared/sync-types.ts");97+const padding = await import(root + "/src/shared/sync-padding.ts");9798 const mode = process.env.PEACH_SYNC_HARNESS_MODE;9899 const portFile = process.env.PEACH_SYNC_HARNESS_PORT_FILE;99100 const transcriptFile = process.env.PEACH_SYNC_HARNESS_TRANSCRIPT_FILE;101+102+// Scenario shape arrives entirely from the parent; this endpoint does not know103+// what a scenario is. Both clients read the same knobs, so the matrix lives in104+// one readable place rather than half here and half in the harness.105+const knobs = {106+  parity: process.env.PEACH_SYNC_HARNESS_MTIME_PARITY ?? "",107+  localVersion: Number(process.env.PEACH_SYNC_HARNESS_LOCAL_VERSION ?? "7"),108+  vaultId: process.env.PEACH_SYNC_HARNESS_VAULT_ID ?? "cross-client-vault",109+  manifestSupport: process.env.PEACH_SYNC_HARNESS_MANIFEST_VERSION !== "3",110+  entryCount: Number(process.env.PEACH_SYNC_HARNESS_ENTRY_COUNT ?? "1"),111+  expectStatus: process.env.PEACH_SYNC_HARNESS_EXPECT_STATUS ?? "completed",112+  drain: process.env.PEACH_SYNC_HARNESS_DRAIN === "1",113+  // A locked session may hold the transport key and stage ciphertext, but it114+  // never authors or applies a device manifest and never merges entries into115+  // the live vault (PB-26-112 C).116+  locked: process.env.PEACH_SYNC_HARNESS_LOCKED === "1",117+  stopAfter: process.env.PEACH_SYNC_HARNESS_STOP_AFTER ?? "",118+};119+100120 const id = mode === "initiator" ? "browser" : "android";101121 const socket = await new Promise((resolve, reject) => {102122   if (mode === "initiator") {@@ -119,43 +139,105 @@119139 const iterator = lines[Symbol.asyncIterator]();120140 const events = [];121141 const typeOf = (message) => message.type;142+let outcome = null;143+let counted = { sent: 0, received: 0, applied: 0, staged: 0 };144+const fs = require("node:fs");145+const writeTranscript = (status) => fs.writeFileSync(transcriptFile, JSON.stringify({146+  status,147+  error: outcome?.error,148+  entriesSent: counted.sent,149+  entriesReceived: counted.received,150+  applied: counted.applied,151+  staged: counted.staged,152+  events,153+}));122154 const session = {123155   remoteStaticPublicKey: new Uint8Array(32).fill(7),124156   send(bytes) {125157     const message = JSON.parse(new TextDecoder().decode(bytes));126158     events.push("send:" + typeOf(message));127-    socket.write(Buffer.from(bytes).toString("base64") + "\n");159+    // Simulated process death: the transcript is on disk before the process160+    // goes, so the parent can still say what the peer had seen.161+    if (knobs.stopAfter !== "" && knobs.stopAfter === typeOf(message)) {162+      writeTranscript("DIED");163+      process.exit(7);164+    }165+    socket.write(Buffer.from(padding.padSyncFrame(bytes)).toString("base64") + "\n");128166   },129167   async receive() {130168     const next = await iterator.next();131169     if (next.done) throw new Error("cross-client peer closed");132-    const bytes = Uint8Array.from(Buffer.from(next.value.trim(), "base64"));133-    events.push("recv:" + typeOf(JSON.parse(new TextDecoder().decode(bytes))));134-    return bytes;170+    const payload = padding.unpadSyncFrame(Uint8Array.from(Buffer.from(next.value.trim(), "base64")));171+    events.push("recv:" + typeOf(JSON.parse(new TextDecoder().decode(payload))));172+    return payload;135173   },136174   async close() { socket.end(); },137175 };138-const entryId = id + "-entry";139-const localMtime = id === "browser" ? 10 : 20;140-const remoteMtime = id === "browser" ? 20 : 10;176+177+// Entries are shared ids so a diff can actually flow. Parity decides which side178+// is newer for a given index: the same parity on both sides means equal data179+// versions (nothing to send), opposite parities means each side is newer for180+// half the vault (entries flow both ways).181+const ids = Array.from({ length: knobs.entryCount }, (_, index) => "shared-entry-" + index);182+const mtimeFor = (index) =>183+  knobs.parity === "" ? 10 : (index % 2 === Number(knobs.parity) ? 15 : 10);184+const entries = Object.fromEntries(ids.map((entryId, index) => [entryId, mtimeFor(index)]));185+const blobs = Object.fromEntries(ids.map((entryId, index) => [entryId, id + "-frame-" + (index % 8)]));186+141187 const adapter = {142-  async computeVaultId() { return "cross-client-vault"; },188+  async computeVaultId() { return knobs.vaultId; },143189   async getOurDeviceId() { return id; },144-  async getLocalVersion() { return 7; },145-  async buildEntryManifest() { return { [entryId]: localMtime }; },190+  async getLocalVersion() { return knobs.localVersion; },191+  async buildEntryManifest() { return { ...entries }; },146192   async buildTombstoneManifest() { return {}; },147-  async getEncryptedEntries(ids) { return Object.fromEntries(ids.filter((value) => value === entryId).map((value) => [value, id + "-frame"])); },148-  async applyEntries() {},193+  async getEncryptedEntries(requested) {194+    return Object.fromEntries(requested.filter((value) => value in blobs).map((value) => [value, blobs[value]]));195+  },196+  async applyEntries(entries) {197+    if (knobs.locked) {198+      counted.staged += Object.keys(entries).length;199+      return;200+    }201+    counted.applied += Object.keys(entries).length;202+  },149203   async applyTombstones() {},150-  async buildOutgoingDeviceManifest() { return { body: { manifestVersion: syncTypes.DEVICE_MANIFEST_VERSION, issuerDeviceId: id, issuedAt: 1700000000000, devices: [], revoked: [] }, signatureHex: "11".repeat(64) }; },151-  async applyIncomingDeviceManifest() {},204+  // The coordinator reads capability off the adapter's shape, not a flag: a205+  // manifest-v3 peer is one that does not expose the exchange at all. Modelling206+  // it any other way would let this fixture advertise a version it cannot speak207+  // — the same class of mistake the scenario is here to catch.208+  ...(knobs.manifestSupport && !knobs.locked209+    ? {210+        async buildOutgoingDeviceManifest() {211+          return { body: { manifestVersion: syncTypes.DEVICE_MANIFEST_VERSION, issuerDeviceId: id, issuedAt: 1700000000000, devices: [], revoked: [] }, signatureHex: "11".repeat(64) };212+        },213+        async applyIncomingDeviceManifest() {},214+      }215+    : {}),152216 };153-const outcome = mode === "initiator"217+218+outcome = mode === "initiator"154219   ? await coordinator.runSyncInitiator(session, adapter)155220   : await coordinator.runSyncResponder(session, adapter);156-require("node:fs").writeFileSync(transcriptFile, JSON.stringify({ status: outcome.status, events }));221+counted.sent = outcome.entriesSent ?? 0;222+counted.received = outcome.entriesReceived ?? 0;223+224+// A clean stop is only deterministic if our socket is still up when the peer225+// writes its last frame, so hold it open until the peer goes away rather than226+// exiting the instant the round ends.227+if (knobs.drain) {228+  await Promise.race([229+    (async () => { for await (const _ of lines) { /* drain */ } })(),230+    new Promise((resolve) => setTimeout(resolve, 2000)),231+  ]);232+}233+234+writeTranscript(outcome.status);157235 socket.end();158-`.replace("const remoteMtime = id === \"browser\" ? 20 : 10;", "");236+if (outcome.status !== knobs.expectStatus) {237+  console.error("expected " + knobs.expectStatus + ", got " + outcome.status);238+  process.exit(1);239+}240+`;159241 160242 const MAX_DIAGNOSTIC_BYTES = 12_000;161243 @@ -208,8 +290,16 @@208290 209291 const androidTask = ["--no-daemon", "--no-configuration-cache", "--rerun-tasks", "testDebugUnitTest", "--tests", "com.peachpasswords.sync.CrossClientSyncProcessTest.exchange_production_frames_with_browser_process"];210292 const gradleProperty = (name, value) => `-D${name.toLowerCase().replaceAll("_", ".")}=${value}`;293+const KNOB_PREFIX = "PEACH_SYNC_HARNESS_";294+const knobEnv = (knobs) =>295+  Object.fromEntries(Object.entries(knobs).map(([name, value]) => [KNOB_PREFIX + name, String(value)]));211296 212-async function runRound({ initiator, mismatch = false }) {297+/**298+ * Run one scenario round. Both endpoints assert their own expected status, so a299+ * non-zero exit means "my side did not behave as the matrix says it should" and300+ * the scenario is free to describe a failure outcome without special-casing.301+ */302+async function runRound({ initiator, android = {}, browser = {}, timeoutMs = 180_000, label = "", expectFailure = [] }) {213303   const dir = mkdtempSync(resolve(tmpdir(), "peach-cross-sync-"));214304   const androidTranscript = resolve(dir, "android.json");215305   const browserTranscript = resolve(dir, "browser.json");@@ -218,6 +308,7 @@218308   const common = { PEACH_BROWSER_ROOT: browserRoot };219309   const launchBrowser = (mode, port, transcript, portFile) => spawnProcess("bun", ["--eval", browserEndpoint], {220310     ...common,311+    ...knobEnv(browser),221312     PEACH_SYNC_HARNESS_MODE: mode,222313     PEACH_SYNC_HARNESS_PORT: port ?? "",223314     PEACH_SYNC_HARNESS_PORT_FILE: portFile ?? "",@@ -225,19 +316,15 @@225316   });226317   const launchAndroid = (mode, port) => spawnProcess(resolve(androidRoot, "gradlew"), [227318     gradleProperty("PEACH_SYNC_HARNESS_MODE", mode),228-    gradleProperty("PEACH_SYNC_HARNESS_PORT", port ?? ""),229-    gradleProperty("PEACH_SYNC_HARNESS_PORT_FILE", androidPortFile),230-    gradleProperty("PEACH_SYNC_HARNESS_TRANSCRIPT_FILE", androidTranscript),231-    ...(mismatch ? [gradleProperty("PEACH_SYNC_HARNESS_MISMATCH", "message-order")] : []),232319     ...androidTask,233320   ], {234321     ...common,322+    ...knobEnv(android),235323     PEACH_PROCESS_ROOT: androidRoot,236324     PEACH_SYNC_HARNESS_MODE: mode,237325     PEACH_SYNC_HARNESS_PORT: port ?? "",238326     PEACH_SYNC_HARNESS_PORT_FILE: androidPortFile,239327     PEACH_SYNC_HARNESS_TRANSCRIPT_FILE: androidTranscript,240-    ...(mismatch ? { PEACH_SYNC_HARNESS_MISMATCH: "message-order" } : {}),241328     ...(javaHome ? { JAVA_HOME: javaHome } : {}),242329   });243330 @@ -256,8 +343,8 @@256343     }257344     const timeout = new Promise((_, reject) => {258345       timeoutId = setTimeout(259-        () => reject(new Error(`cross-client ${initiator} round timed out after 120s`)),260-        120_000,346+        () => reject(new Error(`${label} timed out after ${Math.round(timeoutMs / 1000)}s`)),347+        timeoutMs,261348       );262349     });263350     const results = await Promise.race([@@ -265,12 +352,18 @@265352       timeout,266353     ]);267354     const [firstResult, secondResult] = results;268-    if (!mismatch && (firstResult.code !== 0 || secondResult.code !== 0)) {269-      throw new Error(`cross-client ${initiator} round failed: first ${formatResult(firstResult)}; second ${formatResult(secondResult)}`);355+    // A scenario that deliberately kills an endpoint names that side here; the356+    // other side must still behave, which is what makes the round meaningful.357+    const initiatorSide = initiator;358+    const responderSide = initiator === "android" ? "browser" : "android";359+    const tolerate = (side, result) => expectFailure.includes(side) || result.code === 0;360+    if (!tolerate(initiatorSide, firstResult) || !tolerate(responderSide, secondResult)) {361+      throw new Error(`${label} failed: initiator(${initiatorSide}) ${formatResult(firstResult)}; responder(${responderSide}) ${formatResult(secondResult)}`);270362     }271363     return { android: readTranscript(androidTranscript), browser: readTranscript(browserTranscript) };272364   } catch (error) {273-    const details = [first, second].filter(Boolean).map((child) => child.diagnostics).map((output) => `stdout=${JSON.stringify(output.stdout)} stderr=${JSON.stringify(output.stderr)}`).join("; ");365+    const details = [first, second].filter(Boolean).map((child) => child.diagnostics)366+      .map((output) => `stdout=${JSON.stringify(output.stdout)} stderr=${JSON.stringify(output.stderr)}`).join("; ");274367     throw new Error(`${error.message}; child diagnostics: ${details}`, { cause: error });275368   } finally {276369     clearTimeout(timeoutId);@@ -279,25 +372,278 @@279372   }280373 }281374 375+/** The two halves of a completed round, as a function of what the scenario expects to happen. */376+function expectedRound({ flow, manifests }) {377+  return {378+    initiator: [379+      "send:SYNC_HELLO", "recv:SYNC_HELLO",380+      "send:SYNC_MANIFEST", "recv:SYNC_MANIFEST",381+      "send:SYNC_REQUEST", "recv:SYNC_REQUEST",382+      "send:SYNC_ENTRIES", ...(flow ? ["recv:SYNC_ENTRIES"] : []),383+      "send:SYNC_TOMBSTONES", "recv:SYNC_TOMBSTONES",384+      ...(manifests ? ["send:SYNC_DEVICE_MANIFEST", "recv:SYNC_DEVICE_MANIFEST"] : []),385+      "send:SYNC_DONE", "recv:SYNC_DONE",386+    ],387+    responder: [388+      "recv:SYNC_HELLO", "send:SYNC_HELLO",389+      "recv:SYNC_MANIFEST", "send:SYNC_MANIFEST",390+      "recv:SYNC_REQUEST", "send:SYNC_REQUEST",391+      "recv:SYNC_ENTRIES", ...(flow ? ["send:SYNC_ENTRIES"] : []),392+      "recv:SYNC_TOMBSTONES", "send:SYNC_TOMBSTONES",393+      ...(manifests ? ["recv:SYNC_DEVICE_MANIFEST", "send:SYNC_DEVICE_MANIFEST"] : []),394+      "recv:SYNC_DONE", "send:SYNC_DONE",395+    ],396+  };397+}398+399+const normalise = (status) => String(status).toLowerCase();400+const allEvents = (round) => [...round.android.events, ...round.browser.events];401+282402 function assertTranscript(actual, expected, label) {283403   const got = JSON.stringify(actual.events);284404   const want = JSON.stringify(expected);285405   if (got !== want) throw new Error(`${label} transcript mismatch: expected ${want}, got ${got}`);286406 }287407 288-const androidInitiator = ["send:SYNC_HELLO", "recv:SYNC_HELLO", "send:SYNC_MANIFEST", "recv:SYNC_MANIFEST", "send:SYNC_REQUEST", "recv:SYNC_REQUEST", "send:SYNC_ENTRIES", "send:SYNC_TOMBSTONES", "recv:SYNC_TOMBSTONES", "send:SYNC_DEVICE_MANIFEST", "recv:SYNC_DEVICE_MANIFEST", "send:SYNC_DONE", "recv:SYNC_DONE"];289-const browserResponder = ["recv:SYNC_HELLO", "send:SYNC_HELLO", "recv:SYNC_MANIFEST", "send:SYNC_MANIFEST", "recv:SYNC_REQUEST", "send:SYNC_REQUEST", "recv:SYNC_ENTRIES", "recv:SYNC_TOMBSTONES", "send:SYNC_TOMBSTONES", "recv:SYNC_DEVICE_MANIFEST", "send:SYNC_DEVICE_MANIFEST", "recv:SYNC_DONE", "send:SYNC_DONE"];290-const browserInitiator = ["send:SYNC_HELLO", "recv:SYNC_HELLO", "send:SYNC_MANIFEST", "recv:SYNC_MANIFEST", "send:SYNC_REQUEST", "recv:SYNC_REQUEST", "send:SYNC_ENTRIES", "send:SYNC_TOMBSTONES", "recv:SYNC_TOMBSTONES", "send:SYNC_DEVICE_MANIFEST", "recv:SYNC_DEVICE_MANIFEST", "send:SYNC_DONE", "recv:SYNC_DONE"];291-const androidResponder = ["recv:SYNC_HELLO", "send:SYNC_HELLO", "recv:SYNC_MANIFEST", "send:SYNC_MANIFEST", "recv:SYNC_REQUEST", "send:SYNC_REQUEST", "recv:SYNC_ENTRIES", "recv:SYNC_TOMBSTONES", "send:SYNC_TOMBSTONES", "recv:SYNC_DEVICE_MANIFEST", "send:SYNC_DEVICE_MANIFEST", "recv:SYNC_DONE", "send:SYNC_DONE"];408+function assertCompleted(round, { flow, manifests, label }) {409+  const expected = expectedRound({ flow, manifests });410+  for (const side of ["android", "browser"]) {411+    const status = normalise(round[side].status);412+    if (status !== "completed") throw new Error(`${label}: ${side} ended ${round[side].status} (${round[side].error ?? "no error"})`);413+    const role = side === (round.initiatedBy === "android" ? "android" : "browser");414+    assertTranscript(round[side], expected[role], `${label}: ${side}`);415+  }416+}292417 293-const mismatch = await runRound({ initiator: "browser", mismatch: true });294-if (mismatch.android.status === "COMPLETED" || mismatch.browser.status === "completed") throw new Error("intentional message-order mismatch unexpectedly completed");295-const androidRound = await runRound({ initiator: "android" });296-if (androidRound.android.status !== "COMPLETED" || androidRound.browser.status !== "completed") throw new Error("Android-initiated cross-client round did not complete");297-assertTranscript(androidRound.android, androidInitiator, "Android initiator");298-assertTranscript(androidRound.browser, browserResponder, "Browser responder");299-const browserRound = await runRound({ initiator: "browser" });300-if (browserRound.android.status !== "COMPLETED" || browserRound.browser.status !== "completed") throw new Error("Browser-initiated cross-client round did not complete");301-assertTranscript(browserRound.browser, browserInitiator, "Browser initiator");302-assertTranscript(browserRound.android, androidResponder, "Android responder");303-console.log("Cross-client sync process conformance passed (both initiators, manifest negotiation, mismatch rejection).");418+/**419+ * The §7.1 matrix. Each entry names the scenario it implements, configures both420+ * clients with the same knob vocabulary, and states the observable result.421+ */422+const SCENARIOS = [423+  {424+    id: 1,425+    name: "equal versions, both unlocked, both manifest-v5",426+    initiator: "android",427+    // Same parity on both sides means identical mtimes: equal data versions.428+    android: { MTIME_PARITY: "0", EXPECT_STATUS: "COMPLETED" },429+    browser: { MTIME_PARITY: "0", EXPECT_STATUS: "completed" },430+    check(round, { completed, transcript }) {431+      completed(round, { flow: false, manifests: true, label: "scenario 1" });432+      // The equal-version fast path must not come back: the manifest exchange433+      // runs in full even when neither side has anything to send.434+      transcript(round, "scenario 1", (t) => t.events.includes("send:SYNC_MANIFEST"));435+    },436+  },437+  {438+    id: 2,439+    name: "unequal data versions, both unlocked, both manifest-v5",440+    initiator: "android",441+    // Two entries so each side is newer for one of them: entries genuinely flow442+    // in both directions rather than one side requesting nothing.443+    android: { MTIME_PARITY: "0", ENTRY_COUNT: "2", LOCAL_VERSION: "7", EXPECT_STATUS: "COMPLETED" },444+    browser: { MTIME_PARITY: "1", ENTRY_COUNT: "2", LOCAL_VERSION: "9", EXPECT_STATUS: "completed" },445+    check(round, { completed, expect }) {446+      completed(round, { flow: true, manifests: true, label: "scenario 2" });447+      expect(round.android.entriesSent > 0 && round.android.entriesReceived > 0, "scenario 2: entries must flow both ways");448+      expect(round.browser.entriesSent > 0 && round.browser.entriesReceived > 0, "scenario 2: entries must flow both ways");449+    },450+  },451+  {452+    id: 3,453+    name: "equal versions, one manifest-v5 and one manifest-v3",454+    initiator: "android",455+    android: { MTIME_PARITY: "0", ENTRY_COUNT: "2", EXPECT_STATUS: "COMPLETED" },456+    browser: { MTIME_PARITY: "0", ENTRY_COUNT: "2", MANIFEST_VERSION: "3", EXPECT_STATUS: "completed" },457+    check(round, { completed, expect }) {458+      completed(round, { flow: false, manifests: false, label: "scenario 3" });459+      expect(!allEvents(round).some((event) => event.endsWith("SYNC_DEVICE_MANIFEST")), "scenario 3: the manifest step must be skipped");460+    },461+  },462+  {463+    id: 4,464+    name: "unequal data versions, one manifest-v5 and one manifest-v3",465+    initiator: "browser",466+    android: { MTIME_PARITY: "0", ENTRY_COUNT: "2", MANIFEST_VERSION: "3", EXPECT_STATUS: "COMPLETED" },467+    browser: { MTIME_PARITY: "1", ENTRY_COUNT: "2", LOCAL_VERSION: "9", EXPECT_STATUS: "completed" },468+    check(round, { completed, expect }) {469+      completed(round, { flow: true, manifests: false, label: "scenario 4" });470+      expect(!allEvents(round).some((event) => event.endsWith("SYNC_DEVICE_MANIFEST")), "scenario 4: the manifest step must be skipped");471+    },472+  },473+  {474+    id: 9,475+    name: "manifest negotiation: no peer sends SYNC_DEVICE_MANIFEST unilaterally",476+    initiator: "android",477+    android: { MTIME_PARITY: "0", ENTRY_COUNT: "2", EXPECT_STATUS: "COMPLETED" },478+    browser: { MTIME_PARITY: "0", ENTRY_COUNT: "2", MANIFEST_VERSION: "3", EXPECT_STATUS: "completed" },479+    check(round, { expect }) {480+      // The capable side must not proceed on its own while the peer follows the481+      // legacy sequence; that used to deadlock until the 30s session timeout.482+      const events = allEvents(round);483+      expect(!events.some((event) => event.endsWith("SYNC_DEVICE_MANIFEST")), "scenario 9: a peer sent SYNC_DEVICE_MANIFEST while the other expected SYNC_DONE");484+      expect(events.filter((event) => event.endsWith("SYNC_DONE")).length === 4, "scenario 9: both sides must still reach SYNC_DONE");485+    },486+  },487+  {488+    id: 13,489+    name: "vault id mismatch stops cleanly",490+    initiator: "browser",491+    android: { MTIME_PARITY: "0", VAULT_ID: "vault-android", EXPECT_STATUS: "VAULT_MISMATCH", DRAIN: "1" },492+    browser: { MTIME_PARITY: "0", VAULT_ID: "vault-browser", EXPECT_STATUS: "vault_mismatch", DRAIN: "1" },493+    check(round, { expect, side }) {494+      expect(normalise(round.android.status) === "vault_mismatch", `scenario 13: android ${round.android.status}`);495+      expect(normalise(round.browser.status) === "vault_mismatch", `scenario 13: browser ${round.browser.status}`);496+      // The initiator names the reason and stops; the responder stops without497+      // reading another frame.498+      side(round, "browser", "scenario 13: browser initiator", ["send:SYNC_HELLO", "recv:SYNC_HELLO", "send:SYNC_ERROR"]);499+      side(round, "android", "scenario 13: android responder", ["recv:SYNC_HELLO", "send:SYNC_HELLO"]);500+      expect(!allEvents(round).some((event) => event.endsWith("SYNC_MANIFEST")), "scenario 13: no data frames may follow a vault-id mismatch");501+    },502+  },503+  {504+    id: 5,505+    name: "browser locked (spool active), android unlocked, unequal versions",506+    initiator: "android",507+    android: { MTIME_PARITY: "0", ENTRY_COUNT: "2", LOCAL_VERSION: "7", EXPECT_STATUS: "COMPLETED" },508+    browser: { MTIME_PARITY: "1", ENTRY_COUNT: "2", LOCAL_VERSION: "9", LOCKED: "1", EXPECT_STATUS: "completed" },509+    check(round, { completed, expect }) {510+      completed(round, { flow: true, manifests: false, label: "scenario 5" });511+      // The locked side stages rather than merging, and authors no manifest.512+      expect(round.browser.staged > 0, "scenario 5: the locked side must stage what it receives");513+      expect(round.browser.applied === 0, "scenario 5: a locked session must not merge into the live vault");514+      expect(round.browser.entriesReceived === round.browser.staged, "scenario 5: staged must account for everything received");515+      expect(round.android.applied > 0, "scenario 5: the unlocked side applies normally");516+      expect(!allEvents(round).some((event) => event.endsWith("SYNC_DEVICE_MANIFEST")), "scenario 5: a locked session must not exchange a manifest");517+    },518+  },519+  {520+    id: 6,521+    name: "android locked (listener running), browser unlocked",522+    initiator: "browser",523+    android: { MTIME_PARITY: "0", ENTRY_COUNT: "2", LOCKED: "1", EXPECT_STATUS: "COMPLETED" },524+    browser: { MTIME_PARITY: "1", ENTRY_COUNT: "2", EXPECT_STATUS: "completed" },525+    check(round, { completed, expect }) {526+      completed(round, { flow: true, manifests: false, label: "scenario 6" });527+      expect(round.android.staged > 0, "scenario 6: the locked side must stage what it receives");528+      expect(round.android.applied === 0, "scenario 6: a locked session must not merge into the live vault");529+      expect(round.browser.applied > 0, "scenario 6: the unlocked side applies normally");530+      expect(!allEvents(round).some((event) => event.endsWith("SYNC_DEVICE_MANIFEST")), "scenario 6: a locked session must not exchange a manifest");531+    },532+  },533+  {534+    id: 8,535+    name: "both sides locked",536+    initiator: "android",537+    android: { MTIME_PARITY: "0", ENTRY_COUNT: "2", LOCKED: "1", EXPECT_STATUS: "COMPLETED" },538+    browser: { MTIME_PARITY: "1", ENTRY_COUNT: "2", LOCKED: "1", EXPECT_STATUS: "completed" },539+    check(round, { completed, expect }) {540+      completed(round, { flow: true, manifests: false, label: "scenario 8" });541+      for (const side of ["android", "browser"]) {542+        expect(round[side].staged > 0, `scenario 8: ${side} must stage`);543+        expect(round[side].applied === 0, `scenario 8: ${side} must not merge while locked`);544+      }545+      expect(!allEvents(round).some((event) => event.endsWith("SYNC_DEVICE_MANIFEST")), "scenario 8: neither locked side may exchange a manifest");546+    },547+  },548+  {549+    id: 10,550+    name: "process death mid-session, next round completes cleanly",551+    initiator: "android",552+    // The initiator dies immediately after its own HELLO: the responder must553+    // survive that as an error, and the next round must be byte-clean — no554+    // stale frame from the dead round may leak into it.555+    android: { MTIME_PARITY: "0", ENTRY_COUNT: "2", STOP_AFTER: "SYNC_HELLO", EXPECT_STATUS: "COMPLETED" },556+    browser: { MTIME_PARITY: "1", ENTRY_COUNT: "2", EXPECT_STATUS: "errored", DRAIN: "1" },557+    expectFailure: ["android"],558+    check(round, { expect, side }) {559+      expect(normalise(round.android.status) === "died", `scenario 10: android ended ${round.android.status}`);560+      expect(normalise(round.browser.status) === "errored", `scenario 10: browser ended ${round.browser.status}`);561+      side(round, "android", "scenario 10: the dead initiator", ["send:SYNC_HELLO"]);562+    },563+    async afterwards({ completed }, { runRound: run }) {564+      const restarted = await run({565+        initiator: "android",566+        label: "scenario 10 (restarted)",567+        android: { MTIME_PARITY: "0", ENTRY_COUNT: "2", LOCAL_VERSION: "7", EXPECT_STATUS: "COMPLETED" },568+        browser: { MTIME_PARITY: "1", ENTRY_COUNT: "2", LOCAL_VERSION: "9", EXPECT_STATUS: "completed" },569+      });570+      restarted.initiatedBy = "android";571+      completed(restarted, { flow: true, manifests: true, label: "scenario 10 (restarted)" });572+    },573+  },574+  {575+    id: 15,576+    name: "10,000-entry vault with padded frames",577+    initiator: "android",578+    android: { MTIME_PARITY: "0", ENTRY_COUNT: "10000", EXPECT_STATUS: "COMPLETED" },579+    browser: { MTIME_PARITY: "1", ENTRY_COUNT: "10000", EXPECT_STATUS: "completed" },580+    timeoutMs: 300_000,581+    check(round, { completed, expect }) {582+      completed(round, { flow: true, manifests: true, label: "scenario 15" });583+      // Opposite parities, so each side is newer for exactly half the vault:584+      // all 10,000 entries cross, 5,000 in each direction.585+      for (const side of ["android", "browser"]) {586+        const sent = round[side].entriesSent;587+        const received = round[side].entriesReceived;588+        expect(sent === 5000, `scenario 15: ${side} sent ${sent}`);589+        expect(received === 5000, `scenario 15: ${side} received ${received}`);590+      }591+    },592+  },593+];594+595+const helpers = {596+  expect(condition, message) {597+    if (!condition) throw new Error(message);598+  },599+  completed(round, options) {600+    if (options.flow === undefined) throw new Error("completed() needs an explicit flow expectation");601+    const expected = expectedRound(options);602+    for (const [side, role] of [["android", round.initiatedBy === "android" ? "initiator" : "responder"], ["browser", round.initiatedBy === "browser" ? "initiator" : "responder"]]) {603+      const status = normalise(round[side].status);604+      if (status !== "completed") throw new Error(`${options.label}: ${side} ended ${round[side].status} (${round[side].error ?? "no error"})`);605+      assertTranscript(round[side], expected[role], `${options.label}: ${side} ${role}`);606+    }607+  },608+  transcript(round, label, predicate) {609+    for (const side of ["android", "browser"]) {610+      if (!predicate(round[side])) throw new Error(`${label}: unexpected transcript for ${side}: ${JSON.stringify(round[side].events)}`);611+    }612+  },613+  side(round, side, label, expected) {614+    const got = JSON.stringify(round[side].events);615+    const want = JSON.stringify(expected);616+    if (got !== want) throw new Error(`${label}: expected ${want}, got ${got}`);617+  },618+};619+620+// PEACH_SYNC_HARNESS_ONLY=13,15 runs a subset. Debugging the matrix one full621+// pass at a time is otherwise a four-minute loop.622+const only = (process.env.PEACH_SYNC_HARNESS_ONLY ?? "").split(",").filter(Boolean).map(Number);623+const selected = only.length > 0 ? SCENARIOS.filter((scenario) => only.includes(scenario.id)) : SCENARIOS;624+625+const results = [];626+for (const scenario of selected) {627+  const started = Date.now();628+  const round = await runRound({ ...scenario, label: `scenario ${scenario.id}` });629+  round.initiatedBy = scenario.initiator;630+  scenario.check(round, helpers);631+  if (scenario.afterwards) await scenario.afterwards(helpers, { runRound });632+  const elapsed = Date.now() - started;633+  results.push({ ...scenario, elapsed });634+  console.log(`  ✓ ${String(scenario.id).padStart(2)} ${scenario.name} (${(elapsed / 1000).toFixed(1)}s)`);635+}636+637+console.log("");638+console.log(`Cross-client sync process conformance passed: ${results.length} of 15 §7.1 scenarios.`);639+for (const result of results) {640+  const timing = result.id === 15 ? ` — 10,000-entry round ${(result.elapsed / 1000).toFixed(1)}s wall clock` : "";641+  console.log(`  #${result.id} ${result.name}${timing}`);642+}643+console.log("");644+console.log("Not yet covered by this gate (see the campaign §7.1 table):");645+console.log("  #7  android cold-locked — evidence class is unit + real-device, not a process transcript");646+console.log("  #11 revocation then re-vouch, #12 excessive vouch depth, #14 freshness window —");647+console.log("      these need a correctly signed adversarial manifest on the wire; the");648+console.log("      signed-manifest fixture is not built yet");649+console.log("  the adoption-v2 group, and the eight §16.3 manifest-v5 adversarial cases");

view file