A quote got created on Freebo for a 4pm start, 3-hour duration, on an asset whose latest-return-to-dock is 6pm.
Do the math. That booking ends at 7pm on an asset that has to be back by 6pm. The availability calendar would never have shown that slot to the customer. So how did the quote endpoint accept it?
This post is about the class of bug that lets that happen. Not because it’s rare — because in any booking system that has a rich read path (availability, calendars, slot calculators) and a separate write path (create quote, create reservation), it is very easy to end up with a read path that filters correctly and a write path that trusts whatever the client sends.
The setup
Freebo has a product kind called flex_duration. It’s how yacht-style bookings work: instead of picking a pre-defined slot, the customer picks a date, a start time, and a duration, and the system decides whether that tuple is allowed.
To decide “is it allowed,” the availability engine looks at a stack of constraints:
- The asset’s operating hours (earliest start, latest start).
- The turnaround time after the previous booking.
- Blackout rules that ride on the availability-rules engine.
- Existing reservations on the asset.
- The product-level
booking_cutoff_numberandbooking_cutoff_unit.
The engine spits out an ordered list of (start, start + duration) candidates. The calendar UI renders them. The customer clicks one. Everything downstream is supposed to be a lookup against that list.
Where the write path forgot to check
The POST /public/quotes endpoint accepts a payload that includes date, start_time, duration_minutes, and — for fixed_slot products — a schedule_id. That payload was being trusted essentially as-is.
For flex_duration, the endpoint validated shape (duration is an integer, in range) but never re-ran the availability engine to confirm the tuple was one the read path would have offered.
For fixed_slot, if the client sent a schedule_id, the service’s resolveScheduleForQuote function shortcuts and trusts the schedule. Which means a client could send any schedule_id that existed on that product — including one that had been deactivated, or a blackout schedule (whose empty start_times array matches any start time, because the “does this slot match this schedule?” helper treats an empty list as a wildcard).
Two paths, one hole: the write path was answering a different question than the read path.
Any tuple the frontend would never render is a tuple the backend must never accept.
— the class of bug
The fix: a route-boundary policy guard
The instinct is to push the check into the service layer, next to the pricing math. That would be wrong. The pricing service’s job is to price. It should not care whether a tuple is a candidate the availability engine would offer — because there’s a legitimate case where an operator creates a manual quote for a slot the public calendar wouldn’t show.
The right place is a route-boundary guard: a function that answers “would the public availability read path have offered this?” and lives right at the edge of the public quote endpoint.
That guard now exists at apps/api/src/api/v1/routes/public/utils/flex-slot-guard.ts. Its job is exactly:
- For
flex_duration: resolve the product’s asset, load its config, then call the sameFlexDurationAvailability.computeAvailableStartsthe read path uses, and check that the requested tuple is in the returned candidates. - For
fixed_slotwith a client-suppliedschedule_id: confirm the schedule belongs to this product at this location, that it’sactive, that itsschedule_type = 'availability'(not a blackout), and that the slot actually matches the schedule.

Three things about that shape are load-bearing.
One: it re-uses the read path. The guard doesn’t try to re-implement the availability rules. It calls the exact same FlexDurationAvailability engine and checks membership. If the read path is wrong, the guard is wrong in the same way, and the tests that cover the read path cover the guard by proxy. Anything else guarantees drift.
Two: it fails closed on internal faults. If the Supabase call for the asset config throws, the guard doesn’t return { ok: false } — it lets the error bubble so the route can answer with a 500. Encoding “I couldn’t check” as “check failed” hands attackers a way to differentiate “you sent bad data” from “the database is briefly down.” Fail-closed on a policy check, but the policy failure is a 400. The inability to check is a 500.
Three: it’s a route concern, not a service concern. The pricing service should still price whatever it’s told to price. Manual quotes made in the operator dashboard for a slot outside public availability are a legitimate feature. The check is public-checkout-specific, so it lives at the public route boundary.
The generalization
The specific bug — a 4pm + 3hr booking on an asset with a 6pm hard-stop — is a symptom of the general pattern. Anywhere you have a rich read path that computes what’s offered and a separate write path that accepts what’s submitted, there is a class of bug that says: the write path is trusting a shape it never derived.
- Read pathComputes candidatesApplies operating hours, turnaround, blackouts, cutoffs, existing bookings.
- UIRenders candidatesCustomer can only click something the read path emitted.
- Write path (before)Accepts a payloadTrusted shape, not membership. A crafted or stale payload got through.
- Write path (after)Guards membershipRe-runs the read path and asserts the submitted tuple is in the candidate set.
The clean version of the rule is: for any endpoint that mutates state based on client-supplied selectors, either (a) the server resolves the selectors from a small, closed set it owns, or (b) the server re-runs the read path and asserts membership. There is no third option that’s safe.
What actually shipped
- A new
flex-slot-guard.tsat the public route boundary, wired intoPOST /public/quotesbefore any pricing math runs. - A new error code
SLOT_NOT_AVAILABLEwith the flat message “That start time is not available for the selected duration” — deliberately not leaking which constraint failed, so a probe can’t be used to enumerate the availability rules. - The
fixed_slotbranch checks that the client-suppliedschedule_idis (a) linked to the product, (b) active, and (c) of typeavailability, then runs the sameslotMatchesSchedulehelper the read path uses. - A test file next to it that covers both branches plus the “engine throws → 500” path.
The takeaway
The interesting failure was not that a booking slipped through. It was that the read path already knew the answer, and the write path never bothered to ask.
Every time you add a rule to a read path — a cutoff, a blackout, a turnaround, a new constraint — ask yourself: is there a write path that will faithfully preserve whatever payload it receives past this rule? If yes, that write path is a bomb waiting for someone to send it the right tuple. The bomb doesn’t need to be a hostile customer. Yesterday it was a legitimate quote payload constructed against a slightly stale calendar. Tomorrow it’s a mobile app with a slow refresh. The mechanism is the same.
The one-line version, pinned to the wall: the write path must ask the read path before it agrees.