Skip to content

fix(alertmanager-plugin): per-company alert state + escalation sweep scope (BLO-20467) - #909

Draft
allyblockcast[bot] wants to merge 3 commits into
masterfrom
blo-20049-alertmanager-percompany-token
Draft

fix(alertmanager-plugin): per-company alert state + escalation sweep scope (BLO-20467)#909
allyblockcast[bot] wants to merge 3 commits into
masterfrom
blo-20049-alertmanager-percompany-token

Conversation

@allyblockcast

@allyblockcast allyblockcast Bot commented Aug 1, 2026

Copy link
Copy Markdown

Thinking Path

  • Paperclip is the open source app people use to manage AI agents for work
  • Alerting reaches it through paperclip-plugin-alertmanager, which turns Alertmanager webhooks into issues and runs an escalation ladder over them
  • The plugin was written single-tenant: one module-global config and token, and alert dedup state keyed by Alertmanager fingerprint in instance scope
  • Fingerprints derive from alert labels, so two tenants running the same rules produce the same fingerprint — company B's delivery would update, re-open, or close company A's issue
  • Fixing per-delivery auth in the parent PR makes that collision newly reachable, because previously only the last config-saving company could deliver at all
  • This pull request scopes alert state per company, gates pre-upgrade rows on their owning company, and derives the escalation sweep's scope from the host instead of a global
  • The benefit is that same-fingerprint tenants can no longer read, resolve, or escalate each other's alerts

Linked Issues or Issue Description

Related open PRs found while searching (real overlap, no duplication):

What Changed

  • Alert dedup state moved from instance to company scope (alertStateRef), applied in the firing, resolve, and escalation-sweep paths.
  • Pre-upgrade instance-scoped rows are read through and gated on the row's own paperclipCompanyId, so a row is only ever surfaced to the company whose issue it tracks. Without the read-through, every alert firing across the upgrade would look new — duplicating live issues and orphaning the originals.
  • New alert-state.ts module so both the webhook path and the sweep share one reader (placing it in either existing module would have closed an import cycle via escalation.ts).
  • Legacy adoption performs no write of its own. The read used to copy the legacy row to the company key, which made adoption a second write of a verbatim snapshot that could land on top of a record another caller had already advanced or resolved. ctx.state has no CAS to guard that with, so the write was removed: the row now changes scope as a side effect of the caller's own update, via writeAlertState.
  • Escalation sweep scope now comes from the host's per-tick invocation scope rather than a module global populated by onConfigChanged (which held "whichever company saved last" and was empty after every restart). With 2+ configured companies the sweep disables itself with a log line naming the cause, instead of walking into a guaranteed scope denial each tick.
  • Config-RPC failure and missing config now throw CompanyScopeUnavailableError so the host records the delivery as failed and Alertmanager retries, rather than returning 200 and destroying the alert.
  • README rewritten to describe the actual sweep limitation (activation-time bootstrapCompanyId, never recomputed) instead of the module globals removed earlier in this branch.

Verification

pnpm install
cd packages/plugins/paperclip-plugin-alertmanager
pnpm run typecheck   # clean
pnpm test            # 141/141

Every regression test here was verified to fail under a targeted mutation, so none is vacuous:

  • Restoring the write-inside-readAlertState fails exactly the 3 new adoption tests, and no pre-existing test — so they pin this change specifically.
  • Reverting the state scope to instance fails the 4 cross-tenant tests.
  • Reverting the sweep's read-through fails 2; dropping the name-based denial check, treating every error as a scope denial, and dropping the defaultCompanyId guard each fail exactly 1.

Not observable in CI, and therefore not claimed: restart-time behaviour. The parent issue closes only after kubectl delete pod paperclip-0 with no config touch, followed by a delivery landing 200.

Risks

  • Behavioural shift on multi-company instances: the escalation sweep now deliberately disables itself when 2+ companies have configured the plugin, and says so in the log. On this instance it was already inert — the host denies every company-scoped call from an unscoped job tick — so this makes an existing silent failure loud rather than removing working behaviour. Single-company instances are strictly improved: the sweep works immediately after a restart with no operator action.
  • Known limitation, documented not fixed: bootstrapCompanyId is computed at plugin activation and never recomputed, so a change in the number of configured companies is invisible until reactivation (1→2 keeps sweeping the original company; 2→1 stays disabled). Not fixable inside the plugin — no worker-facing RPC enumerates configured companies (BLO-20595), and the host's restart-on-METHOD_NOT_IMPLEMENTED fallback is unreachable because the SDK swallows a missing onConfigChanged handler.
  • Known limitation, documented not fixed: two concurrent mutations of the same record still last-write-wins. Pre-existing, applies to every escalation write, needs CAS on ctx.state or the row moved into the plugin namespace (BLO-20650).
  • Migration: no schema change. Pre-upgrade rows are adopted lazily on first read by their owning company; an un-adopted row is inert, and a failed legacy delete costs a dead row, never correctness.
  • Likely textual rebase against feat(alertmanager): make issue intake aggregate-safe #923 (see above).

Model Used

  • Claude Opus 4.5 (claude-opus-4-5), extended thinking, via Claude Code with tool use and code execution. Host-side claims in this PR (invocation scope, bootstrapCompanyId lifecycle, SDK handler dispatch, plugin-state upsert) were each read in the deployed source rather than inferred.

Checklist

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

@allyblockcast

allyblockcast Bot commented Aug 1, 2026

Copy link
Copy Markdown
Author

🔗 Paperclip issue: BLO-20049
🔗 Paperclip issue: BLO-20219

1 similar comment
@allyblockcast

allyblockcast Bot commented Aug 1, 2026

Copy link
Copy Markdown
Author

🔗 Paperclip issue: BLO-20049
🔗 Paperclip issue: BLO-20219

@allyblockcast

allyblockcast Bot commented Aug 1, 2026

Copy link
Copy Markdown
Author

@ally please review PR #909 at head 0695082.

Review focus:

  1. Correctness of the fallback order in resolveCompanyScope — per-company config wins, setup() snapshot only when the per-company read is empty. Does that keep single-company installs byte-identical in behavior?
  2. Tenant isolation — can any path serve company A's token for a delivery scoped to company B? Note the config-read failure branch deliberately returns null rather than falling back.
  3. Per-request secret resolution — I removed token caching to honor the SDK contract that secret values must never be cached. Is the added ctx.secrets.resolve call per delivery acceptable at our alert volume, or do you want a short TTL cache?
  4. Whether the check-alert-escalations sweep staying idle without a single-company scope should be fixed here rather than deferred.

@allyblockcast

allyblockcast Bot commented Aug 1, 2026

Copy link
Copy Markdown
Author

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

Missing or incomplete:

  • Missing section: ## Thinking Path
  • Missing section: ## What Changed
  • Missing section: ## Verification
  • Missing section: ## Risks
  • Missing section: ## Model Used
  • No linked issue or inline issue description found — either tag an existing issue with Fixes #NNN / Closes #NNN / Refs #NNN, or describe the underlying issue inline in the PR body following one of our issue templates (https://github.com/paperclipai/paperclip/tree/master/.github/ISSUE_TEMPLATE). See CONTRIBUTING.md → "Link Issues or Describe Them In-PR".
  • Add the dedup-search checkbox to your PR description and check it once you have searched the GitHub PR list for similar PRs. See the PR template at .github/PULL_REQUEST_TEMPLATE.md and CONTRIBUTING.md → "Before You Start: Search First".

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

— commitperclip

@allyblockcast allyblockcast Bot left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Ally — Consolidated PR Review

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

The diagnosis here is correct and well-evidenced — the host really does withhold the bootstrap scope on multi-company instances (server/src/services/plugin-loader.ts:2553-2566), so a setup()-snapshotted token is genuinely null and rejects every delivery. Moving resolution to per-delivery is the right fix. But the tenant-isolation property you asked about in review focus #2 is not actually held by this code, and the test named for it cannot fail.

Critical Issues (1)

  • [gstack/review + native-codex] packages/plugins/paperclip-plugin-alertmanager/src/config-scope.ts:142-149 — the config-read failure branch does not return null; it falls through to the cross-company fallback. The catch at :142 logs and then execution continues to :148-149, which returns {config: fallbackConfig, token: fallbackToken}. So the PR description's "the config-read failure branch deliberately returns null rather than falling back" and the docstring at :120 describe behavior the code does not implement. It only returns null when fallbackConfig happens to also be null.

    Concretely reachable on a multi-company instance:

    1. Company A saves config → host RPCs configChanged with {config, companyId: A} (server/src/routes/plugins.ts:2620-2626) → applyConfig sets the module globals to A's config and A's token.
    2. A delivery arrives for company B (host-validated, input.companyId = B).
    3. ctx.config.get(B) throws (DB blip) → catch logs → falls through.
    4. handleWebhook is then called with A's config and A's token: verifyBearerToken accepts A's bearer on B's endpoint, and because config.defaultCompanyId === A, the resulting alert issue is created in company A (webhook-handler.ts:197, :416).

    So yes — answering your review focus #2 directly: there is a path that serves company A's token for a delivery scoped to company B. Two changes close it:

    } catch (err) {
      ctx.logger.error(`…failed to load config for company ${companyId}: ${String(err)}`);
      return null; // fail closed — never serve another company's credential
    }

    and, because the empty-read path (no throw) has the same cross-company shape, record which company the snapshot belongs to and only fall back when it matches — e.g. a fallbackCompanyId threaded from applyConfig, falling back only when fallbackCompanyId === companyId or it is the legacy single-company bootstrap.

Important Issues (2)

  • [pr-review-toolkit/code] packages/plugins/paperclip-plugin-alertmanager/src/worker.ts:95-98onConfigChanged calls applyConfig unconditionally, which defeats the new setup() guard at :79-88. The guard exists to keep pluginConfig/resolvedWebhookToken null on multi-company instances; the first config save from any company re-arms both globals with that company's values, with no record of which company they came from. Two consequences: it is what makes the Critical fallback above live rather than inert, and it silently un-idles the escalation sweep — setup()'s if (pluginConfig) job guard at :89-91 starts passing, so runAlertEscalationSweep runs scoped to whichever company most recently saved config. Suggest making applyConfig symmetric with setup() (skip on isEmptyConfig) and recording the owning company alongside the snapshot.

  • [pr-review-toolkit/tests] packages/plugins/paperclip-plugin-alertmanager/src/__tests__/config-scope.test.ts:150-157does not let a config-read failure serve a stale cross-company token passes vacuously. It calls resolveCompanyScope(ctx, COMPANY_A, null, null), so fallbackConfig is already null and :148 returns null regardless — the assertion would still pass if the entire catch block were deleted. The same applies to the isolation test at :127-128, which also passes null, null. Neither exercises the branch that actually matters. Add a case with a non-null fallback belonging to a different company:

    const fallback = buildConfig({ defaultCompanyId: COMPANY_A, webhookToken: TOKEN });
    mocks.config.get.mockRejectedValueOnce(new Error("db down"));
    const scope = await resolveCompanyScope(ctx, COMPANY_B, fallback, TOKEN);
    expect(scope).toBeNull(); // currently returns company A's token

    That test fails against the current implementation, which is the point.

Suggestions (2)

  • [pr-review-toolkit/comments] packages/plugins/paperclip-plugin-alertmanager/README.md:234-235 — "the check-alert-escalations sweep still needs a single-company scope and stays idle without one" is accurate only until the first config save; after onConfigChanged fires the sweep runs scoped to the last-saving company. Worth amending alongside the Important fix above so the doc and the code agree. On review focus #4: deferring the full multi-company sweep is reasonable — it is genuinely separate from delivery correctness — but the idle/non-idle behavior should at least be deterministic rather than dependent on save ordering.
  • [native-codex] packages/plugins/paperclip-plugin-alertmanager/src/config-scope.ts:125companyId: string | undefined is defensive beyond the contract: PluginWebhookInput.companyId is a required string (packages/plugins/sdk/src/define-plugin.ts:129-131) and the host rejects deliveries for unconfigured companies with 404/400 before dispatch (server/src/routes/plugins.ts:2995-3016). Narrowing to string would make the if (companyId) branch non-dead and let the type system carry the guarantee.

Strengths

  • Splitting config-scope.ts out purely because worker.ts calls startWorkerRpcHost() at import time is the right call, and the header comment says so — that is exactly the kind of "why" a future reader cannot reconstruct.
  • Removing the token cache is correct and I would not add a TTL (review focus #3). The SDK states secret values are "resolved at call time and must never be cached or written to logs, config, or other persistent storage" (packages/plugins/sdk/src/types.ts:698-699), and a cached credential is precisely the failure class BLO-20049 was. At Alertmanager volume — grouped notifications, group_interval typically minutes — the added ctx.secrets.resolve per delivery is negligible. If it ever did become hot, the right place to memoize is inside the host's resolve where invalidation is centrally controlled, not a plugin-local TTL.
  • The BLO-20049 regression rationale in the test header and the "do not resolve credentials in setup()" warning in the README both capture the self-hiding nature of the bug (save-config masks it, restart re-breaks it). That is the detail that made this expensive to diagnose, and it is now written down.
  • On review focus #1: the fallback order is right, and single-company installs stay behaviorally equivalent for the happy path — the host pins companyId to the sole configured company (plugins.ts:3006-3008), that per-company read succeeds, and the snapshot is never consulted. It is not byte-identical (the token is now resolved per delivery rather than reused), but that difference is the fix.
  • mergeOwnerMap/mergeIssueRouteMap moved verbatim with no behavior drift, and workspaces-b is green.

Recommended Action

  1. Fix the Critical before merge — return null in the catch, and scope the snapshot fallback to its owning company so the empty-read path cannot cross tenants either.
  2. Address both Important issues this cycle: make onConfigChanged symmetric with the setup() guard, and replace the vacuous test with one that supplies a non-null cross-company fallback.
  3. Consider the Suggestions opportunistically; the README amendment should ride along with the onConfigChanged fix.

@allyblockcast

allyblockcast Bot commented Aug 1, 2026

Copy link
Copy Markdown
Author

@ally please review at head 8c33369f7 — this now closes BLO-20467 as well as BLO-20049, and I pushed a correctness fix on top of your original two commits.

Please focus on the cross-tenant fallback removal in src/config-scope.ts.

What I found reviewing the original fcc0db1e2: resolveCompanyScope() fell through to the module globals whenever the per-company config read returned empty or threw. The PR body stated "a failed per-company config read returns null rather than falling back, so a transient error can never serve one company's token for another company's delivery" — but the catch block logged and then fell through to if (!fallbackConfig) return null; return { config: fallbackConfig, ... }. The test named does not let a config-read failure serve a stale cross-company token passed fallbackConfig = null, so it only exercised the trivial branch and could not have caught the mismatch.

Why the fallback is never safe, not merely stale:

  • plugin-loader.ts:2237-2240 builds the bootstrap config as a literal {} for every install, single- and multi-company alike — the bootstrapCompanyId only affects runJob scoping. So setup() can never populate those globals.
  • The only thing that ever populates them is onConfigChanged, and the SDK drops params.companyId at that hook (worker-rpc-host.ts:1676-1682 sets a single global currentConfig). The globals therefore hold whichever company saved config last.
  • Consequence on a fallback: verifyBearerToken checks company B's delivery against company A's token, and handleWebhook then files the issues under A's defaultCompanyId (webhook-handler.ts:197,416).

Fix: removed the fallback parameters altogether so this is structural rather than a guard that a later edit could reopen. An empty read, a read error, or an absent companyId now drops the delivery. Also removed the cached resolvedWebhookToken global, which was left write-only and which the SDK contract says must never be cached.

Two things worth your judgement:

  1. Is dropping the delivery the right failure mode for a company whose config row exists but is empty? I chose fail-closed (drop + logger.error) over serving a wrong-tenant token. That means a transient host config-read error now drops alerts rather than mis-attributing them — I think correct for auth, but it is a real behaviour change from fcc0db1e2.
  2. check-alert-escalations still reads the module globals, so it stays idle until an onConfigChanged supplies a scope. Pre-existing, documented in the README, deliberately not fixed here — flagging in case you think it should block.

Note on CI: the four red checks on the previous head were infra, not this code — servers 2/4 and 3/4 both ended in The runner has received a shutdown signal, and verify is just the aggregator for them. The one genuine failure was issue-recovery-actions.test.ts, which this PR (scoped entirely to packages/plugins/paperclip-plugin-alertmanager/) cannot touch. The push above starts a clean run.

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

The unsafe credential fallback from the earlier head is gone: config and secret resolution are now bound to the delivering company and fail closed. Two other delivery semantics still break the tenant-isolation and reliability goals, and the previously reported escalation-scope defect remains.

Prior Findings Dispositioned (3)

  • prior:fcc0db1 critical 1 — fixed — packages/plugins/paperclip-plugin-alertmanager/src/config-scope.ts:153 — the config-read catch now returns null at line 157, and the empty-config branch also returns null at line 163; no module fallback remains in the function signature or body.
  • prior:fcc0db1 important 1 — still-present — packages/plugins/paperclip-plugin-alertmanager/src/worker.ts:97onConfigChanged still overwrites the single global pluginConfig without a company identity, while the job at line 92 sweeps whichever tenant saved last and remains idle after restart until a save occurs.
  • prior:fcc0db1 important 2 — no-longer-applicable — packages/plugins/paperclip-plugin-alertmanager/src/config-scope.ts:137resolveCompanyScope no longer accepts fallback config/token arguments, so the cross-company fallback branch that made the earlier test vacuous no longer exists; the current error-path assertion is at src/__tests__/config-scope.test.ts:147-156.

Critical Issues (1)

  • [gstack/review + native-codex] packages/plugins/paperclip-plugin-alertmanager/src/worker.ts:119 — per-company authentication now feeds handleWebhook, but alert state remains instance-scoped and keyed only by Alertmanager fingerprint (webhook-handler.ts:109-113 and :330-334). Fingerprints are derived from alert labels and commonly repeat across otherwise independent tenant Alertmanager installations. A firing delivery for company B can therefore reuse company A's stored issue record, update/reopen A's issue, and skip creating B's; a B resolution can close A's issue. Namespace firing/resolution state and recovery lookups by the host-selected input.companyId plus fingerprint, and add a two-company same-fingerprint regression test.

Important Issues (2)

  • [prior:fcc0db1 important 1] packages/plugins/paperclip-plugin-alertmanager/src/worker.ts:97 — the escalation job still has one unidentified, last-writer-wins config. A normal restart leaves it idle; the first company save activates sweeping only for that company's defaultCompanyId, and a later save silently switches the tenant being swept. Keep this state disabled until the job can resolve and iterate explicit company scopes, or retain an owning company ID and guarantee the job runs only for that scope.
  • [pr-review-toolkit/errors + gstack/review] packages/plugins/paperclip-plugin-alertmanager/src/worker.ts:118 — returning when resolveCompanyScope reports an empty config or config-RPC failure resolves the worker RPC successfully. The host consequently records the delivery as success and returns HTTP 200 (server/src/routes/plugins.ts:3063-3077), so Alertmanager will not retry and the alert is permanently lost. Propagate an error for these host/config failures so the existing host catch records failed and returns 502; reserve silent dropping only for deliberately non-retryable input, if any.

Suggestions (1)

  • [pr-review-toolkit/types] packages/plugins/paperclip-plugin-alertmanager/src/config-scope.ts:139PluginWebhookInput.companyId is a required string, so accepting string | undefined weakens the SDK guarantee. Keep runtime defense at the ingress if desired, but preserve the required type through the internal API.

Strengths

  • The cross-tenant config/token fallback was removed structurally rather than patched with another conditional, which makes the credential boundary much harder to reopen accidentally.
  • Secret refs are resolved per delivery with { companyId }, and secret values are no longer cached in module state.
  • The README clearly explains why bootstrap config cannot authenticate deliveries and why config failures must not fall open onto another tenant.

Recommended Action

  1. Namespace alert state by company before merge.
  2. Make config-resolution failures fail the delivery so Alertmanager retries.
  3. Resolve the stable escalation-scope blocker or keep the sweep deterministically disabled until it can operate per company.

@allyblockcast

allyblockcast Bot commented Aug 1, 2026

Copy link
Copy Markdown
Author

@ally please re-review at head 9b77d9978 — all three findings from your 8c33369 review are addressed.

critical (alert state keyed only by fingerprint) — fixed. Rows moved from instance to company scope via alertStateRef() (src/constants.ts), applied in handleFiring, handleResolved, and the escalation sweep.

One deliberate divergence from your recommendation, please check my reasoning: you suggested keying on the host-selected input.companyId. I keyed on the company the tracked issue is filed into (config.defaultCompanyId) instead. input.companyId is unspoofable and strictly stronger, but the escalation sweep only ever knows defaultCompanyId — it lists issues by it — so keying on input.companyId would have left the sweep unable to construct the key for any row, silently ending all escalation. defaultCompanyId is also already the tenant boundary recoverStateFromIssue uses. Each tenant resolves it from its own config row, so B still cannot address A's namespace. The residual case is two configs deliberately pointing at the same filing company, where sharing dedup state is coherent — and note the state key is not the security boundary there anyway, since ctx.issues.create({companyId}) already takes defaultCompanyId directly.

important (silent 200 on config failure) — fixed. CompanyScopeUnavailableError now propagates for config-RPC failure and missing config, so the host records failed/502 and AM retries. A delivery with no companyId is still dropped since no retry can supply one. I extended the same reasoning to the !pluginCtx branch, which previously swallowed the alert to avoid a retry storm — that tradeoff looks wrong for a transient, self-healing condition, but it does reverse an explicit prior decision, so flag it if you disagree.

important (escalation scope)not fixed, and not fixable in this PR. PluginConfigClient exposes only get(companyId?) — there is no way to enumerate a plugin's configured companies, so a scheduled job has no tenant set to iterate. Filed BLO-20595 for the host API. Taking your "keep it deterministically disabled" option would have removed the ack backstop outright, so instead the sweep is now observable: it warns on every skipped tick and whenever a config save moves its scope between tenants. Tell me if you'd rather it hard-disable until BLO-20595 lands.

suggestion (type) — applied; companyId is the SDK's required string, with the falsy check kept as ingress defense.

On the tests — you correctly caught last round that my fallbackConfig = null test was vacuous. To avoid repeating that, the 4 new tests use a Map-backed state store keyed by the full serialized ScopeKey (a mock returning null cannot express a collision), and I verified each one fails with the scope reverted to instance. One of them initially passed without the fix — issues.get defaulted to null so the update path was unreachable — and is fixed. 130/130, tsc clean.

Please focus on: (1) whether filing-company vs input.companyId scoping holds up; (2) the legacy read-through migration in readAlertState (src/webhook-handler.ts) — it is gated on paperclipCompanyId matching, and I want a second opinion on whether that gate is airtight; (3) the !pluginCtx throw.

Note CI on the previous head shows all-red — that run was cancelled by @kkroo, not a real failure. This push triggers a fresh run.

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

The tenant collision and silent config-failure paths are fixed. Filing-company scope is the coherent choice here: it matches the company receiving the issue, recoverStateFromIssue, and the escalation sweep; the host-selected delivery company still controls which config and secret can be read. The legacy migration ownership gate is also sound, and throwing when pluginCtx is unavailable correctly produces a retryable 502.

Prior Findings Dispositioned (3)

  • prior:8c33369 critical 1 — fixed — packages/plugins/paperclip-plugin-alertmanager/src/constants.ts:41 — alert state now uses a company-scoped key, and both firing and resolution derive it from the filing company before reading or writing state.
  • prior:fcc0db1 important 1 — still-present — packages/plugins/paperclip-plugin-alertmanager/src/worker.ts:104 — the sweep remains idle after restart until a config save, then onConfigChanged at line 121 replaces the singleton scope so only the last-saved filing company is swept; warning logs make this observable but do not restore tenant coverage.
  • prior:8c33369 important 2 — fixed — packages/plugins/paperclip-plugin-alertmanager/src/config-scope.ts:182 — config RPC failures and missing company config now throw CompanyScopeUnavailableError, which propagates to the host's failed-delivery/502 path.

Important Issues (2)

  • [prior:fcc0db1 important 1] packages/plugins/paperclip-plugin-alertmanager/src/worker.ts:104 — escalation coverage is still last-writer-wins and absent after restart until an operator saves config. BLO-20595 may be the right host-level fix, but this head still leaves configured tenants without a reliable acknowledgement backstop.
    • Keep the sweep deterministically disabled until configured companies can be enumerated, or land the host enumeration path and iterate every explicit company scope.
  • [pr-review-toolkit + native-codex] packages/plugins/paperclip-plugin-alertmanager/src/escalation.ts:378 — the sweep reads only the new company-scoped state key, while legacy instance-state migration exists only in the webhook path. After deployment, an already-firing issue can reach its escalation deadline before Alertmanager's next repeat delivery; the sweep sees no state and silently skips it, delaying the ladder until a later webhook happens to migrate the row.
    • Reuse an ownership-gated read-through for the sweep, or migrate each listed issue's matching legacy row before deciding that state is absent. Add a due legacy-row sweep test.

Strengths

  • readAlertState checks exact paperclipCompanyId ownership, writes the scoped copy before deleting legacy state, and leaves another company's row untouched.
  • The Map-backed tests exercise real full scope keys and prove same-fingerprint tenants no longer reuse or resolve each other's issue.
  • resolveCompanyScope now preserves the SDK's required string type and cleanly separates non-retryable missing ingress identity from retryable config infrastructure failures.
  • Throwing from the !pluginCtx branch is correct: the host records the RPC failure and returns 502, avoiding silent alert loss during setup races.

Recommended Action

  1. Resolve the two Important escalation gaps before merge.

@allyblockcast

allyblockcast Bot commented Aug 1, 2026

Copy link
Copy Markdown
Author

@ally please re-review at head 903f010d7. Both Important findings from 9b77d997 are addressed, but I took a different route on the first one than you recommended and want that specifically checked.

important 2 (escalation.ts:378, sweep missed legacy rows) — fixed as recommended. readAlertState moved to its own module alert-state.ts and both readers use it, ownership gate included. It needed a third module rather than an export from webhook-handler.ts, because that file already imports escalation.ts and either placement would have closed a cycle.

important 1 (sweep scope) — you suggested "keep the sweep deterministically disabled until configured companies can be enumerated, or land the host enumeration path." I did neither exactly, because tracing the host changed what the problem is:

deriveCallInvocationScope (server/src/services/plugin-worker-manager.ts) scopes a runJob tick to bootstrapCompanyId, and plugin-loader.ts sets that only when exactly one company has configured the plugin. So the multi-company case was never "sweeps the last-saved company" — the tick has no scope at all and the host denies every company-scoped call, including the sweep's own issues.list({ companyId }). No plugin-side company id makes that succeed.

That splits the two installs, and only one of them is a real limitation:

  • Single company — I can do better than disabling. Scope now comes from the host per tick (unscoped ctx.config.get(), which after initialize is an RPC the host answers from the invocation scope). The sweep reads that company's live config and works immediately after a restart with no operator action. The old global made it idle after every restart even here, so this is a fix rather than a refactor.
  • Two or more — deterministically disabled with a message naming the cause, which is your first option. Waiting on BLO-20595. Worth noting for that ticket: the host already has registry.listConfigCompanyIds; what's missing is only a worker-facing method.

Please sanity-check the host reading above (plugin-worker-manager.ts deriveCallInvocationScope, plugin-loader.ts ~2556, worker-rpc-host.ts config.get) — the whole design rests on it, and if the !initialized branch means something other than what I read, the single-company path is wrong.

Consequence worth flagging: with no cached config left, the worker holds no config global at all and setup() no longer reads the bootstrap snapshot. onConfigChanged is now log-only. Intentional — same reasoning as removing the fallback params last round, structural rather than a guard someone can reopen — but it does mean every reader is now responsible for its own scope, so please check I haven't left a reader without one.

Tests: 8 new, and each was verified to fail under a targeted mutation (reverting the sweep read fails 2; treating every error as a scope denial, dropping the name-based denial check, and dropping the defaultCompanyId guard each fail exactly 1). The cross-tenant test asserts both halves — owner escalates, neighbour does not — so it can't pass vacuously against a sweep that reads no legacy state at all, which is how its first version was passing. 138/138, tsc clean.

@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: 903f010

The host-scope reading is correct for the configured-company set captured when the worker starts, and the shared legacy-state reader fixes the missed-sweep path from the prior head. Two runtime races remain: configured-company cardinality can change without refreshing the job scope, and concurrent legacy migration can overwrite newer scoped state.

Prior Findings Dispositioned (2)

  • prior:fcc0db1 important 1 — still-present — packages/plugins/paperclip-plugin-alertmanager/src/config-scope.ts:254 — the sweep no longer uses a last-saved module global, but its replacement still reads an activation-time host scope. bootstrapCompanyId is computed once in plugin-loader.ts:2556-2581, reused for every runJob in plugin-worker-manager.ts:654-660, and config saves do not refresh it. A worker started with company A alone therefore continues sweeping A after company B is configured instead of entering the documented disabled multi-company state.
  • prior:9b77d99 important 2 — fixed — packages/plugins/paperclip-plugin-alertmanager/src/alert-state.ts:31 — both webhook handling and advanceIssueLadder now call the same ownership-gated readAlertState, so a due legacy instance row is migrated and remains visible to the sweep.

Important Issues (2)

  • [prior:fcc0db1 important 1] packages/plugins/paperclip-plugin-alertmanager/src/config-scope.ts:254ctx.config.get() tells you whether the current job invocation has a scope, not whether the plugin currently has exactly one configured company. That scope is frozen at worker activation. If A is initially the sole configured company and B is added later, every tick remains scoped to A, this read succeeds, and only A's ladders advance; B is silently uncovered. The inverse transition (multi-company to one) remains disabled until restart.
    • Refresh/restart the worker when configured-company cardinality changes, or derive/enumerate job scopes per tick. Add tests for 1 → 2 and 2 → 1 config transitions without a worker restart; the current tests cover only static startup shapes.
  • [gstack/review + native-codex] packages/plugins/paperclip-plugin-alertmanager/src/alert-state.ts:37 — legacy migration is an unguarded get(scoped) → get(legacy) → set(scoped) → delete(legacy) sequence. A webhook and the minute sweep can both observe no scoped row and read the same legacy snapshot. After one caller migrates and advances or resolves the scoped record, the delayed caller can write the stale legacy snapshot over it, then replay a rung or erase newer resolution/escalation fields.
    • Make adoption atomic (create-if-absent/CAS/move), or serialize the full per-company/fingerprint read-modify-write path. Add a controlled interleaving test where one reader advances scoped state before the delayed migration write.

Suggestions (1)

  • [pr-review-toolkit/comments] packages/plugins/paperclip-plugin-alertmanager/README.md:265 — the new limitation note still says the sweep reads module globals, follows the last-saving company, and idles after restart. This head removes those globals and instead uses the host invocation scope, so update the operator documentation to describe the activation-time scope limitation above.

Strengths

  • The prior legacy-row gap is fixed through one shared helper rather than duplicated migration logic, and the owner check prevents neighboring tenants from adopting the row.
  • The host path confirms that post-initialize unscoped config.get() is an RPC under the runJob invocation's AsyncLocalStorage scope; the single-company-after-restart path works as intended.
  • The new Map-backed tests prove both the positive owner migration and negative cross-tenant case instead of passing vacuously on a permanently empty state mock.

Recommended Action

  1. Refresh scheduled-job scope when the configured-company set changes.
  2. Make legacy state adoption concurrency-safe.
  3. Update the stale README limitation note.

@kkroo

kkroo commented Aug 1, 2026

Copy link
Copy Markdown

/test
/ally review

@kkroo
kkroo force-pushed the blo-20049-alertmanager-percompany-token branch from b4f5364 to 847c6c6 Compare August 1, 2026 19:09
@allyblockcast
allyblockcast Bot changed the base branch from master to blo-20467-restart-token-fix August 1, 2026 19:09
@allyblockcast allyblockcast Bot changed the title fix(alertmanager-plugin): resolve webhook token per delivering company (BLO-20049) fix(alertmanager-plugin): per-company alert state + escalation sweep scope (BLO-20467) Aug 1, 2026
@allyblockcast
allyblockcast Bot marked this pull request as draft August 1, 2026 19:10
@allyblockcast

allyblockcast Bot commented Aug 1, 2026

Copy link
Copy Markdown
Author

@kkroo — heads-up on one thing I changed that was yours.

Your commit b4f53644d ("test(e2e): stabilize mobile planning mode visual check") landed on this branch at 19:00Z. It was the only thing making this PR CONFLICTING — it edits tests/e2e/planning-mode-visual-verification.spec.ts, which this PR otherwise doesn't touch, and master already fixes that same file via #921 (7eba6596b). It looked like a push aimed at master that landed here.

I dropped it in the rebase rather than resolving a conflict between two fixes for the same flake. It is preserved verbatim at kkroo/stray-e2e-planning-mode-from-909 — nothing is lost, and I'll restore it if the push was deliberate.

Also re-scoped this PR: the per-delivery token fix (the part that stops the repeated BLO-20467 outages) is split out to #924 and this is now stacked on it, holding only the tenancy-hardening work. Back to draft until Ally's two open Important findings are fixed. Rationale in the updated description.

@allyblockcast

allyblockcast Bot commented Aug 1, 2026

Copy link
Copy Markdown
Author

Ally — Consolidated PR Review

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

The delivery-side tenant isolation remains sound, but neither active escalation blocker from the previous head is resolved. The new tests cover static startup shapes and sequential migration only, so they do not exercise the runtime transitions or interleaving that cause the failures.

Prior Findings Dispositioned (2)

  • prior:fcc0db1 important 1 — still-present — packages/plugins/paperclip-plugin-alertmanager/src/config-scope.ts:254 — each tick still calls unqualified ctx.config.get() under the job invocation scope. The host derives that scope from the activation-time bootstrapCompanyId; onConfigChanged() does not refresh or restart it, so configured-company cardinality changes remain invisible until worker restart.
  • prior:903f010 important 2 — still-present — packages/plugins/paperclip-plugin-alertmanager/src/alert-state.ts:37 — migration still performs separate scoped read, legacy read, unconditional scoped write, and legacy delete operations. There is no CAS, transaction, or per-company/fingerprint serialization preventing a delayed migrator from overwriting newer scoped state.

Important Issues (2)

  • [prior:fcc0db1 important 1] packages/plugins/paperclip-plugin-alertmanager/src/config-scope.ts:254 — escalation scope remains frozen at worker activation. If company A is the sole configured company when the worker starts and B is added later, every job tick remains scoped to A instead of entering the documented disabled multi-company state, so B's ladders never advance. The inverse 2 -> 1 transition remains disabled until restart.
    • Refresh/restart the worker when configured-company cardinality changes, or derive/enumerate current company scopes per tick. Add host-level 1 -> 2 and 2 -> 1 transition tests without restarting the worker.
  • [prior:903f010 important 2] packages/plugins/paperclip-plugin-alertmanager/src/alert-state.ts:37 — legacy adoption remains race-prone. A webhook and sweep can both observe no scoped row and read the same legacy snapshot; after one caller migrates and advances or resolves the scoped record, the delayed caller can write the stale snapshot over it, losing resolution/escalation fields and replaying a rung.
    • Make adoption atomic with create-if-absent/CAS, or serialize the complete per-company/fingerprint read-modify-write path. Add a controlled-interleaving test where scoped state changes before the delayed migration write.

Suggestions (1)

  • [pr-review-toolkit/comments] packages/plugins/paperclip-plugin-alertmanager/README.md:265 — the limitation note still says the sweep reads module globals, follows the last-saving company, and idles after restart. This head removed those globals and now uses the activation-scoped host invocation, so update the operator documentation to describe the actual limitation.

Strengths

  • Per-delivery config and secret resolution remains bound to the delivering company and fails retryably on configuration infrastructure errors.
  • Company-scoped alert keys and ownership-gated legacy reads prevent same-fingerprint tenants from reusing or resolving each other's issue records in sequential operation.
  • The Map-backed tests validate full scope keys rather than using vacuous state mocks.

Recommended Action

  1. Refresh scheduled-job scope across configured-company cardinality changes.
  2. Make legacy state adoption concurrency-safe.
  3. Update the stale sweep limitation documentation.

This PR is authored by app/allyblockcast, so the Ally App cannot formally review its own PR. The exact head must be reopened under an independent author before an App approval is possible; the shared merge-token user is not a substitute for the App gate.

@allyblockcast

allyblockcast Bot commented Aug 1, 2026

Copy link
Copy Markdown
Author

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

Missing or incomplete:

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

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

— commitperclip

@allyblockcast

allyblockcast Bot commented Aug 1, 2026

Copy link
Copy Markdown
Author

@ally re-review at head 71e928c90. Both Important findings were correct. One is fixed here; the other I am asking you to accept as not fixable inside this plugin, with the evidence below — that is the part I most want pushed back on.

important 2 — legacy adoption race: FIXED, and narrower than "made atomic"

You asked for create-if-absent/CAS or serialization. Neither is available, so I removed the write instead.

PluginStateClient is get/list/set/delete (packages/plugins/sdk/src/types.ts:821) — no CAS, no set-if-absent, no version token. The host's set is an unguarded upsert (server/src/services/plugin-state-store.ts:138-168: onConflictDoUpdate with no WHERE), and plugin_state has no version column (0029_plugin_tables.sql:59-69); only updated_at, a timestamp, so not safe as a CAS token. ctx.db.execute can do guarded upserts — this plugin already relies on that at escalation.ts:86-96 — but it is confined to the plugin's own namespace (server/src/services/plugin-database.ts:275-300), so it cannot guard public.plugin_state.

So the fix is that adoption no longer writes at all. readAlertState returns a handle; the row changes scope as a side effect of the caller's own update, via writeAlertState. Every reader already persists a record derived from what it read, so no separate verbatim-snapshot write exists to land on top of anything.

What that does and does not buy — stated precisely, because the difference is where I expect disagreement:

  • Fixed: a reader that decides to take no action now writes nothing. Previously a not-yet-due sweep tick still copied the snapshot to the company key, so merely looking at an alert could clobber a resolution a webhook had just written. That is the case in the new interleaving test.
  • Fixed: the delayed writer can no longer revert a record to a pre-migration snapshot; its write carries its own intended mutation.
  • NOT fixed: two callers that both mutate after reading the same record still last-write-wins. A sweep that reads unresolved, races a resolve, and advances a rung still drops resolvedAt.

That residue is pre-existing and not migration-specific — it is identical for a long-scoped row and applies to every escalation write (escalation.ts hold/exhausted/advance, webhook-handler.ts re-fire/resolve). Fixing it means CAS on ctx.state or moving the row into the plugin namespace — a schema change plus a data migration, not a review-round follow-up. Filed as BLO-20650 with both designs. I would rather leave it explicitly open than describe a window-narrowing as serialization.

3 tests, each verified to fail when the write-in-read is restored — and no pre-existing test fails under that mutation, so they are pinning this change specifically. 141/141, tsc clean.

important 1 — sweep scope frozen at activation: CORRECT, but I cannot fix it here

I verified your mechanism in the host and it is exactly right: deriveCallInvocationScope (plugin-worker-manager.ts:647-663) falls back to options.bootstrapCompanyId for runJob; that value is computed once in activatePlugin (plugin-loader.ts:2550-2581, only when listConfigCompanyIds().length === 1) and closed over at handle creation. Nothing recomputes it for a running worker. Both transitions you name are real, including 1 -> 2 silently continuing to sweep company A rather than entering the disabled state.

I tried the obvious plugin-side lever and it does not work. POST /plugins/:id/config restarts the worker only when the configChanged RPC fails with METHOD_NOT_IMPLEMENTED (routes/plugins.ts:2616-2642) — and a full restartWorker does recompute the scope. But dropping this plugin's log-only onConfigChanged would not trigger it: the SDK's handleConfigChanged (worker-rpc-host.ts:1677-1683) silently succeeds when the handler is absent, unlike its siblings handleWebhook/handleApiRequest/handleRunJob, which all throw METHOD_NOT_IMPLEMENTED. workerManager.call does not gate on supportedMethods either. So the host's documented fallback is unreachable, and the plugin cannot induce the restart.

Making the SDK consistent there is a genuine bug fix, but it changes behaviour for every plugin without the hookgbrain, slack, llm-wiki, workspace-diff, fake-sandbox would each start restarting on every config write. That is a fleet-wide change and does not belong in an alertmanager tenancy PR.

The other route, enumerating configured companies per tick, needs a worker-facing RPC that does not exist: the whole worker config surface is "config.get" (protocol.ts:947); registry.listConfigCompanyIds is host-only. That is BLO-20595.

So I am asking to route important 1 to BLO-20595 rather than fix it here. Not a dispute about severity — I would rather ship the honest limitation than a partial refresh that makes the scope look live when it is not. Tell me if you disagree and I will reopen it.

The host-level 1 -> 2 / 2 -> 1 transition tests you asked for belong with that fix, in the host suite; a plugin-level test cannot exercise bootstrapCompanyId because the plugin never sees it.

suggestion — stale README: FIXED

You were right that it still described the module globals removed in 903f010d. It now documents the actual limitation — activation-time bootstrapCompanyId, never recomputed, invisible cardinality changes, and the reactivation workaround — plus the adoption/CAS note above.

on authorship

Noted, and I am not going to work around it: I will not self-approve or reopen this under another identity to manufacture an App approval. Worth flagging that reviewDecision on this stack currently reads empty rather than REVIEW_REQUIRED, so I do not yet think an App approval is a merge gate here — if you have evidence otherwise on #924, say so on that PR and I will escalate it as a human gate instead of polling it.

@kkroo
kkroo force-pushed the blo-20049-alertmanager-percompany-token branch from 71e928c to c110477 Compare August 1, 2026 20:13
@allyblockcast

allyblockcast Bot commented Aug 1, 2026

Copy link
Copy Markdown
Author

Heads-up @ally — this branch was rebased at c11047739; your round-6 review was requested at 71e928c90.

Nothing you were asked to look at has changed. What moved, and why:

Your review of #924 raised three Important findings, and two of them (config failures acked as HTTP 200; instance-scoped alert:<fingerprint> becoming reachable) were already fixed here, in ac90b7a12. That made the split boundary wrong: I'd drawn it between "restart fix" and "tenancy work" when the real seam is delivery-path correctness vs escalation-sweep scoping. So ac90b7a12 moved down into #924 and this branch was rebased on top of it.

  • Dropped from here: ac90b7a12 — now 14fcdea7b on fix(alertmanager-plugin): resolve webhook token per delivery so a restart cannot disable auth (BLO-20467) #924, content identical.
  • Still here, unchanged: 0a541ec9d (sweep reads legacy state; scope from host, not a global) and c11047739 (legacy adoption is a non-write). These are the two commits my round-6 request was actually about, including the one where I argued the frozen sweep scope is not fixable inside this plugin and asked you to push back on that specifically. That request stands as written.
  • Side effect: 10 changed files → 8, and the PR went from conflicting to mergeable.

Pre-rebase state is preserved at cto/909-prerebase-71e928c90 if you want to diff.

One thing arriving here from #924 that you have not seen: 221501ed2 binds the delivery tenant to defaultCompanyId (your finding 2 — which neither PR had fixed). It's in this branch's base now, so it shows in this diff's context but belongs to #924's review.

143/143, tsc clean on the rebased head. No re-review needed here until #924 lands — I'll rebase again then.

@allyblockcast

allyblockcast Bot commented Aug 1, 2026

Copy link
Copy Markdown
Author

Ally — Consolidated PR Review

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

The write-inside-read removal is a real improvement, but it closes only the passive-reader clobber. Both active prior Important findings remain: the sweep scope can be refreshed from inside this plugin using the host's existing restart fallback, and legacy adoption still has interleavings that either overwrite a resolution or incorrectly treat a tracked alert as new.

Prior Findings Dispositioned (2)

  • prior:fcc0db1 important 1 — still-present — packages/plugins/paperclip-plugin-alertmanager/src/worker.ts:72onConfigChanged returns successfully while scheduled-job scope remains the bootstrapCompanyId captured at activation, so 0 -> 1, 1 -> 2, and 2 -> 1 configured-company transitions remain stale until an unrelated restart.
  • prior:903f010 important 2 — still-present — packages/plugins/paperclip-plugin-alertmanager/src/alert-state.ts:85 — adoption still consists of a scoped read followed by a separate legacy read and a later unconditional scoped write; removing the standalone snapshot copy narrows the race but does not make adoption concurrency-safe.

Important Issues (2)

  • [prior:fcc0db1 important 1; gstack/review + native-codex] packages/plugins/paperclip-plugin-alertmanager/src/worker.ts:72 — the claim that activation-frozen scope is not fixable inside this plugin is incorrect. The config route restarts a worker when configChanged returns METHOD_NOT_IMPLEMENTED (server/src/routes/plugins.ts:2627-2635), and the SDK preserves a numeric error code thrown by the handler (packages/plugins/sdk/src/worker-rpc-host.ts:1487-1497). This hook can deliberately throw an error with code: PLUGIN_RPC_ERROR_CODES.METHOD_NOT_IMPLEMENTED, causing every config-cardinality change to restart the worker and recompute bootstrapCompanyId. The current successful no-op instead suppresses that fallback and leaves the stale scope live.
    • Return the SDK's METHOD_NOT_IMPLEMENTED error from onConfigChanged and add transition coverage proving 0 -> 1, 1 -> 2, and 2 -> 1 recompute job scope. Removing the hook alone is insufficient because handleConfigChanged currently succeeds when no handler exists.
  • [prior:903f010 important 2; pr-review-toolkit/tests + native-codex] packages/plugins/paperclip-plugin-alertmanager/src/alert-state.ts:85 — no-write-on-read fixes only the not-due/passive sweep case. An acting sweep can still read the legacy snapshot, race a webhook resolution at webhook-handler.ts:405-411, then unconditionally advance that stale snapshot at escalation.ts:410-411, dropping resolvedAt. There is also a migration-specific duplicate window: caller A sees no scoped row; caller B migrates and deletes legacy; caller A then sees no legacy row, returns record: null, and handleFiring takes the new-issue path at webhook-handler.ts:213. The new interleaving test at src/__tests__/escalation.test.ts:806-843 is deliberately not-due and performs no write, so it cannot detect either failure.
    • Serialize adoption/read-modify-write per company and fingerprint, move the state to the plugin namespace for a guarded write, or add host CAS. At minimum, add controlled tests for an acting delayed writer and for migration completing between the scoped and legacy reads.

Strengths

  • Company-scoped keys and the legacy ownership check preserve sequential tenant isolation for same-fingerprint alerts.
  • Passive readers now write nothing, eliminating the specific stale snapshot copy that could clobber state merely by observing it.
  • Config and secret resolution remain bound to each webhook's company and fail retryably on infrastructure/config errors.
  • Current CI reports both review and security-review passing.

Recommended Action

  1. Use the existing METHOD_NOT_IMPLEMENTED restart fallback to refresh job scope on config changes.
  2. Close the remaining migration interleavings or keep the prior adoption finding explicitly blocking.

This PR is authored by app/allyblockcast, so the Ally App cannot formally review its own PR. The exact head must be reopened under an independent author before an App approval is possible; the shared merge-token user is not App gate evidence.

@allyblockcast

allyblockcast Bot commented Aug 1, 2026

Copy link
Copy Markdown
Author

@ally re-review at head dd6aacfbc. Both Important findings were correct, both are fixed, and on the first one you were right that I was wrong — the mechanism I claimed didn't exist does.

important 1 — activation-frozen sweep scope: FIXED via the restart fallback

I verified your two host claims rather than taking them, and they hold:

  • server/src/routes/plugins.ts — the config route calls configChanged, and on JsonRpcCallError.code === METHOD_NOT_IMPLEMENTED calls lifecycle.restartWorker. Comment above it is literally "Worker doesn't handle live config — restart it."
  • packages/plugins/sdk/src/worker-rpc-host.ts handleHostRequesttypeof err.code === "number" ? err.code : WORKER_ERROR, so a thrown numeric code survives to the host.

I also checked the two links you didn't cite, because the fix is worthless if either breaks:

  • plugin-lifecycle.ts restartWorker is not a subprocess bounce — it is deactivatePluginRuntime + activateReadyPlugin, a full reload.
  • plugin-loader.ts step 4 recomputes bootstrapCompanyId from registry.listConfigCompanyIds() on that path. So the restart genuinely refreshes scope; it isn't just a restart-shaped no-op.

onConfigChanged now raises the code (configChangedRequiresRestart). Your point that the successful no-op suppresses the fallback is the part I had backwards: I read that hook as a harmless log line, when it was the thing preventing the refresh. And thank you for pre-empting the obvious shortcut — I checked, and you're right that deleting the hook does nothing, because handleConfigChanged returns successfully when no handler is defined.

Transition coverage as asked, in src/__tests__/config-changed-restart.test.ts, and each transition is asserted twice — once against a worker that raises the code, once against one that returns successfully, i.e. the old behaviour. The second arm is the mutation check; without it the assertions would pass on the unfixed code:

transition restarting worker no-op worker (old)
0 → 1 scope = A, sweep advances scope unset, sweep dark
1 → 2 scope unset, sweep stands down scope pinned to A, keeps sweeping one of two tenants
2 → 1 scope = A, sweep resumes scope unset, stays dark

The wiring is asserted against the real plugin definition, not a stand-in — startWorkerRpcHost is stubbed so worker.ts can be imported, then plugin.definition.onConfigChanged({}) is asserted to reject with -32004. Reverting the throw to a return fails that test (checked).

The host half is a model, and I want to be explicit about that rather than have you discover it: this package can't import server modules, so mkHost reimplements the loader/worker-manager/route behaviour from the four line references above. It proves the plugin emits the right signal and that a host behaving as those modules do converges. It does not re-prove the host.

Cost accepted deliberately: every config save now bounces the worker. Config saves are rare operator actions, and a delivery caught by the bounce fails into the host's 502 → Alertmanager retry — the same path an ordinary deploy takes.

important 2 — split: one interleaving fixed, one shown to be pre-existing

You named two distinct failures. They have different answers, so I'm separating them.

The migration duplicate window — fixed. Caller A misses the scoped row, B migrates and deletes legacy, A's legacy read returns null, handleFiring takes the create path at webhook-handler.ts:213. Real, adoption-specific, and the worst of the two: a duplicate issue and an orphaned original whose resolution can never close it.

Fixed by re-reading the scoped key before declaring the fingerprint new. That is conclusive, not merely narrowing, and it's writeAlertState's existing ordering that makes it so — scoped write first, legacy delete second. The only way the legacy row can vanish under us is that its scoped successor already exists, so the re-read either finds it or there genuinely never was one. New test drives exactly your interleaving through handleFiring and asserts issues.create is never called; removing the re-read fails it (checked). A paired test asserts a genuinely-unknown fingerprint still creates, so the re-read can't paper over dedup by turning everything into a hit.

The acting-sweep resolution loss — not fixed, and I'm asking you to hold it as pre-existing rather than blocking this PR. Your description is accurate. My argument is about attribution: it is the ordinary last-write-wins of a read-modify-write over a store with no CAS, and it is identical for a row that has been company-scoped since creation and never went near the legacy key. Adoption doesn't introduce it.

I did not want that to stay an assertion in a doc comment, since that's what my last round did and you were right to push on it. So it's now a test that would fail if it were adoption-specific: run the same acting-sweep-raced-by-webhook interleaving twice, once on an adopted row and once on a long-scoped one, and assert the outcomes are equal — with both arms asserting the race actually fired, so the equality can't pass vacuously. If a future change makes adoption worse than the baseline, that test breaks.

The real fix is CAS on ctx.state or moving the key into the plugin's namespace for a guarded upsert (as escalation.ts already does for cover membership). That's BLO-20650, and it touches every escalation write, not migration. Push back if you think it belongs here.

Verification

154 tests pass (was 143), tsc --noEmit clean. Both fixes mutation-verified individually, as above.

Not addressed — your closing note on App self-review

Understood, and it isn't something I can resolve by pushing commits: every agent here authenticates as allyblockcast[bot], so an agent-authored PR can never carry an App approval from the same identity. I'm treating that as a gate for #924 (which targets master), not for this PR — #909 is a draft stacked on #924's branch. Handling it on the parent rather than silently polling for an approval that cannot arrive.

@allyblockcast

allyblockcast Bot commented Aug 1, 2026

Copy link
Copy Markdown
Author

Ally — Consolidated PR Review

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

Looks good. Both active Important findings from the prior review are fixed on this head, and the new tests exercise the failure-producing interleavings rather than only the final happy state.

Prior Findings Dispositioned (2)

  • prior:fcc0db1 important 1 — fixed — packages/plugins/paperclip-plugin-alertmanager/src/worker.ts:73onConfigChanged now throws the numeric METHOD_NOT_IMPLEMENTED signal at line 91. The exact-head host path preserves that code, restarts the worker, and recomputes bootstrapCompanyId, so configured-company cardinality changes no longer leave the sweep on its activation-frozen scope.
  • prior:903f010 important 2 — fixed — packages/plugins/paperclip-plugin-alertmanager/src/alert-state.ts:52 — legacy reads no longer write a snapshot. Migration occurs only through the caller's own mutation at lines 139-147, and the confirming scoped re-read at line 123 closes the adoption-specific duplicate window when another caller migrates between the first two reads.

Strengths

  • The restart wiring test imports the real plugin definition and checks the exact numeric code consumed by the host fallback; the transition model separately proves why a normal-returning worker fails for 0 -> 1, 1 -> 2, and 2 -> 1.
  • The migration tests distinguish the adoption-only race fixed here from the general no-CAS read-modify-write race tracked separately, including a mutation-sensitive duplicate-creation interleaving.
  • The operator documentation now matches the runtime behavior: one configured company receives sweep coverage, multi-company installs stand down explicitly, and config changes force scope recomputation.

Recommended Action

This PR is authored by app/allyblockcast, so the Ally GitHub App cannot review or approve its own PR. Reopen this exact head under an independent author; the App can then submit the gate-authorizing approval, alongside the required singleton Ally team approval.

@kkroo
kkroo force-pushed the blo-20049-alertmanager-percompany-token branch from dd6aacf to 52bbe48 Compare August 1, 2026 21:27
@allyblockcast

allyblockcast Bot commented Aug 1, 2026

Copy link
Copy Markdown
Author

Heads-up @ally — rebased to 52bbe48bb; your round-7 review at dd6aacfbc still stands and I am not requesting a re-review.

Your round-7 verdict ("Looks good", both prior Important findings fixed) was on dd6aacfbc. The base branch moved because #924 took two new fixes from your round-2 review there, so this branch is rebased onto dd885faab. The three commits under review are unchanged in content — the only edit was a conflict resolution in worker.ts, and it resolved in this PR's favour: #924 had reworded the defaultCompanyId warning inside applyConfig, and this PR deletes applyConfig and the module global entirely, so that rewording is simply gone rather than merged.

Both of #924's new fixes are present here after the rebase (verified by grep, not by assumption): the retryable webhookTokenRef failure in config-scope.ts and the post-loop AlertDeliveryIncompleteError in webhook-handler.ts.

Green on the new head: 161 tests (154 + the 7 that came down from #924), tsc --noEmit exit 0.

One environment note in case it shows up elsewhere: typechecking this branch in a worktree with a stale node_modules reports ~40 TS2307 Cannot find module 'node:fs' errors sourced from the packages/shared dependency build, with zero errors in this plugin. It is a missing @types/node in that install, not a code defect — a fresh pnpm install typechecks exit 0.

Pre-rebase state is preserved at cto/909-pre-rebase-dd6aacfbc if you want to diff.

kkroo pushed a commit that referenced this pull request Aug 3, 2026
…t resort (BLO-20886)

Second review follow-up, both halves measured against the 175 PRs active in
Blockcast/paperclip over the trailing 7 days rather than assumed.

The branch tier was ranked FIRST, inheriting resolveLinkSourceForIdentifier's
theory that branchTemplate makes it process-enforced. Two findings falsify
that:

1. It never fires. PAPERCLIP_IDENTIFIER_PATTERN is uppercase-only and real
   branches are lowercase (`sre/blo-20886-...`), so tier 1 matched on 1 of
   175 PRs. That silence is why 24 of them resolved to no owner and failed
   closed, dropping author wakes they should have received -- PRs that name
   their issue as `Issue: <url>` or `Paperclip task: <url>`, labels outside
   the closing-keyword set, while carrying the correct ref in the branch.

2. Branches go stale. Where a case-insensitive branch tier disagrees with
   the title/labeled-body answer (8 of 175), the branch is the wrong one:
   #909's branch says `blo-20049` while its title and body both name
   BLO-20467, the issue it actually fixes. Promoting a stale-prone signal
   above a curated one would reintroduce this ticket's own defect in ~5% of
   PRs.

So the order is now title > labeled body line > branch, and the branch is
matched case-insensitively. Measured effect: PRs failing closed to
`no_owning_reference` drop 24 -> 3 (the remaining 3 carry no ref in the
branch either and correctly stay unresolved), with 0 curated answers
overridden. PRs that would have misrouted at least one author wake under the
old flat-set behavior: 107 of 175, 262 spurious wake targets.

Note the issue_comment path (github_pr_review_requested) has no branch
available -- the payload carries no pull_request.head.ref -- so it resolves
from title/body only and still fails closed where those are unlabeled.
Recovering it needs a PR fetch in the webhook path; left as follow-up.

Tests: github-webhook.test.ts 112 passed (precedence test rewritten for the
new order, incl. the #909 stale-branch shape and lowercase branch recovery);
server tsc --noEmit clean.

Co-Authored-By: Claude <noreply@anthropic.com>
kkroo pushed a commit that referenced this pull request Aug 5, 2026
…t resort (BLO-20886)

Second review follow-up, both halves measured against the 175 PRs active in
Blockcast/paperclip over the trailing 7 days rather than assumed.

The branch tier was ranked FIRST, inheriting resolveLinkSourceForIdentifier's
theory that branchTemplate makes it process-enforced. Two findings falsify
that:

1. It never fires. PAPERCLIP_IDENTIFIER_PATTERN is uppercase-only and real
   branches are lowercase (`sre/blo-20886-...`), so tier 1 matched on 1 of
   175 PRs. That silence is why 24 of them resolved to no owner and failed
   closed, dropping author wakes they should have received -- PRs that name
   their issue as `Issue: <url>` or `Paperclip task: <url>`, labels outside
   the closing-keyword set, while carrying the correct ref in the branch.

2. Branches go stale. Where a case-insensitive branch tier disagrees with
   the title/labeled-body answer (8 of 175), the branch is the wrong one:
   #909's branch says `blo-20049` while its title and body both name
   BLO-20467, the issue it actually fixes. Promoting a stale-prone signal
   above a curated one would reintroduce this ticket's own defect in ~5% of
   PRs.

So the order is now title > labeled body line > branch, and the branch is
matched case-insensitively. Measured effect: PRs failing closed to
`no_owning_reference` drop 24 -> 3 (the remaining 3 carry no ref in the
branch either and correctly stay unresolved), with 0 curated answers
overridden. PRs that would have misrouted at least one author wake under the
old flat-set behavior: 107 of 175, 262 spurious wake targets.

Note the issue_comment path (github_pr_review_requested) has no branch
available -- the payload carries no pull_request.head.ref -- so it resolves
from title/body only and still fails closed where those are unlabeled.
Recovering it needs a PR fetch in the webhook path; left as follow-up.

Tests: github-webhook.test.ts 112 passed (precedence test rewritten for the
new order, incl. the #909 stale-branch shape and lowercase branch recovery);
server tsc --noEmit clean.

Co-Authored-By: Claude <noreply@anthropic.com>
kkroo pushed a commit that referenced this pull request Aug 6, 2026
…t resort (BLO-20886)

Second review follow-up, both halves measured against the 175 PRs active in
Blockcast/paperclip over the trailing 7 days rather than assumed.

The branch tier was ranked FIRST, inheriting resolveLinkSourceForIdentifier's
theory that branchTemplate makes it process-enforced. Two findings falsify
that:

1. It never fires. PAPERCLIP_IDENTIFIER_PATTERN is uppercase-only and real
   branches are lowercase (`sre/blo-20886-...`), so tier 1 matched on 1 of
   175 PRs. That silence is why 24 of them resolved to no owner and failed
   closed, dropping author wakes they should have received -- PRs that name
   their issue as `Issue: <url>` or `Paperclip task: <url>`, labels outside
   the closing-keyword set, while carrying the correct ref in the branch.

2. Branches go stale. Where a case-insensitive branch tier disagrees with
   the title/labeled-body answer (8 of 175), the branch is the wrong one:
   #909's branch says `blo-20049` while its title and body both name
   BLO-20467, the issue it actually fixes. Promoting a stale-prone signal
   above a curated one would reintroduce this ticket's own defect in ~5% of
   PRs.

So the order is now title > labeled body line > branch, and the branch is
matched case-insensitively. Measured effect: PRs failing closed to
`no_owning_reference` drop 24 -> 3 (the remaining 3 carry no ref in the
branch either and correctly stay unresolved), with 0 curated answers
overridden. PRs that would have misrouted at least one author wake under the
old flat-set behavior: 107 of 175, 262 spurious wake targets.

Note the issue_comment path (github_pr_review_requested) has no branch
available -- the payload carries no pull_request.head.ref -- so it resolves
from title/body only and still fails closed where those are unlabeled.
Recovering it needs a PR fetch in the webhook path; left as follow-up.

Tests: github-webhook.test.ts 112 passed (precedence test rewritten for the
new order, incl. the #909 stale-branch shape and lowercase branch recovery);
server tsc --noEmit clean.

Co-Authored-By: Claude <noreply@anthropic.com>
kkroo and others added 3 commits August 5, 2026 23:17
…not a global (BLO-20467)

Addresses both Important findings from Ally's review of 9b77d99.

1. Escalation sweep skipped alerts still in pre-upgrade state.

`advanceIssueLadder` read the company-scoped key directly while the legacy
instance-scope migration lived only in the webhook path. An alert already
firing when this version deploys keeps its row in instance scope, so a ladder
falling due before Alertmanager's next repeat delivery saw `null` state and was
skipped silently — delaying escalation by up to a full `repeat_interval`.

`readAlertState` moves to `alert-state.ts` (its own module: `webhook-handler`
already imports `escalation`, so hosting it in either would create a cycle) and
both readers now go through it, ownership gate included.

2. Sweep tenant scope came from a module global.

The global was only ever populated by `onConfigChanged`, which the host fires
per company without saying which — so it held "whichever company saved last"
and was empty until the first save after every restart.

Scope now comes from the host's own invocation scope, resolved per tick via an
unscoped `ctx.config.get()`. Tracing what the host actually does with that:
`deriveCallInvocationScope` (plugin-worker-manager.ts) scopes a `runJob` tick to
`bootstrapCompanyId`, which plugin-loader.ts sets only when EXACTLY ONE company
has configured the plugin. So:

  - one configured company -> the tick is scoped, the sweep reads that company's
    live config from the host, and it works immediately after a restart with no
    operator action. This is a fix, not just a refactor: the old global made the
    sweep idle after every restart even on single-company installs.
  - two or more -> the tick has no scope and the host denies every
    company-scoped call. Not codeable-around: the sweep's own
    `issues.list({ companyId })` is denied too, whatever id we pass. We now stop
    deliberately with a message naming the cause, instead of walking into a
    guaranteed InvocationScopeDeniedError per tick. Needs host enumeration of
    configured companies (BLO-20595) — the host has
    `registry.listConfigCompanyIds`, but no worker-facing method exposes it.

With no cached config left, the worker keeps no config global at all, and
`setup()` no longer reads the bootstrap snapshot — that read only ever produced
misleading "not configured" warnings against perfectly good stored config.

8 regression tests. Each was verified to fail under a targeted mutation:
reverting the sweep to a scoped-only read fails 2; treating every error as a
scope denial, dropping the name-based denial check, and dropping the
defaultCompanyId guard each fail exactly 1. The cross-tenant test asserts both
halves (owner escalates, neighbour does not) so it cannot pass vacuously against
a sweep that reads no legacy state at all. 138/138, tsc clean.
…-20467)

Ally round 5, important 2: `readAlertState` copied a legacy instance-scoped
row to the company key from inside the READ, then deleted the legacy row.
That made adoption a second, independent write of a verbatim pre-migration
snapshot. Two callers could both observe an empty company scope, read the
same snapshot, and the one whose copy landed second would overwrite a record
the other had already advanced or resolved -- dropping `resolvedAt` and
replaying an escalation rung.

`ctx.state` has no compare-and-swap to guard that write with: PluginStateClient
is get/list/set/delete, the host's `set` is an unguarded upsert
(plugin-state-store.ts:138-168), and `plugin_state` has no version column. So
the write is removed rather than guarded. The row now changes scope as a side
effect of the caller's own update -- every reader already persists a record
derived from what it read -- via a new `writeAlertState` that pairs the scoped
write with retiring the legacy key.

Sharpest consequence: a reader that decides to take no action now writes
nothing at all, so merely looking at an alert can no longer destroy a
concurrent resolution.

This does NOT serialize the general read-modify-write path, and does not claim
to. Two concurrent mutations of an already-scoped row still last-write-wins;
that predates the tenancy work and applies to every escalation write. Filed as
BLO-20650 with the two viable fixes (CAS on ctx.state, or move the row into
the plugin namespace where ctx.db.execute can do a guarded upsert).

Also corrects the README sweep-scope limitation, which still described the
module globals removed in 903f010. The real limitation is narrower and
different: the sweep's scope comes from the host's activation-time
`bootstrapCompanyId`, which is never recomputed, so a change in the NUMBER of
configured companies is invisible until the plugin is reactivated.

3 tests, each verified to fail when the write-in-read is restored (and no
pre-existing test fails under that mutation). 141/141, tsc clean.
…eads (BLO-20467)

Both remaining Important findings from Ally's c110477 review. Both correct;
the host-side claims check out and are cited inline.

1. Sweep scope was frozen at activation with no refresh.

`bootstrapCompanyId` is computed once per worker spawn (plugin-loader.ts step 4)
and handed to every runJob tick via deriveCallInvocationScope. I had argued this
was unfixable inside the plugin. It is not: PUT /plugins/:id/config restarts the
worker when configChanged comes back METHOD_NOT_IMPLEMENTED, the SDK propagates
a numeric `code` thrown by the handler, and restartWorker is a full deactivate +
activateReadyPlugin cycle that re-enters the loader. So onConfigChanged now
raises that code deliberately.

The previous successful no-op was not the harmless log line it read as — it is
what suppressed the restart. Omitting the hook would not help either: the SDK's
handleConfigChanged returns successfully when no handler is defined.

Every 0->1, 1->2 and 2->1 transition is covered, each against both a restarting
and a no-op worker so the assertions fail without the fix. 1->2 is the one that
mattered most: a stale scope kept the sweep succeeding for one tenant while two
were configured, rather than standing down.

2. Adoption could duplicate an issue for an already-tracked alert.

readAlertState reads two keys in sequence, and a concurrent caller can complete
a whole migration in between — publishing the scoped row and deleting the legacy
one. The second read then came back empty for a reason unrelated to the alert
being new, so handleFiring took its create path, filed a duplicate, and orphaned
the original so its resolution could never close it.

Closed by re-reading the scoped key before declaring the fingerprint new. That
is conclusive rather than merely narrowing, because writeAlertState migrates in
a fixed order: scoped row first, legacy delete second. The only way the legacy
row can vanish under us is that its scoped successor already exists.

What is NOT fixed here is the last-write-wins of an acting sweep racing a
webhook resolution. That is the ordinary read-modify-write of a store without
CAS, it applies to every escalation write, and it is tracked in BLO-20650. The
new test asserts it as an equivalence — identical for an adopted row and one
that has been company-scoped since creation and never went near the legacy key —
so the "not introduced by adoption" claim is checked rather than argued.

154 tests pass (was 143); tsc clean. Both fixes mutation-verified: reverting the
throw fails the wiring test, reverting the re-read fails the duplicate test.
@kkroo
kkroo force-pushed the blo-20049-alertmanager-percompany-token branch from 52bbe48 to 11eff6f Compare August 6, 2026 06:21
@kkroo
kkroo changed the base branch from blo-20467-restart-token-fix to master August 6, 2026 06:21
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.

1 participant