# Exceptions to recurring schedules override the rule in real time

- **Pattern:** `ab-000535` (`booking-calendar-availability.recurring.exception-handling`)
- **Severity:** low
- **Lifecycle:** active
- **Last modified:** 2026-04-18
- **Canonical URL:** https://auditbuffet.com/patterns/ab-000535
- **License:** CC-BY-4.0 — attribute to AuditBuffet Pattern Catalog (https://auditbuffet.com/patterns/ab-000535)

## Why it matters

A recurring schedule exception that does not override the calendar in real time is an invisible lie. The host marks next Monday as closed; the customer sees open slots for next Monday; the booking confirms; the host is unavailable. This ISO 25010 functional-correctness failure occurs when the exception record is stored correctly but the slot generation query does not join against the exceptions table before returning availability. The result is phantom slots: the system thinks it is correctly managing exceptions, but customers book into them anyway.

## Severity rationale

Low because the failure requires a recurring-schedule-plus-exceptions setup to trigger, but when it does, it creates confirmed bookings on dates the host explicitly marked unavailable.

## Remediation

In your slot generation function (typically `src/lib/availability.ts` or your availability API route), query exceptions in the same request as the recurring rule and filter before returning results.

```ts
// src/lib/availability.ts
export async function getRecurringSlots(recurringId: string, start: Date, end: Date) {
  const [rec, exceptions] = await Promise.all([
    db.recurringAvailability.findUnique({ where: { id: recurringId } }),
    db.availabilityExceptions.findMany({
      where: { recurringId, exceptionDate: { gte: start, lte: end } },
    }),
  ])

  if (!rec) return []

  const blocked = new Set(exceptions.map(e => e.exceptionDate.toISOString().slice(0, 10)))

  const occurrences = rrulestr(rec.rrule, { dtstart: start }).between(start, end)
  return occurrences.filter(d => !blocked.has(d.toISOString().slice(0, 10)))
}
```

The exception filter must run synchronously in the slot generation path, not as a background job or deferred reconciliation task.

## Detection

- **ID:** `exception-handling`
- **Severity:** `low`
- **What to look for:** Count all code paths that generate slots from recurring rules. For each, verify that exception dates are queried and filtered before returning availability. Look for an exceptions table (e.g., `availability_exceptions`, `schedule_exceptions`) or an exceptions array within the recurring rule model. Trace the slot generation flow: RRULE parse -> generate occurrences -> filter by exceptions -> return available slots. Examine files matching `src/lib/*recurring*`, `src/lib/*schedule*`, `src/lib/*availability*`, `src/app/api/availability*`.
- **Pass criteria:** Count all slot generation paths from recurring rules. 100% of slot generation paths must query and apply exceptions before returning slots. At least 1 exception storage mechanism must exist (database table, JSON array, or exception column). The exception filter must run at query time, not as a deferred background job. Report: "X of Y recurring slot paths apply exception filters. Exception storage: [table/array/column]."
- **Fail criteria:** Calendar shows the default recurring pattern even on exception dates, or no exception querying exists in the slot generation code.
- **Skip (N/A) when:** Platform does not support recurring schedules, or recurring schedules do not support exceptions.
- **Detail on fail:** Example: `"Recurring schedule: Mon-Fri 9-5. Exception added: next Monday, closed. Calendar still shows Mon-Fri 9-5 for next Monday."`
- **Remediation:** Apply exceptions when fetching slots in `src/lib/availability.ts` or your slot-generation service. Query the `availability_exceptions` table and filter before returning:

  ```ts
  const exceptions = await db.availabilityExceptions.findMany({
    where: { recurringId, exceptionDate: { gte: start, lte: end } },
  })
  const blocked = new Set(exceptions.map(e => e.exceptionDate.toISOString()))
  return slots.filter(s => !blocked.has(s.date.toISOString()))
  ```
- **Cross-reference:** Check `rule-based-storage` — rule-based storage is the prerequisite; exceptions override specific instances of the rule.
- **Cross-reference:** Check `recurring-edit-scope` — "edit this only" creates an exception; this check verifies exceptions are applied.
- **Cross-reference:** Check `data-freshness` — exception changes must be reflected on the next calendar load.

## External references

- iso-25010 functional-correctness

Taxons: data-integrity

HTML version: https://auditbuffet.com/patterns/ab-000535
