feat(cassettes): consume operator operations instead of counts

The machine now owns its bay counts outright. The operator publishes what
it did — a refill in notes added, an empty, a recount, a denomination
change — and this process applies it to the total it already holds.

Both sides used to write the same value over a transport that never tells
a writer it lost. Addressable events order by created_at at second
granularity with ties broken on event id, and a relay returns OK for an
event it then discards, so a dashboard form loaded before a dispense
silently discarded that dispense and neither side could detect it. A
value with one writer cannot be clobbered.

Schema v13 adds cassette_ops, the dedup ledger. A delta applied twice is
wrong and addressable events are re-delivered on every reconnect, so the
operator mints an id per operation and this table records the ones
applied. That also retires the created_at watermark on this path: it was
the only replay defence under absolute counts, but it drops an
out-of-order event whole, operations included, where per-op ids let the
unseen ones through and no-op the rest.

A window is applied oldest-first by `at`, ties broken by id, in one
transaction with the count mutation. A recount then a refill is not the
same as the reverse, and a crash mid-apply must roll back to a coherent
count rather than a partial one.

A malformed op or one naming a bay this machine does not have is neither
applied nor recorded, so it stays pending on the operator's dashboard.
That is the honest outcome. Recording it as applied would stop the noise
by telling the operator their refill landed.

The state document gains applied_ops, seq and schema_version. applied_ops
is the acknowledgement leg — echoing the ids back is the only way the
operator can tell an operation that landed from one merely sent. seq is
bumped on every local count change from any cause, so a reader can reject
a regression without trusting either clock.
This commit is contained in:
Padreug 2026-09-23 12:55:52 +02:00
commit c889f7f0df
6 changed files with 537 additions and 165 deletions

View file

@ -1,11 +1,17 @@
/**
* Operator-config consumer (aiolabs/lamassu-next#56).
* Operator-config consumer (aiolabs/lamassu-next#56, v2 per bitspire ADR-004).
*
* Subscribes to operator-published kind-30078 events carrying cassette
* config updates, validates + applies them to state.db, and hot-reloads
* the HAL dispenser. Also publishes a one-shot ATM-state hello-event on
* first boot so the operator dashboard (satmachineadmin) can auto-populate
* `cassette_configs` rows for this machine.
* OPERATIONS — a refill, an empty, a recount, a denomination change —
* applies the ones it has not already seen, and hot-reloads the HAL
* dispenser. It also publishes this machine's cassette state, which is
* what populates the operator dashboard's bay rows.
*
* The operator used to publish absolute counts and this machine applied them
* outright. Both sides wrote the same value over a transport that never tells
* a writer it lost: a dashboard form loaded before a dispense silently
* discarded that dispense, and neither side could detect it. This machine now
* owns the count — it holds the notes — and the operator says what it did.
*
* Architecture (see ~/dev/coordination/log.md entries on 2026-05-30):
*
@ -35,6 +41,20 @@ import type {} from '@/types/electron'
const KIND_NIP78 = 30078
/** The wire schema this machine speaks. Operations, not counts (ADR-004). */
const CASSETTE_SCHEMA_VERSION = 2
/** One operator-authored operation, as it arrives on the wire. */
type CassetteOp = {
id: string
at: number
type: 'refill' | 'empty' | 'recount' | 'set_denomination'
position: number
bills?: number
count?: number
denomination?: number
}
/** Accept operator events stamped up to this many seconds in the future. */
const MAX_FUTURE_SKEW_S = 60
@ -165,17 +185,13 @@ async function handleOperatorConfigEvent(
return
}
// 2. Replay protection — drop stale events. NIP-78 replaceable events
// DO get re-delivered on reconnect/restart; without this check, the
// ATM would re-apply the same payload on every boot and clobber any
// cash-out decrements that landed between operator publishes.
const watermark = await api.getLastKnownConfigCreatedAt()
if (event.created_at <= watermark) {
console.log(
`[OperatorConfig] Stale event dropped (created_at=${event.created_at} <= watermark=${watermark})`
)
return
}
// 2. There is deliberately no `created_at` watermark here any more.
// Under absolute counts it was the only replay defence, and it cost us:
// an event re-delivered out of order was dropped whole, operations
// included. Idempotency now rides on the operations themselves — the
// operator mints an id per op and this machine records the ones it
// applied — which is strictly stronger, because it survives an event
// that mixes operations we have seen with ones we have not.
// 3. Clock-skew defense — reject events stamped too far in the future.
// Limits damage from a leaked operator nsec future-stamping a fake
@ -189,7 +205,7 @@ async function handleOperatorConfigEvent(
}
// 4. Decrypt content (NIP-44 v2).
let parsed: { positions: Record<string, { denomination: number; count: number }> }
let parsed: { schema_version?: number; ops?: unknown }
try {
const plaintext = await cfg.signer.nip44Decrypt(event.pubkey, event.content)
parsed = JSON.parse(plaintext) as typeof parsed
@ -197,22 +213,36 @@ async function handleOperatorConfigEvent(
console.error('[OperatorConfig] Decrypt/parse failed:', err)
return
}
if (!parsed || typeof parsed !== 'object' || !parsed.positions) {
console.error('[OperatorConfig] Payload missing `positions` field')
if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.ops)) {
// A v1 operator publishing absolute counts lands here and is ignored.
// That direction fails safe: the machine keeps its own counts, which it
// is now the only writer of, and simply will not dispense notes it
// believes it lacks. The opposite — applying a count from a form loaded
// before a dispense — is what ADR-004 exists to stop.
console.error('[OperatorConfig] Payload missing `ops` array — dropped')
return
}
const ops = parsed.ops as CassetteOp[]
// 5. Atomic apply (cassettes + meta watermark) via IPC. The state-store
// function re-validates watermark + position key-set equality +
// per-entry types inside the SQLite transaction. Duplicate
// denominations across positions are allowed — real machines load
// N cassettes of the same denomination for cash-out throughput.
const result = await api.applyOperatorCassettesConfig(
{ positions: parsed.positions },
event.created_at
)
if (!result.applied) {
console.warn('[OperatorConfig] Apply rejected:', result.reason)
// 5. Apply the ones we have not seen, in one transaction with the sequence
// bump. No `created_at` watermark: each op carries an operator-minted id
// and the machine records what it applied, so a re-delivered event is a
// no-op on its own merits. The watermark would be strictly weaker and
// actively harmful — an event arriving out of order can still carry an
// operation this machine has never seen.
const result = await api.applyOperatorCassetteOps(ops)
for (const bad of result.rejected) {
console.warn(`[OperatorConfig] Op ${bad.id} rejected: ${bad.reason}`)
}
if (result.applied.length === 0) {
console.log(`[OperatorConfig] No new ops in event ${event.id.slice(0, 12)}…`)
// Still republish: the operator learns from our applied_ops echo that
// earlier operations landed, and an event carrying nothing new can be the
// first one we successfully answer after a relay outage.
const machineIdNoop = cfg.machineId ?? cfg.signer.pubkey
await publishCassettesState(cfg, api, machineIdNoop).catch((err) =>
console.warn('[OperatorConfig] post-apply cassettes-state republish failed:', err)
)
return
}
@ -220,8 +250,8 @@ async function handleOperatorConfigEvent(
// picks up the new per-position mapping. state.db is already updated;
// HAL re-init failure means the renderer's persistedInventory may be
// ahead of the HAL until next service restart — log loudly but don't
// unwind the state.db apply (the operator wants their config landed;
// HAL can catch up).
// unwind the state.db apply (the operation happened physically; HAL
// can catch up).
const cassettesAfter = await api.loadCassettes()
const halResult = await api.halReloadCassettes(
cassettesAfter.map((c) => ({
@ -233,9 +263,7 @@ async function handleOperatorConfigEvent(
if (!halResult.ok) {
console.error('[OperatorConfig] HAL reload failed:', halResult.error)
}
console.log(
`[OperatorConfig] Applied — created_at=${event.created_at}, positions=${Object.keys(parsed.positions).join(',')}`
)
console.log(`[OperatorConfig] Applied ops: ${result.applied.join(', ')}`)
// Republish our resulting cassette state so the operator's view reflects the
// applied config (the "on cassette reload" case). Different d-tag from the
@ -274,9 +302,22 @@ async function publishCassettesState(
// ignores this, so it needs no coordinated release. When set, the counts
// above are the machine's best guess, not a measurement.
const countsUncertainSince = await api.getCountsUncertainSince()
const payload = countsUncertainSince
? { positions, counts_uncertain_since: countsUncertainSince }
: { positions }
// `applied_ops` is the acknowledgement leg. An addressable event gives its
// publisher no failure signal — the relay returns OK for an event it then
// discards — so echoing the ids back is the only way the operator can tell
// an operation that landed from one that was merely sent. `seq` lets a
// reader reject a regression without trusting a clock: `created_at` has
// second granularity and ties break on event id, so it cannot order two
// reports from the same second.
const [appliedOps, seq] = await Promise.all([api.getAppliedOpIds(), api.getCassetteStateSeq()])
const payload: Record<string, unknown> = {
schema_version: CASSETTE_SCHEMA_VERSION,
positions,
seq,
applied_ops: appliedOps,
}
if (countsUncertainSince) payload.counts_uncertain_since = countsUncertainSince
const ciphertext = await cfg.signer.nip44Encrypt(operatorPubkey, JSON.stringify(payload))
// Force the stamp strictly above our last one. Addressable events are ordered

View file

@ -183,10 +183,22 @@ declare global {
pay: CardSession['pay']
amountMsat: number
}) => Promise<{ ok: boolean; bolt11?: string; reason?: string }>
applyOperatorCassettesConfig: (
payload: { positions: Record<string, { denomination: number; count: number }> },
eventCreatedAt: number
) => Promise<{ applied: true } | { applied: false; reason: string }>
applyOperatorCassetteOps: (
ops: {
id: string
at: number
type: 'refill' | 'empty' | 'recount' | 'set_denomination'
position: number
bills?: number
count?: number
denomination?: number
}[]
) => Promise<{
applied: string[]
rejected: { id: string; reason: string }[]
}>
getAppliedOpIds: (limit?: number) => Promise<string[]>
getCassetteStateSeq: () => Promise<number>
getFeeConfig: () => Promise<{
cashInFeeFraction: number
cashOutFeeFraction: number