Overview
Cabana is a member portal and operations layer for Sailfish Pool Care — a fictional three-tech residential pool company. It's a real system for an invented client: the client is fabricated so the hard parts could be exercised in the open — injecting failures, killing infrastructure mid-run, publishing a secret-rotation runbook — with no customer's live pipeline on the line.
The spine is one sentence: a member describes a problem in their own words → Claude Haiku triages it (confidence-gated, structurally unable to promise a price or a time) → qualified repairs collect a $75 deposit through webhook-authoritative Stripe Checkout → the owner approves with one tap from Telegram → the office runs the week from an Airtable projection → a transactional outbox and n8n guarantee that no side effect is ever silently dropped.
The complexity budget is spent in exactly three places — payment truth, delivery guarantees, and member-data isolation — and the design is aggressively boring everywhere else.
That allocation is the point. A pool company doesn't need a route optimizer or a billing migration; it needs intake that never loses a customer, money that's never wrong, and a system where the app, the owner's phone, the office board, and the member's portal can't disagree about what's true.
The system
Supabase Postgres is the single source of truth; RLS enforces member isolation at the database, not in UI filters. Next.js on Vercel is the member portal — server actions perform every write, so no service key ever reaches the browser. Three edge functions are the only inbound webhook surfaces (Stripe, Telegram, Airtable write-back): they validate authenticity, translate to a state change plus an outbox row, and exit. All outbound side effects — Airtable, Telegram pings, email — are delivered by n8n from the outbox, where a non-engineer operator can open the flow and see it. Claude Haiku is a bounded subsystem: schema-validated, confidence-gated, allowed to draft but never to commit.
Problem framing
Three observations shaped the build, and none of them are about features:
- The obvious build drops data in the places that matter most. Fire-and-forget notifications vanish the moment a downstream service blinks. A status change that emits its side effect after the transaction commits can succeed while the notification silently fails — and nobody knows a request was lost.
- The success redirect is not payment truth. Trusting Stripe's redirect double-books on replay and lies when a member closes the tab. Payment state has to derive from signature-verified events, recorded once, idempotently.
- A helpful AI is a liability if it can promise anything. The owner's own framing was the spec: a confident wrong answer is worse than a slow one. An intake model that can quote a price or a time turns a misread into a commitment the company has to honor.
AI that drafts but cannot commit
Claude Haiku classifies free-text intake against a zod schema and drafts a warm, specific acknowledgment. The guarantee is structural, not a prompt asking nicely: no code path exists by which model output sets a price, promises a time, or advances a booking past awaiting_deposit / needs_review. A timeout, malformed output, or low confidence routes to a human queue with a holding reply — and the member flow can never throw because a model call failed. The golden set in CI, including prompt-injection cases, enforces 100% containment deterministically (temperature pinned to 0, after a flaky-green run was root-caused to an unset temperature defaulting to 1.0). The system's floor is the pre-existing normal: a person looks at it.
Payment truth is the webhook, not the redirect
Deposits run through hosted Stripe Checkout — chosen over the embedded Payment Element because a $75 deposit doesn't justify the PCI-adjacent surface area. Two invariants hold regardless of the UI: payment state transitions originate only from signature-verified webhook events, and every event ID is persisted in a stripe_events ledger so duplicates and out-of-order deliveries are no-ops. The success page renders "confirming…" and polls until the database says paid; a member who pays and closes the tab still lands on Scheduled, because the webhook — not the redirect — is the authority.
The transactional outbox
This is the load-bearing decision. Every status change commits its side-effect event in the same transaction, so the event exists if and only if the state change did. n8n consumes at-least-once with a dedupe key (idempotent consumers make that safe), retries to a dead-letter queue, alerts on the dead-letter, and a nightly reconciliation is the backstop. Latency and durability are handled by different mechanisms — a webhook nudge for speed, a 60-second sweep for the guarantee — instead of asking one channel to do both. The alternative, database triggers calling n8n directly, is the amateur default: if n8n is down at that instant, the event is gone.
Reliability — and what chaos day found
The failure-modes table is the artifact a reviewer should read first, because detection is the column amateur builds leave blank — every integration names how its failure is noticed (a stripe_events PK conflict, an outbox-depth health probe, a per-row last_error, nightly reconciliation).
Then it was tested for real. One committed chaos run pushed 50 bookings through the live cloud pipeline while the n8n consumer was killed for 90 seconds, Airtable auth was broken, and Stripe events were replayed: 0 lost, 0 duplicated — exactly-once delivery verified from both directions, with payment idempotency ledger-checked.
A single reproducible chaos run at demo volume — not sustained production. The guarantee under test is delivery correctness under injected failure, not throughput.
And the run earned its keep: it surfaced a genuine bug (#23) — dead-lettering wasn't terminal, so the sweep re-retried dead-lettered rows and piled up duplicate alerts. Nothing was lost (if anything it was louder than intended), but the semantics were wrong — since fixed in migration 0018: dead-lettering is now terminal (a dead_lettered_at column the sweep skips), so the row stops re-retrying. That's what chaos day is for: it finds the flaw, and the flaw gets fixed. The submit-to-delivered p95 for that run reads ~41 minutes — a durability datapoint, not a latency one, because it folds in the 90-second kill and a ~25-minute injected auth outage.
Honest notes
- Fictional client, fabricated data. Every member, address, and number is invented; there is no client or third-party PII in the repo (the author's own name and alert-routing address appear by design). gitleaks scans full history in CI.
- Solo, pairing with Claude Code — stated as a strength. I owned the discovery, the architecture, the invariants (a "never-cut list"), and every review; the AI accelerated the typing. The value shows in what review caught — a
redirect()in atry/catchthat swallowedNEXT_REDIRECT; a discardedpayments.inserterror that would have sent a member to Stripe with nothing for the webhook to flip; an AI acknowledgment that rendered only on the review path, hiding it on the entire happy path — each documented in the build log. - A pre-agreed cut, honored. The member-email leg was cut deliberately rather than slipping a gate, and the chaos log states the adapted assertion instead of pretending. The Telegram bot refuses and logs unauthorized chats but doesn't yet rate-limit them (a named v1.5 item).
- Real incidents. Two secrets were rotated for real during the build; a Railway↔Telegram network flake is tracked and mitigated with an email fallback; chaos day found #23. All in the log, not swept under it.
- A public demo that still enforces isolation. Anyone can click Enter the demo and drive the app as a seeded member — fictional data, Stripe in test mode. It's a real authenticated session, not a bypass: RLS scopes every visitor to that one member exactly as it does a paying customer. The member-gate is still the boundary; the demo just walks a fabricated member through it.
Reflections
- The outbox was the decision that mattered. Everything downstream — retries, dead-lettering, reconciliation, the "silence means healthy" invariant — follows from committing the event with the state change. Get that wrong and no amount of alerting saves you.
- Chaos day is worth more than a green badge. It found a real distributed-systems bug that unit tests couldn't, precisely because it broke the live system on purpose.
- Honesty is a design constraint, not a disclaimer. Naming the cuts, the deferrals, and the one bug the chaos run exposed is the same discipline as filling in the Detection column — it's what makes the rest of the claims trustworthy.
Closing observation
Silence never means loss. A dropped notification, a replayed payment, a model that guessed wrong — each one is either impossible by construction or loud by design. That guarantee is the product; the pool company is just where it's proven.