fix(cassettes): publish state on every change, and make the stamps monotonic

Three linked failures in one mechanism, so one commit.

The state publish was gated on a one-shot 'have we said hello' flag. It
fired once on first boot and then only after a dispense or an applied
operator config, so any change to the layout itself — a reseed, an
atm-tui edit, direct SQL — was never announced. The operator kept
validating against a bay set the machine no longer had, and a publish
from the dashboard could overwrite a fresh seed (#94). State is now
published on every start.

A publish is one fire-and-forget event with no retry. If the relay was
unreachable at the moment of a dispense, that update was gone until the
next customer bought cash. A five-minute heartbeat makes the channel
self-healing and is also the only way an out-of-band edit to the table
ever reaches the operator.

Addressable events are ordered by created_at at second granularity with
ties broken by lowest event id, and a relay acknowledges an event it
then discards. Two publishes inside one second therefore left the winner
decided by a hash, permanently, and a clock stepping backwards would
have made every report from this machine vanish silently. Each publish
now takes a stamp strictly above the last, recorded in the meta row that
used to hold the gate — same key, no migration, honest name.

Closes #94

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-09-22 22:12:07 +02:00
commit 54c59fadcc
6 changed files with 98 additions and 77 deletions

View file

@ -24,9 +24,9 @@ import {
markCommandExecuting,
completeCommand,
getLastKnownConfigCreatedAt,
getBootstrapPublishedAt,
markBootstrapPublished,
resetBootstrapGate,
getLastStatePublishedAt,
markStatePublished,
resetStatePublishWatermark,
resetForRepair,
applyOperatorCassettesConfig,
getFeeConfig,
@ -410,7 +410,7 @@ ipcMain.handle('get-atm-secrets', () => {
})
// Bunker binding persistence — the renderer writes the binding after a
// successful pairing (connectNewSeed), and resets the bootstrap gate so the
// successful pairing (connectNewSeed), and resets the publish watermark so the
// new operator receives the spire's hello-event (aiolabs/bitspire#52 / #56).
ipcMain.handle('state:save-bunker-binding', (_event, binding: StoredBunkerBinding): void => {
saveBunkerBinding(binding)
@ -418,8 +418,8 @@ ipcMain.handle('state:save-bunker-binding', (_event, binding: StoredBunkerBindin
ipcMain.handle('state:clear-bunker-binding', (): void => {
clearBunkerBinding()
})
ipcMain.handle('state:reset-bootstrap-gate', (): void => {
resetBootstrapGate()
ipcMain.handle('state:reset-state-publish-watermark', (): void => {
resetStatePublishWatermark()
})
ipcMain.handle('state:reset-for-repair', (): void => {
resetForRepair()
@ -555,9 +555,9 @@ ipcMain.handle('state:remediate-transaction', (_event, txid: string, remediatedB
ipcMain.handle('state:get-last-known-config-created-at', (): number =>
getLastKnownConfigCreatedAt()
)
ipcMain.handle('state:get-bootstrap-published-at', (): number | null => getBootstrapPublishedAt())
ipcMain.handle('state:mark-bootstrap-published', (_event, unixTimestamp: number): void => {
markBootstrapPublished(unixTimestamp)
ipcMain.handle('state:get-last-state-published-at', (): number | null => getLastStatePublishedAt())
ipcMain.handle('state:mark-state-published', (_event, unixTimestamp: number): void => {
markStatePublished(unixTimestamp)
})
ipcMain.handle(
'state:apply-operator-cassettes-config',