ai

HiveJournal Conventions

Patterns and naming conventions used throughout the codebase. Following these makes new code blend in cleanly.

File naming

  • Backend routes: kebab-case.ts matching the URL prefix (open-energy.ts/api/open-energy)
  • Frontend pages: page.tsx inside a route folder (app/open-energy/experiments/page.tsx/open-energy/experiments)
  • Frontend layouts: layout.tsx (Next.js App Router convention) — used to export metadata for client pages
  • CSS modules: <route-name>.module.css co-located with the page
  • Migrations: NNN_descriptive_name.sql where NNN is zero-padded sequential
  • Scripts: kebab-case.ts in scripts/, runnable via npx tsx

Backend route file structure

Every backend route file follows roughly this shape:

import express from 'express'
import { authenticateUser, AuthRequest } from '../middleware/auth.js'
import { isSuperAdmin } from '../database/roles.js'
import { supabase } from '../index.js'

const router = express.Router()

// ─── Helpers ──────────────────────────────────────
// (small helpers used only by this file)

// ─── Public endpoints ─────────────────────────────
router.get('/some-public-thing', async (_req, res) => { ... })

// ─── Authenticated endpoints ──────────────────────
router.get('/me/things', authenticateUser, async (req: AuthRequest, res) => {
  try {
    const { data, error } = await supabase
      .from('table')
      .select('*')
      .eq('user_id', req.user!.id)
    if (error) throw error
    res.json({ items: data || [] })
  } catch (error: any) {
    console.error('Error fetching things:', error)
    res.status(500).json({ error: error.message })
  }
})

// ─── Admin endpoints ──────────────────────────────
router.post('/admin/seed', authenticateUser, async (req: AuthRequest, res) => {
  if (!(await requireSuperAdmin(req, res))) return
  // ...
})

export default router

Then mount in apps/backend/src/index.ts:

import yourFeatureRoutes from './routes/your-feature.js'
// ...
app.use('/api/your-feature', yourFeatureRoutes)

Progressive loading (skeletons + localStorage priming)

Every data-heavy page in /dashboard/** paints a complete layout on first frame — no blocking "Loading…" text. The pattern:

  1. Start with SSR-safe defaults for every state variable (no localStorage reads in useState initializers — see the gotcha below).
  2. Render skeletons sized from a cached count (also stored in localStorage). Default count for new visitors is 3-4.
  3. Post-mount useEffect reads the real cached data/count from localStorage or sessionStorage and sets it. Skeletons swap to cached data in one frame.
  4. Background refetch via the relevant hook (TanStack useQuery or a direct apiRequest) replaces the hydrated view in place when fresh data arrives.
  5. Write-through updates the localStorage entry on every successful fetch so next visit is accurate.

Examples across the codebase:

  • Navbar auth state — hj-navbar-snapshot localStorage
  • Notebooks index skeletons — hj-notebooks-count + hj-orphan-exists
  • Notebooks list data — hj-notebooks-list-v1 (primed via usePrimeNotebooksFromCache hook + queryClient.setQueryData)
  • Notebook detail + /other — hj-notebook-entries-count:{id} via the shared EntrySkeleton component at apps/frontend/src/components/notebooks/EntrySkeleton.tsx
  • Dashboard entries — dashboardCachedEntries + dashboardCachedAt (sessionStorage, 30 min TTL)
  • Stream notes — hj-stream-cache-v1 sessionStorage (30 min TTL, invalidated on quote-share write)

Gotcha: React error #418 (HTML mismatch on hydration). Reading localStorage in a useState lazy initializer causes a hydration mismatch in Next.js App Router — on the server there's no window so the initializer returns the default, but on the client the initializer returns cached data. The first client render then disagrees with the server-rendered HTML and React throws, unmounts the tree, and re-renders. Always seed state with SSR-safe defaults and hydrate in a useEffect:

// ❌ DON'T — causes React #418
const [count] = useState<number>(() => readCachedCount() ?? 4)

// ✅ DO
const [count, setCount] = useState<number>(4)
useEffect(() => {
  setCount(readCachedCount() ?? 4)
}, [])

For TanStack useQuery, the same gotcha applies to initialData — it runs on both server and client. Use a post-mount queryClient.setQueryData call instead (see usePrimeNotebooksFromCache in apps/frontend/src/hooks/useNotebooks.ts).

Filter persistence pattern

When a page has more than one filter dimension (search input + type pills + sort + visibility, etc.), persist the user's selections to localStorage so they survive reload. Writers tend to settle on a preferred view ("📖 Stories sorted by most active") and lose trust if every visit resets it.

The pattern is mature — four pages now share the same shape:

  • apps/frontend/src/components/filters/StreamFilterPanel.tsxhj:stream-filters-v1
  • apps/frontend/src/app/dashboard/highlights/page.tsxhj:highlights-filters-v1
  • apps/frontend/src/app/dashboard/admin/seasons/page.tsxhj:admin-seasons-filters-v1
  • apps/frontend/src/app/dashboard/notebooks/page.tsxhj:notebooks-filters-v1

Lifecycle

flowchart TD
    A[Mount: SSR-safe defaults<br/>filtersHydrated = false] --> B[Hydrate effect runs once<br/>reads localStorage]
    B --> C{Stored blob<br/>parses?}
    C -- yes --> D[Per-field type + allowed-value<br/>check, setState each]
    C -- no / corrupted --> E[Swallow, keep defaults]
    D --> F[setFiltersHydrated true]
    E --> F
    F --> G[User changes a filter]
    G --> H[Write effect fires<br/>gated on filtersHydrated]
    H --> I[localStorage.setItem<br/>JSON.stringify all fields]
    I --> G

The filtersHydrated gate is the critical bit. Without it, the write effect runs on first paint with the initial defaults and clobbers the persisted blob before the hydrate effect gets to read it. The gate keeps the write effect inert until the hydrate effect has had its turn.

Rules

  1. Key naming: hj:<surface>-filters-v<n>. Use a colon, not a hyphen — distinguishes filter blobs from cache-priming keys like hj-notebooks-count. Bump the version suffix on shape-breaking changes (don't try to migrate old blobs; just let them fail the per-field validation and fall back to defaults).
  2. One blob per surface, not per field. Single getItem / setItem pair keeps the effects readable.
  3. Per-field type validation on read — never trust the parsed JSON. typeof p.query === 'string' for free-form input; explicit allowed-value lists (p.sortBy === 'newest' || p.sortBy === 'oldest' || …) for enums. A corrupted blob shouldn't crash the page.
  4. Silent failure on both read and write paths. Private mode + quota errors are persistence-is-a-nicety territory, not user-visible.
  5. No SSR reads. The hydrate effect runs post-mount; typeof window === 'undefined' guards both sides anyway.
  6. Sort runs after filtering, on a shallow copy of the filtered list — keeps the unfiltered reference stable for any caller that iterates it (e.g. bulk actions).

Template

Paste into a new page and rename the fields:

const SURFACE_FILTERS_KEY = 'hj:<surface>-filters-v1'

const [query, setQuery] = useState('')
const [statusFilter, setStatusFilter] = useState<'all' | 'active' | 'archived'>('all')
const [sortBy, setSortBy] = useState<'newest' | 'oldest' | 'title'>('newest')
const [filtersHydrated, setFiltersHydrated] = useState(false)

// Hydrate once on mount — runs post-mount so SSR matches first client paint.
useEffect(() => {
  if (typeof window === 'undefined') return
  try {
    const raw = window.localStorage.getItem(SURFACE_FILTERS_KEY)
    if (raw) {
      const p = JSON.parse(raw)
      if (typeof p.query === 'string') setQuery(p.query)
      if (p.statusFilter === 'all' || p.statusFilter === 'active' || p.statusFilter === 'archived')
        setStatusFilter(p.statusFilter)
      if (p.sortBy === 'newest' || p.sortBy === 'oldest' || p.sortBy === 'title')
        setSortBy(p.sortBy)
    }
  } catch {/* swallow corrupted blob */}
  setFiltersHydrated(true)
}, [])

// Write on every change — gated on filtersHydrated so the initial defaults
// don't clobber persisted state before the hydrate effect lands.
useEffect(() => {
  if (!filtersHydrated || typeof window === 'undefined') return
  try {
    window.localStorage.setItem(
      SURFACE_FILTERS_KEY,
      JSON.stringify({ query, statusFilter, sortBy }),
    )
  } catch {/* private mode / quota — persistence is a nicety */}
}, [filtersHydrated, query, statusFilter, sortBy])

Inventory of every hj:* / hj-* key currently in the codebase: docs/reference/LOCAL_STORAGE.md.

Modifier-key navigation for clickable cards

Any card or row that the user clicks to navigate must support Cmd/Ctrl-click and middle-click for "open in new tab" — matches native anchor behavior. Power users (writers reviewing multiple notebooks side-by-side, admins comparing seasons) rely on this; cards that swallow modifier keys feel broken even though the plain click still works.

Two acceptable implementations

  1. Wrap the card in <Link> (Next.js anchor) — preferred when the entire card is the navigation target with no internal interactive children (or only children whose own click bubbles up). The browser handles modifier keys natively; no extra wiring needed.

  2. openX(id, e) helper with onClick + onAuxClick on a <div> or <button> — required when the card has internal stop-propagation children (Edit/Delete chips, follow buttons, etc.) that shouldn't trigger navigation. Inside the helper, call the shared openInNewTabIfModifier(url, e) util (apps/frontend/src/lib/modifier-nav.ts) — it returns true if a modifier was hit (caller returns early) or false to fall through to router.push(url).

Pick (1) by default. Reach for (2) only when (1) would force you to fight event propagation.

Canonical shared util

apps/frontend/src/lib/modifier-nav.tsopenInNewTabIfModifier(url, e) is the single source of truth for the Cmd/Ctrl/middle-click → new-tab branch. It uses window.open(url, '_blank', 'noopener') (the noopener flag is a security best practice — the new tab can't reach back into window.opener). Always use it for new card/row navigation helpers; only write the inline check when the surrounding handler has extra logic (touch-mode branching, scroll caching, etc.) where the shared util is one piece of a larger sequence.

Reference implementations

  • <Link>-wrapped cardsapps/frontend/src/app/dashboard/lovio/page.tsx (capsule rows), apps/frontend/src/app/writers/[handle]/page.tsx (show posters), apps/frontend/src/app/dashboard/calendar/page.tsx (day-entry rows)
  • openX helper using the shared utilapps/frontend/src/app/dashboard/notebooks/page.tsx (openNotebook — both poster + standard card variants), apps/frontend/src/app/dashboard/notebooks/other/page.tsx (openEntry — orphan-entries grid)
  • Shared util inline inside a larger handlerapps/frontend/src/app/dashboard/page.tsx (handleCardClick — modifier short-circuit, then touch-mode branching + sessionStorage scroll caching), apps/frontend/src/app/dashboard/stream/page.tsx (handleNoteClick)

Code template

import { useRouter } from 'next/navigation'
import { openInNewTabIfModifier } from '@/lib/modifier-nav'

const router = useRouter()

const openX = (id: string, e?: { metaKey?: boolean; ctrlKey?: boolean; button?: number }) => {
  const url = `/dashboard/x/${id}`
  if (openInNewTabIfModifier(url, e)) return
  router.push(url)
}

// On the card:
<Card
  onClick={(e) => openX(item.id, e)}
  onAuxClick={(e) => openX(item.id, e)}
>
  ...
  {/* Edit/Delete chips stop propagation so they don't navigate */}
  <button onClick={(e) => { e.stopPropagation(); /* … */ }}>Edit</button>
</Card>

Both onClick and onAuxClick are required. onClick covers plain-click + Cmd/Ctrl-click (mouse button 0); onAuxClick covers middle-click (button 1), which doesn't fire onClick on most browsers.

onMouseMove handlers must not call setState on every pixel

Never call setState({ x, y }) (or any non-primitive value) from onMouseMove on a list-rendered item — every motion event re-renders the whole list and the cursor visibly jitters.

Why: Commit a3624542 had this exact bug on /dashboard/stream. A per-card handleMouseMove called setHoverPosition({ x, y }) on every event; with hundreds of cards mounted, the whole stream re-rendered hundreds of times per second of motion, making the cursor jump. The fix gutted the handler to a no-op (kept as a stub at stream/page.tsx:1834 so the many call sites don't need a sweep-remove). Hover position only depends on the card's bounding rect, which doesn't change while you're still on the same card — onMouseEnter is enough.

How to apply:

  • Preferred: write to a ref instead of state (stream/page.tsx:915mouseYRef.current = e.clientY).
  • OK: setting a boolean to a constant (setHovered(true)), since React bails out from re-renders when the new primitive value equals the old one — but only on the same component, not on a sibling list of 100 cards.
  • Not OK: setting an object or coordinate (setHoverPosition({...})) — new identity every time, React re-renders.
  • Exempt: drag handles and resizable splitters, where setting coordinates IS the point (SatisfactionSlider.tsx, life-map/page.tsx). These are one-off, not list-rendered.

When you genuinely need cursor-tracking state for a list, debounce / rAF-throttle it, or guard with a sector predicate ("only update if the cursor moved into a new bucket").

Impression analytics must be viewport-gated, not mount-gated

Any "X was shown / viewed" analytics event must fire only after the element is actually in the viewport for a short dwell, never on component mount. A mount-fired impression counts every page render — including instant-bounce traffic that never saw the element — and silently inflates the denominator of every downstream conversion rate.

Why: the 2026-06-10 funnel readout found the Facebook-ad cohort had a 4.9s median time-on-page (50% gone in under 5s) yet every one of them fired cold_ad_listen_hero_shown / season_trailer_view on mount, making the ~2% play rate look like a UX problem when it was traffic quality. See docs/reference/POSTHOG_EVENTS.md.

How to apply: use the shared useImpressionRef hook — it returns a ref to attach to the target element and fires the callback once, after the element has been ≥50% visible for ~1s (both tunable). SSR-safe; falls back to firing immediately when IntersectionObserver is unavailable so the event is never silently lost. Reference implementations: TrailerHero.tsx (season_trailer_view) and ColdAdListenHero.tsx (cold_ad_listen_hero_shown).

(The older graphene_poster_hover "only fires after 400ms dwell" is the same principle hand-rolled; prefer useImpressionRef for new impression events.)

Frontend data fetching

TanStack Query (@tanstack/react-query) is the default for any data shared across views. The QueryProvider is wired in app/layout.tsx; you don't need to re-wrap.

Rules:

  • If two or more components read the same API data (tasks/routines/notebooks/etc.), create hooks in src/hooks/useX.ts using useQuery with stable array query keys (['user-tasks'], ['user-tasks', 'routines']).
  • Mutations go in the same hooks file as useMutation with onMutate for optimistic updates and onSettled to invalidateQueries the affected keys.
  • One-shot writes that don't read back (form submits, OAuth redirects) can still use plain apiRequest.

Canonical reference: src/hooks/useUserTasks.ts — queries for tasks + routines, mutations for create (NL), update (optimistic status), delete (optimistic), spawn, backfill. Consumers: /dashboard/tasks, JQ chat RoutinesTabView / TasksTabView.

Defaults on the QueryClient: staleTime 30s, gcTime 5m, refetchOnWindowFocus: true, retry 1. Override per-hook if needed.

Frontend page conventions

Client component pages with metadata

The pattern for SEO/OG-cards on pages that need to be 'use client' (which is most of them):

// app/some-route/layout.tsx — server component
import type { Metadata } from 'next'

export const metadata: Metadata = {
  title: 'Page Title',
  description: '...',
  openGraph: { ... },
  twitter: { ... },
}

export default function SomeLayout({ children }: { children: React.ReactNode }) {
  return children
}
// app/some-route/page.tsx — client component
'use client'

import { useState, useEffect } from 'react'
// ...

export default function SomePage() {
  // your client logic
}

Dynamic per-resource OG cards

When a page's share preview should reflect a specific row (a battle, a tournament, a short story), pair generateMetadata with an edge-runtime OG image route. The image route fetches its own data — keeps the metadata cheap and lets the image cache independently. Pattern, repeated across cafe-battle/[id], cafe-tournament/[id], sj-anderson-story/[id], etc.:

// app/things/[id]/page.tsx (server component)
import type { Metadata } from 'next'
const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL || '...'

export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> {
  const { id } = await params
  const ogImage = `${SITE_URL}/api/og/thing/${id}`
  const data = await loadMeta(id)  // small public meta endpoint, never bodies
  // build title/description from data, fall back gracefully when null
  return {
    title: data ? `${data.title} — ...` : 'Things — ...',
    openGraph: { images: [{ url: ogImage, width: 1200, height: 630 }] },
    twitter: { card: 'summary_large_image', images: [ogImage] },
  }
}

export default async function ThingPage({ params }) {
  const { id } = await params
  return <ThingClient id={id} />
}
// app/api/og/thing/[id]/route.tsx
import { ImageResponse } from '@vercel/og'
export const runtime = 'edge'
const API_BASE = process.env.NEXT_PUBLIC_API_URL || ''

export async function GET(_req, ctx) {
  const { id } = await ctx.params
  const data = await fetch(`${API_BASE}/api/things/${id}/meta`, { next: { revalidate: 600 } })
    .then(r => r.ok ? r.json() : null).catch(() => null)
  return new ImageResponse(<div style={{...}}>...</div>, { width: 1200, height: 630 })
}

Conventions: dimensions 1200×630 for OG cards, 600×900 for fake "book covers" (matches Open Library 2:3 aspect). Use a 60s–600s revalidate on the meta fetch so unfurls don't hammer the backend. Always render something on lookup failure (neutral fallback) so a stale link still produces a card. Public meta endpoints should be deliberately narrow (no bodies, just title/status/scores) so they're safe to expose without auth.

Auth check pattern

const supabase = createSupabaseClient()
const [authToken, setAuthToken] = useState<string | null>(null)
const [isAuthed, setIsAuthed] = useState(false)

useEffect(() => {
  supabase.auth.getSession().then(({ data: { session } }) => {
    if (session) {
      setAuthToken(session.access_token)
      setIsAuthed(true)
    }
  })

  const { data: { subscription } } = supabase.auth.onAuthStateChange((_event, session) => {
    setAuthToken(session?.access_token || null)
    setIsAuthed(!!session)
  })

  return () => subscription.unsubscribe()
}, [])

Optimistic update pattern

For state that syncs to the backend, update local state first then fire-and-forget the API call:

const updateThing = (id: string, newValue: string) => {
  setThings((prev) => ({ ...prev, [id]: newValue }))

  if (isAuthed && authToken) {
    fetch(`${apiBase}/api/things/${id}`, {
      method: 'PUT',
      headers: {
        Authorization: `Bearer ${authToken}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({ value: newValue }),
    }).catch(() => {
      // Silently fail — user already saw the update
    })
  }
}

LocalStorage fallback pattern

For features that work both authenticated and anonymously:

const STORAGE_KEY = 'feature-state'

// Load on mount
useEffect(() => {
  try {
    const stored = localStorage.getItem(STORAGE_KEY)
    if (stored) setState(JSON.parse(stored))
  } catch {}
}, [])

// Save when state changes
useEffect(() => {
  try {
    localStorage.setItem(STORAGE_KEY, JSON.stringify(state))
  } catch {}
}, [state])

// Migrate to server on first sign-in
useEffect(() => {
  if (!isAuthed || !authToken) return
  const alreadyMigrated = localStorage.getItem(STORAGE_KEY + '-migrated') === 'true'
  if (!alreadyMigrated && Object.keys(state).length > 0) {
    fetch(`${apiBase}/api/feature/migrate`, { /* ... */ })
      .then(() => localStorage.setItem(STORAGE_KEY + '-migrated', 'true'))
  }
}, [isAuthed, authToken])

Database conventions

Migration template

-- Migration NNN: Title
-- Brief description.

CREATE EXTENSION IF NOT EXISTS "pgcrypto";

-- Updated-at helper (only if not already created by an earlier migration)
DO $$
BEGIN
  IF NOT EXISTS (
    SELECT 1 FROM pg_proc
    WHERE proname = 'set_updated_at' AND pg_function_is_visible(oid)
  ) THEN
    CREATE FUNCTION public.set_updated_at()
    RETURNS TRIGGER AS $fn$
    BEGIN
      NEW.updated_at = NOW();
      RETURN NEW;
    END;
    $fn$ LANGUAGE plpgsql;
  END IF;
END $$;

-- 1) Main table
CREATE TABLE IF NOT EXISTS public.feature_thing (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
  -- your fields here
  is_seed BOOLEAN NOT NULL DEFAULT FALSE,
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- Indexes
CREATE INDEX IF NOT EXISTS feature_thing_user_idx ON public.feature_thing (user_id);
CREATE INDEX IF NOT EXISTS feature_thing_seed_idx ON public.feature_thing (is_seed) WHERE is_seed = TRUE;

-- Updated-at trigger
DROP TRIGGER IF EXISTS set_feature_thing_updated_at ON public.feature_thing;
CREATE TRIGGER set_feature_thing_updated_at
BEFORE UPDATE ON public.feature_thing
FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();

-- RLS
ALTER TABLE public.feature_thing ENABLE ROW LEVEL SECURITY;

DROP POLICY IF EXISTS "Users can view own feature_thing" ON public.feature_thing;
CREATE POLICY "Users can view own feature_thing"
ON public.feature_thing FOR SELECT
TO authenticated
USING (user_id = auth.uid());

-- ... insert/update/delete policies similarly

-- 2) Aggregate view (optional, for public stats)
CREATE OR REPLACE VIEW public.feature_thing_stats AS
SELECT
  some_grouping_column,
  COUNT(*) FILTER (WHERE NOT is_seed) AS real_count,
  COUNT(*) AS total_count
FROM public.feature_thing
GROUP BY some_grouping_column;

GRANT SELECT ON public.feature_thing_stats TO anon, authenticated;

Migrations must be replay-safe

Every DDL statement in a migration must be idempotent — running the migration twice against the same database must not error. Replays happen routinely: restoring an MCP / staging Supabase project from a snapshot that's behind prod, re-applying to recover after a partial failure, running migrations through different tooling on different days. A migration that errors on replay leaves the schema in an inconsistent state and forces manual SQL surgery.

Postgres gives you IF (NOT) EXISTS for most DDL — use it everywhere:

DDLIdempotent form
CREATE TABLECREATE TABLE IF NOT EXISTS
CREATE INDEXCREATE INDEX IF NOT EXISTS
CREATE UNIQUE INDEXCREATE UNIQUE INDEX IF NOT EXISTS
ALTER TABLE ADD COLUMNALTER TABLE ... ADD COLUMN IF NOT EXISTS
CREATE TRIGGERDROP TRIGGER IF EXISTS … ; then CREATE TRIGGER
CREATE POLICYDROP POLICY IF EXISTS "name" ON table ; then CREATE POLICY "name" ON table …
CREATE FUNCTIONCREATE OR REPLACE FUNCTION (or DO $$ existence check, see template above for set_updated_at)
CREATE TYPEDO $$ … IF NOT EXISTS … END $$ (no native IF NOT EXISTS for types)

The most-forgotten case is CREATE POLICY. Postgres has no CREATE POLICY IF NOT EXISTS until PG15 (and Supabase doesn't reliably support that syntax across versions), so the pattern is drop-then-create:

DROP POLICY IF EXISTS "Users can view own feature_thing" ON public.feature_thing;
CREATE POLICY "Users can view own feature_thing"
ON public.feature_thing FOR SELECT
TO authenticated
USING (user_id = auth.uid());

DROP POLICY IF EXISTS is a no-op when the policy doesn't exist, so fresh applies work identically to replays. The migration template above already demonstrates this — the rule is just easy to forget when you're adding a single new policy to an existing table.

Audit script: scripts/audit-migration-policy-guards.py finds every ^create policy whose corresponding drop policy if exists ... on <same table> guard is missing. Run before merging any migration that touches RLS:

python3 scripts/audit-migration-policy-guards.py            # full tree
python3 scripts/audit-migration-policy-guards.py --since 280 # recent only

Exits 0 when all guards present, 1 (with file/policy/table list) when any are missing.

Migrations must actually be applied — verify with the schema-drift audit

A replay-safe migration file that was never applied to the live database is the worst failure mode: no error anywhere, but current code references a table/column that isn't there, so the feature fails quietly. We shipped this for a long time — migrations 044 / 045 / 047 / 049 / 448 / 510 sat in supabase/migrations/ while their tables/columns were missing from prod, silently breaking satisfaction↔journal links, health recommendations, Open Energy replications/cheers, journal-song credits, and (510) the entire AisleAsk store-geo layer. (Migrations here apply via your own dashboard/CLI process — MCP ≠ prod — so a skipped one leaves no trace.)

Audit script: scripts/audit-schema-drift.mjs parses every migration for the tables it CREATEs and columns it ADDs, then checks each against the live schema and reports anything missing (exit 1 on drift). It reads SUPABASE_URL / SUPABASE_SERVICE_ROLE_KEY from the env or apps/backend/.env.

npm run audit:schema   # or: node scripts/audit-schema-drift.mjs

Run it after applying migrations to prod (and it's a good scheduled/post-deploy check) — it checks live prod state, not PR code, so it is intentionally NOT part of the PR hard-gate npm run audit. A hit means "verify + apply that migration."

One-command runner: npm run audit runs this script plus the four other audits (RLS coverage, cron heartbeats, route auth, service_errors coverage) in sequence and exits non-zero if any hard-gate audit fails. Drop-in suitable as a pre-merge / CI gate.

This was retroactively backfilled across all migrations in #200 / #201 / #202 (117 guards added across 46 files). The whole tree is now replay-safe.

When to add is_seed

Always, on any user-data table that admins might want to seed for testing. The pattern lets you generate fake records that:

  • Look real to the UI (no special handling needed)
  • Are clearly distinguishable in the database
  • Can be cleanly wiped with DELETE FROM table WHERE is_seed = TRUE
  • Show up in admin stats with a "real vs seed" breakdown

Aggregate views for public stats

When the frontend needs counts/aggregates that are publicly readable but the underlying records have RLS restricting reads, create a VIEW and GRANT SELECT ... TO anon, authenticated. The view runs with the definer's privileges and bypasses RLS.

Visibility settings

Every entry/notebook supports these levels:

  • 'user' — owner only (default)
  • 'team' — team members (requires team_id)
  • 'org' — org members (requires org_id)
  • 'public' — anyone (used for share links and Open Energy content)

Both metadata visibility and content visibility are independent. Use validateVisibilitySettings() from apps/backend/src/database/visibility.ts when accepting these from user input.

Naming patterns

Tags

Tags are flexible strings stored as a TEXT[] column on journal_entries. Conventions used in the codebase:

  • Patent entries: ["Patent", category] (e.g. ["Patent", "electromagnetic"])
  • Experiment entries: ["Experiment", pattern_slug, ...referenced_patent_numbers]
  • Build entries: ["Build", experiment_id, pattern_slug, outcome]
  • Seed builds: ["Build", "SeedBuild", experiment_id, ...] — the SeedBuild tag is the unique marker for safe wiping

Display names

Short user names rendered in feeds and leaderboards use shortenName(fullName, email) from apps/backend/src/routes/open-energy.ts — first name + last initial, falls back to first 8 chars of email local-part, then "Someone".

CSS conventions

Tailwind for most things

Most existing pages use Tailwind utility classes inline. The dashboard, admin panel, and auth pages all follow this pattern.

CSS modules for visual-heavy pages

Pages with custom animations or complex layouts (Open Energy, variant-b landing) use CSS modules co-located with the page. The _ separator on dynamic class names (like status_completed) is for CSS modules' camelCase auto-conversion.

Color palette (Open Energy)

  • Amber #fbbf24 — Patents, Coil Geometry, "Light", non-linearity element
  • Emerald #10b981 — Experiments notebook
  • Cyan #22d3ee — Builds, Pulsed Excitation element, Water Splitting
  • Purple #a78bfa — Replications, Resonance element
  • Rose #fb7185 — Refuted, Permanent Magnets

The dark sky background gradient is consistent across all Open Energy pages:

background: radial-gradient(ellipse at top, #0c0e2a 0%, #050614 50%, #000000 100%);

Brand palette (EmberKiln studio surfaces)

The production-studio surfaces that fall under the EmberKiln brand (/studio creator app, the Scene Studio + Reverse Screenplay admin pages, the Story Bible / Universe editor modals) use a teal scale keyed to the EmberKiln logo, not purple. The token lives in apps/frontend/tailwind.config.js as emberkiln (50→950, anchor emberkiln-500 #3f7a86, deep face emberkiln-800 #1b3e45 matches the logo).

Rule when theming an EmberKiln surface:

  • Cool decorative / brand-gradient colors → emberkiln-*. Never introduce purple-, violet-, fuchsia-, or indigo- on these surfaces — that's the old "EmberKiln" gradient we moved off of.
  • Keep semantic (traffic-light) colors as-is — they carry meaning, not brand: emerald = success/healthy, amber = warning (and the Story Bible's identity color), rose/red = danger/error, sky/cyan = neutral. The spend gauge in StudioSpendChip.tsx (pctTone) is the canonical example: don't tealify it.
  • primary (sky-blue) stays the HiveJournal app color; EmberKiln surfaces are the ones that go teal.

Skinning a shared component (theme-token map)

When one component must render in two brand contexts (e.g. ProseEditorModal shows amber in the modal but violet inside the Studio cockpit), do not fork the component or scatter context === 'x' ? … : … across the JSX. Add a theme?: '<a>' | '<b>' prop defaulting to the original (so every existing call site is byte-identical), back it with a module-level token map keyed by theme, and reference t.<slot> in the JSX:

const THEMES = {
  amber:  { panel: 'border-amber-400/20 bg-[#1a1613]', title: 'text-amber-100', /* … */ },
  violet: { panel: 'border-violet-500/20 bg-[#140e26]', title: 'text-white',    /* … */ },
} as const
// inside the component: const t = THEMES[theme]  → className={`… ${t.panel}`}

Rules: keep the two shapes in lockstep (same keys, so a new slot can't be half-themed); write the class strings as full literals (Tailwind's scanner must see them — never build class names by interpolation); thread the same theme prop into any child components that carry their own color (else you get a half-skinned surface). Canonical example: ProseEditorModal + BibleReferencePanel + InlineAiToolbar (PR #1147).

Adding a WebXR / immersive 3D scene

Three scenes now share one recipe — the Stream (StreamXRScene.tsx), the Dome, and the Graphene VR Story Player (StoryVrScene.tsx). Clone the pattern; do not reinvent it. Load rules + the full landmine list live in docs/product/IMMERSIVE_3D_HANDOFF.md. The load-bearing rules:

  • Page loads the scene dynamic(() => import(...), { ssr: false }) and keeps everything from @react-three/* out of page module scope (types are fine — they erase). Importing the reconciler at page scope crashes the Next-15/React-19 prerender (ReactCurrentBatchConfig). Use useParams() on [param] pages, not the Promise params arg.
  • The scene owns the XR store + Enter-VR button (createXRStore()<XR><XROrigin> + a plain button calling store.enterVR()), with <OrbitControls> as the desktop fallback. Capability detect with navigator.xr.isSessionSupported('immersive-vr').
  • fiber must be v9 (@react-three/fiber ^9); the @types/react pin, the @iwer/devui stub, and the fiber single-instance alias in next.config.js are already set — don't regress them.
  • Keep story/media logic on a renderer-agnostic data layer (plain fetch + a reduced shape, e.g. grapheneChapters.ts's VrStory/VrChapter) so the scene is one consumer and a future native (visionOS/RealityKit) renderer — or another surface — can reuse it.
  • You cannot see renders. These are tuned by the user screenshotting on-device; leave 3D positions/scales as clearly-commented first-guesses and expect a screenshot-iterate loop. Run the full next build (not just npm run check) before opening the PR — the prerender landmine only shows there.

Commit messages

The repo uses imperative present tense, prefixed with the area touched:

feat(open-energy): Phase 7 — synthesis reveal climax

Adds the emotional payoff of the constellation pathway. When a user
has completed at least one experiment from each meta-pattern element...

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

Multi-line bodies are common — they document the why, not just the what. Use them for any non-trivial change.

Testing

There's no comprehensive test suite. The pattern that exists:

  • Backend tests: apps/backend/src/routes/__tests__/*.test.ts (one example for workout-window)
  • Build verification: npx next build from apps/frontend is the most-used "test"
  • Type checking: npx tsc --noEmit from apps/backend for backend type safety

For new features, prefer running the full build locally before pushing — this catches the React-context-during-static-generation class of issues that doesn't surface until prod build.

Tracking events via PostHog

Every product-analytics event flows through trackProductEvent(name, properties?) in lib/analytics.ts — never call posthog.capture directly. The helper is a no-op when PostHog isn't initialized (SSR, missing key, errors) so it's safe to call from any client surface without guards.

When to add a new event vs. extending an existing one

  • New event when the action is meaningfully different from anything in the union. "User finished a season" and "user shared a season" are different actions.
  • New property when it's a variant of the same action. The same season_publish_toggled event carries action: 'publish' | 'unpublish' rather than splitting into two events — the variant-as-property keeps the funnel-shape readable in PostHog.

If you're not sure, lean toward extending — the catalog already has 60+ events and grouping by action keeps it grep-able.

Naming pattern

<feature>_<noun>_<verb>, lowercase, snake_case. The feature prefix makes events sort together in the union and in PostHog's event-name autocomplete.

Good examples from the existing union:

  • season_publish_toggled — feature season, noun publish, verb toggled
  • odessa_story_shared — feature odessa, noun story, verb shared
  • graphene_poster_clicked — feature graphene, noun poster, verb clicked
  • episode_tip_started — feature episode, noun tip, verb started

Avoid <verb>_<noun> ("click_poster"), abbreviations ("gphn"), and tense drift ("clicking" vs. "clicked"). Use the past tense for completed actions, present tense only for impressions (*_view, *_impression).

Standard payload fields

Include these whenever they're meaningful — consistency across events makes cross-event funnels possible without per-event mapping:

  • season_id / notebook_id / entry_id / story_id — the canonical object the event acts on. Always include if the event is scoped to one.
  • source — string identifying the UI surface that fired the event. Use a small enum, not free text. Examples already in use: 'publish_strip', 'public_reader_finished', 'locked_chapter_row', 'hero_pill'. New events should reuse existing source values where they apply.
  • action — when one event covers a toggle or binary state, distinguish the variants with action. Example: season_publish_toggled with action: 'publish' | 'unpublish'.
  • experiment_key + variant_key — when the event fires inside an A/B-rendered component, thread these through (use getCachedExperimentVariant(key) from lib/useExperimentVariant.ts for a sync read at click time).

Union enforcement

The PostHogEvent union in lib/analytics.ts is the source of truth — TypeScript will reject trackProductEvent('typo_here', …) if the name isn't in the union. Every new event MUST be added to the union (with a one-line comment about what fires it) before it can be tracked.

Logging the new event in the catalog

After adding the event to the union, add a row to the matching category table in docs/reference/POSTHOG_EVENTS.md (event name, source surface, typical payload). Use the existing comment dividers in the union as the category — don't invent new categories without an obvious need.

Code template

import { trackProductEvent } from '@/lib/analytics'

// Inside a click handler / effect / mutation onSuccess:
trackProductEvent('season_publish_toggled', {
  season_id: season.id,
  action: nextState ? 'publish' : 'unpublish',
  source: 'publish_strip',
})

For monetization-funnel events (paywall impression → modal → checkout), see the next section for the additional dual-write contract.

Analytics + funnel events

When adding a new product analytics event for a monetization-relevant flow:

  1. Always go through trackProductEvent(name, properties?) in lib/analytics.ts — never call posthog.capture directly. The helper dual-writes Graphene+ funnel stages to PostHog AND to our self-hosted monetization_funnel_events table.
  2. Add the event name to the PostHogEvent union in the same file with a one-line comment about what fires it. The union is the source of truth; future analyses grep this list.
  3. Server-side terminal events (e.g. subscribed after a Stripe webhook fires) bypass the frontend helper — call recordFunnelEvent({ event_type, user_id, source, metadata }) from services/monetization-funnel.ts directly. Never accept a terminal-stage event from a public ingest endpoint.
  4. A/B attribution: thread experiment_key + variant_key into the properties blob whenever a click happens inside a variant-rendered component. Use getCachedExperimentVariant(key) from lib/useExperimentVariant.ts for a synchronous read at click time (the hook's localStorage cache is populated on first render). The funnel-summary endpoint reads metadata.variant_key directly so no schema change is needed per-experiment.

Self-logging error capture

When adding a new backend service that wraps risky work in try/catch:

  1. Call recordServiceError({ service, context, error, metadata, severity? }) from services/service-errors.ts inside the catch — don't just console.warn. Railway logs roll off; the service_errors table is the long-running ops surface at /dashboard/admin/errors.
  2. Convention for the service field: kebab-case matching the source filename (e.g. 'auto-universe-batches', 'short-stories-audio'). The dashboard groups by (service, context, message) so consistent naming makes failures collapse correctly.
  3. Frontend errors auto-flow via <ClientErrorReporter /> (mounted site-wide) hooking window.error + unhandledrejection. Explicit catch sites can additionally call reportClientError(err, { context }) from lib/report-error.ts.
  4. Don't add a console.error AND recordServiceError for the same path — pick one. The dashboard is for triage; Railway logs are for tailing in real time. We default to recordServiceError only.

Snapshot prose before overwriting it

Any code path that overwrites prose on a Graphene canonical surface (story_episodes.chapter_prose, story_episode_takes.prose, season_audio_segments.text_content, season_script_takes.segments) must call snapshotProse() from services/prose-versions.ts BEFORE the UPDATE.

The takes layer only captures the happy path (generate → critique → promote). Leak repairs, script regens, timeline regens, segment overrides, and take promotion itself all bypass takes and would silently lose prose without this snapshot. The audit that motivated this convention found 5 such gaps; closing them is what makes "restore any prior version" actually work.

import { snapshotProse } from './prose-versions.js'

// Always read the prior value first, then snapshot, then update.
const { data: prior } = await supabase
  .from('story_episodes').select('chapter_prose').eq('id', id).maybeSingle()
await snapshotProse({
  surface: 'episode.chapter_prose',
  row_id: id,
  season_id: seasonId,
  episode_id: id,
  episode_number: n,
  prose_before: (prior as any)?.chapter_prose ?? null,
  prose_after: nextProse,
  operation: 'repair',          // or 'generation' | 'promotion' | 'segment_override' | 'manual_edit'
  reason_key: 'leak_fix_envelope',  // free-form, surfaces in the UI
  triggered_by: 'cron:my_job',  // 'user' | 'cron:<name>' | 'pipeline:<stage>'
  metadata: { /* whatever's useful for diagnostics */ },
})
await supabase.from('story_episodes').update({ chapter_prose: nextProse }).eq('id', id)

snapshotProse() is best-effort and never throws — a history-write failure must not break the mutation. Identical prose_before/prose_after are deduped (except segment_override, which records metadata-only diffs). The Versions panel in ProseVersionsModal.tsx renders every snapshot for one-click restore.

If you add a NEW prose surface beyond the four listed above, extend the surface enum in migration 321 AND the dispatch in restoreProseVersion() together — the helper has no runtime registry.

Daily-snapshot pattern (for "is this metric going up?" views)

Build new dashboard metrics on top of this pattern rather than re-querying source tables on every page load:

  1. New table <feature>_snapshots with date date PRIMARY KEY + the metrics you want to track + captured_at timestamptz.
  2. Service exports capture<Feature>Snapshot() (UPSERT on date for idempotency), backfill<Feature>Snapshots() (walks source data on first deploy so the chart isn't empty for 30 days), and get<Feature>History(daysBack).
  3. Wire as a heartbeat-tracked cron in apps/backend/src/index.ts — see the monetization_snapshot_tick block as the reference pattern. Add a label to CRON_LABELS in services/system-health.ts.
  4. Frontend renders via the inline-SVG <SparklinePanel> (no chart-lib dep) plus <MetricCard delta={...} deltaFormat="usd" /> for the WoW pill.

Every table needs RLS enabled

Supabase's PostgREST auto-exposes every public-schema table as a REST endpoint. Without alter table … enable row level security, any authenticated client can SELECT/INSERT/UPDATE/DELETE rows directly via that endpoint, bypassing whatever route-level auth your backend thinks it has. A missing ENABLE is a quiet data leak waiting to happen — the bug is not an error you'll see during dev.

Defense in depth: enable RLS on every table. Two patterns:

  1. User-facing tablesENABLE RLS + at least one policy granting the legitimate client read (the migration template above shows this pattern).
  2. Backend-only / audit-log tables (service_errors, system_heartbeats, drip logs, etc.) — ENABLE RLS with no policies. Service-role bypasses RLS so backend code still reads/writes fine; clients get nothing through PostgREST. This is the correct shape for audit logs, internal infra tables, anything only the backend should touch.
-- For an audit/internal table: enable RLS, no policies.
ALTER TABLE public.service_errors ENABLE ROW LEVEL SECURITY;
-- Backend service-role queries continue working; client-side
-- queries return zero rows.

Audit script (scripts/audit-rls-coverage.py) — scans supabase/migrations/ for every create table and checks whether a corresponding alter table … enable row level security appears anywhere in the tree. Run before merging any migration that creates a table:

python3 scripts/audit-rls-coverage.py

Exits 0 when every created table has RLS enabled, 1 with the offender list otherwise.

Mutating routes need an auth middleware

Every router.post / patch / put / delete handler in apps/backend/src/routes/ must have an auth middleware in its chain — authenticateUser, requireSuperAdmin, maybeAuthenticateUser for anon-tolerant flows, or be on an explicit allowlist of public endpoints (webhooks, public form submissions, cron triggers, unsubscribe links).

Two equally-valid placements:

// Per-route — required when only some routes in the file need auth.
router.post('/path', authenticateUser, handler)

// File-level — the right choice when EVERY route in the file
// is admin-only. router.use applies to every subsequent route
// declaration in the file.
router.use(authenticateUser, requireSuperAdmin)
router.post('/issues', handler)
router.delete('/issues/:id', handler)

The pattern to avoid: a router with no middleware where some routes parse req.headers.authorization inline and others don't. It's hard to grep, hard to audit, and easy to miss a route when you add a new one.

Audit script (scripts/audit-route-auth-coverage.py) — scans every mutating handler, detects both per-route middleware AND file-level router.use(...) gates, and flags anything that isn't on the public-endpoint allowlist. Caveat: it can't see inline req.headers.authorization parsing, so some flagged routes ARE protected — human review of each result is required. Run before merging any PR that adds a new POST/PATCH/PUT/DELETE handler:

python3 scripts/audit-route-auth-coverage.py

Exits 0 when clean, 1 with the offender list otherwise.

When to call recordServiceError

Backend catch blocks that capture unexpected errors should call recordServiceError() (from services/service-errors.ts) so the failure surfaces on /dashboard/admin/errors. The daily /errors triage flow + Marlow's Sunday ops report both pull from that table.

Do call it when:

  • A request handler hits an unexpected condition (DB write failed, external API returned 5xx, downstream service threw)
  • A cron tick errors out (lets the dashboard correlate "this loop is broken" with the heartbeat)
  • A scheduled batch task fails partway through

Don't call it for:

  • 400 responses on invalid user input (those are caller-fault, not service-fault)
  • JSON.parse catches around tolerated input (the catch IS the recovery path)
  • Idempotency unique-violation catches (the duplicate is the expected state)
  • Optional-feature failures where the absence is fine (e.g. "tone pack didn't load, render anyway")

Use a tag taxonomy that scales: service: 'feature-name', context: '<function-or-callsite>', metadata: { …whatever the triager needs to reproduce }. The dashboard groups by (service, context, message) so consistent tagging is what makes triage fast.

Audit script (scripts/audit-service-errors-coverage.py) — surveys catch-block coverage across apps/backend/src/services/ and apps/backend/src/routes/. Informational only — always exits 0. Surfaces files with high catch counts but zero recordServiceError calls so you can scan them for actual silent-failure candidates. Unlike the other four audit scripts in this set, this one isn't a hard gate — most catch blocks are legitimate non-loggers, and forcing recordServiceError everywhere would flood the dashboard with noise that buries the real signal.

python3 scripts/audit-service-errors-coverage.py
python3 scripts/audit-service-errors-coverage.py --threshold 20  # only files with 20+ catches

Cron heartbeats are mandatory

Every recurring task — setInterval cron loop, scheduled job, setTimeout retry sweep, anything that should fire on a known cadence — must call recordHeartbeat() (or wrap with withHeartbeat()) on every fire, success AND error paths. The full pattern + label-registration steps live in CLAUDE.md → "Recurring tasks must report a heartbeat."

A loop without a heartbeat is invisible: when it wedges or silently stops firing, /dashboard/admin/system-health keeps showing green and nobody notices until a downstream metric goes flat days later.

Audit script (scripts/audit-cron-heartbeat-coverage.py) — for each setInterval(callback_name, …) in apps/backend/src/index.ts, finds the latest declaration of callback_name, walks its body, and reports any callback whose body doesn't mention recordHeartbeat or withHeartbeat. Run before merging any PR that adds a new cron:

python3 scripts/audit-cron-heartbeat-coverage.py

Exits 0 when all callbacks call a heartbeat helper, 1 with the offender list otherwise. Imported callbacks (declaration in another file) are flagged "unclear" rather than failed — verify those manually.

For opaque state markers that reuse system_heartbeats for KV-style persistence (e.g. cafe_battle_spectator_active, cafe_battle_demo_started), add the name to NON_CRON_HEARTBEATS in services/system-health.ts so the staleness check skips them. The dashboard's default 15-min staleness threshold would otherwise light them red whenever the underlying feature is quiet.

Approval-gated content pipelines

When a feature ships content that needs human review before reaching listeners (the Writer Fleet's persona-authored seasons are the canonical example), wire it as a nullable enum on the parent row, not a separate table:

  1. Add a review_state text column to the parent table, CHECK-constrained to (NULL, 'unreviewed', 'changes_requested', 'approved'). Nullable on purpose — content produced by humans bypasses the gate entirely (their seasons stay NULL), so adding the review pipeline to one cohort doesn't disrupt the rest.
  2. Add reviewed_by uuid REFERENCES auth.users(id) + reviewed_at timestamptz + review_notes text for the audit trail. Stamp them on every state change.
  3. Add a partial index WHERE review_state IN ('unreviewed', 'changes_requested') for the admin review-queue query — it's the only "live" subset.
  4. The publish endpoint reads the column and 403s when review_state IS NOT NULL AND review_state != 'approved'. Human-authored content (review_state IS NULL) bypasses entirely.
  5. Surface the state via a banner component above the chapter / item list (see WriterFleetReviewBar) — admin-only, with Approve / Request-changes (notes) / fix-the-content actions inline.

Reference implementation: migration 231 + the PATCH /:id/review endpoint + the publish-endpoint gate in story-seasons.ts.

One-time-token recruitment / claim flows

When an admin needs to mint a single-use URL that grants a specific privilege when a recipient signs up and clicks it (the Writer Fleet's persona-claim flow is the canonical example), use this shape:

  1. Opaque random tokens, stored in DB — not signed JWTs. Generate 24 bytes via crypto.randomBytes(24).toString('base64url'), store on the target row (invite_token text + invite_minted_at timestamptz + invite_minted_by uuid). Rotation = overwrite (a fresh mint invalidates prior links). DB lookup confirms validity atomically; stateless JWTs let claimants race.
  2. Unique partial index on the token column WHERE token IS NOT NULL for the public lookup endpoint.
  3. Two routers — admin-gated mint endpoint, separate public router for the lookup + claim endpoints (so the unauthenticated lookup doesn't sit under a super-admin-gated mount).
  4. Atomic guard at claim time — the first DB write inside the claim handler is a guarded UPDATE ... WHERE used_at IS NULL AND claimed_by_user_id IS NULL. If zero rows match, return 409 (race). Only after this lock-claim step do you start mutating downstream tables.
  5. Document what transfers vs stays in a top-of-file comment listing each FK-bearing table the claim touches. For Writer Fleet: seasons + earnings + writer-follows + handle transfer; journal entries + comments + battle stats stay archived with the originating row. The decision is feature-specific but the doc is mandatory.
  6. Handle UNIQUE-constraint swaps in two steps when transferring a slug-like field (username, handle). Park the source row's field under archived_<value>_<id-suffix> first, then move the real value to the claimant. Single-step UPDATEs hit the UNIQUE constraint.

Reference implementation: migration 232 + routes/persona-invites.ts + the public landing page at /invite/persona/[token].

Anonymous-first features (anon_token + account claim)

For a logged-out-usable feature that still saves per-user data (Rehearsal Room runs + saved people are the canonical example), key rows by a client-generated anon_token AND let signed-in requests key by user_id:

  1. Table: user_id uuid null (FK auth.users, ON DELETE CASCADE) + anon_token text (both nullable). RLS enabled, no public policy — all access via the service-role backend with app-layer ownership checks.
  2. Client token: generate once via crypto.randomUUID(), persist in localStorage, send it (query param or body) on every call.
  3. Auth-aware calls: also send the Supabase access token when present (apiRequest injects it automatically; or build the Authorization header). Backend uses maybeAuthenticateUser, then req.user?.id ? eq('user_id', …) : eq('anon_token', …) — signed-in users get proper ownership + cross-device, anonymous users get same-browser continuity.
  4. Ownership check: owns if user_id === req.user.id OR anon_token === <token>.
  5. Claim on signup/login: POST /<feature>/claim { anon_token } (auth-required) runs UPDATE … SET user_id = :uid WHERE anon_token = :token AND user_id IS NULL across the feature's tables. Call it client-side on mount whenever both a session and an anon_token exist, then reload by user.

Reference: Rehearsal Room — rehearsal_runs / rehearsal_personas, routes/rehearsal-room.ts (/claim), RehearsalTool.tsx.

Public user content → pseudonymize before exposure

When user-authored content goes to a public surface (the Rehearsal Room gallery is the canonical example), anonymize it at the source, not at render:

  • A prep step (prepareForPublic) runs ONE gpt-4o-mini call that swaps real person names → consistent pseudonyms across every field AND generates topic tags, before the main modeling — so derived output (reactions) is public-safe too. Model on the sanitized inputs.
  • Tell the user: a heads-up before the action ("this is public; we swap names") + a post-action notice of exactly what changed (name_map: "Sam → Riley").
  • Opt-out: a "keep private" flag skips prep entirely (real names, owner-only, no public slug); the share endpoint refuses to publish a never-pseudonymized row.
  • Fiction/character content skips name-swap (names are invented) but still tags. Falls back to originals on any LLM/parse failure — never blocks the action.

Reference: services/rehearsal-room.ts (prepareForPublic / generateTags).

AI-content transparency chip

Any persona-authored content visible to listeners (poster cards, season pages, writer hubs) carries a "🤖 AI writer" chip until a real human claims the voice. Pattern:

  1. Backend exposes a derived authored_by_ai_persona: boolean on the response payload — single-query lookup against ai_personas keyed on owner_user_id, true when claimed_at IS NULL. For list endpoints, batch the lookup with .in('user_id', ownerIds) to avoid N+1.
  2. The flag flows through the API → component prop → chip render. Same chip styling everywhere (fuchsia accent, "🤖 AI writer", tooltip explaining the invite-and-claim mechanic) so listeners learn the convention once.
  3. The chip disappears the moment the persona is claimed — no separate "previously AI" indicator. The transparency is about current authorship, not history. The audit trail lives in ai_personas.claimed_by_user_id.
  4. Reference: PosterCard.tsx + SeasonClient.tsx + the authored_by_ai_persona field on /api/story-seasons/public and /api/story-seasons/:id.

Model selection by stage

All LLM calls in the prose pipeline route through apps/backend/src/services/llm.ts complete(). The catalog exposes three named tiers — pick by what touches the listener, not by raw cost.

StageTier constantModelWhen
Iteration / draft chapter takesDEFAULT_MODEL_KEYClaude Haiku 4.5Default for generateChapterTake. Cheap, fast — admins throw most drafts away.
Critics (8-pass craft review)DEFAULT_MODEL_KEYClaude Haiku 4.5Structured fault-finding; 8× amplifies any tier bump. Bump individual critics only when one underperforms.
Planning (knowledge / spine / stakes)PLANNING_MODEL_KEYClaude Sonnet 4.6One-shot per season, constraint quality propagates into every chapter. ~$0.05/season total.
Promote-time re-roll (audio-bound)PROMOTE_MODEL_KEYClaude Opus 4.7Fires inside promoteChapterTake when the picked take is on the cheap tier. ~$0.15/chapter against $0.60-1.50 TTS.

Why this tiering exists — ElevenLabs TTS dominates the per-chapter cost line (4-30× the LLM spend). Spending an extra ~$0.15 of Opus money on the prose that gets read aloud is a small premium on the audio bill, and Haiku is fine for the takes nobody will ship.

Adding a new LLM call:

  1. Decide which tier it belongs to. The default is "iteration tier" (Haiku) — most calls fit there.
  2. Import the tier constant, not a hardcoded model string: import { PLANNING_MODEL_KEY } from './llm.js'. Hardcoded strings drift; constants follow the policy.
  3. Pass a feature_key (dot-delimited, e.g. 'chapter_take.prose', 'critic.tension_editor'). This drives the breakdown on /dashboard/admin/llm-spend.
  4. If the call is audio-bound (its output will be TTS-narrated), consider whether it needs a promote-time re-roll like chapter prose.

Opt-out for promote re-roll: promoteChapterTake(takeId, { upgrade_on_promote: false }) skips the Opus regeneration and ships the exact take picked. Used by:

  • Auto-fire "generate next chapter" one-shot (story-seasons.ts:3441) — unreviewed take.
  • A/B compare flows where the model identity is the point of the test.

The PATCH route at story-seasons.ts:4590 accepts upgrade_on_promote: boolean in the body so the admin UI can surface the toggle.

Meter non-token generation spend with recordGenerationCost

complete() auto-logs every token-based chat call to llm_call_log (the table behind /dashboard/admin/llm-spend). Image / video / audio generation does not — a direct replicate.run(...) (Flux, Flux Kontext, Kling, musicgen) or client.images.generate(...) (gpt-image) has no token concept and never touches complete(), so unless you log it explicitly the spend is invisible to our tooling and shows up only on the provider's billing page.

Rule: every replicate.run / .images.generate success path calls recordGenerationCost() (exported from llm.ts):

import { recordGenerationCost } from './llm.js'

const out = await replicate.run('kwaivgi/kling-v1.6-standard', { input })
const url = replicateUrl(out)
if (!url) return null
void recordGenerationCost({
  feature_key: 'scene_studio.clip',          // dot-delimited, like complete()'s
  provider: 'replicate',                       // free text → llm_call_log.provider
  model: 'kwaivgi/kling-v1.6-standard',        // bare id; ':version' suffix stripped
  unitCostUsd: duration === 10 ? 0.5 : undefined, // override the price table when a call costs more
  context: { season_id, episode_id },          // optional attribution
})
return buf
  • It's best-effort + never throws (lazy-imports supabase, swallows errors) — call it with void, don't await-block the gen on it.
  • Prices live in GEN_PRICE_USD in llm.ts (rough list prices). A model not in the table logs at cost 0 — still a visible call count. Pass unitCostUsd for off-table sizes/durations (10s Kling, hi-res images).
  • Log on success only (the gen is billed regardless of whether your download/upload then succeeds, so log right after the provider returns a URL/buffer). For a recorded failure, pass error.

This is a systemic class, not a one-off. scripts/audit-gen-cost-logging.py (in npm run audit, info-only) lists every file with a gen call and whether it logs cost. All gen surfaces are now metered (the audit reports zero un-metered) — keep it that way: any new replicate.run / .images.generate site must call recordGenerationCost on success, or it'll show up as un-metered on the next audit run.

Daily spend cap (the same data, enforced). Because gens land in llm_call_log, you can also cap them. services/gen-budget.ts reads today's (UTC) spend by feature_key prefix: genSpendTodayUsd(prefix) and assertGenBudget(scope, prefix, capUsd) (throws BudgetExceededError, code: 'BUDGET_EXCEEDED' → routes map to HTTP 429). Fail-open on a read error, fail-closed when over. Scene Studio is the first consumer — assertSceneBudget() runs at the top of every expensive entry point (keyframes, single + bulk clips, character refs, style presets) and the bulk-clip loop breaks (not fails-every-shot) when the cap trips mid-run. Cap = SCENE_STUDIO_DAILY_CAP_USD env (default $25; 0 disables). GET /api/scene-studio/budget surfaces { cap_usd, spent_usd, remaining_usd } for the UI meter. Other consumers follow the same shape: universe-canon image gen (universe_canon. / UNIVERSE_CANON_DAILY_CAP_USD, default $15) and VR entity generation (services/vr-entities-gen.ts — super-admin, on-demand item/prop cutout synthesis for the VR Story Player) which guards with assertGenBudget('VR entities', 'vr_entities.', VR_ENTITIES_DAILY_CAP_USD) (env, default $10) re-checked before each paid image, so a mid-run overspend stops the batch. Apply the same pattern to any feature whose gens can run unattended (a cron, a bulk button).

Tool calling through the LLM shim

The shim's complete() accepts tools + tool_choice and surfaces tool_calls + finish_reason on the result. Use it instead of a direct OpenAI SDK call for any chat path that needs function calling.

Why route through the shim:

  • Automatic per-call cost logging to llm_call_log (so the /dashboard/admin/llm-spend feature breakdown picks up your new call without extra wiring)
  • Shared model_key catalog (no scattered 'gpt-4o-mini' literals)
  • Provider-fallback retry on transient OpenAI errors (only when tools is NOT set — see below)

The contract:

import { complete, type LLMMessage, type LLMTool } from './llm.js'

const tools: LLMTool[] = [
  {
    type: 'function',
    function: {
      name: 'spawn_universe_batch',
      description: 'Fire a new auto-universe batch.',
      parameters: { type: 'object', properties: { steering_hint: { type: 'string' } } },
    },
  },
]

const messages: LLMMessage[] = [
  { role: 'system', content: systemPrompt },
  { role: 'user', content: userMessage },
]

const result = await complete({
  model_key: 'gpt-4o-mini', // OpenAI-only — see Anthropic note below
  messages,
  tools,
  tool_choice: 'auto',
  feature_key: 'my_feature.first_call',
  context: { user_id: userId },
})

if (result.tool_calls?.length) {
  // 1. Execute each tool call
  // 2. Append the assistant turn (with tool_calls) + a 'tool' role message
  //    per result to the messages array
  // 3. Re-submit with a follow-up complete() call to get the natural-language reply
}

Multi-turn shape:

LLMMessage supports role: 'tool' with tool_call_id on the response side, and tool_calls on an assistant turn that emitted them. The follow-up complete() call sends:

[system, user, assistant{ tool_calls: [...] }, tool{ tool_call_id, content: JSON.stringify(result) }, ...]

See chat-service.ts generateChatResponse for the canonical implementation — first call may emit tool_calls; if it does, execute + re-submit with feature_key: 'chat_assistant.tool_followup' to distinguish the two billing surfaces.

Tools work on both providers (as of 2026-07-10). The shim translates the OpenAI-shaped tools / tool_calls / role:'tool' messages to Anthropic's schema (input_schema, tool_use/tool_result blocks, coalescing consecutive tool results for Anthropic's strict user/assistant alternation) via toAnthropicTools / toAnthropicMessages / toAnthropicToolChoice in llm.ts (golden-tested in __tests__/llm-tools.test.ts). So you can now pass a Claude model_key with tools — a JQ-style agent loop runs on Sonnet/Haiku, and provider-fallback carries tool calls across providers on a transient 429. The caller's loop is identical either way (execute tool_calls → append role:'tool' results → re-submit).

Errors from inside a tool: wrap executeToolCall(name, args) in try/catch, record to service_errors with service: 'chat-tool' / context: <tool name> so failures surface to /dashboard/admin/errors. Without this, the user sees a vague "there was an error" reply from the model and nobody knows what actually went wrong.

Manual image upload over an auto-generated asset

When an asset (poster, cover, character portrait) can be both AI-generated AND hand-uploaded (e.g. Midjourney art — no API, so it can only arrive by upload), follow the established pattern (/:id/upload-art, /universes/:key/characters/:characterKey/portrait-upload, /:id/cast/:castId/upload-portrait):

  • Transport: base64-in-JSON ({ content_base64 }), not multer — keeps the route dependency-light and matches the existing upload routes.
  • Normalize: sharp(buffer).resize(…).png() server-side; cap input bytes.
  • Cache-bust: append ?v=${Date.now()} to the public URL since the storage path is reused (upsert) — otherwise the CDN/<img> serves the stale image.
  • Mark provenance: store a source ('auto' | 'upload') so a later bulk auto-regenerate skips/doesn't clobber hand-made art. (Whatever the column, the regen loop must respect it.)
  • Client: a small self-contained uploader component that detects super-admin (supabase session + profiles.role), file→base64→POST, and refreshes. When nested in a card <Link>, preventDefault()+ stopPropagation() on the button (see CastPortraitUploadButton.tsx).

Cohort-gated UI vs. analytics/recording gates

When gating UI on an acquisition cohort (e.g. the cold-ad hero on isPaidAdVisitor()), keep the preview/QA path separate from the analytics gate. The view-as "Ad visitor" modes flip the UI via isAdViewAs(mode) but deliberately do NOT make isPaidAdVisitor() / shouldRecordSession() return true — so a super-admin previewing the experience is never recorded as a session or counted as real ad traffic. Don't fold preview state into the data-collection gate.

Effective-style / override-vs-default resolution

For "book overrides universe default" style values, resolve through one helper (getEffectiveStyleForSeason()season.visual_style_override ?? universe.visual_style) rather than reading the columns ad hoc, so every call site applies the same fallback. Generation prepends the resolved value; null falls back to prior behavior.

Image-to-image style anchoring (reference image → portraits/covers)

When a feature can anchor generated art to a reference IMAGE (not just a text descriptor), use the shared dalleEditToBuffer + fetchImageBuffer helpers in season-media.ts (gpt-image-1 images.edit), and follow three rules so it stays safe and on-purpose:

  1. Style-only prompt. images.edit copies the reference's subject + composition by default, not just its look. When the reference path is taken, swap in an explicit style-transfer prompt — "render in the EXACT art style (palette/linework/texture/lighting/mood); do NOT copy the reference's people, faces, or composition; invent a fresh subject." A plain "in the style of" prefix is not enough.
  2. Always fall back to text-to-image. Treat the reference as additive: fetch it once per gen run; on a missing URL, a fetch failure, or a null images.edit result, drop through to images.generate and log a warning. A bad/absent anchor must never hard-fail generation.
  3. Anchor source = a stable image. Use a purpose-built column (story_universes.style_reference_image_url, 357), not a display cover or a regenerated poster, so the anchor doesn't shift under you.

Text-descriptor (visual_style) and reference-image are complementary — the text describes, the image anchors. Wire both; the image is the only way to match a Midjourney aesthetic the model can't reproduce from prose.

Painterly marketing-section images (offline generator)

Large image-led "highlight bands" on marketing pages use a painterly section image generated offline (committed to apps/frontend/public/…), not at request time. The generator is scripts/generate-painterly-images.mjs — it implements the network painterly-backdrop recipe (crosstalk/contracts/painterly-backdrop.md): gpt-image-1, landscape 1536×1024, and it appends a Rule 9 "no people / no text" suffix to every prompt so a marketing image can never depict a person.

To add a new section image: append one { slug, dir, prompt } entry to SECTIONS (prompt describes a place / interior / objects — never people), then node scripts/generate-painterly-images.mjs (skips existing; a batch of >1 needs --yes and prints count + est. spend first; --force to regen; --only <slug> for one). It writes <slug>.webp (falls back to .png without cwebp). Render it with the shared HighlightBand component (image + eyebrow + title + points; reverse to zig-zag; opaque dark ground so it reads on any page). Key comes from OPENAI_API_KEY (env or apps/backend/.env).

Three disciplines (PorchHearth redlines, ratified 2026-07-26 — the suffix requests Rule 9 but does not guarantee it; only viewing does):

  1. View every generated image before commit. gpt-image-1 renders stray figures / legible text even when told not to (our see-your-story panels came back with people the first time — caught only by looking, regenerated). The generator prints the paths + a reminder; don't commit an unseen image.
  2. Write alt from the RENDERED image, not the prompt. A prompt-derived alt is a confident false description delivered only to people who can't see the image, and it passes every sighted review. Look, then describe.
  3. Rule 7: the band's image panel carries its own bg-slate-900 so a 404 degrades to a plain panel (with alt text), never a broken-image icon.

Mesh contract: crosstalk/contracts/painterly-highlight-band.md.

Escaping text for strict XML output (feeds, EPUB)

Emitting XML or XHTML — an RSS/podcast feed, an EPUB document, any <?xml?> payload — import escapeXml from utils/xml-escape.ts. Do not hand-roll a .replace(/&/g,'&amp;')… chain per file. Beyond the five metacharacters, the shared helper strips C0/C1 control characters that XML 1.0 forbids even as numeric references — a single one flowing in from AI-generated text otherwise makes the whole document invalid and gets the feed rejected by Apple/Spotify or the EPUB rejected by Kindle. (There were five hand-rolled copies; four had this bug — consolidated + golden-tested 2026-07-05, xml-escape.test.ts.)

HTML email templates are a separate concern — they use a lenient local escapeHtml (HTML parsers tolerate control chars). Don't cross-wire the two.

Likewise, format a feed's <pubDate> and itunes:duration with formatRfc822 / formatDuration from utils/feed-format.tsnever new Date(x).toUTCString() inline. A null/malformed timestamp makes the raw call emit "Invalid Date", which fails RFC-822 and drops the item from the directory; the shared helper falls back to a valid date. (All three feeds — podcast network, SJ Anderson, write.cafe bento — had this bug; consolidated + tested 2026-07-05.) The SRT caption timestamp formatter lives in utils/srt.ts for the same reason.

When in doubt

  • Looking for a route? Grep app.use('/api/ in apps/backend/src/index.ts
  • Looking for a page? It's at apps/frontend/src/app/<that-route>/page.tsx
  • Looking for a migration? They're in supabase/migrations/ numbered sequentially
  • Looking for a setup guide? Check docs/<FEATURE>_SETUP.md or docs/<FEATURE>.md
  • Looking for the catalog of everything? INDEX.md
CONVENTIONS — Docs | HiveJournal