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_cancellationandregistration_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 ignoresorganizations.timezone. - Entitlement is a counter, not a ledger.
subscriptions.remaining_creditsis changed by raw SQL in five places (deductCreditAtomic,tryDeductCreditAtomic,refundCreditAtomic, a private copy inclass-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 frombookingsinside windows built fromquota_anchor_atandquota_cycle_state. - Booking rules exist in three copies:
BookingsService.book(),computeBlockReasonandgetBookingEligibility(schedule eligibility), andBookingEntitlementSweepService.classifyForNewSub. Unit tests are the only thing that keeps them in step.promoteFromWaitlistchecks credits only. It does not re-check caps, overlap, horizon or registration. - Plans have no scope.
plans.program_idis used only by course plans. A member can hold several plans at once. If more than one plan can pay and the client sends nosubscriptionId,book()saves the booking untracked: it spends no credit and checks no caps. locationsis the only space-like table. Itscapacityis never read.class_sessions.location_idis nullable. No conflict check covers rooms (the header ofsession-conflicts.ts). The tier limitmaxLocationsis 1 on Lite and Pro.
Decision
Spaces
-
D1. A space is a
locationsrow. 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|sharedto each space.exclusiveblocks 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 existingconflicts[]and skip that session.sharedallows overlap (for example open gym next to a class). Amended 2026-10-16 (Saar, TKN-479):sharedis the default for every new hall and area;exclusiveis an opt-in staff choose. Rows that existed before halls are migrated toshared(D13). -
D3. Space capacity is a ceiling. On create and edit,
class_sessions.capacitymust 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_idto pre-fill new sessions. Each session holds its real space.location_idstays 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_idis 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 makesclass_sessions.location_idNOT NULL, once that release was live and no row, soft-deleted included, was left without a hall.
- Landed (TKN-463, with TKN-479 areas), expand then contract: migrations 0174–0176 add halls and areas (
- Amended 2026-10-12 (TKN-459, decision 4): every session has a space. Each org has a default space (
-
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_idstays the hall). Everylocationsrow that existed before halls is asharedarea of one hidden,shareddefault hall per business (no business ever chose a rule for them); new halls and areas default tosharedas 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_bucketshasplan_id,name,position,allowance(null = unlimited),period(window= quota window,week,day,termfor packs) andscope(the condition model, D9). A booking draws from the first matching bucket inpositionorder 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_bucketsrow, so every existing plan behaves as before (and nobooking_policy_rulesrow is written for the org window: the resolver reads theorganizationscolumns as its bottom layer). Flag:plan-entitlement-buckets— NOTentitlement-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
positionrefusesbucket_exhausted { bucketName }; a matching quota of 0 means “not included in this plan” and refusesnot_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, andpositionis display order only. Periods areday,week,month(the same windows as the plan’smaxBookingsPer*caps),window(billing cycle) andterm. Credits are unchanged: the plan’s ledger is the only credit source and the debit is the plan’s;bookings.entitlement_bucket_idis dropped andentitlement_ledger.bucket_idstays null with no foreign key. Usage is one grouped read ofbookings+class_sessions+class_typesper request, matched to each quota’s scope and window. The plan’smaxBookingsPerDay/Week/Monthcolumns 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 aplan_entitlement_bucketsrow. The API needs no change for the columns.
- Amended 2026-10-16 (TKN-446): no backfill. No migration writes a
- 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, andidempotency_keyUNIQUE (debit:<bookingId>,reversal:<bookingId>). Window allowance comes from the subscription’s per-cycle bucket snapshot, so a cycle start writes no grant rows.grantrows 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 oneEntitlementBalanceService.- Amended 2026-10-14:
entitlement-bucketsremoved; the ledger is the sole credit authority for every org (owner decision).subscriptions.remaining_creditsstays 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).
- Amended 2026-10-14:
- 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 thedebit. 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_bucketsrows, 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 ontosubscriptions.entitlement_snapshotinrecordCreditRebase; 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/sharedas 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) andage. 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_incovers Arbox’s “except class type”. - D10. Each setting is resolved on its own, and the most specific rule wins.
booking_policy_ruleshasscope, an optional pinnedclass_session_id, and partialsettings. 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 aplancondition 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 recordsdecidedBy: { 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 400booking_policy_limit. Every booking, cancel and schedule read resolves against all of them, so the count is bounded.
- 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 (
- D11. A time rule has three modes:
before_start { minutes },day_before_at { days, time }andweek_before_at { weeks, weekday, time }. The third is the weekly calendar release and is used only for booking open. All three are computed inorganizations.timezonewithdate-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 writebooking_policy.updatedaudit 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-ruleswas off, which is every booking on the day it flips on) cancels on theorganizationscolumns, 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.
- Amended 2026-10-16 (TKN-446 review): a booking without a snapshot (made while
Delivery constraints
- One evaluator. A pure function
evaluateBooking(input)inlibs/sharedreplaces the three copies before any new rule is added. It is called bybook(), eligibility, the sweep,promoteFromWaitlistand the staff “why” preview.
Superseded detail (waitlist hardening, migration
0181).promoteFromWaitlistno longer exists. Every promotion goes throughfillFreeSeats(apps/api/src/bookings/booking-enforcement.util.ts), which judges pack expiry, horizon, overlap and caps always, plan quotas only withbooking-policy-ruleson, 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-bucketswas 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-rulesalone 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-parityandwaitlist-promotion-policy-checkare removed and never reused. Copying a session to another day by drag or “Copy to day…” (TKN-478) ships with no flag (schedule-drag-copyis removed and never reused): it is additive, since a drop on another day did nothing before; the same-day move between halls is unchanged.
- Amended 2026-10-17, Saar: one flag
- 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 withsubscriptions:manage, except the late-cancel waiver (refund-credit), which coaches can do with a reason; every one is audited (a required reason and anentitlement.adjustedaudit row). - Tier. Spaces are limited per tier. Because a space is a
locationsrow (D1), the existingmaxLocationslimit becomesmaxSpaces(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
spacestable underlocations. 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.
Related
- ADR-0016 (sweep), ADR-0019 (boundary model, renew-early), ADR-0018, ADR-0008 (tier limit for spaces)
- scheduling behavior, subscriptions behavior
- TKN-357 (org window = default layer), TKN-383 (levels as a condition field), TKN-385 (make-up = ledger grants), TKN-391 (shared capacity, out of scope)