Operator dashboard: per-machine cassette inventory config #29
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Problem
Cassette denomination + count on each deployed ATM is currently only editable via:
atm-tuion the ATM box (interactive TUI, requires SSH + sudo) —~/dev/bitspire/atm-tuiVITE_LAMASSU_CASSETTESenv var (first-boot seed only)state.dbFor 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 ↔ bitspiresessions (~/dev/coordination/log.md, entries06: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>]tagLocalSigneradapter); bunker-mediated postaiolabs/lnbits#26cascade via config-only swap toRemoteBunkerSignerRuled 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_configsPK 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):
denominationskeys 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_configstableThe 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:
(machine_id, denomination)pairbitspire-cassettes-state:<machine_id>event (v1)countorpositionupdated_at+updated_by; triggers publish-to-ATMVersioning — v1 ships first, v2 before prod fleet
v1 (this issue's scope) — operator → ATM publish + ATM bootstrap consumer + dashboard UI
cassette_configstable with reserved v2 columnsbitspire-cassettes-state:<machine_id>events from each known machine and auto-populatescassette_configscount+position; submit publishes a kind-30078 to the targeted ATM07:50Z— calls out the v1 reconciliation gotcha to the operator before clobbering ATM-tracked counts)state_count/state_at/state_event_idon bootstrap receipt; doesn't render reconciliation UIShippable 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 UIapplied_config_event_idcarried in the state event)Bootstrap consumer (v1)
Subscribe to ATM-published state events for each known machine:
(
#dfilter takes a list — one subscription covers all machines in satmachineadmin's machines table.)On event receipt:
event.pubkeynot in the known ATM npub set for that machinedenominationspayloadcassette_configsrow per(machine_id, denomination): setcount = 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'state_event_idalready matches the incoming event, no-opDashboard UI (v1)
Per-machine "Cassettes" tab in the existing machine detail view:
cassette_configsfor that machinecount,positioncassette_configs(updatingupdated_at+updated_by) AND triggers publish-to-ATMPublish-to-ATM (v1)
On submit:
cassette_configsfor the machine:{ "denominations": { "<denom>": { "position": <pos>, "count": <count> }, ... } }denominationskeys match the currentcassette_configsdenomination set for that machine (defensive — UI prevents add/remove but API enforces);positionpositive int;countnon-negative int; no duplicate positionsLocalSigner; post lnbits#26:RemoteBunkerSigner)kind=30078,["d", "bitspire-cassettes:<machine_id>"],["p", <atm_npub>]to configured relay(s)Acceptance criteria (v1)
Schema + storage
cassette_configstable withPRIMARY KEY (machine_id, denomination)and reserved nullablestate_count/state_at/state_event_idcolumnsBootstrap consumer
bitspire-cassettes-state:<machine_id>events for all known machines (single subscription with#dlist filter)event.pubkeynot in machine's known ATM npub setcassette_configsrows from payload; populatestate_count/state_at/state_event_idstate_event_idmatches)Dashboard UI
cassette_configscount+positionper row; no add/remove controlscassette_configs(auditupdated_at+updated_by) + trigger publish-to-ATMPublish
POST /api/v1/dca/machines/<machine_id>/cassettes/publish, superuser/admin authcassette_configsfor that machine)operator_commands/ kind-21003 fallback pathOut of scope (this issue)
aiolabs/lamassu-next#57recordTransaction→updateCassetteCount; satmachineadmin doesn't track decrements in v1 (reconciliation lands in v2 via the reverse channel)dcd0874's privacy pivotaiolabs/lamassu-next#56Coordination
aiolabs/lamassu-next#56aiolabs/lamassu-next#57(ATM-side continuous reverse channel) + satmachineadmin counterpart (dashboard reconciliation UI)dcd0874(NIP-78 rip + privacy-by-default architecture decision)aiolabs/lnbits#18/#26(signer abstraction — v1 uses env-pinnedLocalSigner, post-cascade swaps toRemoteBunkerSignervia config-only change)~/dev/CLAUDE.md(Nostr architecture → "Respect protocol semantics over friction reduction" — workspace-level rule grounded in this decision)~/dev/bitspire/atm-tui~/dev/coordination/log.mdentries at 2026-05-30T06:30Z, 06:40Z, 07:30Z, 07:50Z, 07:55Z, 08:00ZDesign 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:db.zig:31—cassettes (denomination INTEGER PRIMARY KEY, ...)state-store.ts:54— same PK; migration v5→v6 addedpositionas a sortable column, not a keystate-store.ts:265—INSERT ... ON CONFLICT(denomination) DO UPDATE SET count = excluded.count, position = excluded.positionstate-store.ts:287—UPDATE cassettes SET count = ... WHERE denomination = ?hal-service.ts:116—inventory[cassette.denomination] = cassette.counthal-service.ts:189—cassetteDenominations.indexOf(denomination)— first match wins; duplicates silently collapsehal-service.ts:207— assigns syntheticposition: ifrom array index, not from DBThe 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:
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.{denomination → {position, count}}PRIMARY KEY (machine_id, denomination)withpositionas 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).#29+#56); no ATM-side code change.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_configsrows 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#56is 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_commandskind-21003 queue) is a trapOnce 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
#29and#56AC 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 oncassette_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_atcolumns from day 1Reconciliation (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_configstable on satmachineadmin withPRIMARY KEY (machine_id, denomination), columns:denomination,count,position,updated_at,updated_by,ack_count NULLABLE,ack_at NULLABLE{denomination → {position, count}}for denominations currently incassette_configsfor that machine; no option-3 stopgap["d", "bitspire-cassettes-ack:<machine_id>"]carrying content hash + applied counts; satmachineadmin updatesack_count+ack_atper row on receiptupdated_at+updated_byretained on every operator editCoordination
Filed paired entry in
~/dev/coordination/log.mdat2026-05-30T06:30Z(and follow-up flipping A→B at2026-05-30T06:40Z) for the bitspire/atm-tui session workingaiolabs/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), commitdcd0874(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.v1 local-prvkey caveat (mirror of bitspire
lamassu-next#56)For symmetry with bitspire's
lamassu-next#56documentation update on the ATM side:The cassette transport's NIP-44 v2 encrypt + decrypt paths in v1 + v1.1 require the operator's
account.prvkeyto be populated on the LNbits side for any operator account not yet migrated toRemoteBunkerSigner. Concretely:RemoteBunkerSigneraccounts (postaiolabs/lnbitsPR #18 + PR #38): the bunker holds the nsec; satmachineadmin's transport callssigner.nip44_encrypt/signer.nip44_decryptover NIP-46. No operator nsec on the LNbits disk. This is the steady-state architecture peraiolabs/lnbits#9reframe +aiolabs/satmachineadmin#21(S7).LocalSigneraccounts (transitional, pre-S7-completion):cassette_transport._nip44_*_via_signercatches theSignerUnavailableErrorthat the LocalSigner stub raises onnip44_*and falls back to the hand-rollednip44.pyimpl againstaccount.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 fromcassette_transport._nip44_*_via_signerand thenip44.pyruntime exports collapse to test-only fixtures.What this means operationally today
RemoteBunkerSignerconfigured: operators provisioned via the bunker haveaccounts.prvkey IS NULL; v1/v1.1 cassette transport works through the bunker. ✅LocalSigner, someRemoteBunkerSigner): cassette transport works for both; the LocalSigner path readsaccount.prvkeydirectly, the bunker path doesn't. ⚠️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 (transientNsecBunkerTimeoutErrorsemantics); the publish endpoint surfaces a meaningful operator-facing error. ✅accounts.prvkeybeing 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 +RemoteBunkerSignerinfrastructure.aiolabs/lnbitsPR #38 —signer.nip44_*surface that the satmachineadmin transport consumes.dcb7de0— the satmachineadmin migration that lands the hybrid path.Closed — shipped via PR #30 →
v2-bitspire@44f6c0bOperator-side per-machine cassette inventory landed as designed. All AC items from the v1.1 issue body satisfied:
cassette_configstable atPRIMARY KEY (machine_id, position), with reserved nullablestate_denomination/state_count/state_at/state_event_idcolumns for v2reverse-channel reconciliation (no migration needed when v2 lands)
cassette_configsfrom ATM-publishedbitspire-cassettes-state:<atm_pubkey_hex>events viasigner.nip44_decryptover NIP-46 bunkeroverwrite-confirmation modal
{positions: {<pos>: {denomination, count}}}—signer.nip44_encrypt+signer.sign_eventboth viabunker
operator_commands/ kind-21003 fallback path (workspace "respect protocol semantics" rule)Validated end-to-end on Sintra hardware + regtest
04a57d6a6ce2…lands on relay via bunker, log 2026-05-31T13:21:37Z970c67147e18…decrypted via bunker +apply_bootstrap_stateupserts, log 2026-05-31T13:43:10Zbay1:20×20, bay2:20×10after operator-driven cassette denomination edit, bitspire coord-log 2026-05-31T06:22ZPaired with
aiolabs/lamassu-next#56(ATM-side consumer + bootstrap publisher),aiolabs/lnbits#38(bunker-mediatedsigner.nip44_*), andaiolabs/nsecbunkerd#15(NDK 3.0.3 nip44strategy) +
#17(boot autounlock).Out of scope (tracked separately)
aiolabs/lamassu-next#57aiolabs/satmachineadmin#21(S7) final AC#31, root-cause guard at#32, architectural fix at#20(S6)lamassu-next#56Closing as shipped.