Skip to content
← Back to work
Case Study / Cabana

Cabana

Member portal and operations layer for a field-service company — free-text repair requests triaged by Claude Haiku, deposits taken through webhook-authoritative Stripe, and a transactional outbox that guarantees no side effect is ever silently dropped; chaos-tested to zero lost and zero duplicated under injected failure.

8 services · transactional outboxWebhook-authoritative payments · RLSChaos-tested: 0 lost / 0 duplicated · 50 bookingsNext.js 15 · Supabase · Claude Haiku · n8nTry the demoSource ↗
Translation artifact
2026Solo build
01Problem
Situation: Sailfish Pool Care — a fictional three-tech residential pool company — runs like many real ones: repair requests arrive as free text across call, text, and Messenger; the owner approves work from his truck; the office lives in a spreadsheet. Requests get lost, customer data is retyped into four places, and deposits go uncollected because asking is awkward.
Complication: The obvious build drops data in the places that matter most. Fire-and-forget notifications vanish when a downstream service blinks; trusting Stripe's success redirect double-books on replay; two-way sync between the app and Airtable drifts; and an AI that can promise a price or a time turns a confident misread into a commitment the company has to honor.
Question: Can a solo build hold an SMB ops system to a distributed-systems correctness bar — every integration with a named failure, detection, and handling path — without over-building past what three techs actually need?
02Requirements
  • Member

    Describe a problem in plain words and get a real acknowledgment in seconds — no form shaped like the company's database, and no promise the business can't keep.

    Free-text intake to Haiku triage; drafts only, never commits a price or time

  • Dana (owner)

    Approve or decline work in one tap from the field, with an audit trail of who acted and how.

    Telegram inline Approve · idempotent · one booking_transitions row per decision

  • Marie (office)

    Run the week from a familiar console she can actually edit — without it becoming a second source of truth.

    Airtable projection + two-field whitelisted write-back through a guarded edge function

  • The system itself

    No status change, payment, or notification may ever be silently lost, and every failure must be detectable.

    Transactional outbox + stripe_events ledger + RLS · a failure-modes table with a Detection column per integration

03Decision

Transactional outbox committed with the state change, consumed by n8n

chosen
  • meets criterion: No side effect silently lost
  • meets criterion: Recovers after downtime
  • meets criterion: Operator-inspectable
  • partially meets criterion: Build effort

The event commits in the same transaction as the status change, so it exists if and only if the state change did — the failure the requirement names (a notification that silently vanishes when a downstream service blinks) becomes unrepresentable. n8n consumes at-least-once with a dedupe key, retries to a dead-letter queue, alerts, and a nightly reconciliation backstops it; a webhook nudge handles latency while a 60-second sweep handles the guarantee, so no single channel is asked to do both. Fire-and-forget triggers are the amateur default — if n8n is down at that instant, the event is gone — and inline calls also couple the member's request latency to three third-party APIs. The cost is a consumer and a queue to operate; n8n on Railway keeps that inspectable by a non-engineer.

Call Airtable / Telegram / email inline from app code

  • does not meet criterion: No side effect silently lost
  • does not meet criterion: Recovers after downtime
  • does not meet criterion: Operator-inspectable
  • meets criterion: Build effort

Fire-and-forget: DB triggers call n8n directly

  • does not meet criterion: No side effect silently lost
  • does not meet criterion: Recovers after downtime
  • partially meets criterion: Operator-inspectable
  • meets criterion: Build effort
04Solution

A Supabase-Postgres core as the single source of truth, fronted by a Next.js member portal and three inbound webhook edge functions, with every outbound side effect delivered through a transactional outbox and n8n — retries, dead-lettering, alerting, nightly reconciliation. Claude Haiku triages intake as a bounded subsystem that can draft but never commit.

AI that drafts, never commits
Haiku classifies free-text intake against a zod schema and drafts an acknowledgment. No code path lets model output set a price, a time, or a status past awaiting_deposit / needs_review; timeout or bad output routes to a human, and the member flow can never throw on a model failure. The golden set enforces 100% prompt-injection containment in CI, deterministically at temperature 0.
Payment truth is the webhook
Hosted Stripe Checkout; payment state changes only from signature-verified events, recorded in a stripe_events idempotency ledger tolerant of replay and out-of-order delivery. The success redirect is cosmetic — the page polls until the database says paid, so a member who pays and closes the tab still lands on Scheduled.
The transactional outbox
Every status change commits its side-effect event in the same transaction; n8n consumes at-least-once with a dedupe key, retries to a dead-letter queue, alerts, and reconciles nightly. Orchestration lives where a non-engineer operator can open it and see the flow.
Isolation and audit in the database
RLS is the security boundary — enforced in Postgres, not UI filters, and probed by an adversarial test suite (cross-member reads through join paths; browsers get 42501 on writes). Every status write goes through one RPC so the actor and the write share a transaction, and the audit records who acted via which channel.
05Outcome
  • Delivery under chaos

    0 lost / 0 duplicated

    50 bookings through a live pipeline while n8n was killed, Airtable auth broken, and Stripe events replayed — one committed run, demo volume

  • Payment truth

    Webhook-authoritative + idempotent

    State changes only from signature-verified Stripe events, recorded once in a stripe_events ledger

  • Member isolation

    RLS, adversarially tested

    18-test suite probes cross-member reads through join paths; the database is the boundary, not UI filters

  • AI safety

    Structural, not prompted

    No path lets triage commit price/time/status; golden set: 100% injection containment in CI at temperature 0

  • What chaos found

    A real DLQ bug (#23)

    Dead-lettering wasn't terminal — the test surfaced it; nothing lost, louder than intended; fix scoped as migration 0016

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.

RoleSolo build — discovery, architecture, invariants, implementation, and review (paired with Claude Code)
Year2026
DomainField-service operations · fictional client, real system
StackNext.js 15 (Vercel) · Supabase (Postgres · RLS · Edge Functions) · Stripe Checkout · n8n · Airtable · Telegram · Claude Haiku · Resend
Statusv1.0 — tagged · gates 1–3a (chaos) closed · live one-click public demo (fictional data · Stripe test mode)

The system

System architecture — the spine and its three complexity zones
Cabana system architectureMember portal and ops automation for Sailfish Pool Care: member browser to Next.js on Vercel, Supabase Postgres as source of truth with three edge functions, and external services Claude Haiku, Stripe, n8n, Airtable, Telegram and Email.MEMBERVERCELSUPABASEserver actioncreate sessionsigned webhookoutbox: trigger + sweepwhitelisted automationcommands / buttonsMemberphone / browserClaude Haikuintake triageNext.js App Routerportal UI +server actionsStripe Checkoutpayment sessionPostgresRLS · source of truthoutbox · auditairtable-writebacktelegram-webhookstripe-webhookn8norchestrationretries · DLQAirtableowner consoleTelegramowner botEmailmember notificationsCOMPLEXITY ZONESpayment truth — verified webhook → DB, idempotentdelivery guarantee — outbox → n8n, retries + DLQmember isolation — RLS on Postgres, per-account

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:

  1. 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.
  2. 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.
  3. 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 a try/catch that swallowed NEXT_REDIRECT; a discarded payments.insert error 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.