ADR 007: Atomic Per-Day Exception Model for Calendar¶
Date: 2026-03-12 Status: Accepted
Note: Retrospective ADR written 2026-04-26. The refactor was scoped, executed, and documented under plan 016 (created 2026-03-12, completed 2026-03-18, 102 unit tests).
Context¶
ADR-001 introduced calendar exceptions as the highest-priority layer on top of templates and service bindings. The original schema modeled an exception as a datetime range: from_datetime + until_datetime, plus a reason. A single row could represent anything from a 1-hour partial closure to a 2-week vacation.
This shape produced four concrete problems in practice:
- Semantic ambiguity. A row with
from = 2026-12-23T08:00anduntil = 2026-12-27T18:00could mean "closed for those five days" or "closed every day from 08:00 to 18:00 across that range" — depending on which engine code touched it. - Frontend timezone drift. Template-level forms used
dateToUtcMidnight(); service-level forms usednew Date().toISOString(). Same field, two encodings, off-by-one bugs at locale boundaries. - Engine complexity. The availability engine had to subtract a range interval from a slotted day, handle DST transitions inside the range, and reason about partial-day overlap on both endpoints. The math was correct but the test surface kept growing.
- No standard alignment. iCalendar RFC 5545 (which we needed to interop with for the ICS work — ADR-006) uses per-date EXDATE / RDATE entries, not range exceptions. Every export translation was a fan-out from one row to N VEVENTs, and import had to fold N back into one row heuristically.
Decision¶
Refactor exceptions to an atomic per-day model. Each row represents exactly one date, with optional time bounds. Multi-day scenarios are composed at the UX layer.
Schema¶
| Column | Type | Meaning |
|---|---|---|
exception_date |
DATE | The single date this exception affects |
is_full_day |
BOOLEAN | If true, the whole day; if false, use start_time/end_time |
start_time |
TIME | Window start (when partial) |
end_time |
TIME | Window end (when partial) |
availability_type |
ENUM | UNAVAILABLE (subtract slots) or AVAILABLE (inject slots) |
batch_id |
UUID NULL | Groups exceptions created in one multi-day operation |
scope |
ENUM | PARTNER (all services) or SERVICE (one service) |
template_id |
UUID NULL | Further scopes the exception to services using a template |
Constraint preventing duplicate full-day blocks for the same scope/target/date:
Time-range exceptions on the same date are allowed to coexist (different windows are valid).
Multi-day handled at the UX layer¶
The "but what about a holiday week?" objection is solved by:
- UI lets the user pick a date range.
- Backend creates N atomic rows (one per day).
- All N share a
batch_id. - Batch endpoints (
POST /exceptions/batch,DELETE /exceptions/batch/:batchId) treat them as a unit.
Engine integration¶
Availability calculation in availability-layering.service.ts:
- Fetch exceptions filtered by date range (not all historical entries).
toInterval()converts each atomic exception to a single start/end ISO interval for its date.UNAVAILABLE→ subtract that interval from existing slots;AVAILABLE→ inject as a new slot.- Exceptions remain the highest-priority layer.
Migration¶
Existing range-based rows were expanded into N atomic rows during a one-shot migration. Each expansion group received a shared batch_id. Time-of-day fields were preserved from the legacy from_datetime / until_datetime. Old columns dropped after data migration.
Consequences¶
Positive¶
- RFC 5545-shaped. EXDATE / RDATE export is now a 1:1 mapping; ICS import (plan 015) lands rows in the native shape; ICS sync worker (plan 017, ADR-006) creates one row per external VEVENT instance.
- Engine simplicity. Single-date lookup, no range intersection math, no DST edge cases inside an exception.
- Trivial conflict detection. Two exceptions on the same date with the same scope are an immediate UNIQUE-constraint or query hit, not a date-range overlap analysis.
- Industry alignment. Stripe, Square, and Airbnb all model availability overrides per-date. The platform now matches that mental model.
- Front-end consistency. One date primitive (
exception_dateasDATE, no timestamp) means one parsing path on every form. - Composable with ICS sync. Plan 017's
SyncWorkerServicewrites one row per imported external event — the bus and the table speak the same shape.
Negative¶
- Row-count multiplier. A two-week summer-vacation block is now 14 rows instead of 1. At MVP scale (a few thousand exceptions per partner over the platform's lifetime) this is comfortably absorbed by the existing indexes; it is not free if a single partner ever needs to block a continuous several-month window.
- Batch operations are now first-class. Any UX that presents a "vacation" as a single editable entity must hold the
batch_idand operate on the group. Editing one day in the middle of a batch is straightforward (it just becomes a non-batched row); editing the whole batch atomically is more code. - Migration was destructive. Pre-existing range rows had to be expanded; the original
from_datetime/until_datetimecolumns were dropped. Worth flagging that any external scripts reading the old shape broke at refactor time. - No native "weekly closed Mondays for a year" expression. That use case belongs in the template layer, not exceptions — but partners occasionally try to model it as exceptions and the atomic model amplifies the row count if they do. UI guard rails steer them to templates instead.
References¶
services/calendar-service/src/modules/exceptions/— atomic-model service + controllerservices/calendar-service/src/modules/availability/availability-layering.service.ts— engine integration (toInterval, AVAILABLE vs UNAVAILABLE handling)docs/implementation-plans/016-exception-refactor/human/explanation.md— full design rationaledocs/implementation-plans/016-exception-refactor/artifacts/calendar-exception-analysis.md— approach comparison- ADR-001 — the layered availability model exceptions belong to