feat(ops): atm-reconcile — check cassette ledgers against recorded history

The cassettes table is a running total, so it can be re-derived: an
absolute truth point (a recount, or an empty) plus the refills and
dispenses since. A derived count that disagrees with the stored one is
evidence of something the ledger never saw.

Reconciliation deliberately refuses to start from a refill. A refill is
a delta, and applying deltas on top of a wrong number just carries the
error forward — which is how sintra's 20-EUR bay ran 10 notes high for
weeks while its 50-EUR bay, zeroed by an `empty` before refilling,
reconciled exactly. A bay with no baseline is reported as
unreconcilable rather than silently assumed good.

Also surfaces the two things that make a count untrustworthy: the
counts-uncertain flag, and any transaction still sitting in
dispense_error / partial.

The SQL uses scalar subqueries rather than joins on purpose — joining
transaction_bills to cassettes fans out across bays, and a LEFT JOIN
whose rows are all excluded by the baseline cutoff collapses to NULL
and poisons the arithmetic downstream (the first draft read "expected:
blank" for exactly that reason).

Exits non-zero on any gap or missing baseline so it can be run as a
check after a test session.

Refs #122
This commit is contained in:
Padreug 2026-10-09 09:40:41 +02:00
commit ee28275bf3
2 changed files with 173 additions and 0 deletions

172
deploy/nixos/atm-reconcile.sh Executable file
View file

@ -0,0 +1,172 @@
#!/usr/bin/env bash
# atm-reconcile — Check each cassette's ledger count against recorded history
#
# The cassettes table is a running total maintained by the machine: operator
# ops add to it (refill) or set it (recount / empty), and dispenses subtract
# from it. That means the count can be re-derived, and a derived value that
# disagrees with the stored one is evidence of something the ledger never saw.
#
# Reconciliation runs forward from each bay's last ABSOLUTE truth point — a
# `recount` (someone opened the bay and counted it) or an `empty` (set to
# zero). Refills are deltas and cannot serve as a baseline: a refill applied
# on top of a wrong number just carries the error forward, which is exactly
# how a bad count survives for weeks.
#
# expected = base + refills_since_base - dispensed_since_base
# gap = ledger - expected
#
# A non-zero gap means either notes moved without an op recording it, or a
# dispense moved notes the dispenser's counters did not report. The second is
# real: a note that leaves the bay and jams in the transport completes neither
# the `dispensed` nor the `rejected` counter, so the bay silently reads one
# high while the transaction row says nothing was dispensed (aiolabs/bitspire#122).
#
# A bay with NO baseline cannot be reconciled at all — its starting number
# came from somewhere unrecorded. Publish a recount for it; that is the only
# op that establishes ground truth (and the only one that clears the
# counts-uncertain flag).
#
# Usage:
# atm-reconcile # reconcile every bay
# atm-reconcile --csv # machine-readable
# atm-reconcile --quiet # exit status only, no output
#
# Exit status:
# 0 every bay reconciles
# 1 at least one bay has a non-zero gap or no baseline
# 2 database missing / unreadable
set -euo pipefail
DB="${ATM_STATE_DB:-/var/lib/bitspire/state.db}"
FORMAT="-column -header"
QUIET=false
while [[ $# -gt 0 ]]; do
case "$1" in
--csv) FORMAT="-csv -header"; shift ;;
--quiet) QUIET=true; shift ;;
-h|--help)
sed -n '2,36p' "$0" | sed 's/^# \{0,1\}//'
exit 0 ;;
*) echo "Unknown option: $1" >&2; exit 1 ;;
esac
done
if [ ! -r "$DB" ]; then
echo "ERROR: state database not readable at $DB" >&2
exit 2
fi
# Per-bay baseline, then the deltas since it. Written as scalar subqueries
# rather than joins on purpose: joining transaction_bills to cassettes fans
# out across bays, and a LEFT JOIN whose rows are all filtered out by the
# baseline cutoff collapses to NULL and poisons the arithmetic downstream.
RECONCILE_SQL="
WITH bay AS (
SELECT
c.position AS position,
c.denomination AS denomination,
c.count AS ledger,
(SELECT o.op_at FROM cassette_ops o
WHERE o.position = c.position AND o.op_type IN ('recount','empty')
ORDER BY o.op_at DESC, o.id DESC LIMIT 1) AS base_at,
(SELECT CASE o.op_type WHEN 'empty' THEN 0 ELSE o.count END FROM cassette_ops o
WHERE o.position = c.position AND o.op_type IN ('recount','empty')
ORDER BY o.op_at DESC, o.id DESC LIMIT 1) AS base_count
FROM cassettes c
),
delta AS (
SELECT
bay.*,
(SELECT COALESCE(SUM(o.bills), 0) FROM cassette_ops o
WHERE o.position = bay.position AND o.op_type = 'refill'
AND o.op_at > COALESCE(bay.base_at, -1)) AS added,
(SELECT COALESCE(SUM(tb.count), 0)
FROM transaction_bills tb
JOIN transactions t ON t.txid = tb.txid
WHERE tb.denomination = bay.denomination
AND t.type IN ('cash_out','manual_dispense')
AND t.created_at / 1000 > COALESCE(bay.base_at, -1)) AS dispensed
FROM bay
)
SELECT
position AS 'Bay',
denomination AS 'Denom',
CASE WHEN base_at IS NULL THEN '(none)' ELSE datetime(base_at,'unixepoch') END AS 'Baseline',
CASE WHEN base_at IS NULL THEN NULL ELSE base_count END AS 'Base',
added AS 'Refilled',
dispensed AS 'Dispensed',
ledger AS 'Ledger',
CASE WHEN base_at IS NULL THEN NULL
ELSE base_count + added - dispensed END AS 'Expected',
CASE WHEN base_at IS NULL THEN 'NO BASELINE'
ELSE printf('%+d', ledger - (base_count + added - dispensed)) END AS 'Gap'
FROM delta
ORDER BY position;
"
# Bays sharing a denomination break per-bay attribution: transaction_bills
# records what denomination went out, never which bay it came from, so the
# dispensed figure lands on every matching bay.
AMBIGUOUS=$(sqlite3 "$DB" \
"SELECT group_concat(denomination) FROM (
SELECT denomination FROM cassettes GROUP BY denomination HAVING COUNT(*) > 1);")
PROBLEMS=$(sqlite3 "$DB" "
WITH bay AS (
SELECT c.position AS position, c.denomination AS denomination, c.count AS ledger,
(SELECT o.op_at FROM cassette_ops o
WHERE o.position = c.position AND o.op_type IN ('recount','empty')
ORDER BY o.op_at DESC, o.id DESC LIMIT 1) AS base_at,
(SELECT CASE o.op_type WHEN 'empty' THEN 0 ELSE o.count END FROM cassette_ops o
WHERE o.position = c.position AND o.op_type IN ('recount','empty')
ORDER BY o.op_at DESC, o.id DESC LIMIT 1) AS base_count
FROM cassettes c
)
SELECT COUNT(*) FROM bay WHERE base_at IS NULL OR ledger <> (
base_count
+ (SELECT COALESCE(SUM(o.bills),0) FROM cassette_ops o
WHERE o.position = bay.position AND o.op_type = 'refill' AND o.op_at > bay.base_at)
- (SELECT COALESCE(SUM(tb.count),0) FROM transaction_bills tb
JOIN transactions t ON t.txid = tb.txid
WHERE tb.denomination = bay.denomination
AND t.type IN ('cash_out','manual_dispense')
AND t.created_at / 1000 > bay.base_at));")
if ! $QUIET; then
# shellcheck disable=SC2086 # $FORMAT must word-split into two sqlite3 flags
sqlite3 $FORMAT "$DB" "$RECONCILE_SQL"
echo
UNCERTAIN=$(sqlite3 "$DB" \
"SELECT COALESCE(NULLIF(value,''),'') FROM meta WHERE key = 'countsUncertainSince';")
if [ -n "$UNCERTAIN" ]; then
echo "Counts flagged UNVERIFIED since $(date -u -d "@$UNCERTAIN" '+%F %T UTC' 2>/dev/null || echo "$UNCERTAIN")"
echo " A dispense ended without a trustworthy report. Only a recount clears this."
else
echo "Counts not flagged unverified."
fi
echo
echo "=== Unresolved dispense failures ==="
# shellcheck disable=SC2086 # $FORMAT must word-split into two sqlite3 flags
sqlite3 $FORMAT "$DB" "
SELECT txid AS 'TX ID', status AS 'Status',
printf('%.2f', fiat_cents / 100.0) AS 'Fiat', currency AS 'Cur',
COALESCE(error,'') AS 'Error',
datetime(created_at / 1000,'unixepoch') AS 'Time (UTC)'
FROM transactions
WHERE status IN ('dispense_error','partial')
ORDER BY created_at DESC;"
if [ -n "$AMBIGUOUS" ]; then
echo
echo "WARNING: denomination(s) $AMBIGUOUS are loaded in more than one bay."
echo " Dispenses are recorded by denomination, not by bay, so the Dispensed"
echo " column double-counts across those bays. Reconcile them as a group."
fi
fi
[ "${PROBLEMS:-0}" -eq 0 ] || exit 1