fix(machine): re-pair wipes the prior operator's config + watermarks (#70)

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 <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-07-02 21:13:40 +02:00
commit 4745790b40
5 changed files with 42 additions and 0 deletions

View file

@ -27,6 +27,7 @@ import {
getBootstrapPublishedAt, getBootstrapPublishedAt,
markBootstrapPublished, markBootstrapPublished,
resetBootstrapGate, resetBootstrapGate,
resetForRepair,
applyOperatorCassettesConfig, applyOperatorCassettesConfig,
getFeeConfig, getFeeConfig,
getLastKnownFeeConfigCreatedAt, getLastKnownFeeConfigCreatedAt,
@ -355,6 +356,9 @@ ipcMain.handle('state:clear-bunker-binding', (): void => {
ipcMain.handle('state:reset-bootstrap-gate', (): void => { ipcMain.handle('state:reset-bootstrap-gate', (): void => {
resetBootstrapGate() resetBootstrapGate()
}) })
ipcMain.handle('state:reset-for-repair', (): void => {
resetForRepair()
})
// QR-pairing wizard (aiolabs/bitspire#52): an unpaired machine scans a // 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 // spire-seed off its camera, and we persist it as VITE_SPIRE_SEED in the

View file

@ -118,6 +118,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
ipcRenderer.invoke('state:save-bunker-binding', binding), ipcRenderer.invoke('state:save-bunker-binding', binding),
clearBunkerBinding: (): Promise<void> => ipcRenderer.invoke('state:clear-bunker-binding'), clearBunkerBinding: (): Promise<void> => ipcRenderer.invoke('state:clear-bunker-binding'),
resetBootstrapGate: (): Promise<void> => ipcRenderer.invoke('state:reset-bootstrap-gate'), resetBootstrapGate: (): Promise<void> => ipcRenderer.invoke('state:reset-bootstrap-gate'),
resetForRepair: (): Promise<void> => ipcRenderer.invoke('state:reset-for-repair'),
// QR-pairing wizard (aiolabs/bitspire#52): persist a scanned spire-seed, // QR-pairing wizard (aiolabs/bitspire#52): persist a scanned spire-seed,
// then relaunch so the normal boot flow pairs it. // then relaunch so the normal boot flow pairs it.
@ -239,6 +240,7 @@ declare global {
saveBunkerBinding: (binding: BunkerBindingRecord) => Promise<void> saveBunkerBinding: (binding: BunkerBindingRecord) => Promise<void>
clearBunkerBinding: () => Promise<void> clearBunkerBinding: () => Promise<void>
resetBootstrapGate: () => Promise<void> resetBootstrapGate: () => Promise<void>
resetForRepair: () => Promise<void>
saveSpireSeed: (seed: string) => Promise<void> saveSpireSeed: (seed: string) => Promise<void>
relaunchApp: () => Promise<void> relaunchApp: () => Promise<void>
applyOperatorCassettesConfig: ( applyOperatorCassettesConfig: (

View file

@ -540,6 +540,32 @@ export function resetBootstrapGate(): void {
db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('', 'bootstrapPublishedAt') 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 = { export type OperatorCassettesPayload = {
positions: Record<string, { denomination: number; count: number }> positions: Record<string, { denomination: number; count: number }>
} }

View file

@ -144,6 +144,15 @@ export async function resolveSigner(opts: ResolveSignerOptions): Promise<Resolve
clientSecretHex: transport.secretHex, clientSecretHex: transport.secretHex,
}) })
if (isElectron && window.electronAPI) { if (isElectron && window.electronAPI) {
// Re-pair (a NEW seed replacing a prior binding) → wipe the previous
// operator's config/trust state (fee config + replay watermarks) so it
// can't linger or silently replay-block the new operator's config. A
// first pair (no prior binding) has nothing to reset. Cash accounting is
// preserved — see resetForRepair; a full wipe is the factory-reset path.
if (binding) {
console.log('[Signer] Re-pair (new seed fingerprint) — clearing prior operator config state')
await window.electronAPI.resetForRepair()
}
// Persist the seed's transport config alongside the binding so a later // Persist the seed's transport config alongside the binding so a later
// seedless resume still reaches the backend without env provisioning. // seedless resume still reaches the backend without env provisioning.
await window.electronAPI.saveBunkerBinding({ await window.electronAPI.saveBunkerBinding({

View file

@ -98,6 +98,7 @@ declare global {
saveBunkerBinding: (binding: BunkerBindingRecord) => Promise<void> saveBunkerBinding: (binding: BunkerBindingRecord) => Promise<void>
clearBunkerBinding: () => Promise<void> clearBunkerBinding: () => Promise<void>
resetBootstrapGate: () => Promise<void> resetBootstrapGate: () => Promise<void>
resetForRepair: () => Promise<void>
saveSpireSeed: (seed: string) => Promise<void> saveSpireSeed: (seed: string) => Promise<void>
relaunchApp: () => Promise<void> relaunchApp: () => Promise<void>
applyOperatorCassettesConfig: ( applyOperatorCassettesConfig: (