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.
When the dispenser throws or the dispense times out there is no per-bay
report, so nothing is debited — not the cassette rows, not HAL's bays.
Bills may well have reached the customer, and both counters then read
high with nothing to indicate it. The machine went on treating a number
it had reason to doubt as measurement.
A dispense that ends with no report now latches a countsUncertainSince
flag, which rides along in the state document as counts_uncertain_since
so the operator can see the numbers need a recount. The field is
additive: a consumer reading positions ignores it, so this needs no
coordinated release. An operator config apply clears the flag inside the
same transaction, since asserting authoritative counts is precisely what
a recount is.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
getInventory dropped zero-count bays, so a fully dispensed machine
returned an empty map — identical to a machine with no cassettes
configured. Every caller reads an empty map as "nothing known, ask the
hardware": reloadPersistedInventory skipped the update entirely, so the
last non-empty snapshot stuck and the public availability beacon went on
advertising bills that had already gone out the slot.
Zero-count bays are kept, so an empty map now means exactly one thing:
no cassettes are configured. Consumers already filter for > 0 before
offering a denomination. loadInventoryFromDb returns null when the DB
could not be asked at all (browser dev, failed IPC) so callers can still
tell "no answer" from an answer of "the bays are empty", and only the
former defers to HAL.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
recordTransaction only debited the cassette rows for type 'cash_out'.
An operator remediation is recorded as 'manual_dispense', so HAL's
in-memory bays went down while the persisted rows did not — and HAL
re-seeds from those rows on the next boot, so the machine came back
believing it still held bills a customer had already been handed.
A remediation against a partly-dispensed original debits again on
purpose: the original only ever debited what physically left, and this
is a second lot of bills leaving the bay.
Closes#76
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
recordTransaction() updated cassette counts with WHERE denomination = ?,
but the v9 migration made position the PK precisely so duplicate
denominations across bays are legal (and the HAL dispense path already
returns authoritative per-position results). On any machine with two
bays of the same denomination, a single dispense drained every matching
bay row — silently corrupting inventory, the operator cassette-state
publish, and out-of-money gating.
- cassettes branch: decrement by c.position
- mock-only fallback (no per-bay results): drain matching bays greedily
in position order, mirroring the dispenser's own fill order
- regression tests with a duplicate-denomination layout (3 of 5 fail
against the old code)
Found during the dev-branch architecture review.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Completes the consumer half of bitspire-#70: a paired machine gets its LNbits
transport relay(s) + server pubkey from the pairing, so a blank-.env unit reaches
the backend after scanning a seed — no VITE_RELAY_URL / VITE_LNBITS_SERVER_PUBKEY
provisioning.
- resolveSigner now returns { signer, transport }. transport (relays +
lnbitsServerPubkey) comes from the seed on a fresh pair / seeded resume, and
from the binding on a seedless resume. It's threaded out of resolveSigner
rather than re-parsed in loadLightningConfig because the seed arrives over the
one-shot get-atm-secrets IPC — a second consumer would break that contract.
- bunker_binding persists relays + lnbits_server_pubkey (state.db v11→v12,
nullable so pre-#70 bindings resume and fall back to env). Mirrored into
BunkerBindingRecord (preload + electron.d.ts).
- initializeLightningServices resolves effective transport with env-wins
precedence (explicit env override for dev, else pairing, else a dev-only
localhost relay), mutating CONFIG to a single source of truth and building the
Nostr/LNbits/CLINK clients from the full relay list. Strict + required-config
validation now run on the resolved values.
state.db round-trip test covers the new columns + their absence on a pre-#70
binding. Renderer + electron typechecks and all 38 machine tests pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
24 tests for the load-bearing logic introduced by the previous commit:
`src/services/__tests__/operator-fees.test.ts` (10):
- canonical v1 payload with components parses cleanly
- absent schema_version treated as v1 (back-compat with cassette config
doc that shipped without one)
- unknown top-level keys silently ignored (v2 forward-compat)
- absent `components` → WARN + zero breakdown (graceful degrade,
producer-mandatory at v1 but consumer-safe)
- components present but sums disagree with totals → WARN + still
parses (totals authoritative per coord log §`14:25Z`)
- tiny float drift (well under 1e-6) does NOT trip the consistency
assert
- required fields missing → throws
- non-numeric component → throws with the offending key in the message
- FEE_CAP_PER_DIRECTION exposed at 0.15
`electron/__tests__/state-store-fees.test.ts` (14):
- null pre-apply (`getFeeConfig` + watermark)
- round-trip via getFeeConfig after applyFeeConfig
- upsert on subsequent newer event (singleton id=1)
- watermark dedup: rejects equal AND older event.created_at
- persisted row unchanged when stale event is rejected
- 15% per-direction cap: rejects above-cap on either direction
- accepts at the cap boundary exactly
- rejects negative + non-finite fractions
- schema_version < 1 rejected
- non-integer event_created_at rejected
- watermark does NOT advance when payload validation fails (atomicity)
Uses in-memory SQLite (`:memory:`) — fresh DB per test, no on-disk
artifacts, no parallel-test interference.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>