Skip to content

🔨 [3550 by Claude Opus 5.5] Plugin inbound webhooks: verified endpoints that call a handler - #3579

Open
propr-dev[bot] wants to merge 4 commits into
3542-epic-plugin-system-xu8from
3550/claude-opus-5-5-plugin-inbound-webhooks-v-20261115-1755-x67
Open

propr-dev[bot] wants to merge 4 commits into
3542-epic-plugin-system-xu8from
3550/claude-opus-5-5-plugin-inbound-webhooks-v-20261115-1755-x67

Conversation

@propr-dev

@propr-dev propr-dev Bot commented Nov 15, 2026 •

Copy link
Copy Markdown
Contributor

ProPR 🔨 Waiting for CI · Ultrafix cycle 3 of 10 · last review 6/10 · checks: 34 passed, 9 pending · updated 19:50 UTC

AI Implementation Summary

Closes #3550

Branch: 3550/claude-opus-5-5-plugin-inbound-webhooks-v-20261115-1755-x67
Commits: ✅ Changes committed (c2b0f6c)


AI Processing Completed

Execution Details:

Summary:
External services can now call a plugin at POST /api/plugins/<installation key>/hooks/<name>. ProPR checks the request's signature or token before any plugin code runs, then runs the plugin's handler from a queue. Everything I ran passes: 19 server-side test files including the new ones, 58 UI component tests and 5 Playwright tests. Typecheck, the zero-warning lint and the OpenAPI check are clean too. I didn't run the full repository suite or a docs site build.

Manifest. A webhook is now declared as { name, description, handler, verification, deliveryIdHeader?, respond? }. verification is one of:

  • hmac-sha256: a header holding an optional prefix plus the HMAC of the raw body, in hex or base64.
  • slack-v0: Slack's signature, with the timestamp required to be within 5 minutes.
  • token-header: a header holding an optional prefix plus the secret, compared in constant time.

Each scheme uses one of the plugin's signing-secret connections. This replaces the old placeholder { name, description, connection }, so I updated the two tests that used it.

What the route does with a request:

  • 404: an unknown or regenerated key, a disabled or uninstalled plugin, or a webhook name the manifest doesn't declare. Nothing is loaded.
  • 401: a missing or wrong signature, a stale Slack timestamp, or no signing secret set. It is counted per installation and logged with only the plugin, webhook and reason.
  • 409: a delivery id that was already received (a replay).
  • 413: a body over 256 KiB.
  • 429: the same rate limiter /webhook uses, applied before the body is read.
  • 202: verified and queued. The handler then runs as a delivery of kind webhook, shown in Activity, with the same retry schedule as event deliveries.

Challenge responses. respond names another function in handlers.ts. It runs after verification and gets only the frozen request, never ctx, and it must return synchronously. Slack's url_verification echo works this way.

Installation key. It is stored in a new plugin_webhook_keys table. Regenerate replaces it in one step, so the old URLs answer 404 at once, and it is recorded in the audit trail. Uninstalling deletes the key, so a reinstall gets a new URL. Two new admin routes list the URLs and regenerate the key; I regenerated the OpenAPI spec and client types.

Plugins page. The Connect step now has a Webhook URLs section: one URL per webhook with Copy, the count of rejected requests, and Regenerate behind a confirmation. Preview screenshots (desktop, the confirmation, and phone) are attached separately.

Behaviour you might not expect:

  • Request bodies are never written anywhere, so the queue lives only in the API process's memory. A webhook delivery still waiting when the API stops is marked failed on the next start, and a failed webhook delivery has no Retry button; the sending service has to send it again.
  • Webhook failures don't count towards the plugin's Degraded or Paused health. While event deliveries are paused, new webhook requests get 503.
  • A webhook's signing secret isn't required to enable the plugin. Until it is set, requests get 401.

Acceptance criteria

  • AC1: met — pluginWebhookReceiver.ts, pluginWebhookRoutes.ts; packages/core/test/pluginWebhooks.test.ts ("a verified request runs its handler once; … run no plugin code") checks the handler runs once and no plugin module loads for a bad signature, stale timestamp or replay; packages/api/test/pluginWebhookRoutes.test.ts covers 413 for oversized bodies and 429 from the rate limiter.
  • AC2: met — pluginWebhooks.test.ts uses GitHub's documented vector and RFC 4231 test case 2 for hmac-sha256 (hex and base64), Slack's documented request for slack-v0, and GitLab-style and bearer vectors for token-header, each with tampered cases.
  • AC3: met — pluginWebhooks.test.ts ("the synchronous challenge response …"): the challenge is echoed with no handler run, and a respond function that reaches for ctx gets one frozen argument, fails with 500, and changes nothing. The route test also checks the echo over HTTP.
  • AC4: met — pluginWebhooks.test.ts and pluginWebhookRoutes.test.ts: disabled, uninstalled and reinstalled plugins answer 404, and after Regenerate the old URL answers 404 while the new one works.
  • AC5: met — sentinel tests in pluginWebhooks.test.ts and pluginWebhookRoutes.test.ts dump every table and capture every log and console line. I also added a deliberate body log, and the test caught it.
  • AC6: met — propr-ui/src/pages/PluginsPage/PluginWebhookUrls.tsx in PluginConnections.tsx; PluginWebhookUrls.test.tsx, propr-ui/e2e/plugin-webhooks.pw.ts.
  • AC7: met — docs/docs/features/plugins.md (Inbound webhooks: the URL, verification schemes, delivery, challenge responses, routes) and docs/docs/architecture/plugin-runtime.md (Inbound webhooks: key, request path, schemes table, respond function, delivery runner, what is stored).

Review digest

Acceptance criteria (7 of 7 met, as stated by the agent)

  • ✅ AC1 (met): Valid signature runs the handler once; bad signature, stale timestamp, replayed delivery id and oversized body are rejected without running plugin code; the rate limiter applies. — `pluginWebhookReceiver.ts`, `pluginWebhookRoutes.ts`; `packages/core/test/pluginWebhooks.test.ts` ("a verified request runs its handler once; … run no plugin code") checks the handler runs once and no plugin module loads for a bad signature, stale timestamp or replay; `packages/api/test/pluginWebho…
  • ✅ AC2 (met): Each verification scheme has a test with a documented test vector. — `pluginWebhooks.test.ts` uses GitHub's documented vector and RFC 4231 test case 2 for `hmac-sha256` (hex and base64), Slack's documented request for `slack-v0`, and GitLab-style and bearer vectors for `token-header`, each with tampered cases.
  • ✅ AC3 (met): The synchronous challenge response works and cannot call `ctx`. — `pluginWebhooks.test.ts` ("the synchronous challenge response …"): the challenge is echoed with no handler run, and a respond function that reaches for `ctx` gets one frozen argument, fails with 500, and changes nothing. The route test also checks the echo over HTTP.
  • ✅ AC4 (met): A disabled plugin's URL answers 404; regenerating the key invalidates the old URL. — `pluginWebhooks.test.ts` and `pluginWebhookRoutes.test.ts`: disabled, uninstalled and reinstalled plugins answer 404, and after Regenerate the old URL answers 404 while the new one works.
  • ✅ AC5 (met): Request bodies and signature headers are never stored or logged (sentinel test). — sentinel tests in `pluginWebhooks.test.ts` and `pluginWebhookRoutes.test.ts` dump every table and capture every log and console line. I also added a deliberate body log, and the test caught it.
  • ✅ AC6 (met): The Plugins page shows the URL per webhook with copy and Regenerate. — `propr-ui/src/pages/PluginsPage/PluginWebhookUrls.tsx` in `PluginConnections.tsx`; `PluginWebhookUrls.test.tsx`, `propr-ui/e2e/plugin-webhooks.pw.ts`.
  • ✅ AC7 (met): Docs: webhook verification schemes and the URL in `docs/docs/features/plugins.md` and `docs/docs/architecture/plugin-runtime.md`. — `docs/docs/features/plugins.md` (Inbound webhooks: the URL, verification schemes, delivery, challenge responses, routes) and `docs/docs/architecture/plugin-runtime.md` (Inbound webhooks: key, request path, schemes table, respond function, delivery runner, what is stored).

Risk-tiered paths

  • High risk (1 file)
    • packages/core/src/db/migrations/20261116000000_plugin_webhook_keys.js (added, +30/−0): database migration
  • Normal risk (22 files)
    • packages/api/openapi/directRoutes.ts (+2/−0)
    • packages/api/openapi/registeredRoutes.ts (+1/−1)
    • packages/api/openapi/routeDocsPlugins.ts (+28/−1)
    • packages/api/openapi/schemasPlugins.ts (+18/−7)
    • packages/api/pluginRouteRegistry.ts (+5/−2)
    • packages/api/routes/pluginWebhookRoutes.ts (added, +71/−0)
    • packages/api/server.ts (+12/−1)
    • packages/client/src/generated/apiTypes.ts (+38/−10)
    • packages/core/src/index.ts (+3/−0)
    • packages/core/src/plugins/pluginStore.ts (+2/−0)
    • packages/core/src/plugins/pluginWebhookReceiver.ts (added, +125/−0)
    • packages/core/src/plugins/pluginWebhookRunner.ts (added, +167/−0)
    • packages/core/src/plugins/pluginWebhooks.ts (added, +213/−0)
    • packages/shared/src/pluginDeliveries.ts (+10/−5)
    • packages/shared/src/pluginInstallations.ts (+1/−1)
    • packages/shared/src/pluginSdk.ts (+32/−1)
    • packages/shared/src/plugins.ts (+48/−5)
    • propr-ui/src/api/pluginsApi.ts (+7/−0)
    • propr-ui/src/pages/PluginsPage/PluginActivity.tsx (+8/−6)
    • propr-ui/src/pages/PluginsPage/PluginConnections.tsx (+3/−1)
    • propr-ui/src/pages/PluginsPage/PluginWebhookUrls.tsx (added, +95/−0)
    • propr-ui/src/pages/PluginsPage/usePluginWebhooks.ts (added, +33/−0)
  • Low risk (18 files)
    • docs/docs/architecture/plugin-runtime.md (+128/−2)
    • docs/docs/features/plugins.md (+162/−5)
    • docs/docs/operations/configuration-reference.md (+1/−1)
    • docs/static/openapi/propr-api.yaml (+265/−6)
    • packages/api/test/pluginActivityRoutes.test.ts (+4/−2)
    • packages/api/test/pluginRoutes.test.ts (+4/−2)
    • packages/api/test/pluginWebhookRoutes.test.ts (added, +183/−0)
    • packages/core/test/fixtures/plugins/fixture-webhooks/handlers.ts (added, +45/−0)
    • packages/core/test/fixtures/plugins/fixture-webhooks/manifest.json (added, +34/−0)
    • packages/core/test/fixtures/plugins/registry.json (+2/−1)
    • packages/core/test/helpers/pluginHandlerFixture.ts (+2/−1)
    • packages/core/test/pluginContributions.test.ts (+2/−1)
    • packages/core/test/pluginStore.test.ts (+3/−2)
    • packages/core/test/pluginWebhooks.test.ts (added, +257/−0)
    • packages/shared/test/plugins.test.ts (+20/−2)
    • propr-ui/e2e/plugin-webhooks.pw.ts (added, +86/−0)
    • propr-ui/src/pages/PluginsPage/PluginActivity.test.tsx (+11/−0)
    • propr-ui/src/pages/PluginsPage/PluginWebhookUrls.test.tsx (added, +113/−0)

Validation evidence

  • No repository validation commands ran.

Diff size: 41 files changed, +2,274/−66 lines

Detailed Logs:

  • Conversation: 36 messages
  • Session: 9ec4bcf7-6210-40a3-b9af-c0c5552352de

Log files stored at:

  • conversation: /usr/src/app/logs/executions/issue-3550-2026-11-15T18-26-13-060Z-conversation.json
  • output: /usr/src/app/logs/executions/issue-3550-2026-11-15T18-26-13-060Z-output.txt
Latest Conversation Messages
ASSISTANT: [content unavailable]

ASSISTANT: External services can now call a plugin at `POST /api/plugins/<installation key>/hooks/<name>`. ProPR checks the request's signature or token before any plugin code runs, then runs the plugin's handle...


This PR was created automatically by ProPR after processing issue #3550.


💡 Need changes?

Comment on this PR to request refinements — the AI agent monitors comments and will update the implementation based on your feedback. Keep iterating until you're satisfied!


Visual preview

Webhook URLs in the Connect step (desktop)

Webhook URLs in the Connect step (desktop)

Below the signing secret, one URL per webhook with its verification scheme and a Copy button (the second just copied), the count of rejected unverified requests, and Regenerate.

Full-size original

Webhook URLs after Regenerate (phone)

Webhook URLs after Regenerate (phone)

The new URLs at 390 px wide, with full-width Copy buttons and no horizontal overflow; the rejected count started over with the new key.

Full-size original

Regenerate confirmation (desktop)

Regenerate confirmation (desktop)

Regenerate asks for confirmation first: every URL stops working at once.

Full-size original

…lation key>/hooks/<name>`. ProPR checks the request's signature or token before any plugin code runs, then runs the plugin's handler from a queue. Everything I ran passes: 19 server-side test files including the new ones, 58 UI component tests and 5 Playwright tests. Typecheck, the zero-warning lint and the OpenAPI check are clean too. I didn't run the full repository suite or a docs site build.

Manifest. A webhook is now declared as `{ name, description, handler, verification, deliveryIdHeader?, respond? }`. `verification` is one of:
- `hmac-sha256`: a header holding an optional prefix plus the HMAC of the raw body, in hex or base64.
- `slack-v0`: Slack's signature, with the timestamp required to be within 5 minutes.
- `token-header`: a header holding an optional prefix plus the secret, compared in constant time.

Each scheme uses one of the plugin's signing-secret connections. This replaces the old placeholder `{ name, description, connection }`, so I updated the two tests that used it.

What the route does with a request:
- 404: an unknown or regenerated key, a disabled or uninstalled plugin, or a webhook name the manifest doesn't declare. Nothing is loaded.
- 401: a missing or wrong signature, a stale Slack timestamp, or no signing secret set. It is counted per installation and logged with only the plugin, webhook and reason.
- 409: a delivery id that was already received (a replay).
- 413: a body over 256 KiB.
- 429: the same rate limiter `/webhook` uses, applied before the body is read.
- 202: verified and queued. The handler then runs as a delivery of kind `webhook`, shown in Activity, with the same retry schedule as event deliveries.

Challenge responses. `respond` names another function in `handlers.ts`. It runs after verification and gets only the frozen request, never `ctx`, and it must return synchronously. Slack's `url_verification` echo works this way.

Installation key. It is stored in a new `plugin_webhook_keys` table. Regenerate replaces it in one step, so the old URLs answer 404 at once, and it is recorded in the audit trail. Uninstalling deletes the key, so a reinstall gets a new URL. Two new admin routes list the URLs and regenerate the key; I regenerated the OpenAPI spec and client types.

Plugins page. The Connect step now has a Webhook URLs section: one URL per webhook with Copy, the count of rejected requests, and Regenerate behind a confirmation. Preview screenshots (desktop, the confirmation, and phone) are attached separately.

Behaviour you might not expect:
- Request bodies are never written anywhere, so the queue lives only in the API process's memory. A webhook delivery still waiting when the API stops is marked failed on the next start, and a failed webhook delivery has no Retry button; the sending service has to send it again.
- Webhook failures don't count towards the plugin's Degraded or Paused health. While event deliveries are paused, new webhook requests get 503.
- A webhook's signing secret isn't required to enable the plugin. Until it is set, requests get 401.

Acceptance criteria
- AC1: met — `pluginWebhookReceiver.ts`, `pluginWebhookRoutes.ts`; `packages/core/test/pluginWebhooks.test.ts` ("a verified request runs its handler once; … run no plugin code") checks the handler runs once and no plugin module loads for a bad signature, stale timestamp or replay; `packages/api/test/pluginWebhookRoutes.test.ts` covers 413 for oversized bodies and 429 from the rate limiter.
- AC2: met — `pluginWebhooks.test.ts` uses GitHub's documented vector and RFC 4231 test case 2 for `hmac-sha256` (hex and base64), Slack's documented request for `slack-v0`, and GitLab-style and bearer vectors for `token-header`, each with tampered cases.
- AC3: met — `pluginWebhooks.test.ts` ("the synchronous challenge response …"): the challenge is echoed with no handler run, and a respond function that reaches for `ctx` gets one frozen argument, fails with 500, and changes nothing. The route test also checks the echo over HTTP.
- AC4: met — `pluginWebhooks.test.ts` and `pluginWebhookRoutes.test.ts`: disabled, uninstalled and reinstalled plugins answer 404, and after Regenerate the old URL answers 404 while the new one works.
- AC5: met — sentinel tests in `pluginWebhooks.test.ts` and `pluginWebhookRoutes.test.ts` dump every table and capture every log and console line. I also added a deliberate body log, and the test caught it.
- AC6: met — `propr-ui/src/pages/PluginsPage/PluginWebhookUrls.tsx` in `PluginConnections.tsx`; `PluginWebhookUrls.test.tsx`, `propr-ui/e2e/plugin-webhooks.pw.ts`.
- AC7: met — `docs/docs/features/plugins.md` (Inbound webhooks: the URL, verification schemes, delivery, challenge responses, routes) and `docs/docs/architecture/plugin-runtime.md` (Inbound webhooks: key, request path, schemes table, respond function, delivery runner, what is stored).
@propr-dev

propr-dev Bot commented Nov 15, 2026

Copy link
Copy Markdown
Contributor Author

/ultrafix goal=8 max=10
Triggered automatically by the requested execution settings.

@propr-dev propr-dev Bot added the ultrafix label Nov 15, 2026
@propr-dev

propr-dev Bot commented Nov 15, 2026

Copy link
Copy Markdown
Contributor Author

🔄 Ultrafix loop started (goal: 8/10, max cycles: 10)

First action: /review

💡 Tip: Remove the ultrafix label from this PR to stop further ultrafix cycles.

@propr-dev

propr-dev Bot commented Nov 15, 2026 •

Copy link
Copy Markdown
Contributor Author

✅ AI Code Review Complete requested by @propr-ultrafix

Posted 1 review:

View Task Details

@propr-dev

propr-dev Bot commented Nov 15, 2026

Copy link
Copy Markdown
Contributor Author

CI failed: Validate unsigned linux-x64 package

Please investigate and fix this CI failure.

  • Check: Validate unsigned linux-x64 package
  • Result: failure
  • Commit: c2b0f6c63499 (c2b0f6c6349915add4caa287ddba9f795899b327)
  • Details: View CI failure

Failure evidence

.github:38
Process completed with exit code 2.

.github:43
Type 'null' is not assignable to type 'number'.

@propr-dev

propr-dev Bot commented Nov 15, 2026 •

Copy link
Copy Markdown
Contributor Author

✅ Applied the requested follow-up changes in commit 3da3c8e

Summary of Changes

I fixed the CI failure: the propr-ui typecheck (tsc --noEmit) was failing on a test file this PR added. The packaging job runs that typecheck, so it went red too.

Cause: In propr-ui/e2e/plugin-webhooks.pw.ts, the mock state for the rejected-request count starts with lastAt set to a number. TypeScript therefore typed lastAt as number. The Regenerate route mock then sets it to null (line 43), which the type didn't allow.

Fix: I declared lastAt on line 19 as number | null, the same way lastReason is already declared as string | null. This only changes a type, so the test behaves exactly as before.

tsc --noEmit and ESLint on that file now pass in propr-ui. I didn't rerun the Playwright test or the full packaging job, since the test's behaviour didn't change. There are no new previews because nothing visible changed.

Review digest

Risk-tiered paths

  • High risk: none
  • Normal risk: none
  • Low risk (1 file)
    • propr-ui/e2e/plugin-webhooks.pw.ts (+1/−1)

Validation evidence

  • No repository validation commands ran.

Diff size: 1 file changed, +1/−1 lines


🤖 Implementation Details

  • Model: Claude Opus 5.5 (medium)
  • Requested By: @propr-dev[bot]
  • Time: 3m 47s
  • Tokens: 193,632 (192,670 in / 962 out)
  • Cost: $0.25

Undo Changes • View Task Execution


💡 ProPR Slash Commands
Command Description Example
/merge Merge target branch into this PR and resolve conflicts /merge
/review Request an AI code review /review or /review claude-sonnet
/fix Address review blockers, or blockers and suggestions with /fix all /fix, /fix all, or /fix F3 S5
/switch Change the AI model for this PR /switch claude-opus
/use Override the model for a single follow-up run /use claude-sonnet
/ultrafix Loop review→fix cycles until score goal is met /ultrafix or /ultrafix goal=8 max=10

🤖 Generated by ProPR
Processing comment ID: 6899204646✓

…Emit`) was failing on a test file this PR added. The packaging job runs that typecheck, so it went red too.

I fixed the CI failure: the `propr-ui` typecheck (`tsc --noEmit`) was failing on a test file this PR added. The packaging job runs that typecheck, so it went red too.

**Cause:** In `propr-ui/e2e/plugin-webhooks.pw.ts`, the mock state for the rejected-request count starts with `lastAt` set to a number. TypeScript therefore typed `lastAt` as `number`. The Regenerate route mock then sets it to `null` (line 43), which the type didn't allow.

**Fix:** I declared `lastAt` on line 19 as `number | null`, the same way `lastReason` is already declared as `string | null`. This only changes a type, so the test behaves exactly as before.

`tsc --noEmit` and ESLint on that file now pass in `propr-ui`. I didn't rerun the Playwright test or the full packaging job, since the test's behaviour didn't change. There are no new previews because nothing visible changed.

PR: #3579
Comment by: @propr-dev[bot] (ID: 6899204646)
Model: Claude Opus 5.5
@propr-dev

propr-dev Bot commented Nov 15, 2026

Copy link
Copy Markdown
Contributor Author

🔍 AI Code Review — codex:gpt-6-astra

Overall Evaluation

This PR adds inbound webhook verification, queued delivery, installation-key management, and UI/documentation support. It needs significant corrections before merge: replay handling, payload confidentiality, recovery ownership, and delivery identity have verified defects.

✅ Verification before dispatch — All three schemes validate credentials before handler dispatch; Slack verification also checks timestamp freshness.

✅ Administrative controls — URL management reuses durable administrator authorization, and regeneration requires the existing preflight-protected mutation contract.

✅ Focused coverage — Added tests cover signature vectors, key rotation, disabled installations, body limits, and UI interactions.

This review is a static analysis of the supplied diff and context; no commands were run. The authoritative checks show 31 passed, three pending, and no failures.

Merge blockers

Every finding below was introduced by this PR and must be resolved before merging.

F1: 🔴 Responders run before replay rejection

  • Category: correctness

  • Required behavior: AC1 requires replayed delivery IDs to be rejected without running plugin code.

  • Evidence: packages/core/src/plugins/pluginWebhookReceiver.ts:109 — receive calls respond before deriving or reserving the delivery ID.

    1. A declared webhook has both respond and deliveryIdHeader; its responder returns null for ordinary events.
    2. A verified request executes the responder and is subsequently accepted into the delivery table.
    3. The sender repeats that request with the same delivery ID.
    4. The receiver loads and executes the responder again before runner.accept discovers the duplicate and returns 409.

    The duplicate has already run plugin code, violating the explicit refusal guarantee. If the responder returns a challenge response, the receiver returns immediately without recording or checking that delivery ID at all. Verification authenticates the request but does not establish freshness. Existing replay tests use signed, which has no responder.

    static trace: verified from the ordering in receive, respond, and PluginWebhookRunner.accept.

  • Minimum fix: Validate and atomically reserve declared delivery IDs before invoking any responder, including challenge responses, while preserving appropriate retry behavior when processing fails. Add coverage combining a responder with a delivery-ID header.

F2: 🔴 Handler logs can persist request bodies

  • Category: security

  • Required behavior: AC5 requires request bodies never to be stored or logged.

  • Evidence: packages/core/src/plugins/pluginWebhookRunner.ts:150, packages/core/src/plugins/pluginWebhookRunner.ts:160 — webhook input reaches the ordinary handler runner, and its returned errors and logs are persisted.

    1. A verified request contains ordinary text such as customer-private-message-123.
    2. Its handler calls ctx.log.info(input.body) or throws new Error(input.body), for example while reporting an unexpected payload.
    3. The existing context redacts credentials and recognized secret patterns; it is not given the webhook body as sensitive input.
    4. The webhook runner writes the resulting text into plugin_deliveries.logs or error, making it available through Activity.

    Arbitrary request content is not necessarily a credential or recognizable secret. The supplied sentinel fixture deliberately records only body length, so its passing assertion does not exercise this path.

    static trace: verified through the new runner’s invocation and persistence calls and the supplied pluginHandlerRunner and pluginContext excerpts.

  • Minimum fix: Enforce webhook payload confidentiality at the diagnostic boundary, covering errors and log messages/fields before persistence. If arbitrary handler diagnostics cannot meet that guarantee, retain only safe webhook diagnostic metadata. Add sentinel cases that deliberately log and throw request content.

F3: 🔴 Startup recovery fails another process’s deliveries

  • Category: correctness

  • Required behavior: Recovery must only abandon deliveries whose owning process has stopped; starting another API process must not discard accepted work.

  • Evidence: packages/core/src/plugins/pluginWebhookRunner.ts:61 — recover; packages/api/server.ts:693 — startup invokes recovery for every new runner.

    1. API process A accepts a webhook, returns 202, and holds its request while the delivery waits for a retry.
    2. API process B starts against the same database while A remains alive.
    3. B calls recover() with an empty local held map and marks every unfinished webhook delivery failed, including A’s.
    4. When A’s retry timer fires, attempt reads the failed status and deletes its held request without executing the retry.

    This permanently drops accepted work. The server explicitly accommodates multiple API processes, but recovery has no process ownership or liveness condition. Excluding IDs in B’s local map cannot protect A’s deliveries. The supplied recovery test inserts an orphan and does not exercise a live owner.

    static trace: verified from startup, the unrestricted recovery update, and the status guard in attempt. The competing actor is B’s startup recovery during A’s scheduled retry delay; no lease or transaction fences that update.

  • Minimum fix: Associate unfinished webhook deliveries with their owning process and recover only owners established to be dead, or enforce a single webhook runner owner before performing recovery. Add a two-runner regression proving startup cannot finalize a live runner’s pending or retrying work.

F4: 🔴 Delivery IDs collide across webhook names

  • Category: correctness

  • Required behavior: Replay detection must distinguish different webhooks and their respective sender delivery IDs.

  • Evidence: packages/core/src/plugins/pluginWebhookReceiver.ts:94 — deliveryId; packages/core/src/plugins/pluginWebhookRunner.ts:73 — insertion uses the resulting string as the unique delivery identity.

    1. One plugin declares valid webhook names a and a-b, both with delivery-ID headers.
    2. Webhook a receives sender ID b-c, producing whk-a-b-c.
    3. Webhook a-b receives sender ID c, also producing whk-a-b-c.
    4. The second legitimate request conflicts with the first record’s (plugin_id, delivery_id) key and returns 409 without delivery.

    Both names and IDs satisfy the new validators. A shared plugin ID and ambiguous hyphen concatenation make distinct requests indistinguishable; signature verification cannot prevent this collision.

    static trace: verified from deliveryId, the permitted webhook-name/delivery-ID characters, and the delivery table’s unique constraint.

  • Minimum fix: Use an unambiguous encoding or a separate composite uniqueness key for webhook name and sender ID. Add the two-webhook collision case.

Suggestions

These are optional follow-ups and are not sent to /fix.

S1: 🟢 Describe raw bodies in OpenAPI

packages/api/openapi/routeDocsPlugins.ts describes arbitrary raw input but models the request as a JSON object, and documents only a text challenge response despite supporting JSON. Aligning these schemas and media types would improve generated-client usability. This is optional because the runtime route already accepts the documented body formats.

Score

The implementation has useful verification and UI coverage, but four demonstrated defects affect explicit acceptance criteria and reliable delivery. Passing checks do not cover these failure sequences.

Score: 5/10


🤖 Review Details

  • Model: GPT-6 Astra
  • Time: 1m 4s
  • Tokens: 100,166 (98,319 in / 1,847 out)
  • Cost: $0.97

View Task


💡 Next step: Comment /fix to address every F# merge blocker, /fix all for every pending blocker and suggestion, or name records explicitly, as in /fix F3 S5.
F# and S# IDs increment across review comments and remain permanent, so selectors such as /fix F3 F5 stay unambiguous across cycles. S# suggestions stay optional: they are implemented only when you name them or request /fix all, and they never relax a merge blocker.

Copy and edit to choose records from this review:

/fix F1 F2 F3 F4 S1

🤖 Review by ProPR

@propr-dev

propr-dev Bot commented Nov 15, 2026 •

Copy link
Copy Markdown
Contributor Author

✅ Applied the requested follow-up changes in commit 2031768

Addressed 1 AI review comment (IDs: 6899511469)

Addressed findings F1, F2, F3, F4

Summary of Changes

I fixed F1–F4. The core plugin suites pass (35 tests across pluginStore, pluginContributions and pluginWebhooks), and so do the three API route test files. pluginRoutes passed and then hung at exit until the timeout killed it, which is a known pattern for API tests in this container. Typecheck for core and api, the zero-warning lint on the changed source files, the OpenAPI check and the PluginActivity UI test are clean.

F1 – a replay could run the responder. The receiver now works out the delivery id first (400 if malformed). It then reserves it in plugin_deliveries before any plugin code runs, so a replay gets 409 without loading the module or calling respond. The runner gained reserve, start, answered and release, and accept is now just reserve then start.

  • Challenge answered: if the id came from the sender, the record is marked succeeded under the respond function's name, so a replayed challenge also gets 409. A ProPR-assigned id can't be sent again, so its record is deleted.
  • Refused before delivery: a respond failure (500), a paused plugin (503) or an error releases the reservation, so the sender's retry is accepted rather than treated as a replay.
  • New test: a responder combined with X-Delivery-Id, checking that it ran once on a replayed ordinary event and on a replayed challenge, and that a paused-then-retried request is delivered.

F2 – handler output could store the body. A webhook attempt now keeps only each log line's level and time, and a fixed error: "The webhook handler failed (its error is not kept: it may contain the request)" or "…timed out". Matching the body text isn't enough, because a handler can log any part or transformation of it. The same change applies where a respond function fails: that log no longer includes the error's name, since a plugin can set it to anything. A new leakyWebhook fixture logs the body as a message and in fields and throws it, and the sentinel test checks that no table or log line contains customer-private-message-123.

F3 – a starting process could fail another's deliveries. Each runner now has an owner name and a heartbeat in a new plugin_webhook_runners table, renewed every 30 s. Each delivery records its owner in a new webhook_owner column. Recovery runs at startup and with every heartbeat, as a single UPDATE, and fails only deliveries with no owner or whose owner's heartbeat is more than 2 minutes old. A new test runs two runners: starting B fails nothing of A, A's retry still succeeds, and a delivery owned by a runner with a stale heartbeat is failed.

F4 – delivery ids collided across webhooks. Ids are now whk-<webhook>:<sender id>, or whk-<webhook>/<uuid> when ProPR assigns one. Webhook names can't contain : or /, so the name always ends where the separator is. The new test sends a with b-c and a-b with c, and both are delivered.

Things to know:

  • I added the new table and column to this PR's existing migration, 20261116000000_plugin_webhook_keys.js, rather than a new file, because tests list migrations by name. Each step is guarded, so it is safe to re-run.
  • Webhook Activity entries no longer show the handler's error or log text. That costs debugging detail, and it's the only way to meet AC5 for arbitrary request content.
  • A process whose event loop stalls for more than 2 minutes would be taken for stopped, and its deliveries failed. That's inherent in using a heartbeat.
  • I updated the docs (plugins.md, plugin-runtime.md), the deliveryId description in the OpenAPI spec and generated client types, and the existing tests that expected the old id format and error text. The fixture manifest has four new webhooks, so the route test's list of webhooks changed too.
  • There are no visible UI changes, so I made no previews.

Not fixed, outside these findings: the audit record that ctx.propr calls write to plugin_audit stores the call's details. A webhook handler that puts request content into such a call (for example a comment body) would have it stored there.

Review digest

Risk-tiered paths

  • High risk (1 file)
    • packages/core/src/db/migrations/20261116000000_plugin_webhook_keys.js (+16/−2): database migration
  • Normal risk (6 files)
    • packages/api/openapi/schemasPlugins.ts (+1/−1)
    • packages/api/server.ts (+1/−1)
    • packages/client/src/generated/apiTypes.ts (+1/−1)
    • packages/core/src/plugins/pluginWebhookReceiver.ts (+47/−21)
    • packages/core/src/plugins/pluginWebhookRunner.ts (+136/−16)
    • packages/shared/src/pluginDeliveries.ts (+1/−1)
  • Low risk (8 files)
    • docs/docs/architecture/plugin-runtime.md (+34/−17)
    • docs/docs/features/plugins.md (+18/−12)
    • docs/static/openapi/propr-api.yaml (+1/−1)
    • packages/api/test/pluginWebhookRoutes.test.ts (+1/−1)
    • packages/core/test/fixtures/plugins/fixture-webhooks/handlers.ts (+16/−0)
    • packages/core/test/fixtures/plugins/fixture-webhooks/manifest.json (+20/−1)
    • packages/core/test/pluginWebhooks.test.ts (+97/−4)
    • propr-ui/src/pages/PluginsPage/PluginActivity.test.tsx (+1/−1)

Validation evidence

  • No repository validation commands ran.

Diff size: 15 files changed, +391/−80 lines


🤖 Implementation Details

  • Model: Claude Opus 5.5 (medium)
  • Requested By: @propr-ultrafix
  • Time: 21m 59s
  • Tokens: 5,180,886 (5,144,801 in / 36,085 out)
  • Cost: $2.36

View Task Execution


💡 ProPR Slash Commands
Command Description Example
/merge Merge target branch into this PR and resolve conflicts /merge
/review Request an AI code review /review or /review claude-sonnet
/fix Address review blockers, or blockers and suggestions with /fix all /fix, /fix all, or /fix F3 S5
/switch Change the AI model for this PR /switch claude-opus
/use Override the model for a single follow-up run /use claude-sonnet
/ultrafix Loop review→fix cycles until score goal is met /ultrafix or /ultrafix goal=8 max=10

🤖 Generated by ProPR

… `pluginStore`, `pluginContributions` and `pluginWebhooks`), and so do the three API route test files. `pluginRoutes` passed and then hung at exit until the timeout killed it, which is a known pattern for API tests in this container. Typecheck for core and api, the zero-warning lint on the changed source files, the OpenAPI check and the `PluginActivity` UI test are clean.

I fixed F1–F4. The core plugin suites pass (35 tests across `pluginStore`, `pluginContributions` and `pluginWebhooks`), and so do the three API route test files. `pluginRoutes` passed and then hung at exit until the timeout killed it, which is a known pattern for API tests in this container. Typecheck for core and api, the zero-warning lint on the changed source files, the OpenAPI check and the `PluginActivity` UI test are clean.

**F1 – a replay could run the responder.** The receiver now works out the delivery id first (400 if malformed). It then reserves it in `plugin_deliveries` before any plugin code runs, so a replay gets 409 without loading the module or calling `respond`. The runner gained `reserve`, `start`, `answered` and `release`, and `accept` is now just reserve then start.
- **Challenge answered:** if the id came from the sender, the record is marked succeeded under the respond function's name, so a replayed challenge also gets 409. A ProPR-assigned id can't be sent again, so its record is deleted.
- **Refused before delivery:** a respond failure (500), a paused plugin (503) or an error releases the reservation, so the sender's retry is accepted rather than treated as a replay.
- **New test:** a responder combined with `X-Delivery-Id`, checking that it ran once on a replayed ordinary event and on a replayed challenge, and that a paused-then-retried request is delivered.

**F2 – handler output could store the body.** A webhook attempt now keeps only each log line's level and time, and a fixed error: "The webhook handler failed (its error is not kept: it may contain the request)" or "…timed out". Matching the body text isn't enough, because a handler can log any part or transformation of it. The same change applies where a respond function fails: that log no longer includes the error's name, since a plugin can set it to anything. A new `leakyWebhook` fixture logs the body as a message and in fields and throws it, and the sentinel test checks that no table or log line contains `customer-private-message-123`.

**F3 – a starting process could fail another's deliveries.** Each runner now has an owner name and a heartbeat in a new `plugin_webhook_runners` table, renewed every 30 s. Each delivery records its owner in a new `webhook_owner` column. Recovery runs at startup and with every heartbeat, as a single UPDATE, and fails only deliveries with no owner or whose owner's heartbeat is more than 2 minutes old. A new test runs two runners: starting B fails nothing of A, A's retry still succeeds, and a delivery owned by a runner with a stale heartbeat is failed.

**F4 – delivery ids collided across webhooks.** Ids are now `whk-<webhook>:<sender id>`, or `whk-<webhook>/<uuid>` when ProPR assigns one. Webhook names can't contain `:` or `/`, so the name always ends where the separator is. The new test sends `a` with `b-c` and `a-b` with `c`, and both are delivered.

Things to know:
- I added the new table and column to this PR's existing migration, `20261116000000_plugin_webhook_keys.js`, rather than a new file, because tests list migrations by name. Each step is guarded, so it is safe to re-run.
- Webhook Activity entries no longer show the handler's error or log text. That costs debugging detail, and it's the only way to meet AC5 for arbitrary request content.
- A process whose event loop stalls for more than 2 minutes would be taken for stopped, and its deliveries failed. That's inherent in using a heartbeat.
- I updated the docs (`plugins.md`, `plugin-runtime.md`), the `deliveryId` description in the OpenAPI spec and generated client types, and the existing tests that expected the old id format and error text. The fixture manifest has four new webhooks, so the route test's list of webhooks changed too.
- There are no visible UI changes, so I made no previews.

Not fixed, outside these findings: the audit record that `ctx.propr` calls write to `plugin_audit` stores the call's details. A webhook handler that puts request content into such a call (for example a comment body) would have it stored there.

PR: #3579
Comment by: @propr-ultrafix (ID: 0)
Model: Claude Opus 5.5
@propr-dev

propr-dev Bot commented Nov 15, 2026 •

Copy link
Copy Markdown
Contributor Author

✅ AI Code Review Complete requested by @propr-ultrafix

Posted 1 review:

View Task Details

@propr-dev

propr-dev Bot commented Nov 15, 2026

Copy link
Copy Markdown
Contributor Author

🔍 AI Code Review — codex:gpt-6-astra

Overall Evaluation

This PR adds inbound webhook verification, delivery tracking, challenge responses, and administration UI. It needs changes before merge: asynchronous responder failures, concurrent capacity admission, and installation lifecycle races remain.

✅ Verification before execution — Signature verification and delivery-ID reservation precede responder loading, including replayed challenges.

✅ Restricted diagnostics — Webhook delivery records discard handler error text and log contents that could contain request data.

✅ End-to-end coverage — The diff includes verification vectors, route tests, lifecycle tests, and UI coverage.

The supplied current-head status shows 32 passed, 2 pending, and no failed checks. This review is based on static tracing; no commands were run.

Merge blockers

Every finding below was introduced by this PR and must be resolved before merging.

F5: 🔴 Rejected responder promises remain unhandled

  • Category: correctness

  • Required behavior: A responder returning a promise must produce a controlled 500 response without disrupting the API process.

  • Evidence: packages/core/src/plugins/pluginWebhookReceiver.ts, checkedResponse and respond

    static trace:

    1. A configured responder is accidentally declared async and throws while parsing a verified request, producing a rejected promise.
    2. Reflect.apply returns that promise. checkedResponse detects its then method and throws a separate synchronous validation error.
    3. respond catches the validation error and returns 500, but never attaches a rejection handler to the responder's original promise.

    The original rejection remains unhandled and can terminate the Node process under its default unhandled-rejection behavior. The surrounding try/catch catches only the synchronous validation error. The fixture tests exercise a synchronous throw, not a rejected promise. Verification is a static trace; no runtime test was executed.

  • Minimum fix: Consume rejection from returned promises while still refusing asynchronous responses with 500. Add a regression covering an async responder that throws, including after an await.

F6: 🔴 Concurrent requests bypass queue capacity

  • Category: correctness

  • Required behavior: Admission must enforce the configured maximum of held and reserved webhook deliveries.

  • Evidence: packages/core/src/plugins/pluginWebhookRunner.ts, reserve

    static trace:

    1. The runner has 999 held or reserved deliveries under the default 1,000 limit.
    2. Two verified requests with distinct delivery IDs call reserve. Both pass the capacity check before either increments any reservation count.
    3. Both yield at await this.register() and subsequently at the database insertion. Each successfully inserts a different delivery and adds its record to reserved.
    4. Both requests can start, leaving 1,001 requests retained in memory. A larger concurrent burst exceeds the limit further.

    The database's unique index prevents duplicate IDs, not excess admission. Even an already-resolved registration promise yields at await; no semaphore or provisional counter protects this window. Per-client rate limiting does not serialize requests or enforce aggregate runner capacity.

  • Minimum fix: Claim a capacity slot synchronously before the first await, and release or transfer that slot on every success, duplicate, error, and shutdown path. Test concurrent admission at the limit.

F7: 🔴 Old requests can recreate deliveries after uninstall

  • Category: correctness

  • Required behavior: Webhook deliveries and replay reservations must belong to the installation that accepted them; uninstall must prevent old requests from contaminating a replacement installation.

  • Evidence: packages/core/src/plugins/pluginWebhookRunner.ts, reserve and current; packages/core/src/plugins/pluginWebhookReceiver.ts, receive

    static trace:

    1. A verified request resolves installation A and enters reserve, then yields at await this.register() before inserting its delivery.
    2. An administrator uninstalls A during that window. Uninstall deletes its delivery records and webhook key. The plugin is then reinstalled as B.
    3. The suspended reservation resumes and inserts A's delivery using only plugin_id and delivery_id; it never checks that A still exists.
    4. The runner's later current check correctly prevents execution against B, but marks the newly inserted record failed and leaves it stored.
    5. The sender submits that delivery ID to B's new URL. The unique (plugin_id, delivery_id) constraint rejects it as a replay, although B never received it.

    Uninstall's transaction cannot delete a row inserted after it commits. The later installation check protects handler execution but does not prevent the stale reservation from poisoning B's replay history. This is a static trace of the explicit await and supplied uninstall behavior.

  • Minimum fix: Validate that the target installation still exists with the same installed_at and is enabled inside the transaction that inserts the reservation. Refuse stale requests without inserting a record. Add an uninstall/reinstall interleaving regression.

Suggestions

These are optional follow-ups and are not sent to /fix.

S2: 🟢 Clarify responder isolation claims

The new documentation says responders cannot send HTTP requests because they receive no ctx, while the same documentation permits Node built-ins and describes plugins as trusted, in-process code. Clarify that responders receive no SDK capabilities, rather than implying runtime isolation. This is optional documentation precision within the existing trusted-plugin model.

Score

The implementation covers the intended feature broadly, but three localized runtime and lifecycle defects require correction. Passing checks do not cover these failure sequences.

Score: 6/10


🤖 Review Details

  • Model: GPT-6 Astra
  • Time: 1m 2s
  • Tokens: 104,962 (103,373 in / 1,589 out)
  • Cost: $0.98

View Task


💡 Next step: Comment /fix to address every F# merge blocker, /fix all for every pending blocker and suggestion, or name records explicitly, as in /fix F3 S5.
F# and S# IDs increment across review comments and remain permanent, so selectors such as /fix F3 F5 stay unambiguous across cycles. S# suggestions stay optional: they are implemented only when you name them or request /fix all, and they never relax a merge blocker.

Copy and edit to choose records from this review:

/fix F5 F6 F7 S2

🤖 Review by ProPR

@propr-dev

propr-dev Bot commented Nov 15, 2026 •

Copy link
Copy Markdown
Contributor Author

✅ Applied the requested follow-up changes in commit c8af153

Addressed 1 AI review comment (IDs: 6900110040)

Addressed findings F5, F6, F7

Summary of Changes

I fixed F5, F6 and F7. Three new regression tests pass and fail when I put the old runner and receiver code back. All 13 tests in pluginWebhooks.test.ts pass, and so do pluginWebhookRoutes.test.ts and pluginStore.test.ts. Core typecheck (including the changed test file) and the zero-warning lint on the two changed source files are clean.

F5: async responder rejections. The check that refuses a promise-shaped answer (checkedResponse in pluginWebhookReceiver.ts) now attaches a no-op rejection handler to the promise before throwing. The request still gets the same 500 and its reservation is still given back. The regression uses an async responder that throws at once and one that throws after an await. It records unhandled rejections and also checks that nothing was queued. On the old code Node reported the after-await rejection as unhandled.

F6: queue capacity under concurrency. reserve in pluginWebhookRunner.ts now takes a capacity slot before its first await. That slot counts towards the limit along with held and reserved deliveries. It turns into a reservation at the same moment the record is added, and is given back on every other path: duplicate, stale installation, shutdown during registration, or an error. The regression sends a burst of 5 concurrent reserves at maxPending: 2 and exactly 2 get in. It also checks that a duplicate or a stale request leaves its slot free, and that a released slot can be used once.

F7: stale requests after uninstall. reserve now checks the installation and inserts the record in the same transaction. If the installation is gone, has a different installed_at, or is disabled, it refuses with a new reason, gone, and records nothing; the receiver answers that with 404, as it would for a dead URL. The database is a single SQLite connection, so this transaction runs strictly before or after uninstall's transaction: either nothing is inserted, or uninstall deletes the row. The regression holds a verified request after its key was resolved, then uninstalls, reinstalls and re-enables the plugin before letting it continue. The held request gets 404 and leaves no record, and the same delivery id sent to the new URL gets 202 and runs.

I looked at the other places the runner acts on a reserved record (start, answered, release and the attempt loop). After an uninstall they find no record and do nothing, so the same defect isn't there and they needed no change.

There are no visual changes, so I made no previews.

Review digest

Risk-tiered paths

  • High risk: none
  • Normal risk (2 files)
    • packages/core/src/plugins/pluginWebhookReceiver.ts (+12/−2)
    • packages/core/src/plugins/pluginWebhookRunner.ts (+39/−16)
  • Low risk (1 file)
    • packages/core/test/pluginWebhooks.test.ts (+81/−1)

Validation evidence

  • No repository validation commands ran.

Diff size: 3 files changed, +132/−19 lines


🤖 Implementation Details

  • Model: Claude Opus 5.5 (medium)
  • Requested By: @propr-ultrafix
  • Time: 7m 0s
  • Tokens: 1,578,344 (1,563,836 in / 14,508 out)
  • Cost: $1.00

View Task Execution


💡 ProPR Slash Commands
Command Description Example
/merge Merge target branch into this PR and resolve conflicts /merge
/review Request an AI code review /review or /review claude-sonnet
/fix Address review blockers, or blockers and suggestions with /fix all /fix, /fix all, or /fix F3 S5
/switch Change the AI model for this PR /switch claude-opus
/use Override the model for a single follow-up run /use claude-sonnet
/ultrafix Loop review→fix cycles until score goal is met /ultrafix or /ultrafix goal=8 max=10

🤖 Generated by ProPR

…fail when I put the old runner and receiver code back. All 13 tests in `pluginWebhooks.test.ts` pass, and so do `pluginWebhookRoutes.test.ts` and `pluginStore.test.ts`. Core typecheck (including the changed test file) and the zero-warning lint on the two changed source files are clean.

I fixed F5, F6 and F7. Three new regression tests pass and fail when I put the old runner and receiver code back. All 13 tests in `pluginWebhooks.test.ts` pass, and so do `pluginWebhookRoutes.test.ts` and `pluginStore.test.ts`. Core typecheck (including the changed test file) and the zero-warning lint on the two changed source files are clean.

**F5: async responder rejections.** The check that refuses a promise-shaped answer (`checkedResponse` in `pluginWebhookReceiver.ts`) now attaches a no-op rejection handler to the promise before throwing. The request still gets the same 500 and its reservation is still given back. The regression uses an `async` responder that throws at once and one that throws after an await. It records unhandled rejections and also checks that nothing was queued. On the old code Node reported the after-await rejection as unhandled.

**F6: queue capacity under concurrency.** `reserve` in `pluginWebhookRunner.ts` now takes a capacity slot before its first await. That slot counts towards the limit along with held and reserved deliveries. It turns into a reservation at the same moment the record is added, and is given back on every other path: duplicate, stale installation, shutdown during registration, or an error. The regression sends a burst of 5 concurrent reserves at `maxPending: 2` and exactly 2 get in. It also checks that a duplicate or a stale request leaves its slot free, and that a released slot can be used once.

**F7: stale requests after uninstall.** `reserve` now checks the installation and inserts the record in the same transaction. If the installation is gone, has a different `installed_at`, or is disabled, it refuses with a new reason, `gone`, and records nothing; the receiver answers that with 404, as it would for a dead URL. The database is a single SQLite connection, so this transaction runs strictly before or after uninstall's transaction: either nothing is inserted, or uninstall deletes the row. The regression holds a verified request after its key was resolved, then uninstalls, reinstalls and re-enables the plugin before letting it continue. The held request gets 404 and leaves no record, and the same delivery id sent to the new URL gets 202 and runs.

I looked at the other places the runner acts on a reserved record (`start`, `answered`, `release` and the attempt loop). After an uninstall they find no record and do nothing, so the same defect isn't there and they needed no change.

There are no visual changes, so I made no previews.

PR: #3579
Comment by: @propr-ultrafix (ID: 0)
Model: Claude Opus 5.5
@propr-dev

propr-dev Bot commented Nov 15, 2026

Copy link
Copy Markdown
Contributor Author

⏳ Ultrafix is waiting for CI before the next /review.

These blocking checks on c8af153 are holding the loop back:

  • Full Test Suite Shard 4/4 — not finished
  • Full Test Suite Shard 1/4 — not finished
  • Validate Changes — not finished
  • Full Test Suite Shard 3/4 — not finished
  • Classify changed surfaces — not finished
  • Classify changed surfaces — not finished
  • Full Test Suite Shard 2/4 — not finished
  • Classify changed surfaces — not finished
  • Classify changed surfaces — not finished
  • Classify changed surfaces — not finished

Checks matching this repository's non-blocking checks never gate Ultrafix. The loop continues automatically once the blocking checks pass; if they have not settled within 2 hours, Ultrafix stops.

This branch has not been deployed

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

0 participants