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.

July 9, 2026

ScanProvider registry for 44+ providers + BYOK hardening

Architecture

  • Full registry in @repo/ai (Map + registerProvider, getDirectProviderIds, registerKeyResolver, providesSettledCostMetadata, isRouter, shouldUseSettledCost).
  • No hard-coded provider lists or === "openrouter" checks in hot paths (execute, dispatch, Inngest, costs, extractor).
  • computeUsedOurKeyForPlatforms is injectable for tests.
  • API side registers resolvers at load (proper layering).

User-facing fix

  • Fixed crash on Settings → API keys: DIRECT_BYOK_CONFIGS.map is not a function (was caused by exporting data from a "use client" module and using it in an RSC page under Turbopack).

Other

  • All hot paths, BYOK UI, costs, and tests updated to be registry-driven.
  • Extensive plan + research notes captured in .apsolut/03-plan/.
  • feat(ai): registry core
  • feat(api): dynamic execution + resolver wiring
  • feat(saas): integration + RSC data fix
  • docs: handoffs and research

July 6, 2026

Cost accuracy + spend controls (plan 043)

Cost accuracy (the ~4–6× undercount)

  • Web-search cost is now captured. estimateCostMicros is token-only, but :online/Perplexity scans are dominated by per-request search fees. We now enable OpenRouter usage accounting (usage:{include:true}) and store the settled, search-inclusive cost from result.providerMetadata.openrouter.usage.cost, falling back to the estimate only when absent. The fragile post-hoc /generation true-up becomes a backfill, not the primary path (it was failing silently — 100% when the key was dead, ~20% success on Anthropic even normally). Verified live via pnpm --filter @repo/scripts verify:settled-cost.
  • Anthropic rows no longer vanish. OpenRouter returns anthropic/claude-4.5-haiku-20251001; the pricing table keyed claude-haiku-4.5 (version in a different position), so the lookup missed → NULL cost → the row was dropped by the dashboard's isNotNull filter (63/79 rows one week, 68/68 the next). Added the returned-slug aliases.
  • New `response.costSource` (settled | estimate) records provenance; the workspace cost view surfaces an "≈ N of M calls estimated" indicator so an undercount is visible instead of silent.

Scan robustness

  • `maxOutputTokens` clamped for BYOK scans (SCAN_DEFAULT_MAX_OUTPUT_TOKENS = 4000). Previously BYOK passed no ceiling, so OpenRouter reserved credit against the model's 64k max up front and rejected requests near a key's budget edge with "requires more credits, or fewer max_tokens" — even when the key could afford a normal answer.

Notifications

  • "Your API key is out of credits / was rejected." 402 (credits) and 401/403 (auth) are now distinguished from generic provider errors (classifyScanError + scan_error.errorClass), and a new ALERT_API_KEY_ISSUE notification fires from the scan failure path — including on a fully-failed scan, where the metric alerts (which need data) don't run. Cooldown once/day/class via alert_state.

Spend controls

  • Per-org cost alert threshold. ALERT_COST_THRESHOLD now reads organization_quota.costAlertThresholdUsd (falls back to the global COST_ALERT_MONTHLY_USD env default).
  • Optional hard budget cap. organization_quota.budgetCapUsd — new scans are refused at dispatch once month-to-date spend reaches it (BudgetExceededError → HTTP 402 → existing paywall). Counts BYOK + managed spend alike. Enforcement is a pre-scan gate, so a scan already in flight can overshoot by its own cost. Both configurable on Settings → Cost (admin-only "Spend controls" card). Wired into all three dispatch paths — manual, bulk, and scheduled/cron (the last is where unattended runaway spend happens).

Reconciliation guardrail (automated)

  • Daily backend cron /api/cron/reconcile-spend (06:00 UTC, vercel.json) compares our recorded month-to-date spend against OpenRouter's authoritative usage_monthly (from GET /api/v1/key — works for the managed key _and_ every BYOK key, grouped by key). Drift > 5% AND > $0.50 is logged at error level → Sentry, and a key under 10% of its cap is logged at warn level — so we're paged automatically instead of watching a console. Shared logic lives in reconcileSpend() (packages/api/modules/scans/lib/reconcile-spend.ts); pnpm --filter @repo/scripts reconcile:spend is the CLI view of the same job. First run confirmed the incident key's historical undercount (ours $0.69 vs OpenRouter $5.40). Alerts us, not the customer — drift is our data-integrity concern. (Needs the existing CRON_SECRET; no new env.)

July 5, 2026

Citation-engine audit fixes (plan 041)

Measurement correctness (the big ones)

  • Web retrieval is now actually ON — the product's core defect: scans ran plain chat completions, so OpenAI/Anthropic/Gemini answered from parametric memory with structurally zero citations and every citation surface was Perplexity-only. resolveScanModel routes platform defaults through OpenRouter's web plugin (:online suffix; Perplexity stays search-native), and each response records searchStatus (performed / not_triggered) so a legitimate zero-citation answer is distinguishable from "search never ran". Historical rows have searchStatus NULL = not_triggered. Free-tier worst-case cost rises to ~$0.40 lifetime per org (documented in free-tier-policy).
  • Provider format drift now fails tests, not production data — recorded real OpenRouter wire fixtures (packages/ai/fixtures/: perplexity 15 citations, :online 5 citations, ungrounded 0, error 400) and a replay suite that drives runPlatformScan through the REAL @openrouter/ai-sdk-provider mapping via a fetch seam on createOpenRouterClient. Pins annotations→sources, ranks, usage, searchStatus, and re-run conflict semantics.
  • N samples per platform — response.sampleIndex + widened unique index (promptId, platform, sampleIndex), options.samples (1-5, free tier forced 1), sequential durable sample steps in the platform worker, scan_session.samplesPerPlatform. Single-draw answers charted as trends was a HIGH audit finding; N>1 makes variance measurable. Default stays 1 (no UI knob yet).
  • Prompt versioning — query.version bumps on any text edit; scan_session.queryVersion snapshots it at dispatch. Editing a saved query no longer silently rewrites its own trend history (readers can now segment; UI annotation is follow-up).
  • One mention-rate definition — PER-RESPONSE everywhere (docs in insights-snapshot.ts): the brand leaderboard (aggregator + procedure) divided by SESSION count and disagreed with the insights snapshot/SoV timeseries for the same window; get-scan + FirstScanResults counted only the primary mention's sentiment while rollups counted per mention. All aligned; brand-profile "X of Y scans" stat stays session-scoped by design (labeled as such).
  • Denominators exclude pending/running/failed scans — the raw-walk read paths (load-context + leaderboard/top-sources/gap-sources/citation-drift/platform-patterns procedures + topic snapshot) counted sessions of ANY status, so clicking Run Scan visibly deflated every mention rate until responses landed. Shared FINALIZED_SCAN_STATUSES now used by every read path including the rollup reader.
  • Registrable-domain identity — new @repo/utils registrableDomainOf (tldts, eTLD+1) + canonicalizeUrl, replacing five duplicated hostnameOf copies. docs.brand.com/brand.com now tally together, unparseable URLs are skipped instead of tallied as garbage keys, unicode hosts key identically to punycode. Policy written down in packages/utils/lib/domain.ts.

Scan engine hardening

  • Citation rank preserved — source.rank (0-based provider order); reads ORDER BY rank. Batch inserts share a second-granularity createdAt, so order was unrecoverable before.
  • Re-runs can no longer mismatch text and children — on insert conflict the engine now extracts against the STORED text and recovers the stored generation's citations from its rawResponse digest (previously: attempt-1 text with attempt-2 mentions/spans/citations). Child delete+reinsert is one transaction (a crash between them used to leave a response with no children and no error).
  • Timeouts — AbortSignal.timeout(90s) on scan calls, 60s on the extractor; a hung upstream can no longer outlive the finalize backstop.
  • Global position ordinal — the regex fallback numbered each brand's mentions from 1 (every brand "best position 1" on the free tier); now a cross-brand order-of-appearance ordinal matching the LLM extractor's semantics.
  • Scheduled scans honor `scan_schedule.platforms` — the column was written by settings but never read at dispatch; every scheduled scan silently ran all four platforms.
  • Unicode word boundaries — brand names ending in accented/non-Latin characters ("Café") never matched under ASCII \b; lookaround boundaries with the u flag fix it. Exclusion-phrase path now has tests.
  • `PLATFORM_ORDER` exported from `@repo/ai` — replaces seven private copies across dashboard/pages.

Wave 2 (same day, [plan 042](.apsolut/03-plan/DONE/042-plan-citation-engine-wave-2.md)) — reliability + forensics

  • Late responses can no longer be stranded (F8) — rollup idempotency moved from a session-level seal to per-response markers (analytics_daily_applied_response, backfilled for sealed history). A response landing after the 3-minute backstop or a reap now folds into analytics_daily on re-apply; the platform worker re-applies after late landings (promoting reaper-failed sessions with data to partial), and the reaper itself finalizes with real semantics (partial + rollup + revalidate) instead of a bare status flip. reset:rollup/backfill:rollup updated for the new marker.
  • Durable retries (F5) — runPlatformScan rethrows retryable failures (SDK isRetryable, RetryError-wrapped exhaustion, abort timeouts) so Inngest retries with backoff (retries: 3); terminal errors and final attempts fall through to quarantine.
  • Failed calls leave evidence (F6/F17) — new scan_error table (platform, sampleIndex, errorClass rate_limit|timeout|schema|provider|unknown, message, raw body). rawResponse on successes now also carries the verbatim provider wire body for future re-parsing.
  • Extractor can't silently zero out mentions (F9) — span repair (indexOf when the quote is unique) before the offset guard, and the engine falls back to regex candidates when the LLM answered but every mention was guard-dropped. Guard + repair now unit-tested.
  • Run manifest (F14/D3/F20) — scan_session.config (platforms, samples, temperature, maxTokens, systemPrompt, resolved model ids, locale) at dispatch; scan_session.summary (expected/succeeded/failedPlatforms/totalCostMicros) at finalize. Explicit DEFAULT_SCAN_TEMPERATURE = 0.7 replaces per-provider defaults.
  • "Your site was cited" (C1, write side) — analytics_daily.citedScans counts responses whose citation set contains the brand's website registrable domain; cited-but-unmentioned competitors materialize zero-scan cells no reader divides by. Read/UI surfacing is plan 042 §12.
  • Citation dedupe (F16) — identical canonical URLs within one response count once (write-side).
  • Matcher versioning (C5) — brand_mention.matcherVersion stamped from MATCHER_VERSION = 2 (unicode-boundary era); backfill script stamps too.
  • Monthly cost alert (F19) — cost_threshold rule: MTD spend vs COST_ALERT_MONTHLY_USD (default $50), once per calendar month per org, new ALERT_COST_THRESHOLD notification type wired through catalog + preferences + i18n.
  • Global scan concurrency cap (F23) — org-level 10 plus a global 30 on the platform worker.
  • CI hygiene — turbo test task declares outputs: [] (warnings gone, caching on).

Wave 3 (same day) — the deferred list, cleared

  • "Your site cited" surfaced (C1 read) — InsightSnapshot.current/previous.cited on both read paths (raw walk + rollup reader, reconciliation-tested non-zero) with a new KPI tile ("N/M answers") on Insights.
  • Inline `[n]` markers are links (B5) — linkifyCitationMarkers turns Perplexity-style markers into links to the ranked citation ([1] → rank 0, off-by-one pinned by tests); get-scan sources now ORDER BY rank.
  • Samples knob — RunScanForm select (1/2/3/5) wired to options.samples; server still clamps and forces 1 on free tier.
  • Prompt-version markers (D2 read) — the query trend chart draws "prompt v{n}" reference lines at each version's first run, so rewording breaks are attributable.
  • Dated model pin — openai/gpt-4o-mini-2024-07-18 (verified in OpenRouter's catalog + a live :online call with 5 citations); Anthropic/Gemini/Perplexity have no dated slugs on OpenRouter (documented; response.model remains the mitigation). Extractor pinned too.
  • Provider upgraded to `@openrouter/ai-sdk-provider@2.10.0` (spec v3 — kills the AI SDK compat-mode warning); validated by the fixture-replay suite on first run, which is exactly what it was built for.
  • Extractor bills the org's BYOK key — extraction spend previously landed on the shared operator key for BYOK orgs; now it rides the same client as the scan (env-key fallback kept for the backfill script).
  • Prod flip checklist — DEPLOY.md §7b (migrate → backfill → compare → flag → epoch note → cost sanity). Webhook error-body reads capped at 4KB.

Still open

    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.

    Page 4 of 9