fix(access): only accept END_SESSION from idle so the session cap can't strand funds

The root-level END_SESSION let the 10-minute hard cap (and the End Session
button) jump to `locked` from any state, bypassing the money-path guards
the machine already has: confirmAbandon with bills stacked, an in-flight
dispense, an outbound cash-in payment. Cap fires at minute 10 while a
customer's bills sit in the stacker → locked → next unlock resetContext
wipes them unpaid; during dispensingCash the done-event is dropped and
no transaction record is written.

Nothing is lost by scoping it: every transaction terminal state already
targets #atm.locked on this branch, so the machine re-locks on its own
when the transaction ends. END_SESSION now lives on idle.on only, and
useSessionSecurity defers both deadlines until currentState is idle —
an expired session re-locks on the first tick back at the menu.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-09-20 14:11:38 +02:00
commit 5114619fce
3 changed files with 34 additions and 19 deletions

View file

@ -65,7 +65,8 @@ describe('ATM access control (ADR-003)', () => {
// entry-anchored timer can't measure inactivity (it never resets on screen
// touches). It's enforced at the DOM layer (useSessionSecurity), which sends
// END_SESSION on true idleness / at the hard cap. The machine's contract is
// just: END_SESSION re-locks from any unlocked state when the gate is active.
// just: END_SESSION re-locks from idle when the gate is active, and is
// ignored mid-transaction (a transaction re-locks on its own when it ends).
describe('session end (button / inactivity / hard cap all route here)', () => {
it('END_SESSION re-locks immediately from idle when the gate is active', () => {
const actor = createActor(createATMMachine({}, { accessControlEnabled: true }))
@ -76,9 +77,10 @@ describe('ATM access control (ADR-003)', () => {
expect(actor.getSnapshot().value).toBe('locked')
})
it('END_SESSION re-locks from an in-flight cash-out (hard-cap path)', () => {
// The absolute session cap must be able to lock mid-transaction, so the
// transition lives at the machine root, not only on `idle`.
it('END_SESSION is ignored mid-transaction (never strands funds in flight)', () => {
// A root-level END_SESSION would bypass confirmAbandon / an in-flight
// dispense. The transition lives on `idle` only; a transaction's own
// terminal states return to `locked` when it ends.
const rateServices = {
getExchangeRate: () => Promise.resolve(2500),
getAvailableBalance: () => Promise.resolve(1_000_000),
@ -87,9 +89,11 @@ describe('ATM access control (ADR-003)', () => {
actor.start()
actor.send({ type: 'ACCESS_GRANTED', role: 'user', credentialIdHash: 'abc' })
actor.send({ type: 'SELECT_CASH_OUT' })
expect(actor.getSnapshot().value).not.toBe('locked') // now inside cashOut
const before = actor.getSnapshot().value
expect(before).not.toBe('locked') // now inside cashOut
actor.send({ type: 'END_SESSION' })
expect(actor.getSnapshot().value).toBe('locked')
expect(actor.getSnapshot().value).toEqual(before)
expect(actor.getSnapshot().context.accessSession).not.toBeNull()
})
it('END_SESSION is a no-op when the gate is disabled (stays at idle)', () => {

View file

@ -513,14 +513,6 @@ export function createATMMachine(
cashOutFeeFraction: ({ event }) => event.cashOutFeeFraction,
}),
},
// Root-level session kill switch (ADR-003). Any unlocked state re-locks
// on END_SESSION — the "End session" button, the idle-inactivity timer,
// and the absolute session cap all route here (see useSessionSecurity).
// Placed at the root so the hard cap can lock mid cash-in/out, not just
// from idle. Guarded to the active gate so a gate-disabled machine (which
// rests at idle) can't be knocked out of it. `locked`'s entry clears the
// access session + loaded card. Fail-closed: locking is always allowed.
END_SESSION: { guard: 'accessGateActive', target: '#atm.locked' },
},
states: {
// === ACCESS GATE (ADR-003) ===
@ -546,9 +538,20 @@ export function createATMMachine(
// touches (the machine can't see raw pointer events), so it would fire
// a fixed countdown regardless of activity. Inactivity is measured at
// the DOM layer (useSessionSecurity) and drives END_SESSION on true
// idleness. The hard session cap is handled the same way. Both re-lock
// via the root-level END_SESSION transition above.
// idleness. The hard session cap is handled the same way.
on: {
// Session kill switch (ADR-003): the "End session" button, the
// idle-inactivity timer, and the absolute session cap all route here.
// Deliberately handled ONLY from `idle`, not at the machine root: a
// root-level END_SESSION would bypass the money-path protections
// (`confirmAbandon` with bills stacked, an in-flight dispense, an
// outbound payment) and strand the customer's funds. Every
// transaction terminal state already returns to `locked` on its own,
// so a cap that fires mid-transaction has nothing to gain — the
// DOM-layer timers defer until the machine is back at idle. Guarded to
// the active gate so a gate-disabled machine (which rests at idle)
// can't be knocked out of it. `locked`'s entry clears the session.
END_SESSION: { guard: 'accessGateActive', target: '#atm.locked' },
SELECT_CASH_IN: {
target: 'cashIn',
actions: ['setStartTime', 'setCashInFee'],