Escalate findings that miss their response target - #172
Conversation
Part 4 of #146, and the last. Closes #146. An announcement says "a new secret appeared". This says the opposite thing: "nobody fixed the one we told you about". It fires when an open, unsuppressed finding passes the response target for its severity — which the maintenance loop has to notice, because a deadline passing is not something that *happens* to a finding and nothing else would. **Reuses the outbox rather than rebuilding one.** `notification_delivery` becomes a two-kind table, so retry, backoff, the attempt ceiling and "never lost silently" all come from #60 unchanged. **Who hears about it is narrower than an announcement.** The owning team's channel, and nowhere else: telling six channels about work that has an owner is how alerting becomes noise. A team with no channel is silent by choice, and the console's overdue queue is still the record. A finding that is unowned — or owned by a disbanded team, which is the same thing with extra steps — falls back to every enabled channel whose filter selects it, because "late, and nobody has picked it up" is the state most worth saying out loud and it is exactly the state with no team to tell. **Once per deadline, structurally.** The row carries the `due_at` it is about and a partial unique index over `(channel_id, finding_id, due_at)` enforces it. The existing constraint cannot: it includes `scan_id`, which is NULL on an escalation, and NULLs do not collide in a unique constraint — so without the index the loop would mail the owning team once a minute until somebody stopped it. A reopened finding gets a fresh clock and escalates again if it misses the new target, which is the intent: a new deadline was missed, not the old one again. The payload keeps the same `finding` and `source` blocks as `finding.opened`, so a receiver parses one shape and switches on `event`; the block that differs carries the deadline, how many whole hours late it is, and who was supposed to be looking at it. There is no `scan` block, because nothing scanned. Factoring the finding block into one function is deliberate — an explicit field list is what stops a new `Finding` column silently leaving the deployment, and a second copy would be the one that grew a field nobody reviewed. A beat is bounded at 200, so turning escalation on after months of backlog does not try to mail the whole history in one round. 22 tests in `apps/api/tests/test_escalation.py`; docs/notifications.md carries the routing table and the payload. make check green: 1767 passed.
There was a problem hiding this comment.
Verdict
CHANGES_REQUESTED
Completed bounded review across 1 immutable scope(s). 2 high-severity correctness blockers found.
Scope health
Convergence: healthy. Review mode: initial.
Recommended action: CONTINUE_INCREMENTAL.
- No escalation signals.
Prior findings
| Finding | Status |
|---|---|
| — | No prior finding state |
New findings
Root cause: Pagination is applied before the deduplication predicate.
- BLOCKER · high: Escalation backlog stalls permanently after the first page —
apps/api/src/iceberg_api/notifications/dispatch.py
Status: NEW. Attribution: new_in_scope.
The query limits overdue findings before excluding findings that already have an escalation. On the second maintenance beat it selects the same oldest 200 rows, skips them in Python as already queued, and never reaches later overdue findings.
Invariant: Every actionable overdue finding must eventually receive one escalation per eligible channel and deadline.
Ownership: Maintenance escalation queueing. Behaviour: Overdue notification delivery.
Evidence:escalate_overdue()applies.order_by(Finding.due_at).limit(limit)and only afterwards checks for an existingNotificationDeliveryinside the loop. Reproduction: create 201 overdue findings, run one beat and commit (200 rows queued), then run subsequent beats; each selects and skips the same 200, leaving the 201st unqueued.
Independent assessment: The fixed.limit(limit)is applied before per-row deduplication. Once the oldest page has deliveries, every later beat reselects and skips that page, so later overdue findings are never reached.
Root cause: Delivery payload construction reads mutable finding state rather than the immutable outbox event deadline.
- BLOCKER · high: Delayed escalation delivers the finding's newer deadline instead of its queued deadline —
apps/api/src/iceberg_api/notifications/payload.py
Status: NEW. Attribution: new_in_scope.
Although the outbox row storesdue_at, delivery passes only the currentFindingtofinding_overdue(), which readsfinding.due_at. A pending escalation therefore changes meaning if the finding is resolved and reopened with a new response target before retry delivery.
Invariant: An escalation must describe the specific deadline recorded on its outbox row.
Ownership: Notification outbox delivery. Behaviour: Escalation payload and email accuracy.
Evidence:NotificationDelivery.due_atis populated when queueing, butfinding_overdue()assignsdue_at = finding.due_at. Reproduction: queue an old-deadline escalation, make its first delivery retryable-fail, reopen the finding with a futuredue_at, then retry delivery; the old outbox row is sent using the future deadline.
Independent assessment: Queueing records the event deadline inNotificationDelivery.due_at, but delivery constructs the payload from mutableFinding.due_at. The change explicitly allows reopened findings to receive a fresh deadline, so a retried older delivery can report the newer deadline.
Fix-induced regressions
- None evidenced.
Uncertainty
- No material uncertainty recorded.
Validation
- Reviewed the supplied immutable diff only.
- Exact-head CI was reported as passing.
Residual risks
- None identified.
|
Both blockers fixed in 3b22df1. Both were real, and both were where you said. Pagination before deduplication. The exclusion moved into SQL, before the One deliberate consequence worth naming: the exclusion is keyed on Mutable finding state in the payload. Both are pinned by tests that fail without the fix (verified by stashing the two
|
Both were real, and both were exactly where the reviewer said. **The backlog stalled after one page.** `escalate_overdue` applied `.limit(200)` and *then* skipped already-escalated findings in Python. Once the oldest page had deliveries, every later beat re-selected the same 200, skipped all of them, and never reached the 201st — a backlog that stops draining silently, precisely when it is largest. The exclusion now happens in SQL, before the limit, so each beat takes the next page. It is keyed on (finding, deadline) rather than (channel, finding, deadline). That means a channel added later does not hear about findings that went overdue before it existed — the same rule `enqueue_for_scan` already follows, where a new channel hears about the next scan rather than every finding in the table. The per-row dedup check inside the loop is gone with it: nothing that reaches the loop has an escalation any more, and the partial unique index is still the structural guard. **A retried escalation announced the wrong deadline.** The row stores the `due_at` it was queued for — which was the entire point of the column — and then delivery built the payload from `finding.due_at`. A message queued for a missed deadline, failed once, and retried after the finding was reopened would announce the *new* deadline, which had not passed yet. `finding_overdue` now takes the deadline as an argument and delivery passes `delivery.due_at`. Both regressions are pinned: `test_a_backlog_larger_than_one_beat_drains_across_beats` asserts five findings drain 2/2/1/0 across four bounded beats, and `test_a_retried_escalation_reports_the_deadline_it_was_queued_for` reopens a finding between queueing and delivery. Verified by stashing the fixes and watching both fail. make check green: 1769 passed.
There was a problem hiding this comment.
Verdict
APPROVE
Completed bounded review across 1 immutable scope(s). No new actionable findings. Both prior blockers are resolved by the supplied change.
Scope health
Convergence: healthy. Review mode: delta.
Recommended action: CONTINUE_INCREMENTAL.
- No escalation signals.
Prior findings
| Finding | Status |
|---|---|
| F-3F5529592D91 | Resolved |
| F-B1C81A948AB3 | Resolved |
New findings
No new findings.
Fix-induced regressions
- None evidenced.
Uncertainty
- No material uncertainty recorded.
Validation
- Prior blocker F-3F5529592D91 resolved: dispatch.py passes due_at=delivery.due_at to finding_overdue(), and the regression test asserts a reopened finding's retried escalation reports the original deadline.
- Prior blocker F-B1C81A948AB3 resolved: The correlated NOT EXISTS predicate precedes .limit(limit); the new multi-beat test verifies a five-item backlog drains as [2, 2, 1, 0].
- Reviewed the supplied immutable diff; exact-head CI is reported as passed.
- Escalation selection now excludes already-queued (finding, deadline) rows before applying the page limit.
- Delivery now passes the immutable outbox deadline into the overdue payload.
Residual risks
- None identified.
Part 4 of #146, and the last.
Closes #146
An announcement says "a new secret appeared". This says the opposite thing: nobody
fixed the one we told you about. It fires when an open, unsuppressed finding passes
the response target for its severity — which the maintenance loop has to notice,
because a deadline passing is not something that happens to a finding, so there is
no transaction to hang an outbox row off and nothing else would ever see it.
Reuses the outbox rather than building one
notification_deliverybecomes a two-kind table, so retry, exponential backoff, theattempt ceiling, and the "never lost silently" guarantee all come from #60 unchanged.
finding_opened(channel_id, finding_id, scan_id)— the existing constraintfinding_overdue(channel_id, finding_id, due_at)— a new partial indexThe three calls worth arguing about
Who hears about it is narrower than an announcement.
Telling six channels about work that has an owner is how alerting becomes noise. The
unowned fallback exists because "late, and nobody has picked it up" is the state
most worth saying out loud, and it is exactly the state with no team to tell. A
disbanded team is that state with extra steps — nobody is reading that channel.
Once per deadline, structurally. The row carries the
due_atit is about. Theexisting unique constraint cannot do this job: it includes
scan_id, which is NULLon an escalation, and NULLs do not collide in a unique constraint — so without the
partial index the loop would insert a fresh escalation every beat and mail the owning
team once a minute until somebody stopped it.
test_a_duplicate_escalation_is_refused_by_the_databaseexercises the constraint rather than assuming it.
The deadline is copied onto the row rather than read back off the finding, because a
reopened finding gets a fresh clock: a team that misses the new target should hear
about it, and one that already heard about the old one should not hear twice.
One
findingblock, two events. The payload keeps the samefindingandsourceblocks as
finding.opened, so a receiver parses one shape and switches onevent;what differs is an
escalationblock with the deadline, whole hours late, and who wassupposed to be looking at it. No
scanblock — nothing scanned. Factoring the findingblock into one function is deliberate: an explicit field list is what stops a new
Findingcolumn silently leaving the deployment, and a second copy would be the onethat grew a field nobody reviewed.
Smaller things
does not try to mail the whole history in one round; the rest go on the next beat.
finding keeps its date, because the suppression can lapse, but mailing somebody
about it would honour "stop telling me" in the console and ignore it in their inbox.
0015drops escalation rows on downgrade rather than inventing a scan idfor a message that never had one.
#146 closed out
22 tests in
apps/api/tests/test_escalation.py;docs/notifications.mdcarries therouting table and the payload.
make checkgreen, 1767 passed.