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.
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/:escalatefor 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_tierreturns 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.
CriticalDatevalidates 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 adate, 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.
Related
- Escalating Missed Notice Windows to Manual Review — the triage playbook for dates that reach the manual-review queue, and how a reviewer re-arms the ladder.
- Calendar Event Generation — the passive calendar artifacts that complement this active escalation layer.
- Payment Schedule Sync — the sibling downstream layer that posts rent and CAM rows with the same idempotent, natural-key dispatch contract.
- Fallback Routing Logic — the shared seam that carries unacknowledged, escalated dates into the manual-review queue.
- Escalation Formula Mapping — how rent-review and other derived dates are computed before this layer ever watches them.