Building & Shipping

The Read Path Filtered It. The Write Path Didn't.

Brett Ridenour Brett Ridenour · Published July 2026

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_number and booking_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:

  1. For flex_duration: resolve the product’s asset, load its config, then call the same FlexDurationAvailability.computeAvailableStarts the read path uses, and check that the requested tuple is in the returned candidates.
  2. For fixed_slot with a client-supplied schedule_id: confirm the schedule belongs to this product at this location, that it’s active, that its schedule_type = 'availability' (not a blackout), and that the slot actually matches the schedule.

The route-boundary guard: dispatch on product kind, delegate to the shared availability engine, fail closed on internal faults.

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.

  1. Read path
    Computes candidates
    Applies operating hours, turnaround, blackouts, cutoffs, existing bookings.
  2. UI
    Renders candidates
    Customer can only click something the read path emitted.
  3. Write path (before)
    Accepts a payload
    Trusted shape, not membership. A crafted or stale payload got through.
  4. Write path (after)
    Guards membership
    Re-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.ts at the public route boundary, wired into POST /public/quotes before any pricing math runs.
  • A new error code SLOT_NOT_AVAILABLE with 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_slot branch checks that the client-supplied schedule_id is (a) linked to the product, (b) active, and (c) of type availability, then runs the same slotMatchesSchedule helper 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.