Calendar Event Generation

Calendar event generation is the stage that converts a lease’s temporal terms into concrete, dated obligations that surface in the tools property teams already live in — an ICS feed, a shared Google or Microsoft calendar, a task board. It belongs to Downstream Automation & Sync, the domain that consumes validated canonical lease records and turns their terms into actions; it never parses a document and never re-derives a clause. It subscribes to a canonical event such as lease.canonicalized, reads the already-structured dates — commencement, expiration, renewal windows, option-exercise deadlines, notice periods, holdover triggers — and emits calendar events and reminders. The whole discipline of this page is doing that emission deterministically and idempotently, so that regenerating a portfolio produces the same events twice and moving a date in an amendment updates the existing reminder instead of scattering a duplicate.

The failure this stage exists to prevent is a missed deadline that costs real money. An option to renew that lapses because nobody put the notice window on a calendar can forfeit a below-market rate for a decade; a notice period miscounted by a weekend can invalidate a termination. Getting the date arithmetic exactly right — a notice window computed as deadline − notice_days, adjusted off weekends and holidays, anchored to the property’s own timezone — is not a formatting nicety. It is the product. Everything below is the engineering of turning temporal terms into trustworthy reminders: the deterministic rules, the stable identity that makes re-emission safe, and the handling of amendments that move a date after the events were already published.

Canonical lease temporal terms flowing into calendar sinks A canonical lease record on the left supplies temporal terms: expiration, renewal window, option-exercise deadline, notice period, and holdover trigger. These feed a date-rule engine in the center that performs deterministic arithmetic: notice window equals deadline minus notice days, business-day adjustment off weekends and holidays, and timezone anchoring to the property's zone. Each computed event passes through a dedup and idempotency stage that assigns a stable UID keyed on lease id, event type, and date, so re-emission updates rather than duplicates. Valid events then fan out to three sinks: an ICS feed, Google and Microsoft calendars, and a task or ticketing system. A separate arrow shows an amendment re-entering the engine and revising the previously published event by its stable UID. Canonical lease temporal terms expiration renewal window option deadline notice period holdover trigger Date-rule engine window = deadline − notice_days business-day weekend / holiday shift timezone anchor property zoneinfo Dedup & identity stable UID = lease_id + type + date re-emit → update, never duplicate ICS feed Google / Microsoft task / ticket system Amendment moves a date re-enters engine, same lease revise by UID
Canonical temporal terms enter a deterministic date-rule engine, each event is given a stable UID that makes re-emission idempotent, and results fan out to calendar sinks; an amendment re-enters the same engine and revises the published event by its UID.

Where this fits in the pipeline

The boundary of this stage is deliberately narrow, which is what makes its output auditable. It sits strictly downstream of the ingestion and canonicalization boundary:

  • Upstream of it: the canonical lease data models supply already-validated dates and durations. Where a term is missing or ambiguous — a renewal option present in the text but with no explicit notice count — fallback routing logic has already resolved or flagged it, so the engine never guesses at a date.
  • This stage: apply deterministic date rules to those terms, assign a stable identity to each resulting event, and write it to one or more calendar sinks — idempotently, so a portfolio-wide regeneration is safe to run any night.
  • Downstream of it: the emitted events feed the human-facing surfaces. When a computed deadline is already inside its notice window with no acknowledgement, that condition escalates through critical date alerting rather than sitting silently on a feed.

A calendar generator that also tried to interpret an option clause or arbitrate which amendment wins would be impossible to trust under a portfolio-wide rebuild. It answers exactly one question — “given these canonical dates, what dated reminders should exist, and where?” — and hands everything else back to Core Architecture.

Prerequisites and environment setup

The reference implementation targets Python 3.11+ so that zoneinfo and datetime handle timezone-aware arithmetic from the standard library, with a small, vendorable dependency set.

Dependency Version Role in the pipeline
python 3.11+ zoneinfo, datetime.date/datetime, structured dataclass/enum support
pydantic 2.6+ Validating the CalendarEvent model at the emission boundary via field_validator / model_validator
icalendar 5.0+ Serializing events to RFC 5545 VEVENT/VALARM for ICS feeds
zoneinfo (stdlib) IANA timezone database for anchoring events to a property’s local zone
holidays 0.40+ Jurisdiction-aware holiday calendars for business-day adjustment

Install with pip install "pydantic>=2.6" "icalendar>=5.0" holidays. Two environment assumptions matter. First, every date entering the engine is either a naive calendar date (for all-day obligations) or is anchored to the property’s IANA timezone before any comparison — mixing a UTC datetime with a naive one is the single most common source of an event landing a day early or late. Second, the notice-period and business-day rules are pure functions of their inputs: given the same canonical lease and the same holiday calendar, the engine must produce byte-identical UIDs and dates on every run. That purity is what lets you regenerate an entire portfolio and trust that nothing moved except what an amendment actually changed.

Event types, lead times, and sinks

The engine works from a fixed decision table. Each canonical temporal term maps to one or more event types, each event type has a default lead time (how early the reminder fires relative to the hard date), a severity that downstream alerting reads, and a preferred sink. Encoding this as data — not scattered if branches — is what keeps the rules reviewable by the people who own the leases.

Event type Anchor date Default lead time Severity Primary sink
Renewal-window open commencement of renewal window on the day info ICS + calendar
Option-exercise deadline option deadline notice_days before high calendar + task
Notice-period deadline required-notice date 14 days before high calendar + task
Lease expiration expiration date 90 / 30 days before medium ICS + calendar
Holdover onset day after expiration on the day high task

The notice-period rule is the one that must be exactly right: the reminder date is deadline − notice_days, then adjusted backward to the previous business day if it lands on a weekend or a holiday in the property’s jurisdiction — you never want a “give notice by” reminder to fire on a day the office is closed. Expiration carries two lead times because a 90-day heads-up drives strategy while a 30-day one drives execution; both are separate events with their own UIDs. The severity column is consumed verbatim by critical date alerting, so a high event that goes unacknowledged inside its window is what triggers an escalation.

Primary implementation

Below is a production-grade generator that reads a canonical lease and emits validated calendar events. It leans on zoneinfo for timezone anchoring, a pure business-day adjuster for weekend/holiday shifting, and a stable UID so that re-emitting an event updates the existing entry rather than creating a duplicate. A pydantic CalendarEvent model is the emission gate — no event reaches a sink until it validates.

from __future__ import annotations

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

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


class EventType(str, Enum):
    RENEWAL_WINDOW_OPEN = "renewal_window_open"
    OPTION_DEADLINE = "option_deadline"
    NOTICE_DEADLINE = "notice_deadline"
    EXPIRATION = "expiration"
    HOLDOVER_ONSET = "holdover_onset"


SEVERITY = {
    EventType.RENEWAL_WINDOW_OPEN: "info",
    EventType.OPTION_DEADLINE: "high",
    EventType.NOTICE_DEADLINE: "high",
    EventType.EXPIRATION: "medium",
    EventType.HOLDOVER_ONSET: "high",
}


class CanonicalLease(BaseModel):
    """The validated upstream record this stage consumes; it never parses documents."""
    lease_id: str
    property_timezone: str = "America/New_York"
    jurisdiction: str = "US"          # for the holiday calendar
    expiration: date
    option_deadline: Optional[date] = None
    notice_days: int = Field(default=0, ge=0)
    renewal_window_start: Optional[date] = None

    @field_validator("property_timezone")
    @classmethod
    def _tz_must_resolve(cls, v: str) -> str:
        ZoneInfo(v)  # raises if the IANA name is unknown
        return v


class CalendarEvent(BaseModel):
    uid: str
    lease_id: str
    event_type: EventType
    summary: str
    starts_at: datetime          # timezone-aware, anchored to the property zone
    all_day: bool
    severity: str
    lead_days: int = Field(ge=0)

    @model_validator(mode="after")
    def _no_naive_datetimes(self) -> "CalendarEvent":
        if self.starts_at.tzinfo is None:
            raise ValueError("starts_at must be timezone-aware")
        return self


def previous_business_day(d: date, cal: holidays.HolidayBase) -> date:
    """Shift backward to the last weekday that is not a holiday."""
    while d.weekday() >= 5 or d in cal:
        d -= timedelta(days=1)
    return d


def stable_uid(lease_id: str, event_type: EventType, on: date) -> str:
    """Deterministic identity: same lease + type + date always yields the same UID,
    so re-emission updates the existing calendar entry instead of duplicating it."""
    raw = f"{lease_id}|{event_type.value}|{on.isoformat()}"
    digest = hashlib.sha256(raw.encode()).hexdigest()[:24]
    return f"{digest}@leaseautomation.org"


class CalendarEventGenerator:
    def __init__(self, lease: CanonicalLease) -> None:
        self.lease = lease
        self.tz = ZoneInfo(lease.property_timezone)
        # Holiday calendar spanning the years this lease can touch.
        self.cal = holidays.country_holidays(
            lease.jurisdiction, years=range(2020, lease.expiration.year + 2)
        )

    def _event(self, event_type: EventType, on: date, summary: str,
               lead_days: int, all_day: bool = True) -> CalendarEvent:
        starts = datetime(on.year, on.month, on.day, 9, 0, tzinfo=self.tz)
        return CalendarEvent(
            uid=stable_uid(self.lease.lease_id, event_type, on),
            lease_id=self.lease.lease_id,
            event_type=event_type,
            summary=summary,
            starts_at=starts,
            all_day=all_day,
            severity=SEVERITY[event_type],
            lead_days=lead_days,
        )

    def generate(self) -> list[CalendarEvent]:
        events: list[CalendarEvent] = []
        L = self.lease

        # Expiration: two lead times, two independent events.
        events.append(self._event(
            EventType.EXPIRATION, L.expiration,
            f"Lease {L.lease_id} expires", lead_days=90))

        # Holdover begins the day after expiration.
        events.append(self._event(
            EventType.HOLDOVER_ONSET, L.expiration + timedelta(days=1),
            f"Holdover begins for {L.lease_id}", lead_days=0))

        # Option-exercise deadline and its derived notice window.
        if L.option_deadline is not None:
            events.append(self._event(
                EventType.OPTION_DEADLINE, L.option_deadline,
                f"Option exercise deadline for {L.lease_id}", lead_days=0))

            if L.notice_days > 0:
                raw_notice = L.option_deadline - timedelta(days=L.notice_days)
                notice_on = previous_business_day(raw_notice, self.cal)
                events.append(self._event(
                    EventType.NOTICE_DEADLINE, notice_on,
                    f"Give notice to exercise option on {L.lease_id}",
                    lead_days=14))

        if L.renewal_window_start is not None:
            events.append(self._event(
                EventType.RENEWAL_WINDOW_OPEN, L.renewal_window_start,
                f"Renewal window opens for {L.lease_id}", lead_days=0))

        return events


if __name__ == "__main__":
    lease = CanonicalLease(
        lease_id="L-4821",
        property_timezone="America/Chicago",
        expiration=date(2027, 3, 31),
        option_deadline=date(2026, 12, 31),   # a Thursday
        notice_days=180,
        renewal_window_start=date(2026, 6, 1),
    )
    for ev in CalendarEventGenerator(lease).generate():
        print(ev.event_type.value, ev.starts_at.date(), ev.severity, ev.uid)

The stable_uid function is the pivot of the whole design. Because it is a pure hash of lease_id + event_type + date, the same obligation always produces the same UID. Writing to an ICS feed or a Google/Microsoft calendar then becomes an upsert keyed on that UID: re-running the generator against an unchanged lease is a no-op, and re-running it against a lease whose option deadline moved produces a new UID for the moved event while the old one is retired — never a silent duplicate. The business-day shift is isolated in previous_business_day so it stays testable against known holiday edge cases, and the timezone is applied once, at event construction, so nothing downstream ever handles a naive datetime.

Validation and quality gates

Correctness here is enforced before an event is ever written, not reconciled afterward. Three gates run in sequence:

  • Timezone-aware by construction. The CalendarEvent model rejects any naive starts_at. An event that reached a sink without a zone would drift by up to a day across a DST boundary, so the model refuses to build one. Every date is anchored to the property’s IANA zone, the same discipline the canonical lease data models apply at the storage boundary.
  • No past-dated events on emission. A regeneration should never publish a reminder whose date has already gone by — a “give notice” alert dated last month is noise that erodes trust in the feed. Filter events whose adjusted date is before today at emission time, and let already-lapsed obligations flow to review through critical date alerting instead of being re-posted as fresh reminders.
  • Idempotent identity. Every event carries the deterministic UID, and every sink write is an upsert on it. This is what makes bulk regeneration safe: a nightly rebuild of a 10,000-lease portfolio touches only the calendar entries whose underlying dates actually changed.

When an input is malformed — an unresolvable timezone name, a negative notice_days, an option deadline earlier than commencement — the pydantic gate raises rather than emitting a plausible-but-wrong event. Those records route back to fallback routing for a human to correct the canonical source, because a calendar generator must never invent a date to paper over a bad input.

Troubleshooting

Symptom Likely cause Diagnostic + fix
Event fires a day early or late Naive datetime compared against a zoned one, or a DST transition Confirm starts_at.tzinfo is set; anchor every date to the property ZoneInfo before comparison. The model’s _no_naive_datetimes gate should have caught it — check for a sink that re-parses the date as UTC.
Notice reminder lands on a Saturday or holiday Business-day adjustment skipped or applied to the wrong direction Route the raw deadline − notice_days through previous_business_day; verify the holiday calendar matches the property’s jurisdiction, not the server’s locale.
Deadline moved but the old reminder still shows Amendment produced a new UID without retiring the old one Diff the current UID set against the last-published set for that lease_id; delete UIDs no longer generated so the sink retires stale events.
Duplicate entries after a rebuild Sink write is an insert, not an upsert on UID Make every calendar/ICS write an upsert keyed on the stable UID; a re-run of an unchanged lease must be a no-op.
All-day event shows as a timed 9 a.m. block all_day flag ignored during ICS serialization Emit a DATE-valued DTSTART (not DATE-TIME) when all_day is true; timed events keep the zoned DATE-TIME.
Holiday calendar wrong near year boundaries Calendar built for too narrow a year range Build holidays.country_holidays(..., years=range(...)) to span every year the lease’s dates can reach, including the year after expiration for holdover.

Performance and scale notes

Two operating modes matter. The first is incremental, event-driven generation: a single lease.canonicalized or amendment event arrives, the generator runs for that one lease, and it upserts a handful of calendar entries. This is cheap — microseconds of date arithmetic plus a few sink writes — and it is the path that keeps calendars fresh in near-real time. The second is bulk regeneration across a whole portfolio, run on a schedule to catch drift and to backfill after a rule change (say, a new default lead time). Because the generator is pure and the UIDs are deterministic, a portfolio-wide rebuild produces the same events it produced last night except where a lease actually changed, so the diff against the previously published UID set is small even when the input set is enormous. Compute the current UID set per lease, upsert the additions and changes, and delete the UIDs that no longer appear — that three-way reconciliation is what retires an event whose date an amendment moved.

Build the holiday calendar once per jurisdiction and reuse it across every lease in that jurisdiction rather than reconstructing it per event; it is the only non-trivial allocation in the hot path. When a portfolio spans tens of thousands of leases, the write side dominates — batch the sink upserts and parallelize across leases, since each lease’s event set is independent. The same idempotent, deterministic semantics that make a single event safe to re-emit are exactly what make the bulk path safe to run as often as you like.

Frequently asked questions

How do I keep re-running the generator from creating duplicate calendar entries? Give every event a stable UID that is a pure function of the lease id, the event type, and the date, then make every sink write an upsert keyed on that UID. Re-running against an unchanged lease reproduces the same UIDs, so the writes are no-ops; only a genuinely changed date produces a new UID and a corresponding update.

What happens to already-published events when an amendment moves a date? The amendment re-enters the same engine and regenerates the event set for that lease. Because the UID is keyed on the date, the moved obligation gets a new UID while the old one no longer appears in the generated set. Reconcile by upserting the current UIDs and deleting any previously published UID that is now absent, which retires the stale reminder cleanly.

How is a notice window computed, and why adjust for weekends? The reminder date is the hard deadline minus the required notice days, then shifted backward to the previous business day if it falls on a weekend or a jurisdiction holiday. You shift backward, never forward, because a “give notice by” reminder must fire on a day the office is open and still leaves the full statutory window intact.

Should calendar events be all-day or timed? Model deadlines and expirations as all-day events with a DATE-valued start, since they are calendar obligations rather than meetings. Reserve timed events for things that genuinely happen at a moment. Emitting a timed 9 a.m. block for an all-day deadline is a common source of confusing entries and cross-timezone drift.

How do you prevent past-dated reminders during a bulk rebuild? Filter any event whose adjusted date is before today at emission time. A regeneration should never re-post a reminder for a deadline that has already passed; a lapsed obligation belongs in critical-date alerting for review, not back on the calendar as if it were upcoming.

← Back to Downstream Automation & Sync

← Back to Downstream Automation & Sync