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>
This commit is contained in:
parent
54c59fadcc
commit
474903c38b
7 changed files with 130 additions and 2 deletions
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
3
apps/machine/src/types/electron.d.ts
vendored
3
apps/machine/src/types/electron.d.ts
vendored
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue