feat(v2): m007 cassette_configs schema + Pydantic models (#29 v1)

Add the operator-side schema for per-machine ATM cassette inventory
(aiolabs/satmachineadmin#29). Schema choice mirrors the ATM-side
denomination-as-key invariant audited at coord-log 2026-05-30T06:40Z
across bitspire/atm-tui/src/db.zig:31, lamassu-next state-store.ts:54,
and hal-service.ts:116/189 — every ATM layer keys on denomination, so
the operator-side PK is (machine_id, denomination) to make
duplicate-denomination payloads impossible at the schema boundary.

Reserved nullable columns (state_count, state_at, state_event_id) hold
the latest bitspire-cassettes-state:<atm_pubkey_hex> event the ATM
publishes. v1 populates them on bootstrap-event receipt; v1 UI doesn't
render reconciliation. v2 reconciliation UI consumes them without a
migration.

Pydantic models in this commit:
  - CassetteConfig — read model for a stored row
  - UpsertCassetteConfigData — operator-edit form (count and/or position)
  - CassettePayloadRow — one denomination's wire-format values
  - PublishCassettesPayload — the full kind-30078 content payload,
    bidirectional (operator → ATM and ATM → operator share the shape).
    Validates int-coerced denomination keys, positive ints, no duplicate
    positions, and exposes to_wire_dict() that re-stringifies keys for
    JSON compatibility.

CRUD + transport + API + UI land in subsequent commits.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-05-30 18:00:11 +02:00
commit 13684e7134
2 changed files with 161 additions and 0 deletions

View file

@ -538,3 +538,40 @@ async def m005_lock_deposit_currency_to_machine_fiat_code(db):
AND m.fiat_code != d.currency
)
""")
async def m007_add_cassette_configs(db):
"""Add cassette_configs table for operator-driven ATM cassette inventory.
Tracks per-machine cassette state (denomination, count, position) editable
via the satmachineadmin dashboard and published to the ATM as encrypted
kind-30078 events. See aiolabs/satmachineadmin#29 + lamassu-next#56.
Schema choice: PK (machine_id, denomination) mirrors the ATM-side
denomination-as-key invariant in
bitspire/atm-tui/src/db.zig:31 and
lamassu-next/apps/machine/electron/state-store.ts:54
(the cassettes table PK is denomination; HAL inventory map keys on
denomination; dispense lookup is cassetteDenominations.indexOf
duplicates collapse silently). Position is operator-assignable display
order, not the addressable unit.
Reserved nullable columns (state_count, state_at, state_event_id) hold
the latest bitspire-cassettes-state:<atm_pubkey_hex> event the ATM
publishes (one-shot bootstrap in v1; continuous in v2). v1 UI doesn't
render them; v2 reconciliation UI consumes them without a migration.
"""
await db.execute(f"""
CREATE TABLE IF NOT EXISTS satoshimachine.cassette_configs (
machine_id TEXT NOT NULL,
denomination INTEGER NOT NULL,
count INTEGER NOT NULL,
position INTEGER NOT NULL,
updated_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now},
updated_by TEXT,
state_count INTEGER,
state_at TIMESTAMP,
state_event_id TEXT,
PRIMARY KEY (machine_id, denomination)
);
""")

124
models.py
View file

@ -546,3 +546,127 @@ class SettleBalanceData(BaseModel):
if v <= 0:
raise ValueError("amount_fiat must be > 0 if specified")
return round(float(v), 2)
# =============================================================================
# Cassette configs — operator-driven ATM cassette inventory (#29).
# =============================================================================
# Schema is denomination-keyed per the locked design (#29 body + the
# 06:40Z coord-log audit): every ATM-side layer below the wire keys on
# denomination (state-store.ts:54, hal-service.ts:116/189). The
# satmachineadmin schema mirrors this so the operator UI can't author a
# duplicate-denomination payload that the ATM would silently collapse.
#
# Position is operator-assignable display order (and used by the ATM as
# the HAL slot-index assignment), not the addressable unit.
#
# state_count / state_at / state_event_id are reserved nullable from day 1
# for the v2 reverse-channel reconciliation consumer (bitspire-cassettes-
# state:<atm_pubkey_hex>). v1 populates them on bootstrap-event receipt
# but the UI doesn't render reconciliation.
class CassetteConfig(BaseModel):
machine_id: str
denomination: int
count: int
position: int
updated_at: datetime
updated_by: Optional[str]
state_count: Optional[int]
state_at: Optional[datetime]
state_event_id: Optional[str]
class UpsertCassetteConfigData(BaseModel):
"""Operator edits a single cassette row's count or position from the
dashboard. Both fields optional; pass only those changed."""
count: Optional[int] = None
position: Optional[int] = None
@validator("count")
def count_non_negative(cls, v):
if v is None:
return v
if v < 0:
raise ValueError("count must be >= 0")
return v
@validator("position")
def position_positive(cls, v):
if v is None:
return v
if v <= 0:
raise ValueError("position must be > 0")
return v
class CassettePayloadRow(BaseModel):
"""One denomination's payload values in the wire-format
`{"denominations": {"<denom>": {"position", "count"}}}`."""
position: int
count: int
@validator("position")
def position_positive(cls, v):
if v <= 0:
raise ValueError("position must be > 0")
return v
@validator("count")
def count_non_negative(cls, v):
if v < 0:
raise ValueError("count must be >= 0")
return v
class PublishCassettesPayload(BaseModel):
"""The decrypted JSON content of a kind-30078 cassette event, both
directions:
- operator ATM (d-tag `bitspire-cassettes:<atm_pubkey_hex>`)
- ATM operator (d-tag `bitspire-cassettes-state:<atm_pubkey_hex>`)
Wire shape: `{"denominations": {"<denom_str>": {"position", "count"}}}`.
JSON object keys are always strings; the validator coerces back to
int on parse. The denomination key set MUST match what the receiver
already has (no add / no remove from this payload).
"""
denominations: dict[int, CassettePayloadRow]
@validator("denominations", pre=True)
def coerce_string_keys_to_int(cls, v):
if not isinstance(v, dict):
raise ValueError("denominations must be a dict")
out = {}
for k, val in v.items():
try:
key_int = int(k)
except (TypeError, ValueError) as exc:
raise ValueError(
f"denomination key {k!r} is not an int"
) from exc
if key_int <= 0:
raise ValueError(f"denomination must be > 0 (got {key_int})")
out[key_int] = val
return out
@validator("denominations")
def no_duplicate_positions(cls, v):
positions = [row.position for row in v.values()]
if len(set(positions)) != len(positions):
raise ValueError("duplicate position values in payload")
return v
def to_wire_dict(self) -> dict:
"""Serialise back to the wire format with string keys for JSON
object compatibility. Used by the publisher to build the kind-30078
event content before NIP-44 v2 encryption."""
return {
"denominations": {
str(denom): {"position": row.position, "count": row.count}
for denom, row in self.denominations.items()
}
}