Conventions that are not optional
These are the rules that have already cost the team an incident. CLAUDE.md in
the repo root is the canonical list; this page explains the why.
Database
Section titled “Database”-
webSchema.table(), never barepgTable(). Everything lives in thewebschema. -
Migrations only through Drizzle.
bun run db:generate→ review the SQL →bun run db:migrate(ordb:migrate:beta). Neverdb:push, never hand-editpackages/db/migrations/meta/, never run DDL through a SQL console or an MCP tool — all three bypass the journal and the next generate produces garbage. Custom SQL:npx drizzle-kit generate --custom. -
Set
updatedAtmanually on every update:updatedAt: new Date().toISOString(). It is not auto-managed. -
Take the advisory lock inside the same transaction as the insert. For slot, booth and comic holds:
await db.transaction(async (tx) => {await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtext(${id}))`);// ...insert in the SAME transaction});On the pooled Neon endpoint, an execute-then-insert pair can land on different connections — which re-opens double booking.
Payments
Section titled “Payments”- Never trust a client total.
packages/core/src/http/create-payment-intent.tsvalidates the amount server-side. - Never bypass webhook idempotency.
payments.stripe_payment_intent_idis unique because Stripe will redeliverpayment_intent.succeeded. - Never POST raw card numbers to Stripe, not even in a test script. The
raw-card API is off account-wide and attempts trigger security emails. Use
pm_card_*fixtures;bun run security:stripechecks for violations. - Every change to the booking/Stripe path appends to
docs/booking-payments/decisions.md. Append-only: a reversal is a new entry that supersedes the old one, never an edit. Two production incidents came out of that code and the log is the proof trail.
Elsewhere
Section titled “Elsewhere”- Every admin create, update and delete calls
logAudit(ctx, {...}). It is a compliance requirement, andbun run check:audit-coverageenforces it in CI. - Email failures must not fail the operation they belong to. Wrap the send
in try/catch and report with
captureEmailFailure. - Marketing and newsletter sends go through the Autosend MCP server only —
draft, then
send_campaignwithconfirmed: true. Never a bulk-send code path; that is what got the sending domain flagged. Transactional email stays in code viasendEmail. - Supabase tooling never points at the
webschema. Supabase is legacy-only, for the old mobile project.
Front-end conventions
Section titled “Front-end conventions”- URL state goes through nuqs (
useQueryState/useQueryStateswith theparseAs*parsers) — neveruseSearchParamsplus a manualrouter.push. Tabs, filters, pagination and any shareable UI state belong in the URL.NuqsAdapteris already mounted in the root layout. - Select controls compose
apps/web/src/components/ui/Select.tsx. Never a raw<select>. - Imports inside the
(main)route group use@/app/(main)/..., not@/app/.... - The design system is dark-mode only. Tokens and rules live in
DESIGN.md,apps/web/src/app/globals.cssandapps/web/src/styles/design-tokens.json. Cyan--color-primarymarks every interactive element, contrast meets WCAG AA, and status is never signalled by colour alone.
Domain facts that surprise people
Section titled “Domain facts that surprise people”- The cart is database-backed (
carts/cart_itemsplus slot reservations). Zustand holds onlycartIdandeventId. time_slots.slot_kind: onlysigningis bookable.createHoldblocks the rest.bookings.statushas six values, not three. Revenue and attendance must countconfirmedandpartially_paid.- Hold windows: slot, entry and comic are 15 minutes; booth is 60 minutes, cut to 45 during payment.
- OTP is hardcoded
666666only whenAPP_VARIANTis notproduction. Production always generates a random OTP and sends the real email.
