Separate delivery receipts from dismissals

The client acks a frame as soon as it arrives — that is how an online device
stops being re-told — and the service recorded that ack in acked_at, the
column the drain-to-empty rule counts. Measured on an Android 15 emulator:
the owner's trigger reached the subscriber at .562 and the alert was
destroyed 27 ms later, mid-siren, so the owner's disarm answered
ERR_ALERT_NOT_FOUND.

alarm_deliveries now carries two timestamps. delivered_at is the receipt
(the WS ack frame and the offline check-in): it retires re-delivery and
nothing else. acked_at is the human dismissal (ackAlarm): only that ends the
alert, once every subscription's row is dismissed. Migration 011 adds the
column and replaces the pending index with an undelivered one.

ackAlarm also runs the drain check on every explicit dismissal, not only when
this call cleared a row: a receipted row still has to be dismissed.

Also fixed, found by the same probe run: db.Close left the closed pool as the
process singleton (a second probe inherited it), and the lifecycle probe's
three subscriptions shared one device id, which migration 010 made illegal.

Verified: both probes pass against the live dev database, including the new
receipt-vs-dismissal case; migration 011 applied to the dev DB.
This commit is contained in:
2026-10-19 20:40:23 +01:00
parent ca2130258b
commit 9f1ac9c25b
8 changed files with 203 additions and 58 deletions
+20 -10
View File
@@ -646,15 +646,15 @@ connect (see "Reconnect catch-up"), which reaches the native service even when
the JS layer is not running. The 409 is the secondary path for a client that is
running and polling but has no socket — both settle the same rows.
- The rows are marked acknowledged by the act of returning this response, so the
same uninterrupted alarm cannot produce a second `409`.
- On the live path the WebSocket `ack` frame marks them too, so an online client
that actually received the frame is never told again.
- The rows are marked **delivered** (`delivered_at`) by the act of returning this
response, so the same uninterrupted alarm cannot produce a second `409`. That
is a receipt, not a dismissal: it does not end the alert.
- On the live path the WebSocket `ack` frame records the same receipt, so an
online client that actually received the frame is never told again.
- Everything is idempotent: an ack for an event the device never owed, or a
repeated ack, updates nothing and is not an error.
- A trigger that was already disarmed before the device checked back in is not
news: the ledger entries are cleared and the normal body (`active: false`) is
returned instead of a stale ring.
news: the normal body (`active: false`) is returned instead of a stale ring.
- `?deviceId=` selects the subscription (raw value, as sent to `joinAlert`);
without it the caller's oldest subscription on the alert is used, mirroring the
socket. The service hashes it before comparing.
@@ -686,11 +686,21 @@ Response `200`:
- Clearing is **ack-driven**, never response-driven: `alarmStatus` only *reports*
debt (409 + frame) and clears nothing, so a 409 lost in transit does not
forfeit the alarm — the next check-in finds the same row.
- **Receipt and dismissal are different facts.** A client acks a frame as soon as
it receives it (that is how an online device stops being told about the same
alarm on every poll). That writes `delivered_at`, which retires re-delivery but
is *not* a dismissal. Only `ackAlarm` writes `acked_at`, and only `acked_at`
counts towards the drain-to-empty rule — so the alert survives while phones are
still ringing even when everyone happened to be online.
- Idempotent: a repeat ack (or an ack for an event the device never owed) returns
`acked: 0`, never an error. The WebSocket `ack` frame settles the same rows.
- `acked` counts the ledger rows cleared for this device. When the ack clears the
last unacked row of the alert, the alert drains and is destroyed (reason
`delivered`) — `destroyed: true` reports that intended outcome. Note that a
`acked: 0`, never an error. The WebSocket `ack` frame does NOT dismiss an
alarm — it only records the receipt.
- `acked` counts the ledger rows dismissed for this device. When the last
un-dismissed row of the alert is dismissed, the alert drains and is destroyed
(reason `delivered`) — `destroyed: true` reports that intended outcome. A
receipt (the WS `ack` frame) never counts here: it only sets `delivered_at`.
The drain check runs on every explicit dismissal, even when this call cleared
no row, because a receipted row is still undismissed. Note that a
destroyed alert takes its subscriptions with it, so a device token for it stops
resolving (the next call is `401 ERR_UNAUTHORIZED`, not `404`); clients must
treat that as "the subscription is over", not as a retryable failure.