Skip to content

Domain model & invariants

The primitive

A resource held over a time interval, with the invariant that concurrent allocations never exceed that resource's capacity over any overlapping interval.

Two axes are easy to conflate and must not be:

AxisQuestionPadel courtRental pool
Resource capacityhow many concurrent reservations fit?1 — held exclusively10 rackets
Participant fill targethow many people are on this reservation?4 players1

Four players on a court is not capacity 4. It is four participants on one reservation that exclusively holds a capacity-1 court. Capacity above 1 is only for fungible pools; when you care which unit, model separate resources.

The state machine

held ──confirm──▶ confirmed ──start──▶ in_service ──complete──▶ completed
  │                   │
  └──expire──▶ expired └──cancel──▶ cancelled
                       └──no-show──▶ no_show

States that consume capacity: held (unexpired), confirmed, in_service. Terminal states release it.

confirm re-runs the allocation check rather than trusting the hold, because the hold may have lapsed and the slot been taken since.

Tables

TableHolds
booking_resourcesid, kind (vertical vocabulary), name, capacity, active
booking_reservationsresource, starts_at/ends_at, state, quantity, expires_at, fill_target, note
booking_participantsreservation, party_ref, share, joined_at, left_at — append-only

party_ref is a DataSubjectId, never a PrincipalId: a participant is a customer, not a staff principal, and the type says so because the value keys crypto-shredding.

The invariants

  1. No overallocation. SUM(quantity) of live reservations overlapping any instant never exceeds capacity.
  2. Half-open intervals. starts_at < :end AND ends_at > :start — back-to-back bookings do not overlap.
  3. A hold carries a deadline. CHECK (state != 'held' OR expires_at IS NOT NULL).
  4. Lazy expiry. A lapsed hold stops counting without being swept. Correct for allocation — and a trap for display, which is why every reservation also carries effectiveState (below).
  5. No skipped transitions.
  6. Participants are never deleted.

Instants are canonicalised

Every instant is normalised to UTC before it is stored or compared. This is load-bearing, not hygiene: the overlap check compares instants as strings in SQL, and the contract permits any offset — so 19:00+02:00 and 17:00Z are the same moment but sort differently as text. Normalising is the only reason lexicographic comparison equals chronological comparison.

state vs effectiveState

FieldMeaningRead by
stateas stored — a held row says held until swepttransition guards
effectiveStateexpired once a hold's deadline passed, swept or notevery read path and UI

Lazy expiry is right for allocation and wrong for rendering: a calendar drawing state shows a held cell counting down past 0:00 forever. Guards use the stored value; anything a person looks at uses the derived one.

The hard parts, hosted.