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:
parent
30870ebd16
commit
d8190375a6
3 changed files with 348 additions and 1 deletions
|
|
@ -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)"
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue