Repository navigation
feat(alerts): hold a platform delivery until its source releases it - #123623
Conversation
A source can now hold an evaluation's delivery instead of starting it. record_outcomes writes the hold in the outcome's transaction, a release hands the request back to one winner, and a per-minute sweep sends any hold past its deadline. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
😎 Merged successfully - details. |
🤖 CI report
|
| File | Comment lines | Added lines |
|---|---|---|
products/alerts_platform/backend/logic/delivery_holds.py |
6 | 207 |
products/alerts_platform/backend/models/platform_alert.py |
6 | 48 |
products/alerts_platform/backend/temporal/workflows.py |
6 | 93 |
products/alerts_platform/backend/facade/contracts.py |
5 | 41 |
products/alerts_platform/backend/logic/platform_lifecycle.py |
2 | 15 |
products/alerts_platform/backend/tests/test_delivery_holds.py |
2 | 169 |
This check does not block merging. It updates on every push and clears when the share drops.
⚠️ Django migration SQL — 1 new migration to review
We've detected new migrations on this PR. Review the SQL output for each migration:
products/alerts_platform/backend/migrations/0020_delivery_hold.py
/home/runner/work/tmp/tool_cache/Python/3.14.7/x64/lib/python3.14/site-packages/anyio/from_thread.py:119: SyntaxWarning: 'return' in a 'finally' block
return result
2026-10-26T21:17:15.286654Z [error ] Path must be a valid database or directory containing databases. [posthog.exceptions_capture] pid=6526 tid=140242322107264
Traceback (most recent call last):
File "/home/runner/work/posthog/posthog/posthog/geoip.py", line 15, in <module>
geoip: Optional[GeoIP2] = GeoIP2(cache=8)
~~~~~~^^^^^^^^^
File "/home/runner/work/tmp/tool_cache/Python/3.14.7/x64/lib/python3.14/site-packages/django/contrib/gis/geoip2.py", line 116, in __init__
raise GeoIP2Exception(
"Path must be a valid database or directory containing databases."
)
django.contrib.gis.geoip2.GeoIP2Exception: Path must be a valid database or directory containing databases.
/home/runner/work/tmp/tool_cache/Python/3.14.7/x64/lib/python3.14/site-packages/sshtunnel.py:1040: SyntaxWarning: 'return' in a 'finally' block
return (ssh_host,
/home/runner/work/tmp/tool_cache/Python/3.14.7/x64/lib/python3.14/site-packages/langchain_core/_api/deprecation.py:27: UserWarning: Core Pydantic V1 functionality isn't compatible with Python 3.14 or greater.
from pydantic.v1.fields import FieldInfo as FieldInfoV1
System check identified some issues:
WARNINGS:
?: (axes.W001) You are using the django-axes cache handler for login attempt tracking. Your cache configuration is however invalid and will not work correctly with django-axes. This can leave security holes in your login systems as attempts are not tracked correctly. Reconfigure settings.AXES_CACHE and settings.CACHES per django-axes configuration documentation.
?: (staticfiles.W004) The directory '/home/runner/work/posthog/posthog/frontend/dist' in the STATICFILES_DIRS setting does not exist.
BEGIN;
--
-- Create model PlatformAlertDeliveryHold
--
CREATE TABLE "alerts_platformalertdeliveryhold" ("id" uuid NOT NULL PRIMARY KEY, "evaluation_key" varchar(255) NOT NULL, "reason" varchar(64) NOT NULL, "request" jsonb NOT NULL, "held_at" timestamp with time zone NOT NULL, "release_after" timestamp with time zone NOT NULL, "released_at" timestamp with time zone NULL, "release_action" varchar(16) NULL, "released_by" varchar(16) NULL, "delivery_started_at" timestamp with time zone NULL, "configuration_id" uuid NOT NULL, "team_id" integer NOT NULL, CONSTRAINT "platform_alert_delivery_hold_one_per_evaluation" UNIQUE ("configuration_id", "evaluation_key"));
ALTER TABLE "alerts_platformalertdeliveryhold" ADD CONSTRAINT "alerts_platformalert_configuration_id_376c2fa3_fk_alerts_pl" FOREIGN KEY ("configuration_id") REFERENCES "alerts_platformalertconfiguration" ("id") DEFERRABLE INITIALLY DEFERRED;
CREATE INDEX "alerts_platformalertdeliveryhold_configuration_id_376c2fa3" ON "alerts_platformalertdeliveryhold" ("configuration_id");
CREATE INDEX "alerts_platformalertdeliveryhold_team_id_19cc58b1" ON "alerts_platformalertdeliveryhold" ("team_id");
CREATE INDEX "platform_alert_hold_due_idx" ON "alerts_platformalertdeliveryhold" ("release_after") WHERE "released_at" IS NULL;
CREATE INDEX "platform_alert_hold_start_idx" ON "alerts_platformalertdeliveryhold" ("released_at") WHERE ("delivery_started_at" IS NULL AND "release_action" = 'send');
CREATE INDEX "platform_alert_hold_age_idx" ON "alerts_platformalertdeliveryhold" ("held_at");
COMMIT;Last updated: 2026-10-26 21:18 UTC (f7919aa)
✅ Django migration risk — migration analysis complete
We've analyzed your migrations for potential risks.
Summary: 1 Safe | 0 Needs Review | 0 Blocked
✅ Safe
Brief or no lock, backwards compatible
alerts_platform.0020_delivery_hold
└─ #1 ✅ CreateModel
Creating new table is safe
model: PlatformAlertDeliveryHold
│
└──> ℹ️ INFO:
ℹ️ Skipped operations on newly created tables (empty tables
don't cause lock contention).
Last updated: 2026-10-26 21:18 UTC (f7919aa)
There was a problem hiding this comment.
PostHog Review · Standard · reviewed 806a145: 3 Should fix.
This is the only automatic review while this pull request is a draft. Comment @posthog review to review new changes sooner. Otherwise, a review of the new changes runs when you mark it ready for review.
See this review and its findings in PostHog Review. Comment @posthog review --deep for a Deep review. A Deep review takes longer and looks deeper than a Standard review.
Register the hold model in the IDOR semgrep rules. The sweep now re-reads the deadline under the row lock, suppresses the hold of a disabled alert instead of sending it on re-enable, starts released sends whose start was lost, and keeps its result within one payload. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Defer the Temporal converter and payload imports used by held deliveries, because the alerts_platform models load the contracts module during django.setup(). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Risk: No findings This increment refines the delivery-hold safety net: the sweep now skips rows locked by other transactions, reuses delivery workflow ids with ALLOW_DUPLICATE_FAILED_ONLY so a completed delivery is never re-run, and the expired-hold metric counts only fresh deadline releases. I found no new security issues; the reuse-policy change in particular tightens double-send behavior rather than loosening it. Sentinel reviewed |
The sweep restarts lost delivery starts with ALLOW_DUPLICATE_FAILED_ONLY, so a completed delivery does not run again. It skips holds another transaction has locked, and counts only holds it released at their deadline as expired. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The prune deletes holds older than their history by id, across teams, with no request or team input. Mark it for idor-lookup-without-team. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Approved.
The risky changes have owning-team and STRONG-familiarity authorship assurance, with targeted tests. No specific unresolved review concerns or policy refusal grounds are evidenced; the listed formal reviews are on older commits.
- Author wrote 75% of the modified lines and has 29 merged PRs in these paths (familiarity STRONG).
Gate mechanics and policy version
| Gate | Result | |
|---|---|---|
| prerequisites | ✓ | all clear |
| deny-list | ✓ | no deny categories matched |
| size | ✓ | 673L, 15F substantive, 931L/19F incl. docs/generated/snapshots — within ceiling |
| tier | ✓ | T1-agent / T1d-complex (931L, 19F, cross-cutting, feat) |
| stamphog 2.6.1 | .stamphog/policy.yml @ f7919aa · reviewed head f7919aa |
|
/trunk merge |
Problem
Changes
PlatformAlertOutcome.held_delivery(request, reason, deadline) instead of starting it.record_outcomeswrites the hold to a newPlatformAlertDeliveryHoldtable in the outcome's own transaction. A recorded firing cannot lose its hold.release_holdin the facade releases a hold once, with send or suppress:start_deliveries, so the delivery keeps its usual id.reschedule_holdmoves an unreleased hold's deadline, for a source that needs a shorter grace once it knows the outcome.deliver_evaluationrefuses an evaluation whose hold is unreleased or suppressed, as a second guard.alerts-platform-release-expired-holdsruns every minute on the delivery queue.record_hold_delivery_startedlets a releaser mark the delivery it started, so the sweep does not start it again.Warning
A disabled alert's held firing is suppressed, not sent on re-enable. This departs on purpose from the insight stack's own safety net, which skips a disabled alert and sends the firing whenever the alert comes back.
alerts_platform_holds_expired_totalcounts safety-net sends. A rise means a source holds deliveries and does not release them.docs/internal/alerts-platform-temporal.mdgets a "Held deliveries" section.Note
A worker on the previous release decodes
PlatformAlertOutcomewithoutheld_delivery. The source change that sets it must ship at least one deploy after this one.How did you test this code?
Test rationale:
test_delivery_holds.py(new, no existing coverage for holds):test_delivery_evaluation.py: one parameterized case shows an unreleased or suppressed hold sends nothing and a released send goes out. Its default configuration id became a UUID, because the guard queries a UUID column.test_delivery_evaluation.py,test_temporal_workflows.py,test_temporal_schedule.py, and the fullproducts/alerts_platformsuite. 17 platform tests that read history from ClickHouse fail locally because the local test schema predates thesource_detailcolumn on master. CI runs them against a fresh schema.Release status
Docs update
Updated
docs/internal/alerts-platform-temporal.md.🤖 Agent context
Autonomy: Human-driven (agent-assisted)
Agent: Claude Code, Claude Opus 5.5
/django-migrations,/writing-tests,/code-review high --fix,/simplify,/reviewing-with-coderabbit,/writing-pr-descriptions./code-reviewsplit pruning out of the expiry lookup./simplifymovedfrom_storedonto Temporal's own converter, tied hold retention to the history TTL, and kept a failed prune from blocking the sweep.--deep: one finding, fixed. A hold reason longer than its column, or a negative deadline, is now rejected when the hold is built instead of failing the batch record.