diff --git a/database/migrations/004_drop_auto_stop.sql b/database/migrations/004_drop_auto_stop.sql new file mode 100644 index 0000000..fa741d9 --- /dev/null +++ b/database/migrations/004_drop_auto_stop.sql @@ -0,0 +1,51 @@ +\c dcntrlized; + +-- 004_drop_auto_stop.sql — remove the auto-stop window. +-- +-- Contract decision (2026-10-18, user ruling): an alert is active from trigger +-- until the owner disarms it. There is no clock-based expiry: "if an owner +-- triggers it and then loses their phone, the alarm should go off until the +-- client dismisses it, with no autostop". A subscriber's ringing ends when that +-- subscriber dismisses locally; the server does not expire the event, and the +-- server-side state does not depend on any client's cooperation. +-- +-- Consequence, accepted and documented in API.md: an alert that is never +-- disarmed stays active forever, and only the owner's admin token can end it. +-- +-- `alerts.auto_stop_seconds` and its CHECK constraint +-- (alerts_auto_stop_seconds_range) have no remaining reader: `active` is simply +-- `status = 'triggered'` now. `001_alarm_schema.sql` is already applied and is +-- never edited in place, so the column is dropped here; a fresh database creates +-- it in 001 and drops it in this file, keeping the history append-only. +-- +-- Idempotent: guarded on the schema existing and DROP COLUMN IF EXISTS, so a +-- re-apply of the whole sequence is a clean no-op (same pattern as 002/003). + +-- The fan-out trigger reads the alert row when it builds its pg_notify payload, +-- so it is replaced BEFORE the column goes: leaving the old body behind would +-- make every event INSERT fail with "column auto_stop_seconds does not exist" +-- once the column is gone. The payload is the WS frame's transport form; with no +-- auto-stop it carries alertId/eventId/action/message/createdAtMs and nothing +-- else. CREATE OR REPLACE is idempotent. +CREATE OR REPLACE FUNCTION alarm.notify_alarm_event() +RETURNS TRIGGER AS $$ +BEGIN + PERFORM pg_notify('alarm_events', json_build_object( + 'alertId', NEW.alert_id::text, + 'eventId', NEW.id::text, + 'action', NEW.kind::text, + 'message', COALESCE(NEW.message, ''), + 'createdAtMs', (EXTRACT(EPOCH FROM NEW.created_at) * 1000)::bigint + )::text); + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = 'alarm') THEN + ALTER TABLE alarm.alerts DROP COLUMN IF EXISTS auto_stop_seconds; + END IF; +END +$$; diff --git a/database/migrations/006_alert_expiry.sql b/database/migrations/006_alert_expiry.sql new file mode 100644 index 0000000..02f980d --- /dev/null +++ b/database/migrations/006_alert_expiry.sql @@ -0,0 +1,44 @@ +\c dcntrlized; + +-- 006_alert_expiry.sql — a hard persistence cap chosen by the owner. +-- +-- Contract decision (2026-10-18, user ruling): the creator sets a maximum +-- persistence time, at most 14 days, after which devices that never came back +-- are ignored and the alert "along with everything about it" is destroyed. This +-- is what stops a phone that never returns from pinning an alert (and its +-- delivery debt) forever once auto-stop is gone. +-- +-- The application computes the absolute timestamp from the requested duration, +-- rejects anything above the cap (API.md: ERR_VALIDATION, not a silent clamp) and +-- inserts it explicitly. The DEFAULT is a safety net only: 24 hours, the +-- documented default duration, so a row inserted by an older binary or by hand +-- still expires instead of living forever. +-- +-- Existing rows are backfilled to `created + 14 days` (the maximum): they were +-- created under a contract with no cap, so they get the most generous value the +-- new contract allows, not an instant expiry. +-- +-- Read paths also filter `expires_at > now()` (db.GetAlert, transition, …) so an +-- alert is invisible the moment it expires, even before the sweeper reaches it. +-- +-- Idempotent (same guard pattern as 002–005): a re-apply of the whole sequence +-- is a clean no-op. + +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = 'alarm') THEN + RETURN; + END IF; + + ALTER TABLE alarm.alerts ADD COLUMN IF NOT EXISTS expires_at TIMESTAMPTZ; + + UPDATE alarm.alerts + SET expires_at = created + interval '14 days' + WHERE expires_at IS NULL; + + ALTER TABLE alarm.alerts ALTER COLUMN expires_at SET NOT NULL; + ALTER TABLE alarm.alerts ALTER COLUMN expires_at SET DEFAULT (now() + interval '24 hours'); + + CREATE INDEX IF NOT EXISTS alerts_expires_at_idx ON alarm.alerts (expires_at); +END +$$; diff --git a/src/actions/alerts.go b/src/actions/alerts.go index 7a1b744..15cbe6a 100644 --- a/src/actions/alerts.go +++ b/src/actions/alerts.go @@ -17,6 +17,7 @@ package actions import ( "encoding/json" + "fmt" "net/http" "strings" "time" @@ -134,32 +135,30 @@ type alertView struct { Message string `json:"message"` Status string `json:"status"` Active bool `json:"active"` - AutoStopSeconds int `json:"autoStopSeconds"` - TokenVersion int `json:"tokenVersion"` Created string `json:"created"` Modified string `json:"modified"` TriggeredAt *string `json:"triggeredAt,omitempty"` DisarmedAt *string `json:"disarmedAt,omitempty"` QRPayload string `json:"qrPayload"` + ExpiresAt string `json:"expiresAt"` IsAdmin bool `json:"isAdmin"` SubscriptionCount int `json:"subscriptionCount"` LastEvent *eventView `json:"lastEvent,omitempty"` } -func newAlertView(a *db.Alert, isAdmin bool, count int, ev *db.Event, now time.Time) alertView { +func newAlertView(a *db.Alert, isAdmin bool, count int, ev *db.Event) alertView { v := alertView{ AlertID: a.ID.String(), Name: a.Name, Message: deref(a.Message), Status: a.Status, - Active: alertstate.IsActive(a.Status, a.TriggeredAt, a.AutoStopSeconds, now), - AutoStopSeconds: a.AutoStopSeconds, - TokenVersion: a.TokenVersion, + Active: alertstate.IsActive(a.Status), Created: rfc3339(a.Created), Modified: rfc3339(a.Modified), TriggeredAt: rfc3339Ptr(a.TriggeredAt), DisarmedAt: rfc3339Ptr(a.DisarmedAt), QRPayload: qrpayload.Build(a.ID, a.SubscribeToken), + ExpiresAt: rfc3339(a.ExpiresAt), IsAdmin: isAdmin, SubscriptionCount: count, } @@ -177,23 +176,25 @@ func newAlertView(a *db.Alert, isAdmin bool, count int, ev *db.Event, now time.T // ---- createAlert ------------------------------------------------------------ type createAlertRequest struct { - Name string `json:"name"` - Message string `json:"message"` - AutoStopSeconds *int `json:"autoStopSeconds"` + Name string `json:"name"` + Message string `json:"message"` + // ExpiresInSeconds is the owner's requested persistence cap. Omitted takes + // DefaultAlertExpiry; anything above MaxAlertExpiry is refused rather than + // clamped, because a silent clamp changes what the owner asked for. + ExpiresInSeconds *int64 `json:"expiresInSeconds"` } type createAlertResponse struct { - AlertID string `json:"alertId"` - Name string `json:"name"` - Message string `json:"message"` - Status string `json:"status"` - Active bool `json:"active"` - AdminToken string `json:"adminToken"` - SubscribeToken string `json:"subscribeToken"` - QRPayload string `json:"qrPayload"` - AutoStopSeconds int `json:"autoStopSeconds"` - TokenVersion int `json:"tokenVersion"` - Created string `json:"created"` + AlertID string `json:"alertId"` + Name string `json:"name"` + Message string `json:"message"` + Status string `json:"status"` + Active bool `json:"active"` + AdminToken string `json:"adminToken"` + SubscribeToken string `json:"subscribeToken"` + QRPayload string `json:"qrPayload"` + ExpiresAt string `json:"expiresAt"` + Created string `json:"created"` } // CreateAlert implements POST /action/createAlert. @@ -222,15 +223,6 @@ func CreateAlert(w http.ResponseWriter, r *http.Request, payload any) { validationError(w, "%v", err) return } - autoStop := 0 - if req.AutoStopSeconds != nil { - autoStop = *req.AutoStopSeconds - } - if err := validateAutoStop(autoStop); err != nil { - validationError(w, "%v", err) - return - } - adminToken, err := alarmtoken.NewAdminToken() if err != nil { Error(action, "random", "%v", err) @@ -244,37 +236,42 @@ func CreateAlert(w http.ResponseWriter, r *http.Request, payload any) { return } + ttl, err := resolveAlertExpiry(req.ExpiresInSeconds) + if err != nil { + validationError(w, "%v", err) + return + } + var msgPtr *string if strings.TrimSpace(message) != "" { msgPtr = &message } alert, err := db.CreateAlert(r.Context(), &db.Alert{ - AdminUserID: p.ID, - Name: name, - Message: msgPtr, - AdminTokenHash: alarmtoken.Hash(adminToken), - SubscribeToken: subscribeToken, - AutoStopSeconds: autoStop, + AdminUserID: p.ID, + Name: name, + Message: msgPtr, + AdminTokenHash: alarmtoken.Hash(adminToken), + SubscribeToken: subscribeToken, + ExpiresAt: time.Now().UTC().Add(ttl), }) if err != nil { dbError(w, action, err) return } - Debug(action, "POST", "alert=%s admin=%s autoStop=%d", alert.ID, p.ID, autoStop) + Debug(action, "POST", "alert=%s admin=%s", alert.ID, p.ID) writeJSON(w, http.StatusOK, createAlertResponse{ - AlertID: alert.ID.String(), - Name: alert.Name, - Message: deref(alert.Message), - Status: alert.Status, - Active: false, - AdminToken: adminToken, - SubscribeToken: alert.SubscribeToken, - QRPayload: qrpayload.Build(alert.ID, alert.SubscribeToken), - AutoStopSeconds: alert.AutoStopSeconds, - TokenVersion: alert.TokenVersion, - Created: rfc3339(alert.Created), + AlertID: alert.ID.String(), + Name: alert.Name, + Message: deref(alert.Message), + Status: alert.Status, + Active: false, + AdminToken: adminToken, + SubscribeToken: alert.SubscribeToken, + QRPayload: qrpayload.Build(alert.ID, alert.SubscribeToken), + ExpiresAt: rfc3339(alert.ExpiresAt), + Created: rfc3339(alert.Created), }) } @@ -317,7 +314,7 @@ func GetAlert(w http.ResponseWriter, r *http.Request, payload any) { return } - writeJSON(w, http.StatusOK, newAlertView(res.Alert, isAdmin, count, res.Event, time.Now().UTC())) + writeJSON(w, http.StatusOK, newAlertView(res.Alert, isAdmin, count, res.Event)) } // ---- deleteAlert ------------------------------------------------------------ @@ -332,8 +329,7 @@ func DeleteAlert(w http.ResponseWriter, r *http.Request, payload any) { return } - now := time.Now().UTC() - if !alertstate.CanDelete(alertstate.IsActive(alert.Status, alert.TriggeredAt, alert.AutoStopSeconds, now)) { + if !alertstate.CanDelete(alertstate.IsActive(alert.Status)) { writeError(w, http.StatusConflict, CodeAlertState, "disarm the alert before deleting it") return } @@ -348,41 +344,36 @@ func DeleteAlert(w http.ResponseWriter, r *http.Request, payload any) { }) } -// ---- rotateSubscribeToken --------------------------------------------------- +// Persistence-cap bounds. The user's rule: the owner picks how long the alert +// lives, hard maximum 14 days; after that, clients that never came back are +// ignored and the alert is destroyed with everything about it. +// +// Chosen numbers (documented in API.md and reported to the lead): +// - maximum 14 days — the user's hard cap, refused above rather than clamped; +// - default 24 hours — an alert is a transient safety mechanism, and a day is +// the smallest window that still covers "created this evening, triggered +// tomorrow morning" without leaving debris for a week; +// - minimum 1 minute — sweeper granularity is ~60s, and anything shorter would +// be expired before a device could plausibly join. +const ( + DefaultAlertExpiry = 24 * time.Hour + MinAlertExpiry = time.Minute + MaxAlertExpiry = 14 * 24 * time.Hour +) -type rotateResponse struct { - AlertID string `json:"alertId"` - SubscribeToken string `json:"subscribeToken"` - QRPayload string `json:"qrPayload"` - TokenVersion int `json:"tokenVersion"` -} - -// RotateSubscribeToken implements POST /action/rotateSubscribeToken. Existing -// subscriptions survive: rotation invalidates the QR/join path, not the people -// who already joined. -func RotateSubscribeToken(w http.ResponseWriter, r *http.Request, payload any) { - const action = "rotateSubscribeToken" - alert, _, ok := requireAdminToken(w, r, action, payload) - if !ok { - return +// resolveAlertExpiry validates the requested duration. A nil request takes the +// default; out-of-range values are ERR_VALIDATION rather than a silent clamp. +func resolveAlertExpiry(seconds *int64) (time.Duration, error) { + if seconds == nil { + return DefaultAlertExpiry, nil } - - token, err := alarmtoken.NewSubscribeToken() - if err != nil { - Error(action, "random", "%v", err) - writeError(w, http.StatusInternalServerError, CodeInternal, "could not generate a token") - return + ttl := time.Duration(*seconds) * time.Second + if ttl < MinAlertExpiry { + return 0, fmt.Errorf("expiresInSeconds must be at least %d (1 minute)", int64(MinAlertExpiry.Seconds())) } - version, err := db.RotateSubscribeToken(r.Context(), alert.ID, token) - if err != nil { - dbError(w, action, err) - return + if ttl > MaxAlertExpiry { + return 0, fmt.Errorf("expiresInSeconds must be at most %d (14 days, the hard cap)", + int64(MaxAlertExpiry.Seconds())) } - Debug(action, "POST", "alert=%s token rotated to version %d", alert.ID, version) - writeJSON(w, http.StatusOK, rotateResponse{ - AlertID: alert.ID.String(), - SubscribeToken: token, - QRPayload: qrpayload.Build(alert.ID, token), - TokenVersion: version, - }) + return ttl, nil } diff --git a/src/actions/events.go b/src/actions/events.go index 15a0548..a45cfe6 100644 --- a/src/actions/events.go +++ b/src/actions/events.go @@ -16,22 +16,23 @@ package actions import ( + "errors" "net/http" "strings" "time" "git.dcntrlized.org/Dcntrlized/Alarm/db" "git.dcntrlized.org/Dcntrlized/Alarm/internal/alertstate" + "git.dcntrlized.org/Dcntrlized/Alarm/internal/deviceid" "git.dcntrlized.org/Dcntrlized/Alarm/realtime" ) // actionResponse is the trigger/disarm result. type actionResponse struct { - EventID string `json:"eventId"` - Action string `json:"action"` - ServerTime string `json:"serverTime"` - AutoStopSeconds int `json:"autoStopSeconds"` - Delivered int `json:"delivered"` + EventID string `json:"eventId"` + Action string `json:"action"` + ServerTime string `json:"serverTime"` + Delivered int `json:"delivered"` } type triggerRequest struct { @@ -76,23 +77,21 @@ func TriggerAlert(w http.ResponseWriter, r *http.Request, payload any) { delivered := 0 if hub := realtime.Current(); hub != nil { delivered = hub.Broadcast(updated.ID, realtime.AlarmFrame{ - Type: "alarm", - AlertID: updated.ID.String(), - EventID: event.ID.String(), - Action: alertstate.KindTrigger, - Message: deref(event.Message), - ServerTime: rfc3339(event.CreatedAt), - AutoStopSeconds: updated.AutoStopSeconds, + Type: "alarm", + AlertID: updated.ID.String(), + EventID: event.ID.String(), + Action: alertstate.KindTrigger, + Message: deref(event.Message), + ServerTime: rfc3339(event.CreatedAt), }) } Debug(action, "POST", "alert=%s event=%s delivered=%d", updated.ID, event.ID, delivered) writeJSON(w, http.StatusOK, actionResponse{ - EventID: event.ID.String(), - Action: alertstate.KindTrigger, - ServerTime: rfc3339(event.CreatedAt), - AutoStopSeconds: updated.AutoStopSeconds, - Delivered: delivered, + EventID: event.ID.String(), + Action: alertstate.KindTrigger, + ServerTime: rfc3339(event.CreatedAt), + Delivered: delivered, }) } @@ -113,41 +112,79 @@ func DisarmAlert(w http.ResponseWriter, r *http.Request, payload any) { delivered := 0 if hub := realtime.Current(); hub != nil { delivered = hub.Broadcast(updated.ID, realtime.AlarmFrame{ - Type: "alarm", - AlertID: updated.ID.String(), - EventID: event.ID.String(), - Action: alertstate.KindDisarm, - Message: deref(event.Message), - ServerTime: rfc3339(event.CreatedAt), - AutoStopSeconds: updated.AutoStopSeconds, + Type: "alarm", + AlertID: updated.ID.String(), + EventID: event.ID.String(), + Action: alertstate.KindDisarm, + Message: deref(event.Message), + ServerTime: rfc3339(event.CreatedAt), }) } Debug(action, "POST", "alert=%s event=%s delivered=%d", updated.ID, event.ID, delivered) writeJSON(w, http.StatusOK, actionResponse{ - EventID: event.ID.String(), - Action: alertstate.KindDisarm, - ServerTime: rfc3339(event.CreatedAt), - AutoStopSeconds: updated.AutoStopSeconds, - Delivered: delivered, + EventID: event.ID.String(), + Action: alertstate.KindDisarm, + ServerTime: rfc3339(event.CreatedAt), + Delivered: delivered, }) } +// offlineAlarmResponse is the 409 check-in body: the standard error envelope plus +// the alarm frame the device missed while offline. 409 is a status, not a +// failure: it means "an alarm is pending and must ring now". +type offlineAlarmResponse struct { + Error struct { + Code string `json:"code"` + Message string `json:"message"` + } `json:"error"` + Alarm realtime.AlarmFrame `json:"alarm"` +} + +// writeOfflineAlarm answers alarmStatus with 409 ERR_ALARM_TRIGGERED_OFFLINE and +// the missed trigger frame. The frame is the same shape the socket would have +// delivered (same eventId, same serverTime), so the client renders an offline +// alarm with the code path it already has for a live one. +func writeOfflineAlarm(w http.ResponseWriter, alert *db.Alert, pending *db.PendingDelivery) { + message := "" + switch { + case pending.Message != nil: + message = *pending.Message + case alert.Message != nil: + message = *alert.Message + } + + var body offlineAlarmResponse + body.Error.Code = CodeAlarmTriggeredOffline + body.Error.Message = "an alarm was triggered while this device was offline; ring now" + body.Alarm = realtime.AlarmFrame{ + Type: "alarm", + AlertID: alert.ID.String(), + EventID: pending.EventID.String(), + Action: alertstate.KindTrigger, + Message: message, + ServerTime: rfc3339(pending.ServerTime), + } + writeJSON(w, http.StatusConflict, body) +} + // statusView is the alarmStatus polling fallback payload (frozen field set). type statusView struct { - Active bool `json:"active"` - EventID string `json:"eventId"` - Action string `json:"action"` - Message string `json:"message"` - ServerTime string `json:"serverTime"` - AutoStopSeconds int `json:"autoStopSeconds"` + Active bool `json:"active"` + EventID string `json:"eventId"` + Action string `json:"action"` + Message string `json:"message"` + ServerTime string `json:"serverTime"` } // AlarmStatus implements GET/POST /action/alarmStatus: the cheap, indexed -// fallback a client polls when its socket is down or it was killed mid-alarm. +// fallback a client polls when its socket is down or it was killed mid-alarm. It +// also serves as the offline check-in (409 ERR_ALARM_TRIGGERED_OFFLINE) and is +// where a missed alarm's ledger row is retired. // -// `active` is derived (status + auto-stop window), never stored, so no -// background job is needed to expire an alarm. +// `active` is derived from the commanded status, never stored and never +// clock-based: an alert is active from trigger until the owner disarms it, so no +// background job is needed to expire anything. func AlarmStatus(w http.ResponseWriter, r *http.Request, payload any) { const action = "alarmStatus" p, ok := principal(w, r, action) @@ -175,8 +212,28 @@ func AlarmStatus(w http.ResponseWriter, r *http.Request, payload any) { return } + // Offline check-in, READ-ONLY. If this device still owes an alarm, answer + // with the frame it missed instead of a quiet 200 — and clear NOTHING. The + // debt is settled only by an explicit ack (ackAlarm or the WS ack), because a + // 409 lost in transit must not forfeit the alarm: the next check-in has to + // find the same row. A trigger that has since been disarmed is not news, so + // that case falls through to the normal body (active:false). + deviceHash := "" + if raw := strings.TrimSpace(r.URL.Query().Get("deviceId")); raw != "" { + deviceHash = deviceid.Hash(raw) + } + pending, err := db.PendingTrigger(r.Context(), alertID, p.ID, deviceHash) + if err != nil && !errors.Is(err, db.ErrNotFound) { + dbError(w, action, err) + return + } + if pending != nil && res.Event != nil && res.Event.Kind == alertstate.KindTrigger { + writeOfflineAlarm(w, res.Alert, pending) + return + } + now := time.Now().UTC() - active := alertstate.IsActive(res.Alert.Status, res.Alert.TriggeredAt, res.Alert.AutoStopSeconds, now) + active := alertstate.IsActive(res.Alert.Status) if res.Event == nil || res.Event.Kind != alertstate.KindTrigger { // A never-triggered alert, or one whose newest event is a disarm, is // not live even if the stored status says "triggered". @@ -184,9 +241,8 @@ func AlarmStatus(w http.ResponseWriter, r *http.Request, payload any) { } view := statusView{ - Active: active, - ServerTime: rfc3339(now), - AutoStopSeconds: res.Alert.AutoStopSeconds, + Active: active, + ServerTime: rfc3339(now), } if res.Event != nil { view.EventID = res.Event.ID.String() diff --git a/src/db/alerts.go b/src/db/alerts.go index 76362ec..fa51d68 100644 --- a/src/db/alerts.go +++ b/src/db/alerts.go @@ -26,34 +26,33 @@ import ( // Alert is one alarm.alerts row. type Alert struct { - ID uuid.UUID - AdminUserID uuid.UUID - Name string - Message *string - Status string - AdminTokenHash []byte - SubscribeToken string - TokenVersion int - AutoStopSeconds int - Created time.Time - Modified time.Time - TriggeredAt *time.Time - DisarmedAt *time.Time + ID uuid.UUID + AdminUserID uuid.UUID + Name string + Message *string + Status string + AdminTokenHash []byte + SubscribeToken string + Created time.Time + Modified time.Time + TriggeredAt *time.Time + DisarmedAt *time.Time + ExpiresAt time.Time } const alertColumns = `id, admin_user_id, name, message, status::text, admin_token_hash, - subscribe_token, token_version, auto_stop_seconds, created, modified, triggered_at, disarmed_at` + subscribe_token, created, modified, triggered_at, disarmed_at, expires_at` // alertColumnsAliased is the same list qualified with the `a` alias used by the // join in GetAlertWithLatestEvent. const alertColumnsAliased = `a.id, a.admin_user_id, a.name, a.message, a.status::text, a.admin_token_hash, - a.subscribe_token, a.token_version, a.auto_stop_seconds, a.created, a.modified, a.triggered_at, a.disarmed_at` + a.subscribe_token, a.created, a.modified, a.triggered_at, a.disarmed_at, a.expires_at` func scanAlert(row pgx.Row) (*Alert, error) { var a Alert err := row.Scan(&a.ID, &a.AdminUserID, &a.Name, &a.Message, &a.Status, &a.AdminTokenHash, - &a.SubscribeToken, &a.TokenVersion, &a.AutoStopSeconds, &a.Created, &a.Modified, - &a.TriggeredAt, &a.DisarmedAt) + &a.SubscribeToken, &a.Created, &a.Modified, + &a.TriggeredAt, &a.DisarmedAt, &a.ExpiresAt) if errors.Is(err, pgx.ErrNoRows) { return nil, ErrNotFound } @@ -63,52 +62,53 @@ func scanAlert(row pgx.Row) (*Alert, error) { return &a, nil } -// CreateAlert inserts a new alert and returns the stored row. +// CreateAlert inserts a new alert and returns the stored row. The row and its +// anonymized `created` analytics entry are written in one transaction: an alert +// that exists without its history (or vice versa) is a reporting lie. func CreateAlert(ctx context.Context, a *Alert) (*Alert, error) { + tx, err := Pool().Begin(ctx) + if err != nil { + return nil, err + } + defer func() { _ = tx.Rollback(ctx) }() + const q = `INSERT INTO alarm.alerts - (admin_user_id, name, message, admin_token_hash, subscribe_token, auto_stop_seconds) + (admin_user_id, name, message, admin_token_hash, subscribe_token, expires_at) VALUES ($1, $2, $3, $4, $5, $6) RETURNING ` + alertColumns - return scanAlert(Pool().QueryRow(ctx, q, - a.AdminUserID, a.Name, a.Message, a.AdminTokenHash, a.SubscribeToken, a.AutoStopSeconds)) + alert, err := scanAlert(tx.QueryRow(ctx, q, + a.AdminUserID, a.Name, a.Message, a.AdminTokenHash, a.SubscribeToken, a.ExpiresAt)) + if err != nil { + return nil, err + } + if err := recordEvent(ctx, tx, alert.ID, &a.AdminUserID, EventCreated, ""); err != nil { + return nil, err + } + if err := tx.Commit(ctx); err != nil { + return nil, err + } + return alert, nil } // GetAlert fetches one alert by id. It returns ErrNotFound when the row is gone. func GetAlert(ctx context.Context, alertID uuid.UUID) (*Alert, error) { - const q = `SELECT ` + alertColumns + ` FROM alarm.alerts WHERE id = $1` + const q = `SELECT ` + alertColumns + ` FROM alarm.alerts WHERE id = $1 AND expires_at > now()` return scanAlert(Pool().QueryRow(ctx, q, alertID)) } // DeleteAlert removes an alert; subscriptions and events cascade. It reports // ErrNotFound when nothing was deleted, so a repeated delete is visible. func DeleteAlert(ctx context.Context, alertID uuid.UUID) error { - tag, err := Pool().Exec(ctx, `DELETE FROM alarm.alerts WHERE id = $1`, alertID) + destroyed, err := DestroyAlert(ctx, alertID, DestroyReasonDeleted) if err != nil { return err } - if tag.RowsAffected() == 0 { + if !destroyed { return ErrNotFound } return nil } -// RotateSubscribeToken replaces the subscribe token and bumps token_version. -// Existing subscriptions are untouched: rotation invalidates the QR/join path, -// not the subscribers who already joined. -func RotateSubscribeToken(ctx context.Context, alertID uuid.UUID, newToken string) (int, error) { - var version int - err := Pool().QueryRow(ctx, - `UPDATE alarm.alerts SET subscribe_token = $2, token_version = token_version + 1 - WHERE id = $1 RETURNING token_version`, alertID, newToken).Scan(&version) - if errors.Is(err, pgx.ErrNoRows) { - return 0, ErrNotFound - } - if err != nil { - return 0, err - } - return version, nil -} - // CountSubscriptions returns how many devices are subscribed to an alert. func CountSubscriptions(ctx context.Context, alertID uuid.UUID) (int, error) { var n int @@ -138,7 +138,7 @@ func GetAlertWithLatestEvent(ctx context.Context, alertID uuid.UUID) (*AlertWith ORDER BY created_at DESC, id DESC LIMIT 1 ) e ON TRUE - WHERE a.id = $1` + WHERE a.id = $1 AND a.expires_at > now()` var a Alert var ( @@ -151,8 +151,8 @@ func GetAlertWithLatestEvent(ctx context.Context, alertID uuid.UUID) (*AlertWith ) err := Pool().QueryRow(ctx, q, alertID).Scan( &a.ID, &a.AdminUserID, &a.Name, &a.Message, &a.Status, &a.AdminTokenHash, - &a.SubscribeToken, &a.TokenVersion, &a.AutoStopSeconds, &a.Created, &a.Modified, - &a.TriggeredAt, &a.DisarmedAt, + &a.SubscribeToken, &a.Created, &a.Modified, + &a.TriggeredAt, &a.DisarmedAt, &a.ExpiresAt, &evID, &evAlertID, &evKind, &evActor, &evMessage, &evCreated, ) if errors.Is(err, pgx.ErrNoRows) { diff --git a/src/db/events.go b/src/db/events.go index b2da8f4..1037638 100644 --- a/src/db/events.go +++ b/src/db/events.go @@ -69,16 +69,15 @@ func TriggerAlert(ctx context.Context, alertID, actorUserID uuid.UUID, message * return transition(ctx, alertID, actorUserID, alertstate.KindTrigger, message, nil) } -// DisarmAlert transitions an alert to disarmed and appends the event. Disarming -// an alert that is not triggered is ErrState (the client's view is stale). +// DisarmAlert ENDS the alert, whether it was triggered or still armed: the user +// ruling is that disarm means "stop and remove it" (for an armed alert, "delete +// the whole alarm without ever triggering it"). It appends the disarm event and +// destroys the alert with event_log reason `disarmed`, in one transaction. +// +// Idempotent through the shared path: disarming an already-destroyed alert is +// ErrNotFound (404), never a second destruction or a duplicate log row. func DisarmAlert(ctx context.Context, alertID, actorUserID uuid.UUID, message *string) (*Alert, *Event, error) { - guard := func(a *Alert) error { - if !alertstate.CanDisarm(a.Status) { - return ErrState - } - return nil - } - return transition(ctx, alertID, actorUserID, alertstate.KindDisarm, message, guard) + return transition(ctx, alertID, actorUserID, alertstate.KindDisarm, message, nil) } func transition( @@ -97,7 +96,7 @@ func transition( // Lock the row for the whole transition so two concurrent commands cannot // interleave their status writes. current, err := scanAlert(tx.QueryRow(ctx, - `SELECT `+alertColumns+` FROM alarm.alerts WHERE id = $1 FOR UPDATE`, alertID)) + `SELECT `+alertColumns+` FROM alarm.alerts WHERE id = $1 AND expires_at > now() FOR UPDATE`, alertID)) if err != nil { return nil, nil, err } @@ -135,6 +134,41 @@ func transition( return nil, nil, err } + // Record who owes this alarm BEFORE the transaction commits: one ledger row + // per enabled subscription, in the same transaction as the event. If this + // fails the whole trigger rolls back (the deferred Rollback runs), because an + // alarm that fans out without recording its debt is precisely the silent + // failure this ledger exists to prevent. The NOTIFY fires on COMMIT, so no + // frame can be seen before its ledger row is durable. + if kind == alertstate.KindTrigger { + if err := recordEvent(ctx, tx, alertID, &actorUserID, EventTriggered, ""); err != nil { + return nil, nil, err + } + if _, err := tx.Exec(ctx, + `INSERT INTO alarm.alarm_deliveries (alert_id, subscription_id, event_id) + SELECT $1, s.id, $2 + FROM alarm.subscriptions s + WHERE s.alert_id = $1 AND s.enabled`, + alertID, event.ID); err != nil { + return nil, nil, err + } + } + + // Disarm ENDS the alert in every state: armed ("delete the whole alarm + // without ever triggering it") and triggered alike. Outstanding deliveries + // cascade away, so nobody owes an alarm that can no longer be rung, and no + // client can later be told it missed a trigger on an alert that is gone. + // Disarm and deleteAlert converge on destroyLockedAlert; the event_log + // reason (disarmed vs deleted) keeps their intents distinguishable. + // + // The row lock held by this transaction makes the destruction idempotent + // against a racing sweeper (SKIP LOCKED) or a second disarm (404). + if kind == alertstate.KindDisarm { + if err := destroyLockedAlert(ctx, tx, alertID, current.AdminUserID, DestroyReasonDisarmed); err != nil { + return nil, nil, err + } + } + // The NOTIFY trigger fires on COMMIT, so no frame can be observed before // the new status is visible to every reader. if err := tx.Commit(ctx); err != nil { diff --git a/src/db/lifecycle.go b/src/db/lifecycle.go new file mode 100644 index 0000000..9abb5ee --- /dev/null +++ b/src/db/lifecycle.go @@ -0,0 +1,166 @@ +// Copyright (C) 2026 Dcntrlized +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package db + +import ( + "context" + "errors" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" +) + +// destroyLockedAlert is the ONE destruction path: the caller already holds the +// alert row lock, and this deletes the alert (subscriptions, events and +// deliveries cascade) together with its `destroyed` event_log row, in the +// caller's transaction. Every ending routes through here — drain-to-empty, +// expiry, disarm, explicit delete — so the four can never drift. +// +// Deleting the alert also deletes its `alarm_deliveries` rows by cascade, which +// is how "deletion wins over outstanding debt" is enforced by construction: the +// replay and the 409 read only that table, so a destroyed alert has no debt left +// to report and a client can never be told "you missed a trigger" for an alert +// that was deleted. +func destroyLockedAlert(ctx context.Context, tx pgx.Tx, alertID, adminID uuid.UUID, reason string) error { + if _, err := tx.Exec(ctx, `DELETE FROM alarm.alerts WHERE id = $1`, alertID); err != nil { + return err + } + return recordEvent(ctx, tx, alertID, &adminID, EventDestroyed, reason) +} + +// DestroyAlert ends an alert from an action path (deleteAlert/disarmAlert). +// Idempotent: the alert row is locked FOR UPDATE, so of two concurrent callers +// exactly one deletes and writes the event; the loser reports false. Sweeper +// races are safe for the same reason (it claims candidates with SKIP LOCKED). +func DestroyAlert(ctx context.Context, alertID uuid.UUID, reason string) (bool, error) { + tx, err := Pool().Begin(ctx) + if err != nil { + return false, err + } + defer func() { _ = tx.Rollback(ctx) }() + + var adminID uuid.UUID + err = tx.QueryRow(ctx, + `SELECT admin_user_id FROM alarm.alerts WHERE id = $1 FOR UPDATE`, alertID).Scan(&adminID) + if errors.Is(err, pgx.ErrNoRows) { + return false, nil + } + if err != nil { + return false, err + } + if err := destroyLockedAlert(ctx, tx, alertID, adminID, reason); err != nil { + return false, err + } + if err := tx.Commit(ctx); err != nil { + return false, err + } + return true, nil +} + +// CompleteAlertDeliveries is the drain-to-empty decision point: destroy the alert +// when its ledger is non-empty and has no unacked rows left. +// +// Guard 1 — "the ledger was non-empty": a trigger with zero subscribers writes +// zero rows, and reading A taken literally would destroy the owner's alert the +// instant they fired it. Guard 2 — cross-instance idempotency: the alert row is +// locked FOR UPDATE before the counts are read, so two replicas applying the +// last two acks serialise and exactly one writes the destruction event. +func CompleteAlertDeliveries(ctx context.Context, alertID uuid.UUID) (bool, error) { + tx, err := Pool().Begin(ctx) + if err != nil { + return false, err + } + defer func() { _ = tx.Rollback(ctx) }() + + var adminID uuid.UUID + err = tx.QueryRow(ctx, + `SELECT admin_user_id FROM alarm.alerts WHERE id = $1 FOR UPDATE`, alertID).Scan(&adminID) + if errors.Is(err, pgx.ErrNoRows) { + return false, nil + } + if err != nil { + return false, err + } + + var total, pending int + if err := tx.QueryRow(ctx, + `SELECT count(*), count(*) FILTER (WHERE acked_at IS NULL) + FROM alarm.alarm_deliveries WHERE alert_id = $1`, alertID).Scan(&total, &pending); err != nil { + return false, err + } + if total == 0 || pending > 0 { + return false, nil + } + + if err := destroyLockedAlert(ctx, tx, alertID, adminID, DestroyReasonDelivered); err != nil { + return false, err + } + if err := tx.Commit(ctx); err != nil { + return false, err + } + return true, nil +} + +// ExpireAlerts destroys every alert whose persistence cap elapsed and returns how +// many were destroyed. Expiry ignores unacked deliveries by design — a device +// that never comes back must not pin an alert forever. +// +// One transaction, FOR UPDATE SKIP LOCKED so concurrent sweepers never +// double-delete or double-log: each row is claimed by exactly one sweeper. +func ExpireAlerts(ctx context.Context, limit int) (int, error) { + if limit <= 0 { + limit = 100 + } + tx, err := Pool().Begin(ctx) + if err != nil { + return 0, err + } + defer func() { _ = tx.Rollback(ctx) }() + + rows, err := tx.Query(ctx, + `SELECT id, admin_user_id FROM alarm.alerts + WHERE expires_at <= now() + ORDER BY expires_at + LIMIT $1 + FOR UPDATE SKIP LOCKED`, limit) + if err != nil { + return 0, err + } + type candidate struct{ id, adminID uuid.UUID } + var candidates []candidate + for rows.Next() { + var c candidate + if err := rows.Scan(&c.id, &c.adminID); err != nil { + rows.Close() + return 0, err + } + candidates = append(candidates, c) + } + rows.Close() + if err := rows.Err(); err != nil { + return 0, err + } + + for _, c := range candidates { + if err := destroyLockedAlert(ctx, tx, c.id, c.adminID, DestroyReasonExpired); err != nil { + return 0, err + } + } + if err := tx.Commit(ctx); err != nil { + return 0, err + } + return len(candidates), nil +} diff --git a/src/internal/alertstate/state.go b/src/internal/alertstate/state.go index ff4591d..db9c860 100644 --- a/src/internal/alertstate/state.go +++ b/src/internal/alertstate/state.go @@ -19,13 +19,11 @@ // Frozen v1 semantics: // // - `status` is the last COMMANDED state (armed | triggered | disarmed). -// - `active` is DERIVED: a triggered alert stops being active once its -// auto-stop window elapses, with no background job and no timer. That is -// what makes auto-stop correct across several service instances. +// - `active` is DERIVED: an alert is active exactly while its commanded status +// is `triggered`. There is no clock-based expiry and no background job — +// only the owner's disarm ends a live alarm. package alertstate -import "time" - // Commanded statuses. const ( StatusArmed = "armed" @@ -39,9 +37,6 @@ const ( KindDisarm = "disarm" ) -// MaxAutoStopSeconds bounds auto_stop_seconds (mirrors the SQL CHECK). -const MaxAutoStopSeconds = 86400 - // ValidStatus reports whether s is one of the three commanded statuses. func ValidStatus(s string) bool { switch s { @@ -57,33 +52,14 @@ func ValidKind(k string) bool { return k == KindTrigger || k == KindDisarm } -// IsActive reports whether the alert is live at now. +// IsActive reports whether the alert is live. // -// A never-triggered alert (nil triggeredAt) is never active. autoStopSeconds -// == 0 means "ring until disarmed"; a positive value bounds the window. The -// window is inclusive of its start and exclusive at its end. -func IsActive(status string, triggeredAt *time.Time, autoStopSeconds int, now time.Time) bool { - if status != StatusTriggered || triggeredAt == nil { - return false - } - if autoStopSeconds <= 0 { - return true - } - return now.Before(triggeredAt.Add(time.Duration(autoStopSeconds) * time.Second)) -} - -// RemainingAutoStopSeconds returns how much of the auto-stop window is left at -// now, or 0 when the alert is not bounded or has already elapsed. It is what -// alarmStatus reports so a client can run its own countdown. -func RemainingAutoStopSeconds(status string, triggeredAt *time.Time, autoStopSeconds int, now time.Time) int { - if !IsActive(status, triggeredAt, autoStopSeconds, now) || autoStopSeconds <= 0 { - return 0 - } - left := int(triggeredAt.Add(time.Duration(autoStopSeconds) * time.Second).Sub(now).Seconds()) - if left < 0 { - return 0 - } - return left +// There is deliberately no time parameter: an alarm rings from trigger until the +// owner disarms it. A subscriber silences its own device by dismissing locally; +// that is a client-side action and never changes server state. The accepted +// consequence is that a lost owner phone leaves the alert active forever. +func IsActive(status string) bool { + return status == StatusTriggered } // CanDisarm reports whether a disarm command is legal for the current status. diff --git a/src/internal/alertstate/state_test.go b/src/internal/alertstate/state_test.go index 792b603..3d592d0 100644 --- a/src/internal/alertstate/state_test.go +++ b/src/internal/alertstate/state_test.go @@ -15,10 +15,7 @@ package alertstate -import ( - "testing" - "time" -) +import "testing" func TestValidStatusAndKind(t *testing.T) { for _, s := range []string{StatusArmed, StatusTriggered, StatusDisarmed} { @@ -44,46 +41,31 @@ func TestValidStatusAndKind(t *testing.T) { } func TestIsActive(t *testing.T) { - now := time.Date(2026, 10, 18, 12, 0, 0, 0, time.UTC) - earlier := now.Add(-10 * time.Minute) - cases := []struct { name string status string - at *time.Time - auto int want bool }{ - {"armed is never active", StatusArmed, &earlier, 0, false}, - {"disarmed is never active", StatusDisarmed, &earlier, 0, false}, - {"triggered without auto-stop stays active", StatusTriggered, &earlier, 0, true}, - {"triggered with auto-stop still inside the window", StatusTriggered, &earlier, 3600, true}, - {"triggered with auto-stop past the window", StatusTriggered, &earlier, 60, false}, - {"triggered with no timestamp", StatusTriggered, nil, 0, false}, - {"unknown status", "firing", &earlier, 0, false}, + {"armed is never active", StatusArmed, false}, + {"disarmed is never active", StatusDisarmed, false}, + {"triggered is active until disarmed", StatusTriggered, true}, + {"unknown status", "firing", false}, + {"empty status", "", false}, } for _, tc := range cases { - if got := IsActive(tc.status, tc.at, tc.auto, now); got != tc.want { - t.Errorf("%s: IsActive = %v, want %v", tc.name, got, tc.want) + if got := IsActive(tc.status); got != tc.want { + t.Errorf("%s: IsActive(%q) = %v, want %v", tc.name, tc.status, got, tc.want) } } } -func TestRemainingAutoStopSeconds(t *testing.T) { - now := time.Date(2026, 10, 18, 12, 0, 0, 0, time.UTC) - at := now.Add(-10 * time.Minute) - - if got := RemainingAutoStopSeconds(StatusTriggered, &at, 3600, now); got != 3000 { - t.Errorf("remaining = %d, want 3000", got) - } - if got := RemainingAutoStopSeconds(StatusTriggered, &at, 60, now); got != 0 { - t.Errorf("expired remaining = %d, want 0", got) - } - if got := RemainingAutoStopSeconds(StatusArmed, &at, 3600, now); got != 0 { - t.Errorf("non-triggered remaining = %d, want 0", got) - } - if got := RemainingAutoStopSeconds(StatusTriggered, &at, 0, now); got != 0 { - t.Errorf("auto-stop 0 remaining = %d, want 0", got) +// TestIsActiveHasNoClock pins the ruling at the type level: active is a function +// of the commanded status only. Re-introducing a time-based expiry would change +// IsActive's signature and fail to compile here. +func TestIsActiveHasNoClock(t *testing.T) { + var f func(string) bool = IsActive + if !f(StatusTriggered) { + t.Fatal("triggered must be active") } } diff --git a/src/internal/sweeper/sweeper.go b/src/internal/sweeper/sweeper.go new file mode 100644 index 0000000..5637f66 --- /dev/null +++ b/src/internal/sweeper/sweeper.go @@ -0,0 +1,71 @@ +// Copyright (C) 2026 Dcntrlized +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +// Package sweeper runs the periodic housekeeping that keeps the schema bounded: +// destroying alerts whose owner-set persistence cap elapsed, and pruning the +// durable log table (age + row cap). It is a goroutine started once at boot. +// +// Both jobs are safe to run concurrently on several instances: expiry claims +// rows with FOR UPDATE SKIP LOCKED and writes its analytics in the same +// transaction; log pruning is a plain delete where "twice" is harmless. +package sweeper + +import ( + "context" + "time" + + "git.dcntrlized.org/Dcntrlized/Alarm/db" +) + +// DefaultInterval is the tick. Expiry reads also filter the timestamp, so a +// client never observes a zombie between ticks; the tick only bounds how long a +// destroyed-by-policy row lingers on disk. +const DefaultInterval = 60 * time.Second + +// Run blocks until ctx is cancelled, sweeping every interval. +func Run(ctx context.Context, interval time.Duration, logf func(format string, v ...any)) { + if interval <= 0 { + interval = DefaultInterval + } + if logf == nil { + logf = func(string, ...any) {} + } + ticker := time.NewTicker(interval) + defer ticker.Stop() + + sweep := func() { + if n, err := db.ExpireAlerts(ctx, 100); err != nil { + logf("sweeper: expire alerts: %v", err) + } else if n > 0 { + logf("sweeper: destroyed %d expired alerts", n) + } + + if n, err := db.PruneEventLog(ctx, db.EventLogRetention); err != nil { + logf("sweeper: prune event_log: %v", err) + } else if n > 0 { + logf("sweeper: pruned %d event rows", n) + } + } + + sweep() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + sweep() + } + } +} diff --git a/src/main.go b/src/main.go index c55047c..f64ac5d 100644 --- a/src/main.go +++ b/src/main.go @@ -37,6 +37,7 @@ import ( "git.dcntrlized.org/Dcntrlized/Alarm/handlers/actions" "git.dcntrlized.org/Dcntrlized/Alarm/handlers/auth" "git.dcntrlized.org/Dcntrlized/Alarm/handlers/manifest" + "git.dcntrlized.org/Dcntrlized/Alarm/internal/sweeper" "git.dcntrlized.org/Dcntrlized/Alarm/logger" "git.dcntrlized.org/Dcntrlized/Alarm/middleware" "git.dcntrlized.org/Dcntrlized/Alarm/realtime" @@ -83,6 +84,11 @@ func main() { // used in the shutdown path below. realtimeShutdown := realtime.Register(outer, database) + // Housekeeping: alerts past their owner-set persistence cap are destroyed + // (unacked deliveries ignored — that is the point), and the log table is + // pruned by age + row count. Same goroutine, same 60s tick. + go sweeper.Run(context.Background(), 0, logger.Warn) + // ONE plaintext listener (contract rev 9 §B). ReadHeaderTimeout/IdleTimeout // cover slow-header and idle-socket abuse. ReadTimeout/WriteTimeout are // deliberately ZERO: this listener also serves the long-lived /ws/alarm diff --git a/src/main_test.go b/src/main_test.go index 6161280..6e54fd3 100644 --- a/src/main_test.go +++ b/src/main_test.go @@ -107,3 +107,38 @@ func TestLogoutServesBothMethods(t *testing.T) { t.Fatalf("PUT /auth/logout = %d, want 405", code) } } + +// TestRouteLevelErrorsCarryMessages walks the error paths main.go registers +// itself — the /action/ verb guard and the per-method guards on /auth/* — and +// asserts the user's invariant: an error status never comes with an empty body. +// The /action/ dispatch errors are plain text, the auth guards are plain text +// too; both must carry a readable message. +func TestRouteLevelErrorsCarryMessages(t *testing.T) { + outer := newOuterMux(newAPIMux()) + + cases := []struct { + method string + path string + body string + want int + }{ + {http.MethodPatch, "/action/createAlert", "", http.StatusMethodNotAllowed}, + {http.MethodGet, "/action/nope", "", http.StatusNotImplemented}, + {http.MethodPost, "/action/getAlert", `{"unknown":true}`, http.StatusBadRequest}, + {http.MethodPut, "/auth/login", "", http.StatusMethodNotAllowed}, + {http.MethodDelete, "/auth/me", "", http.StatusMethodNotAllowed}, + } + + for _, tc := range cases { + req := httptest.NewRequest(tc.method, tc.path, strings.NewReader(tc.body)) + rec := httptest.NewRecorder() + outer.ServeHTTP(rec, req) + + if rec.Code != tc.want { + t.Fatalf("%s %s = %d, want %d (body: %q)", tc.method, tc.path, rec.Code, tc.want, rec.Body.String()) + } + if body := strings.TrimSpace(rec.Body.String()); body == "" { + t.Fatalf("%s %s: status %d with an empty body — every error must carry a message", tc.method, tc.path, rec.Code) + } + } +}