"""Add ``hr_lifecycle_events.dispatched_at`` — durable coordinator-dispatch marker. Revision ID: 439 Revises: 438 Create Date: 2026-07-31 #2220: both apply paths (``confirm_batch`` and ``process_webhook_events``) gate coordinator dispatch on ``event_created or upgraded_from_seed``. Both also commit the person flip + the ledger row BEFORE dispatch (#2130, to avoid holding ``identity_persons`` locks across vendor round-trips). So when dispatch raises — a transient 1205, a blocker-scan AppException, a deploy killing the process mid-flight — the ledger row is already durable and every subsequent confirm/webhook for the same ``(person, event_type, effective_date)`` computes ``event_created=False`` and silently skips dispatch forever. The staged row is marked ``failed`` with "retry next sync", and the retry does nothing: no OffboardingProcess, no T-7/T-3/T-1 reminders, no Leaver-Blocked, no welcome mail / SF e-mail write-back. ``dispatched_at`` makes "coordinators have run for this ledger row" a persisted fact instead of an inference from row age, so a failed dispatch re-arms on the next apply. Backfill: every EXISTING row is stamped with ``created_at``. A NULL default over historical rows would make the first post-deploy sync re-dispatch coordinators for the entire ledger — welcome e-mails and offboarding fan-out for people onboarded months ago. Only rows created after this migration start out NULL. """ from __future__ import annotations import sqlalchemy as sa from alembic import op revision = "439" down_revision = "438" branch_labels = None depends_on = None def upgrade() -> None: op.add_column( "hr_lifecycle_events", sa.Column("dispatched_at", sa.DateTime(timezone=True), nullable=True), ) # Historical rows: treat as already dispatched (see docstring). op.execute( "UPDATE hr_lifecycle_events SET dispatched_at = created_at " "WHERE dispatched_at IS NULL" ) def downgrade() -> None: op.drop_column("hr_lifecycle_events", "dispatched_at")