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

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