ADR-0094: Calendar-Anchored Obligation Schedules (Clamped, Never Too Late)¶
- Status: accepted (Nicolas 2026-07-22, play-test session: "Instead [of] once every 30 days I would also like to be able to select something like 'end of the month', 'end of the year' or on the '25th of the month'… If e.g. the 31st is selected, just move the due date closer to the start of the month… so we guarantee that the deadline never is too late but rather too soon")
- Date: 2026-07-22
- Deciders: Nicolas Burri, design by Claude.
- Relates to: ADR-0089 (recurring obligations — extends its O2 recurrence model; everything else — O1 type-only completion, O3 oversight, task fan-out, VIEW_OBLIGATIONS — unchanged), ADR-0037/0038/0039 (sweep contract).
Decision¶
- Three schedule kinds on
recurring_obligation(aschedule_kinddiscriminator with per-kind parameter columns and CHECK constraints): INTERVAL— every N days from the last completion (today's behavior, unchanged, stays the default);MONTHLY— due on day D (1–31) of every month;YEARLY— due on (month M, day D) of every year.- Presets are parameters, not kinds. "End of the month" = MONTHLY day 31; "end of the year" = YEARLY Dec 31. The UI offers them as named presets; the model stores only the numbers.
- The clamp rule (Nicolas's, pinned): when month m has fewer than D days, the due date is the LAST day of m (Feb → 28/29, Apr → 30). The deadline is thereby never later than the nominal day — "too soon rather than too late". Same rule for YEARLY (Feb-29 → Feb-28 in non-leap years).
- Next-due semantics:
next_due= the first schedule occurrence STRICTLY AFTER the anchor, where the anchor is the last completion (the latest effective RELEASED record of the type, exactly as today) or, absent one, the obligation'screated_at. Consequences: an early completion (on the 5th for a due-25th) satisfies that occurrence and movesnext_dueto the next month's 25th; a completion exactly on the due day counts for it. One completion never satisfies two occurrences. - Dates are UTC calendar dates, consistent with system-wide UTC storage (REQ-I18N-001). UTC month-end precedes the local (CET/CEST) one by 1–2 hours — the error direction §3 already chose.
- The due-soon window is unchanged:
due_window_daysbeforenext_due, all kinds. - Sweep–derivation parity stays load-bearing: the V063 contract (the Kotlin oversight derivation and the SQL sweep compute the same anchor/next-due arithmetic) extends to the calendar kinds — both sides implement the occurrence-after-anchor + clamp rule, and a test pins them against each other on the hostile cases (Jan 31 → Feb, leap years, anchor exactly on an occurrence).
Consequences¶
- Migration (additive:
schedule_kinddefault INTERVAL,due_day,due_month;interval_daysbecomes per-kind-checked), the sweep function re-created with the extended arithmetic (self-contained per the V050 pattern — no other function touched), derivation + oversight DTO gain the schedule display, obligation dialog gains the kind selector with presets and the clamp hint, seed example, tests incl. the parity pins. - Existing obligations are untouched (INTERVAL default reproduces today's rows exactly).
- REQ row at the next baseline (v1.8 feeder).
- Deliberately not built until asked: QUARTERLY (expressible later as a kind or a preset), local-timezone due dates, per-obligation custom windows.
Clarification (2026-07-25): a completion discharges the OUTSTANDING occurrence (the fold rule)¶
Decided by Nicolas ("agree with c") after the review-wave-1 fix (V069) exposed the edge its own header documents: deriving the discharged occurrence from the completion's calendar period alone cannot distinguish "late for the previous occurrence" from "early for the next" — and the END-OF-PERIOD presets (annual review due 12-31, monthly check due month-end) live entirely in that blind spot, so a 5-days-late annual review still skipped a year.
- The rule: each completion discharges the occurrence that was OUTSTANDING at its moment —
next_dueis a fold over the completion history in order:next_due₀= first occurrence strictly aftercreated_at; each completion advances to the first occurrence strictly after the occurrence it discharged. A completion discharges exactly one occurrence by construction (§4's "never two" becomes structural), whether it is early, on-day, late, or so late it crossed a period boundary: the annual review completed 01-05 discharges the outstanding 12-31 and the next one is due THIS Dec-31, not next year's. When SEVERAL occurrences elapsed uncompleted, the outstanding one is the MOST RECENT that came due — missed occurrences do not stack into a debt (they remain visible history in the audit trail); one completion always yieldsnext_duestrictly after itself (realized asoccurrence_after(greatest(completion, next))in the V070 fold — the gap-catch-up rule, test-pinned). - §3's "derived, never stored" survives in spirit and letter: nothing new is stored — the derivation reads the completion sequence (released executions, already queryable in order) instead of only the latest one. Two completions inside one period behave sensibly: the first discharges the outstanding occurrence, the second is an early completion of the next.
- The single-source discipline (V069) is retained: the fold ships as the successor SQL function/sweep recreation (migrations immutable — a new migration replaces V069's function); the oversight derivation keeps selecting the same code the sweep runs.
- Cost accepted knowingly: the derivation walks an obligation's completion history (a handful of rows per year) rather than one anchor — computationally irrelevant at any realistic scale; the recursion/fold lives in one place.