BETAPro is not billed during beta. Lock in the price and we will honor it at launch.Freeze this price

Changelog

Stay up to date with the latest changes in our product.

2026-07-05/06 — Post-audit launch hardening (PR #26 cont.)

2026-07-05/06 — Post-audit launch hardening (PR #26 cont.)

Observability — Langfuse LLM tracing

  • Every LLM call is now traced (OpenRouter lanes + the Anthropic Batches lane): input/output, token usage, cost, and grouping metadata. Isolated NodeTracerProvider + LangfuseSpanProcessor coexisting with Sentry's global OTel. Full guide in `OBSERVABILITY.md`.
  • Two silent failures found + fixed: spans were dropped by Sentry's request-span sampling (fixed with AlwaysOnSampler + a ROOT_CONTEXT detach), and generation input/output rendered null until the tracer was named "ai" — the only scope name LangfuseSpanProcessor maps I/O for.
  • Conventions: Langfuse sessionId = scan_session.id (a scan's four platform calls group into one session), userId = organizationId; scan traces carry queryId + topicId for cross-run filtering. LANGFUSE_* keys are optional — unset = zero-overhead no-op.

Billing / quota

  • Manual/comp tier grants take effect immediately — enforcement read the raw organization_quota.tier and ignored manualTier until a Stripe sync ran, so a granted org stayed capped at its stale tier ("out of scans" on a comped account). New effectiveTier(row) = higherTier(tier, manualTier) drives all three gates + the quota display; unknown tiers still fail closed to free.

Server-side pagination on every list page

  • Queries, Topics, and Scans now paginate + filter server-side. Scans already did; Topics and Queries were converted. Queries was the worst offender — it fetched _every_ query (1000+ for a large org) and built a sparkline for each on every visit, then filtered/paged in the browser (so client-side filters silently only saw the loaded page). Now search / topic / importance / status filters, sort, and pagination are all SQL; state lives in the URL (deep-linkable, back-button friendly); sparklines build for the visible page only. Bulk multi-select preserved across navigation.

Insights LLM cost — ~45% cheaper per narrative, thin-topic calls eliminated

  • Thin data no longer reaches the LLM — topic narratives (sync + batch) now enforce the INSIGHTS_MIN_SCANS (5) floor the workspace already used, plus a responses === 0 skip that closes a raw-walk empty-snapshot edge (≥5 scans but no content). A 1–4 scan topic used to burn a full Sonnet call to say "not much yet."
  • Output clamped 3000 → 1000 tokens on all four lanes (output is ~68% of cost; the schema caps at 6+6 items). Brevity via .describe() hints + prompt guidance, _not_ .max() (a hard cap would fail Zod and burn a second billed call through the text fallback — see the documented rationale in insights-prompt.ts).
  • Prompt payload ~67% smaller — new compactSnapshotForPrompt drops zero-response platform slices, null context fields, and per-source sampleUrl/sampleTitle (the stored snapshot keeps them for the UI). Serialized compact, not pretty-printed. Measured ~1760 → ~589 tokens on a rich real snapshot.
  • Better cache hit-rate — hashSnapshot no longer keys on the volatile per-row sample citation, so citation churn stops invalidating an otherwise-identical narrative.
  • Honest batch metrics — the Anthropic Batches lane records its cost with the 50% discount (Opus added to pricing.ts); it was previously un-priced in Langfuse.
  • Verified live: a rich-snapshot narrative comes in at ~730 output tokens (under the 1000 cap), complete, calibration intact.

2026-06-13/14 — Deep audit + hardening sweep (plan 037)

2026-06-13/14 — Deep audit + hardening sweep (plan 037)

Security & multi-tenancy

  • BYOK decrypt failure now fails closed — see ADR 005. A customer's saved key that can't be decrypted (e.g. after a KEYRING_MASTER_KEY rotation) no longer silently falls back to — and bills — our shared key. The resolver throws ("re-enter your key"); only system admins keep the shared-key fallback (disaster recovery). The admin flag is threaded through scanRequested/platformScanRequested events so the Inngest worker honors the procedure's decision. Free-tier (no-credential) path unchanged.
  • `createCheckoutLink` IDOR closed — added an owner-membership guard on the input organizationId (matched the sibling createCustomerPortalLink). Previously any authed user could read another org's paymentsCustomerId and, on payment, attach a subscription to a foreign org via the webhook.
  • `scan-schedule` writes require org admin — enabling auto-scans spends money, so upsertScanSchedule now gates on requireOrgAdmin (was any member). Reads stay member-level. Dropped the duplicated local assertMember.
  • Typed errors across the API — a whitelist codemod converted 51 inline `throw new Error(...)` across 29 procedures to ORPCError (FORBIDDEN / NOT_FOUND / BAD_REQUEST). They previously surfaced as opaque HTTP 500s with the message stripped by oRPC; the not-found ones also bypassed the Sentry filter. The shared org guards (requireOrgMembership, requireProjectAccess, requireTopicAccess, requireOrgAdmin) throw ORPCError too. Internal errors (e.g. the "Failed to create response record" template) intentionally stay Error.

Reliability & observability

  • Partial scans finalize correctly — a single platform failure no longer strands a scan in running for 10 min and then marks the whole thing failed. A durable 3-min grace backstop in scanRequested force-classifies partial / failed / completed from whatever responses arrived; the happy path still completes immediately. Both paths route through one finalizeScanSession helper whose status transition is an atomic CAS (UPDATE … WHERE status='running' RETURNING), so post-scan side-effects (alerts, markAnalyticsStale) fire exactly once even when two platform jobs finish together.
  • Background errors reach Sentry — logger.error (consola, stderr-only) now forwards to Sentry via a DSN-guarded reporter registered in sentry.server.config.ts, so Inngest-worker / cron / scan-pipeline failures are no longer invisible. @repo/logs stays Sentry-agnostic.
  • Sentry filter stops swallowing real errors — removed the over-broad "too many" beforeSend pattern that silently dropped Turso "too many connections" / SQLITE_BUSY incidents. Expected business errors are now dropped by structured oRPC code (FORBIDDEN/UNAUTHORIZED/NOT_FOUND/PAYMENT_REQUIRED/TOO_MANY_REQUESTS/CONFLICT), not message substrings.
  • Cost true-up no longer lost — trueUpCostFromOpenRouter was a fire-and-forget that died when the Vercel function froze at the Inngest step boundary, leaving the row on the estimate. Now awaited inside the step (failure logged, not fatal).
  • BYOK decrypt failures are logged with org/provider/credential context (was an empty catch {}).

Scaling

  • Atomic scan-quota gate — checkScanQuota + fire-and-forget consumeScanQuota was a check-then-act race; two concurrent scans could both slip past the cap. And bulkExecuteScans called _neither_, so BYOK/paid orgs ran unlimited bulk scans and scansThisMonth never moved. New checkAndConsumeScanQuota does a single compare-and-increment UPDATE … WHERE scansThisMonth < limit RETURNING (race-free on single-writer Turso), wired into execute-scan, the scheduled-scan cron path, and bulk-execute (which also gets an up-front whole-batch headroom check for a clean paywall).
  • Insight-submit crons fan out over Inngest — nightly-topic-insights-submit + weekly-digest-submit ran Promise.all(orgs.map(...)) inline (full aggregation walk + Anthropic batch submit per org) on the Vercel-function timeout path, dropping the tail past a few hundred orgs. Now thin dispatchers emitting one event per org; new submitTopicInsights / submitWeeklyDigest workers run under a global concurrency cap of 5. Mirrors the process-completed-batches pattern.
  • Killed a tautological cross-org scan — submitTopicInsightsForOrg used .where(eq(topic.id, topic.id)) (always true), loading _every_ org's topics each night then filtering in JS. Replaced with one org-scoped query joining through project.

Cost / cleanup

  • Stopped computing unused embeddings — embedMany (text-embedding-3-small) wrote vectors to brand_mention/observation.embedding on every scan, but nothing reads them for retrieval (the chatbot "RAG" is chronological). Pure OpenAI spend + row bloat; removed. Re-enable with real vector search (Turso F32_BLOB + vector_distance_cos).
  • Removed Prisma + pg — all data access is Drizzle/libSQL; the only Prisma footprint was a no-op PrismaPlugin in the saas webpack config. Removed the plugin, dropped @prisma/* + pg from saas deps, and pruned the dead @prisma/{client,adapter-pg,nextjs-monorepo-workaround-plugin} / prisma / prisma-zod-generator / pg catalog + allowBuilds entries (none referenced by any package.json). Saas build verified green.
  • Deleted dead schema/quota code — schema/postgres.ts + schema/mysql.ts (unexported Supastarter multi-dialect leftovers carrying a stale NotificationType enum) and the never-called checkKeywordQuota / incrementKeywordCount.
  • Dropped the `@deprecated keyword` table (migration 0023_overjoyed_turbo.sql) — replaced by query/query_topic in the 2026-05-16 refactor, zero code references. Schema entry + relations + Keyword type removed; the generated migration was reviewed (a single DROP TABLE keyword, no other drift) and applied + verified on the dev DB. Runs against prod on the next deploy migrate. keywordsCount/maxKeywords kept (live, repurposed for query counts).
  • `scan_session.status` narrowed to a Drizzle `text({ enum })` so a typo'd status is a compile error instead of silently breaking the dashboard/reaper filters.

Tests & tooling

  • New unit coverage on previously-untested load-bearing paths: crypto seal/open round-trip + tamper/wrong-key/malformed (found & fixed an empty-plaintext envelope guard bug); the atomic checkAndConsumeScanQuota contract; the Sentry beforeSend filter (keeps real connection/crash errors, drops expected); and checkCronAuth (valid/wrong/missing/unset). vitest added to @repo/utils. 105 → 110 unit tests.
  • `oxlint . --deny-warnings` — the 150 type-aware rules ran at warn-only with no CI gate; warnings now block. Tree is already warning-clean.

Deferred (need a decision / its own PR; documented in plan 037)

  • One-time-purchase webhook idempotency (needs a processed_webhook_event table).
  • generateMetadata on tracking pages (authed routes, zero SEO; i18n cost).
  • Bigger "better ways" (orgProcedure middleware, vector search, db.batch()) — own PRs.

May 26, 2026

Category-parity quick wins + marketing surface

Product

  • CSV data export (e926091) — /settings/data-export now offers both JSON (lossless, GDPR Art. 20 portability) and CSV (denormalized one-row-per-scan flat file for BI / finance / procurement). Same backend procedure; client-side format choice. Strips embedding vectors automatically.
  • Uncited-prompts card on the dashboard (0021e3b) — the query-level mirror of gap-sources. Tracked queries where competitors got cited and the workspace's primary brand wasn't. AEO investment hit list. New orpc.dashboard.uncitedPrompts procedure + self-contained UncitedPromptsCard component.

Marketing surface

  • Public `/roadmap` (b2a4e6a) — customer-facing version of the internal roadmap. Four honest buckets: recently shipped, coming soon, deferred with explicit trigger, won't do. Linked from footer + sitemap.
  • `/customers` page (5afb770) — early-stage honest framing ("BrandBanta is early. Be the first case study"). Three placeholder "slot — open" cards + "what you get as an early customer" + CTA. Replaces with real logos when customers do agree to case studies.
  • `/integrations` page (2bc871d) — 7 live integrations, 4 planned, 3 considered with explicit trigger. Anti-vendor-lock framing.
  • `/solutions/aeo-teams` (be65171) — first persona landing. 6 capability cards + 4 differentiators (multi-engine on Free tier, open methodology, append-only scan history, exportable data) + team / workspace framing.

Deferred (with reasons documented in plan 035)

  • Platform expansion (Grok + DeepSeek) — requires verified current OpenRouter model IDs (catalog changes monthly).
  • Daily-cadence preset on scheduled-scans — the scan_schedule UI form doesn't exist yet; A4 turned into "build the form," out of scope for overnight.

May 25, 2026

Multi-provider BYOK + per-member cost + legal disclosure layer

BYOK + provider expansion

  • Migrated `packages/ai` to `@openrouter/ai-sdk-provider` (f473e1a). The previous @ai-sdk/openai + custom baseURL setup broke when the upstream package made the OpenAI Responses API its default (different endpoint shape from OpenRouter's chat completions). Provider-specific BYOK key, model fallbacks, and OpenRouter usage accounting now available via providerMetadata. embedMany() gated on OPENAI_API_KEY being set — silently skips embeddings when unset instead of throwing the noisy "OpenAI API key is missing" log on every scan.
  • Anthropic + OpenAI BYOK exposed in `/settings/api-keys` (8307e23). orpc.apiKeys.upsert widened to accept all three providers, each validated by a cheap upstream call (Anthropic: 1-token messages.create; OpenAI: models.list; OpenRouter: 1-token generateText — existing). resolveOpenAIKey(organizationId) added for parity; sits ready for the upcoming OpenAI batch lane. New ProviderApiKeyForm renders Anthropic + OpenAI sections (OpenRouter keeps its dedicated form to avoid touching the already-shipped surface). Validation logic lives in @repo/ai/validate so @repo/api doesn't grow direct SDK deps.
  • Scope card on `/settings/api-keys` (297a09b). Three-row reference card above the forms — "OpenRouter → Scan execution; Anthropic → Batch insights; OpenAI → Embeddings". Per-section copy rewritten to factual workload statements (no cost-savings framing). Absent-state language consistent across providers: "Not configured. Falls back to the platform's shared key for this workload."

Per-user attribution

  • `scan_session.userId` attribution (a1d12fa, migration 0020_fat_miek.sql). Nullable column references user(id) with ON DELETE SET NULL so leaving members don't cascade-delete workspace scan history. Captured by execute-scan.ts + bulk-execute.ts; cron-triggered path (dispatch-scan-for-org.ts) explicitly sets userId: null so admins can distinguish manual from scheduled scans.

Workspace cost dashboard

  • `/[org]/settings/cost` page (d7c3f53). Customer-facing twin of /admin/cost. Returns a viewerScope field so the UI renders different shapes by caller role:
  • Privacy framing baked into the page footer (admin vs member copy differs) and the procedure docstring. Cost data is metadata only — prompts and responses never surface alongside attribution.

Legal layer

  • Privacy policy rewritten from placeholder (15293b1, apps/marketing/content/legal/privacy-policy.md). Substantive disclosures covering: data categories, GDPR Art. 6 legal bases per processing purpose, workspace-admin visibility model, member self-access path, sub-processors (Vercel/Turso/OpenRouter/Anthropic/OpenAI/Stripe/Resend/Sentry/Inngest with locations), retention windows (24mo scans, 12mo audit logs, 30d point-in-time recovery), security controls (TLS 1.3, XChaCha20-Poly1305 at rest, Argon2id passwords), GDPR + CCPA rights, SCC-based international transfer.
  • DPA template (apps/marketing/content/legal/dpa.md). Controller/processor split, sub-processor list mirroring the privacy policy, SCC incorporation (Module 2 + 3), audit cooperation, 72h breach notification, return/deletion timelines.

May 25, 2026

GDPR + EU AI Act compliance — product layer

Cookie consent (`8d3a8fa`)

  • Real banner replacing the demo stub — three-button: Reject optional / Customize / Accept all
  • Granular three-category model: necessary (always on), analytics (off by default), marketing (off by default)
  • 12-month consent cookie with explicit annual re-prompt expectation
  • Banner copy honest about today's state (functional cookies only) + link to /legal/privacy-policy
  • AnalyticsScript stub gate documented for when analytics provider lands

Marketing email opt-in (`104f636`)

  • `user.marketingConsent` boolean (nullable, default false) — migration 0021_handy_tiger_shark.sql
  • Better Auth additionalFields config registers it for the signup flow
  • Explicit unchecked-by-default checkbox on signup form; copy distinguishes marketing vs transactional
  • Future-proofs the lawful basis for any newsletter / product-update campaign

Data portability (`9f441d8`)

  • `orpc.tracking.export.workspace` + `/[org]/settings/data-export` page — GDPR Art. 20
  • Single-payload JSON dump: organization, members (id + role + email), projects, brands, topics, queries + topic mappings, scan sessions with prompts + responses + brand mentions + observations + sources, BYOK credentials (keyHint only, never plaintext), quota state
  • Admin-only; non-admin members exercise Art. 15 via /settings/cost (own data)

EU AI Act Art. 50 transparency (`91c349a`)

  • App footer disclosure: "BrandBanta queries third-party AI assistants… outputs are AI-generated and may contain inaccuracies. Verify decisions against primary sources."
  • Public `/legal/ai-policy`: risk classification (limited-risk deployer), models in use per workload, what we do with outputs, limitations (hallucination + bias + drift + provider differential), per-provider training-data policy, human oversight model, AI literacy (Art. 4) commitment
  • Marketing footer links to /legal/ai-policy and /legal/dpa alongside existing privacy + terms

DSAR runbook (`7005ecf`)

  • `.apsolut/06-knowledge/runbooks/dsar-handling.md` — operator procedure for GDPR Art. 15/16/17/20/21 requests
  • Statutory timeline, identity verification, per-right procedure (access / rectification / erasure / portability / objection), edge cases (controller-vs-processor, special-category data), response template, audit-trail requirements

Tooling

  • `fix(lint-docs)` (`6d16b40`): docs-lint now also skips status: planning / in-progress plan files. Forward-looking plans legitimately reference paths that don't exist yet; linting them produces only false positives.

May 24, 2026

Stability foundation: Sentry verify endpoint + runbooks

Observability

  • `/api/debug/sentry` verification endpoint (apps/saas/app/api/debug/sentry/route.ts): deliberately throws so the operator can confirm Sentry capture works end-to-end. Dev: always enabled, no auth. Prod: requires ?token=<CRON_SECRET> (same gate as /api/cron/*). Returns 503 with a hint when SENTRY_DSN is unset instead of throwing into the void.

Runbooks (`.apsolut/06-knowledge/runbooks/`)

  • New `.apsolut/06-knowledge/runbooks/` namespace for ops procedures that a human must run (CLI sessions, dashboard clicks, credential operations). README documents the format + naming.
  • `sentry-verify.md`: end-to-end Sentry capture verification — dev + prod + client-side procedures, source-map sanity check, recovery for "events arrive but no source maps".
  • `turso-backup.md`: manual snapshot + point-in-time recovery + restore-from-dump procedures. Restores ALWAYS into a new DB, never overwrite prod. Post-restore verification checklist covering /api/health + auth + org load + Inngest queue + billing reconciliation.
  • `secret-rotation.md`: per-secret rotation procedures for all 10 secrets. Sorted by blast radius. Includes the critical warning on KEYRING_MASTER_KEY — rotation invalidates every encrypted BYOK row and is deferred to Sprint G's KMS migration unless the key is confirmed leaked.
  • `.apsolut/07-files/backups/` added to `.gitignore` — Turso dumps contain customer data and must never enter git.

May 24, 2026

Overnight stability hardening + cost monitor dashboard

Accessibility

  • Marketing home: demote 4 sibling `<h1>` elements to `<h2>` (6009108): hero stays h1 (page subject). Pricing, FAQ, Newsletter sections demoted. Real WCAG violation — surfaced when scoping the home e2e test ran into a Playwright strict-mode violation. Visual styling unchanged (class lists kept identical).

Code quality + CI

  • `lint:docs` STRICT gate enforced (bc303c8): three drift findings cleared (cron-jobs.mdx placeholder, two stale Stripe webhook paths in payments/README). CI flipped from warn-only to STRICT — stale markdown path references now fail PRs.

Developer ergonomics

  • 4 new skeleton loading states (28074b9): /[org]/chatbot, /chatbot (account-scope), /admin/organizations, /admin/users. Mirror their rendered surfaces so layouts don't jump on hydration. Pattern copied from existing skeletons (dashboard/queries).
  • Authenticated screenshots via Playwright storageState (fddd6e4): new pnpm screenshot:login (screenshot-login.ts) seeds a one-shot login session into tmp/auth-storage.json. pnpm screenshot now accepts --storage=<path> to capture authenticated routes without re-logging in per shot. Credentials read from $SCREENSHOT_EMAIL + $SCREENSHOT_PASSWORD shell env (not .env.local — avoids committing real user creds).

Admin

  • Cost monitor dashboard at `/admin/cost` (2c6e3f4): platform-wide spend over 7/30/90-day windows. Headline totals + per-day SVG bar chart + per-platform table + top 10 orgs by spend. Only counts scan_session.usedOurKey = true rows (BYOK customers pay OpenRouter directly). Backed by new orpc.admin.cost.summary procedure aggregating response.usageCostUsd (micro-dollars). Closes the ROADMAP "Queued" item; cleared from list.

Deferred (with reasons, see plan 031 morning summary)

  • 3 e2e units (paywall causal chain / dashboard happy path / scan execution) share a blocker: saas e2e has no test-DB seed strategy. Brief 031 documents three design options + a recommendation; ~10 min morning decision unblocks all three.
  • Onboarding analytics wiring — needs provider call (PostHog vs Plausible).
  • DB restore drill — runbook ready; execution is operator-only.

May 24, 2026

Ad-hoc UI screenshot helper + e2e hardening

Developer tooling

  • `pnpm screenshot` helper (tooling/scripts/src/screenshot.ts): drives headless chromium against any URL and writes a PNG. Built for visual verification during agent loops without paying the cost of the full Playwright e2e suite (~3 min build+start per app). Supports --width / --height / --full / --wait / --dark. Output paths resolve from the directory you invoked from (uses INIT_CWD), defaults to tmp/ which is gitignored. Auth flows are a TODO — currently for public pages or pages reachable from your local cookie state.
  • Chromium browser bundle installed locally via pnpm --filter saas exec playwright install chromium. Also added @playwright/test to @repo/scripts devDependencies so the helper can be invoked standalone.
  • Marketing home e2e de-brittled (apps/marketing/tests/home.spec.ts): previous test asserted a literal copy string ("Your revolutionary SaaS built with Next.js") that lives in i18n — once we update marketing copy the test would silently break. Rewritten to assert structural anchors via data-test hooks (navigation, color-mode-toggle, price-table-plan) + the existence of an h1, so copy refreshes don't break CI.
  • `tmp/` added to `.gitignore` so screenshot output doesn't accidentally get committed. Curated screenshots worth keeping should live in .apsolut/08-screenshots/ (already excluded from the public repo) or be referenced from a plan doc.
  • agents.md / CLAUDE.md / claude.md re-synced with a new "Ad-hoc UI screenshots" section under Testing so future sessions discover the helper without re-deriving it.

May 23, 2026

/insights UX upgrade + data retention + idea park

Features and additions

  • Visibility trend chart rewritten as a real sparkline: smooth catmull-rom-to-bezier line, gradient area fill, trend-aware coloring (teal up / coral down / muted flat), dashed reference line at the starting value for instant "where we started" anchoring, halo on the latest point as a "you are here" marker. Date labels replaced the bare min/max numbers. Legend hides itself when every point is the same kind. Previous version's preserveAspectRatio="none" was stretching circles into horizontal dashes — switched to xMidYMid meet so dots are always round regardless of container width.
  • "Regenerate narrative" button on the Executive Summary card (/insights): the procedure's refresh flag was being accepted but silently dropped — now actually wired through to generateInsightsForOrg. Method flipped from GET → POST since refresh: true has real side effects (writes a new analytics_snapshot row + spends LLM tokens). Page loads always return the cached narrative instantly; users opt into the 30-50s wait when they want fresh data.
  • Freshness indicator on Executive Summary: "Generated 12m ago / 2h ago / 3d ago" under the title. Backed by narrativeStatus.generatedAt now exposed from both dashboardInsights and topicInsights procedures.

Operations

  • `/api/cron/prune-analytics-snapshots` (daily 02:30): value-preserving retention for analytics_snapshot rows. KEEPS rows with narratives forever (LLM cost preserved), rows from the last 90 days (powers Visibility trend), and the latest row per (org, scope, subjectId, windowDays) regardless of age. DELETES only old + narrative-less + non-latest rows — pure cache leftovers. One observed dev org had 352 workspace/30d rows; this trims the long tail without losing any computed value.

Roadmap

  • Idea 029 parked — _Conversation depth analysis_ (multi-turn brand visibility). Most defensible category moat available but deferred on cost economics (3-5× tokens per chain) and unproven demand. Trigger conditions documented in .apsolut/03-plan/DONE/029-idea-conversation-depth-analysis.md: 3+ paying users ask, competitor ships and customers cite it, Pro tier hits ≥50 customers and needs upgrade-tier differentiation, OR ≥70% of orgs on BYOK. Q4 2026 reassessment fallback.

May 22, 2026

Timestamp-cleanup migrations 0018 + 0019

Database

  • Migration 0018 (`a9c7d37`) — converted all 37 mode: "timestamp" columns across 23 tables from sql\CURRENT_TIMESTAMP\`default to$defaultFn(() => new Date()). Reason: Drizzle's SQLite mode: "timestamp"parses the column as **unix seconds**, butCURRENT_TIMESTAMPwritesTEXT 'YYYY-MM-DD HH:MM:SS', which Drizzle parses to invalid Date. Symptom: Date.toISOString()throwsRangeError: Invalid time valuedeep inside hot paths (chatbot, scan list, mention rendering). Header doc onanalyticsSnapshot.computedAt` documents the trap in detail so new timestamp columns don't reintroduce it.
  • Migration 0019 (`aad6ff1`) — backfill bug-fix for 0018. The generated rebuild block multiplied existing values by 1000 (strftime('%s', col) * 1000) on the false assumption Drizzle stored milliseconds. With seconds-mode, the multiplier wrote ms-shaped values into seconds columns (e.g. 1748000000000 instead of 1748000000) — interpreted as year-58348 timestamps. Migration 0019 divides any row where col > 10000000000 (~year 2286) by 1000. Idempotent — re-running only hits values still in ms-form. 3175 rows fixed across 25 columns. `apps/saas/app/api/cron/reset-quotas/route.ts` also updated: resetAt: sql\(unixepoch() \* 1000)\`→resetAt: new Date()` so it stops generating ms-in-seconds writes.

May 22, 2026

Plan 025 (1k-user readiness — all 7 steps)

CI, observability, and tests

  • GitHub Actions workflow (ci.yml): pnpm install --frozen-lockfile → pnpm format:check → pnpm lint → workspace-wide type-check → pnpm build. Runs on push to main + every PR. lint:docs step catches stale links in .apsolut/03-plan/ files. Plan 025 step 1.
  • Sentry install + DSN-gated scaffold (b6d6947): @sentry/nextjs wired into apps/saas. instrumentation.ts + sentry.client.config.ts + sentry.edge.config.ts + sentry.server.config.ts follow the official wizard layout. All SDK calls go through a reportError boundary at @repo/api/orpc/handler.ts — Sentry never imports directly from procedure code, which keeps the @sentry/nextjs peer-dependency contained to the saas app and stops Turbopack from emitting "Cannot resolve" warnings on every API request (see 70cc4be). No-op when SENTRY_DSN is unset. @opentelemetry/api: "1.9.0" pinned in pnpm-workspace.yaml to stop Sentry bumps from forking the drizzle/better-auth peer tree across pnpm hashes. Plan 025 step 2.
  • Quota + tier subsystem pure-function tests (adcfb21): per-tier limits, monthly reset semantics, hourly free-tier cap, BYOK exemption, and edge cases (zero-quota orgs, mid-month upgrade). Plan 025 step 3.

Billing, tiers, and quotas

  • Per-tier quota schema + enforcement (58b2aed): new organization_quota table (organizationId, tier, scansThisMonth, topicsCount, keywordsCount, resetAt). Quota counters maintained transactionally alongside row inserts/deletes for topics and keywords; scan counter incremented per-scan on completion. TIER_LIMITS config at packages/api/modules/quotas/config.ts is the single source of truth. Free: 5 scans/250 keywords/1 topic. Pro: 500/25 topics/250 keywords. Team: unlimited. Hourly free-tier cap (10/hour) prevents one-shot exhaustion. Plan 025 step 4a + 4b.
  • Stripe webhook → org tier sync (7f181ea): /api/webhooks/stripe handles checkout.session.completed, customer.subscription.created, customer.subscription.updated, customer.subscription.deleted, invoice.payment_failed. Maps Stripe price_id → tier via STRIPE_PRICE_TO_TIER env. Updates organization_quota.tier + resets monthly counters on tier change. Signed-webhook verification via STRIPE_WEBHOOK_SECRET. Plan 025 step 4c.
  • Quota usage card on `/settings/billing` (b2e7ec2): shows current usage vs tier cap with a progress bar, "X scans left" message, monthly reset date, "Upgrade" CTA when ≥80% utilized. Plan 025 step 4d.
  • Stripe parked behind `NEXT_PUBLIC_BILLING_ENABLED` (2731299): pricing-page CTAs become "Join waitlist" mailto links when the flag is off, real /signup?intent=pro when on. Same flag gates the saas-side ChangePlan section. One env flip lights up both surfaces for launch.

Marketing

  • Public pricing page at /pricing (16767e7): Free / Pro ($49) / Team ($199) cards. Tier limits mirrored verbatim from packages/api/modules/quotas/config.ts — pages render correctly even if the saas app or DB is down (no runtime dependency). Highlights BYOK story: "Bring your own LLM key. We charge for insights, history, and seats — never per scan token." Plan 025 step 5.

Inngest cutover (off Vercel function-timeout path)

  • Scheduled-scans executor on Inngest (3fb9d7f): the per-minute cron at /api/cron/scheduled-scans now enqueues a scheduled-scan/due Inngest event for each due schedule instead of running the scans inline. The Inngest function picks up the event, resolves BYOK key inside step.run, and dispatches via runScanInBackground. Removes the 60s Vercel function-timeout ceiling; a 25-query bulk schedule no longer times out the cron. Plan 025 step 6.
  • `processPendingBatches` off Vercel timeout (ddb8d1e): batch-drain logic moved from inline cron execution into an inngest.createFunction triggered by batch/drain-requested. Per-batch step.run isolation means one failing batch row no longer poisons the whole drain. Concurrency capped at 1 per org via event.data.organizationId. Plan 025 step 7.

Hygiene

  • Sentry token guidance corrected (b815718, c1f88fa): .env.local.example updated for Sentry's current UI (Personal Tokens with Read/Read & Write/Admin permission pickers — not the legacy colon-syntax scopes).
  • DEPLOY.md added (5d2d7ea): first-time Vercel deploy checklist covering Turso provisioning, env-var matrix, Inngest Cloud wiring, cron config, smoke-test path.

May 22, 2026

Chatbot org-scoped + brand-tracking aware

AI assistant

  • Chatbot moved to `/<org-handle>/chatbot` (b44687b): previously account-scoped at /chatbot. Now an org-scoped product surface like /dashboard and /insights. The account-scoped route persists as a redirect that resolves the user's active org (session → lastActiveOrganizationId → first membership) and forwards to the org-scoped path. Vision doc at .apsolut/03-plan/DONE/028-idea-chat-with-your-data.md covers the deeper "tool-calling chatbot grounded in scan data" build that promotion to a sprint would unlock.
  • BYOK + free-tier gating on chat (c4c3235): chat now uses the same key-resolution + quota-enforcement code path as scans. Was previously hard-wired to the platform's shared OPENROUTER_API_KEY, which (a) bypassed BYOK orgs entirely, (b) drained the shared key without quota accounting, and (c) failed entirely if OPENROUTER_API_KEY was unset. Now calls resolveOpenRouterKey(organizationId) → createOpenRouterClient(apiKey) → openrouterModel("openai", client).
  • Stream-handler race fix (f8ddd13, ef7a0e1): suggestion buttons + form submit now gate on isOrgReady = orgLoaded && !!organizationId to avoid "No active organization" errors when the user clicks before ActiveOrganizationProvider resolves.

Bug fixes

  • `ActiveOrganizationProvider` URL-vs-DB-slug parse fix (f1ba797): the provider was passing the full Notion-style URL handle (acme-abc123…) as the DB slug for the org lookup, which never matched (organization.slug = "acme"). Result: navigating to /<org-handle>/dashboard from outside the org context returned null and the sidebar lost every tracking-nav item. Now uses parseOrgParam() to extract the trailing CUID and look up by ID (canonical) with slug-fallback for legacy URLs.
  • Better Auth session sync from URL-derived org (9f30f3f): the URL is the source of truth for active workspace, but non-org-scoped routes (like /chatbot post-redirect) read session.activeOrganizationId. The provider now best-effort writes the URL-resolved org back to the session so cross-route navigation doesn't drop into a different org's context.

Page 3 of 8