From 4745790b40361e20b7fa3ca641aa911fa2727d56 Mon Sep 17 00:00:00 2001 From: Padreug Date: Thu, 2 Jul 2026 21:13:40 +0200 Subject: [PATCH] fix(machine): re-pair wipes the prior operator's config + watermarks (#70) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A new-seed re-pair UPSERTed the bunker binding but left fee_config, cassettes, and the created_at replay watermarks intact. The watermarks are the trap: a new backend whose first config event has a lower created_at than the old operator's last event is silently dropped as a replay, so re-pairing a long-lived install to a fresh backend appears to pair but never picks up new config. Add resetForRepair() (main-process state-store): in one transaction it clears fee_config and resets both replay watermarks to 0. Wired function → IPC (state:reset-for-repair) → preload → renderer, and called from the re-pair branch in signer-resolver, gated on an existing binding (re-pair only; a first pair has nothing to reset). Deliberately preserves cassettes/cashbox/transactions — those track PHYSICAL cash that survives an operator handover; a full wipe is the factory-reset path. Co-Authored-By: Claude Opus 4.8 --- apps/machine/electron/main.ts | 4 +++ apps/machine/electron/preload.ts | 2 ++ apps/machine/electron/state-store.ts | 26 ++++++++++++++++++++ apps/machine/src/services/signer-resolver.ts | 9 +++++++ apps/machine/src/types/electron.d.ts | 1 + 5 files changed, 42 insertions(+) diff --git a/apps/machine/electron/main.ts b/apps/machine/electron/main.ts index faedf2b..9107951 100644 --- a/apps/machine/electron/main.ts +++ b/apps/machine/electron/main.ts @@ -27,6 +27,7 @@ import { getBootstrapPublishedAt, markBootstrapPublished, resetBootstrapGate, + resetForRepair, applyOperatorCassettesConfig, getFeeConfig, getLastKnownFeeConfigCreatedAt, @@ -355,6 +356,9 @@ ipcMain.handle('state:clear-bunker-binding', (): void => { ipcMain.handle('state:reset-bootstrap-gate', (): void => { resetBootstrapGate() }) +ipcMain.handle('state:reset-for-repair', (): void => { + resetForRepair() +}) // QR-pairing wizard (aiolabs/bitspire#52): an unpaired machine scans a // spire-seed off its camera, and we persist it as VITE_SPIRE_SEED in the diff --git a/apps/machine/electron/preload.ts b/apps/machine/electron/preload.ts index ba53b62..a811eab 100644 --- a/apps/machine/electron/preload.ts +++ b/apps/machine/electron/preload.ts @@ -118,6 +118,7 @@ contextBridge.exposeInMainWorld('electronAPI', { ipcRenderer.invoke('state:save-bunker-binding', binding), clearBunkerBinding: (): Promise => ipcRenderer.invoke('state:clear-bunker-binding'), resetBootstrapGate: (): Promise => ipcRenderer.invoke('state:reset-bootstrap-gate'), + resetForRepair: (): Promise => ipcRenderer.invoke('state:reset-for-repair'), // QR-pairing wizard (aiolabs/bitspire#52): persist a scanned spire-seed, // then relaunch so the normal boot flow pairs it. @@ -239,6 +240,7 @@ declare global { saveBunkerBinding: (binding: BunkerBindingRecord) => Promise clearBunkerBinding: () => Promise resetBootstrapGate: () => Promise + resetForRepair: () => Promise saveSpireSeed: (seed: string) => Promise relaunchApp: () => Promise applyOperatorCassettesConfig: ( diff --git a/apps/machine/electron/state-store.ts b/apps/machine/electron/state-store.ts index 7ea3e16..a274565 100644 --- a/apps/machine/electron/state-store.ts +++ b/apps/machine/electron/state-store.ts @@ -540,6 +540,32 @@ export function resetBootstrapGate(): void { db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('', 'bootstrapPublishedAt') } +/** + * Wipe operator-scoped CONFIG/TRUST state on a re-pair to a new operator/backend, + * so stale policy from the previous pairing can't linger or silently reject the + * new operator's config. + * + * Clears the fee config and resets BOTH replay watermarks to 0. The watermark + * reset is the load-bearing part: without it, a new backend whose first config + * event has a lower `created_at` than the old operator's last event is silently + * dropped as a replay — the exact remnant trap where re-pairing a long-lived + * install to a fresh backend appears to "work" but never picks up new config. + * + * Deliberately does NOT touch cassettes / cashbox / transactions: those track + * PHYSICAL cash, which survives an operator handover. A full wipe (decommission + * or a truly-fresh test) is the factory-reset path, not this. + */ +export function resetForRepair(): void { + if (!db) throw new Error('Database not initialized') + const database = db + database.transaction(() => { + database.prepare('DELETE FROM fee_config').run() + const setWatermark = database.prepare('UPDATE meta SET value = ? WHERE key = ?') + setWatermark.run('0', 'lastKnownFeeConfigCreatedAt') + setWatermark.run('0', 'lastKnownConfigCreatedAt') + })() +} + export type OperatorCassettesPayload = { positions: Record } diff --git a/apps/machine/src/services/signer-resolver.ts b/apps/machine/src/services/signer-resolver.ts index 2d29ab6..f830073 100644 --- a/apps/machine/src/services/signer-resolver.ts +++ b/apps/machine/src/services/signer-resolver.ts @@ -144,6 +144,15 @@ export async function resolveSigner(opts: ResolveSignerOptions): Promise Promise clearBunkerBinding: () => Promise resetBootstrapGate: () => Promise + resetForRepair: () => Promise saveSpireSeed: (seed: string) => Promise relaunchApp: () => Promise applyOperatorCassettesConfig: (