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',

View file

@ -108,16 +108,16 @@ contextBridge.exposeInMainWorld('electronAPI', {
// Operator-config consumer (aiolabs/lamassu-next#56)
getLastKnownConfigCreatedAt: (): Promise<number> =>
ipcRenderer.invoke('state:get-last-known-config-created-at'),
getBootstrapPublishedAt: (): Promise<number | null> =>
ipcRenderer.invoke('state:get-bootstrap-published-at'),
markBootstrapPublished: (unixTimestamp: number): Promise<void> =>
ipcRenderer.invoke('state:mark-bootstrap-published', unixTimestamp),
getLastStatePublishedAt: (): Promise<number | null> =>
ipcRenderer.invoke('state:get-last-state-published-at'),
markStatePublished: (unixTimestamp: number): Promise<void> =>
ipcRenderer.invoke('state:mark-state-published', unixTimestamp),
// Bunker binding persistence (aiolabs/bitspire#52)
saveBunkerBinding: (binding: BunkerBindingRecord): Promise<void> =>
ipcRenderer.invoke('state:save-bunker-binding', binding),
clearBunkerBinding: (): Promise<void> => ipcRenderer.invoke('state:clear-bunker-binding'),
resetBootstrapGate: (): Promise<void> => ipcRenderer.invoke('state:reset-bootstrap-gate'),
resetStatePublishWatermark: (): Promise<void> => ipcRenderer.invoke('state:reset-state-publish-watermark'),
resetForRepair: (): Promise<void> => ipcRenderer.invoke('state:reset-for-repair'),
// QR-pairing wizard (aiolabs/bitspire#52): persist a scanned spire-seed,
@ -289,11 +289,11 @@ declare global {
emptyCashbox: () => Promise<void>
remediateTransaction: (txid: string, remediatedByTxid: string) => Promise<boolean>
getLastKnownConfigCreatedAt: () => Promise<number>
getBootstrapPublishedAt: () => Promise<number | null>
markBootstrapPublished: (unixTimestamp: number) => Promise<void>
getLastStatePublishedAt: () => Promise<number | null>
markStatePublished: (unixTimestamp: number) => Promise<void>
saveBunkerBinding: (binding: BunkerBindingRecord) => Promise<void>
clearBunkerBinding: () => Promise<void>
resetBootstrapGate: () => Promise<void>
resetStatePublishWatermark: () => Promise<void>
resetForRepair: () => Promise<void>
saveSpireSeed: (seed: string) => Promise<void>
relaunchApp: () => Promise<void>

View file

@ -406,10 +406,20 @@ export function getLastKnownConfigCreatedAt(): number {
}
/**
* Read the one-shot bootstrap-publish gate. Returns null if the ATM has
* not yet published its `bitspire-cassettes-state:<machine_id>` hello-event.
* The `created_at` of the last `bitspire-cassettes-state` event this machine
* published, or null if it has never published one.
*
* This used to be a one-shot gate ("have we said hello yet"), which meant a
* layout change after first boot was never announced (#94). It is now a
* high-water mark: every publish records its stamp, and the next one is forced
* strictly above it. Addressable events are ordered by `created_at` at second
* granularity, and a relay silently keeps the higher one, so a clock that steps
* backwards would otherwise make this machine's reports vanish with an `OK`.
*
* Stored under the original `bootstrapPublishedAt` meta key so no migration is
* needed; the name is historical, the meaning is not.
*/
export function getBootstrapPublishedAt(): number | null {
export function getLastStatePublishedAt(): number | null {
if (!db) throw new Error('Database not initialized')
const row = db.prepare('SELECT value FROM meta WHERE key = ?').get('bootstrapPublishedAt') as
| { value: string }
@ -419,12 +429,8 @@ export function getBootstrapPublishedAt(): number | null {
return Number.isFinite(n) ? n : null
}
/**
* Mark the bootstrap hello-event as published. Idempotent — only takes
* effect the first time it's set. Subsequent calls overwrite the
* timestamp (harmless; the gate just needs to be non-null).
*/
export function markBootstrapPublished(unixTimestamp: number): void {
/** Record the `created_at` just published, as the next publish's floor. */
export function markStatePublished(unixTimestamp: number): void {
if (!db) throw new Error('Database not initialized')
db.prepare('UPDATE meta SET value = ? WHERE key = ?').run(
String(unixTimestamp),
@ -533,11 +539,12 @@ export function clearBunkerBinding(): void {
}
/**
* Reset the bootstrap-publish gate so the ATM re-publishes its
* `bitspire-cassettes-state` hello-event. Called on a re-pair (new seed) so
* the new operator receives the spire's current state (aiolabs/bitspire#56).
* Forget the publish high-water mark. Called on a re-pair (new seed): the
* next publish is then free to use the wall clock, which is what a fresh
* operator relationship wants. The state itself is republished on startup
* regardless, so the new operator always receives current counts.
*/
export function resetBootstrapGate(): void {
export function resetStatePublishWatermark(): void {
if (!db) throw new Error('Database not initialized')
db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('', 'bootstrapPublishedAt')
}