fix(cassettes): close the machine-side divergence paths #104

Merged
padreug merged 5 commits from fix/cassette-sync-machine into dev 2026-09-22 20:30:24 +00:00
7 changed files with 130 additions and 2 deletions
Showing only changes of commit 474903c38b - Show all commits

fix(cassettes): say when the counts are unverified instead of reporting a guess

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 <noreply@anthropic.com>
Padreug 2026-09-22 22:14:06 +02:00

View file

@ -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)
})
})

View file

@ -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)
})

View file

@ -110,6 +110,10 @@ contextBridge.exposeInMainWorld('electronAPI', {
ipcRenderer.invoke('state:get-last-known-config-created-at'),
getLastStatePublishedAt: (): Promise<number | null> =>
ipcRenderer.invoke('state:get-last-state-published-at'),
getCountsUncertainSince: (): Promise<number | null> =>
ipcRenderer.invoke('state:get-counts-uncertain-since'),
markCountsUncertain: (unixTimestamp: number): Promise<void> =>
ipcRenderer.invoke('state:mark-counts-uncertain', unixTimestamp),
markStatePublished: (unixTimestamp: number): Promise<void> =>
ipcRenderer.invoke('state:mark-state-published', unixTimestamp),
@ -117,7 +121,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
saveBunkerBinding: (binding: BunkerBindingRecord): Promise<void> =>
ipcRenderer.invoke('state:save-bunker-binding', binding),
clearBunkerBinding: (): Promise<void> => ipcRenderer.invoke('state:clear-bunker-binding'),
resetStatePublishWatermark: (): Promise<void> => ipcRenderer.invoke('state:reset-state-publish-watermark'),
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,
@ -290,6 +295,8 @@ declare global {
remediateTransaction: (txid: string, remediatedByTxid: string) => Promise<boolean>
getLastKnownConfigCreatedAt: () => Promise<number>
getLastStatePublishedAt: () => Promise<number | null>
getCountsUncertainSince: () => Promise<number | null>
markCountsUncertain: (unixTimestamp: number) => Promise<void>
markStatePublished: (unixTimestamp: number) => Promise<void>
saveBunkerBinding: (binding: BunkerBindingRecord) => Promise<void>
clearBunkerBinding: () => Promise<void>

View file

@ -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()

View file

@ -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

View file

@ -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({

View file

@ -147,6 +147,9 @@ declare global {
remediateTransaction: (txid: string, remediatedByTxid: string) => Promise<boolean>
getLastKnownConfigCreatedAt: () => Promise<number>
getLastStatePublishedAt: () => Promise<number | null>
/** When the bay counts became unverified (a dispense that reported nothing), or null. */
getCountsUncertainSince: () => Promise<number | null>
markCountsUncertain: (unixTimestamp: number) => Promise<void>
markStatePublished: (unixTimestamp: number) => Promise<void>
saveBunkerBinding: (binding: BunkerBindingRecord) => Promise<void>
clearBunkerBinding: () => Promise<void>