Building & Shipping

The One-Checkbox Feature That Needed Ten Rules to Ship

Brett Ridenour Brett Ridenour · Published July 2026

An operator on Freebo talked a customer through a cancellation on the phone. Refund handled, apology delivered, the whole thing warm and human. Then they went back to the dashboard, hit Cancel Reservation, and realized the platform was about to send that same customer a cold automated “your reservation has been cancelled” email a second later.

They pinged me: is there a way to just… not send that one?

There wasn’t. The customer trigger fired unconditionally on every cancel, every rebook, every refund. The feature request was one sentence: “Give me a checkbox that says send notification to customer, checked by default. Uncheck it when I already talked to them.”

I wrote the PRD. Then the plan doc. Then I counted ten no-shortcut rules on the plan doc and realized the checkbox was the easy part.

The polarity trap

The most tempting way to add an optional boolean to an API is also the way that breaks every existing caller.

reservation-management/index.ts
− wrong (breaks every existing caller)
if (body.send_notification) {
await novuTriggerService.trigger({
  workflowId: 'reservation-cancelled',
  subscriberId: `${location_id}:customer:${email}`,
  payload: cancelPayload,
});
}
+ right (absent means send)
if (body.send_notification !== false) {
await novuTriggerService.trigger({
  workflowId: 'reservation-cancelled',
  subscriberId: `${location_id}:customer:${email}`,
  payload: cancelPayload,
});
}

The iOS app doesn’t send send_notification. The old web dashboard doesn’t send it. Every internal script, every E2E test, every third-party integration — none of them know this field exists. If the guard is if (body.send_notification), every one of those callers just quietly stopped sending customer emails on cancellations. You’d notice a week later when your first angry customer emails asking why they never got a confirmation.

sendNotification !== false is a weird-looking line. It’s also the whole feature. Absent means send. New callers can pass false to opt out. Everyone else keeps working exactly the way they did yesterday.

The dual-schema trap

This one cost me a real evening two months ago on a different feature.

The offline-payment endpoint has two body validators stacked on top of each other. Fastify parses the request body against a JSON Schema for OpenAPI docs and shape enforcement. Then the handler calls recordOfflinePaymentSchema.parse() — a Zod schema — for real validation and type inference.

Fastify’s schema is additionalProperties: false by default in this codebase. If you add send_notification to the Zod schema but forget the Fastify one, the field never reaches your handler. Fastify silently strips it in front of the door. Your Zod schema sees a payload that doesn’t have the field. Your handler sees a payload that doesn’t have the field. The guard reads send_notification !== false and evaluates to true. The email goes out anyway.

Nothing throws. Nothing warns. The checkbox looks like it’s working in the network tab. It just doesn’t do anything.

Rule #8 on the plan doc: send_notification goes in BOTH the Zod schema AND the Fastify body schema on record-offline-payment, or Fastify strips it before .parse() sees it. Every endpoint touched by this PRD gets the same double-add and a test that specifically sends false and asserts the trigger was never called.

What the checkbox does NOT touch

Half the PRD is fences. The cancel handler fires two Novu triggers back-to-back inside one try block:

if (body.send_notification !== false) {
  await novuTriggerService.trigger({
    workflowId: 'reservation-cancelled',
    subscriberId: `${location_id}:customer:${email}`,
    payload: cancelPayload,
  });
}
// Also notify staff (non-blocking)
await novuTriggerService.triggerToStaff(
  'staff-booking-cancelled',
  location_id,
  cancelPayload,
).catch((err) => request.log.error(err, 'staff notification failed'));

Only the customer trigger gets the guard. The staff-side notification fires no matter what — the operator’s team lead still needs to know a cancellation happened, even if the customer got a personal call instead of an email. Same story on rebook: scheduleReminders() runs unconditionally. If a customer opts into a 24-hour reminder when they book, that reminder ships regardless of whether the ops team wants the “your reservation was rebooked” email to go with it.

And nowhere in the touch list: webhook.service.ts, anything under stripe.*, refunds. Card charges and card refunds route through Stripe’s webhook, which fires a receipt email from Stripe’s system, which this checkbox has no business touching. Adding a “suppress receipt” checkbox to a card refund would mean either handing Stripe a metadata flag it doesn’t know about, or racing the webhook — both bad. The scope of the flag stops at things Freebo’s own Novu workflows send.

The one behavior change

Every existing caller keeps behaving identically — with one intentional exception buried on line 147 of the plan doc.

Offline payments (cash, check, other) never sent the customer a receipt. Card payments send one via Stripe’s webhook, but recording an offline payment against a reservation was silent: no email, no in-app notification, nothing. Not because that was a good design — because nobody had built it.

This feature adds a payment-received trigger to the offline path, guarded by the same sendNotification !== false flag. Which means: for callers not sending the field, offline payments now email a receipt by default. That’s the only behavior change for anyone who upgrades and doesn’t touch their integration. The release notes call it out; the docs page for the endpoint calls it out. The plan doc calls it out three times.

Every other endpoint touched by this PRD is behavior-preserving. This one is not, on purpose, because “no receipt at all” was worse than “receipt by default with an opt-out.”

Field naming, endpoint by endpoint

The last rule is the pettiest, and also the one I would have absolutely gotten wrong if I hadn’t read the endpoint schemas first.

  • POST .../reservations/:id/cancel — body is snake_case (cancellation_reason) → field is send_notification
  • POST .../reservations/:id/rebook — body is snake_case → field is send_notification
  • POST .../reservations-v2/payments/.../record-offline-payment — body is camelCase (reservationId, amountCents) → field is sendNotification

There’s no universe where I “clean this up” by picking one convention. Every existing caller is already sending reservationId to the third endpoint and cancellation_reason to the first. Forcing a rename to make three lines of documentation prettier means breaking every mobile client, every internal script, every E2E test. The rule on the plan doc: field naming follows each endpoint’s existing body convention.

The Playwright specs assert against snake_case on two endpoints and camelCase on the third. It looks wrong in the spec file. It’s the right kind of wrong.

Adding a flag to a live API is almost never a code problem. It’s an “every existing caller keeps working” problem, and the code you write is downstream of that.

— the lesson

The ten rules on the plan doc

The ten no-shortcut rules from the plan doc

Every one of them exists because a version of the feature without it would either silently do the wrong thing or quietly break somebody who wasn’t asking for a change.

Why ten rules for a checkbox

The demo version of this feature is a one-line change: if (body.send_notification) { ... }. Ships in an hour. Breaks silently for weeks.

The real version is a ten-rule PRD, a Zod-plus-Fastify schema pair on three endpoints, a shared React checkbox that resets to “checked” on every dialog open, a Playwright spec that specifically clicks the box off and inspects the request body, and one deliberate behavior change that gets called out in three separate docs.

That’s what “just add a checkbox” costs when the API has already shipped and people are already calling it. Every one of those rules exists because a version of the feature without it would either (a) silently do the wrong thing or (b) quietly break somebody who wasn’t asking for a change in the first place.

The interesting part is that none of the rules are clever. They’re all obvious in hindsight. They’re just the ten obvious things you have to remember at the same time to keep an operator’s phone call from turning into a robot email a minute later.