dhaga.docs
Development

Testing

A manual, step-by-step guide for taking Dhaga from zero to a fully clicked-through app — first deploy, first account, first admin, and every shipped product feature.

This is a manual, step-by-step guide for taking Dhaga from zero to a fully clicked-through app: first deploy, first account, first admin, and every shipped product feature. Automated unit/e2e tests are tracked separately (build checklist §0) — this doc is for a human in a browser.

How to read the confidence markers below: every claim here was checked against the actual source file cited next to it (not against what an older version of this doc, or SELF_HOSTING.md/DEPLOYING.md, merely said). Where a behavior can only really be confirmed by running it — a live Stripe test purchase, an actual Supabase project, a real Vercel deploy, Docker on a machine that has it — it's marked unverified (needs a live run). Treat those as correct instructions to follow, not as outcomes already confirmed. Nothing in this pass had a live Anthropic/Stripe/Supabase/Firecrawl credential or a Docker daemon available, so anything gated on those is necessarily in that bucket.


1. Pick a deploy path

The deploy paths in this section are for the Dhaga team and for a self-hosted enterprise deployment — an instance Dhaga provisions for a customer on request. Everything from §2 onward is the product walkthrough and applies to any instance, hosted or self-hosted.

PathWhat it's forAdmin panel / hosted-mode features
Vercel (Hobby/free) + Supabase (free) — §1aThe realistic target: this is what you're actually deploying toYes, if you add the extra env vars in §3
Local dev, embedded DB (PGlite) — §1bFastest loop for the core product (capture/notes/search/drafts/export)No — needs real Postgres, see §3
Docker Compose — §1cA self-hosted enterprise deployment on the customer's own boxNot wired by default; needs manual edits, see §1c

Step 1 — create the Supabase project. Free tier, any region.

Step 2 — get DATABASE_URL. In Supabase: Project Settings → Database → Connection string → pick the Session pooler mode (port 5432 on the pooler host, like aws-0-<region>.pooler.supabase.com) — NOT the Transaction pooler (port 6543). An earlier revision of this guide recommended 6543 because Vercel serverless functions open many short-lived connections and transaction-mode multiplexing handles that best. A live run then proved transaction pooling breaks this app: tenant scoping rides on session-level set_config('app.current_user_id', …), and transaction mode re-assigns the server backend between queries — RLS intermittently returns zero rows, and the tenant setting can leak onto backends later handed to other clients. The app now refuses to boot on 6543 (fail-loud guard in packages/ee/src/db/bootstrap.ts). The session pooler still pools (it handles Vercel's many instances far better than direct connections), while pinning one backend per client so session state is safe; the app frees its slots quickly (small per-instance max, and tenant-scoped clients are discarded after each request). When warm instances briefly overshoot the shared 15 and the pooler rejects a new backend (EMAXCONNSESSION / max clients reached, or node-postgres' timeout exceeded when trying to connect), connection acquisition now retries the transient rejection with backoff+jitter instead of 500ing (connect-retry.ts in both packages/ee/src/db/ and apps/web/src/lib/db/; tune with DB_CONNECT_RETRY_MAX / DB_CONNECT_RETRY_BASE_MS). That is a graceful safety net, not more capacity: if session-pooler slots run out under sustained load, the durable lever is raising pool_size in Supabase's pooler settings (see docs/SCALING.md §2). Session mode stays required — the durable fix for the double-connection hold (transaction-scoped tenancy) is tracked as a follow-up on PR #11.

Step 3 — pgvector. You should not need to touch the Supabase SQL editor yourself: the app's own idempotent schema DDL runs CREATE EXTENSION IF NOT EXISTS vector; every time the DB is opened (apps/web/src/lib/db/ddl/vector.ts, executed by initHosted() in apps/web/src/lib/db/index.ts:62-67 on first request — there is no separate migration step). Supabase's own docs say pgvector is available on every plan including free, and that the extension can be enabled either via their dashboard or by running the CREATE EXTENSION SQL directly. Unverified (needs a live run): whether the default postgres role in a fresh Supabase project has the grant to run that DDL itself without you first flipping the toggle. If the app errors out on first request with a permissions-flavored Postgres error, go to Supabase Dashboard → Database → Extensions → enable vector yourself, then reload.

Step 4 — Vercel project. Import the Dhaga codebase, set Root Directory to apps/web, keep the default Next.js build command.

Step 5 — env vars (Project Settings → Environment Variables):

VarRequired?Why
DATABASE_URLYesFrom step 2. Without it, getDb() falls back to embedded PGlite (apps/web/src/lib/db/index.ts:84-90), which needs a writable filesystem — Vercel's is read-only/ephemeral, so /app cannot store anything without this var set.
BETTER_AUTH_SECRET, BETTER_AUTH_URLYesAuth cookie/session signing; BETTER_AUTH_URL is your *.vercel.app URL or custom domain.
DHAGA_EMBEDDINGS=offRecommendedThe local semantic-search embedding model is a heavy native runtime, a poor fit for serverless functions; search still works via keyword matching without it.
ANTHROPIC_API_KEYNo (your own key, add whenever)Every AI feature has a documented degraded mode without it — see the table in §8.
CRON_SECRETNoOnly needed for the job-change/news signal sweep (§7j). FIRECRAWL_API_KEY is optional there now — the sweep's search defaults to ANTHROPIC_API_KEY.
RESEND_API_KEY, RESEND_FROM_EMAIL, DHAGA_OWNER_EMAILNoOnly for event-digest / waitlist emails.
DHAGA_HOSTED_MODE, DHAGA_ADMIN_EMAILS, STRIPE_*No, add laterSkip for now — add these once you want to test the admin panel; see §3.

Step 6 — deploy, then check function duration isn't a concern. Confirmed against Vercel's current docs (fetched during this pass, dated 2026-07-01): Fluid Compute is on by default for new projects, which gives the Hobby plan a 300-second (5 minute) default and maximum duration per function invocation — comfortably above the low-single-digit-second calls this app makes to Claude for extraction/search/drafts. If your project predates Fluid Compute and it's somehow off, the legacy non-Fluid Hobby ceiling was much shorter and AI calls could 504 (FUNCTION_INVOCATION_TIMEOUT); check Project Settings → Functions → Fluid Compute is enabled if you ever see that error.

Step 7 — cron (optional, only if you want job-change/news detection). apps/web/vercel.json declares one cron job: /api/jobs/detect-signals on schedule 17 6 * * * (once daily). Confirmed against Vercel's current docs: Hobby accounts are limited to at-most-once-daily cron schedules — anything more frequent fails at deploy time — so this project's single once-a-day job fits the Hobby tier as-is. Vercel notes it may run the job at any point within the scheduled hour, not the exact minute. The route always 401s unless CRON_SECRET is set (fails closed by design — see §7j).

Need a blank database? Use local PGlite (§1b), local Docker Postgres, or a brand-new disposable Supabase project. Never run destructive SQL, reset, delete, or recreate commands against Dhaga's shared Supabase instance; it contains data that cannot be recreated (see CLAUDE.md).

1b. Local dev — fastest loop for the core product only

npm install
npm run dev        # serves http://localhost:3000

apps/web/.env.local (gitignored) must exist:

BETTER_AUTH_SECRET=<long random>    # auth cookie/session signing secret
BETTER_AUTH_URL=http://localhost:3000
ANTHROPIC_API_KEY=sk-ant-...        # OPTIONAL — enables the AI paths

No DATABASE_URL set → the app uses the embedded PGlite database on disk (apps/web/src/lib/db/index.ts:70-82, apps/web/.dhaga-data/ by default, override with DHAGA_DATA_DIR). This mode cannot show the admin panel or any hosted-mode feature at all, regardless of env vars you set — see §3 for why (packages/ee's own Postgres pool throws immediately if DATABASE_URL is unset, and /app/admin 404s unconditionally without DHAGA_HOSTED_MODE=true). Use this path for §7's core-product walkthrough; use §1a or a real Postgres for §3–§6.

Reset to a blank database: stop the dev server, delete apps/web/.dhaga-data/, restart.

1c. Docker Compose — exists, unverified in this pass

The codebase root ships a Dockerfile and a compose.yml (single-stage Node 22 image + a pgvector/pgvector:pg17 Postgres service with healthchecks). No local Docker was available in any session that has touched this codebase so far — the compose files have never actually been run end-to-end. Read-through says they're complete and idempotent (same first-request DDL as §1a), but treat this path as unverified-by-execution until someone actually runs it.

# next to compose.yml, create .env:
#   BETTER_AUTH_SECRET=<openssl rand -base64 32>
docker compose up --build

Important gap if you want to test hosted-mode/admin here: compose.yml does not wire DHAGA_HOSTED_MODE, DHAGA_ADMIN_EMAILS, or any STRIPE_* var into the web service's environment (confirmed by reading compose.yml:30-41 — only DATABASE_URL, BETTER_AUTH_*, ANTHROPIC_API_KEY, RESEND_*, DHAGA_OWNER_EMAIL, DHAGA_AI_MONTHLY_CAP, SEARCH_PROVIDER, FIRECRAWL_API_KEY, CRON_SECRET are passed through). This is the plain self-host path only — to test §3–§6 via Docker you'd need to add those vars to compose.yml's web.environment block yourself first.


2. First visit → sign up

  • / renders; scroll the feature story — desktop and phone mockups swap as the story progresses; no horizontal scroll at 375px.
  • /app/people while signed out → redirected to /login (requireUserIdForPage, apps/web/src/lib/auth/guard.ts:21-25).
  • Plain mode (DHAGA_HOSTED_MODE unset — true for §1b, and for §1a until you add the vars in §3): /signup creates an account immediately and lands on People. Refresh — still signed in.
  • Wrong password on /login → error stays on the form.
  • curl -s -o /dev/null -w "%{http_code}" <url>/api/export/csv401 (API routes are gated too — confirmed: GET in apps/web/src/app/api/export/[format]/route.ts calls requireUserIdFromRequest before it reads anything).
  • Sign out (top right) → back to /login, and /app/people redirects again.
  • Hosted mode only (DHAGA_HOSTED_MODE=true): on /signup the terms checkbox starts unticked and the Google button and Submit stay enabled. Click either without ticking → no OAuth redirect and no account is created; instead the page scrolls to the checkbox, focuses it, pulses it, and shows the explanation. Tick it and both work. A screen reader should announce the explanation, and the pulse should be a static ring under prefers-reduced-motion.

If DHAGA_HOSTED_MODE=true is already set at this point, plain signup behaves differently — see §3 and §5 instead of the bullet above.


3. Turning on Dhaga Cloud features + becoming the first admin

Everything in this section (admin panel, gated signup, billing) is packages/ee — inert by default. It needs all three of:

  1. A real Postgres DATABASE_URL (Supabase from §1a, or any Postgres) — confirmed by reading packages/ee/src/db/pool.ts:12-21: it throws "DATABASE_URL is required for DHAGA_HOSTED_MODE (packages/ee needs real Postgres — PGlite has no RLS)" if the var is unset. This is the one thing local PGlite-only dev (§1b) can never satisfy.
  2. DHAGA_HOSTED_MODE=true — the master switch. Without it, every one of the four extension points in apps/web/src/lib/hosted/gate.ts falls back to its permissive/inert default (open signup, no billing UI, no admin access) before @dhaga/ee is even imported.
  3. DHAGA_ADMIN_EMAILS=you@yourdomain.com (comma-separated for more than one) — set in the same apps/web/.env.local / Vercel project env vars core reads (packages/ee has no separate env file at runtime; its own .env.example is just a documentation copy of the same var names).

There's a deliberate chicken-and-egg problem this var exists to solve: the admin panel can only promote someone to admin if you're already an admin, and in hosted mode a brand-new account is created unapproved — it can only reach /pending until an admin approves it or a payment is confirmed, and normally only an admin can do the approving. DHAGA_ADMIN_EMAILS breaks the circle:

  • With the three vars above set, go to /signup and create an account with the exact email in DHAGA_ADMIN_EMAILS. Confirmed (packages/ee/src/access-requests/index.ts:11-21): the signup gate's checkEmail short-circuits to { allowed: true } for bootstrap-admin emails, so grantOrRequestApproval stamps approved_at on the way in and you land in /app rather than on /pending. Belt and braces: isUserApproved (packages/ee/src/approval/repo.ts) also lets any is_admin / DHAGA_ADMIN_EMAILS account through even with approved_at null, so an admin can never be locked out.
  • You're an admin immediately, no manual DB flip — confirmed (packages/ee/src/admin/repo.ts:14-25): isUserAdmin checks DHAGA_ADMIN_EMAILS in addition to the stored isAdmin column, so matching the env var is sufficient on its own.
  • /app/admin now loads instead of 404ing.
  • DHAGA_ADMIN_EMAILS is safe to leave set permanently as a break-glass path — it's env config, not a stored credential.

4. Admin panel walkthrough (needs §3)

  • /app/admin — dashboard: three stat cards (pending access requests, total users, active subscriptions), each links through (apps/web/src/app/app/admin/page.tsx).
  • /app/admin/access-requests — tabs for pending/approved/rejected; Approve/Reject buttons appear only on the pending tab.
  • /app/admin/users — table of every user (name/email/joined date, an "admin" badge if applicable); click a row → /app/admin/users/[id].
  • /app/admin/users/[id] — AI usage this month vs. their cap (N / unlimited if they have an active paid subscription, else N / No AI (free tier)), their subscription plan+status if any, a Grant credits to this user card (see §4a), and a Make admin / Revoke admin toggle button. When that user has active grants, a second line reads "+N granted" — usage above it is what was actually spent, and grants never change it.
  • /app/admin/subscriptions — table of every subscription (user link, plan, status, renewal date).
  • Sign out of the admin account, sign in as (or create) a non-admin account, visit any /app/admin/* URL → 404, not a redirect. Confirmed deliberate (apps/web/src/app/app/admin/layout.tsx:14-18): "a non-admin shouldn't be able to distinguish 'doesn't exist' from 'exists but you're blocked'."
  • With DHAGA_HOSTED_MODE unset entirely (§1b or plain §1a), /app/admin 404s for every account including one listed in DHAGA_ADMIN_EMAILS — there is no way to reach this section at all outside hosted mode.

4a. AI credit controls (/app/admin/ai-credits, needs §3)

The instance-wide AI-credit levers. Reach them from the AI credits card sitting under the three stat cards on /app/admin ("Plan allowances, a promotional month, and make-good grants"). The page header restates the precedence the resolver actually uses (apps/web/src/lib/ai/metering/cap/index.ts), highest first:

per-user override → active promotion → plan allowance (only with the master switch on, and only when a paid plan is in play) → the instance default (instanceDefaultCap(), apps/web/src/lib/ai/metering/cap/instance-default.ts — the admin-set Free allowance, else DHAGA_AI_MONTHLY_CAP, else the shipped 10) — then active grants are added on top of whichever one wins.

Enforcement is on out of the box, so a fresh instance already holds every user to the allowance for their plan — free included, at 10 credits a month. The master switch survives as an escape hatch (a migration, an incident), and it is turning it off that changes behaviour: paid plans then fall back to the raw hasUnlimitedAi entitlement of §6, and everyone else falls to the instance default. DHAGA_AI_MONTHLY_CAP is a seed, not an override — it supplies that instance default only while nothing has been set in the database, and the first number an admin saves here retires it for good.

  • Plan-cap enforcement (first card) reads On on a fresh instance (AI_PLAN_CAP_ENFORCEMENT_DEFAULT = true, apps/web/src/utils/constants/ai-budget/allowances.ts — that path is a DIRECTORY now, split under the 150-line rule, and the import path @/utils/constants/ai-budget is unchanged), with the copy: "Limits are being enforced — the shipped default. Every user is held to the monthly allowance for their plan, below: Free, Pro and Power each have a number. This is what the pricing page states, so leave it on unless you have a reason not to." Toggle it off and the copy flips to "Limits are not being enforced. You have turned off the shipped default… every plan resolves through its raw billing entitlement instead, and users with no plan fall back to the instance default… a temporary escape hatch — a migration or an incident — not a setting to leave here." Turn it back on when you're done poking at it.
  • Monthly allowance per plan — the editable ladder (Free, Pro, Power), each with Use default / Custom monthly credits / No cap. Defaults come from PLAN_AI_CREDITS_PER_MONTH in apps/web/src/utils/constants/plans/ (free 10, pro 300, power 1000), so "Use default" is not a stored number. Free is editable exactly like the paid rows, and it doubles as the instance default — the card's closing line names the live number and where it came from: "Effective default: 10 credits / month — from the shipped default in code", or "…from the Free allowance set here", or "…from the DHAGA_AI_MONTHLY_CAP seed". Set Free and that seed stops mattering. With enforcement off the card says so: "Stored but not applied while enforcement is off."
  • Promotional month — one allowance for every user on the instance ("everyone gets 1000 credits this month"), with a start date, an end date and a note. It ends at the start of the end date (exclusive), so pick the first day it should no longer apply; expiry is evaluated on every read, so nobody has to remember to undo it. A promotion applies whether or not enforcement is on, and a per-user override still beats it. Clear the credits field to end it.
  • Grant credits — the additive make-good. "Added on top of whatever cap applies. Recorded usage is never changed — a grant repairs the ceiling, not the history." On this instance-wide page the card always broadcasts to everyone (no free-text user id field — copy reads "Grant credits to everyone"); a reason is required; the expiry defaults to the first of next month (blank = never expires, so it repeats every month). The same card, scoped to one person instead, is pinned to /app/admin/users/[id] ("Grant credits to this user").
  • Grant ledger — moved off this page onto its own paginated screen at /app/admin/ai-credits/grants (linked from a "Grant ledger" card here). Every grant ever made, newest first, searchable by the recipient's name or email (typing "everyone" also surfaces broadcast grants), with who/when/how many/why and an End now button on active ones. "End now" sets ends_at; it never deletes the row, and ai_actions is untouched by any of it.
  • Automated: the precedence above is pinned by three vitest files — apps/web/src/lib/__tests__/ai-action-metering/budget-precedence.test.ts (enforcement on by default, every rung, plus "master switch off ⇒ fall back to the raw billing entitlement"), env-seed.test.ts (the seed semantics: env supplies the instance default only until an admin sets a number, and is still the only control on a core-only self-host with no admin panel) and promotion-and-grants.test.ts (a promotion self-expires; a grant raises the ceiling without touching recorded usage). Run them with npm run test --prefix apps/web -- ai-action-metering.

Getting a server up for this walkthrough. next dev is unreliable on this box (leaked Turbopack workers eventually exhaust memory), so the recipe that has actually worked is a production build served on a free port and driven by the Playwright harness in apps/web/e2e — the config reuses an already-running server whenever E2E_BASE_URL points at one, instead of starting its own:

cd apps/web
npx next build
npx next start -p 3021          # any free port
# in another shell, against that port:
E2E_BASE_URL=http://localhost:3021 E2E_HEADLESS=1 npm run test:e2e

Admin screens still need §3's env (DHAGA_HOSTED_MODE=true, DHAGA_ADMIN_EMAILS, real Postgres) on the server you start — embedded PGlite can't serve them.


5. Access-request flow, end to end (needs §3, a non-admin test email)

Signup is open — the wall moved from in front of account creation to behind it ("Model A: payment is the invite"). Anyone can create an account; a hosted account that nobody invited is created with approved_at null and can reach only /pending, the checkout that pays for it, and sign-out.

  • /signup with an email nobody has approved and that isn't in DHAGA_ADMIN_EMAILS → the account is created. Verify the email, sign in, and you land on /pending, not /app (apps/web/src/lib/auth/guard.ts redirects every /app/* page).
  • The same signup filed the access request automatically (grantOrRequestApproval, apps/web/src/lib/auth/config/signup-hooks.ts) — no separate form needed.
  • The requester got one onboarding email, not two. Signup notifies only DHAGA_OWNER_EMAIL (notifyOwnerOfAccessRequest); the waiting-list news rides the welcome email at verification. See §7p.
  • While pending, confirm the account is actually boxed in: any /app/** URL redirects to /pending, and an authenticated API route (e.g. GET /api/contacts) refuses it. Only /pending, /api/razorpay/{order,verify} and sign-out are reachable.
  • The public intake form is still there for people without an account: POST /api/access-requests (404s unless DHAGA_HOSTED_MODE=true). No landing page posts to it any more — the marketing CTAs go to /signup.
  • Sign in as the admin from §3 → /app/admin/access-requests → pending tab shows that email.
  • Click ApprovereviewAccessRequest (packages/ee/src/access-requests/repo.ts) flips status to approved and stamps approved_at on the account. Reload /pending in the other browser → it redirects to /app.
  • Click Reject (on a different pending email) instead → status flips to rejected and the account's approved_at is cleared, so an already-approved user is sent back to /pending. A rejected email may re-request after ACCESS_REQUEST_RETRY_DAYS (30) — the onConflictDoUpdate in submitAccessRequest reopens it.
  • An admin can never lock themselves out: revoke your own row and /app/admin still loads, because isUserApproved honours is_admin and DHAGA_ADMIN_EMAILS regardless of approved_at.
  • Pay instead of waiting. From /pending, start a plan. Abandon the Stripe Checkout / dismiss the Razorpay modal → still pending (nothing grants at checkout-intent time). Complete the payment → the webhook (packages/ee/src/billing/webhook, .../razorpay/webhook.ts) stamps approved_at and /pending starts redirecting to /app. Refund it → back to /pending. Cancel it instead → still in, for the term they paid for.
  • The queue-skip offer is the settings picker. /pending renders the same PlanPicker as Settings → Plan & billing: tier cards with a price, the monthly/yearly toggle, and the currency of whichever processor can actually charge. Prices match /pricing. A pending account is never shown plan-CHANGE buttons — they run through requireUserId, which refuses it — so the picker is given current: null and the pending-tolerant startPendingCheckoutAction (pending-checkout.guard.test.ts).
  • Enabled ≠ purchasable. With RAZORPAY_KEY_ID/_SECRET set but no RAZORPAY_PLAN_* ids, /pending shows no "Skip the queue" section at all rather than a heading above an empty row. Same for STRIPE_PRICE_*.
  • An admin comp plan (/app/admin/users/[id] → set Pro/Power) also approves the account.
  • With RESEND_API_KEY/RESEND_FROM_EMAIL set: approving sends a "You're in" email linking straight to /app (apps/web/src/lib/access/notify.ts). Without Resend configured, this is a silent no-op (emailEnabled() guard) — the approval still works, there's just no email.

6. Stripe test-mode checkout (needs §3 + Stripe test-mode keys)

Env vars, all from the Stripe Dashboard in test mode: STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, and one Price id per (tier, cadence): STRIPE_PRICE_PRO_MONTHLY, STRIPE_PRICE_PRO_ANNUAL, STRIPE_PRICE_POWER_MONTHLY, STRIPE_PRICE_POWER_ANNUAL (Price IDs from Products you create yourself in test mode). Register a webhook pointing at <url>/api/stripe/webhook, subscribed to at least checkout.session.completed, customer.subscription.updated, customer.subscription.deleted, invoice.payment_failed.

  • Billing UI cleanly absent when STRIPE_SECRET_KEY is unset, even with DHAGA_HOSTED_MODE=true — confirmed (packages/ee/src/billing/index.ts:10-13): getPlanSummary returns null if the key is missing, and the settings page (apps/web/src/app/app/settings/page.tsx:33) renders nothing at all when that's null — not a broken "Upgrade" button, no section.
  • With the key set: /app/settings shows a billing section; Go Pro / Go Power, at the selected cadence → Stripe-hosted checkout (createCheckoutUrl, packages/ee/src/billing/checkout.ts:8-25).
  • Unverified (needs a live run): complete a test-mode purchase with Stripe's test card 4242 4242 4242 4242 → redirected to /app/settings?checkout=success → webhook fires → subscription row created → that user's AI cap becomes their plan's allowance: 300 credits a month on Pro, 1,000 on Power (PLAN_AI_CREDITS_PER_MONTH, applied because plan-cap enforcement is on by default — §4a). Only with that switch off does it fall back to the raw billing entitlement and read "unlimited" for both (hasUnlimitedAi, same file, checked against active status and pro/power plan). Nobody has run an actual Stripe test purchase against this code yet — the webhook handler is typechecked/tested but not click-verified end to end.
  • Manage billing → Stripe billing portal (createPortalUrl) → cancel/change plan → webhook updates the subscription row accordingly. Also unverified by a live run.
  • /app/admin/subscriptions and the user's admin detail page reflect the new subscription (see §4).

6a. Razorpay test-mode checkout (needs §3 + Razorpay test-mode keys)

INR checkout, independent of Stripe: an instance can run either processor or both. Env vars (packages/ee/.env.example): RAZORPAY_KEY_ID, RAZORPAY_KEY_SECRET, RAZORPAY_WEBHOOK_SECRET, a Plan id per (tier, cadence) — RAZORPAY_PLAN_PRO_MONTHLY, RAZORPAY_PLAN_PRO_YEARLY, RAZORPAY_PLAN_POWER_MONTHLY, RAZORPAY_PLAN_POWER_YEARLY, the four introductory ids RAZORPAY_PLAN_PRO_INTRO_MONTHLY, RAZORPAY_PLAN_PRO_INTRO_YEARLY, RAZORPAY_PLAN_POWER_INTRO_MONTHLY, RAZORPAY_PLAN_POWER_INTRO_YEARLY (see 6b), and the retired RAZORPAY_PLAN_PRO_FOUNDING_YEARLY, which is reverse-lookup only and must not be unset (see 6c).

An introductory price is sold with an OFFER, not with one of those four intro plan idsRAZORPAY_OFFER_PRO_MONTHLY, RAZORPAY_OFFER_PRO_YEARLY, RAZORPAY_OFFER_POWER_MONTHLY, RAZORPAY_OFFER_POWER_YEARLY, one id per (tier, cadence), attached to the standing plan's subscription at creation as offer_id. Offers exist only in the Razorpay Dashboard — the API will not create, list or read one — so these four are how an offer reaches the app, and leaving all four unset is a valid configuration: every surface then shows the standing price and every button sells the standing plan. See 6d.

Nine vars are reverse-lookup only and must never be unset. The founding id above, the four introductory plan ids (demoted to resolve-only on 2026-08-23 — nobody ever bought one, but a plan id that could appear on a historical row has to resolve to a tier rather than throw), plus the four _LEGACY vars that hold the superseded standing plan ids (RAZORPAY_PLAN_PRO_MONTHLY_LEGACY, RAZORPAY_PLAN_PRO_YEARLY_LEGACY, RAZORPAY_PLAN_POWER_MONTHLY_LEGACY, RAZORPAY_PLAN_POWER_YEARLY_LEGACYRAZORPAY_LEGACY_PLAN_ENV in packages/ee/src/billing/catalog/plan-env/tables.ts). Nothing can buy or switch onto any of them; planIdFor() reads the current table alone. But a Razorpay Plan is immutable, so a subscriber who bought before a repricing renews against their old plan id forever, and the renewal webhook resolves the tier it grants by looking that incoming id up. Unset one and a renewal resolves no tier, grants nothing, and silently drops a paying customer to free.

Every tier is a Razorpay Plan. They ride the Subscriptions API, so Razorpay re-charges on its own and the plan owns both price and cadence — moving a tier between monthly and yearly is a dashboard change, no deploy. The repo does hold INR figures (PRICES.INR, INTRO_PRICES.INR), but they are display only — the amount charged is whatever the Plan object says, so the two can disagree silently, and §6b is the check that they don't.

The webhook secret is a different secret from the API key secret (Dashboard › Settings › Webhooks). Using the API secret rejects every event — there is a unit test pinning that behaviour.

Both /api/razorpay/* routes 404 unless DHAGA_HOSTED_MODE=true and both keys are set — so next dev on the default .env.local (PGlite) will not serve them; use a dev server carrying .env.vercel's DATABASE_URL.

  • Billing UI absent when neither processor is configuredgetPlanSummary (packages/ee/src/billing/index.ts) returns null only when both STRIPE_SECRET_KEY and the Razorpay pair are missing. With Razorpay alone, billing renders with no Stripe buttons.
  • With the keys set: /app/settings shows Pay in INR buttons → Razorpay modal opens. Test card 4111 1111 1111 1111, any future expiry/CVV; or UPI id success@razorpay.
  • Success → /api/razorpay/verify → subscription row written with razorpay_payment_id set and the Stripe columns null → plan badge flips without a manual reload (router.refresh()).
  • Dismissing the modal is a cancel: no toast, button re-enables.
  • payment.failed surfaces Razorpay's description as an error toast.
  • Tampering is rejected: POST /api/razorpay/verify with a made-up signature → 400, no row written. Amount, plan and owning user are all re-read from Razorpay's copy of the order, so a cheap order cannot be redeemed for an expensive plan.
  • Unverified (needs a live run): nobody has run a real Razorpay test-mode purchase against this code yet.

Webhook (the authoritative grant path)

stripe listen has no Razorpay equivalent, so testing this needs a public URL: either a deployed preview or a tunnel (cloudflared, ngrok) pointed at /api/razorpay/webhook.

  • Closing the tab still upgrades. Complete a payment, then kill the tab before the modal finishes returning. subscription.charged (or grants the plan anyway. This is the whole reason the webhook exists.
  • Wrong secret fails closed. Point RAZORPAY_WEBHOOK_SECRET at the API key secret instead → every event 400s, no rows written.
  • Renewal keeps the plan alive. In test mode, a subscription.charged event pushes current_period_end forward.
  • A failed charge drops the entitlement. subscription.haltedpast_duehasUnlimitedAi returns false while the row survives.
  • A mandate that runs out ends the plan. subscription.expired is dispatched (it used to fall through to default: and leave the row active forever) → status canceled.
  • An expired plan lapses without any job flipping it. A row still stored active whose current_period_end is more than 72h old — an admin comp that hit its expiry, a spent referral month, a subscription whose renewal webhook never arrived — reports canceled from getPlanSummary, so paid features and the paid AI-credit allowance both fall back to free. The 72h is PLAN_LAPSE_GRACE_MS (packages/ee/src/billing/entitlement.ts): current_period_end is the renewal boundary, so grace absorbs processor/webhook lag rather than cutting off someone who has just been charged.
  • Automated: who actually receives the lapse email. The sweep query (findLapsedSubscriptionsNeedingNotice) runs against a real Postgres — an in-process PGlite, no DATABASE_URL, created and discarded inside the test — in packages/ee/src/billing/__tests__/lapse-sweep-rows/, with the subscriptions schema replayed from SUBSCRIPTIONS_DDL itself. It pins both directions: an expired admin comp, a spent referral grant, a cancelled Stripe row and a lapsed row whose account was deleted are RETURNED; a healthy plan, a row still inside the 72h grace, past_due, incomplete, free and an already-notified row are NOT. It also round-trips markLapseNotified — mark, and the same query must stop returning that row; move current_period_end past the marked value, and it must come back. The sibling lapse-sweep-query.test.ts only compiles the SQL, which can prove an exclusion is in the text but can never prove the query returns anybody at all.
  • Redelivery is a no-op. Replay the same event; the upsert is keyed on userId and rewrites identical values.

Remaining limits, deliberate — not bugs found in testing:

  • No billing portal. createPortalUrl throws a specific error for a Razorpay-paid plan; the "Manage billing" button is hidden for them. Cancelling a Razorpay subscription is dashboard-side for now.

6b. Plan matrix, processor routing, local currency

Tiers are Pro and Power, each sold monthly or yearly, at a standing price and — while the offer is open — also at an introductory price. That is six prices to check, not four: only Pro still has an introductory price distinct from its standing one.

Standing (PRICES in apps/web/src/utils/constants/pricing/prices.ts): Pro $5/mo or $48/yr, Power $8.99/mo or $89.99/yr; in INR, ₹499 / ₹4,799 / ₹899 / ₹8,999. Yearly saves 20% on Pro and 17% on Power — the two badges do not match, and yearlySavingPercent computes each from its own tier.

Introductory (INTRO_PRICES in pricing/offers.ts), charged in INR only: Pro ₹199/mo or ₹1,999/yr. The USD figures on those cards ($1.99, $19.99) are marketing equivalents with no Stripe price behind them — the Stripe account is not approved, so Razorpay INR is the only live processor. Power has no introductory price to check. Since the 2026-08-24 reprice its introductory rows and its standing rows are the same ₹899 / ₹8,999 ($8.99 / $89.99), so an offer card would strike a price through and reprint it; no RAZORPAY_OFFER_POWER_* id is configured, getIntroOffers withholds Power, and nothing sells or renders a Power introductory price.

An introductory price is a twelve-month term for the buyer, not a sale with a calendar end — the customer holds it for their own first twelve months and then steps up to the standing price for the same tier and cadence. Whether it is still being sold is a separate, runtime question an admin answers. Both live in §6d, which is where the six-price check gets its second half. The step-up that ends that term used to be a nightly sweep of ours and was a confirmed defect; since 2026-08-23 it is Razorpay's own doing — the subscription is created on the standing plan with an Offer discounting its first cycles, so the price rises when the offer runs out and no call of ours can be refused. Read §6d before testing anything that depends on it.

Every tier is recurring — there is no one-time purchase.

Cadence is stored, and so is any booked change. The subscription row carries cadence, scheduled_plan, scheduled_cadence, scheduled_change_at and synced_at alongside the tier. This reverses the earlier "keep the tier only" design, and deliberately: every screen that reads a plan — the settings picker, the entitlement check — must answer from the database, so getPlanSummary makes no processor call at all (pinned by entitlement-no-processor-call.test.ts). The processor is still the authority; the row is a cache the webhooks and reconcilePlanState refresh, and synced_at records when one last confirmed it. Nothing reconciles in the background, so drift between webhooks is expected and bounded by them.

reconcilePlanState also writes back the status, and that is what makes the lapse notice actionable. A dropped terminal webhook leaves the row stored active for a subscription the processor ended: effectiveStatus reports canceled (lapse is decided at read time) and the "your plan ended" email goes out, but activeSubscriptionRef reads the column raw — deliberately, it is the double-billing guard — so the settings page offered "switch plan" and assertNoExistingSubscription refused the very purchase we had just asked for. Opening billing settings now asks the processor and repairs the row, after which checkout works normally. Both processors map their status through the SAME table their webhook does (STRIPE_STATUS_TO_STORED, RAZORPAY_STATUS_TO_STORED). Three things it will not do, all pinned in reconcile-status-repair.test.ts: a processor outage writes nothing at all (an outage must never downgrade anyone), a status neither map recognises is omitted rather than blanked, and an admin_granted row is never restatused — a comp's active was put there by an admin, and a comp written over a real subscription keeps that subscription's id, so it does reach the call.

  • A lapsed-but-stored-active row self-heals on the settings page. Let a Stripe subscription end without delivering the webhook (delete the endpoint first), wait past the 72h grace, then open Settings › Billing: the row's status becomes canceled and the buy buttons replace the switch buttons.

Prices

  • The four standing prices render on /pricing and in the in-app picker, in both currencies and both cadences: Pro $5 / ₹499 monthly and $48 / ₹4,799 yearly, Power $8.99 / ₹899 monthly and $89.99 / ₹8,999 yearly. The yearly cards strike through $60 / $107.88 and ₹5,988 / ₹10,788, and the badge reads Save 20% on Pro and Save 17% on Power — the two are not the same number any more, so a Power card claiming 20% is the bug.
  • The two introductory prices render for a first-time buyer while the offer is open — Pro ₹199 / ₹1,999 — each against its struck-through standing price, and neither offered to an account that already has a live subscription. Power is not in this list on purpose: its introductory rows equal its standing rows since 2026-08-24, so there is no Power offer card to render and no RAZORPAY_OFFER_POWER_* id set. Known discrepancy, check the constant before "fixing" the card: the USD Pro monthly offer strikes through $4.99 (INTRO_PRICES.USD.pro.monthly.originalAmount) while PRICES puts the standing USD Pro monthly price at $5. The other three USD comparisons match their standing rows.
  • Closing the offer empties the cards, and nothing else. Flip availability shut (§6d) and the offer cards, the checkout paths and the schema.org Offer all fall back to the standing price for a new visitor — while an existing introductory subscriber's price, term and term-end date are untouched. There is no date anywhere in this: the constant that used to run this (INTRO_OFFER_ENDS_AT, intro/window.ts) and its test are deleted, and the toggle is the only thing that closes an offer.
  • Unsetting the offer ids does the same thing, and it is the default. With no RAZORPAY_OFFER_* configured, getIntroOffers() is empty, every card shows the standing price and every button sells the standing plan. This is the correct out-of-the-box state, not a degraded one — do not file it as a bug.
  • Checkout fails closed on a half-configuration. Configure the offer for one (tier, cadence) and not another, then drive the unconfigured one to checkout: the purchase is REFUSED with a customer-visible message, never completed at the standing amount behind a button that advertised the introductory price. This is the single most important box in §6d.
  • An introductory cadence is bought once and never switched onto, and it is a checkout selection only: the row that results carries monthly / yearly, because it is on the standing plan, and records the purchase in subscriptions.intro_offer_id. intro_monthly / intro_yearly never appear in availableCombinations, and POSTing either to the change-plan action is refused by changePlan(). Each of the four offers is also checked on its own, so configuring two of the four sells two — the other two show and sell the standing price.
  • Every configured plan id charges what the page displays. A Razorpay Plan is immutable: a price change is always a NEW plan object, never an edit, so a repricing is only finished once the env var points at the new object. Verify it the way prices.ts intends — read each configured plan id back from the Razorpay API and compare item.amount against PRICES.INR (standing) or INTRO_PRICES.INR (offer), for all eight combinations. A display price that has moved ahead of its Plan is the failure that file exists to prevent: showing a customer one amount and charging another. This is the read-back, not the creation — an env var pointing at the wrong Plan object looks identical to one pointing at the right one until you ask Razorpay.
  • The standing plans are what an introductory subscriber is bought onto on day one, discounted by an offer for their first cycles (§6d). This box used to say they were the step-up target, reached at a twelve-month anniversary; the offer model brings the risk forward rather than removing it. A standing Plan left at a superseded amount is now the wrong mandate amount and the wrong renewal price from the very first charge, not a surprise a year out.

Plan matrix

  • Only configured combinations appear. Unset STRIPE_PRICE_POWER_MONTHLY → Power's monthly button vanishes rather than erroring (availableCombinations).
  • The cadence toggle re-prices both cards, and the yearly view shows the struck-through original plus the "Save 20%" badge. It opens on monthly — on /pricing always, and in the in-app picker for anyone without a live subscription (a subscriber opens on the cadence they are already paying).
  • Buying Power grants Power, not Pro: the row's plan is power and the credit allowance becomes 1,000 (PLAN_AI_CREDITS_PER_MONTH).
  • A Razorpay plan id this instance doesn't sell grants nothingtierForRazorpayPlanId returns null → wrong_plan → 400. Unit-tested.
  • Admin can comp Power at /app/admin/users/[id], and the subscriptions table filters by it.

Processor routing

  • From an Indian IP, the INR button comes first and the cards show ₹. From anywhere else, Stripe leads and cards show $. (x-vercel-ip-country; absent locally, so Stripe leads in dev.)
  • Both remain clickable wherever both processors are configured. Routing reorders, it never removes — a VPN user must still be able to pay.
  • The /pricing currency toggle opens on the visitor's region — the same preferredProcessor() read, so INR from an Indian IP and USD elsewhere (absent locally, so USD in dev). Switch it, navigate away and back: the choice survives, in the dhaga-price-currency cookie. The page is dynamic = "force-dynamic" for exactly this — a cached copy would serve one visitor's currency to the next.
  • The toggle is display only. Select the currency you would not be charged in and a caption calls the figures an approximate conversion and names the charging one; select the charging currency and the caption stops rendering. Checkout still states the INR amount, and the page's schema.org Offer always publishes the charging currency — Razorpay is the only live processor, so today everyone is billed in rupees whatever the toggle says.
  • Local currency for non-INR is Stripe Adaptive Pricing, a Dashboard setting, not code. With it off, everyone outside India is charged USD. Nothing in this repo converts at a live rate — the toggle switches between two hand-set tables (PRICES, @/utils/constants/pricing).

Plan changes, cancel, and the admin guard

Nothing below has been run against a live processor. The decision layer (classifyPlanChange, planChangeTiming, lowestAdminSettablePlan) is unit-tested, but no test and no recorded run covers the processor calls underneath it — treat every box here as a first run, and the Stripe deferred downgrade as the one most likely to be wrong.

  • Upgrade Pro → Power on Stripe — a prorated charge lands immediately and the row flips to power now. The button said "Takes effect now, prorated" before you pressed it.
  • Downgrade Power → Pro on Stripe — no charge now. The row keeps plan = 'power' and gains scheduled_plan = 'pro' plus scheduled_change_at; settings reads "Power yearly until date, then Pro …". After the boundary, customer.subscription.updated flips the row to pro. This exercises a two-phase Subscription Schedule with end_behavior: "release" that has never touched the live Stripe API.
  • Undo before it lands (Stripe)Undo scheduled change drops the booked change at the processor and clears scheduled_*; the pending line disappears.

Razorpay plan changes — the two-subscription flow

Razorpay cannot move a mandate to another plan (confirmed 2026-08-20 on both Indian rails, for a tier change and a cadence change alike, with no timing option permitted), so a change here mints a second subscription starting at the current one's renewal date, the customer authorises its mandate, the old subscription is set to end at that boundary, and the new one takes over when it first charges. None of the boxes below has ever been ticked — the whole flow is unit-tested and has never run against a live Razorpay subscription, so treat every one as a first run.

Everything here needs a completed Razorpay subscription to start from; a test-mode mandate authorised end to end is the minimum setup. Nothing below can be inferred from unit tests, because each one is about what exists at the processor, not what our row says.

  • The two subscriptions genuinely overlap, and only one charges. After approving a change, the Razorpay dashboard must show two subscriptions for the customer: the old one active with a cancellation booked at its renewal date, and the new one authorised with no charge taken, no invoice, and a start date equal to that same renewal date. Our row carries the new id in pending_razorpay_subscription_id with pending_authorized_at set. The failure this catches is double billing, and it is only visible at the processor.
  • The mandate registers at the NEW plan's amount, and a small refundable debit is taken. On the authorisation screen, check the e-mandate maximum is the target plan's price (not the current one's), that the debit actually taken matches what the button promised (RAZORPAY_MANDATE_REGISTRATION_INR, ₹5), and that it is refunded. A number here that the bank statement contradicts is the worst error this surface can make — and "nothing is charged today" must appear nowhere.
  • The upgrade grant is live immediately, and stops on its own. Upgrade Pro → Power, approve, and Power features must work before the renewal date, with the card reading "Power features are on now, free until date". Check a Power-gated control, the AI-credit allowance, and the inference-dollar ceiling on /app/admin/ai-credits (it must be sized from Power's cadence, not the Pro subscription still being charged). Then confirm nothing is granted for a downgrade, for a monthly ↔ yearly switch at the same tier, or before approval.
  • Abandoning the modal changes nothing. Start a change, close the Razorpay window without approving. The card must read "A change to X is waiting for your approval. Nothing has changed yet", the old subscription must still be active with no cancellation booked, no tier must have been granted, and nothing must have been debited.
  • Discard works before approval and is refused after. From that unapproved state, press discard: the pending subscription must be cancelled at the processor, not merely cleared from our row — check the dashboard, because a subscription left alive charges on its own start date whatever our row says. Then approve a fresh change and confirm the discard control is gone, and that posting the action anyway (stale page) is refused server-side rather than leaving the account with nothing after the boundary.
  • Cancel with a change in flight cancels BOTH. With an approved change pending, press Cancel plan. The dialog must say the pending change goes too, and afterwards both subscriptions must be cancelled at Razorpay. A customer told "your plan ends on the 3rd" being debited for the new plan on the 3rd is the most expensive bug on this surface.
  • Hand-over on the first charge. Let the boundary pass (or advance a test-mode subscription). On subscription.charged, the row's razorpay_subscription_id must become the new subscription's, the pending columns must clear, the plan/cadence must be the target, a payments ledger row must exist, and a redelivery of the same event must be a no-op.
  • The first charge FAILS. Force the new subscription's first debit to fail. The row must be repointed at the subscription Razorpay is retrying, at past_due, with current_period_end preserved (never blanked: null reads as "never expires"). The account lapses through the ordinary grace window, and a later successful retry must restore it as a plain renewal. There is no rollback to the old plan and there cannot be: that mandate is cancelled and Razorpay has no resume.
  • A webhook for a subscription this account doesn't own is skipped. Deliver a subscription.* event whose entity id matches neither the current nor the pending id. The row's razorpay_subscription_id must be unchanged; it is keyed on the user alone, so a write here would silently repoint the account.
  • A halted or pending subscription refuses the change with "paused for a failed payment" rather than half-applying it.

The checkout guard, cancel, and the admin guard

  • Attempt checkout while subscribed: refused (assertNoExistingSubscription), and no second subscription exists at the processor afterwards. Test the server action / /api/razorpay/order directly — the UI hiding the buy buttons is not the enforcement point. Note this guards the FIRST-PURCHASE door only: the second subscription a Razorpay plan change mints comes through /api/razorpay/change-plan, which carries the opposite guard (it refuses an account with no live subscription).
  • Cancel on Razorpay — the subscription ends at cycle end, the row has cancel_at_period_end = true, and the dialog does not offer "Keep my plan", because Razorpay has no resume API.
  • Cancel then un-cancel on Stripe — "Keep my plan" clears cancel_at_period_end at Stripe as well as in the row.
  • Cancelling does not revoke accessapproved_at survives to the boundary; only a refund or dispute revokes it (§6a).
  • Admin downgrade of a live paying user is refused server-side. The option is disabled in the selector and a request past the disabled control still fails (lowestAdminSettablePlan, re-checked under the advisory lock). Downgrading a comped user succeeds, and setting a cancelled former customer to free succeeds too.
  • A comp granted over a stuck checkout stays reversible. Comp pro to a user whose row is pro/incomplete with a real processor subscription id (nobody paid), then lower them again — it must succeed. The comp preserves the processor id and sets the status to active, so every inferred signal says "paying customer"; subscriptions.admin_granted is what makes it undoable.
  • …and stops being reversible once the charge settles. With that comp in place, deliver a customer.subscription.updated carrying active for the same subscription id (Stripe CLI, or the Razorpay subscription.charged equivalent). admin_granted must go back to false and the plan selector must re-lock: the user is genuinely paying now, and stripping them would cancel a live subscription.
  • A goodwill bump is reversible down to the paid tier, and no further. Comp power to a user genuinely paying for Pro. admin_granted_over_plan records pro, the selector offers Pro and Power and disables Free, setting pro succeeds, and a crafted request for free fails server-side.
  • Revoking a comp does not cancel a pending payment. For the pro/incomplete comp above, set the user back to free: the row must SURVIVE with its processor ids, back at pro/incomplete with admin_granted = false, and no cancel call must reach Stripe/Razorpay (check the processor dashboard — the subscription is still there). A later active webhook must then grant access normally. A pure comp with no processor id still has its row deleted, and an abandoned processor subscription is still cancelled before the row is deleted.

6c. Founding Pro is retired — what is left to check

Founding Pro (₹6,999 a year, capped at 500 seats) is no longer sold. The offer, the seat cap, the claim button, the /pricing aside, the JSON-LD entry and the admin seat counter are all gone from the code, so there is nothing left to click through, and parsePlanSelection refuses a founding_yearly selection, so nothing can buy one. The reasoning behind the price is kept as history in BRD §11 Q6 rather than deleted — a retired offer's reasoning is why the next one is shaped the way it is.

Three things deliberately survive the withdrawal, and each one is a way to break a customer who already paid:

  • The founding_yearly cadence string (pricing/prices.ts) and its CADENCE_LABEL entry. A Razorpay Plan is immutable, so an existing subscriber's row carries that string forever, and the plan status line renders CADENCE_LABEL[current.cadence] — deleting the union member would crash the settings page for exactly those customers.

  • The founding_seats table and its DDL (packages/ee/src/db/tables-ddl/billing.ts): no destructive migration, and it is the only sale record for any live founding subscriber. It stays in the account-deletion table list, and deliberately has no Drizzle-schema mirror — a definition with no reader would only assert that the offer still exists.

  • RAZORPAY_PLAN_PRO_FOUNDING_YEARLY, now reverse-lookup only (packages/ee/src/billing/catalog/plan-env.ts). Unset it and a renewal webhook for that immutable plan id resolves no tier, grants nothing, and silently drops a paying customer to free.

  • Nothing sells it. /pricing shows no founding card and no founding entry in its JSON-LD; /pending and /app/settings show no claim button; /app/admin has no seat-count stat card.

  • A legacy founding subscriber still reads correctly. With a subscription row at plan = 'pro', cadence = 'founding_yearly', /app/settings renders "Founding yearly" instead of crashing, the account keeps its Pro entitlements, and no control offers to move them off the price.

  • Their renewal still grants Pro. Replay a subscription.charged event carrying the founding plan id: tierForRazorpayPlanId resolves pro and selectionForRazorpayPlanId resolves { pro, founding_yearly }. Both halves — plus "nothing may buy or switch onto it" — are unit-tested in packages/ee/src/billing/__tests__/catalog.test.ts.

  • Their AI dollar ceiling is still sized from ₹6,999/yr (LEGACY_FOUNDING_PRO_YEARLY_INR in apps/web/src/utils/constants/ai-budget/plan-revenue.ts), not from standing yearly. Founding is the more expensive plan — ₹583/month against standing yearly's ₹400 — so a silent fallback would quietly shrink the ceiling of the customers who paid earliest and most.


6d. The introductory term, the offer, and the availability toggle

Two separate questions, deliberately not the same switch:

How long a buyer keeps the price is INTRO_TERM_MONTHS = 12 (packages/ee/src/billing/intro/term.ts). The clock starts at their own subscription start, so two people who bought months apart step up months apart, and there is no global date left in the package to disagree with that. It is a guarantee, not a lock-in — nothing holds anyone to the term, and the customer may cancel in any month. INTRO_OFFER_ENDS_AT, intro/window.ts and intro-window.test.ts are deleted; if you find a surface still quoting a 2026-11-30 end date, that surface is stale.

Whether it is still being sold is introOfferOpen() / setIntroOfferOpen() (intro/availability.ts), stored under the key intro_offer_availability in the EE table billing_settings (text key PK, text value, updated_at; no RLS — a control-plane table with no tenant column, same as subscriptions next door). Not an env var: the owner closes it from a screen at a moment of their choosing ("when ten people have bought"), and an env var would need a deploy.

  • Default is OPEN — absence of a row means open. On an instance that has never been told otherwise the four offers are live with nobody having switched them on.
  • Only the literal "closed" closes it. Corrupt the stored value by hand and the offer reads open. Deliberate, and it is the recoverable misread: over-selling at a price whose margin is positive costs a few subscriptions, while reading corruption as closed would silently withdraw the launch offer from every page with nothing raised anywhere.
  • A failed read returns the default rather than throwing — the public /pricing page calls this and must not go blank over a settings lookup. Point the app at a dead database and confirm /pricing still renders (at the standing prices, since checkout could not complete anyway).
  • Closing affects NEW purchases only. With a live introductory subscriber on the instance, close the offer: their price, their term and their step-up date must all be unchanged. Reopening is possible on purpose — closing an offer is a revenue decision, not a destruction of one.
  • The admin surface. /app/admin/subscriptions renders the availability card with the open/closed state and the derived sold count. That count is not a stored counter: countIntroSubscriptionsSold() unions subscriptions and payments and de-duplicates by user_id, so a customer charged five times counts once.

The step-up

RAZORPAY DOES THE STEP-UP — there is nothing of ours to observe

Redesigned 2026-08-23. An introductory purchase is the standing plan with a Razorpay Offer attached when the subscription is created (offer_id), configured in the Dashboard to discount a limited number of cycles — 12 on a monthly plan, 1 on a yearly one, because a yearly plan bills once a year and 12 cycles would discount twelve years. When those cycles run out Razorpay charges the standing amount by itself. The mandate registers at the STANDING amount, which is what makes that automatic and why the customer's bank shows a cap higher than their first charge. Measured end to end in the sandbox on both rails: standing ₹499 plan + limited-cycle offer → ₹199 invoice paid, mandate cap ₹499.

  • The first invoice is the introductory amount and the mandate cap is the STANDING amount. Buy an introductory cadence in test mode and read both off Razorpay: invoice ₹199, max_amount ₹499. If the cap comes back ₹199 the offer was not attached and the step-up will be refused a year later.
  • The row records the offer, not an introductory plan. The subscription sits on the standing plan id, cadence is monthly / yearly, and subscriptions.intro_offer_id holds the offer it was bought with.
  • One offer id per (tier, cadence), never selected by rail. Razorpay rewrites a UPI offer id posted for a card payment to the card offer itself; confirm the code passes a single id and does not branch on payment method.

🛑 HISTORY — the SWEEP that used to do this could not run, and is deleted

Kept because it is the evidence the offer model rests on, and because the boxes below were written against it. Tested against Razorpay on 2026-08-20 with a real human-authorised subscription against a ₹199 test plan in the sandbox, once on a card and once on UPI AutoPay:

What was measuredcardUPI AutoPay
max_amount on the registered mandate₹199₹199
PATCH /subscriptions/{id} {plan_id, schedule_change_at:"cycle_end"}400"Only offers can be updated for subscriptions when payment mode is domestic card."400"subscriptions cannot be updated when payment mode is upi"
bare {quantity} update400"Can't update subscription immediately when card mandate is applicable"not attempted

Two independent blockers, either alone fatal. The mandate is capped at the PLAN amount, not at a default — the ₹99,000 SDK default cited in earlier versions of these docs belongs to the registration flow, not to Subscriptions, and that inference was wrong; a ₹499 debit would be refused at charge time. And subscriptions.update is refused outright on both Indian payment rails, UPI more strictly than card.

Sandbox mandates never touch real bank rails, so do not overstate it — but the refusals are structural payment-mode constraints with explicit error messages and they agree across two independent rails, so treat them as authoritative unless Razorpay says otherwise.

Both blockers are RESOLVED, and this subsection used to end "do not sell an introductory price until it is resolved". They dissolve rather than get fixed: the subscription is created on the standing plan, so the mandate registers at ₹499, and no update call is made anywhere, so there is nothing left for Razorpay to refuse. The nightly sweep, its package, its wrapper, its entry in the daily route and the in-place plan-update call itself are all deleted. Every checkbox that used to sit here — the cycle_end booking, the lead window, the sweep's idempotence, the per-account dispositions, the batch bound — is deleted with it. There is nothing left to observe, so do not go looking for it.

Separate defect uncovered by the same measurement, and it is FIXED: ordinary tier changes used to make the same call. Since 2026-08-21 they do not — a Razorpay plan change mints a second, future-dated subscription the customer authorises instead, so Pro → Power works again. This paragraph used to end by saying the fix was not applied to the intro step-up; with the sweep gone that call has no callers at all. See the Razorpay plan-change cases in §6b for what a human must verify about the new flow.

Nobody was affected. No purchase has ever been completed at an introductory price, so no subscriber is on a term and none is due to step up — still true, so nothing below has ever been exercised against a real customer.

  • The price moves at the first cycle end on or after the anniversary, one rule for both cadences: the twelfth renewal for a monthly subscriber, the first for a yearly one. Razorpay decides this from the offer's cycle count, not from anything we compute — what we compute is only the date the two warnings quote (next box).
  • A 29 February buyer is not told the wrong month. introTermEndsAt clamps to the last day of the target month instead of letting JS roll it forward to 1 March — one day after the 28 February cycle Razorpay actually ends on. This clamp outlived the sweep it was written for: it used to stop a thirteenth introductory month being handed out, and it now keeps the notice email and the banner on the right date.

The admin hold

🛑 What a hold now DOES is an open question. Every box below was written against a sweep that booked a plan change, so a hold could unbook it. Razorpay now owns the step-up and there is no booking to take back. The columns (subscriptions.intro_price_held_at / intro_price_held_by) and the admin card are not among the deletions, so they still exist — but until someone decides what freezing a price means when the processor raises it, do not tick any of these and do not tell a customer their price is frozen.

  • holdIntroPrice(userId, adminUserId) stamps subscriptions.intro_price_held_at / intro_price_held_by. It used to also unbook an already-scheduled step-up at Razorpay, writing the flag first so a failure in the second step could not leave tonight's sweep free to re-book what was just cancelled. There is no sweep and no booking now, so that second step has nothing to act on.
  • It refuses an account that is not on an introductory price.
  • The card is on the user detail page. IntroPriceHoldCard (apps/web/src/components/app/admin/intro-offer/) renders on /app/admin/users/[id] and calls the server actions in apps/web/src/lib/actions/admin/intro-offer.ts. Its toast used to report whether a booked step-up was cancelled; check what it claims now, because claiming an outcome the processor never gave us is worse than saying nothing.
  • Neither control is reachable by a non-admin, and a request past the UI is refused with a message rather than a silent no-op.

The nightly run and the 30-day notice

The notice landed on 2026-08-20 and rides the existing daily cron. There is no /api/cron route in this repoapps/web/vercel.json declares one job, /api/jobs/daily, which calls runIntroStepUpNotices from apps/web/src/lib/jobs/intro-step-up/. The sweep that used to run beside it is deleted, and so is its introStepUps key.

  • The notice quotes the deterministic anniversary, and depends on no other job. This checklist used to say the sweep ran BEFORE the notice and that the order was load-bearing: it was, because until a change was booked the date was only a prediction and once booked it was the processor's own cycle end. There is nothing to book now, so the date is intro_offer_id plus the computed anniversary, and there is no ordering left to get wrong. Confirm no surface still waits on a booked change.
  • The notice keeps its own idempotence record. It is intro_step_up_notice_sent_for in the per-user settings table (sent-record.ts) — not a subscriptions column, because that table is packages/ee's and core cannot add to it, and not the daily brief's per-local-day record, because this must send exactly once per term end across ~30 consecutive nights and then never again.
  • The record stores a DATE, not a boolean, so it re-arms: a customer who buys again later has a term end the stored date no longer matches. And the comparison is isSameStepUp, a ±45-day window (INTRO_STEP_UP_SAME_TERM_DAYS), not equality — the tolerance was there because booking a change shifted the date by a day or two, and it is kept because a computed date can still differ by hours across a timezone or DST boundary, and an equality test would read that as a fresh term end and email the same charge twice.
  • The window is [now, now + 30), half-open — a customer sitting on the boundary must not be picked up by two consecutive nights.
  • Transactional, so it sits outside the brief's one-email-a-day budget — no DAILY_EMAIL_JOB_KEYS entry, no local-hour gate, exactly like the lapse notice. Confirm a customer inside the window gets the price notice and their daily brief on the same night.
  • Unconfigured Resend is a clean no-op, reported as reason: "no_email" rather than a failure. On a core-only self-host the no-op billing gate returns [], so the job finds nothing, never enumerates a tenant, and reports zeros — nobody can be on an introductory price where nothing was ever sold.
  • A skip and a failure both stay unrecorded, and so are retried. No email address, a disposable test account, a bounced send — none of them mark the customer notified, and there are ~30 nights of retries before the date arrives.

The in-app final-month banner

Built and mounted: apps/web/src/components/app/billing/, rendered from apps/web/src/app/app/layout.tsx on every /app page and mutually exclusive with the lapsed-plan banner. This paragraph used to point at the daily route's comment about "the in-app banner that reads the booked change off the same row" — there is no booked change any more, so if that comment still says so, it is stale and the banner it describes now reads the recorded offer plus the computed anniversary instead.

Keep it distinct from the settings PlanCard's introTermLine, which describes the OFFER to a prospective buyer. That is a pre-purchase disclosure; this is a countdown for someone already subscribed. Two surfaces; do not test one and tick the other.

  • The banner and the email quote the SAME date, because both compute it the same way from the recorded offer and the subscription's own anniversary. This box used to say they agreed because both read the booked pending change, and that this was why the sweep ran before the notice; neither half is true any more. It is still the check most worth doing by hand once a real subscriber exists, and it fails if either surface starts computing its own date.
  • It appears only in the final month — the ~30-day window before the term end — and not for someone already cancelling at the boundary.
  • Dismissing it sticks for that term end and re-arms for the next, because dismissal stores that term end's own token rather than a boolean.
  • It reads at 375px and does not stack awkwardly above the page content.

No real subscriber has ever seen it — no purchase has ever been made at an introductory price, so it has only ever rendered under test. Do not tick anything above as observed in production.

Automated coverage

Coverage is split across both packages, and apps/web's vitest run does NOT cover packages/ee — that package has its own vitest project and must be run separately:

npm run test --prefix packages/ee -- src/billing/__tests__/intro-
npm run test --prefix apps/web -- intro
  • Six packages/ee files pin the decision layer, and they pass — 44 tests, executed 2026-08-23. ⚠ The intro-availability and intro-cohort-rows bullets below still date from 2026-08-20, when the sweep existed, so they may name a booked processor change or a lead window that no longer does; the rest were rewritten with the offer model and describe the files as they stand. Re-run the commands above before quoting any of it as current coverage.
    • intro-availability.test.ts — open by default; closes and reopens at runtime with no deploy; a corrupt value or a failed read still reads open; getIntroOffers reports every configured offer while open, drops only the offer whose plan id is missing, withholds all four when Razorpay is unconfigured or an admin has closed it; the intro cadences stay off availableCombinations and are refused as a change target.
    • intro-term.test.ts — the anniversary is start + 12 calendar months; the 29 February clamp; which date a customer is quoted (the anniversary until the processor's own cycle reaches it, that cycle end after); and — added 2026-08-23 — that the answer is null once the term is behind them, for a monthly subscriber a month past their anniversary, one a year past and a yearly subscriber past theirs, inclusive of the anniversary itself so the half-open notice window keeps its edge case.
    • intro-step-up.test.tsdeleted 2026-08-23 with the sweep it covered. It pinned booking at cycle_end, the batch limit, the dispositions and the refusal reporting; none of that exists now.
    • intro-notice-and-hold.test.tsfindIntroStepUpsBetween returns the customers whose price moves inside the window with the date and the target, excludes those outside it, is half-open so a nightly window cannot notify anyone twice, pairs a yearly customer with the yearly rung, says nothing about an account that will not be repriced and — added 2026-08-23 — nothing about one whose twelve months are already over, which nothing else could have ended since intro_offer_id is never cleared for a customer who stays subscribed; the hold recognises an introductory customer by the offer id rather than the cadence, and refuses an account that never bought at one.
    • intro-offer-recorded.test.ts — the offer id reaches the row from BOTH write paths (browser callback and webhook), travels in notes, leaves the cadence STANDING, and is absent for an ordinary purchase.
    • the marker's WRITE rules are deliberately NOT in this group: they are a property of one SET clause, so they sit beside the cancel_at_period_end carve-out in subscription-write-rules.integration.test.ts (real PGlite). It survives every later event for the SAME subscription, is cleared by a write installing a different one so a cancel-then-rebuy at the standing price cannot inherit it, and a rebuy that was itself introductory records its own.
    • intro-banner-term-end.test.ts — the banner's own input, getPlanSummary().current.introStepsUpAt: reported during the term, null once it is over and null a year later, and null for a subscriber who never bought at an introductory price.
    • intro-cohort-rows/cohort.integration.test.ts (+ harness.ts) — the only one that touches a real database (in-process PGlite, created and discarded inside the test, no DATABASE_URL): listIntroSubscriptions returns the introductory Razorpay subscriptions and never a founding subscriber; a start is read from the first ledgered charge rather than created_at, and is absent when nothing has been charged; countIntroSubscriptionsSold counts people from row and ledger together, once per customer however many times they were charged; and the hold and the availability toggle round-trip without touching anyone already subscribed.
  • Two apps/web files pin the admin surface and the copy — 17 tests, also run in this pass:
    • src/lib/actions/admin/__tests__/intro-offer.test.ts — closing flips the availability switch and nothing else, no subscriber touched; reopening goes through the same switch so a mistaken close needs no deploy; both are recorded against the acting admin; the hold is attributed and logged, reports a cancelled step-up instead of swallowing it, records a release separately from a grant, and refuses a request naming no account; a non-admin can do none of it and is told so.
    • src/utils/constants/landing/pricing/__tests__/intro.test.ts — an offer is paired to a card on tier AND cadence; the term sentence names the buyer's own term and the price after it, reading both figures out of the price tables rather than restating them; scarcity is framed as the offer closing, never as anybody's price rising; and no date is put on an offer that has none — the direct guard against a 2026-11-30 creeping back into the copy.
  • The two nightly jobs are covered, beside their source. They live at apps/web/src/lib/jobs/intro-step-up/*.test.tsnot in a __tests__/ directory, which is why more than one search for them has wrongly concluded there were none. Glob the sibling files before deciding something here is untested.
    • notices.test.ts — 18 cases over runIntroStepUpNotices: exactly once per customer per step-up (silent on the second night, silent when the booked date shifts by a day or two, a step-up a year later treated as new, the date recorded rather than a boolean); the half-open thirty-day window and never enumerating on an empty night; a missing address or a failed send recording nothing so tomorrow retries, and one customer's failure not costing the rest theirs; no recipient address in the log; the subject naming the tier and date, the body quoting the processor's amount when one resolves and saying nothing about money when none does, telling them how to leave, and carrying no opt-out footer because it is transactional; and clean no-ops on a core-only self-host and with email unconfigured.
    • step-ups.test.tsdeleted 2026-08-23 with the sweep; its 9 cases were all about the nightly booking report an operator read. isSameStepUp and sendStepUpNotice are covered THROUGH the job, not by direct unit tests — the ±45-day same-step-up window and the skip/fail-stay-unrecorded rule are exercised by the cases above, which is what matters, since the failure they guard against is emailing a customer twice about one charge.
  • The banner's decision layer is coveredsrc/components/app/billing/, 21 cases, also beside their source: notice.test.ts (13) pins every case that renders nothing (no summary, free, standing price, the retired founding price, someone outside the final-month window, someone cancelling at the boundary), the tier/cadence/term-end date when it does render on both rungs, and dismissal that sticks for one token and re-arms for a later one; resolve.test.ts (8) pins the read budget — no settings read at all for a standing-price subscriber, a free account, a core-only self-host or someone not yet in their final month, exactly one for someone genuinely in it, and structurally that the shell mounts through the resolver rather than the raw accessor. These descriptions were written against the booked-change model — several cases named a pending processor change that no longer exists, so re-read the files rather than trusting this summary case-for-case.
  • ⚠ The email TEMPLATE has no test of its own. lib/email/intro-step-up .ts has no sibling .test.ts; its subject and body are asserted only through the notice job (the four "what the customer is actually told" cases). That is real coverage of the rendered output, so the gap is narrow — a template change that keeps those assertions passing is unguarded, and nothing pins the template in isolation. This is the one genuine coverage gap in the step-up work; it is not "the notice job is untested".

Caveats — the measurements that decided the mechanism

  • MEASURED, and it is why the mechanism changed: the Indian e-mandate max_amount is registered at the PLAN AMOUNT. An e-mandate carries a maximum debit amount fixed at authorisation. This box used to say nobody had confirmed what Razorpay registers, and that the SDK's documented ₹99,000 default made it probably fine. That reading was wrong: the ₹99,000 default belongs to the registration flow, not to Subscriptions. Measured 2026-08-20 by authorising a real subscription against a ₹199 test plan in the sandbox and reading the registered cap back — ₹199 on card and ₹199 on UPI AutoPay. Under the old model that was fatal: a ₹499 step-up debit would have been refused at charge time for every introductory subscriber on the same night, since they are all authorised the same way. Under the offer model it is the mechanism: the subscription is created on the ₹499 standing plan, so the cap registers at ₹499 and the discounted first cycles ride under it. Verify the cap on every test purchase — a ₹199 cap means the offer was not attached.
  • MEASURED: the in-place plan update was REFUSED by Razorpay on both Indian payment rails — and it is no longer called. The first real call was made on 2026-08-20 against a sandbox subscription rather than a customer's: PATCH /subscriptions/{id} returned 400 both times — card: "Only offers can be updated for subscriptions when payment mode is domestic card."; UPI: "subscriptions cannot be updated when payment mode is upi". A bare {quantity} update was refused on card too. This box used to conclude that no amount of decision-layer correctness reached a working step-up, and that was right. The call and the sweep that made it are deleted; the card error's own hint — only offers — is what the replacement is built on. Sandbox result — but structural payment-mode constraints with explicit error messages, agreeing across two rails.
  • No purchase has been made at any introductory price, so the cohort is still empty, every box in this section that needs a live subscriber is unrunnable until someone buys, and the offer path itself has only been proved in the sandbox — no introductory subscription has ever run to the end of its discounted cycles in production.

7. Core product walkthrough (works today regardless of deploy path)

7a. People (manual CRUD)

  • People → Add person → save with just a name → detail page opens.
  • Add another with title/company/emails — the People list shows "title · company"; the filter box narrows by name, title, and company.
  • Empty-state cards appear when the list/filter has no results.

7b. Quick add (capture → review → save)

Open Quick add, pick Paste text from the "How do you want to add them?" chooser, and paste this in:

Nisha Shah
Principal, Early Stage — Meridian Capital
nisha@meridian.vc | +65 9123 4567
meridian.vc · linkedin.com/in/nishashah
Singapore
  • Click Extract contact — a badge says Extracted with AI (key set) or Parsed offline (no key) — confirmed exact wording in apps/web/src/components/app/QuickAddForm/QuickAddResult.tsx:31 and apps/web/src/lib/ai/contact-extraction.ts:32. Fields are pre-filled; fix anything wrong.
  • Type a new event name (e.g. Web Summit 2026) in the event picker, then Save person.
  • On the new contact: the event chip appears, and the pasted text is stored as a capture source note — that's the receipt for the fields.
  • With a key: the "N of {cap} AI credits used" counter (confirmed apps/web/src/app/app/quick-add/page.tsx, cap from effectiveMonthlyAiCap(), apps/web/src/lib/ai/metering/cap/index.ts) increased by one. A free account has a cap of its own — 10 credits a month (FREE_TIER_AI_CREDITS_PER_MONTH, apps/web/src/utils/constants/app.ts, derived from PLAN_AI_CREDITS_PER_MONTH.free) — so it reads "N of 10" like any other plan. Only a cap of literally 0 — an admin set a plan's allowance to 0, or an operator pinned DHAGA_AI_MONTHLY_CAP=0 — reads "No monthly AI credits on this plan — upgrade to enable AI actions".

7c. Card photo scan (needs ANTHROPIC_API_KEY)

  • Open Quick add on your phone browser (same network: http://<your-ip>:3000 locally, or your deployed URL) → Scan a card in the method chooser (it opens the camera straight away) → take a photo of a real business card. Fields extract into the review form; a receipt note is composed from those fields (cardReceiptText) and saved with the photo.
  • The receipt fills in afterwards: right after saving, the receipt note lists only the extracted fields. Wait ~5s and reload — the SAME note (one receipt, not two; the photo still attached) now holds the card's verbatim text, including whatever maps to no field: the office address, the fax line, the tagline. That is a second Haiku call scheduled with after() once the save has committed (scheduleCardTranscription), so it costs the scan zero user-facing latency. If the note never changes, check the server log for cardTranscription — a budget refusal is silent by design.
  • Speed: click Scan → review dialog in under ~3s (measured 3.1s end-to-end locally, of which ~2.3s is the model). The scan asks for fields ONLY — it used to also request a verbatim transcription of the card, which tripled output tokens and took it to ~6s. If you reinstate any long free-text field in cardScanSchema, re-measure: that is the one change that reliably breaks this budget. Image size is the other lever (CARD_SCAN_MAX_DIMENSION, 1024) — 768px started misreading digits in phone numbers, so don't go lower without re-checking accuracy.
  • Tray resets: after a scan is saved or dismissed, reopen the card-photo surface (← All methodsScan a card) — the tray is EMPTY. It used to keep the previous card's photos, so the next scan silently merged two people's cards into one contact.
  • Bare-domain website: scan a card whose site is printed as pune.stpi.in (no https://). It saves. The link field is type="url", so the scheme-less value used to fail native validation and the Save button did nothing with no message — withUrlScheme now adds the scheme at the capture→profile boundary.
  • After saving: the person's page shows the photo under Card photo (the visual receipt). Clicking it opens the full-size image.
  • Desktop: choose an image file instead — same flow.
  • Multi-image: add several photos in one capture — front and back of the same card (or several leaflet pages) via multi-file select or the thumbnail tray's "Add photos". They extract into a single merged contact (e.g. name/title from the front, address/phone from the back), and after saving every image appears as its own receipt on the person's page. Removing a thumbnail before scanning drops that image; the count reads n/6 and "Add" disables at six.
  • Desktop live webcam: "Use live camera" → allow camera → take multiple shots (each drops into the tray) → Done → Scan — same merge into one contact. Denying the permission shows a message and a "Choose a photo instead" fallback.
  • Dock camera: the capture dock's Camera button → shoot several frames → Done → the capture dialog opens on the card-photo surface with those frames already in the tray (crop / reorder / remove available); pressing Scan starts the extraction. Done never scans blind.
  • Scanning feedback: from the moment Scan is pressed until the review dialog opens, a branded loader blocks the WHOLE viewport — the capture dialog blanks out behind it (React hides home's re-suspended HomeDock boundary for the duration of the action, which is why the overlay lives in the app shell, not in the form; see BusyOverlay). Same for a dock Upload, which scans with that dialog shut, and for a paste extraction. Covered by apps/web/e2e/capture-loader.spec.ts.
  • The overlay always CLEARS — on a successful scan, on a failed one, and with a full six-image tray. A scrim that outlives its work freezes the app (a 120s safety timeout is the backstop, not the mechanism).
  • Without a key: honest "Card scanning needs cloud AI" error (hasLLM() gate, apps/web/src/lib/ai/card-scan.ts:30).

7d. Card-photo storage setting (Settings page)

  • Settings → "Store captured photos" is ON by default; the Quick add photo tab says the photo is kept as the visual receipt.
  • Toggle it OFF → the Quick add photo tab now says the photo is not stored; scan a card → the saved contact has no Card photo section (the field-derived receipt note is still there).
  • Toggle back ON, scan again → the photo appears on the contact.
  • Delete all stored card photos → confirm → the count clears and contacts keep their receipt notes but lose the photos.
  • Deleting a scanned contact's receipt note (or the person) removes its photo too — /api/card-image/<id> returns 404 afterwards (confirmed apps/web/src/app/api/card-image/[id]/route.ts:25).

7e. Events

  • Events lists Web Summit 2026 with a people count.
  • Open it — Nisha is listed; click through back to her page.
  • Create a second event from the Events page directly.

7f. Notes → facts with receipts (needs the API key for extraction)

On a contact, add this note:

Runs ops for a freight forwarder. They're evaluating route-optimisation AI next quarter, and she introduced me to their CTO. Follow up after their fiscal year starts.

  • Note saves instantly; message reports how many facts/follow-ups were extracted (or explains why not, without a key — apps/web/src/lib/ai/note-extraction.ts:30 gates on hasLLM()).
  • Facts show with type labels and "from note, {date}" receipts.
  • Follow-ups shows the action with the timing hint; the checkmark marks it done.
  • Delete a single fact — only it disappears (its embedding is also cleaned up — deleteFact owns this itself, per a same-event fix).
  • Delete the note — its remaining derived facts disappear with it (receipts invariant: no fact outlives its source), and the note's own embeddings go with it too (deleteNote owns this cleanup directly, same fix).

7g. Voice notes & pre-meeting brief

  • On a contact, tap Voice note (Chrome/Edge) — allow the mic, talk, tap stop. The transcript lands in the textarea; Add note saves it labelled voice note, and facts extract as usual.
  • With a key: Brief me — a WHO / WHAT MATTERS / OPEN LOOPS / OPENERS dossier under 180 words (confirmed packages/core/src/llm/prompts/brief.ts:17), drawn only from your notes; empty areas say "nothing on file" rather than inventing.

7h. Search (hybrid: keyword + local semantic)

  • Search freight — the contact appears with the matching fact/note snippet quoted under the name.
  • If an "Indexing N items in the background" line shows, existing data is being embedded automatically (first run downloads a model, one-time, local — skipped entirely if DHAGA_EMBEDDINGS=off, which is the recommended Vercel setting from §1a). Refresh after a minute and the line disappears.
  • Semantic test (only meaningful with local embeddings on): search logistics shipping (words that appear in NO note verbatim) — the freight-forwarder contact still surfaces, with a "related note/fact:" snippet.
  • Search gibberish — honest "No matches" empty state.
  • With a key: Ask AI composes an answer naming the right person and citing their facts/notes. It only runs when clicked. Try "who did I meet at Web Summit in fintech?" and confirm the answer respects the event scope (query-understanding step, apps/web/src/lib/ai/ search.ts).

7i. Warm paths (Graph page)

  • On Graph, pick a company in "Warm path to" → Find path — chains render as You → person → … → target.
  • Pick a target with no connection — honest "No thread reaches…" message.

7j. Job-change & news signal detection (opt-in, needs ANTHROPIC_API_KEY + CRON_SECRET)

FIRECRAWL_API_KEY is no longer needed: since 2026-08-08 the search gateway defaults to Anthropic's own server-side web_search tool, so the same ANTHROPIC_API_KEY the rest of §7 needs also arms this sweep and un-greys the watch toggle. Setting FIRECRAWL_API_KEY still switches back to Firecrawl. Nothing below has ever been run — the Anthropic search path has never been exercised against a live key, so treat every box here as untested, not merely unchecked. Expect a real bill while you do: $10 per 1,000 searches on top of input tokens for every retrieved page.

  • On a contact, toggle "Watch for job changes & news" — it should be a live control, not greyed out "Coming soon", once ANTHROPIC_API_KEY is set.
  • Hit /api/jobs/detect-signals yourself with the cron header: curl -H "Authorization: Bearer $CRON_SECRET" <url>/api/jobs/ detect-signals — without CRON_SECRET set, this always 401s (hasLLM() also gated inside — apps/web/src/lib/jobs/detect- signals.ts:33 — no key means skipped: "no_llm", not a crash).
  • A detected signal shows on Home; Add as note files it as a regular note (facts/receipts as usual). Re-running the sweep doesn't re-surface the same unresolved signal (hasOpenSignal guard). Note: double-clicking "Add as note" has no idempotency guard yet and could create duplicate notes; worth being aware of if you click it twice.

7k. Follow-up draft (needs the API key)

  • On the contact, Draft follow-up — the draft must reference at least one real note-derived fact (e.g. the route-optimisation AI evaluation).
  • Edit the text, Copy, paste somewhere — matches your edit.
  • Redraft replaces your edits with a fresh draft.

7l. Enrichment (needs ANTHROPIC_API_KEY)

Correction (2026-08-08): this section used to say "ideally FIRECRAWL_API_KEY too". It never applied. Enrichment does not go through the search gateway at all — apps/web/src/lib/ai/enrich.ts passes webSearch: true to the LLM client and lets the provider run its own web search, so FIRECRAWL_API_KEY changes nothing here. Only §7j's sweep reads SEARCH_PROVIDER.

  • On a contact with a real public identity, Enrich from public web — a "web enrichment" note appears with cited source URLs, and facts extracted from it carry the note as their receipt.
  • Delete the enrichment note — its derived facts disappear with it.

7m. Export (no lock-in)

  • On People, the csv / vcard / json links each download a file.
  • CSV opens in a spreadsheet with correct columns; vCard imports into a contacts app; JSON contains contacts, companies, events, notes, facts, edges, follow_ups.
  • Address-book seed scope (there is no UI for it — edit the URL). /api/export/vcard?scope=authored drops contacts whose source is mentioned or import and any with no name, while the plain /api/export/vcard still contains every one of them. Check both files: the portability download must not have narrowed.
  • Adding &provider=device (or google / microsoft) also drops anyone already linked on that provider — including a tombstoned link. Delete a synced contact on the phone, sync, then re-download: they must not be in the seed file, or a bulk import would resurrect them.
  • Bad parameters are rejected, never ignored: ?scope=nope, ?provider=nope, ?provider=device without scope=authored, and either parameter on /api/export/json400 with a message.
  • An unlabeled email/phone exports as a bare EMAIL: / TEL: line with no TYPE, and a contact with a nickname and dates exports NICKNAME, BDAY and itemN.X-ABDATE / itemN.X-ABLabel. This is round-trip correctness, not tidiness: a field the seed drops comes back from the address book empty and the second sync reads it as a deletion.

7n. Forget this person (privacy cascade)

  • On a contact, Forget this person → browser confirm → gone, redirected to People.
  • Their events no longer list them; search finds nothing; a fresh JSON export contains no trace (contact, notes, facts, edges, follow-ups, embeddings and now notifications all gone — apps/web/src/lib/repo/contacts/mutations/forget.ts's forgetContact).
  • Known gap, unfixed at the time of writing: cascadeForget never deletes extraction_jobs, whose contact_id is a NOT NULL RESTRICT foreign key — so forgetting a contact that still has a job row should abort with FK 23503 rather than cascading. Test a contact that has had a note extracted, and treat a failure here as this known bug, not a new one.

7o. LinkedIn/Google CSV import

  • /app/import → upload a LinkedIn "Connections.csv" export → contacts appear with the event/company mapping expected; re-importing the same file doesn't create duplicates (whitespace/accent-insensitive dedup — see recent commits de2d9d1, 0690adb).
  • Same for a Google Contacts CSV export — including a location field if present (Address N - Formatted column).

7p. Email digest & waitlist (needs RESEND_API_KEY + RESEND_FROM_EMAIL)

  • Sign up on a hosted instance with an email nobody has invited — the account is created, lands on /pending, and exactly one branded "Welcome to Dhaga" email arrives once the address is verified: the product-guide link, the waiting-list explanation, and a Skip the waiting list button to /pending. DHAGA_OWNER_EMAIL separately gets the "New Dhaga access request" notification at signup time.
  • Now sign up with an email listed in DHAGA_ADMIN_EMAILS (or one an admin approved first, or pay from /pending before verifying) — the same "Welcome to Dhaga" email arrives with the guide link and no waiting-list wording anywhere in it. Telling a paying or hand-invited customer they are queued is the failure the approval branch in apps/web/src/lib/auth/config/welcome.ts exists to prevent; it reads the gate at send time, so approving mid-flow flips the variant.
  • Re-trigger the hook (verify an old link again) — no second welcome email; the per-user welcome_email_sent settings key blocks it.
  • On an event page with people in it, Email me the digest — the digest (people + facts + follow-ups) arrives at DHAGA_OWNER_EMAIL.
  • Unset DHAGA_OWNER_EMAIL and retry — clear "Set DHAGA_OWNER_EMAIL…" error, no crash (apps/web/src/lib/actions/events.ts:53).
  • Settings → Suggestions tab → enable Morning follow-up reminders → hit /api/jobs/daily (same CRON_SECRET header as §7j) with open follow-ups or due reach-outs pending — an email lands at DHAGA_OWNER_EMAIL naming the pending count. Confirm the dummy/ load-test account (loadtest@dhaga.internal) never receives it even if it's the configured owner — isDummyAccount() skips it (apps/web/src/lib/jobs/daily-brief/index.ts). The count arrives as the Also waiting section of the one daily brief, not as its own email. Gated on RESEND_* being set; EMAIL_JOBS_HOURLY=true (old name MORNING_REMINDER_HOURLY, honoured for one release) additionally restricts the send to the recipient's local ~08:00 run, and only makes sense if you drive the endpoint hourly. Full coverage of the brief, its sections and the per-tenant fix is in §7aa.

7q. Telegram bot & outbound webhooks (optional envs)

  • With TELEGRAM_* set and the webhook registered (see .env.example): message the bot a signature → "Saved {name}"; send ?who did I meet in fintech → an answer. Messages from other chats are silently ignored (chat-id allowlist check, apps/web/src/app/api/telegram/route.ts).
  • With DHAGA_WEBHOOK_URL set: creating a contact POSTs contact.created to your URL; extracted follow-ups POST followup.created.

7r. Metering cap

  • Set DHAGA_AI_MONTHLY_CAP=1, restart/redeploy, run one extraction, then try another AI action — friendly "cap reached" message, and capture falls back to the offline parser instead of failing. The var is a seed, so it only bites while nothing has been set in the database: an admin-set Free allowance (§4a) wins over it without a restart.
  • Remove the seed and restart/redeploy — the instance default falls back to the shipped 10 credits a month, not to zero.
  • Hosted/EE only: the same ceiling can be raised from the admin panel without an env change or a restart — a per-user override, an instance-wide promotion, or a grant (§4a).

7r-i. Your own credits page (/app/settings → Credits)

  • Run a few AI actions (a card scan, a note, one Ask Dhaga question), then open Settings → Credits. Three cards, top to bottom: credits remaining of your total with a used-bar and the reset date; Where your credits went — one row per action kind with counts; and Recent activity — "Card scan · 1 credit · 2 hours ago".
  • Add the row credits in the breakdown by hand: they must equal the Total line, and the Total must match the "used" figure on the Home dock / quick-add usage line. Those are the same number by construction — a mismatch is a bug, not rounding.
  • Turn on a watchlist and let a nightly scan run (or insert a signal_detection row): it appears in the breakdown marked Free, raises the action count, and adds nothing to the credit total.
  • Hosted/EE only: grant yourself credits from §4a. The allowance card splits into Monthly allowance / Credits added for you / Total, the total rises, and the used figure and the breakdown are unchanged.
  • On an unlimited plan the card reads Unlimited with "you have used N credits so far this month" — never "N of 0", never a broken bar.
  • The activity list stops at 20 rows however many actions the account has run (AI_ACTIVITY_LIMIT) — ai_actions is append-only, so an unbounded list here would grow forever.
  • At 375px: no horizontal scroll, the tab strip scrolls sideways to reach Credits, and every row stays on one line or wraps cleanly.
  • First-run walkthrough: take the tour to the end. It now starts on /app/settings (a brand-new account landing on /app is forwarded there), runs eight steps down the tab strip — Appearance, Credits ("Know what the AI costs you"), Contact accounts, Calendars, the two Messaging steps, notification preferences, Import — then hands off to a four-step Home leg via a "Show me the app" button. Check the progress counter matches the steps you are actually shown: on an instance with no LLM key or no billing the missing anchors are filtered out before driver.js sees them, so "3 of 7" is correct and "3 of 8 over a blank screen" is the bug. Also check it at 375px, where the last Home step must land on the hamburger rather than on nothing.

7r-ii. Running out of credits (the pre-click gate)

The AI controls no longer wait for a failed click to tell you the month is spent: at zero credits they render disabled with the reason beside them — a calm amber pill reading "You're out of AI credits this month — all 10 used. They reset on the 1st." with a See credits link to /app/settings#credits. assertAiBudget is unchanged and is still the enforcement; this is purely the pre-click layer (aiGateReason, apps/web/src/lib/ai/gate.ts), composed from the same three accessors in the same order so the UI can never disagree with the server. The gate is "zero left", not "can I afford this one" — a user with 5 credits left may genuinely start a 20-credit deep research and go over, because the server refuses on used >= cap and never prices the action.

Getting to zero — DHAGA_AI_MONTHLY_CAP=0 does NOT do it. This is the trap: instanceDefaultCap() (apps/web/src/lib/ai/metering/cap/instance-default.ts) only honours the env var when it parses as > 0; a 0 falls straight through to FREE_TIER_AI_CREDITS_PER_MONTH (10, from PLAN_AI_CREDITS_PER_MONTH.free), so an instance you think you pinned to zero is quietly handing out ten credits. Two routes that actually work:

  • Set DHAGA_AI_MONTHLY_CAP=1, restart, then spend the one credit (any extraction) — or insert a single ai_actions row for the account.

  • Hosted/EE: /app/admin/ai-credits → set the Free allowance (or that user's per-user override) to 0. An admin-set 0 is honoured; it is only the env seed that isn't.

  • Greyed, each with the reason next to it: Extract contact (paste capture), Scan card / Scan N images, Ask Dhaga ✦ (both the palette's Ask tab and the search-tab bridge rail), Brief me ✦ / Refresh brief ✦, Draft follow-up ✦ / Redraft ✦, and Enrich from public web ✦.

  • Still fully usable — this is the half that matters: the Manual capture tab, manual person create/edit, adding a fact or a follow-up by hand, keyword search, the graph, import, export, on-device voice dictation, and adding photos to the card tray (only the Scan submit is greyed).

  • Typed and photo notes still save. Add a note at zero balance: it saves and degrades to the existing "Note saved. You're out of AI credits this month, so facts weren't extracted." Note re-process, confirmation- resolution cards, "Add as note" on a signal and an extraction-job retry are deliberately not greyed either — they're manual paths where AI is a bonus that already degrades. Greying them would break the free/no-AI product commitment.

  • A cap of literally 0 shows the other sentence instead — "No monthly AI credits on this plan — upgrade to enable AI actions." — the same string aiUsageLabel uses, so the usage line and the greyed control never say two different things.

  • The nav Add dialog and the Ask Dhaga palette live in the client-only app shell with no server component above them to hand a prop down, so they read /api/ai/gate lazily when opened (useAiGate). Open each one cold and confirm the greyed state still arrives.

  • Unset ANTHROPIC_API_KEY entirely → nothing is greyed for credits. aiGateReason returns null without hasLLM(), so the existing "needs cloud AI" / "Configure an LLM provider…" messages still own that case (§8) and a self-hosted instance doesn't look broken for a second reason.

  • Automated: npm run test --prefix apps/web -- ai-gate — 8 tests across src/lib/__tests__/ai-gate/reason.test.ts (the two sentences, no gate while credits remain, no gate when unlimited, no gate with no LLM) and never-over-gates.test.ts (price is never consulted; the gate opens exactly when the server would refuse; manual fact/follow-up entry works at zero).

7s. PWA install

  • On your phone (local network IP or the deployed URL): browser menu → Add to Home Screen — Dhaga installs with the knot icon and opens standalone straight into /app (apps/web/src/app/manifest.ts).

7t. Browser extension

  • npm run build --workspace @dhaga/extension (confirmed script exists, apps/extension/package.json:9, bundles into apps/extension/dist), then Chrome → chrome://extensions → Developer mode → Load unpackedapps/extension/dist. Clicking the toolbar icon must open the side panel, not a popup — if a popup appears, the service worker never ran setPanelBehavior.
  • First run shows "Grant access", and the button works. The manifest declares NO required host_permissions on purpose: a specific origin listed there is shadowed by the broad https://*/* in optional_host_permissions, and Chrome then withholds it — which in v1.1.0 meant every request died as a console-only CORS error with a silent UI. If this banner is missing on a fresh profile, that regression is back. Grant, and confirm the banner disappears.
  • With access withheld (remove it via chrome://extensions → Site access), type in the contact search: the list must go empty and quiet. An Uncaught (in promise) TypeError: Failed to fetch in the console means the catch in picker.ts was lost again.
  • Signed in to https://dhaga.app with no extension setup at all: select a person's details on any page (name, title, email), click the Dhaga icon — the selection is pre-filled — Save to my network → success links to the contact. Both cloud origins are declared host permissions since v1.1.0, so there must be no access prompt.
  • The contact's notes include the selection with the page URL (receipt).
  • Signed out: saving shows "Sign in to Dhaga" with a login link.
  • Gear icon → the settings panel opens with both fields blank. Blank is the working default (Dhaga Cloud + your cookie session), not a missing config — if either field looks required, the default path has regressed.
  • Set the instance URL to http://localhost:3000Save settings → no permission prompt (declared origin), and captures now reach the local instance. Set it to a domain that is not declared → Chrome prompts; declining must leave the status line asking you to allow it, not fail silently.
  • API key fallback: sign out of the web app, paste a key minted at Settings → API keys, capture again → it still saves. Signed-out is exactly the condition this field exists for (a browser that drops the cross-site session cookie), so testing it signed in proves nothing. Minting a key is plan-gated, but using the extension is not — a free account must still capture fine on the cookie path above.
  • The key must never reach synced storage: in the panel's DevTools, chrome.storage.sync.get(console.log) shows only baseUrl, and chrome.storage.local.get(console.log) shows the key. sync would replicate a credential through the user's Google account.

Screenshot capture

  • On a profile page, Add screenshot → a thumbnail appears and the count reads "1 of 6". Scroll the page and add a second. The panel must stay open through the scroll — that is the whole reason it is not a popup, and a popup would have discarded the first shot.
  • Remove a shot with its × — the count decrements and the right thumbnail goes.
  • New person + screenshots → Save. A contact is created from what the page showed, and the note holds the transcript plus Source: <url>. The page must be read by the SCREENSHOT prompt: if the contact comes back empty, the request lost imageKind: "page" and hit the business-card prompt, which is instructed to return empty fields for non-cards.
  • Attach to someone + screenshots → pick a person → Save. It must attach to THAT person. A newly created duplicate contact means the server took the card branch, which returns before it ever reads contactId.
  • It returns as soon as the note is saved and says facts are extracting in the background — it must NOT sit spinning through a second model call. Extraction is queued as a note_extraction job like every other note's; confirm the facts appear on the contact shortly after, without a reload of the panel.
  • Type a line in the box AND add a screenshot. The note body must lead with your line, then a blank line, then the transcript. The typed line is the instruction ("follow up about this") and the screenshot is the evidence — dropping the text loses the only part that says what to do, and it is what the extractor acts on.
  • The screenshots are not retained: the contact shows no card images, and the response reports photoStored: false. The store-card-photos setting must not be treated as consent to keep pictures of web pages.
  • Credits: one capture, however many screenshots it carried, bills ONE page_scan action (2 credits) — not one per image. Attach mode adds a separate note_extraction, as any note would.

7u. Contact network: pagination, context ranking, and identity resolution

This flow needs ANTHROPIC_API_KEY only for turning a note into facts and relationships. Loading, filtering, pagination, ranking, promotion, and merge are deterministic database operations and must not increase the AI usage counter.

Paginated connections and dynamic filters

  • Create one company and at least 30 contacts at that company. Open one contact, expand Network, then Show connections. Confirm only the first bounded page renders and Load more connections retrieves the next page without duplicates.
  • Confirm Same company is presented as a filterable affiliation, not as an extracted direct relationship.
  • Add two contacts to the same event and add a relationship note. Reopen Network and confirm the available filter menu includes the event and the extracted predicate, each with a count.
  • Add multiple filter tokens, remove one token, clear all filters, and search by name. Confirm filtering happens before pagination and no stale results remain after applying a new filter.

Open-ended shared context and mentioned people

  • On Aditi Sharma's profile, add the voice/text note Attended an interview together with Aaryan Mehta. Expand Connections and filter by attended interview with. Aaryan should appear even when he was not previously in People, labelled Mentioned person.
  • Open Aaryan. Confirm he is absent from the main People list and the page offers Add to People and, when a plausible existing contact exists, Merge with existing.
  • Click Add to People. Confirm Aaryan now appears in People and the relationship to Aditi remains.
  • Repeat with a second mentioned person, create a corresponding full contact, then choose Merge with existing. Confirm the mentioned profile redirects to the full contact and the original relationship and source-note receipt remain attached to that full contact.
  • Add a new person manually with the exact name of a single hidden mention. Confirm the mention is promoted instead of creating a duplicate.

Ambiguous global voice/paste capture

  • Create three contacts named Aditi Sharma, Aditi Singh, and Aditi Mehta, with different companies or titles.
  • In global Quick add, dictate or paste Aditi has a son named Aaryan. Confirm Dhaga asks Which person did you mean? before relationship extraction and shows title/company evidence for all three Aditis.
  • Select Aditi Sharma. Confirm the note is attached only to her and the resulting parent of connection appears only on her profile.
  • Repeat and select None of these — create someone new. Confirm the normal contact-review flow opens rather than updating an arbitrary Aditi.
  • Paste a full unique name such as Aditi Sharma has a son named Aaryan. Confirm the exact full-name match bypasses the ambiguity screen.

Context-aware relevant people

  • Create two CEOs: one tagged fintech and one with no shared sector, tag, geography, event, or warm path. Open Network → Find relevant people, select Founder, enter fintech, and click Rank locally.
  • Confirm the fintech CEO appears with a concrete explanation and action; the unrelated CEO must not appear merely because their title is CEO.
  • Try Founder, Sales, Investor, and Any goal with sector, stage, and geography context. Confirm every result states why it matched and the browsing/ranking actions do not increment AI usage.

Targeted automated verification:

npm exec --workspace apps/web -- tsc --noEmit
npm exec --workspace packages/core -- tsc --noEmit
npm test --workspace apps/web -- --run \
  src/lib/__tests__/network-retrieval.test.ts \
  src/lib/__tests__/graph-receipts.test.ts \
  src/lib/__tests__/contacts-mutations.test.ts

If Vitest fails before collecting tests with a missing schema module, check git status first. In a shared worktree, another session may be moving or deleting that schema file; restore or finish that parallel change before interpreting the failure as a network-feature regression.

7v. Photo notes (needs ANTHROPIC_API_KEY)

Photo is the third way to capture a note, alongside typing and voice — for the things a card scan is wrong for: a whiteboard, a conference poster, a handwritten page, a receipt.

  • On a contact, tap Photo note in the same composer as Voice note (on a phone this opens the camera). The tray appears with crop / reorder / remove, and the textarea stops being required — the photo carries the text.
  • Add note → the saved note is labelled photo, and its body is the text read out of the image, so searching for a phrase written on the whiteboard finds it. Facts extract from it exactly as from a typed note, with the note as their receipt.
  • Type a line as well → the note keeps both: your line and the transcription, not one replacing the other.
  • The photos appear on the person's page as receipts, under the same Store captured photos setting (§7d) as scanned cards, and are deleted with the note.
  • A photo with nothing legible and no typed line is refused, not saved as a blank note. With the AI cap exhausted, a typed line still saves as a plain note rather than the whole thing failing.
  • Automated: npx playwright test e2e/photo-note.spec.ts (needs a real key — it makes a live vision call).

7w. Inbound messaging: every kind of forward (WhatsApp / Telegram)

The point of this section is that nothing you send is ever silently dropped. Each case gets a contact, a note, or a reply saying why not. Covered by apps/web/src/lib/__tests__/messaging-cases/.

  • Text → routed like a web quick-add: a confident single match attaches the note to that person (no duplicate); no match creates them; ambiguity asks (below).
  • Forwarded contact card (vCard) → the contact is built from the structured payload, with no AI re-parse. An unreadable card raises a notice rather than vanishing.
  • Photo of a business card → scanned into a contact. Any other photo → transcribed into a note on the person; a caption is kept as well. Test this on Telegram specifically: Telegram sends no mime type, and every Telegram photo used to be dropped for that reason alone.
  • Voice note → replies "Voice notes aren't supported yet — coming soon! For now please type it, send a photo, or forward a contact." It is refused at the door, not stored as a stub. This message is gated on a transcription provider being registered, so it disappears by itself the day one is plugged in — don't hard-code around it.
  • Ambiguous note ("met Aditi today" with three Aditis) → a numbered question in the chat. Answer by number, by name, or new. Reply with something else instead and the question is released: the pending note is saved under a new person and your new message is handled normally — it is never lost.
  • Video / document / sticker / unknown attachment → an immediate reply naming what it was; nothing stored.
  • Empty message → an immediate reply.
  • A media download that fails → that one item fails and the rest of the batch still processes. It used to abort the whole batch.
  • The batch summary reflects what actually happened — a batch that only asked a question says it is waiting on your answer, not that it couldn't find a contact.

7x. Birthdays & anniversaries → reminders (no API key needed)

Important dates have been storable on a contact for a while; what is new is that they now do something. Reminders are derived from the dates on your contacts — there is no reminders table — so editing a date or forgetting a person needs no cleanup, and that is the property to test.

  • On a contact → More detailsImportant datesAdd date. The row starts as Birthday and the value is a real calendar picker: month and year are dropdowns, so a 1974 birth year takes two clicks rather than scrolling back six hundred months. Save.
  • Reopen the contact — the picker opens on that date's month, not on today.
  • A date imported from Google or a .vcf may be stored verbatim (December 9) or without a year (12-09). Open the form and confirm the picker trigger shows that text as a placeholder, and that saving other fields leaves the string byte-identical. Dhaga must not silently reinterpret a date the user never typed.
  • Set a date a few days out, then check all three surfaces agree: /app/plan (Month view) shows an all-day amber entry reading Name — Birthday, its side panel lists it under Upcoming dates — as does the matching tile on Home — and the nav bell carries it in the feed.
  • On the calendar, try to drag it — it snaps back and nothing is saved, and clicking it opens the contact rather than a Done/Reschedule dialog. A birthday is not a task; the caption under the grid says so ("Birthdays and anniversaries come from your contacts — open the contact to change one").
  • Set the date further out than the lead window (Settings → Suggestions → Birthdays & anniversariesDays ahead, default 7) — it disappears from all three. Widen the lead time and it comes back. The Upcoming dates section hides entirely when nothing is in the window rather than showing an empty heading.
  • Save 02-29 on a contact and confirm that in a non-leap year the occurrence lands on 28 February, never 1 March.
  • Delete the date, or forget the person → every surface above clears with no leftover reminder. That is the point of deriving them.
  • Automated: npm test --workspace packages/core -- --run src/dates/important-dates.test.ts for the recurrence maths, plus npm test --workspace apps/web -- --run src/utils/__tests__/upcoming-date.test.ts src/components/app/calendar/__tests__/event-map.test.ts.

7y. The notification bell, and knowing a background job finished

The bell used to count follow-ups only. It is now a feed of three kinds — follow-up reminders and upcoming dates (both derived, nothing stored) and persisted notifications (a real table), which is the first thing that tells you a background job finished after you navigated away.

  • Open the bell: the header reads Notifications, unread persisted items sort above the derived reminders, and read ones stay below as history. Empty state: "You're all caught up ✨".
  • A follow-up row still has a Done pill. An important-date row has no action — only a link to the contact.
  • The badge counts overdue + due-today follow-ups, unread notifications, and important dates only when they land today. A birthday six days out belongs in the panel but must not inflate the badge — the badge means "act now".
  • Add a note on a contact then navigate away immediately (Home is fine). When extraction finishes, the bell gains an unread item — "Extracted 4 facts and 1 follow-up from your note about …". Click it: it marks read and opens the contact. Also exercise the X (dismiss) and Mark all read.
  • Exhaust the AI budget (§7r) and add a note → a blocked notification reading "You're out of AI credits this month, so facts weren't extracted. Your note is saved." (EXTRACTION_BLOCKED_LABEL), and the note is still saved — the save itself reports "Note saved. You're out of AI credits this month, so facts weren't extracted." Force a failure → a failed notification whose body carries the reason, with Retry on the contact page.
  • Forget a contact that has notifications → its rows go with it. Titles embed contact names, so both foreign keys are ON DELETE CASCADE; a notification outliving its contact would be a privacy bug, not clutter. (Separately and still unfixed: forgetting a contact that has an extraction_jobs row aborts on a foreign key — see §7n.)
  • Automated: npm test --workspace apps/web -- --run src/lib/__tests__/notifications.test.ts src/lib/__tests__/notification-feed/ordering.test.ts src/lib/__tests__/notification-feed/actions.test.ts.

7z. Background-job progress that actually settles

Each of these was a real failure, so test them as regressions rather than as features.

  • Add a note → the copy says the work keeps running if you leave the page — then leave, come back, and confirm the facts are there. The claim has to be true, not merely reassuring.
  • Stay on the page → the pill becomes a confirmation with real counts ("Extraction finished — 4 facts and 1 follow-up added."), a toast fires, and the pill clears itself after ~12s. It must not need a reload; a sticky "extracting facts…" notice that only a refresh cleared was the bug.
  • Trigger Enrich from public web → same shape ("Searching the public web in the background. This keeps running if you leave the page…"), and it settles into a finished or failed state — never a spinner that runs forever.
  • Start a job then navigate away mid-stream. The job must be recorded done, not failed: writing to a closed stream used to mark an already-successful job FAILED.
  • Open the same contact in two tabs and start one job. Both settle; only the tab that ran the worker toasts, and the other reconciles by polling.
  • Automated: npm test --workspace apps/web -- --run src/components/app/extraction/ExtractionStatus/useExtractionStream/settle.test.ts src/components/app/extraction/ExtractionStatus/useExtractionStream/live-state.test.ts.

7aa. Time zone + the daily brief (needs RESEND_* + CRON_SECRET)

Since 2026-08-09 there is one scheduled email, the daily brief (apps/web/src/lib/jobs/daily-brief/); the reach-out digest, confirmations digest, morning reminder, due-follow-up sweep, birthday/anniversary reminder and LinkedIn nudge are now sections of it. The headline case is the first box below: where three emails used to arrive, exactly one must.

Three of the emails now merged into the brief could never send for a hosted user before 2026-07-30: they read their own opt-in setting on an unscoped connection, so under EE row-level security the read matched zero rows and every toggle looked off. If you run hosted mode, treat this section as the regression test for that.

  • Settings → Suggestions → Time zone. It defaults to UTC (existing users see no change until they choose), the picker searches cities, and Use detected zone (…) appears only when your browser disagrees — it fills the field and never auto-saves. Save it.
  • Three emails become one. Enable Morning follow-up reminders, the Reach-out digest, the Confirmations digest and Birthday and anniversary reminders, then arrange for all of them to have content at once (an overdue follow-up, a queued confirmation, a birthday inside the lead window). Hit /api/jobs/daily with the CRON_SECRET header (as §7p) → exactly one email arrives, carrying each of those as a section in urgency order (follow-ups → dates → confirmations → reach-outs → also waiting → LinkedIn), with the subject drawn from the first section that had content. Two or three messages inside a minute is the bug this replaced.
  • Same setup in hosted mode with a second non-owner account — each opted-in account gets its own single brief. Only the owner receiving mail means the per-tenant fan-out has regressed.
  • Turn one section's toggle off and re-run on a fresh local day — the brief still arrives, minus that section. A toggle now selects a section, not a separate email.
  • A day with nothing in it sends nothing. On an account with contacts but no due follow-ups, no dates in the lead window, no confirmations, nobody to reach out to and no going-quiet contacts, run the endpoint → no email at all. A "you have nothing today" message is a failure, not a pass.
  • Hit the same endpoint twice in a row → the second run sends nothing. The brief records the recipient's local day under the single daily_brief_last_local_day settings key, so a re-triggered cron is a no-op even for someone at UTC+14 — and the activation nudge below shares that same record, so the two can never both land on one day.
  • The follow-ups section includes items due within the next 3 days, each tagged honestly (Overdue / Due today / Due tomorrow / Due in N days) — previously an item due in three days was not emailed until it was already late. The bell is deliberately unchanged: still overdue + due-today only.
  • The important-dates section names a given occurrence at most twice — once when it enters the lead window, once on the day itself. Run the cron on several consecutive days and confirm there is no third mention.
  • With the birthday toggle off, no birthday section is ever included however many dates are saved. Imported address books arrive full of dates the user never reviewed, which is why opt-in is the design.
  • Daily check-in fallback. With Reach-out digest (daily_digest_enabled) on and nobody due from the suggestion engine, the brief still carries a "Threads going quiet" section listing at most three real contacts (QUIET_CONTACTS_IN_BRIEF). With no going-quiet contacts either, the section is omitted — and if that was the only section, no email.
  • Activation nudge on an empty account. On a verified account with zero contacts and Morning follow-up reminders on, run the endpoint → instead of a brief, a short "Start your network in Dhaga" email pointing at adding a first contact and the product guide. Add one contact and run again on a later local day → no nudge (it is the empty graph, not the account's age, that qualifies).
  • The nudge stops. Keep the account empty and run the endpoint on consecutive local days: a nudge goes out at most every ACTIVATION_NUDGE_INTERVAL_DAYS (7) and at most ACTIVATION_NUDGE_MAX (3) times in total, then never again. Check the activation_nudges_sent settings row holds one day key per send. A brief and a nudge must never arrive on the same day — the nudge is an alternative, not an extra.
  • An established account on a quiet day gets neither. Contacts present, nothing to report → no brief and no nudge.
  • Opt-out footer. The daily brief and the activation nudge both end with "If you'd rather not receive these, turn them off in Settings → Suggestions", and the link resolves to an absolute …/app/settings#suggestions that opens the Suggestions tab with the toggles in view. A relative or dhaga.app-hardcoded href on a self-host is a failure (emailLinkBase()).
  • No footer on transactional mail. Trigger a password reset (and, if convenient, the welcome and email-verification mails) — none of them carry the opt-out line, because no setting turns them off. The user-triggered event digest (§7p) is transactional too and likewise has none.
  • EMAIL_JOBS_HOURLY=true applies only if you drive the endpoint hourly; then the daily brief sends only on the run matching the recipient's local ~08:00. Unset — the Vercel Hobby default of one cron a day — it still sends on that single run: the gate must never be able to discard the only invocation of the day. MORNING_REMINDER_HOURLY is honoured for one release as the old name.
  • Automated: npm test --workspace apps/web -- --run src/lib/jobs/daily-brief/index.test.ts src/lib/jobs/daily-brief/activation.test.ts src/lib/jobs/daily-brief/follow-ups.test.ts src/lib/jobs/daily-brief/important-dates.test.ts src/lib/__tests__/email-opt-out-footer.test.ts src/lib/__tests__/timezone-zone.test.ts src/lib/__tests__/timezone-settings.test.ts. index.test.ts pins the headline case — one email where there used to be three — and the job suites model RLS by returning rows only inside a tenant scope and counting unscoped reads, so an unscoped implementation fails them.

None of §7x–§7aa has had a browser click-through yet. All four were built and unit-tested while the working tree was under concurrent edit, so treat every box in them as genuinely unrun rather than "probably fine". The 2026-08-09 merge into one brief is in the same position: no live email was sent through Resend to verify it — the boxes above are unrun, and the change is covered by unit tests only.


7ab. Home lifecycle, globe, responsive nav, and city map

Run this in both light and dark mode. The phone pass is 375×812 or narrower; also use one tablet width (768–1023px) and one desktop width (≥1024px).

  • With a fresh local/PGlite account containing no people, open /app. Confirm Welcome to Dhaga, Your world starts with one person, the transparent thread ribbon, and both add/import actions fit at 375px with no horizontal scroll. The fixed phone bar must not cover the last setup row or a toast.
  • Add one person. Home becomes Taking shape, shows Moments for today, the network area, and the five-step setup card. Person completion must come from the actual contact count. Connect a calendar (or use a local test fixture), save calling hours, review reminders, and open the map; refresh after each and confirm the first incomplete step is active.
  • At the bottom of Home, set a relationship goal, edit it, and archive it. For an existing matched goal, confirm progress and Request now remain reachable without changing the lifecycle composition above them.
  • Complete all five facts and refresh. Home becomes Integrated. Its date and morning/afternoon/evening greeting use the configured user time zone, not the browser's accidental local zone. Export JSON and confirm the calling/setup preferences are present but internal settings keys are not.
  • With at least three cached cities, drag the globe and verify it rotates without navigating. Wheel/pinch zoom it, then click without dragging and confirm it opens /app/map. Focus it with the keyboard: arrow keys rotate and Enter opens the map. With prefers-reduced-motion: reduce, manual interaction still works but Calling windows does not auto-rotate.
  • Switch Places → Calling windows. Compare one city inside and one city outside the saved window, including a zone on the other side of midnight. The labels and available-people total must agree, and the day/night shadow should move with the rotating globe. Home must not issue a Nominatim or other geocoding request while rendering.
  • Simulate WebGL construction failure. The fallback copy must remain focusable/clickable and open the map; a blank circle or trapped keyboard focus is a failure. Leave and return repeatedly while recording the Performance panel; detached canvases or a continuously running hidden-tab animation are failures.
  • On /app/map, select a city point. The camera frames it at zoom 11 without pulling back if already closer; drag and zoom continue to work afterward. Confirm the selected-city sheet lists the correct people, coverage counts stay distinct, and OpenFreeMap/OpenStreetMap attribution remains visible. Return Home and confirm direct or nav entry also marks Explore your network complete.
  • Navigation: phone shows Today / People / Capture / Map / You; You contains every Workspace, Explore, More, Resources, Preferences and Account destination. Tablet uses the right-side Menu sheet. Desktop uses the permanent rail with the existing Dhaga mark/wordmark. Capture opens one dialog at every width; search/keyboard shortcuts must not fire twice.
  • Signed out, inspect the landing app window. Switch all three lifecycle stages, drag/zoom the globe, open its map, select a city, and return Home. Network data must stay synthetic, stage totals must update with the chosen fixture, and the landing must not call authenticated Home/map APIs.
  • In DevTools Network, verify the three Earth textures come from local /assets/globe/* URLs and total under 200 KB. Scroll the landing preview well out of view and hide the tab: CPU/GPU recording should settle. On a 4-core/mobile emulation, verify the globe remains responsive at the capped DPR rather than forcing a full-resolution canvas.

Static guards for this section:

npm test --workspace apps/web -- --run \
  src/components/app/home/DashboardSection/presentation.test.ts \
  src/lib/repo/home-network/build.test.ts \
  src/lib/repo/home-setup/derive.test.ts \
  src/lib/repo/home-setup/store.test.ts \
  src/lib/time/calling-hours.test.ts \
  src/lib/time/place-zone.test.ts \
  src/lib/__tests__/export-preferences.test.ts \
  src/components/ui/map/point-focus.test.ts \
  src/utils/constants/app/nav.test.ts \
  src/utils/constants/landing/lifecycle-preview.test.ts
npx playwright test e2e/nav.spec.ts e2e/responsive.spec.ts

7ac. Facts by hand on a Company page (no API key needed)

Nothing in this section or the two below has been browser-verified. Only §7ac has an e2e spec at all — e2e/facts.spec.ts, "add a fact to a company", added on this branch and never executed. §7ad and §7ae have no e2e spec, and neither does the archive export. That is exactly why these walkthroughs exist. Treat every box in §7ac–§7ae as unverified (needs a live run).

  • Open a company at /app/companies/[id] (via a contact's company link, or /app/companies). The Facts section now carries the same Add fact form the person page has. It used to be hidden here, because repo/manual-entries.addFact took only a contact id.
  • Pick a type, type a fact, Add. It appears immediately (optimistic row) and is still there after a reload — filed under the company, not under some contact. An optimistic row that vanishes on refresh means the write went to the wrong owner.
  • The new fact carries no receipt: no "from note, {date}" line and no unverified badge. A hand-typed fact is written with a null source_note_id (addFact, apps/web/src/lib/repo/manual-entries.ts), so there is no note to cite and no note deletion that can tombstone it.
  • No AI credit was spent. Compare /app/settings → Credits (§7r-i) before and after: the "N of {cap}" figure is unchanged and no new row appears in the usage history. There is no model call on this path at all — the fact is indexed with the local embedder, a free on-device primitive.
  • With ANTHROPIC_API_KEY unset entirely, the form still works end to end. That is the check that matters for a core-only self-host.
  • Delete the fact — only it disappears, same as §7f.

7ad. Enriching a company (needs ANTHROPIC_API_KEY)

Unverified (needs a live run), and no e2e spec covers this flow at all — see §7ac. The person-page counterpart is §7l; what this section is really checking is that the company arm is the same gate at the same price, not a cheaper way into the same web searches.

  • On /app/companies/[id], the Facts section shows Enrich from public web ✦, described as "Searches the public web for this company — cited, saved as a note, fully deletable."
  • The plan gate. On a free account the button is clickable — the greyed-out state is the credits gate, not this one — and the action comes back inline with "Enrichment requires a Pro or Power plan." On Pro, Power or any core-only self-host it runs: currentPlan() resolves to self_hosted there, and that plan holds the enrichment feature (apps/web/src/utils/constants/plans/).
  • The credits gate is separate and stacks in front of it. With the monthly allowance exhausted the button is disabled before any click with a credits reason (§7r-ii), exactly as on the person page.
  • Run it on a company with a real public footprint. The button holds its loader until the findings exist and the page then refreshes itself — there is no FactsPanel here streaming facts into a live window, so EnrichButton drives the worker itself and the page's own ExtractionStatus re-fires it for any job still active on a later visit. Watch for the failure that replaces: a button that returns instantly and leaves nothing behind means the job was enqueued and never driven.
  • A web enrichment note lands on the company with cited source URLs, and the facts extracted from it appear badged unverified, each carrying that note as its receipt.
  • The one-tap confirm on an unverified fact clears the badge; the fact itself stays.
  • Delete the enrichment note — every fact derived from it goes with it (receipts invariant, same as §7f/§7l).
  • It costs exactly ONE 20-credit action, not two. In /app/settings → Credits, usage rises by 20 and the history shows a single enrichment row for the run: the whole job is wrapped in one withAiAction("enrichment"), so the search call and the extraction call fold into that one row with their tokens summed.
  • Without a key: "Configure an LLM provider to enable enrichment", and no job is enqueued.

7ae. Attachments on a contact and on a company (no API key needed)

Unverified (needs a live run), and no e2e spec covers this flow at all — see §7ac. Files are stored base64 in your own Postgres, the same shape card photos already use, so there is no bucket to configure and nothing here costs a credit.

Run the whole list twice — once on /app/people/[id] and once on /app/companies/[id]. It is one component rendered in both asides, so any divergence between the two is a wiring bug, not a feature difference.

  • The aside shows a Files section: an Attach file button, "No files yet." beneath it, and the line "PDF, Word, PowerPoint, Excel, text, CSV or image — up to 4 MB."
  • Attach a small PDF. A "File attached." toast fires and the row lists the file name, size and date, newest first.
  • Download it back and compare the bytes. Clicking the row saves the file rather than previewing it — the route always answers application/octet-stream with Content-Disposition: attachment and nosniff, whatever type was stored. Diff the downloaded file against the original: same size, same content. A base64 round trip that loses bytes is exactly what a "it appears in the list" check misses.
  • A file whose name is non-ASCII (accented, or Devanagari) downloads with its name intact rather than mangled — it rides in the RFC 5987 filename* parameter.
  • Over the cap is refused in the browser, before any upload. Pick a file larger than 4 MB → "That file is larger than the 4 MB limit." appears under the button and no request leaves the page (confirm in DevTools Network: no POST /api/attachment). That is the point of the client check — a phone must not spend an upload to be told no. The route and the repo re-check the same rule, so it holds however the request arrives.
  • A format outside ATTACHMENT_TYPES is refused too. The picker filters to the accepted list, so switch the OS dialog to "All files" and choose e.g. a .zip or an .svg → "Attach a PDF, Office document, spreadsheet, text file, or image."
  • Retry immediately with the same file after a rejection — it must work. (The input is cleared on pick precisely because re-choosing an unchanged file fires no change event, so a retry used to do nothing at all.)
  • Delete via the row's ✕ → a confirm dialog quoting the file name → the row disappears with a "File removed." toast, and GET /api/attachment/<id> afterwards returns 404. This is a hard delete, no tombstone: the row is a document the user asked us to stop keeping.
  • Nothing here spends a credit. /app/settings → Credits is unchanged across the entire list, and every step above works with ANTHROPIC_API_KEY unset — the shelf has to work on an instance with no LLM configured.
  • At 375px a long filename truncates inside the aside instead of overflowing it, and the ✕ target is at least 44×44.
  • Forget this person (§7n), or delete the company — their files go too, and the download URL 404s.

7z. Tasks, recurrence, date confirmations, and recovery states

  • Open /app/plan (List view); create an undated task with no associations, then one linked only to a company. Confirm both save without a placeholder person.
  • Create daily and weekly recurring tasks, mark each done once, and confirm the same row advances exactly one occurrence and appears in the Month view.
  • Add a person follow-up with daily/weekly/monthly/yearly recurrence; verify its cadence controls and completion behavior match Tasks.
  • Switch Day / Week / Month / List. The page opens on Week (PLAN_DEFAULT_VIEW); paging to another month in Month and switching to Week keeps that date. Set a search, a scope chip, a status and a person — all four views honour them, and completing a row in List removes it from Month with no reload. Status defaults to Active; All and Completed bring done work back, struck through, never overdue and never draggable.
  • New event creates a Dhaga event (title required; all-day, location, notes and a colour optional). It renders on the grid, opens for edit and delete, and snaps back if dragged — only follow-ups re-date on a drop. Confirm it does NOT change Home's open-slot / busy-day tiles.
  • With an upgraded connection: an imported event opens a read-only panel naming its calendar and account, with no link out to the provider and no way to edit it. In Settings, untick one of that account's calendars → its events leave the grid; recolour one → its events follow. (Google only — an Outlook connection lists no calendars by design.)
  • Add a note containing “reach out next weekend”. Confirm Saturday appears on Calendar before review, then exercise Keep Saturday and Move to Sunday from /app/confirmations.
  • On a person's Keep in touch card, verify all cadence-specific controls. Auto must survive refresh; an explicit over-capacity weekday must remain unsaved until Save anyway, then persist without silently changing days.
  • Visit unknown public and /app URLs in light/dark at 375px and desktop. Confirm recovery actions, reduced-motion behavior, and loading stability.
  • On the same preview deployment, inspect /app/map and the URL in MAPLIBRE_WORKER_URL. The app response must carry neither Cross-Origin-Opener-Policy: same-origin nor Cross-Origin-Embedder-Policy: credentialless — isolation was removed on 2026-08-25 because it blocked Razorpay checkout everywhere under /app (e2e/checkout-isolation.spec.ts). The worker must still be 200 JavaScript with Cross-Origin-Embedder-Policy: credentialless: inert while nothing is isolated, and the guard if isolation ever returns.
  • Open /app/map in Chrome with DevTools recording. Confirm the MapLibre worker completes without ERR_BLOCKED_BY_RESPONSE or coep-frame-resource-needs-coep-header, the loading veil clears, the canvas and attribution render, and no new console error is emitted.
  • Same check for the graph worker, which is bundled rather than copied: open /app/graph in Chrome with DevTools recording and confirm the /_next/static/chunks/turbopack-worker-*.js request is 200 and carries Cross-Origin-Embedder-Policy: credentialless, with no ERR_BLOCKED_BY_RESPONSE. Verified locally under next start; the Vercel path is unverified/_next/static is CDN-served there, so confirm the header actually survives on a preview deployment. This one fails silently: when the worker is blocked the graph still renders, just via a multi-second synchronous main-thread layout, so the only symptom is a frozen tab on first load of a large graph.

7ac. The Me page: profile, company, and templates (no API key needed)

Nothing here calls a model, so this section works on an instance with no ANTHROPIC_API_KEY and spends no credits. If a credit counter moves while you do any of this, that is a bug, not a surprise.

  • Me is in the desktop More menu and the phone You sheet — not the rail or the bottom bar. Open /app/me: three tabs, Profile, Company, Templates.
  • Switching tabs changes the URL (?tab=company). Reload on that URL and you land on the same tab; the back button walks back through them. Type ?tab=nonsense by hand — you get Profile, not an error page.
  • Fill in Profile and save; reload and the values are still there. Now clear one field and save again: present-and-blank means delete, so it comes back empty rather than reverting to the old value.
  • Save Profile again with only some fields on the form — the fields you did not submit must be left alone, not blanked.
  • Paste 300 characters into a single-line field (name, headline, email…) and save. It is refused with a message, never silently truncated.
  • Fill in Company as well, then check the database (or export) — there is still exactly one me_profile row. Saving twice must not accumulate rows.
  • Templates → New template. Create a document: give it a title, type a body, and use the variable picker to drop {{me.name}} and {{today}} in. Tokens are inserted at the cursor, not appended.
  • The preview updates as you type and shows your real profile values for {{me.*}} / {{company.*}}. Anything you have not filled in shows a sample instead, and {{contact.*}} always shows samples — confirm no real contact from your graph ever appears in the preview.
  • Type {{me.nmae}} (deliberate typo). It stays visible verbatim in the preview. Then clear your profile phone and use {{me.phone}}: a known but empty variable renders as nothing at all.
  • Type an unclosed {{ and keep typing — the preview must not blank out or throw.
  • Create an email template: a Subject field appears and previews its own variables. Edit it back to a document — the subject is dropped, and re-opening it confirms the subject is gone rather than hidden.
  • Templates are grouped Documents / Email drafts, with the body shown as a two-line clamp with its {{tokens}} unexpanded (that is the thing being edited).
  • Edit a template, save, reopen: the change is there. Delete one: it goes through a confirmation and does not come back on reload.
  • Change your name in Profile, then reopen a template that uses {{me.name}} — the preview shows the new name. Bodies are stored unexpanded on purpose, so nothing anywhere holds a stale copy.
  • At 375px: the tabs, the editor dialog and the preview all fit, and the dialog scrolls rather than trapping the save button off screen.
  • Known limit, not a bug: there is no way yet to render a template against a chosen contact{{contact.*}} only ever previews with sample values. Do not go hunting for a "use this template on Alex" button.

This is the one section where the point is what does not appear. Do the recipient-side checks in a private/incognito window with no Dhaga session, never in the tab you are signed in to — signed in, the page would look right for the wrong reason.

Creating and copying

  • Open a contact → Share. The dialog says plainly that anyone with the link can see it without a Dhaga account, until it expires.
  • Expires after offers 24 hours / 7 days / 30 days / 90 days, defaults to 7 days, and offers nothing meaning "never". Confirm there is no such option anywhere in the dialog.
  • All the Also include toggles start off. A contact offers Contact details and Notes; a company offers People and Notes — neither subject is offered a toggle that cannot apply to it.
  • Add a Label and create the link. The label is listed for you in the dialog — open the link and confirm the label appears nowhere on the public page. It is your note to self.
  • Copy the link. It is <your host>/s/<a long random token> — nothing in the URL should resemble the contact's id or name.
  • Create a second link for the same contact with different toggles. Both are listed, newest first, each with its own expiry and view count.

What the recipient sees (private window, no session)

  • With every toggle off: the page shows name, nickname, title, company and job history — and no email address, no phone number, no location, no links, and no notes. Search the rendered page for the contact's email and phone: zero hits. Then view source and search again — a field must not be present-but-hidden.
  • Nothing private appears at any toggle setting: no AI-derived facts, no tags, no follow-ups, no star, no keep-in-touch cadence, no capture source, no signals, no relationship edges, and no card photos. Fetch /api/card-image/<id> in the same private window — it must answer 401.
  • Now create a link with Contact details on: emails, phones, links and location appear. Nothing else changed. Put a private note on one of those numbers first (the per-method annotation) — the label (Work, Mobile) shows, your note about it must not.
  • Add a private note to one of the contact's job positions as well. The role, employer, department and dates are shared; your remark about it is not, and neither is the note the role was derived from.
  • Create one with Notes on: your notes about the person appear. Confirm the previous link (created with notes off) still shows none — a toggle applies per link, and an existing link must never widen retroactively.
  • Company link: with toggles off you get name, domain, sector and aliases. With People on, the people you know there appear by name and role only — confirm the page carries no way to reach their contact records.
  • The page must not be indexable: view source and confirm <meta name="robots" content="noindex, nofollow">, a generic tab title that does not contain the contact's name, and no description quoting their details. /<host>/robots.txt disallows /s/.

Expiry, revocation, and telling nothing apart

  • Revoke a live link in the dialog. Reload it in the private window immediately — it is dead now, with no waiting for a job to run. The row stays listed for you, marked Revoked, with its view count intact.
  • An expired link: either create one and wait out a 24-hour preset, or set an existing row's expires_at to the past directly in the database. It stops resolving on the next load, with no sweeper having run.
  • The three must be indistinguishable. Open (a) an expired link, (b) a revoked link, and (c) a completely made-up token like /s/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa. All three must render the same page — same heading, same wording, same status code. If one of them says "expired" while another says "not found", that leaks whether a token ever existed and is a bug.
  • Forget this person on a shared contact, then open their live link: the same neutral page. Deleting the record is itself a revocation.
  • Open a working link three times and confirm the owner's dialog shows 3 views. Then open a made-up token twice — the count must not move for anything, and nothing anywhere records who the visitor was: no IP, no user agent, no visitor identity.
  • The token must not reach analytics (a preview or production deployment — the scripts do not run locally). Open a share link with DevTools → Network filtered to insights, and read the request body of the /_vercel/insights/view (and Speed Insights /vitals) call: the reported url must be /s/[id], not /s/<the real token>. This one was a live leak until 2026-08-22 and it fails silently — the page works perfectly either way, and the token is only visible in a store you do not read.

Tenancy (hosted / multi-user instances only)

  • On an instance with two accounts, create a link as user A and open it while signed in as user B in another browser. B sees A's record — the page is resolved from the link's owner, not from whoever is looking.
  • Signed in as B, attempt to revoke A's token (the action takes the token, so this is worth trying via the network tab). It must fail, and A's link must keep working.
  • Not yet done anywhere: the live-database RLS isolation test for me_profile and me_templates skips itself without DATABASE_URL, including in CI, so no run has proven those policies against a real Postgres. Run it against a disposable local Postgres before treating tenant isolation of the two new tenant tables as verified.

8. What works with zero API keys vs. what needs one

Confirmed via hasLLM() (packages/core/src/llm/index.ts:46-48, Boolean( process.env.ANTHROPIC_API_KEY)) and its callers — every AI-shaped feature checks this before calling out, and degrades to an honest message or an offline fallback rather than failing:

No key ≠ no credits. These are two separate gates with two separate messages, and this table is only about the first. Without a key the features on the right degrade exactly as described here (offline parser, "needs cloud AI"); with a key but an empty credit balance they are instead greyed out before the click with a credits reason (§7r-ii). aiGateReason returns null when hasLLM() is false precisely so the two never stack.

Works with zero API keysNeeds ANTHROPIC_API_KEYNeeds something else too
Sign up / login / sessions (better-auth)Card photo scan (OCR)
Manual People CRUDQuick-add AI extraction (falls back to a heuristic offline parser without it)
Quick add via the offline heuristic parser (no AI badge)Notes → fact/follow-up extraction
Keyword searchSemantic search snippets ("related note/fact:")Local embeddings must be on (DHAGA_EMBEDDINGS not off)
Warm-path graph traversalAsk AI (search reasoning)
CSV/vCard/JSON exportFollow-up drafts
Forget this person (deletion cascade)Brief me (pre-meeting dossier)
LinkedIn/Google CSV importWeb enrichment— (the LLM provider's own web search; no FIRECRAWL_API_KEY involved — see §7l)
Browser extension capture
Telegram bot capture/queryTelegram's ?who... query answersTELEGRAM_*
Admin panel / access requests / billingDHAGA_HOSTED_MODE, real Postgres (DATABASE_URL), DHAGA_ADMIN_EMAILS; billing also needs STRIPE_SECRET_KEY
Job-change/news signal detectionThe whole sweep — search and classification — needs the keyCRON_SECRET (FIRECRAWL_API_KEY optional; search defaults to Anthropic's own web-search tool)
Event digest emailsRESEND_API_KEY, RESEND_FROM_EMAIL, DHAGA_OWNER_EMAIL

9. Static verification (CI)

npm run lint --prefix apps/web           # ESLint
npm run typecheck --workspace @dhaga/core
npm run build                            # production build must pass
npm run test --prefix apps/web           # vitest
npm run test --workspace @dhaga/ee       # vitest (EE billing/admin/RLS)

The @dhaga/ee suite needs no database: its DATABASE_URL-gated integration tests skip when the var is unset, and the lapse-sweep tests bring up their own in-process PGlite.


10. Load-testing /app/graph and /app/people at scale

Neither page paginates today: listContacts() (apps/web/src/lib/repo/ contacts/queries/recent.ts) selects the entire contacts table with no LIMIT, and GraphBrowser (apps/web/src/components/app/graph/ GraphBrowser.tsx:16-43) renders every node/edge through @xyflow/react (DOM/SVG, no virtualization) after recomputing a full ring layout on every load. There is no coded cap on contact count anywhere (checked apps/web/src/utils/constants/plans/ — the only metering is the monthly AI-action cap, not row count), so the practical ceiling is UI render performance, not storage.

apps/web/scripts/seed-dummy-graph.mjs can add one synthetic, RLS-scoped account on a disposable local database. Do not run it against .env.vercel or any Supabase URL:

cd apps/web
node scripts/seed-dummy-graph.mjs create --contacts=1000

If the script needs real Postgres/RLS behavior, point it only at the disposable local Docker database. The shared Supabase instance is additive/read-only for testing: never use the script's delete or recreate modes there.

What it does: creates one user row + a credential account (via better-auth/crypto's hashPassword, so the account logs in through the normal /login form) at a fixed id/email (loadtest@dhaga.internal / LoadTest-Dummy-2026!, printed on create), then — with app.current_user_id set to that account's id for the whole transaction — inserts N contacts, N/15 companies, and N/3 relationship edges, all tagged user_id = dummy-loadtest-user. Because packages/ee's tenant_isolation RLS policy (packages/ee/src/db/ rls-ddl.ts) scopes every read/write/delete by that session variable, RLS scopes the generated rows, but that is not permission to delete them from a shared instance; use disposable local storage for teardown experiments.

  • Confirmed by a live run (2026-07-12): ran create --contacts=1000 against the deployed Vercel project's Supabase — created 67 companies, 1000 contacts, 333 explicit edges (plus the "works at" edges fetchGraphView derives from company_id, apps/web/src/lib/repo/graph-data.ts:58-69 — so /app/graph renders roughly 2,000+ nodes/edges total for this account). Log in as that account and open /app/graph and /app/people to feel where render time and pan/zoom actually degrade — use fresh disposable local databases at other sizes to bracket it. Not yet done: nobody has recorded the actual degradation threshold from a live run — this section only confirms the seeding step works, not a measured performance number.
  • Discard the disposable local database when finished. Do not delete rows from the shared Supabase instance.

On this page

1. Pick a deploy path1a. Vercel (Hobby) + Supabase (free) — recommended path1b. Local dev — fastest loop for the core product only1c. Docker Compose — exists, unverified in this pass2. First visit → sign up3. Turning on Dhaga Cloud features + becoming the first admin4. Admin panel walkthrough (needs §3)4a. AI credit controls (/app/admin/ai-credits, needs §3)5. Access-request flow, end to end (needs §3, a non-admin test email)6. Stripe test-mode checkout (needs §3 + Stripe test-mode keys)6a. Razorpay test-mode checkout (needs §3 + Razorpay test-mode keys)Webhook (the authoritative grant path)6b. Plan matrix, processor routing, local currencyPricesPlan matrixProcessor routingPlan changes, cancel, and the admin guardRazorpay plan changes — the two-subscription flowThe checkout guard, cancel, and the admin guard6c. Founding Pro is retired — what is left to check6d. The introductory term, the offer, and the availability toggleThe step-upRAZORPAY DOES THE STEP-UP — there is nothing of ours to observe🛑 HISTORY — the SWEEP that used to do this could not run, and is deletedThe admin holdThe nightly run and the 30-day noticeThe in-app final-month bannerAutomated coverageCaveats — the measurements that decided the mechanism7. Core product walkthrough (works today regardless of deploy path)7a. People (manual CRUD)7b. Quick add (capture → review → save)7c. Card photo scan (needs ANTHROPIC_API_KEY)7d. Card-photo storage setting (Settings page)7e. Events7f. Notes → facts with receipts (needs the API key for extraction)7g. Voice notes & pre-meeting brief7h. Search (hybrid: keyword + local semantic)7i. Warm paths (Graph page)7j. Job-change & news signal detection (opt-in, needs ANTHROPIC_API_KEY + CRON_SECRET)7k. Follow-up draft (needs the API key)7l. Enrichment (needs ANTHROPIC_API_KEY)7m. Export (no lock-in)7n. Forget this person (privacy cascade)7o. LinkedIn/Google CSV import7p. Email digest & waitlist (needs RESEND_API_KEY + RESEND_FROM_EMAIL)7q. Telegram bot & outbound webhooks (optional envs)7r. Metering cap7r-i. Your own credits page (/app/settings → Credits)7r-ii. Running out of credits (the pre-click gate)7s. PWA install7t. Browser extension7u. Contact network: pagination, context ranking, and identity resolution7v. Photo notes (needs ANTHROPIC_API_KEY)7w. Inbound messaging: every kind of forward (WhatsApp / Telegram)7x. Birthdays & anniversaries → reminders (no API key needed)7y. The notification bell, and knowing a background job finished7z. Background-job progress that actually settles7aa. Time zone + the daily brief (needs RESEND_* + CRON_SECRET)7ab. Home lifecycle, globe, responsive nav, and city map7ac. Facts by hand on a Company page (no API key needed)7ad. Enriching a company (needs ANTHROPIC_API_KEY)7ae. Attachments on a contact and on a company (no API key needed)7z. Tasks, recurrence, date confirmations, and recovery states7ac. The Me page: profile, company, and templates (no API key needed)7ad. Share links, and what the recipient can see (privacy pass)8. What works with zero API keys vs. what needs one9. Static verification (CI)10. Load-testing /app/graph and /app/people at scale