You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Part of #1332 (architecture tracker) and #1319 (wave-5 umbrella, §4 "U2 god-module split (own epic after PR 2)"). This issue is the plan that #1332 §4 row 1 and #1319 §2 "UPDATE" both defer to. It is post-MVP and not scheduled. Nothing here changes behaviour; every step is a move plus a facade, with the existing suites as the oracle.
#1332 records the decisions (what things should be called, where folders should live). This issue records the mechanics for exactly two files: the measured section map, the caller inventory, the target tree, the order of extraction, and the traps. Where the two overlap, #1332 wins on naming and this issue defers to it; renames are explicitly excluded from every step below so that a rename never rides along with a move.
1. Scope measurement
Measured on dev at 5f057bc02 on 2026-09-03, after the whole wave-5 train (#1321 through #1337) had merged. Every file in the requested scope that exceeds 1,000 lines is listed below.
app/api/checkout/route.ts is 246 lines and is not a problem: it is a thin caller that validates, replays an idempotency key and delegates. That is the shape every other entry point should end up with, and it is worth saying out loud that the route layer is already fine. The god modules are behind it.
Three files just outside the requested scope also cross 1,000 lines and belong in the same conversation, so they are recorded here rather than discovered later: app/api/appointments/[appointmentId]/reschedule/route.ts (1,004), utils/timeSlotsProcessing.ts (988, just under) and app/api/appointments/[appointmentId]/cancel/route.ts (792, just under). The reschedule route is the one genuine counter-example to "the route layer is already fine", and it is listed as a follow-on in §8.
Both giants grew during wave 5.#1319 and #1332 quote them at 3,901 and 3,666 lines respectively. They are now 4,357 and 3,808, so the allocator gained 456 lines and checkout gained 142 in a single train. The split is not competing with a static target, which is the main argument for giving it a freeze window rather than interleaving it with feature work.
Only the two giants are planned in detail here. handlers.ts, refund.ts and UnifiedCalendar.tsx are named as follow-ons in §8 because they are large but structurally much healthier: each already has a flat list of named top-level functions rather than one 1,500-line function.
2. utils/slotAllocation/SlotAllocationService.ts
2.1 Section map
One class, 39 static methods, 4,357 lines. The table below gives every method with its line range, its size, the number of #NNNN issue references in that range (the patch-density proxy) and what it actually does. The cluster column is the responsibility the method belongs to, which is not the order the file puts them in.
deleteExistingAppointments: releases never-paid tentative rows; carries all three doctrine rule-2 payment guards at :3795, :3883, :3972
4000-4123
124
2
lifecycle
updateEventStatus: CAS status writes on the parent event
4124-4340
217
11
lifecycle
fetchEventData: the polymorphic five-arm read that loads consultant, config and org context
Grouped by cluster, the patch density is where you would expect it. The three mode functions are 27% of the file and carry 37% of all issue references.
allocate, plus a source-text assertion: reads utils/slotAllocation/SlotAllocationService.ts from disk at :289 and asserts the literal string "SlotAllocationService.assertCollaboratorsFree(" appears at :297
The two source-text assertions are the single biggest trap in this whole plan. They pass or fail on the contents of one file path, so moving assertCollaboratorsFree or the recordParticipants call into a sibling module makes them fail even though the behaviour is byte-for-byte identical. They must be converted to behavioural assertions before any extraction starts, otherwise every later step has a red suite for the wrong reason and the oracle stops being trustworthy.
2.3 The AFTER layout
lib/booking/allocation/
├── index.ts ~60 public surface: allocate() + AllocationRequest/AllocationResult re-exports
├── allocate.ts ~130 from 147-265: entry, dispatch, post-success notification hook
├── classify.ts ~100 from 533-557 + 583-650: isModeledOutcome, classifyError, the Sentry level policy
├── notify.ts ~275 from 266-532: notifyAllocationPlaced (the only Novu importer)
├── identity.ts ~120 from 651-748 + 4341-4357: profile/consultee resolution and the two enum mappers
├── event-data.ts ~350 from 4000-4340: fetchEventData + updateEventStatus (the five-arm polymorphic read)
├── guards.ts ~130 from 749-860: initial-allocation guard, confirmed-slot assert, both tentative-count asserts
├── idempotency.ts ~130 from 861-983: findIdempotentAllocation, replayPartialCounts, releasedSlotIdsOf
├── preference.ts ~130 from 984-1104: the preference read model and the supersede transition
├── collaborators.ts ~35 from 558-582: assertCollaboratorsFree (AE-2)
├── search/
│ ├── constants.ts ~45 from 109-141: SLOT_DURATION_MS, MAX_CANDIDATE_STARTS_PER_ROW, AllocationWalkContext
│ ├── window-walk.ts ~160 from 2269-2415: isWithinAvailability, candidateStartsInRow, reportRowWalkTruncated
│ ├── blocks.ts ~230 from 2416-2635: buildConsecutiveBlock, bestFittingBlockInRow, bestBlockForSingleSession
│ ├── recurrence.ts ~130 from 3169-3285: getNextOccurrenceWeekly, matchWeeklySlotToDay, matchCustomSlotToDay
│ └── find-available-slots.ts ~550 from 2636-3168: the read-only search driver, still taking the base client
├── modes/
│ ├── auto.ts ~460 from 1105-1544: whole lock envelope, unchanged
│ ├── manual.ts ~460 from 1545-1982: whole lock envelope, unchanged
│ └── use-requested.ts ~300 from 1983-2268: whole lock envelope, unchanged
└── commit/
├── create-appointments.ts ~230 from 3286-3495: Appointment + atoms + org stamp + recordParticipants
├── release-tentative.ts ~310 from 3707-3999: deleteExistingAppointments, keeping all three payment guards inline
├── reconnect-enrolled.ts ~70 from 3656-3706: attendee re-seating
└── subscription-cap.ts ~175 from 3496-3655: weekly-cap bookkeeping
utils/slotAllocation/SlotAllocationService.ts ~45 FACADE ONLY (see below)
utils/slotAllocation/occupancyPolicy.ts 222 unchanged in this epic; renamed to lib/booking/occupancy by #1332
utils/slotAllocation/SlotValidationService.ts 1190 unchanged in this epic; its own follow-on
Target: no file over 600 lines, and find-available-slots.ts is the only one near it. Total lands around 4,600 across 23 files, which is a small increase over 4,357 because each file re-declares its imports. That increase is expected and is not a regression.
2.4 The facade
utils/slotAllocation/SlotAllocationService.ts stays at its current path and keeps exporting a SlotAllocationService binding, so all six production callers and all eleven test files keep compiling with no import churn. It becomes a delegation object rather than a class:
// utils/slotAllocation/SlotAllocationService.ts: facade, see #1375import{allocate}from"@/lib/booking/allocation";import{fetchEventData}from"@/lib/booking/allocation/event-data";import{findAvailableSlots}from"@/lib/booking/allocation/search/find-available-slots";// ... the handful of members the suites reach through the `as any` castexportconstSlotAllocationService={
allocate,
fetchEventData,
findAvailableSlots,// ...};
Two properties matter. First, the facade must expose every member the tests currently reach by casting, or those suites break on a move; the alternative is to update them in the same PR, which is acceptable but must be a deliberate choice per member rather than a discovery mid-step. Second, the facade is temporary: once the tests are migrated to import the extracted modules directly, a final PR deletes it and rewrites the six production imports to @/lib/booking/allocation. That deletion is the last step of the epic, never the first.
2.5 Extraction order
Each step is one PR based on dev, moves code with zero semantic edits, and is merged before the next begins. Leaves come first so that later steps have fewer live edges.
Step
PR scope
Files created
Why this order
A0
Convert the two source-text test assertions to behavioural ones; no source change
Highest risk in the allocator: doctrine rules 1 and 2 both live here. Race suite and chaos required
A7
modes/auto.ts, modes/manual.ts, modes/use-requested.ts, allocate.ts, index.ts; the class collapses to the facade
5
Last, because each mode owns a whole lock envelope and can only move once everything it calls has already moved
A8
Delete the facade; rewrite the six production imports and the test imports
0
Cosmetic and mechanical, separate so a revert of A8 does not revert the split
Renames from #1332 (deleteExistingAppointments to releaseUnpaidTentativeRows, the occupancy consolidation, mergeAdjacentWeeklyRows to coalesceAvailabilityRows) are excluded from A1 to A8 and land afterwards as their own PRs, so a reviewer can always diff a move as a move.
3. lib/payments/operations/checkout.ts
3.1 Section map
Eleven top-level exports, seven private helpers and one 1,530-line orchestrator, 3,808 lines total.
Lines
LOC
#NNNN refs
Cluster
Member and purpose
105-150
46
2
schema
unifiedCheckoutSchema, CheckoutInput, SubscriptionCheckoutResult re-exports and types
151-211
61
2
schema
buildPaymentMetadata: builds gateway notes shared by intent creation and the webhook
212-377
166
4
order reuse
ReusableOrder + findReusablePendingOrderPayment: adopt-or-supersede for an open PENDING order (#1220)
378-482
105
0
gateway
PaymentIntentManager: bounded FIFO intent tracking with cleanup
483-833
351
5
pricing
calculateAmountAndValidate: the one price derivation; discounts, credits, tax, buyer country
There is exactly one production caller and it uses one symbol. Nothing in lib/, jobs/, scripts/, actions/ or components/ imports the module by relative or aliased path; the webhook path in lib/payments/webhooks/handlers.ts has its own createConsultation / createSubscription / createWebinar / createClass twins rather than importing checkout's handlers, which is the duplication #1332 §4 last row proposes to collapse into one createBookingRows.
Of the eleven exported symbols, only three are imported anywhere: handleCheckout, findReusablePendingOrderPayment and handleConsultationCheckout. The other eight (unifiedCheckoutSchema, the CheckoutInput type re-export, PaymentIntentManager, calculateAmountAndValidate, validateSlotAvailability, handleSubscriptionCheckout, handleWebinarCheckout, handleClassCheckout) are imported by nothing at all. They are exported because the file grew, not because anything needs them. unifiedCheckoutSchema at :105 goes one better: it is a plain alias of checkoutSchema that is never referenced anywhere in the repository, including inside its own file, so it is fully dead code. The split should reduce the surface to handleCheckout plus whatever the suites genuinely pin.
3.3 The AFTER layout
lib/payments/checkout/
├── index.ts ~55 public surface: handleCheckout, plus the symbols the suites pin
├── schema.ts ~120 from 105-211: CheckoutInput, unifiedCheckoutSchema, buildPaymentMetadata, result types
├── order-reuse.ts ~180 from 212-377: findReusablePendingOrderPayment + ReusableOrder
├── gateway-intent.ts ~120 from 378-482: PaymentIntentManager
├── pricing.ts ~370 from 483-833: calculateAmountAndValidate (still delegating the arithmetic to lib/payments/pricing/derive-checkout-amount.ts)
├── occupancy-validate.ts ~280 from 834-1093: validateSlotAvailability (moves to lib/booking/occupancy under #1332's rename, not here)
├── locks.ts ~310 from 1094-1381: getPlanDataForLock, readEventCapacity, acquire/releaseCheckoutLock, verifyPlanExistsInsideLock
├── revalidate.ts ~420 from 1382-1785: revalidateInsideLock + OrgFundingContext
├── org-funding.ts ~240 from 2325-2532: the org gate lifted out of handleCheckout's preamble into one resolver returning a typed context
├── handlers/
│ ├── index.ts ~35 the kind-to-handler map that replaces the four-arm switch at 2886-2965
│ ├── consultation.ts ~115 from 1786-1884
│ ├── subscription.ts ~165 from 1885-2032
│ ├── webinar.ts ~140 from 2033-2157
│ └── class.ts ~135 from 2158-2278
├── commit/
│ ├── index.ts ~200 from 2808-2965: opens withSerializableRetry, renews the grant, re-checks credit, dispatches to handlers/
│ ├── payment-row.ts ~100 from 2966-3050: Payment, participants, atom-shape assertions
│ ├── org-utilization.ts ~300 from 3051-3335: utilization row, overage decision, invoice accrual
│ ├── payment-legs.ts ~290 from 3336-3611: leg construction and the sum assertion
│ └── ledger.ts ~170 from 3612-3762: journal entries and the healer enqueue
└── orchestrate.ts ~230 from 2279-2324 + 2533-2807 + 3763-3808: the five steps, the catch, the finally
lib/payments/operations/checkout.ts ~30 FACADE ONLY: re-exports from lib/payments/checkout
lib/payments/operations/checkout-replay.ts 62 unchanged (already separate, already thin)
Target: no file over 450 lines. Total lands around 4,000 across 21 files, which is a slight increase over 3,808 for the same reason as the allocator.
Note the deliberate asymmetry with the allocator: the checkout commit/ modules are called from inside the withSerializableRetry closure that commit/index.ts owns, so commit/index.ts is the only file that may open a transaction. Everything under commit/ takes tx as its first parameter and never touches the global client. That is the rule that keeps constraint 12 in §6 from being violated by a later edit.
3.4 The facade
lib/payments/operations/checkout.ts shrinks to a re-export file at its current path:
// lib/payments/operations/checkout.ts: facade, see #1375export{handleCheckout}from"@/lib/payments/checkout";export{findReusablePendingOrderPayment}from"@/lib/payments/checkout/order-reuse";export{handleConsultationCheckout}from"@/lib/payments/checkout/handlers/consultation";export{unifiedCheckoutSchema,typeCheckoutInput}from"@/lib/payments/checkout/schema";// ... the remaining legacy exports, each with a // deprecated: import from the module directly
Because only three symbols are actually imported anywhere, step C0 can demote the other eight to module-private before the split starts. That deletes no logic and changes no behaviour, but it removes eight symbols the facade would otherwise have to preserve through eleven PRs, and it deletes unifiedCheckoutSchema outright.
3.5 Extraction order
Step
PR scope
Files created
Why this order
C0
Demote the eight exports nothing imports to module-private; delete the fully dead unifiedCheckoutSchema
0
Shrinks the surface the facade has to preserve through eleven PRs; provably safe because the grep is empty
C1
schema.ts, gateway-intent.ts, order-reuse.ts
3
Leaves: no locks, no transactions, one has a dedicated suite already
C2
locks.ts
1
Must move as one unit; the acquire and release pair and the ordering comment belong together
C3
pricing.ts
1
Self-contained transaction; the price-parity test in __tests__/payments is a direct oracle
C4
occupancy-validate.ts
1
Highest ref density in the file (4.2 per 100 LOC) but a narrow contract
C5
revalidate.ts
1
Large, but a single function with one caller
C6
handlers/*
5
Four sibling functions plus the map that replaces the switch
C7
org-funding.ts
1
Lifts 208 lines out of the orchestrator preamble into a resolver returning a typed context object
C8
commit/*
5
The riskiest step by a wide margin: Serializable boundary, ledger, legs, utilization. Race suite plus chaos plus a mock-payment dev-server round
C9
orchestrate.ts, index.ts; the old file collapses to the facade
2
Last, for the same reason as A7
C10
Delete the facade; rewrite app/api/checkout/route.ts and the three test imports
0
Mechanical; separate so it can be reverted alone
4. Where the code has drifted from the documented shape
docs/booking/01-architecture.md and docs/booking/06-dependency-graphs.md were both refreshed by #1335 (wave-5 PR 11) on 2026-09-02, so this is drift that survived a deliberate docs pass rather than neglect. That is worth saying because it changes the remedy: the two documents are hand-maintained descriptions of a shape that has been re-derived four times, and the sustainable fix is to regenerate the dependency graph from the imports after this epic rather than to hand-patch it again.
The good news first. Every file path either document names still exists, so there are zero broken references, and neither document makes a literal line-count claim, so there is nothing to correct there. 06's cron table is exact: all eight workflows, all eight schedules, all eight abortIfMaintenance guards verified. 06's core service edges (types into all three services, Calc into Val and Alloc, Val into Alloc and Occ) are correct, as is its validate-then-BEGIN TRANSACTION ordering. The claim in both documents that auto-allocation is server-only with no surviving client engine is true: lib/scheduling/allocationAlgorithms.ts is 316 lines exporting only manualAllocate and allocateRequestedSlots, and its only product importer is hooks/scheduling/useSlotAllocation.ts.
The drift falls into four kinds, and it matters to this epic because a reviewer who checks a step against the docs will be checking against the wrong picture.
The first kind is layering edges that no longer exist or point the wrong way.
Doc claim
What the code shows
Kind
06:106 draws MERGE --> COV
availabilityCoverage.ts imports only @/lib/prisma and ./slotTimeUtils; the two modules do not touch. Merge's consumers are the four availability CRUD routes plus two scripts/db/coalesce-* scripts; coverage's consumers are checkout.ts:8-11 and app/api/trials/[trialId]/route.ts:50
stale layering
06:148 draws UC --> SP
UnifiedCalendar.tsx never imports SlotPicker. They are peers, both imported at line 4 of four separate page clients
stale layering
06:196-201 shows routes calling autoAllocate, manualAllocate and useRequestedSlots
All three are private static (:1105, :1545, :1983). The route surface is one method, allocate at :147, fanning out through dispatch at :208
stale layering and stale name
06:99-105 draws six route edges backwards against the document's own bottom-up convention stated at :51
The routes are the importers, not the imported
stale layering
06:93 omits any allocator-to-lock edge while :317 asserts every slot-occupying write holds one
SlotAllocationService.ts:59-65 imports four lock functions, used at :1142, :1153, :1607, :1617, :2015, :2036
stale layering
06:9 states each layer only calls the layer directly below
UnifiedCalendar.tsx:58 imports SlotCalculationService directly, skipping two tiers; lib/scheduling/calendarUtils.ts and allocationAlgorithms.ts do the same
stale layering
06:81 files lib/payments/operations/checkout.ts under the "API Routes" subgraph
It is a lib module; the route is app/api/checkout/route.ts (246 LOC)
mis-tiered node
01:162 says allocationService.ts and useSlotAllocation.ts both call utils/subscriptionValidation directly
Neither imports it. Its only product importers are SlotValidationService.ts:23 and one route. The two named functions getSubscriptionWeek and getSubscriptionType are referenced solely by a test
stale layering
06:134 declares node EC["EventCard.tsx"]
The node has no edges at all
dead node
The second kind is names and signatures that have moved, and two of these bear directly on steps in this plan.
Doc claim
What the code shows
Kind
01:74 says all allocator operations run inside a Prisma transaction with a 60-second timeout
Both halves are wrong. lib/prisma.ts:83-86 sets ALLOCATION_TX_TIMEOUT_MS to 30,000 with an 8,000 ms max wait, and #908 moved reads, search and validation out of the write transaction entirely (SlotAllocationService.ts:1173, :2637, :4125). 06:207-216 gets this right, so the two documents contradict each other
stale layering and stale figure
06:204 names lockSlotBooking as an allocator dependency
The allocator never imports it. lockSlotBooking belongs to the checkout path (checkout.ts:34, used at :1221). The allocator uses lockAutoAllocate and lockConsulteeBooking
stale name
01:36 lists the occupancy statuses as APPROVED subscriptions, PENDING/APPROVED/APPROVED_PENDING_PAYMENT consultations, SCHEDULED events
occupancyPolicy.ts:29-34 defines one list applied identically to both request types, and it includes SCHEDULED; :41 gives events ["SCHEDULED", "IN_PROGRESS"]. The doc also frames it as an exclusion of terminal statuses when the code is an inclusion allowlist
stale name
01:101 says weekly generation covers 8 weeks and builds a lookup set of all available blocks
SlotAllocationService.ts:2555 uses 8 weeks for consultations and 4 for everything else, and the code walks availability rows against a bookedSlots occupancy set rather than materialising blocks
SlotValidationService.ts:682-704 takes a timezone and compares SlotCalculationService.dayKey. toDateString() is gone
stale name
01:29 gives validate five parameters
SlotValidationService.ts:159-180 takes seven, adding excludeAppointmentIds and an options bag
stale signature
01:19-21 lists countCompletedCalls, groupSlotsByDay and groupSlotsByWeek as public with untimezoned keys
countCompletedCalls is private static at :563; both groupSlots* now take a timezone and key on dayKey/weekKey
stale name
01:166 says preference scoring orders candidates but never filters them
matchesPreferredDaysis used as a filter in the first sweep (:3011-3017, :3038). The claim survives only because a second relaxed sweep runs at :3087, and the two-pass design is never described
imprecise
01:304 documents a rate limit of three pending attempts per slot per 30 minutes
Removed with an explicit do-not-re-add note at checkout.ts:1076-1079: since #1169 blocks on any live hold, the count could never reach one
deleted rule
The third kind is modules that exist and carry real weight but appear in neither document. This is the largest category and the one that most affects a reader trying to understand the subsystem from the docs.
utils/slotAllocation/ has 11 files and 01 documents 3. Undocumented: slotTimeUtils.ts (408 LOC, imported by five modules including UnifiedCalendar.tsx), sessionCaps.ts, errors.ts, occupancyPolicy.ts, availabilityCoverage.ts, mergeAdjacentWeeklyRows.ts. preferenceScoring.ts appears in 01's prose and 06's diagram 4 but is missing from 06's diagram 2.
lib/booking/ has 14 files and the docs name one, transitions.ts, and only in 06. 01-architecture.md never mentions it at all, which for an architecture document that describes CAS-guarded lifecycles is a notable gap. Also undocumented: participants.ts, cancellation-scope.ts, org-actor.ts, overlap-meta.ts, reschedule-auto-confirm.ts, reschedule-proposals.ts, reschedule-respond.ts, reschedule-withdraw.ts and six more.
01 never mentions utils/appointmentlock.ts (999 LOC) at all. Both correctness pillars this epic must not break, the lock module and the transition helpers, are invisible in the architecture document.
Neither document mentions lib/db/serializable-retry.ts, lib/db/pg-errors.ts, lib/api/scope/*, lib/payments/pricing/derive-checkout-amount.ts or the prisma/sql/ sidecars, even though slot_no_confirmed_overlap is what makes classifyError return 409 rather than 500.
06's utils subgraph names 3 of the 12 files in lib/scheduling/, omitting slotSelectionValidation.ts (873 LOC), which 01:143 itself calls "the pure rules", plus allocationMessages.ts, slot-status-tokens.ts and slot-picker-focus.ts.
01's data-model tables omit columns this epic's code reads: SlotOfAppointment.completionStatus, completedAt, deletedAt and the denormalised consultantProfileId that backs the exclusion constraint; SlotOfAvailabilityWeekly.utcOffsetMinutes (which the weekly recurrence stepper actually uses) and the five frozen DST columns; Appointment.allocationIdempotencyKey, cancellationPolicySnapshot, trialSession and participants.
The fourth kind is a scope gap in 06's ER and cron diagrams. The ER omits Appointment ||--o| TrialSession even though the same document draws the trial expiry cron, and its BookingStatusHistory edge is drawn as appointment-keyed when the real key is entity plus entityId with a nullable appointmentId (prisma/schema.prisma:4119-4123). The cron diagram omits four booking-relevant jobs that live in the same directories, including detect-consultant-no-shows and cleanup-abandoned-payments.
What this means for the epic. Three concrete actions, none of which block a step.
Do not treat 06 as the source of truth for the extraction order. Use the caller tables in §2.2 and §3.2, which were derived from the imports directly.
Regenerate both documents from the new tree as the final PR of the epic, and make the regeneration mechanical so the fifth rewrite does not have the same failure mode. The dependency graph in particular should be produced by a script over the import edges rather than drawn by hand.
5. The AppointmentParticipant read shape
The split must land on the final read shape, not today's, because rewriting the same call sites twice is exactly the cost this epic exists to avoid.
Today, participation truth is the implicit many-to-many join SlotOfAppointment.user User[] @relation("SlotOfAppointmentToUser") (prisma/schema.prisma:4478, mirrored at :64 on User). AppointmentParticipant was added by #1322 as ADR A9 with @@unique([appointmentId, userId]) and a SetNull payment link (prisma/schema.prisma:4066-4095), but it is a shadow write only:
Writers: lib/booking/participants.ts (81 LOC) exports exactly three functions, recordParticipants, setParticipantStatus, linkParticipantsToPayment. They are called from utils/slotAllocation/SlotAllocationService.ts (inside createAppointments), lib/payments/operations/checkout.ts:3011, app/api/participants/webinar/[webinarId]/route.ts:233, app/api/participants/class/[classId]/route.ts:234 and prisma/seedFiles/8b-create-payments.ts:135.
Readers: none in product code. The only consumer is the parity check in scripts/appointments/reconcile-slot-availability.ts:426, which compares the new table against the old join.
Meanwhile 129 files under app/, lib/, jobs/, scripts/, components/, hooks/ and actions/ still read slotsOfAppointment.
Three consequences for this plan.
First, seat-writing must end up in exactly one file per giant: lib/booking/allocation/commit/create-appointments.ts and lib/payments/checkout/commit/payment-row.ts. Once it is, flipping the readers later touches those two files plus the read sites, not the whole allocator.
Second, __tests__/booking-algorithm/participant-shadow-write.test.ts:105 currently pins the shadow write by asserting /recordParticipants\(/ appears inside SlotAllocationService.ts. That assertion must be retargeted in step A0, and when it is, retarget it to the behaviour (a recordParticipants spy is called with the right rows) rather than to the new file path, so the reader flip does not have to touch it a third time.
Third, the reader flip itself is out of scope for this epic and belongs to its own issue under #1332 §3. This epic only has to guarantee that the flip becomes a small diff.
6. Risks specific to this repository
6.1 Doctrine invariants that a pure move can silently break
Read .claude/skills/booking-doctrine/SKILL.md and .claude/skills/booking-concurrency/SKILL.md before writing any step. The six doctrine rules are: every status write goes through a CAS transition helper; nothing a Payment points at is ever deleted; refunds have exactly two front doors; org scoping is explicit on every list; an approved request that was never paid has one outcome, EXPIRED; and there are no backfill migrations.
Most of those survive a file move for free. These do not, and each one is a co-location invariant, meaning the guard and the statement it guards must remain in the same statement or the same transaction.
Invariant
Where it lives today
How a move breaks it
Doctrine rule 2: the payment guard rides inside the DELETE WHERE
SlotAllocationService.ts:3795, :3883, :3972, all three inside deleteExistingAppointments
If step A6 hoists the payment check into a caller-side precondition, the rule breaks with no test-visible signature change. All three sites must move together into commit/release-tentative.ts with the guard still in the WHERE
Doctrine rule 1: BookingStatusHistory is appended in the same transaction as the CAS, reading the from-status first
inside lib/booking/transitions.ts, invoked from both giants
Do not extract history-writing into a post-commit module in either train
one call site, checkout.ts:2817, four attempts max
Step C8 must keep gateway calls, notifications and pay-link mints strictly outside the closure. The closure's contents are exactly what commit/index.ts owns
Concurrency: a lock grant taken before the retry loop is renewed inside it
renewOrAbort(perAttemptTtl) at the top of the retry callback in checkout.ts; extendSlotInterval for the slot atoms
If C2 puts acquisition in locks.ts and C8 puts the retry loop in commit/index.ts, the renewal call is the thing that quietly gets dropped. Pin it with a test before C2
Concurrency: every lock key is minted in utils/appointmentlock.ts and nowhere else
999 LOC, eight atom families
No new module may rebuild slot-booking:${id}:${iso} inline. Two names for one atom is no lock at all
Concurrency: acquireGuarded is the single acquisition path, fail-closed via checkRedisHealth plus withCircuitBreaker
utils/appointmentlock.ts
No new module may touch the Redis client directly; that turns fail-closed into fail-open
Concurrency: lockSlotInterval is all-or-nothing, acquiring atoms in ascending key order and releasing in reverse on any failure
utils/appointmentlock.ts
Do not decompose interval acquisition into per-atom acquisition across files
Concurrency: the global lock order appointment / event / consultant → consultee → slot, with approval → mint as the only legal nesting
a property of the call sequence, not of any file
This is the reason steps A7 and C9 come last. All three allocator modes acquire their own envelope (SlotAllocationService.ts:1142/:1153, :1607/:1617, :2015/:2036), so each mode file owns acquire, critical section and release as one unit and never delegates a partial envelope
Concurrency: the 23P01 heuristic is quarantined in lib/db/pg-errors.ts
56 LOC
The allocator has nowithSerializableRetry call of its own; its correctness rests on locks plus CAS plus the GiST constraint, so isExclusionViolation catch sites are its only 409 path and must survive step A6 intact
Sidecar parity: slot_no_confirmed_overlap excludes tentative rows and null-consultantProfileId rows and uses half-open tstzrange
prisma/sql/check-constraints.sql:66-73
Any JS occupancy predicate extracted in A1/A5/C4 must keep mirroring that predicate exactly
One more thing worth recording: booking-concurrency §4 explicitly retracts the "26 s function ceiling" as a configured number. There is no 26000 constant and no [functions] block in netlify.toml; it is an observed figure. The comment at utils/appointmentlock.ts:108 still states it as if configured, and that comment is stale. Correcting it is a one-line docs fix, not part of this epic, but do not build a new module around the number.
6.2 Shared node_modules across the fw-* worktrees
There are five sibling worktrees on this machine (fw-dpdp-docs, fw-explore-responsive, fw-perf-1148, fw-support-hub, fw-ui-overhaul) plus the agent worktrees under .claude/worktrees/. At least one of them, fw-support-hub, has node_modules as a symlink to the canonical tree at /Users/kaustavghosh/Desktop/familiarise_web/node_modules. There is therefore one generated Prisma client shared by every worktree, and running prisma generate in any of them rewrites it for all of them.
For an epic that churns imports across roughly forty new files, that matters concretely:
Always chain prisma generate and tsc --noEmit in a single command so nothing can regenerate the client between them.
jest.config.ts sets modulePathIgnorePatterns: ["/\\.claude/worktrees/"] and testPathIgnorePatterns with the same entry. That covers the agent worktrees, which live inside the repo root. It does not cover the fw-* siblings, which are separate roots outside the repo and so are invisible to Jest's crawler anyway. No config change is needed, but do not "helpfully" broaden the pattern.
Do not run this epic's steps concurrently with another agent in a sibling worktree. Two extraction PRs touching the same file will produce conflict markers that a fork can commit without noticing.
6.3 Formatting
A format-only pre-PR is the usual precondition for an extraction, because otherwise the first move reformats the file and the diff stops being reviewable as a move. It turns out neither giant needs one. Verified on 5f057bc02: prettier --check passes on both lib/payments/operations/checkout.ts and utils/slotAllocation/SlotAllocationService.ts, so any earlier note that checkout.ts is prettier-dirty on dev is out of date. The two files in the booking scope that are currently dirty are:
Neither is touched by steps A0 to A8 or C0 to C10, so the format-only pre-PR is not needed for this epic as scoped. It is needed before the refund.ts follow-on in §8, and it should land as its own commit there so the extraction diff stays pure movement. Filing it now as a one-line PR against dev is cheap and removes the trap entirely.
Separately, .gitignore:108 lists prompts/, which Prettier reads as an ignore file, so prettier --check silently passes on unformatted files under prompts/. That does not affect this epic but is worth knowing when a step touches the prompt corpus.
6.4 The pre-MVP data reset
The reset runbook lives in #1319 §6 and is owned by #1322. Two consequences:
This epic touches no schema and needs no migration, which is what makes it safe to run either side of the reset. Doctrine rule 6 forbids backfill migrations, and nothing here needs one.
Every step must be verifiable without db push against the shared Supabase project. That project serves both dev and prod, has no branches, and every mutating script against it is a production operation. Verification is jest plus a seeded dev server with mock payments, never a push. See .claude/skills/booking-verification/SKILL.md.
6.5 The moving target
Both giants grew during wave 5 (§1). Running these twenty PRs while a feature train is also patching the same two files produces continuous conflicts, and rebase --onto restacks across squash merges are the exact failure mode #1319 already documented. This epic needs a declared freeze window on the two files, or it needs to run strictly between trains. It should not be started opportunistically.
6.6 Test coupling that a move breaks even when behaviour does not change
Restated here because it is the highest-probability failure in the plan: two suites assert on the text of SlotAllocationService.ts (collaborator-availability-modes.test.ts:289-297, participant-shadow-write.test.ts:105) and four reach private statics through a cast. Step A0 exists solely to defuse these. Do not begin A1 until A0 is merged and green.
7. Verification per step and definition of done
7.1 The per-step gate
Every PR in both trains runs the same gate. The suites are the oracle: since no step changes behaviour, any red test is a real regression in that step, never an expected update.
Check
Command
Applies to
Schema still valid, client fresh
npx prisma validate && npx prisma generate chained with the next line
07-real-api-booking and 09-webhook-storm against a deploy preview
A7, C8, C9
Dev-server round
one mock-payment booking end to end per booking type touched
A6, A7, C8, C9
Bot triage
CodeRabbit and Gemini threads triaged, SonarCloud PR gate green
every step
The suite counts above are as of 5f057bc02. Both grew during the wave-5 train, so any older figure carried in a planning document is stale: __tests__/booking-algorithm is now 66 files and __tests__/payments is now 51. tests/typescript/race-conditions/scenarios holds 10 categories.
7.2 The move-purity check
The distinguishing gate for this epic, and the one that makes a reviewer's job tractable, is a mechanical proof that a step moved code rather than changed it. For each extraction PR:
Concatenate the moved regions from the pre-PR file and the new files, strip import blocks and leading whitespace, and diff. The diff must be empty except for this. becoming a bare call, private static becoming function, and added export keywords.
Assert the issue-reference count is conserved: grep -oE '#[0-9]{3,4}' over the before-file and over the after-set must produce the same multiset. A dropped #NNNN comment means a dropped patch, and that is the cheapest possible detector for the failure mode this whole epic is about.
Assert the doctrine-critical string set survives: payment: { none: {} } still appears three times in the allocator's after-set, withSerializableRetry still appears exactly once in checkout's, and every transition* call site count is unchanged.
Items 2 and 3 are worth writing as a small script or as a jest pin, because they are run twenty times.
7.3 Definition of done
The epic is done when all of the following hold on dev.
No file under lib/booking/allocation/ exceeds 600 lines and no file under lib/payments/checkout/ exceeds 450 lines.
utils/slotAllocation/SlotAllocationService.ts and lib/payments/operations/checkout.ts no longer exist; the six allocator callers import @/lib/booking/allocation and app/api/checkout/route.ts imports @/lib/payments/checkout.
The full issue-reference multiset from the two original files is conserved across the two new trees, verified by the §7.2 check.
All three payment: { none: {} } guards remain inside their deleteMany WHERE clauses, in one file.
checkout has exactly one withSerializableRetry call site and it lives in lib/payments/checkout/commit/index.ts; no file under commit/ opens its own transaction or touches the global Prisma client.
Every lock acquisition still goes through utils/appointmentlock.ts; no new module constructs a lock key string or calls Redis directly. Each of the three allocator mode files owns a complete acquire-critical-section-release envelope.
No source-text test assertions remain against any file in either tree.
__tests__/booking-algorithm, __tests__/payments, __tests__/booking, __tests__/collaborators and __tests__/maintenance are green; the race suite categories listed in §7.1 pass; chaos 07 and 09 pass against a deploy preview.
Zero schema changes and zero migrations were produced by the epic.
docs/booking/01-architecture.md and docs/booking/06-dependency-graphs.md are regenerated from the new tree in a final docs PR, and the drift items in §4 are closed rather than carried forward.
8. Follow-ons, explicitly out of scope here
These are named so they are not silently absorbed into this epic.
lib/payments/webhooks/handlers.ts (2,270 LOC). Already a flat list of eighteen named functions with handlePaymentSuccess at 950 lines being the only giant. Its createConsultation / createSubscription / createWebinar / createClass are the duplicate creators [TRACKER] Booking + payments code architecture: folder layout, API surface, model and function naming #1332 §4 proposes to collapse into one createBookingRows shared with checkout's handlers, so it should follow C6 rather than precede it.
lib/payments/operations/refund.ts (1,717 LOC). applyRefundCascade is 773 lines and refundPayment is 535. Needs the format-only PR from §6.3 first.
components/dashboard/shared/requests/RequestSlotAllocationTab.tsx (1,454 LOC), 90% of its folder.
utils/slotAllocation/SlotValidationService.ts (1,190 LOC), fifteen methods, the natural home for validation extracted from either giant. It should be split after A8 so the allocator's needs are known.
app/api/appointments/[appointmentId]/reschedule/route.ts (1,004 LOC) and app/api/appointments/[appointmentId]/cancel/route.ts (792 LOC). These are the only route handlers in the subsystem carrying real logic rather than delegation, and most of it belongs in lib/booking/ next to the four reschedule-* modules that already exist there.
utils/timeSlotsProcessing.ts (988 LOC) and lib/scheduling/slotSelectionValidation.ts (873 LOC), both undocumented and both load-bearing for the availability grid.
Part of #1332 (architecture tracker) and #1319 (wave-5 umbrella, §4 "U2 god-module split (own epic after PR 2)"). This issue is the plan that #1332 §4 row 1 and #1319 §2 "UPDATE" both defer to. It is post-MVP and not scheduled. Nothing here changes behaviour; every step is a move plus a facade, with the existing suites as the oracle.
#1332 records the decisions (what things should be called, where folders should live). This issue records the mechanics for exactly two files: the measured section map, the caller inventory, the target tree, the order of extraction, and the traps. Where the two overlap, #1332 wins on naming and this issue defers to it; renames are explicitly excluded from every step below so that a rename never rides along with a move.
1. Scope measurement
Measured on
devat5f057bc02on 2026-09-03, after the whole wave-5 train (#1321 through #1337) had merged. Every file in the requested scope that exceeds 1,000 lines is listed below.utils/slotAllocation/SlotAllocationService.tslib/payments/operations/checkout.tslib/payments/webhooks/handlers.tslib/payments/operations/refund.tscomponents/scheduling/UnifiedCalendar.tsxhooks/scheduling/useSlotAllocation.tscomponents/dashboard/shared/requests/RequestSlotAllocationTab.tsxutils/slotAllocation/SlotValidationService.tsFor context, here are the folder totals the two giants sit inside, so the concentration is visible rather than asserted.
utils/slotAllocation/SlotAllocationService.tslib/payments/operations/checkout.tslib/payments/webhooks/handlers.tsapp/api/slots/**/route.tsavailability-with-allocation/[consultantId]/route.ts(768)components/scheduling/UnifiedCalendar.tsxhooks/scheduling/useSlotAllocation.tscomponents/dashboard/shared/requests/RequestSlotAllocationTab.tsxapp/api/checkout/route.tsis 246 lines and is not a problem: it is a thin caller that validates, replays an idempotency key and delegates. That is the shape every other entry point should end up with, and it is worth saying out loud that the route layer is already fine. The god modules are behind it.Three files just outside the requested scope also cross 1,000 lines and belong in the same conversation, so they are recorded here rather than discovered later:
app/api/appointments/[appointmentId]/reschedule/route.ts(1,004),utils/timeSlotsProcessing.ts(988, just under) andapp/api/appointments/[appointmentId]/cancel/route.ts(792, just under). The reschedule route is the one genuine counter-example to "the route layer is already fine", and it is listed as a follow-on in §8.Both giants grew during wave 5. #1319 and #1332 quote them at 3,901 and 3,666 lines respectively. They are now 4,357 and 3,808, so the allocator gained 456 lines and checkout gained 142 in a single train. The split is not competing with a static target, which is the main argument for giving it a freeze window rather than interleaving it with feature work.
Only the two giants are planned in detail here.
handlers.ts,refund.tsandUnifiedCalendar.tsxare named as follow-ons in §8 because they are large but structurally much healthier: each already has a flat list of named top-level functions rather than one 1,500-line function.2.
utils/slotAllocation/SlotAllocationService.ts2.1 Section map
One class, 39 static methods, 4,357 lines. The table below gives every method with its line range, its size, the number of
#NNNNissue references in that range (the patch-density proxy) and what it actually does. Theclustercolumn is the responsibility the method belongs to, which is not the order the file puts them in.#NNNNrefsAppointmentWithSlots,SLOT_DURATION_MS,MAX_CANDIDATE_STARTS_PER_ROW,AllocationWalkContextallocate: the one public entry; wrapsdispatch, fires the placement notification, classifies thrown errors into anAllocationResultdispatch: mode router (AUTO / MANUAL / USE_REQUESTED)isModeledOutcome: decides whether an error is an expected refusal or a fault, for Sentry levelclassifyError: maps every thrown type toerrorCodeplus HTTP statusnotifyAllocationPlaced: fire-and-forget Novu fan-out to both parties, partial-allocation awaregetConsultantProfileId: resolves the owning consultant for any of the four event typesgetConsulteeUserId: resolves the booking consultee for the consultee lockgetAppointmentType:EventTypetoAppointmentsTypegetEventRelationField: event type to Prisma relation nameassertCollaboratorsFree: the AE-2 co-host guardguardInitialAllocationInTx: in-transaction advisory guard for ADR B10assertNoConfirmedSlots: refuses to reallocate over confirmed rowsassertExpectedTentativeSlotCount: optimistic count check outside the txassertExpectedTentativeSlotCountInTx: the same check as a CAS-style precondition inside the txfindIdempotentAllocation: returns the prior batch for a repeated keyreplayPartialCounts: reconstructs placed/required/unplaced for a replayreleasedSlotIdsOf: slot ids freed by a replayed attemptopenPreferenceRequestWhere: the open-reschedule-preference predicatefindAllocationPreference: loads the consultee's preferred windowresolveConsumedPreferenceRequests: declines superseded preference requests via the CAS helperautoAllocate: acquiresauto-allocate:+consultee-booking:, searches, commits, releasesmanualAllocate: same lock envelope for consultant-chosen times, with the wide-lock variantuseRequestedSlots: same envelope for approving consultee-stored timesisWithinAvailability: is a candidate start inside published covercandidateStartsInRow: the bounded 30-minute walk over one availability rowreportRowWalkTruncated: deduplicated truncation telemetry (#1194)buildConsecutiveBlock: assembles N contiguous atoms from a startbestFittingBlockInRow: preference-scored best block in one rowbestBlockForSingleSession: single-session variantfindAvailableSlots: the read-only search driver; explicitly takes the base client so it runs outside the write transaction (#908)getNextOccurrenceWeekly: weekly recurrence steppermatchWeeklySlotToDay: projects a weekly rule onto a datematchCustomSlotToDay: projects a dated exception onto a datecreateAppointments: writesAppointment+ NSlotOfAppointmentatoms, stamps org context, records participantsrecordSubscriptionAllocationCap: weekly-cap bookkeeping for recurring plansreconnectEnrolledUsers: re-seats attendees onto regenerated slotsdeleteExistingAppointments: releases never-paid tentative rows; carries all three doctrine rule-2 payment guards at:3795,:3883,:3972updateEventStatus: CAS status writes on the parent eventfetchEventData: the polymorphic five-arm read that loads consultant, config and org contextGrouped by cluster, the patch density is where you would expect it. The three mode functions are 27% of the file and carry 37% of all issue references.
#NNNNrefsThe file carries 128 issue references across 24 distinct issues: #440, #446, #676, #710, #768, #784, #836, #837, #843, #860, #873, #898, #908, #939, #1012, #1060, #1065, #1071, #1132, #1169, #1189, #1194, #1206, #1319. That is the concrete form of "correct by accumulated patch, not by construction" from #1319 §2.
2.2 Callers
The production surface is six call sites, and every one of them uses exactly one symbol:
SlotAllocationService.allocate.app/api/bookings/consultations/[consultationId]/allocate/route.tsSlotAllocationService.allocateapp/api/bookings/subscriptions/[subscriptionId]/allocate/route.tsSlotAllocationService.allocateapp/api/bookings/webinars/[webinarId]/allocate/route.tsSlotAllocationService.allocateapp/api/bookings/classes/[classId]/allocate/route.tsSlotAllocationService.allocatelib/booking/reschedule-auto-confirm.tsSlotAllocationService.allocatelib/booking/reschedule-respond.tsSlotAllocationService.allocateNothing in
jobs/orscripts/imports it.scripts/appointments/reconcile-slot-availability.ts:397only mentions it in a comment.The test surface is much wider and, critically, is not confined to the public method. These are the couplings that decide the extraction order.
__tests__/booking-algorithm/slotAllocationService.test.tsallocateonly, roughly 90 call sites__tests__/booking-algorithm/collaborator-availability-modes.test.tsallocate, plus a source-text assertion: readsutils/slotAllocation/SlotAllocationService.tsfrom disk at:289and asserts the literal string"SlotAllocationService.assertCollaboratorsFree("appears at:297__tests__/booking-algorithm/participant-shadow-write.test.ts:105: asserts/recordParticipants\(/matches insideutils/slotAllocation/SlotAllocationService.ts__tests__/booking-algorithm/availability-window-scan.test.ts:58to reach a private static__tests__/booking-algorithm/preference-scored-allocation.test.ts:74and:584to reach private statics__tests__/enterprise/appointment-org-stamping.test.tsconst svc = SlotAllocationService as anyat:46, then callsfetchEventDatadirectly__tests__/booking-algorithm/expected-tentative-count.test.tsallocate__tests__/booking-algorithm/initial-allocation-guard.test.tsallocate__tests__/booking-algorithm/row-walk-truncation.test.tsallocate__tests__/booking-algorithm/reschedule-respond.test.tsjest.mockof the module path at:55__tests__/payments/allocation-utilization-integrity.test.tsallocateThe two source-text assertions are the single biggest trap in this whole plan. They pass or fail on the contents of one file path, so moving
assertCollaboratorsFreeor therecordParticipantscall into a sibling module makes them fail even though the behaviour is byte-for-byte identical. They must be converted to behavioural assertions before any extraction starts, otherwise every later step has a red suite for the wrong reason and the oracle stops being trustworthy.2.3 The AFTER layout
Target: no file over 600 lines, and
find-available-slots.tsis the only one near it. Total lands around 4,600 across 23 files, which is a small increase over 4,357 because each file re-declares its imports. That increase is expected and is not a regression.2.4 The facade
utils/slotAllocation/SlotAllocationService.tsstays at its current path and keeps exporting aSlotAllocationServicebinding, so all six production callers and all eleven test files keep compiling with no import churn. It becomes a delegation object rather than a class:Two properties matter. First, the facade must expose every member the tests currently reach by casting, or those suites break on a move; the alternative is to update them in the same PR, which is acceptable but must be a deliberate choice per member rather than a discovery mid-step. Second, the facade is temporary: once the tests are migrated to import the extracted modules directly, a final PR deletes it and rewrites the six production imports to
@/lib/booking/allocation. That deletion is the last step of the epic, never the first.2.5 Extraction order
Each step is one PR based on
dev, moves code with zero semantic edits, and is merged before the next begins. Leaves come first so that later steps have fewer live edges.search/constants.ts,search/window-walk.ts,search/blocks.ts,search/recurrence.tsclassify.ts,notify.tserrors.ts)identity.ts,event-data.tsguards.ts,idempotency.ts,preference.ts,collaborators.tssearch/find-available-slots.tscommit/create-appointments.ts,commit/release-tentative.ts,commit/reconnect-enrolled.ts,commit/subscription-cap.tsmodes/auto.ts,modes/manual.ts,modes/use-requested.ts,allocate.ts,index.ts; the class collapses to the facadeRenames from #1332 (
deleteExistingAppointmentstoreleaseUnpaidTentativeRows, the occupancy consolidation,mergeAdjacentWeeklyRowstocoalesceAvailabilityRows) are excluded from A1 to A8 and land afterwards as their own PRs, so a reviewer can always diff a move as a move.3.
lib/payments/operations/checkout.ts3.1 Section map
Eleven top-level exports, seven private helpers and one 1,530-line orchestrator, 3,808 lines total.
#NNNNrefsunifiedCheckoutSchema,CheckoutInput,SubscriptionCheckoutResultre-exports and typesbuildPaymentMetadata: builds gatewaynotesshared by intent creation and the webhookReusableOrder+findReusablePendingOrderPayment: adopt-or-supersede for an open PENDING order (#1220)PaymentIntentManager: bounded FIFO intent tracking with cleanupcalculateAmountAndValidate: the one price derivation; discounts, credits, tax, buyer countryvalidateSlotAvailability: DPDP consent gate, confirmed-overlap check, duplicate tentative, tentative rate limitgetPlanDataForLock: resolves the consultant id needed to mint the lock keyreadEventCapacity: pre-lock capacity fast-failacquireCheckoutLock: the whole three-family envelope: event, consultee, slotreleaseCheckoutLockverifyPlanExistsInsideLock: BUG-E re-readOrgFundingContext+revalidateInsideLock: re-runs the whole pre-lock validation under the lockhandleConsultationCheckout: creates the consultation request rowshandleSubscriptionCheckout: creates the subscription placeholder and its periodhandleWebinarCheckout: seats a registrant on an existing webinarhandleClassCheckout: seats a learner across the class's sessionshandleCheckout: see the sub-map belowhandleCheckoutis the real problem, so it gets its own map. The step comments in the source are the natural seams and they are already numbered.#NNNNrefslock,lockType,consulteeLock,paymentResponse,organizationId,billingAccountId,fundingSource,programAssignmentId,fundingProgramId,callerMembershipId,creditEffectiveLimit)canSponsor, billing account, dunning suspension, funding source, program assignment resolve, credit limit fast-failcalculateAmountAndValidate, outside the lockgetPlanDataForLockthenacquireCheckoutLock, with the global lock order in a commentrevalidateInsideLockwithSerializableRetryopens, lock renewed per attempt, INVOICE credit re-checked inside the txswitchthat calls the per-type handlerPaymentrow,AppointmentParticipantshadow write, atom-shape assertionsPaymentLegconstruction and the legs-sum-to-amount assertioncatch: the outermost classification boundary for steps 1 to 3finally: lock releaseGrouped, the picture is that half the file is one function and that function is itself six functions in a trench coat.
#NNNNrefshandleCheckout)The file carries 76 issue references across 30 distinct issues: #437, #440, #520, #540, #674, #687, #701, #710, #750, #768, #775, #778, #780, #781, #785, #812, #828, #832, #837, #898, #971, #1071, #1076, #1093, #1132, #1169, #1220, #1230, #1319, #1320.
3.2 Callers
The public surface is remarkably small, which is the good news in this file.
app/api/checkout/route.tshandleCheckouttests/typescript/race-conditions/test-checkout-race-condition-fix.tshandleCheckout__tests__/payments/checkout-open-order-reuse.test.tshandleCheckout,findReusablePendingOrderPayment__tests__/payments/consultation-atom-parity.test.tshandleConsultationCheckoutThere is exactly one production caller and it uses one symbol. Nothing in
lib/,jobs/,scripts/,actions/orcomponents/imports the module by relative or aliased path; the webhook path inlib/payments/webhooks/handlers.tshas its owncreateConsultation/createSubscription/createWebinar/createClasstwins rather than importing checkout's handlers, which is the duplication #1332 §4 last row proposes to collapse into onecreateBookingRows.Of the eleven exported symbols, only three are imported anywhere:
handleCheckout,findReusablePendingOrderPaymentandhandleConsultationCheckout. The other eight (unifiedCheckoutSchema, theCheckoutInputtype re-export,PaymentIntentManager,calculateAmountAndValidate,validateSlotAvailability,handleSubscriptionCheckout,handleWebinarCheckout,handleClassCheckout) are imported by nothing at all. They are exported because the file grew, not because anything needs them.unifiedCheckoutSchemaat:105goes one better: it is a plain alias ofcheckoutSchemathat is never referenced anywhere in the repository, including inside its own file, so it is fully dead code. The split should reduce the surface tohandleCheckoutplus whatever the suites genuinely pin.3.3 The AFTER layout
Target: no file over 450 lines. Total lands around 4,000 across 21 files, which is a slight increase over 3,808 for the same reason as the allocator.
Note the deliberate asymmetry with the allocator: the checkout
commit/modules are called from inside thewithSerializableRetryclosure thatcommit/index.tsowns, socommit/index.tsis the only file that may open a transaction. Everything undercommit/takestxas its first parameter and never touches the global client. That is the rule that keeps constraint 12 in §6 from being violated by a later edit.3.4 The facade
lib/payments/operations/checkout.tsshrinks to a re-export file at its current path:Because only three symbols are actually imported anywhere, step C0 can demote the other eight to module-private before the split starts. That deletes no logic and changes no behaviour, but it removes eight symbols the facade would otherwise have to preserve through eleven PRs, and it deletes
unifiedCheckoutSchemaoutright.3.5 Extraction order
unifiedCheckoutSchemaschema.ts,gateway-intent.ts,order-reuse.tslocks.tspricing.ts__tests__/paymentsis a direct oracleoccupancy-validate.tsrevalidate.tshandlers/*org-funding.tscommit/*orchestrate.ts,index.ts; the old file collapses to the facadeapp/api/checkout/route.tsand the three test imports4. Where the code has drifted from the documented shape
docs/booking/01-architecture.mdanddocs/booking/06-dependency-graphs.mdwere both refreshed by #1335 (wave-5 PR 11) on 2026-09-02, so this is drift that survived a deliberate docs pass rather than neglect. That is worth saying because it changes the remedy: the two documents are hand-maintained descriptions of a shape that has been re-derived four times, and the sustainable fix is to regenerate the dependency graph from the imports after this epic rather than to hand-patch it again.The good news first. Every file path either document names still exists, so there are zero broken references, and neither document makes a literal line-count claim, so there is nothing to correct there.
06's cron table is exact: all eight workflows, all eight schedules, all eightabortIfMaintenanceguards verified.06's core service edges (typesinto all three services,CalcintoValandAlloc,ValintoAllocandOcc) are correct, as is its validate-then-BEGIN TRANSACTIONordering. The claim in both documents that auto-allocation is server-only with no surviving client engine is true:lib/scheduling/allocationAlgorithms.tsis 316 lines exporting onlymanualAllocateandallocateRequestedSlots, and its only product importer ishooks/scheduling/useSlotAllocation.ts.The drift falls into four kinds, and it matters to this epic because a reviewer who checks a step against the docs will be checking against the wrong picture.
The first kind is layering edges that no longer exist or point the wrong way.
06:106drawsMERGE --> COVavailabilityCoverage.tsimports only@/lib/prismaand./slotTimeUtils; the two modules do not touch. Merge's consumers are the four availability CRUD routes plus twoscripts/db/coalesce-*scripts; coverage's consumers arecheckout.ts:8-11andapp/api/trials/[trialId]/route.ts:5006:148drawsUC --> SPUnifiedCalendar.tsxnever importsSlotPicker. They are peers, both imported at line 4 of four separate page clients06:196-201shows routes callingautoAllocate,manualAllocateanduseRequestedSlotsprivate static(:1105,:1545,:1983). The route surface is one method,allocateat:147, fanning out throughdispatchat:20806:99-105draws six route edges backwards against the document's own bottom-up convention stated at:5106:93omits any allocator-to-lock edge while:317asserts every slot-occupying write holds oneSlotAllocationService.ts:59-65imports four lock functions, used at:1142,:1153,:1607,:1617,:2015,:203606:9states each layer only calls the layer directly belowUnifiedCalendar.tsx:58importsSlotCalculationServicedirectly, skipping two tiers;lib/scheduling/calendarUtils.tsandallocationAlgorithms.tsdo the same06:81fileslib/payments/operations/checkout.tsunder the "API Routes" subgraphapp/api/checkout/route.ts(246 LOC)01:162saysallocationService.tsanduseSlotAllocation.tsboth callutils/subscriptionValidationdirectlySlotValidationService.ts:23and one route. The two named functionsgetSubscriptionWeekandgetSubscriptionTypeare referenced solely by a test06:134declares nodeEC["EventCard.tsx"]The second kind is names and signatures that have moved, and two of these bear directly on steps in this plan.
01:74says all allocator operations run inside a Prisma transaction with a 60-second timeoutlib/prisma.ts:83-86setsALLOCATION_TX_TIMEOUT_MSto 30,000 with an 8,000 ms max wait, and #908 moved reads, search and validation out of the write transaction entirely (SlotAllocationService.ts:1173,:2637,:4125).06:207-216gets this right, so the two documents contradict each other06:204nameslockSlotBookingas an allocator dependencylockSlotBookingbelongs to the checkout path (checkout.ts:34, used at:1221). The allocator useslockAutoAllocateandlockConsulteeBooking01:36lists the occupancy statuses as APPROVED subscriptions, PENDING/APPROVED/APPROVED_PENDING_PAYMENT consultations, SCHEDULED eventsoccupancyPolicy.ts:29-34defines one list applied identically to both request types, and it includesSCHEDULED;:41gives events["SCHEDULED", "IN_PROGRESS"]. The doc also frames it as an exclusion of terminal statuses when the code is an inclusion allowlist01:101says weekly generation covers 8 weeks and builds a lookup set of all available blocksSlotAllocationService.ts:2555uses 8 weeks for consultations and 4 for everything else, and the code walks availability rows against abookedSlotsoccupancy set rather than materialising blocks01:40saysvalidateSameDaySlotscomparestoDateString()SlotValidationService.ts:682-704takes a timezone and comparesSlotCalculationService.dayKey.toDateString()is gone01:29givesvalidatefive parametersSlotValidationService.ts:159-180takes seven, addingexcludeAppointmentIdsand an options bag01:19-21listscountCompletedCalls,groupSlotsByDayandgroupSlotsByWeekas public with untimezoned keyscountCompletedCallsisprivate staticat:563; bothgroupSlots*now take a timezone and key ondayKey/weekKey01:166says preference scoring orders candidates but never filters themmatchesPreferredDaysis used as a filter in the first sweep (:3011-3017,:3038). The claim survives only because a second relaxed sweep runs at:3087, and the two-pass design is never described01:304documents a rate limit of three pending attempts per slot per 30 minutescheckout.ts:1076-1079: since #1169 blocks on any live hold, the count could never reach oneThe third kind is modules that exist and carry real weight but appear in neither document. This is the largest category and the one that most affects a reader trying to understand the subsystem from the docs.
utils/slotAllocation/has 11 files and01documents 3. Undocumented:slotTimeUtils.ts(408 LOC, imported by five modules includingUnifiedCalendar.tsx),sessionCaps.ts,errors.ts,occupancyPolicy.ts,availabilityCoverage.ts,mergeAdjacentWeeklyRows.ts.preferenceScoring.tsappears in01's prose and06's diagram 4 but is missing from06's diagram 2.lib/booking/has 14 files and the docs name one,transitions.ts, and only in06.01-architecture.mdnever mentions it at all, which for an architecture document that describes CAS-guarded lifecycles is a notable gap. Also undocumented:participants.ts,cancellation-scope.ts,org-actor.ts,overlap-meta.ts,reschedule-auto-confirm.ts,reschedule-proposals.ts,reschedule-respond.ts,reschedule-withdraw.tsand six more.01never mentionsutils/appointmentlock.ts(999 LOC) at all. Both correctness pillars this epic must not break, the lock module and the transition helpers, are invisible in the architecture document.lib/db/serializable-retry.ts,lib/db/pg-errors.ts,lib/api/scope/*,lib/payments/pricing/derive-checkout-amount.tsor theprisma/sql/sidecars, even thoughslot_no_confirmed_overlapis what makesclassifyErrorreturn 409 rather than 500.06'sutilssubgraph names 3 of the 12 files inlib/scheduling/, omittingslotSelectionValidation.ts(873 LOC), which01:143itself calls "the pure rules", plusallocationMessages.ts,slot-status-tokens.tsandslot-picker-focus.ts.01's data-model tables omit columns this epic's code reads:SlotOfAppointment.completionStatus,completedAt,deletedAtand the denormalisedconsultantProfileIdthat backs the exclusion constraint;SlotOfAvailabilityWeekly.utcOffsetMinutes(which the weekly recurrence stepper actually uses) and the five frozen DST columns;Appointment.allocationIdempotencyKey,cancellationPolicySnapshot,trialSessionandparticipants.01:97-121describes the three allocation modes without any of the features that account for most of their 1,164 lines: the two-pass preference sweep (N>1 reschedule: preference-scored auto-allocation #1065), partial allocation (Product decision: should consultants be able to PARTIALLY allocate recurring events when availability falls short? #1206), the collaborator guard, the idempotency replay path, the three in-transaction guards, and walk truncation (MAX_CANDIDATE_STARTS_PER_ROW is a silent truncation risk in auto-allocate candidate walks (tracked undeveloped in #1169) #1194).The fourth kind is a scope gap in
06's ER and cron diagrams. The ER omitsAppointment ||--o| TrialSessioneven though the same document draws the trial expiry cron, and itsBookingStatusHistoryedge is drawn as appointment-keyed when the real key isentityplusentityIdwith a nullableappointmentId(prisma/schema.prisma:4119-4123). The cron diagram omits four booking-relevant jobs that live in the same directories, includingdetect-consultant-no-showsandcleanup-abandoned-payments.What this means for the epic. Three concrete actions, none of which block a step.
01:74before anyone reads it as licence to wrap a whole mode in a transaction. It is the one drift item that could cause a wrong change: a contributor who believes everything runs in a 60-second transaction might "restore" reads into the write transaction during step A5 or A7 and undo bug(allocation): subscription Auto-Allocate fails with 500 'Unable to start a transaction in the given time' (~115s) — paid subscription left unallocated #908.06as the source of truth for the extraction order. Use the caller tables in §2.2 and §3.2, which were derived from the imports directly.5. The
AppointmentParticipantread shapeThe split must land on the final read shape, not today's, because rewriting the same call sites twice is exactly the cost this epic exists to avoid.
Today, participation truth is the implicit many-to-many join
SlotOfAppointment.user User[] @relation("SlotOfAppointmentToUser")(prisma/schema.prisma:4478, mirrored at:64onUser).AppointmentParticipantwas added by #1322 as ADR A9 with@@unique([appointmentId, userId])and aSetNullpayment link (prisma/schema.prisma:4066-4095), but it is a shadow write only:lib/booking/participants.ts(81 LOC) exports exactly three functions,recordParticipants,setParticipantStatus,linkParticipantsToPayment. They are called fromutils/slotAllocation/SlotAllocationService.ts(insidecreateAppointments),lib/payments/operations/checkout.ts:3011,app/api/participants/webinar/[webinarId]/route.ts:233,app/api/participants/class/[classId]/route.ts:234andprisma/seedFiles/8b-create-payments.ts:135.scripts/appointments/reconcile-slot-availability.ts:426, which compares the new table against the old join.app/,lib/,jobs/,scripts/,components/,hooks/andactions/still readslotsOfAppointment.Three consequences for this plan.
First, seat-writing must end up in exactly one file per giant:
lib/booking/allocation/commit/create-appointments.tsandlib/payments/checkout/commit/payment-row.ts. Once it is, flipping the readers later touches those two files plus the read sites, not the whole allocator.Second,
__tests__/booking-algorithm/participant-shadow-write.test.ts:105currently pins the shadow write by asserting/recordParticipants\(/appears insideSlotAllocationService.ts. That assertion must be retargeted in step A0, and when it is, retarget it to the behaviour (arecordParticipantsspy is called with the right rows) rather than to the new file path, so the reader flip does not have to touch it a third time.Third, the reader flip itself is out of scope for this epic and belongs to its own issue under #1332 §3. This epic only has to guarantee that the flip becomes a small diff.
6. Risks specific to this repository
6.1 Doctrine invariants that a pure move can silently break
Read
.claude/skills/booking-doctrine/SKILL.mdand.claude/skills/booking-concurrency/SKILL.mdbefore writing any step. The six doctrine rules are: every status write goes through a CAS transition helper; nothing aPaymentpoints at is ever deleted; refunds have exactly two front doors; org scoping is explicit on every list; an approved request that was never paid has one outcome,EXPIRED; and there are no backfill migrations.Most of those survive a file move for free. These do not, and each one is a co-location invariant, meaning the guard and the statement it guards must remain in the same statement or the same transaction.
DELETEWHERESlotAllocationService.ts:3795,:3883,:3972, all three insidedeleteExistingAppointmentscommit/release-tentative.tswith the guard still in the WHEREBookingStatusHistoryis appended in the same transaction as the CAS, reading the from-status firstlib/booking/transitions.ts, invoked from both giantswithSerializableRetrycheckout.ts:2817, four attempts maxcommit/index.tsownsrenewOrAbort(perAttemptTtl)at the top of the retry callback incheckout.ts;extendSlotIntervalfor the slot atomslocks.tsand C8 puts the retry loop incommit/index.ts, the renewal call is the thing that quietly gets dropped. Pin it with a test before C2utils/appointmentlock.tsand nowhere elseslot-booking:${id}:${iso}inline. Two names for one atom is no lock at allacquireGuardedis the single acquisition path, fail-closed viacheckRedisHealthpluswithCircuitBreakerutils/appointmentlock.tslockSlotIntervalis all-or-nothing, acquiring atoms in ascending key order and releasing in reverse on any failureutils/appointmentlock.tsappointment / event / consultant → consultee → slot, withapproval → mintas the only legal nestingSlotAllocationService.ts:1142/:1153,:1607/:1617,:2015/:2036), so each mode file owns acquire, critical section and release as one unit and never delegates a partial envelope23P01heuristic is quarantined inlib/db/pg-errors.tswithSerializableRetrycall of its own; its correctness rests on locks plus CAS plus the GiST constraint, soisExclusionViolationcatch sites are its only 409 path and must survive step A6 intactslot_no_confirmed_overlapexcludes tentative rows and null-consultantProfileIdrows and uses half-opentstzrangeprisma/sql/check-constraints.sql:66-73One more thing worth recording:
booking-concurrency§4 explicitly retracts the "26 s function ceiling" as a configured number. There is no26000constant and no[functions]block innetlify.toml; it is an observed figure. The comment atutils/appointmentlock.ts:108still states it as if configured, and that comment is stale. Correcting it is a one-line docs fix, not part of this epic, but do not build a new module around the number.6.2 Shared
node_modulesacross thefw-*worktreesThere are five sibling worktrees on this machine (
fw-dpdp-docs,fw-explore-responsive,fw-perf-1148,fw-support-hub,fw-ui-overhaul) plus the agent worktrees under.claude/worktrees/. At least one of them,fw-support-hub, hasnode_modulesas a symlink to the canonical tree at/Users/kaustavghosh/Desktop/familiarise_web/node_modules. There is therefore one generated Prisma client shared by every worktree, and runningprisma generatein any of them rewrites it for all of them.For an epic that churns imports across roughly forty new files, that matters concretely:
prisma generateandtsc --noEmitin a single command so nothing can regenerate the client between them.jest.config.tssetsmodulePathIgnorePatterns: ["/\\.claude/worktrees/"]andtestPathIgnorePatternswith the same entry. That covers the agent worktrees, which live inside the repo root. It does not cover thefw-*siblings, which are separate roots outside the repo and so are invisible to Jest's crawler anyway. No config change is needed, but do not "helpfully" broaden the pattern.6.3 Formatting
A format-only pre-PR is the usual precondition for an extraction, because otherwise the first move reformats the file and the diff stops being reviewable as a move. It turns out neither giant needs one. Verified on
5f057bc02:prettier --checkpasses on bothlib/payments/operations/checkout.tsandutils/slotAllocation/SlotAllocationService.ts, so any earlier note thatcheckout.tsis prettier-dirty ondevis out of date. The two files in the booking scope that are currently dirty are:lib/payments/operations/refund.tscomponents/dashboard/shared/requests/RequestSlotAllocationTab.tsxNeither is touched by steps A0 to A8 or C0 to C10, so the format-only pre-PR is not needed for this epic as scoped. It is needed before the
refund.tsfollow-on in §8, and it should land as its own commit there so the extraction diff stays pure movement. Filing it now as a one-line PR againstdevis cheap and removes the trap entirely.Separately,
.gitignore:108listsprompts/, which Prettier reads as an ignore file, soprettier --checksilently passes on unformatted files underprompts/. That does not affect this epic but is worth knowing when a step touches the prompt corpus.6.4 The pre-MVP data reset
The reset runbook lives in #1319 §6 and is owned by #1322. Two consequences:
db pushagainst the shared Supabase project. That project serves both dev and prod, has no branches, and every mutating script against it is a production operation. Verification is jest plus a seeded dev server with mock payments, never a push. See.claude/skills/booking-verification/SKILL.md.6.5 The moving target
Both giants grew during wave 5 (§1). Running these twenty PRs while a feature train is also patching the same two files produces continuous conflicts, and
rebase --ontorestacks across squash merges are the exact failure mode #1319 already documented. This epic needs a declared freeze window on the two files, or it needs to run strictly between trains. It should not be started opportunistically.6.6 Test coupling that a move breaks even when behaviour does not change
Restated here because it is the highest-probability failure in the plan: two suites assert on the text of
SlotAllocationService.ts(collaborator-availability-modes.test.ts:289-297,participant-shadow-write.test.ts:105) and four reach private statics through a cast. Step A0 exists solely to defuse these. Do not begin A1 until A0 is merged and green.7. Verification per step and definition of done
7.1 The per-step gate
Every PR in both trains runs the same gate. The suites are the oracle: since no step changes behaviour, any red test is a real regression in that step, never an expected update.
npx prisma validate && npx prisma generatechained with the next linerm -f tsconfig.tsbuildinfo && NODE_OPTIONS=--max-old-space-size=12288 npx tsc --noEmitnpx next lint(unused-vars is a warning locally but fails the SonarCloud PR gate)npx prettier --checkon the changed filesnpx jest __tests__/booking-algorithm(66 files)npx jest __tests__/payments(51 files)npx jest __tests__/booking(5 files)npx jest __tests__/enterprise/appointment-org-stamping.test.tstests/typescript/race-conditions/master runner, categories01-concurrent-access,07-real-api-booking,09-webhook-storm,10-interval-lock-semantics(10 scenario folders total)07-real-api-bookingand09-webhook-stormagainst a deploy previewThe suite counts above are as of
5f057bc02. Both grew during the wave-5 train, so any older figure carried in a planning document is stale:__tests__/booking-algorithmis now 66 files and__tests__/paymentsis now 51.tests/typescript/race-conditions/scenariosholds 10 categories.7.2 The move-purity check
The distinguishing gate for this epic, and the one that makes a reviewer's job tractable, is a mechanical proof that a step moved code rather than changed it. For each extraction PR:
this.becoming a bare call,private staticbecomingfunction, and addedexportkeywords.grep -oE '#[0-9]{3,4}'over the before-file and over the after-set must produce the same multiset. A dropped#NNNNcomment means a dropped patch, and that is the cheapest possible detector for the failure mode this whole epic is about.payment: { none: {} }still appears three times in the allocator's after-set,withSerializableRetrystill appears exactly once in checkout's, and everytransition*call site count is unchanged.Items 2 and 3 are worth writing as a small script or as a jest pin, because they are run twenty times.
7.3 Definition of done
The epic is done when all of the following hold on
dev.lib/booking/allocation/exceeds 600 lines and no file underlib/payments/checkout/exceeds 450 lines.utils/slotAllocation/SlotAllocationService.tsandlib/payments/operations/checkout.tsno longer exist; the six allocator callers import@/lib/booking/allocationandapp/api/checkout/route.tsimports@/lib/payments/checkout.payment: { none: {} }guards remain inside theirdeleteManyWHERE clauses, in one file.checkouthas exactly onewithSerializableRetrycall site and it lives inlib/payments/checkout/commit/index.ts; no file undercommit/opens its own transaction or touches the global Prisma client.utils/appointmentlock.ts; no new module constructs a lock key string or calls Redis directly. Each of the three allocator mode files owns a complete acquire-critical-section-release envelope.__tests__/booking-algorithm,__tests__/payments,__tests__/booking,__tests__/collaboratorsand__tests__/maintenanceare green; the race suite categories listed in §7.1 pass; chaos07and09pass against a deploy preview.docs/booking/01-architecture.mdanddocs/booking/06-dependency-graphs.mdare regenerated from the new tree in a final docs PR, and the drift items in §4 are closed rather than carried forward.8. Follow-ons, explicitly out of scope here
These are named so they are not silently absorbed into this epic.
lib/payments/webhooks/handlers.ts(2,270 LOC). Already a flat list of eighteen named functions withhandlePaymentSuccessat 950 lines being the only giant. ItscreateConsultation/createSubscription/createWebinar/createClassare the duplicate creators [TRACKER] Booking + payments code architecture: folder layout, API surface, model and function naming #1332 §4 proposes to collapse into onecreateBookingRowsshared with checkout's handlers, so it should follow C6 rather than precede it.lib/payments/operations/refund.ts(1,717 LOC).applyRefundCascadeis 773 lines andrefundPaymentis 535. Needs the format-only PR from §6.3 first.components/scheduling/UnifiedCalendar.tsx(1,667 LOC) andhooks/scheduling/useSlotAllocation.ts(1,460 LOC). Client-side; a different oracle and a different risk profile. [TRACKER] Booking + payments code architecture: folder layout, API surface, model and function naming #1332 §1 proposes these move tofeatures/scheduling/.components/dashboard/shared/requests/RequestSlotAllocationTab.tsx(1,454 LOC), 90% of its folder.utils/slotAllocation/SlotValidationService.ts(1,190 LOC), fifteen methods, the natural home for validation extracted from either giant. It should be split after A8 so the allocator's needs are known.app/api/appointments/[appointmentId]/reschedule/route.ts(1,004 LOC) andapp/api/appointments/[appointmentId]/cancel/route.ts(792 LOC). These are the only route handlers in the subsystem carrying real logic rather than delegation, and most of it belongs inlib/booking/next to the fourreschedule-*modules that already exist there.utils/timeSlotsProcessing.ts(988 LOC) andlib/scheduling/slotSelectionValidation.ts(873 LOC), both undocumented and both load-bearing for the availability grid.AppointmentParticipantreader flip across 129 files (§5).[id]/allocate/route.tsfiles ([TRACKER] Booking + payments code architecture: folder layout, API surface, model and function naming #1332 §2, 54% duplicated per SonarCloud on fix(booking): co-host guard in every allocation mode, Serializable class create, and partial allocation with explicit confirm #1329). Collapsing them to one route withmodein the body is an API change, not an extraction.Labels
booking,finance,tech-debt,launch: post-mvp🤖 Generated with Claude Code
https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7