refactor(v2): cassette CRUD + transport — position-keyed (#29 v1.1)

CRUD layer flips:
  - get_cassette_config(machine_id, position) — was (..., denomination)
  - list_cassette_configs_for_machine returns ORDER BY position alone
    (no secondary denomination ordering — position is the unique key)
  - update_cassette_config(machine_id, position, data, updated_by) —
    operator edits denomination + count for a fixed slot
  - apply_bootstrap_state upserts ON CONFLICT(machine_id, position)
    iterating payload.positions; populates new state_denomination
    column from row.denomination alongside state_count

cassette_transport.py needs almost no functional change — the wire
shape is implicit via PublishCassettesPayload.to_wire_dict (now emits
{"positions": {...}}) and decrypt_and_parse_state_event accepts what
the model parses. Just the module docstring + the publish log line
get updated to reference positions rather than denominations.

Tests still red until commit f rewrites them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-05-30 22:25:27 +02:00
commit 5dbd7314f4
2 changed files with 38 additions and 34 deletions

68
crud.py
View file

@ -1379,14 +1379,14 @@ async def upsert_fleet_snapshot(
# =============================================================================
# Cassette configs — operator-driven ATM cassette inventory (#29).
# Cassette configs — operator-driven ATM cassette inventory (#29 v1.1).
# =============================================================================
# Row lifecycle per #29:
# - First population for a (machine_id, denomination) pair → apply_bootstrap_state
# - First population for a (machine_id, position) pair → apply_bootstrap_state
# (consumer reading the ATM's one-shot bitspire-cassettes-state event)
# - Operator edit of count or position → update_cassette_config (refuses to
# create new rows; the denomination set is hardware-determined)
# - Row creation/deletion for a new denomination → admin only, via ATM
# - Operator edit of denomination or count → update_cassette_config
# (refuses to create new rows; the slot count is hardware-determined)
# - Row creation/deletion for a new position → admin only, via ATM
# re-provisioning + new bootstrap event (not exposed in v1 here)
@ -1407,12 +1407,12 @@ def _should_apply_bootstrap_state(
async def get_cassette_config(
machine_id: str, denomination: int
machine_id: str, position: int
) -> Optional[CassetteConfig]:
return await db.fetchone(
"SELECT * FROM satoshimachine.cassette_configs "
"WHERE machine_id = :mid AND denomination = :denom",
{"mid": machine_id, "denom": denomination},
"WHERE machine_id = :mid AND position = :pos",
{"mid": machine_id, "pos": position},
CassetteConfig,
)
@ -1422,7 +1422,7 @@ async def list_cassette_configs_for_machine(
) -> List[CassetteConfig]:
return await db.fetchall(
"SELECT * FROM satoshimachine.cassette_configs "
"WHERE machine_id = :mid ORDER BY position, denomination",
"WHERE machine_id = :mid ORDER BY position",
{"mid": machine_id},
CassetteConfig,
)
@ -1430,18 +1430,18 @@ async def list_cassette_configs_for_machine(
async def update_cassette_config(
machine_id: str,
denomination: int,
position: int,
data: UpsertCassetteConfigData,
*,
updated_by: Optional[str] = None,
) -> Optional[CassetteConfig]:
"""Operator-driven row update: change count and/or position for a single
cassette. Refuses to create new rows those only land via
"""Operator-driven row update: change denomination and/or count for a
single cassette slot. Refuses to create new rows those only land via
apply_bootstrap_state() consuming an ATM bootstrap event (per #29 row
lifecycle: hardware-determined denomination set, not operator-creatable).
Returns None if the (machine_id, denomination) row doesn't exist.
lifecycle: hardware-determined slot count, not operator-creatable).
Returns None if the (machine_id, position) row doesn't exist.
"""
existing = await get_cassette_config(machine_id, denomination)
existing = await get_cassette_config(machine_id, position)
if existing is None:
return None
update_data: dict = {k: v for k, v in data.dict().items() if v is not None}
@ -1451,13 +1451,13 @@ async def update_cassette_config(
update_data["updated_by"] = updated_by
set_clause = ", ".join(f"{k} = :{k}" for k in update_data)
update_data["mid"] = machine_id
update_data["denom"] = denomination
update_data["pos"] = position
await db.execute(
f"UPDATE satoshimachine.cassette_configs SET {set_clause} "
"WHERE machine_id = :mid AND denomination = :denom",
"WHERE machine_id = :mid AND position = :pos",
update_data,
)
return await get_cassette_config(machine_id, denomination)
return await get_cassette_config(machine_id, position)
async def apply_bootstrap_state(
@ -1467,17 +1467,18 @@ async def apply_bootstrap_state(
payload: PublishCassettesPayload,
) -> bool:
"""Consume an ATM-published kind-30078 bitspire-cassettes-state:<m> event
and upsert one cassette_configs row per denomination in the payload.
and upsert one cassette_configs row per position in the payload.
Returns True if the upsert ran; False if any existing row for this
machine already references this event_id (idempotent on relay
re-delivery / restart).
Populates both the operator-believed columns (count, position,
Populates both the operator-believed columns (denomination, count,
updated_at, updated_by='atm-bootstrap') AND the v2 reverse-channel
columns (state_count, state_at, state_event_id) so the operator's
initial view matches the ATM's reported state. v2 reconciliation UI
will diverge them when continuous reverse-channel events land.
columns (state_denomination, state_count, state_at, state_event_id)
so the operator's initial view matches the ATM's reported state. v2
reconciliation UI will diverge them when continuous reverse-channel
events land + the operator subsequently edits.
"""
existing_first = await db.fetchone(
"SELECT state_event_id FROM satoshimachine.cassette_configs "
@ -1495,30 +1496,33 @@ async def apply_bootstrap_state(
return False
now = datetime.now()
for denom, row in payload.denominations.items():
for pos, row in payload.positions.items():
await db.execute(
"""
INSERT INTO satoshimachine.cassette_configs
(machine_id, denomination, count, position, updated_at,
updated_by, state_count, state_at, state_event_id)
VALUES (:mid, :denom, :count, :pos, :now, :by,
:state_count, :state_at, :event_id)
ON CONFLICT (machine_id, denomination) DO UPDATE SET
(machine_id, position, denomination, count, updated_at,
updated_by, state_denomination, state_count, state_at,
state_event_id)
VALUES (:mid, :pos, :denom, :count, :now, :by,
:state_denom, :state_count, :state_at, :event_id)
ON CONFLICT (machine_id, position) DO UPDATE SET
denomination = excluded.denomination,
count = excluded.count,
position = excluded.position,
updated_at = excluded.updated_at,
updated_by = excluded.updated_by,
state_denomination = excluded.state_denomination,
state_count = excluded.state_count,
state_at = excluded.state_at,
state_event_id = excluded.state_event_id
""",
{
"mid": machine_id,
"denom": denom,
"pos": pos,
"denom": row.denomination,
"count": row.count,
"pos": row.position,
"now": now,
"by": "atm-bootstrap",
"state_denom": row.denomination,
"state_count": row.count,
"state_at": event_created_at,
"event_id": event_id,