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

View file

@ -3,7 +3,7 @@ Cassette-config Nostr transport — operator ↔ ATM kind-30078 publish + consum
Per the locked design at aiolabs/satmachineadmin#29 (paired with Per the locked design at aiolabs/satmachineadmin#29 (paired with
lamassu-next#56) and the dcd0874 privacy-by-default pivot, the operator lamassu-next#56) and the dcd0874 privacy-by-default pivot, the operator
publishes denomination-keyed cassette config to a target ATM via: publishes position-keyed cassette config to a target ATM via:
kind = 30078 (NIP-78, replaceable) kind = 30078 (NIP-78, replaceable)
tags = [ tags = [
@ -304,7 +304,7 @@ async def publish_to_atm(
logger.info( logger.info(
f"satmachineadmin: published kind-30078 cassette config to ATM " f"satmachineadmin: published kind-30078 cassette config to ATM "
f"{atm_pubkey_hex[:12]}... (event_id={signed['id'][:12]}..., " f"{atm_pubkey_hex[:12]}... (event_id={signed['id'][:12]}..., "
f"machine_id={machine.id}, denominations={list(payload.denominations.keys())})" f"machine_id={machine.id}, positions={sorted(payload.positions.keys())})"
) )
return signed return signed

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