feat(cassettes): schema and models for operator operations

First piece of the v2 wire (bitspire ADR-004). The operator stops
publishing counts and starts publishing what it DID; the machine, which
holds the notes, keeps the running total. A value with one writer cannot
be clobbered, which is the whole point: the absolute-count wire let a
form loaded before a dispense discard that dispense when published, and
nothing in an addressable event can tell the loser it lost.

m013 adds cassette_ops, append-only. The id is minted here and is the
idempotency key the machine dedups on, because a delta applied twice is
wrong and addressable events are re-delivered on reconnect. acked_at is
set when the machine reports that id back, which is the only
acknowledgement this transport can carry.

The models enforce that an op carries exactly the one field its type
means, so an instance is publishable by construction — the same contract
FeeConfigPayload has — and nulls never reach the wire for the machine to
disambiguate. recount is the only absolute, deliberately: it is what an
operator opening a bay and counting actually does, and it stays
auditable as its own act rather than looking like a stale form.

Vocabulary mirrors lamassu-server's cash_unit_operation_type.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-09-23 09:50:28 +02:00
commit d8190375a6
3 changed files with 348 additions and 1 deletions

View file

@ -860,3 +860,44 @@ async def m012_add_max_cash_in_sats(db):
await db.execute(
"ALTER TABLE spirekeeper.super_config ADD COLUMN max_cash_in_sats INTEGER"
)
async def m013_add_cassette_ops(db):
"""Cassette operations — the operator→ATM v2 wire (aiolabs/bitspire ADR-004).
Until now the operator published absolute counts and the ATM applied them
outright. Both sides wrote the same value over a transport that never tells
a writer it lost, so a dashboard form loaded before a dispense would
silently discard that dispense when published. The fix is to stop the
operator writing counts at all: it publishes OPERATIONS and the machine,
which holds the physical notes, owns the running count.
Each row here is one operator intent — a refill, an empty, a recount, a
denomination change. `id` is minted here and is the idempotency key the ATM
dedups on, because a delta applied twice is wrong and addressable events
are re-delivered on reconnect. `acked_at` is set when the machine reports
the id back in its state document, which is the only acknowledgement this
transport can carry.
Kept append-only on purpose: the published window is a slice of this table,
and an operation the machine has not yet acknowledged must stay publishable.
"""
await db.execute(f"""
CREATE TABLE IF NOT EXISTS spirekeeper.cassette_ops (
id TEXT PRIMARY KEY,
machine_id TEXT NOT NULL,
position INTEGER NOT NULL,
op_type TEXT NOT NULL,
bills INTEGER,
count INTEGER,
denomination INTEGER,
created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now},
created_by TEXT,
acked_at TIMESTAMP
);
""")
# The publisher reads the most recent N for a machine on every publish.
await db.execute(
"CREATE INDEX IF NOT EXISTS cassette_ops_machine_idx "
"ON cassette_ops (machine_id, created_at DESC)"
)