feat(machine): v1.1 cassette config — position-keyed wire, multi-same-denom HAL

Mirrors satmachineadmin's PR #30 v1.1 commits (df6e8e0..1cebefc). Three
load-bearing corrections from the v1.0 implementation:

1. **Wire shape flips from denomination-keyed to position-keyed**
   (`{positions: {<pos>: {denomination, count}}}`). The original `#56`
   spec was position-keyed; my `06:40Z` audit-and-flip was wrong on
   both the load-bearingness of the ATM denom-PK invariant AND on the
   operational requirement (per-slot denomination must be operator-
   editable for swap-during-refill).

2. **Drop "one cassette per denomination" invariant.** Real production
   machines load multiple cassettes with the same denomination for
   cash-out throughput on a single bill class (4 × $20 cassettes on
   Tejo/batm3 are normal). NO unique index on denomination.

3. **HAL refactor for per-position state + greedy distribution.** When
   asked for N of denomination D, iterate matching bays in position
   order draining greedy until the request is satisfied or all matching
   bays empty. Surfaces "Insufficient inventory for denomination D:
   short K" rather than crashing on the first under-stocked bay.

Schema migration v8 → v9: rebuild `cassettes` with `position INTEGER
PRIMARY KEY`, `denomination INTEGER NOT NULL`, `count INTEGER NOT NULL
DEFAULT 0`. SQLite create-copy-drop-rename per the v4→v5 precedent
(FKs off during, no data loss). Existing rows backfill column-by-column.

`setCassettes()` upserts `ON CONFLICT(position)`. `updateCassetteCount
(denomination, delta)` → `updateCassetteCountByPosition(position, delta)`
since the dispenser returns per-position results. `getInventory()`
boundary stays denomination-keyed (sums across matching bays) for
backwards compat with renderer callers.

HAL `inventory: Record<denom, count>` + `cassetteDenominations: number[]`
collapse into a single `bays: {position, denomination, count}[]` array.
Dispense per-bay note assignment + per-bay decrement on result. Bay
ordering by position throughout.

Operator-config consumer (`operator-config.ts`) flips both the apply
direction (`{positions: ...}` parse + validate position-set equality +
denom/count int checks, NO denom-uniqueness) and the bootstrap publish
direction (position-keyed payload encoding).

IPC type signatures updated in `preload.ts` + `types/electron.d.ts` for
both the new `OperatorCassettesPayload` shape and the per-position
`halReloadCassettes` argument.

`atm-tui` schema flip + handler updates land in a separate commit on
`aiolabs/atm-tui` (this commit's changes are limited to lamassu-next).
Bumping the atm-tui flake input on `deploy/server-deploy` (or the local
flake.lock here) after the atm-tui push reaches the sintra closure.

12/12 typecheck, 18/18 state-machine tests, 11/11 clink, 11/11 lnbits,
11/11 nostr-client all green.

Design history: `~/dev/coordination/log.md` entries 2026-05-30T06:30Z →
20:55Z. Satmachineadmin counterpart at PR #30. Issue body refreshed.

refs: aiolabs/lamassu-next#56, aiolabs/satmachineadmin#29, aiolabs/satmachineadmin PR #30 (commits df6e8e0..1cebefc)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-05-30 22:40:51 +02:00
commit 41f9412524
6 changed files with 232 additions and 107 deletions

View file

@ -104,7 +104,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
ipcRenderer.invoke('state:mark-bootstrap-published', unixTimestamp),
applyOperatorCassettesConfig: (
payload: {
denominations: Record<string, { position: number; count: number }>
positions: Record<string, { denomination: number; count: number }>
},
eventCreatedAt: number
): Promise<{ applied: true } | { applied: false; reason: string }> =>
@ -124,7 +124,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
halRejectBill: (): Promise<void> => ipcRenderer.invoke('hal:reject-bill'),
halGetInventory: (): Promise<Record<number, number>> => ipcRenderer.invoke('hal:get-inventory'),
halReloadCassettes: (
cassettes: { denomination: number; count?: number }[]
cassettes: { position: number; denomination: number; count?: number }[]
): Promise<{ ok: boolean; error?: string }> =>
ipcRenderer.invoke('hal:reload-cassettes', cassettes),
halCleanup: (): Promise<void> => ipcRenderer.invoke('hal:cleanup'),
@ -195,7 +195,7 @@ declare global {
getBootstrapPublishedAt: () => Promise<number | null>
markBootstrapPublished: (unixTimestamp: number) => Promise<void>
applyOperatorCassettesConfig: (
payload: { denominations: Record<string, { position: number; count: number }> },
payload: { positions: Record<string, { denomination: number; count: number }> },
eventCreatedAt: number
) => Promise<{ applied: true } | { applied: false; reason: string }>
getSupportPages: () => Promise<{ id: string; title: string; content: string }[]>
@ -208,7 +208,7 @@ declare global {
halRejectBill: () => Promise<void>
halGetInventory: () => Promise<Record<number, number>>
halReloadCassettes: (
cassettes: { denomination: number; count?: number }[]
cassettes: { position: number; denomination: number; count?: number }[]
) => Promise<{ ok: boolean; error?: string }>
halCleanup: () => Promise<void>
onHalBillRead: (callback: (denomination: number) => void) => void