Skip to content

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.

  1. webSchema.table(), never bare pgTable(). Everything lives in the web schema.

  2. Migrations only through Drizzle. bun run db:generate → review the SQL → bun run db:migrate (or db:migrate:beta). Never db:push, never hand-edit packages/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.

  3. Set updatedAt manually on every update: updatedAt: new Date().toISOString(). It is not auto-managed.

  4. 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.

  1. Never trust a client total. packages/core/src/http/create-payment-intent.ts validates the amount server-side.
  2. Never bypass webhook idempotency. payments.stripe_payment_intent_id is unique because Stripe will redeliver payment_intent.succeeded.
  3. 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:stripe checks for violations.
  4. 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.
  1. Every admin create, update and delete calls logAudit(ctx, {...}). It is a compliance requirement, and bun run check:audit-coverage enforces it in CI.
  2. Email failures must not fail the operation they belong to. Wrap the send in try/catch and report with captureEmailFailure.
  3. Marketing and newsletter sends go through the Autosend MCP server only — draft, then send_campaign with confirmed: true. Never a bulk-send code path; that is what got the sending domain flagged. Transactional email stays in code via sendEmail.
  4. Supabase tooling never points at the web schema. Supabase is legacy-only, for the old mobile project.
  • URL state goes through nuqs (useQueryState / useQueryStates with the parseAs* parsers) — never useSearchParams plus a manual router.push. Tabs, filters, pagination and any shareable UI state belong in the URL. NuqsAdapter is 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.css and apps/web/src/styles/design-tokens.json. Cyan --color-primary marks every interactive element, contrast meets WCAG AA, and status is never signalled by colour alone.
  • The cart is database-backed (carts / cart_items plus slot reservations). Zustand holds only cartId and eventId.
  • time_slots.slot_kind: only signing is bookable. createHold blocks the rest.
  • bookings.status has six values, not three. Revenue and attendance must count confirmed and partially_paid.
  • Hold windows: slot, entry and comic are 15 minutes; booth is 60 minutes, cut to 45 during payment.
  • OTP is hardcoded 666666 only when APP_VARIANT is not production. Production always generates a random OTP and sends the real email.