Deadline Tracking Routing Engines

Generating iCalendar Feeds for Compliance Deadlines

This guide is part of the Calendar Sync & Notification Pipelines area within the Deadline Tracking & Routing Engines framework: it covers the one output format every legal-ops calendar already speaks — a subscribable iCalendar feed — and how to emit it so that a portfolio’s statutory deadlines appear, update in place, and alarm on time inside Google Calendar, Outlook, and Apple Calendar without a single manual copy-paste. The parent area routes and dispatches events; this page produces a standing, self-refreshing artifact that any stakeholder can subscribe to once and never touch again.

Scope of This Page

This page covers building a standards-compliant RFC 5545 feed: mapping obligations to VEVENT components, minting stable UIDs so a re-emitted feed updates existing entries rather than duplicating them, anchoring each DTSTART to a correct VTIMEZONE, and attaching VALARM reminders keyed to each jurisdiction’s grace window. It deliberately excludes the surrounding pipeline. It does not resolve the underlying deadlines — those come from State Filing Deadline Calendars, which already applies weekend and holiday roll-forward. It does not push interactive alerts; the real-time channel work lives in Setting up automated Slack alerts for upcoming annual report deadlines. Here the input is a set of resolved, timezone-aware obligations and the output is a single .ics byte stream served over webcal.

The Constraint That Forces Standards-Exact iCalendar

A compliance calendar is only useful if it is trustworthy, and in iCalendar trust is a function of two fields most quick implementations get wrong: UID and VTIMEZONE. Statutory deadlines are fixed local instants — Delaware’s annual report and franchise tax fall due March 1 under 8 Del. C. § 502, California’s Statement of Information tracks a formation-anniversary window under Cal. Corp. Code § 1502, New York’s biennial statement is keyed to the anniversary month under N.Y. BCL § 408, and Texas anchors its franchise and Public Information Report to May 15 under Tex. Tax Code § 171.203. Each closes at local end-of-day, so a feed that emits naive UTC times will display a March 1 deadline as February 28 for a subscriber in a western timezone and quietly move the alarm a day early or late. RFC 5545 solves this only if every DTSTART carries a TZID that resolves against an embedded VTIMEZONE block; omit the block and clients fall back to their own guesses. Equally, because a compliance feed is regenerated on every refresh, a UID that is not stable across regenerations produces a duplicate event on each sync — so the UID must be a deterministic function of the obligation, never a fresh random value.

Prerequisites

  • Python 3.10+ — for zoneinfo (stdlib IANA timezone database) and modern typing.
  • icalendar 5.0+ — a typed RFC 5545 builder with add_missing_timezones() for automatic VTIMEZONE emission.
  • Resolved, timezone-aware obligations carrying entity, jurisdiction, filing type, filing year, the local due instant, and the grace-window length in days.
  • A web endpoint that serves the generated bytes with Content-Type: text/calendar; charset=utf-8 and a stable webcal:// URL for one-click subscription.
  • A regeneration schedule (cron or the pipeline’s own scheduler) that rebuilds the feed whenever obligations change, so subscribers pull fresh state on their next refresh.

Implementation: A Stable-UID iCalendar Feed Builder

The module below maps each obligation to one VEVENT. The UID is derived deterministically from (entity, jurisdiction, filing_type, year), so regenerating the feed updates the existing entry in every subscribed client instead of duplicating it; a monotonically increasing SEQUENCE plus a fresh LAST-MODIFIED tells clients the event changed. Each event carries a VALARM whose trigger is the negative of the grace window, so the reminder fires as the grace period opens rather than on the raw deadline. add_missing_timezones() walks the emitted datetimes and inserts a correct VTIMEZONE for each TZID. Comments mark the compliance-critical lines.

From resolved obligation to subscribed calendars via a stable-UID iCalendar feed One resolved obligation is mapped to a VEVENT with a stable UID, a TZID-bearing DTSTART/DTEND, a SUMMARY, and a grace-keyed VALARM. The VEVENT is wrapped in a VCALENDAR carrying a matching VTIMEZONE, serialized to a .ics feed served over webcal, and pulled by subscribed calendars on refresh. The stable UID makes regeneration update entries in place. Map obligations to VEVENTs, wrap in a timezone-correct VCALENDAR, serve as a subscribable feed Obligation entity · jurisdiction due instant (local) grace days VEVENT mapping BEGIN:VEVENT UID: e42-DE-AR-2026     (stable → update-in-place) DTSTART;TZID=America/New_York DTEND;TZID=America/New_York SUMMARY: DE Annual Report BEGIN:VALARM   TRIGGER:-P30D   (grace-window keyed) END:VALARM END:VEVENT VCALENDAR + embedded VTIMEZONE add_missing_timezones() PRODID · SEQUENCE .ics feed text/calendar webcal:// URL served on refresh Google Calendar Outlook Apple Calendar subscribed once · auto-refresh map wrap serve
from __future__ import annotations

import hashlib
import json
import logging
from dataclasses import dataclass
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo

from icalendar import Alarm, Calendar, Event

# Structured JSON logging — every emitted event is an observability record.
logging.basicConfig(level=logging.INFO, format="%(message)s")
logger = logging.getLogger("calendar_sync.ical_feed")

# Stable namespace so a UID is reproducible across regenerations of the feed.
UID_DOMAIN = "compliance.annual-filing.org"


@dataclass(frozen=True)
class ComplianceObligation:
    entity_id: str
    jurisdiction: str            # e.g. "DE", "CA", "NY", "TX"
    filing_type: str             # e.g. "annual_report", "statement_of_information"
    filing_year: int
    due_local: datetime          # timezone-aware local due instant (end of business)
    grace_days: int              # jurisdiction grace window; drives the VALARM trigger
    summary: str

    def stable_uid(self) -> str:
        # The UID is a pure function of the obligation identity, NOT a random value,
        # so a regenerated feed updates the existing event in place instead of
        # producing a duplicate on every calendar refresh.
        seed = f"{self.entity_id}:{self.jurisdiction}:{self.filing_type}:{self.filing_year}"
        digest = hashlib.sha1(seed.encode("utf-8")).hexdigest()[:20]
        return f"{digest}@{UID_DOMAIN}"


class ICalFeedBuilder:
    """Build an RFC 5545 VCALENDAR of compliance deadlines with grace-keyed alarms."""

    def __init__(self, calendar_name: str, sequence: int = 0) -> None:
        self._name = calendar_name
        # SEQUENCE increments when an obligation changes so clients accept the update.
        self._sequence = sequence

    def _event(self, ob: ComplianceObligation, generated_at: datetime) -> Event:
        if ob.due_local.tzinfo is None:
            # A naive datetime would emit a TZID-less DTSTART and drift by a day.
            raise ValueError(f"due_local must be timezone-aware for {ob.entity_id}")

        event = Event()
        event.add("uid", ob.stable_uid())                 # stable identity
        event.add("summary", ob.summary)
        event.add("dtstart", ob.due_local)                # carries TZID from tzinfo
        event.add("dtend", ob.due_local + timedelta(hours=1))
        event.add("dtstamp", generated_at)
        event.add("last-modified", generated_at)          # marks this regeneration
        event.add("sequence", self._sequence)             # bump => clients update
        event.add("categories", ["COMPLIANCE", ob.jurisdiction])
        event.add(
            "description",
            f"{ob.jurisdiction} {ob.filing_type} for {ob.entity_id} "
            f"({ob.filing_year}). Grace window: {ob.grace_days} days.",
        )

        # VALARM keyed to the grace window: remind as the grace period opens, not
        # on the raw statutory deadline, so action starts with time still on the clock.
        alarm = Alarm()
        alarm.add("action", "DISPLAY")
        alarm.add("description", f"Filing due: {ob.summary}")
        alarm.add("trigger", timedelta(days=-ob.grace_days))
        event.add_component(alarm)
        return event

    def build(self, obligations: list[ComplianceObligation]) -> bytes:
        cal = Calendar()
        cal.add("prodid", f"-//{UID_DOMAIN}//Compliance Feed//EN")
        cal.add("version", "2.0")                          # RFC 5545 required
        cal.add("calscale", "GREGORIAN")
        cal.add("method", "PUBLISH")
        cal.add("x-wr-calname", self._name)
        cal.add("x-published-ttl", "PT12H")               # hint clients to refresh

        generated_at = datetime.now(ZoneInfo("UTC"))
        for ob in obligations:
            cal.add_component(self._event(ob, generated_at))
            logger.info(json.dumps({
                "event": "vevent_emitted", "entity_id": ob.entity_id,
                "jurisdiction": ob.jurisdiction, "uid": ob.stable_uid(),
                "dtstart": ob.due_local.isoformat(), "grace_days": ob.grace_days,
            }))

        # Emit one VTIMEZONE per distinct TZID actually used by the events, so
        # clients resolve local cutoffs deterministically instead of guessing.
        cal.add_missing_timezones()
        logger.info(json.dumps({
            "event": "feed_built", "calendar": self._name,
            "event_count": len(obligations), "sequence": self._sequence,
        }))
        return cal.to_ical()


if __name__ == "__main__":
    obligations = [
        ComplianceObligation(
            entity_id="e42", jurisdiction="DE", filing_type="annual_report",
            filing_year=2026,
            due_local=datetime(2026, 3, 1, 17, 0, tzinfo=ZoneInfo("America/New_York")),
            grace_days=30, summary="DE Annual Report + Franchise Tax",
        ),
        ComplianceObligation(
            entity_id="e77", jurisdiction="TX", filing_type="public_information_report",
            filing_year=2026,
            due_local=datetime(2026, 5, 15, 17, 0, tzinfo=ZoneInfo("America/Chicago")),
            grace_days=45, summary="TX Franchise / Public Information Report",
        ),
    ]
    feed = ICalFeedBuilder("Corporate Compliance Deadlines").build(obligations)
    # Serve `feed` with Content-Type: text/calendar; charset=utf-8 over a webcal URL.

The builder is intentionally pure over its inputs: the same obligation list, regenerated an hour later, yields byte-for-byte the same UIDs and therefore updates rather than clones. add_missing_timezones() is what keeps the feed honest across clients — without an embedded VTIMEZONE, a TZID=America/Chicago on the Texas deadline is a name the client is free to misinterpret, and the alarm can fire on the wrong day.

Configuration Reference

These fields are dictated by RFC 5545 and by each jurisdiction’s grace window, not by presentation preference.

Field Suggested value Standards / operational justification
UID sha1(entity:jurisdiction:type:year) Deterministic identity; RFC 5545 requires uniqueness, and stability across regenerations is what makes a refresh update-in-place instead of duplicating.
DTSTART TZID IANA name (e.g. America/New_York) Statutory cutoffs are local; a bare UTC time drifts a deadline across the date boundary for offset subscribers.
VTIMEZONE auto via add_missing_timezones() Without the embedded block, clients guess the offset for the TZID and alarms fire early or late.
VALARM TRIGGER -P{grace_days}D Fires as the grace window opens so remediation begins with statutory slack remaining.
SEQUENCE monotonically increasing Signals a changed event; a client ignores an update whose sequence has not advanced.
X-PUBLISHED-TTL PT12H Advises clients how often to re-pull; too long and a corrected deadline lingers stale.
METHOD PUBLISH Marks the feed as a one-way subscription, not an invitation requiring an RSVP.

Failure Modes and Fallback Routing

Each fault maps onto the parent area’s error taxonomy — StatutoryMappingError, IdempotencyConflict, and TransientNetworkError — and the feed builder handles each differently.

  1. Duplicate events multiply on every refresh (idempotency conflict). The UID was minted with a fresh random value per regeneration instead of derived from the obligation, so each sync looks like a new event. Deriving the UID deterministically from (entity, jurisdiction, filing_type, year) collapses regenerations to a single, updated entry — the calendar analogue of the pipeline’s trace-ID deduplication.
  2. A deadline displays one day off for some subscribers (statutory mapping). A naive DTSTART was emitted without a TZID, or the VTIMEZONE block was omitted, so clients applied their own offset. Reject naive datetimes at the boundary and always call add_missing_timezones(); the resolved local instant comes from State Filing Deadline Calendars already timezone-aware.
  3. A corrected deadline never reaches subscribers (transient / staleness). The event body changed but SEQUENCE was not incremented, so compliant clients kept the cached version. Bump SEQUENCE and refresh LAST-MODIFIED on any material change, and keep X-PUBLISHED-TTL short enough that a correction propagates within one refresh cycle.
  4. The feed endpoint is briefly unreachable during a client refresh (transient network). A subscription pull is a best-effort GET; a single failed refresh is not a missed deadline because the client retries on its next cycle and the alarm is already local to the subscriber. For time-critical corrections, the interactive path in Setting up automated Slack alerts for upcoming annual report deadlines carries the change out-of-band rather than waiting on the next calendar poll.

Frequently Asked Questions

Why must the UID be derived from the obligation instead of a random UUID?

Because a compliance feed is regenerated on a schedule, and a calendar client keys entirely off the UID to decide whether an incoming event is new or an update to one it already holds. A fresh random UID per regeneration makes every event look new, so each refresh appends a duplicate and the subscriber’s calendar fills with copies of the same deadline. Deriving the UID from (entity, jurisdiction, filing_type, year) makes it stable, so a regenerated feed updates the existing entry in place — the same idempotency discipline the parent pipeline applies with trace IDs.

Do I really need an embedded VTIMEZONE if every DTSTART already has a TZID?

Yes. A TZID is only a name; the embedded VTIMEZONE is the definition that tells the client what UTC offset and DST rules that name resolves to. Without it, clients fall back to their own timezone database, which may lag IANA updates or interpret an ambiguous name differently — and for a deadline that closes at local midnight, a one-hour misinterpretation can move it across the date boundary and fire the alarm a day early or late. Calling add_missing_timezones() emits one correct VTIMEZONE per TZID the feed actually uses.

Why key the VALARM to the grace window instead of the raw deadline?

Because the deadline is the moment penalty exposure begins, not the moment work should start. A TRIGGER of -P{grace_days}D fires the reminder as the grace window opens, so a Delaware obligation with a 30-day grace window alarms a month before the March 1 cutoff and a Texas one 45 days before May 15. That gives the responsible team the full statutory slack to act, and it keeps the alarm cadence consistent with how Penalty Avoidance & Grace-Period Mapping treats the grace period as first-class data rather than an informal buffer.