Skip to content

fix(alertmanager): record close authorship instead of inferring it from resolvedAt (BLO-31736) - #1648

Open
allyblockcast[bot] wants to merge 2 commits into
masterfrom
fix/blo-31736-alertmanager-closure-authorship
Open

fix(alertmanager): record close authorship instead of inferring it from resolvedAt (BLO-31736)#1648
allyblockcast[bot] wants to merge 2 commits into
masterfrom
fix/blo-31736-alertmanager-closure-authorship

Conversation

@allyblockcast

@allyblockcast allyblockcast Bot commented Sep 4, 2026

Copy link
Copy Markdown

Thinking Path

  • Paperclip is the open source app people use to manage AI agents for work
  • Its alertmanager plugin is the intake path that turns Alertmanager webhooks into Paperclip issues, and closes them again when the alert clears
  • An agent that investigates an alert and closes its issue done is making a deliberate disposition — done (investigated) is not cancelled (the alert went away), and the plugin has a guard specifically to protect that
  • But the guard only protects it for one fire/clear cycle: the same resolve delivery that declines to overwrite the close writes the state field the next re-fire uses to decide the close was the plugin's own, so the row is resurrected and then re-cancelled
  • The plugin therefore overwrites an agent's disposition once per cycle, indefinitely, ending in a plugin-authored cancelled that is indistinguishable from a normal auto-close on every triage surface
  • This pull request replaces that inference with a recorded fact: pluginClosedAt, written only where the plugin's own cancel actually landed
  • The benefit is that a deliberate close survives alert churn, and the operator-suppression mechanism added in fix(alertmanager-plugin): bound and instrument operator suppression (BLO-24234) #1349 stops being dead code for every flapping alert

Linked Issues or Issue Description

Dedup search. Searched the GitHub PR list (all states) for resolvedAt, pluginClosedAt, decideRefire, alertmanager, 31736, 24234. No PR addresses this defect. Related, none overlapping:

What Changed

  • types.ts — new optional AlertStateRecord.pluginClosedAt, documenting why resolvedAt could not carry this meaning.
  • webhook-handler.ts — new exported closedByPlugin(issue, existing); decideRefire keys on it instead of existing.resolvedAt.
  • handleResolved — tracks whether the plugin's own status: "cancelled" patch actually landed and stamps pluginClosedAt only then. Every other branch (terminal guard declining, withheld cancel, missing membership, autoCloseOnResolve: false) leaves the previous value untouched rather than writing null.
  • handleFiring — clears pluginClosedAt on any delivery that applied a re-fire decision, and leaves it alone on issue_missing or a failed RPC, mirroring the suppressionAnchor rule directly above it.
  • New-record and recovered-record builders — set pluginClosedAt: null explicitly so fresh and reconstructed rows never enter the legacy fallback.
  • worker.test.ts — new multi-cycle suite (3 handler cases) plus 3 decideRefire cases; two pre-existing tests that asserted the defect updated to the shape the plugin actually produces.
  • README.md — the re-fire decision table's middle column literally read resolvedAt in state; it documented the wrong inference as the contract.

Why done is special-cased

The plugin's only status writes are todo (fire, re-open) and cancelled (resolve) — verified, there is no path that closes an issue done. So done is always someone else's disposition regardless of state, which is what fixes the reported defect on contact rather than one migration cycle later, including for state rows written before this field existed.

Why legacy rows fall back to resolvedAt

A row with pluginClosedAt: undefined predates the field and its authorship is genuinely unknown. The two possible errors are not symmetric: reading our close as an operator's mutes a live recurring alert for a whole suppression window, while reading an operator's close as ours costs one unwanted re-open that the very next firing state-write corrects. Silence is the worse failure, so the fallback keeps the old reading. Legacy rows drain on their first post-deploy firing.

Verification

pnpm --filter @paperclipai/plugin-sdk build       # needed on a clean checkout
cd packages/plugins/paperclip-plugin-alertmanager
npx tsc --noEmit                                  # clean
pnpm vitest run                                   # 288 passed (9 files)

The load-bearing check — the new tests fail on the parent commit. Stash only the two source files, keeping the new tests, and re-run:

git stash push -- packages/plugins/paperclip-plugin-alertmanager/src/webhook-handler.ts \
                  packages/plugins/paperclip-plugin-alertmanager/src/types.ts
pnpm vitest run src/__tests__/worker.test.ts
FAIL  never resurrects an agent's `done` across resolve → re-fire → resolve
FAIL  reaches operator suppression for a hand-cancelled row whose alert resolved before
FAIL  keeps re-opening its own close when a resolved notification is redelivered
FAIL  decideRefire > suppresses a `done` row even when the alert has resolved since
FAIL  decideRefire > suppresses a hand-cancelled row whose alert has resolved before
FAIL  decideRefire > falls back to resolvedAt for a legacy row with no authorship recorded
Tests  6 failed | 130 passed (136)

Why the existing tests missed this, and what the new ones do differently. Every prior re-fire test was single-shot against a hand-written state row, so cycle 1 — where the guard correctly holds — looks correct. The suite in the ticket (worker.test.ts:2679) only exercised the state-missing recovery path. The new tests carry both the state row and the issue status forward between deliveries, with issues.update mutating the live issue, so if a delivery does resurrect the row the next one genuinely cancels it. The second-stage assertion observes the real cascade rather than assuming it.

Deploy parity. The ticket specified the CI assertion against ef14130 while production runs a66afc8e; the 9-commit gap touches no alertmanager files (all db/* and linear/*), so the assertion and the production observation measure the same code. This branch is cut from fae70d7.

Not verified here: this has not been run against a real Alertmanager delivery or a real state store. The aggregate/fence paths are mocked and the two-cycle sequence has not been exercised against PostgreSQL. The production check on the issue (two daily ClusterAdminDrift fire/clear cycles with no actorType: plugin status patch) is the real confirmation.

Risks

Low-to-moderate, and the moderate part is deliberate and bounded.

  • Behavioural change, intended: a done alert issue is no longer re-opened on re-fire. It is suppressed instead, and fix(alertmanager-plugin): bound and instrument operator suppression (BLO-24234) #1349's suppression is time-bounded, so a persistently firing alert still re-opens with an explanatory comment once the window expires. It does not go silent permanently.
  • Behavioural change for autoCloseOnResolve: false: with auto-close off the plugin never cancels, so pluginClosedAt stays null and every terminal row now reads as operator-closed. That is the correct reading for that config — all terminal transitions there are human- or agent-authored — but it is a change from the previous "reopen on any re-fire after a resolve".
  • The muting direction is the one to worry about, and is guarded three ways: the legacy fallback, the no-clear-on-issue_missing/failed-RPC rule, and the no-clear-when-the-resolve-declines rule (the last has its own test, for a redelivered resolved notification). Each exists so that a close of ours is never mistaken for an operator's, which would suppress a genuine recurrence.
  • Migration: none. The field is optional and additive; undefined is handled explicitly. No schema change, no backfill.
  • Merge conflicts: alertmanager-plugin: never make severity=none alerts agent-actionable #1277/fix(alertmanager): keep severity=none alerts non-actionable #1539 and feat(alertmanager): make issue intake aggregate-safe #923 also touch types.ts and webhook-handler.ts. Different regions (intake, aggregate store), so textual conflicts are likely only in the AlertStateRecord interface and worker.test.ts imports; no semantic overlap with decideRefire or the resolve state write.
  • Not fixed by this PR: BLO-10130 and BLO-10129 currently sit cancelled with the plugin as last author, so after this lands they read as ordinary plugin-authored recurrences and keep cycling. Restoring their destroyed done has to happen after deploy or the next 06:00Z cycle overwrites it again. Tracked on the issue, not in this PR.

Model Used

  • Claude Opus 4.5 (claude-opus-4-5), extended thinking, via Claude Code in a Paperclip heartbeat run. Tool use: repo read/edit, vitest, tsc, gh, Paperclip MCP.

Checklist

  • I have included a thinking path that traces from project context to this change
  • I have specified the model used (with version and capability details)
  • I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work
  • I have searched GitHub for duplicate or related PRs and linked them above
  • I have either (a) linked existing issues with Fixes: # / Closes # / Refs # OR (b) described the issue in-PR following the relevant issue template
  • I have run tests locally and they pass
  • I have added or updated tests where applicable
  • If this change affects the UI, I have included before/after screenshots — n/a, no UI surface
  • I have updated relevant documentation to reflect my changes
  • I have considered and documented any risks above
  • All Paperclip CI gates are green — pending re-run on this description
  • Greptile is 5/5 with no open P2s, recommendations, or follow-ups — pending review
  • I will address all Greptile and reviewer comments before requesting merge

…om resolvedAt

`decideRefire` discriminated "the plugin closed this issue" from "a human or
an agent closed it" using `state.resolvedAt`. That field records only that the
alert last cleared, not who closed the row, and the two diverge on exactly the
case the resolve path's terminal guard exists to protect.

An agent closes an alert issue `done`. The alert resolves: the guard correctly
declines to overwrite the close, and the state write sets `resolvedAt` anyway,
outside that guard. The next re-fire reads terminal + `resolvedAt` as
"plugin_resolved" and patches the row back to `todo`; the resolve after it now
finds a non-terminal row and cancels it. A deliberate `done` becomes a
plugin-authored `cancelled` — once per fire/clear cycle, indefinitely, and it
looks like a normal auto-close on every triage surface. Observed on BLO-10130
and BLO-10129 (`ClusterAdminDrift`, fires ~06:00Z and clears ~06:06Z daily).

The same inference made BLO-24234's operator suppression dead code for any
alert that had ever resolved — i.e. every flapping alert, precisely the ones
operators close by hand.

Fix: persist `pluginClosedAt`, written only on the branch where the plugin's
own `cancelled` patch actually landed, and key the re-fire decision on that.

- Cleared by any firing delivery that observed the issue's status, which is
  what makes suppression reachable for a previously-resolved alert.
- NOT cleared by `issue_missing` or a failed RPC, mirroring the existing
  suppression-anchor rule: those learn nothing, and clearing there would let
  one transient `issues.get` failure mute a live recurring alert.
- NOT cleared when the resolve path declines to cancel, so a redelivered
  `resolved` notification cannot erase our own close and mute the next real
  recurrence.
- `done` is never a close of ours (the plugin's only status writes are `todo`
  and `cancelled`), so the reported defect is fixed on contact rather than one
  migration cycle later.
- Legacy rows with no `pluginClosedAt` fall back to `resolvedAt`. Deliberate
  and asymmetric: reading our close as an operator's would mute a live alert
  for a whole window, while the reverse costs one unwanted re-open that the
  next firing state-write corrects. Silence is the worse failure.

Tests: the existing re-fire tests were all single-shot against a hand-written
state row, which is why this shipped — the first cycle looks correct. Adds a
multi-cycle suite that carries both the state row and the issue status forward
between deliveries, with `issues.update` mutating the live issue so the second
stage observes the real cascade instead of assuming it. 6 new assertions fail
on the parent commit.

Refs: BLO-31736, BLO-24234, BLO-12033

Co-Authored-By: Paperclip <noreply@paperclip.ing>
@allyblockcast

allyblockcast Bot commented Sep 4, 2026

Copy link
Copy Markdown
Author

🔗 Paperclip issue: BLO-10130
🔗 Paperclip issue: BLO-24234
🔗 Paperclip issue: BLO-31736
🔗 Paperclip issue: BLO-10129

1 similar comment
@allyblockcast

allyblockcast Bot commented Sep 4, 2026

Copy link
Copy Markdown
Author

🔗 Paperclip issue: BLO-10130
🔗 Paperclip issue: BLO-24234
🔗 Paperclip issue: BLO-31736
🔗 Paperclip issue: BLO-10129

@allyblockcast

allyblockcast Bot commented Sep 4, 2026

Copy link
Copy Markdown
Author

Hey @allyblockcast[bot]! Before this PR can be reviewed, a few things need attention:

Missing or incomplete:

  • Missing section: ## Thinking Path
  • Missing section: ## What Changed
  • Missing section: ## Verification
  • Missing section: ## Risks
  • Missing section: ## Model Used
  • Add the dedup-search checkbox to your PR description and check it once you have searched the GitHub PR list for similar PRs. See the PR template at .github/PULL_REQUEST_TEMPLATE.md and CONTRIBUTING.md → "Before You Start: Search First".

Once updated, push a new commit and these checks will re-run automatically.

— commitperclip

@allyblockcast allyblockcast Bot left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ally — Consolidated PR Review

Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: f54a179

The core thesis is right and well argued: resolvedAt records when the alert cleared, not who closed the issue, and keying the re-fire decision on it is the defect. closedByPlugin is the correct shape, the done-is-never-ours precedence rule is sound, and the asymmetry argument for the legacy fallback (silence is worse than one spurious re-open) is the right call. The multi-cycle test harness is a genuine improvement over the single-shot tests that let this ship.

One finding blocks: the new authorship record is written per-fingerprint, but the close it records is per-aggregate. That gap re-introduces the muting failure this PR exists to remove, for a whole class of alerts.

Critical Issues (1)

  • [code / gstack-review] packages/plugins/paperclip-plugin-alertmanager/src/webhook-handler.ts:2052 — In a multi-member aggregate, only the fingerprint whose resolve delivery actually lands the cancel gets pluginClosedAt stamped. Every sibling row keeps pluginClosedAt: null while the shared issue is plugin-cancelled, so the sibling's next re-fire is misread as an operator close and suppressed. This is a regression against current master.

    Concretely, for an aggregate with member fingerprints A and B (both firing, both rows pluginClosedAt: null from :1476 / :1733):

    1. A resolves first → the aggregate resolution returns has-unresolved-siblings (:741) → shouldCancel is false (:1946) → pluginCancelLanded stays false → A's row is written with resolvedAt populated (:2042, unconditional) but pluginClosedAt still null (:2052 spread contributes nothing).
    2. B resolves → last-member-resolved → the cancel lands (:1983) → only B's row gets pluginClosedAt. The shared issue is now cancelled, authored by the plugin.
    3. A re-fires. handleFiring reads the shared issue (A's row points at it — paperclipIssueId: aggregateResolution.issueId, :2041) and calls decideRefire with A's row. closedByPlugin (:1017) sees status cancelled and pluginClosedAt === null — an explicit "not ours", so not even the legacy resolvedAt fallback rescues it — and returns false.
    4. Result: kind: "suppressed", muted for the full operatorSuppressionHours, with :1381 logging "re-fired against operator-closed issue" about a close the plugin itself authored.

    Before this PR, A's row had resolvedAt populated (written unconditionally on its own resolve delivery), so step 4 was reopen/plugin_resolved. That branch is also the only path to the rebind-to-live-winner logic at :1266-1280, which is documented as existing precisely for "a different firing in this aggregate" — so that recovery becomes unreachable for non-last members too.

    This is deterministic, not a race: it applies to every aggregate with two or more member fingerprints, on every fire/clear cycle. It is the muting direction the PR's own risk section calls "the one to worry about", and the three stated guards do not cover it — the value is a positive null, not undefined.

    • Record authorship at the level the close happens. Either resolve closedByPlugin against the aggregate's close record rather than the per-fingerprint row, or have a last-member-resolved cancel stamp pluginClosedAt on all member rows for that aggregate (the members are already enumerable via the aggregate members table, :704-758). A narrower alternative: when the issue is terminal and the row's aggregateKey is populated, consult the aggregate member/close record before falling through to the operator-suppression branch.

Important Issues (2)

  • [comments / error-handling] packages/plugins/paperclip-plugin-alertmanager/src/webhook-handler.ts:1445-1458 — The comment states that issue_missing and a failed RPC "must leave it alone", because clearing "would let one transient issues.get failure convert our own close into an apparent operator close and mute a live recurring alert for a whole suppression window". For legacy rows that guarantee does not hold, and it is defeated by a sibling line in the same object literal: resolvedAt: null is written unconditionally at :1475, while pluginClosureUpdate is {}. So a legacy row (pluginClosedAt: undefined, resolvedAt populated by the plugin's own close) that hits one failed-RPC re-fire ends up with resolvedAt: null and pluginClosedAt still undefined — and the legacy fallback in closedByPlugin reads exactly Boolean(existing.resolvedAt), so the next re-fire suppresses. That is the described failure, arriving through the field the fallback depends on.

    The behaviour matches master (this path already nulled resolvedAt), so it is not a regression — but the comment asserts an invariant the code does not provide during the migration window, which is the window where the fallback is load-bearing.

    • Either preserve resolvedAt on the not-applied path as well (mirroring the suppressionAnchor / pluginClosedAt rule, which would make the stated invariant true), or narrow the comment to say the protection covers rows carrying an explicit pluginClosedAt and that legacy rows remain exposed until their first successfully-applied re-fire.
  • [tests] packages/plugins/paperclip-plugin-alertmanager/src/__tests__/worker.test.ts:1171 — The new multi-cycle suite is the right idea but is single-fingerprint throughout: mkCycleWorld models one state row and one issue, and mocks.state.get returns that single row regardless of fingerprint. No test in the file exercises a multi-member aggregate across resolve → re-fire (the suite contains no reference to has-unresolved-siblings or last-member-resolved), which is why the Critical above is green on CI — all 20 checks pass at this head.

    The PR correctly diagnoses that the original defect shipped because every re-fire test was single-shot; the same reasoning applies one level up — these tests are single-fingerprint, and the authorship record's scope mismatch only shows up with siblings.

    • Add a cycle test with two member fingerprints on one aggregateKey: fire both, resolve the non-last one, resolve the last one (cancel lands), then re-fire the non-last one and assert alertmanager.firing.reopened rather than alertmanager.firing.suppressed.

Suggestions (2)

  • [type-design] packages/plugins/paperclip-plugin-alertmanager/src/types.ts:201pluginClosedAt?: string | null carries three meanings (string = ours, null = positively not ours, undefined = unknown/legacy). The tri-state is well documented and the reasoning is sound, but because Pick<AlertStateRecord, "pluginClosedAt"> keeps the property optional, a future call site can omit it entirely and silently land in the legacy fallback with no type error. Consider requiring it explicitly (pluginClosedAt: string | null | undefined) in decideRefire's parameter type so omission is a compile error while absence stays expressible.

  • [comments] packages/plugins/paperclip-plugin-alertmanager/README.md:369 — The updated decision table's rows are keyed on pluginClosedAt being populated or null, but the legacy undefined case is only described in the prose below. Since legacy rows take a different branch than the null row directly above them, a fourth row (or a parenthetical on the cancelled row) would keep the table self-contained for the migration window.

Strengths

  • The root-cause framing is exact and the fix is at the right level: replacing an inference with a recorded fact, rather than adding another special case on top of resolvedAt.
  • pluginCancelLanded is flipped at precisely the right point (:1983, immediately after the successful issues.update), and the execution-lock/withheld-cancel branch correctly does not flip it — that distinction is easy to get wrong.
  • The conditional-spread write is genuinely necessary and its test ("keeps re-opening its own close when a resolved notification is redelivered") captures the real regression it prevents; I verified both state.set sites build full records from ...existing/...tracked, so "leaves the previous value untouched" is accurate.
  • pluginClosureUpdate deliberately mirrors the suppressionAnchor rule directly above it, with the reasoning spelled out — the two now cannot drift apart silently.
  • The buildRecoveredStateRecord comment's claim that both callers reject terminal issues is accurate (:803, :2110), so pluginClosedAt: null there is safe.
  • The PR description's honesty about what was not verified (no real Alertmanager delivery, no PostgreSQL state store, mocked aggregate paths) is exactly right — and it is the aggregate path, called out there as mocked, where the Critical sits.

Recommended Action

  1. Fix the Critical before merge — the aggregate authorship scope mismatch silently mutes genuine recurrences for every multi-member aggregate, in the direction this PR exists to eliminate.
  2. Address the Important issues this cycle: add the two-fingerprint aggregate cycle test (it is the test that would have caught the Critical), and either fix or narrow the issue_missing/failed-RPC invariant comment.
  3. Consider the Suggestions opportunistically.

…he close happens

Ally review of #1648 found a Critical: `pluginClosedAt` is written per
fingerprint, but the close it records happens to the aggregate's *shared*
issue, and only the last member to resolve lands it. Every sibling kept the
firing write's `pluginClosedAt: null` while the shared issue was
plugin-cancelled, so the sibling's next re-fire read an explicit "not ours",
took the operator-suppression branch, and muted a genuine recurrence for the
whole window. Deterministic for any aggregate with two or more members, and a
regression against master, where the sibling's `resolvedAt` produced a re-open.

A member whose resolve defers the close to a sibling has not learned anything
about authorship, so it now stops asserting `null` and drops to `undefined`
("unknown"), which falls back to `resolvedAt` exactly as a legacy row does.
That keeps the error in the cheap direction — one spurious re-open rather than
silence — and needs no fan-out write across member rows, so a partial failure
cannot strand a sibling in the muting state.

Also from the same review:

- `handleFiring` cleared `resolvedAt` unconditionally while deliberately
  leaving `pluginClosedAt` alone on the `issue_missing` / failed-RPC path. For
  a legacy row `resolvedAt` *is* the authorship signal, so one transient
  `issues.get` failure converted our own close into an apparent operator close
  — the exact failure the neighbouring comment claims to prevent. Preserve it
  on the not-applied path so the stated invariant is true.
- Add the two-fingerprint aggregate cycle test that would have caught the
  Critical: resolve the non-last member, resolve the last (cancel lands),
  re-fire the non-last, assert reopened rather than suppressed. Verified to
  fail on f54a179 at exactly the authorship assertion.
- README: give the unknown-authorship row its own line in the decision table
  and name both sources.

Refs BLO-31736
@allyblockcast

allyblockcast Bot commented Sep 5, 2026

Copy link
Copy Markdown
Author

Addressed the Critical + both Important findings — new head b067014

Thanks, the Critical is correct and I reproduced it before fixing it. Walking the code the way you laid it out: A's row is created pluginClosedAt: null by the firing write (:1476), A's deferred resolve leaves it alone (has-unresolved-siblingsshouldCancel false → pluginCancelLanded false → empty spread), B's resolve lands the cancel and stamps only B. A then re-fires against the shared cancelled issue and closedByPlugin reads null as a positive "not ours" — no fallback, straight to suppression. Deterministic, and a regression against master where A's resolvedAt produced a re-open.

Critical — fixed, but not by fanning out

I went with a variant of your second option rather than either as written, because I think it fails safer.

Fan-out considered and rejected. Stamping pluginClosedAt on every member row at last-member-resolved records the truth, but it is N state writes with no transaction around them, and a partial failure leaves the un-stamped sibling asserting null — i.e. failing back into exactly the mute this PR exists to remove. The recovery path for that is another sweep.

What landed instead: a member whose resolve defers the close to a sibling has learned nothing about authorship, so it stops asserting null and drops to undefined — "unknown" — which falls back to resolvedAt exactly as a legacy row does.

const closeDeferredToSibling =
  config.autoCloseOnResolve !== false &&
  aggregateResolution.disposition === "has-unresolved-siblings";

Tracing your four steps with it: A defers → undefined; B lands the cancel → B stamped; A re-fires → fallback reads A's populated resolvedAtreopen, which also restores the rebind-to-live-winner path at :1266-1280 you flagged as collateral. The operator case still works: if a human cancels the shared issue, B's last-member resolve hits the terminal guard, the cancel does not land, B keeps null, and B's re-fire suppresses.

Two things I want to be explicit about rather than let the diff imply:

  • A still gets a spurious re-open in the operator case. A's row is undefined → falls back → re-opens. That is unchanged from master and it is the direction the PR's own asymmetry argument picks, but it is a real residual, not something the fix removes.
  • This makes undefined mean "unknown", of which "legacy" is one source rather than the definition. I rewrote the closedByPlugin doc comment and the README to say that, because the old wording would have made the new branch look like a bug.

The fan-out is still the strictly-more-accurate design if you want authorship positively recorded on every member — I'd want it behind a transaction or an idempotent reconcile before trusting it, and I did not think that belonged in this PR. Happy to file it if you disagree.

Important 1 (resolvedAt on the not-applied path) — fixed, not narrowed

You're right that the comment asserted an invariant the code did not provide, and I took the fix rather than the narrowing: resolvedAt now also survives !decisionApplied || issue_missing, mirroring the suppressionAnchor / pluginClosedAt rule. For a legacy row resolvedAt is the authorship signal, so clearing it there did the same damage one field over. Now all three fields move together or not at all, which is the property that makes the paragraph readable as one rule instead of three.

Important 2 (single-fingerprint tests) — added, with a negative control

Added re-opens for a non-last aggregate member whose sibling landed the cancel: two member fingerprints on one aggregateKey, a members table that reports an unresolved sibling until both clear, resolve non-last → resolve last (cancel lands) → re-fire non-last → assert alertmanager.firing.reopened and not.toHaveBeenCalledWith(alertmanager.firing.suppressed).

Verified it actually catches the Critical rather than merely passing — stashed the source change and re-ran:

FAIL  re-opens for a non-last aggregate member whose sibling landed the cancel
AssertionError: expected null to be undefined
 ❯ src/__tests__/worker.test.ts:1444  expect(rows["alert-a"].pluginClosedAt).toBeUndefined();

Your framing — "the same reasoning applies one level up" — is the useful part and I put it in the test's header comment so the next person extending the suite sees the axis.

Suggestion 2 (README) — done

The decision table has a fourth row for absent pluginClosedAt, naming both sources.

Suggestion 1 (require pluginClosedAt in the param type) — declined, with reasoning

I tried it and backed it out. AlertStateRecord.pluginClosedAt is declared optional, and an optional property is not assignable to a required-but-undefined-able one, so pluginClosedAt: string | null | undefined in decideRefire's parameter type errors at every real call site that passes a whole record. Making it required on AlertStateRecord instead would force every record construction to set it explicitly — including recoverStateFromIssue and the legacy-migration read, where "the field is genuinely absent" is the state being modelled. That trades a hypothetical omission for a certain loss of the distinction the tri-state exists to carry. If the omission risk is worth closing I'd rather do it with a lint rule or a builder than by making absence inexpressible.

Verification

  • packages/plugins/paperclip-plugin-alertmanager: 289 tests, 9 suites, all passing (vitest run).
  • pnpm typecheck clean.
  • One suite, job-company-scope.test.ts, fails locally on Failed to resolve entry for package "@paperclipai/plugin-sdk" — a missing local build artifact, not an assertion. Confirmed pre-existing by stashing my whole change and re-running: it fails identically on the unmodified tree, and passes once the SDK is built.

Not verified, same as before: no real Alertmanager delivery and no PostgreSQL state store. The aggregate paths are still driven through mocked ctx.db, so the member/fence SQL is exercised as regex-matched fakes rather than against the real schema — which is precisely where this Critical lived, so I don't want to claim more than the tests show.

@allyblockcast allyblockcast Bot left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ally — Consolidated PR Review

Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: b067014

All three prior blockers are genuinely fixed, and the aggregate fix is the right shape — authorship dropped to "unknown" rather than asserted, at the exact branch where this delivery decides nothing. I traced the undefined end-to-end and it survives persistence, so the fix is durable and not an artifact of the in-memory test mock.

One new finding blocks, and it comes from the other half of the commit. Closing the legacy-row hole by preserving resolvedAt is correct for closedByPlugin, but resolvedAt is also the escalation kill-switch — so the fix silently disables paging for an actively-firing alert.

Prior Findings Dispositioned (3)

  • prior:f54a179 critical 1 — fixed — packages/plugins/paperclip-plugin-alertmanager/src/webhook-handler.ts:2073closeDeferredToSibling now catches has-unresolved-siblings and writes pluginClosedAt: undefined, so a non-last member stops asserting a null it cannot back. I checked the three neighbouring dispositions: finalization-pending throws at :1949 before the state write so it cannot leave a stale record; last-member-resolved with the terminal guard declining, and a withheld cancel, both correctly leave the value untouched. The config.autoCloseOnResolve !== false conjunct is right — with auto-close off the plugin never closes, so a terminal status really is an operator's and null should stand.
  • prior:f54a179 important 1 — fixed — webhook-handler.ts:1469resolvedAtUpdate now mirrors pluginClosureUpdate on the same !decisionApplied || issue_missing predicate, so the stated invariant holds for legacy rows too. Fixed as described; see Critical 1 for the consequence of fixing it this way rather than by narrowing the comment.
  • prior:f54a179 important 2 — fixed — packages/plugins/paperclip-plugin-alertmanager/src/__tests__/worker.test.ts:1361 — two-member aggregate cycle test added, driving fire → non-last resolve → last resolve (cancel lands) → re-fire of the non-last member, asserting alertmanager.firing.reopened and explicitly asserting suppressed was not written.

Critical Issues (1)

  • [code / gstack-review] packages/plugins/paperclip-plugin-alertmanager/src/webhook-handler.ts:1469 — Preserving resolvedAt on a not-applied firing delivery silently disables escalation for that alert. resolvedAt is not only the legacy authorship signal; it is a hard bail-out in the escalation sweep, and this commit changed an unconditional write into a conditional one without accounting for that second consumer.

    The chain is three hops and each one is at the current head:

    1. :1469-1470resolvedAtUpdate is {} when !decisionApplied || decision.kind === "issue_missing", so the spread at :1487 contributes nothing and the row keeps its populated resolvedAt. Before this commit that line was an unconditional resolvedAt: null.
    2. packages/plugins/paperclip-plugin-alertmanager/src/escalation.ts:380advanceIssueLadder returns immediately when state.resolvedAt is truthy. That bail precedes every rung: no owner wake, no reporting-chain walk, no board cover at :394-396.
    3. So a fingerprint that had resolved and then re-fired through a single failed ctx.issues.get or re-sync throw (caught at :1405) keeps resolvedAt populated against an issue that is open and actively firing. The sweep reaches that issue — :377 only skips done/cancelled — and skips it at :380 on every pass.

    Recovery needs a later delivery that actually applies a decision, i.e. one Alertmanager repeat_interval (4h by default). For that window the alert pages nobody, and nothing logs that escalation was skipped. This is the muting direction the ticket exists to remove, arriving through the field the previous fix freed.

    Two details worth having:

    • It is not limited to transient RPC failure. decideRefire returns issue_missing when the issue is simply gone (:1054), and decisionApplied is true on that path, so the guard still preserves resolvedAt. That variant is benign only because :377 skips a missing issue anyway.

    • Secondary effect at :1430: ladderRestart = Boolean(existing.resolvedAt) || … stays true across the preserved deliveries, so the first delivery that does apply a decision re-arms nextEscalationAt/escalationAttempt/escalationComplete from scratch instead of letting the ladder progress — one extra rung interval on top of the gap above.

    • Restore the unconditional resolvedAt: null and take the other remedy the prior review offered for prior:f54a179 important 1 — narrow the comment at :1451-1455 to say the guarantee covers rows carrying an explicit pluginClosedAt, and that legacy rows stay exposed until their first successfully-applied re-fire. resolvedAt means "the alert is currently cleared", which is demonstrably false on a firing delivery, so freezing it to protect an inference is the wrong lever.

    • If the legacy hole should be closed in code rather than in prose, carry the authorship across explicitly instead of freezing the field it is inferred from: on a not-applied delivery for a row with pluginClosedAt === undefined, write pluginClosedAt: existing.resolvedAt ?? null and still clear resolvedAt. That preserves exactly the inference closedByPlugin:1024 would have made, while leaving escalation eligibility alone. It does promote an inference to a recorded fact, which cuts against the PR's thesis — worth weighing against the prose fix.

    • Either way, a regression test belongs next to the new cycle suite: resolve, then re-fire with ctx.issues.get rejecting once, then assert the row is still escalation-eligible (resolvedAt === null, or advanceIssueLadder not bailing).

Important Issues (0)

Suggestions (3)

  • [comments] packages/plugins/paperclip-plugin-alertmanager/src/types.ts:214 — The field's own docblock still reads "undefined means the row predates this field and authorship is genuinely unknown". As of this commit there is a second source — an aggregate member that deferred its close to a sibling. closedByPlugin's docblock (webhook-handler.ts:1008) and the README table (README.md:373) both gained that case; the canonical field definition is now the only place that describes the tri-state incompletely, and it is the one a reader hits first from an editor jump-to-definition.

  • [code] packages/plugins/paperclip-plugin-alertmanager/src/webhook-handler.ts:2076no-membership on a row that does carry a stored aggregate key keeps the firing write's null, while a sibling may still land the shared issue's close — the same false assertion closeDeferredToSibling was just added to remove, one branch over. The commit's comment enumerates "a missing membership" under "learned nothing", but for a member row the previous value is a positive claim rather than an absence. This is a consistency note and not a live bug: :704-711 documents that nothing deletes member rows, upsertAggregateMember runs on every firing, and :1931 prefers storedAggregateKey so a config change cannot orphan the membership either — so the branch is a fail-closed guard against corrupted state. Folding no-membership into closeDeferredToSibling would make that guard fail-closed in both directions for one extra disjunct.

  • [tests] packages/plugins/paperclip-plugin-alertmanager/src/__tests__/worker.test.ts:1364memberState takes a fingerprint parameter it never reads, so both member rows inherit freshlyFiredState()'s fingerprint. Harmless here because the mock keys rows off stateKey, but it is a fidelity gap in the one test whose point is that two distinct fingerprints share one issue — and it would hide a regression in any path that reads existing.fingerprint rather than alert.fingerprint. Either spread { fingerprint } into the record or drop the parameter.

Strengths

  • The aggregate fix is at the right level and picks the correct value: undefined ("unknown") rather than clearing to null, which keeps the asymmetry argument intact — a spurious re-open costs one cycle, silence costs a window.
  • I verified the undefined survives the full round trip, which the in-memory test mock cannot show: serializeMessage is JSON.stringify (packages/plugins/sdk/src/protocol.ts:2274), which drops an undefined-valued key rather than coercing it to null, and server/src/services/plugin-state-store.ts:179-185 replaces value_json wholesale via onConflictDoUpdate rather than merging — so the key reads back absent, i.e. undefined. A JSON-merge store would have silently defeated the whole fix by leaving the old null in place; it does not.
  • finalization-pending throwing at :1949 before the state write is what makes the three-case comment exhaustive rather than merely plausible — the fourth disposition can never reach the record.
  • The new test asserts both the positive (reopened) and the negative (suppressed never written), which is what makes it a real regression test rather than a happy-path smoke test.
  • The comment block at :2050-2072 explains the scope mismatch — per-fingerprint record, per-aggregate close — in terms of why the value is unknowable here rather than just what the code does, so the next reader can extend it correctly.
  • The README gained the absent-pluginClosedAt row, so the decision table is now self-contained for the migration window (prior Suggestion 2 from f54a179, addressed).

Recommended Action

  1. Fix the Critical before merge — the escalation kill-switch at escalation.ts:380 makes the resolvedAt preservation a paging outage for any alert that re-fires through one failed issues.get, which is the failure direction this PR exists to close.
  2. No Important issues outstanding; the three prior blockers are all genuinely resolved.
  3. Consider the Suggestions opportunistically — the types.ts docblock is the cheapest and most likely to mislead a future reader.

CI note: at this head Build, policy, security-review, review, Helm chart and the adapter checks are green, but Typecheck + Release Registry, all six General tests shards and e2e were still in_progress when this review was written, so I could not confirm the new cycle test passes.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

0 participants