feat(v2): operator-side cassette inventory v1.1 + signer.nip44_* migration (#29) #30

Merged
padreug merged 19 commits from feat/cassette-config-v1 into v2-bitspire 2026-05-31 13:54:19 +00:00
Showing only changes of commit 427cad33de - Show all commits

refactor(v2): cassette models — position-keyed wire shape (#29 v1.1)

The wire format flips from
  {"denominations": {"<denom>": {"position", "count"}}}
to
  {"positions": {"<pos>": {"denomination", "count"}}}

per coord-log 2026-05-30T18:30Z + 18:45Z.

Per-row editable surface changes:
  - denomination becomes mutable (operator swaps cartridges during refill)
  - count remains mutable
  - position becomes the row identity (hardware bay number)

Removed: the no_duplicate_positions validator (no longer relevant — position
is the key, dups are impossible at the dict level) and the implicit
"denomination unique" assumption. Multiple positions with the same
denomination are now operationally valid per bitspire 18:45Z.

CassetteConfig model adds state_denomination (Optional[int]) for v2
reverse-channel reconciliation diff highlighting.

Tests under tests/test_cassette_configs.py and test_cassette_state_consumer.py
will fail at this commit — they reference the old denomination-keyed
shape. They get rewritten in v1.1 commit f. Branch tip is green only after
commit f lands; this and the next 3 commits are intermediate states.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Padreug 2026-05-30 22:23:51 +02:00

111
models.py
View file

@ -549,41 +549,64 @@ class SettleBalanceData(BaseModel):
# ============================================================================= # =============================================================================
# Cassette configs — operator-driven ATM cassette inventory (#29). # Cassette configs — operator-driven ATM cassette inventory (#29 v1.1).
# ============================================================================= # =============================================================================
# Schema is denomination-keyed per the locked design (#29 body + the # Schema is position-keyed per the coordinated v1.1 redesign at coord-log
# 06:40Z coord-log audit): every ATM-side layer below the wire keys on # 2026-05-30T18:30Z + 18:45Z. The earlier denomination-keyed shape (m007)
# denomination (state-store.ts:54, hal-service.ts:116/189). The # was wrong: real machines have N cassettes of the same denomination for
# satmachineadmin schema mirrors this so the operator UI can't author a # cash-out throughput, and operators need to swap cartridge denominations
# duplicate-denomination payload that the ATM would silently collapse. # during refill ($20 bay becomes a $50 bay) without re-provisioning.
# #
# Position is operator-assignable display order (and used by the ATM as # Wire shape:
# the HAL slot-index assignment), not the addressable unit. # {"positions": {"<position_str>": {"denomination": N, "count": M}}}
# #
# state_count / state_at / state_event_id are reserved nullable from day 1 # Editable surface per row:
# for the v2 reverse-channel reconciliation consumer (bitspire-cassettes- # - denomination: yes (operator swaps cartridges during refill)
# state:<atm_pubkey_hex>). v1 populates them on bootstrap-event receipt # - count: yes (refill / decrement)
# but the UI doesn't render reconciliation. # Read-only per row:
# - 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 count or position from the """Operator edits a single cassette row's denomination or count from
dashboard. Both fields optional; pass only those changed.""" the 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):
@ -593,26 +616,18 @@ 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 denomination's payload values in the wire-format """One position's payload values in the wire-format
`{"denominations": {"<denom>": {"position", "count"}}}`.""" `{"positions": {"<pos>": {"denomination", "count"}}}`."""
position: int denomination: int
count: int count: int
@validator("position") @validator("denomination")
def position_positive(cls, v): def denomination_positive(cls, v):
if v <= 0: if v <= 0:
raise ValueError("position must be > 0") raise ValueError("denomination must be > 0")
return v return v
@validator("count") @validator("count")
@ -628,45 +643,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: `{"denominations": {"<denom_str>": {"position", "count"}}}`. Wire shape: `{"positions": {"<pos_str>": {"denomination", "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 denomination key set MUST match what the receiver int on parse. The position key set MUST match what the receiver
already has (no add / no remove from this payload). already has (slot count is hardware-fixed; no add/remove from this
payload).
No denomination-unique constraint: multiple same-denom cassettes are
operationally valid (cash-out throughput on a popular denom).
""" """
denominations: dict[int, CassettePayloadRow] positions: dict[int, CassettePayloadRow]
@validator("denominations", pre=True) @validator("positions", 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("denominations must be a dict") raise ValueError("positions 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"denomination key {k!r} is not an int" f"position key {k!r} is not an int"
) from exc ) from exc
if key_int <= 0: if key_int <= 0:
raise ValueError(f"denomination must be > 0 (got {key_int})") raise ValueError(f"position 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 {
"denominations": { "positions": {
str(denom): {"position": row.position, "count": row.count} str(pos): {
for denom, row in self.denominations.items() "denomination": row.denomination,
"count": row.count,
}
for pos, row in self.positions.items()
} }
} }