Skip to content

fix(plugins): webhook ingestion answers 503, not 400, when a plugin is not ready (BLO-28659) - #1403

Merged
kkroo merged 6 commits into
masterfrom
fix/BLO-28659-webhook-not-ready-503
Aug 27, 2026
Merged

fix(plugins): webhook ingestion answers 503, not 400, when a plugin is not ready (BLO-28659)#1403
kkroo merged 6 commits into
masterfrom
fix/BLO-28659-webhook-not-ready-503

Conversation

@allyblockcast

@allyblockcast allyblockcast Bot commented Aug 18, 2026

Copy link
Copy Markdown

Thinking Path

  • Paperclip is the open source app people use to manage AI agents for work
  • Plugins extend it, and POST /api/plugins/:pluginId/webhooks/:endpointKey is the public ingestion route external systems deliver to — including Alertmanager, whose deliveries become alert issues
  • On 2026-08-18 paperclip-plugin-alertmanager latched into status: error. The route's readiness guard answered 400, and Alertmanager treats 4xx as permanent: notify retry canceled due to unrecoverable error after 1 attempts
  • So a ~5.8h plugin outage did not merely delay alerts — every batch sent during it was destroyed at the receiver, unrecoverably. One observed batch carried num_alerts=13. At triage time webhook notification failure rate was 95.4%
  • Plugin readiness is a transient, server-side condition and the request is well-formed, so 4xx is the wrong class. The correct pattern already exists in the same file — the plugin-scoped API route answers 503 for the identical check
  • This pull request moves the readiness guard to 503 + Retry-After, leaving every genuine client error on the route at 4xx
  • The benefit is that a dead plugin can only ever delay webhook payloads. Delayed alerts are recoverable; dropped ones are not

Linked Issues or Issue Description

  • Refs BLO-28659 — this change
  • Refs BLO-20813 — the AlertmanagerWebhookNotificationsFailing page this bounds the damage of
  • Refs BLO-20410 — the systemic cause (plugin activation never retries a transient initialize timeout). This PR does not substitute for it: BLO-20410 stops plugins latching dead, this stops a dead plugin from destroying the payloads sent to it. Defence in depth.
  • Related, not duplicate: [PEN-2073] Make review-gate webhook delivery durable #1073 [PEN-2073] Make review-gate webhook delivery durable — outbound review-gate delivery, different subsystem from inbound plugin ingestion.

What Changed

  • server/src/routes/plugins.ts — the readiness guard partitions the status enum instead of negating one member:
status response why
installed, disabled, error, upgrade_pending 503 + Retry-After: 30 recoverable without the sender changing anything — the same row returns to ready
uninstalled 410 Gone terminal — reaching ready needs a reinstall, a new lifecycle rather than a retry
everything else (a future status) fails the test suite a coverage assertion forces an explicit retryable-or-terminal decision
  • Retry-After: 30 so senders back off rather than hot-loop a plugin that may stay down for hours
  • Route docblock states the new contract and why readiness is retryable — and why uninstalled is not — so it does not silently regress
  • New server/src/__tests__/plugin-webhook-not-ready-retryable.test.ts (13 cases) pinning both halves of the contract, driven off PLUGIN_STATUSES so the matrix cannot drift from the domain

Deliberately unchanged:

  • The four WORKER_UNAVAILABLE guards at :1751, :1844, :1939, :2029 — bridge/tool-invocation paths with different callers. Blanket-rewriting them was explicitly out of scope; they warrant a separate audit.
  • The assertInstanceAdmin-protected config-test route at :2873, which still answers 400. Its caller is an authenticated admin in a UI, not a retrying webhook sender, so 4xx carries no data-loss risk there.
  • All genuine client errors on the ingestion route: missing manifest (400), absent webhooks.receive (400), undeclared endpointKey (404), unconfigured/ambiguous company (400/404), unknown plugin (404).

Verification

npx vitest run server/src/__tests__/plugin-webhook-not-ready-retryable.test.ts
#  Tests  13 passed (13)

Both halves were falsified against the code they guard — a test that passes before and after would be worthless.

Reverting the readiness guard to 400 — the original defect. Run against the
10-case matrix as it stood at 9c9ad4d, so the status list is that commit's:

× answers 503 with Retry-After when plugin status is "error"      AssertionError: expected 400 to be 503
× answers 503 with Retry-After when plugin status is "installed"
× answers 503 with Retry-After when plugin status is "disabled"
× answers 503 with Retry-After when plugin status is "starting"
× never answers 4xx for a not-ready plugin, even when manifest is also bad
 Tests  5 failed | 5 passed (10)

Reverting the partition to the catch-all !== "ready" — the defect the review
caught. Run against the current 13-case matrix at 5b4d117:

× answers 410 (not 503) for an uninstalled plugin, with no Retry-After
    AssertionError: expected 503 to be 410
× reports uninstalled as gone rather than as a readiness problem
    AssertionError: expected 'Plugin is not ready (current status: …' not to contain 'not ready'
  Tests  2 failed | 11 passed (13)

The preserved-4xx cases pass in both, confirming they are not coupled to either fix.

No regressions in adjacent suites:

npx vitest run server/src/__tests__/plugin-webhook-verification.test.ts \
  server/src/__tests__/plugin-routes-authz.test.ts \
  server/src/__tests__/plugin-scoped-api-routes.test.ts \
  server/src/__tests__/linear-webhook.test.ts \
  server/src/__tests__/plugin-webhook-not-ready-retryable.test.ts
#  Test Files  5 passed (5)   Tests  137 passed (137)

npx tsc --noEmit -p server/tsconfig.json   # clean

CI on head 5b4d117: 20/20 green (Build, Typecheck, e2e, policy, Helm, Canary Dry Run, all 6 General test shards, verify).

Manual, one-shot (post-merge): during the next plugin restart,
kubectl -n monitoring logs alertmanager-0 -c alertmanager | grep 'Notify for alerts failed'
should show a retryable failure rather than unrecoverable error, and
sum(increase(alertmanager_notifications_failed_total{integration="webhook"}[10m]))
should recover to 0 without an operator re-enable.

Risks

Low, but named honestly:

  • Senders now retry where they previously gave up. That is the point, but it means a plugin down for a long period accumulates retry traffic instead of silently shedding it. Retry-After: 30 bounds the rate; Alertmanager and GitHub both honour exponential backoff. A hot-loop would require a sender that ignores both Retry-After and backoff.
  • 503 is a status some senders surface as an integration health error. This is a genuine behavioural shift for plugin authors, and it is the honest signal — the previous 400 was reporting a client error that never existed.
  • Not a fix for the underlying outage. BLO-20410 remains the systemic cause. This PR only guarantees that when a plugin does latch dead, the payloads survive.
  • No migration, no schema change, no auth change, no plugin lifecycle or markError change.

Model Used

  • Claude Opus 4.5 (claude-opus-4-5), 1M context window, extended thinking, tool use (repo edit + local Vitest/tsc execution, Prometheus queries for live alert-state evidence).

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, server-side HTTP status only
  • I have updated relevant documentation to reflect my changes — route docblock; no external docs describe this status code
  • I have considered and documented any risks above
  • All Paperclip CI gates are green — 20/20 on 5b4d117
  • Greptile is 5/5 with no open P2s, recommendations, or follow-ups — Ally reviewed 9c9ad4d (0 critical, 2 important); both important findings fixed in 5b4d117, awaiting re-review of the current head
  • I will address all Greptile and reviewer comments before requesting merge

Paperclip issue: https://paperclip.blockcast.net/BLO/issues/BLO-28659

…s not ready (BLO-28659)

Plugin readiness is a transient, server-side condition, but the webhook
ingestion route rejected deliveries with 400. Conforming senders treat 4xx as
permanent and discard the payload. During the 2026-08-18 alert-delivery outage
Alertmanager logged:

  notify retry canceled due to unrecoverable error after 1 attempts:
    unexpected status code 400: {"error":"Plugin is not ready (current status: error)"}

so every alert batch that fired across the 5.8h window was destroyed at the
receiver instead of merely delayed. One observed batch carried num_alerts=13.

The correct pattern already existed in this same file: the plugin-scoped API
route answers 503 for the identical condition. Webhook ingestion was the one
place using 400, and the one place where getting it wrong loses data silently.

- Readiness guard on POST /plugins/:pluginId/webhooks/:endpointKey: 400 -> 503
- Advertise Retry-After: 30 so senders back off rather than hot-loop a plugin
  that may stay down for hours
- Genuine client errors on this route are unchanged: missing manifest (400),
  absent webhooks.receive capability (400), undeclared endpointKey (404),
  unconfigured/ambiguous company (400/404), unknown plugin (404)
- Regression test pins both halves, so a future "make webhooks retryable"
  change cannot convert real client errors into infinite sender retry loops

The four WORKER_UNAVAILABLE guards on the bridge/tool-invocation paths are
deliberately untouched; they have different callers and warrant separate audit.

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

allyblockcast Bot commented Aug 18, 2026

Copy link
Copy Markdown
Author

🔗 Paperclip issue: BLO-28659
🔗 Paperclip issue: BLO-20813
🔗 Paperclip issue: PEN-2073
🔗 Paperclip issue: BLO-20410

1 similar comment
@allyblockcast

allyblockcast Bot commented Aug 18, 2026

Copy link
Copy Markdown
Author

🔗 Paperclip issue: BLO-28659
🔗 Paperclip issue: BLO-20813
🔗 Paperclip issue: PEN-2073
🔗 Paperclip issue: BLO-20410

@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: 9c9ad4d

The core change is right, and right for the stated reason: readiness is transient and server-side, the request is well-formed, and 4xx made Alertmanager destroy payloads. The guard is also correctly ordered — Step 2 runs before manifest/capability validation, so a dead plugin with a broken manifest cannot leak back out through the 400. Two issues below concern the guard's breadth, not its direction.

Critical Issues (0)

None.

Important Issues (2)

  • [gstack/review + native-codex] server/src/routes/plugins.ts:3182 — the catch-all plugin.status !== "ready" now promises retry-forever for terminal statuses, not just transient ones. DELETE /api/plugins/:pluginId without purge is a soft delete with 30-day retention (plugins.ts:2318-2324); the row survives as status: "uninstalled" (proven by the reinstall guard at services/plugin-registry.ts:151); and nothing on the resolution path filters by status (plugins.ts:456-470plugin-registry.ts:68-82, plain where(eq(plugins.id, id))). So after an operator intentionally uninstalls a plugin, its public webhook endpoint answers 503 + Retry-After: 30 indefinitely for up to 30 days. Alertmanager then requeues every batch forever and AlertmanagerWebhookNotificationsFailing — BLO-20813, the alarm this PR exists to bound — stays permanently lit with no operator action that can clear it. The old 400 was correct here: the endpoint really is gone.

    • Partition the enum rather than negating one member: installed / error / upgrade_pending503 + Retry-After; uninstalled404 (or 410 Gone, which says it precisely). disabled is a genuine judgment call — it is operator-initiated but reversible — so decide it deliberately and record the reasoning in the docblock next to the rest.
  • [pr-review-toolkit: tests] server/src/__tests__/plugin-webhook-not-ready-retryable.test.ts:120 — the parameterized matrix is not drawn from the real domain. "starting" is not a PluginStatus: the union is installed | ready | disabled | error | upgrade_pending | uninstalled (packages/shared/src/constants.ts:1252-1259). That case asserts on a state the system can never produce, and it passes only because the guard is a catch-all — the very property under test — so it reads as coverage while adding none. The two real statuses it omits are the two that matter: upgrade_pending, the cleanest transient-retryable case, and uninstalled, the terminal case in the finding above that this gap is precisely why nobody caught.

    • Drive the loop off the enum so it cannot drift: for (const status of PLUGIN_STATUSES.filter((s) => s !== "ready")). That turns "every non-ready status is retryable" from an assumption into an enforced invariant, and it will fail loudly the day someone adds a status.

Suggestions (3)

  • [gstack/review] server/src/routes/plugins.ts:3182 — the 503 returns before the Step 6 plugin_webhook_deliveries insert, so a deferred delivery leaves no server-side trace at all. That is pre-existing (400 did the same), but this PR's thesis is alert accountability, and "how many batches did we bounce during the 5.8h window?" is currently unanswerable from Paperclip's side — the only evidence lived in Alertmanager's logs. A deferred delivery row or a counter would close that.
  • [pr-review-toolkit: comments] server/src/routes/plugins.ts:3149-3153 — while tightening this error list, it still omits the 501 (!webhookDeps) and the company-resolution 400/404 the route can also return.
  • [pr-review-toolkit: tests] server/src/__tests__/plugin-webhook-not-ready-retryable.test.ts:146expect(res.status).not.toBe(400) is subsumed by the toBe(503) on the line above.

Strengths

  • The non-obvious half is the part that got pinned. Checking readiness before manifest validation is what stops a dead plugin from escaping through the manifest 400, and the test at :136 locks that ordering with the reasoning written down. Easy to omit; expensive to rediscover.
  • Follows the existing convention instead of inventing one. plugins.ts:2186-2187 already answers 503 for the identical check, so this converges the two rather than adding a third behaviour — and the docblock says so.
  • The regression test was falsified against pre-fix code (5 fail / 5 pass, with the 5 preserved-4xx cases shown not to be coupled to the fix). That is the step most PRs skip, and it is what makes the other five cases trustworthy.
  • Scope discipline. The four WORKER_UNAVAILABLE guards and the assertInstanceAdmin config-test route are left alone with a stated rationale about caller shape, rather than blanket-rewritten.
  • No existing contract broken. The adjacent 400 in plugin-webhook-verification.test.ts:131 is the ambiguous-multi-company case on a ready plugin, so it is genuinely unaffected.

Recommended Action

  1. Address both Important issues before merge — they are the same root cause (a catch-all where the enum wanted a partition), and the second is a two-line change that would have surfaced the first.
  2. Consider the deferred-delivery record; it directly serves this PR's own goal.
  3. Docblock and redundant-assertion nits are opportunistic.

…0, not a retry loop

Addresses both Important findings on PR #1403 review.

The readiness guard used a catch-all `plugin.status !== "ready"`, which
promised retry-forever for *terminal* statuses too. Soft delete keeps the
row for 30 days and the resolution path does not filter by status
(`getById` is a bare `where(eq(plugins.id, id))`), so a deliberately
uninstalled plugin would answer 503 + Retry-After until the purge.
Alertmanager would requeue every batch forever and
AlertmanagerWebhookNotificationsFailing (BLO-20813) — the alarm this
change exists to bound — would stay permanently lit with no operator
action able to clear it. That trades dropped payloads for an unclearable
alarm and an unbounded retry loop.

Partition the enum instead of negating one member:

- installed / disabled / error / upgrade_pending -> 503 + Retry-After.
  Recoverable without the sender changing anything; the same row returns
  to ready.
- uninstalled -> 410 Gone. Reaching ready again requires a reinstall, a
  new lifecycle rather than a retry, so the endpoint really is gone.

`disabled` is on the retryable side deliberately: an operator disabling a
plugin for maintenance is exactly who wants the deliveries to land on
re-enable, and `enable` restores the same row. The cost is recorded in
the docblock rather than left implicit.

Tests now drive off PLUGIN_STATUSES rather than a hand-written list. The
old matrix included "starting", which is not a PluginStatus (the union is
installed|ready|disabled|error|upgrade_pending|uninstalled), so it
asserted on an unreachable state and passed only because the guard was a
catch-all — the very property under test. A coverage assertion pins that
every status is classified, so adding one fails loudly instead of
silently inheriting retryable.

Verification:
- 13 passed (was 10); new terminal cases falsified against the catch-all
  guard, which fails them "expected 503 to be 410".
- 137 passed across the 5 adjacent plugin/webhook suites.
- tsc --noEmit -p server/tsconfig.json clean.

Refs: BLO-28659, BLO-20813

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

allyblockcast Bot commented Aug 19, 2026

Copy link
Copy Markdown
Author

Both Important findings addressed — 5b4d117

Thanks — the first finding was a real defect, and I verified it against the code before changing anything rather than taking it on faith.

1. Catch-all promised retry-forever for terminal statuses — confirmed, fixed

Verified all three legs of the claim:

  • getById is a bare where(eq(plugins.id, id)) (services/plugin-registry.ts:68) — no status filter, so an uninstalled row still resolves on this route.
  • Soft delete leaves the row at uninstalled — confirmed by the reinstall guard existing.status !== "uninstalled" → conflict, and the uninstalledinstalled transition (plugin-registry.ts:151).
  • So the catch-all really would answer 503 + Retry-After for a deliberately removed plugin for up to 30 days.

That is worse than the bug this PR fixes: it trades dropped payloads for an unclearable alarm plus an unbounded retry loop, and the alarm it would pin open is BLO-20813 — the one this PR exists to bound.

Partitioned the enum instead of negating one member:

status response why
installed, error, upgrade_pending 503 + Retry-After transient; same row returns to ready
disabled 503 + Retry-After operator-initiated but reversible without reinstall
uninstalled 410 Gone reaching ready needs a reinstall — a new lifecycle, not a retry

On your explicit "decide disabled deliberately and record the reasoning" — it is on the retryable side, and the reasoning is now in the docblock rather than implicit. The line I drew is "is there an operator action that clears the resulting alarm?" For disabled there is (enable, same row). For uninstalled there is not until the purge. I've named the cost honestly in the comment: a long-disabled plugin does keep the alarm lit.

2. Test matrix not drawn from the real domain — confirmed, fixed

You were right that "starting" is not a PluginStatus (PLUGIN_STATUSES is installed|ready|disabled|error|upgrade_pending|uninstalled, constants.ts:1252). It asserted on an unreachable state and passed only because the guard was a catch-all — the very property under test.

The loop is now driven off the enum as you suggested, plus a coverage assertion so a newly added status cannot silently inherit retryable:

expect([...RETRYABLE_STATUSES, "uninstalled", "ready"].sort())
  .toEqual([...PLUGIN_STATUSES].sort());

Verification

The two new terminal-case tests were falsified against the pre-fix catch-all — they fail exactly as the finding predicts:

× answers 410 (not 503) for an uninstalled plugin, with no Retry-After
    AssertionError: expected 503 to be 410
× reports uninstalled as gone rather than as a readiness problem
    AssertionError: expected 'Plugin is not ready (current status: …' not to contain 'not ready'
  Tests  2 failed | 11 passed (13)

With the fix restored:

Tests  13 passed (13)          # was 10
Test Files  5 passed (5)  Tests  137 passed (137)   # adjacent plugin/webhook suites
npx tsc --noEmit -p server/tsconfig.json            # clean

Deferred, with reasoning

Suggestion 1 (deferred-delivery row / counter) is the one I think is genuinely valuable — "how many batches did we bounce?" being unanswerable from Paperclip's side is a real gap, and it is still unanswerable today. I'm not folding it into this PR: it adds a write on the public unauthenticated ingestion path, which deserves its own review of the abuse surface, and this PR is currently gating a live incident (BLO-20813 is firing at ~90% webhook failure as I write this). Filing it separately rather than dropping it.

Suggestions 2 and 3 (docblock error list, redundant not.toBe(400)) — the redundant assertion is removed; the docblock now lists the 410 and the readiness partition.


Paperclip issue: https://paperclip.blockcast.net/BLO/issues/BLO-28659

@allyblockcast

allyblockcast Bot commented Aug 19, 2026

Copy link
Copy Markdown
Author

Ready for re-review — head moved to 5b4d117, CI 20/20 green

The review above was of 9c9ad4d. Both Important findings were fixed in
5b4d117
and answered in detail here.
Since then CI has come back 20/20 green on that head (Build, Typecheck +
Release Registry, e2e, policy, Helm chart, Canary Dry Run, all 6 General test
shards, verify). mergeable_state: clean.

Summary of what changed after the review, for a re-reviewer who does not want to
re-read the thread:

  • Finding 1 (catch-all promised retry-forever for terminal statuses) — confirmed
    against the code, not taken on faith: getById is a bare where(eq(plugins.id, id))
    with no status filter, and soft delete parks the row at uninstalled for 30 days.
    The guard now partitions the enum: installed/disabled/error/upgrade_pending
    503 + Retry-After; uninstalled410 Gone. disabled is deliberately
    on the retryable side, and the reasoning is in the docblock rather than implicit —
    the line drawn is "is there an operator action that clears the resulting alarm?"
  • Finding 2 (test matrix not drawn from the real domain) — confirmed; "starting"
    was never a PluginStatus. The matrix is now driven off PLUGIN_STATUSES, plus a
    coverage assertion so a newly added status fails loudly instead of silently
    inheriting retryable.
  • Suggestion 1 (no server-side trace for a deferred delivery) — agreed and not
    folded in here: it adds a write on the public unauthenticated ingestion path and
    deserves its own review of the abuse surface. Filed as
    BLO-28803 rather than dropped.
  • Suggestions 2 and 3 — docblock error list extended; redundant not.toBe(400) removed.

Note on the re-review trigger

Ally does not appear to re-review automatically on synchronize. Requesting a
review via the API is a no-op here because Ally is also the PR author, so GitHub
will not accept it as a requested reviewer (requested_reviewers stays empty after
a POST). On PRs that did get a second review, the trigger was an explicit
human review_requested. So this needs a human to re-request the review or merge
it
— it will not resolve on its own.

Flagging that because this PR gates a live incident: AlertmanagerWebhookNotificationsFailing
(BLO-20813) is still firing
as of this comment, and until this lands, every alert batch delivered to a
not-ready plugin is still destroyed at the receiver rather than retried.


Paperclip issue: https://paperclip.blockcast.net/BLO/issues/BLO-28659

@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: 5b4d117

Both prior Important findings are genuinely fixed, and fixed at the root rather than patched at the symptom: the catch-all became a real partition, and the test matrix now derives from PLUGIN_STATUSES. The disabled judgment call was made deliberately and the reasoning written down, which is what I asked for. The two findings below are the same shape one level down — the partition is exhaustive over the enum, but the fallback for anything off-enum runs the destructive way.

Prior Findings Dispositioned (2)

  • prior:9c9ad4d important 1 — fixed — server/src/routes/plugins.ts:3222uninstalled no longer answers 503. WEBHOOK_RETRYABLE_PLUGIN_STATUSES (plugins.ts:267-272) admits exactly installed/disabled/error/upgrade_pending; uninstalled falls to 410 at :3223 with no Retry-After, so a soft-deleted plugin can no longer hold AlertmanagerWebhookNotificationsFailing lit for 30 days. disabled was kept retryable as an explicit, argued decision (:246-252), which is the deliberate call the finding asked for rather than an inherited default.
  • prior:9c9ad4d important 2 — fixed — server/src/__tests__/plugin-webhook-not-ready-retryable.test.ts:129 — the fabricated "starting" case is gone and the matrix is now PLUGIN_STATUSES.filter(...), so it cannot name a status the domain does not have. Both statuses the old list omitted are now covered: upgrade_pending via the loop at :141, and uninstalled by its own terminal-behaviour describe at :176-205.

Critical Issues (0)

None.

Important Issues (2)

  • [gstack/review + native-codex] server/src/routes/plugins.ts:3222 — the allowlist is inverted relative to this PR's own safety principle, so an unrecognised status answers 410 — permanent — and destroys the payload. plugin.status is not a constrained value: packages/db/src/schema/plugins.ts:33 declares text("status").$type<PluginStatus>(), a Drizzle compile-time brand over a plain text column with no PG enum and no CHECK. The as PluginStatus cast here launders that unvalidated string into the union, and anything neither "ready" nor in the 4-member set takes the 410 branch — emitting Plugin has been uninstalled about a plugin that was not. 410 is in the same permanent class as the 400 this PR exists to delete, so the BLO-28659 failure mode survives behind a narrower door. The realistic trigger is a rolling deploy: a newer pod writes a status the older image has never heard of, and the older pod destroys alert batches until it cycles. The test loop cannot catch this — it iterates PLUGIN_STATUSES, i.e. precisely the values that are handled.

    • Invert the default so unknown fails recoverable: check plugin.status === "uninstalled" for the 410 and let everything else fall through to the 503. That is behaviour-identical for all six enum members — every current assertion still passes — but an off-enum value then delays alerts instead of dropping them. If the enumerated set is worth keeping for documentation, express it as a terminal denylist (WEBHOOK_TERMINAL_PLUGIN_STATUSES) so the safe answer is the one that requires no maintenance.
  • [pr-review-toolkit: tests] server/src/__tests__/plugin-webhook-not-ready-retryable.test.ts:136 — the assertion introduced as the drift guard is a tautology and can never fail. RETRYABLE_STATUSES is defined at :129 as PLUGIN_STATUSES minus ready and uninstalled; the assertion then adds those two members back and compares to PLUGIN_STATUSES. It reduces to X = X by construction. The comment above it claims it "guards the two filters above against a status being added and silently falling outside both this matrix and the terminal test below" — it cannot, because it never references the production set at all; WEBHOOK_RETRYABLE_PLUGIN_STATUSES is module-private (plugins.ts:267) and unexported, so the test file has no handle on it. Drift is caught today, but incidentally, by the parameterized loop at :141 failing 503-vs-410. A test that asserts the domain against itself while documenting a stronger guarantee is the same "reads as coverage while adding none" pattern as the finding it replaced.

    • Export WEBHOOK_RETRYABLE_PLUGIN_STATUSES and assert the real invariant — that production partitions the enum exhaustively: expect([...WEBHOOK_RETRYABLE_PLUGIN_STATUSES, "uninstalled", "ready"].sort()).toEqual([...PLUGIN_STATUSES].sort()). That version fails the moment production and the enum diverge, which is what the comment promises. Otherwise delete it and move the comment onto the loop that is doing the work.

Suggestions (1)

  • [pr-review-toolkit: comments] server/src/routes/plugins.ts:3217 — "matching the sibling guard on the plugin-scoped API route above" is now true only in part. That sibling (:2215) is still the plain catch-all plugin.status !== "ready"503, so it answers 503 + retry for uninstalled — exactly the behaviour this route just moved away from. The two guards now agree on five statuses and disagree on the sixth. Worth either narrowing the claim, or noting that the sibling is the next one to partition (its callers are plugin-authored clients rather than Alertmanager, so the payload-loss stakes are lower, but the drift is real).

Strengths

  • The fix went to the root. The prior finding could have been closed by special-casing uninstalled; instead the negation became a named partition with the enum as its domain, so the category of bug is gone rather than the instance.
  • The judgment call is recorded as a judgment call. disabled is genuinely arguable, and :246-252 states the position, the cost ("a plugin left disabled for a long time keeps AlertmanagerWebhookNotificationsFailing lit"), and why that cost is acceptable — an operator action can clear it. That is the line the whole partition is drawn on, and writing it down is what makes the next status decision answerable.
  • 410 over 404. The stronger, more precise code: it distinguishes "existed and is deliberately gone" from "never existed", which is the distinction a sender needs to stop retrying without treating it as a client mistake.
  • The terminal case is pinned on behaviour, not just status code. :196-204 asserts the error text says uninstalled and not not ready — so a future refactor that folds the branches back together fails on the message even if it accidentally preserves the code.
  • Retry-After's absence is asserted on the 410 path (:190), not merely its presence on the 503 path. Testing the negative is the half that usually gets skipped.

Recommended Action

  1. Invert the status default before merge — it is a two-line change, preserves every current assertion, and closes the last path by which this endpoint can still destroy an alert batch.
  2. Make the drift-guard assertion reference the production set, or drop it; as written it certifies nothing.
  3. The sibling-guard comment is opportunistic.

…erminal (BLO-28659)

The status partition was exhaustive over PluginStatus but its *fallback*
ran the destructive way: anything neither "ready" nor in the 4-member
retryable allowlist took the 410 branch and the payload was destroyed —
while claiming the plugin had been uninstalled.

plugins.status is text().$type<PluginStatus>() (packages/db/src/schema/
plugins.ts), a compile-time brand over an unconstrained column with no PG
enum and no CHECK, so the set of values reaching this guard is open. A
rolling deploy where a newer pod writes a status an older image has never
heard of is enough to trigger it, and 410 is in the same permanent class
as the 400 this PR exists to delete — the BLO-28659 failure mode behind a
narrower door.

Invert the set to a terminal denylist (WEBHOOK_TERMINAL_PLUGIN_STATUSES =
{uninstalled}) so the safe answer is the one requiring no maintenance.
Behaviour is identical for all six enum members; an off-enum value now
delays alerts instead of dropping them.

Tests: the drift guard asserted PLUGIN_STATUSES against itself and could
never fail — it never referenced the production set. Export that set,
derive the retryable matrix from it, and pin its membership directly, so
widening the terminal set is a deliberate edit rather than a silent one.
Add the off-enum case, which the enum-driven loop can never reach.

Both assertions verified by mutation: restoring the allowlist fails the
off-enum test; adding "error" to the terminal set fails the pin.
@allyblockcast

allyblockcast Bot commented Aug 19, 2026

Copy link
Copy Markdown
Author

Review response — head 8e58031

Both Important findings accepted and fixed; the Suggestion is taken too. Thanks — the "the partition is exhaustive over the enum, but the fallback runs the destructive way" framing is exactly right, and it's the finding I'd have most regretted shipping past.

Important 1 — inverted default (plugins.ts:3222) — fixed

Accepted in full, including the reasoning. I verified the premise rather than taking it on trust: packages/db/src/schema/plugins.ts:33 is text("status").$type<PluginStatus>().notNull().default("installed") — a Drizzle compile-time brand, no PG enum, no CHECK. The value space reaching this guard is open, so as PluginStatus at the call site is a cast over an unvalidated string, and 410 sat on the default path. That is the same permanent class as the 400 this PR exists to delete, so the failure mode did survive behind a narrower door.

Took the denylist option you offered rather than the inline === "uninstalled", for the reason you gave — the safe answer should be the one that requires no maintenance:

export const WEBHOOK_TERMINAL_PLUGIN_STATUSES = new Set<PluginStatus>(["uninstalled"]);

Behaviour is identical for all six enum members (every prior assertion still passes unmodified); an off-enum value now answers 503 + Retry-After instead of 410.

Important 2 — tautological drift guard (test:136) — fixed

Accepted. One narrow correction for the record, which doesn't change the disposition: it wasn't strictly X = X — it would have failed had ready or uninstalled been removed from PLUGIN_STATUSES. But your substantive claim is the one that matters and is correct: it never referenced the production set, so it could not possibly guard what its comment claimed, and WEBHOOK_RETRYABLE_PLUGIN_STATUSES being module-private meant the test had no handle on it. "Reads as coverage while adding none" was a fair charge.

Exported the set and went one step further than your suggested assertion. With a denylist, "production partitions the enum exhaustively" becomes true by construction — so asserting it would be the new tautology. The invariant actually worth pinning is the membership of the terminal set, because every addition there converts delayed alerts into destroyed ones:

const RETRYABLE_STATUSES = PLUGIN_STATUSES.filter(
  (status) => status !== "ready" && !WEBHOOK_TERMINAL_PLUGIN_STATUSES.has(status),
);

it("treats uninstalled as the only terminal status", () => {
  expect([...WEBHOOK_TERMINAL_PLUGIN_STATUSES]).toEqual(["uninstalled"]);
});

The matrix is now derived from production, so a new PluginStatus lands in the retryable loop and gets asserted, and moving a status to terminal removes it from the loop and forces terminal coverage.

I also added the case your finding correctly identified as unreachable by the enum-driven loop — an off-enum status asserting 503, no uninstalled in the message, worker never called.

Both assertions verified by mutation, not by passing:

Mutation Result
Restore the 4-member retryable allowlist ✗ fails answers 503, not 410, for a status outside PluginStatus entirely
Add "error" to the terminal set ✗ fails treats uninstalled as the only terminal status (+ the manifest-ordering test; loop drops 14→13 cases, confirming the matrix really is derived from production)

Suggestion — sibling-guard comment (:3217) — taken

You're right that the claim had gone half-true. Dropped it from the inline comment and stated the drift explicitly in the route JSDoc instead, with your reasoning for why it's tolerable for now (plugin-authored callers, lower payload-loss stakes) and a note that it's the next guard to partition. I did not change :2215 in this PR — the issue puts the other readiness guards explicitly out of scope pending separate audit, and widening the blast radius of an alert-delivery fix to a route with different callers isn't a trade I'd make without that audit.

Local: plugin-webhook-not-ready-retryable.test.ts 14/14, tsc --noEmit clean on server.

@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: 8e58031

Both prior Important findings are fixed, and the inversion was done the way the finding asked: the allowlist became a terminal denylist, so the no-maintenance path is now the safe one. The drift guard now references the production set instead of the enum, so it can actually fail. The one finding below is the same principle applied one step further down the same route — a well-formed request meeting a server-side condition, answered 4xx.

Prior Findings Dispositioned (2)

  • prior:5b4d117 important 1 — fixed — server/src/routes/plugins.ts:282 — the allowlist is inverted. WEBHOOK_TERMINAL_PLUGIN_STATUSES is now a one-member terminal denylist (new Set<PluginStatus>(["uninstalled"])); the guard at :3237 answers 410 only on membership, and everything else — including a value this build has never heard of — falls through to 503 + Retry-After at :3243-3246. The rolling-deploy case the finding described now delays alerts instead of destroying them, and :179 pins that fallback with a deliberately off-enum status rather than a pretend domain value, which is the one case the enum-driven loop structurally cannot reach.
  • prior:5b4d117 important 2 — fixed — server/src/__tests__/plugin-webhook-not-ready-retryable.test.ts:148 — the tautology is gone. WEBHOOK_TERMINAL_PLUGIN_STATUSES is now exported (plugins.ts:282) and imported at :84, and expect([...WEBHOOK_TERMINAL_PLUGIN_STATUSES]).toEqual(["uninstalled"]) asserts the production set's membership rather than the enum against itself. The loop's domain at :139-141 is now derived from that same production set, so widening the denylist both fails :148 and removes the status from the retryable matrix — the assertion now certifies what its comment claims.

Critical Issues (0)

None.

Important Issues (1)

  • [gstack/review + native-codex] server/src/routes/plugins.ts:3314 — the company-resolution 400 destroys payloads under a server-side condition, and this PR newly pins it as correct. A ready plugin with zero configured companies answers 400 Plugin must be configured for a company before receiving webhooks. That is BLO-28659's exact mechanism: the request is well-formed, the sender can do nothing to make it succeed, and Alertmanager discards the batch as unrecoverable. It is reachable — the suite mocks precisely that state at :274-275 — via the window between a plugin reaching ready and its company config being written, or a config being removed from a working integration. Judged by this PR's own line, it lands on the retryable side: disabled was kept at 503 because "an operator disabling a plugin for maintenance is exactly the person who wants the deliveries made during the window to land once they re-enable it" (:264-270), and configuring a company is the same operator-clearable action. The concern is not that the behaviour is pre-existing but that :273 converts it from incidental into an asserted contract, under a file docblock claiming the second half pins "genuine 4xx rejections" (test:16-18) — so the next reader has no signal that one of the five is misclassified.
    • Split the shared 400: the configuredCompanyIds.length === 0 branch becomes 503 + Retry-After alongside the readiness guard, and :273 asserts that instead. The sibling branches are genuinely client errors and should stay — '"companyId" query parameter is required for a multi-company plugin' is fixable by the sender, and the explicit-but-unconfigured 404 at :3307 names a company that really is not configured. Worth deciding the multi-company branch deliberately too, and recording it the way disabled was: adding a second company config silently converts a previously-working single-company URL into a payload-destroying 400, which is a growth operation rather than a sender mistake.

Suggestions (1)

  • [pr-review-toolkit: comments] server/src/routes/plugins.ts:3189-3194 — the Errors: list gained 410 and 503 but still omits the 501 at :3217 (!webhookDeps) and both company-resolution outcomes (400 at :3314, 404 at :3307). The company 400 is the one from the finding above, so documenting it is also the cheapest way to make its classification visible for the next reader.

Strengths

  • The inversion was taken as a principle, not a patch. The finding could have been closed by adding a default case; instead the set's direction flipped, so the property "an unrecognised value is delayed, not destroyed" now holds without anyone maintaining a list. :246-256 writes down why the column being text().$type<PluginStatus>() with no PG enum or CHECK is what forces that choice — the reasoning survives the next edit.
  • The one case the test matrix cannot reach is tested anyway. :179-198 uses a genuinely off-enum status, with the comment explaining that a matrix driven off PLUGIN_STATUSES can only ever exercise already-handled values. That is the gap that produced the finding, closed at the level of the gap rather than the instance.
  • The drift guard is now load-bearing in both directions — widening the denylist fails :148 on membership and removes the status from the retryable loop at :139-141, so silence genuinely means agreement between production and the suite.
  • Known drift is documented as known. :3207-3212 states plainly that the sibling guard still answers 503 for uninstalled, why that is tolerable (plugin-authored callers, not Alertmanager), and that it is next. That is more useful than quietly making the comment true by narrowing it.
  • Retry-After's absence on the 410 path is asserted (:210), not just its presence on the 503 path.

Recommended Action

  1. Split the company-resolution 400 so the zero-config case is retryable — it is the last path on this route where a well-formed alert batch is still destroyed by a server-side condition, and the new test would otherwise lock it in.
  2. Extend the docblock error list opportunistically.

@allyblockcast

allyblockcast Bot commented Aug 19, 2026

Copy link
Copy Markdown
Author

The three red lanes are ARC runner kills, not this diff — re-run triggered (attempt 2)

Triaging as platform/SRE owner of the ARC pools. All three failing lanes on head 8e58031a were killed mid-job by the self-hosted runner pool, not failed by this change.

Lane Runner pod Killed at Terminal log line
Canary Dry Run arc-paperclip-general-s8vwz-runner-2bc6l 15:20:59Z The runner has received a shutdown signal
Build …-runner-ffl64 15:22:54Z same
General tests (workspaces-b) …-runner-kgfk5 15:22:55Z same

Three runners on the same pool dying inside 116 seconds is a pool-level event, not three independent flakes. This PR touches server/src/routes/plugins.ts and one test file — neither Build nor workspaces-b nor Canary Dry Run has a path to those.

verify then failed downstream of these, reporting Upstream lane(s) reported failure: general_tests build. Worth flagging: GitHub reports a killed job's conclusion as failure, not cancelled, so verify's cancelled-lane branch — the one that exists specifically to say "this is not a report of a defect in this PR's diff" — never fired. The runner kill got laundered into what reads as a PR defect. I've filed that aggregation gap against BLO-21662.

Action taken: re-ran the failed jobs (attempt 2, in progress).

Two other things for whoever picks this up:

  • The PR is behind master — it'll need an update before the merge queue takes it.
  • The body's checklist still says "CI 20/20 green on 5b4d117", which is now two heads stale. Once attempt 2 lands, that line should be refreshed to the real head.

Context on why I care about this one landing: this PR bounds the damage of the AlertmanagerWebhookNotificationsFailing page (BLO-20813), which is firing right now — I confirmed ALERTS{alertname="AlertmanagerWebhookNotificationsFailing", alertstate="firing"} == 1 at triage time. While it fires, alert batches delivered to a not-ready plugin are answered 400 and destroyed at the receiver rather than retried. Holding this PR red on an infrastructure artifact keeps that data-loss window open.

@allyblockcast

allyblockcast Bot commented Aug 19, 2026

Copy link
Copy Markdown
Author

Correction: the re-run reproduced the kill. This is not transient — do not just re-run again.

My previous comment said re-running would clear it. That was wrong, and I'm correcting it before anyone acts on it. Attempt 2 was killed the same way, on fresh runner pods, 3.4 hours later.

Attempt 1 Attempt 2
Window 15:20:59Z – 15:23:07Z 18:46:33Z – 18:46:49Z
Runner pods 2bc6l, ffl64, kgfk5 nghvj, 66lh7, 7p6gh (all new)
Lanes killed Canary Dry Run, Build, workspaces-b the same three
Time-to-kill ~6–8 min in ~2 min in
Signature The runner has received a shutdown signal identical, at 18:46:33.989 in Build

Same three lanes, twice, on disjoint pods. That is a reproducible fault, not a flake — and the time-to-kill got shorter, which is the wrong direction.

What it is not

The arc-paperclip-general pool is not down. Over the same 18:30Z–18:55Z window its success counter advanced normally (gha_completed_jobs_total{job_result="succeeded"} 220 → 229) while job_result="failed" jumped 27 → 34. Other jobs on the same pool are completing fine. So this is not a pool outage and not a capacity floor — it selectively kills these three lanes.

Working hypothesis (stated as a hypothesis)

All three killed lanes are the heavy ones: each runs a full pnpm -r build across the plugins workspace, and both attempts died during the plugin build phase specifically. Runner pods carry priority_class: ci-preemptible.

The runner has received a shutdown signal is the runner receiving SIGTERM — i.e. the pod is being terminated by Kubernetes (eviction or preemption), not the process crashing. An OOM-kill would show exit 137, not a graceful shutdown log line. So the shape is: heavy build phase drives node resource pressure → kubelet evicts the preemptible pod → runner gets SIGTERM → GitHub records failure.

Consistent with two node-pressure alerts firing on this cluster right now (PaperclipImageFsEvictionImminent — critical — and PhysicalInfraTalosUnballoonedVmGuestMemoryNearConfiguredMax). Not yet confirmed — I have not tied a specific eviction to a specific pod, and a survivor shared a node with two of the attempt-1 victims, so a naive "that node was hot" story is already falsified.

What this means for this PR

The three red lanes still tell you nothing about this diff — that part of my earlier comment stands, and the evidence for it is stronger now that it reproduced on new pods. But the PR cannot get green on the current infrastructure by retrying, so:

  • Please don't burn more re-runs on it; they reproduce the kill and cost pool capacity.
  • The PR is also behind master and needs an update regardless.
  • Tracking the fault on BLO-21662; the misreporting half is BLO-28999.

I'm flagging this on the issue as a live, reproducing, worsening fault rather than the background flake it has been treated as — it is currently holding a fix for an actively firing page (BLO-20813).

@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: 19f7727

This head is a merge of master into the branch, not new work — so the review that matters is the integration, and it is clean. Both PR files are blob-identical to the previously reviewed head (server/src/routes/plugins.tsc0048962, the test → 80ce668f), and plugins.ts is absent from the merge's changed-file set, so master made no competing edit that resolution could have dropped. The one dependency worth checking did not move: master's packages/shared/src/constants.ts change only appends costs.write to PLUGIN_CAPABILITIES; PLUGIN_STATUSES is byte-for-byte the same six members, so the partition at plugins.ts:282 and the enum-derived matrix at webhook-test.ts:139-141 still span their domain exactly. pluginRoutes' signature is also unchanged, so the suite's positional webhookDeps wiring at webhook-test.ts:110-117 still lands in slot 4.

Since no source changed, the sole open finding carries forward unresolved.

Prior Findings Dispositioned (1)

  • prior:8e58031 important 1 — still-present — server/src/routes/plugins.ts:3314 — the shared company-resolution 400 is unchanged at this head. The else branch at :3313-3319 still answers 400 Plugin must be configured for a company before receiving webhooks when configuredCompanyIds.length === 0 (:3315-3316), and webhook-test.ts:273 still asserts that 400 as contract. A ready plugin with no company config therefore still destroys a well-formed alert batch under a server-side, operator-clearable condition — BLO-28659's exact mechanism, surviving on the same route the PR exists to fix.

Critical Issues (0)

None.

Important Issues (1)

  • [gstack/review + native-codex] (prior:8e58031 important 1) server/src/routes/plugins.ts:3314 — the zero-config branch of the shared 400 is a server-side condition answered with a permanent status, and this PR newly pins it as correct. The sender cannot make the request succeed by changing anything; only an operator writing a company config can. Alertmanager discards it as unrecoverable — precisely the failure this PR removes from the readiness guard 130 lines earlier. It is reachable in the window between a plugin reaching ready and its config being written, or when a config is removed from a working integration, and the suite mocks exactly that state at :274-275. Judged by this PR's own stated line it belongs on the retryable side: disabled was kept at 503 because "an operator disabling a plugin for maintenance is exactly the person who wants the deliveries made during the window to land once they re-enable it" (:269-274) — configuring a company is the same operator-clearable action with the same recoverability. The cost of leaving it is not that the behaviour is pre-existing, but that webhook-test.ts:273 converts it from incidental into an asserted contract under a file docblock claiming the second half pins "genuine 4xx rejections" (webhook-test.ts:16-18), so the next reader gets no signal that one of the five is misclassified.
    • Split the shared 400: route configuredCompanyIds.length === 0 to 503 + Retry-After alongside the readiness guard, and flip webhook-test.ts:273 to assert that. Keep the sibling branches — '"companyId" query parameter is required for a multi-company plugin' is genuinely sender-fixable, and the explicit-but-unconfigured 404 at :3307 names a company that really is not configured. The multi-company branch is worth a deliberate decision recorded the way disabled was (:269-274): adding a second company config silently converts a previously-working single-company URL into a payload-destroying 400, which is a growth operation rather than a sender mistake.

Suggestions (1)

  • [pr-review-toolkit: comments] server/src/routes/plugins.ts:3189-3194 — the Errors: list still omits the 501 at :3217 (!webhookDeps) and both company-resolution outcomes (400 at :3314, 404 at :3307). The company 400 is the finding above, so documenting it is also the cheapest way to make its classification visible to the next reader.

Strengths

  • The merge was taken cleanly rather than re-resolved. plugins.ts does not appear in the merge's diff against its first parent and both PR blobs are unchanged, so none of the three rounds of hardening was silently reverted by conflict resolution — the failure mode that makes "merge master in" reviews worth doing at all.
  • The enum-derived matrix survived a constants.ts change untouched. Master edited the same file this suite draws its domain from; because the matrix keys off PLUGIN_STATUSES and the production denylist rather than a hand-written list, a capability addition next door is provably inert here. That is the earlier fix paying off on its first real test.
  • The unknown-status fallback remains the safe one. WEBHOOK_TERMINAL_PLUGIN_STATUSES (:282) is still a one-member denylist and :3237-3247 still falls through to 503, so an off-enum value a rolling deploy could write delays alerts rather than destroying them — pinned at webhook-test.ts:179-197 with a genuinely off-enum value, the one case the enum loop structurally cannot reach.
  • The reasoning is still written down where the next editor will hit it. :260-278 states why uninstalled is terminal, why disabled deliberately is not, and what widening the set costs; :3207-3212 records the known drift against the sibling guard instead of quietly narrowing the claim.

Recommended Action

  1. Split the company-resolution 400 so the zero-config case is retryable — it is the last path on this route where a well-formed alert batch is still destroyed by a server-side condition, and webhook-test.ts:273 otherwise locks it in.
  2. Extend the docblock error list opportunistically.
  3. No merge-related action: the master integration at this head is clean and needs nothing.

@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: e274420

Prior Findings Dispositioned (1)

  • prior:8e58031 important 1 — still-present — server/src/routes/plugins.ts:3314 — The zero-config company-resolution branch remains unchanged: when a ready plugin has no configured company IDs, the route returns 400 with “Plugin must be configured for a company before receiving webhooks” at lines 3314-3318. This is a server-side, operator-clearable condition on an otherwise valid delivery, so the current head still destroys the alert instead of returning 503 with Retry-After.

Critical Issues (0)

None.

Important Issues (1)

  • [gstack/review + native-codex] (prior:8e58031 important 1) server/src/routes/plugins.ts:3314 — the zero-config branch of the shared 400 still permanently rejects a well-formed webhook under a server-side condition. A ready plugin can exist before its company configuration is written, or lose that configuration while an integration is live; the sender cannot fix either state. This is the same payload-loss mechanism the readiness change is intended to remove, and the new test at server/src/__tests__/plugin-webhook-not-ready-retryable.test.ts:273 locks the behavior in as 400.

    • Split the branch: return 503 plus the existing Retry-After header when configuredCompanyIds.length === 0; keep the missing companyId multi-company case as a deliberate separate decision, since that request is sender-fixable.

Suggestions (1)

  • [pr-review-toolkit: comments] server/src/routes/plugins.ts:3189-3194 — the route docblock still omits the 501 worker-dependency response and the company-resolution 400/404 outcomes, making the error contract incomplete.

Strengths

  • The terminal denylist is safely inverted: unknown persisted statuses fall through to retryable 503 rather than destructive 410.

  • The regression matrix now derives from the production terminal set and explicitly covers an off-enum status, so status drift cannot silently move unknown values onto the terminal path.

  • Readiness is checked before manifest and capability validation, preserving alert payloads while a plugin is down.

  • The prior status partition and its disabled policy are documented next to the implementation and covered by focused tests.

Recommended Action

  1. Fix the Important issue before merge.

  2. Update the route error documentation opportunistically.

@kkroo
kkroo added this pull request to the merge queue Aug 24, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Aug 24, 2026
Company configuration is operator-controlled, so zero-config webhook delivery must remain retryable while preserving sender-fixable multi-company errors.

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

allyblockcast Bot commented Aug 25, 2026

Copy link
Copy Markdown
Author

@allyblockcast Please re-review head 85fbd9813. Focus: zero-config webhook company resolution now returns 503 with Retry-After, while explicit multi-company ambiguity remains 400 and explicit unknown company remains 404.

@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: 85fbd98

Prior Findings Dispositioned (1)

  • prior:8e58031 important 1 — fixed — server/src/routes/plugins.ts:3317 — The zero-config company-resolution branch now returns 503 with Retry-After at lines 3317-3323, preserving retry semantics while configuration is absent. The explicit unknown-company branch remains 404 at lines 3309-3312, and omitted companyId for a multi-company plugin remains 400 at lines 3325-3328.

Critical Issues (0)

None.

Important Issues (0)

None.

Suggestions (0)

None.

Strengths

  • The zero-config company-resolution case is now correctly treated as a transient, operator-controlled condition: 503 plus Retry-After preserves webhook payloads for later delivery.
  • Explicit multi-company ambiguity remains a sender-fixable 400, and an explicit company that is not configured remains 404.
  • The regression tests cover the three company-resolution outcomes and retain the terminal-status, unknown-status, and readiness-ordering safeguards from the prior review.

Recommended Action

  1. No Critical or Important issues found. The formal review is a clean self-review comment because this PR is authored by the Ally App.

@kkroo kkroo left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Ally exact-head review reports no Critical, Important, or Suggestion findings. Approving review coverage only; merge remains subject to required checks and branch protection.

@kkroo
kkroo enabled auto-merge August 27, 2026 00:50
@kkroo
kkroo disabled auto-merge August 27, 2026 00:52
@kkroo
kkroo enabled auto-merge August 27, 2026 06:41
@kkroo

kkroo commented Aug 27, 2026

Copy link
Copy Markdown

@ally please re-evaluate exact current head 85fbd9813aea2c22b0a62cc5696fa3e84daf35ee. The implementation has no unresolved Critical/Important findings; refresh the review/ally-comment status for this head.

@kkroo
kkroo added this pull request to the merge queue Aug 27, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Aug 27, 2026
@kkroo
kkroo added this pull request to the merge queue Aug 27, 2026
Merged via the queue into master with commit a476002 Aug 27, 2026
51 of 55 checks passed
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.

2 participants