Compare commits

..

No commits in common. "1cebefcde57d3aa9230e91fe0d2cdad8af51f9b4" and "df6e8e0a22ee5ffab492ba206273471d6734a855" have entirely different histories.

10 changed files with 226 additions and 303 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 position-keyed cassette config to a target ATM via: publishes denomination-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}, positions={sorted(payload.positions.keys())})" f"machine_id={machine.id}, denominations={list(payload.denominations.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 v1.1). # Cassette configs — operator-driven ATM cassette inventory (#29).
# ============================================================================= # =============================================================================
# Row lifecycle per #29: # Row lifecycle per #29:
# - First population for a (machine_id, position) pair → apply_bootstrap_state # - First population for a (machine_id, denomination) 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 denomination or count → update_cassette_config # - Operator edit of count or position → update_cassette_config (refuses to
# (refuses to create new rows; the slot count is hardware-determined) # create new rows; the denomination set is hardware-determined)
# - Row creation/deletion for a new position → admin only, via ATM # - Row creation/deletion for a new denomination → 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, position: int machine_id: str, denomination: 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 position = :pos", "WHERE machine_id = :mid AND denomination = :denom",
{"mid": machine_id, "pos": position}, {"mid": machine_id, "denom": denomination},
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", "WHERE machine_id = :mid ORDER BY position, denomination",
{"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,
position: int, denomination: int,
data: UpsertCassetteConfigData, data: UpsertCassetteConfigData,
*, *,
updated_by: Optional[str] = None, updated_by: Optional[str] = None,
) -> Optional[CassetteConfig]: ) -> Optional[CassetteConfig]:
"""Operator-driven row update: change denomination and/or count for a """Operator-driven row update: change count and/or position for a single
single cassette slot. Refuses to create new rows those only land via cassette. 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 slot count, not operator-creatable). lifecycle: hardware-determined denomination set, not operator-creatable).
Returns None if the (machine_id, position) row doesn't exist. Returns None if the (machine_id, denomination) row doesn't exist.
""" """
existing = await get_cassette_config(machine_id, position) existing = await get_cassette_config(machine_id, denomination)
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["pos"] = position update_data["denom"] = denomination
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 position = :pos", "WHERE machine_id = :mid AND denomination = :denom",
update_data, update_data,
) )
return await get_cassette_config(machine_id, position) return await get_cassette_config(machine_id, denomination)
async def apply_bootstrap_state( async def apply_bootstrap_state(
@ -1467,18 +1467,17 @@ 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 position in the payload. and upsert one cassette_configs row per denomination 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 (denomination, count, Populates both the operator-believed columns (count, position,
updated_at, updated_by='atm-bootstrap') AND the v2 reverse-channel updated_at, updated_by='atm-bootstrap') AND the v2 reverse-channel
columns (state_denomination, state_count, state_at, state_event_id) columns (state_count, state_at, state_event_id) so the operator's
so the operator's initial view matches the ATM's reported state. v2 initial view matches the ATM's reported state. v2 reconciliation UI
reconciliation UI will diverge them when continuous reverse-channel will diverge them when continuous reverse-channel events land.
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 "
@ -1496,33 +1495,30 @@ async def apply_bootstrap_state(
return False return False
now = datetime.now() now = datetime.now()
for pos, row in payload.positions.items(): for denom, row in payload.denominations.items():
await db.execute( await db.execute(
""" """
INSERT INTO satoshimachine.cassette_configs INSERT INTO satoshimachine.cassette_configs
(machine_id, position, denomination, count, updated_at, (machine_id, denomination, count, position, updated_at,
updated_by, state_denomination, state_count, state_at, updated_by, state_count, state_at, state_event_id)
state_event_id) VALUES (:mid, :denom, :count, :pos, :now, :by,
VALUES (:mid, :pos, :denom, :count, :now, :by, :state_count, :state_at, :event_id)
:state_denom, :state_count, :state_at, :event_id) ON CONFLICT (machine_id, denomination) DO UPDATE SET
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,
"pos": pos, "denom": denom,
"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,

111
models.py
View file

@ -549,64 +549,41 @@ class SettleBalanceData(BaseModel):
# ============================================================================= # =============================================================================
# Cassette configs — operator-driven ATM cassette inventory (#29 v1.1). # Cassette configs — operator-driven ATM cassette inventory (#29).
# ============================================================================= # =============================================================================
# Schema is position-keyed per the coordinated v1.1 redesign at coord-log # Schema is denomination-keyed per the locked design (#29 body + the
# 2026-05-30T18:30Z + 18:45Z. The earlier denomination-keyed shape (m007) # 06:40Z coord-log audit): every ATM-side layer below the wire keys on
# was wrong: real machines have N cassettes of the same denomination for # denomination (state-store.ts:54, hal-service.ts:116/189). The
# cash-out throughput, and operators need to swap cartridge denominations # satmachineadmin schema mirrors this so the operator UI can't author a
# during refill ($20 bay becomes a $50 bay) without re-provisioning. # duplicate-denomination payload that the ATM would silently collapse.
# #
# Wire shape: # Position is operator-assignable display order (and used by the ATM as
# {"positions": {"<position_str>": {"denomination": N, "count": M}}} # the HAL slot-index assignment), not the addressable unit.
# #
# Editable surface per row: # state_count / state_at / state_event_id are reserved nullable from day 1
# - denomination: yes (operator swaps cartridges during refill) # for the v2 reverse-channel reconciliation consumer (bitspire-cassettes-
# - count: yes (refill / decrement) # state:<atm_pubkey_hex>). v1 populates them on bootstrap-event receipt
# Read-only per row: # but the UI doesn't render reconciliation.
# - position: hardware bay number; the slot count is fixed by the
# dispenser model (e.g., Tejo has 4 positions).
#
# No "denomination must be unique within payload" constraint: multiple
# same-denom cassettes are operationally valid. The ATM HAL distributes
# a dispense request greedy across all positions matching the requested
# denomination (lamassu-next#56 v1.1 HAL refactor).
#
# state_* columns are reserved nullable 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. state_denomination (added in m008) lets v2 highlight
# operator-believed-vs-ATM-reported denomination drift per slot.
class CassetteConfig(BaseModel): class CassetteConfig(BaseModel):
machine_id: str machine_id: str
position: int
denomination: int denomination: int
count: int count: int
position: int
updated_at: datetime updated_at: datetime
updated_by: Optional[str] updated_by: Optional[str]
state_denomination: Optional[int]
state_count: Optional[int] state_count: Optional[int]
state_at: Optional[datetime] state_at: Optional[datetime]
state_event_id: Optional[str] state_event_id: Optional[str]
class UpsertCassetteConfigData(BaseModel): class UpsertCassetteConfigData(BaseModel):
"""Operator edits a single cassette row's denomination or count from """Operator edits a single cassette row's count or position from the
the dashboard. Both fields optional; pass only those changed. dashboard. Both fields optional; pass only those changed."""
Position is not edited it's the row's identity (hardware bay)."""
denomination: Optional[int] = None
count: Optional[int] = None count: Optional[int] = None
position: Optional[int] = None
@validator("denomination")
def denomination_positive(cls, v):
if v is None:
return v
if v <= 0:
raise ValueError("denomination must be > 0")
return v
@validator("count") @validator("count")
def count_non_negative(cls, v): def count_non_negative(cls, v):
@ -616,18 +593,26 @@ class UpsertCassetteConfigData(BaseModel):
raise ValueError("count must be >= 0") raise ValueError("count must be >= 0")
return v 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): class CassettePayloadRow(BaseModel):
"""One position's payload values in the wire-format """One denomination's payload values in the wire-format
`{"positions": {"<pos>": {"denomination", "count"}}}`.""" `{"denominations": {"<denom>": {"position", "count"}}}`."""
denomination: int position: int
count: int count: int
@validator("denomination") @validator("position")
def denomination_positive(cls, v): def position_positive(cls, v):
if v <= 0: if v <= 0:
raise ValueError("denomination must be > 0") raise ValueError("position must be > 0")
return v return v
@validator("count") @validator("count")
@ -643,45 +628,45 @@ class PublishCassettesPayload(BaseModel):
- operator ATM (d-tag `bitspire-cassettes:<atm_pubkey_hex>`) - operator ATM (d-tag `bitspire-cassettes:<atm_pubkey_hex>`)
- ATM operator (d-tag `bitspire-cassettes-state:<atm_pubkey_hex>`) - ATM operator (d-tag `bitspire-cassettes-state:<atm_pubkey_hex>`)
Wire shape: `{"positions": {"<pos_str>": {"denomination", "count"}}}`. Wire shape: `{"denominations": {"<denom_str>": {"position", "count"}}}`.
JSON object keys are always strings; the validator coerces back to JSON object keys are always strings; the validator coerces back to
int on parse. The position key set MUST match what the receiver int on parse. The denomination key set MUST match what the receiver
already has (slot count is hardware-fixed; no add/remove from this already has (no add / no remove from this payload).
payload).
No denomination-unique constraint: multiple same-denom cassettes are
operationally valid (cash-out throughput on a popular denom).
""" """
positions: dict[int, CassettePayloadRow] denominations: dict[int, CassettePayloadRow]
@validator("positions", pre=True) @validator("denominations", pre=True)
def coerce_string_keys_to_int(cls, v): def coerce_string_keys_to_int(cls, v):
if not isinstance(v, dict): if not isinstance(v, dict):
raise ValueError("positions must be a dict") raise ValueError("denominations must be a dict")
out = {} out = {}
for k, val in v.items(): for k, val in v.items():
try: try:
key_int = int(k) key_int = int(k)
except (TypeError, ValueError) as exc: except (TypeError, ValueError) as exc:
raise ValueError( raise ValueError(
f"position key {k!r} is not an int" f"denomination key {k!r} is not an int"
) from exc ) from exc
if key_int <= 0: if key_int <= 0:
raise ValueError(f"position must be > 0 (got {key_int})") raise ValueError(f"denomination must be > 0 (got {key_int})")
out[key_int] = val out[key_int] = val
return out 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: def to_wire_dict(self) -> dict:
"""Serialise back to the wire format with string keys for JSON """Serialise back to the wire format with string keys for JSON
object compatibility. Used by the publisher to build the kind-30078 object compatibility. Used by the publisher to build the kind-30078
event content before NIP-44 v2 encryption.""" event content before NIP-44 v2 encryption."""
return { return {
"positions": { "denominations": {
str(pos): { str(denom): {"position": row.position, "count": row.count}
"denomination": row.denomination, for denom, row in self.denominations.items()
"count": row.count,
}
for pos, row in self.positions.items()
} }
} }

View file

@ -205,10 +205,10 @@ window.app = Vue.createApp({
}, },
cassettesTable: { cassettesTable: {
columns: [ columns: [
{name: 'position', label: 'Bay', field: 'position', align: 'right'},
{name: 'denomination', label: 'Denomination', field: 'denomination', align: 'right'}, {name: 'denomination', label: 'Denomination', field: 'denomination', align: 'right'},
{name: 'count', label: 'Count', field: 'count', align: 'right'}, {name: 'count', label: 'Count', field: 'count', align: 'right'},
{name: 'state', label: 'ATM-reported', field: 'state_denomination', align: 'right'}, {name: 'position', label: 'Position', field: 'position', align: 'right'},
{name: 'state_count', label: 'ATM-reported', field: 'state_count', align: 'right'},
{name: 'updated_at', label: 'Updated', field: 'updated_at', align: 'left'} {name: 'updated_at', label: 'Updated', field: 'updated_at', align: 'left'}
], ],
pagination: {rowsPerPage: 0} // hide pagination — cassette count is small pagination: {rowsPerPage: 0} // hide pagination — cassette count is small
@ -816,16 +816,15 @@ window.app = Vue.createApp({
}, },
markCassetteDirty(row) { markCassetteDirty(row) {
// Find pristine match by position (the row identity) and compare; // Find pristine match by denomination and compare; flip _dirty +
// flip _dirty + overall dirty flag accordingly. Editable fields // overall dirty flag accordingly.
// are denomination + count; position is the immutable row key.
const pristine = this.machineDetail.cassettesPristine.find( const pristine = this.machineDetail.cassettesPristine.find(
p => p.position === row.position p => p.denomination === row.denomination
) )
row._dirty = row._dirty =
!pristine || !pristine ||
Number(row.denomination) !== Number(pristine.denomination) || Number(row.count) !== Number(pristine.count) ||
Number(row.count) !== Number(pristine.count) Number(row.position) !== Number(pristine.position)
this.machineDetail.cassettesDirty = this.machineDetail.cassettesDirty =
this.machineDetail.cassetteEdits.some(r => r._dirty) this.machineDetail.cassetteEdits.some(r => r._dirty)
}, },
@ -845,18 +844,18 @@ window.app = Vue.createApp({
}, },
async submitCassettePublish() { async submitCassettePublish() {
// Build the PublishCassettesPayload shape (v1.1, position-keyed): // Build the PublishCassettesPayload shape:
// { positions: { "<pos>": { denomination, count }, ... } } // { denominations: { "<denom>": { position, count }, ... } }
// The API enforces the position set matches what's stored — // The API enforces the denomination set matches what's stored —
// since we only edit existing rows, this should always pass. // since we only edit existing rows, this should always pass.
const positions = {} const denominations = {}
for (const row of this.machineDetail.cassetteEdits) { for (const row of this.machineDetail.cassetteEdits) {
positions[String(row.position)] = { denominations[String(row.denomination)] = {
denomination: Number(row.denomination), position: Number(row.position),
count: Number(row.count) count: Number(row.count)
} }
} }
const payload = {positions} const payload = {denominations}
this.machineDetail.cassettesPublishing = true this.machineDetail.cassettesPublishing = true
try { try {
const {data} = await LNbits.api.request( const {data} = await LNbits.api.request(

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.positions)} cassettes)" f"to machine {machine.id} ({len(payload.denominations)} 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

@ -1023,7 +1023,7 @@
<q-table v-if="machineDetail.cassetteEdits.length" <q-table v-if="machineDetail.cassetteEdits.length"
dense flat dense flat
:rows="machineDetail.cassetteEdits" :rows="machineDetail.cassetteEdits"
row-key="position" row-key="denomination"
:columns="cassettesTable.columns" :columns="cassettesTable.columns"
:pagination="cassettesTable.pagination" :pagination="cassettesTable.pagination"
hide-pagination> hide-pagination>
@ -1032,15 +1032,10 @@
:style="props.row._dirty :style="props.row._dirty
? {boxShadow: 'inset 4px 0 0 0 #fdd835'} ? {boxShadow: 'inset 4px 0 0 0 #fdd835'}
: {}"> : {}">
<q-td key="position" class="text-right">
<b v-text="'Bay ' + props.row.position"></b>
</q-td>
<q-td key="denomination" class="text-right"> <q-td key="denomination" class="text-right">
<q-input v-model.number="props.row.denomination" <b v-text="props.row.denomination"></b>
type="number" min="1" step="1" dense outlined <span :style="{fontSize: '0.85em', opacity: 0.6}"
:suffix="machineDetail.machine.fiat_code || ''" v-text="' ' + (machineDetail.machine.fiat_code || '')"></span>
:style="{width: '140px', display: 'inline-block'}"
@update:model-value="markCassetteDirty(props.row)"></q-input>
</q-td> </q-td>
<q-td key="count" class="text-right"> <q-td key="count" class="text-right">
<q-input v-model.number="props.row.count" type="number" <q-input v-model.number="props.row.count" type="number"
@ -1048,15 +1043,16 @@
:style="{width: '120px', display: 'inline-block'}" :style="{width: '120px', display: 'inline-block'}"
@update:model-value="markCassetteDirty(props.row)"></q-input> @update:model-value="markCassetteDirty(props.row)"></q-input>
</q-td> </q-td>
<q-td key="state" class="text-right"> <q-td key="position" class="text-right">
<span v-if="props.row.state_denomination !== null" <q-input v-model.number="props.row.position" type="number"
:style="{fontSize: '0.85em', opacity: 0.7}"> min="1" step="1" dense outlined
<span v-text="props.row.state_denomination"></span> :style="{width: '80px', display: 'inline-block'}"
<span :style="{opacity: 0.6}" @update:model-value="markCassetteDirty(props.row)"></q-input>
v-text="' ' + (machineDetail.machine.fiat_code || '')"></span> </q-td>
<span :style="{opacity: 0.6}"> · </span> <q-td key="state_count" class="text-right">
<span v-text="'×' + props.row.state_count"></span> <span v-if="props.row.state_count !== null"
</span> :style="{fontSize: '0.85em', opacity: 0.7}"
v-text="props.row.state_count"></span>
<span v-else :style="{opacity: 0.4}"></span> <span v-else :style="{opacity: 0.4}"></span>
</q-td> </q-td>
<q-td key="updated_at"> <q-td key="updated_at">
@ -1098,16 +1094,17 @@
<p class="q-mb-sm">Sending to ATM:</p> <p class="q-mb-sm">Sending to ATM:</p>
<q-list dense bordered> <q-list dense bordered>
<q-item v-for="row in machineDetail.cassetteEdits" <q-item v-for="row in machineDetail.cassetteEdits"
:key="row.position"> :key="row.denomination">
<q-item-section> <q-item-section>
<q-item-label> <q-item-label>
<b v-text="'Bay ' + row.position"></b> <b v-text="row.denomination + ' ' +
(machineDetail.machine.fiat_code || '')"></b>
</q-item-label> </q-item-label>
</q-item-section> </q-item-section>
<q-item-section side> <q-item-section side>
<q-item-label caption> <q-item-label caption>
<b v-text="row.denomination + ' ' + position
(machineDetail.machine.fiat_code || '')"></b> <b v-text="row.position"></b>
· count · count
<b v-text="row.count"></b> <b v-text="row.count"></b>
</q-item-label> </q-item-label>

View file

@ -1,10 +1,10 @@
""" """
Tests for the v1.1 cassette-config layer (aiolabs/satmachineadmin#29). Tests for the v1 cassette-config layer (aiolabs/satmachineadmin#29).
Covers the pure pieces that don't need a live DB: Covers the pure pieces that don't need a live DB:
- Pydantic validator behaviour on PublishCassettesPayload + the row / - Pydantic validator behaviour on PublishCassettesPayload + the row /
upsert models (position key coercion, integer ranges, multiple-same- upsert models (denomination key coercion, integer ranges, no-duplicate-
denomination payloads, wire-format round-trip) positions, wire-format round-trip)
- _should_apply_bootstrap_state dedup helper (extracted from - _should_apply_bootstrap_state dedup helper (extracted from
apply_bootstrap_state so the relay-re-delivery decision is testable apply_bootstrap_state so the relay-re-delivery decision is testable
without a database round-trip) without a database round-trip)
@ -14,11 +14,6 @@ machine ordering, etc.) follow the project convention from
test_deposit_currency.py: "Layer 2 is an endpoint-level behaviour better test_deposit_currency.py: "Layer 2 is an endpoint-level behaviour better
covered by an integration test against a running LNbits; tracked in #26 covered by an integration test against a running LNbits; tracked in #26
as a follow-up." Smoke-tested manually via the dev container. as a follow-up." Smoke-tested manually via the dev container.
Wire shape pivot from m007 m008 is the v1.1 coordination point per
coord-log 2026-05-30T18:30Z + 18:45Z: position is the row identity,
denomination + count are operator-editable per row, multiple same-denom
cassettes are valid.
""" """
import pytest import pytest
@ -39,127 +34,116 @@ from ..models import (
class TestPublishCassettesPayload: class TestPublishCassettesPayload:
"""The kind-30078 content payload, bidirectional (operator→ATM and """The kind-30078 content payload, bidirectional (operator→ATM and
ATMoperator share the shape). String JSON keys must coerce to int; ATMoperator share the shape). String JSON keys must coerce to int;
per-row int constraints enforced; multiple same-denom rows are valid.""" duplicate positions must reject; per-row int constraints enforced."""
def test_happy_path_coerces_string_keys_to_int(self): def test_happy_path_coerces_string_keys_to_int(self):
p = PublishCassettesPayload( p = PublishCassettesPayload(
positions={ denominations={
"1": {"denomination": 20, "count": 49}, "20": {"position": 1, "count": 49},
"2": {"denomination": 50, "count": 100}, "50": {"position": 2, "count": 100},
} }
) )
assert set(p.positions.keys()) == {1, 2} assert set(p.denominations.keys()) == {20, 50}
assert p.positions[1].denomination == 20 assert p.denominations[20].position == 1
assert p.positions[1].count == 49 assert p.denominations[20].count == 49
assert p.positions[2].denomination == 50 assert p.denominations[50].count == 100
assert p.positions[2].count == 100
def test_wire_dict_round_trip_restringifies_keys(self): def test_wire_dict_round_trip_restringifies_keys(self):
"""to_wire_dict() must restringify position keys so the resulting """to_wire_dict() must restringify denomination keys so the
JSON is parseable by clients (including the ATM-side nostr-tools resulting JSON is parseable by clients (including the ATM-side
NIP-44 v2 consumer per the byte-compat cross-test).""" nostr-tools NIP-44 v2 consumer per the byte-compat cross-test)."""
original = PublishCassettesPayload( original = PublishCassettesPayload(
positions={ denominations={
"1": {"denomination": 20, "count": 49}, "20": {"position": 1, "count": 49},
"2": {"denomination": 50, "count": 100}, "50": {"position": 2, "count": 100},
} }
) )
wire = original.to_wire_dict() wire = original.to_wire_dict()
assert wire == { assert wire == {
"positions": { "denominations": {
"1": {"denomination": 20, "count": 49}, "20": {"position": 1, "count": 49},
"2": {"denomination": 50, "count": 100}, "50": {"position": 2, "count": 100},
} }
} }
# And the wire form round-trips back through the parser cleanly. # And the wire form round-trips back through the parser cleanly.
reparsed = PublishCassettesPayload(**wire) reparsed = PublishCassettesPayload(**wire)
assert reparsed.positions == original.positions assert reparsed.denominations == original.denominations
def test_accepts_multiple_same_denomination_cassettes(self): def test_rejects_non_int_key(self):
"""v1.1 operational case: real machines have N cassettes loaded
with the same denomination for cash-out throughput. The wire shape
must accept this, and we explicitly do NOT validate uniqueness on
denomination. Coord-log 2026-05-30T18:45Z bitspire response."""
p = PublishCassettesPayload(
positions={
"1": {"denomination": 20, "count": 100},
"2": {"denomination": 20, "count": 100},
"3": {"denomination": 50, "count": 50},
"4": {"denomination": 100, "count": 25},
}
)
assert len(p.positions) == 4
denoms = [row.denomination for row in p.positions.values()]
assert denoms.count(20) == 2 # two $20 cassettes
assert sorted(denoms) == [20, 20, 50, 100]
def test_rejects_non_int_position_key(self):
with pytest.raises(ValueError) as exc: with pytest.raises(ValueError) as exc:
PublishCassettesPayload( PublishCassettesPayload(
positions={"abc": {"denomination": 20, "count": 1}} denominations={"abc": {"position": 1, "count": 1}}
) )
assert "is not an int" in str(exc.value) assert "is not an int" in str(exc.value)
def test_rejects_non_positive_position(self): def test_rejects_non_positive_denomination(self):
with pytest.raises(ValueError) as exc: with pytest.raises(ValueError) as exc:
PublishCassettesPayload( PublishCassettesPayload(
positions={"0": {"denomination": 20, "count": 1}} denominations={"0": {"position": 1, "count": 1}}
) )
assert "position must be > 0" in str(exc.value) assert "denomination must be > 0" in str(exc.value)
def test_rejects_negative_position(self): def test_rejects_negative_denomination(self):
with pytest.raises(ValueError) as exc: with pytest.raises(ValueError) as exc:
PublishCassettesPayload( PublishCassettesPayload(
positions={"-1": {"denomination": 20, "count": 1}} denominations={"-20": {"position": 1, "count": 1}}
) )
assert "position must be > 0" in str(exc.value) assert "denomination must be > 0" in str(exc.value)
def test_rejects_duplicate_position(self):
"""Two cassettes can't occupy the same physical slot. The schema
PK is (machine_id, denomination), so duplicates land via the
payload; reject at the validator layer before the publish path
builds an event the ATM will misinterpret."""
with pytest.raises(ValueError) as exc:
PublishCassettesPayload(
denominations={
"20": {"position": 1, "count": 49},
"50": {"position": 1, "count": 100},
}
)
assert "duplicate position" in str(exc.value)
def test_rejects_negative_count(self): def test_rejects_negative_count(self):
with pytest.raises(ValueError): with pytest.raises(ValueError):
PublishCassettesPayload( PublishCassettesPayload(
positions={"1": {"denomination": 20, "count": -1}} denominations={"20": {"position": 1, "count": -1}}
) )
def test_rejects_zero_denomination(self): def test_rejects_zero_position(self):
with pytest.raises(ValueError): with pytest.raises(ValueError):
PublishCassettesPayload( PublishCassettesPayload(
positions={"1": {"denomination": 0, "count": 49}} denominations={"20": {"position": 0, "count": 1}}
)
def test_rejects_negative_denomination(self):
with pytest.raises(ValueError):
PublishCassettesPayload(
positions={"1": {"denomination": -20, "count": 49}}
) )
def test_allows_zero_count(self): def test_allows_zero_count(self):
"""An empty cassette is a legal state — operator must be able to """An empty cassette is a legal state — operator must be able to
record `count=0` after a dispatcher pulled the cassette mid-day.""" record `count=0` after a dispatcher pulled the cassette mid-day."""
p = PublishCassettesPayload( p = PublishCassettesPayload(
positions={"1": {"denomination": 20, "count": 0}} denominations={"20": {"position": 1, "count": 0}}
) )
assert p.positions[1].count == 0 assert p.denominations[20].count == 0
# ============================================================================= # =============================================================================
# CassettePayloadRow — per-row int constraints # CassettePayloadRow — per-row int constraints (single-row tests)
# ============================================================================= # =============================================================================
class TestCassettePayloadRow: class TestCassettePayloadRow:
def test_happy_path(self): def test_happy_path(self):
row = CassettePayloadRow(denomination=20, count=49) row = CassettePayloadRow(position=1, count=49)
assert row.denomination == 20 assert row.position == 1
assert row.count == 49 assert row.count == 49
@pytest.mark.parametrize("bad_denom", [0, -1, -100]) @pytest.mark.parametrize("bad_position", [0, -1, -100])
def test_rejects_non_positive_denomination(self, bad_denom): def test_rejects_non_positive_position(self, bad_position):
with pytest.raises(ValueError): with pytest.raises(ValueError):
CassettePayloadRow(denomination=bad_denom, count=1) CassettePayloadRow(position=bad_position, count=1)
def test_rejects_negative_count(self): def test_rejects_negative_count(self):
with pytest.raises(ValueError): with pytest.raises(ValueError):
CassettePayloadRow(denomination=20, count=-1) CassettePayloadRow(position=1, count=-1)
# ============================================================================= # =============================================================================
@ -169,19 +153,16 @@ class TestCassettePayloadRow:
class TestUpsertCassetteConfigData: class TestUpsertCassetteConfigData:
"""Operator-driven row edit. Both fields optional; same int constraints """Operator-driven row edit. Both fields optional; same int constraints
as the wire-format row but applied independently per-edit. Position is as the wire-format row but applied independently per-edit."""
NOT editable it's the row's identity (the hardware bay number)."""
def test_partial_update_count_only(self): def test_partial_update_count_only(self):
d = UpsertCassetteConfigData(count=80) d = UpsertCassetteConfigData(count=80)
assert d.count == 80 assert d.count == 80
assert d.denomination is None assert d.position is None
def test_partial_update_denomination_only(self): def test_partial_update_position_only(self):
"""v1.1 operational case: operator records a cartridge swap at d = UpsertCassetteConfigData(position=3)
refill slot 1 was $20, dispatcher replaced with $50.""" assert d.position == 3
d = UpsertCassetteConfigData(denomination=50)
assert d.denomination == 50
assert d.count is None assert d.count is None
def test_empty_update_is_legal(self): def test_empty_update_is_legal(self):
@ -189,15 +170,15 @@ class TestUpsertCassetteConfigData:
circuits a no-op on empty payload (no SQL emitted).""" circuits a no-op on empty payload (no SQL emitted)."""
d = UpsertCassetteConfigData() d = UpsertCassetteConfigData()
assert d.count is None assert d.count is None
assert d.denomination is None assert d.position is None
def test_rejects_negative_count(self): def test_rejects_negative_count(self):
with pytest.raises(ValueError): with pytest.raises(ValueError):
UpsertCassetteConfigData(count=-1) UpsertCassetteConfigData(count=-1)
def test_rejects_non_positive_denomination(self): def test_rejects_non_positive_position(self):
with pytest.raises(ValueError): with pytest.raises(ValueError):
UpsertCassetteConfigData(denomination=0) UpsertCassetteConfigData(position=0)
# ============================================================================= # =============================================================================
@ -210,7 +191,7 @@ class TestShouldApplyBootstrapState:
decision is testable without a DB. Logic: apply if-and-only-if the decision is testable without a DB. Logic: apply if-and-only-if the
existing row's state_event_id differs from the incoming event_id. existing row's state_event_id differs from the incoming event_id.
In v1.1 the ATM publishes the bootstrap event exactly once per machine, In v1 the ATM publishes the bootstrap event exactly once per machine,
so this is sufficient for replay protection. v2 will need a so this is sufficient for replay protection. v2 will need a
`last_state_created_at` watermark in addition (per bitspire's `last_state_created_at` watermark in addition (per bitspire's
`meta.lastKnownConfigCreatedAt` on the ATM side) flagged in #29's `meta.lastKnownConfigCreatedAt` on the ATM side) flagged in #29's

View file

@ -5,14 +5,13 @@ and `cassette_transport.decrypt_and_parse_state_event`).
Covers the consumer-side validation path end-to-end without standing up Covers the consumer-side validation path end-to-end without standing up
the full nostrclient relay subscription: the full nostrclient relay subscription:
- happy path: signed event from a known ATM decrypt parse returns - happy path: signed event from a known ATM decrypt parse returns
a position-keyed PublishCassettesPayload a PublishCassettesPayload
- multiple same-denom cassettes (v1.1 operational case) round-trips - sig-verify failure path (covered at the transport-decrypt level + the
handler-level rejection test)
- tampered ciphertext CassetteEventDecodeError - tampered ciphertext CassetteEventDecodeError
- wrong operator privkey CassetteEventDecodeError (well-formed but - unknown sender pubkey CassetteEventDecodeError (well-formed but
decrypt fails because conversation key is wrong) decrypt fails because conversation key is wrong)
- malformed pubkey CassetteEventDecodeError - malformed pubkey CassetteEventDecodeError
- missing fields CassetteEventDecodeError
- decrypted garbage / wrong-shape JSON CassetteEventDecodeError
Full handler tests (the dispatch through verify_event get_machine_by_atm_ Full handler tests (the dispatch through verify_event get_machine_by_atm_
pubkey_hex apply_bootstrap_state) need a live LNbits DB; they're pubkey_hex apply_bootstrap_state) need a live LNbits DB; they're
@ -96,40 +95,21 @@ class TestDecryptAndParseStateEvent:
def test_happy_path(self): def test_happy_path(self):
payload = PublishCassettesPayload( payload = PublishCassettesPayload(
positions={ denominations={
"1": {"denomination": 20, "count": 49}, "20": {"position": 1, "count": 49},
"2": {"denomination": 50, "count": 100}, "50": {"position": 2, "count": 100},
} }
) )
event = _make_state_event(payload) event = _make_state_event(payload)
recovered = decrypt_and_parse_state_event(event, _OP_SEC) recovered = decrypt_and_parse_state_event(event, _OP_SEC)
assert sorted(recovered.positions.keys()) == [1, 2] assert sorted(recovered.denominations.keys()) == [20, 50]
assert recovered.positions[1].denomination == 20 assert recovered.denominations[20].position == 1
assert recovered.positions[1].count == 49 assert recovered.denominations[20].count == 49
assert recovered.positions[2].denomination == 50 assert recovered.denominations[50].count == 100
assert recovered.positions[2].count == 100
def test_round_trips_multiple_same_denomination(self):
"""v1.1 operational case from coord-log 18:45Z: real machines
load multiple cassettes with the same denomination."""
payload = PublishCassettesPayload(
positions={
"1": {"denomination": 20, "count": 100},
"2": {"denomination": 20, "count": 100},
"3": {"denomination": 20, "count": 100},
"4": {"denomination": 20, "count": 100},
}
)
event = _make_state_event(payload)
recovered = decrypt_and_parse_state_event(event, _OP_SEC)
assert len(recovered.positions) == 4
for pos in (1, 2, 3, 4):
assert recovered.positions[pos].denomination == 20
assert recovered.positions[pos].count == 100
def test_tampered_content_rejected(self): def test_tampered_content_rejected(self):
payload = PublishCassettesPayload( payload = PublishCassettesPayload(
positions={"1": {"denomination": 20, "count": 49}} denominations={"20": {"position": 1, "count": 49}}
) )
event = _make_state_event(payload) event = _make_state_event(payload)
# Flip a base64 character — corrupts the ciphertext or MAC # Flip a base64 character — corrupts the ciphertext or MAC
@ -144,7 +124,7 @@ class TestDecryptAndParseStateEvent:
different hmac_key, so MAC verification inside NIP-44 v2 decrypt different hmac_key, so MAC verification inside NIP-44 v2 decrypt
fails surfaced as CassetteEventDecodeError.""" fails surfaced as CassetteEventDecodeError."""
payload = PublishCassettesPayload( payload = PublishCassettesPayload(
positions={"1": {"denomination": 20, "count": 49}} denominations={"20": {"position": 1, "count": 49}}
) )
event = _make_state_event(payload) event = _make_state_event(payload)
wrong_sec = "00" * 31 + "03" wrong_sec = "00" * 31 + "03"
@ -153,7 +133,7 @@ class TestDecryptAndParseStateEvent:
def test_malformed_sender_pubkey_rejected(self): def test_malformed_sender_pubkey_rejected(self):
payload = PublishCassettesPayload( payload = PublishCassettesPayload(
positions={"1": {"denomination": 20, "count": 49}} denominations={"20": {"position": 1, "count": 49}}
) )
event = _make_state_event(payload) event = _make_state_event(payload)
event["pubkey"] = "not-a-real-pubkey" event["pubkey"] = "not-a-real-pubkey"
@ -162,9 +142,7 @@ class TestDecryptAndParseStateEvent:
def test_missing_content_rejected(self): def test_missing_content_rejected(self):
event = _make_state_event( event = _make_state_event(
PublishCassettesPayload( PublishCassettesPayload(denominations={"20": {"position": 1, "count": 49}})
positions={"1": {"denomination": 20, "count": 49}}
)
) )
del event["content"] del event["content"]
with pytest.raises(CassetteEventDecodeError): with pytest.raises(CassetteEventDecodeError):
@ -172,9 +150,7 @@ class TestDecryptAndParseStateEvent:
def test_missing_pubkey_rejected(self): def test_missing_pubkey_rejected(self):
event = _make_state_event( event = _make_state_event(
PublishCassettesPayload( PublishCassettesPayload(denominations={"20": {"position": 1, "count": 49}})
positions={"1": {"denomination": 20, "count": 49}}
)
) )
del event["pubkey"] del event["pubkey"]
with pytest.raises(CassetteEventDecodeError): with pytest.raises(CassetteEventDecodeError):
@ -183,6 +159,7 @@ class TestDecryptAndParseStateEvent:
def test_decrypted_garbage_json_rejected(self): def test_decrypted_garbage_json_rejected(self):
"""If the plaintext decrypts but isn't JSON, we surface an error """If the plaintext decrypts but isn't JSON, we surface an error
rather than crashing the consumer loop.""" rather than crashing the consumer loop."""
# Encrypt something that isn't JSON
ck = get_conversation_key(_ATM_SEC, _OP_PUB) ck = get_conversation_key(_ATM_SEC, _OP_PUB)
bad_plaintext_event = { bad_plaintext_event = {
"kind": 30078, "kind": 30078,
@ -199,7 +176,7 @@ class TestDecryptAndParseStateEvent:
assert "JSON" in str(exc.value) or "didn't validate" in str(exc.value) assert "JSON" in str(exc.value) or "didn't validate" in str(exc.value)
def test_decrypted_json_with_wrong_shape_rejected(self): def test_decrypted_json_with_wrong_shape_rejected(self):
"""Well-formed JSON but missing the 'positions' field is """Well-formed JSON but missing the 'denominations' field is
a payload-shape failure, not a decrypt failure.""" a payload-shape failure, not a decrypt failure."""
ck = get_conversation_key(_ATM_SEC, _OP_PUB) ck = get_conversation_key(_ATM_SEC, _OP_PUB)
bad_shape_event = { bad_shape_event = {

View file

@ -308,16 +308,6 @@ _BITSPIRE_FIXTURE = {
} }
@pytest.mark.skip(
reason=(
"v1.1 wire-shape pivot (coord-log 2026-05-30T18:30Z + 18:45Z): the "
"13:15Z fixture above is encoded with the old `{denominations: ...}` "
"wire shape; the v1.1 wire shape is `{positions: {<pos>: "
"{denomination, count}}}`. Bitspire will post a fresh fixture against "
"the v1.1 shape; once posted, swap `_BITSPIRE_FIXTURE` for the new "
"JSON and drop this skip (v1.1 commit g on the PR)."
)
)
class TestBitspireCrossTest: class TestBitspireCrossTest:
"""Byte-compat cross-test between our hand-rolled NIP-44 v2 (`nip44.py`) """Byte-compat cross-test between our hand-rolled NIP-44 v2 (`nip44.py`)
and the nostr-tools NIP-44 v2 impl that bitspire uses on the ATM side and the nostr-tools NIP-44 v2 impl that bitspire uses on the ATM side

View file

@ -774,16 +774,15 @@ async def api_update_super_config(
# ============================================================================= # =============================================================================
# Cassette configs (#29 v1.1) — per-machine ATM cassette inventory # Cassette configs (#29 v1) — per-machine ATM cassette inventory
# ============================================================================= # =============================================================================
# v1.1 surface, paired with aiolabs/lamassu-next#56 ATM-side. Two endpoints: # v1 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, position) pairs) is admin-only via the # Row creation (new (machine_id, denomination) pairs) is admin-only via the
# bootstrap consumer task — slot count is hardware-determined. Operator- # bootstrap consumer task — denomination set is hardware-determined.
# side flow is edit-and-publish over the existing rows only; the editable # Operator-side flow is edit-and-publish over the existing rows only.
# fields per row are denomination and count.
@satmachineadmin_api_router.get( @satmachineadmin_api_router.get(
@ -794,9 +793,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. Empty list = ATM hasn't yet published its ordered by position then denomination. Empty list = ATM hasn't yet
bootstrap event (or the bootstrap consumer hasn't processed it yet); published its bootstrap event (or the bootstrap consumer hasn't
UI should show a "waiting for ATM" state.""" processed it yet); 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)
@ -811,11 +810,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 position set matches what's currently in the ATM. Validates the denomination set matches what's currently in
cassette_configs for the machine (slot count is hardware-fixed), cassette_configs for the machine (defensive UI prevents add/remove
upserts each row, then encrypts + signs + publishes a kind-30078 but API enforces), upserts each row, then encrypts + signs + publishes
event tagged with d=bitspire-cassettes:<atm_pubkey_hex> and a kind-30078 event tagged with d=bitspire-cassettes:<atm_pubkey_hex>
p=<atm_pubkey_hex>. and 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),
@ -826,8 +825,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 position set doesn't match the machine's stored set 400 payload denomination set doesn't match the machine's stored
(operator publishing for a slot that doesn't exist on the set (operator publishing a cassette 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
@ -837,8 +836,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_positions = {row.position for row in existing} existing_denoms = {row.denomination for row in existing}
incoming_positions = set(payload.positions.keys()) incoming_denoms = set(payload.denominations.keys())
if not existing: if not existing:
raise HTTPException( raise HTTPException(
@ -851,30 +850,29 @@ async def api_publish_machine_cassettes(
"receipt." "receipt."
), ),
) )
if existing_positions != incoming_positions: if existing_denoms != incoming_denoms:
missing = existing_positions - incoming_positions missing = existing_denoms - incoming_denoms
extra = incoming_positions - existing_positions extra = incoming_denoms - existing_denoms
raise HTTPException( raise HTTPException(
HTTPStatus.BAD_REQUEST, HTTPStatus.BAD_REQUEST,
( (
"Payload position set doesn't match the machine's stored " "Payload denomination set doesn't match the machine's "
f"set. Missing from payload: {sorted(missing)}; extra in " f"stored set. Missing from payload: {sorted(missing)}; "
f"payload: {sorted(extra)}. Slot count is hardware-fixed " f"extra in payload: {sorted(extra)}. "
"— re-provision the ATM via atm-tui to add/remove physical " "Denomination set is hardware-determined — re-provision "
"bays, then re-publish." "the ATM via atm-tui to add/remove cassettes, then "
"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 pos, row in payload.positions.items(): for denom, row in payload.denominations.items():
updated = await update_cassette_config( updated = await update_cassette_config(
machine_id, machine_id,
pos, denom,
UpsertCassetteConfigData( UpsertCassetteConfigData(count=row.count, position=row.position),
denomination=row.denomination, count=row.count
),
updated_by=user.id, updated_by=user.id,
) )
if updated is None: if updated is None:
@ -882,7 +880,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 position {pos} disappeared mid-publish", f"cassette row for denomination {denom} disappeared mid-publish",
) )
try: try: