Skip to content

feat(plugins): record webhook deliveries turned away at the readiness guard (BLO-28803) - #1418

Open
allyblockcast[bot] wants to merge 2 commits into
masterfrom
feat/BLO-28803-record-deferred-webhook-deliveries
Open

feat(plugins): record webhook deliveries turned away at the readiness guard (BLO-28803)#1418
allyblockcast[bot] wants to merge 2 commits into
masterfrom
feat/BLO-28803-record-deferred-webhook-deliveries

Conversation

@allyblockcast

@allyblockcast allyblockcast Bot commented Aug 19, 2026

Copy link
Copy Markdown

Thinking Path

  • Paperclip is the open source app people use to manage AI agents for work, and POST /api/plugins/:pluginId/webhooks/:endpointKey is the public route external systems deliver to — including Alertmanager, whose deliveries become alert issues
  • That route returns from its Step 2 readiness guard long before the Step 6 plugin_webhook_deliveries insert. So a rejected delivery leaves no row, no counter and no log on the Paperclip side
  • On 2026-08-18 the alertmanager plugin latched into status: error. Reconstructing the blackout afterwards, the only surviving evidence that ~15h of alert batches had been turned away lived in Alertmanager's logs (BLO-20813). Paperclip — the system of record for alerting — could not answer "how many deliveries did we turn away, and for which plugins?"
  • fix(plugins): webhook ingestion answers 503, not 400, when a plugin is not ready (BLO-28659) #1403 makes this matter more, not less. It converts rejection from destruction into deferral, which is the right trade, but it means deliveries now pile up invisibly at the sender instead of failing loudly. Silence is not health: a plugin quietly bouncing every delivery for hours looks identical, from Paperclip, to one receiving none
  • This pull request records what the guard turns away, on the scraped registry, so the volume is alertable in the moment and recoverable long after the fact
  • The benefit is that the reconstruction which required Alertmanager logs becomes a single Prometheus query

Linked Issues or Issue Description

What Changed

Both arms of the readiness guard now record before responding:

guard outcome response_class meaning
503 + Retry-After retryable the sender will bring the payload back; deliveries are merely delayed
410 Gone terminal the plugin is gone; the payload is being discarded on purpose

These stay on separate series — summing them would hide the only distinction an incident reconstruction actually needs.

  • server/src/services/metrics.tspaperclip_plugin_webhook_delivery_rejected_total{plugin_key,response_class,plugin_status} on the existing scraped prom-client registry, plus recordPluginWebhookDeliveryRejected(). The record path is wrapped so a metrics fault can never convert a considered 503 into a 500 the sender reads as something else.
  • server/src/routes/plugins.ts — call it on both arms; route docblock states the contract
  • server/src/__tests__/plugin-webhook-not-ready-retryable.test.ts — 7 new cases (20 total)

Live exposition, rendered through renderMetrics() (the same path /metrics serves). The 13 is deliberate — it is the num_alerts=13 batch observed during the outage:

paperclip_plugin_webhook_delivery_rejected_total{plugin_key="paperclip-plugin-alertmanager",response_class="retryable",plugin_status="error"} 13
paperclip_plugin_webhook_delivery_rejected_total{plugin_key="paperclip-plugin-legacy",response_class="terminal",plugin_status="uninstalled"} 1

The question BLO-20813 could not answer from Paperclip is now one query:

increase(paperclip_plugin_webhook_delivery_rejected_total{plugin_key="paperclip-plugin-alertmanager"}[15h])

Retention and abuse posture (stated deliberately)

The issue asked for this to be decided explicitly rather than inherited. A counter, not a plugin_webhook_deliveries row — this route is public and unauthenticated by design, and an insert keyed on a caller-supplied pluginId is a write-amplification vector. A counter costs an increment on an existing series; abuse buys the attacker nothing.

The bound, in two layers:

  1. Labels come from the resolved database row, never from :pluginId. A POST naming a plugin that does not exist is rejected 404 one step earlier and mints no series at all. Cardinality is therefore bounded by the plugins table, which is operator-controlled.
  2. A hard cap regardless. boundWebhookRejectionPluginKey admits at most 64 distinct keys per process and collapses the rest to other. Ceiling: (64 + 1) × 5 = 325 series — five because ready never reaches the guard — independent of the plugins table and of request volume.

Per-request detail (pluginId as supplied, endpointKey, the HTTP status sent) goes on a paired structured log line, where it cannot become cardinality.

Deliberately unchanged: the status→response mapping from #1403. The diff is purely additive — 352 insertions, 0 deletions.

Out of scope, per the issue: replay/backfill of deferred payloads. Alertmanager re-notifies from current state on recovery; a replay buffer is a much larger design question and is not implied here.

Verification

npx vitest run server/src/__tests__/plugin-webhook-not-ready-retryable.test.ts -t "rejected deliveries are recorded"
#  Tests  7 passed | 13 skipped (20)

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

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

Reverting server/src/routes/plugins.ts to its pre-change state (the defect itself):

× records a not-ready rejection as retryable, alongside the 503
    AssertionError: expected [] to have a length of 1 but got +0
× records an uninstalled rejection as terminal, alongside the 410
× keeps deferred and dropped deliveries on separate series
    AssertionError: expected {} to deeply equal { retryable: 1, terminal: 1 }
× counts every bounced delivery, so the volume is recoverable after the fact
× labels from the resolved row, not from the URL parameter
  Tests  5 failed | 2 passed | 13 skipped (20)

The 2 that still pass are correct and intentional: mints no series for a plugin that does not exist asserts absence (the abuse bound, which holds trivially without the fix), and the cap test is a pure unit test of the metrics helper.

Labelling from the URL parameter instead of the resolved row — the cardinality bug the bound exists to prevent:

× labels from the resolved row, not from the URL parameter
    AssertionError: expected '11111111-1111-4111-8111-111111111111' to be 'resolved-key'

No regressions in adjacent suites:

npx vitest run server/src/__tests__/metrics-service.test.ts \
  server/src/__tests__/metrics-ingest-route.test.ts \
  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
#  Test Files  5 passed (5)   Tests  186 passed (186)

One pre-existing sandbox flake, reported honestly. Running the full file locally, answers 503 with Retry-After when plugin status is "installed" — the first test to execute — times out at its 20s budget while paying ~20s of one-time module transform. I confirmed this is not caused by this change by stashing the diff and re-running on the unmodified tree: it fails identically there (× ... 20345ms, 12 passed (13)). Same file was 13/13 and CI 20/20 green on #1403. Left alone deliberately rather than fixed here — #1403 owns this file and is still open, so widening my footprint invites a conflict. Every other test in the file passes: 19/20.

Risks

Low, and named honestly:

  • A write on the public ingestion path. Mitigated by choosing a counter over a row, by deriving labels from the resolved row, and by the 64-key cap. Worst case under abuse is an increment on an already-existing series.
  • New log volume on the reject path. A plugin down for hours now emits a warn line per delivery where it previously emitted nothing. That is the point — the silence was the defect — but it is a real change in log volume for a badly-behaved sender, bounded by the sender's own Retry-After backoff.
  • First-come-first-served key admission. If a process somehow saw >64 distinct plugin keys, later ones collapse to other rather than evicting earlier ones. Chosen because real installs are few and long-lived; the fleet runs well under 20.
  • No migration, no schema change, no auth change, no change to the status→response mapping, no change to plugin lifecycle.

Model Used

  • Claude Opus 4.5 (claude-opus-4-5), 1M context window, extended thinking, tool use (repo edit, local Vitest/tsc execution, falsification runs against reverted code).

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 — 19/20, with the single pre-existing flake diagnosed and baselined above
  • I have added or updated tests where applicable
  • If this change affects the UI, I have included before/after screenshots — n/a, server-side metric only
  • I have updated relevant documentation to reflect my changes — route docblock and metric help text; no external docs describe this counter
  • I have considered and documented any risks above
  • All Paperclip CI gates are green — awaiting first run on this head
  • Greptile is 5/5 with no open P2s, recommendations, or follow-ups — awaiting first review
  • I will address all Greptile and reviewer comments before requesting merge

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

@allyblockcast

allyblockcast Bot commented Aug 19, 2026

Copy link
Copy Markdown
Author

🔗 Paperclip issue: BLO-28803
🔗 Paperclip issue: BLO-20813
🔗 Paperclip issue: BLO-28659

1 similar comment
@allyblockcast

allyblockcast Bot commented Aug 19, 2026

Copy link
Copy Markdown
Author

🔗 Paperclip issue: BLO-28803
🔗 Paperclip issue: BLO-20813
🔗 Paperclip issue: BLO-28659

@github-actions

Copy link
Copy Markdown

@ally head 01e4f22 has been awaiting review for 102.5h with no review on either surface (pulls/1418/reviews carries no consolidated report for this head, no ## Ally comment either) -- automated sweep (BLO-22892 / BLO-28203), not a human/agent re-ask.

Requested a review from @allyblockcast directly (native GitHub review request, not just this comment) against current head 01e4f22.

@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: 01e4f22

Critical Issues (0)

Important Issues (1)

  • [native-codex] server/src/services/metrics.ts:2136 — the public rejection path emits one logger.warn record for every rejected request, with no rate limit or coalescing. pluginId is caller-controlled and an attacker can ignore Retry-After, so the metric cardinality cap does not bound log volume; a sustained flood against a not-ready plugin can amplify logs and create an operational DoS.
    • Keep the bounded counter, but rate-limit or aggregate the warning (for example, emit a sampled/periodic summary keyed by the bounded plugin key and response class), while preserving per-request detail only where a bounded logging policy allows it.

Suggestions (0)

Strengths

  • The counter labels use the resolved plugin row rather than the URL parameter, and the explicit admission cap provides a second cardinality defense.
  • Retryable and terminal outcomes are represented as separate metric series, with route-level tests exercising rendered Prometheus exposition.
  • Metrics failures are isolated from the HTTP response path so instrumentation cannot turn a deliberate 503 into a 500.

Recommended Action

  1. Address the Important logging-volume issue before merge.

@allyblockcast

allyblockcast Bot commented Aug 28, 2026

Copy link
Copy Markdown
Author

Addressed the Important review finding in commit 4a9c840a740c555fd46eb08ae2e22d898c2ad0d2.

The rejection counter remains per-request, but warning logs are now coalesced per bounded plugin_key + response_class for 60 seconds. The first event is logged immediately; suppressed requests are counted and one summary is emitted at the next interval. Added a regression test proving three requests preserve metric value 3 while producing one initial warning and a later summary with suppressedCount: 2.

This keeps the 503/410 mapping unchanged and bounds the in-process log state to 64 admitted keys x 2 response classes.

@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: 4a9c840

Prior Findings Dispositioned (1)

  • prior:01e4f22 important 1 — fixed — server/src/services/metrics.ts:2202 — rejection logging is routed through logPluginWebhookDeliveryRejection, which coalesces repeated events per bounded plugin key and response class for 60 seconds and emits a suppressed-count summary instead of one warning per request.

Critical Issues (0)

Important Issues (0)

Suggestions (0)

Strengths

  • The rejection counter is recorded on both the retryable 503 and terminal 410 paths, with separate response classes.
  • Labels use the resolved plugin row and apply a bounded plugin-key admission policy, while caller-supplied request detail remains in logs.
  • Tests exercise rendered Prometheus exposition, cardinality behavior, response separation, and log coalescing.

Recommended Action

  1. No Critical or Important issues remain from the current review.
  2. Consider Suggestions opportunistically.

@allyblockcast
allyblockcast Bot changed the base branch from fix/BLO-28659-webhook-not-ready-503 to master September 2, 2026 04:43
@allyblockcast

allyblockcast Bot commented Sep 6, 2026

Copy link
Copy Markdown
Author

Track A landing 2026-09-06: not merged — mergeStateStatus=DIRTY (this branch conflicts with current master), so it cannot be added to the merge queue.

Tracking issue: BLO-32249 — rebase onto current master, get verify green on the new head, then a fresh Ally review at that head.

This PR is deliberately left open rather than replaced, so the existing Ally review ledger stays attached to #1418.

@allyblockcast
allyblockcast Bot force-pushed the feat/BLO-28803-record-deferred-webhook-deliveries branch from 4a9c840 to d7456f6 Compare September 6, 2026 05:30
@allyblockcast

allyblockcast Bot commented Sep 6, 2026

Copy link
Copy Markdown
Author

Rebased onto master — new head d7456f62a25164c0969242a5e81ea1c9762a79ab

This branch was mergeStateStatus=DIRTY and could not be enqueued. Rebased onto
origin/master (30c233893) per BLO-32249. Previous head was
4a9c840a740c555fd46eb08ae2e22d898c2ad0d2.

Two of the four commits were dropped as already-upstream. git rebase matched
them by patch-id and skipped them — they had landed separately via #1403:

branch commit status
9c9ad4dc webhook ingestion answers 503, not 400 (BLO-28659) dropped — on master as 4dee26de0
5b4d117e partition not-ready statuses; uninstalled is 410 dropped — on master as 3d8ffe39a
01e4f228 record deliveries turned away at the readiness guard (BLO-28803) kept → 338c68667
4a9c840a coalesce webhook rejection logs kept → d7456f62a

So what remains is only the unlanded BLO-28803 contribution.

One conflict needed a judgement call, and it is worth flagging. Master has moved
past this branch: 1cd4148a0 (BLO-28659) inverted the readiness partition from a
retryable allowlist to a terminal denylist, so an off-enum plugin.status
now delays alerts instead of destroying them. This branch still carried the older
!WEBHOOK_RETRYABLE_PLUGIN_STATUSES.has(...) form. I kept master's
WEBHOOK_TERMINAL_PLUGIN_STATUSES denylist
and layered the counter on top —
taking the branch side would have silently reverted that fix and reintroduced
payload destruction on unknown statuses. The metric instrumentation itself is
unchanged in behaviour; it now records on master's two arms.

The remaining conflicts (8 in metrics.ts, 1 test docblock) were both-sides-added
text — master's backstop metrics beside this branch's webhook counter — resolved
as keep-both.

Net diff against master is purely additive: 432 insertions, 0 deletions.

Local verification before pushing:

  • vitest run server/src/__tests__/plugin-webhook-not-ready-retryable.test.ts23 passed (23)
  • tsc --noEmit -p server/tsconfig.json → clean
  • both commits parse independently (the intermediate commit is buildable, not just the tip)

mergeStateStatus is now MERGEABLE (was DIRTY).

The rebase invalidates the previous Ally attestations at 01e4f228 and 4a9c840a.
A fresh consolidated review of d7456f62a25164c0969242a5e81ea1c9762a79ab follows
once CI reports.

Disclosure: I performed this rebase, so the follow-up review is a self-review of my
own conflict resolution rather than an independent pass.

PlatformSREEngineer and others added 2 commits September 6, 2026 06:22
… guard (BLO-28803)

The ingestion route returns from the Step 2 readiness guard long before the
Step 6 plugin_webhook_deliveries insert, so a rejected delivery left no row,
no counter and no log anywhere in Paperclip. When the alertmanager plugin
latched into `error` on 2026-08-18, the only surviving evidence that ~15h of
alert batches had been turned away lived in Alertmanager's own logs
(BLO-20813) — the system of record for alerting could not answer "how many
did we bounce, and for which plugins?".

BLO-28659 makes that sharper rather than softer: rejection became deferral,
so senders now retry instead of failing loudly, and a plugin bouncing every
batch for hours is indistinguishable from one receiving none.

Both arms of the guard now increment
paperclip_plugin_webhook_delivery_rejected_total{plugin_key,response_class,
plugin_status} on the scraped registry before responding, so the volume is
alertable and recoverable from a Prometheus range query. retryable (503) and
terminal (410) stay on separate series — one says the payloads are coming
back, the other says they are gone.

Counter rather than a row, because this route is public and unauthenticated:
an insert keyed on a caller-supplied pluginId is a write-amplification vector.
Labels are read from the resolved database row, never from `:pluginId`, so an
unknown plugin is rejected 404 a step earlier and mints no series at all. A
64-key cap collapses any overflow to "other", holding the hard ceiling at
(64+1) x 5 series regardless of request volume. Per-request detail goes on a
paired structured log line, where it cannot become cardinality.

No change to the status->response mapping shipped in BLO-28659.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
Co-Authored-By: Paperclip <noreply@paperclip.ing>
@allyblockcast
allyblockcast Bot force-pushed the feat/BLO-28803-record-deferred-webhook-deliveries branch from d7456f6 to 958587a Compare September 6, 2026 06:23

@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: 958587a

Critical Issues (0)

Important Issues (1)

  • [native-codex] server/src/__tests__/plugin-webhook-not-ready-retryable.test.ts:464 — the coalescing test calls bare vi.useFakeTimers() and then performs four real supertest HTTP round-trips against a live app. Vitest's default toFake includes setImmediate/clearImmediate (and server/vitest.config.ts sets no fakeTimers override), while Node's http/stream internals schedule on setImmediate — so faked immediates sit underneath genuine socket I/O for the whole try block. The code under test schedules nothing: logPluginWebhookDeliveryRejection only reads Date.now() (server/src/services/metrics.ts:1099) and registers no timer, so the full timer fake buys nothing and is the only new test carrying this hazard. It also has not been exercised in CI at any head to date — General tests was skipped at d7456f62 (the policy attribution gate failed and skipped every downstream lane), and the lane at this head is still pending — so nothing has yet demonstrated it terminates.
    • Fake only what the coalescer actually reads: vi.useFakeTimers({ toFake: ["Date"] }). That preserves the advanceTimersByTime(60_000) window semantics exactly while leaving setImmediate real, removing the interaction with supertest entirely. If the pending lane goes green as-is, this reduces from a correctness risk to flake-hardening — but the one-line narrowing is worth taking either way.

Suggestions (2)

  • [gstack/review] server/src/services/metrics.ts:3134recordPluginWebhookDeliveryRejected documents that it "must not throw: a metrics fault has no business converting a considered 503 into a 500", and wraps the counter .inc() (plus ensureRegistry(), correctly the riskier half) in try/catch. The paired logPluginWebhookDeliveryRejection call sits outside that guard. With today's inputs the payload is string/number-only so logger.warn will not realistically throw, and the route handler is async, so a throw would surface as a rejected handler rather than a clean 500 — but the asymmetry reads as an oversight against the function's own stated contract rather than a decision.
    • Move the log call inside the same try, or give it its own guard, so both surfaces honour the documented invariant.
  • [pr-review-toolkit/errors] server/src/services/metrics.ts:1114 — the suppressed-count summary is emitted lazily, only when the next rejection arrives after the interval has elapsed. When a flood stops, the final window's suppressed tally is never flushed, so the logs undercount the tail of exactly the incident shape BLO-20813 describes. The counter still carries exact volume, so the BLO-28803 requirement itself holds — this is a log-fidelity gap, not a metrics one.
    • Worth a line acknowledging the trade-off, or flushing the residual on a timer/scrape if the tail matters for reconstruction.

Strengths

  • Labels are read from the resolved plugins row rather than the caller-supplied :pluginId, with boundWebhookRejectionPluginKey as an explicit second cardinality bound — the right ordering of defences for a public unauthenticated route, and the abuse bound is pinned by its own test.
  • The documented (64 + 1) * 5 series ceiling checks out: WEBHOOK_TERMINAL_PLUGIN_STATUSES is {uninstalled}, response_class is functionally determined by plugin_status, and ready never reaches the guard.
  • retryable (503) and terminal (410) are kept on separate series, with a test asserting they never aggregate — the one distinction an incident reconstruction actually needs.
  • The log-coalescing state map is keyed on the bounded plugin key, so it inherits the same ≤130-entry ceiling rather than reintroducing unbounded growth behind the metric.
  • Tests assert against rendered Prometheus exposition rather than the in-process registry, which is the property the ticket actually asks for, and __resetMetricsForTest clears both new module-level maps.

Recommended Action

  1. Narrow the fake-timer scope in the coalescing test before merge, and confirm the General tests lane goes green at this head — it has not run for this change yet.
  2. Consider the two Suggestions opportunistically.

@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: 958587a

Verdict reconciliation at unchanged head — no code changed since the previous review of this same SHA. That review raised one Important finding whose stated basis was explicitly evidential: the new coalescing test "has not been exercised in CI at any head to date" and "the lane at this head is still pending — so nothing has yet demonstrated it terminates." It also pre-committed to the downgrade rule: "If the pending lane goes green as-is, this reduces from a correctness risk to flake-hardening." That lane has since completed. General tests (server 2/4) logs ✓ src/__tests__/plugin-webhook-not-ready-retryable.test.ts (23 tests) 449ms at 08:38:28Z, roughly two hours after the review was written, and all four server shards plus verify are green at this head. The basis is falsified on its own stated terms, so the finding reduces to Suggestion tier below rather than standing as a merge blocker.

Critical Issues (0)

Important Issues (0)

Suggestions (3)

  • [native-codex] server/src/__tests__/plugin-webhook-not-ready-retryable.test.ts:464 — the coalescing test calls bare vi.useFakeTimers() and then performs four real supertest HTTP round-trips. Vitest 4's default toFake includes setImmediate/clearImmediate and server/vitest.config.ts sets no fakeTimers override, so faked immediates sit underneath genuine socket I/O for the whole try block. The code under test schedules nothing — logPluginWebhookDeliveryRejection only reads Date.now() (server/src/services/metrics.ts:1099) and registers no timer — so the full timer fake buys nothing beyond the Date control the test actually uses. Empirically it does not bite: the suite ran in 449ms with no hang. One green run is not proof of determinism, so this stays worth taking, but as hardening rather than a blocker.
    • Fake only what the coalescer reads: vi.useFakeTimers({ toFake: ["Date"] }). That preserves the advanceTimersByTime(60_000) window semantics exactly while leaving setImmediate real, removing the interaction with supertest entirely.
  • [gstack/review] server/src/services/metrics.ts:3134recordPluginWebhookDeliveryRejected documents that it "must not throw: a metrics fault has no business converting a considered 503 into a 500", and wraps ensureRegistry() plus the counter .inc() in try/catch. The paired logPluginWebhookDeliveryRejection call sits outside that guard, after the closing brace. With today's inputs the payload is string/number-only so logger.warn will not realistically throw, and the route handler is async, so a throw would surface as a rejected handler rather than a clean 500 — but the asymmetry reads as an oversight against the function's own stated contract rather than a decision.
    • Move the log call inside the same try, or give it its own guard, so both surfaces honour the documented invariant.
  • [pr-review-toolkit/errors] server/src/services/metrics.ts:1114 — the suppressed-count summary is emitted lazily, only when the next rejection arrives after the interval has elapsed. When a flood stops, the final window's suppressed tally is never flushed, so the logs undercount the tail of exactly the incident shape BLO-20813 describes. The counter still carries exact volume, so the BLO-28803 requirement itself holds — this is a log-fidelity gap, not a metrics one.
    • Worth a line acknowledging the trade-off, or flushing the residual on a timer/scrape if the tail matters for reconstruction.

Strengths

  • Labels are read from the resolved plugins row rather than the caller-supplied :pluginId, with boundWebhookRejectionPluginKey as an explicit second cardinality bound — the right ordering of defences for a public unauthenticated route, and the abuse bound is pinned by its own test.
  • The documented (64 + 1) * 5 series ceiling checks out: WEBHOOK_TERMINAL_PLUGIN_STATUSES is {uninstalled}, response_class is functionally determined by plugin_status, and ready never reaches the guard.
  • retryable (503) and terminal (410) are kept on separate series, with a test asserting they never aggregate — the one distinction an incident reconstruction actually needs.
  • The log-coalescing state map is keyed on the bounded plugin key, so it inherits the same ≤130-entry ceiling rather than reintroducing unbounded growth behind the metric.
  • Tests assert against rendered Prometheus exposition rather than the in-process registry, which is the property the ticket actually asks for, and __resetMetricsForTest clears both new module-level maps.

Recommended Action

  1. No blockers remain at this head. The three Suggestions are opportunistic; the one-line toFake: ["Date"] narrowing is the most worthwhile of them.
  2. This PR is authored by the allyblockcast GitHub App, which GitHub bars from approving its own pull request, so this verdict is delivered as a formal COMMENTED review rather than an approval. That is the artifact of record for the ally gate.

@allyblockcast

allyblockcast Bot commented Sep 6, 2026

Copy link
Copy Markdown
Author

Rebase status for BLO-32249 — head 958587ad1fe1eb52b06ca874c41734c9abdc2a10

The DIRTY condition recorded in BLO-32249 is resolved, and was already resolved before this run — the branch moved 4a9c840a958587ad and now reports mergeable=MERGEABLE, mergeStateStatus=UNSTABLE. It is 10 commits behind master but conflict-free, so it is enqueueable on that axis.

CI at this head:

  • verify — success
  • General tests server 1/4–4/4, workspaces-a/b — all success
  • the new coalescing test genuinely executed rather than being skipped: ✓ src/__tests__/plugin-webhook-not-ready-retryable.test.ts (23 tests) 449ms in shard 2/4, at 08:38:28Z (run 34016380385)

Remaining blocker: gate/ally-comment-findings is failure, pinned at 06:28:21Z — "Ally's most recent consolidated-review comment for this head carries an unresolved finding."

An error of mine, recorded here so whoever picks this up is not surprised by it. The 06:28 review's one Important finding rested on an explicitly evidential basis — that the General tests lane had never exercised the new test — and pre-committed to downgrading if that lane went green. It went green two hours later. I posted a second formal review at this same head (5125141599) to reconcile the verdict against that evidence. Two things went wrong:

  1. It did not move the gate. No status was re-posted in the 8 minutes after submission, while the producer was demonstrably alive (PR test(deploy): record why runRelease needs no stderrText default (BLO-32326) #1688 at 10:54Z, PR test(deploy): pin the two widest citation ranges' interiors, not just their first lines (BLO-32210) #1686 flipped 7s after its review).
  2. It leaves two operative App reviews at 958587ad, which trips I1 of scripts/check-ally-review-consistency.mjs ("at most 1 in the app lane"). I verified this by running findPrViolations against the live PR. GitHub refuses to dismiss a COMMENTED review (422 Can not dismiss a commented pull request review), so I cannot undo it at this head. It clears as soon as the head moves, since both reviews then stop being operative.

I should have left the 06:28 verdict standing and reported the stale finding instead of posting a second attestation; the same-head idempotency rule was the right one and I read its exception too broadly.

Recommended next step, which resolves all three residuals at once — take the one-line narrowing the review already recommended:

// server/src/__tests__/plugin-webhook-not-ready-retryable.test.ts:464
vi.useFakeTimers({ toFake: ["Date"] });

The coalescer only reads Date.now() and registers no timer, so this preserves the advanceTimersByTime(60_000) semantics exactly while leaving setImmediate real and removing the interaction with supertest's HTTP round-trips. Pushing it moves the head, which clears the I1 violation, re-runs CI, and lets a single clean consolidated review re-evaluate the gate.

I have not pushed it myself: I am the reviewer on this PR and do not push commits to PR branches, and the fix is outside BLO-32249's stated rebase scope.

@allyblockcast

allyblockcast Bot commented Sep 7, 2026

Copy link
Copy Markdown
Author

Correction to my 11:23Z comment — I overstated the blocker

Run 5eb6f78c. My earlier comment on this PR called gate/ally-comment-findings the "Remaining blocker." That was wrong, and since it could talk someone out of enqueueing a PR that is in fact enqueueable, I am correcting it rather than leaving it to stand.

Measured this run, head 958587ad1fe1eb52b06ca874c41734c9abdc2a10 (unchanged):

gh api repos/Blockcast/paperclip/rules/branches/master returns exactly one rule — merge_queue (merge_method: REBASE, grouping_strategy: ALLGREEN). There is no pull_request rule and no required-status-checks rule. So gate/ally-comment-findings is not a required context, and mergeable_state: unstable + mergeable: true is GitHub reporting exactly that: a non-required status is red, nothing required is. rebaseable: true, 2 commits, both parents=1 — no merge commits, so the BLO-22300 REBASE-dequeue trap does not apply.

The status is still genuinely stale, and that part of the earlier comment stands: it is pinned at 2026-09-06T06:28:21Z, six seconds after the 06:28 review that reported Important Issues (1). The 11:13Z review at this same head reported Critical (0) / Important (0) and did not move it — now 20+ hours. I could not find the producer of that context anywhere in master (not in sweep-stalled-ally-reviews.py, ally-review-detection.ts, or github-webhook.ts), so I am not asserting a mechanism for why it did not re-fire — only that it did not.

The one real defect at this head is mine, and it is not a merge gate either. Two operative Ally App reviews stand at 958587ad, which violates I1. Verified by running the repo's own predicate rather than by eye:

I1 PR #1418 @958587ad: 2 operative Ally App reviews
   (COMMENTED/5124450619, COMMENTED/5125141599) — expected at most 1 in the app lane

Two things worth recording about that check:

  • .github/workflows/ally-review-consistency.yml runs on schedule (hourly, 23 * * * *) and workflow_dispatch only — not on pull_request. It is an audit, not a PR gate. It also only inspects open PRs, so merging clears the finding.
  • My first run of that predicate also reported an I4 violation ("clean App evidence must be APPROVED"). That was a false positive I created, by passing a pr object without an author field — isCleanAppSelfReview() reads pr.author.login/is_bot, and I4 has an explicit carve-out for a clean App self-review on an App-authored PR. With the author populated the violation set is I1 alone. Recording it so nobody re-derives the phantom.

I am deliberately not posting a third consolidated review to "refresh" anything — that would take I1 from 2 to 3. The attestation at this head exists and is correct.

Net: no code blocker (the 11:13Z review is clean; the three Suggestions, including the vi.useFakeTimers({ toFake: ["Date"] }) narrowing, are hardening), and no required check failing. This PR is enqueueable as it stands. The toFake narrowing remains worth taking on its own merits, but it is not mine to push — under the BLO-32317 ruling I may perform a clean replay, not author content on a PR I attest.

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