# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Project Overview

AIWoiz is a multi-tenant SaaS platform for embedding AI voice assistants on customer websites. It is a monorepo with three npm packages:

- **`aiw-voice-backend/`** — Fastify API + admin panel
- **`aiw-voice-app/`** — React SPA (customer signup, dashboard, onboarding wizard)
- **`aiw-voice-widget/`** — Embeddable JavaScript bundle (drops via `<script>` tag on customer sites)

## Commands

### Backend (`aiw-voice-backend/`)
```bash
npm run dev          # ts-node dev server on :3002
npm run build        # TypeScript → dist/
npm run start        # node dist/server.js
npm run lint
npm run db:generate  # Drizzle schema generation
npm run db:migrate   # Run pending migrations
npm run db:seed      # Seed dev data (dev@example.com / devpass123)
npm run db:studio    # Drizzle Studio GUI
```

### Frontend (`aiw-voice-app/`)
```bash
npm run dev          # Vite dev server on :5173 (strict port)
npm run build        # tsc typecheck + Vite build → dist/
npm run typecheck    # TypeScript --noEmit only
```

### Widget (`aiw-voice-widget/`)
```bash
npm run dev          # esbuild watch → dist/aiw-voice-widget.js
npm run build        # esbuild production (minified)
npm run serve        # esbuild + local dev server
npm run typecheck
npm run lint
```

### Full local stack
1. Copy and fill `aiw-voice-backend/.env.example` → `.env`, then `npm run db:migrate && npm run db:seed && npm run dev`
2. `cd aiw-voice-app && npm run dev`
3. Widget test page: `cd aiw-voice-widget && npm run dev`, then `npx http-server -p 8000` and open `http://localhost:8000/test/index.html`

There is no test suite — no Jest, Vitest, or similar is configured.

## Architecture

### Domain model
- **Tenants** — one per SaaS customer account (one user per tenant today). Carries `subscription_started_at` (the 30-day anniversary billing-cycle anchor) and a nullable `plan_id` FK.
- **Plans** — three seeded rows: `basic` / `intermediate` / `advanced`. `basic` is the default; the widget's `show_powered_by` flag derives from `plans.code === 'basic'`. `plans.max_minutes_month` is the nullable quota cap the pricing module will read in the next phase.
- **Users** — one per login. `is_staff` flag (default false) flips a user into "ops staff" eligible to be the **actor** in `POST /api/admin/impersonate`. Set via `npx ts-node src/scripts/seed-staff.ts <email>`.
- **Widgets** — one per use case, created via a 7-step onboarding wizard. Optional `visitor_identity_enabled` + `widget_secret` for signed-JWT visitor identity from host sites.
- **KB Documents** — uploaded files (.txt / .md / .pdf / .docx) converted to markdown and indexed into the voice agent
- **Widget FAQs** — optional structured Q&A pairs per widget
- **Triggers** — behaviour rules (`urlMatches`, `dwellSeconds`, `scrollDepth`, `exitIntent`, `idleSeconds`, `repeatVisitor`) attached to a widget; evaluated client-side by the widget runtime. Cooldown shape is `{ perSession?, perDays? }` (both optional). JS API on `window.AIWWidget`: `.open(anchor)`, `.setVisitorToken(jwt)`, `.identify(token)`. Stable since M2 — no new condition types, no new JS API additions since `44f15ac`.
- **Sessions** — runtime voice call sessions (Retell access tokens minted on demand); per-IP rate limit of 30/hour
- **Conversations** — one row per completed call. Written by the Retell `call_ended` webhook. Source-of-truth for minute usage.
- **Session Captures** — one row per Submit on the widget's inline lead-capture pill. `kind ∈ {email, phone, name}`, classified by `source ∈ {agent_asked, user_volunteered, pre_filled}`. Rolled up by `provider_call_id` into "leads" on read.
- **Usage Period Cache** — `(scope, scope_id, period_start)`-keyed cache of `minutes_used` + `call_count` for each anniversary cycle. Reads always hit cache; stale rows regenerate lazily from `conversations`. Pure cache, never a ledger.
- **Impersonation Audit Logs** — append-only record of every successful master-ops impersonation token issuance. Written BEFORE the JWT is returned; abort if the insert fails.

### Backend module structure (`src/modules/`)
Each module owns its routes, handlers, and services:
- `auth` — signup / login / email verification (JWT + argon2) + Google/LinkedIn OAuth + `verifyStaffCredentials` for impersonation
- `widgets` — 7-step wizard CRUD + submission → agent creation
- `sessions` — Retell access token minting for web calls + `POST /api/sessions/:id/captures` (inline-pill lead capture)
- `widget` — **public** `/api/widget/resolve` (no auth; used by embed script)
- `leads` — tenant-scoped `GET /api/leads` (rolls up `session_captures` by `provider_call_id` → one row per call with name/email/phone columns + conversation deep-link)
- `usage` — customer-facing `/api/usage/{current,history,daily,recompute}` + per-widget `/api/widgets/:id/usage/*` (cache-backed via `usage_period_cache`, 30-day anniversary cycle)
- `admin` — API-key-protected admin panel; now also hosts `POST /admin/impersonate` (master ops sign-in-as-customer with two-layer auth: admin key + staff email/password) and the Retell reconciliation endpoints (`/admin/widgets/:id/{retell-diff,reconcile/backfill,reconcile/update-duration}`, `/admin/tenants/:id/usage`)
- `conversion` — in-process FIFO worker converting uploaded files to markdown
- `notifications` — admin email + Slack ops alerts
- `health` — health checks

### KB pipeline
Upload → raw file saved to `STORAGE_BASE_PATH/raw/{widget_id}/` → conversion worker converts to markdown → `STORAGE_BASE_PATH/md/{widget_id}/` → ops manually triggers merge → final output pushed to Retell agent.

Conversion worker: in-process FIFO queue, concurrency 1, re-queues `pending` jobs on startup. OCR for scanned PDFs is not yet supported.

### Voice provider abstraction (`src/providers/`)
Pluggable interface with implementations for Retell (default), Vapi, and Bolna. Controlled by `DEFAULT_PROVIDER` env var. Retell is the only production-tested provider.

> **Retell conversation-flow RE-GREET TRAP (confirmed live 2026-06-19).** A node the flow **loops back into** must NOT be the node that emits the opening greeting. The common single-node layout — one "Welcome" node that both speaks `"Hi there! I see you are interested…"` AND hosts all the outgoing branches — re-speaks the greeting **every time an edge returns to it** (e.g. a `show_page` co-browse function looping back after navigation, or any "return to main" edge). The visitor hears the intro again mid-conversation. (Call context is NOT lost — Retell keeps the full transcript across node hops — but the re-emitted greeting nudges the model into "restart" behaviour.) This is the **audio** half of the widget "re-greet" glitch; the widget reconciler's `suppressRegreet` only hides the *display* of it. **Fix at the flow level:** keep the greeting OUT of any looped node — either (a) move it to the agent's global **Begin Message** and make the looped node a greeting-free hub (instruction-only: *"Assist the visitor using the KB; do not greet — the conversation is already underway."*), or (b) `Begin → Greeting node → Main Hub`, with every branch (incl. any return edge) hanging off the **Hub**, never the Greeting node. The DIY `show_page` flow TEMPLATE (co-browse plan) must ship greeting-free so all cloned agents inherit the fix.

### Widget runtime
Single `<script>` tag drops on customer sites. On load it:
1. Calls `/api/widget/resolve` (keyed by script `data-widget-id`) to fetch branding/config
2. Injects a shadow-DOM UI (all CSS inlined — no external stylesheet request)
3. Mints a Retell session via `/api/sessions` and opens the voice call
4. Exposes `window.AIWWidget.open()` as public API

### Frontend routes
> **The SPA is the APP origin only (`app.<domain>`).** The public marketing pages (`/`, `/about`, `/how-it-works`, `/pricing`, `/investors`, `/contact`) live ONLY in `aiw-voice-marketing/` (the apex). They were removed from the SPA on 2026-06-12 — see the "SPA ↔ Marketing de-duplication" milestone + the Key Convention below. Do NOT re-add marketing pages to the SPA.
```
/                                       RootGate — authed → /dashboard; unauthed → redirect to marketing apex (VITE_MARKETING_BASE_URL)
/login  /signup                         Auth (email/password + Google/LinkedIn OAuth). Use AuthLayout, NOT PublicLayout.
/forgot-password  /reset-password       Password reset (AuthLayout)
/verify-email                           Email verification
/auth/callback                          OAuth token handoff page (reads ?token=, consumes pending-checkout → /billing/checkout or /dashboard)
/auth/impersonate                       Master-ops impersonation landing (reads ?token= then → /dashboard)
/dashboard                              Authed dashboard — stats overview + UsageThisPeriodCard
/widgets                                Authed widgets list (formerly at /dashboard)
/leads                                  Authed leads — captured name/email/phone rolled up by call
/usage                                  Authed usage view — minutes this cycle + history + daily chart
/billing  /billing/plans  /billing/change-plan  /billing/checkout  /billing/payment-methods  /billing/invoices  /billing/topups   Authed pricing/payment flow (F1-F11). /billing/plans is the authed plan comparison — distinct from the public marketing /pricing
/account/plans                          Legacy → <Navigate> to /billing/plans
/widgets/new/step-{1..7}                7-step onboarding wizard
/widgets/{widgetId}                     Widget detail: FAQs + KB + triggers + VisitorIdentityCard + WidgetUsagePanel
/widgets/{widgetId}/conversations       Per-widget call list (transcript + recording + post-call analysis)
*                                       Catch-all → /  (i.e. → RootGate; removed marketing paths fall here → apex for unauthed)
```
Auth state lives in `AuthProvider.tsx`; server state via TanStack React Query. API calls go through `src/api/client.ts` (thin fetch wrapper with JWT header injection). The `ImpersonationBanner` is mounted above `<Routes>` and only renders when `/api/auth/me` echoes an `impersonatedBy` claim. The auth pages use `components/AuthLayout.tsx` (slim chrome → apex links), NOT the marketing `PublicLayout`, so the auth surface does not pull the marketing `landing/` JS into the app bundle.

> **Note (stale below):** the "Public-page composition pattern" and several "Public Marketing Pages" milestones describe `src/routes/{about,how-it-works,landing}/` in the SPA. Those component trees still exist on disk but are **unrouted/dead** (tree-shaken out of the bundle) pending deletion, and the canonical implementations now live in `aiw-voice-marketing/`. Treat the SPA copies as historical.

### Public-page composition pattern
Each non-homepage public route is a thin composer that imports modular section components and a single per-page CSS file (`src/routes/{page}/styles/{page}.css`). The reference implementations are:
- `src/routes/about/` — `AboutHero` / `AboutClimax` / `AboutOrb` / `AboutPrinciple` / `AboutTimeline` / `YellowUnderline` / `SocialIcons` + `about.content.ts`
- `src/routes/how-it-works/` — `HiwHero` / `HiwSteps` / `HiwClimax` / `HiwUnderTheHood` / `HiwManifesto` / `HiwKnowledge` / `HiwClosing` / `HiwFooterAdjacent` + `hiw.content.ts` + `illustrations/ClimaxIllustration.tsx`

Both pages reuse the homepage's `WidgetFeaturesFull` (from `src/routes/landing/Sections.tsx`) for the 9-card feature grid — the wfeat-prefixed CSS is scoped to land cleanly inside `.hiw-page` / `.about-page` token blocks. Wrap each page in `<MotionConfig reducedMotion="user">` so Framer Motion honours `prefers-reduced-motion` in one line.

## Design System

All three surfaces share the **AIWoiz Design System**:
- CSS token variables in `aiw-voice-app/src/design-system/colors_and_type.css` — monochrome chrome with warm cream neutrals
- Fonts: Inter (body), Geist Mono (code), Libre Baskerville (rich content)
- Icons: `lucide-react`
- Widget: tokens are inlined into `WIDGET_CSS` inside `src/widget.ts` — never import external CSS into the widget
- Admin panel: design system assets served via `/admin/design-system/*`
- Integration playbook with lessons learned: `docs/DESIGN_SYSTEM_INTEGRATION.md`
- **Public-page design north-star: `aiw-voice-app/docs/page-design-principles.md`.** Codifies the section rhythm, typography scale, colour discipline, motion language, and visual-climax rule established by `/about` (the reference implementation at `aiw-voice-app/src/routes/about/`). Applies to every non-homepage public page — Pricing, Contact, Investors, Blog, How it works, etc. The homepage (`/`) is intentionally out of scope and follows its own landing-page conventions under `aiw-voice-app/src/routes/landing/`. Read this doc before building any new public route.

## Key Conventions

- All three packages use strict TypeScript. Run `npm run typecheck` before assuming a build is clean.
- **Widget package isolation is build-enforced (since 2026-07-04).** `aiw-voice-widget` (DIY/SaaS, `window.AIWWidget`) and `aiw-cafe-widget` (cafe vertical, `window.AsqCafeWidget`) are separate by design — they share the backend, never code; shared logic is deliberately DUPLICATED per package so cafe UX changes can never leak into the SaaS widget. Each package's `scripts/check-isolation.js` fails the build if `src/` imports the sibling package by name OR any relative import escapes the package root; it's chained into `build`/`dev`/`serve` (the commands that produce the committed bundles), plus a standalone `npm run check:isolation`. If it fires, duplicate the code into the package — do not import across.
- **SPA vs Marketing — strict origin split (no duplication).** The Next.js marketing site (`aiw-voice-marketing/`) owns the **apex** (`aiandhumn.com` / `aiworkfllow.com`) and ALL public/marketing/blog pages. The Vite SPA (`aiw-voice-app/`) owns **`app.<domain>`** and ONLY the application: auth, dashboard, widgets, leads, usage, the wizard, and the `/billing/*` pricing+payment flow. **Never add a public marketing page (homepage, about, how-it-works, pricing display, investors, contact, blog, security, legal) to the SPA** — doing so duplicates the marketing site and splits SEO authority across two domains (Google can't tell which is canonical). This split was enforced 2026-06-12 (the SPA previously shadowed all of these). Consequences baked into the code: (a) SPA `/` is `RootGate` (authed → `/dashboard`, unauthed → `window.location.replace(VITE_MARKETING_BASE_URL)`), not a landing; (b) auth pages use `components/AuthLayout.tsx` (apex-linked chrome), not the marketing `PublicLayout`; (c) `app.aiandhumn.com` is `X-Robots-Tag: noindex` on every path **except** exact-match `/login` + `/signup` (nginx user-include), so the apex owns search; (d) cross-domain plan handoff (marketing `/pricing` CTA → SPA) rides `lib/pending-checkout.ts` (path-only, `/billing/checkout` allowlist, 30-min TTL) — captured on BOTH `/signup` and `/login`, consumed after auth / OAuth callback, and already-authed visitors to `/login`+`/signup` are forwarded straight to checkout-or-dashboard. The marketing apex URL per env is `VITE_MARKETING_BASE_URL` (`aiw-voice-app/.env.*`). **⚠️ (2026-07-08) The marketing package was split for the soft launch:** `aiw-voice-marketing/` was **renamed to `beta-v1/`** (the parked full product site) and **forked into `site-v0/`** (the live waitlist site). Everything in this convention applies to whichever package is live; which one the `marketing` container builds is chosen by `MARKETING_PKG` in `/opt/aiw/{dev,prod}/.env` (default `site-v0`). Read the "Soft-launch waitlist site" milestone. Historical references to `aiw-voice-marketing/` elsewhere in this file mean today's `beta-v1/`.
- Zod is used for validation at all system boundaries (API request bodies, env config via `src/config/env.ts`).
- Database access only through Drizzle ORM (`src/lib/db/schema/`). Schema lives in individual files per entity.
- Rate limiting applied on submit and doc-upload endpoints (10 req/min).
- CORS allowlist covers `:5173` (app), `:3001`, and `:8000` (widget test) by default.
- Old modules (`analytics`, `assistants`, `tenants`, `transcripts`, `webhooks`) exist in history but are excluded from the current build pending schema rewrite — do not re-enable them without updating the schema first.
- OAuth secrets (GOOGLE_CLIENT_SECRET, LINKEDIN_CLIENT_SECRET) live **only** in `aiw-voice-backend/.env` — never in the frontend. The backend-redirect OAuth flow keeps all secrets server-side.
- **Migration `when` timestamps in `_journal.json` must be strictly monotonic.** Drizzle's built-in `migrate()` (`drizzle-orm/pg-core/dialect.js:62`) decides whether to apply a journal entry by checking `entry.when > max(__drizzle_migrations.created_at)`. If two branches each add a migration and one merges later with a smaller `when`, drizzle **silently skips it forever** — the entry never makes it into the migrations table and its SQL never runs. We hit this exact trap when `0007_session_captures_name` was committed after `0008` + `0009` had already been applied to dev. Our `src/lib/db/migrate.ts` has been replaced with a self-healing migrator that (a) validates monotonicity and throws on a non-monotonic journal, (b) decides "applied?" by hash lookup not `created_at`, and (c) recovers out-of-order gaps automatically (logged as `[orphan-fix]`). If `npm run db:migrate` ever throws a non-monotonic error, fix it by editing `_journal.json` to bump the offending entry's `when` so it's > the previous entry's. When generating new migrations via `npm run db:generate`, the new entry's `when` will be `Date.now()` and therefore correct by construction — manual journal edits are where this convention matters.
  - **Branch-merge etiquette when two features each add a migration.** If branch A adds `0015_foo.sql` and branch B (built in parallel) also adds `0015_bar.sql`, the SECOND merge resolves the journal conflict by (a) renaming the SQL file + the journal `tag` to `0016_bar.sql`, AND (b) bumping the `when` to be strictly greater than `0015_foo`'s `when`. Picking either entry without renumbering will produce a `_journal.json` with two `idx: 15` entries (drizzle's `db:generate` rejects this on next run) OR a non-monotonic journal (the self-healing migrator rejects this at boot). When in doubt: re-run `npm run db:generate` after the merge — drizzle-kit detects the conflict and resequences cleanly using `Date.now()`.
  - **Always verify with `npm run db:status` after migrating.** New script (added 2026-05-30): a read-only inspector that compares `_journal.json` against `drizzle.__drizzle_migrations` and prints applied/pending/drift. Exits 0 if clean, 1 if anything's missing. Useful three ways: (1) before committing a new migration, run it against your local dev DB to catch "I added SQL but forgot the journal entry" mistakes, (2) before a deploy, run it inside the api container to see exactly what's about to be applied, (3) after a deploy, re-run it as the verification step that proves the migrator didn't silently skip anything. The migrator itself also logs `Migrator: journal has N entries; db has M applied; P pending: [...]` upfront BEFORE any DB write — so "no `[apply]` lines" is no longer indistinguishable from "deploy didn't even start".
- **The `CEIL(duration_sec / 60)` minute-rounding rule lives in two places: `src/lib/usage/recompute.ts` (cache writer) AND `src/modules/dashboard/dashboard-stats.service.ts:32-39` (legacy stats endpoint).** Both implement Retell's per-call rounding convention — partial minutes round UP. If you ever change one (e.g. switching to per-second billing or finer rounding), change BOTH or the customer dashboard will silently diverge from the cached aggregates. Grep `CEIL.*duration_sec` to find every callsite before editing.
- **Never reflect raw server error messages to the UI.** The backend's per-route catch blocks must distinguish between *curated* application errors (anything thrown with an explicit `statusCode` — `Invalid email or password`, `EMAIL_NOT_VERIFIED`, etc.) and *unexpected* runtime errors (DB unreachable, JSON parse blowups, anything Drizzle or Postgres throws). Curated errors pass `{ error: code, message }` through verbatim; unexpected errors get `req.log.error({ err }, ...)` server-side and return a generic `{ error: 'internal_error', message: 'Something on our end is misbehaving...' }` 500. Anything else leaks Drizzle's `Failed query: select "user_id"... params: ...` text — including column names, parameter values, and the full SQL — straight to the SPA's red `ErrorBanner`. The pattern is encoded in `auth.routes.ts` (`sendAuthError` helper), `leads.routes.ts` (`sendErr`), `usage.routes.ts`, etc.; new route modules MUST follow it. Belt-and-braces: `app.setErrorHandler` in `app.ts` catches anything that bypasses a route's own try/catch (body-parse failures, plugin errors). On the SPA side, `api/client.ts` runs every error `message` through `sanitizeMessage()` (collapses 5xx + SQL/stack/`pg_*`/`drizzle`/`ECONNREFUSED` markers to a generic fallback before constructing `ApiError`) and `ErrorBanner` re-applies the same check at render time. The original server text is parked on `err.rawServerMessage` and logged via `console.error` so devs can still debug in DevTools — it just cannot render. Fixed 2026-05-25; previously the DB-down login path showed the full SELECT query + email to the user.
- **Scroll-reveal class name must be `is-visible`, NOT `revealed`.** `landing.css` defines `.reveal.is-visible` and `.reveal-stagger.is-visible > *` as the *visible* states (pre-reveal defaults to `opacity: 0`). Any `IntersectionObserver` that toggles these classes MUST call `classList.add('is-visible')` — adding any other class name (e.g. `'revealed'`) leaves the element permanently invisible with no console error. There are two observers in the codebase: `HomePage.tsx` (homepage only) and `PublicLayout.tsx` (every non-homepage public route: `/how-it-works`, `/about`, `/pricing`, `/blog`, `/investors`, `/login`, `/signup`). Both must use `'is-visible'`. PublicLayout shipped with the wrong class name (`'revealed'`) which silently hid every `.reveal-*` element on those routes — fixed 2026-05-18. When adding a new public route or new `IntersectionObserver`, verify the class name matches the CSS by grepping `landing.css` for the reveal selectors before trusting that elements will fade in.
- **Do NOT export plain data from `'use client'` modules and import it into a Server Component.** Next.js's RSC compiler wraps every export from a `'use client'` module as a client-reference Proxy when consumed by a Server Component — calling `.map()` / `.filter()` / property access on that Proxy throws at render time with `"Attempted to call <fn>() from the server but <fn> is on the client."` (error Digest visible in the stack trace; `.map()` produced `330002672`). This caught us on commit `1cbda0f` (Section 2 / S-04 of the SEO audit) when `HOMEPAGE_FAQ_ITEMS` was exported from `components/home/SectionsB.tsx` (a `'use client'` FAQ accordion using `useState`) and imported by `app/page.tsx` (a Server Component) to serialize FAQPage JSON-LD. DEV + PROD both 500'd on first deploy of Sections 1-4 (2026-06-09); hotfix `3c7ff0a` moved the array to `lib/faqs.ts` (server-safe, alongside `buildFaqLd`). The rule: **shared data consumed across the RSC boundary lives in a server-safe module — never in a `'use client'` file.** Both the client component and the Server Component import the data from a `lib/*.ts` file as plain data. Side-test for "is this module server-safe?": grep the file for `'use client'`, `useState`, `useEffect`, `useRouter` etc. If any are present, the module is client-only and exporting data from it for Server Component consumption will throw at render. Pricing was unaffected only because `BILLING_FAQ` lives inside `app/pricing/page.tsx` itself (no boundary crossed) — but that's a happy accident, not a design; if someone later extracts the pricing FAQ into a separate `'use client'` file, the same bug returns.

## Deployment

### Target architecture
Two parallel environments on the same cPanel/WHM server (one VPS, IP `65.254.80.37`):

| Env | Frontend URL | API | Widget | cPanel user |
|---|---|---|---|---|
| **Dev** | `app.aiworkfllow.com` | `api.aiworkfllow.com` | `widget.aiworkfllow.com` | `aiworkfllow` |
| **Prod** | `aiandhumn.com` (**apex**, not `app.`) | `api.aiandhumn.com` | `widget.aiandhumn.com` | `aiandhumn` |

> **PROD apex SPA**: the prod SPA is served from the apex domain (`aiandhumn.com`), NOT a subdomain. `www.aiandhumn.com` is 301-redirected to apex by the nginx user-include at `/etc/nginx/conf.d/users/aiandhumn/aiandhumn.com/proxy.conf` (uses `if ($host = "www...") return 301`). Dev still uses the `app.` subdomain pattern.

### Server context
- **cPanel/WHM** with EasyApache nginx (EA-nginx) on the host — NOT a vanilla VPS
- nginx owns ports 80/443 directly (system service, not Docker)
- Hosts ~25 client sites (medical, dental, legal, pharma) — every change must be additive and scoped to the `aiworkfllow` cPanel user
- Other Docker containers on the host: `ai-voice-qdrant-1` + `hospital-pg-proxy` (in use — leave alone). **`n8n` (+ its `n8n-postgres`) was retired 2026-06-13** (`docker update --restart=no` + `docker stop`; data volumes preserved, reversible) — it was chronically crash-looping + unused.
- **The `aiw-dev-*` stack idles on-demand (since 2026-06-13).** It's `docker compose stop`'d by default to remove dev↔prod build contention (the root trigger of the shim-crash incident). `unless-stopped` + a manual stop means it stays down across reboots, so **run `cd /opt/aiw/dev && docker compose start` before any dev deploy/test** — the dev URLs (`*.aiworkfllow.com`) return 502 while idle (intended).
- Existing `aiw-voice-backend-api-1` container (10 days old, no `users` table, pre-OAuth code) is **safe to retire** — no real data
- WHM root access available

### cPanel proxy pattern
- Subdomains created via cPanel UI (auto-provisions vhost + AutoSSL Let's Encrypt cert)
- Custom proxy includes go in `/etc/nginx/conf.d/users/<cpanel_user>/<subdomain>.conf` (cPanel preserves these as user-includes via `users.conf` → `include conf.d/users/*.conf`)
- Working template on this server: `n8n.aiworkfllow.com` already proxies to a Docker container — mirror its config exactly
- Hand-edits to `/etc/nginx/conf.d/*.conf` (top-level) get overwritten by cPanel rebuilds — only `users/*` paths are safe
- **cPanel "Force HTTPS Redirect" toggle is a no-op for proxied vhosts.** The toggle inserts an Apache/docroot-layer rewrite. When a vhost `proxy_pass`es straight to a Docker container, the request never reaches Apache — the rewrite fires on nothing. The HTTPS redirect must live in the nginx user-include. Two patterns, used based on the shape of the vhost:
  - **Pattern A (inside-location `if`)** — for vhosts with a single regex catch-all that covers every path including bare `/`. The redirect lives INSIDE the `location ~ ^/(?!\.well-known/)` block. The regex's negative lookahead excludes `/.well-known/acme-challenge/`, so AutoSSL renewals fall through to cPanel's managed handler. Used by: apex `aiandhumn.com` + `aiworkfllow.com`, `app.aiandhumn.com` + `app.aiworkfllow.com`. Reference: `deploy/cpanel-nginx/aiandhumn/aiandhumn.com/proxy.conf`.
  - **Pattern B (server-scope `set`/`if` stack)** — for vhosts with multiple explicit per-path `location` blocks where bare `/` falls through to cPanel's default. Inside-location ifs would miss the bare-root case. Use a `set $force_https 0;` + three `if`s + final `if ($force_https = 1) return 301` stack at server scope (BEFORE any `location` block). Skip `/.well-known/` to preserve AutoSSL. Optionally skip bare `/` if a `location = /` block handles it specifically (lets api hosts redirect HTTP `/` → HTTPS `/admin/` in ONE hop instead of two). The `set` + `return` pattern is in nginx's "OK uses of if" allowlist — the "if is evil" warning applies to `rewrite`-based logic, not this. Used by: `api.{aiandhumn,aiworkfllow}.com`, `widget.{aiandhumn,aiworkfllow}.com`. Reference: `deploy/cpanel-nginx/aiandhumn/api.aiandhumn.com/proxy.conf`.
  - **Why a NAIVE server-scope `if ($scheme = http) { return 301 ... }` is wrong**: it fires in nginx's rewrite phase BEFORE location matching, so it 301s the ACME path too — breaking AutoSSL cert renewals. The `set`/`if` stack fixes this by explicitly resetting `$force_https = 0` for `/.well-known/` paths before the final `return`.
- **`/robots.txt` is shadowed by a cPanel default — override at the docroot, NOT in nginx.** EA-nginx auto-generates `location = /robots.txt { proxy_pass $CPANEL_APACHE_PROXY_PASS; }` at server scope for every cPanel vhost. It's an `=` exact-match block sitting in the same `server{}` our user-includes are pulled into. nginx location priority is `= > ^~ > ~ > prefix`, so this default beats every other modifier — AND a second `=` block at the same path in the same server is a parse-time `duplicate location` error (we tried; nginx-t failed). cPanel's default proxies the request to Apache, which serves it from the docroot `/home/<cpanel_user>/public_html/`. **Fix:** drop a static `robots.txt` in that docroot. Survives cPanel rebuilds (it's a file, not config). Reference copy lives at `deploy/cpanel-docroots/aiandhumn/robots.txt`; the dynamic `aiw-voice-marketing/app/robots.ts` Next route is kept as a fallback for any future non-cPanel hosting but does NOT serve on cPanel-hosted prod. Same gotcha applies to `/favicon.ico` and any other path cPanel templates an `=` block for — diagnose by running `sudo nginx -T 2>/dev/null | grep -B5 -A15 'location = /<path>'`.

### Stack topology (per env)
Each env is a self-contained docker-compose stack in `/opt/aiw/dev/` or `/opt/aiw/prod/`:

| Service | Image | Host port (dev / prod) | Notes |
|---|---|---|---|
| `postgres` | `postgres:16-alpine` | none (internal) | Separate volume per env |
| `migrate` | built from `aiw-voice-backend/` | none (one-shot, blocks `api`) | Runs `node dist/lib/db/migrate.js` |
| `api` | same image as migrate | `127.0.0.1:3010 / :3020` | Storage volume mounted at `/app/storage` |
| `app` | built from `aiw-voice-app/` | `127.0.0.1:5173 / :5183` | nginx static, BrowserRouter `try_files` fallback |
| `widget` | built from `aiw-voice-widget/` | `127.0.0.1:5174 / :5184` | nginx static, CORS-permissive |
| `cafe` | built from `boojee-cafe-app/` | `127.0.0.1:5176 / :5186` | nginx static (SPA fallback); also serves the vendored `aiw-cafe-widget.js` at `/aiw-cafe-widget.js`. `cafe.{aiworkfllow,aiandhumn}.com`. noindex. |
| `marketing` | built from **`${MARKETING_PKG}/`** (`site-v0` live / `beta-v1` parked) | `127.0.0.1:5175 / :5185` | Next.js standalone Node server. Apex marketing site (`aiworkfllow.com` dev / `asqvox.com` prod). `MARKETING_PKG` in `.env` picks the package (default `site-v0`); the `MARKETING_MODE` build arg picks `.env.{staging,production}` inside it. |

Networks: `aiw-dev-net` and `aiw-prod-net` are disjoint — dev API cannot reach prod DB.

> **`MARKETING_PKG` (compose-level, in `/opt/aiw/{dev,prod}/.env` — NOT a backend runtime var):** selects which marketing package the `marketing` service builds. Default `site-v0` (the live waitlist site). Flip to `beta-v1` (the parked full product site) + `docker compose build marketing && up -d marketing` to restore the full site when billing goes live. See the "Soft-launch waitlist site" milestone.

### Key per-env env vars (backend)
The same code, parameterised at runtime:

```env
APP_PUBLIC_BASE_URL=https://api.<domain>
APP_FRONTEND_URL=https://app.<domain>
WIDGET_ALLOWED_ORIGINS=https://app.<domain>
DATABASE_URL=postgresql://aiw_voice:<pw>@postgres:5432/aiw_voice_<env>
JWT_SECRET=<openssl rand -hex 48 — DIFFERENT per env>
GOOGLE_CLIENT_ID/SECRET, LINKEDIN_CLIENT_ID/SECRET   # SEPARATE OAuth apps per env
SMTP_HOST/PORT/USER/PASS/FROM                        # required for email verification
```

### Frontend env files (in repo)
- `aiw-voice-app/.env.development` → `VITE_API_BASE_URL=http://localhost:3002` (local dev)
- `aiw-voice-app/.env.staging` → `VITE_API_BASE_URL=https://api.aiworkfllow.com` (deployed dev)
- `aiw-voice-app/.env.production` → `VITE_API_BASE_URL=https://api.aiandhumn.com` (deployed prod)

Build commands: `npm run build` (default = production mode), `npm run build -- --mode staging` (dev domain build).

### Widget hosting model
A single bundle (`aiw-voice-widget.js`) serves both envs because `data-backend-url` is set on the customer's `<script>` tag, not baked in. Same JS, two URLs:
- Dev: `https://widget.aiworkfllow.com/aiw-voice-widget.js`
- Prod: `https://widget.aiandhumn.com/aiw-voice-widget.js`

### OAuth callbacks per env
Register **separate** Google + LinkedIn OAuth apps for each env:
- Dev: `https://api.aiworkfllow.com/api/auth/{google,linkedin}/callback`
- Prod: `https://api.aiandhumn.com/api/auth/{google,linkedin}/callback`

LinkedIn requires "Sign In with LinkedIn using OpenID Connect" product approval per app.

### Deploy flow (manual SSH for now)
```bash
ssh deploy@vps
cd /opt/aiw/<env>
git pull
docker compose build
docker compose up -d           # migrate runs first, then api/app/widget
docker compose logs -f api     # verify
```

**Migration-bearing deploys: rebuild `migrate` explicitly.** `docker compose build` (no service args) rebuilds every service including `migrate`. Cherry-picking only `api app` leaves the `migrate` container on a stale image without the new SQL files, and migrations silently no-op on the next `up -d` — no error surfaced, the API just boots against an older schema. Either build all (`docker compose build`) or include `migrate` by name (`docker compose build migrate api app`). To force a re-run after a stale build, `docker compose up -d --force-recreate migrate` re-creates the container against the freshly-built image. Hit this with `0010_password_reset` on dev (2026-05-24); root-caused after a `password_reset_token` column check came back empty even though 0010 was in git.

**Drizzle migrations table lives at `drizzle.__drizzle_migrations` (not `public`).** When triaging a missed migration, query with the schema prefix: `SELECT hash, created_at FROM drizzle.__drizzle_migrations ORDER BY created_at DESC LIMIT 10;`. Querying without the prefix returns `relation "__drizzle_migrations" does not exist` even on a healthy DB, which can mislead a tired ops human into thinking migrations have never run.

GitHub Actions push-to-deploy is a planned follow-up (deploy on push to `develop` → dev, `main` → prod).

### Dev stack — start / stop (idles on-demand, since 2026-06-13)

The `aiw-dev-*` stack is **stopped by default** to keep the oversubscribed host free of dev↔prod build contention (see the "Host headroom remediation" milestone — parallel dev+prod builds on a near-full disk corrupted the containerd shims). It stays down across reboots (`unless-stopped` + a manual stop), and **while idle the dev URLs return `502`** (`app.aiworkfllow.com`, `api.aiworkfllow.com`, `widget.aiworkfllow.com`, and the `aiworkfllow.com` apex) — that's expected, not a fault.

**Bring dev back for testing (no code change):**
```bash
cd /opt/aiw/dev
docker compose start                              # fast: re-runs the stopped containers, no rebuild/recreate
docker compose ps                                 # confirm all dev services are Up
curl -si http://127.0.0.1:3010/health | head -1   # dev api → 200
```

**Dev deploy (new code) — build ONE service at a time, never in parallel with prod, prune-first:**
```bash
cd /opt/aiw/src && git pull origin main
cd /opt/aiw/dev
df -h /                                       # confirm headroom BEFORE building (keep < ~85%)
docker compose build <svc> && df -h /         # <svc> = app | marketing | widget | (migration-bearing: "migrate api")
docker compose up -d <svc>
docker image prune -f                         # drop the now-dangling old image
```

**Idle dev again when done:**
```bash
cd /opt/aiw/dev && docker compose stop
```

`docker compose start` just re-runs already-created, stopped containers (instant — use it to bring dev back for a quick test); `docker compose up -d` creates/recreates containers to match the compose file + current images (use after a `git pull`/`build`). Neither rebuilds an image without an explicit `docker compose build`. **Prod is unaffected by any of this** — it's a separate stack/network and stays up.

### Reference
Full deployment plan at `.claude/plans/snappy-leaping-willow.md` (12 sections including cPanel-specific vhost templates).

---

## Completed Milestones

### Soft-launch waitlist site — park `beta-v1`, ship `site-v0` (+ "global accents" homepage section) ✅ _(2026-07-08, on `main`)_

Company registration + bank account aren't done → the payment gateway can't go live, so the full signup→checkout funnel is a dead end. Decision: **replace the live marketing frontend with a lean waitlist site (`site-v0`) that captures emails + demos the product**, while **preserving the full product site (`beta-v1`) byte-restorable** with a one-line flip the day billing is ready. Plan: `.claude/plans/snappy-leaping-willow.md`. Two commits: `8bee68a` (waitlist, merged `14e6e55`) + the accents section `d7f2e92` (merged `bc04352`).

**The swap mechanism (the core of this milestone):**
- `git mv aiw-voice-marketing → beta-v1` (history preserved) = the **parked full product site** (all pages incl. pricing/checkout, functionally untouched). Restore target.
- **`site-v0/`** = a **fork** of beta-v1 (inherits the design system + live voice demo), then stripped/repointed for the waitlist.
- Both `deploy/{dev,prod}/docker-compose.yml` `marketing` services now build `context: ${AIW_SRC:-../..}/${MARKETING_PKG:-site-v0}` — **which package is live is set entirely by `MARKETING_PKG` in `/opt/aiw/{dev,prod}/.env`** (default `site-v0`), NOT by git. Go-live = `MARKETING_PKG=site-v0`; **restore beta-v1 = flip to `beta-v1` + rebuild `marketing`** (marketing-only, NO git merge — the two sites are sibling dirs that never touch the same files; beta-v1 just freezes at the fork point). `.gitignore` allowlists `beta-v1/` + `site-v0/` (+ their `.env.{staging,production}`); `.claude/launch.json` has `marketing-preview` (site-v0, :3101) + `beta-v1-preview` (:3102).

**site-v0 (fork edits):** **pricing REMOVED** (page + homepage section + `/api/plans` fetch — no gateway, so concrete prices you can't buy only confuse; a numberless "founding-member pricing at launch — join the list" teaser replaces it); every signup/login CTA → the waitlist modal / `/waitlist`; `/about` + `/how-it-works` kept (closing CTAs repointed). **M1 live "talk-now" orb** in the hero reuses `MarketingWidget` + `window.AIWWidget.open()` (a real Retell call — the demo IS the product). **M2 referral line-jump**: success state shows "You're #N — share to jump 10 spots per friend" + copy link + live position (display-only vanity math via `referral_code`/`referred_by`/`referral_count`, no DB reorder). **Analytics**: GTM (`GTM-N6JL334L`, **prod-only** — gated in `app/layout.tsx` on `NEXT_PUBLIC_ENABLE_GTM==='true'`, set `true` in `.env.production` / `false` in `.env.staging` in BOTH site-v0 + beta-v1) already present; every CTA carries a stable class (`waitlist-btn`/`demo-orb-btn`/`referral-share-btn`) + `data-analytics-id`; `lib/analytics.ts` `track()` → `dataLayer.push`; heatmaps via **Microsoft Clarity as a GTM tag** (no code — user provisions the Clarity id in the GTM UI). **⚠️ `site-v0/.env.production` still has `NEXT_PUBLIC_MARKETING_WIDGET_TOKEN=__REPLACE_AFTER_FIRST_DEPLOY__`** → the prod hero orb falls back to the DemoModal (→ /waitlist) until a real `wt_` token is pasted + marketing rebuilt (dev's `.env.staging` has a valid token).

**Backend waitlist funnel** (mirrors the enterprise-lead pattern; dedicated table): **migration `0032_waitlist.sql`** (journal head — idx 32, `when 1781500000000`) creates `waitlist_signups` (email UNIQUE, name, source, interest, `referral_code` UNIQUE, `referred_by`, `referral_count`, user_agent, request_ip, status, timestamps) + extends the `email_templates` event_key CHECK + seeds the `waitlist_confirmed` template. `src/modules/waitlist/` — public `POST /api/waitlist` (rate-limit 6/min, Zod, idempotent `ON CONFLICT (email) DO NOTHING`, referrer +1, fire-and-forget ops alert + confirmation email) + `GET /api/waitlist/position` (30/min) + admin `GET /api/admin/waitlist` (X-Admin-Key) → new admin-panel "Waitlist" page. Registered at `/api` (app.ts). site-v0 same-origin proxies `app/api/waitlist/{route,position/route}.ts` forward server-side to the backend (prefer `INTERNAL_API_BASE_URL`, forward `x-forwarded-for`). Optional env: `WAITLIST_ALERT_EMAIL` (default `updates@aiandhumn.com`), `WAITLIST_REFERRAL_BASE_URL` (default `https://asqvox.com`).

**Homepage "English, with global accents" section** (`d7f2e92`, added to BOTH `site-v0` + `beta-v1`): a new `AccentsAndVoices` component directly under the hero/stats marquee (`HomeClient`: `Hero → StatsMarquee → AccentsAndVoices → VoiceThesis`) — an auto-scrolling flag marquee of **20 English-speaking markets, India-first**, scrolling →, and a reverse marquee of voice personas (Sales, Customer support, Calm, Energetic, Deep & authoritative, Personal assistant, Friendly & warm, Professional, Concierge) scrolling ←. **Flags are SVG (`country-flag-icons@^1.6.20`, added to both marketing `package.json` + lockfile, tree-shaken to the 20 used) — NOT emoji** (emoji regional-indicator flags render as bare letters "IN"/"US" on Windows/Chrome, which would break the showcase). Reuses the existing `@keyframes marqueeScroll` (the voices track is the same keyframe with `reverse`); both marquees are `aria-hidden` with a visually-hidden `.accents__sr` a11y/SEO summary paragraph; `prefers-reduced-motion` disables the scroll; responsive flag sizing at 880px. Verified: `tsc` clean both packages, `next build` prerenders `/`, live DOM check (20 flags India-first, reverse voices marquee, reduced-motion rule).

**Deploy** = **marketing-only** rebuild per env with `MARKETING_PKG=site-v0` (`docker compose build marketing && up -d marketing`), **PLUS a one-time `migrate api` rebuild for migration 0032** (`docker compose build migrate api && up -d migrate api`; rebuild `migrate` EXPLICITLY). **⚠️ Gotcha (hit live 2026-07-08):** the waitlist form 500s with the generic *"Something on our end is misbehaving"* if `marketing` was rebuilt but `migrate` was NOT — `waitlist_signups` is missing and the INSERT throws (caught → generic 500; the real error is logged as `waitlist signup failed`). Fix = rebuild `migrate` explicitly + run, verify `db:status` shows 32/32. **Second live bug (2026-07-08, fixed — `api` rebuild):** even with 0032 applied, the form still 500'd — `computePosition` (`waitlist.service.ts`) embedded a raw JS `Date` (`signup.createdAt`) in a `sql\`\`` template, and postgres.js can't serialize a bare `Date` param (`ERR_INVALID_ARG_TYPE … Received an instance of Date`). Fixed by using drizzle's typed `lte(col, date)` (encodes Date → ISO string via the column's `mapToDriverValue`). **⚠️ The same raw-`Date`-in-`sql` anti-pattern still lurks (cold paths, UNFIXED) in `leads.service.ts` (`${new Date(p.from|to|cursor)}` — the leads date-filter), `email.service.ts:525` (dev-only offline branch), and `retell-webhook.service.ts:486-487` (try/caught quota path) — convert each to `${d.toISOString()}::timestamptz`, the safe pattern the rest of the codebase (gate.ts, conversations.ts, `retell-webhook.ts:512-513`) already uses.** No SPA/widget/cafe/nginx change. beta-v1's accents section ships automatically whenever `MARKETING_PKG` is later flipped to `beta-v1` for the payment-gateway restore.

**Perf P0/P1 groundwork** (`b2d76b0`, branch `feature/perf-p0-p1-groundwork`, **NOT merged to main**): nightly-backup script + baselines doc + DB pool config (`postgres(url,{max:15,…})`) + challenge TTL 90→300s + timing-safe admin-key compares (the 4 remaining `!==` admin routes) + widget replay test wired to `npm test` + a `pg_stat_statements` compose line. Pending user merge — the P0/P1 slice of the Architecture/Performance plan in `.claude/plans/snappy-leaping-willow.md`.

### Cafe vertical — voice-ordering relay + standalone cafe widget + Boojee dine-in app ✅ _(2026-06-29, committed `747fb54` + deploy infra)_

A NEW vertical to woo cafe-industry clients: a demo dine-in ordering web app (**Boojee Cafe**) driven by a **separate** cafe voice widget. Hard constraint from the user: **the cafe widget code must NOT be mixed with the AsqVox (aiw-voice) widget.** It isn't — the cafe widget is its own bundle and the backend relay is a parallel thin module; the AsqVox widget / cobrowse / contact-sync paths are untouched. Reuses the shared backend (`/api/widget/resolve` + `/api/sessions`) and only the cobrowse **sub-token HMAC** (`lib/cobrowse/sub-token.ts`) — nothing else is shared.

**Three packages (all gitignored-then-allowlisted at repo root; `tsc` clean):**
- **`aiw-cafe-widget/`** — the standalone cafe Orb bundle (521.5 KB, committed). Exposes `window.AsqCafeWidget` (NOT `window.AIWWidget`). Executes agent commands against the host page's `window.CafeApp`. Files: `commerce/{types,sse,bridge}.ts` (SSE = fetch+ReadableStream, header `X-AIW-Cafe-Token`, event `cafe`), `providers/retell/*`, `api.ts`, `widget.ts`, `index.ts`.
- **`boojee-cafe-app/`** — mobile-first, table-based dine-in ordering SPA (QR → `?outlet=&table=`, no login). ~140-item menu (`data/menu.ts`), 6 outlets, veg toggle (default OFF; ON hides egg+nonveg), modifier sheet, cart + running tab + **simulated** place-order, mood-based "Suggested for you" strip, brand design system (cream/oxblood, Archivo+Hanken). Implements `window.CafeApp` (the contract the Orb drives) in `lib/cart.tsx`. `DevOrbSim` fires real `window.CafeApp` commands for a no-backend demo. **Explicitly NOT: delivery/takeaway, payment/checkout, login, reservations.**
- **`aiw-cafe-demo/`** — tiny static harness ("The Daily Grind") documenting the `window.CafeApp` contract.

**Backend relay (parallel to cobrowse, prod untouched):**
- **`POST /api/cafe/command`** (OUTSIDE `/api/widget/`, server-to-server from Retell) — per-widget `X-AIW-Cafe-Key` static-header auth (`timingSafeEqual`, fail-closed); the function **name IS the action** (`add_to_cart`/`remove_from_cart`/`set_quantity`/`clear_cart`/`place_order`/`open_cart`/`show_menu`/`suggest_items`); `conversations`-row trust anchor (NEVER `metadata.widget_id`); **DUMB relay** — the menu lives in the host app, so item matching is entirely client-side. Returns `{delivered, action}` the instant the SSE write returns.
- **`GET /api/widget/cafe/stream` + `/ack`** (under `/api/widget/` → CORS reflects) — per-call SSE (`event: cafe`), sub-token-bound + exact-origin, last-writer-wins in-memory registry (`lib/cafe/registry.ts`) with global/per-tenant/per-widget/per-IP caps + dedup + reconnect replay. **Single-replica lock-in** (same as cobrowse). NO min-gap throttle (rapid multi-item adds are legit); per-call ceiling 300.
- **`/api/sessions`** mints a `cafe` sub-token when `widgets.cafe_enabled` (**ALL plans — no entitlement gate**, parity with the un-gated pill); reuses `mintSubToken`. The cafe widget reads `cafe.subToken ?? cobrowse.subToken`.
- **Migration `0029_widget_cafe_ordering.sql`** — adds `widgets.cafe_enabled` (default false) + `widgets.cafe_command_key` (secret, ops-only, never in resolve). Additive only. Journal idx 29, `when` 1781200000000.
- **`AIW_CAFE_DISABLED`** killswitch (reuses `COBROWSE_SUB_SECRET`, no new secret). `call_ended` webhook tears down the cafe registry beside cobrowse.
- **Admin** (`X-Admin-Key`): `POST /api/admin/widgets/:id/cafe/enable` (lazy-mints + reveals the key), `GET .../cafe-key` (reveal), `POST .../cafe-key/rotate`.

**No production behaviour change — cafe is OFF for every widget until explicitly enabled.** Existing AsqVox widgets are unaffected.

**Deploy infra (this milestone):** `boojee-cafe-app/{Dockerfile,nginx.conf,.dockerignore}` (Vite→nginx static, mirrors `aiw-voice-app`); `.env.{development,staging,production}` (`VITE_CAFE_{BACKEND_URL,WIDGET_URL,WIDGET_TOKEN}`); a new **`cafe`** service in `deploy/{dev,prod}/docker-compose.yml` (dev `127.0.0.1:5176`, prod `:5186`); cPanel vhosts `deploy/cpanel-nginx/{aiworkfllow,aiandhumn}/cafe.*/proxy.conf` (regex-location, force-HTTPS, **noindex** — it's a client demo). The cafe widget bundle is **vendored** into `boojee-cafe-app/public/aiw-cafe-widget.js` (served same-origin at `/aiw-cafe-widget.js`); refresh with `npm run sync:widget` after rebuilding the widget. The Boojee app **injects the embed env-gated**: with `VITE_CAFE_WIDGET_TOKEN` set it loads the widget (`data-widget-token/-backend-url/-outlet/-table/-diet`) and routes the hero CTA → `window.AsqCafeWidget.open()` (hiding its own FAB to avoid two orbs); with an empty token (local dev) it falls back to the `DevOrbSim` + placeholder.

**To run the real mic-voice path:** (1) create a widget in the dashboard (Step-1 domain = `cafe.aiworkfllow.com`) → ops links a Retell agent; (2) `POST /api/admin/widgets/:id/cafe/enable` → paste the returned key into the Retell cafe flow's `X-AIW-Cafe-Key` header on every ordering function (URL `…/api/cafe/command`, "Payload: args only" **OFF**, function name = action); (3) paste the widget's public token into `boojee-cafe-app/.env.<mode>`; (4) `docker compose build migrate api cafe && up -d` on the env; install the `cafe.*` cPanel vhost. **Re-greet trap applies** to the cafe flow (greeting-free hub).

**Known follow-ups (not built):** the agent doesn't yet receive `data-outlet/-table/-diet` as Retell metadata (cosmetic context only today); one shared widget token serves all 6 outlets in the demo; the menu must be in the agent KB so it knows item names (the relay passes the spoken name, the widget fuzzy-matches it host-side).

### Contact-Capture Sync — Stage 4 production-hardening pass (103-agent audit) ✅ _(2026-06-29, committed)_

Follow-up to the Stage 4 refinement below: the user asked for one more swarm audit to make the spoken path "production-level really really smooth without bugs," naming four bugs to solve at once (P1 email re-ask + "No problem" mid-speech, P2 ASR suddenly garbles Indian words, P3 spoken phone never reaches `call_analysis`/`/leads`, **P4 the agent must separate voice from background noise**). A **103-agent audit swarm** (7 recon → 12 bug-hunters → 4 noise-lens → 3-skeptic adversarial verify → synth/critic) returned **12 adversarially-verified survivors**, and the headline was a real miss: **my own Stage 4 fix did NOT actually close P3** for the common case where ASR renders a dictated phone as number-WORDS. `looksLikePhone` requires literal `\d{7,15}`, the widget never pings for phone, and the PCA backstop only read `call_analysis.customer_phone` — so `"Nine zero five six zero nine nine nine"` reached **no sink at all**.

**Code fixes shipped (6 files, +128/−13, both packages `tsc` clean, bundle 561.9 KB, unit-verified):**
- **`aiw-voice-backend/src/lib/contact/detect.ts`** — new **`spokenToDigits()`** maps spoken number-words → a digit string (`zero/oh/nought`→0…`nine`→9, `double`/`triple` repeat-next, leading `plus`→`+`, literal digit chunks kept) and is wired into `detectUserContact`'s phone branch as a fallback AFTER the literal `looksLikePhone` check. Returns null when no number-word is present, so the 7–15-digit `looksLikePhone` bound stays the precision gate and casual prose ("one two three apples") never coerces to a phone. Because every spoken-path sink funnels through `detectUserContact` (controller fire-time scan, `sessions.routes` voice parse, PCA validate, PCA transcript fallback), this single fix makes a dictated phone parseable **everywhere at once**.
- **`aiw-voice-backend/src/modules/webhooks/retell-webhook.service.ts`** — `backfillCapturesFromAnalysis` hardened: (a) `raw()` now **coerces number/bigint → String** (Retell "Extract Dynamic Variables" can type a phone as a JSON Number, which was silently dropped); (b) a **transcript-scan fallback for PHONE** — when `call_analysis` carries no phone, it concatenates the visitor's own `transcript_object` User turns and runs `looksLikePhone(userText) ?? looksLikePhone(spokenToDigits(userText))`, dedup-guarded against kinds already present. Email is intentionally NOT transcript-scanned (too ASR-fragile — pill stays authoritative). This is the P3 safety net so a recovered phone always reaches `/leads` even when Retell's own extractor misses it.
- **`aiw-voice-widget/src/widget.ts`** — `resetPillState()` now clears the per-field `voicePingedKinds` latch (was cleared only at `startCall`), so a legitimate same-field NAME re-ask within one call can ping again while the within-open double-fire guard still holds. Email/phone never schedule a ping → harmless no-op.
- **`aiw-voice-backend/src/lib/contact/decline.ts`** — `detectDecline` now treats a turn that carries a real email/phone (`"no, it's a@b.com"`, `"not that one, use nine eight seven…"`) as a **correction, not a sticky decline** (matches the file's own doc-comment; `HARD_NO_RE` for a bare "no." stays absolute). Imports `detectUserContact` (no cycle — `detect.ts` imports nothing).
- **`aiw-voice-backend/docs/retell-agent-tuning.md`** — added a "⚠️ KNOWN LIVE REGRESSIONS" callout + config rows for the P2/P4 dashboard fixes and a phone digit-readback prompt instruction.

**P2 + P4 are NOT widget code (audit unanimous) — they live in the Retell dashboard.** The widget runs no ASR and maxes its mic constraints, but Retell re-acquires its own stream, so the only real noise gate is server-side. **MUST-DO in the Retell dashboard, then Publish:** Language **en-IN** (was en-US), Interruption Sensitivity **0.3** (was 0.9), Response Eagerness **low** (was max), Background-noise reduction/denoising **on**, `enable_backchannel` **false**, `boosted_keywords` += email domains **and digit-words** (zero…nine, double, triple). These six settings *are* P2 (accuracy) and ~90% of P4 (background-noise rejection). **SHOULD-DO flow change** (removes the last P1/P3 stall + the hot-mic noise amplifier): move each `collect_contact` to fire **after** the agent's ask, `received:false` edge **Continues**, one greeting-free hub (RE-GREET TRAP), digit read-back confirm for phone.

**Explicitly rejected (user, do not revisit):** auto-muting the mic while an email/phone pill is open — it defeats the spoken-contact-capture feature. Background noise is solved Retell-side, never by muting. (Memory: `feedback-no-mic-automute-pill`.)

**Deploy:** rebuild `api` + `widget` per env (`docker compose build api widget && up -d api widget`), hard-refresh so the new bundle loads. **No migration.** PCA backstop still depends on the agent's post-call-analysis schema defining `customer_name/email/phone`.

### Contact-Capture Sync — Stage 4 spoken-path refinement (swarm-diagnosed) ✅ _(2026-06-28, committed)_

Fixed the three spoken-path bugs the customer reported after Stage 4a/4b shipped (`17d122c`): **P1** the agent said "No problem, let's continue" *while the visitor was still speaking* the email + then re-asked; **P2** ASR suddenly mangled Indian names/emails/digits; **P3** a spoken phone never reached `call_analysis` or `/leads`. Root-caused by a **6-lens voice-AI expert swarm** (32 raw causes → 8 candidates → **7 survived adversarial verification**) then implemented + verified. The headline finding: **Stage 4's value-less voice ping made the spoken path structurally incapable of capturing email/phone** — it was the wrong design, not a tuning miss.

**Verified root causes (swarm):**
- **P1/P3 — the voice ping fired on the FIRST ≥2-char ASR partial** (`MIN_USER_RESPONSE_CHARS=2`, `widget.ts`) and released the 15s Retell hold ~8.6s in (on `"Through alternate"`), **before the visitor finished the email**. The release carried **`value: null`** (`sessions.routes.ts` voice branch), so the confirm node had to re-extract email/phone from an ASR-garbled transcript `detect.ts` can never parse → "No problem" / re-ask. The live diagnostic (`contact_collect_log`) confirmed the email resolved `voice / received:true` (branch **c** — released-but-value-less), and a spoken phone resolved `timed_out / received:false`.
- **P3 — `call_analysis` is Retell's verbatim post-call extraction** over the same garbled transcript with **no backstop** writing recovered values into `session_captures`; a `'voice'`-resolved value also never wrote `session_captures`. Phone was absent from **both** sinks.
- **P2 — NOT a widget defect** (the widget runs no ASR; `createWebCall` passes only `agent_id + metadata`). It was a **Retell agent-config regression**: **Language was `en-US`** (should be `en-IN` for Indian accents), **Interruption Sensitivity `0.9`** (tuning doc prescribes `0.3` — at 0.9 the agent's own speech fragments), and **Response Eagerness `1`/max** — the agent endpointed the visitor at the first pause, chopping a slowly-spelled email (`"dhruv … at gmail … dot com"`) into partials (`"Through alternate"` + `"mail dot com"`) before the full utterance arrived. **Fixed in the Retell dashboard, not code** (set en-IN, drop Interruption Sensitivity → 0.3, Response Eagerness → low, then Publish — web calls use the published version).

**Code fixes shipped (3 source files + rebuilt bundle; typecheck clean both packages; bundle 561.8 KB):**
- **`aiw-voice-widget/src/widget.ts`** — (a) **email/phone no longer fire the value-less voice ping** at all: the typed pill is now the authoritative value channel for the two ASR-fragile fields (pill stays open → a typed submit resolves with the real value; a purely-spoken answer is recovered by the flow's after-the-ask scan + the PCA backstop). (b) The surviving **`name` voice release is debounced** (`scheduleNameVoicePing` / `cancelPendingVoicePing`, `VOICE_PING_SETTLE_MS=1100`) — re-armed on every spoken-text growth, fires only once the turn settles, re-validated at fire time, cancelled on every field-close path (`resetPillState`).
- **`aiw-voice-backend/src/modules/sessions/sessions.routes.ts`** — the `contact-signal` voice branch now **parses a clean email/phone** (`detectUserContact`), carries the **real value** into `resolvePending`, and **inserts a `session_captures` row** (`source:'agent_asked'`) so clean spoken values reach `/leads`. Selects `tenantId` + `sessionId` for the insert; insert wrapped in try/catch (never breaks the signal).
- **`aiw-voice-backend/src/modules/webhooks/retell-webhook.service.ts`** — new **`backfillCapturesFromAnalysis`** on `call_analyzed`: when `call.call_analysis` (`custom_analysis_data` or top-level) yields `customer_name/email/phone`, it **validates** (email/phone via `detectUserContact`; name trimmed ≤120) and **upserts into `session_captures`**, dedup-guarded against kinds already present for the call (the typed pill wins). This is the P3 safety net — any value Retell's own extractor recovered always reaches `/leads`.

**Deliberately skipped:** the swarm's P2 regression-test item (no test runner exists in this repo → a test file would be dead weight; the `decline.ts`/`detect.ts` behaviour it would guard is already verified correct).

**Companion Retell-flow change (recommended, dashboard — not strictly required for the re-ask bug, but makes the spoken-only path smooth):** move each `collect_contact` to fire **after** the agent's ask (not on-ask) so a spoken answer is already the last transcript turn and resolves instantly at fire-time (no 15s hold, no Talk-While-Waiting interrupt storm); keep one greeting-free hub (RE-GREET TRAP); add a spell-back confirm for email/phone; ensure the `received:false`/timeout edge **Continues** (never End Call).

**Deploy:** rebuild `api` + `widget` on each env (`docker compose build api widget && up -d api widget`), hard-refresh so the new `aiw-voice-widget.js` loads. **No migration** (`session_captures` already exists). The PCA backstop depends on the agent's post-call-analysis schema defining `customer_name/email/phone` fields — confirm those keys in the Retell dashboard.

### Rebrand → Asqvox (visible brand only; domains/infra unchanged) ✅ _(2026-06-17)_

Company renamed **AI&Humn → Asqvox**. Scope was deliberately **visible brand only** — name, logo, favicons, OG image, legal entity. **Domains and infra were NOT touched** (`aiandhumn.com` / `aiworkfllow.com` / all `*.com` URLs, nginx vhosts, OAuth callbacks, SMTP host, cPanel users, env URL values all stay) — a domain migration to `asqvox.com` is a separate future effort.

**Brand decisions (locked with the user):**
- Brand mark: **Asqvox** (with a `q` — the logo path id is `AsqVox`, OG image says "Powered by Asqvox", domain is asqvox.com).
- Legal entity: **`Asqvox Technologies Pvt. Ltd.`** (replaces `AI and Humn Technologies Pvt Ltd` in JSON-LD `legalName`, invoice/tax/legal copy).
- Logo gradient unchanged in spirit (orange→yellow `#ff4800`→`#ffcc00`).

**What changed (86 files):**
- **Assets** — new wordmark SVG dropped in-place over the 3 tracked `aih-logo.svg` files (filename kept → zero import churn); the unreferenced legacy `logo.svg` / `wordmark-dark.svg` / `wordmark-aih-dark.svg` overwritten too so no stale AI&Humn art remains. Favicon set (`16/32/48/64/180/192/512` PNG + `apple-touch-icon` + `android-chrome-192/512` + multi-size `favicon.ico` + vector `favicon.svg`) regenerated from the user's `asqvox-favicon.jpg` via a throwaway `sharp` + `png-to-ico` script, distributed across marketing `/public`, app `/public` + `/src/design-system/assets`, and backend `/admin` (matching each package's existing filenames). `og-default.png` replaced with the new 1200×630 share image. `site.webmanifest` `name`/`short_name` → `Asqvox` in marketing + app.
- **Text** — `aiw-voice-marketing/lib/site.ts` is the keystone (`SITE_NAME`, `SITE_LEGAL_NAME`, `SITE_LONG_DESCRIPTION`); plus marketing page copy + metadata + JSON-LD, SPA auth/billing/index.html copy, widget **"Powered by Asqvox"** footer, admin panel title, `seed.ts`, and `SMTP_FROM` display name in `.env.example`. Done as targeted edits (3 parallel subagents, one per package) — NOT a blind sed.
- **Widget bundle rebuilt** — `dist/aiw-voice-widget.js` (tracked artifact) recompiled so the footer change ships; 544 KB.

**Deliberately KEPT (not brand display):** all domains/URLs/subdomains, all `@…com` emails, the `@aiandhumn` X handle + `linkedin.com/company/aiandhumn` (external accounts — update separately if those handles are rebranded), cpanel paths in `robots.ts` comments. **Historical drizzle migration SQL was NOT edited** (would corrupt applied-migration hashes); no customer-facing email template needed a new migration (none carry the brand in seeded rows). CLAUDE.md / handoff.md milestone history left intact (this entry is additive).

**Verified:** all four packages `tsc --noEmit` clean; SPA `vite build` + marketing `next build` (all 12 routes) green; widget bundle contains "Powered by Asqvox" and zero `ai&humn`. Repo-wide grep confirms zero brand **display** strings remain (only domains/emails/handles).

**Deploy:** standard rebuild of `marketing` + `app` + `widget` containers on each env. Landed on `feature/cobrowse-t0-t1` (dev) first, then replicated to `main` (prod) — both deploy from their own branch. **Browser hard-refresh / CDN cache-bust** needed for favicons + OG (social scrapers cache aggressively — re-scrape via the platform's debugger if an old preview lingers).

### Host headroom remediation — dev idles on-demand, n8n retired, Docker log rotation ✅ _(2026-06-13)_

Follow-up to the containerd-shim incident below — removed the three failure modes that caused it, after confirming the box was **never RAM-starved** (2.9 GB free at idle; the Docker stacks total only ~580 MB — the rest of the 2.2 GB used is the cPanel mail/security stack: `spamd` ×3 ≈ 412 MB, `mariadbd` 250 MB, clamd/`cpsrvd`). The real triggers were **disk pressure** (root disk was at 92%) during **parallel** dev+prod builds + **unbounded** container logs. Applied:
1. **Idled the dev stack** — `cd /opt/aiw/dev && docker compose stop`. Frees ~250 MB and removes dev↔prod build contention. `unless-stopped` + a manual stop ⇒ it stays down across reboots, so **`docker compose start` before any dev deploy/test**; `*.aiworkfllow.com` 502 while idle (intended).
2. **Retired n8n** — `n8n-n8n-1` + `n8n-postgres` (`docker update --restart=no` + `docker stop`; volumes preserved). It was chronically crash-looping + churning restarts/logs.
3. **Docker log rotation** — `/etc/docker/daemon.json` = `{ "log-driver": "json-file", "log-opts": { "max-size": "10m", "max-file": "3" } }` → 30 MB cap per container, so no future crash-loop can re-bloat the disk (the api's 643 restarts had grown a 168 MB JSON log; truncated). **Activation:** the daemon reads `daemon.json` only at start, so it goes live after `systemctl restart docker` (config load) + container recreate; new containers on future deploys are then capped.
4. **Reclaimed disk** — truncated the 168 MB crash log + `docker builder prune -f` → **92% → 83%**.

Verdict: **do NOT add RAM** (ample headroom). Disk is the watch-item, but the growth driver is **client `/home` (39 G** — `tgbitscoin` 9 G, `togglebytesco` 6 G, …), not us (our entire Docker footprint is ~11 G) — resize the 80 G disk when `/home` nears the ceiling, or build images off-host. Pairs with the prune-first + one-service-at-a-time build preflight in the incident milestone below. Captured in memory `ops-vps-build-shim-crash`.

### Fix — `window.AIWWidget.open()` host-CTA regression (anti-abuse Phase 1.1) ✅ _(2026-06-13)_

The prod marketing homepage's voice CTAs — "Try this" on the Chat-is-dead section, the LiveDemo orb ("This page has a voice agent"), and the UseCases `voice-rings__orb` — stopped opening the voice widget. **Root cause: the Phase 1.1 `event.isTrusted` gate on `window.AIWWidget.open()`** (shipped `4099423`, deployed 2026-06-10) checked `widget.inTrustedClickHandler` — a flag set ONLY inside the widget's own launcher click handler, which calls `startCall()` directly and **never reaches `open()`**. A host-page CTA's click runs in the host's handler, where the flag is `false` → `open()` rejected for **every** host site (the corner launcher orb kept working because it's the launcher's own trusted path, not an `open()` call). So the documented `window.AIWWidget.open()` public API had been 100% broken for host CTAs since 2026-06-10. **Fix (`aiw-voice-widget/src/index.ts`):** gate `open()` on **`navigator.userActivation.isActive`** (OR the legacy flag as a fallback) — true only during a genuine user gesture (CTA click/tap), false for DevTools-console / `setTimeout` / synthetic-click abuse. Restores the host-CTA API **and** actually closes the console hole the flag-only version never really did. The marketing CTAs were wired correctly all along (`onTry`/`onOpenDemo` → `HomeClient.handleOrbClick` → `AIWWidget.open(anchor)`) — **no marketing change**. **Deploy = rebuild the WIDGET bundle only** (`widget.{aiandhumn,aiworkfllow}.com`); no marketing/app rebuild. Widget `typecheck` + `build` clean; tracked `dist/aiw-voice-widget.js` rebuilt + committed.

### Incident — VPS `api` crash-loop (502 on every `/api/*`): containerd shim corruption from host disk pressure — ROOT CAUSE + RESOLUTION ✅ _(2026-06-13)_

**Symptom:** `502 Bad Gateway` on `https://api.{aiworkfllow,aiandhumn}.com/api/auth/{google,linkedin}` — and in fact on **every** `/api/*` — on **both** dev and prod. Surfaced via the SPA "Sign in with Google/LinkedIn" buttons (a full-page nav to the api host, so the 502 renders directly); email/password login was failing too (softer XHR error). `aiw-{dev,prod}-api` were in `Restarting (137)`; prod's `RestartCount` reached **643**.

**Root cause: containerd shim corruption, triggered by host disk pressure — NOT the app, NOT memory, NOT OAuth code.** The VPS root disk (`/dev/vda1`, 80 G) was at **92 %** (7.1 G free). Heavy `docker compose build` runs (Next.js `marketing` + Vite `app`, run during the SPA↔marketing de-dup deploy) write hard into `/var/lib/docker`; on a near-full disk that transiently starves containerd and **corrupts the per-container shims of long-lived containers**. `/var/log/messages` carried the signature: `shim disconnected` / `runc did not terminate successfully: exit status 255` / `task must be stopped before deletion: running: failed precondition` / `cannot delete running task … failed precondition`. The shim's death SIGKILLs the container task → exit `137`. The same fault hit `n8n-n8n-1` (its long-noted "crash-looping" state = same cause).

**Red herrings (logged so the next hunt is short, not the ~10 rounds this took):**
- `OOMKilled=false`, nothing in `dmesg`/`journalctl -k`, `systemd-oomd` inactive, CSF/lfd not installed → genuinely **not** an OOM and **not** a kernel/host process-killer.
- `docker inspect`'s `OOMKilled`/`ExitCode` reflect only the **most recent** exit. The dev container's last exit was a port-bind conflict *we* caused, so its `OOMKilled=false` said nothing about the loop deaths. **Trust a live-looping container's flags, never a stopped one's.**
- `docker events` showed only `die (137)` → `start`, with **no `kill` and no `oom` event** → the kill came from the **runtime/shim layer**, not a `docker kill`/`stop` (rules out cron/watchdog) and not a cgroup OOM.
- A fresh `docker compose run --rm --no-deps --service-ports api` container ran **perfectly** (served live traffic incl. OAuth `302`, fired crons) because it got a **fresh shim** — while the long-lived managed container with the **corrupted** shim died in ~2 s. **"Standalone works but the managed container loops" → suspect runtime/shim state, not code.**
- The "~62 s cadence" in the logs was Docker's **maxed restart-policy backoff**, not the kill interval (`execDuration=2` = the real ~2 s lifetime).

**Resolution applied:**
1. `systemctl restart docker` — reinitializes containerd, clears the wedged/zombie tasks + dead shims. (Restarts only Docker containers ~30–60 s; the ~25 cPanel/Apache sites are **not** docker → unaffected.) Note: `restart: unless-stopped` does **not** auto-start a container you previously `docker compose stop`'d, so the api containers came back **absent from `ps`** (cleanly stopped), not looping — that's correct, not a failure.
2. `cd /opt/aiw/<env> && docker compose up -d --force-recreate api` per stack — fresh container = fresh shim. Verified both: `/health` 200, `/api/auth/google` 302.
3. Disk reclaim (the actual fix): `docker system df` showed **~8.5 G reclaimable** (Build Cache 5.587 G all-reclaimable + ~2.9 G dangling images from repeated rebuilds). Ran `docker builder prune -f` + `docker image prune -f` (dangling only). **Never** `docker volume prune` (the 11 volumes are dev+prod Postgres + n8n/qdrant data) and **not** `docker image prune -a` (drops rollback images + forces base re-pulls).

**Prevention (the host is oversubscribed — 5.5 GB RAM / 80 GB disk @ 92 %, running dev + prod full stacks + n8n/mongo/qdrant):** before ANY deploy that builds images — (a) reclaim disk first and keep it well under ~85 %; (b) build **one service at a time** (`build marketing` → `up -d --no-deps marketing`, then `build app` → …), never in parallel, watching `df -h /` between each; (c) ideally build off-host or stop the dev stack during a prod build. **This is now a required preflight for the migration-bearing-deploy and de-dup-deploy runbooks above.** Full incident also captured in memory `ops-vps-build-shim-crash`.

### SPA ↔ Marketing de-duplication + app-origin hardening ✅ _(2026-06-12, uncommitted at write time)_

Killed a major redundancy + SEO problem: the SPA (`app.<domain>`) was shadowing every public marketing page that also lives on the apex Next.js site (`/`, `/about`, `/how-it-works`, `/pricing`, `/investors`, `/contact`) — duplicate content on two domains with no canonical/robots signal, and `app.aiandhumn.com` was fully indexable. Audited first via a 7-reader parallel workflow + adversarial verification of every load-bearing claim, then 4 strategic decisions locked with the user (hard-remove duplicates / root→apex / noindex-except-login-signup / fix the plan handoff). Read-only audit found the resume-intended-action machinery already existed and is open-redirect-safe (`ProtectedRoute` `from` + `lib/pending-checkout.ts`).

**What changed (SPA + one nginx file):**
- **Removed** the 5 duplicate marketing routes from `AppRouter` (`/about`, `/how-it-works`, `/pricing`, `/investors`, `/contact`). They fall through the catch-all → `/` → `RootGate`.
- **`RootGate`** (`src/routes/RootGate.tsx`) at `/`: loading → spinner; authed → `<Navigate to="/dashboard">`; unauthed → `window.location.replace(VITE_MARKETING_BASE_URL)` (cross-origin, so a full-page redirect, not react-router).
- **`AuthLayout`** (`src/components/AuthLayout.tsx`): slim auth-page chrome (wordmark + "Back to website" + legal footer, all → apex). `LoginPage`/`SignupPage`/`ForgotPasswordPage`/`ResetPasswordPage` moved off `PublicLayout` → `AuthLayout`. This severs the auth surface from the marketing `landing/` JS — **bundle dropped 673 KB → 453 KB JS, 100 KB → 63 KB CSS** (marketing landing/framer tree-shaken out). `AuthLayout` still imports `landing.css` for the `.auth-*` card styles (those should be extracted in a later cleanup).
- **Plan handoff hardened**: `setPendingCheckoutFromQuery` now runs on `LoginPage` mount too (was signup-only); both `LoginPage` + `SignupPage` forward an **already-authed** visitor straight to the pending `/billing/checkout` (or `/dashboard`). `OAuthCallbackPage` already consumed pending-checkout (unchanged). All reuse the existing path-only/allowlist/30-min-TTL guards in `lib/pending-checkout.ts`.
- **`VITE_MARKETING_BASE_URL`** added to `.env.{development,staging,production}` (`localhost:3001` / `https://aiworkfllow.com` / `https://aiandhumn.com`).
- **nginx PROD `app.aiandhumn.com`** (`deploy/cpanel-nginx/aiandhumn/app.aiandhumn.com/proxy.conf`): `X-Robots-Tag: noindex, nofollow` (with `always`) on the regex catch-all, with exact-match `location = /login` + `= /signup` blocks (no header → indexable, since `=` outranks the regex). Dev `app.aiworkfllow.com` already blanket-noindexes (untouched). `index.html` deliberately gets **no** robots meta (a blanket meta would noindex login/signup too).

**Decisions locked (user):** hard-remove (no per-path 301s — removed paths land on the apex *home*, not the exact twin); unauthed app root → marketing apex; noindex all of app.* **except** `/login`+`/signup`; harden the marketing-pricing→checkout handoff.

**Deferred (intentional):** the orphaned marketing component files (`HomePage`, `landing/`, `about/`, `how-it-works/`, the 5 page files, `PublicLayout`) still sit on disk — unrouted + tree-shaken, harmless — to be deleted once verified live on prod. Two flags: `MarketingWidget` (the demo voice-orb) is still globally mounted in `main.tsx` and injects on the SPA's auth pages (decide whether to drop it from the app origin); and `/login`+`/signup` are indexable but share one static `index.html` title/meta (a Vite SPA can't easily give them distinct rich meta — revisit if branded-login SERP quality matters).

**Verified:** typecheck + production build clean; live preview confirmed `/login`+`/signup` render with `AuthLayout` and zero marketing nav, `RootGate` fires the apex redirect when unauthed, and a removed route (`/about`) no longer renders (→ gate → apex). **Deploy needs all three:** rebuild `app` container + install the updated `app.aiandhumn.com` nginx include + reload + (still-pending) marketing `a131aa7`.

### Widget Anti-Abuse — Phase 1 (front-door gates) + Phase 4 (spike detection + alerts) ✅ _(2026-06-10)_

Protects customer Retell minutes from bot/script-orchestrated calls. Full plan + per-layer rationale: `.claude/plans/snappy-leaping-willow.md`. Built with parallel-agent recon (9 readers) → implementation → 6-dimension adversarial review (38 agents, 32 findings → 14 confirmed → 4 real fixes).

**Phase 1 — front-door gates (commit `4099423`):**
- **`event.isTrusted` gate** on every call-init path. `launcher.ts` rejects programmatic clicks; `widget.ts` sets a synchronous `inTrustedClickHandler` flag (cleared in a microtask); `index.ts` gates `window.AIWWidget.open()` on it. BehaviorTracker auto-open is deliberately ungated (no DOM event exists on that path — `isTrusted` is a property of Events only; defended instead by the engagement gate).
- **Resolve→sessions HMAC challenge** (`src/lib/widget-challenge.ts`): `/api/widget/resolve` mints `base64url(payload).base64url(HMAC-SHA256(payload, WIDGET_CHALLENGE_SECRET))` with a 90s TTL embedded in `iat`; `/api/sessions` requires it as `X-AIW-Challenge`. No IP binding (would break mobile network switches). Stateless. `WIDGET_CHALLENGE_SECRET` is independent of `JWT_SECRET`/widget secrets.
- **Engagement gate**: the challenge payload carries `engagementSeen`; `POST /api/widget/engagement` re-mints it true after the widget observes any of mousemove/scroll/keydown/pointerdown/touchstart/click/visibilitychange. Defends the trigger-auto-open path (real visitors interact; pure-passive bots never flip the bit).
- **Rollout flags (env, default false = shadow mode):** `AIW_REQUIRE_CHALLENGE`, `AIW_REQUIRE_ENGAGEMENT`. Deploy shadow → bake 1 week watching logs for missing-header warnings → flip challenge → bake → flip engagement. Rollback = env var + restart. **Deploy prereq:** set `WIDGET_CHALLENGE_SECRET` (`openssl rand -hex 32`) per env.

**Phase 4 — spike detection + alerts (this milestone, uncommitted at write time):**
- **Migration `0025_spike_alerts.sql`** — `spike_alerts` table (`UNIQUE(widget_id, window_start)` = one alert per widget per clock-hour); `widgets.auto_pause_reason` + `auto_pause_set_at` (customer self-pause markers, distinct from the F6 admin `service_status_override_*` columns); `conversations.request_ip` (captured at session-create via `req.ip`/trustProxy, feeds the top-IPs rollup); extends the `email_templates` event_key CHECK + seeds the `widget_spike_detected` template.
- **Detection cron** (`src/lib/cron/spike-detection.ts`, hourly, `CronLockKey.SPIKE_DETECTION = 0x7c4e_f9b2`): candidate = widgets with ≥ `AIW_SPIKE_FLOOR` (10) calls in the last rolling hour; baseline = `PERCENTILE_CONT(0.5)` over hourly counts in the prior 14 days (active hours only — conservative); `threshold = max(AIW_SPIKE_MULTIPLIER(2) × median, FLOOR)`; alert when `observed > threshold`. Idempotent `ON CONFLICT DO NOTHING RETURNING` → a returned row fires the emails exactly once/hour. Same advisory-lock-TX + post-TX-dispatch pattern as F4 replenishment.
- **Ops notification**: `sendOpsAlert` gained an optional `recipientEmail` override → routes spike alerts to `OPS_SPIKE_ALERT_EMAIL` (default `updates@aiandhumn.com`) without changing the global `ADMIN_EMAIL`. **Customer email**: new F11 lifecycle `notifyWidgetSpikeDetected` (`widget_spike_detected` event, idempotency `:<widgetId>:<windowStart-iso>`).
- **Admin dashboard**: new "Spike Alerts" LHS-nav page in `admin/index.html` (5s poll guarded by `state.currentRoute`, status filter, acknowledge, red unread badge). Backend `GET /admin/spike-alerts` + `PATCH /admin/spike-alerts/:id/ack` (`admin-spike-alerts.routes.ts` → `modules/spike-alerts/spike-alerts.service.ts`).
- **Customer SPA**: `WidgetActivityHealth.tsx` — amber spike banner (above the widget-detail grid, with one-click self-pause, dismissal persisted per `(widget, window)` in sessionStorage) + Activity Health card (alert history + pause/resume). New JWT/tenant-scoped `GET /api/widgets/:id/spike-alerts` + `POST /pause` + `POST /resume`.
- **Self-pause security model** (hardened in review): customer pause sets `service_status_override='paused'` + `auto_pause_set_at`; admin pause leaves `auto_pause_set_at` NULL. **Both** customer resume AND customer re-pause refuse with 409 `ADMIN_PAUSED` when the widget is admin-paused (the pause guard mirrors the resume guard — without it a customer pause would clobber `service_status_override_by` and convert an admin pause into a customer-resumable one). The admin service-status endpoint now clears `auto_pause_*` on both set and clear, so a customer who self-paused first can't later lift an admin override.

**Conventions reinforced:**
- **`event.isTrusted` only exists on DOM Event objects.** Programmatic paths have no event to check — gate them by their own signal, never by looking for `isTrusted` where there's no event. `window.AIWWidget.open()` keys on **`navigator.userActivation.isActive`** (true only during a real gesture — a host-page CTA click/tap — false for console / `setTimeout` / synthetic clicks); BehaviorTracker triggers are defended by the engagement claim. (The original `inTrustedClickHandler`-flag gate for `open()` was a bug — the flag is set inside the widget's own launcher handler, which calls `startCall()` directly and never reaches `open()`, so it rejected **every** host-page CTA; fixed 2026-06-13 — see milestone.)
- **`db:generate` is NOT this project's migration path.** Running it produces a ~20-table spurious diff because the drizzle snapshot baseline was never maintained against the hand-authored migrations. Migrations are hand-authored SQL + `_journal.json` entry, applied by the hash-based self-healing migrator. **Never commit `db:generate` output** (e.g. a stray `0026_*.sql` + `meta/*_snapshot.json`). A review agent accidentally ran it this milestone; the artifacts were deleted and the journal entry restored.
- **drizzle `numeric()` columns infer `string` in TS** (arbitrary precision) — insert values as `String(x)` and `.default('0')`, not numbers. A review flagged this as a "bug"; it's correct as written.

### Marketing Site SEO/GEO/LLMO Audit — Sections 1-4 ✅ _(2026-06-06 → 2026-06-09, on `origin/main`)_

Four-section SEO / Schema / On-page / LLM-searchability audit close-out for the marketing site (`aiw-voice-marketing/`, deployed at `aiandhumn.com` apex for prod + `aiworkfllow.com` apex for dev). All landable findings shipped; deferred items tracked at the bottom. Run section-by-section with a strict per-finding triage protocol — short summary (audit report, change, effects, breakage risk, does-it-serve-the-purpose) BEFORE implementation, batched as one commit per section.

**Standing protocol (still applies — carries forward to any future audit work):**
- Each audit finding gets a written triage summary; user approves explicitly before code lands
- One commit per section, bundled
- Never act autonomously — surface follow-ups, never run them without explicit ask
- Cannot SSH to VPS (user runs commands and pastes output)

**Locked brand + metadata decisions** (apply to all marketing surfaces going forward):
- `SITE_NAME = 'AI&Humn'` — no-space brand mark (final)
- `SITE_LEGAL_NAME = 'AI and Humn Technologies Pvt Ltd'` — ampersand spelled out (per JSON-LD legalName requirements)
- `SITE_DESCRIPTION` is the 152-char meta-description form
- `SITE_LONG_DESCRIPTION` = `"AI&Humn is a voice AI widget for websites — whereby Visitors click, speak, and get answers grounded in your business and in real time."`
- HQ: Mumbai, India. No phone surface — `contact@aiandhumn.com` is the only contact channel
- Founding year: 2026
- All page metadata flows through `buildPageMetadata({path, title, description})` and `buildAlternates(path)` helpers in `lib/site.ts`. Title shape is `string | { absolute: string }` — simple string gets ` — AI&Humn` suffix appended by the global `title.template` (`'%s — AI&Humn'` in `app/layout.tsx`); `{ absolute: '...' }` is rendered verbatim (used for SEO-tuned page titles per O-01)
- `aiw-voice-marketing/lib/faqs.ts` exports `buildFaqLd(items)` — used to serialize visible FAQ accordions into FAQPage JSON-LD. Homepage uses `HOMEPAGE_FAQ_ITEMS` (exported at module scope from `components/home/SectionsB.tsx`), pricing uses `BILLING_FAQ` (exported from `app/pricing/page.tsx`). The visible accordion and the schema read from the same source — can never drift

**Claim discipline (LOCKED — these claims are REMOVED everywhere and must NOT be revived without a measured backing number AND a CLAUDE.md update first):**
- `HIPAA-ready` → replaced with `"Enterprise-ready deployments include audit logs and per-tenant isolation"`
- `99.97% uptime` → removed (not a measured SLA)
- `240+ sites live` → removed (no measured number behind it)
- `English, Hindi, mixed` / `EN · HI` → replaced with `"English with strong handling of global accents"` (Hindi not live)
- `RBI-aligned` → removed (fintech use-case note now enterprise-framed)
- `Avg time-to-first-word: 1.8s` / `under two seconds` → replaced with soft `"low latency"` framing across marketing surfaces. **FAQ Q5 is the one exception** — keeps a concrete `'Less than 0.6 sec'` answer because specific Q→A pairs are the unit of structured-data retrieval (AEO benefit)

#### Section 1 — Technical SEO (T-01..T-09) — commits `1be2e85` + `a8ea5c8`
- **T-01** (`1be2e85`): primary keyword injected into homepage hero lead — `"AI & Humn is a voice AI widget for websites — embed a voice agent into your site in minutes..."`
- **T-02**: 152-char meta-description form in `SITE_DESCRIPTION` (`lib/site.ts`)
- **T-03**: `SITE_NAME = 'AI&Humn'` (no-space) locked; `buildPageMetadata` helper introduced
- **T-04, T-05**: per-page OG / Twitter metadata via the helper (was previously inheriting homepage values across all routes)
- **T-06**: sitemap lastmod infrastructure (`scripts/generate-lastmod.mjs` + `lib/lastmod.generated.json` + `app/sitemap.ts`) — infrastructure built but NOT functional in Docker container (no `.git`, no `git` binary). Marked won't-fix; infrastructure left in place for future non-Docker hosting
- **T-07**: `/blog` deferred — separate effort
- **T-08**: brand standardization across body copy intentionally deferred (mixed `"AI&Humn"` / `"AI & Humn"` kept in body prose; SITE_NAME helper still uses the no-space form)
- **T-09**: address — Mumbai, India only; no phone surface

#### Section 2 — Schema Markup (S-01..S-05) — commit `1cbda0f`
- **S-01**: full Organization JSON-LD in `app/layout.tsx` — `@id`, founder Dhruv Dholakia + sameAs (LinkedIn + X), org-level sameAs, PostalAddress (Mumbai/IN), foundingDate 2026, legalName `"AI and Humn Technologies Pvt Ltd"`, description = `SITE_LONG_DESCRIPTION`
- **S-02**: Option A — WebSite JSON-LD with publisher `@id`-linked to Organization (single source of truth for org references)
- **S-03**: SoftwareApplication JSON-LD with AggregateOffer array carrying both currencies — `PRICING_RANGE_INR = { lowPrice: '1999', highPrice: '12999', offerCount: '3' }`, `PRICING_RANGE_USD = { lowPrice: '40', highPrice: '250', offerCount: '3' }`. 8-item featureList. Hindi support DROPPED from feature list per claim discipline
- **S-04**: FAQPage JSON-LD on homepage AND pricing via `buildFaqLd(items)` helper in `lib/faqs.ts`. `HOMEPAGE_FAQ_ITEMS` exported at module scope from `SectionsB.tsx`, `BILLING_FAQ` exported from `app/pricing/page.tsx` — accordion + schema read from same source
- **S-05**: BreadcrumbList deferred until URL structure deepens (today's URLs are 1-level: `/pricing`, `/about`, `/how-it-works`, etc.)

#### Section 3 — On-Page SEO (O-01..O-05) — commit `657abdb`
- **O-01**: 6 SEO-optimized page titles via `title: { absolute: '...' }`:
  - `/about`: `"About AI&Humn — Voice Conversion Layer for the Web"`
  - `/how-it-works`: `"How to Add a Voice AI Widget to Your Website — AI&Humn"`
  - `/pricing`: `"Voice AI Widget Pricing Plans — From $40 (₹1,999)/mo — AI&Humn"`
  - `/security`: `"Security & Compliance — GDPR, DPDP, Enterprise-Ready — AI&Humn"` (HIPAA dropped per O-05)
  - `/contact`: `"Contact AI&Humn — Support, Partnerships & Press"`
  - `/investors`, `/privacy-policy`, `/terms` keep simple templated titles
- **O-02**: DOM-text word-boundary fix on word-stagger Framer Motion animations — `HiwHero.tsx` + `AboutHero.tsx` now wrap each word as `<Fragment key={i}><motion.span variants={word}>{w}</motion.span>{i < words.length - 1 && ' '}</Fragment>`. Inline `marginRight: '0.3em'` dropped from `styles/about.css` + components. Why: CSS-margin spacing meant the rendered text node had NO whitespace between words — copy/paste of the hero gave a concatenated string, and screen readers / search-engine text extractors saw one giant word
- **O-03**: `availability: 'https://schema.org/InStock'` added to each Offer in the pricing ItemList JSON-LD (both USD and INR Products) — required by Google Merchant guidelines for Offer schema
- **O-04**: `/blog` deferred
- **O-05**: full claim-set cleanup across 8 surfaces (see "Claim discipline" lock above)

#### Section 4 — LLM Searchability / AEO / GEO / LLMO (L-01..L-06) — commit `f967d31`
- **L-01**: ship `llms.txt` at marketing apex (`aiw-voice-marketing/public/llms.txt`). Static file under Next.js public dir; serves at `https://aiandhumn.com/llms.txt` once marketing redeploys. Body honours the locked claim set — uses `"Low Latency for real-time conversations"` (NOT the audit-suggested `"Avg time-to-first-word: 1.8 seconds"`)
- **L-02**: closed as duplicate of S-04 (FAQPage JSON-LD already shipped)
- **L-03**: `/blog` cornerstone content deferred — same bucket as T-07 / O-04. Day-one targets when blog launches: `/blog/what-is-a-voice-ai-widget` + `/blog/how-voice-ai-improves-website-conversion`
- **L-04**: dev-env noindex shipped here (see below). Bing Webmaster registration + IndexNow API integration DEFERRED — code queued behind user's out-of-band Bing registration
- **L-05**: backlinks / external citations parked as non-code growth track (Product Hunt launch, G2/Capterra/Trustpilot listings, guest posts, HARO/Connectively, Reddit AMAs)
- **L-06**: Reddit searchability parked as non-code growth track

**Latency claim refresh (Section 4 — supersedes the prior 1.8s claim everywhere it lived):**
- Homepage STATS marquee (`components/home/Sections.tsx`) → `{ label: 'response', val: 'low latency' }`
- Homepage Differentiation table → `"One click, low-latency voice"`
- Homepage FAQ Q5 answer (`components/home/SectionsB.tsx`) → `"Less than 0.6 sec"` (the ONE place a specific number lives, intentionally — AEO retrieval value)
- Investors METRICS card (`app/investors/page.tsx`) → `{ label: 'Response', val: 'Low latency' }`
- `/how-it-works` "Under the hood" section lead (`hiw.content.ts`) → `"Four things happen in sequence, with low latency end-to-end."`
- `/how-it-works` Step 04 body → `"... End-to-end: low-latency voice, built for real-time conversation."`
- `/how-it-works` SEO meta description (`app/how-it-works/page.tsx`) → `"... with low-latency voice."`

**Dev-env noindex (Section 4 — best-practice gate against dev competing with prod in search results):**
- **A. Dev `robots.txt`** at `deploy/cpanel-docroots/aiworkfllow/robots.txt` (`User-agent: *` / `Disallow: /`). Install at `/home/aiworkfllow/public_html/robots.txt` on VPS. Same cPanel-shadow pattern as the prod robots.txt — drops in docroot because EA-nginx auto-generates `location = /robots.txt` at server scope which cannot be overridden from a user-include (parse-time "duplicate location" error)
- **B. `X-Robots-Tag: noindex, nofollow` header** on every location in all 4 dev nginx user-includes (`deploy/cpanel-nginx/aiworkfllow/{aiworkfllow.com,app.aiworkfllow.com,api.aiworkfllow.com,widget.aiworkfllow.com}/proxy.conf`) with the `always` flag so 301 redirects also carry the directive
- **PROD (aiandhumn) user-includes deliberately untouched** — `aiandhumn.com` stays the canonical, indexable domain. Dev vs prod symmetry is intentionally broken here

**Deploy ops** (all 5 commits + hotfix `3c7ff0a` deployed to DEV + PROD on 2026-06-09; Phase 3 dev-noindex install completed same day. Runbook preserved below for reference):

```bash
# 1. Pull on VPS
cd /opt/aiw/src && git pull origin main

# 2. Marketing redeploy (carries llms.txt + latency-claim refresh + all per-page metadata across Sections 1-4)
cd /opt/aiw/dev  && docker compose build marketing && docker compose up -d --no-deps marketing
cd /opt/aiw/prod && docker compose build marketing && docker compose up -d --no-deps marketing

# 3. Dev noindex — one-time setup (Section 4 only)
install -m 644 -o aiworkfllow -g aiworkfllow \
  /opt/aiw/src/deploy/cpanel-docroots/aiworkfllow/robots.txt \
  /home/aiworkfllow/public_html/robots.txt
cp /opt/aiw/src/deploy/cpanel-nginx/aiworkfllow/aiworkfllow.com/proxy.conf       /etc/nginx/conf.d/users/aiworkfllow/aiworkfllow.com/proxy.conf
cp /opt/aiw/src/deploy/cpanel-nginx/aiworkfllow/app.aiworkfllow.com/proxy.conf   /etc/nginx/conf.d/users/aiworkfllow/app.aiworkfllow.com/proxy.conf
cp /opt/aiw/src/deploy/cpanel-nginx/aiworkfllow/api.aiworkfllow.com/proxy.conf   /etc/nginx/conf.d/users/aiworkfllow/api.aiworkfllow.com/proxy.conf
cp /opt/aiw/src/deploy/cpanel-nginx/aiworkfllow/widget.aiworkfllow.com/proxy.conf /etc/nginx/conf.d/users/aiworkfllow/widget.aiworkfllow.com/proxy.conf
nginx -t && systemctl reload nginx
```

Verify (after all phases):
```bash
curl -s https://aiandhumn.com/llms.txt | head -3                                       # llms.txt live on prod
curl -s https://aiworkfllow.com/robots.txt                                              # "Disallow: /"
curl -sI https://aiworkfllow.com/                              | grep -i x-robots-tag   # noindex, nofollow
curl -sI https://app.aiworkfllow.com/login                     | grep -i x-robots-tag   # noindex, nofollow
curl -sI https://api.aiworkfllow.com/admin/                    | grep -i x-robots-tag   # noindex, nofollow
curl -sI https://widget.aiworkfllow.com/aiw-voice-widget.js    | grep -i x-robots-tag   # noindex, nofollow
curl -sI https://aiandhumn.com/                                | grep -i x-robots-tag   # MUST be empty — prod stays indexable
```

**Parked / deferred items** (track for future SEO sweeps):
- T-06: sitemap lastmod (won't fix in Docker; infrastructure inactive but in place for future non-Docker hosting)
- T-07 / O-04 / L-03: `/blog` cornerstone content launch (separate effort)
- T-08: brand standardization across body copy (`SITE_NAME` is no-space; body prose still mixed)
- S-05: BreadcrumbList (defer until nested URLs exist)
- L-04: Bing Webmaster registration + IndexNow API integration (code queued behind user's out-of-band registration)
- L-05: backlinks (Product Hunt, G2/Capterra/Trustpilot, HARO, guest posts)
- L-06: Reddit searchability (active community presence on r/SaaS, r/webdev, r/artificial)
- Backend `tax_profiles` legal-entity rename to `"AI and Humn Technologies Pvt Ltd"` (operational, separate from marketing SEO)
- Review JSON-LD (needs real customer references first)

**Commit summary:**

| Section | Commit | Title |
|---|---|---|
| T-01 (early) | `1be2e85` | `fix(marketing,seo): inject primary keyword into homepage hero lead paragraph (T-01)` |
| Section 1 | `a8ea5c8` | `feat(marketing,seo): Section 1 SEO audit fixes (T-02..T-06, T-09)` |
| Section 2 | `1cbda0f` | `feat(marketing,seo): Section 2 schema audit fixes (S-01..S-04)` |
| Section 3 | `657abdb` | `feat(marketing,seo): Section 3 on-page audit fixes (O-01..O-05)` |
| Section 4 | `f967d31` | `feat(marketing,seo): Section 4 LLM-search audit (L-01..L-06)` |

### Pricing Module — Feature 9: Payment gateway adapters (Razorpay + Paddle) ✅ _(2026-05-30, uncommitted)_

Gateway-adapter layer + webhook plumbing for the pricing module. Two gateways behind one `PaymentGateway` interface — Razorpay for INR rails, Paddle for USD (Merchant of Record, absorbs international tax compliance per Decision #12). Currency-routed via `getGatewayForCurrency` (server-side derivation only — RESPECTS Feature 1's SECURITY LOCK). Both adapters use native `fetch` + `node:crypto` — no Razorpay or Paddle SDK dependency. Built in 6 chunks + 3-agent swarm review (Architecture + Security + Product/UX); 22 prioritized arbiter-approved fixes applied.

**What was built:**
- **Migration `0013_payment_methods.sql`** — `payment_methods` table. One row per saved gateway token (card / UPI / wallet). PCI-clean: only `last4` + `brand` + `expires_at` display fields. Partial unique index on `(tenant_id) WHERE is_default = true` prevents multi-default while allowing all-false (legitimate zero-card state). CHECK constraint on `last4` ('^[0-9]{4}$') + on `gateway` ('razorpay'|'paddle'). Composite UNIQUE on `(tenant_id, gateway, gateway_method_id)` for ON CONFLICT idempotency.
- **Migration `0014_billing_idempotency.sql`** — DB-level idempotency primitives for webhook redelivery. UNIQUE `(subscription_id, period_start)` on `subscription_billing_periods` so duplicate renewal webhooks raise 23505 instead of creating dup rows (the application-layer SELECT-then-INSERT pattern had a TOCTOU window — post-F9-swarm-review CRITICAL #1). Partial unique on `provider_webhook_events (provider_name, provider_call_id) WHERE provider_name IN ('razorpay','paddle')` gives webhook routes an event-id dedup primitive without affecting Retell rows.
- **`src/modules/billing/gateways/gateway.interface.ts`** — `PaymentGateway` interface + `InternalEvent` discriminated union (`subscription.charged`/`payment_failed`/`canceled`/`topup.purchased`/`refund.processed`). Typed errors: `GatewayNotConfiguredError` (→503), `WebhookSignatureError` (→401), `GatewayNotImplementedError` (→501). Interface documented as the single contract every adapter implements; orchestrator written once against the interface, never against a specific gateway.
- **`razorpay.adapter.ts`** — REAL signature verification (HMAC-SHA256 on raw body, `timingSafeEqual` length-checked, header-array normalisation). REAL `createSubscriptionCheckout` (Razorpay Subscriptions API), `cancelSubscription`, `refundTransaction`. Webhook event normalization: `subscription.charged`, `subscription.cancelled` (note: Razorpay uses British spelling), `payment.failed`, `subscription.halted` → `subscription.payment_failed`, `refund.processed`. Silent-drop paths log warn so ops can see WHY a webhook didn't emit (e.g. missing `notes.tenant_id`).
- **`paddle.adapter.ts`** — Paddle Billing v2 (NOT classic). Same shape. Anti-replay: rejects `ts` more than 5 minutes off current clock. Header-array normalisation. `parseCents` throws on non-integer (no silent revenue miscoercion). `cancelSubscription`, `refundTransaction` (Paddle's "adjustments" endpoint). `PADDLE_API_BASE` env validated against `api.paddle.com` / `sandbox-api.paddle.com` allowlist (anti-SSRF).
- **`src/modules/billing/gateways/index.ts`** — singleton registry + `getGatewayForCurrency(currency)` router. `unsafe_getGatewayByName(name)` exposed for webhook-route dispatch only; name has explicit `unsafe_` prefix so future code-reviewers notice that bypassing the currency router skips the SECURITY LOCK.
- **`src/modules/billing/gateways/README.md`** — adapter playbook (signature schemes, error mapping, currency routing, "how to add a new gateway").
- **`src/modules/billing/billing.service.ts`** — `processInternalEvent` orchestrator. Switches on `InternalEvent.type`, dispatches to F2's `createSubscription` (new) or `createNextBillingPeriod` (renewal). Past_due recovery: when a renewal succeeds on a sub that was past_due, status flips back to active and `widgets.status = 'payment_failed'` rows lift to `'active'`. Cancel paths: `cancel_at_period_end` when scheduled, immediate `canceled` when not. Default case (unknown event type) logs + returns SUCCESS — refusing to throw stops gateway retry loops on poison-pill payloads.
- **`src/modules/billing/billing.routes.ts`** — `POST /api/webhooks/razorpay` + `/paddle`. PUBLIC routes (signature IS the auth). Exempt from global 60/min rate limit (`config: { rateLimit: false }`) so legitimate redelivery storms don't get 429'd. Event dedup via INSERT into `provider_webhook_events` BEFORE orchestrator runs — partial unique on `(provider_name, provider_call_id)` raises 23505 on replay → log + 200 + skip. Per-event sequential processing (not Promise.all) so a single failure surfaces precisely without confusing retry semantics.
- **`src/modules/billing/payment-methods.service.ts`** — `listForTenant` / `findForTenant` / `setDefault` / `remove` / `upsertFromWebhook`. `setDefault` rewritten as single-statement `UPDATE … SET is_default = (id = $target) WHERE tenant_id` to eliminate the two-update deadlock window. `remove` returns `RemoveResult { deleted, refused?: 'last_default_with_active_subscription' }` — route maps to 409 when removing the last default would silently kill auto-renewal. `upsertFromWebhook` excludes target row from the default-flip + NEVER sets `is_default` in `onConflictDoUpdate.set` — duplicate webhook delivery no longer silently demotes the customer's default card.
- **`src/modules/billing/payment-methods.routes.ts`** — JWT-gated `GET /api/account/payment-methods`, `POST /add` (returns gateway-hosted URL), `DELETE /:id`, `POST /:id/set-default`. Per-route rate limit on `/add` at 6/min (outbound-API DoS guard — matches the `usage.recompute` precedent). `resolveTenantCurrency` reads server-side from active subscription's currency → `users.currency_preference` → default USD. NEVER trusts request input — preserves F1's SECURITY LOCK at the trust boundary.
- **`/admin/tenants/:tenantId/payment-methods`** — read-only ops triage endpoint. New "Payment methods" card in admin widget inspector. No delete / set-default from admin (single audit trail via customer SPA).
- **`src/config/env.ts`** — adds `RAZORPAY_KEY_ID` / `RAZORPAY_KEY_SECRET` / `RAZORPAY_WEBHOOK_SECRET` + `PADDLE_API_KEY` / `PADDLE_WEBHOOK_SECRET` + validated `PADDLE_API_BASE` (host allowlist).
- **SPA `/billing/payment-methods`** — `PaymentMethodsPage.tsx`. Lists cards with brand + last4 + expiry + default badge. Mobile-responsive row layout via inline `@media` block. `aria-label="Default payment method"` on default badge so screen readers don't depend on color + uppercase. Add CTA gated behind `VITE_BILLING_ADD_CARD_ENABLED` (default OFF until F8 + live-key roundtrip lands) — button renders inert with explanatory copy + mailto fallback rather than 100% error rate. 501/503/409/no_primary_email error codes mapped to distinct actionable copy. Post-add list polling with 30s backstop + "Refresh" link if the card hasn't shown by then. Empty state branches on `hasActiveSubscription` so paid customers don't see "add a card to start" misdirection. `window.confirm` on remove: hard-blocks if removing the last default while a subscription is active (defense-in-depth — server also 409s). `setSearchParams` returns a NEW `URLSearchParams` so react-router doesn't bailout on Object.is equality.
- **SPA `BillingOverviewPage.tsx`** — `past_due` status banner now offers "Update payment method" → `/billing/payment-methods` deep-link as the primary action, with the existing "Contact support" mailto as fallback. Customer is funneled straight to the actual fix.
- **SPA `AppRouter.tsx`** — registers `/billing/payment-methods` under `ProtectedRoute`.
- **`.env.{development,staging,production}`** — adds `VITE_BILLING_ADD_CARD_ENABLED=false` (default OFF; flip true on staging first, then prod after live-key verification).

**Open follow-ups (deferred by arbiter, tracked for later):**
- F9 → F2 module boundary: orchestrator's `existingSub` SELECT can route through a new F2 `getSubscriptionByGatewayId` helper once F2's contract extends. Not blocking — direct read is safe.
- Admin "Transactions" card with `gateway_customer_id` link-out to gateway dashboards + `last_webhook_received` columns. Defer until ops has a real triage case to justify the surface.
- `BillingOverviewPage` button-cluster reorg — cosmetic, ship with F10's real cancel button.
- INR-subscription + no-saved-card edge case — Razorpay model allows it; document UX when F8 lands.
- `nextRetryAt` field on `subscription.payment_failed` InternalEvent — wire-typed but always null today. Populate once F4 cron needs it.
- Refund admin endpoint — adapter `refundTransaction` is real on both gateways; admin invocation deferred to F7 (needs invoice-anchored audit row).

**Migrations 0013 + 0014 NOT yet applied to dev/prod** — run on next `docker compose up -d` in `/opt/aiw/{dev,prod}/`.

### Pricing Module — Feature 2: Subscription model + period-snapshot grandfathering ✅ _(2026-05-30, commit `f56f398`)_

Foundation data model for paid plans. One active `subscriptions` row per tenant; one `subscription_billing_periods` row per paid period carrying a FROZEN `plan_snapshot` (jsonb copy of the live plan row at period creation); one `subscription_usage_cycles` row per monthly quota window (Decision #8 — monthly refresh regardless of billing cycle, so yearly subs get 12 cycles under one billing period). Read-only by design — write endpoints (cancel / change-plan / resume) deferred to Feature 10. Built in 6 chunks + 3-agent swarm review (Architecture + Security + Product/UX); 12 prioritized arbiter-approved fixes applied.

**What was built:**
- **Migration `0012_subscriptions.sql`** — three tables. Partial unique index on `subscriptions(tenant_id) WHERE status IN ('active','trialing','past_due','paused')` so canceled subs don't block a re-subscription (Decision #17). Circular FK (`subscriptions.current_billing_period_id` ↔ `subscription_billing_periods.subscription_id`) resolved by making the column nullable + adding the FK constraint after both tables exist via `DO $ ALTER TABLE ... ADD CONSTRAINT $`. Schema indexes use `DESC` to match the SQL exactly so `npm run db:generate` doesn't diff-then-DROP+CREATE.
- **`src/lib/db/schema/subscriptions.ts`** — Drizzle schema mirroring 0012. Index direction expressed via `sql\`${t.periodStart} DESC\`` fragments (the `.desc()` modifier API moved across drizzle versions).
- **`src/modules/subscription/subscription.service.ts`** — public reads `getCurrentSubscription` + `getSubscriptionHistory`, plus internal `takePlanSnapshot`, `createSubscription`, `createNextBillingPeriod` (all `@internal` JSDoc-tagged — direct callers must come from gateway webhook handlers / change-plan flows once Feature 9/10 land).
  - **Split DTOs:** `PlanSnapshot` (internal, includes `entitlements`) vs `PlanSnapshotPublic` (no entitlements). `toPublicSnapshot(snapshot)` is the boundary helper. The full snapshot stays in DB JSONB for Feature 6's quota gate; entitlements are STRIPPED before any HTTP response leaves the server (post-swarm-review Sec H1 fix).
  - **Cycle-window fallback:** if `getCurrentSubscription` finds a sub with no `currentUsageCycle` (transient state during a renewal-cron lag), it computes a `[periodStart, periodStart+30d)` window from the billing period itself instead of returning null. Customer-facing surfaces never see a "no cycle" gap.
  - **23505 idempotency catch:** `createSubscription` catches the partial-unique-index violation thrown when two concurrent re-subscribes race; second caller gets the existing active row, not an error.
- **`/api/account/subscription` + `/api/account/subscription/history`** — JWT-auth-gated read routes. History takes `?periods=N` (default 12, max 60).
- **`GET /admin/tenants/:tenantId/subscription`** — admin-key gated, UUID-validated. Mirrors customer endpoint for ops triaging.
- **Seed updated** — `src/scripts/seed.ts` now creates a `subscriptions` row + first billing period + first usage cycle for the dev tenant so the dashboard `/billing` page has data on first boot.
- **SPA `/billing` route** — new `BillingOverviewPage.tsx`. Shows current plan card + period dates + cycle minutes. **Five arbiter UX fixes baked in:** past_due / paused recovery banners, trial countdown surface (reads `subscription.trialEndsAt`), `pendingChange` disclosure banner (Feature 10 will write this), yearly format parity with PricingGrid, mailto cancel link in the v1 (so customers aren't dropped into a dead UI before Feature 10 lands).
- **SPA routing** — `/billing/plans` now hosts the plan comparison surface (renamed from `/account/plans`); legacy URL `/account/plans` is a `<Navigate to="/billing/plans" replace />` so bookmarks keep working AND the sidebar highlight stays consistent (arbiter fix #10 — removed the duplicate-mount Route that didn't highlight the nav).
- **Sidebar** — new "Billing" item with `CreditCard` icon, `matchPrefix: '/billing'`, between Usage and the wizard entry.
- **Admin panel** — new "Subscription" card in the widget inspector at `aiw-voice-backend/admin/index.html`. Shows plan_snapshot name, status badge, period dates, current cycle minutes, `pendingChange` (raw JSON for ops), expandable billing history table. Loads on widget select alongside the existing Usage + Reconciliation cards.
- **Reciprocal cycle-ownership note** added at the top of `src/lib/usage/periods.ts`: for subscribed tenants going forward, `subscription_usage_cycles` is the source of truth for the entitlement window. `periods.ts:currentPeriod` stays in service only for the `minutes_used` aggregate cache (correct by construction) and legacy non-subscribed tenants. Feature 6's quota gate MUST read the entitlement window from `subscription_usage_cycles` for subscribed tenants — using anchor-based math would drift after the first renewal that lands on a different calendar day. The plan file's Feature 6 section carries the matching `CYCLE-SOURCE-OF-TRUTH LOCK` callout.

**Open follow-ups (deferred by arbiter, tracked for later):**
- Denorm `tenant_id` onto `subscription_billing_periods` + index `(tenant_id, period_start DESC)` so `getSubscriptionHistory` is a single index range scan instead of an `inArray` fan-out. Defer until a tenant accumulates >10 subscriptions.
- Zod re-parse `plan_snapshot` jsonb on every read (defense-in-depth for the future Feature 7 PDF invoice generator that won't auto-escape via React).
- Mobile-responsive billing history table (`@media` stack layout).

**Migration 0012 NOT yet applied to dev/prod** — runs on next `docker compose up -d` in `/opt/aiw/{dev,prod}/`.

### Pricing Module — Feature 1: Dynamic geo-localized pricing display ✅ _(2026-05-30, commit `bda2e20`)_

First feature of the 11-feature pricing/tax plan locked in `.claude/plans/snappy-leaping-willow.md`. Moves all pricing data into the database, exposes it via a public API, and renders every visible pricing surface (homepage section, `/pricing`, dashboard `/billing/plans`) from the same source. Visitors see one price in their local currency (INR for India IPs, USD elsewhere) with a manual `₹ / $` switcher. Admin edits any plan's name, prices, inclusions, and features list and the change appears on every surface on the next request. Built in 6 chunks + 3-agent swarm review (Architecture + Security + Product/UX) over 2 debate rounds + 1 arbiter round; 23 prioritized fixes applied.

**What was built:**
- **Migration `0011_plans_pricing_extension.sql`** — extends `plans` with `price_inr_monthly_cents`, `price_usd_monthly_cents`, `price_inr_yearly_cents`, `price_usd_yearly_cents`, `tagline`, `features` jsonb, `inclusions` jsonb, `entitlements` jsonb, `display_order`, `is_public`, `highlight`, `updated_at`. Idempotent (every `ADD COLUMN` uses `IF NOT EXISTS`). Partial index on `(is_public, display_order)`. Relabels the seeded `basic`/`intermediate`/`advanced` rows to Starter/Business/Advanced with the locked v1 pricing (admin re-edits via the new Plans modal). Also adds `users.currency_preference` with `CHECK ('INR'|'USD'|null)`.
- **`src/plugins/geo.ts`** — lazy `resolveGeoForRequest(req)` helper using offline `geoip-lite` (npm package bundles MaxMind DB). NOT a global hook — called only inside `/api/plans` per arbiter recommendation (avoids per-request lookup cost on unrelated endpoints). `Fastify({ trustProxy: true })` enabled in `app.ts` so PROD geo resolution works behind cPanel nginx → Docker.
- **`GET /api/plans`** — public, rate-limited 60/min per IP, `Cache-Control: private, max-age=60`. Returns both currencies + features + inclusions + `displayOrder` + `highlight`. Echoes `detectedCountry` + `defaultCurrency` so the SPA can decide-once on first paint. **Entitlements stripped from the public DTO** (Feature 3 server-side gate keys must never reach customer-facing JSON — post-swarm-review Sec H1 fix). SQL-level `WHERE is_public = true` filter (not in-memory after fetch).
- **Admin `/admin/plans`** — GET (list with full entitlements) + PATCH `/admin/plans/:id` (UUID validation; narrowed single-highlight transaction; reply sent OUTSIDE the tx so post-commit failures cannot ship phantom 200s).
- **Admin panel** — `admin/index.html`: Plans button in topbar opens a modal with list + inline-edit form, live JSON validation (red/green border) and a transient "Saved" pill on successful save.
- **SPA `<PricingGrid>`** — single component consumed by homepage Pricing, `/pricing`, AND dashboard `/billing/plans` (with "Current plan" pill + legacy-plan callout + Upgrade vs Downgrade CTA branching via `me.planDisplayOrder`). **First-paint skeleton** until currency is known prevents the `$` → `₹` flicker that no-cookie India IPs would have seen. Single React Query key (currency switch is purely client-side — both currencies already in the payload, no refetch). Retry button on error, explicit empty-state message.
- **`<CurrencySwitcher>` + `lib/format-price.ts` + `lib/currency-storage.ts`** — Indian comma grouping (`1,24,788`) via `en-IN` Intl locale; USD via `en-US`. 1-year `SameSite=Lax` cookie for currency persistence (`Secure` when over HTTPS).
- **`src/api/plans.ts`** — typed `/api/plans` client.
- **AppRouter** — wires `/billing/plans` under `ProtectedRoute`; Sidebar adds "Billing" nav item (later refined by Feature 2's rename, but it lands here first under `/account/plans`).
- **Plan-file updates** — `.claude/plans/snappy-leaping-willow.md` gains a `SECURITY LOCK` callout on Feature 9 (payment gateways): the checkout flow MUST resolve the charge currency SERVER-SIDE from the authenticated tenant. Never trust `?currency=`, `?country=`, or `aih_currency` cookie at money-bearing endpoints — Feature 1's currency surface is display-only; checkout is the trust boundary. Also adds a GDPR escalation note: `/privacy-policy` SPA route is now also a Feature 1 EU-launch prerequisite (IP-derived country is personal data under GDPR Art. 4(1)).

**Deliberately deferred (NOT in v1):**
- **Cross-device currency persistence** — `users.currency_preference` COLUMN exists with the `CHECK ('INR','USD',null)` constraint but no endpoint writes/reads it. The `Me` API response field was deliberately removed pre-arbiter to avoid shipping a typed claim with no validation path. Feature 10's `PATCH /api/account` work re-introduces the field end-to-end (read from DB on `getMe`, write via the account-settings endpoint).
- **Feature 3 entitlements gate** — the `entitlements` JSONB column is populated (e.g. `['lead_capture','behavioural_triggers',...]`) but no code reads it yet. Feature 3 will wire `/api/widget/resolve` + the dashboard upgrade-CTA logic.

**Migration 0011 NOT yet applied to dev/prod** — runs on next `docker compose up -d` in `/opt/aiw/{dev,prod}/`.

### Error-Message Sanitization (security) ✅ _(2026-05-25, commit `6a2fe9d`)_

Stopped raw backend errors — Drizzle SQL strings, parameter values, stack frames, network internals — from rendering in the SPA's `ErrorBanner` when the DB or another runtime dependency fails. Spotted live on `/login` where a downed DB surfaced `Failed query: select "user_id", "tenant_id", "password_hash", "email_verified" from "users" where "users"."email" = $1 limit $2 params: dd@aiworkfllow.io,1` directly to the user.

**Two root causes:**
1. `aiw-voice-backend/src/modules/auth/auth.routes.ts` was the outlier among route modules — every catch block did `reply.code(err.statusCode ?? 500).send({ message: err.message })`. For curated app errors that was fine; for an unexpected Drizzle throw it reflected the full SQL. Other route files (`leads`, `usage`, `conversations`, `widgets`) already had a `sendErr`-style helper guarding the same path.
2. The SPA's `ApiError` and `ErrorBanner` trusted `payload.message` blindly.

**Backend fix (root cause):**
- `auth.routes.ts`: new `sendAuthError(req, reply, err, fallbackCode)` helper. Curated errors (`typeof err.statusCode === 'number'`) pass through; unknown errors → `req.log.error` + generic `{ error: 'internal_error', message: 'Something on our end is misbehaving. Please try again in a moment.' }` 500. All six catch blocks now route through it.
- `app.ts`: global `app.setErrorHandler` as a safety net for anything bypassing per-route try/catch (body-parse failures, plugin errors, future routes that forget to wrap).

**Frontend defense in depth:**
- `aiw-voice-app/src/api/client.ts`: wraps `fetch()` in try/catch so a downed server surfaces as `ApiError(0, 'We could not reach the server...', { error: 'network_error' })` instead of `TypeError: Failed to fetch`. New `sanitizeMessage(status, raw)` strips suspicious payloads (5xx, `Failed query`, raw SQL fragments like `select ... from`, `insert into`, `update ... set`, Drizzle's `params:` dump, Node stack frames, `ECONNREFUSED|ENOTFOUND|ETIMEDOUT|EAI_AGAIN`, `pg_*`/`postgres`/`drizzle` markers, oversized > 240 char strings) → generic fallback before constructing `ApiError`. The raw server text is parked on `err.rawServerMessage` and logged via `console.error('[api] suppressed server error message', ...)` so devs can still debug in DevTools — it just can't render.
- `components/ui/ErrorBanner.tsx`: re-applies the same suspicious-pattern check at the render boundary. Now accepts `string | Error | ApiError | unknown` so the `VisitorIdentityCard.setError(err.message)` path (which stores a string) is also defended.

**Curated flows still pass through unchanged** — `EMAIL_NOT_VERIFIED`, `USE_SOCIAL_LOGIN`, `EMAIL_ALREADY_REGISTERED`, `RESET_TOKEN_EXPIRED`, `INVALID_STAFF_CREDENTIALS`, Zod field issues — `payload.error` codes that components key off still arrive intact.

**Files changed:** `aiw-voice-backend/src/{app.ts, modules/auth/auth.routes.ts}`, `aiw-voice-app/src/{api/client.ts, components/ui/ErrorBanner.tsx}`. Both packages typecheck clean. Verification: stop the backend (or break `DATABASE_URL`), try to log in. Before: SQL string in red banner. After: `"We could not reach the server..."` (server down) or `"Something on our end is misbehaving..."` (server up, DB down). Suppressed raw text visible only via DevTools console.

### Leads Dashboard + `name` Capture Kind ✅ _(2026-05-22, uncommitted)_

**What was built:**
- **Migration `0007_session_captures_name.sql`** — extends `session_captures.kind` CHECK constraint from `('email','phone')` to `('email','phone','name')`. Adds `(tenant_id, captured_at DESC)` partial index for the new listing endpoint.
- **Backend `src/modules/leads/`** — new module.
  - `leads.routes.ts`: single `GET /api/leads` route, JWT-auth-gated, Zod-validated query (`?widgetId=`, `?from=`, `?to=`, `?cursor=`, `?limit=`).
  - `leads.service.ts`: rolls captures up by `(provider_call_id, widget_id)` into one Lead row per call with `{ name, email, phone }` as columns. Uses `array_agg(value ORDER BY captured_at DESC)[1]` filtered by `kind` so the LATEST captured value per kind wins (not the lexicographic MAX, which would silently pick a corrected → original value the wrong way around). Joins `conversations` on `provider_call_id` for a deep-link target. Keyset pagination on `captured_at DESC` — stable under concurrent inserts.
- **Frontend `src/routes/LeadsPage.tsx`** + `src/api/leads.ts` — global tenant-scoped table mounted at `/leads`. Filters: widget dropdown + date range (mirrors `ConversationsPage` UX). Infinite-scroll via `IntersectionObserver` sentinel. Each row deep-links to `/widgets/:widgetId/conversations?focus=:conversationId`, which auto-paginates up to 5 pages looking for the ID then scrolls + expands the row.
- **Widget pill update** — `inline-capture.ts` + `contact-intent.ts` already extended to accept the `name` kind (agent-asked OR user-volunteered). The `/api/sessions/:id/captures` endpoint accepts all three kinds.
- **Sidebar nav** — "Leads" item added to `Sidebar.tsx` (between Widgets and Usage).

**Why roll up on read (not denormalize into a `leads` table):** keeps the data model simple. If listing perf gets slow at >100k captures/tenant, the obvious next step is a materialised view, not a write-path change.

### Master Ops Impersonation ✅ _(2026-05-22, uncommitted)_

Lets a named human ops staff member sign into the SPA AS any customer for support — with mandatory audit trail and a sticky in-app banner. Two-layer auth: the existing admin-panel `X-Admin-Key` gate **plus** the staff user's own email + password (so a leaked admin key alone can't impersonate).

**What was built:**
- **Migration `0009_impersonation.sql`** — adds `users.is_staff boolean DEFAULT false` (with a partial index `WHERE is_staff = true` since nearly every row is false) and creates `impersonation_audit_logs` (append-only: `staff_user_id`, `staff_email`, `target_user_id`, `target_email`, `target_tenant_id`, `reason`, `ip_address`, `user_agent`, `created_at`). Email columns are **denormalised** so the log stays readable years later if users are deleted or re-keyed.
- **`src/modules/auth/auth.service.ts`** — new `verifyStaffCredentials(email, password)`. Strict: user must exist, have `is_staff=true`, have a verified email, and pass argon2 password verify. Returns a generic 401 for any failure so the endpoint cannot enumerate staff accounts. No audit row written on failure (otherwise the log would itself become an oracle).
- **`src/modules/admin/impersonate.service.ts`** — `createImpersonationToken({staffEmail, staffPassword, targetEmail, reason?})`:
  1. `verifyStaffCredentials` (Layer 2).
  2. Look up target user by email → 404 if missing.
  3. **Insert audit row** (with IP + user-agent from the request) — must succeed before token is minted.
  4. Sign a JWT with `{ userId: target, tenantId: target, impersonatedBy: { staffUserId, staffEmail } }`.
  5. Return `{ token, targetUserId, targetTenantId, redirectUrl: APP_FRONTEND_URL + '/auth/impersonate?token=...' }`.
- **`POST /api/admin/impersonate`** route in `admin.routes.ts` (admin-key-gated).
- **`src/plugins/jwt.ts`** — JWT payload type extended with optional `impersonatedBy` claim. `/api/auth/me` echoes it through verbatim (the JWT is signed with `JWT_SECRET`, so an attacker can't forge it).
- **Frontend `src/routes/ImpersonateLanding.tsx`** — `/auth/impersonate` route. Reads `?token=`, calls `login(token)`, redirects to `/dashboard`. Mirrors `OAuthCallbackPage` but kept separate so the URL surface makes intent obvious in logs.
- **Frontend `src/components/ImpersonationBanner.tsx`** — sticky yellow banner mounted above `<Routes>` in `AppRouter.tsx`. Renders only when `me?.impersonatedBy` is set. "Exit impersonation" button calls `logout()` + redirects to `/login` (the admin's original tab is unaffected because the impersonation opens in a new tab).
- **`src/scripts/seed-staff.ts`** — `npx ts-node src/scripts/seed-staff.ts <email>` flips `is_staff=true` on an already-registered user. Idempotent; refuses to flag a missing email (so a typo can't silently succeed); warns if the user is unverified or OAuth-only (staff MUST have a real password).

**Onboarding ops users:** the staff user signs up normally via `/signup` first (so they pick their own password + verify their email), then a separate ops step runs `seed-staff.ts` against their address.

### Public Marketing Pages — Section-Composition Era ✅

Three milestones that established the modular section-component pattern for non-homepage public routes.

#### About — Brand Manifesto ✅ _(2026-05-19, commit `f827342`)_
Full `/about` rewrite as a 10-section brand manifesto in a *quiet → visual CLIMAX → quiet → personal → closing* rhythm. Section 2 is the visual climax — a dual-panel composition (black + cream) inside one shared rounded silhouette, with a custom inline-SVG line-art illustration (browser frame + live yellow orb + animated speech-curl). **Framer Motion** added as a dependency for word-stagger fade-up (hero), `whileInView` fade-ups (every section), `staggerChildren` for lists, `pathLength` draw-ins on SVG paths, and `useScroll/useTransform` for Section 8's scroll-linked vertical timeline. `<MotionConfig reducedMotion="user">` wraps the page; CSS animations honour `prefers-reduced-motion` via `@media` block.

Recurring motif: `YellowUnderline` — same speed / easing / colour / thickness everywhere it appears. `AboutOrb` is decorative-only (CSS pulse + ring pulse, NOT a button — production widget Orb stays in the landing pages where clicks need to mint a voice session). Content lives in a single typed `about.content.ts` so future CMS migration is one swap.

Also shipped alongside `f827342`:
- **`PublicLayout` reveal-class fix** — observer was adding `'revealed'` but `landing.css` defines `.is-visible`. Every `.reveal*` element on `/how-it-works`, `/about`, `/pricing`, `/blog`, `/investors`, `/login`, `/signup` had been silently invisible since PublicLayout shipped. One-character fix. Pitfall now documented as a Key Convention above.
- **`ScrollToTop`** — `useLayoutEffect` inside `BrowserRouter` snaps to top on PUSH/REPLACE (no paint flash). Skips POP (preserves back-button restoration) and hash nav (preserves anchor jumps).
- **All three orb buttons** (Hero, LiveDemo, UseCases) on HomePage now call `window.AIWWidget.open()` when the marketing widget is loaded, else fall back to the in-app DemoModal. Logged-in users get the modal; unauth visitors get the real voice chat positioned near the clicked orb.

#### Voice Chat Widget Features Section ✅ _(2026-05-18, commit `e11332d`)_
New `WidgetFeatures` component on the homepage (between Differentiation and UseCases): 3×3 grid of feature cards with pure-CSS animated icons. Covers live transcript, behavioural triggers, knowledge grounding, one-widget-vs-per-page, multilingual, 3× conversion, script tag, live in minutes. Mirrors the `.step-card` chrome from HowItWorks; uses `--font-display` (Urbanist) directly rather than the legacy `--font-sora` alias. Honours `prefers-reduced-motion`. The 9-card variant `WidgetFeaturesFull` is kept as a separate export so other pages (`/how-it-works`) can reuse it verbatim.

#### `/how-it-works` Rewrite ✅ _(uncommitted on `main`)_
Rewritten as 8 modular sections under `src/routes/how-it-works/`:

| Section | Purpose |
|---|---|
| `HiwHero` | Eyebrow + headline + sub — "Three steps. Live in moments." |
| `HiwSteps` | Sign up → Knowledge → Embed (3 step cards with viz: bars / docs / script) |
| `HiwClimax` | The one-tag promise — dark panel with syntax-highlighted `<script>` snippet + custom `ClimaxIllustration.tsx` |
| `HiwUnderTheHood` | Animated cards explaining the runtime |
| `WidgetFeaturesFull` (from `landing/Sections.tsx`) | 9-card feature grid reused verbatim |
| `HiwManifesto` | Quiet brand-manifesto block |
| `HiwKnowledge` | KB pipeline explainer |
| `HiwClosing` + `HiwFooterAdjacent` | CTA + footer-adjacent quiet section |

Content in `hiw.content.ts` (typed, mirrors `about.content.ts`). Per-page CSS at `styles/hiw.css`. Wrapped in `<MotionConfig reducedMotion="user">`. Multiple ClimaxIllustration option mock-ups are in `aiw-voice-app/wireframes/climax-illustration-options{-v2,-v3,-v4}.html`.

### Usage Minutes Tracking + Retell Reconciliation ✅ _(2026-05-22, uncommitted)_

**What was built:**
- 30-day anniversary billing-cycle anchor — new `tenants.subscription_started_at` column, backfilled to `created_at` for every existing tenant. Future paid-plan flow will reset it on upgrade/renewal. Period math (`src/lib/usage/periods.ts`) derives the current cycle as `[anchor + N*30d, anchor + (N+1)*30d)`.
- **Cached-aggregate compute model** — new `usage_period_cache` table keyed by `(scope, scope_id, period_start)`. Reads always hit cache; cache regenerates lazily from a single deterministic SQL aggregate over `conversations` when stale (open periods refresh if `recomputed_at < now − 60s`; a period flips to "closed" only when `period_end + 1h grace < now` — the grace window prevents flicker right at cycle boundaries where the period identity can shift mid-request). **The Retell webhook handler is NOT modified** — it only writes `conversations` (with `ON CONFLICT DO NOTHING` for race-safety against admin backfills), and the next stale-read recomputes the affected period. This eliminates drift surfaces by design: the cache is always reproducible from the source.
- **Customer-facing APIs**:
  - `GET /api/usage/current` — account-level current period + per-widget breakdown
  - `GET /api/usage/history?periods=12` — last N periods
  - `GET /api/usage/daily?from=&to=` — daily-bucket chart data (90-day cap; uncached, direct over `conversations`)
  - `POST /api/usage/recompute` — force "Sync now" (rate-limited 6/min)
  - `GET /api/widgets/:widgetId/usage/{current,history}` — per-widget
- **Customer-facing UI**:
  - New `/usage` SPA route (period selector / headline cards / pure-SVG daily chart / by-widget breakdown table)
  - `UsageThisPeriodCard` on `/dashboard` linking to `/usage`
  - `WidgetUsagePanel` on the widget detail page showing this-cycle + last-3-cycles trail
  - `UsageBar` primitive (quota-gate-ready for next phase)
  - New "Usage" sidebar nav item
- **Admin reconciliation** — read-only Retell diff + manual write actions:
  - `GET /api/admin/widgets/:widgetId/retell-diff` pages `client.call.list()`, joins against `conversations`, returns `{missingLocally, missingInRetell, durationMismatches}` rows
  - `POST /api/admin/widgets/:widgetId/reconcile/backfill` — pulls calls from Retell via `client.call.retrieve()`, inserts `conversations` rows with `ON CONFLICT DO NOTHING` (race-safe if a late webhook lands during the backfill). Audit-logged to `provider_webhook_events` with `event_name='admin_reconcile_backfill'`.
  - `POST /api/admin/widgets/:widgetId/reconcile/update-duration` — syncs local `duration_sec` to Retell's value. Audit-logged with `event_name='admin_reconcile_update'` (note: distinct from the backfill event — dashboards filtering on event_name need both).
  - Both write paths call `recomputeAfterReconcile(widget + tenant period)` **unconditionally** before returning, so the customer-facing dashboard reflects the fix immediately rather than waiting for the 60-second staleness window.
  - `GET /api/admin/tenants/:tenantId/usage` — cross-tenant view (no JWT-tenant trust boundary)
- **Admin panel UI** — `aiw-voice-backend/admin/index.html` inspector got two new cards:
  - "Usage" — current cycle + last 3 cycles, auto-loaded on inspector open
  - "Reconciliation" — date window picker, "Run diff" button, diff table with per-row Backfill / Update buttons + "Backfill all" / "Update all" bulk actions. Post-action auto-re-runs the diff AND re-loads the Usage card in the same panel, so corrected numbers appear without a manual refresh.

**Architecture decisions locked in:**
- Anniversary cycle (not calendar month) — anchored on `subscription_started_at`
- Cached aggregates with lazy recompute, never incremental from webhooks (zero drift surface)
- Read-only Retell diff with explicit per-call admin action for fixes (no auto-backfill — keeps a human in the loop)
- New `usage_period_cache` table; old `usage_counters` left untouched (different schema shape, will be dropped later once we confirm zero readers)
- Pricing convention: `CEIL(duration_sec / 60)` per row before SUM (partial minutes round UP, matching Retell's billing)
- Half-open period intervals `[start, end)` to prevent off-by-one ambiguity on cycle boundaries

**Pricing-module hooks** (next phase — NOT in this milestone):
- `plans.max_minutes_month` already exists (nullable). `tenants.plan_id` already exists as a **non-FK-enforced** `uuid` column (per `schema/tenants.ts:24` — comment: "hook for future pricing enforcement"). The 0004 seed migration sets every tenant to `basic`. So the join is technically possible today; what's missing is a **reader** that uses it.
- Reader pattern: left-join `plans` via `tenants.plan_id` + read `usage_period_cache.minutes_used` for the current period; compare in the `/api/sessions` create handler.
- Compares in `/api/sessions` create handler → 402 if exhausted
- `widgets.status='quota_exceeded'` surfaced in `/api/widget/resolve`
- New `usage_topups` table; period_total = plan_quota + sum(topups)
- `UsageBar` already accepts a `cap` prop — just pass the plan's quota in

**Schema migration**: `0008_usage_tracking.sql` — adds `tenants.subscription_started_at` (backfilled to `created_at`) and creates `usage_period_cache` with CHECK constraint on `scope`. No new env vars.

**Files added:**
```
aiw-voice-backend/
  drizzle/0008_usage_tracking.sql
  src/lib/db/schema/usage-period-cache.ts
  src/lib/usage/{periods,recompute,read}.ts
  src/modules/usage/{usage.routes,usage.service,usage.schemas}.ts
  src/modules/admin/reconcile.service.ts
  src/providers/retell/retell.usage.ts
aiw-voice-app/
  src/api/usage.ts
  src/components/{UsageBar,DailyMinutesChart,UsageThisPeriodCard,WidgetUsagePanel}.tsx
  src/routes/UsagePage.tsx
```

**Files modified:**
- Backend: `tenants.ts` (+ `subscriptionStartedAt`), `schema/index.ts`, `app.ts` (register `usageRoutes`), `admin/admin.routes.ts` (+ 4 routes), `providers/types.ts` (optional `listCalls`/`getCall`), `retell.provider.ts`
- SPA: `DashboardPage.tsx`, `WidgetDetailPage.tsx`, `AppRouter.tsx`, `Sidebar.tsx`
- Admin panel: `admin/index.html` (Usage + Reconciliation sections)

### Auth — Email/Password signup + login ✅
- JWT-based, argon2 password hashing
- Email verification with resend support
- `users` + `tenants` tables, Drizzle ORM
- Migration: `drizzle/0000_initial.sql`

### Auth — Google & LinkedIn OAuth ✅ _(2026-04-29)_
**What was built:**
- Backend-redirect flow: browser → backend → Google/LinkedIn → backend callback → frontend. No secrets ever reach the browser.
- Stateless CSRF protection via HMAC state parameter (`{from}:{nonce}.{hmac}` signed with JWT_SECRET). No session/cookie needed.
- `oauth.service.ts` — URL builders, code exchange (native `fetch`, no new deps), user upsert with account linking by email.
- Account linking logic: look up by `(oauthProvider, oauthProviderId)` → link existing email account → create new tenant+user.
- DB migration `0001_oauth_columns.sql`: `password_hash` made nullable, `oauth_provider` + `oauth_provider_id` columns added with a conditional unique index.
- `auth.routes.ts`: 4 new GET routes (`/api/auth/google`, `/api/auth/google/callback`, `/api/auth/linkedin`, `/api/auth/linkedin/callback`).
- `auth.service.ts` patched: login() checks for null `passwordHash` and returns `USE_SOCIAL_LOGIN` error for OAuth-only accounts.
- Frontend: `OAuthCallbackPage.tsx` reads `?token=` JWT, calls `login()`, redirects to `/`. Wired in `AppRouter.tsx`.
- Login + Signup pages: Google and LinkedIn buttons, error display showing originating-page detail.
- Error redirects go back to `/login` or `/signup` (whichever initiated the OAuth), not always `/login`.

**Key env vars added (backend .env only):**
```
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
LINKEDIN_CLIENT_ID=
LINKEDIN_CLIENT_SECRET=
APP_FRONTEND_URL=http://localhost:5173
APP_PUBLIC_BASE_URL=http://localhost:3002   # (already existed)
```

**LinkedIn Developer Portal requirements:**
1. Authorized redirect URL: `{APP_PUBLIC_BASE_URL}/api/auth/linkedin/callback`
2. Product: "Sign In with LinkedIn using OpenID Connect" must be added and approved

### UI / Design System ✅
- AIWoiz design system applied to all three surfaces (app, widget, admin panel)
- Public landing page at `/` with hero, feature grid, CTA
- Widget reskinned with inlined CSS tokens
- Admin panel using shared design system assets

### Landing — Mobile Responsive + Mobile Menu ✅ _(2026-05-02, commit `646e0a3`)_
- **Mobile nav menu**: animated hamburger button (asymmetric voice-waveform line widths 16/22/11px) → animates to X on open. Slide-from-left drawer (320px) with backdrop blur, body-scroll lock, Escape-to-close. Auth-aware bottom CTA reads "Get started" when logged out (→ `/signup`) or "Your Dashboard" when logged in (→ `/dashboard`) via `useAuth()`.
- **Hero section ≤880px**: bulletproof width containment to fix overflow — `min-width: 0` + `overflow-x: clip` + `grid-template-columns: minmax(0, 1fr)` (CSS Grid implicit `min-width: auto` was the root cause). `clamp(28px, 6.5vw, 48px)` heading with `overflow-wrap: anywhere`. CTAs stack full-width. Animated graphic capped at `max-width: 90%`.
- **Hero copy reorder**: extracted `.hero-new__copy` wrapper so flex `order` reshuffles chip/heading/lead → graphic → CTAs → small text on mobile.
- **Stats marquee**: fixed loop-jump bug (`width: max-content` so `translateX(-50%)` scrolls by content width not viewport width; padding-right replaces gap to land loop boundary cleanly). 1.5× faster scroll on ≤880px.
- **768px → 880px breakpoint**: raised collapse breakpoint so footer, solution, usecases, and diff-table sections collapse correctly at tablet width.
- **`html { overflow-x: hidden }`** belt-and-suspenders to prevent horizontal scroll edge cases.

### Email Verification URL Fix ✅ _(2026-05-02, commit `646e0a3`)_
Verification email link was built from `APP_PUBLIC_BASE_URL` (backend, port 3002 / `api.<domain>`) instead of `APP_FRONTEND_URL` (SPA). Clicking the link hit Fastify with no `/verify-email` route → 404. Patched both signup and resend-verification flows in `auth.service.ts` to use `APP_FRONTEND_URL`. Also renamed `APP_BASE_URL` → `APP_PUBLIC_BASE_URL` in `.env` to match what `env.ts` reads.

### Widget Polish + Anti-Abuse — M2 ✅ _(2026-05-12, uncommitted)_

Five batches of widget-facing work, each scoped as a separate PR for bisectable history. Full session log in `handoff.md`; plan at `~/.claude/plans/Widget-Feature-implementation-v2.md`.

1. **PR 1 — Transcript polish + mic constraints.**
   - `aiw-voice-widget/src/components/transcript.ts`: `wasPinned` measured *before* the DOM diff (fixes "new lines hidden at bottom"); append-suffix instead of full-textContent replacement for cumulative-streaming updates (fixes whole-bubble re-layout flicker).
   - `aiw-voice-widget/src/components/mic.ts`: explicit `{ noiseSuppression, echoCancellation, autoGainControl }: true` constraints on `getUserMedia`.
   - `aiw-voice-backend/docs/retell-agent-tuning.md` (new) — ops doc for `interruption_sensitivity: 0.3`, `enable_backchannel: false`, `end_call_after_silence_ms: 15000`. Settings live in docs (not code) because the backend doesn't programmatically configure Retell agents today.

2. **PR 2 — Plan-gated "Powered by ai&humn" footer.**
   - Migration `0004_seed_plans.sql`: seeds three rows in `plans` (`basic` / `intermediate` / `advanced`) and assigns existing tenants to `basic`.
   - `widget.service.ts` left-joins `plans` via `tenants.planId` and returns `show_powered_by = (plans.code === 'basic')`. Falls back to `true` on a null join — safer to over-attribute than to silently white-label a misconfigured tenant.
   - Widget renders an `<a target="_blank">` footer below the controls row when `show_powered_by` is true. `applyFooterVisibility()` runs from both `bootstrap()` (paints on first load) and `startCall()` (idempotent).

3. **PR 3 — Silence detection + soft prompt.**
   - New `aiw-voice-widget/src/components/silence-prompt.ts` — shadow-DOM toast with title + countdown + "I'm here" button.
   - Timer logic in `widget.ts`: 1-second `setInterval` compares `Date.now() - lastUserTurnAt`. At 8 s → show toast with 5-second countdown. At 13 s → `voice.stop()` with a friendly transcript note. Resets on any user-text growth in `onTranscriptUpdate`. Cleared on call end / error / modal close / restart.
   - Retell agent's `end_call_after_silence_ms: 15000` (per PR 1's doc) is the hard backstop if the client-side timer fails.

4. **PR 4 — Rate limit tightening.**
   - `aiw-voice-backend/src/modules/sessions/sessions.routes.ts`: `/api/sessions` rate limit changed from `30/min` to `30/hour` per IP. Targets scripted abuse of Retell minutes.
   - Widget: new `RateLimitedError` class thrown on 429. Caught in `widget.ts` → friendly transcript message, orb disabled for the remaining cool-down. Persisted via `sessionStorage` keyed by widget token so a page reload doesn't bypass the cool-down. `applyRateLimitCooldown()` in `init()` re-applies the disable on next load if still within the window.
   - **Explicitly NOT built** (user-locked decisions): concurrent-session check, `active_sessions` table, `client_id` plumbing, per-tenant monthly quota.

5. **PR 5 — Inline email/phone capture + signed-JWT visitor identity (opt-in).**

   *(a) Widget secret + toggle.* Migration `0005_visitor_identity.sql` adds `visitor_identity_enabled boolean NOT NULL DEFAULT false` and nullable `widget_secret text` to `widgets`. `widgets.service.ts`: `setVisitorIdentityEnabled` lazily generates a 64-char hex secret via `crypto.randomBytes(32).toString('hex')` on first enable; `rotateWidgetSecret` rotates it (409 if feature disabled). Three new auth-required routes on `/api/widgets/:widgetId/...`: `PATCH visitor-identity`, `POST rotate-secret`, `POST visitor-token/verify` (debug endpoint for the dashboard's "Test now" button).

   *(b) JWT verifier.* `aiw-voice-backend/src/lib/jwt/visitor-token.ts` (new) — single-purpose HS256 verifier using Node's built-in `crypto.createHmac` (no new dependency). Header alg pinned to HS256; timing-safe signature compare; rejects expired `exp`, future `iat` (60s skew), malformed shape. Returns structured error code (`INVALID_SIGNATURE | TOKEN_EXPIRED | MALFORMED`). Strips unknown claims — only `sub`, `email`, `phone`, `name`, `iat`, `exp` propagate.

   *(c) Session flow.* `sessions.service.ts`: three branches on `visitor_token` — silently ignore when widget has feature off (don't error; the widget may be embedded across pages that vary); verify + carry claims when feature on + token valid; 401 with specific code when feature on + token bad. Verified claims surface back to the widget as `visitorClaims` in the session-create response AND into the Retell `metadata` blob (`visitor_email`, `visitor_phone`, `visitor_name`, `visitor_external_id`) for post-call data joining. The agent itself does NOT see these (per existing comment about node-based agents ignoring `retell_llm_dynamic_variables`).

   *(d) Widget-side transport.* `aiw-voice-widget/src/index.ts` reads optional `data-visitor-token` attribute alongside `data-widget-token`. New public API `window.AIWWidget.identify(token)` for SPAs that get the token after their auth completes. `widget.ts` stores `visitorToken` + `visitorClaims`, passes the token to `createSession` on every call, caches returned claims for pill pre-fill. `InvalidVisitorTokenError` caught with a clear in-widget error — no silent fallback that would mask integration bugs.

   *(e) Inline capture pill.* New `src/utils/contact-intent.ts` — `detectAgentAsk(text)` requires `?` ending + curated email/phone/name keyword match (name patterns checked FIRST: `full name` / `your name` / `first and last name` / `may i (have|get|know) your name` / `what(?:'s| is) your name` / `can i (have|get) your name` / `introduce yourself` / `who am i (speaking|talking) (to|with)`); `detectUserContact(text)` requires strong contact-shape regex AND **deliberately does NOT return `kind='name'`** — names are too ambiguous in free text, so only email + phone can be user-volunteered. Tuned conservatively (high precision, lower recall). New `src/components/inline-capture.ts` — shadow-DOM pill below transcript with three input modes per kind: `email` (type=email, placeholder "you@example.com"), `phone` (country-code dropdown +1/+44/+91/… + tel input, submits as "+CC NNNNNN"), `name` (type=text, placeholder "Jane Smith", maxLength **120**). Three visibility states (empty / pre-filled "Use [value]?" / confirm user-volunteered value). Widget runs the detectors on every `onTranscriptUpdate`, surfaces the pill at most once per `(kind, call)` via a `capturedKinds` Set (kind is added to the Set on dismiss OR submit), classifies the source on submit (`agent_asked | user_volunteered | pre_filled`), POSTs to a new `/api/sessions/:id/captures` endpoint (public, widget-token-gated, **60/hour rate limit per IP** — symmetric with the 30/hour session limit, allowing ~2 captures per session). New `session_captures` table (migration `0006`) with widget+tenant FKs, kind/source CHECK constraints, and indexes on `widget_id` + `provider_call_id`. Captures are NEVER reconciled against transcript entries — submitted captures surface in the transcript via `transcript.appendInline(...)` which sets the `sealed` flag (immune to Retell's per-tick shrinking arrays).

   *(f) Dashboard.* New `aiw-voice-app/src/routes/VisitorIdentityCard.tsx` — mounted in `WidgetDetailPage` below the triggers panel. When OFF: collapsed strip + Enable button. When ON: secret display (mask / show / copy / regenerate with confirm-dialog warning), 5-step inline integration instructions with Node/Python/PHP/Ruby tabs and copy-pasteable code blocks, working "Test now" debug input.

   *(g) Customer-facing doc.* `aiw-voice-backend/docs/host-site-identify.md` (new) — comprehensive integration guide: JWT spec table, full code samples per language, both transport options with framework-specific notes, DevTools verification walkthrough, `openssl` one-liner for hand-signed test tokens, security guidance, troubleshooting matrix for each error code, API reference.

**Migrations added (NOT yet run against any DB):** `0004_seed_plans.sql`, `0005_visitor_identity.sql`, `0006_session_captures.sql`. Apply via `npm run db:migrate` in `aiw-voice-backend/` after pulling.

**Local env fix-ups surfaced during the session:**
- `aiw-voice-backend/.env`'s `DATABASE_URL` should use `127.0.0.1`, NOT `localhost`. On Windows, `localhost` → IPv6 `::1` → Docker binds IPv4 → `AggregateError` at `internalConnectMultiple`. Already documented in this file; just not patched in the live `.env`.
- `aiw-voice-app/.env.development` ships with `VITE_MARKETING_WIDGET_TOKEN=<placeholder>`. For local testing, set to the seed widget's token `wt_vdkk_local_dev_token_001` (from `aiw-voice-backend/src/scripts/seed.ts`) and `VITE_MARKETING_WIDGET_BUNDLE_URL=http://localhost:8000/dist/aiw-voice-widget.js`.

**Verified:** all three packages typecheck clean; widget bundles at 523.6 KB at M2 PR5 lock-in (later 527.6 KB post-PR1 follow-up; **535 KB after the e8a4491 rebuild** that landed name-kind support). **Pending:** commit (preferably as 5 separate commits, one per PR), push, migrations applied, end-to-end smoke against a real call.

#### PR 1 follow-up — Transcript reconciler hardening _(2026-05-13, uncommitted)_

Live-call smoke testing surfaced a string of transcript-display bugs all rooted in how `setEntries` reconciles Retell's per-tick snapshot against `retellEntries`. Retell's array is not a clean append-only event log — during user interruption it emits truncated turns, tail-fragment echoes, ASR partials that never finalise, and cumulative-growth entries that span an interrupt boundary. Each one needed its own pass.

`aiw-voice-widget/src/components/transcript.ts` is now a **6-step pipeline** inside `setEntries`:

1. **`dropInlineOrphans`** (incoming, pre-merge) — drops a short (≤ 12 chars AND no sentence-final punct) opposite-role entry sandwiched between two same-role neighbours. Lets `mergeConsecutiveSameRole` collapse the surrounders. Symptom without: user's interrupted question "Where is" / agent fragment "Vitamin D" / user "is your" as three separate bubbles.
2. **`mergeConsecutiveSameRole`** (unchanged from M2) — exact-dup drop, prefix-growth keep-longer, fall-through concat.
3. **`applySplitPrefixes`** (incoming, post-merge) — strips silence-breadcrumb prefix snapshots from incoming. Iteration left-to-right through `retellEntries` so chained breadcrumbs (two taps in one call) strip in order. Empty post-strip turns dropped.
4. **Main reconcile loop** — `findMatchFromEnd` now skips `sealed` entries in Pass 1+2; Pass 3 (same-role-at-end fallback) bails entirely if the last entry is sealed (chronological boundary — new content appends after the breadcrumb, never matches the in-flight turn above it). Superset guard at the call site protects against ASR rollback shrinkage.
5. **`collapseOrphans`** (`retellEntries`, post-reconcile) — evicts ABA orphans that emerge retroactively when the third surrounding entry arrives in a later update. Drops mid only — never merges prev+next contents (concatenation would create retransmission duplicates because Retell keeps sending the original separate entries on the next tick).
6. **`dropSameRoleEchoes`** (`retellEntries`, post-collapse) — final pass. Walks pairs (a, b) with at most ONE non-sealed entry between (adjacent OR separated by one user turn). Drops the shorter row from model + DOM when contents match exactly, or one is a clean **prefix or suffix** chunk of the other. Catches "VD is important for both women" appearing back-to-back after `collapseOrphans` removed a short user partial; agent "...assist" followed by tail-echo "assist"; agent "...Let me know" → user "Yeah. Can you" → agent "supplement? Let me know" (cross-user-turn suffix echo).

**`trimTrailingOrphans()`** is a separate public method called from `widget.ts` on `onCallEnded` / `onError`. Evicts stale trailing ASR partials (e.g. an aborted "Is" the user said while trying to interrupt) so the post-call transcript doesn't end with orphan noise above "CALL ENDED".

**Sealed entries + `splitPrefix`.** `retellEntries` elements now optionally carry `sealed: true` and `splitPrefix: string`. Set only by `appendInline()` — a new public method on `TranscriptPanel` for chronologically-anchored local breadcrumbs (currently just "I'm still here." from the silence prompt button). Sealed = invisible to all match passes, never collapsed, never trimmed. `splitPrefix` is the content snapshot of the previous in-flight retell entry at the moment the breadcrumb was inserted; `applySplitPrefixes` uses it to route post-breadcrumb cumulative-growth to a fresh bubble below the breadcrumb instead of swallowing back into the pre-breadcrumb bubble.

**Widget wiring change.** `dismissSilencePrompt(resetTimer=true)` now calls `this.transcript.appendInline('user', "I'm still here.")` instead of `append(...)`. The old tail-zone `append` pinned the breadcrumb to DOM-bottom and every subsequent retell turn inserted above it — visually the breadcrumb stayed at the bottom with the conversation piling up overhead.

**Heuristic constants** (tunable in `transcript.ts`): `MAX_ORPHAN_CHARS = 12`, `SENTENCE_END = /[.!?]\s*$/`. Conservative on purpose — real short turns ("Yes.", "What?", "OK.") finalize with punctuation from ASR and pass through untouched.

**Files changed (delta on top of M2 PR1):**
- `aiw-voice-widget/src/components/transcript.ts` — new methods: `appendInline`, `dropInlineOrphans`, `collapseOrphans`, `trimTrailingOrphans` (public), `applySplitPrefixes`, `dropSameRoleEchoes`, `isOrphanContent` (static); sealed-skipping in `findMatchFromEnd`; `retellEntries` element type extended with `sealed?` + `splitPrefix?`.
- `aiw-voice-widget/src/widget.ts` — `dismissSilencePrompt` → `appendInline`; `onCallEnded` + `onError` call `trimTrailingOrphans`.

**Verified:** worktree + main repo byte-identical; both typecheck clean; bundle 527.6 KB (was 523.6 KB pre-follow-up). No new env vars, no new migrations — pure widget bundle replacement.

### Typography refresh + AI&Humn wordmark logo ✅ _(2026-05-14, commits `e58a51a` / `eb57683` / `cca3cbf`, deployed to DEV)_

Brand-visual pass shipped across the frontend, customer dashboard, and admin panel.

**Font system swap** (`cca3cbf`) — `aiw-voice-app/src/design-system/colors_and_type.css`:
- `--font-display`: `Libre Baskerville` → **`Urbanist`** (geometric with rounded terminals; matches the new Monr-based wordmark's letterform DNA)
- `--font-body`: `Inter` → **`DM Sans`** (humanist, ~5% wider — reads better at the existing 14px body size)
- `--font-mono`: `Geist Mono` (unchanged — already pairs)
- Minimal-diff: only the `@import url(...)` and the three `--font-*` tokens changed. Sizes / weights / line-heights untouched. Original Inter + Libre Baskerville values preserved as commented twins inline for trivial rollback.
- `landing.css` bonus fix: the previously-undefined `--font-sora` token (used in ~10 landing-page declarations, silently falling back to system-ui) now aliases to `var(--font-display)` — single source of truth.

**Wordmark logo asset** (`e58a51a`) — `aih-logo.svg` shipped to both:
- `aiw-voice-app/src/design-system/assets/aih-logo.svg` (bundled by Vite)
- `aiw-voice-backend/admin/design-system/assets/aih-logo.svg` (served as static file by `@fastify/static`)
- Monr-based, 420×67 viewBox (~6.27:1 ratio), fill `#feca00`

**Wordmark wired into 3 headers** (`eb57683`):
| Header | Replaced | Logo height |
|---|---|---|
| Landing TopNav (`Atoms.tsx` `<Wordmark/>`) | `<h3>ai&humn</h3>` | 32px |
| Dashboard sidebar (`Sidebar.tsx`) | 28px circle + "AIWoiz" text | 24px |
| Admin topbar (`admin/index.html`) | brand-mark circle + `<h1>` | 24px |

All `width: auto` so the SVG's natural ratio is preserved. Vite SVG-URL imports via `/// <reference types="vite/client" />` — no new ambient declarations needed.

**Deliberately out of scope** (header nav only):
- `.auth-card__brand` text inside login/signup cards (body content; PublicLayout's TopNav above already shows the SVG)
- `footer__brand-name` in landing footer
- All inline body-copy mentions of "AI & Humn" across About / Investors / Contact / landing sections

**Cosmetic cleanup deferred** (not yet done):
- Old wordmark SVGs in `aiw-voice-app/src/design-system/assets/{logo,wordmark-dark,wordmark-aih-dark}.svg` likely unused now. Hardcode `font-family="Inter"` inside `<text>` elements (SVG `<text>` ignores CSS). Audit imports + delete in a separate pass.
- `aiw-voice-app/src/design-system/fonts/README.md` still documents Inter + Libre Baskerville. Stale.
- Local Libre Baskerville `@font-face` declarations at top of `colors_and_type.css` left intact — zero cost when unreferenced, easy re-enable for rollback.

**Deployed:** dev VPS pulled both commits, `docker compose build app api && up -d app api` on `/opt/aiw/dev/`. Live on `app.aiworkfllow.com` (Urbanist + DM Sans + logo in sidebar) and `api.aiworkfllow.com/admin/` (logo in topbar).

**Rollback paths** (all three valid):
1. Uncomment the original `@import` + `--font-*` triplet in `colors_and_type.css` (preserved inline).
2. `git revert cca3cbf eb57683` — reverts both feature commits cleanly. Asset commit `e58a51a` stays.
3. Per-concern revert: `git revert eb57683` (logo only) or `git revert cca3cbf` (fonts only).

### Backend `/admin/` Panel Static Files ✅ _(2026-05-02, commit `548d7b4`)_
`@fastify/static` serves the admin panel at `/admin/` from `path.join(__dirname, '..', 'admin')` (i.e. `/app/admin` at runtime). The Dockerfile never copied that folder into the runtime image, so the plugin failed to register and the route 404'd in deployed envs. Added `COPY --from=builder /app/admin ./admin` to the runtime stage. **VPS rebuild pending** — once `docker compose build api && up -d api` runs on `/opt/aiw/dev/`, `https://api.aiworkfllow.com/admin/` will serve the panel.

### VPS Deployment — DEV + PROD on `4403b7e` (Widget Anti-Abuse Phase 1 + 4) ✅ _(2026-06-10)_

Deployed the widget anti-abuse payload to both envs in one controlled session. Source fast-forwarded `4099423 → 4403b7e`; the only new migration in range is `0025_spike_alerts` (Phase 4) — Phase 1 (`4099423`) shipped no migration, and `4403b7e` is the handoff/docs commit. Built `migrate api app widget` + `up -d` per env (migrate by name, per the migration-bearing-deploy rule).

**Payload:**
- **Phase 1 — front-door gates** (`4099423`): `event.isTrusted` gate, resolve→sessions HMAC challenge (`X-AIW-Challenge`), engagement gate. Deployed in **shadow mode** — `AIW_REQUIRE_CHALLENGE` / `AIW_REQUIRE_ENGAGEMENT` left unset (default `false`), so the widget sends the header + engagement signal but the backend only logs, never rejects. Both Phase 1 markers (`X-AIW-Challenge` + `/api/widget/engagement`) confirmed present in the served `aiw-voice-widget.js` on both envs.
- **Phase 4 — spike detection + alerts** (`a1d20cc`): hourly spike-detection cron, admin "Spike Alerts" page, customer Activity Health card + self-pause. **Fully active on deploy** — the cron is NOT gated by the shadow flags (registered hourly, `lock_keys.spike_detection: 2085550514` = `0x7c4ef9b2`, on both envs).

**Migration `0025_spike_alerts` applied cleanly on both** — DEV + PROD each logged `journal has 26 entries; db has 25 applied; 1 pending: 0025_spike_alerts` → `[apply]` → `Migrations complete: applied 1.`, and `db:status` reports **26/26, 0 pending, 0 drift**, zero `[orphan-fix]`. (Benign `42P06`/`42P07` "schema/relation already exists, skipping" NOTICEs from drizzle's idempotent bootstrap are normal, not errors.)

**Per-env env changes (`/opt/aiw/{dev,prod}/.env`):**
- `WIDGET_CHALLENGE_SECRET` set to a real `openssl rand -hex 32`, **distinct per env**, independent of `JWT_SECRET` / `RETELL_API_KEY` / widget secrets. **Gotcha:** the literal placeholder `<DEV_SECRET_FROM_PREFLIGHT>` got pasted into dev `.env` on first pass — replaced with a real value via `sed` + `docker compose up -d api`. It boots either way in shadow mode, but a placeholder secret becomes a real hole the moment enforcement flips.
- Phase 4 spike vars left at defaults (`OPS_SPIKE_ALERT_EMAIL=updates@aiandhumn.com`, `AIW_SPIKE_MULTIPLIER=2`, `AIW_SPIKE_FLOOR=10`). `AIW_CRON_DISABLED=false` confirmed (cron must run); prod `PADDLE_API_BASE=https://api.paddle.com` confirmed untouched.

**Smoke (both envs green):** `/health` 200; widget bundle carries both Phase 1 markers; `cron: scheduler registered (… + hourly spike-detection)`; `GET /api/admin/spike-alerts` → `{"ok":true,"alerts":[],…,"unacknowledged":0}`; admin panel "Spike Alerts" nav + customer Activity Health card render.

**Route-path note:** the admin spike-alerts **JSON API** is `GET /api/admin/spike-alerts` (registered with `{ prefix: '/api/admin' }` + handler `/spike-alerts`) — **not** `/admin/spike-alerts`. `/admin/#spike-alerts` is the static panel's client-side hash route (the LHS-nav UI), a separate surface. Same `/api/…`-embedded convention as the rest of the backend.

**Deferred follow-ups (not autonomous — owner's call):**
- **Phase 1 enforcement flip** — bake ~1 week in shadow, grep api logs for missing/invalid-challenge warnings to confirm legit traffic carries the header, then flip `AIW_REQUIRE_CHALLENGE=true` (+ `docker compose up -d api`), bake, then `AIW_REQUIRE_ENGAGEMENT=true`. Rollback = unset flag + restart.
- Optional prod `AIW_SPIKE_FLOOR` bump (e.g. `25`) to suppress early false-positive customer emails while 14-day baselines accumulate.

### VPS Deployment — DEV on `5ca3ddc` (Pricing Flow F1-F11) ✅ _(2026-06-01)_

Rolled up the **complete pricing-flow payload — Features 1 through 11** to DEV in one controlled chunk-by-chunk session. **PROD still on `c6520a4`** — same payload deployment planned next.

**Payload from `d1256c5` → `5ca3ddc`** (16 commits, 14 new migrations `0011 → 0024`):

| F | Feature | Commit | Migrations |
|---|---|---|---|
| F1 | Dynamic geo-localized pricing | `bda2e20` | `0011_plans_pricing_extension` |
| F2 | Subscription model + period snapshots | `f56f398` | `0012_subscriptions` |
| F9 | Payment gateway adapters (Razorpay + Paddle) | `d0032dc` | `0013_payment_methods`, `0014_billing_idempotency` |
| F7 | Tax module (Indian GST + Paddle MoR) | `0fee171` | `0015_tax_invoices` |
| F8 | Checkout + coupons | `eac88a7` | `0016_coupons`, `0017_plan_gateway_ids`, `0018_coupon_constraints` |
| F4 | Replenishment cron + cycle audit | `3f88fa6` | `0019_cycle_closed_at` |
| F5 | Top-ups (SKU catalogue + FIFO + expiry) | `4be9a1e` | `0020_topups` |
| F6 | Quota gating (two-gate + Retell max_duration) | `90bd310` | `0021_widget_service_status`, `0022_topup_debited_per_cycle` |
| F3 | Per-plan entitlements | `db17452` | (none — code-level filter) |
| F10 | Plan upgrade/downgrade/cancel/resume lifecycle | `75be9a6` | `0023_subscription_change_events` |
| F11 | Email automation | `5ca3ddc` | `0024_email_automation` |

**Migrations applied cleanly**: all 14 in monotonic order via the self-healing migrator, **zero `[orphan-fix]` lines**, `Migrations complete: applied 14.` `npm run db:status` reports 25 entries / 25 applied / 0 drift.

**New runtime env vars added to `/opt/aiw/dev/.env`**:
- `AIW_SRC=/opt/aiw/src` (from `980be47` — required for compose's `${AIW_SRC}` interpolation)
- `AIW_CRON_DISABLED=false` (F4 cron kill-switch — default `false` so cron runs)
- `RAZORPAY_KEY_ID=`, `RAZORPAY_KEY_SECRET=`, `RAZORPAY_WEBHOOK_SECRET=` (BLANK — fail-closed by design; Razorpay KYC pending)
- `PADDLE_API_KEY=`, `PADDLE_WEBHOOK_SECRET=` (BLANK — same)
- `PADDLE_API_BASE=https://sandbox-api.paddle.com` (sandbox for DEV; PROD must use `https://api.paddle.com`)

**Smoke matrix — Feature 1-11 verification on DEV**:
- F1 → `GET /api/plans` returns INR+USD prices, `detectedCountry:"US"`, `defaultCurrency:"USD"` ✅
- F2 → 7 `/api/account/subscription*` routes → 401 (auth-gated) ✅
- F3 → entitlements filter wired through F2 surfaces ✅
- F4 → API logs: `replenishment: tick complete (scanned: 0)` at hourly cadence ✅
- F5 → 3 `/api/account/topups/*` routes → 401; logs: `topup-expiry: no rows due to expire` daily ✅
- F6 → service-status admin route + `/sessions` quota check (code-level) ✅
- F7 → `/api/account/tax-details` + `/api/account/invoices` → 401 ✅
- F8 → `/api/billing/checkout/{preview,complete}` + `/coupons/validate` → 401 ✅
- F9 → `/api/account/payment-methods/*` → 401; `/api/webhooks/{razorpay,paddle}` → **503** (correct: blank creds → `GatewayNotConfiguredError`) ✅
- F10 → `change-plan/preview`, `change-plan`, `cancel`, `cancel-pending-change`, `resume` → 401 ✅
- F11 → API logs: `email_dispatch_interval_ms: 120000` (2-min cron) ✅

**Route prefix surprise**: F2/F5/F7/F9/F10 routes are registered via `app.register(routes)` *without* a `{ prefix: '/api' }` argument — the routes embed their full URL (`/api/account/*`) inside. My initial smoke tests guessed `/api/billing/payment-methods` and 404'd; the real path is `/api/account/payment-methods`. F8 checkout DOES live under `/api/billing/checkout/*`. This split (`/api/account/*` for SPA-facing user data, `/api/billing/*` for transactional billing actions) is the intended convention going forward.

**Bug noted (non-blocking)**: `npm run db:status` invokes `ts-node` which isn't in the production runtime image (`npm ci --omit=dev` strips it). Workaround used: `docker compose run --rm api node dist/scripts/db-status.js`. Fix is a one-line edit in `aiw-voice-backend/package.json` to point the script at compiled JS. Spawned as follow-up.

**Deploy HEAD**: `5ca3ddc` (DEV). **Next**: same SOP to PROD, with `PADDLE_API_BASE=https://api.paddle.com` instead of sandbox.

### VPS Deployment — DEV + PROD on `c6520a4` ✅ _(2026-05-23)_

Rolled up **18 commits since `7ee8111`** to both DEV and PROD in one controlled sequence. Headline payload: self-healing drizzle migrator + monotonic-timestamp validation (`c6520a4`), usage tracking + leads + Retell reconciliation (`e8a4491`, adds migrations `0007`/`0008`/`0009`), master ops impersonation (`f1affe5`), About page + How-it-works rebuild + Wizard Step 4 redesign, AI&Humn wordmark + Urbanist/DM Sans typography refresh, sourcemap leak fix, widget M2 polish + visitor identity, marketing widget URL/env corrections, auth-gated marketing widget, conversations table + RetellAI webhook + dashboard stats API.

**Migrations applied cleanly on both envs**: `0007_session_captures_name`, `0008_usage_tracking`, `0009_impersonation`. No `[orphan-fix]` entries on either side → both DBs were in clean monotonic state. The new self-healing migrator is now the live migration path going forward, ready to catch any future out-of-order journal merges before they silently fail.

**Smoke tests all green** on both envs:
- `api.{aiworkfllow,aiandhumn}.com/health` → 200 (8ms / 10ms)
- SPA root → 200
- `widget.{aiworkfllow,aiandhumn}.com/aiw-voice-widget.js` → 200
- API logs clean: "Server listening" + healthy `/health` request, no errors

**Build-context note**: the cached `[builder 6/6] RUN npm run build` layer raised a false alarm during the DEV build (CACHED despite a source change), but `head -3 /opt/aiw/src/aiw-voice-backend/src/lib/db/migrate.ts` confirmed the source pull reached the file and the compose contexts at `/opt/aiw/{dev,prod}/docker-compose.yml` correctly point at `/opt/aiw/src/aiw-voice-{backend,app,widget}`. Verified the new migrator was actually baked into the image by checking compiled JS line count (209) and `grep -c "orphan-fix"` (2). The "compose build-context cleanup" follow-up (kill the historical sed-patch workaround) is still on the list — turned out fine this round but worth removing.

**Deploy commit**: `c6520a4`. **Follow-ups still pending** (not deploy-blocking): `www.aiandhumn.com` CNAME in Cloudflare, marketing widget `.env.production` token/URL, `/privacy-policy` + `/terms` SPA routes for prod OAuth consent screen publication.

### VPS Deployment — PROD LIVE ✅ _(2026-05-17)_

Prod is live at `https://aiandhumn.com` (apex), `https://api.aiandhumn.com`, `https://widget.aiandhumn.com`. Smoke tests: API health, SPA root, deep links (`/pricing` etc.), widget bundle, admin panel — all 200. Signup + email verification + Google OAuth + LinkedIn OAuth verified end-to-end in incognito.

**Apex SPA difference from dev**: prod nginx user-include is at `/etc/nginx/conf.d/users/aiandhumn/aiandhumn.com/proxy.conf` (apex, NOT `app.aiandhumn.com/`). It has an `if ($host = "www.aiandhumn.com") return 301 https://aiandhumn.com$request_uri` block above the SPA proxy. Dev still uses the `app.aiworkfllow.com` subdomain pattern unchanged.

**Outstanding (small)**: `www.aiandhumn.com` CNAME pending in Cloudflare DNS — once added, www→apex 301 redirect activates automatically (nginx config is already in place).

Deploy commit: `7ee8111`. See "PROD DEPLOY LIVE" section in handoff.md for the full chunk-by-chunk runbook + exact env keys verified.

### VPS Deployment — DEV LIVE ✅ _(2026-05-02)_
**Dev environment deployed and verified end-to-end on `aiworkfllow.com`.** Prod (`aiandhumn.com`) pending user out-of-band tasks (DNS + WHM cPanel account creation). See `.claude/plans/snappy-leaping-willow.md` and the **Deployment** section above for the full plan.

**Dev deployment summary:**
- All 6 cPanel subdomain certs auto-issued via AutoSSL
- Docker stack (postgres + migrate + api + app + widget) running healthy on `127.0.0.1:{3010,5173,5174}` per env-port allocation
- nginx user-include `proxy.conf` files installed and verified working
- End-to-end flows verified: email signup + verification email delivery, Google OAuth, LinkedIn OAuth, login, BrowserRouter deep links (`/signup`, `/dashboard`)
- CORS delegator working: SPA endpoints get explicit allowlist origin (`https://app.aiworkfllow.com`); public widget endpoints reflect any origin

**Lessons learned during dev deploy** (folded into final compose + nginx files):
1. **`location ^~ /` triggers nginx duplicate-URI error** when alongside cPanel's auto-generated `location /`. Even though nginx docs say modifiers create distinct locations, EA-nginx 1.29 rejects this. **Fix used**: per-subdomain path-specific overrides:
   - `api.<domain>`: `^~ /api/`, `^~ /admin`, `= /health` (no `/` override)
   - `app.<domain>`: `~ ^/(?!\.well-known/)` regex (regex doesn't collide with prefix locations, lookahead preserves AutoSSL)
   - `widget.<domain>`: `= /aiw-voice-widget.js` (single file)
2. **`COPY --from=builder /app/data ./data`** in `aiw-voice-backend/Dockerfile` was failing on fresh clones because `data/` is in the root `.gitignore`. The data was only used by disabled modules (analytics, guardrails-test, kb-text-fallback) so the COPY was removed entirely.
3. **`tsc --noEmit` exits 0 on TS6133 (unused locals)** in some shells but `tsc && vite build` in the Docker builder does NOT — caught a stale `wordmarkSrc` import in `landing/Atoms.tsx` that had to be removed.
4. **Compose build-context relative paths break under symlinks**: with `/opt/aiw/dev/docker-compose.yml` symlinked to the repo, `context: ../../aiw-voice-backend` resolves from the symlink path to `/opt/aiw-voice-backend` (wrong) instead of the target's location. Current workaround on VPS: replace symlink with a copy and `sed` the contexts to absolute paths (`/opt/aiw/src/aiw-voice-backend`). Repo-level fix deferred to a follow-up — see "Plan approved, code work" todos.
5. **cPanel email auth is SMTPS port 465 (implicit TLS)** — `mailer.ts` already handles this correctly via `secure: env.SMTP_PORT === 465`.
6. **The `aiworkfllow` cPanel user has Docker access** (rootless or `docker` group) — non-standard for cPanel. Worth knowing if we ever need to run commands as the user instead of root.

**Decisions locked in:**
- Same VPS for dev + prod (cPanel/WHM server, IP `65.254.80.37`)
- Subdomains split by role: `app.`, `api.`, `widget.` per domain
- Reuse existing host nginx (cPanel-managed via EA-nginx) with user-include proxy configs
- Manual SSH deploys now; GitHub Actions push-to-deploy as a follow-up

**Status checklist:**
- [x] Plan approved
- [x] VPS diagnostic complete
- [x] `aiw-voice-app/.env.production` + `.env.staging`
- [x] `aiw-voice-app/Dockerfile` + `nginx.conf` + `.dockerignore`
- [x] `aiw-voice-widget/Dockerfile` + `nginx.conf` + `.dockerignore`
- [x] `aiw-voice-backend/Dockerfile` patch
- [x] `aiw-voice-backend/src/plugins/cors.ts` patch
- [x] `deploy/{dev,prod}/docker-compose.yml` + `.env.example`
- [x] cPanel proxy include configs (6 files — final strategy: `^~ /api/`, `^~ /admin`, `= /health` for api; `~ ^/(?!\.well-known/)` regex for app; `= /aiw-voice-widget.js` for widget)
- [x] `deploy/README.md`
- [x] `.gitignore` allowlist for `deploy/`
- [x] **DEV: Subdomain DNS + cPanel + AutoSSL** (`{app,api,widget}.aiworkfllow.com`)
- [x] **DEV: OAuth apps registered** (Google + LinkedIn, dev redirect URI added to existing localhost apps so credentials shared)
- [x] **DEV: SMTP** — `widget@aiworkfllow.com` via `mail.aiworkfllow.com:465`
- [x] **DEV: Secrets generated** (`JWT_SECRET`, `ADMIN_API_KEY`, `POSTGRES_PASSWORD` via `openssl rand`)
- [x] **DEV: Legacy `aiw-voice-backend-api-1` container retired**
- [x] **DEV: Stack built + running** (`docker compose up -d` in `/opt/aiw/dev/`)
- [x] **DEV: cPanel proxy includes installed** (`/etc/nginx/conf.d/users/aiworkfllow/{api,app,widget}.aiworkfllow.com/proxy.conf`)
- [x] **DEV: Smoke tests passed** — health, signup, login, OAuth (Google + LinkedIn), CORS, DB writes, BrowserRouter deep links
- [ ] **PROD: Out-of-band (user)** — Add `aiandhumn.com` as cPanel account in WHM
- [ ] **PROD: Out-of-band (user)** — DNS: 3 A-records for `{app,api,widget}.aiandhumn.com` → `65.254.80.37`
- [ ] **PROD: Out-of-band (user)** — Create 3 subdomains in cPanel UI; trigger AutoSSL
- [ ] **PROD: Out-of-band (user)** — Register separate Google + LinkedIn OAuth apps (NOT shared with dev) with prod callback URIs
- [ ] **PROD: Out-of-band (user)** — Decide on prod SMTP sender (probably `noreply@aiandhumn.com` once email is set up)
- [ ] **PROD: Out-of-band (user)** — Generate fresh secrets per env (DO NOT reuse dev's)
- [ ] **PROD: Stack build + deploy** (parallel of Chunks A.2–F)
- [ ] **PROD: Smoke tests**

**Deferred (will do after prod is live, or sooner if blocking):**
- [x] Patch `aiw-voice-backend/Dockerfile` to `COPY admin/` so `/admin` static panel works — **done in commit `548d7b4`**, pending VPS `docker compose build api && up -d api` to take effect
- [ ] Properly fix compose build-context layout — replace VPS-side `sed`-patch workaround with an env-driven path (`${SRC_DIR:-../..}` or run from repo path with `--env-file`) so `git pull` updates the compose file cleanly
- [ ] GitHub Actions push-to-deploy (after both envs are stable)

## Next / Not-yet-built
- Multi-user tenants (today: one user per tenant)
- Billing / subscription gating
- Analytics and call transcripts (modules exist in git history, disabled pending schema rewrite)
- OCR support for scanned PDFs in KB pipeline
- Automated tests (no test suite currently configured)
- GitHub Actions push-to-deploy (after manual deploys are stable)
- Postgres backups (daily `pg_dump` to off-VPS storage)
- Uptime monitoring (Uptime Kuma pinging `/health`)
