Skip to Content
Living documentation — last reviewed 2026-05-28
DecisionsADR-0024: Spaces, entitlement ledger and layered booking policy

ADR-0024: Spaces, entitlement ledger and layered booking policy

Status: Accepted 2026-10-09 (TKN-441). Saar explicitly signed off D2, D5, D7, D9, D10, D11, D12 and the tier rule. D1, D3, D4, D6 and D8 follow from those and stand unless challenged in review. CrossFit Rishon City examples (TKN-442) become test fixtures; they do not reopen the decisions. Date: 2026-10-09 Context owner: Saar Issue: TKN-358  · input: Arbox vs Taikan gap analysis (2026-10-09), Rishon City calls 2026-09-24 and 2026-10-08


Context

CrossFit Rishon City runs three spaces in one org. It needs plans whose entries are shared across some spaces and capped per space (for example 18 CrossFit + 9 open gym), and booking windows that differ per space and per class type. One window shape is a clock time (“20:00 the night before”). Victor also wants OR inside a condition (“CrossFit OR Open Gym”).

What the code has today:

  • Policy is org-only. organizations.cancellation_window_hours, allow_late_cancellation and registration_opens/closes_before_minutes (TKN-357, PR #502) apply to every session. The window math (libs/shared/src/lib/registration-window.ts) uses minutes and ignores organizations.timezone.
  • Entitlement is a counter, not a ledger. subscriptions.remaining_credits is changed by raw SQL in five places (deductCreditAtomic, tryDeductCreditAtomic, refundCreditAtomic, a private copy in class-sessions.service.ts, SubscriptionsService.adjustCredits/refundCredit). There is no per-change row and no idempotency key. Manual adjustments are not audited and need no reason. Period caps are counted from bookings inside windows built from quota_anchor_at and quota_cycle_state.
  • Booking rules exist in three copies: BookingsService.book(), computeBlockReason and getBookingEligibility (schedule eligibility), and BookingEntitlementSweepService.classifyForNewSub. Unit tests are the only thing that keeps them in step. promoteFromWaitlist checks credits only. It does not re-check caps, overlap, horizon or registration.
  • Plans have no scope. plans.program_id is used only by course plans. A member can hold several plans at once. If more than one plan can pay and the client sends no subscriptionId, book() saves the booking untracked: it spends no credit and checks no caps.
  • locations is the only space-like table. Its capacity is never read. class_sessions.location_id is nullable. No conflict check covers rooms (the header of session-conflicts.ts). The tier limit maxLocations is 1 on Lite and Pro.

Decision

Spaces

  • D1. A space is a locations row. No second table and no rename. The UI calls it a space. A branch at another address is its own org (TKN-358), so one org rarely needs a site above its spaces. Each row keeps its own coordinates for GPS check-in.

  • D2. Conflicts are set per space. Add concurrency = exclusive | shared to each space. exclusive blocks a second overlapping session in the same space on single create and edit, unless staff override with a reason. Template apply and copy-week report the clash in the existing conflicts[] and skip that session. shared allows overlap (for example open gym next to a class). Amended 2026-10-16 (Saar, TKN-479): shared is the default for every new hall and area; exclusive is an opt-in staff choose. Rows that existed before halls are migrated to shared (D13).

  • D3. Space capacity is a ceiling. On create and edit, class_sessions.capacity must not be more than the space capacity, unless staff override. A capacity pool shared by sessions running at the same time is out of scope (TKN-391).

  • D4. A class type does not belong to a space. Add class_types.default_location_id to pre-fill new sessions. Each session holds its real space. location_id stays nullable. When an org has two or more active spaces, the UI asks staff to pick one.

    • Amended 2026-10-12 (TKN-459, decision 4): every session has a space. Each org has a default space (locations.is_default, auto-created “Main space” when none exists), class_sessions.location_id is backfilled and becomes NOT NULL, and a session inherits its class type’s space. Changing a class type’s space moves its future sessions that still follow it, after a preview; hand-moved and past sessions stay. Spec: docs/features/space-context/README.md §8.3–8.4.
      • Landed (TKN-463, with TKN-479 areas), expand then contract: migrations 0174–0176 add halls and areas (kind, parent_id, is_default, area_id), give each org one hidden default hall whose areas are its existing locations, and backfill every session’s hall (its old location becomes its area); every writer sets a hall from then on. Migration 0177 then makes class_sessions.location_id NOT NULL, once that release was live and no row, soft-deleted included, was left without a hall.
  • D13. Three levels: branch, hall, area (amended 2026-10-15, TKN-479). A branch is its own organization (TKN-358). A hall (מתחם; “space” in code and in D1–D4) is a perimeter inside the business with its own plans, reports and schedule. An area (אזור) is a place inside one hall (locations.kind = 'area', parent_id = the hall); everything of the hall applies, and it only says where a session runs (class_sessions.area_id, optional; location_id stays the hall). Every locations row that existed before halls is a shared area of one hidden, shared default hall per business (no business ever chose a rule for them); new halls and areas default to shared as well (D2). Clashes: a session in an area clashes within that area when the area is exclusive; a session without an area, within the hall when the hall is exclusive; an exclusive hall makes any overlap in it a clash. Capacity is capped by the area and by the hall. Areas never drive the space context, report splits, plan scopes or the tier limit for halls. Spec: docs/features/space-context/README.md §8.5.

Entitlement

  • D5. A plan has named buckets. plan_entitlement_buckets has plan_id, name, position, allowance (null = unlimited), period (window = quota window, week, day, term for packs) and scope (the condition model, D9). A booking draws from the first matching bucket in position order that has room. Staff set the order. The plan-wide day, week and month caps stay as overall limits. A plan without buckets acts as one implicit “all” bucket, so current behavior holds.
    • Amended 2026-10-16 (TKN-446): no backfill. No migration writes a plan_entitlement_buckets row, so every existing plan behaves as before (and no booking_policy_rules row is written for the org window: the resolver reads the organizations columns as its bottom layer). Flag: plan-entitlement-buckets — NOT entitlement-buckets, which gated the ledger rollout, was retired on 2026-10-14 and is archived in PostHog; reusing it would revive behaviour an org once switched on.
    • Amended 2026-10-16, Saar: quotas are limits, not pools; plan columns are the all-classes rows. The plan sheet calls buckets “entry quotas” (מכסות כניסה), and every quota is a limit. A booking must fit every quota whose scope matches its session: the member’s bookings on the subscription in that quota’s window that match its scope (confirmed and waitlisted, counted like the caps) stay below its allowance. The first full quota by position refuses bucket_exhausted { bucketName }; a matching quota of 0 means “not included in this plan” and refuses not_covered_by_plan, judged first; a session no quota matches is allowed (subject to credits and the other rules). There is no “first with room”, no implicit or materialised “all classes” bucket, and position is display order only. Periods are day, week, month (the same windows as the plan’s maxBookingsPer* caps), window (billing cycle) and term. Credits are unchanged: the plan’s ledger is the only credit source and the debit is the plan’s; bookings.entitlement_bucket_id is dropped and entitlement_ledger.bucket_id stays null with no foreign key. Usage is one grouped read of bookings + class_sessions + class_types per request, matched to each quota’s scope and window. The plan’s maxBookingsPerDay/Week/Month columns are exactly the “All classes, N per day/week/month” quotas: with the flag on the editor shows them as “All classes” rows in the one list and writes an all-classes day/week/month row back to those columns (two for one period are refused); every other row is a plan_entitlement_buckets row. The API needs no change for the columns.
  • D6. Usage is recorded in an append-only entitlement_ledger. Columns: subscription_id, bucket_id, window_start, booking_id, kind (grant | debit | reversal | adjustment), delta, reason_code, note, actor_user_id, expires_at, and idempotency_key UNIQUE (debit:<bookingId>, reversal:<bookingId>). Window allowance comes from the subscription’s per-cycle bucket snapshot, so a cycle start writes no grant rows. grant rows are for packs, make-ups (TKN-385) and manual gifts. Balance = allowance + sum(delta) within the window. Member and staff screens read the same balance through one EntitlementBalanceService.
    • Amended 2026-10-14: entitlement-buckets removed; the ledger is the sole credit authority for every org (owner decision). subscriptions.remaining_credits stays as a mirror written in the same transaction until a later step drops it. Prod backfill applied before the switch: 20 opening rows (19 non-zero), 0 subscriptions whose ledger sum differed from the counter, 0 tracked subscriptions without rows. A read of a subscription with no opened ledger falls back to its counter and is reported (check=entitlement_ledger_unopened).
  • D7. The entry is spent at booking, as today. An early cancel writes a reversal. A late cancel or a no-show keeps the debit. A waitlist join spends nothing; promotion writes the debit. Spending at check-in would need a two-phase hold, and no customer has asked for it.
  • D8. Unused entries do not roll over. Plan edits apply at the next cycle. Buckets are copied into the subscription when each cycle opens, which matches the ADR-0019 boundary model. A full bucket blocks the booking with bucket_exhausted. The member is offered the existing ways out: a pack purchase, or renew-early, which already applies a scheduled upgrade (ADR-0019 §4). Nothing is charged automatically.
    • Amended 2026-10-16, Saar: quotas are limits, not pools. A full quota is one of several limits the booking must fit, not the end of a pool a booking falls through: any matching quota that is full refuses, and the one named is the first by position.
    • Amended 2026-10-16, Saar: quota edits apply immediately to every member, like plan limits; there’s no per-cycle snapshot. This replaces “plan edits apply at the next cycle” and the per-cycle copy above (and in D6). Every read judges a subscription against its plan’s live plan_entitlement_buckets rows, the way the plan’s day/week/month caps have always been read. A quota added or tightened mid-cycle limits the member’s next booking; bookings already made stay. Nothing on the purchase or renewal path writes quota state, so existing plans and subscriptions are untouched until an org adds a quota. History: an earlier draft of TKN-446 copied the buckets onto subscriptions.entitlement_snapshot in recordCreditRebase; it never shipped and the column was dropped from migration 0178 before release.

Policy

  • D9. One condition model serves both buckets and rules. It lives in libs/shared as zod: Scope = { anyOf: [{ allOf: Condition[] }] }. A condition is { field, op: in | not_in | between, values }. v1 fields: space, class_type, program, weekday, start_time, plan, lead_time. Later fields: level (TKN-383) and age. The listed values are ORed, the conditions in a group are ANDed, and the groups are ORed. The v1 UI edits one group. The stored form already allows several, so OR across fields needs no migration later. not_in covers Arbox’s “except class type”.
  • D10. Each setting is resolved on its own, and the most specific rule wins. booking_policy_rules has scope, an optional pinned class_session_id, and partial settings. Precedence: pinned session > scope names a class type > scope names a space > scope names a program > org columns (the default layer, unchanged). On a tie, a rule with a plan condition wins. Saving is refused if two rules at the same level overlap and set different values for the same setting. Access checks (bucket scope, and later level and age) must all pass. Every answer records decidedBy: { layer, ruleId }.
    • Amended 2026-10-16 (TKN-446 review): the conflict check and the write run in one transaction under a per-org advisory lock, reading the live rules through it, so two staff saving overlapping rules at once cannot both pass. An org holds at most 200 live rules (MAX_LIVE_BOOKING_POLICY_RULES); a create past it is refused 400 booking_policy_limit. Every booking, cancel and schedule read resolves against all of them, so the count is bounded.
  • D11. A time rule has three modes: before_start { minutes }, day_before_at { days, time } and week_before_at { weeks, weekday, time }. The third is the weekly calendar release and is used only for booking open. All three are computed in organizations.timezone with date-fns-tz. A local time that does not exist on a DST day moves forward. An ambiguous one takes the earlier instant. Saving is refused if open is not before close or if close is after start. Booking open, booking close and the cancel deadline all use the same type.
  • D12. Timing is judged when the action happens. Cancel terms are copied onto the booking. Registration windows use the current rules. When a booking is created it stores policy_snapshot (cancel deadline, whether late cancel is allowed, the late-cancel credit effect, decidedBy). A later rule edit therefore does not change terms the member already accepted. Rule saves write booking_policy.updated audit rows. The save dialog shows how many upcoming sessions get a different window.
    • Amended 2026-10-16 (TKN-446 review): a booking without a snapshot (made while booking-policy-rules was off, which is every booking on the day it flips on) cancels on the organizations columns, never on rules it never accepted. The schedule shows that same deadline: both read the booking’s terms through one function (cancelTermsFor), so the deadline shown and the deadline enforced cannot disagree. A cancel reads no rules at all.

Delivery constraints

  • One evaluator. A pure function evaluateBooking(input) in libs/shared replaces the three copies before any new rule is added. It is called by book(), eligibility, the sweep, promoteFromWaitlist and the staff “why” preview.

Superseded detail (waitlist hardening, migration 0181). promoteFromWaitlist no longer exists. Every promotion goes through fillFreeSeats (apps/api/src/bookings/booking-enforcement.util.ts), which judges pack expiry, horizon, overlap and caps always, plan quotas only with booking-policy-rules on, plus registration close and a 15-minute cutoff. Current rules: scheduling-bookings › Waitlist. The text above is kept as decided at the time.

  • Flags. Per org, default off, and when off the code behaves as today: spaces (conflict and capacity enforcement), booking-policy-rules (layered rules), plan-entitlement-buckets (buckets, D5), booking-rules-parity (the sweep also judges pack expiry and plan horizon), waitlist-promotion-policy-check. (entitlement-buckets was removed on 2026-10-14 and is never reused; see D5, D6.)
    • Amended 2026-10-17, Saar: one flag booking-policy-rules; drag-to-copy unflagged. booking-policy-rules alone turns on everything that is a booking rule: the layered rules (D9–D12), plan entry quotas (D5, D8), the sweep’s pack-expiry and plan-horizon check, and the waitlist promotion rule check (quotas included). Off, an org has none of them, exactly as before. plan-entitlement-buckets, booking-rules-parity and waitlist-promotion-policy-check are removed and never reused. Copying a session to another day by drag or “Copy to day…” (TKN-478) ships with no flag (schedule-drag-copy is removed and never reused): it is additive, since a drop on another day did nothing before; the same-day move between halls is unchanged.
  • Permissions. Owner and admin edit rules and buckets (new module bookingPolicy: M,M,V,N). Coaches may view the rules, so the session sheet can show a coach which rule decides a class’s windows; they cannot change them. (Amended 2026-10-16, TKN-447: view for coaches, owner decision; was M,M,N,N.) Manual balance changes stay with subscriptions:manage, except the late-cancel waiver (refund-credit), which coaches can do with a reason; every one is audited (a required reason and an entitlement.adjusted audit row).
  • Tier. Spaces are limited per tier. Because a space is a locations row (D1), the existing maxLocations limit becomes maxSpaces (renamed, no alias) with Lite 1, Pro 2, Elite unlimited. The Pro value moves from 1 to 2; the numbers are a starting point that can change without a new ADR. (Amended 2026-10-14: Pro is 2, not 5.)

Consequences

  • One engine answers every booking question, and the staff preview shows which rule decided.
  • The ledger brings idempotency and auditability that the counter never had. The cost is a dual-write period, a migration of five write sites plus renewal resets, and a later column drop. Each migration needs owner approval.
  • Copying cancel terms per booking adds a jsonb column. In return, a rule edit never changes a booking already made. Quota edits, like plan limits, apply at once (D8 amendment): bookings already made stay, the next booking is judged on the new list.
  • Rejecting same-level conflicts on save moves complexity to the save step. Staff see the overlap at once instead of having to debug priority order later.
  • Raising Pro from 1 to 2 spaces covers a box with one extra room on Pro and keeps space count as an upsell lever: 3 or more spaces, such as Rishon City’s, need Elite.

Alternatives considered

  • A separate spaces table under locations. Rejected for v1: it creates two concepts, and a multi-site org is a separate org.
  • Arbox-style flat rules (“10 per year”). Rejected: no balance, no reversal, no shared pool.
  • Staff-ordered rule priority (firewall style). Rejected: harder for gym staff to predict than fixed specificity plus conflict errors.
  • Free nested boolean trees. Rejected for v1. One OR level (D9) covers every case collected so far.
  • Spending the entry at check-in. Deferred: it needs holds, and no customer has asked.