[fix](alarm): make dismissal stick and count the devices

Four defects, three of them the same missing piece of the delivery ledger.

- listSubscriptions scanned nine destinations for ten columns and read a NULL
  subscriber_user_id into a uuid. Every alert with an anonymous subscriber
  answered 500 ("number of field descriptions must equal number of
  destinations, got 10 and 9"), and the client renders a failed read as an
  empty list — so 'Subscribed devices' said 0 while the row existed. The
  listing now uses the same scan as every other subscription read.
- alarmStatus reported the alert's commanded state, not the caller's own. An
  alert stays 'triggered' until the owner disarms or every subscriber acks, so
  a device that had already dismissed the alarm was told active:true on every
  poll and re-raised its takeover. active is now true only while the caller
  still owes the newest trigger; the owner keeps seeing their own alert.
- ackAlarm required an eventId, which the native takeover launch does not
  have. A dismissal that cannot name its event now clears the subscription's
  whole outstanding set (AckAllDeliveries / AckAllForSubscriber), so the debt
  cannot survive because the client could not echo a uuid.
- The device ledger probe (gated on DATABASE_URL, like the lifecycle probe)
  pins the per-subscriber debt, the ack-all semantics and the cascade that
  takes a device token with the destroyed alert.

Verified against the dev database and through Maestro: 2 subscriptions listed,
device A acked -> its status turns active:false while device B still gets the
409 check-in, ack-all without eventId clears the debt and drains the alert.
This commit is contained in:
2026-10-19 19:34:24 +01:00
parent 0e24c4e4f0
commit ca2130258b
6 changed files with 305 additions and 26 deletions
+24 -4
View File
@@ -587,9 +587,18 @@ read; send it in the query if you want to pin the subscription)
```
`active` is **derived**, never a ringing timer: it is true only when the newest
event is a `trigger` and the stored status is `triggered`. The answer stays
correct across restarts and instances, and it stays `true` until the owner
disarms or the alert expires. (There *is* a sweeper, but it destroys expired
event is a `trigger`, the stored status is `triggered`, **and this caller still
owes the alarm** (an unacknowledged delivery row for its subscription). The
answer stays correct across restarts and instances, and it stays `true` until
the owner disarms, the alert expires, or this caller acknowledges.
The caller-scoped `active` is what makes dismissal final: an alert stays
commanded `triggered` until the owner disarms it or *every* subscriber ack, so a
device that already dismissed the alarm would otherwise be told `active: true`
on every poll and re-raise its takeover. A client must treat `active: false`
with a stale `eventId` as "nothing owed by this device", not as a new trigger.
The alert's owner (who has no delivery row) keeps seeing their own alert's
commanded state. (There *is* a sweeper, but it destroys expired
alerts — it never changes an event's meaning.) An expired or destroyed alert is
`ERR_ALERT_NOT_FOUND` here like everywhere else. When there is no event yet the
fields are empty and `active` is `false`.
@@ -663,12 +672,15 @@ device rides the payload (or the query string), exactly like `joinAlert`:
```json
{ "payload": { "alertId": "…", "eventId": "…", "deviceId": "pixel-7" } }
{ "payload": { "alertId": "…", "eventId": "…" } } // device token in X-Device-Token
{ "payload": { "alertId": "…" } } // dismiss everything owed
```
Response `200`:
```json
{ "alertId": "…", "eventId": "…", "acked": 1, "destroyed": false }
// with no eventId: "eventId": "" and acked counts every row that was cleared
```
- Clearing is **ack-driven**, never response-driven: `alarmStatus` only *reports*
@@ -678,7 +690,15 @@ Response `200`:
`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.
`delivered`) — `destroyed: true` reports that intended outcome. 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.
- **`eventId` is optional.** Without it the dismissal retires the caller's whole
outstanding set on the alert. That exists because the native takeover launch
carries only the alert: a client that dismissed an alarm without being able to
name its event must still be able to clear the debt, or the replay loop it
could not describe keeps ringing forever.
- The alert owner has no delivery row, so an owner ack is a well-formed no-op.
- Subscribers must pass `deviceId` (raw; the server hashes it). A device can only
clear its own row.