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>
This commit is contained in:
parent
df6e8e0a22
commit
427cad33de
1 changed files with 63 additions and 48 deletions
111
models.py
111
models.py
|
|
@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue