0039 — Group close stays activity-driven
⛔ SUPERSEDED — THE OBJECT OF THIS DECISION IS DELETED.
alert_groupsis gone, root and branch (git-bug7570090, migration00069): there is no generation to close, so nothing is activity-driven or otherwise.group_close_delay_s, whose meaning this ADR was written to keep, left with it (git-bug7287b28, migration00071). The conversation is now the Case, and a conversation holds exactly one — so what ends a conversation is the Case ending, and there is no delay in it.⭐ The reasoning is kept because the shape recurs: this ADR refused to implement a clause verbatim on the grounds that doing so would delete a mechanism the clause never mentioned. That is the same failure mode as reading a “stated purpose” as a “whole purpose”, which git-bug
7570090went on to pay for twice.
Status: Accepted · 2026-08-18
Amends: 0038 — records what the derived key deliberately did not change. ADR 0005’s thread-ownership claim survives unamended.
Context
Section titled “Context”git-bug fe73f9a collapsed alert_group_members into alert_cases.group_id. Its Done when
went one clause further: group close should become “no live case with this split key” rather than
last_activity_at plus a delay. That clause was not implemented, and an omission with no written
reason is indistinguishable from an oversight. This ADR is the reason.
The clause is attractive because, after 00051, membership is derived rather than recorded — so
“are there live cases?” is now a cheap, always-true question rather than a join against a table
that could drift. The temptation is to make close ask exactly that question and nothing else.
Decision
Section titled “Decision”CloseIdle stays driven by last_activity_at. The rollup’s refusal to close over a live member
stays as the safety property. Three grounds:
-
Taken literally, the clause deletes
group_close_delay. A generation whose cases have all ended would close on the next sweep. An alert that resolves and re-fires inside the delay — the ordinary flapping case the delay exists for — would then find its generation gone and open a new one, and a new generation is a new Slack thread that cannot be re-parented. The clause trades a correctness property nobody reported broken for a visible regression in the product’s most visible output. -
The safety goal the clause states is already held.
CanCloseAtrefuses whilecounts.Live() > 0, over a rollup re-derived insideCloseIdle’s own transaction. What00051changed is that this refusal became true rather than nominal: the firing/suppressed counts now derive fromalert_cases.ended_at, which the state machine writes on every T5/T6. Before00051the same check consulted a recorded membership row that could lag; after it, the check cannot be stale. The ticket’s motivation was satisfied by the part that shipped. -
The two rules differ only when the delay is doing its job. “No live case” and “no live case and idle for
group_close_delay” agree on every generation except one that has gone quiet within the window — which is precisely the generation the delay exists to hold open.
Consequences
Section titled “Consequences”group_close_delaykeeps its meaning, and a generation may sit open with zero live cases for the length of the window. This is the re-fire grace, not a leak.- The safety property is now asserted, not assumed: a test pins that a generation holding a live
case is refused closure (
Held == 1) rather than merely that the sweep ran. fe73f9ais closed as partially implemented, deliberately. The unbuilt clause is recorded here rather than left as a silent gap in the ticket’s Done-when.- If the delay is ever shown to hold generations open long enough to matter, the fix is to tune the delay — an org-wide knob that already exists — not to remove it.
Alternatives rejected
Section titled “Alternatives rejected”| considered | rejected because |
|---|---|
| Implement the clause verbatim | deletes group_close_delay; every short resolve/re-fire strands a Slack thread |
| Close on “no live case” but keep the delay as a second condition | that is the behaviour already shipped, written twice |
| Close immediately and re-parent the thread on re-fire | Slack has no re-parent; a thread’s identity is its first message |
| Leave the omission undocumented in the ticket | an unexplained gap reads as an oversight and invites the next agent to “fix” it |