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:
Padreug 2026-09-22 22:14:06 +02:00
commit 474903c38b
7 changed files with 130 additions and 2 deletions

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>