Operator dashboard: per-machine cassette inventory config #29

Closed
opened 2026-05-28 19:13:05 +00:00 by padreug · 3 comments
Owner

Problem

Cassette denomination + count on each deployed ATM is currently only editable via:

  1. atm-tui on the ATM box (interactive TUI, requires SSH + sudo) — ~/dev/bitspire/atm-tui
  2. VITE_LAMASSU_CASSETTES env var (first-boot seed only)
  3. Direct SQL on the ATM's state.db

For prod operation, the operator needs to manage cassette inventory from the satmachineadmin dashboard without SSH-ing to the box. Currently dispatchers physically refilling cassettes can't communicate the new counts back to the operator's records, and the operator can't change denomination assignments remotely.

This issue tracks the operator-side producer + dashboard UI. The ATM-side consumer lives at aiolabs/lamassu-next#56 (paired).

Decisions (2026-05-30)

After scoping conversation across satmachineadmin ↔ bitspire sessions (~/dev/coordination/log.md, entries 06:30Z, 06:40Z, 07:30Z, 07:50Z, 07:55Z, 08:00Z):

Transport: encrypted kind-30078 (operator → ATM)

  • kind = 30078 (NIP-78 arbitrary application data, replaceable)
  • ["d", "bitspire-cassettes:<machine_id>"] tag (operator → ATM direction)
  • ["p", <atm_npub>] tag
  • Content: NIP-44 v2 encrypted JSON payload
  • Author: operator pubkey, signed via satmachineadmin's existing signer in v1 (env-pinned LocalSigner adapter); bunker-mediated post aiolabs/lnbits#26 cascade via config-only swap to RemoteBunkerSigner

Ruled out CLINK kind-21003 per workspace ~/dev/CLAUDE.md "Respect protocol semantics over friction reduction": CLINK is a payment-flow protocol, not operator-config. Ruled out LNbits-sidecar-bunker-query: expands LNbits' role beyond payments and routes a different threat-model relationship through the wallet-ops bus.

Wire shape: denomination-keyed, not position-keyed

Audited the ATM stack end-to-end (06:40Z): every layer beneath the wire keys on denomination, not position (state-store.ts:54/265/287, hal-service.ts:116/189/207, bitspire/atm-tui/src/db.zig:31). The ATM enforces an implicit "one cassette per denomination per machine" invariant. Position is a sortable display column, not an addressable unit.

Satmachineadmin's cassette_configs PK is (machine_id, denomination) so the schema can't author a duplicate-denomination payload in the first place.

Wire payload (kind-30078 content, NIP-44 v2 encrypted):

{
  "denominations": {
    "20": { "position": 1, "count": 49 },
    "50": { "position": 2, "count": 100 }
  }
}

denominations keys MUST be exactly the set of denominations currently present for that machine (no add, no remove — that requires a re-provisioning event on the ATM side).

Storage: cassette_configs table

CREATE TABLE cassette_configs (
    machine_id       TEXT NOT NULL,
    denomination     INTEGER NOT NULL,           -- bill value, e.g. 20, 50
    count            INTEGER NOT NULL,           -- operator-believed count
    position         INTEGER NOT NULL,           -- physical bay / display order
    updated_at       TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_by       TEXT,                       -- operator user that last edited
    -- v2 reverse-channel reconciliation columns (reserved from day 1)
    state_count      INTEGER,                    -- ATM-reported count from last bitspire-cassettes-state: event
    state_at         TIMESTAMP,                  -- when ATM reported it
    state_event_id   TEXT,                       -- event id of last consumed bitspire-cassettes-state: event
    PRIMARY KEY (machine_id, denomination)
);

The v2 columns (state_count, state_at, state_event_id) are populated in v1 by the bootstrap consumer; v1 UI doesn't render them. v2 reconciliation UI starts reading them without a migration on existing installs.

Row lifecycle:

Operation Path
First population for a (machine_id, denomination) pair Auto from ATM bootstrap bitspire-cassettes-state:<machine_id> event (v1)
Operator edit of count or position Dashboard UI; writes updated_at + updated_by; triggers publish-to-ATM
Row creation for a new denomination Admin only — happens via ATM re-provisioning + new bootstrap event, not via the per-machine cassette editor
Row deletion Admin only — same channel

Versioning — v1 ships first, v2 before prod fleet

v1 (this issue's scope) — operator → ATM publish + ATM bootstrap consumer + dashboard UI

  • cassette_configs table with reserved v2 columns
  • Bootstrap consumer subscribes to bitspire-cassettes-state:<machine_id> events from each known machine and auto-populates cassette_configs
  • Per-machine cassettes tab in the existing machine detail view; edit count + position; submit publishes a kind-30078 to the targeted ATM
  • Overwrite-confirmation modal on submit (per 07:50Z — calls out the v1 reconciliation gotcha to the operator before clobbering ATM-tracked counts)
  • Stores state_count / state_at / state_event_id on bootstrap receipt; doesn't render reconciliation UI

Shippable for dev (sintra: operator can SSH the ATM to verify state.db). Not safe for multi-machine prod.

v2 (filed as a separate issue when ready, paired with aiolabs/lamassu-next#57) — dashboard reconciliation UI

  • Continuous ATM reverse-channel consumer (same event shape as v1 bootstrap, just continuous)
  • Dashboard shows ATM-reported alongside operator-believed counts
  • / on whether ATM has applied operator's latest publish (applied_config_event_id carried in the state event)
  • Safe "Add N bills" UX gated on freshness of ATM-reported state

Bootstrap consumer (v1)

Subscribe to ATM-published state events for each known machine:

{
  kinds: [30078],
  "#p": [<operator_pubkey>],
  "#d": ["bitspire-cassettes-state:<m1>", "bitspire-cassettes-state:<m2>", ...]
}

(#d filter takes a list — one subscription covers all machines in satmachineadmin's machines table.)

On event receipt:

  1. Verify signature; reject if event.pubkey not in the known ATM npub set for that machine
  2. NIP-44 v2 decrypt content with operator privkey
  3. Parse denominations payload
  4. UPSERT one cassette_configs row per (machine_id, denomination): set count = state_count = denomination.count, position = denomination.position, state_count = denomination.count, state_at = event.created_at, state_event_id = event.id, updated_at = now(), updated_by = 'atm-bootstrap'
  5. Idempotent on the satmachineadmin side: if state_event_id already matches the incoming event, no-op

Dashboard UI (v1)

Per-machine "Cassettes" tab in the existing machine detail view:

  • One row per denomination, read from cassette_configs for that machine
  • Editable fields per row: count, position
  • No "add denomination" / "remove denomination" controls in this view. Denomination set is hardware-determined; managed via re-provisioning + ATM bootstrap event.
  • Submit-time confirmation modal: "This publish will overwrite the ATM's currently-tracked counts. If the ATM has dispensed cash since your last refill, those decrements will be lost." Operator must confirm. (v2 replaces the modal with reconciled state display.)
  • Submit writes cassette_configs (updating updated_at + updated_by) AND triggers publish-to-ATM

Publish-to-ATM (v1)

On submit:

  1. Build payload from cassette_configs for the machine: { "denominations": { "<denom>": { "position": <pos>, "count": <count> }, ... } }
  2. Validate: denominations keys match the current cassette_configs denomination set for that machine (defensive — UI prevents add/remove but API enforces); position positive int; count non-negative int; no duplicate positions
  3. NIP-44 v2 encrypt to the target ATM's npub (operator → ATM direction)
  4. Sign with operator's nsec via the configured signer (v1: env-pinned LocalSigner; post lnbits#26: RemoteBunkerSigner)
  5. Publish kind=30078, ["d", "bitspire-cassettes:<machine_id>"], ["p", <atm_npub>] to configured relay(s)
  6. Surface publish status to operator (relay accept/reject — distinct from ATM-apply ack, which is v2)

Acceptance criteria (v1)

Schema + storage

  • cassette_configs table with PRIMARY KEY (machine_id, denomination) and reserved nullable state_count / state_at / state_event_id columns
  • CRUD operations: upsert by (machine_id, denomination), select-by-machine, select-by-(machine_id, denomination)
  • Validation: denomination > 0, count >= 0, position > 0

Bootstrap consumer

  • Subscribe to bitspire-cassettes-state:<machine_id> events for all known machines (single subscription with #d list filter)
  • Verify event signature; reject if event.pubkey not in machine's known ATM npub set
  • NIP-44 v2 decrypt content
  • UPSERT cassette_configs rows from payload; populate state_count / state_at / state_event_id
  • Idempotent on re-delivery (no-op if state_event_id matches)

Dashboard UI

  • Per-machine Cassettes tab; one row per denomination from cassette_configs
  • Editable count + position per row; no add/remove controls
  • Submit-time overwrite-confirmation modal
  • On submit: write cassette_configs (audit updated_at + updated_by) + trigger publish-to-ATM

Publish

  • API endpoint: POST /api/v1/dca/machines/<machine_id>/cassettes/publish, superuser/admin auth
  • Build + validate denomination-keyed payload (denomination set matches current cassette_configs for that machine)
  • NIP-44 v2 encrypt to target ATM npub
  • Sign via configured signer (LocalSigner in v1)
  • Publish to configured relay(s); surface relay accept/reject status to operator
  • No operator_commands / kind-21003 fallback path

Out of scope (this issue)

  • v2 reconciliation UI — separate issue when ready, paired with aiolabs/lamassu-next#57
  • Continuous ATM reverse-channel consumption — v2; v1 only consumes the one-shot bootstrap event from each ATM
  • Cassette count decrement on dispense — handled ATM-side via recordTransactionupdateCassetteCount; satmachineadmin doesn't track decrements in v1 (reconciliation lands in v2 via the reverse channel)
  • Adding or removing cassette denominations — hardware-determined; managed via ATM re-provisioning + new bootstrap event, not via this UI
  • Fleet-wide cassette config — each ATM owns its own per dcd0874's privacy pivot
  • ATM-side consumer + bootstrap publisher — lives in aiolabs/lamassu-next#56

Coordination

  • Mirror in lamassu-next (ATM-side consumer + bootstrap publisher): aiolabs/lamassu-next#56
  • Future v2 pair: aiolabs/lamassu-next#57 (ATM-side continuous reverse channel) + satmachineadmin counterpart (dashboard reconciliation UI)
  • Related: dcd0874 (NIP-78 rip + privacy-by-default architecture decision)
  • Related: aiolabs/lnbits#18 / #26 (signer abstraction — v1 uses env-pinned LocalSigner, post-cascade swaps to RemoteBunkerSigner via config-only change)
  • Related: ~/dev/CLAUDE.md (Nostr architecture → "Respect protocol semantics over friction reduction" — workspace-level rule grounded in this decision)
  • Existing ATM-side surface for parallel operator work: ~/dev/bitspire/atm-tui
  • Full design rationale: comment 1442 + ~/dev/coordination/log.md entries at 2026-05-30T06:30Z, 06:40Z, 07:30Z, 07:50Z, 07:55Z, 08:00Z
## Problem Cassette denomination + count on each deployed ATM is currently only editable via: 1. `atm-tui` on the ATM box (interactive TUI, requires SSH + sudo) — `~/dev/bitspire/atm-tui` 2. `VITE_LAMASSU_CASSETTES` env var (first-boot seed only) 3. Direct SQL on the ATM's `state.db` For prod operation, the operator needs to manage cassette inventory from the satmachineadmin dashboard without SSH-ing to the box. Currently dispatchers physically refilling cassettes can't communicate the new counts back to the operator's records, and the operator can't change denomination assignments remotely. This issue tracks the **operator-side producer + dashboard UI**. The **ATM-side consumer** lives at aiolabs/lamassu-next#56 (paired). ## Decisions (2026-05-30) After scoping conversation across `satmachineadmin ↔ bitspire` sessions (`~/dev/coordination/log.md`, entries `06:30Z`, `06:40Z`, `07:30Z`, `07:50Z`, `07:55Z`, `08:00Z`): ### Transport: encrypted kind-30078 (operator → ATM) - `kind = 30078` (NIP-78 arbitrary application data, replaceable) - `["d", "bitspire-cassettes:<machine_id>"]` tag (operator → ATM direction) - `["p", <atm_npub>]` tag - Content: NIP-44 v2 encrypted JSON payload - Author: operator pubkey, signed via satmachineadmin's existing signer in v1 (env-pinned `LocalSigner` adapter); bunker-mediated post `aiolabs/lnbits#26` cascade via config-only swap to `RemoteBunkerSigner` Ruled out CLINK kind-21003 per workspace `~/dev/CLAUDE.md` "Respect protocol semantics over friction reduction": CLINK is a payment-flow protocol, not operator-config. Ruled out LNbits-sidecar-bunker-query: expands LNbits' role beyond payments and routes a different threat-model relationship through the wallet-ops bus. ### Wire shape: denomination-keyed, not position-keyed Audited the ATM stack end-to-end (`06:40Z`): every layer beneath the wire keys on denomination, not position (`state-store.ts:54/265/287`, `hal-service.ts:116/189/207`, `bitspire/atm-tui/src/db.zig:31`). The ATM enforces an implicit "one cassette per denomination per machine" invariant. Position is a sortable display column, not an addressable unit. Satmachineadmin's `cassette_configs` PK is `(machine_id, denomination)` so the schema can't author a duplicate-denomination payload in the first place. **Wire payload (kind-30078 content, NIP-44 v2 encrypted):** ```json { "denominations": { "20": { "position": 1, "count": 49 }, "50": { "position": 2, "count": 100 } } } ``` `denominations` keys MUST be exactly the set of denominations currently present for that machine (no add, no remove — that requires a re-provisioning event on the ATM side). ## Storage: `cassette_configs` table ```sql CREATE TABLE cassette_configs ( machine_id TEXT NOT NULL, denomination INTEGER NOT NULL, -- bill value, e.g. 20, 50 count INTEGER NOT NULL, -- operator-believed count position INTEGER NOT NULL, -- physical bay / display order updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_by TEXT, -- operator user that last edited -- v2 reverse-channel reconciliation columns (reserved from day 1) state_count INTEGER, -- ATM-reported count from last bitspire-cassettes-state: event state_at TIMESTAMP, -- when ATM reported it state_event_id TEXT, -- event id of last consumed bitspire-cassettes-state: event PRIMARY KEY (machine_id, denomination) ); ``` The v2 columns (`state_count`, `state_at`, `state_event_id`) are populated in v1 by the bootstrap consumer; v1 UI doesn't render them. v2 reconciliation UI starts reading them without a migration on existing installs. Row lifecycle: | Operation | Path | |---|---| | **First population** for a `(machine_id, denomination)` pair | Auto from ATM bootstrap `bitspire-cassettes-state:<machine_id>` event (v1) | | **Operator edit** of `count` or `position` | Dashboard UI; writes `updated_at` + `updated_by`; triggers publish-to-ATM | | **Row creation** for a new denomination | Admin only — happens via ATM re-provisioning + new bootstrap event, not via the per-machine cassette editor | | **Row deletion** | Admin only — same channel | ## Versioning — v1 ships first, v2 before prod fleet **v1** (this issue's scope) — **operator → ATM publish + ATM bootstrap consumer + dashboard UI** - `cassette_configs` table with reserved v2 columns - Bootstrap consumer subscribes to `bitspire-cassettes-state:<machine_id>` events from each known machine and auto-populates `cassette_configs` - Per-machine cassettes tab in the existing machine detail view; edit `count` + `position`; submit publishes a kind-30078 to the targeted ATM - Overwrite-confirmation modal on submit (per `07:50Z` — calls out the v1 reconciliation gotcha to the operator before clobbering ATM-tracked counts) - Stores `state_count` / `state_at` / `state_event_id` on bootstrap receipt; doesn't render reconciliation UI Shippable for **dev** (sintra: operator can SSH the ATM to verify state.db). Not safe for multi-machine prod. **v2** (filed as a separate issue when ready, paired with `aiolabs/lamassu-next#57`) — **dashboard reconciliation UI** - Continuous ATM reverse-channel consumer (same event shape as v1 bootstrap, just continuous) - Dashboard shows ATM-reported alongside operator-believed counts - ✅/⏳ on whether ATM has applied operator's latest publish (`applied_config_event_id` carried in the state event) - Safe "Add N bills" UX gated on freshness of ATM-reported state ## Bootstrap consumer (v1) Subscribe to ATM-published state events for each known machine: ``` { kinds: [30078], "#p": [<operator_pubkey>], "#d": ["bitspire-cassettes-state:<m1>", "bitspire-cassettes-state:<m2>", ...] } ``` (`#d` filter takes a list — one subscription covers all machines in satmachineadmin's machines table.) On event receipt: 1. Verify signature; reject if `event.pubkey` not in the known ATM npub set for that machine 2. NIP-44 v2 decrypt content with operator privkey 3. Parse `denominations` payload 4. UPSERT one `cassette_configs` row per `(machine_id, denomination)`: set `count = state_count = denomination.count`, `position = denomination.position`, `state_count = denomination.count`, `state_at = event.created_at`, `state_event_id = event.id`, `updated_at = now()`, `updated_by = 'atm-bootstrap'` 5. Idempotent on the satmachineadmin side: if `state_event_id` already matches the incoming event, no-op ## Dashboard UI (v1) Per-machine "Cassettes" tab in the existing machine detail view: - One row per denomination, read from `cassette_configs` for that machine - Editable fields per row: `count`, `position` - **No "add denomination" / "remove denomination" controls in this view.** Denomination set is hardware-determined; managed via re-provisioning + ATM bootstrap event. - **Submit-time confirmation modal**: "This publish will overwrite the ATM's currently-tracked counts. If the ATM has dispensed cash since your last refill, those decrements will be lost." Operator must confirm. (v2 replaces the modal with reconciled state display.) - Submit writes `cassette_configs` (updating `updated_at` + `updated_by`) AND triggers publish-to-ATM ## Publish-to-ATM (v1) On submit: 1. Build payload from `cassette_configs` for the machine: `{ "denominations": { "<denom>": { "position": <pos>, "count": <count> }, ... } }` 2. Validate: `denominations` keys match the current `cassette_configs` denomination set for that machine (defensive — UI prevents add/remove but API enforces); `position` positive int; `count` non-negative int; no duplicate positions 3. NIP-44 v2 encrypt to the target ATM's npub (operator → ATM direction) 4. Sign with operator's nsec via the configured signer (v1: env-pinned `LocalSigner`; post lnbits#26: `RemoteBunkerSigner`) 5. Publish `kind=30078`, `["d", "bitspire-cassettes:<machine_id>"]`, `["p", <atm_npub>]` to configured relay(s) 6. Surface publish status to operator (relay accept/reject — distinct from ATM-apply ack, which is v2) ## Acceptance criteria (v1) ### Schema + storage - [ ] `cassette_configs` table with `PRIMARY KEY (machine_id, denomination)` and reserved nullable `state_count` / `state_at` / `state_event_id` columns - [ ] CRUD operations: upsert by (machine_id, denomination), select-by-machine, select-by-(machine_id, denomination) - [ ] Validation: denomination > 0, count >= 0, position > 0 ### Bootstrap consumer - [ ] Subscribe to `bitspire-cassettes-state:<machine_id>` events for all known machines (single subscription with `#d` list filter) - [ ] Verify event signature; reject if `event.pubkey` not in machine's known ATM npub set - [ ] NIP-44 v2 decrypt content - [ ] UPSERT `cassette_configs` rows from payload; populate `state_count` / `state_at` / `state_event_id` - [ ] Idempotent on re-delivery (no-op if `state_event_id` matches) ### Dashboard UI - [ ] Per-machine Cassettes tab; one row per denomination from `cassette_configs` - [ ] Editable `count` + `position` per row; no add/remove controls - [ ] Submit-time overwrite-confirmation modal - [ ] On submit: write `cassette_configs` (audit `updated_at` + `updated_by`) + trigger publish-to-ATM ### Publish - [ ] API endpoint: `POST /api/v1/dca/machines/<machine_id>/cassettes/publish`, superuser/admin auth - [ ] Build + validate denomination-keyed payload (denomination set matches current `cassette_configs` for that machine) - [ ] NIP-44 v2 encrypt to target ATM npub - [ ] Sign via configured signer (LocalSigner in v1) - [ ] Publish to configured relay(s); surface relay accept/reject status to operator - [ ] No `operator_commands` / kind-21003 fallback path ## Out of scope (this issue) - **v2 reconciliation UI** — separate issue when ready, paired with `aiolabs/lamassu-next#57` - **Continuous ATM reverse-channel consumption** — v2; v1 only consumes the one-shot bootstrap event from each ATM - **Cassette *count decrement* on dispense** — handled ATM-side via `recordTransaction` → `updateCassetteCount`; satmachineadmin doesn't track decrements in v1 (reconciliation lands in v2 via the reverse channel) - **Adding or removing cassette denominations** — hardware-determined; managed via ATM re-provisioning + new bootstrap event, not via this UI - **Fleet-wide cassette config** — each ATM owns its own per `dcd0874`'s privacy pivot - **ATM-side consumer + bootstrap publisher** — lives in `aiolabs/lamassu-next#56` ## Coordination - Mirror in lamassu-next (ATM-side consumer + bootstrap publisher): `aiolabs/lamassu-next#56` - Future v2 pair: `aiolabs/lamassu-next#57` (ATM-side continuous reverse channel) + satmachineadmin counterpart (dashboard reconciliation UI) - Related: `dcd0874` (NIP-78 rip + privacy-by-default architecture decision) - Related: `aiolabs/lnbits#18` / `#26` (signer abstraction — v1 uses env-pinned `LocalSigner`, post-cascade swaps to `RemoteBunkerSigner` via config-only change) - Related: `~/dev/CLAUDE.md` (Nostr architecture → "Respect protocol semantics over friction reduction" — workspace-level rule grounded in this decision) - Existing ATM-side surface for parallel operator work: `~/dev/bitspire/atm-tui` - Full design rationale: comment 1442 + `~/dev/coordination/log.md` entries at 2026-05-30T06:30Z, 06:40Z, 07:30Z, 07:50Z, 07:55Z, 08:00Z
Author
Owner

Design review (paired with aiolabs/lamassu-next#56 — ATM-side consumer)

Reviewed against the current code in ~/dev/shared/extensions/satmachineadmin, the existing ATM stack (~/dev/shocknet/lamassu-next/apps/machine/electron/{state-store,hal-service,main}.ts), and ~/dev/bitspire/atm-tui/src/db.zig. Six points; #1 is the load-bearing one and needs to land before implementation on either side.

1. Schema PK: mirror the ATM's denomination key, not invent a new position key

The issue proposes PRIMARY KEY (machine_id, position). The ATM is denomination-keyed at every layer below the wire:

Layer Evidence
atm-tui schema db.zig:31cassettes (denomination INTEGER PRIMARY KEY, ...)
state-store schema (electron) state-store.ts:54 — same PK; migration v5→v6 added position as a sortable column, not a key
Operator-driven write path state-store.ts:265INSERT ... ON CONFLICT(denomination) DO UPDATE SET count = excluded.count, position = excluded.position
Dispense decrement state-store.ts:287UPDATE cassettes SET count = ... WHERE denomination = ?
HAL inventory map hal-service.ts:116inventory[cassette.denomination] = cassette.count
HAL dispense lookup hal-service.ts:189cassetteDenominations.indexOf(denomination) — first match wins; duplicates silently collapse
HAL result builder hal-service.ts:207 — assigns synthetic position: i from array index, not from DB

The ATM stack carries a strong implicit invariant: one cassette per denomination per machine. The DB enforces it via PK; the HAL inventory map can't represent two cassettes of the same denomination; the dispense lookup picks the first index and ignores the rest. This isn't a bug — it's the operational reality of the F56 dispenser stack as it stands today.

If satmachineadmin keys on (machine_id, position), two operator-side rows with the same denomination silently collapse into one cassette on the ATM. Operator sees "I configured two $50 cassettes"; ATM dispenses from one and treats the other as zero. No error surfaced anywhere.

Three resolutions:

Option What changes Cost Verdict
A. Bump ATM schema to position-PK + restructure HAL atm-tui db.zig, state-store schema + 4 functions, hal-service inventory map + dispense lookup + decrement + result builder. Conceptual shift: HAL stops thinking in denominations, starts thinking in slots. Schema migration on deployed sintra. Substantial cross-codebase work; touches HAL math. Defer.
B. Mirror denomination-PK on satmachineadmin; flip wire to {denomination → {position, count}} satmachineadmin schema becomes PRIMARY KEY (machine_id, denomination) with position as a column. Wire payload reframes from {position → {denomination, count}} to {denomination → {position, count}}. Operator UI shows one row per denomination per machine; "change denomination on slot N" becomes delete-old-denomination + insert-new-denomination (or the UI hides this as a slot-edit but the underlying ops are delete+insert). Wire-shape change on both issues (#29 + #56); no ATM-side code change. Recommended.
C. Keep wire position-keyed; enforce uniqueness-on-denomination at publish time in satmachineadmin. Smallest delta; no schema change anywhere. Inherits the constraint without documenting it. Will surprise an operator the first time it matters. Reject.

Recommend B. It correctly documents the existing invariant rather than fighting it with a position-keyed abstraction that the ATM stack can't honor. If at some future point the operational reality changes (e.g. a different dispenser stack that genuinely supports two-cassettes-same-denomination), schema migration is local to satmachineadmin and #56's ATM-side work is unaffected.

2. Onboarding row creation: ATM hello-event auto-populate, not operator hand-entry

The issue floats two options for how cassette_configs rows first appear (auto from an ATM "hello" event, OR hand-entered admin form). Option A is the only safe one — option B re-introduces the same foot-gun the "no add slot" UI rule prevents, just shifted to onboarding time. Since #56 is wiring publish-from-ATM anyway, adding an initial "I have N cassettes at these denominations + positions" hello event during the same work is the natural place to source the canonical truth. Lock to option A.

3. Option 3 stopgap (operator_commands kind-21003 queue) is a trap

Once cassettes ride on the operator-commands queue, migrating to kind-30078 later is uphill: code paths, downstream consumers, and operator mental model all reorganize around "cassettes are commands." If aiolabs/lnbits#18 (bunker integration) isn't ready when this lands, prefer waiting over shipping option 3. Cassettes are config (replaceable state); kind-30078 is the right shape regardless of timing.

4. Ack/error wire shape — pick one, kind-30078 from ATM

Both #29 and #56 AC say "publish a status event back OR write to a status column" — two incompatible shapes left undecided. Recommend the ATM publishes a kind-30078 ack with ["d", "bitspire-cassettes-ack:<machine_id>"] carrying last-applied content hash + timestamp. Keeps the whole flow event-shaped and replaceable-event-symmetric with the operator publish. The "status column on cassette_configs" alternative requires satmachineadmin to derive ack state from some other channel (HTTP polling? operator_commands result column?) which defeats the privacy-by-default architecture.

5. Reserve nullable ack_count / ack_at columns from day 1

Reconciliation (operator-believed count vs ATM-reported count) is correctly deferred to post-v1. But the schema should land these columns nullable from day 1 so reconciliation doesn't need a follow-up migration on deployed instances. The ATM ack from #4 should always carry the current count — operator UI doesn't have to render it yet, but the data should be persisted from the first ack onward.

6. Payload validation: cross-check denomination set against ATM-reported state

With option B (denomination-keyed), the validation criterion becomes: at publish time, satmachineadmin must verify that the denomination set in the outgoing payload matches the ATM's last-acked denomination set. Otherwise publishing a config for a denomination the ATM doesn't have a cassette for either fails silently or, depending on ATM-side validation strictness, gets the operator into a state where their UI shows a cassette the ATM doesn't have. The hello-event from #2 + the ack-event from #4 together give us the ATM-reported denomination set; satmachineadmin should reject (or warn) on publish if the outgoing denomination set doesn't match.

Acceptance criteria as revised

Replacing the original AC bullets to reflect items 1-6:

  • cassette_configs table on satmachineadmin with PRIMARY KEY (machine_id, denomination), columns: denomination, count, position, updated_at, updated_by, ack_count NULLABLE, ack_at NULLABLE
  • Rows auto-populated from ATM hello-event (kind-30078 from ATM advertising its denomination + position set); no admin hand-entry form in v1
  • Operator UI: per-machine cassettes tab, edit count + position per denomination, submit
  • No "add denomination" / "remove denomination" controls in the editor (admin-only path, populated by hello-event)
  • Publish-to-ATM via encrypted kind-30078 once lnbits#18 lands; payload shape {denomination → {position, count}} for denominations currently in cassette_configs for that machine; no option-3 stopgap
  • Validation at publish time: denomination set in payload matches ATM-reported set (from last ack or hello-event)
  • ATM publishes kind-30078 ack with ["d", "bitspire-cassettes-ack:<machine_id>"] carrying content hash + applied counts; satmachineadmin updates ack_count + ack_at per row on receipt
  • Audit: updated_at + updated_by retained on every operator edit
  • Validation: denominations are positive ints, counts are non-negative ints, positions are non-negative ints

Coordination

Filed paired entry in ~/dev/coordination/log.md at 2026-05-30T06:30Z (and follow-up flipping A→B at 2026-05-30T06:40Z) for the bitspire/atm-tui session working aiolabs/lamassu-next#56. Wire-shape change from {position → {denomination, count}} to {denomination → {position, count}} needs to land on both issues together — cross-comment on #56 with the same recommendation.

Refs: aiolabs/lamassu-next#56 (paired ATM-side consumer), commit dcd0874 (NIP-78 rip + privacy-by-default), aiolabs/lnbits#18 (bunker integration — option 1 dependency), ~/dev/shocknet/lamassu-next/apps/machine/electron/state-store.ts:243-305, ~/dev/shocknet/lamassu-next/apps/machine/electron/hal-service.ts:110-230, ~/dev/bitspire/atm-tui/src/db.zig:31-124.

## Design review (paired with `aiolabs/lamassu-next#56` — ATM-side consumer) Reviewed against the current code in `~/dev/shared/extensions/satmachineadmin`, the existing ATM stack (`~/dev/shocknet/lamassu-next/apps/machine/electron/{state-store,hal-service,main}.ts`), and `~/dev/bitspire/atm-tui/src/db.zig`. Six points; #1 is the load-bearing one and needs to land before implementation on either side. ### 1. Schema PK: mirror the ATM's denomination key, not invent a new position key The issue proposes `PRIMARY KEY (machine_id, position)`. The ATM is denomination-keyed at every layer below the wire: | Layer | Evidence | |---|---| | atm-tui schema | `db.zig:31` — `cassettes (denomination INTEGER PRIMARY KEY, ...)` | | state-store schema (electron) | `state-store.ts:54` — same PK; migration v5→v6 added `position` as a sortable column, not a key | | Operator-driven write path | `state-store.ts:265` — `INSERT ... ON CONFLICT(denomination) DO UPDATE SET count = excluded.count, position = excluded.position` | | Dispense decrement | `state-store.ts:287` — `UPDATE cassettes SET count = ... WHERE denomination = ?` | | HAL inventory map | `hal-service.ts:116` — `inventory[cassette.denomination] = cassette.count` | | HAL dispense lookup | `hal-service.ts:189` — `cassetteDenominations.indexOf(denomination)` — first match wins; duplicates silently collapse | | HAL result builder | `hal-service.ts:207` — assigns synthetic `position: i` from array index, not from DB | The ATM stack carries a strong implicit invariant: **one cassette per denomination per machine**. The DB enforces it via PK; the HAL inventory map can't represent two cassettes of the same denomination; the dispense lookup picks the first index and ignores the rest. This isn't a bug — it's the operational reality of the F56 dispenser stack as it stands today. If satmachineadmin keys on `(machine_id, position)`, two operator-side rows with the same denomination silently collapse into one cassette on the ATM. Operator sees "I configured two $50 cassettes"; ATM dispenses from one and treats the other as zero. No error surfaced anywhere. Three resolutions: | Option | What changes | Cost | Verdict | |---|---|---|---| | **A. Bump ATM schema to position-PK + restructure HAL** | atm-tui `db.zig`, state-store schema + 4 functions, hal-service inventory map + dispense lookup + decrement + result builder. Conceptual shift: HAL stops thinking in denominations, starts thinking in slots. Schema migration on deployed sintra. | Substantial cross-codebase work; touches HAL math. | Defer. | | **B. Mirror denomination-PK on satmachineadmin; flip wire to `{denomination → {position, count}}`** | satmachineadmin schema becomes `PRIMARY KEY (machine_id, denomination)` with `position` as a column. Wire payload reframes from `{position → {denomination, count}}` to `{denomination → {position, count}}`. Operator UI shows one row per denomination per machine; "change denomination on slot N" becomes delete-old-denomination + insert-new-denomination (or the UI hides this as a slot-edit but the underlying ops are delete+insert). | Wire-shape change on both issues (`#29` + `#56`); no ATM-side code change. | **Recommended.** | | C. Keep wire position-keyed; enforce uniqueness-on-denomination at publish time in satmachineadmin. | Smallest delta; no schema change anywhere. | Inherits the constraint without documenting it. Will surprise an operator the first time it matters. | Reject. | **Recommend B.** It correctly documents the existing invariant rather than fighting it with a position-keyed abstraction that the ATM stack can't honor. If at some future point the operational reality changes (e.g. a different dispenser stack that genuinely supports two-cassettes-same-denomination), schema migration is local to satmachineadmin and #56's ATM-side work is unaffected. ### 2. Onboarding row creation: ATM hello-event auto-populate, not operator hand-entry The issue floats two options for how `cassette_configs` rows first appear (auto from an ATM "hello" event, OR hand-entered admin form). Option A is the only safe one — option B re-introduces the same foot-gun the "no add slot" UI rule prevents, just shifted to onboarding time. Since `#56` is wiring publish-from-ATM anyway, adding an initial "I have N cassettes at these denominations + positions" hello event during the same work is the natural place to source the canonical truth. **Lock to option A.** ### 3. Option 3 stopgap (`operator_commands` kind-21003 queue) is a trap Once cassettes ride on the operator-commands queue, migrating to kind-30078 later is uphill: code paths, downstream consumers, and operator mental model all reorganize around "cassettes are commands." If `aiolabs/lnbits#18` (bunker integration) isn't ready when this lands, prefer waiting over shipping option 3. Cassettes are config (replaceable state); kind-30078 is the right shape regardless of timing. ### 4. Ack/error wire shape — pick one, kind-30078 from ATM Both `#29` and `#56` AC say "publish a status event back OR write to a status column" — two incompatible shapes left undecided. Recommend the ATM publishes a kind-30078 ack with `["d", "bitspire-cassettes-ack:<machine_id>"]` carrying last-applied content hash + timestamp. Keeps the whole flow event-shaped and replaceable-event-symmetric with the operator publish. The "status column on `cassette_configs`" alternative requires satmachineadmin to derive ack state from some other channel (HTTP polling? operator_commands result column?) which defeats the privacy-by-default architecture. ### 5. Reserve nullable `ack_count` / `ack_at` columns from day 1 Reconciliation (operator-believed count vs ATM-reported count) is correctly deferred to post-v1. But the schema should land these columns nullable from day 1 so reconciliation doesn't need a follow-up migration on deployed instances. The ATM ack from #4 should always carry the current count — operator UI doesn't have to render it yet, but the data should be persisted from the first ack onward. ### 6. Payload validation: cross-check denomination set against ATM-reported state With option B (denomination-keyed), the validation criterion becomes: at publish time, satmachineadmin must verify that the denomination set in the outgoing payload matches the ATM's last-acked denomination set. Otherwise publishing a config for a denomination the ATM doesn't have a cassette for either fails silently or, depending on ATM-side validation strictness, gets the operator into a state where their UI shows a cassette the ATM doesn't have. The hello-event from #2 + the ack-event from #4 together give us the ATM-reported denomination set; satmachineadmin should reject (or warn) on publish if the outgoing denomination set doesn't match. ### Acceptance criteria as revised Replacing the original AC bullets to reflect items 1-6: - [ ] `cassette_configs` table on satmachineadmin with `PRIMARY KEY (machine_id, denomination)`, columns: `denomination`, `count`, `position`, `updated_at`, `updated_by`, `ack_count NULLABLE`, `ack_at NULLABLE` - [ ] Rows auto-populated from ATM hello-event (kind-30078 from ATM advertising its denomination + position set); no admin hand-entry form in v1 - [ ] Operator UI: per-machine cassettes tab, edit count + position per denomination, submit - [ ] No "add denomination" / "remove denomination" controls in the editor (admin-only path, populated by hello-event) - [ ] Publish-to-ATM via encrypted kind-30078 once lnbits#18 lands; payload shape `{denomination → {position, count}}` for denominations currently in `cassette_configs` for that machine; **no option-3 stopgap** - [ ] Validation at publish time: denomination set in payload matches ATM-reported set (from last ack or hello-event) - [ ] ATM publishes kind-30078 ack with `["d", "bitspire-cassettes-ack:<machine_id>"]` carrying content hash + applied counts; satmachineadmin updates `ack_count` + `ack_at` per row on receipt - [ ] Audit: `updated_at` + `updated_by` retained on every operator edit - [ ] Validation: denominations are positive ints, counts are non-negative ints, positions are non-negative ints ### Coordination Filed paired entry in `~/dev/coordination/log.md` at `2026-05-30T06:30Z` (and follow-up flipping A→B at `2026-05-30T06:40Z`) for the bitspire/atm-tui session working `aiolabs/lamassu-next#56`. Wire-shape change from `{position → {denomination, count}}` to `{denomination → {position, count}}` needs to land on both issues together — cross-comment on #56 with the same recommendation. Refs: `aiolabs/lamassu-next#56` (paired ATM-side consumer), commit `dcd0874` (NIP-78 rip + privacy-by-default), `aiolabs/lnbits#18` (bunker integration — option 1 dependency), `~/dev/shocknet/lamassu-next/apps/machine/electron/state-store.ts:243-305`, `~/dev/shocknet/lamassu-next/apps/machine/electron/hal-service.ts:110-230`, `~/dev/bitspire/atm-tui/src/db.zig:31-124`.
Author
Owner

v1 local-prvkey caveat (mirror of bitspire lamassu-next#56)

For symmetry with bitspire's lamassu-next#56 documentation update on the ATM side:

The cassette transport's NIP-44 v2 encrypt + decrypt paths in v1 + v1.1 require the operator's account.prvkey to be populated on the LNbits side for any operator account not yet migrated to RemoteBunkerSigner. Concretely:

  • RemoteBunkerSigner accounts (post aiolabs/lnbits PR #18 + PR #38): the bunker holds the nsec; satmachineadmin's transport calls signer.nip44_encrypt / signer.nip44_decrypt over NIP-46. No operator nsec on the LNbits disk. This is the steady-state architecture per aiolabs/lnbits#9 reframe + aiolabs/satmachineadmin#21 (S7).
  • LocalSigner accounts (transitional, pre-S7-completion): cassette_transport._nip44_*_via_signer catches the SignerUnavailableError that the LocalSigner stub raises on nip44_* and falls back to the hand-rolled nip44.py impl against account.prvkey. Same wire output; same operational behaviour. Operator nsec IS on the LNbits disk during this transitional window.

The transitional LocalSigner-with-prvkey path retires when every operator account that owns satmachineadmin machines has been migrated to RemoteBunkerSigner. Tracked as the final acceptance item on #21. After that, the LocalSigner fallback can be deleted from cassette_transport._nip44_*_via_signer and the nip44.py runtime exports collapse to test-only fixtures.

What this means operationally today

  • On a fresh LNbits instance with RemoteBunkerSigner configured: operators provisioned via the bunker have accounts.prvkey IS NULL; v1/v1.1 cassette transport works through the bunker.
  • On an LNbits instance with mixed accounts (some LocalSigner, some RemoteBunkerSigner): cassette transport works for both; the LocalSigner path reads account.prvkey directly, the bunker path doesn't. ⚠️
  • On a fresh RemoteBunkerSigner-only instance where the operator's bunker is offline / locked / unprovisioned: transport returns 503 (SignerUnavailable) on publish and skips on consume. The consumer logs at INFO and retries on next poll (transient NsecBunkerTimeoutError semantics); the publish endpoint surfaces a meaningful operator-facing error.
  • accounts.prvkey being readable from the LNbits DB during the transitional window is the residual security gap S7 + S6 + S2 collectively close.

Sequencing

Not a blocker for v1.1 closure. Documented here as the operator-facing caveat for any deployment that's not yet fully bunker-backed.

Cross-references

  • aiolabs/lamassu-next#56 — bitspire's mirror of this caveat on the ATM side.
  • aiolabs/satmachineadmin#21 — S7 (consume bunker) tracking the full retirement.
  • aiolabs/lnbits#18 — bunker provisioning + RemoteBunkerSigner infrastructure.
  • aiolabs/lnbits PR #38signer.nip44_* surface that the satmachineadmin transport consumes.
  • PR #30 commit dcb7de0 — the satmachineadmin migration that lands the hybrid path.
## v1 local-prvkey caveat (mirror of bitspire `lamassu-next#56`) For symmetry with bitspire's `lamassu-next#56` documentation update on the ATM side: The cassette transport's NIP-44 v2 encrypt + decrypt paths in v1 + v1.1 require the operator's `account.prvkey` to be populated on the LNbits side **for any operator account not yet migrated to `RemoteBunkerSigner`**. Concretely: - **`RemoteBunkerSigner` accounts** (post `aiolabs/lnbits` PR #18 + PR #38): the bunker holds the nsec; satmachineadmin's transport calls `signer.nip44_encrypt` / `signer.nip44_decrypt` over NIP-46. **No operator nsec on the LNbits disk.** This is the steady-state architecture per `aiolabs/lnbits#9` reframe + `aiolabs/satmachineadmin#21` (S7). - **`LocalSigner` accounts** (transitional, pre-S7-completion): `cassette_transport._nip44_*_via_signer` catches the `SignerUnavailableError` that the LocalSigner stub raises on `nip44_*` and falls back to the hand-rolled `nip44.py` impl against `account.prvkey`. Same wire output; same operational behaviour. **Operator nsec IS on the LNbits disk during this transitional window.** The transitional LocalSigner-with-prvkey path retires when every operator account that owns satmachineadmin machines has been migrated to `RemoteBunkerSigner`. Tracked as the final acceptance item on `#21`. After that, the LocalSigner fallback can be deleted from `cassette_transport._nip44_*_via_signer` and the `nip44.py` runtime exports collapse to test-only fixtures. ### What this means operationally today - On a fresh LNbits instance with `RemoteBunkerSigner` configured: operators provisioned via the bunker have `accounts.prvkey IS NULL`; v1/v1.1 cassette transport works through the bunker. ✅ - On an LNbits instance with mixed accounts (some `LocalSigner`, some `RemoteBunkerSigner`): cassette transport works for both; the LocalSigner path reads `account.prvkey` directly, the bunker path doesn't. ⚠️ - On a fresh `RemoteBunkerSigner`-only instance where the operator's bunker is offline / locked / unprovisioned: transport returns 503 (`SignerUnavailable`) on publish and skips on consume. The consumer logs at INFO and retries on next poll (transient `NsecBunkerTimeoutError` semantics); the publish endpoint surfaces a meaningful operator-facing error. ✅ - `accounts.prvkey` being readable from the LNbits DB during the transitional window is the residual security gap S7 + S6 + S2 collectively close. ### Sequencing Not a blocker for v1.1 closure. Documented here as the operator-facing caveat for any deployment that's not yet fully bunker-backed. ### Cross-references - `aiolabs/lamassu-next#56` — bitspire's mirror of this caveat on the ATM side. - `aiolabs/satmachineadmin#21` — S7 (consume bunker) tracking the full retirement. - `aiolabs/lnbits#18` — bunker provisioning + `RemoteBunkerSigner` infrastructure. - `aiolabs/lnbits` PR #38 — `signer.nip44_*` surface that the satmachineadmin transport consumes. - PR #30 commit `dcb7de0` — the satmachineadmin migration that lands the hybrid path.
Author
Owner

Closed — shipped via PR #30v2-bitspire @ 44f6c0b

Operator-side per-machine cassette inventory landed as designed. All AC items from the v1.1 issue body satisfied:

  • cassette_configs table at PRIMARY KEY (machine_id, position), with reserved nullable state_denomination / state_count / state_at / state_event_id columns for v2
    reverse-channel reconciliation (no migration needed when v2 lands)
  • Bootstrap consumer auto-populates cassette_configs from ATM-published bitspire-cassettes-state:<atm_pubkey_hex> events via signer.nip44_decrypt over NIP-46 bunker
  • Per-machine Cassettes sub-tab in the existing machine-detail modal — editable denomination + count per slot, position read-only ("Bay N"), no add/remove controls, submit-time
    overwrite-confirmation modal
  • Publish-to-ATM via kind-30078 NIP-44 v2-encrypted positions-keyed payload {positions: {<pos>: {denomination, count}}}signer.nip44_encrypt + signer.sign_event both via
    bunker
  • Multi-same-denom support — production case where a machine loads N cassettes with the same denomination for cash-out throughput
  • No operator_commands / kind-21003 fallback path (workspace "respect protocol semantics" rule)

Validated end-to-end on Sintra hardware + regtest

Direction Evidence
Operator → ATM publish event_id 04a57d6a6ce2… lands on relay via bunker, log 2026-05-31T13:21:37Z
ATM → operator consume event_id 970c67147e18… decrypted via bunker + apply_bootstrap_state upserts, log 2026-05-31T13:43:10Z
Multi-same-denom HAL dispense Live $20 cash-out drained from bay1:20×20, bay2:20×10 after operator-driven cassette denomination edit, bitspire coord-log 2026-05-31T06:22Z

Paired with aiolabs/lamassu-next#56 (ATM-side consumer + bootstrap publisher), aiolabs/lnbits#38 (bunker-mediated signer.nip44_*), and aiolabs/nsecbunkerd#15 (NDK 3.0.3 nip44
strategy) + #17 (boot autounlock).

Out of scope (tracked separately)

  • v2 continuous reverse-channel consumer + dashboard reconciliation UI — future issue paired with aiolabs/lamassu-next#57
  • LocalSigner→bunker retirement for any operator not yet migrated — aiolabs/satmachineadmin#21 (S7) final AC
  • Wallet-routing miss when LNbits' nostr-transport auto-account-from-npub fires for an ATM npub instead of the registered operator — symptom mitigation at #31, root-cause guard at
    #32, architectural fix at #20 (S6)
  • Local-prvkey caveat documentation — already in this issue's earlier comment thread + mirrored on bitspire lamassu-next#56

Closing as shipped.

## Closed — shipped via PR #30 → `v2-bitspire` @ `44f6c0b` Operator-side per-machine cassette inventory landed as designed. All AC items from the v1.1 issue body satisfied: - ✅ `cassette_configs` table at `PRIMARY KEY (machine_id, position)`, with reserved nullable `state_denomination` / `state_count` / `state_at` / `state_event_id` columns for v2 reverse-channel reconciliation (no migration needed when v2 lands) - ✅ Bootstrap consumer auto-populates `cassette_configs` from ATM-published `bitspire-cassettes-state:<atm_pubkey_hex>` events via `signer.nip44_decrypt` over NIP-46 bunker - ✅ Per-machine Cassettes sub-tab in the existing machine-detail modal — editable denomination + count per slot, position read-only ("Bay N"), no add/remove controls, submit-time overwrite-confirmation modal - ✅ Publish-to-ATM via kind-30078 NIP-44 v2-encrypted positions-keyed payload `{positions: {<pos>: {denomination, count}}}` — `signer.nip44_encrypt` + `signer.sign_event` both via bunker - ✅ Multi-same-denom support — production case where a machine loads N cassettes with the same denomination for cash-out throughput - ✅ No `operator_commands` / kind-21003 fallback path (workspace "respect protocol semantics" rule) ### Validated end-to-end on Sintra hardware + regtest | Direction | Evidence | |---|---| | Operator → ATM publish | event_id `04a57d6a6ce2…` lands on relay via bunker, log 2026-05-31T13:21:37Z | | ATM → operator consume | event_id `970c67147e18…` decrypted via bunker + `apply_bootstrap_state` upserts, log 2026-05-31T13:43:10Z | | Multi-same-denom HAL dispense | Live $20 cash-out drained from `bay1:20×20, bay2:20×10` after operator-driven cassette denomination edit, bitspire coord-log 2026-05-31T06:22Z | Paired with `aiolabs/lamassu-next#56` (ATM-side consumer + bootstrap publisher), `aiolabs/lnbits#38` (bunker-mediated `signer.nip44_*`), and `aiolabs/nsecbunkerd#15` (NDK 3.0.3 nip44 strategy) + `#17` (boot autounlock). ### Out of scope (tracked separately) - v2 continuous reverse-channel consumer + dashboard reconciliation UI — future issue paired with `aiolabs/lamassu-next#57` - LocalSigner→bunker retirement for any operator not yet migrated — `aiolabs/satmachineadmin#21` (S7) final AC - Wallet-routing miss when LNbits' nostr-transport auto-account-from-npub fires for an ATM npub instead of the registered operator — symptom mitigation at `#31`, root-cause guard at `#32`, architectural fix at `#20` (S6) - Local-prvkey caveat documentation — already in this issue's earlier comment thread + mirrored on bitspire `lamassu-next#56` Closing as shipped.
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
aiolabs/satmachineadmin#29
No description provided.