Critical Date Alerting

Critical date alerting is the layer that watches the time-sensitive terms buried in a canonical lease record and makes sure a human acts on them before the clock runs out. It lives inside Downstream Automation & Sync, consuming the validated temporal terms — option-exercise deadlines, notice windows, insurance-certificate expiry, estoppel deadlines, rent-review dates — and turning each one into a staged, stateful sequence of reminders that escalate to progressively more senior owners until someone acknowledges them. A missed option deadline can cost a tenant a renewal; a lapsed insurance certificate can void coverage on a whole building. This page is the engineering of the machine that refuses to let those dates pass silently.

It is important to separate this from calendar event generation. A calendar entry is a passive artifact: it is written once, it sits in someone’s calendar, and it does nothing if that person is on leave, has muted the calendar, or never opened it. Alerting is active and stateful. It runs on a schedule, re-evaluates every watched date against today, fires a specific reminder tier when a threshold is crossed, records whether the recipient acknowledged it, and — if the acknowledgement does not arrive inside a service-level window — escalates to a manager and finally to a manual-review queue. The two are complementary: the Calendar Event Generation guide covers the passive artifacts, while everything here is the escalation ladder, the acknowledgement contract, and the idempotency guarantee that keeps re-evaluation from spamming duplicate alerts.

Where alerting sits in the pipeline

Keeping this layer’s remit narrow is what makes it trustworthy under audit. It never parses a document and it never decides what a critical date is — it subscribes to already-canonical dates and decides who to tell, when, and how loudly.

  • Upstream of it: the canonical lease data models that carry each due date as a typed, effective-dated field, and the Escalation Formula Mapping rules that turn a rent-review clause into a concrete review date on the calendar.
  • This stage: for each watched date, compute which reminder tier is due today, dispatch it idempotently to the right owner, track acknowledgement, and advance the escalation state machine when an acknowledgement is overdue.
  • Downstream of it: an unacknowledged, escalated date routes through Fallback Routing Logic into the manual-review queue, exactly the same seam a low-confidence extraction uses, so operations has one place to triage things that machines could not resolve.

An alerting layer that also tries to interpret a notice clause, or bill a rent review, becomes impossible to reason about. It answers one question — “for this date, on this run, what is the next action, and has it already been taken?” — and hands everything else off.

The escalation state machine

The core abstraction is a state machine, not a list of reminders. Each watched date begins Pending. As today advances past each tier threshold, the date moves to Notified at that tier and a reminder goes to the owner. An acknowledgement from any tier moves the date to Acknowledged and stops the ladder. If a tier’s reminder goes unacknowledged past the acknowledgement SLA, the date Escalates to the manager; if the deadline itself passes with no acknowledgement, it drops into Manual review via fallback routing. The diagram below is the contract the evaluator implements.

Critical-date escalation state machine and tiered reminder ladder A watched critical date starts in a Pending state that watches the canonical due date. On the first scheduled evaluation it enters a four-rung escalation ladder of notification tiers: T-120, T-60, T-30, and T-7 days before the deadline. Each rung sends a reminder to the owner; when the acknowledgement SLA lapses with no acknowledgement, evaluation drops to the next nearer rung. An acknowledgement event from any tier moves the date to the Acknowledged state, which stops the ladder. If the final T-7 tier goes unacknowledged past the SLA, the date moves to the Escalated state and the manager is notified; if it is still unacknowledged, it is routed into the Manual review queue through fallback routing. Every evaluation is idempotent, so re-running the same day never re-sends a tier already dispatched. Pending watches canonical due date Escalation ladder · tiered reminders Notified · T-120 120 days before deadline Notified · T-60 60 days before deadline Notified · T-30 30 days before deadline Notified · T-7 final tier · 7 days out SLA lapsed · no ack SLA lapsed · no ack SLA lapsed · no ack first eval Acknowledged owner confirms · ladder stops Escalated manager notified Manual review fallback-routing queue ack event no ack after T-7 deadline passed daily re-evaluation is idempotent · a fired tier never re-sends
Each watched date climbs the ladder as the deadline nears; an acknowledgement from any tier stops it, while an unacknowledged final tier escalates to the manager and then to manual review. Re-running the evaluator on the same day is a no-op.

Prerequisites and environment setup

The reference implementation is deliberately small so it can be vendored into a scheduled worker or a serverless function without dragging in a framework. The evaluator itself is pure and synchronous — given a date, a state, and today, it returns a decision — which is what makes it trivially testable and safe to re-run.

Dependency Version Role in the alerting layer
python 3.11+ datetime.date arithmetic, enum.StrEnum, structural typing via Protocol
pydantic 2.6+ Schema enforcement on CriticalDate and AlertState via field_validator / model_validator
APScheduler 3.10+ In-process cron trigger that runs the daily evaluation sweep (a system cron entry works equally well)
notification transport A thin Protocol over email / Slack / SMS so the evaluator stays transport-agnostic and testable

Install with pip install "pydantic>=2.6" APScheduler. Three environment assumptions matter. First, evaluation runs on a fixed daily cadence in a single, well-defined timezone — usually the timezone of the lease’s jurisdiction — because a critical date is a legal calendar date, not an instant, and mixing UTC boundaries with local dates is the single most common source of off-by-one alerts. Second, alert dispatch must be idempotent: the same evaluation, re-run because a job retried or a second worker woke up, must never emit a second copy of a tier already sent. Third, the notification transport is abstracted behind a Protocol so the deterministic core never imports an email client — dispatch is injected, which keeps the evaluator unit-testable with a fake transport and a frozen today.

Primary implementation

The design splits cleanly into three pieces: two pydantic models that hold the immutable schedule (CriticalDate) and the mutable progress (AlertState); a pure evaluate function that maps (date, state, today) to a single AlertDecision; and an AlertDispatcher that performs the side effect exactly once, keyed on (lease_id, date_type, tier). The evaluator never sends anything and never mutates state in place — it decides, and the caller applies. That separation is what makes the whole ladder deterministic and replayable.

from __future__ import annotations

import logging
from datetime import date
from enum import Enum
from typing import Optional, Protocol

from pydantic import BaseModel, Field, field_validator

logger = logging.getLogger(__name__)


class DateType(str, Enum):
    OPTION_EXERCISE = "option_exercise"
    NOTICE_WINDOW = "notice_window"
    INSURANCE_EXPIRY = "insurance_expiry"
    ESTOPPEL_DEADLINE = "estoppel_deadline"
    RENT_REVIEW = "rent_review"


class AlertStatus(str, Enum):
    PENDING = "pending"
    NOTIFIED = "notified"
    ACKNOWLEDGED = "acknowledged"
    ESCALATED = "escalated"
    MANUAL_REVIEW = "manual_review"


# Tiers are days-before-deadline, strictly descending, so the evaluator can fire
# the single nearest un-notified rung on any given run.
DEFAULT_TIERS: tuple[int, ...] = (120, 60, 30, 7)


class CriticalDate(BaseModel):
    """Immutable watched date, sourced from the canonical lease record."""
    lease_id: str
    date_type: DateType
    due_date: date
    owner_email: str
    manager_email: str
    tiers: tuple[int, ...] = DEFAULT_TIERS
    ack_sla_days: int = Field(default=5, ge=1)

    @field_validator("tiers")
    @classmethod
    def _descending_and_unique(cls, v: tuple[int, ...]) -> tuple[int, ...]:
        if list(v) != sorted(set(v), reverse=True):
            raise ValueError("tiers must be unique and strictly descending days-before")
        return v


class AlertState(BaseModel):
    """Mutable progress for one watched date. Persisted between runs."""
    lease_id: str
    date_type: DateType
    last_tier_fired: Optional[int] = None   # smallest days-before already sent
    last_fired_on: Optional[date] = None    # when that tier was dispatched
    acknowledged: bool = False
    escalated: bool = False
    status: AlertStatus = AlertStatus.PENDING


class AlertDecision(BaseModel):
    dispatch: bool = False
    channel: str = "noop"                    # reminder | escalation | manual_review | noop
    tier: Optional[int] = None
    recipient: Optional[str] = None
    key: Optional[str] = None                # idempotency key, or None for a no-op
    next_status: AlertStatus = AlertStatus.PENDING


def current_tier(cd: CriticalDate, days_left: int) -> Optional[int]:
    """The nearest tier whose threshold today has already crossed, else None."""
    crossed = [t for t in cd.tiers if days_left <= t]
    return min(crossed) if crossed else None


def evaluate(cd: CriticalDate, state: AlertState, today: date) -> AlertDecision:
    """Pure decision: given the date, its state, and today, what happens next?

    Deterministic and side-effect free. Re-running with the same inputs yields
    the same decision, and applying that decision twice is a no-op because the
    dispatcher deduplicates on the returned idempotency key.
    """
    days_left = (cd.due_date - today).days

    # 1. Resolved dates are inert. An acknowledgement stops the ladder for good.
    if state.acknowledged:
        return AlertDecision(next_status=AlertStatus.ACKNOWLEDGED)

    # 2. Deadline has passed unacknowledged: hand to manual review exactly once.
    if days_left < 0:
        if state.status is AlertStatus.MANUAL_REVIEW:
            return AlertDecision(next_status=AlertStatus.MANUAL_REVIEW)
        return AlertDecision(
            dispatch=True,
            channel="manual_review",
            recipient=cd.manager_email,
            key=f"{cd.lease_id}:{cd.date_type.value}:review",
            next_status=AlertStatus.MANUAL_REVIEW,
        )

    tier = current_tier(cd, days_left)
    if tier is None:
        # Too early — no threshold crossed yet. Keep watching.
        return AlertDecision(next_status=AlertStatus.PENDING)

    # 3. A nearer rung than any previously fired is now due -> send its reminder.
    if state.last_tier_fired is None or tier < state.last_tier_fired:
        return AlertDecision(
            dispatch=True,
            channel="reminder",
            tier=tier,
            recipient=cd.owner_email,
            key=f"{cd.lease_id}:{cd.date_type.value}:T-{tier}",
            next_status=AlertStatus.NOTIFIED,
        )

    # 4. Same rung still outstanding and the ack SLA has elapsed -> escalate once.
    sla_elapsed = (
        state.last_fired_on is not None
        and (today - state.last_fired_on).days >= cd.ack_sla_days
    )
    if sla_elapsed and not state.escalated:
        return AlertDecision(
            dispatch=True,
            channel="escalation",
            tier=tier,
            recipient=cd.manager_email,
            key=f"{cd.lease_id}:{cd.date_type.value}:T-{tier}:escalate",
            next_status=AlertStatus.ESCALATED,
        )

    # 5. Nothing new this run — the idempotent no-op path.
    return AlertDecision(next_status=state.status)


class NotificationTransport(Protocol):
    def send(self, recipient: str, subject: str, body: str) -> None: ...


class AlertDispatcher:
    """Performs the side effect exactly once per idempotency key."""

    def __init__(self, transport: NotificationTransport, sent_keys: set[str] | None = None):
        self._transport = transport
        self._sent = sent_keys if sent_keys is not None else set()

    def dispatch(self, decision: AlertDecision, cd: CriticalDate) -> bool:
        if not decision.dispatch or decision.key is None:
            return False
        if decision.key in self._sent:                      # idempotency guard
            logger.info("skip duplicate alert %s", decision.key)
            return False
        subject = (
            f"[{decision.channel}] {cd.date_type.value} for {cd.lease_id} "
            f"due {cd.due_date.isoformat()}"
        )
        self._transport.send(decision.recipient, subject, subject)
        self._sent.add(decision.key)
        logger.info("dispatched %s to %s", decision.key, decision.recipient)
        return True


def apply(state: AlertState, decision: AlertDecision, dispatched: bool, today: date) -> AlertState:
    """Advance persisted state after a decision has (or has not) been dispatched."""
    updates: dict = {"status": decision.next_status}
    if decision.next_status is AlertStatus.ACKNOWLEDGED:
        updates["acknowledged"] = True
    if decision.channel == "reminder" and dispatched:
        updates["last_tier_fired"] = decision.tier
        updates["last_fired_on"] = today
    if decision.channel == "escalation" and dispatched:
        updates["escalated"] = True
    return state.model_copy(update=updates)

The daily sweep is thin: load every open CriticalDate, evaluate it against today, dispatch, persist. Wiring it to APScheduler or a cron line is a few lines, and because evaluate is pure the whole loop is trivially testable by freezing today.

def run_daily_sweep(
    dates: list[tuple[CriticalDate, AlertState]],
    dispatcher: AlertDispatcher,
    today: date,
) -> list[AlertState]:
    updated: list[AlertState] = []
    for cd, state in dates:
        decision = evaluate(cd, state, today)
        dispatched = dispatcher.dispatch(decision, cd)
        updated.append(apply(state, decision, dispatched, today))
    return updated

When a recipient acknowledges — clicks a link, replies, marks done in the app — the handler sets acknowledged=True on that date’s AlertState and persists it. The very next sweep sees rule 1 and the ladder falls silent. There is no separate “cancel” path to get wrong; acknowledgement is just a state that every subsequent decision short-circuits on.

Validation and quality gates

The guarantees that make this layer safe to point at a live portfolio are enforced structurally, not by convention:

  • Idempotent dispatch. Every alert carries a key of (lease_id, date_type, tier) — or :review / :escalate for the terminal channels. The dispatcher records sent keys (in production, a unique constraint in the alert-log table, not an in-memory set), so a retried job, an overlapping second worker, or a re-run over the same day is a no-op. This is the same replay-safety contract the Payment Schedule Sync layer uses when it posts rent rows: emit with a natural key, dedupe on write.
  • No alert for resolved or expired dates. Rule 1 makes an acknowledged date inert forever; rule 2 sends a date past its deadline to review exactly once and then goes quiet. Neither can re-enter the reminder ladder, so a portfolio that has been running for years does not accumulate a growing backlog of noise.
  • Deterministic tier selection. current_tier returns the single nearest crossed threshold, and a reminder fires only when that tier is strictly nearer than the last one sent. Skipping a tier — because the job did not run for a fortnight and two thresholds were crossed at once — collapses to one reminder at the nearest rung, never a burst of backdated ones.
  • Schema enforcement at the boundary. CriticalDate validates that tiers are unique and descending and that the ack SLA is at least a day, so a misconfigured schedule fails loudly at load time rather than producing a silently broken ladder. Dates and SLAs are typed; a due date arrives as a date, never a string parsed at alert time.

Anything the evaluator cannot resolve on its own — a date that reaches its deadline unacknowledged — is not dropped. It is escalated through the same fallback-routing seam the rest of the platform uses for low-confidence work, so operations triages missed critical dates in one queue. The detailed playbook for that queue, including how a reviewer reconstructs why a notice window lapsed and re-arms the schedule, is covered in Escalating Missed Notice Windows to Manual Review.

Troubleshooting

Symptom Likely cause Diagnostic and fix
The same tier alert arrives twice Job retried or two workers ran the sweep; dispatch not deduped Back the sent-key set with a unique constraint on (lease_id, date_type, tier) in the alert-log table; make the dispatch an insert-if-absent so a replay is a no-op rather than a second email.
Reminders fire a day early or late today computed in UTC while the due date is a local legal date Evaluate in the lease’s jurisdiction timezone; compare date to date, never datetime. A single UTC boundary shifts every threshold by a day near midnight.
An acknowledged date keeps escalating Acknowledgement written to a stale copy of the state, or the ack handler didn’t persist Ensure the ack handler updates the same persisted AlertState the sweep reads; assert rule 1 fires by unit-testing evaluate with acknowledged=True.
A notice date moved but old alerts still reference the deadline An amendment changed the due date; the watched CriticalDate was not re-derived Treat the amendment as an event: recompute the date via escalation-formula mapping, reset last_tier_fired/last_fired_on, and let the ladder re-arm against the new deadline.
A date escalates to the manager on every single run escalated flag never set, so rule 4 re-fires Confirm apply writes escalated=True after an escalation dispatch; the guard and not state.escalated depends on that flag being persisted.
A deadline passed with no alert at all The scheduled sweep did not run for a stretch Monitor sweep liveness (last-run heartbeat) and alert on staleness; because current_tier collapses missed thresholds, a resumed sweep still fires the nearest rung and then routes overdue dates to review.

Performance and scale notes

The evaluator is cheap — a subtraction and a small comparison per date — so cost is dominated by loading state and dispatching, not computing. A single daily sweep over tens of thousands of watched dates finishes in well under a minute if you avoid the obvious trap: do not query every date individually. Load open dates in pages, batch the state read, and dispatch through a transport that itself batches network calls. Because each decision is independent, the sweep parallelizes cleanly across workers as long as the idempotency key remains the arbiter of truth — two workers evaluating the same date concurrently will both decide “send T-30”, but only the first insert of that key wins, and the second is discarded.

Persist AlertState in the same store as the canonical lease so acknowledgement and escalation are part of the lease’s audit trail, not a side database that can drift. And keep the cadence honest: a critical-date system that silently stops running is worse than none, because it manufactures false confidence. A last-run heartbeat with its own staleness alert is the one piece of the design that must never itself depend on the sweep. For dates whose thresholds are derived — a rent-review date that follows a CPI cycle, say — regenerate the watched CriticalDate whenever the underlying escalation formula mapping changes, so the ladder always tracks the current terms rather than a stale snapshot.

Frequently asked questions

How is alerting different from generating a calendar event? A calendar event is a passive artifact: it is written once and does nothing on its own. Alerting is an active, stateful escalation ladder — it re-evaluates each date on a schedule, fires tiered reminders, tracks whether someone acknowledged, and escalates to a manager and then a manual-review queue if no acknowledgement arrives inside the SLA. Most teams generate calendar events and run alerting side by side.

What tier schedule should I use for critical dates? A four-tier ladder of T-120, T-60, T-30, and T-7 days works well for option and notice deadlines because it gives an owner months of runway and then a sharp final nudge. Short-fuse items like an estoppel certificate may warrant a tighter ladder. The tiers are configured per date and validated to be unique and strictly descending, so you can tune them without touching the evaluator.

How do I stop duplicate alerts when the job re-runs? Give every alert a natural idempotency key of lease id, date type, and tier, and record sent keys with a unique constraint. Dispatch becomes insert-if-absent, so a retried job, an overlapping worker, or a same-day re-run is a no-op. The evaluator is pure, so re-running it produces the same decision, and the dedup guard makes acting on that decision twice harmless.

What happens when a lease amendment changes a critical date? Treat the amendment as an event that re-derives the watched date. Recompute the due date from the amended terms, reset the tier-fired and last-fired markers on the alert state, and let the ladder re-arm against the new deadline. Never edit the deadline in place while leaving old tier history attached, or the evaluator will reason about a date that no longer exists.

Where does a date go if nobody ever acknowledges it? If the acknowledgement SLA lapses on the final tier, it escalates to the manager; if the deadline itself passes unacknowledged, it routes into the manual-review queue through the same fallback-routing seam the platform uses for low-confidence work. Operations triages it there, reconstructs why it lapsed, and re-arms the schedule if the date is still actionable.

← Back to Downstream Automation & Sync

← Back to Downstream Automation & Sync