From 427cad33deefe9660b2306550d13b03225e015ec Mon Sep 17 00:00:00 2001 From: Padreug Date: Sat, 30 May 2026 22:23:51 +0200 Subject: [PATCH] =?UTF-8?q?refactor(v2):=20cassette=20models=20=E2=80=94?= =?UTF-8?q?=20position-keyed=20wire=20shape=20(#29=20v1.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wire format flips from {"denominations": {"": {"position", "count"}}} to {"positions": {"": {"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) --- models.py | 111 +++++++++++++++++++++++++++++++----------------------- 1 file changed, 63 insertions(+), 48 deletions(-) diff --git a/models.py b/models.py index 5f88067..bec54aa 100644 --- a/models.py +++ b/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 -# 06:40Z coord-log audit): every ATM-side layer below the wire keys on -# denomination (state-store.ts:54, hal-service.ts:116/189). The -# satmachineadmin schema mirrors this so the operator UI can't author a -# duplicate-denomination payload that the ATM would silently collapse. +# Schema is position-keyed per the coordinated v1.1 redesign at coord-log +# 2026-05-30T18:30Z + 18:45Z. The earlier denomination-keyed shape (m007) +# was wrong: real machines have N cassettes of the same denomination for +# cash-out throughput, and operators need to swap cartridge denominations +# during refill ($20 bay becomes a $50 bay) without re-provisioning. # -# Position is operator-assignable display order (and used by the ATM as -# the HAL slot-index assignment), not the addressable unit. +# Wire shape: +# {"positions": {"": {"denomination": N, "count": M}}} # -# state_count / state_at / state_event_id are reserved nullable from day 1 -# for the v2 reverse-channel reconciliation consumer (bitspire-cassettes- -# state:). v1 populates them on bootstrap-event receipt -# but the UI doesn't render reconciliation. +# Editable surface per row: +# - denomination: yes (operator swaps cartridges during refill) +# - count: yes (refill / decrement) +# 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:). +# 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): machine_id: str + position: int denomination: int count: int - position: int updated_at: datetime updated_by: Optional[str] + state_denomination: Optional[int] state_count: Optional[int] state_at: Optional[datetime] state_event_id: Optional[str] class UpsertCassetteConfigData(BaseModel): - """Operator edits a single cassette row's count or position from the - dashboard. Both fields optional; pass only those changed.""" + """Operator edits a single cassette row's denomination or count from + 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 - 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") def count_non_negative(cls, v): @@ -593,26 +616,18 @@ class UpsertCassetteConfigData(BaseModel): raise ValueError("count must be >= 0") 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): - """One denomination's payload values in the wire-format - `{"denominations": {"": {"position", "count"}}}`.""" + """One position's payload values in the wire-format + `{"positions": {"": {"denomination", "count"}}}`.""" - position: int + denomination: int count: int - @validator("position") - def position_positive(cls, v): + @validator("denomination") + def denomination_positive(cls, v): if v <= 0: - raise ValueError("position must be > 0") + raise ValueError("denomination must be > 0") return v @validator("count") @@ -628,45 +643,45 @@ class PublishCassettesPayload(BaseModel): - operator → ATM (d-tag `bitspire-cassettes:`) - ATM → operator (d-tag `bitspire-cassettes-state:`) - Wire shape: `{"denominations": {"": {"position", "count"}}}`. + Wire shape: `{"positions": {"": {"denomination", "count"}}}`. JSON object keys are always strings; the validator coerces back to - int on parse. The denomination key set MUST match what the receiver - already has (no add / no remove from this payload). + int on parse. The position key set MUST match what the receiver + 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): if not isinstance(v, dict): - raise ValueError("denominations must be a dict") + raise ValueError("positions must be a dict") out = {} for k, val in v.items(): try: key_int = int(k) except (TypeError, ValueError) as exc: raise ValueError( - f"denomination key {k!r} is not an int" + f"position key {k!r} is not an int" ) from exc 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 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: """Serialise back to the wire format with string keys for JSON object compatibility. Used by the publisher to build the kind-30078 event content before NIP-44 v2 encryption.""" return { - "denominations": { - str(denom): {"position": row.position, "count": row.count} - for denom, row in self.denominations.items() + "positions": { + str(pos): { + "denomination": row.denomination, + "count": row.count, + } + for pos, row in self.positions.items() } }