ATM-side consumer: operator-driven cassette inventory config #56

Open
opened 2026-06-13 22:03:05 +00:00 by padreug · 2 comments
Owner

Migrated from aiolabs/lamassu-next#56 — opened by @padreug on 2026-05-28.\n\n## Problem

Today, cassette denomination + count on a deployed ATM are set in three places, none of which are operator-facing from a remote dashboard:

  1. atm-tui (interactive TUI on the ATM, requires SSH + sudo) — ~/dev/bitspire/atm-tui
  2. VITE_LAMASSU_CASSETTES env var (only seeds an empty DB on first run; never overrides existing rows) — apps/machine/electron/main.ts:622
  3. Direct SQL on /var/lib/bitspire/state.db (escape hatch)

For dev (sintra), in-person is fine. For prod operation, the operator needs to manage cassette inventory from the satmachineadmin dashboard without SSH-ing to the box.

This issue tracks the ATM-side consumer of operator-driven cassette config. The operator-side producer + dashboard UI lives at aiolabs/satmachineadmin#29 (filed in parallel).

Decisions (2026-05-30; v1.1 revision after design audit)

After scoping conversation across bitspire ↔ satmachineadmin sessions and a v1.1 correction (see ~/dev/coordination/log.md entries 06:30Z → 18:45Z), the design is:

Transport: encrypted kind-30078

  • 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 today; bunker-mediated post aiolabs/lnbits#26 cascade)

Ruled out CLINK kind-21003 per workspace ~/dev/CLAUDE.md "Respect protocol semantics over friction reduction": CLINK is a payment-flow protocol (Offer/Debit/Manage of payment offers), documented dormant on the dev branch — conceptually different from operator → ATM config.

Ruled out LNbits-sidecar-bunker-query: expands LNbits' role beyond payments, couples operator-config availability to LNbits availability, routes a different threat-model relationship through the wallet-ops bus.

Wire shape: position-keyed (v1.1 correction from earlier denomination-keyed attempt)

The earlier 06:40Z audit recommended denomination-keyed wire under the (mistaken) assumption that the ATM stack's denomination-PK invariant was load-bearing AND that one-cassette-per-denomination was a real operational constraint. Both were wrong:

  • The operator must be able to edit per-slot denomination remotely (dispatcher swaps a $20 cassette into bay 2 during refill — operator records that)
  • Real production machines load multiple cassettes with the same denomination for cash-out throughput on a single denomination (4 × $20 cassettes on Tejo / batm3 are normal)

So position is the addressable unit (matches the hardware bay layout), and denomination is a mutable per-row field. Two rows may carry the same denomination.

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

{
  "positions": {
    "1": { "denomination": 20, "count": 49 },
    "2": { "denomination": 20, "count": 38 },
    "3": { "denomination": 50, "count": 100 }
  }
}

positions keys MUST be exactly the set of positions currently present in the ATM's state.db (no add, no remove from this path — the bay count is hardware-determined). Denomination + count per row are both operator-mutable. NO denomination-uniqueness constraint — duplicate denominations across positions are intentionally permitted.

Editable surface — what the dashboard can change

Property Editable from satmachineadmin? Why
Number of cartridge slots No — fixed per machine Hardware-determined; the F56 dispenser has N physical bays. Set once at provisioning via VITE_LAMASSU_CASSETTES or atm-tui. Changing it requires a service tech / hardware event.
Per-slot denomination Yes Reflects what the dispatcher physically loaded into that bay. Operationally fluid: swapping a $20 cartridge for a $50 cartridge during refill is a normal, expected event.
Per-slot count Yes Daily-operational; primary use case.

Bounds blast radius even under satmachineadmin compromise: an attacker can shift positions or counts within the existing N slots but cannot add phantom cartridges that don't exist physically.

Existing ATM-side state

  • cassettes table in /var/lib/bitspire/state.db: post-v9 migration the PK is position INTEGER PRIMARY KEY; denomination is a regular column (allows duplicates across rows); count INTEGER NOT NULL DEFAULT 0
  • meta table extends with lastKnownConfigCreatedAt (v7→v8) and bootstrapPublishedAt (v7→v8); v8→v9 rebuilds cassettes PK
  • setCassettes() in apps/machine/electron/state-store.ts is the canonical write path (ON CONFLICT(position) DO UPDATE)
  • loadCassettes() is read at HAL init (main.ts:381) — HAL needs per-position layout
  • HAL (hal-service.ts) tracks per-bay state and distributes a denomination ask across all matching bays greedily on dispense
  • IPC handlers exist for renderer↔main: state:load-cassettes, state:set-cassettes, hal:reload-cassettes

Versioning — v1 ships first, v2 before prod fleet

v1 (this issue's primary scope, with v1.1 wire-shape correction) — operator → ATM publish + one-shot ATM bootstrap publish

  • ATM subscribes to operator-published kind-30078 (["d", "bitspire-cassettes:<machine_id>"]), validates, applies to state.db, hot-reloads HAL
  • ATM tracks cassettes.count decrements locally on cash-out (existing path, now per-position via dispenser results)
  • One-shot ATM-side bootstrap publish on first boot (["d", "bitspire-cassettes-state:<machine_id>"]) so satmachineadmin can auto-populate cassette_configs for the machine before the operator's first config publish — see "Bootstrap" section below
  • No continuous reverse-channel publish in v1

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

v2 (filed as aiolabs/lamassu-next#57 when ready, with satmachineadmin counterpart) — continuous ATM-state reverse channel

ATM publishes the same ["d", "bitspire-cassettes-state:<machine_id>"] event continuously: on every count change (cash-out, count refresh) + periodic heartbeat. Carries observed per-position state + applied_config_event_id. Enables dashboard reconciliation, ✅/⏳ apply confirmation, safe "Add N bills" UX.

Bootstrap hello-event (v1, satmachineadmin asks)

A fresh ATM's state.db.cassettes is seeded at provisioning (via VITE_LAMASSU_CASSETTES or atm-tui) but satmachineadmin's cassette_configs is empty for that machine. Without a bootstrap, the operator's first config publish has nothing to match against.

On first boot (meta.bootstrapPublishedAt is null AND state.db.cassettes is non-empty):

  1. ATM reads current cassettes from state.db
  2. Builds payload: { "positions": { "<pos>": { "denomination": <denom>, "count": <count> }, ... } }
  3. Publishes kind=30078, ["d", "bitspire-cassettes-state:<machine_id>"], ["p", <operator_pubkey>], NIP-44 v2 encrypted content, signed by ATM
  4. Sets meta.bootstrapPublishedAt = now after publish succeeds

Bootstrap is one-shot per ATM. If satmachineadmin loses its cassette_configs rows, the recovery path is on-box only in v1:

ssh bitspire@<atm>
sudo sqlite3 /var/lib/bitspire/state.db \
  "UPDATE meta SET value = '' WHERE key = 'bootstrapPublishedAt'"
sudo systemctl restart bitspire

v1 caveat — operator account requires a local nsec

Decryption of inbound bitspire-cassettes-state: events on the satmachineadmin side uses the operator's local accounts.prvkey (the LNbits Nostr-login flow populates this). Operator MUST onboard via Nostr-login before the consumer can decrypt bootstrap events. This violates the "no nsec at rest on LNbits" architectural rule but is the working path until lnbits phase 2.4 (bunker-mediated nip44_decrypt, gated on nsecbunkerd's create_new_policy rule-kind fix) ships. Phase 2.4 will let the consumer route through signer.nip44_decrypt(...) instead of reading account.prvkey directly. See ~/dev/coordination/log.md 2026-05-30T17:25Z for the architectural gap diagnosis.

v1 reconciliation gotcha (call out in operator UX)

Cash-out decrements cassettes.count locally on the ATM. If the operator publishes a new config without accounting for cash-outs since their last baseline, the ATM count gets clobbered to the operator's stated value. Mitigations in v1:

  • Operator UX should publish ONLY after physical refills (a known total), not "tweak" counts mid-day. Satmachineadmin commits to surfacing a "this publish will overwrite ATM-tracked counts" confirmation modal on submit (07:50Z).

Resolved in v2 once the continuous reverse channel + dashboard reconciliation surface lands.

ATM-side acceptance criteria (v1)

Subscription path

  • Subscribe to operator-config events with filter:
    { kinds: [30078], "#p": [<my_atm_npub>], "#d": ["bitspire-cassettes:<machine_id>"], authors: VITE_OPERATOR_PUBKEYS }
    
  • On each event, verify signature; reject if event.pubkey not in VITE_OPERATOR_PUBKEYS
  • Replay protection via meta.lastKnownConfigCreatedAt:
    • Reject event.created_at <= meta.lastKnownConfigCreatedAt (includes stale events re-delivered on relay reconnect / after restart)
    • Reject event.created_at > now + MAX_FUTURE_SKEW_S (clock-skew / future-stamping defense)
  • NIP-44 v2 decrypt content (existing helpers in @bitSpire/nostr-client)
  • Validate decrypted payload:
    • positions keys are exactly the set of positions currently in state.db.cassettes (reject unknown positions; reject any update that omits a known position — partial updates not supported)
    • All denomination values are positive ints; all count values are non-negative ints
    • No denomination-uniqueness check — duplicate denominations across positions are valid
  • Apply in a single SQLite transaction: upsert cassettes rows on position PK (denomination + count both mutate per row) + update meta.lastKnownConfigCreatedAt
  • HAL re-initializes with new cassette mapping via hal:reload-cassettes IPC (rebuild per-bay state + re-init dispenser)
  • Renderer's persistedInventory ref refreshes after apply
  • atm-tui continues to work as a fallback (write paths don't conflict)

Bootstrap publish path

  • On startup, if meta.bootstrapPublishedAt IS NULL AND state.db.cassettes has at least one row:
    • Build position-keyed payload from current cassettes table
    • Encrypt content with NIP-44 v2 to VITE_OPERATOR_PUBKEYS[0] (first operator pubkey)
    • Sign with ATM's nsec
    • Publish to configured relay(s) with ["d", "bitspire-cassettes-state:<machine_id>"], ["p", <operator_pubkey>]
    • On publish success: set meta.bootstrapPublishedAt = unix_now()
  • If publish fails (relay unreachable, signer error): leave bootstrapPublishedAt null and retry next boot. Don't block ATM startup.
  • Skip bootstrap publish entirely (without setting bootstrapPublishedAt) if VITE_OPERATOR_PUBKEYS is empty, so retry happens on next boot once operator pubkeys are configured.

HAL per-position dispense

  • HAL inventory tracked per-bay (not per-denomination summed)
  • Dispense distribution: when asked for N of denomination D, iterate all bays whose denomination = D, drain greedy until ask is satisfied or all matching bays empty. Error if insufficient inventory across matching bays.
  • After dispense, decrement per-bay count by what each bay actually dispensed (dispenser returns per-position results)
  • getInventory() boundary stays denomination-keyed (sums across matching bays) for backwards compat with the renderer

Out of scope

  • Continuous ATM-side reverse-channel publish — v2, separate issue
  • Adding or removing cassette slots from the dashboard — hardware-determined, set once at machine onboarding or by re-provisioning via VITE_LAMASSU_CASSETTES / atm-tui
  • Cassette count updates from dispense events (already handled ATM-side via recordTransaction → per-position count decrement)
  • Multi-machine fleet-wide cassette config (each ATM owns its own)
  • Operator-side dashboard UI + storage (lives in aiolabs/satmachineadmin#29)

Coordination

  • Mirror in satmachineadmin: aiolabs/satmachineadmin#29
  • Related: satmachineadmin@dcd0874 (NIP-78 rip + privacy-by-default architecture decision)
  • Related: aiolabs/lnbits#18 / #26 (signer abstraction; phase 2.4 for bunker-decrypt)
  • Related: ~/dev/CLAUDE.md (Nostr architecture → "Respect protocol semantics over friction reduction" — workspace-level rule grounded in this decision)
  • Existing UI surface for parallel operator work: ~/dev/bitspire/atm-tui
  • Decision rationale: ~/dev/coordination/log.md entries at 2026-05-30T06:30Z → 19:00Z
> _Migrated from [aiolabs/lamassu-next#56](https://git.atitlan.io/aiolabs/lamassu-next/issues/56) — opened by @padreug on 2026-05-28._\n\n## Problem Today, cassette denomination + count on a deployed ATM are set in three places, none of which are operator-facing from a remote dashboard: 1. `atm-tui` (interactive TUI on the ATM, requires SSH + sudo) — `~/dev/bitspire/atm-tui` 2. `VITE_LAMASSU_CASSETTES` env var (only seeds an empty DB on first run; never overrides existing rows) — `apps/machine/electron/main.ts:622` 3. Direct SQL on `/var/lib/bitspire/state.db` (escape hatch) For dev (sintra), in-person is fine. For prod operation, the operator needs to manage cassette inventory from the satmachineadmin dashboard without SSH-ing to the box. This issue tracks the **ATM-side consumer** of operator-driven cassette config. The **operator-side producer + dashboard UI** lives at aiolabs/satmachineadmin#29 (filed in parallel). ## Decisions (2026-05-30; v1.1 revision after design audit) After scoping conversation across `bitspire ↔ satmachineadmin` sessions and a v1.1 correction (see `~/dev/coordination/log.md` entries 06:30Z → 18:45Z), the design is: ### Transport: encrypted kind-30078 - `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 today; bunker-mediated post `aiolabs/lnbits#26` cascade) Ruled out CLINK kind-21003 per workspace `~/dev/CLAUDE.md` "Respect protocol semantics over friction reduction": CLINK is a payment-flow protocol (Offer/Debit/Manage of payment offers), documented dormant on the dev branch — conceptually different from operator → ATM config. Ruled out LNbits-sidecar-bunker-query: expands LNbits' role beyond payments, couples operator-config availability to LNbits availability, routes a different threat-model relationship through the wallet-ops bus. ### Wire shape: position-keyed (v1.1 correction from earlier denomination-keyed attempt) The earlier `06:40Z` audit recommended denomination-keyed wire under the (mistaken) assumption that the ATM stack's denomination-PK invariant was load-bearing AND that one-cassette-per-denomination was a real operational constraint. Both were wrong: - **The operator must be able to edit per-slot denomination remotely** (dispatcher swaps a $20 cassette into bay 2 during refill — operator records that) - **Real production machines load multiple cassettes with the same denomination** for cash-out throughput on a single denomination (4 × $20 cassettes on Tejo / batm3 are normal) So `position` is the addressable unit (matches the hardware bay layout), and `denomination` is a mutable per-row field. Two rows may carry the same denomination. **Wire payload (kind-30078 content, NIP-44 v2 encrypted):** ```json { "positions": { "1": { "denomination": 20, "count": 49 }, "2": { "denomination": 20, "count": 38 }, "3": { "denomination": 50, "count": 100 } } } ``` `positions` keys MUST be exactly the set of positions currently present in the ATM's `state.db` (no add, no remove from this path — the bay count is hardware-determined). Denomination + count per row are both operator-mutable. NO denomination-uniqueness constraint — duplicate denominations across positions are intentionally permitted. ## Editable surface — what the dashboard can change | Property | Editable from satmachineadmin? | Why | |---|---|---| | **Number of cartridge slots** | **No** — fixed per machine | Hardware-determined; the F56 dispenser has N physical bays. Set once at provisioning via `VITE_LAMASSU_CASSETTES` or atm-tui. Changing it requires a service tech / hardware event. | | **Per-slot denomination** | **Yes** | Reflects what the dispatcher physically loaded into that bay. Operationally fluid: swapping a $20 cartridge for a $50 cartridge during refill is a normal, expected event. | | **Per-slot count** | **Yes** | Daily-operational; primary use case. | Bounds blast radius even under satmachineadmin compromise: an attacker can shift positions or counts within the existing N slots but cannot add phantom cartridges that don't exist physically. ## Existing ATM-side state - `cassettes` table in `/var/lib/bitspire/state.db`: post-v9 migration the PK is `position INTEGER PRIMARY KEY`; `denomination` is a regular column (allows duplicates across rows); `count INTEGER NOT NULL DEFAULT 0` - `meta` table extends with `lastKnownConfigCreatedAt` (v7→v8) and `bootstrapPublishedAt` (v7→v8); v8→v9 rebuilds `cassettes` PK - `setCassettes()` in `apps/machine/electron/state-store.ts` is the canonical write path (`ON CONFLICT(position) DO UPDATE`) - `loadCassettes()` is read at HAL init (`main.ts:381`) — HAL needs per-position layout - HAL (`hal-service.ts`) tracks per-bay state and distributes a denomination ask across all matching bays greedily on dispense - IPC handlers exist for renderer↔main: `state:load-cassettes`, `state:set-cassettes`, `hal:reload-cassettes` ## Versioning — v1 ships first, v2 before prod fleet **v1** (this issue's primary scope, with v1.1 wire-shape correction) — **operator → ATM publish + one-shot ATM bootstrap publish** - ATM subscribes to operator-published kind-30078 (`["d", "bitspire-cassettes:<machine_id>"]`), validates, applies to state.db, hot-reloads HAL - ATM tracks `cassettes.count` decrements locally on cash-out (existing path, now per-position via dispenser results) - **One-shot ATM-side bootstrap publish on first boot** (`["d", "bitspire-cassettes-state:<machine_id>"]`) so satmachineadmin can auto-populate `cassette_configs` for the machine before the operator's first config publish — see "Bootstrap" section below - No continuous reverse-channel publish in v1 Shippable for **dev** (Sintra-style: operator can SSH to verify state.db). Not safe for multi-machine prod. **v2** (filed as `aiolabs/lamassu-next#57` when ready, with satmachineadmin counterpart) — **continuous ATM-state reverse channel** ATM publishes the same `["d", "bitspire-cassettes-state:<machine_id>"]` event continuously: on every count change (cash-out, count refresh) + periodic heartbeat. Carries observed per-position state + `applied_config_event_id`. Enables dashboard reconciliation, ✅/⏳ apply confirmation, safe "Add N bills" UX. ## Bootstrap hello-event (v1, satmachineadmin asks) A fresh ATM's `state.db.cassettes` is seeded at provisioning (via `VITE_LAMASSU_CASSETTES` or atm-tui) but satmachineadmin's `cassette_configs` is empty for that machine. Without a bootstrap, the operator's first config publish has nothing to match against. **On first boot** (`meta.bootstrapPublishedAt` is null AND `state.db.cassettes` is non-empty): 1. ATM reads current cassettes from state.db 2. Builds payload: `{ "positions": { "<pos>": { "denomination": <denom>, "count": <count> }, ... } }` 3. Publishes `kind=30078`, `["d", "bitspire-cassettes-state:<machine_id>"]`, `["p", <operator_pubkey>]`, NIP-44 v2 encrypted content, signed by ATM 4. Sets `meta.bootstrapPublishedAt = now` after publish succeeds **Bootstrap is one-shot per ATM.** If satmachineadmin loses its `cassette_configs` rows, the recovery path is **on-box only** in v1: ```bash ssh bitspire@<atm> sudo sqlite3 /var/lib/bitspire/state.db \ "UPDATE meta SET value = '' WHERE key = 'bootstrapPublishedAt'" sudo systemctl restart bitspire ``` ## v1 caveat — operator account requires a local nsec Decryption of inbound `bitspire-cassettes-state:` events on the satmachineadmin side uses the operator's local `accounts.prvkey` (the LNbits Nostr-login flow populates this). Operator MUST onboard via Nostr-login before the consumer can decrypt bootstrap events. This **violates the "no nsec at rest on LNbits" architectural rule** but is the working path until `lnbits` phase 2.4 (bunker-mediated `nip44_decrypt`, gated on `nsecbunkerd`'s `create_new_policy` rule-kind fix) ships. Phase 2.4 will let the consumer route through `signer.nip44_decrypt(...)` instead of reading `account.prvkey` directly. See `~/dev/coordination/log.md` 2026-05-30T17:25Z for the architectural gap diagnosis. ## v1 reconciliation gotcha (call out in operator UX) Cash-out decrements `cassettes.count` locally on the ATM. If the operator publishes a new config without accounting for cash-outs since their last baseline, the ATM count gets clobbered to the operator's stated value. Mitigations in v1: - Operator UX should publish ONLY after physical refills (a known total), not "tweak" counts mid-day. Satmachineadmin commits to surfacing a "this publish will overwrite ATM-tracked counts" confirmation modal on submit (`07:50Z`). Resolved in v2 once the continuous reverse channel + dashboard reconciliation surface lands. ## ATM-side acceptance criteria (v1) ### Subscription path - [ ] Subscribe to operator-config events with filter: ``` { kinds: [30078], "#p": [<my_atm_npub>], "#d": ["bitspire-cassettes:<machine_id>"], authors: VITE_OPERATOR_PUBKEYS } ``` - [ ] On each event, verify signature; reject if `event.pubkey` not in `VITE_OPERATOR_PUBKEYS` - [ ] **Replay protection via `meta.lastKnownConfigCreatedAt`**: - Reject `event.created_at <= meta.lastKnownConfigCreatedAt` (includes stale events re-delivered on relay reconnect / after restart) - Reject `event.created_at > now + MAX_FUTURE_SKEW_S` (clock-skew / future-stamping defense) - [ ] NIP-44 v2 decrypt content (existing helpers in `@bitSpire/nostr-client`) - [ ] Validate decrypted payload: - `positions` keys are exactly the set of positions currently in `state.db.cassettes` (reject unknown positions; reject any update that omits a known position — partial updates not supported) - All `denomination` values are positive ints; all `count` values are non-negative ints - **No** denomination-uniqueness check — duplicate denominations across positions are valid - [ ] Apply in a **single SQLite transaction**: upsert `cassettes` rows on `position` PK (denomination + count both mutate per row) + update `meta.lastKnownConfigCreatedAt` - [ ] HAL re-initializes with new cassette mapping via `hal:reload-cassettes` IPC (rebuild per-bay state + re-init dispenser) - [ ] Renderer's `persistedInventory` ref refreshes after apply - [ ] atm-tui continues to work as a fallback (write paths don't conflict) ### Bootstrap publish path - [ ] On startup, if `meta.bootstrapPublishedAt IS NULL` AND `state.db.cassettes` has at least one row: - Build position-keyed payload from current cassettes table - Encrypt content with NIP-44 v2 to `VITE_OPERATOR_PUBKEYS[0]` (first operator pubkey) - Sign with ATM's nsec - Publish to configured relay(s) with `["d", "bitspire-cassettes-state:<machine_id>"]`, `["p", <operator_pubkey>]` - On publish success: set `meta.bootstrapPublishedAt = unix_now()` - [ ] If publish fails (relay unreachable, signer error): leave `bootstrapPublishedAt` null and retry next boot. Don't block ATM startup. - [ ] Skip bootstrap publish entirely (without setting `bootstrapPublishedAt`) if `VITE_OPERATOR_PUBKEYS` is empty, so retry happens on next boot once operator pubkeys are configured. ### HAL per-position dispense - [ ] HAL inventory tracked per-bay (not per-denomination summed) - [ ] Dispense distribution: when asked for N of denomination D, iterate all bays whose denomination = D, drain greedy until ask is satisfied or all matching bays empty. Error if insufficient inventory across matching bays. - [ ] After dispense, decrement per-bay count by what each bay actually dispensed (dispenser returns per-position results) - [ ] `getInventory()` boundary stays denomination-keyed (sums across matching bays) for backwards compat with the renderer ## Out of scope - **Continuous ATM-side reverse-channel publish** — v2, separate issue - **Adding or removing cassette slots from the dashboard** — hardware-determined, set once at machine onboarding or by re-provisioning via `VITE_LAMASSU_CASSETTES` / atm-tui - Cassette *count* updates from dispense events (already handled ATM-side via `recordTransaction` → per-position count decrement) - Multi-machine fleet-wide cassette config (each ATM owns its own) - Operator-side dashboard UI + storage (lives in aiolabs/satmachineadmin#29) ## Coordination - Mirror in satmachineadmin: aiolabs/satmachineadmin#29 - Related: satmachineadmin@dcd0874 (NIP-78 rip + privacy-by-default architecture decision) - Related: aiolabs/lnbits#18 / #26 (signer abstraction; phase 2.4 for bunker-decrypt) - Related: `~/dev/CLAUDE.md` (Nostr architecture → "Respect protocol semantics over friction reduction" — workspace-level rule grounded in this decision) - Existing UI surface for parallel operator work: `~/dev/bitspire/atm-tui` - Decision rationale: `~/dev/coordination/log.md` entries at 2026-05-30T06:30Z → 19:00Z
Author
Owner

@padreug commented on 2026-05-30 (lamassu-next#56):

Cross-comment from aiolabs/satmachineadmin#29 review

Reviewed #29 (operator-side producer) against the current ATM stack and surfaced a set of cross-codebase decisions both issues need to converge on before implementation. Full write-up lives at satmachineadmin#29 comment. Summary of the points that land on the ATM side:

Wire-shape change: {position → {denomination, count}} → {denomination → {position, count}}

Wire payload in this issue is keyed on position, but the ATM stack is denomination-keyed at every layer below the wire:

  • atm-tui cassettes (denomination INTEGER PRIMARY KEY) — ~/dev/bitspire/atm-tui/src/db.zig:31
  • state-store (electron) same PK — apps/machine/electron/state-store.ts:54
  • setCassettes upserts ON CONFLICT(denomination) — state-store.ts:265
  • HAL inventory map keys on denomination — hal-service.ts:116
  • HAL dispense lookup uses cassetteDenominations.indexOf(denomination) — hal-service.ts:189 (first match wins; duplicates silently collapse)

The ATM stack carries a strong implicit invariant: one cassette per denomination per machine. A position-keyed wire allows two operator-side rows with the same denomination, which the ATM silently collapses on apply. No error surfaced.

Recommend flipping the wire to {denomination → {position, count}} rather than bumping the ATM schema + HAL to a position-keyed model. The cost on the satmachineadmin side is a schema choice (PK becomes (machine_id, denomination) with position as a column); the cost on the ATM side is zero code change. Full trade-off table in the linked comment.

Hello-event in scope here?

#29 proposed two options for how the operator-side cassette_configs rows first appear (auto from ATM hello-event vs operator hand-entry at onboarding). Recommend locking to hello-event auto-populate. Since #56 is wiring publish-from-ATM anyway, sending an initial kind-30078 advertising the machine's current denomination + position + count set during the same work is the natural place to source the canonical truth. Worth confirming whether the hello-event lands in this issue's scope or filed as a follow-up.

Ack shape: kind-30078 from ATM, not status column

Both issues' AC left ack/error shape undecided between two incompatible options ("publish a status event back OR write to a status column"). Recommend the ATM publishes a kind-30078 ack with ["d", "bitspire-cassettes-ack:<machine_id>"] carrying last-applied content hash + timestamp + per-position applied counts. Keeps the flow event-shaped and replaceable-event-symmetric with the operator publish.

Reject option 3 stopgap

The operator_commands kind-21003 queue route is a trap. Once cassettes ride on it, migrating to kind-30078 later is uphill. If aiolabs/lnbits#18 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.

Coordination

Paired entries in ~/dev/coordination/log.md:

  • 2026-05-30T06:30Z — initial cross-session message to bitspire/atm-tui session
  • 2026-05-30T06:40Z — follow-up correcting the schema recommendation (A → B) after deeper investigation of the HAL layer

Refs: aiolabs/satmachineadmin#29 (paired operator-side producer + full review comment), commit satmachineadmin:dcd0874 (privacy-by-default architecture), aiolabs/lnbits#18 (bunker integration — option 1 dependency).

> _@padreug commented on 2026-05-30 ([lamassu-next#56](https://git.atitlan.io/aiolabs/lamassu-next/issues/56#issuecomment-1446)):_ ## Cross-comment from `aiolabs/satmachineadmin#29` review Reviewed `#29` (operator-side producer) against the current ATM stack and surfaced a set of cross-codebase decisions both issues need to converge on before implementation. Full write-up lives at [satmachineadmin#29 comment](https://git.atitlan.io/aiolabs/satmachineadmin/issues/29#issuecomment-1442). Summary of the points that land on the ATM side: ### Wire-shape change: `{position → {denomination, count}}` → `{denomination → {position, count}}` Wire payload in this issue is keyed on `position`, but the ATM stack is denomination-keyed at every layer below the wire: - atm-tui `cassettes (denomination INTEGER PRIMARY KEY)` — `~/dev/bitspire/atm-tui/src/db.zig:31` - state-store (electron) same PK — `apps/machine/electron/state-store.ts:54` - `setCassettes` upserts `ON CONFLICT(denomination)` — `state-store.ts:265` - HAL inventory map keys on denomination — `hal-service.ts:116` - HAL dispense lookup uses `cassetteDenominations.indexOf(denomination)` — `hal-service.ts:189` (first match wins; duplicates silently collapse) The ATM stack carries a strong implicit invariant: **one cassette per denomination per machine**. A position-keyed wire allows two operator-side rows with the same denomination, which the ATM silently collapses on apply. No error surfaced. **Recommend flipping the wire to `{denomination → {position, count}}`** rather than bumping the ATM schema + HAL to a position-keyed model. The cost on the satmachineadmin side is a schema choice (PK becomes `(machine_id, denomination)` with `position` as a column); the cost on the ATM side is zero code change. Full trade-off table in the linked comment. ### Hello-event in scope here? `#29` proposed two options for how the operator-side `cassette_configs` rows first appear (auto from ATM hello-event vs operator hand-entry at onboarding). Recommend locking to **hello-event auto-populate**. Since `#56` is wiring publish-from-ATM anyway, sending an initial kind-30078 advertising the machine's current denomination + position + count set during the same work is the natural place to source the canonical truth. Worth confirming whether the hello-event lands in this issue's scope or filed as a follow-up. ### Ack shape: kind-30078 from ATM, not status column Both issues' AC left ack/error shape undecided between two incompatible options ("publish a status event back OR write to a status column"). Recommend the ATM publishes a kind-30078 ack with `["d", "bitspire-cassettes-ack:<machine_id>"]` carrying last-applied content hash + timestamp + per-position applied counts. Keeps the flow event-shaped and replaceable-event-symmetric with the operator publish. ### Reject option 3 stopgap The `operator_commands` kind-21003 queue route is a trap. Once cassettes ride on it, migrating to kind-30078 later is uphill. If `aiolabs/lnbits#18` 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. ### Coordination Paired entries in `~/dev/coordination/log.md`: - `2026-05-30T06:30Z` — initial cross-session message to bitspire/atm-tui session - `2026-05-30T06:40Z` — follow-up correcting the schema recommendation (A → B) after deeper investigation of the HAL layer Refs: `aiolabs/satmachineadmin#29` (paired operator-side producer + full review comment), commit `satmachineadmin:dcd0874` (privacy-by-default architecture), `aiolabs/lnbits#18` (bunker integration — option 1 dependency).
Author
Owner

Status 2026-06-16 — bootstrap hello-event does not re-publish on re-pair

Reproduced on demo today: an ATM that published its bitspire-cassettes-state hello-event while paired to one operator/relay never re-publishes after being re-pointed at a new operator/server. maybePublishBootstrap (operator-config.ts) early-returns when state.db meta.bootstrapPublishedAt !== '', and state.db survives restarts/re-provisioning. Net effect: the operator dashboard sits on "waiting for the ATM's bootstrap state event" forever, even though relay, npub, subscription and fee-config delivery are all correct.

Manual workaround: UPDATE meta SET value='' WHERE key='bootstrapPublishedAt' + restart.

Proper fix belongs in the S0 pairing consumer (spirekeeper#9 / bitspire#52): consuming a new/changed seed resets bootstrapPublishedAt (and re-publishes operator-scoped state) under the new relationship. Alternatively the v2 continuous reverse channel (publish on count change + heartbeat) noted in this issue's header removes the one-shot gate entirely. Either subsumes the standalone bug.

## Status 2026-06-16 — bootstrap hello-event does not re-publish on re-pair Reproduced on demo today: an ATM that published its `bitspire-cassettes-state` hello-event while paired to one operator/relay **never re-publishes** after being re-pointed at a new operator/server. `maybePublishBootstrap` (operator-config.ts) early-returns when `state.db meta.bootstrapPublishedAt !== ''`, and `state.db` survives restarts/re-provisioning. Net effect: the operator dashboard sits on "waiting for the ATM's bootstrap state event" forever, even though relay, npub, subscription and fee-config delivery are all correct. Manual workaround: `UPDATE meta SET value='' WHERE key='bootstrapPublishedAt'` + restart. Proper fix belongs in the S0 pairing consumer (spirekeeper#9 / bitspire#52): consuming a new/changed seed resets `bootstrapPublishedAt` (and re-publishes operator-scoped state) under the new relationship. Alternatively the v2 continuous reverse channel (publish on count change + heartbeat) noted in this issue's header removes the one-shot gate entirely. Either subsumes the standalone bug.
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/bitspire#56
No description provided.