From 474903c38bbd7337d83f32b5a3508c61bc401ec8 Mon Sep 17 00:00:00 2001 From: Padreug Date: Tue, 22 Sep 2026 22:14:06 +0200 Subject: [PATCH] fix(cassettes): say when the counts are unverified instead of reporting a guess MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the dispenser throws or the dispense times out there is no per-bay report, so nothing is debited — not the cassette rows, not HAL's bays. Bills may well have reached the customer, and both counters then read high with nothing to indicate it. The machine went on treating a number it had reason to doubt as measurement. A dispense that ends with no report now latches a countsUncertainSince flag, which rides along in the state document as counts_uncertain_since so the operator can see the numbers need a recount. The field is additive: a consumer reading positions ignores it, so this needs no coordinated release. An operator config apply clears the flag inside the same transaction, since asserting authoritative counts is precisely what a recount is. Co-Authored-By: Claude Fable 5.1 --- .../state-store-transactions.test.ts | 43 ++++++++++++++++ apps/machine/electron/main.ts | 6 +++ apps/machine/electron/preload.ts | 9 +++- apps/machine/electron/state-store.ts | 49 +++++++++++++++++++ apps/machine/src/services/operator-config.ts | 9 +++- apps/machine/src/stores/atm.ts | 13 +++++ apps/machine/src/types/electron.d.ts | 3 ++ 7 files changed, 130 insertions(+), 2 deletions(-) diff --git a/apps/machine/electron/__tests__/state-store-transactions.test.ts b/apps/machine/electron/__tests__/state-store-transactions.test.ts index cb64aa5..93c3434 100644 --- a/apps/machine/electron/__tests__/state-store-transactions.test.ts +++ b/apps/machine/electron/__tests__/state-store-transactions.test.ts @@ -12,11 +12,14 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { + applyOperatorCassettesConfig, closeDatabase, getCashbox, + getCountsUncertainSince, getInventory, initDatabase, loadCassettes, + markCountsUncertain, recordTransaction, setCassettes, } from '../state-store.js' @@ -322,3 +325,43 @@ describe('state-store: getInventory represents a drained machine', () => { expect(getInventory()).toEqual({}) }) }) + +describe('state-store: unverified counts after a silent dispense', () => { + it('starts clear, latches the first time, and keeps the earliest time', () => { + expect(getCountsUncertainSince()).toBeNull() + markCountsUncertain(1000) + expect(getCountsUncertainSince()).toBe(1000) + // A second failure does not move the clock forward — the question is how + // long the numbers have been untrustworthy, not when we last noticed. + markCountsUncertain(2000) + expect(getCountsUncertainSince()).toBe(1000) + }) + + it('clears when an operator asserts real counts', () => { + markCountsUncertain(1000) + const applied = applyOperatorCassettesConfig( + { + positions: { + '1': { denomination: 20, count: 40 }, + '2': { denomination: 20, count: 40 }, + '3': { denomination: 50, count: 25 }, + }, + }, + 1_700_000_000 + ) + expect(applied.applied).toBe(true) + // A recount is exactly the operator asserting authoritative counts. + expect(getCountsUncertainSince()).toBeNull() + }) + + it('leaves the flag alone when the operator config is rejected', () => { + markCountsUncertain(1000) + // Position key-set mismatch — the bay layout is hardware-determined. + const applied = applyOperatorCassettesConfig( + { positions: { '1': { denomination: 20, count: 40 } } }, + 1_700_000_001 + ) + expect(applied.applied).toBe(false) + expect(getCountsUncertainSince()).toBe(1000) + }) +}) diff --git a/apps/machine/electron/main.ts b/apps/machine/electron/main.ts index ae06f43..7383552 100644 --- a/apps/machine/electron/main.ts +++ b/apps/machine/electron/main.ts @@ -24,7 +24,9 @@ import { markCommandExecuting, completeCommand, getLastKnownConfigCreatedAt, + getCountsUncertainSince, getLastStatePublishedAt, + markCountsUncertain, markStatePublished, resetStatePublishWatermark, resetForRepair, @@ -556,6 +558,10 @@ ipcMain.handle('state:get-last-known-config-created-at', (): number => getLastKnownConfigCreatedAt() ) ipcMain.handle('state:get-last-state-published-at', (): number | null => getLastStatePublishedAt()) +ipcMain.handle('state:get-counts-uncertain-since', (): number | null => getCountsUncertainSince()) +ipcMain.handle('state:mark-counts-uncertain', (_event, unixTimestamp: number): void => { + markCountsUncertain(unixTimestamp) +}) ipcMain.handle('state:mark-state-published', (_event, unixTimestamp: number): void => { markStatePublished(unixTimestamp) }) diff --git a/apps/machine/electron/preload.ts b/apps/machine/electron/preload.ts index cae791e..c376170 100644 --- a/apps/machine/electron/preload.ts +++ b/apps/machine/electron/preload.ts @@ -110,6 +110,10 @@ contextBridge.exposeInMainWorld('electronAPI', { ipcRenderer.invoke('state:get-last-known-config-created-at'), getLastStatePublishedAt: (): Promise => ipcRenderer.invoke('state:get-last-state-published-at'), + getCountsUncertainSince: (): Promise => + ipcRenderer.invoke('state:get-counts-uncertain-since'), + markCountsUncertain: (unixTimestamp: number): Promise => + ipcRenderer.invoke('state:mark-counts-uncertain', unixTimestamp), markStatePublished: (unixTimestamp: number): Promise => ipcRenderer.invoke('state:mark-state-published', unixTimestamp), @@ -117,7 +121,8 @@ contextBridge.exposeInMainWorld('electronAPI', { saveBunkerBinding: (binding: BunkerBindingRecord): Promise => ipcRenderer.invoke('state:save-bunker-binding', binding), clearBunkerBinding: (): Promise => ipcRenderer.invoke('state:clear-bunker-binding'), - resetStatePublishWatermark: (): Promise => ipcRenderer.invoke('state:reset-state-publish-watermark'), + resetStatePublishWatermark: (): Promise => + ipcRenderer.invoke('state:reset-state-publish-watermark'), resetForRepair: (): Promise => ipcRenderer.invoke('state:reset-for-repair'), // QR-pairing wizard (aiolabs/bitspire#52): persist a scanned spire-seed, @@ -290,6 +295,8 @@ declare global { remediateTransaction: (txid: string, remediatedByTxid: string) => Promise getLastKnownConfigCreatedAt: () => Promise getLastStatePublishedAt: () => Promise + getCountsUncertainSince: () => Promise + markCountsUncertain: (unixTimestamp: number) => Promise markStatePublished: (unixTimestamp: number) => Promise saveBunkerBinding: (binding: BunkerBindingRecord) => Promise clearBunkerBinding: () => Promise diff --git a/apps/machine/electron/state-store.ts b/apps/machine/electron/state-store.ts index dca1170..ab3e304 100644 --- a/apps/machine/electron/state-store.ts +++ b/apps/machine/electron/state-store.ts @@ -429,6 +429,48 @@ export function getLastStatePublishedAt(): number | null { return Number.isFinite(n) ? n : null } +/** + * Whether the bay counts are known to be unverified, and since when. + * + * Set when a dispense ends without the dispenser reporting what it moved — a + * driver throw, or the dispense timeout. Bills may well have reached the + * customer, but nothing knows how many, so neither the rows here nor HAL's + * bays were debited and both now read high. Reporting that number as fact is + * the worst option available; saying the number is unverified is honest and + * tells the operator to open the machine and recount. + * + * Cleared when an operator asserts authoritative counts (a config apply), + * which is precisely what a recount is. Uses an upsert so no migration is + * needed for machines whose meta table predates the key. + */ +export function getCountsUncertainSince(): number | null { + if (!db) throw new Error('Database not initialized') + const row = db.prepare('SELECT value FROM meta WHERE key = ?').get('countsUncertainSince') as + | { value: string } + | undefined + if (!row || row.value === '') return null + const n = Number(row.value) + return Number.isFinite(n) ? n : null +} + +/** Flag the counts as unverified. Keeps the earliest time it went bad. */ +export function markCountsUncertain(unixTimestamp: number): void { + if (!db) throw new Error('Database not initialized') + if (getCountsUncertainSince() !== null) return + db.prepare( + 'INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value' + ).run('countsUncertainSince', String(unixTimestamp)) + console.warn('[StateStore] Cassette counts flagged unverified at', unixTimestamp) +} + +/** Clear the flag — an operator has asserted real counts. */ +export function clearCountsUncertain(): void { + if (!db) throw new Error('Database not initialized') + db.prepare( + 'INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value' + ).run('countsUncertainSince', '') +} + /** 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') @@ -661,11 +703,18 @@ export function applyOperatorCassettesConfig( ) const setWatermark = db.prepare('UPDATE meta SET value = ? WHERE key = ?') + const clearUncertain = db.prepare( + 'INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value' + ) + const run = db.transaction(() => { for (const [posKey, entry] of Object.entries(payload.positions)) { updateCassette.run(entry.denomination, entry.count, Number(posKey)) } setWatermark.run(String(eventCreatedAt), 'lastKnownConfigCreatedAt') + // The operator just asserted real counts, which is what a recount is. + // Whatever made the old numbers untrustworthy no longer applies. + clearUncertain.run('countsUncertainSince', '') }) run() diff --git a/apps/machine/src/services/operator-config.ts b/apps/machine/src/services/operator-config.ts index 44b1979..1627f5a 100644 --- a/apps/machine/src/services/operator-config.ts +++ b/apps/machine/src/services/operator-config.ts @@ -270,7 +270,14 @@ async function publishCassettesState( for (const c of cassettes) { positions[String(c.position)] = { denomination: c.denomination, count: c.count } } - const ciphertext = await cfg.signer.nip44Encrypt(operatorPubkey, JSON.stringify({ positions })) + // Additive field: an operator on the old consumer reads `positions` and + // ignores this, so it needs no coordinated release. When set, the counts + // above are the machine's best guess, not a measurement. + const countsUncertainSince = await api.getCountsUncertainSince() + const payload = countsUncertainSince + ? { positions, counts_uncertain_since: countsUncertainSince } + : { positions } + const ciphertext = await cfg.signer.nip44Encrypt(operatorPubkey, JSON.stringify(payload)) // Force the stamp strictly above our last one. Addressable events are ordered // by `created_at` at second granularity, ties broken by lowest event id, and diff --git a/apps/machine/src/stores/atm.ts b/apps/machine/src/stores/atm.ts index ec8b41e..23fcd2e 100644 --- a/apps/machine/src/stores/atm.ts +++ b/apps/machine/src/stores/atm.ts @@ -633,6 +633,19 @@ export const useAtmStore = defineStore('atm', () => { bills = dr.bills .filter((b) => b.dispensed > 0) .map((b) => ({ denomination: b.denomination, count: b.dispensed })) + } else { + // The dispenser threw, or the dispense timed out, so there is no + // per-bay report. Bills may well have reached the customer, and + // nothing knows how many: neither the cassette rows nor HAL's bays + // were debited, so both now read high. Record that the counts are + // unverified instead of letting a number we know may be wrong go + // on being treated as fact. + console.error( + `[ATM] Dispense ended with no report — bay counts are unverified (txid=${ctx.txid})` + ) + void window.electronAPI + ?.markCountsUncertain(Math.floor(Date.now() / 1000)) + .catch((e) => console.warn('[ATM] Could not flag counts unverified:', e)) } persistTransaction({ diff --git a/apps/machine/src/types/electron.d.ts b/apps/machine/src/types/electron.d.ts index 3cf73fe..e366f6d 100644 --- a/apps/machine/src/types/electron.d.ts +++ b/apps/machine/src/types/electron.d.ts @@ -147,6 +147,9 @@ declare global { remediateTransaction: (txid: string, remediatedByTxid: string) => Promise getLastKnownConfigCreatedAt: () => Promise getLastStatePublishedAt: () => Promise + /** When the bay counts became unverified (a dispense that reported nothing), or null. */ + getCountsUncertainSince: () => Promise + markCountsUncertain: (unixTimestamp: number) => Promise markStatePublished: (unixTimestamp: number) => Promise saveBunkerBinding: (binding: BunkerBindingRecord) => Promise clearBunkerBinding: () => Promise