HiveJournal Conventions
Patterns and naming conventions used throughout the codebase. Following these makes new code blend in cleanly.
File naming
- Backend routes:
kebab-case.tsmatching the URL prefix (open-energy.ts→/api/open-energy) - Frontend pages:
page.tsxinside 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.cssco-located with the page - Migrations:
NNN_descriptive_name.sqlwhereNNNis zero-padded sequential - Scripts:
kebab-case.tsinscripts/, runnable vianpx 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:
- Start with SSR-safe defaults for every state variable (no localStorage reads in
useStateinitializers — see the gotcha below). - Render skeletons sized from a cached count (also stored in localStorage). Default count for new visitors is 3-4.
- Post-mount
useEffectreads the real cached data/count fromlocalStorageorsessionStorageand sets it. Skeletons swap to cached data in one frame. - Background refetch via the relevant hook (TanStack
useQueryor a directapiRequest) replaces the hydrated view in place when fresh data arrives. - Write-through updates the localStorage entry on every successful fetch so next visit is accurate.
Examples across the codebase:
- Navbar auth state —
hj-navbar-snapshotlocalStorage - Notebooks index skeletons —
hj-notebooks-count+hj-orphan-exists - Notebooks list data —
hj-notebooks-list-v1(primed viausePrimeNotebooksFromCachehook +queryClient.setQueryData) - Notebook detail + /other —
hj-notebook-entries-count:{id}via the sharedEntrySkeletoncomponent atapps/frontend/src/components/notebooks/EntrySkeleton.tsx - Dashboard entries —
dashboardCachedEntries+dashboardCachedAt(sessionStorage, 30 min TTL) - Stream notes —
hj-stream-cache-v1sessionStorage (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.tsx—hj:stream-filters-v1apps/frontend/src/app/dashboard/highlights/page.tsx—hj:highlights-filters-v1apps/frontend/src/app/dashboard/admin/seasons/page.tsx—hj:admin-seasons-filters-v1apps/frontend/src/app/dashboard/notebooks/page.tsx—hj: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
- Key naming:
hj:<surface>-filters-v<n>. Use a colon, not a hyphen — distinguishes filter blobs from cache-priming keys likehj-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). - One blob per surface, not per field. Single
getItem/setItempair keeps the effects readable. - 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. - Silent failure on both read and write paths. Private mode + quota errors are persistence-is-a-nicety territory, not user-visible.
- No SSR reads. The hydrate effect runs post-mount;
typeof window === 'undefined'guards both sides anyway. - 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
-
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. -
openX(id, e)helper withonClick + onAuxClickon 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 sharedopenInNewTabIfModifier(url, e)util (apps/frontend/src/lib/modifier-nav.ts) — it returnstrueif a modifier was hit (caller returns early) orfalseto fall through torouter.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.ts — openInNewTabIfModifier(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 cards —apps/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)openXhelper using the shared util —apps/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 handler —
apps/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
refinstead of state (stream/page.tsx:915—mouseYRef.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.tsusinguseQuerywith stable array query keys (['user-tasks'],['user-tasks', 'routines']). - Mutations go in the same hooks file as
useMutationwithonMutatefor optimistic updates andonSettledtoinvalidateQueriesthe 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:
| DDL | Idempotent form |
|---|---|
CREATE TABLE | CREATE TABLE IF NOT EXISTS |
CREATE INDEX | CREATE INDEX IF NOT EXISTS |
CREATE UNIQUE INDEX | CREATE UNIQUE INDEX IF NOT EXISTS |
ALTER TABLE ADD COLUMN | ALTER TABLE ... ADD COLUMN IF NOT EXISTS |
CREATE TRIGGER | DROP TRIGGER IF EXISTS … ; then CREATE TRIGGER |
CREATE POLICY | DROP POLICY IF EXISTS "name" ON table ; then CREATE POLICY "name" ON table … |
CREATE FUNCTION | CREATE OR REPLACE FUNCTION (or DO $$ existence check, see template above for set_updated_at) |
CREATE TYPE | DO $$ … 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 auditruns 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 (requiresteam_id)'org'— org members (requiresorg_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, ...]— theSeedBuildtag 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 introducepurple-,violet-,fuchsia-, orindigo-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 inStudioSpendChip.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). UseuseParams()on[param]pages, not the Promiseparamsarg. - The scene owns the XR store + Enter-VR button (
createXRStore()→<XR>→<XROrigin>+ a plain button callingstore.enterVR()), with<OrbitControls>as the desktop fallback. Capability detect withnavigator.xr.isSessionSupported('immersive-vr'). - fiber must be v9 (
@react-three/fiber ^9); the@types/reactpin, the@iwer/devuistub, and the fiber single-instance alias innext.config.jsare 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'sVrStory/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 justnpm 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 buildfromapps/frontendis the most-used "test" - Type checking:
npx tsc --noEmitfromapps/backendfor 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_toggledevent carriesaction: '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— featureseason, nounpublish, verbtoggledodessa_story_shared— featureodessa, nounstory, verbsharedgraphene_poster_clicked— featuregraphene, nounposter, verbclickedepisode_tip_started— featureepisode, nountip, verbstarted
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 withaction. Example:season_publish_toggledwithaction: 'publish' | 'unpublish'.experiment_key+variant_key— when the event fires inside an A/B-rendered component, thread these through (usegetCachedExperimentVariant(key)fromlib/useExperimentVariant.tsfor 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:
- Always go through
trackProductEvent(name, properties?)inlib/analytics.ts— never callposthog.capturedirectly. The helper dual-writes Graphene+ funnel stages to PostHog AND to our self-hostedtable.monetization_funnel_events - Add the event name to the
PostHogEventunion 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. - Server-side terminal events (e.g.
subscribedafter a Stripe webhook fires) bypass the frontend helper — callrecordFunnelEvent({ event_type, user_id, source, metadata })fromservices/monetization-funnel.tsdirectly. Never accept a terminal-stage event from a public ingest endpoint. - A/B attribution: thread
experiment_key+variant_keyinto thepropertiesblob whenever a click happens inside a variant-rendered component. UsegetCachedExperimentVariant(key)fromlib/useExperimentVariant.tsfor a synchronous read at click time (the hook's localStorage cache is populated on first render). The funnel-summary endpoint readsmetadata.variant_keydirectly 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:
- Call
recordServiceError({ service, context, error, metadata, severity? })fromservices/service-errors.tsinside the catch — don't justconsole.warn. Railway logs roll off; theservice_errorstable is the long-running ops surface at/dashboard/admin/errors. - Convention for the
servicefield: 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. - Frontend errors auto-flow via
<ClientErrorReporter />(mounted site-wide) hookingwindow.error+unhandledrejection. Explicit catch sites can additionally callreportClientError(err, { context })fromlib/report-error.ts. - Don't add a
console.errorANDrecordServiceErrorfor the same path — pick one. The dashboard is for triage; Railway logs are for tailing in real time. We default torecordServiceErroronly.
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:
- New table
<feature>_snapshotswithdate date PRIMARY KEY+ the metrics you want to track +captured_at timestamptz. - Service exports
capture<Feature>Snapshot()(UPSERT ondatefor idempotency),backfill<Feature>Snapshots()(walks source data on first deploy so the chart isn't empty for 30 days), andget<Feature>History(daysBack). - Wire as a heartbeat-tracked cron in
apps/backend/src/index.ts— see themonetization_snapshot_tickblock as the reference pattern. Add a label toCRON_LABELSinservices/system-health.ts. - 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:
- User-facing tables —
ENABLE RLS+ at least one policy granting the legitimate client read (the migration template above shows this pattern). - Backend-only / audit-log tables (service_errors, system_heartbeats, drip logs, etc.) —
ENABLE RLSwith 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.parsecatches 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:
- Add a
review_state textcolumn 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 stayNULL), so adding the review pipeline to one cohort doesn't disrupt the rest. - Add
reviewed_by uuid REFERENCES auth.users(id)+reviewed_at timestamptz+review_notes textfor the audit trail. Stamp them on every state change. - Add a partial index
WHERE review_state IN ('unreviewed', 'changes_requested')for the admin review-queue query — it's the only "live" subset. - 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. - 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:
- 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. - Unique partial index on the token column
WHERE token IS NOT NULLfor the public lookup endpoint. - 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).
- 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. - 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.
- 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:
- Table:
user_id uuid null(FKauth.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. - Client token: generate once via
crypto.randomUUID(), persist inlocalStorage, send it (query param or body) on every call. - Auth-aware calls: also send the Supabase access token when present (
apiRequestinjects it automatically; or build theAuthorizationheader). Backend usesmaybeAuthenticateUser, thenreq.user?.id ? eq('user_id', …) : eq('anon_token', …)— signed-in users get proper ownership + cross-device, anonymous users get same-browser continuity. - Ownership check: owns if
user_id === req.user.idORanon_token === <token>. - Claim on signup/login:
POST /<feature>/claim { anon_token }(auth-required) runsUPDATE … SET user_id = :uid WHERE anon_token = :token AND user_id IS NULLacross the feature's tables. Call it client-side on mount whenever both a session and ananon_tokenexist, 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 ONEgpt-4o-minicall 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:
- Backend exposes a derived
authored_by_ai_persona: booleanon the response payload — single-query lookup againstai_personaskeyed onowner_user_id, true whenclaimed_at IS NULL. For list endpoints, batch the lookup with.in('user_id', ownerIds)to avoid N+1. - 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.
- 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. - Reference:
PosterCard.tsx+SeasonClient.tsx+ theauthored_by_ai_personafield on/api/story-seasons/publicand/api/story-seasons/:id.
Model selection by stage
All LLM calls in the prose pipeline route through apps/backend/src/services/llm.tscomplete(). The catalog exposes three named tiers — pick by what touches the listener, not by raw cost.
| Stage | Tier constant | Model | When |
|---|---|---|---|
| Iteration / draft chapter takes | DEFAULT_MODEL_KEY | Claude Haiku 4.5 | Default for generateChapterTake. Cheap, fast — admins throw most drafts away. |
| Critics (8-pass craft review) | DEFAULT_MODEL_KEY | Claude Haiku 4.5 | Structured fault-finding; 8× amplifies any tier bump. Bump individual critics only when one underperforms. |
| Planning (knowledge / spine / stakes) | PLANNING_MODEL_KEY | Claude Sonnet 4.6 | One-shot per season, constraint quality propagates into every chapter. ~$0.05/season total. |
| Promote-time re-roll (audio-bound) | PROMOTE_MODEL_KEY | Claude Opus 4.7 | Fires 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:
- Decide which tier it belongs to. The default is "iteration tier" (Haiku) — most calls fit there.
- Import the tier constant, not a hardcoded model string:
import { PLANNING_MODEL_KEY } from './llm.js'. Hardcoded strings drift; constants follow the policy. - Pass a
feature_key(dot-delimited, e.g.'chapter_take.prose','critic.tension_editor'). This drives the breakdown on/dashboard/admin/llm-spend. - 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_USDinllm.ts(rough list prices). A model not in the table logs at cost 0 — still a visible call count. PassunitCostUsdfor 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. reads today's (UTC) spend by services/gen-budget.tsfeature_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 ( — super-admin, on-demand item/prop cutout synthesis for the VR Story Player) which guards with services/vr-entities-gen.tsassertGenBudget('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-spendfeature 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 (seeCastPortraitUploadButton.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:
- Style-only prompt.
images.editcopies 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. - 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.editresult, drop through toimages.generateand log a warning. A bad/absent anchor must never hard-fail generation. - 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
— it implements the network painterly-backdrop recipe (scripts/generate-painterly-images.mjscrosstalk/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
component (image + eyebrow + title + points; HighlightBandreverse 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):
- View every generated image before commit.
gpt-image-1renders stray figures / legible text even when told not to (oursee-your-storypanels 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. - Write
altfrom 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. - Rule 7: the band's image panel carries its own
bg-slate-900so 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 . Do not hand-roll a
utils/xml-escape.ts.replace(/&/g,'&')… 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 — never utils/feed-format.tsnew 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 for the same reason.utils/srt.ts
When in doubt
- Looking for a route? Grep
app.use('/api/inapps/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.mdordocs/<FEATURE>.md - Looking for the catalog of everything? INDEX.md