refactor(v2): cassette consumer + API endpoint — position-keyed (#29 v1.1)

API endpoint:
  - api_publish_machine_cassettes validates incoming payload.positions
    set matches stored cassette_configs.position set (was: denomination
    set match). Error message updated to "slot count is hardware-fixed
    — re-provision the ATM via atm-tui to add/remove physical bays."
  - Per-row upsert loop iterates payload.positions and passes
    UpsertCassetteConfigData(denomination=, count=) — operator edits
    denomination + count for a fixed slot.

Bootstrap consumer task: just a log-message field rename (now reports
"N cassettes" from len(payload.positions) instead of len(payload.
denominations)). Per-event handler already routes through the
transport's decrypt_and_parse_state_event, which returns a
PublishCassettesPayload that's now position-keyed via the model.

Tests still red until commit f.

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

View file

@ -453,7 +453,7 @@ async def _handle_cassette_state_event(
if applied: if applied:
logger.info( logger.info(
f"satmachineadmin: applied bootstrap state event {event_id[:12]}... " f"satmachineadmin: applied bootstrap state event {event_id[:12]}... "
f"to machine {machine.id} ({len(payload.denominations)} cassettes)" f"to machine {machine.id} ({len(payload.positions)} cassettes)"
) )
else: else:
# Replay: event_id already on file. Normal on relay reconnect. # Replay: event_id already on file. Normal on relay reconnect.

View file

@ -774,15 +774,16 @@ async def api_update_super_config(
# ============================================================================= # =============================================================================
# Cassette configs (#29 v1) — per-machine ATM cassette inventory # Cassette configs (#29 v1.1) — per-machine ATM cassette inventory
# ============================================================================= # =============================================================================
# v1 surface, paired with aiolabs/lamassu-next#56 ATM-side. Two endpoints: # v1.1 surface, paired with aiolabs/lamassu-next#56 ATM-side. Two endpoints:
# GET /machines/{id}/cassettes — list rows for the operator UI # GET /machines/{id}/cassettes — list rows for the operator UI
# POST /machines/{id}/cassettes/publish — apply edits + publish kind-30078 # POST /machines/{id}/cassettes/publish — apply edits + publish kind-30078
# #
# Row creation (new (machine_id, denomination) pairs) is admin-only via the # Row creation (new (machine_id, position) pairs) is admin-only via the
# bootstrap consumer task — denomination set is hardware-determined. # bootstrap consumer task — slot count is hardware-determined. Operator-
# Operator-side flow is edit-and-publish over the existing rows only. # side flow is edit-and-publish over the existing rows only; the editable
# fields per row are denomination and count.
@satmachineadmin_api_router.get( @satmachineadmin_api_router.get(
@ -793,9 +794,9 @@ async def api_list_machine_cassettes(
machine_id: str, user: User = Depends(check_user_exists) machine_id: str, user: User = Depends(check_user_exists)
) -> list[CassetteConfig]: ) -> list[CassetteConfig]:
"""List the cassette config rows for one of the operator's machines, """List the cassette config rows for one of the operator's machines,
ordered by position then denomination. Empty list = ATM hasn't yet ordered by position. Empty list = ATM hasn't yet published its
published its bootstrap event (or the bootstrap consumer hasn't bootstrap event (or the bootstrap consumer hasn't processed it yet);
processed it yet); UI should show a "waiting for ATM" state.""" UI should show a "waiting for ATM" state."""
await _machine_owned_by(machine_id, user.id) await _machine_owned_by(machine_id, user.id)
return await list_cassette_configs_for_machine(machine_id) return await list_cassette_configs_for_machine(machine_id)
@ -810,11 +811,11 @@ async def api_publish_machine_cassettes(
user: User = Depends(check_user_exists), user: User = Depends(check_user_exists),
) -> list[CassetteConfig]: ) -> list[CassetteConfig]:
"""Operator submits the full per-machine cassette state for publish to """Operator submits the full per-machine cassette state for publish to
the ATM. Validates the denomination set matches what's currently in the ATM. Validates the position set matches what's currently in
cassette_configs for the machine (defensive UI prevents add/remove cassette_configs for the machine (slot count is hardware-fixed),
but API enforces), upserts each row, then encrypts + signs + publishes upserts each row, then encrypts + signs + publishes a kind-30078
a kind-30078 event tagged with d=bitspire-cassettes:<atm_pubkey_hex> event tagged with d=bitspire-cassettes:<atm_pubkey_hex> and
and p=<atm_pubkey_hex>. p=<atm_pubkey_hex>.
The `<m>` placeholder in the published d-tag is the ATM's hex pubkey The `<m>` placeholder in the published d-tag is the ATM's hex pubkey
from machine.machine_npub (canonicalised via normalize_public_key), from machine.machine_npub (canonicalised via normalize_public_key),
@ -825,8 +826,8 @@ async def api_publish_machine_cassettes(
can refresh its table from one round-trip. can refresh its table from one round-trip.
Errors: Errors:
400 payload denomination set doesn't match the machine's stored 400 payload position set doesn't match the machine's stored set
set (operator publishing a cassette that doesn't exist on the (operator publishing for a slot that doesn't exist on the
ATM; or the bootstrap hasn't landed yet so no rows exist) ATM; or the bootstrap hasn't landed yet so no rows exist)
400 operator hasn't onboarded a Nostr identity 400 operator hasn't onboarded a Nostr identity
503 signer offline / client-side-only, or nostrclient extension 503 signer offline / client-side-only, or nostrclient extension
@ -836,8 +837,8 @@ async def api_publish_machine_cassettes(
machine = await _machine_owned_by(machine_id, user.id) machine = await _machine_owned_by(machine_id, user.id)
existing = await list_cassette_configs_for_machine(machine_id) existing = await list_cassette_configs_for_machine(machine_id)
existing_denoms = {row.denomination for row in existing} existing_positions = {row.position for row in existing}
incoming_denoms = set(payload.denominations.keys()) incoming_positions = set(payload.positions.keys())
if not existing: if not existing:
raise HTTPException( raise HTTPException(
@ -850,29 +851,30 @@ async def api_publish_machine_cassettes(
"receipt." "receipt."
), ),
) )
if existing_denoms != incoming_denoms: if existing_positions != incoming_positions:
missing = existing_denoms - incoming_denoms missing = existing_positions - incoming_positions
extra = incoming_denoms - existing_denoms extra = incoming_positions - existing_positions
raise HTTPException( raise HTTPException(
HTTPStatus.BAD_REQUEST, HTTPStatus.BAD_REQUEST,
( (
"Payload denomination set doesn't match the machine's " "Payload position set doesn't match the machine's stored "
f"stored set. Missing from payload: {sorted(missing)}; " f"set. Missing from payload: {sorted(missing)}; extra in "
f"extra in payload: {sorted(extra)}. " f"payload: {sorted(extra)}. Slot count is hardware-fixed "
"Denomination set is hardware-determined — re-provision " "— re-provision the ATM via atm-tui to add/remove physical "
"the ATM via atm-tui to add/remove cassettes, then " "bays, then re-publish."
"re-publish."
), ),
) )
# Apply each per-row edit so the operator-believed state on # Apply each per-row edit so the operator-believed state on
# satmachineadmin reflects the published payload, even if the ATM # satmachineadmin reflects the published payload, even if the ATM
# ack lands later (v2). updated_by audit-stamps the operator user id. # ack lands later (v2). updated_by audit-stamps the operator user id.
for denom, row in payload.denominations.items(): for pos, row in payload.positions.items():
updated = await update_cassette_config( updated = await update_cassette_config(
machine_id, machine_id,
denom, pos,
UpsertCassetteConfigData(count=row.count, position=row.position), UpsertCassetteConfigData(
denomination=row.denomination, count=row.count
),
updated_by=user.id, updated_by=user.id,
) )
if updated is None: if updated is None:
@ -880,7 +882,7 @@ async def api_publish_machine_cassettes(
# concurrent delete could land between. Surface as 500. # concurrent delete could land between. Surface as 500.
raise HTTPException( raise HTTPException(
HTTPStatus.INTERNAL_SERVER_ERROR, HTTPStatus.INTERNAL_SERVER_ERROR,
f"cassette row for denomination {denom} disappeared mid-publish", f"cassette row for position {pos} disappeared mid-publish",
) )
try: try: