Mapping Option Deadlines to Recurring Reminders

A single hard deadline — tenant must give written notice to renew no later than nine months before expiry — is not adequately served by one calendar entry. One reminder on one day is a single point of failure: the responsible person is on leave, the email is buried, the ticket is closed by accident, and a below-market renewal option lapses for a decade. The precise problem this page resolves is how to expand one option-exercise or notice deadline into a ladder of recurring reminders that ramp up in frequency and urgency as the date approaches, and how to stop that ladder cleanly the moment the option is exercised or acknowledged. This is a cadence-design problem, and it sits under Calendar Event Generation, which already computes the hard date; here we decide how often, to whom, and through what mechanism the run-up reminders fire.

The distinction from the sibling guide matters. Generating ICS calendar events for lease renewal deadlines is about serializing the obligation into an RFC 5545 VEVENT with VALARM triggers so it appears on a feed. This page is about the recurrence policy behind the reminders — the tiered lead-time schedule, whether it is best modeled as an RRULE, discrete alarms, or an escalation ladder, and the state machine that halts repetition. You can consume both: derive the date once, build the ladder here, and serialize each rung there.

Architectural context

The engine that owns this logic is strictly downstream of canonicalization. It never re-reads a lease document; it subscribes to a lease.canonicalized event, reads the already-validated option_deadline and notice_days from the lease data models, and derives the hard notice date exactly as the parent Calendar Event Generation stage does: notice_date = option_deadline − notice_days, shifted backward off weekends and jurisdiction holidays. Where a renewal option is present in the text but its notice count is missing, fallback routing logic has already resolved or flagged it, so the ladder never fires against a guessed date.

Once the hard date is fixed, the ladder is a set of derived reminder dates counting backward from it: T-180, T-120, T-90, T-60, T-30, T-14, T-7. Each rung is an independent obligation with its own stable identity, its own audience, and its own channel. Two rungs that miss entirely — the deadline slips inside the window with no acknowledgement — hand off to critical date alerting, which escalates a high-severity event that goes unacknowledged. The whole design is a funnel: gentle and infrequent far out, dense and loud near the date, then silent the instant the option is exercised.

A ladder of recurring reminders converging on an option notice deadline A horizontal timeline runs left to right toward a hard notice deadline on the right marked "give notice by". Seven reminder rungs sit along the timeline at decreasing intervals: T minus 180 and T minus 120 days are quarterly informational reminders routed to the asset manager; T minus 90 and T minus 60 are monthly reminders routed to the lease administrator; T minus 30, T minus 14, and T minus 7 are weekly then daily reminders routed to the lease administrator and, at T minus 7, escalated to a director. Each rung grows taller and darker toward the deadline to show rising urgency and frequency. A branch labelled "option exercised or acknowledged" leaves the T minus 60 rung and drops to a terminal state labelled "ladder halts — no further reminders", showing that acknowledgement stops all remaining rungs. Past the deadline a red marker labelled "missed — escalate to manual review" hands off to critical date alerting. today far out · gentle & infrequent near date · dense & loud Notice deadline give notice by T-180 quarterly T-120 quarterly T-90 monthly T-60 monthly T-30 weekly T-14 weekly T-7 daily option exercised / acknowledged Ladder halts no further reminders missed → escalate to manual review
One hard notice deadline expands into a ladder of reminders that grow denser and louder toward the date; exercising or acknowledging the option halts every remaining rung, while a missed date escalates to manual review.

RRULE vs discrete alarms vs an escalation ladder

There are three honest ways to express “remind me repeatedly before this date,” and they are not interchangeable. A single calendar RRULE (an RFC 5545 recurrence rule) is elegant for a uniform cadence but cannot express tiers that change frequency, audience, or channel as the date nears. Discrete alarms are precise but verbose. The escalation ladder is the model this domain actually needs, because the whole point is that urgency and routing change over the run-up.

Approach How repeats are expressed Varies frequency near date? Per-rung audience / channel? Best fit for option deadlines
Single RRULE One recurrence rule, e.g. FREQ=WEEKLY;UNTIL=<deadline> No — one fixed interval No — one event, one attendee set Uniform “every week until due” nudges only
Discrete VALARMs One alarm per lead offset on a single event Yes, but hand-listed No — alarms share the event’s audience A fixed handful of pop-ups for one owner
Escalation ladder Independent events per tier, each with its own cadence Yes — quarterly → monthly → weekly → daily Yes — each tier routes independently Cross-role option/notice deadlines (recommended)

The pragmatic composite is: model the ladder as a set of discrete reminder events (one per rung), and within a dense tier use a bounded RRULE to avoid emitting thirty daily rows by hand. So T-180 and T-120 are single dated nudges; T-30 through the deadline is one event carrying FREQ=WEEKLY;UNTIL=... and then FREQ=DAILY;UNTIL=... inside T-7. Each tier still owns its audience and channel. The lead-time tiers map to audience and channel like this:

Lead-time tier Cadence Audience (role) Channel Severity
T-180 / T-120 Quarterly (single nudge) Asset manager Calendar + email info
T-90 / T-60 Monthly Lease administrator Calendar + task medium
T-30 Weekly (RRULE) Lease administrator Task + email high
T-14 Weekly (RRULE) Lease administrator + manager Task + email high
T-7 → T-0 Daily (RRULE) Lease admin + director Task + email + SMS critical

The generator takes the derived hard notice date and a cadence policy, and produces the reminder set. The policy is data — a list of tiers — so the people who own the leases can review and tune it without touching branching logic. Every rung is shifted backward off weekends and holidays with the same previous_business_day discipline the parent stage uses, and each carries a stable identity so re-emission upserts rather than duplicates. Rungs whose date has already passed at build time are dropped, and if the deadline is nearer than the widest tier, only the tiers that still fit are emitted.

from __future__ import annotations

import hashlib
from datetime import date, timedelta
from enum import Enum
from typing import Optional

import holidays
from pydantic import BaseModel, Field, field_validator, model_validator


class Cadence(str, Enum):
    ONCE = "once"
    WEEKLY = "weekly"
    DAILY = "daily"


class ReminderTier(BaseModel):
    """One rung of the ladder: fire this many days before the deadline, repeat
    at this cadence up to the deadline, route to this role and channels."""
    lead_days: int = Field(ge=0)
    cadence: Cadence
    role: str
    channels: tuple[str, ...]
    severity: str


class CadencePolicy(BaseModel):
    tiers: list[ReminderTier]

    @field_validator("tiers")
    @classmethod
    def _descending_unique(cls, v: list[ReminderTier]) -> list[ReminderTier]:
        leads = [t.lead_days for t in v]
        if leads != sorted(set(leads), reverse=True):
            raise ValueError("tiers must have unique, strictly descending lead_days")
        return v


class Reminder(BaseModel):
    uid: str
    lease_id: str
    fires_on: date          # business-day-adjusted first occurrence
    repeat_until: Optional[date]   # None for a one-shot rung
    cadence: Cadence
    role: str
    channels: tuple[str, ...]
    severity: str

    @model_validator(mode="after")
    def _repeat_after_start(self) -> "Reminder":
        if self.repeat_until is not None and self.repeat_until < self.fires_on:
            raise ValueError("repeat_until must be on or after fires_on")
        return self


def previous_business_day(d: date, cal: holidays.HolidayBase) -> date:
    while d.weekday() >= 5 or d in cal:
        d -= timedelta(days=1)
    return d


def rrule_for(cadence: Cadence, until: date) -> Optional[str]:
    """Bounded RFC 5545 recurrence so a dense tier is one event, not many rows."""
    if cadence is Cadence.WEEKLY:
        return f"FREQ=WEEKLY;UNTIL={until.strftime('%Y%m%d')}"
    if cadence is Cadence.DAILY:
        return f"FREQ=DAILY;UNTIL={until.strftime('%Y%m%d')}"
    return None


def stable_uid(lease_id: str, lead_days: int, on: date) -> str:
    raw = f"{lease_id}|notice_ladder|{lead_days}|{on.isoformat()}"
    digest = hashlib.sha256(raw.encode()).hexdigest()[:24]
    return f"{digest}@leaseautomation.org"


def build_ladder(
    lease_id: str,
    notice_deadline: date,
    policy: CadencePolicy,
    cal: holidays.HolidayBase,
    today: Optional[date] = None,
) -> list[Reminder]:
    """Expand one hard notice deadline into a business-day-aware reminder ladder."""
    today = today or date.today()
    reminders: list[Reminder] = []

    for tier in policy.tiers:
        raw = notice_deadline - timedelta(days=tier.lead_days)
        fires_on = previous_business_day(raw, cal)

        # Deadline nearer than this tier's lead time, or already elapsed: skip.
        if fires_on >= notice_deadline or fires_on < today:
            continue

        repeat_until = notice_deadline if tier.cadence is not Cadence.ONCE else None
        reminders.append(Reminder(
            uid=stable_uid(lease_id, tier.lead_days, fires_on),
            lease_id=lease_id,
            fires_on=fires_on,
            repeat_until=repeat_until,
            cadence=tier.cadence,
            role=tier.role,
            channels=tier.channels,
            severity=tier.severity,
        ))
    return reminders


DEFAULT_POLICY = CadencePolicy(tiers=[
    ReminderTier(lead_days=180, cadence=Cadence.ONCE, role="asset_manager",
                 channels=("calendar", "email"), severity="info"),
    ReminderTier(lead_days=120, cadence=Cadence.ONCE, role="asset_manager",
                 channels=("calendar", "email"), severity="info"),
    ReminderTier(lead_days=90, cadence=Cadence.ONCE, role="lease_admin",
                 channels=("calendar", "task"), severity="medium"),
    ReminderTier(lead_days=60, cadence=Cadence.ONCE, role="lease_admin",
                 channels=("calendar", "task"), severity="medium"),
    ReminderTier(lead_days=30, cadence=Cadence.WEEKLY, role="lease_admin",
                 channels=("task", "email"), severity="high"),
    ReminderTier(lead_days=14, cadence=Cadence.WEEKLY, role="lease_manager",
                 channels=("task", "email"), severity="high"),
    ReminderTier(lead_days=7, cadence=Cadence.DAILY, role="director",
                 channels=("task", "email", "sms"), severity="critical"),
])


if __name__ == "__main__":
    cal = holidays.country_holidays("US", years=range(2026, 2029))
    deadline = date(2027, 6, 30)   # give-notice-by, already business-day-adjusted
    for r in build_ladder("L-4821", deadline, DEFAULT_POLICY, cal,
                          today=date(2026, 7, 15)):
        rule = rrule_for(r.cadence, r.repeat_until) if r.repeat_until else "-"
        print(r.fires_on, r.role, r.severity, rule)

The design keeps three concerns separate: the policy (which tiers exist and how they route), the arithmetic (business-day-shifted dates, isolated in previous_business_day), and the identity (stable_uid, keyed on the lease, the lead offset, and the fire date). Because the UID includes lead_days, moving the deadline retires the old rungs and mints new ones cleanly — the same three-way reconciliation the parent Calendar Event Generation stage relies on. Each Reminder is then handed to the ICS serializer as an event with a bounded RRULE, so a dense weekly or daily tier becomes one recurring VEVENT rather than dozens of rows.

Edge cases specific to commercial leases

  • Deadline nearer than the widest tier. A lease is canonicalized only four months before its notice date, so T-180 has already passed. The build simply skips any rung whose adjusted date is before today, emitting only the tiers that still fit — never a past-dated “6 months out” nudge that reads as noise.
  • A rung lands on a weekend or holiday. Every rung routes through previous_business_day, so a T-30 that falls on a public holiday fires the prior open day. You shift backward, never forward, so the reminder never lands after the window it is meant to protect.
  • An amendment moves the deadline. A negotiated amendment changes the option-exercise date, which changes the derived notice date, which changes every rung. Because each UID is keyed on the lead offset and fire date, the whole ladder is rebuilt: new UIDs are upserted and the previously published UIDs that no longer appear are deleted, so stale reminders are retired rather than left firing against the old date.
  • The option is already exercised or acknowledged. Once a lease.option_exercised (or an explicit acknowledgement) event arrives, the ladder must go silent immediately — including any in-flight RRULE occurrences. Delete every outstanding UID for that ladder rather than waiting for UNTIL; a recurring daily reminder that keeps firing after notice was given is worse than no reminder.
  • Timezone and all-day semantics. Reminder rungs are all-day obligations anchored to the property’s own zone, exactly like the parent stage. A rung computed in the server’s locale can land a day off across a zone boundary, so anchor every date before comparison and emit DATE-valued starts.

When to escalate

The ladder is a nudge mechanism, not a guarantee. It hands off to a louder path in three situations, all routed through critical date alerting:

  • A high or critical rung goes unacknowledged. If the T-14 or T-7 tier fires with no acknowledgement recorded, that unacknowledged high-severity state is precisely what critical date alerting exists to escalate — it pages a human rather than adding another calendar row.
  • The deadline passes with no exercise and no acknowledgement. A missed notice window is not a reminder problem any more; it routes to escalating missed notice windows to manual review so a person can assess whether the option can still be salvaged or the consequence must be logged.
  • The routing role is unstaffed or the channel bounces. If a tier’s role has no active assignee, the reminder has nowhere to land; defer to fallback routing logic to re-route to a backstop owner rather than silently dropping the rung. The severity a rung carries — driven by the same taxonomy as escalation formula mapping — determines how aggressively that backstop is pursued.

Frequently asked questions

Why build a ladder instead of a single reminder on the deadline? A single reminder is one point of failure — one person, one day, one channel. A ladder that fires quarterly far out and daily in the final week gives multiple independent chances to act and lets urgency and audience rise as the date nears, which is what actually prevents a lapsed option.

Should I use one RRULE or discrete reminders for the run-up? Use both. Model each lead-time tier as its own event so audience, channel, and severity can differ, but inside a dense tier use a bounded RRULE such as FREQ=WEEKLY;UNTIL=<deadline> so you emit one recurring event instead of dozens of individual rows. A single global RRULE cannot express tiers that change frequency or routing.

How do I stop the reminders once the option is exercised? Treat exercise or acknowledgement as a state transition that halts the ladder. On the option_exercised event, delete every outstanding UID for that ladder immediately rather than waiting for the RRULE’s UNTIL bound, so no further weekly or daily occurrences fire after notice has been given.

What happens when an amendment changes the deadline? The derived notice date changes, so every rung’s date changes. Because each UID is keyed on the lead offset and fire date, rebuild the whole ladder: upsert the new UID set and delete the previously published UIDs that no longer appear. That retires the old rungs cleanly and repositions the entire ladder around the new date.

What if the lease is canonicalized after some tiers would already have fired? Skip any rung whose business-day-adjusted date is before today. If a lease arrives four months out, the T-180 rung is dropped and the ladder begins at the next tier that still fits, so you never publish a past-dated nudge.

← Back to Calendar Event Generation

← Back to Calendar Event Generation