Developer docs
Quality

Verification

Every claim about this build and the command that proves it.

docs/VERIFICATION.md

Every claim about this build, and the command that proves it. If a claim has no command, it is not verified — say so.

ClaimProof (exact command)Last runResult
deploy-nonprod.yml uses nonprod's environment and never prod's, has no push trigger, checks the signature against the commit, rolls only digests, and trusts the same signer as container-candidate.jsonbash scripts/workflow-contract.test.sh, plus three negative controls (signer moved, environment: prod, a push: trigger) that each fail it2026-10-06✅ passes; each control fails
The nonprod roll works end to end: the #402 candidate's signed digests, rolled with deploy/roll-vm.sh and the GHCR MinIO copy onto an empty nonprod droplet, migrate, pass the health gate and serve that committhe workflow's steps run by hand (cosign verify --certificate-github-workflow-sha 1aedef40…; roll-vm.sh <api@sha256:f563d8de…> <web@sha256:0ba188eb…>); then curl http://144.126.249.116.nip.io/api/v1/version2026-10-06✅ healthy on …candidate-api@sha256:f563d8de…; {"commit":"1aedef40…","release":null}; health 200, web 200; prod health 200 throughout
The candidate config names exactly one GHCR and one GAR destination, and the org's generated helper expands it to a GAR repository per imagepython3 scripts/container_registry_destinations.py --config container-candidate.json --owner verJSON and PATH="$(brew --prefix coreutils)/libexec/gnubin:$PATH" bash scripts/container-candidate-contract.test.sh2026-10-06✅ …/marketing-studio-candidates/marketing-studio-candidate-{api,web}; container candidate generated contract passed
The demo dialog takes its form token and Calendly URL from the API at runtime: asked on first open, the label follows Calendly, the request goes to the token the API named, nothing is sent when unconfigured, and a failed request is retried on the next open, not cachedcd apps/web && npx vitest run 'src/app/(marketing)/demo-dialog.test.tsx' src/lib/demo-form.test.ts · cd apps/api && npx vitest run src/test/demo-settings.test.ts2026-10-06✅ web 17/17 (13 new, including production's own case: Calendly set, no token → straight to booking, nothing posted), api 3/3. Mutation checks: dropping the retry line, the network-error branch, or the load-on-page-open each fail a dialog test. Not verified yet: prod's dialog after the deploy, which curl https://studio.lightningleap.store/api/v1/public/demo-settings checks (calendly_url set)
The four candidate files are the pinned org contract, unedited, and the config is validbash scripts/container-candidate-contract.test.sh (CI: docker-build → deploy · migration gate)2026-10-06✅ "container candidate generated contract passed". Not verified yet: the first publish on main, and a deploy that runs a candidate (#383)
Creating nonprod changed nothing in prod: prod's Pulumi stack previews unchanged before and after the code change and after pulumi up; prod stays healthy; each firewall holds only its own droplet; prod refuses nonprod's SSH key. Nonprod boots: cloud-init done, Docker and Compose installed, placeholder /healthz 200cd deploy/pulumi && pulumi preview --stack prod (7 unchanged ×3) · pulumi up --stack nonprod (+7 created) · curl https://studio.lightningleap.store/api/v1/health (200 before and after) · doctl compute firewall list · ssh -i ~/.ssh/marketing-studio-nonprod-deploy root@134.199.250.245 (Permission denied) · curl http://144.126.249.116/healthz2026-10-06✅
The MinIO image is in GHCR, readable like api, and the compose change leaves prod's MinIO aloneActions run 37461921502 (mirror-minio): layers matched the droplet's, pushed …minio@sha256:a1a8bd4a… · gh api /orgs/verjson/packages/container/marketing-studio%2Fminio (private, repo verJSON/marketing-studio, same as api) · docker compose -f docker-compose.prod.yml config --hash minio with MINIO_IMAGE unset, before and after the change2026-10-06✅ hash 0390e5dc… identical before and after; with MINIO_IMAGE set it differs, as nonprod needs. Not verified yet: a droplet pulling it, which comes with the nonprod deploy
GET /api/v1/version answers { commit, release } without auth and with Cache-Control: no-store; unset values are null; a short, uppercase or non-SHA commit and a non-vX.Y.Z release refuse the boot naming the variable; the API image carries the commit passed at buildcd apps/api && npx vitest run src/test/version.test.ts + docker build -f apps/api/Dockerfile --build-arg SOURCE_COMMIT=<sha> . then node -e 'console.log(process.env.SOURCE_COMMIT)' in the image2026-10-06✅ 9/9, including loading the real core/config with stubbed env (values read; a malformed commit refuses the load). Mutation checks: unmounting the route fails 3, dropping the header fails 1, misspelling the variable in config.ts fails 1. Full API suite 3941 passed. Image: built with SOURCE_COMMIT=0a7f7f1…, and the container's environment carries it. Not verified yet: prod serving its commit, which needs the next deploy
mirror-minio.yml pushes the droplet's exact MinIO image to GHCRActions → mirror-minio → Run workflow; the run summary prints the pushed digest. Locally: the remote docker image inspect command, quoted with printf %q, returns the droplet's layer list over SSH2026-10-06⏳ Not run yet: it needs this workflow on main. The run refuses to push unless the loaded layers equal the droplet's. actionlint shows the same SC2029 notes as deploy.yml; scripts/workflow-contract.test.sh passes
Competitor lookups and compares need a Growth plan or above: Free and Starter get 402 before any Instagram call, Scale is allowed, stored lookups stay readable after a downgrade, and the subscription reports the entitlement to the UIcd apps/api && npx vitest run src/test/competitor-analysis.test.ts + headless Chromium on /projects/{id}/analytics/organic/instagram with the org on Starter, then Growth2026-10-06✅ 19/19 (5 new). Billing + agents suites 104/104. Browser: Starter shows the upgrade prompt and See plans, no search; Growth shows the search, no prompt
Social listening is project-scoped and production-guarded: another org gets 404 on every listening read; a member reads but gets 403 on create/update/delete/run/SocialCrawl search (no provider call made); the monthly budget refuses Run now with 402 and the sweep skips over-budget orgs; budgets are per organization; a running term is not run again (409 / sweep busy) and a stale claim expires; explorer SocialCrawl searches are recorded as runs, counted and audited; term changes are audited; the per-project term cap holds; the explorer uses only the project's own social accounts; retention purges unseen mentions; malformed ids 404cd apps/api && npx vitest run src/test/listening-tracking.test.ts src/test/listening-explorer-routes.test.ts + web npx vitest run + live API on test_project2026-10-06✅ 29/29 tracking (9 new) and 6/6 explorer routes; listening explorer/probe suites 36/36; full web 1430/1430; API and web typecheck and lint clean. Live API: owner sees can_manage, usage 4 of 300; official-only explore uses the project's Instagram account and Bluesky; SocialCrawl without sources 422; a member not on the project 404. Browser: Performance → Social listening in the sidebar; Overview / Mentions / Keyword explorer / Tracked terms tabs with their own URLs; Overview panels render on 100 mentions; Tracked terms shows the usage bar (4 of 300, resets Nov 1), add form and Run now for an owner.
The brand overview totals, platform split, top posts / viewed / creators and labelled splits are computed over stored mentions under the term and date filters, keep unreported sums null and report each split's coverage, bucket the volume daily or weekly over exactly the window, and 404 a term from another projectcd apps/api && npx vitest run src/test/listening-tracking.test.ts + cd apps/web && npx vitest run src/lib/mention-explorer.test.ts + live browser check on test_project2026-10-05✅ 20/20 API (2 new) and 8/8 web helpers; full web suite 1430/1430; API and web typecheck and lint clean. Live on 100 mentions: 11.4M views (TikTok, 59 of 100), 705.2K engagement, 95 creators; TikTok 59 at 10.8K avg vs Reddit 41 at 1.7K; countries US 49% of 59; conversation type over 47 labelled; Reddit communities over all 41; top post opens its detail.
The mention explorer filters (platform, search over text/title/author, published-date window), sorts (newest, oldest, engagement with unknown last), pages with limit/offset, names metrics per platform without inventing zeros, survives a malformed raw, returns platform fields and the raw payload, 404s across projects, and collection still stores the engagement totalcd apps/api && npx vitest run src/test/listening-tracking.test.ts + cd apps/web && npx vitest run src/lib/mention-explorer.test.ts + live browser check on test_project2026-10-05✅ 18/18 API (6 new) and 6/6 web helpers; API and web typecheck and lint clean. Live on 100 stored mentions (59 TikTok, 41 Reddit): platform list from data, Reddit + most engagement led by 16.7K upvotes · 895 comments, search 'air max' → 1, last 7 days → 55, page 4 of 25 → 25 rows; TikTok detail shows country, downloads, music id; Reddit detail shows subreddit, upvote ratio, flair; raw payload opens; empty-filter state shown.
Tracked terms validate their sources, a run stores each mention once and refreshes it later, unreported metrics stay null, the sweep honours cadence and the credit ceiling and stops on an account refusal, and all of it is project-scoped and platform-admin onlycd apps/api && npx vitest run src/test/listening-tracking.test.ts + live POST /api/v1/projects/{id}/listening/terms then /run on test_project2026-10-01✅ 12/12; explorer + worker-tick suites 39/39; API and web typecheck and lint clean. Live: 'Nike' with TikTok + Reddit search → status ok, 55 mentions stored (30 TikTok, 25 Reddit), 2 credits used, 49 remaining; mentions read back with views, likes and author followers.
Project logo · a logo sent on project create is stored as branding.logo_key and served as logo_url; a key from another workspace is refused with 422 and no project is created; patching branding still keeps the rest of the projectcd apps/api && npx vitest run src/test/project-create-logo.test.ts src/test/partial-updates-keep-fields.test.ts · cd apps/web && npx tsc --noEmit -p .2026-10-06✅ pass (2 files / 7 tests; the 2 new tests fail with the create fix reverted)
LinkedIn Company Pages · an admin or content admin connects every Page they hold either role on as its own linkedin_page account (separate LinkedIn app, LINKEDIN_PAGES_CLIENT_*); organizationAcls is read across pages (stops at a short page or paging.total, at most 10 pages); a Page whose lookup is refused (4xx) is skipped while 401/429/5xx/network still fail the connect; posts go out AS the organization — author and upload owner urn:li:organization:<id>, always PUBLIC; no postable Page → invalid_request; the composer offers a Page the Link field and a landscape default image; personal LinkedIn still posts as urn:li:person; the enum migration applies to a fresh and a populated DBcd apps/api && TEST_DB_WORKERS=1 npx vitest run src/test/adapter-linkedin-page.test.ts src/test/channels.test.ts src/test/channel-credentials.test.ts src/test/channel-credentials-multi.test.ts src/test/channels-adapters.test.ts src/test/adapter-linkedin-document.test.ts src/test/adapter-linkedin-webhooks.test.ts src/test/permalink.test.ts src/test/channel-rules.test.ts src/test/agents-content-image.test.ts src/test/kpi-pivot.test.ts src/test/posts-policy.test.ts src/test/listening-runner.test.ts · cd apps/web && npx vitest run src/lib/social-connect.test.ts src/lib/channel-fit.test.ts src/lib/composer-targets.test.ts · cd apps/web && npx vitest run · DATABASE_URL=<fresh or populated db> npx prisma migrate deploy && npx prisma migrate diff --from-url <db> --to-schema-datamodel prisma/schema.prisma --exit-code2026-10-05✅ pass (review fixes: api required set 7 files / 186 tests, adapter-linkedin-page 26 tests; full api suite 231 files / 3831 tests; web touched 3 files / 62 tests, web full 95 files / 1445 tests; first pass: api 13 files / 256 tests; migrate deploy + diff exit 0 on a fresh DB and on a DB holding linkedin/facebook/instagram accounts, then a linkedin_page row inserts)
F-008 · Platform app credentials are stored encrypted per project and managed from Integrationscd apps/api && npx vitest run src/test/channel-credentials.test.ts2026-10-01✅ pass (run together with the four below: 5 files, 106 tests)
F-085 · A user can export their data as a ZIP and delete their account with a grace periodcd apps/api && npx vitest run src/test/gdpr-export.test.ts2026-10-01✅ pass
F-086 · ⌘K search finds projects, campaigns, posts and people inside the caller's scopecd apps/api && npx vitest run src/test/search.test.ts2026-10-01✅ pass
F-102 · A CSV/XLSX of posts previews as a dry run, then imports as drafts onto the calendarcd apps/api && npx vitest run src/test/calendar-import.test.ts2026-10-01✅ pass
F-140 · Multi-step email journeys walk send, wait and branch steps, never send twice, and honour entry rulescd apps/api && npx vitest run src/test/automations-runtime.test.ts2026-10-01✅ pass
F-082 · Every PR runs typecheck, lint, test and build, and deploy reuses that run as its gate.github/workflows/ci.yml and deploy.yml (read, not run locally)2026-10-01✅ by inspection — no local command
The six feature cards are all open and stack as a deck that fits the screen; every card's picture is drawn in code and builds in once; the agents' row drifts left behind Taniya; the Organic card shows the Content Schedulerheadless Chromium against npm run dev: card heights against the screen at 1900×860, 1440×900, 1366×768, 1280×720 and 390×844; each rebuilt picture laid over its original export at 50%; downloads per card2026-10-01✅ every card fits pinned; card 1 2.1 MB → ~40 KB; cards 2–3 ~90 KB → ~4 KB each
Each workflow step shows its copy as text beside a product screen drawn in code, which builds in the first time the step is shown; on phones each screen sits in the panel's bottom-right corner; the step tabs scroll without a scrollbarheadless Chromium against npm run dev: each screen laid over its original at 50%; screens mounted and cascaded per step; screen corners at 390px; all five boxes measured at 1440 and 1024px2026-10-01✅ 5/5 screens drawn in code, 0 SVGs loaded; 0px corner gaps on phones
The closing banner follows the new design (one-line headline, channel cards along its foot with the brand logos); on phones the cards peek in at 35–39% each; desktop and tablet unchangedheadless Chromium against npm run dev: the logos load, the cards' visible share at 390, 375 and 360px, computed styles at 1440 and 768px before vs after2026-10-01✅ 38/36/39/35% shown; 0 existing elements changed at 1440 and 768px
On phones each pricing plan fits on one screen (monthly and yearly, iPhone SE included), its type steps down like the rest of the page, and the plans rise into place; desktop and tablet unchangedheadless Chromium against npm run dev: plan card heights at 390×844, 375×667 and 360×800, monthly and yearly; computed styles of every element at 1440, 768 and 390px before vs after2026-10-01✅ cards 542–594px; 0 of 408 elements changed at 1440 and 768px
The Integrations section shows the designed orbit: ten channel logos travelling the rings, paused off screen, one still frame with reduced motion; no Slackcd apps/web && npx eslint on the orbit files + headless Chromium against npm run dev: the SVG parses, its badges, its label, and their movement over 3s2026-10-01✅ 10 badges, moving, 0 errors
The hero's logo strip has no Slack, never shows the same logo twice at once, keeps its speed and loops seamlessly; the hero headline builds in word by wordEvery pixel offset across the strip's loop checked for a logo in view twice; the track rendered at 0 and at exactly one cycle compared; npx eslint "src/app/(marketing)/hero.tsx"2026-10-01✅ never twice (previous strip: GA4 twice at 119px); seam pixel-identical; 102 px/s before and after
Section headings arrive word by word; the Why and Pricing cards rise into place once (never hidden without JavaScript, never blinking for a visitor landing on /#pricing, still with reduced motion); phone text steps down a notch with desktop and tablet unchangedcd apps/web && npx eslint on the changed files + headless Chromium against npm run dev: computed styles of every text element at 1440, 768 and 390px before vs after; reveal states with JS off, reduced motion and /#pricing2026-10-01✅ 0 of 408 elements changed at 1440 and 768px; cards 1.00 opacity with JS off and reduced motion
The landing header slides out of view while the reader scrolls down and returns as soon as they scroll up; it stays put near the top and on keyboard focuscd apps/web && npx eslint "src/app/(marketing)/smart-header.tsx" "src/app/(marketing)/site-header.tsx" + headless Chromium against npm run dev at 1440px and 390px2026-10-01✅
The page hydrates without a warning when a browser extension (Grammarly) adds its attributes to <body>; a genuine mismatch inside the page is still reportedheadless Chromium against npm run dev, Grammarly's two attributes injected into <body> before hydration; the same run without the fix reproduces the warning2026-10-01✅ 0 warnings (1 without the fix)
The competitor page's metrics, score counts, shares, gaps and formatting derive correctly from the real comparison, and the page renders every tab on live data without horizontal page scrollcd apps/web && npx vitest run src/lib/competitor-view.test.ts (+ full npx vitest run) + in-browser run in the Instagram hub's Competitor tab, @test_ing702 vs @nike2026-09-30✅ 9/9 helper tests; web suite 1417/1417; typecheck and lint clean. Browser: all four tabs rendered on live Business Discovery data (score 1 · 0 · 6, list and card views, profile bento, top posts, day/hour charts); at 390px wide nothing overflows outside the table scrollers. No e2e spec exists for this page.
Instagram competitor lookups run only through the caller's own project account, never average or compare a withheld number as zero, stay inside the tenant, refuse a self-comparison, and answer a dead Instagram token with 424 rather than 401cd apps/api && npx vitest run src/test/competitor-analysis.test.ts + live POST /api/v1/projects/{id}/competitors/instagram/lookups {"username":"nike"} then …/lookups/{lookupId}/compare, as owner@example.com + headless Chromium on /projects/{id}/analytics/organic/instagram2026-09-30✅ 14/14. Live: @nike stored (291,076,572 followers, 25 posts), @test_ing702 vs @nike comparison with all 8 rows comparable, no token in any response. Browser: Compare with ours absent before the lookup, present after it, and the comparison table rendered
SocialCrawl posts and comments map to readable rows (metrics null-safe, Reddit titles de-duplicated), filter/sort correctly and export to Excel-safe CSV; the view renders real datacd apps/web && npx vitest run src/lib/socialcrawl-view.test.ts + in-browser run as admin@example.com (TikTok comments + Bluesky + Reddit + YouTube, 'Nike')2026-09-30✅ 6/6. Browser: 231 of 231 items rendered as cards and as a table, filters and sort populated, source picker showed 5 credits and kept Fetch disabled with no source.
SocialCrawl runs only the chosen sources (plus the search a comment source needs), refuses an empty or unknown selection, and the picker's costs come from the APIcd apps/api && npx vitest run src/test/listening-explorer-routes.test.ts src/test/listening-explorer.test.ts + live POST /api/v1/listening/explore with socialcrawl_sources:["sc.tiktok_comments"]2026-09-30✅ 26/26. Live: SocialCrawl with no sources → 422 before any call; choosing TikTok comments ran exactly the TikTok search + comments (2 credits), with all 7 dimensions detected on the search.
The hero shows "14-Day free trial · No credit card required" 16px under its buttons, and the integrations strip 56px below that at its full 562pxcd apps/web && npx eslint "src/app/(marketing)/hero.tsx" + Playwright (MCP) against npm run dev at 1440×900 and 390×8442026-09-30✅ eslint clean. 1440: buttons 16px apart, buttons → line 16px, line → strip 56px, strip 562×86, line 287px wide (design ~286). 390: line on one line, strip 348×53, no horizontal overflow. ⚠️ No committed test. "No credit card required" is taken from the design and not checked against sign-up
The hero's integrations row is the design's animated SVG (public/motion-svgs/social-icons-hero.svg), centred under the CTAs, with its drawn-in label and the channel list in the alt textcd apps/web && npx eslint "src/app/(marketing)/hero.tsx" + Playwright (MCP) against npm run dev at 1440×900 and 390×8442026-09-30✅ eslint clean. SVG served 200; 544×83px, 0px off centre under the buttons at 1440; 348×53px at 390 with no horizontal overflow; old disc markup gone. ⚠️ No committed test. The SVG loops even under reduced motion (the animation lives in the file), and its label renders ~9px tall on a phone
From sm up, the workflow card pins under the navbar and steps 01 → 05 with the scroll; a tab click jumps straight to its step without passing through the ones between; one vermilion bar slides between tabs; panels cross-fade; phones do not pincd apps/web && npx eslint "src/app/(marketing)/workflow.tsx" + Playwright (MCP) against npm run dev at 1440×900 and 390×844, and with prefers-reduced-motion: reduce2026-09-30✅ eslint clean. Tabs 1 / 2 / 3 / 4 / 5 at 0 / 400 / 800 / 1100 / 1500px into the track, card pinned at 128px, unpinned after. Click 01 → 04 showed tabs [1, 4] only; bar left 0 → 152 → 418 → … → 780px. 390px: position: static, tap 05 selects it without moving the page. Reduced motion: transitions 1e-05s. ⚠️ No committed test. Safari (no scrollend, 1.2s fallback) and 640–1023px unexercised
The landing header stays pinned at the top, its Features link lands on the features section (/#product) and its Pricing link on pricing, both below the header, and "Start free trial" shows only once the hero has scrolled out of viewcd apps/web && npx eslint "src/app/(marketing)/site-header.tsx" "src/app/(marketing)/header-trial.tsx" "src/app/(marketing)/features.tsx" + Playwright (MCP) against npm run dev at 1440×900: scroll to 0 / mid-hero / past hero / deep / back to 0 and read the header; click Features2026-09-30✅ eslint clean. Header position: fixed, top 24px at every depth. Trial button hidden (0px) at the top and mid-hero, shown (153px) past the hero, hidden again back at the top. Features → #product, chip settles at 160px below a 109px header foot. ⚠️ No committed test. Mobile menu links checked by reading, not tapped
The landing page has a pricing section under Features: three plans, a Monthly / Yearly switch that runs without JavaScript (yearly = monthly less 10%, rounded, with the yearly saving shown), and an anchor that lands below the fixed headercd apps/web && npx eslint "src/app/(marketing)/pricing.tsx" "src/app/(marketing)/page.tsx" + Playwright (MCP) against npm run dev at 1440×900 and 390×844: click each switch label, read the prices, measure the chip against the header2026-09-30✅ eslint clean. Monthly $39 / $99 / $249; Yearly $35 / $89 / $224 with Save $48 / $120 / $300/year; card gaps measured at the values set. At 390px no horizontal overflow; after /#pricing the chip sits at 128px, below the header's 76px foot. ⚠️ No committed test. Prices are written into the page, not read from billing, and billing only bills monthly today — Yearly signs a visitor up to a monthly plan
The landing page's four metric figures count up from 0 once, the first time the band scrolls into view, without the tiles changing width, and the server still renders the final figurescd apps/web && npx eslint "src/app/(marketing)/count-up.tsx" "src/app/(marketing)/metrics.tsx" + Playwright (MCP) against npm run dev at 1440×900: scroll the band into view, sample the figures every 150ms2026-09-30✅ eslint clean. Figures read 0 0+ 0 0 → 6 7+ 2 0 → 12 15+ 4 1 over ~1.4s; tile widths fixed throughout. ⚠️ No committed test. The reduced-motion path (final figures, no count) is checked by reading, not run
The SocialCrawl source sends the key only as a header, finds each platform's result list, reports credits, follows comments from the search result's URL, treats success:false as a refusal, and is never run unless explicitly chosencd apps/api && npx vitest run src/test/listening-explorer.test.ts2026-09-30✅ 20/20 explorer tests (2 new). ⚠️ Unexercised against SocialCrawl itself — no API key yet; response shapes are taken from its published docs (llms-*.txt), not observed.
The Reddit explorer mints one app token per run with Basic auth + client_credentials + a User-Agent, returns keyword posts and a flattened comment tree, and reports a credential refusal once instead of per callcd apps/api && npx vitest run src/test/listening-explorer.test.ts2026-09-29✅ 18/18 explorer tests (2 new for Reddit). Live against the running API with no Reddit keys: all five Reddit calls report 'not attempted' with the missing-credential reason. ⚠️ Unexercised against Reddit itself — no Reddit app exists yet.
The Instagram competitor comparison measures both accounts the same way, gives a withheld metric no difference and no leader, compares against the stored competitor snapshot, and refuses a self-comparisoncd services/instagram-competitor-demo && python -m pytest tests -q + live POST /api/analyze {"username":"lenovo","post_limit":10} then POST /api/compare {"analysis_id":<id>,"post_limit":10}2026-09-29✅ 38/38 (11 new in tests/test_compare.py). Live: @test_ing702 vs @lenovo returned all 8 rows comparable (ours leads 1, theirs 6), content mix, busiest days and 8 competitor-only hashtags. Browser: headless Chromium against :8077 — fetch nike → Compare with ours visible → comparison panel rendered
An expired organic token is refreshed and saved before use, a failed refresh degrades to the stale token with its reason, and the LinkedIn explorer builds valid Rest.li 2.0 requests and stops the Page reads when the Page list is refusedcd apps/api && npx vitest run src/test/fresh-token.test.ts src/test/listening-explorer.test.ts + live POST /api/v1/listening/explore {"keyword":"Nike","platforms":["youtube","linkedin"]}2026-09-29✅ 8/8 refresh + 16/16 explorer tests (54 incl. LinkedIn adapter suites). Live: the YouTube token that expired 2026-09-28 was refreshed through Google and saved (new token_expires_at in the DB), then all four YouTube calls returned data (85 items). ⚠️ LinkedIn calls are unexercised live: no LinkedIn account is connected and the app does not have the Community Management API yet.
The listening explorer never reports an unreachable platform as a capability, keeps "0 results" distinct from "refused", keeps the platform's own error text, and returns no token in its payloadcd apps/api && npx vitest run src/test/listening-explorer.test.ts + live POST /api/v1/listening/explore {"keyword":"Nike"} as admin@example.com2026-09-28✅ 14/14 unit. Live: all 10 platforms answered; Instagram returned 25 real @nike posts via Business Discovery, Bluesky 35 items; saved payload contained only access_token=[redacted]. Unauthenticated → 401, member@example.com → 404, empty keyword → 422. ⚠️ Page checked for HTTP 200 only — not clicked through in a browser (Playwright browser was held by another session). Facebook, X, YouTube, TikTok, Threads, Pinterest and LinkedIn endpoints are unexercised live: no connected account/token in this deployment.
The listening report only ever claims what a live API call returned: a refusal keeps the platform's own words, an untested platform never renders as a capability, and a committed sample carries no token or signed URLcd apps/api && npx vitest run src/test/listening-probe.test.ts (report/redaction) + npm run listening:probe (live)2026-09-25✅ 10/10 unit + a live run against the connected Instagram account. The live run corrected two guesses: Instagram has no mentions listing edge (nonexisting field (mentions); mentioned_media/mentioned_comment need an id that only a webhook delivers, so mentions are push-only and cannot be backfilled), and the comment-bodies refusal is proven by contrast — the same query shape returns comments_count for the same post and refuses comments{text}.
The Instagram competitor demo computes its metrics only from data the API actually returned, refuses bad usernames before building a request, and turns each Meta failure into its own messagecd services/instagram-competitor-demo && python -m pytest tests -q2026-09-24✅ 27/27. Averages skip posts whose like_count Instagram withheld (hidden likes) rather than counting them as zero; usernames carrying ) or spaces are refused before reaching business_discovery.username(...); token/permission/not-found/not-eligible/rate-limit/version errors each map to their own answer with Meta's payload kept out of the response. Verified against the live Graph API with a real token (@test_ing702 → @bluebottle, @natgeo): all 9 profile fields returned including the three Meta does not document as public (name, profile_picture_url, follows_count); 25 posts with likes and comments; account_type, insights, story insights and a direct GET on a returned media id each refused with the expected Meta code. Reproduce with python scripts/verify_capability.py <username>. Two corrections came out of that run and are in CAPABILITY.md: another account's active stories ARE readable (the docs implied otherwise), and an unknown username answers code 110/2207013, which now maps to not_found with a regression test.
With no channel connected the Content page shows every post (the list matches the Drafts count); once channels are connected a post stays on its own channel's tabcd apps/web && npx vitest run src/lib/content-view.test.ts -t isOnChannelTab (failed first: isOnChannelTab is not a function)2026-09-30✅ 2/2; content-view + content page suites 17/17; tsc --noEmit and eslint clean. Empty-state copy ("No drafts") checked by reading, not by a test
A Meta webhook delivery for an account connected in two workspaces writes an inbox item to both; a disconnected connection stops receiving once the account is live elsewhere; with no live connection it still lands on onecd apps/api && TEST_DB_WORKERS=1 npx vitest run src/test/webhooks.test.ts -t "every workspace|stops receiving|still lands" (the first two fail on the pre-fix handler)2026-09-30✅ 3/3; whole webhooks + inbox suites 108/108
The composer's AI writes for the channels actually picked (one → that platform, several → one caption at the tightest limit, none → platform-neutral at 280), never a defaulted linkedin; and a model reply that declines the task answers 422 on /ai/caption, /ai/rewrite and /ai/hashtags instead of becoming the postcd apps/api && npx vitest run src/test/ai.test.ts src/test/ai-refusal.test.ts and cd apps/web && npx vitest run src/lib/composer-targets.test.ts src/lib/ai.test.ts2026-09-29✅ API 75/75, web 28/28; full API suite 3690 passed, 17 skipped; full web suite 1386/1386. Reproduces the prod incident: the exact refusal published to Instagram is the first detector case and the route test, both failing first (expected false to be true, expected 500 to be 422). The "leaves a real caption alone" cases ("I can't wait…", "I can't help but smile…", "I cannot believe…", "I specialize in wedding photography…") passed on arrival and were mutation-checked: dropping the refusal-verb requirement fails three of them. The AI provider is stubbed throughout. Not exercised against a live model or in a browser — the popover wiring is covered by typecheck, not a component test.
The public /privacy and /terms pages (the Meta and LinkedIn apps' policy URLs) render signed out and name the legal entity; /privacy carries the "Facebook and Instagram data" section with a relative link to /data-deletion; all three legal pages link to each other; signup links the Terms and Privacy Policycd apps/web && PORT=3471 npx playwright test e2e/smoke.spec.ts -g "data-deletion|privacy policy|terms of service|legal page links|signing up states"2026-09-29✅ 5/5. The privacy and terms tests failed first on a 404 and the signup test on a missing link; the footer test passed on arrival, so it was mutation-checked (with the footer removed from /data-deletion it fails, element not found). Pages checked by eye in a light-theme screenshot. Outside (marketing), so no visitor tracker. Legal text drafted by an AI, not yet reviewed by the company. The rest of smoke.spec.ts has two failures already on main since #327 (landing heading text; Sign in heading matches two elements), not touched here.
Inbox polish: a comment shows the replies we sent under it (oldest first, with the teammate's name; another organisation's item 404s); a named Facebook sender reads by name without the numeric id; a text-less DM reads "Sent a photo" / "Sent a sticker" / "Shared a post" instead of "(no text)"; Suggest reply without an AI key gives one clean reply per tone, never three joined with --- or a quoted prompt fragment; only a 429 reads as throttling ("Pirate Pizza" does not); the DM header stays below the top barcd apps/api && npx vitest run src/test/inbox-item-replies.test.ts src/test/inbox-attachment-label.test.ts src/test/inbox-conversations.test.ts src/test/inbox-ai-reply.test.ts · cd apps/web && npx vitest run src/lib/inbox.test.ts item-replies2026-09-29✅ Full API suite 3671 passed, 17 skipped (220 files); full web suite 1390/1390 (89 files); typecheck (api, web, contracts) and eslint on changed files clean. The Suggest-reply root cause: the keyless mock in ai-provider.ts answered each per-tone call with all three replies joined by --- and quoted userText.slice(0, 40) — the prompt's own "The dm from … said:" framing, cut mid-word. The header fix is layout, so it has no unit test: Playwright against a local dev stack in ?demo=1 measured the bar at 65–65.5px and the scrolled header pinned at y=64 at 390 and 1280px wide; the same run sent a demo reply and saw it render under the comment.
The agent team lives in the project it works on, and a sibling project cannot unlock its gated agentscd apps/web && npx vitest run src/lib/agent-roster-project.test.ts2026-09-29✅ 8/8. /agent asked which project to point the team at in a <select> and remembered it in localStorage: the first paint was a row of greyed buttons with no stated cause, the choice was invisible in a shared link, and the blocked copy read "Pick a project above" — an instruction about the page's own widget rather than about the work. The project is now the route. The correctness half is runsForProject: GET /agents/runs returns the whole ORGANISATION's runs, and gateState decides the Drafter may start by finding an approved Ideator run — so without the filter, approving an Ideator on a different project lit this project's Drafter button up for unrelated work. Pinned directly: the same approved run makes the gate ready unfiltered and blocked filtered. Runs with a null project belong to none and are excluded from all.
The chain's rail and the button beneath it can never disagreecd apps/web && npx vitest run src/lib/agent-roster-project.test.ts2026-09-29✅ chainStepState derives from the same gateState the Run button reads rather than recomputing "can this start" — two sources would drift, and the drift shows as a step drawn Ready above a disabled button, which reads as a broken page rather than a blocked agent. An approved run is done even though its gate stays ready (the gate describes what may start NEXT, so reading it alone would flip a finished step back and lose the one fact the rail exists to show). Open statuses come from the contract's isRunOpen, not a list spelled out again — the copy would have missed queued, which is exactly the state somebody watches the page waiting to leave.
Starting a run no longer leaves the list that should show it stalecd apps/web && npx vitest run2026-09-29✅ 1362/1362, and reproduced by hand against the live worker. Two faults compounded. The proposals list asked the server for status=awaiting_approval, so a run that had just been created as queued was absent from the response — and absent from the data the refetch interval read to decide whether anything was in flight, so the page slept at its idle 30s through the seconds in which the worker finished. A user pressed Run, opened Proposals, and found nothing. The list is now fetched whole (server-capped at 200) and filtered in the browser, which also makes tab switches free and shares one cache entry with the Team page; the interval polls at 5s while any run is open and 30s when the queue is settled.
Every live agent has something real to read on a fresh databasenpm run seed:agents2026-09-29✅ Verified by running all four data-reading agents against the fixtures and reading their proposals: the Scout found the seeded outlier (17,844 impressions · 13.10% engagement), the Optimiser caught the seeded decay (CPA doubled: 1493 → 299) and proposed a pause, the Planner derived cadence from post history, and the Site audit returned 3 critical findings from the two deliberately broken pages. The agents are the one feature unexercisable on an empty database — each reads a different slice of history, and a project with none gives all of them the same empty, truthful, useless run. Data is deliberately uneven, because a clean fixture produces a correct run with nothing in it, which proves the agent ran but not that it works. Deterministic (seeded RNG) so two runs produce the same database, and tagged [fixture] so a re-run replaces its own rows and never touches hand-made ones. The Ideator and Drafter are deliberately NOT fed — their gate is the flow worth testing.
A Page (Messenger) webhook keyed by a Page id that also belongs to a linked Instagram connection lands on the Facebook account; an instagram delivery keyed by that Page id lands on Instagramcd apps/api && npx vitest run src/test/webhooks.test.ts2026-09-28✅ 36/36. The Messenger case failed first (expected [] to deeply equal [ 'hello page' ]): the handler took the first matching row. Found in prod with a correctly signed synthetic Page message, which was accepted (processed: 1) but filed under the Instagram account. Also: a DM under standby (app not the primary receiver) now lands too. With the standby read removed, that test fails expected +0 to be 1; 37/37 with it.
An inbox DM reply goes to POST /v21.0/{page-id}/messages with the Page token and {recipient:{id:<IGSID/PSID>}, message:{text}, messaging_type:"RESPONSE"} on Instagram and Facebook; comment replies still go to /{comment-id}/replies (IG) and /{comment-id}/comments (FB); Meta's 24-hour refusal (#10 / 2534022, 2018278, or "outside of allowed window") becomes "… only allows replies within 24 hours of the customer's last message." and reaches the composer unchanged; META_FACEBOOK_DMS=1 adds pages_messaging and messages for env and org Platform apps, and unset leaves Facebook as before; an is_echo message is not storedcd apps/api && npx vitest run src/test/channels-adapters.test.ts src/test/inbox-reply.test.ts src/test/webhooks.test.ts src/test/adapter-facebook.test.ts and cd apps/web && npx vitest run src/lib/inbox.test.ts2026-09-28✅ API 160/160, web 30/30; full API suite 3542 passed, 17 skipped; full web suite 1354 passed. Graph is mocked. Not yet run against live Meta: the Send API path, the IG subcode 2534022, and the echo payload shape come from Meta's docs and were not captured from a real delivery
On the Content page, "Connect another account" opens the connect dialog on the platform's requirements, headed "Before you connect", and does not start OAuth until that dialog is confirmed. The confirm returns to /projects/:id/content. A failed connect shows a readable toast, and both oauth_error and oauth_platform are removed from the URLcd apps/web && npx vitest run src/lib/social-connect.test.ts src/components/connect-account-modal.test.ts src/components/organic-hub-tiles.test.ts "src/app/(app)/projects/[id]/content/channel-bar.test.ts"2026-09-28✅ 41/41, whole web suite 1349/1349. Every new behaviour test failed first for the right reason; the two passing-on-arrival checks (confirm passes redirectAfter; no second toast while the manager is open) were mutation-checked. oauthErrorMessage is the one wording source for all four readers and echoes neither param unless recognised. Not exercised in a browser or against a live Meta connect. The two organic-page banners switched to the helper without page-level tests.
A channel connect the adapter refuses redirects the browser to its redirect_after path with oauth_error=<code> and oauth_platform=<platform>, never the provider's message; a non-adapter error still fails as a 500 and a successful connect still redirects with connectedcd apps/api && npx vitest run src/test/channels.test.ts2026-09-28✅ 23/23; full API suite 3524 passed, 17 skipped. Four of the five new tests guard behaviour the fix keeps rather than adds, so they passed on their first run. Each one failed against a deliberately broken version of the code (message in the URL, warning removed, every error redirected, connected renamed) before the real code was restored
The roll's pull step (roll-vm.sh step 3) aborts when the release cannot pull or an image is on no registry and not on the host, and carries on when a withdrawn infrastructure image is held locallydeploy/test/pull-gate.sh2026-09-25✅ PASS, 4 cases incl. the control. #309 inlined the step, so the test could no longer find pull_images and deploy · migration gate was red on every PR from #309 on. It is a function again, and #309's rule (api and web must pull; a stale local copy never carries the release) now has its own case: with that abort removed, the case fails. Restored the guard #309 dropped: a failing docker compose config used to give an empty image list, which passed the presence check.
The public /data-deletion page (the Meta app's "User data deletion" URL) renders signed out and names the legal entity, the in-app deletion steps and the contact emailcd apps/web && npx playwright test e2e/smoke.spec.ts -g "data-deletion"2026-09-25✅ 1/1. Lives outside (marketing) so the visitor tracker does not record someone reading how to delete their data. Every step names a flow that exists today: Settings → Privacy account deletion, and the channel row's Disconnect (which nulls the stored OAuth tokens, social-accounts/db.ts markDisconnected) then delete. Account deletion does not remove workspace channels, and the page says so instead of promising it.
A LinkedIn PDF post reaches the feed — it goes to the Posts API, never to ugcPostscd apps/api && npx vitest run src/test/adapter-linkedin-document.test.ts2026-09-25✅ 11/11. ugcPosts.shareMediaCategory is NONE / ARTICLE / IMAGE / VIDEO; "DOCUMENT" was assumed rather than read off the enum, so every PDF post failed from the day the feature shipped with 422 :: "DOCUMENT" is not an enum symbol. The upload half was always right — documents go through the versioned /rest/documents API — and its sibling /rest/posts is the only endpoint where a urn:li:document: URN means anything, so that is where a document now publishes. Pinned: the document body carries singular content.media.id (the PDF IS the post), commentary rather than shareCommentary.text, a LinkedIn-Version header (ugcPosts is unversioned, so it has no counterpart to copy), the mandatory distribution block that ugcPosts lacks entirely, and visibility as a plain string — the nested MemberNetworkVisibility map is accepted here and silently ignored, which would publish a CONNECTIONS-only post to the whole feed and look like success. Images, video, articles and text stay on ugcPosts deliberately: moving four working paths to fix one broken one trades a known bug for four unknown ones.
The document tests can now fail when LinkedIn would reject the postcd apps/api && npx vitest run src/test/adapter-linkedin-document.test.ts2026-09-25✅ This suite existed throughout the bug's life and passed, because the harness stubbed /v2/ugcPosts to succeed and the assertion read back the shareMediaCategory we had just written — it asserted our belief about LinkedIn, not LinkedIn's behaviour. The harness now scripts both publish endpoints, so which one the adapter picks is the thing under test, and the regression case asserts ugcPosts is not called and that shareMediaCategory appears nowhere in the captured traffic. A negative assertion is the only version of this that could have caught the original defect. Also added: a document post with no file attached is refused locally, since the Posts API answers a missing content.media.id with a schema dump naming no file.
Tidy lays out the graphs that most need it — parallel paths, converging paths, orphans, a cycle, a missing trigger — without stacking two cards in one place, and re-saves only what moved; the trigger panel never presents a standing audience as the people who will enter, and warns on a trigger nothing raisescd apps/web && npx vitest run src/lib/automation-layout.test.ts src/lib/automation-audience.test.ts2026-09-24✅ 16/16. The cycle case genuinely failed first — the walk read a parent's position before it was written — so the ordering fix is load-bearing, not defensive. Whole web suite 1323/1323; API automations-api + automations-runtime 40/40 unchanged. Not exercised in a browser yet
A recipe flattens into the right graph — a condition's yes and no arms are not swapped, it points at the email it asks about, and every shipped recipe uses only steps this engine hascd apps/web && npx vitest run src/lib/automation-recipes.test.ts2026-09-24✅ 10/10. The Welcome-series shape was also built through the live API (create → 5 nodes → 5 edges) against a seeded local project: GET /automations/:id/validate answered {"ok":true,"problems":[]}, which is what proves the payloads this writes are ones the server accepts. The gallery itself has no browser test yet
The email section opens on an overview that ranks an unverified sender above everything else and stays quiet about rates on a project that has sent nothing; bounce and complaint rates follow the published thresholds; every tab in the strip routes and a garbage segment falls backcd apps/web && npx vitest run src/lib/email-health.test.ts src/lib/email-broadcasts.test.ts2026-09-24✅ 48/48 (16 new). Whole web suite 1297/1297, npx next build compiles. The rate() cases fail if null is replaced with 0 — checked, because that is the bug the helper exists to prevent. Not exercised in a browser yet — suppression add/remove and the template editor are covered only by the API-side tests for the endpoints they call (channel-style route tests in apps/api/src/test), not by anything that proves the screens wire to them
Every API response carries Server-Timing (errors and 404s included) and exposes it only to allowed origins; slow statements are logged without parameter values; the baseline numbers are reproduciblecd apps/api && TEST_DATABASE_URL=postgresql://app:app@localhost:5437/app_test npx vitest run src/test/server-timing.test.ts src/test/slow-query-log.test.ts; curl -D - -H 'Origin: http://localhost:3000' localhost:4000/api/v1/health shows both headers; SLOW_QUERY_MS=1 on a prisma.user.count() logs the statement; the Lighthouse and perf:sizes commands in docs/PERFORMANCE.md2026-09-24✅ 7 passed; headers observed (app;dur=11.9); slow-query line observed; Lighthouse 96/81 and sizes recorded
The assistant, Recharts and React Flow are no longer in any page's first-load JS, and no route grewcd apps/web && npx next build then a script summing each route's entryJSFiles from .next/server/app/**/page_client-reference-manifest.js (gzipped): shared-by-every-app-page 227 → 84 KB, avg app route 283 → 165 KB, 0 routes grew (re-run on the merged main: shared 99 KB with team chat + walkthrough added, heavy libraries still absent from every route); npx tsc --noEmit && npx eslint src && npx vitest run2026-09-23✅ build clean; 80 files / 1284 tests passed; typecheck + lint clean; assistant, charts and canvas rendered in the browser after load with no console errors
The worker's healthcheck passes only while the heartbeat is landing in the database, and fails with no mark or a stale onecd apps/api && TEST_DATABASE_URL=postgresql://app:app@localhost:5437/app_test npx vitest run src/test/worker-liveness.test.ts src/test/health.test.ts && npm run build && node dist/worker-healthcheck.js (exit 1, "no heartbeat mark"); with npx tsx src/worker.ts running for 15 s → exit 0; after touch -t <2 min ago> on the mark → exit 1 ("120s old (limit 45s)")2026-09-23✅ 6 passed; all three exits observed; bundle boot test passed; bash -n deploy/roll-vm.sh and both compose files parse
Exactly one process hosts the interval workers, chosen by TICK_WORKERS_HOST; the default keeps them in the API; a bad value refuses to bootcd apps/api && TEST_DATABASE_URL=postgresql://app:app@localhost:5437/app_test npx vitest run src/test/tick-workers-host.test.ts && npm run test:production-bundle; then TICK_WORKERS_HOST=worker npx tsx src/worker.ts logs tick workers: hosting 21 interval workers, TICK_WORKERS_HOST=worker npx tsx src/main.ts logs hosted by the worker process, not here and starts no timer, TICK_WORKERS_HOST=both exits with the variable named2026-09-23✅ 4 passed; bundle boot test passed; all three runs observed; channels-adapters, health, publisher* suites 125 passed
A LinkedIn document post sends the API version the project registered for its own app; the version survives a secret rotation and an empty field clears it; a retired version fails once with the screen named, instead of five retriescd apps/api && npx vitest run src/test/adapter-linkedin-document.test.ts src/test/channel-credentials.test.ts2026-09-24✅ 30/30 (9 LinkedIn document cases, 21 credential cases). The 426 case asserts code: "invalid_request" — with the branch removed it is platform_error and the publisher retries it. Neighbouring suites re-run green: channel-credentials-multi, channels, analytics-credentials, ads-credentials 48/48; web channel-credentials 9/9
api_calls is coalesced per organisation and never written on the response path; the User row is read once per session until the row changes; a deactivated user is refused on the very next requestcd apps/api && TEST_DATABASE_URL=postgresql://app:app@localhost:5437/app_test npx vitest run src/test/billing-api-call-meter.test.ts src/test/http-user-cache.test.ts2026-09-23✅ 10 passed; mail/push suites (push, emit-integration, invitations, notifications-email, notifications) 75 passed after the lazy-import fix; authz-middleware, billing-usage-*, better-auth, api-keys, sessions suites 57 passed
A platform's review verdict and reasons are recorded per ad and rolled up (worst wins) without changing status; the mappings never guess "approved"; the worker checks on schedule, skips what it cannot ask about, and backs off on rate limits and dead tokens; publishing makes the first check duecd apps/api && npx vitest run src/test/ads-review-adapters.test.ts src/test/ads-review-sync.test.ts2026-09-22✅ 14/14 + 17/17, plus 433/433 across every ads suite. Meta and Google adapters are exercised with fetch stubbed (paging, requested fields, GAQL scope, error mapping). The publish→due hook was confirmed to fail with the call removed.
A failed ad publish is recorded and survives a reload; only transient failures retry; a retry never duplicates what already reached the platform; exhaustion pages the account's connector oncecd apps/api && npx vitest run src/test/ads-publish-retry.test.ts src/test/ads-publish.test.ts2026-09-21✅ 15/15 new + 31/31 existing. Against the pre-change publish.ts, 9 of the 15 fail. The resumability case asserts the adapter's createCampaign/createAdSet counters stay at 1 across the failure and the worker's retry — the property that costs money if it breaks. Two workers racing one campaign is covered by holding the claim and asserting the tick skips it.
A failed Stripe webhook is retried rather than lost; stale events from an old subscription cannot overwrite the current one; unpaid withdraws paid limits; checkout refuses an org with a live subscriptioncd apps/api && npx vitest run src/test/billing-webhook-lifecycle.test.ts2026-09-18✅ 11/11. Stripe SDK mocked at the module boundary. Run against the pre-fix domain.ts, 10 of the 11 fail — the one that passes (concurrent delivery applies once) is a guarantee that had to survive the rewrite.
Seat limit holds on invite and accept (including two concurrent attaches for the last seat); paid channels refuse a free or lapsed org at OAuth start, OAuth attach and POST /ad-accountscd apps/api && npx vitest run src/test/billing-entitlements.test.ts2026-09-18✅ 12/12. The concurrency case fails with the FOR UPDATE removed — checked, because the first version of it (two HTTP accepts) passed either way. The ads suites now start their orgs on Starter.
A step can lead to several steps and a contact walks them all — without an email going twice, an End leaving a path running, or a person counted twicecd apps/api && npx vitest run src/test/automations-runtime.test.ts src/test/automations-api.test.ts2026-09-22✅ 40/40 (5 new: a fan-out starts a second enrollment row and both paths run; two paths meeting at one send mail it once; End on one path completes the other; three paths count as one entered/active journey and once still refuses re-entry; connecting the same pair twice is one edge and one path is removed by id). The 35 single-path tests pass unchanged. In a browser: the trigger's + labelled "Add a parallel path" adds a second step beside the first, 5 → 6 connections, and the validator reports ready.
Every landing-page component and form field the requirements PDF lists works end to end, and a lead's source records the page it came fromcd apps/api && npx vitest run src/test/email-forms.test.ts src/test/landing-pages.test.ts and cd apps/web && npx vitest run src/components/landing2026-09-21✅ 29/29 API (checkbox/dropdown/radio served with options; an unticked required checkbox is refused; a choice is stored as the option is spelled; a hidden field's fixed value wins over a forged one and never reaches the page; a url-parameter field takes the parameter and falls back; the landing page is recorded and names the source; a bogus page is ignored without losing the lead; an automation whose form was deleted fails validation and activation; a page with a closed form cannot publish) and 6/6 web (every block — video, testimonials, FAQ and footer included — is valid as dropped; only YouTube/Vimeo/Loom/https video files embed). In a browser: video, testimonials, FAQ and footer added in the editor and saved live; the embed code is offered; a visitor on /p/spring-webinar?utm_source=newsletter sees the YouTube iframe, 2 testimonials, 2 FAQ items and the footer, picks a radio option, ticks consent and signs up — the database shows source newsletter, the submission linked to the page, and values Pro and true. No console errors.
A landing page cannot carry script, cannot embed another project's form, and is invisible until published; every drag-and-drop in the email tools lands where it is droppedcd apps/api && npx vitest run src/test/landing-pages.test.ts and cd apps/web && npx vitest run src/components/landing2026-09-21✅ 7/7 API (javascript: link refused; foreign form 404; slug clash 409; empty page cannot publish; unpublished → 404 → published → 200 → unpublished → 404; a closed form is dropped and the public JSON contains no project, page or form id; views count) and 4/4 web (every palette block is valid as dropped; javascript:/data: links refused; slugs). Driven in a browser: a Wait step dragged onto the automation canvas becomes a step (1 → 2); a form field dragged by its grip moves (Email, First name, Company → Email, Company, First name); a Text block dragged from the palette lands in the gap after the hero; the form block dragged by its handle above the heading moves there; both orders survive Save and a reload; the published page at /p/spring-webinar renders and a sign-up through it returns the form's thank-you. No console errors.
A public form reveals nothing, never resubscribes anybody, and starts only the journeys pointed at itcd apps/api && npx vitest run src/test/email-forms.test.ts2026-09-21✅ 14/14. The ones that matter: a new and an existing address get byte-identical answers; the public GET contains neither the project id nor the form id; an existing contact keeps their category and blanks; an unsubscribed contact stays unsubscribed and is not enrolled; a honeypot hit returns 200 and writes nothing; an automation pointed at another form, or at none, is not entered. Driven in a browser against a scratch database: build a form → copy its link → open it logged-out, submit → the thank-you shows and the Forms tab counts 1; an automation's "A form is submitted" trigger lists and names the form. curl -I shows /f/<token> with frame-ancestors * and no X-Frame-Options, /login still SAMEORIGIN. No console errors.
The automation builder draws only what the engine can run, and its numbers count people, not eventscd apps/api && npx vitest run src/test/automations-runtime.test.ts src/test/automations-api.test.ts and cd apps/web && npx vitest run src/components/email/automation-builder src/lib/project-ia.test.ts src/lib/email-broadcasts.test.ts2026-09-21✅ 35/35 API (3 new: per-send open counting with zero rows for unsent steps, unsubscribed counted inside exited, the merged timeline ordering an open after the step that sent it) and 72/72 web (every palette kind's default config, once filled, passes the contract's strict schema; card summaries). Driven end to end in a browser against a scratch database: create → add send, wait, condition, tag, end → the unwired condition is flagged → drag Yes/No → "Ready to activate" → Activate → a new contact enrols and appears on its step with a timeline. No console errors.
The assistant edits posts without skipping approval, reads best times from real data, draws only on Confirm, and remembers privatelycd apps/api && npx vitest run src/test/ai-chat-more.test.ts2026-09-22✅ 17/17. A draft's caption edit leaves its variant draft; a reschedule card is outward and the variant moves to scheduled only on Confirm; with approval required, a reschedule is reversible, leaves scheduledAt null and sets proposedPublishAt to the asked time; a scheduled post whose caption is edited under an approval rule ends with no scheduled variant and its old time proposed; unschedule returns it to draft; a published post is refused; "launch" matching two posts asks which; 281 characters to X is refused. Best times in Europe/London: Tue (80, 60) ranks above Thu (10, 20) as 70 vs 15, a single Saturday post at 500 is left out, the best hour is 09:00–10:00 from 08:00 UTC, and 5 measured posts is called too few; email: Tue 25.0% above Thu 5.0% with a 5-send Saturday unranked. Images: no provider call on propose, one on Confirm, image-1.png then lets an Instagram post schedule, and the next request's system prompt lists it; with no role assignment the proposal is refused. Memory: nothing stored before Confirm, a case-insensitive repeat refused, the text appears in a brand-new conversation's prompt, a project memory is absent from another project while an everywhere one is present, another person cannot find it to forget it, and the owner's forget removes it. Also rerun: ai-chat-actions, ai-chat-reports, ai-chat-tools, ai-chat, ai-images, ai, projects 182/182; web 1271/1271; prisma migrate diff from migrations to schema is empty. Not verified against a real model.
The assistant reports from real data only, attaches only the person's own files, and writes in the brand's voicecd apps/api && npx vitest run src/test/ai-chat-reports.test.ts2026-09-22✅ 23/23. Email report: of two sent emails (Spring 10/6/4, Autumn 10/3/1 with each click written twice), Autumn reads 30.0% open, 10.0% click, 33.3% click-to-open, one link with 1 click, and a usual of 60.0%/40.0% from Spring alone; a lone email reports no usual and says so; another org's email is not found. Automation report: 4 people waiting on the wait step, the first email 4 received / 2 clicked (50.0%), the second 0 received with a null rate. Channel: 1000→1120 followers is +120, 2 posts averaging 50 at 5.0%, Tuesday 90 vs Thursday 10. Website: 400 sessions / 10 conversions (2.5%) across two sources, 50 clicks of 1000 impressions; with the mock GA4 adapter the stored 999 sessions are not reported. Attachments: a foreign-org key is dropped, the model sees [Attached: "bike.jpg" (image)] with no org id, the API returns name/kind/url with no key; an attached image lets an Instagram post schedule and lands in media_assets; a model-written URL is refused; YouTube refuses an image and accepts a video; Pinterest refuses scheduling and accepts a draft. Brand voice: two Confirms merge to {tone, audience} with avoid cleared; nothing is saved before Confirm; the voice appears in the assistant's system prompt and the caption prompt; a PATCH with only blanks stores null; another org's project id on /ai/caption is 404. Also rerun: ai-chat-actions, ai-chat-tools, ai-chat, ai, projects, project-members — 150 passed (two assertions updated for the intended changes: 8 tool rounds, follower counts no longer listed as unavailable); web 1271/1271. Not verified against a real model — the key is still rejected.
The assistant can post, tag and handle lists without reaching past the rules the screens applycd apps/api && npx vitest run src/test/ai-chat-actions.test.ts2026-09-22✅ 46/46 (11 new). A drafted post has no scheduled time and no variant scheduled or published. A scheduled one is outward with the platform on the card; with requires_approval on the project the card says it goes to review and is reversible. Scheduling to Instagram is refused while drafting to it is allowed; a 281-character caption to X is refused; with two LinkedIn accounts the model is told both names and a proposal naming one succeeds. A duplicate tag is refused case-insensitively. A bulk add of three with one already present adds two and tags two; a batch containing "not an address" is refused whole. A bulk tag proposed for a two-person segment, confirmed after a third matching contact is added, tags exactly the two. Not verified: the assistant against a real model. ASSISTANT_LIVE_EVAL=1 npx vitest run src/test/assistant-live.test.ts is written and ready, and its first run failed every scenario on openrouter 401 User not found — confirmed with a direct request to https://openrouter.ai/api/v1/key returning HTTP 401 — with no Groq key set. Until a working key is configured, nothing in this repository has observed the assistant choosing tools on its own.
The assistant builds a branching journey that means what it says, and edits one only where it is safe tocd apps/api && npx vitest run src/test/ai-chat-actions.test.ts2026-09-22✅ 35/35 (17 new). The requirements' own example — email, wait two days, did they open it, two follow-ups — is built from one proposal into exactly trigger→email→wait→condition, condition-yes→email, condition-no→email, and the condition's sendNodeId is asserted to be the id of the "Your guide" node specifically; the result is a draft that an activate_automation proposal then accepts, so it validates. Refused before any card is written (asserted by counting chat_actions): a question about an email not yet sent on that path, steps after an if, and a company greater_than condition. Step edits: get_automation returns steps with ids in the shape the edit tools accept; an insert pushes the next email down behind the new wait; an update keeps the node's kind and refuses a kind change; a removal joins the neighbours; a live journey is refused until stop_automation_for_editing is confirmed; a step id from another automation is refused. Unsubscribe asserts the flag, the suppression row and the enrollment exited. Test send asserts sendMail's to is the actor's own address and the broadcast stays a draft. Not covered: the per-person test-send rate limit — rateLimit is a no-op outside production unless forced, so no test exercises it; its presence is by reading the code. The half-built-draft cleanup has no test, because no available input makes a build fail after its first step once proposal-time parsing has passed. No test calls a real model.
The assistant can act, but cannot act on its own — every action waits for the person, runs once, and runs with their permissionscd apps/api && npx vitest run src/test/ai-chat-actions.test.ts src/test/ai-chat-tools.test.ts src/test/ai-chat.test.ts2026-09-22✅ 18/18 new + 30/30 existing. Proposing does nothing: a send_broadcast proposal leaves the broadcast in draft and sendMail uncalled, and the result the model reads contains "has not happened". Confirm is single-use: three concurrent confirms produce exactly one fulfilled outcome and one send per recipient; confirm-after-dismiss is 409 and confirm-after-expiry is 410, both with sendMail uncalled. Mutation-tested: removing status: "proposed" from the claim makes both the concurrency test and the dismiss test fail — which matters because sendBroadcast has its own draft→sending claim that could otherwise have let the concurrency test pass for the wrong reason (the test counts fulfilled confirms, and a doubled confirm would return a failed outcome, not a rejection). Scope and permission: another user's action is 404 rather than 403; a proposal naming a project the person cannot see is Project not found.; a Member's confirmed send is recorded as failed with assertCanSend's own message and nothing is mailed. What gets built: a model-written body containing <script> is stored escaped with {{first_name}} intact and as a draft; a built automation has trigger → wait → email connected by exactly two edges and is left in draft; an activation that fails validation is refused at proposal. End to end: a mocked model calling send_broadcast yields a reply carrying one proposed card and no mail, and the card is still on the turn when the thread is re-read over HTTP. The confirm endpoint writes a chat_action audit row with the kind and outcome. Web: 1261/1261 (cd apps/web && npx vitest run). API: every suite this change touches, 222/222. Tests ran against an isolated database (app_test_assistant, checked to hold the new table while the shared app_test does not) so they could not collide with other work on the machine. Not covered: no test calls a real model, so whether a live model follows the prompt — prepares rather than claims, looks up names before acting — is untested; the tests prove what happens when it does call a tool, not that it will. The card's UI is not component-tested; its behaviour rests on the server returning status and expired. Content-draft approval from the chat accepts every draft — per-draft decisions still need Approvals.
An automation cannot send the same person the same email twice, and a wait means what it sayscd apps/api && npx vitest run src/test/automations-runtime.test.ts src/test/automations-api.test.ts2026-09-21✅ 32/32. The duplicate-send defence is asserted at both layers rather than reasoned about. The claim: three claimEnrollment calls are issued concurrently against one due row and exactly one returns true — the conditional UPDATE … WHERE claimed_at IS NULL serialises them on the row lock, so there is no check-then-write window. The idempotency gate: an enrollment is deliberately put BACK on a send node it has already executed, as a resurrected claim would, the sweep is run, and sendMail is still called exactly once with a skipped / already sent step recorded. Either mechanism alone is insufficient — the first fails if a worker dies holding a row, the second is what makes that survivable. Timing: a 60-minute wait entered at t0 and next swept at t0+3h proceeds immediately rather than waiting another hour, which is what stops a late tick (deploy, backlog, resumed pause) silently re-timing every contact's journey. A 2-day wait is asserted not to fire at +1 day. A contact who unsubscribes during a wait receives nothing further and exits with the reason recorded. A claim left by a dead worker is reclaimed, without which a claimed row is skipped forever and the contact never moves again. Branching is asserted both ways against the PDF's own example, including the case that matters most: a contact with an unrelated opened send is still branched NO, because the engagement condition is scoped to this enrollment — treating them alike sends the "they're engaged" follow-up to somebody who never read the message. Contact-field conditions go through the segment evaluator, asserted case-insensitively. All three entry rules, plus a suppressed contact refused at the door. Validation catches a half-wired condition and a waitless loop, and accepts a loop that contains a wait (a legitimate re-engagement pattern). Editing a live automation and deleting a step somebody stands on are both refused. Not covered: nodemailer is mocked, so this proves who is mailed and when, never that anything arrived. The sweep is driven by runDueEnrollments(now) rather than by the timer — the worker's own scheduling is not exercised, only the work it calls. No test runs two OS processes, so the concurrency claim rests on the database's row locking rather than on observed parallel workers. form_submitted cannot fire until F-150.
A tracking link cannot be forged, and none of the three public endpoints can be turned against a recipientcd apps/api && npx vitest run src/test/email-tracking.test.ts src/test/email-engagement.test.ts2026-09-21✅ 13/13 + 17/17. The security property under test is unforgeability, not secrecy — the payload is a pair of opaque uuids, and what matters is that nobody can fabricate one, because an unsigned token lets anyone mark any send as opened and choose the branch a journey takes off the back of it. Asserted: a token whose payload is edited but whose signature is kept is rejected; a truncated or absent signature is rejected; and one kind of token cannot be used as another — the unsubscribe token is printed in plain sight at the foot of every email, so without the kind inside the signed payload it would also be a valid open token, and a click token lifted from a link would unsubscribe whoever clicked it. The three endpoints are each pinned against their own failure mode: the pixel returns the identical 200 + image for a real and a forged token (a 404 would confirm which tokens name real sends, and would render a broken image to the person being tracked) and carries no-store; the click redirect 404s a forgery rather than redirecting anywhere and ignores a ?url= on the request entirely; and the GET on the unsubscribe route provably writes nothing — asserted by counting suppression rows after it — which is what stops Outlook Safe Links and corporate scanners unsubscribing people who never opened the email. The POST writes all three records (suppression, contact flag, event) and is asserted idempotent across two calls. The end-to-end claim: an address that unsubscribed, whose contact row is then re-subscribed by hand (the state a re-import produces), is still refused on the next send with a 422 naming the suppression list — suppression being keyed on the address is what makes that hold. Also pinned: opens are counted per person (four fetches, one opened, four rows still in the table), and the first suppression reason survives a later manual entry. Not covered: no test sends real mail — nodemailer is mocked at the transport boundary, so this proves what is in the message and what the endpoints do, not that anything arrived. Nothing here tests delivered / bounced / complained, which do not exist yet: they need provider webhooks. Open tracking is unreliable by nature and no test can fix that — it is labelled as approximate in the UI instead.
One Meta login connects every granted Facebook Page / Instagram account, each with its own Page token, and reconnecting refreshes rather than duplicatescd apps/api && npx vitest run src/test/channels.test.ts src/test/adapter-facebook.test.ts src/test/channels-adapters.test.ts · cd apps/web && npx vitest run src/lib/social-connect.test.ts2026-09-21✅ 104/104 API (7 new: callback → 2 rows + no dupes on reconnect; FB returns all Pages with limit=100; IG returns every linked account, skips one that fails, fails only when all do) + 28/28 web (pickDisplayedAccount). Full suites: web 1266/1266; API 3123/3128 with 5 timing failures in audit/publisher/notifications under load, 48/48 on re-run. Checked in the browser: two IG accounts in one project, analytics switcher changes the account.
A segment is a question asked at send time, and it cannot be made to answer wronglycd apps/api && npx vitest run src/test/email-segments.test.ts src/test/email-fields.test.ts src/test/email-tags.test.ts src/test/email-broadcasts.test.ts2026-09-20✅ 125/125. The four assertions that matter are about answers that RENDER correctly while being wrong. (1) A number condition is evaluated in its own column: two contacts at 9 and 11, greater_than 10 returns 1 — as text it returns 2, because "9" > "10" is true letter by letter. (2) A negation includes the contacts with no value: three contacts, one of them with no job title at all, not_contains "Founder" returns 2. This failed as a real defect during the build — SQL's NULL NOT ILIKE '%x%' is NULL rather than TRUE, so the first implementation silently dropped everyone missing the field, which is how a re-engagement segment mails half the list it should. (3) A rule naming a tag deleted after the segment was saved matches 0, not everybody: an undefined where key is dropped by Prisma, so the obvious implementation widens the segment to the entire project on the one feature that decides who gets mailed. (4) A broadcast targeting a segment resolves it at SEND time — a contact added after the draft was written IS mailed, an unsubscribe matching the rules is NOT, and the count is 2 where a draft-time snapshot gives 1. Cross-tenant is asserted on all three surfaces (rule, filter, broadcast target) and answers 404, never 403. Also pinned: clearing a custom value deletes the sparse row so is empty can tell it from ""; key and type are refused rather than stripped on PATCH; a sent broadcast stays readable after its segment is deleted. Not covered: no test sends real mail — the transport is the existing stubbed path, so this proves who the recipient set contains, not that it arrived. Engagement conditions do not exist to test (F-147). Full API suite run the same day: 3160/3161, the single failure being a pre-existing flake in audit.test.ts cursor pagination (createdAt cursor with no id tiebreaker skips rows sharing a millisecond) which passes in isolation and alongside these suites, and is unrelated to this work.
A tag is a row, so a rename keeps its people — and a tag from another project cannot reach a contactcd apps/api && npx vitest run src/test/email-tags.test.ts2026-09-20✅ 9/9. The two that matter: renaming Webinar to Webinar — September leaves the contact carrying it (as strings, the membership would be orphaned and the renamed tag would match nobody), and a request naming one of our tags plus one belonging to a sibling project in the SAME organization 404s with zero memberships written — not even the legitimate half. Re-applying a tag and removing an absent one both return 200 with the expected end state, which is what the automation re-entry path needs. Not covered: the import and form paths that will create tags with source of import / form; only the user source exists today.
A backlink row means what the column says it means, and a lost link stays lostcd apps/api && npx vitest run src/test/growth-backlinks-provider.test.ts src/test/growth-backlinks.test.ts2026-09-18✅ 21/21 new + existing suite green. fetch stubbed throughout, and the first assertion is that the module reports itself NOT configured under NODE_ENV=test despite real credentials in the injected root .env — the endpoint is billed, so a leak would cost money every run. The three assertions that matter are about mappings whose failure is silent, because the column accepts the wrong value and the UI renders it without complaint: domain_from_rank 0..1000 → domainAuthority 0..100 is pinned at both ends and mid-range (720 → 72, not 720), clamped above, and returns null — not 0 — for absent/NaN/negative/string input, since 0 asserts a measurement of a worthless domain while null renders as "unknown". Attribute precedence is pinned with a multi-attribute row (["nofollow","ugc"] → ugc, ["ugc","sponsored","nofollow"] → sponsored) because the schema stores one value and flattening loses that a link was paid or that the site never chose it; a missing dofollow flag resolves to nofollow rather than crediting authority we cannot confirm. A row DataForSEO already marks is_lost is asserted dropped, since the reconciler decides lost by absence and passing it through would re-activate a dead link on every sweep. Rows missing either half of the (sourceUrl, targetUrl) unique key are skipped rather than failing the whole domain. Failure mapping mirrors the SERP path — 401/429/5xx and a 200 carrying a non-20000 task status each assert their code, with 402xx → auth so a sweep can abort instead of retrying per project, and the provider's own message is asserted to survive into the error. Not covered: no test reaches DataForSEO, so the response shape is from their documentation; the account is unverified, so no successful live response has been observed for either endpoint.
Every growth worker sweeps at least hourly, and sweeping often stays cheapcd apps/api && npx vitest run src/test/growth-worker-ticks.test.ts2026-09-17✅ 5/5. A contract test over main.ts source, deliberately — the defect lived entirely at the call site, and nothing observable failed: the workers started, logged a tick, and reported healthy while never sweeping. It asserts no hard-coded interval above 1h is passed to any of the four start*Worker calls, ignoring process.env overrides (an operator's choice is theirs; a default in our source is ours). Confirmed to fail against the pre-fix code — reinstating the 24h value produced expected 86400000 to be less than or equal to 3600000, so this is a guard rather than a restatement. The companion case drives runDueRankChecks and runDueBacklinkChecks twice with no time passing and asserts the second pass does no work: that is what makes an hourly sweep safe, and if cadence ever migrates from the row into the timer it breaks loudly here instead of as a silent bill for 24 redundant lookups per keyword per day.
A keyword rank is a measurement, and a failed lookup never becomes onecd apps/api && npx vitest run src/test/growth-serp.test.ts src/test/keywords.test.ts src/test/growth-competitors.test.ts2026-09-17✅ 19/19 new + 36/36 existing. fetch is stubbed in every case, and the first assertion is that isSerpConfigured() is false under NODE_ENV=test even with credentials in the injected root .env — the endpoint bills per call, so a leaked suite would cost money on every run. The line the rest defends is result-vs-failure: a domain absent from the top 100 returns rank: null (asserted), while 401, 429, 5xx, a transport throw, a 200 with a non-20000 envelope status and a 200 with a non-20000 task status all throw (asserted individually) so the tick writes no row — a stored null would be indistinguishable from a genuine "not ranked" and would draw a cliff on the history chart that never happened. 200-with-an-error-body is the specific shape a naive if (res.ok) integration records as a successful check, which is why both envelope levels have their own case. Domain matching is pinned in both directions: blog.example.com counts as example.com ranking, notexample.com does not (a naive endsWith would credit a competitor's position to the user), and www. is ignored on either side. rank_group is asserted to win over rank_absolute because the latter counts ads and map packs, so it disagrees with what a human counting the page sees; non-organic items are excluded for the same reason. An unrecognised country throws without calling the API — asserted on the spy — because a US answer stored against a keyword tracked in Germany looks entirely normal. Not covered: no test hits DataForSEO, so the response shape is taken from its documentation rather than observed; the first live run is what confirms it.
/health reports the worker as down when no worker has ever reported, and up once a fresh heartbeat existscd apps/api && npx vitest run src/test/health.test.ts2026-09-21✅ 3/3. No heartbeat row → worker: "down", worker_last_seen_at: null, status: "degraded" (still 200 — a dead worker never takes the whole API out of rotation). A row written within 45s → worker: "up". A row older than 45s → worker: "down" again.
POST /growth/projects/{id}/prompts/suggest generates prompts from the project's own domain, excludes ones already tracked, is owner/admin-gated, and 404s outside the caller's orgcd apps/api && npx vitest run src/test/growth-ai-citations.test.ts -t "prompts/suggest"2026-09-21✅ 5/5. Mock provider (no key configured) returns suggestions seeded from the project name; expected_domains is ["example.com"] for a project at https://www.example.com (www. dropped). 422 with no website_url. A prompt matching one already saved is filtered from the response. A project member without an owner/admin role gets 403; another org's project gets 404.
An assistant answer says which of the reader's own data it was built from, and survives a refreshcd apps/api && npx vitest run src/test/ai-chat-tools.test.ts src/test/ai-chat.test.ts2026-09-18✅ 8/8 + 22/22. Tool names are written to chat_messages.tools_used on the assistant turn and serialised as tools_used, so the trace is present when the thread is re-read rather than only in the response that created it. The serializer treats a non-array column as none rather than throwing a conversation away over one bad row.
The assistant answers from the workspace's real rows, and cannot reach a project its caller cannotcd apps/api && npx vitest run src/test/ai-chat-tools.test.ts src/test/ai-chat.test.ts2026-09-18✅ 8/8 + 22/22. A tool call naming another tenant's project id returns Project not found — not forbidden, so a probe learns nothing; list_projects returns only the caller's own. A tool failure comes back as {error} for the model to explain rather than ending the conversation. Confirmed live against OpenRouter: asked how content did, it called the tool and answered with the real 5 posts / 65,000 impressions / 6,396 engagements and named the actual best post.
The assistant can start a run and can do nothing that a human must decidecd apps/api && npx vitest run src/test/ai-chat-tools.test.ts2026-09-18✅ 8/8. start_agent_run produces a run in queued/running with decidedByUserId null — applied is unreachable from chat. A test asserts no tool name contains approve, reject, publish, schedule, budget or delete. The loop caps at three rounds and withholds tools on the last, so a model that keeps asking is forced to answer rather than returning an empty reply.
The assistant cannot misstate money by a factor of a hundredcd apps/api && npx vitest run src/test/ai-chat-tools.test.ts2026-09-18✅ 8/8. 144,000 minor units come back as GBP 1440.00 with GBP 10.29 per conversion. This is a regression test for observed behaviour: sent the same figure as spend_minor, the live model reported "£144,000" and "£1,029 per conversion". Re-confirmed live after the fix — "GBP 1,440.00 … GBP 10.29".
A storage failure reaches the user as its cause, not as "Internal server error"cd apps/api && npx vitest run src/test/storage-errors.test.ts2026-09-17✅ 6/6. An unreachable endpoint yields an ApiError at 424 whose message still contains ECONNREFUSED; a failed PutObject yields "Could not save the file to media storage" plus the SDK's own text. Confirmed end-to-end against a live MinIO too: a real upload succeeds, and the same call against a dead endpoint returns status: 424 with the reason instead of the previous bare 500.
One MinIO blip does not break storage until the API is restarted, and a lost create race is not treated as failurecd apps/api && npx vitest run src/test/storage-errors.test.ts2026-09-17✅ 6/6. After both the head and the create fail, a second ensureBucket() against a recovered endpoint succeeds — the old code latched bucketReady = true on the way down and skipped the check forever. BucketAlreadyOwnedByYou resolves rather than throwing.
A deleted image and an unreachable store give different answerscd apps/api && npx vitest run src/test/storage-errors.test.ts src/test/ai-posters.test.ts src/test/poster.test.ts2026-09-17✅ 6/6 + suites green (111 across the eight storage-touching suites). A NoSuchKey passes through unwrapped so the poster module still renders its deliberately vague 404; a connection failure becomes an ApiError the poster module rethrows, so an outage is never reported as "that image is no longer in storage".
A Drafter approval creates only the drafts the reviewer kept, and the discarded ones leave no tracecd apps/api && npx vitest run src/test/content-draft.test.ts2026-09-17✅ 21/21. Three drafts get ids d1–d3; approving with d2 declined creates exactly two posts, the detail reads "1 declined by the approver", and the database holds captions A and C only. Sending no verdicts creates all three, unchanged. A draft whose id no verdict mentions is created rather than dropped. Confirmed live against the running API: four drafts, two kept, two declined — Created 2 draft post(s), 2 declined by the approver, 1 with an image attached.
The image a reviewer attaches lands on that draft's post and no othercd apps/api && npx vitest run src/test/content-draft.test.ts2026-09-17✅ 21/21. An asset attached to d1 appears on that post's mediaAssets while the post from d2, accepted in the same approval, has none — the picture belongs to the draft it was attached to, not to the batch. The run's apply step logs the per-draft verdicts including how many assets each carried.
The Planner splits a budget by measured return, and the parts sum to the wholecd apps/api && npx vitest run src/test/plan-generation.test.ts2026-09-17✅ 9/9. Ten days of real KPI rows where search converts twice as efficiently as social yields 66.7% / 33.3%, amounts summing exactly to the 300000 requested, and a reason citing the source numbers (40 conversion(s) from 10000 minor units). Steps run load_brief → gathered_evidence → resolve_budget → allocate → propose.
The Planner refuses to invent a budget, and refuses to allocate without evidencecd apps/api && npx vitest run src/test/plan-generation.test.ts2026-09-17✅ 9/9. With no total given and none budgeted, budgetSource: "none", totalMinor: null, shares present and every amountMinor null. With an existing period budget and no total given, it re-allocates that exact sum (existing_budgets, 80000). Spend with zero recorded conversions produces no allocation and the reason "no conversions recorded". A declared channel with no paid role is named in notConsidered, not dropped. Cross-tenant: org A's plan over its own project sees none of org B's spend — zero allocations, zero evidence rows.
Approving a plan writes the period's budget once, and never fakes spendcd apps/api && npx vitest run src/test/plan-generation.test.ts2026-09-17✅ 9/9. Approval writes one ProjectBudget row at 100000 GBP with spentMinor still 0 — that column is metric ingestion's alone. A second plan for the same period upserts to 60000 rather than stacking a second row, so the budget stays a number one can reason about. A shares-only plan writes nothing and says "no total to divide".
The Site auditor judges this project's own crawled pages, and cannot see another tenant'scd apps/api && npx vitest run src/test/site-audit.test.ts2026-09-17✅ 8/8. Two crawled pages yield both an seo and an aeo audit (the default for a click with no options), pagesAudited: 2, findings sorted worst-first with the page URL attached, and real SiteAudit rows readable under Growth. input.kind: "seo" runs only that one. Cross-tenant: org A's run over a project with no pages reports pagesAudited: 0 while org B's crawled page sits in the same database unaudited. A run with no project_id fails.
The auditor proposes a crawl rather than performing one, and approving is what fetches the sitecd apps/api && npx vitest run src/test/site-audit.test.ts2026-09-17✅ 8/8. With no crawled pages the run reaches awaiting_approval — not failed — carrying crawlProposed.websiteUrl, and nothing is fetched: zero SiteAudit rows exist afterwards. Approving queues exactly that one URL and reports "Queued 1 page(s)". A run that did find pages applies to nothing and says "an audit reads, it does not write". A project with no website URL gets neither a crawl nor an error, just the instruction to set one.
The approvals screen distinguishes the audit that changes nothing from the one that fetches a sitecd apps/web && npx vitest run src/lib/agent-proposals.test.ts src/lib/agent-roster.test.ts2026-09-17✅ 34/34. A findings proposal renders SEO — 62/100 with per-severity counts, a Worst findings (1 of 8) section labelled CRITICAL · missing_title, and the summary "An audit reads; nothing is changed". A crawl proposal instead reads "Approving crawls https://harbour.example — this agent's only outward action". An unparseable proposal returns null so the caller falls back to raw JSON.
Video renders with no private dependency, and a deployment without a key is visibly a placeholdercd apps/api && npx vitest run src/test/video-provider.test.ts src/test/video-render.test.ts src/test/video-routes.test.ts2026-09-16✅ 43/43. The seam is asserted in BOTH directions — a seam that always claimed "real" would hide the fake, and one that always claimed "fake" would cry wolf until nobody read it. The provider assertion now goes through the port's own name; the previous version reached into a vendor object's private opts.apiKey and broke the moment the adapter was replaced, which is exactly the change a seam exists to make cheap. The absent per-word timings are pinned as [] with the reason written beside them, so the loss is a recorded decision rather than a silent regression. Beyond the suite, a real render was driven end to end with both new adapters — OpenRouter voice, Playwright frames, ffmpeg encode — producing 1080x1080 H.264+AAC at 24fps with narration and a CSS camera move in 4.9 seconds, confirmed by ffprobe. And grep -c npm.pkg.github.com package-lock.json returns 0, which is the check that the CI failure is actually gone rather than merely unobserved.
A camera move on a video segment is delayed by that segment's own start, so it plays where it was placedcd apps/api && npx vitest run src/test/video-render.test.ts2026-09-10✅ 28/28 (7 new). The delay is asserted as a literal — animation: mo0 4.00s linear 3.60s both on a clip sitting behind a 1.6s hook and a 2s card — because it is the entire feature: the runtime seeks animations with the COMPOSITION's clock, not the element's, so without the delay a move is already finished on the frame its segment appears, and every captured frame comes back identical and fully zoomed. That failure produces no error and no red test unless the delay itself is pinned. A carousel is asserted to get one rule per PICTURE (mo0 at 0.00s, mo1 at 3.00s), since a single per-segment rule would leave the second image frozen at the end of the first's move. A segment with no motion emits no @keyframes at all, so every brief written before this renders identically. An unknown motion string containing }</style><script> is dropped rather than written into the stylesheet — a brief is JSON on a row and this value lands in a <style>, which the markup escaper does not cover. A focus outside the frame clamps to the edge and a non-finite one falls back to the middle, rather than failing a render whose voiceover has already been paid for. pan_left is asserted to slide the picture RIGHT, because it is named for the camera and getting it backwards is invisible until somebody watches the clip. Beyond the suite, the real composition was driven through the actual HyperFrames CLI — npx hyperframes snapshot . --at 1.7,5.4,5.7,9.4 --no-end over a buildComposition output carrying a zoom (focus {x:0.22,y:0.62}) then a pan — confirming a full frame at 1.7s, the focus point held under a deep zoom at 5.4s, and the pan travelling between 5.7s and 9.4s. The MP4 leg is verified too, end to end through the product: a 16:9 render made from the Make a video page came back 1920×1080 H.264+AAC at 23.20s, and two frames sampled INSIDE one 5s zoom_in segment (1.9s and 6.4s) differ in scale — the sidebar visible at the start and gone at the end. Sampling inside one segment is the only check that means anything here; comparing frames from two different segments would pass on a video with no camera move at all. Learned the hard way, below.
A code change reaches a rendered video only if the WORKER was restarted after itps -eo pid,lstart,command | grep "tsx src/worker.ts"2026-09-11⚠️ Not a suite — an operational fact that invalidated a verification, recorded so it does not do so twice. The first two videos rendered from the app came back with NO camera moves despite passing tests, a proven composition and a stored brief that carried motion correctly. Cause: the dev worker was started as tsx src/worker.ts — without watch — so it held the code from the moment it launched and silently rendered against a months-old composition.ts. Nothing failed; the output was simply the old look, which is indistinguishable from a feature that does not work. apps/api is tsx watch, so the API picked the change up and the worker did not, and the row's brief looked perfect the whole time. Restarted as npx tsx watch --env-file-if-exists=../../.env src/worker.ts; the same render then produced the moves and grew from 2.4 MB to 13 MB, because moving content costs bits. A green suite says nothing about what the worker is running.
An image resized for a platform keeps what the user pointed at, and a re-crop cannot overwrite the firstcd apps/api && npx vitest run src/test/uploads-resize.test.ts2026-09-10✅ 14/14. focalToPosition is asserted directly because it is where the value is: a focal point near the top of the frame must produce sharp's top anchor, not centre - that single mapping is the difference between a 9:16 crop that keeps a face and one that removes it. Coordinates from outside the image are clamped, since a drag that leaves the element reports negatives and an invalid position string throws inside sharp. Two renders of the same photo at the same size but different anchors produce two distinct object keys, asserted as a set size of 2: keying on the preset alone would make the second silently replace the first and change every URL already handed out. One failing size returns the other two plus a NAMED failure rather than an error. A GIF is refused (415) rather than flattened, an unknown preset is a 422 naming it rather than a quietly dropped file, and an anonymous caller gets 401.
A caption is written to its own channel's limit, and copy over it never reaches an approvercd apps/api && npx vitest run src/test/content-draft.test.ts src/test/channel-rules.test.ts2026-09-17✅ 16/16 + 15/15. A run targeting x and linkedin puts "captionLimit":280 and "captionLimit":3000 in the prompt and carries both back on the proposal. A 400-character X caption is dropped, the retry message reads caption is 400 chars; x allows 280, and the surviving draft is inside the ceiling. The drift guard builds all eight adapters and asserts CHANNEL_CAPTION_LIMITS equals each one's capabilities.maxCaptionLength — change a limit in an adapter and forget the map and it fails.
The Drafter writes for this brand, screens its own vocabulary, and proposes a time the project actually posts atcd apps/api && npx vitest run src/test/content-draft.test.ts2026-09-17✅ 16/16. A project named "Harbour Bikes" in "Cycling" has its name, description and industry in the prompt. A caption containing Unlock fails the run with banned_word — previously it would have shipped. With two posting slots configured, two drafts get two different times in chronological order; with none, proposedPublishAt is null and channelsWithoutSlots names the channel rather than a time being invented.
The Ideator is shown the posts it is told not to repeat, and never another tenant'scd apps/api && npx vitest run src/test/marketing-ideate.test.ts2026-09-17✅ 24/24. A project with one published post and one draft puts ALREADY WRITTEN, the welding-jig caption and the webinars draft in the prompt, and records alreadyWrittenCount: 2 on the load_context step. An empty studio says "no published or drafted posts yet" instead. A post seeded on org A's project is absent from org B's prompt.
The approvals screen shows a caption against its ceiling and the suggested time in the reader's localecd apps/web && npx vitest run src/lib/agent-proposals.test.ts2026-09-17✅ 25/25. A draft renders 20 / 280 characters and a localised Suggested time rather than a raw 2026-09-22T08:00:00.000Z. A draft approved before these fields existed omits both rows rather than rendering empty ones.
A brief becomes a stored picture, and a headline lands on it in the real typefacecd apps/api && npx vitest run src/test/ai-image-provider.test.ts src/test/ai-images.test.ts src/test/ai-posters.test.ts src/test/poster.test.ts src/test/agents-content-image.test.ts2026-09-16✅ 69/69. The provider seam is asserted through a stubbed fetch because an image call is billed — a suite that could reach the real one bills whoever runs it. Both wire protocols are pinned: OpenAI's /images/generations, and OpenRouter's chat-completions-with-modalities, which returns data URLs and takes no n, so several variants means several calls made in sequence — and images already drawn are kept when a later one fails, because each was paid for. Cost is pinned in MINOR units: 2 images at $0.04 must report 8, not 80000, since the approvals screen divides by 100 and would otherwise print a four-cent batch as four hundred dollars. An unpriced model reports null, never 0. For the compositor, the claim that matters is that the TEXT LANDED — a missing font renders blank, which a format check misses — so a render is decoded and its dark pixels counted. Beyond the suite, the font substitution was measured directly: the same string in Big Shoulders and in Helvetica produced byte-identical ink until type was converted to paths. The live chain was run end to end through the API — brief → artwork → poster → served URL — and the fitted-vs-cropped claim measured: with a marker down the source's left edge, the fitted render keeps 40,732 of its pixels and the crop keeps 0.
A project's TikTok connect flow goes through that project's own TikTok app, end to endcurl -s -H "Authorization: Bearer $TOKEN" "http://localhost:4000/api/v1/channels?project_id=$PROJECT" then curl -s -X POST -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -d "{\"project_id\":\"$PROJECT\",\"redirect_after\":\"/projects\"}" http://localhost:4000/api/v1/oauth/tiktok/start2026-09-16✅ Run against a live API with a real TikTok app installed on one project. /channels returns tiktok with credential_source: "project" — resolved from the channel_credentials row, not env, which is the load-bearing part: bootstrap.ts had not registered TikTok at boot ([api] channel adapters registered: facebook, instagram, linkedin, x), so a registry-only path would have 404'd. resolveProvider builds the adapter from the stored row before ever consulting the boot registry, so BYO credentials work independently of env. /oauth/tiktok/start then returns a www.tiktok.com/v2/auth/authorize/ URL carrying that project's client_key, scope=user.info.basic,video.upload,video.publish, and a state JWT whose payload is {projectId, adapterKey, userId, aud: "channel-oauth"} with a 300s expiry — projectId, no organizationId, which is the F-142 scope assertion made concrete. The wire param is client_key, not client_id, as TikTok's Login Kit for Business requires. Not covered: the callback leg, which needs TikTok to issue a real code — and TikTok is expected to reject the http://localhost:4000 redirect URI, so a tunnel with a matching PUBLIC_API_URL is required before that half can be exercised at all.
Opening a browser autofill dropdown no longer crashes the pagecd apps/web && npx tsc --noEmit && npx eslint src/components/search/search-palette.tsx2026-09-16⚠️ 0 type errors, 0 lint errors — but that command does not prove the fix, and this row says so rather than implying otherwise. e.key?.toLowerCase() cannot throw where e.key.toLowerCase() did, which is the whole change; the behaviour was confirmed by the reported repro (Chrome autofill on Project → Integrations → Platform apps → Cannot read properties of undefined (reading 'toLowerCase') at search-palette.tsx:65). There is no automated regression test because apps/web has jsdom and vitest but no @testing-library/react, so a hook with a useEffect cannot be rendered by the existing harness. Making this testable means extracting the predicate into a pure isSearchHotkey(e) — the pattern lib/social-connect.ts already uses precisely so its logic tests without a React runtime — and is the follow-up worth doing before anyone else edits this handler.
The Optimiser proposes real changes from real spend, and cannot see another tenant's campaignscd apps/api && npx vitest run src/test/campaign-optimisation.test.ts2026-09-16✅ 10/10. Eight days of snapshots whose CPA doubles yields one pause_campaign proposal, linked to the run via agentRunId, left in draft, with the step log carrying gathered_campaigns → ran_heuristics → propose. Cross-tenant: org A's run over a project with no campaigns reports campaignsConsidered: 0 while org B's doubling-CPA campaign sits in the same database untouched — no row is written against it. A draft campaign is ignored even with qualifying metrics. A run with no project_id fails rather than sweeping every campaign.
The Optimiser does not duplicate the nightly tick, and does not rewrite decisions people have already takencd apps/api && npx vitest run src/test/campaign-optimisation.test.ts2026-09-16✅ 10/10. With an unlinked draft the tick wrote today, the run adopts it — exactly one row for the campaign afterwards, now carrying agentRunId, and the proposal reports adopted: true. With a proposal already in proposed, the run leaves its status and null agentRunId untouched and reports no recommendation, rather than dragging a submitted proposal back into its own batch.
Approving an Optimiser run submits for ads review and changes nothing on any ad platformcd apps/api && npx vitest run src/test/campaign-optimisation.test.ts2026-09-16✅ 10/10. applyApprovedRun moves the run's drafts to proposed with submittedByUserId set and appliedAt still null — applied is the ads flow's own gate and is not reachable from an agent approval. A run that proposed nothing applies as a no-op reporting "nothing was changed" rather than erroring.
The approvals screen shows what the Optimiser wants changed, and is explicit that approving is not spendingcd apps/web && npx vitest run src/lib/agent-proposals.test.ts src/lib/agent-roster.test.ts2026-09-16✅ 34/34. A proposal renders one block per change labelled Pause campaign — Spring Sale (the enum is the wire format, the approver reads a label), with platform, rationale and confidence as a percentage, under a summary reading "No budget or bid changes on any platform yet". A run with no recommendations renders one section and says nothing is changed. An unparseable proposal returns null so the caller falls back to raw JSON rather than showing a confidently empty approval.
The sender-verification loop actually closes: the emailed link resolves to a real page, the public confirm endpoint returns the fields that page renders, and /verify-sender is in the build's route manifestcd apps/api && npx vitest run src/test/project-email-sender.test.ts && cd ../web && npx vitest run "src/app/(auth)/verify-sender" && npm run build --workspace @verjson/web2026-09-15✅ 26/26 + 3/3 + build lists ○ /verify-sender. The API suite was already green on the token mechanics — hashed, single-use, 24-hour, 400-on-anything-wrong — so what was missing was never the verification logic but its destination: a repo-wide grep found verify-sender in exactly one place, the link construction itself, and no route answered it. The new API assertion pins the response body whole (verified, from_email, project_id) rather than just verified, because the other two fields have no in-API consumer — nothing but this page reads them, so a rename would pass every existing test and surface as "undefined is confirmed" above a link to /projects/undefined/integrations, in a client's shared inbox. The web test pins the branch that cannot be caught by eye: a 200 carrying verified: false must render as failure, since "your address is confirmed" is the costliest thing on this page to get wrong. Note the API suite deadlocks (40P01) against a test database another process is holding open — that is the shared-database contention fileParallelism: false exists to avoid, not a fault in these tests; it passes 26/26 on a quiet database.
A project's Resend key never leaves the server, one project never transmits through another's account, and an owner of another organization cannot write eithercd apps/api && MOCK_ADS_MODE=0 npx vitest run src/test/project-resend-account.test.ts src/test/project-integrations.test.ts && cd ../web && npx vitest run src/lib/project-resend-account.test.ts2026-09-15✅ 19/19 + 26/26 + 11/11. Key secrecy is asserted on all four surfaces it could leak from — the connect response, the read, the test result and the audit row (the one most likely to leak by accident: written on every change, read by people who are not the key's owner, retained forever) — and the stored value is asserted to be v1. ciphertext that still round-trips, so the encryption is real rather than a one-way mangling. Isolation is asserted on the credential actually handed to nodemailer, not merely on two rows existing: two projects in the SAME organization produce two transports carrying two different keys, and a project with no account never reaches the other's cache. Rotation is pinned because transports are pooled — a stale entry would keep using the credential somebody was revoking. This suite found a live cross-tenant write: assertCanManageProjectIntegrations returned early for any org-wide role without checking the project's organization, so an owner of Org B could write Org A's integrations. The regression test was validated by reverting the fix and watching it fail (expected 201 to be 404) before restoring it, and it exercises the Slack, webhook, credential and Resend writes separately because the bug was that routes differed in whether a later get() happened to save them.
SMTP credentials never reach a project, and an unverified sender address cannot sendcd apps/api && MOCK_ADS_MODE=0 npx vitest run src/test/project-email-sender.test.ts && cd ../web && npx vitest run src/lib/project-email-sender.test.ts2026-09-15✅ 25/25 + 9/9. The boundary is asserted from both directions: a PUT carrying smtp_host / smtp_user / smtp_pass is a 422 (the schema is strictObject, so this is a rejection rather than a silent strip — the difference between an honest refusal and a route that looks like it accepted credentials), and no read contains any of those keys. The send gate is pinned on the live dispatch AND the test send, with the mock asserting that nothing went out in both cases rather than only that the status was 422 — a gate that refuses but still sends is the failure mode worth catching. Verification is single-use (a replayed token 400s), expires, and a bad token returns a flat refusal containing no @ at all, since the endpoint is unauthenticated by necessity and would otherwise be an oracle for which addresses are registered. Changing the address clears verified_at; changing only the display name or reply-to keeps it; and re-saving the same mailbox in different casing does NOT count as a change, which is the bug lower-casing exists to prevent. The From header is fuzzed with a name containing ", \r\n and a second address — the classic header-injection payload — and asserted to end at the real address with no CR/LF surviving. One test file, one run, no flakes: formatFromHeader and senderStatus are pure and tested directly, so the states that cannot be reached through the API (verified row holding a stale token) are still covered.
One project's integrations, events and credentials never reach another project, and a project event is delivered exactly once however many people it notifiedcd apps/api && MOCK_ADS_MODE=0 npx vitest run src/test/project-integrations.test.ts src/test/channel-credentials.test.ts src/test/ads-credentials.test.ts src/test/analytics-credentials.test.ts src/test/webhooks.test.ts && cd ../web && npx vitest run src/lib/project-integrations.test.ts2026-09-14✅ 25/25 + 20/20 + 6/6 + 12/12 + 33/33 + 8/8. Every isolation case puts both projects in the same organization, because the old org-level scope would have passed a cross-tenant version by accident — that is the sharing this removed. Sibling projects resolve different Meta apps, different Google apps, different ad apps (that one matters most: ad credentials authorize SPEND, so a leak bills one client's campaigns to another's account) and different inbound webhook URLs, since the callback token is minted per credential row — a shared URL would land client A's comments in client B's inbox carrying a valid signature, undetectable downstream. Delivery is asserted once per event three ways: a sequential loop, five concurrent emitters racing the unique index, and a repeat call. Both the Slack URL and the webhook signing secret are asserted absent from every read (JSON.stringify(response) searched for the raw value), with the secret returned exactly once on create. The permission split is pinned in both directions — a contributor reads the integration and gets can_manage: false, and is refused 403 on write; a lead succeeds; another organization gets 404 on read AND write, never 403. emitProjectEvent is asserted to RESOLVE when every destination throws, because its call sites are publishes and approvals and none of them may fail over a renamed Slack channel. One real bug was caught writing these: a first-time Slack connect that Slack rejected stored the row with no failure reason, so the page showed an unverified integration and could not say why.
The composer applies the same platform rules whichever screen opened it, and a disconnected account gets no votecd apps/web && npx vitest run src/lib/composer-targets.test.ts2026-09-14✅ 10/10. The regression is pinned directly: a project with a live LinkedIn account and a stale disconnected Instagram row must offer a document, and must reach the same answer whether the caller pre-filtered its accounts (Content) or did not (the calendar). Both were asserted to FAIL against the old unfiltered behaviour before the fix was kept — without that check the test proves only that the current code agrees with itself. The exemption is pinned too: an account carried by the post being edited survives the filter once disconnected, since dropping it would strand a publishing variant with no way to untick it. Two accounts on one platform de-duplicate to a single destination, because pdfAllowedFor is a length check and a duplicate would read as a second platform.
A post carries one strategic bucket, the filter can find what is still unclassified, and the mix report measures the plan against every post in the projectcd apps/api && npx vitest run src/test/content-buckets.test.ts && cd ../web && npx vitest run src/lib/content-buckets.test.ts src/lib/content-stages.test.ts2026-09-14✅ 15/15 + 17/17. The two silent failures are pinned first. An unknown ?content_bucket= is a 400, asserted, because a dropped filter answers with the FULL list and the caller renders that as "every post is in this bucket" — a wrong answer wearing the shape of a right one. And unassigned is asserted as a real selection rather than a fall-through to "match everything": on a project mid-adoption that difference is between "four posts still need classifying" and the entire project. Re-bucketing is proved content-NEUTRAL by counting PostRevision rows across a PATCH — if it counted as content, a planner tidying the mix would invalidate every approval they touched, and the only person who would notice is the one whose sign-off disappeared. The target map is asserted to REPLACE rather than merge, because a merge leaves a cleared box silently holding its old number; a map totalling over 100% is 422. The mix keeps unclassified posts in the denominator (one of two posts unassigned reports 50%, not a tidy 100% over the one that was classified), emits all six buckets even at zero so the report reads as a whole mix, adds the bucket: null row only when it is non-empty, and 404s on another organization's project rather than returning an empty mix that would confirm the id. On the web side every bucket is asserted to have a distinct series colour — two bars sharing one would make the chart unreadable at the one place the colour is load-bearing — and the unassigned row is asserted to take muted ink rather than a seventh hue.
An image resized for a platform keeps the whole picture unless a crop is asked for, keeps what the user pointed at when one is, and a re-render cannot overwrite the firstcd apps/api && npx vitest run src/test/uploads-resize.test.ts2026-09-14✅ 19/19 (5 new). The default fit is asserted to reach sharp as inside, which is the one call that cannot discard anything: it scales until the image fits WITHIN both dimensions. The cover pass beside it is the blurred backdrop, not the picture, and the two are told apart by their fit rather than their dimensions — which are identical. A crop is asserted to be exactly ONE pass, so asking for one does not quietly pay for a blur it throws away. An unknown fit is a 422 naming it, since a silent fallback would hand someone who asked for a crop a padded image and the difference is only visible by opening every file. fit and crop of the same photo at the same size produce keys ending fit-centre.jpg and crop-centre.jpg, so neither replaces the other. Each item carries key, content_type and kind, because the composer attaches the result straight to the post and a bare URL would have to be parsed back into a key. Beyond the suite — the mock proves orchestration, not that sharp accepts the chain — the real blur → modulate → composite pipeline was run against real bytes: a 1600×900 source became a 1080×1920 JPEG whose foreground is 1080×608, the source aspect exactly. With a marker down the source's left edge, the padded render keeps 40,732 of its pixels and the crop keeps 0 — which is the whole complaint, measured. The earlier assertions still hold: focalToPosition maps a focal point near the top to sharp's top anchor rather than centre (the difference between a 9:16 crop that keeps a face and one that removes it), out-of-image coordinates clamp, one failing size returns the others plus a NAMED failure, a GIF is refused (415) rather than flattened, an unknown preset is a 422, and an anonymous caller gets 401.
A member added to one project sees that project and no other, and cannot write the ones they cannot seecd apps/api && npx vitest run src/test/projects.test.ts2026-09-09✅ 35/35 (5 new). A member with no membership row gets an EMPTY project list and 404 on a direct id - 404 rather than 403, because a 403 confirms the project exists and an invisible project must not do that. Given a membership row, the same id returns 200. A PATCH against a project they are not on also 404s: read and write scope are asserted together, because a project you cannot open must not be one you can rename. The creator case is covered separately - a member creating a project can open it immediately, and carries a lead row written inside the create transaction; without it the project would vanish on the redirect. Owner and admin still see every project with zero membership rows, asserted so the consolidation cannot quietly narrow them too. The whole API suite was re-run after the change: one test in teams.test.ts had to move, and its name said why - "an internal member keeps the access their ORG ROLE already gave them" was asserting the behaviour being removed. Rewritten to grant the access through a ProjectMember row before joining the team, so the case still pins what it was always about: joining a team must not narrow what someone already had.
The analytics module enforces project membership, not just the organizationcd apps/api && npx vitest run src/test/analytics-routes.test.ts2026-09-09✅ 22/22 (5 new). An agency account in the SAME organization, a member of one project and not the other, is excluded from the second - the case the module could not previously get right, because its scope helper took a bare org id and never saw the user. A member of the project still passes, and a cross-tenant id 404s.
A dashboard fed by fabricated numbers says so on screencd apps/api && npx vitest run src/test/analytics-routes.test.ts2026-09-09✅ 22/22. A GA4 adapter built with EMPTY credentials - the production state nobody opts into - reports is_mock: true on its connection, and the connections screen shows the row as sample data rather than a healthy green connection. Real credentials report is_mock: false. The flag is asserted in both directions because the failure modes are opposite: a serializer defaulting to true would cry wolf, one defaulting to false on a mock would hide exactly what this exists to reveal.
An invitation scoped to one project grants that project and hides the rest, and a scope that would grant nothing is refusedcd apps/api && npx vitest run src/test/invitations.test.ts2026-09-09✅ 23/23 (4 new). All three org-wide roles - owner, admin, member - are refused with a project_id, asserted in a loop so adding a fourth org-wide role without revisiting this is a failing test rather than a silent hole. An accepted agency invite lands exactly ONE membership, on the invited project, carrying the chosen lead rather than the contributor the accept path used to hardcode; a scope query then returns only that project and provably not the other one in the same org. A workspace-wide invite stores project_role as null rather than the schema default, since there is no project for it to be a role on. The pre-existing cross-tenant case had to move from member to agency: the role rule now runs first, so with member it would 422 on the role and never reach the 404 that case exists to prove.
A truncated breakdown is never given an invented denominator, and audience history cannot grow without bound or blank a panelcd apps/api && npx vitest run src/test/social-account-analytics.test.ts && npx vitest run src/test/metrics.test.ts -t audience2026-09-09✅ 49/49 + 2/2. Rows with a null total are reachable in production - Facebook answers page_fans_country in responses that carry no page_fans - and the fallback then depends entirely on the dimension. Age and gender PARTITION the audience, so the bucket sum is the total and is used: 60/40 with no stored total reports 60%. Country and city are TRUNCATED, so the sum is a smaller number masquerading as the whole; 300 and 200 with no stored total returns an EMPTY list and a reason, not India at 60% of a base it is 30% of. The retention trim keeps the newest collection per (account, dimension) whatever its age - two 2020 collections leave exactly one, the newer - so an account that stopped polling still renders its last breakdown instead of going blank, and grouping is per dimension so a three-dimension collection survives intact rather than losing two of them.
A comment in the inbox shows the post it is on, with its real picture — a studio post costs no API call, and a change to the rules cannot be masked by an old cachecd apps/api && npx vitest run src/test/inbox-post-context.test.ts2026-09-09✅ 11/11. The ORDER of the two sources is asserted directly: a PlatformPost whose platformPostId equals the comment's parentExternalId resolves with mock.callCount === 0, and platformCaption is asserted over the base caption — asking Instagram for a caption we wrote ourselves would spend a rate-limited call to be told something we already know, in the platform's words rather than the author's. The thumbnail follows the same rule as every other screen: variant platformMedia over post mediaAssets, both asserted, both still zero calls; a studio post with neither borrows only the picture and keeps our caption, asserted together because the point is that the text is not overwritten. The regression that actually shipped is pinned as its own case: an item seeded with a context in the pre-versioning shape (no v, thumbnail_url: null) must be ignored, re-resolved, and re-stamped with v: 2 — the failure mode was a correct fix that no one could see, because a cache hit meant the new code never ran. The version never reaches a client. A DM answers 200 null; a platform refusal answers 200 with a reason and the post's external id and is deliberately NOT cached, since the usual cause is a token since reconnected; cross-tenant id 404s.
A delivery Meta actually sent can no longer vanish without a tracecd apps/api && npx vitest run src/test/webhooks.test.ts2026-09-09✅ 32/32. The account lookup matched only accountPlatformId, but the Instagram subscription is made against the PAGE node, so Meta may key a delivery by the Page id while the connection is stored under the IG User id — and the mismatch returned null in silence. A new case seeds the account under ig-keyed-by-page, puts the Page id in provider_meta, sends a comment whose entry.id is the Page id, and requires processed: 1 and the item on the right account. Both remaining silent paths now log once at warn: an entry id that matches no managed account prints that id, and a batch that verifies and parses but writes nothing prints its event count. Neither is an error — a shared app legitimately receives other tenants' events — but until now "we dropped it" and "Meta never sent it" were the same observation from outside: an empty inbox and a 200 that Meta's dashboard reports as a successful delivery.
Threads' demographics call is Threads', not a copy of Instagram's - and the insights scope is actually requestedcd apps/api && npx vitest run src/test/adapter-threads.test.ts2026-09-09✅ 28/28 (6 new). The load-bearing assertion is a NEGATIVE one: every /me/threads_insights URL must contain metric=follower_demographics and must NOT contain period=, timeframe= or metric_type=. Instagram's follower_demographics requires all three; Threads' takes none and rejects since/until outright, so a shared implementation would have 400'd every call. All four breakdowns are requested separately, the 100-follower floor stops the run after one HTTP call, one failing breakdown keeps the other three, and a 401 on the follower-count read surfaces as token_revoked rather than an empty audience - that read is the one that MUST succeed, since it supplies both the denominator and the floor check. A separate case asserts threads_manage_insights appears on the authorize dialog: without it every breakdown returns empty, and the scope is useless if never asked for.
Facebook reports where fans are, never who they are, and the age/gender panels say the metric was removed rather than showing a shapecd apps/api && npx vitest run src/test/adapter-facebook.test.ts src/test/social-account-analytics.test.ts2026-09-09✅ 28/28 + 47/47 (4 + 4 new). The adapter's returned dimensions are asserted as EXACTLY {country, city} - Meta removed page_fans_gender_age, and inventing those dimensions would defeat the empty state that exists to report the removal. One HTTP call carries all three metrics, on period=day (v3.2 moved page_fans_* off lifetime, which now returns an empty dataset indistinguishable from a Page with no fans) and on the PAGE token, asserted by requiring PAGE-TOKEN and the absence of USER-TOKEN. A two-element series returns the LAST value, not the first - reading values[0] would report a fan base up to three days stale. On the route, Facebook age/gender answers "no longer reports" while Facebook country answers "refreshed once a day": the first is a platform limit and the second is a pending collection, and a single platform-keyed message could not tell them apart.
A broadcast can be aimed at typed addresses, and an address that unsubscribed is still not mailedcd apps/api && npx vitest run src/test/email-broadcast-custom-recipients.test.ts2026-09-09✅ 16/16. The load-bearing cases are the suppression ones: a typed address matching an unsubscribed contact is dropped, and an all-suppressed list 422s with no mail sent at all. Also pins server-side normalisation, the segment/typed exclusivity, the cap, and that the audit row records the COUNT of addresses and never the addresses.
The composer accepts, de-duplicates and rejects typed addresses the same way the server doescd apps/web && npx vitest run src/lib/email-composer.test.ts2026-09-09✅ 51/51. Valid add, invalid rejected by name, duplicate prevented across case and padding, removal, the empty-list send block, and that only the unconsumed text is handed back to the field. A segment draft is asserted never to be blocked for having no typed addresses.
A comment in the inbox shows the post it is on, with its real picture — a studio post costs no API call, and a change to the rules cannot be masked by an old cachecd apps/api && npx vitest run src/test/inbox-post-context.test.ts2026-09-09✅ 11/11. The ORDER of the two sources is asserted directly: a PlatformPost whose platformPostId equals the comment's parentExternalId resolves with mock.callCount === 0, and platformCaption is asserted over the base caption — asking Instagram for a caption we wrote ourselves would spend a rate-limited call to be told something we already know, in the platform's words rather than the author's. The thumbnail follows the same rule as every other screen: variant platformMedia over post mediaAssets, both asserted, both still zero calls; a studio post with neither borrows only the picture and keeps our caption, asserted together because the point is that the text is not overwritten. The regression that actually shipped is pinned as its own case: an item seeded with a context in the pre-versioning shape (no v, thumbnail_url: null) must be ignored, re-resolved, and re-stamped with v: 2 — the failure mode was a correct fix that no one could see, because a cache hit meant the new code never ran. The version never reaches a client. A DM answers 200 null; a platform refusal answers 200 with a reason and the post's external id and is deliberately NOT cached, since the usual cause is a token since reconnected; cross-tenant id 404s.
A delivery Meta actually sent can no longer vanish without a tracecd apps/api && npx vitest run src/test/webhooks.test.ts2026-09-09✅ 32/32. The account lookup matched only accountPlatformId, but the Instagram subscription is made against the PAGE node, so Meta may key a delivery by the Page id while the connection is stored under the IG User id — and the mismatch returned null in silence. A new case seeds the account under ig-keyed-by-page, puts the Page id in provider_meta, sends a comment whose entry.id is the Page id, and requires processed: 1 and the item on the right account. Both remaining silent paths now log once at warn: an entry id that matches no managed account prints that id, and a batch that verifies and parses but writes nothing prints its event count. Neither is an error — a shared app legitimately receives other tenants' events — but until now "we dropped it" and "Meta never sent it" were the same observation from outside: an empty inbox and a 200 that Meta's dashboard reports as a successful delivery.
A post composed as an Instagram story reaches the adapter as a story — not as whatever its media makes itcd apps/api && npx vitest run src/test/publish-post-type.test.ts2026-09-09✅ 8/8. Asserts on the content the ADAPTER receives, not on the row: the declaration only matters if it survives the dispatcher. Covers the video story (the case media-derivation gets most wrong — it reads "video" and builds a REELS container), the undeclared fallback, and an unrecognised value dispatching anyway rather than failing the publish.
The Stories tab composes a story, and says so on the button it offerscd apps/web && npx vitest run src/lib/instagram-hub.test.ts2026-09-09✅ 33/33. The label and the composed type are one decision returned together, so a tab cannot again offer "Create post" and then filter for stories it never produced.
One post's whole life is readable from that post — including rows filed against its channel variants, its approval, and its queue entries — and never another tenant'scd apps/api && npx vitest run src/test/audit-post-timeline.test.ts2026-09-09✅ 10/10. Covers the four entity shapes the trail spreads a post across, a second post in the same project staying out, a post id from another org returning [] at 200 rather than 403 or someone else's rows, newest-first cursor paging, and project_id + post_id in one query ANDing instead of one silently overwriting the other.
An activity row that is about a post links to that post, a row that is not shows no link, and a deleted post never produces a broken URLcd apps/web && npx vitest run src/lib/post-activity.test.ts2026-09-09✅ 20/20. The load-bearing case is platform_post: its entity_id is a channel variant, and linking to it as though it were a post id is a 404. Also pins refusal for agent_run / campaign / project_member / social_account, for an id absent from the project's posts, and for a post in another project.
An Instagram comment in the shape Meta actually sends reaches the Inbox — and a Facebook edit/delete still does notcd apps/api && npx vitest run src/test/webhooks.test.ts2026-09-09✅ 31/31. Four new cases built on the real IG comments payload — { from: {id, username}, media: {id}, id, text }, with no verb, item, comment_id or message. The regression is asserted on processed, not just on status: it was 0 before the fix while the endpoint still answered 200, which is why nothing ever surfaced. The id comes from value.id, the body from value.text, and author_handle is the handle (reader_99) rather than the numeric IGSID, because the notification and AI-reply paths render it as @handle. A top-level comment threads under its media id, a reply under its parent_id. Two comments in one delivery produce two distinct rows — they collapsed onto one synthetic ${entryId}-${field} id before, since comment_id is absent from every IG payload. The Facebook verb: "remove" guard is asserted separately and still drops the event, so normalising the two shapes did not turn edits and deletes into new inbox items.
A connected Meta account is actually subscribed to its webhooks — and a wrong-edge guess is reported rather than swallowedcd apps/api && npx vitest run src/test/channels-adapters.test.ts2026-09-09✅ 47/47. Two production round trips are pinned here as cases, because each wrong edge failed in a way that looked like the others: graph.instagram.com/{page-id} answered 190 Cannot parse access token (it wants an Instagram User token; a Facebook-Login connection holds only a Page token) and graph.facebook.com/{ig-user-id} answered (#3) Application does not have the capability (subscribed_apps is not an edge of that node). The suite now asserts the Page node on graph.facebook.com is tried first and, on success, calls has length 1 so no second subscription is made; that a rejected subscribed_fields falls back to a plain feed install; that the graph.instagram.com form is reached only third; and that when every attempt fails the thrown message contains both host names — asserted by regex, because the first production failure said only 400 and cost a round trip to attribute. target records host, id and fields, so the accepted edge is data rather than folklore. The user token is passed throughout as "user-token-must-not-be-used", so leaking it onto any edge fails the suite.
An idea is targeted at the platforms this brand actually publishes on, not at a constantcd apps/api && npx vitest run src/test/marketing-ideate.test.ts2026-09-08✅ 21/21. A project with connected Instagram + TikTok accounts, started the way the Agents page starts one ({ scoutRunId }, no channels key), yields channels: ["instagram", "tiktok"], channelSource: "project", and a prompt containing instagram and not linkedin. An explicit ["twitter"] wins over the project's accounts and normalises to ["x"] rather than being dropped. A Pinterest-only project keeps pinterest — the platform the hand-written allow-list omitted, which used to send it to the LinkedIn/X fallback. With no connected accounts the run still works but its load_scout step records channelSource: "fallback" and says "no connected accounts", so a run whose channels nobody chose is visible as one.
Instagram audience demographics are fetched from the platform, not fabricated — and the four panels can never silently fall back to sample shapescd apps/api && npx vitest run src/test/channels-adapters.test.ts2026-09-08✅ 44/44. Six new cases pin the contract with Meta: four one-dimensional calls (never breakdown=age,gender, which cross-tabs into results keyed by both and answers a different question than the panel asks), each carrying metric=follower_demographics, period=lifetime, metric_type=total_value and a pinned timeframe=last_30_days — pinned because Meta requires the param and it selects the follower cohort, not a query range, so a dashboard's date picker must not be able to change what the number means. Every call goes out on the PAGE token, asserted by requiring access_token=PAGE-TOKEN and the ABSENCE of USER-TOKEN on all five URLs. The 100-follower floor is enforced BEFORE the insights calls (one HTTP call total, not five) so an empty result provably means "too few followers" rather than "the request failed". One breakdown 400ing keeps the other three. Unlabelled and value-less buckets are dropped rather than rendered as blank meter rows or invented as "Unknown".
A percentage on an audience panel is a share of real followers, and a truncated breakdown is never renormalised to claim full coveragecd apps/api && npx vitest run src/test/social-account-analytics.test.ts2026-09-08✅ 43/43. Seven route cases. Two buckets of 300 and 200 against a stored total of 1000 sum to 50%, not 100% — Instagram returns only the top 45 countries, so scaling the visible slices up would overstate every one of them and hide that the tail is missing. The endpoint serves one whole collection: seeding a 3-bucket day and then a 2-bucket day returns exactly the newer two, proving it reads "every row at the newest fetchedAt" rather than "the newest N rows", which would splice a dropped-out country back in. An empty breakdown returns unavailable_reason rather than a bare [], because "never collected", "platform won't say" and "under the follower floor" are three different answers. Cross-tenant id 404s; an unknown dimension 400s.
The audience poll writes coherent breakdowns, treats "this platform won't say" as normal, and is safe to retrycd apps/api && npx vitest run src/test/metrics.test.ts -t runAudienceDemographicsPoll2026-09-08✅ 5/5. Every bucket of one pass shares a single fetchedAt (asserted as a set of size 1), so a reader can never be handed half of this poll and half of the last. An adapter missing the OPTIONAL method counts unsupported, not failed — today that is every adapter but Instagram, and logging a line per account per tick for an expected state would drown real errors. An adapter returning [] still counts as polled, keeping "collected, and it was empty" distinct from "never collected". token_revoked disconnects the account and records the error. A re-run at the same instant writes one row, not two. ⚠️ The notify-on-disconnect assertion is deliberately NOT duplicated here: its sibling in runAccountMetricsPoll is red on main — emitNotification leaves DB work in flight past the end of its test, so the next beforeEach TRUNCATE races it. Verified pre-existing by stashing this branch's test edits and re-running that case alone: it fails identically.
The Scout counts a post that production actually published — i.e. one whose parent Post.publishedAt is NULL and whose variant carries the timestampcd apps/api && npx vitest run src/test/marketing-scout.test.ts2026-09-08✅ 9/9. The regression guard asserts parent.publishedAt === null first, then requires basis: "published" and postsAnalysed: 1 — so pointing the query back at the parent column fails the suite. The fixture builds the real chain (social account → post → published variant), not the state the old test fabricated. Engagement reaches the model: a post with 4200 impressions / 130 likes / 17 comments puts 4200 impressions and 147 engagements in the prompt. The brief reaches it too — a project named "Reef Coffee Roasters" in "Speciality coffee" has its name, description, industry, website and channels in the prompt text.
A studio with a month of unpublished work gets a review of its pipeline, not cold-start boilerplatecd apps/api && npx vitest run src/test/marketing-scout.test.ts2026-09-08✅ 9/9. Two drafts and zero published posts yield basis: "pipeline", postsAnalysed: 2, and a real model call whose prompt contains NOTHING HAS BEEN PUBLISHED YET plus the draft text. Cold start fires only with neither published posts nor drafts, calls no model (expect(provider.chat).not.toHaveBeenCalled()), and still names the project — "Ridgeline Cycles" and "Cycling" appear in its summary.
The Ideator writes from this project's own history, and one tenant's rejection notes never reach another's promptcd apps/api && npx vitest run src/test/marketing-ideate.test.ts2026-09-08✅ 17/17. With a measured post in the project, the prompt contains the brand name, its description, its own top post, and the heading WHAT HAS WORKED FOR THIS BRAND — and asserts the invented placeholders (onboarding checklist, shipping on Fridays) are absent. A prior rejection note appears verbatim under WHAT A HUMAN ON THIS PROJECT HAS REJECTED BEFORE. With no history the fallbacks return, labelled generic craft references / NOT this brand's posts. Cross-tenant: a note seeded on org A's project is absent from org B's prompt.
Every model call records what it consumed and what it cost, and an unpriced model reports "unknown" rather than "free"cd apps/api && npx vitest run src/test/ai-pricing.test.ts src/test/marketing-scout.test.ts src/test/marketing-ideate.test.ts src/test/content-draft.test.ts2026-09-08✅ 8/8 + 9/9 + 17/17. 1000 prompt + 500 completion tokens on openai/gpt-4o-mini lands costMicroUsd: 450 on the llm_call step (1000×$0.15/M + 500×$0.60/M = $0.00045). Cost accrues across the Ideator's corrective retry: two billed calls report attemptsUsed: 2, totalTokens: 3000, totalCostMicroUsd: 900 — not the price of one. An unknown model slug and a provider that omits its usage block both return null; only provider: "mock" returns 0. Rounding is upward, so a 1-token call costs 1 micro-USD rather than 0. All three agents in the chain report through the same recordLlmUsage, including the Drafter (10/10) — the most expensive call at 2400 output tokens, and the one whose omission would have made a full scout → ideate → draft run unmeasurable at the step that dominates its price.
The approvals screen says what the Scout read, so "found nothing" is distinguishable from "did not look"cd apps/web && npx vitest run src/lib/agent-proposals.test.ts2026-09-08✅ 23/23. A published-basis proposal renders "Analysed 12 published posts across linkedin, instagram — 9 with performance data. 4 posts still unpublished."; a pipeline-basis one says it read 7 unpublished posts and that there is "no performance data" behind it; a published run with zero snapshots says "none had metrics collected yet" rather than implying poor results. A proposal approved before basis existed omits the row entirely instead of guessing.
A platform dashboard shows that platform's own numbers, ratios come from summed parts, and a metric nobody measured never renders as a zerocd apps/web && npx vitest run src/lib/analytics-platform.test.ts2026-09-07✅ 41/41. The null discipline is the half that cannot be seen in a screenshot: two days of impressions: null sum to null, not 0, because (a ?? 0) + (b ?? 0) is what turns "Instagram reported nothing" into a confident "0 impressions" on a card somebody screenshots for a client. A real 0 still reports as 0, and one reported day among nulls makes the total real. platform: null selects the rollup's cross-platform aggregate rather than everything, so the overview reads 1000 where summing the aggregate against its own parts would read 2000. Ratios are pinned on deliberately uneven volumes — a 1000-impression day at 1% and a 100-impression day at 3% give a range CTR of 13/1100 ≈ 1.18%, where averaging the days reports 2%; CPM's denominator is impressions ÷ 1000, so a missing division shows as a 1000× error; a zero denominator returns null, never Infinity or NaN. Blended ROAS divides attributed revenue by spend (3.40×) — the only surface where that division is legitimate, since revenue is attributed per project and day. Impression share divides by the sum of platform rows and never the aggregate (mixing them halves every bar), and a platform reporting zero is dropped rather than drawn as a zero-width bar against its name. ad_campaigns_active takes the peak, not the sum, or a two-day run of three campaigns reports six.
Every illustrative figure in the analytics suite is deterministic, in range, and cannot be mistaken for measurementcd apps/web && npx vitest run src/lib/analytics-sample.test.ts2026-09-07✅ 21/21 across all twelve datasets. Determinism is the load-bearing property: a distribution that reshuffles on every render reads as a live feed, which is exactly the impression the amber Sample data chip exists to prevent — same seed, same bars, asserted for each dataset. Every set sums to 100% within rounding, carries only finite in-range percentages, and two different project seeds produce different bars so no two projects show an identical chart. The platform skews are asserted rather than assumed, because jitter large enough to erase them would make the panels noise rather than representative: TikTok's under-25 share exceeds Facebook's, Pinterest skews female past 60%, X skews male. An unrecognised platform falls back to a known shape instead of rendering an empty card. SAMPLE_NOTICE — the string in both the badge tooltip and the panel footnote — is pinned to still say illustrative and does not ingest audience demographics; if that wording ever drifts, the exception stops being honest.
The analytics restructure ships without regressing the rest of the app, and Reports is gone from the UI onlynpm run typecheck --workspace @verjson/web && npm run lint --workspace @verjson/web && npm run test --workspace @verjson/web && npm run build --workspace @verjson/web2026-09-07✅ typecheck clean, lint 0 errors (17 pre-existing warnings in tracker.js, connect-ads-panel.tsx and api.ts, none introduced), 1016/1016 tests in 64 files, production build compiled. The build output lists all sixteen new routes under /projects/[id]/analytics/{organic,paid,product}/* and no longer lists /analytics/reports. Backend untouched: git status shows changes only under apps/web/, so the kpis/reports module, its endpoints and the hourly scheduled-report worker are still in place and still running — this is a removed reader, not a removed feature. Not verified here: the API suite could not be run on this machine — npm run verify fails at ci:prepare-db with Prisma P3005 because the local app database was created by db push and has no _prisma_migrations table to baseline against. That failure predates this change and is unrelated to it (no API file was modified), but it means the API half of the gate is unproven locally and must pass in CI.
A pivot's ratios are computed from summed parts, tenants cannot see each other's rows, and a breakdown with no data is refused rather than fakedcd apps/api && npx vitest run src/test/kpi-pivot.test.ts · cd apps/web && npx vitest run src/lib/kpi-pivot.test.ts2026-09-04✅ 20/20 + 13/13. The ratio cases are deliberately uneven, because that is the only way the bug shows: a 50%-engagement day and a 1%-engagement day average to 25.5% while the true range figure is 51/1100 ≈ 4.64%, and the same shape pins CTR (100 clicks / 4000 impressions, not the mean of 1% and 3%), CPC, CPA and CPM — CPM's denominator is impressions ÷ 1000, so a missing division shows up as a 1000× error. A zero denominator returns null, never Infinity. The "" cross-platform sentinel row is excluded from platform grouping, which is what stops every platform's share being halved against a doubled total; paid rows are excluded from the social scope; and two projects seeded with the same platform on the same day prove the org scope holds. 0 and null are asserted to survive as different values all the way to the cell. assertSelectable refuses an unavailable breakdown (gender), an unavailable metric (video_views, roas) and a field belonging to the other scope (campaign under social) — the catalogue's key order is asserted whole, so a dropped entry fails rather than silently removing a checkbox. Web side pins the two exits: an em dash for absent data and never a zero, money divided out of minor units exactly once (the 100× spend overstatement), a ratio read as a fraction (0.017 → 1.70%, not 0.02%), and CSV quoting — a campaign called Spring "Big" Sale, 2026 keeps its comma inside one field and doubles its quotes, because an unquoted comma shifts every later column in someone else's spreadsheet.
A design token is never wrapped in a CSS colour function, and impression share cannot be miscountedcd apps/web && npx vitest run src/lib/design-tokens.test.ts src/lib/kpi-share.test.ts2026-09-04✅ 8/8. The scan is the load-bearing half: hsl(var(--primary)) is what four charts shipped, and because this repo's tokens are complete colours (#2563eb) rather than HSL channels, the substitution yields hsl(#2563eb) — invalid at computed-value time. fill and stroke are inherited, so an invalid value resolves to unset, not to "ignore this declaration": the area polyline computed to opaque rgb(0,0,0) and the line's stroke to none. Verified in a browser before the fix, and the guard was verified non-vacuous by reintroducing the pattern in components/ui/chart.tsx and watching it fail. Test files are excluded from the scan, since naming the pattern is how it is described. The share arithmetic pins the denominator: it sums the platform rows and never the platform: null roll-up — mixing them double-counts and halves every bar — plus the pre-total-bucket fallback that keeps a historical range from rendering an empty card, zero and null impressions being dropped rather than drawn as a zero-width bar against a platform name, and the cap that stops one card growing unbounded.
An Ideator idea that breaks a rule is dropped by code, not merely discouraged by the prompt — and the run says which rule dropped itcd apps/api && npx vitest run src/test/marketing-ideate.test.ts2026-09-04✅ 12/12. screenIdeas runs after the model returns and is the authority on every rule the prompt states: a hook of 139 characters is kept and 140 is dropped (the mobile fold — a hook the feed truncates is not the hook the approver read), a missing coreArgument/proof/takeaway drops the idea, a banned word in any field drops it case-insensitively while unlockable survives on the word boundary, and a channel the human didn't pick is filtered out. An idea breaking four rules is reported under all four rather than only the first, so the per-rule drop counts can't under-report a rule that would have caught it anyway. The guardrail_filter step carries generated/survived/dropped/dropRate/byRule — 6 in, 1 out, dropRate 0.83 — and the same line goes to stdout for cross-run comparison.
A batch where nothing survives the guardrails is retried once with the rules that fired, then fails cleanly rather than proposing a thin onecd apps/api && npx vitest run src/test/marketing-ideate.test.ts2026-09-04✅ Retry asserted on the model call itself: the second call's last message names banned_word and the 140 hook limit, so it carries what broke rather than "try again". Both attempts are on the record as guardrail_filter steps (failed then ok) — the first is not hidden. When the retry also yields nothing the run is failed with "guardrails after 2 attempt(s)" plus the rule tally in the error, and proposal is left null: nothing reaches the approval queue for someone to rubber-stamp. A non-JSON reply is retried the same way, while a provider transport error fails on the first attempt — chat called exactly once — because retrying that is the runner's job, not the prompt's.
The approval screen shows an idea's full claim chain, and still renders a pre-Phase-1 proposal already sitting in the queuecd apps/web && npx vitest run src/lib/agent-proposals.test.ts2026-09-04✅ 12/12. Fields render as Hook → Core argument → Proof → Takeaway, ordered the way the idea is judged rather than the way the JSON is keyed: an argument nobody can back is what this screen exists to catch, so Proof sits directly under Core argument. An idea stored in the old { angle, why } shape still renders those two labels instead of collapsing to a bare hook — a proposal approved before the shape changed must read as what it is.
A platform dashboard shows that platform's own numbers, ratios come from summed parts, and a metric nobody measured never renders as a zerocd apps/web && npx vitest run src/lib/analytics-platform.test.ts2026-09-07✅ 41/41. The null discipline is the half that cannot be seen in a screenshot: two days of impressions: null sum to null, not 0, because (a ?? 0) + (b ?? 0) is what turns "Instagram reported nothing" into a confident "0 impressions" on a card somebody screenshots for a client. A real 0 still reports as 0, and one reported day among nulls makes the total real. platform: null selects the rollup's cross-platform aggregate rather than everything, so the overview reads 1000 where summing the aggregate against its own parts would read 2000. Ratios are pinned on deliberately uneven volumes — a 1000-impression day at 1% and a 100-impression day at 3% give a range CTR of 13/1100 ≈ 1.18%, where averaging the days reports 2%; CPM's denominator is impressions ÷ 1000, so a missing division shows as a 1000× error; a zero denominator returns null, never Infinity or NaN. Blended ROAS divides attributed revenue by spend (3.40×) — the only surface where that division is legitimate, since revenue is attributed per project and day. Impression share divides by the sum of platform rows and never the aggregate (mixing them halves every bar), and a platform reporting zero is dropped rather than drawn as a zero-width bar against its name. ad_campaigns_active takes the peak, not the sum, or a two-day run of three campaigns reports six.
Every illustrative figure in the analytics suite is deterministic, in range, and cannot be mistaken for measurementcd apps/web && npx vitest run src/lib/analytics-sample.test.ts2026-09-08✅ 37/37 across all eleven remaining datasets. Determinism is the load-bearing property: a distribution that reshuffles on every render reads as a live feed, which is exactly the impression the amber Sample data chip exists to prevent — same seed, same bars, asserted for each dataset. Every set sums to 100% within rounding, carries only finite in-range percentages, and two different project seeds produce different bars so no two projects show an identical chart. The platform skews are asserted rather than assumed, because jitter large enough to erase them would make the panels noise rather than representative: TikTok's under-25 share exceeds Facebook's, Pinterest skews female past 60%, X skews male. An unrecognised platform falls back to a known shape instead of rendering an empty card. SAMPLE_NOTICE — the string in both the badge tooltip and the panel footnote — is pinned to still say illustrative and does not ingest audience demographics; if that wording ever drifts, the exception stops being honest. sampleCitySplit is gone from the table because Instagram - its only caller - now measures cities; the rule that a sample* function is deleted rather than kept as a fallback is what removed it.
The analytics restructure ships without regressing the rest of the app, and Reports is gone from the UI onlynpm run typecheck --workspace @verjson/web && npm run lint --workspace @verjson/web && npm run test --workspace @verjson/web && npm run build --workspace @verjson/web2026-09-07✅ typecheck clean, lint 0 errors (17 pre-existing warnings in tracker.js, connect-ads-panel.tsx and api.ts, none introduced), 1016/1016 tests in 64 files, production build compiled. The build output lists all sixteen new routes under /projects/[id]/analytics/{organic,paid,product}/* and no longer lists /analytics/reports. Backend untouched: git status shows changes only under apps/web/, so the kpis/reports module, its endpoints and the hourly scheduled-report worker are still in place and still running — this is a removed reader, not a removed feature. Not verified here: the API suite could not be run on this machine — npm run verify fails at ci:prepare-db with Prisma P3005 because the local app database was created by db push and has no _prisma_migrations table to baseline against. That failure predates this change and is unrelated to it (no API file was modified), but it means the API half of the gate is unproven locally and must pass in CI.
A pivot's ratios are computed from summed parts, tenants cannot see each other's rows, and a breakdown with no data is refused rather than fakedcd apps/api && npx vitest run src/test/kpi-pivot.test.ts · cd apps/web && npx vitest run src/lib/kpi-pivot.test.ts2026-09-04✅ 20/20 + 13/13. The ratio cases are deliberately uneven, because that is the only way the bug shows: a 50%-engagement day and a 1%-engagement day average to 25.5% while the true range figure is 51/1100 ≈ 4.64%, and the same shape pins CTR (100 clicks / 4000 impressions, not the mean of 1% and 3%), CPC, CPA and CPM — CPM's denominator is impressions ÷ 1000, so a missing division shows up as a 1000× error. A zero denominator returns null, never Infinity. The "" cross-platform sentinel row is excluded from platform grouping, which is what stops every platform's share being halved against a doubled total; paid rows are excluded from the social scope; and two projects seeded with the same platform on the same day prove the org scope holds. 0 and null are asserted to survive as different values all the way to the cell. assertSelectable refuses an unavailable breakdown (gender), an unavailable metric (video_views, roas) and a field belonging to the other scope (campaign under social) — the catalogue's key order is asserted whole, so a dropped entry fails rather than silently removing a checkbox. Web side pins the two exits: an em dash for absent data and never a zero, money divided out of minor units exactly once (the 100× spend overstatement), a ratio read as a fraction (0.017 → 1.70%, not 0.02%), and CSV quoting — a campaign called Spring "Big" Sale, 2026 keeps its comma inside one field and doubles its quotes, because an unquoted comma shifts every later column in someone else's spreadsheet.
A design token is never wrapped in a CSS colour function, and impression share cannot be miscountedcd apps/web && npx vitest run src/lib/design-tokens.test.ts src/lib/kpi-share.test.ts2026-09-04✅ 8/8. The scan is the load-bearing half: hsl(var(--primary)) is what four charts shipped, and because this repo's tokens are complete colours (#2563eb) rather than HSL channels, the substitution yields hsl(#2563eb) — invalid at computed-value time. fill and stroke are inherited, so an invalid value resolves to unset, not to "ignore this declaration": the area polyline computed to opaque rgb(0,0,0) and the line's stroke to none. Verified in a browser before the fix, and the guard was verified non-vacuous by reintroducing the pattern in components/ui/chart.tsx and watching it fail. Test files are excluded from the scan, since naming the pattern is how it is described. The share arithmetic pins the denominator: it sums the platform rows and never the platform: null roll-up — mixing them double-counts and halves every bar — plus the pre-total-bucket fallback that keeps a historical range from rendering an empty card, zero and null impressions being dropped rather than drawn as a zero-width bar against a platform name, and the cap that stops one card growing unbounded.
An Ideator idea that breaks a rule is dropped by code, not merely discouraged by the prompt — and the run says which rule dropped itcd apps/api && npx vitest run src/test/marketing-ideate.test.ts2026-09-04✅ 12/12. screenIdeas runs after the model returns and is the authority on every rule the prompt states: a hook of 139 characters is kept and 140 is dropped (the mobile fold — a hook the feed truncates is not the hook the approver read), a missing coreArgument/proof/takeaway drops the idea, a banned word in any field drops it case-insensitively while unlockable survives on the word boundary, and a channel the human didn't pick is filtered out. An idea breaking four rules is reported under all four rather than only the first, so the per-rule drop counts can't under-report a rule that would have caught it anyway. The guardrail_filter step carries generated/survived/dropped/dropRate/byRule — 6 in, 1 out, dropRate 0.83 — and the same line goes to stdout for cross-run comparison.
A batch where nothing survives the guardrails is retried once with the rules that fired, then fails cleanly rather than proposing a thin onecd apps/api && npx vitest run src/test/marketing-ideate.test.ts2026-09-04✅ Retry asserted on the model call itself: the second call's last message names banned_word and the 140 hook limit, so it carries what broke rather than "try again". Both attempts are on the record as guardrail_filter steps (failed then ok) — the first is not hidden. When the retry also yields nothing the run is failed with "guardrails after 2 attempt(s)" plus the rule tally in the error, and proposal is left null: nothing reaches the approval queue for someone to rubber-stamp. A non-JSON reply is retried the same way, while a provider transport error fails on the first attempt — chat called exactly once — because retrying that is the runner's job, not the prompt's.
The approval screen shows an idea's full claim chain, and still renders a pre-Phase-1 proposal already sitting in the queuecd apps/web && npx vitest run src/lib/agent-proposals.test.ts2026-09-04✅ 12/12. Fields render as Hook → Core argument → Proof → Takeaway, ordered the way the idea is judged rather than the way the JSON is keyed: an argument nobody can back is what this screen exists to catch, so Proof sits directly under Core argument. An idea stored in the old { angle, why } shape still renders those two labels instead of collapsing to a bare hook — a proposal approved before the shape changed must read as what it is.
A pivot's ratios are computed from summed parts, tenants cannot see each other's rows, and a breakdown with no data is refused rather than fakedcd apps/api && npx vitest run src/test/kpi-pivot.test.ts · cd apps/web && npx vitest run src/lib/kpi-pivot.test.ts2026-09-04✅ 20/20 + 13/13. The ratio cases are deliberately uneven, because that is the only way the bug shows: a 50%-engagement day and a 1%-engagement day average to 25.5% while the true range figure is 51/1100 ≈ 4.64%, and the same shape pins CTR (100 clicks / 4000 impressions, not the mean of 1% and 3%), CPC, CPA and CPM — CPM's denominator is impressions ÷ 1000, so a missing division shows up as a 1000× error. A zero denominator returns null, never Infinity. The "" cross-platform sentinel row is excluded from platform grouping, which is what stops every platform's share being halved against a doubled total; paid rows are excluded from the social scope; and two projects seeded with the same platform on the same day prove the org scope holds. 0 and null are asserted to survive as different values all the way to the cell. assertSelectable refuses an unavailable breakdown (gender), an unavailable metric (video_views, roas) and a field belonging to the other scope (campaign under social) — the catalogue's key order is asserted whole, so a dropped entry fails rather than silently removing a checkbox. Web side pins the two exits: an em dash for absent data and never a zero, money divided out of minor units exactly once (the 100× spend overstatement), a ratio read as a fraction (0.017 → 1.70%, not 0.02%), and CSV quoting — a campaign called Spring "Big" Sale, 2026 keeps its comma inside one field and doubles its quotes, because an unquoted comma shifts every later column in someone else's spreadsheet.
A design token is never wrapped in a CSS colour function, and impression share cannot be miscountedcd apps/web && npx vitest run src/lib/design-tokens.test.ts src/lib/kpi-share.test.ts2026-09-04✅ 8/8. The scan is the load-bearing half: hsl(var(--primary)) is what four charts shipped, and because this repo's tokens are complete colours (#2563eb) rather than HSL channels, the substitution yields hsl(#2563eb) — invalid at computed-value time. fill and stroke are inherited, so an invalid value resolves to unset, not to "ignore this declaration": the area polyline computed to opaque rgb(0,0,0) and the line's stroke to none. Verified in a browser before the fix, and the guard was verified non-vacuous by reintroducing the pattern in components/ui/chart.tsx and watching it fail. Test files are excluded from the scan, since naming the pattern is how it is described. The share arithmetic pins the denominator: it sums the platform rows and never the platform: null roll-up — mixing them double-counts and halves every bar — plus the pre-total-bucket fallback that keeps a historical range from rendering an empty card, zero and null impressions being dropped rather than drawn as a zero-width bar against a platform name, and the cap that stops one card growing unbounded.
The studio assistant remembers a thread across a panel close, answers from the workspace's real state, cannot be handed a history that did not happen, and cannot read another user's threadcd apps/api && npx vitest run src/test/ai-chat.test.ts src/test/ai.test.ts2026-09-07✅ 21/21 chat + 35/35 provider. Covers: memory works without the client resending turns; a messages array in the request body is ignored, so a fabricated history never reaches the model; another user's conversation_id starts a NEW thread rather than 404ing, which would confirm the id exists; the context window is bounded however long the thread grows and never opens on an assistant turn; the user's message survives a provider failure so a retry continues the same thread; provider 429/503/422 map to those statuses rather than 500; the request carries an AbortSignal, and a hung or unreachable provider is reported as unavailable rather than as an unhandled error. Also driven in a browser against a real OpenRouter key: sent a brand-voice instruction, CLOSED and REOPENED the panel, and the follow-up answer still honoured it — with 4 rows in chat_messages and updated_at bumped. No console errors.
An Ideator idea that breaks a rule is dropped by code, not merely discouraged by the prompt — and the run says which rule dropped itcd apps/api && npx vitest run src/test/marketing-ideate.test.ts2026-09-04✅ 12/12. screenIdeas runs after the model returns and is the authority on every rule the prompt states: a hook of 139 characters is kept and 140 is dropped (the mobile fold — a hook the feed truncates is not the hook the approver read), a missing coreArgument/proof/takeaway drops the idea, a banned word in any field drops it case-insensitively while unlockable survives on the word boundary, and a channel the human didn't pick is filtered out. An idea breaking four rules is reported under all four rather than only the first, so the per-rule drop counts can't under-report a rule that would have caught it anyway. The guardrail_filter step carries generated/survived/dropped/dropRate/byRule — 6 in, 1 out, dropRate 0.83 — and the same line goes to stdout for cross-run comparison.
A batch where nothing survives the guardrails is retried once with the rules that fired, then fails cleanly rather than proposing a thin onecd apps/api && npx vitest run src/test/marketing-ideate.test.ts2026-09-04✅ Retry asserted on the model call itself: the second call's last message names banned_word and the 140 hook limit, so it carries what broke rather than "try again". Both attempts are on the record as guardrail_filter steps (failed then ok) — the first is not hidden. When the retry also yields nothing the run is failed with "guardrails after 2 attempt(s)" plus the rule tally in the error, and proposal is left null: nothing reaches the approval queue for someone to rubber-stamp. A non-JSON reply is retried the same way, while a provider transport error fails on the first attempt — chat called exactly once — because retrying that is the runner's job, not the prompt's.
The approval screen shows an idea's full claim chain, and still renders a pre-Phase-1 proposal already sitting in the queuecd apps/web && npx vitest run src/lib/agent-proposals.test.ts2026-09-04✅ 12/12. Fields render as Hook → Core argument → Proof → Takeaway, ordered the way the idea is judged rather than the way the JSON is keyed: an argument nobody can back is what this screen exists to catch, so Proof sits directly under Core argument. An idea stored in the old { angle, why } shape still renders those two labels instead of collapsing to a bare hook — a proposal approved before the shape changed must read as what it is.
A connected ad account that cannot pay says so on the page where its campaigns are, not only in the connect pickercd apps/api && npx vitest run src/test/ads-billing-snapshot.test.ts src/test/ads-billing-state.test.ts2026-09-04✅ 22/22. evaluateMetaBilling shipped in #209 with exactly one caller — getAdAccounts, reached only during the OAuth picker, for accounts that do not exist here yet. That is the one moment the answer is least needed: nobody connects an account they have just let lapse, and an account runs out of money months later, mid-campaign. Nothing was persisted, so a connected account's billing state was frozen at the instant nobody cared about it, and the Meta Ads hub showed a green Connected badge whatever the account's balance. The state is now re-read on sync and by the metrics poll (6h floor — the answer changes about twice a year, and a Graph call per account per tick would re-learn it), stored in AdAccount.providerMeta beside business_id rather than behind a migration for a field only one platform of three can set, and rendered under the hub header with its deep link. Round-trip, bag-preservation (business_id and customer_id survive a billing write — overwriting the bag would disconnect the account from its own platform identity), overwrite-not-accumulate, and four malformed-input cases are pinned.
A billing verdict is never shown without the date it was readcd apps/api && npx vitest run src/test/ads-billing-snapshot.test.ts2026-09-04✅ readBillingSnapshot returns null for a stored state whose timestamp is missing or unparseable, rather than surfacing it undated — the three-state rule of billing-state.ts carried into storage. Dropping it costs one sync to recover; showing it undated would misdate it for as long as the row lives, and this snapshot can be hours old: an account settled an hour ago still reads as broken until the next check. The UI prints · checked <datetime> beside every warning for the same reason. null throughout also remains the honest state for every Google Ads account, since no adapter reports billing for one — getAccountBilling is optional on AdChannelAdapter, and an adapter that does not implement it leaves the snapshot untouched rather than writing an empty one.
A post that has already published opens its record, never a schedule form — and a partly published one still schedules what is leftcd apps/web && npx vitest run src/lib/post-scheduling.test.ts2026-09-03✅ 12/12. isProtectedVariant matches exactly publishing and published — asserted against the full PLATFORM_POST_STATUSES list, so a new status added to the contract cannot silently join or leave the protected set. isPostSchedulable is false for a wholly published post (the case that shipped the bug: Content links every row to /calendar/content?post=<id>, and the calendar answered with "Schedule this post", whose commit the API rejects because published is terminal), true for published + draft and published + failed, and true for a post with no channels yet — that one has no publish record to preview, and the schedule form is where it is told it needs a channel. The calendar consumes the same rule to pick which modal ?post= opens.
A reschedule form opens on the time the post already has, in the reader's own zone — it never silently moves a post that was only being nudgedcd apps/web && npx vitest run src/lib/post-scheduling.test.ts2026-09-03✅ 12/12. defaultScheduleAt returns the post's scheduled_at, preferring it over a merely proposed_publish_at (that is the one the publisher acts on), falls back to the proposal, and only then offers the next round hour — the calendar's old behaviour, which was right for "schedule this draft" and wrong for every reschedule: a form opening an hour from now turns Enter into a move of several hours. toLocalDatetimeInputValue is asserted against a local-zone Date, since toISOString would render a 19:00 slot as its UTC equivalent and put a time nobody chose in the field. Both are consumed by the single SchedulePostModal the calendar and the Content list now share.
The Content, calendar and composer rework typechecks, lints and passes its suites — but NONE of it has been seen in a browsernpm run typecheck --workspace @verjson/web · npm run lint --workspace @verjson/web · npm run test --workspace @verjson/web2026-09-02🟡 Typecheck clean; lint 0 errors / 18 warnings, all pre-existing and in files this work does not touch; tests 918 passed, 7 failed. The 7 are a pre-existing locale defect, not a regression: kpis.test.ts and user-webhooks.test.ts assert US formatting (/1.5M/, Aug 1) while the machine resolves en-IN and Intl renders lakhs and day-month order — lib/utils.ts hardcodes "en-IN" in formatMoney/formatDate while other call sites inherit the machine locale. Worth fixing separately by pinning the locale in the tests or the formatters. Not verified: any of it in a browser. Typecheck and lint cannot see layout, and the Playwright MCP server was unavailable for this session. The composer is the one to check first — the two-step split touches every field in a 1,600-line form, and the paths with no automated cover at all are per-platform save (create-then-PATCH across several channels), the shared uploader writing into a per-tab bucket, and Save-as-draft clearing the schedule. npm run verify (which adds build) has also not been run on this branch tip.
Provider-passthrough failures reach the browser with their diagnosis intact — 424, never a status Cloudflare rebrandscd apps/api && npx vitest run src/test/analytics-routes.test.ts src/test/inbox-reply.test.ts && ! grep -rn "ApiError(502" src/modules2026-08-31✅ 17/17 + 21/21, and the grep proves no ApiError(502, …) remains in src/modules. Cloudflare replaces an origin-generated 502/504 with its own branded body ("The origin web server returned an invalid or incomplete response to Cloudflare…"), which is exactly what the production picker rendered on 2026-08-31 despite #192 carrying the real message in the 502 detail. All nine provider-passthrough sites now throw PROVIDER_ERROR_STATUS (424), documented in core/errors.ts.
An unreachable Google fails the analytics adapters fast with a typed error, and a Google 403 never disconnects the connectioncd apps/api && npx vitest run src/test/analytics-timed-fetch.test.ts src/test/analytics-ga4.test.ts src/test/analytics-gsc.test.ts src/test/analytics-worker.test.ts2026-08-31✅ 55/55. Every GA4/GSC fetch carries AbortSignal.timeout(15s); a timeout becomes platform_error naming the call and the deadline instead of hanging the request path (the hang is what surfaced as a bare CDN 502 in production). mapHttpError splits 401 (token_revoked — genuinely dead, worker disconnects and notifies) from 403 (invalid_request — API disabled / scope not granted; the token is fine, the worker records lastError and leaves the connection alone, because reconnecting can never fix a 403).
The property picker reports what actually failed, not blanket "reconnect" advicecd apps/api && npx vitest run src/test/analytics-routes.test.ts2026-08-31✅ 17/17. A stubbed Google 403 through a REAL org-credential adapter comes back as a 424 whose detail carries the adapter's message — code, failing call, and for a 403 the exact permission to check — and the modal renders that detail via apiError with role="alert".
A pixel is picked by name, and the picker can never be the reason a campaign can't be configuredcd apps/api && npx vitest run src/test/ads-publish.test.ts2026-08-31✅ 46/46, eight of them driving the REAL Meta adapter with Graph stubbed — the exact URL (/act_111/adspixels?fields=id,name,last_fired_time), the act_ prefix being optional, a nameless pixel falling back to its id, a never-fired pixel resolving to null rather than the epoch, and Graph error codes 190 / 17 / 5xx mapping to token_revoked / rate_limited / platform_error. Without those, none of this feature's live path is covered: the endpoint tests all run against a stub adapter. GET /ad-accounts/:id/pixels returns { id, name, last_fired_at } from Meta's adspixels edge; the mock adapter answers deterministically so the form is smoke-testable with no Meta app configured. The firing time is part of the projection because an account accumulates pixels — one per site migration, one made by accident — and it is the only field that tells the live one from the dead ones. The text box did not go away: it is what renders when the fetch fails, when the account has no pixels, or when the stored id is not in the list, because a dropdown is a convenience and must never become a blocker. The "pick from the account instead" link clears the typed id — without that it is a dead button, since an unlisted id is itself one of the reasons the box is showing.
The pixel endpoint is tenant-scoped, and its failures say what to docd apps/api && npx vitest run src/test/ads-publish.test.ts2026-08-31✅ Another tenant's ad account is 404, identical to a nonexistent id, so this cannot be used to enumerate account ids; unauthenticated is 401. The adapter is called with the account's external id (act_111), asserted, not our uuid. Three failure modes are distinguished rather than collapsed into an empty list: an adapter with no pixel concept is 422 naming it ("this platform doesn't have them" is a different statement from "this account has none"), an account with no stored token is 409 with "Reconnect", and a revoked token is 409, not an empty dropdown — an empty dropdown reads as "no pixels" and sends somebody off to create one instead of fixing the connection. A platform rate limit passes through as 429.
Conversions reach Meta's CAPI at the right node, in the right unitscd apps/api && npx vitest run src/test/track-endpoints.test.ts2026-08-31✅ 32/32. Posts to https://graph.facebook.com/v20.0/{pixel_id}/events — asserted exactly, and asserted not to contain act_. Value is converted from minor units (4999 → 49.99): sending minor units overstates revenue a hundredfold and the ROAS optimiser spends against it. event_time is epoch seconds, asserted within 120s of now — milliseconds are accepted by Graph and land the event ~50,000 years out, past every attribution window. event_id carries the conversion's own id so the browser pixel and this path cannot double-count the same purchase, and action_source is website. Kinds map to Meta's standard vocabulary (purchase→Purchase, lead→Lead, signup→CompleteRegistration) because a custom name is accepted but invisible to conversion-optimised delivery.
Forwarding cannot slow, break, or leak from the trackercd apps/api && npx vitest run src/test/track-endpoints.test.ts2026-08-31✅ The caller is a customer's checkout page. A 400 from Graph, an unreachable host, and a project with no Meta account all leave POST /track/conversion returning 201 with the row written — asserted from the database, not just the status. One unreachable pixel does not cost a second pixel its event. Events go out once per distinct pixel, not once per ad set naming it: several ad sets routinely share a pixel, and repeating the send is triple-counting in the client's own reporting. Disconnected accounts and accounts with no token are skipped rather than spending a request to be told the token is bad. No raw IP leaves the process — the only identifier sent is a SHA-256 external_id derived from the visitor id, asserted to be 64 hex characters and asserted client_ip_address is absent, because MarketingTouchpoint stores ipHash and never an address, and quietly handing Meta the raw one would make that posture a fiction.
A broadcast body cannot carry script into a recipient's webmailcd apps/api && npx vitest run src/test/email-html.test.ts · cd apps/api && npx vitest run src/test/email-composer.test.ts2026-08-31✅ 71/71 + 41/41. sanitizeEmailHtml is an ALLOW-LIST at four levels — tags, attributes, URL schemes, CSS properties — so a payload nobody has thought of fails closed rather than waiting for a patch. Pinned: <script>/<style>/<iframe>/<object>/<embed>/<form>/<svg>/<meta>/<base> are dropped with their contents (an unknown tag is unwrapped and keeps its words — a Word paste must not lose the sentence); every on* attribute goes; javascript: is refused entity-encoded (&#106;avascript:), hex-encoded, newline-split, tab-split, control-char-prefixed and mixed-case, because a browser strips those characters before reading the scheme and a naive string compare does not; data: is refused even for images; url( is refused in CSS, which is what a tracking pixel smuggled through background: would use. Sanitising happens on write — the integration test reads the column back, not just the response — so no read path can forget to do it.
The two parts of a message cannot disagree, and neither can the previewcd apps/api && npx vitest run src/test/email-html.test.ts · cd apps/api && npx vitest run src/test/email-composer.test.ts · cd apps/web && npx vitest run src/lib/email-composer.test.ts2026-08-31✅ 71/71 + 41/41 + 28/28. One renderBroadcast builds the subject, the text part and the HTML part for the dispatcher and the test send alike, and the text part is derived from the rendered HTML rather than from the stored body_text — deriving it from the stored copy would substitute merge tags into two different strings. Every broadcast goes out multipart, because a message with no text/plain part renders as nothing in a client with HTML disabled. The browser preview runs the same sanitiser at the same 640px measure the dispatcher wraps the real message in, and the signature is inserted into the body rather than appended at send — an appended sign-off would be a paragraph in the delivered mail that was never on screen. stripSignatureBlock scans for the </div> that balances the block rather than matching to the first one (which would cut a signature containing a div in half) or to the last one in the document (which would take anything written below it) — the case that matters is somebody who inserts a signature and then keeps typing, where a tail-only version leaves the toggle doing nothing at all, silently, with the button still offering to remove it.
A contact's own name cannot become markup in somebody else's inboxcd apps/api && npx vitest run src/test/email-html.test.ts · cd apps/api && npx vitest run src/test/email-composer.test.ts2026-08-31✅ The contact table is filled by CSV import and Google Sheets, so a name is input from outside. A contact called <img src=x onerror=alert(1)> Bob is escaped into the HTML part (asserted: the delivered HTML contains &lt;img, not <img src=x) and not escaped into the subject or the text part, where escaping would deliver a literal &amp; to the inbox. The same test found the bug that made this worth pinning: Prisma rows are camelCase and MergeTagValues is snake_case, the two are structurally compatible in TypeScript, and passing the row straight through compiled cleanly while making every greeting read "Hi there".
A test send is not a sendcd apps/api && npx vitest run src/test/email-composer.test.ts2026-08-31✅ Asserted directly against the database after a test send: status still draft, sent_count 0, recipient_count 0, sent_at null, zero email_broadcast_recipients rows, and the real contact on the list never appears in the mail that went out. sendTestBroadcast shares no code with sendBroadcast and never resolves the audience — a test that ran through the real sender behind a filter is a test that mails the list the first time the filter is wrong. Requires send permission (a demoted member gets 403 with nothing sent), is 404 for another tenant's draft, and is rate limited at 5/min: it is the only endpoint that puts this deployment's from address behind caller-supplied content aimed at a caller-supplied address.
An attachment can only ever be one of your own files, and is read oncecd apps/api && npx vitest run src/test/email-composer.test.ts2026-08-31✅ Bytes are read from OUR bucket by object key — asserted downloadBytes was called with the exact key — never fetched from the url the row also carries, which would make the mail path issue a request to a destination that came out of a database column (the SSRF shape F-111.2 closed on the import side, reintroduced somewhere far less visible). A key outside the caller's org prefix is 404 with no read attempted; 404 rather than 403 so it cannot be used to test whether a key exists. Three recipients, one attachment, one downloadBytes call — a read inside the loop would be 9.6GB of object-store traffic for a 12MB deck across 800 addresses. A missing object fails before the claim, names the file rather than the S3 key, and leaves the broadcast in draft — failing after the claim would mean the first 200 people got mail the other 600 never will.
A draft may be incomplete; a broadcast may notcd apps/api && npx vitest run src/test/email-composer.test.ts · cd apps/web && npx vitest run src/lib/email-composer.test.ts · cd apps/api && npx vitest run src/test/email-broadcasts.test.ts2026-08-31✅ 41/41 + 28/28 + 76/76. Create and PATCH accept an empty name and an empty subject, because autosave writes from the first keystroke and a save path that refused an incomplete draft would throw away the paragraph somebody was mid-way through. Send refuses each of the three separately with the field named, and mails nothing. The 76 pre-existing broadcast tests pass unchanged: body_text stays NOT NULL, a pre-F-113 broadcast still goes out text-only with no HTML part invented for it, and reopening one in the composer does not promote its text to markup.
A Google Sheets import cannot be pointed anywhere but Googlecd apps/api && npx vitest run src/test/email-broadcasts-sheets.test.ts2026-08-27✅ 34/34. The server never fetches the pasted URL. parseGoogleSheetUrl extracts an id matching [A-Za-z0-9_-]{20,200} — anchored, so it carries no /, ., : or % — and sheetCsvUrl builds a fresh URL against a hardcoded https://docs.google.com. The test asserts the exact outgoing URL (/spreadsheets/d/<id>/gviz/tq?tqx=out%3Acsv&gid=42) and that redirect: "manual" was set. Userinfo (https://docs.google.com@evil.test/), suffix (docs.google.com.evil.test), prefix, plain other hosts, http://, file://, gopher://, 127.0.0.1, [::1] and 169.254.169.254 are each refused — and the suite asserts fetch was never called for any of them, because a rejection that still made the request would leave the surface open whatever the message said. Ids that could traverse (../../../a, abc/../../oauth2, a.b:c) fail at parse time.
The sheets endpoint is not a probe for documents the caller cannot seecd apps/api && npx vitest run src/test/email-broadcasts-sheets.test.ts2026-08-27✅ Permission and project scope are checked before the fetch, and the test pins that ordering: another tenant's project id is 404 with fetch not called, and a demoted client role is 403 with fetch not called. Reversed, this endpoint would answer "does this spreadsheet exist, and is it public?" for any authenticated account against any project. Rate limited at 10/min — it is the only endpoint in the product that makes an outbound request on user input.
An unreadable sheet says which setting to changecd apps/api && npx vitest run src/test/email-broadcasts-sheets.test.ts2026-08-27✅ A 302 to accounts.google.com (Google's only signal for "not shared") and an HTML sign-in page served with a 200 both become a 422 naming "Anyone with the link" — the fix takes four seconds in the Share dialog, but only if the message says so. Undetected HTML would instead reach the CSV parser and report "missing an email column", sending the user hunting through their spreadsheet for a sharing problem. A 302 pointing off Google (127.0.0.1:6379) is refused, not chased: exactly one request is made, to Google. 404 reads as "no such spreadsheet", a thrown socket error as "try again" with the underlying ECONNREFUSED not echoed.
A sheet cannot exhaust memory, declared or notcd apps/api && npx vitest run src/test/email-broadcasts-sheets.test.ts2026-08-27✅ A content-length over the 5MB import cap is refused before the body is read. A chunked response that declares nothing and emits 20MB is aborted mid-read — the test asserts fewer than 12 of the 20 megabyte-chunks were pulled, which is what distinguishes a counted stream from arrayBuffer()-then-slice, where the memory is already spent by the time the cap applies.
A sheet and a file import identicallycd apps/api && npx vitest run src/test/email-broadcasts-sheets.test.ts · cd apps/web && npx vitest run src/lib/email-broadcasts.test.ts2026-08-27✅ 34/34 + 35/35. The sheet's CSV text goes into the same parseContactsCsv a file upload uses: a BOM, E-Mail/Name/Organisation/Segment aliases, a mailto: prefix, mixed case and a human category label all behave the same, in-file duplicates count as skipped, and a bad row is rejected with its row number. Custom categories match by folded name and auto-create when asked. A sheet with no email column gets the parser's message, not a Google-flavoured one — the mistake is in the spreadsheet. Provenance is honest: contacts carry source: "google-sheet:<id>", never "csv". Web side pins that the link field validates through the same shared parser the API uses, and that a lookalike link never enables the button.
A project's custom category cannot be used, filtered or mailed by another projectcd apps/api && npx vitest run src/test/email-broadcasts.test.ts2026-08-27✅ 76/76. The interesting case is two projects inside one organization, not two tenants — an org filter already catches the latter, and only the project-scoped lookup catches the former. Filing a contact under another project's custom_category_id is 404 with emailContact.count() === 0 after; so is targeting a broadcast at one, filtering ?category=custom:<other> (404, never an empty list — "that segment is empty" is a different and more misleading answer), and naming one as an import fallback (refused before a single row is written). Cross-tenant list / PATCH / DELETE on a category are all 404 and the row is verifiably untouched. A body carrying both category and custom_category_id is 422 rather than resolved by picking one.
Deleting a category never silently loses the people in itcd apps/api && npx vitest run src/test/email-broadcasts.test.ts2026-08-27✅ Deleting a category holding 2 contacts is 409 carrying contact_count: 2 — the number the UI needs to ask "move 2 contacts where?" instead of a bare "are you sure?" — and both the category and the contacts are still there afterwards. With ?reassign_to=active_clients the delete succeeds and both contacts are re-filed (category: "active_clients", customCategoryId: null), not deleted. Reassigning into another custom category works the same way; a destination in another project is 404 with nothing moved and nothing deleted (a half-applied reassignment is worse than none), and reassigning a category into itself is 422. An empty category deletes outright. The audit row records reassigned: 1 and reassigned_to, because "a segment was deleted" is unanswerable later without knowing where its people went.
A past broadcast stays readable after its audience is deletedcd apps/api && npx vitest run src/test/email-broadcasts.test.ts2026-08-27✅ A broadcast sent to "VIP Tier", then the category deleted: the FK goes null (custom_category_id: null, category: null) but the response still reads audience_label: "VIP Tier" and sent_count: 1. The label is snapshotted at draft time, the same trick email_broadcast_recipients uses for the address. A broadcast is therefore never a blocker on deleting a category (SET NULL), unlike a contact (RESTRICT). A draft whose audience was deleted refuses to send with a 422 naming the missing segment and zero sendMail calls — guessing an audience there would mail a list nobody chose.
A CSV can file rows into a project's own categories, and cannot invent them by accidentcd apps/api && npx vitest run src/test/email-broadcasts.test.ts2026-08-27✅ "Enterprise Renewals", "enterprise renewals" and "ENTERPRISE-RENEWALS" all resolve to the same existing category (folded through audienceSlug). Unknown names are not created by default: the rows still import into the fallback and the names come back in unmatched_categories, because silently folding an unrecognised segment into the default is how 800 contacts end up somewhere nobody chose. With auto_create_categories=true, a file naming "Board Members" / "board members" / "Angel Investors" creates exactly two categories, not three. Built-in vocabulary wins a collision — a clients cell imports as active_clients and creates nothing — and a category whose name the importer already reads as a built-in (Clients, hosts, active_clients, Active Clients & Buyers) is refused at creation with a 409 naming the collision. The exactly-one-audience CHECK constraints are re-applied in src/test/setup.ts, because prisma db push replays the schema and not the migration — without that the invariant would hold in production and not in CI.
A broadcast to a custom segment reaches exactly itcd apps/api && npx vitest run src/test/email-broadcasts.test.ts · cd apps/web && npx vitest run src/lib/email-broadcasts.test.ts2026-08-27✅ 76/76 + 30/30. Five contacts — two built-in segments and a custom "VIP Tier" with two members — produce exactly two sendMail calls, both to the VIP addresses. An unsubscribed member of a custom segment gets nothing. Stats return the three built-ins first then every custom category, each row self-describing (key / label / color / custom) so one card component renders both kinds, and a brand-new category appears at 0 rather than being omitted — the whole point of pressing Create is seeing the card. Web side pins the audience-key round trip (custom:<id> ⇄ {category: null, custom_category_id}), that an unrecognised key returns null rather than a default (a typo must not become mail to the wrong list), that recipient counts read sendable and never total, and that every colour has complete literal Tailwind classes — an interpolated bg-${color}-500/10 is never generated by the scanner and would render every custom category unstyled.
A broadcast reaches exactly the segment it targets, and never an unsubscribecd apps/api && npx vitest run src/test/email-broadcasts.test.ts2026-08-26✅ 34/34. Four contacts on one project — two active_clients, one inbound_leads, and one active_clients that later unsubscribed. Sending an active_clients broadcast produces exactly two sendMail calls, addressed to the two subscribed buyers; the lead and the unsubscribe get nothing. Response reads recipient_count: 2, sent_count: 2, failed_count: 0 and every recipient row is sent. Targeting a segment with no subscribed contacts is a 422 naming the problem, sends nothing, and leaves the broadcast a draft rather than consuming it.
A double-click cannot mail the list twicecd apps/api && npx vitest run src/test/email-broadcasts.test.ts2026-08-26✅ Two POST /email-broadcasts/{id}/send fired concurrently through Promise.all: one 200, the other 409-or-422, and sendMail called once. The guard is a conditional updateMany({ where: { id, status: "draft" } }) claim — a read-then-write would let both callers see draft and both proceed. A sequential second send against an already-sent broadcast is 422, also with no additional call.
A partial send reports which addresses failed, rather than a green tickcd apps/api && npx vitest run src/test/email-broadcasts.test.ts2026-08-26✅ Transport rejects one of two addresses → status partially_sent, sent_count: 1, failed_count: 1, failure_reason: null (the per-address error is the useful detail), and the failing recipient row carries 550 mailbox unavailable. Every address rejected → status failed with a summary reason. With EMAIL_ENABLED=0 the send is refused up front with a 422 and zero recipient rows are written, rather than N identical failures.
A CSV import loses nothing silently, and a re-import cannot resurrect an unsubscribecd apps/api && npx vitest run src/test/email-broadcasts.test.ts2026-08-26✅ A 4-row file with one bad address and one in-file duplicate returns created: 2, updated: 0, skipped: 1 plus rejected: [{ row: 3, email: "broken", … }] — rejections carry their row number, never a silent drop. Re-uploading a corrected file gives created: 0, updated: 1 (one row, not two) and re-files the contact under the new segment; a blank cell in a partial re-upload leaves the known name and company intact rather than nulling them. A contact marked unsubscribed is still unsubscribed after a re-import naming them. The parser tolerates a UTF-8 BOM, E-Mail / Name / Organisation / Segment header aliases, mailto: prefixes, "Name" <a@b.com>, mixed case, and human category labels (Active Clients & Buyers, hosts) — and refuses to guess at vip tier.
A tenant cannot read, write or mail another tenant's audiencecd apps/api && npx vitest run src/test/email-broadcasts.test.ts2026-08-26✅ Every cross-tenant path answers 404, not 403 (a 403 would prove the row exists): contact list, contact PATCH, contact DELETE, broadcast GET, broadcast send, and CSV import — the import case additionally asserts emailContact.count() === 0 afterwards. That last one caught a real hole: assertCanWriteProject short-circuits for owner/admin/member without looking the project up, so the project id has to be scope-checked separately before any write, or an owner of tenant A could file contacts against tenant B's project id (the FK would accept it). Role gates: a demoted member still curates the list but is 403 on send with no sendMail call; a client is 403 on adding a contact at all.
The audience picker cannot show a number the send will not honourcd apps/api && npx vitest run src/test/email-broadcasts.test.ts · cd apps/web && npx vitest run src/lib/email-broadcasts.test.ts2026-08-26✅ 34/34 + 10/10. GET /projects/{id}/email-contacts/stats returns a row for every category including the empty ones (supply_partners at 0, listed rather than omitted), splitting total / sendable / unsubscribed — the composer reads sendable, which is the number that actually receives mail. An unknown ?category= filter is 422 rather than silently returning everything. Web side pins summariseImport naming every non-zero outcome (6 added · 2 updated · 1 duplicate skipped · 1 rejected (from 10 rows).) and saying Nothing to import from 4 rows. rather than reading as a success; looksLikeCsv accepts a .csv the OS reports as application/vnd.ms-excel (what Windows-with-Excel sends, and what a MIME-only check would reject) and refuses .xlsx.
Email Broadcasts sits under Campaigns, and its URLs resolvecd apps/web && npx vitest run src/lib/email-broadcasts.test.ts2026-08-26✅ 10/10. The section is asserted to be the entry immediately after campaigns in its sidebar group, so a later IA edit that moves it fails here rather than in review. iaResolve("email-broadcasts") and iaResolve("email-broadcasts/broadcasts") both land on the section (breadcrumb + sidebar marker), the default tab is audience, both children map 1:1, and a garbage URL segment falls back to audience instead of rendering an empty page.
The rest of the build is unchanged by the featurecd apps/api && npx vitest run · cd apps/web && npx vitest run · npm run typecheck2026-08-26✅ API 2072/2075 across 137 files; web 774/774 across 50 files; typecheck clean in all three workspaces; npx eslint src clean in apps/api, apps/web reporting only its 19 pre-existing warnings. ⚠️ The 3 API failures are ads-credentials.test.ts (resolveAdAdapter returns Meta Ads (mock) where the test wants Meta Ads) — pre-existing and unrelated, proven by git stash --include-untracked and re-running that file against the clean tree, which fails identically 3/6. Not fixed here: it is a different subsystem and folding it into this change would hide it.
The WhatsApp bridge container builds, boots and serves its whole REST surface on :4100docker compose --profile whatsapp up -d --build whatsapp then curl -s localhost:4100/health · curl -s -H "x-bridge-token: dev-bridge-token" localhost:4100/status · localhost:4100/qr · curl -s -X POST -H "x-bridge-token: dev-bridge-token" -H 'content-type: application/json' -d '{"to":"919000000000","text":"hi"}' localhost:4100/send2026-08-26✅ Image builds (node:22-alpine, multi-stage, 398 MB) and docker ps reports healthy in 9s off the image's own /health probe. /health → {"status":"ok","bridge":"up"} 200. /status → {"connectionState":"connecting","phone":null,…} 200. /qr → a data:image/png;base64,iVBORw0KGgo… PNG data URL, 200. /send against an un-paired socket → 409 "WhatsApp session is connecting, not open" rather than a silent drop. Container runs as uid=1000(node) and /app/auth is node-owned and writable, so the auth volume survives a restart without a root container.
The bridge is closed to anyone without the shared secret, and open to the probesame stack, curl -s localhost:4100/status (no header) · curl -s -H "x-bridge-token: nope" localhost:4100/status2026-08-26✅ Both → 401 {"detail":"Invalid or missing x-bridge-token."}, on /status, /qr, /send and /disconnect alike; comparison is timingSafeEqual over SHA-256 digests, so a wrong-length token cannot be distinguished by timing. /health answers 200 with no header — deliberate: it is the container/K8s probe and reveals only that the process is up.
Inbound events reach the API webhook carrying the bridge token, and a dead webhook never takes the socket downLocal run: BRIDGE_TOKEN=smoke-token PORT=4199 WEBHOOK_URL=http://127.0.0.1:4198/events node dist/index.js against a node HTTP sink on :41982026-08-26✅ Sink received POST /events with x-bridge-token: smoke-token for connection.update ({"connection_state":"close","logged_out":true,…}), the follow-up connection.update ("connecting") and qr.updated. With no listener the same dispatches logged "webhook delivery failed" at warn and the socket carried on connecting — the API is the bridge's audience, not its dependency. ⚠️ messages.upsert and delivery receipts share this dispatcher but are not proven end-to-end: that needs a real paired number.
POST /disconnect destroys the session rather than just closing the socketsame local run, curl -s -X POST -H "x-bridge-token: smoke-token" localhost:4199/disconnect then ls /tmp/wa-smoke-auth2026-08-26✅ 200 {"status":"disconnected","connectionState":"close","lastDisconnectReason":"disconnected_by_request"}; the auth folder is gone (ls: No such file or directory), and the log shows session cleared → scheduling reconnect (1 s) → starting WhatsApp socket → pairing QR refreshed, with /status back to connecting, qrAvailable: true. That is the re-pair path: disconnect, scan, different number.
The bridge compiles clean against Baileys' own typescd services/whatsapp && npx tsc -p tsconfig.json --noEmit2026-08-26✅ exit 0, no output. Also caught what would otherwise have failed only at runtime: Baileys 6.7.24 is "type": "module" with no CJS build, so the service is ESM (module: NodeNext, .js on relative imports) — a CommonJS build would have died on require() of an ES module inside the container.
Fine-grained locations map into Meta's geo_locations, and any one location kind is publishablecd apps/api && npx vitest run src/test/targeting.test.ts · cd apps/web && npx vitest run src/lib/ad-builder.test.ts2026-08-25✅ 20/20 + 25/25. New targeting.test.ts covers: regions/zips → bare keys (our display name never reaches Meta); a city with radius + unit; an absent unit written explicitly as mile (Meta's default, so a "10" meant as km would otherwise buy 16 km); a city with no radius; countries DROPPED whenever regions/cities/zips are present, asserted for each kind; all six detailed-targeting categories in ONE flexible_spec group (splitting them would AND rather than OR); flexible_spec/geo_locations absent when nothing is picked; our own keys (daily_budget_minor, optimization_goal) never leaking into targeting. assertRunnableTargeting accepts regions/cities/zips with NO country, still 422s on nowhere-at-all, and the budget/pixel rules are unchanged. Web side pins selectionToTargeting never mixing kinds, selectionCount gating on the active kind only, round-trip of a stored city, and readiness accepting a city-only ad set.
A campaign PATCH no longer wipes extracd apps/api && npx vitest run src/test/ads.test.ts2026-08-25✅ 28/28. adCampaignUpdateSchema.parse({name}) now yields exactly {name} — no extra: {} (probed directly before and after the fix). Regression test creates a campaign with extra.conversion_domain + an Employment declaration, PATCHes only name, and asserts both keys survive.
Special ad categories reach Meta in Meta's vocabulary, and an undeclared campaign behaves exactly as beforecd apps/api && npx vitest run src/test/adapter-meta-ads.test.ts src/test/ads.test.ts2026-08-25✅ 31/31 + 28/28. Adapter: ["housing"] → POST body special_ad_categories=["HOUSING"]; ["none"], {} (a row predating the field), and every junk shape (null, "housing", [42], ["nope"]) → []; a body carrying both a declaration and other extra keys sends the MAPPED category and still passes foo through; updateCampaign drops the create-only field while keeping the rest. Domain: create defaults to ["none"]; ["none","housing"] / ["housing","housing"] / ["nope"] → 422; a draft's declaration is editable, a published one's is 422 while re-sending the same value still saves; a duplicate carries the declaration.
Duplicating an ad campaign copies its whole tree and cannot push anything to a platformcd apps/api && npx vitest run src/test/ads.test.ts2026-08-25✅ 22/22. Four new cases in ads · AdCampaign duplicate: (1) source campaign forced to status: active with externalCampaignId: "meta-123" (and its group/ad likewise) — the copy still comes back draft with every external id "", while ad group, ad, daily budget, objective, bid strategy and extra all carry over and the source is left untouched; audit row meta is exactly { duplicated_from: <source id> }. (2) a budget closed with endDate: 2020-01-01 is not copied. (3) a childless campaign copies with zero groups and zero budgets. (4) a cross-tenant duplicate returns 404, not 403, and creates no row. Neighbouring suites re-run green: ads-publish · ads-budget · ads-sync · ads-bid-strategy · ads-creatives-pipeline · ads-metrics = 75/75.
A dead Slack webhook stops being dialled, and says socd apps/api && npx vitest run src/test/slack.test.ts2026-08-27✅ 35/35. A 404 (the response Slack gives for a webhook deleted inside Slack) disables on the FIRST failure and pages the owner in-app; 410 and 403 too. A 500 only increments — the 10th consecutive one disables. After the disable, a further send makes no HTTP call at all and emits no second page. Before this the same 404 repeated silently on every notification, forever, while the panel showed a green Verified badge.
The news of a dead Slack channel is not sent down that channelcd apps/api && npx vitest run src/test/slack.test.ts2026-08-27✅ Exactly one outbound POST after a 404: the notification that failed. The slack_integration_disabled emit that follows finds the row already disabledAt and skips it — no explicit kind-exclusion needed, unlike the outbound-webhook path.
Pressing "Send test" ten times cannot disable a working-on-it integrationcd apps/api && npx vitest run src/test/slack.test.ts2026-08-27✅ A failed manual test records last_failure_message (with Slack's own reason, e.g. channel_not_found) but leaves consecutive_failures at 0 and disabled_at null. A user pressing a button is diagnostics, not traffic. A successful test clears the disable and the counters — the repair and the proof are one action.
A disabled Slack integration never reads as healthy anywhere in the UInpx vitest run src/lib/user-slack.test.ts src/lib/connection-health.test.ts src/lib/integrations-hub.test.ts --root apps/web2026-08-27✅ 82/82. describeSlackConnectionStatus returns disabled even when the row is still verified: true (it worked once); computeConnectionHealth returns failed/disabled with Slack's reason and a check_settings action; the hub row classifies disconnected. All three check the disable BEFORE the verified flag — the ordering is the whole point.
Deleting the duplicated sidebar sub-nav cannot empty the tab bars it duplicatednpx vitest run src/lib/project-ia.test.ts --root apps/web2026-08-31✅ 19/19. Content, Calendar and Approvals lost the sidebar children that restated their own page's tab strip. Those strips are driven by apps/web/src/lib/section-tabs.ts, which was not touched — pinned in both directions: every calendar tab id (month/week/content/ads) and every approvals tab id still validates, resolveTabFromUrl("calendar", ["monthly"]) → month and urlSlugForTab("calendar", "month") → monthly still round-trip, and iaResolve still resolves calendar/monthly and approvals/pending to their sections. The two round-trip tests exist so a later cleanup cannot "tidy away" the now-unreferenced monthly/weekly mappings and silently break every URL the sidebar emits. iaLeafPaths() was confirmed to have no non-test caller, so no route generation depended on the removed children.
The calendar toolbar and the new project switcher hold their layout in a real browsermanual — npm run dev --workspace @verjson/web, then /projects/<id>/calendar and any project page2026-08-31✅ Checked at the three docs/FRONTEND.md widths (<768 · 768–1024 · ≥1024), switching Month → Week at each. The range label now sits between the arrows and holds min-w-44 + tabular-nums, so the arrows do not shift as the text grows from May 2026 to a cross-month week range — the longest string it carries. Selected view chip is legible against the bg-muted trough in dark mode. A long project name truncates in the switcher rather than pushing the chevron off the 16rem sidebar, and the dropdown does not clip at the aside's lg:overflow-y-auto scroll edge. No automated coverage: these are layout properties typecheck and lint cannot see, and there is no Playwright spec for them yet.
A team grants nobody access to anythingcd apps/api && npx vitest run src/test/teams.test.ts2026-08-26✅ 17/17. A user on a team with no project_members row still gets 404 on that project — the assertion the whole design rests on. Teams, individual roles and project roles are three separate columns; project_members was not touched.
Work can be handed to a team, not just a personcd apps/api && npx vitest run src/test/teams.test.ts2026-08-26✅ An assignment with team_id and no user_id appears in My Work for every member of that team, and disappears from the others' lists the moment one of them claims it. Verified end to end against the running dev server: assigning to Design with no person produced Design (unclaimed) on My Work with its due date.
An assignee can finish their own work without project write accesscd apps/api && npx vitest run src/test/teams.test.ts2026-08-26✅ The assignee may PATCH their own row's status; a non-assignee without project write access gets 403 on the same row. Otherwise every status change would need someone with edit rights, which defeats handing work to a contributor.
The assignment picker needs no sub-teamsnpx vitest run src/lib/teams.test.ts --root apps/web2026-08-26✅ 10/10. membersForAssignment narrows one team by job role — (Design, Video Editor) answers "whoever does video in Design" with two levels of hierarchy, not three. Returns empty rather than silently widening when nobody holds that role. dueState counts calendar days, so work due 09:00 tomorrow reads "Due tomorrow", not "today".
A draft can be deleted from the page that lists draftsnpx vitest run src/lib/post-permissions.test.ts --root apps/web + Playwright MCP2026-08-25✅ 7/7. Delete lived only on the per-channel hubs, so a draft with no channel could not be deleted from anywhere. In the browser: a client user saw the button on their OWN proposal only, deleted it successfully, and the owner's nine posts showed no button at all — the API had returned 403 on that path before the gate was added.
The delete button is never offered to someone who cannot use itnpx vitest run src/lib/post-permissions.test.ts --root apps/web2026-08-25✅ canDeletePost mirrors posts/domain.ts::remove — internal roles delete anything; an external contributor only their own post, and only while every variant is still in a proposal status. Tests fail if the two rules drift.
The tracker key is reachable, with a snippet that needs no editingnpx vitest run src/lib/tracker-key.test.ts --root apps/web + live curl2026-08-25✅ 7/7. GET /projects/:id/tracker-key returned a real key (YAjgWOQrrmUkZIwd7R6K7Q) and the panel renders it with the snippet pre-filled. Pinned: api_base is the ORIGIN, not the /api/v1 path (passing it through unchanged posts to /api/v1/api/v1/track/visit — a 404 per pageview, silently); the queue stub is defined before init (the tag is async); and verjson('page') is present, without which an installed tracker records nothing.
A workflow can be made default after it was createdmanual — Settings → Approvals → Make default2026-08-25🟡 Button added and typechecks; the API path (PATCH /approval-workflows/:id { is_default }) is covered by updateWorkflow, but this specific UI action has no automated test yet. The 409 it fixes (submitForReview falling back to a default that does not exist) is what blocked every send-for-review on a project whose only workflow was unflagged.
Sessionising is right — a refresh is not a navigation, and a trailing slash is not a second pagecd apps/api && npx vitest run src/test/attribution-paths.test.ts2026-08-25✅ 26/26. Consecutive duplicates collapse (incl. /pricing then /pricing?ref=x) while a genuine RETURN later in the visit is preserved; query strings and fragments are stripped but case is not; rows with no session_id fall back to the same 30-minute window the browser SDK uses. Each of these silently shifts a drop-off by a few points if it goes the other way.
A visit is credited to the ad that STARTED it, not one it met latercd apps/api && npx vitest run src/test/attribution-paths.test.ts2026-08-25✅ A session whose first touchpoint carries camp-A and whose second carries camp-B reports as camp-A. ?ad_campaign_id= filters on that first touchpoint, so a visit that merely wandered through a campaign's landing page later is excluded.
Leaving is drawn, not omitted — a drop-off cannot hidecd apps/api && npx vitest run src/test/attribution-paths.test.ts + npx vitest run src/lib/attribution.test.ts --root apps/web2026-08-25✅ Edges out of a node sum to the sessions into it (2 to /signup + 1 exit = 3 in). A visit running deeper than max_depth gets NO exit edge — a phantom one would report people as having left while they were still reading. Web-side shares divide by the edges held, not the entry total, so depth-2 percentages cannot exceed 100.
The path report reads real traffic end to endlive seed + curl + Playwright MCP against npm run dev2026-08-25✅ 116 seeded visits: /lp/summer-sale 88 visits, 44 bounced, 44 continued, 13 converted; next steps 50% left / 39% /pricing / 11% /features; drilling into /pricing gives 53% left / 26% /signup / 21% /faq. Every figure matches the seeded journey weights exactly.
A client can put a post on the calendar but can never put one in the publisher's queuecd apps/api && npx vitest run src/test/posts-proposals.test.ts2026-08-24✅ 14/14. A client composing with scheduled_at gets scheduled_at: null, proposed_publish_at set, variant draft — the identical request from a member yields variant scheduled, so the clamp is on the role, not the payload. The three indirect routes are closed too: PATCHing a date onto the post re-clamps, PATCHing status: "scheduled" onto a variant is 403, publish-now is 403.
A client's proposed date becomes real only through an approval somebody else grantedcd apps/api && npx vitest run src/test/posts-proposals.test.ts2026-08-24✅ Owner approves → scheduleApprovedPost reads scheduledAt ?? proposedPublishAt and the variant lands scheduled at the client's own time. The client self-approving the same post is 403 even while named as an approver on the client_review stage, and the variant stays draft.
An external contributor is scoped to their own work, and to projects they were let intocd apps/api && npx vitest run src/test/posts-proposals.test.ts2026-08-24✅ Editing or deleting somebody else's post is 403; editing their own is 403 once the team has scheduled it; withdrawing their own untouched proposal is 204. A client with no membership row gets 404 (not 403 — the write path must not become an enumeration oracle), and one invited as project viewer gets 403. Agency behaves identically to client.
An agent proposal is readable prose before you approve it, never raw JSONnpx vitest run src/lib/agent-proposals.test.ts --root apps/web2026-08-22✅ 10/10. A drafter proposal renders per-draft blocks (Script / Caption / First comment) under a summary naming the count and channels; empty fields are dropped rather than shown blank.
A proposal we can't parse falls back to JSON instead of an empty-looking summarynpx vitest run src/lib/agent-proposals.test.ts --root apps/web2026-08-22✅ proposalView returns null for an unknown kind, a wrong-shaped payload, a non-object, and drafts carrying neither script nor caption — the page then shows the raw JSON. Guards the failure where an approver reads "creates 0 posts" for a proposal holding five.
An agent's Run now only appears when the backend would accept the runnpx vitest run src/lib/agent-roster.test.ts --root apps/web2026-08-22✅ 8/8. Scout needs a project; Ideator needs an applied scout; Drafter needs applied ideas — pending/rejected parents and wrong-kind runs are excluded from candidates, which arrive newest-first. Planned agents are never runnable and always carry a reason.
Facebook post metrics come back at all — the request no longer carries retired metric nameslive curl with a real Page token + cd apps/api && npx vitest run src/test/adapter-facebook.test.ts2026-08-21✅ Old metric list → (#100) The value must be a valid insights metric on v19.0/v20.0/v21.0/v22.0/v23.0. New list (post_clicks,post_reactions_by_type_total,post_video_views) → HTTP 200. Running the real adapter against a live post returned {likes: 0, comments: 0, shares: 1}; the same call before the fix threw. A unit test asserts post_impressions / post_engaged_users can never reappear on the request URL.
A retired Facebook metric can't blank the engagement counts againcd apps/api && npx vitest run src/test/adapter-facebook.test.ts2026-08-21✅ 24/24. Insights 400 + a healthy post-object call → comments/shares/likes still returned, clicks undefined, extra.insights_unavailable = 1. 429 still throws rate_limited (transient — a swallowed 429 would tell the worker to stop asking).
Attaching several images to a Facebook post publishes all of them, in one postcd apps/api && npx vitest run src/test/adapter-facebook.test.ts2026-08-21✅ 23/23. Three images → four Graph calls asserted in order: three /photos uploads each carrying published=false, then one /feed post whose body carries attached_media[0..2] with the returned fbids. Result extra reads post_type: "multi_photo", media_count: 3. Before this, only mediaUrls[0] was ever sent.
A failed photo upload never leaves a partial Facebook post publishedcd apps/api && npx vitest run src/test/adapter-facebook.test.ts2026-08-21✅ Second of two uploads returns 400 → the error names photo upload 2/2 and exactly two fetches happened: the feed post is never attempted. Over-10 images is refused with the count rather than trimmed.
An agent run writes nothing to the content tables before a human approves itcd apps/api && npx vitest run src/test/content-draft.test.ts2026-08-21✅ 10/10. The Drafter executor is run end-to-end against a mocked model; the run lands awaiting_approval with drafts in its proposal and prisma.post.count() is asserted to be 0.
Approving a Drafter run creates draft posts — and only draftscd apps/api && npx vitest run src/test/content-draft.test.ts2026-08-21✅ approve() creates the post with its script + caption, scheduledAt === null, variant status === "draft", attached to the single connected account. Approving the writing does not schedule or publish anything.
A failed apply cannot silently undo or 500 an approval that already committedcd apps/api && npx vitest run src/test/content-draft.test.ts2026-08-21✅ The apply's DB read is forced to reject; approve() still resolves with status: "applied" and the run carries an apply step with outcome: "failed" naming the error.
An ambiguous channel is left for a human rather than guessedcd apps/api && npx vitest run src/test/content-draft.test.ts2026-08-21✅ Two connected LinkedIn accounts → the post is still created (approved copy isn't discarded) with zero platform variants, and the step log reads "without a channel attached".
The Drafter refuses to run on unapproved or cross-tenant ideascd apps/api && npx vitest run src/test/content-draft.test.ts2026-08-21✅ Parent awaiting_approval → run fails with "must be approved"; a same-shaped parent in another org fails with a flat "Ideation run not found." that doesn't confirm the row exists.
A calendar preview writes nothing, and a sheet with any bad row writes nothingcd apps/api && npx vitest run src/test/calendar-import.test.ts2026-08-21✅ 22/22. The dry-run case asserts prisma.post.count() === 0 after a valid 2-row preview; the all-or-nothing case commits a file whose row 2 has an unparseable date and asserts created_post_ids === [] and a post count of 0 — the one good row is not written. A missing dry_run flag is asserted to default to a preview.
Imported posts are drafts with a proposed date — the import cannot publish anythingcd apps/api && npx vitest run src/test/calendar-import.test.ts2026-08-21✅ Commit test asserts post.scheduledAt === null, post.proposedPublishAt !== null, and every child variant status === "draft". This is what keeps the approval gate meaningful: the publisher only picks up scheduled variants and never consults an approval.
A bare date in a sheet is read in the project's timezone, not UTCcd apps/api && npx vitest run src/test/calendar-import.test.ts2026-08-21✅ Two cases, one per format: CSV 2027-03-04 09:30 under Asia/Kolkata → 2027-03-04T04:00:00.000Z; a real ExcelJS date cell for 2027-03-04 → 2027-03-03T18:30:00.000Z. Either read as UTC would move posts to a different day for the audience.
Excel (.xlsx) imports under the same rules as CSVcd apps/api && npx vitest run src/test/calendar-import.test.ts2026-08-21✅ 4 Excel cases build a real workbook in-memory via ExcelJS: commit path, date-cell timezone, trailing-blank-row skipping, and .xls refused with a "save as .xlsx or .csv" message.
An ambiguous platform is refused, not guessedcd apps/api && npx vitest run src/test/calendar-import.test.ts2026-08-21✅ Two connected Instagram accounts + no account_handle → a row issue naming both handles; adding account_handle resolves to the named account's id. A wrong guess would publish a month of content to the wrong brand.
The template we hand out imports cleanly against the parser that reads itcd apps/api && npx vitest run src/test/calendar-import.test.ts2026-08-21✅ Round-trip: templateCsv() is uploaded as a file and asserted to yield 2 importable rows with zero issues. Guards the drift where a column is added to the contract but not to the starter sheet.
The commit button can't be enabled on a sheet with problemsnpx vitest run src/lib/calendar-import.test.ts --root apps/web2026-08-21✅ 7/7 on summariseImport / canCommitImport — refuses on any row issue, on a file-level error, on an empty sheet and on an absent preview; allows a commit alongside the non-fatal "ignored columns" note.
An organization can install its own OAuth app credentials, and they beat the deployment's envnpm run test --workspace @verjson/api -- channel-credentials2026-08-14✅ 17/17. Precedence proven in both directions: with no row the Meta adapter resolves source: env and builds the auth URL from the env client id; after upsertOrgCredential the same call resolves source: org and the auth URL carries the org's client id. A second org is unaffected (still env)
Removing an org's credentials falls back rather than breaking the platformnpm run test --workspace @verjson/api -- channel-credentials2026-08-14✅ DELETE /channels/credentials/meta → 204, and the very next resolve returns source: env — proving the 60s TTL cache is invalidated on write, not left to expire. A row disabled via enabled: false falls through the same way instead of reading as "nothing configured"
The client secret never crosses the API boundary in either direction of a readnpm run test --workspace @verjson/api -- channel-credentials2026-08-14✅ JSON.stringify of both the PUT response and the GET list contains no occurrence of the secret; the list returns secret_last4: "wxyz". The audit row written on save contains the client id and no secret
The org-aware resolver introduces no regressions across publish, metrics and inboxnpm run test --workspace @verjson/api · npm run test --workspace @verjson/web · npm run lint · npm run build2026-08-14✅ API 1810/1820 — the 8 failures are pre-existing (Instagram adapter scope list, ads-reconciliation) and were confirmed by stashing this work and re-running the same two suites on the clean tree. Web 649/649, lint 0 errors (15 pre-existing warnings), both apps build
The OAuth callback exchanges the code against the same app that built the auth URLnpm run test --workspace @verjson/api -- channels.test2026-08-14✅ 16/16. The acting org is pinned into the signed state at start; the callback verifies state before resolving the adapter. Side effect asserted: an unknown adapter with an unsigned state now returns 401, so an anonymous caller can no longer probe which adapter keys exist by status code
Passkey enrollment failure reports its actual cause instead of "Something went wrong"npm run test --workspace @verjson/web -- src/lib/api.test.ts + live Network capture2026-08-14✅ apiError() now returns a browser-thrown Error's own message; 3 new cases cover detail-string, DOMException message, and the unchanged fallback for valueless throws. Root cause on the deployment was separate and confirmed from the wire: register/start returned "rp": { "id": "localhost" } while the page was on studio.lightningleap.store — WEBAUTHN_RP_ID unset, so config fell to its localhost default
Media URLs survive a change of public host — the bug that broke both thumbnails and publishinglive curl against a real stored asset2026-08-12✅ same asset, same key: the persisted url (…trycloudflare.com/…) returns HTTP 000; the key-derived url returns HTTP 200, image/jpeg, 2,657,868 bytes
The composer's constraints match the server's, instead of describing LinkedIn on every platformcode review against PLATFORM_MEDIA_SUPPORT + npm run typecheck · npm run lint2026-08-12✅ cap and header now read the same table the normaliser clips against (Instagram 10 / LinkedIn 9 / X 4 / TikTok 1); Visibility renders only when a LinkedIn account is targeted. 0 type errors, 0 lint errors
The four UI fixes introduce no regressionsnpm run typecheck · npm run lint · npm run test --workspace @verjson/web · npx vitest run --workspace @verjson/api2026-08-12✅ typecheck clean, lint 0 errors (15 pre-existing warnings), web 639/639. API 1794/1802 — the 8 failures are pre-existing and unrelated (Instagram adapter scope list deliberately dropped instagram_manage_insights without updating its test; ads-reconciliation; adapter-idempotency) and were failing before this work
A real Instagram account connects through the live OAuth flowmanual OAuth against Meta app 1072079535407131 + DB read2026-08-12✅ instagram | Self-Publish.ai | selfpublish.ai | connected | token present. Required a new Meta app: the previous one (1503896745100539) answered "API access blocked." to every Graph call and no one present could access its dashboard
Disconnecting a social account keeps its published posts and every metric row; only the credentials gonpm run test --workspace @verjson/api -- src/test/social-accounts.test.ts2026-08-12✅ 11/11. The disconnect case seeds a published variant + a PostMetricSnapshot, disconnects, then asserts the account row survives with null tokens and both history rows intact
The analytics endpoints still serve a disconnected account, and the hub renders themnpm run dev + curl + Playwright MCP2026-08-12✅ GET /social-accounts/:id/top-posts returned the same item (engagement 140) before and after DELETE. In the browser the retired account shows "Disconnected · analytics kept" and its Analytics tab renders Top posts (716 engagements: 640 ♥ / 55 💬 / 21 ↗ / 9.1k views) + the cadence heatmap. This is how the publishableAccounts over-filter was caught
A dormant account cannot take new work, and reconnecting revives the same rownpm run test --workspace @verjson/api -- src/test/social-accounts.test.ts + live curl2026-08-12✅ POST /posts against a disconnected account → 422 "Disconnected account(s) cannot receive new posts"; re-POST /social-accounts with the same identity returned the same id, connected, disconnected_at: null, and top-posts still reported 1 post
?purge=true is the only path that destroys analytics, and it still doeslive curl + psql2026-08-12✅ 204, then account_rows=0 variant_rows=0 metric_rows=0; the parent Post survives
The citation worker really was never firing, and a prompt can now be checked on demand end to endAPI log inspection + Playwright MCP against npm run dev2026-08-11✅ log showed 30 × "starting worker" and 0 sweeps across a working day. After the fix: clicking Check now flips the row to "checking all engines…", the server logs 4 queried, 0 hit(s), 22 source(s) recorded ~20s later, and the row settles to "checked just now" on its own
Running the tick is still deterministic — the on-demand path did not break the injectable clocknpm run test --workspace @verjson/api -- src/test/growth-ai-citations.test.ts src/test/growth-answer-engines.test.ts2026-08-11✅ 51/51. Caught a regression first: stamping last_checked_at from wall-clock instead of the injected now failed 2 cadence tests, and was reverted
The AEO Competitors tab lists the observed peer set and shows the project's own standing even at zeroPlaywright MCP against npm run dev2026-08-11✅ 138 observed sources / 51 domains. Your domains shows verjson.com and verjson.ai at 0.0%, 0 engines, "not cited"; Who you are up against leads with arxiv.org 15.9% (4 engines, #1) and huggingface.co 15.9% (3 engines, #1)
A check that cites nobody still records the engines' full source list, and the AEO tab reports who won instead of showing an empty boxLive tick + Playwright MCP against npm run dev2026-08-11✅ one prompt, 4 engines → { hits: 0, sourcesRecorded: 23 }; the tab renders "Checked 4m ago — you weren't cited" over a Who-owns-this-answer table led by cropin.com (2 engines, 3 citations, best #2)
The answer-source rollup ranks breadth over volume and distinguishes never-checked from checked-and-lostnpm run test --workspace @verjson/web -- src/lib/growth-helpers.test.ts · npm run test --workspace @verjson/api -- src/test/growth-answer-engines.test.ts2026-08-11✅ 6 web + 4 API cases, incl. "3 engines once beats 1 engine three times" and lastCheckedAtFrom([]) === null
Lint is back to zero errors across all three workspacesnpm run lint2026-08-11✅ 0 errors, 15 pre-existing warnings (all '_' is defined but never used)
The effect→render-phase conversion in the campaign wizard and calendar preserves behaviourPlaywright MCP against npm run dev2026-08-11✅ day-cell open prefills that date (2026-08-20); cancel → different day resets step to 1, clears typed input, shows 2026-08-03; ?post= schedule modal opens pre-filled with the next round hour (13:00 at 12:55)
The AEO citation tracker reaches real answer engines, and all four return citable sources — no engine is silently un-measurablenpm run smoke:engines --workspace @verjson/api2026-08-11✅ 4/4 engines returned sources and cited nextjs.org: ChatGPT 5 sources @ pos 1, Claude 5 @ pos 1, Gemini 5 @ pos 1, Perplexity 9 @ pos 3. The same run first exposed google/gemini-3.6-flash:online returning 0 annotations — slug moved to 2.5-flash
The citation reader is correct on every branch — annotation hit, subdomain, www./case, title fallback, inline mention, other-domain-only, malformed URLnpm run test --workspace @verjson/api -- src/test/growth-answer-engines.test.ts2026-08-11✅ 17/17
Running the test suite cannot make live, billable provider calls even with a real OPENROUTER_API_KEY in the root .envnpm run test --workspace @verjson/api -- src/test/growth-ai-citations.test.ts2026-08-11✅ 24/24 in 7.4s. Before the NODE_ENV=test mock-mode guard this suite hit the network and timed out at 30s — the runtime drop is the proof
Campaign creation supports type-driven wizards — default to other, explicit type persists, unknown 422s at the contract layer, update accepts a type change (F-023 / F-024)npm run test --workspace @verjson/api -- src/test/campaigns.test.ts2026-08-10✅ 14/14 in 7.4s (4 new cases on the type field, 10 pre-existing)
Real Google Ads, Meta Ads, X and YouTube adapter behaviour is covered — resumable-upload chunking + Range-header resync (YouTube), budget/bid mutation with update_mask (Google Ads), CBO-vs-ad-set 400 mapping and manual-bid skip (Meta Ads), the x.com OAuth host (X) — including quota/error surfaces (PR #116)npm run test --workspace @verjson/api -- src/test/adapter-google-ads.test.ts src/test/adapter-meta-ads.test.ts src/test/adapter-x.test.ts src/test/adapter-youtube.test.ts2026-08-10✅ 103/103 across 4 suites in 25.3s
A user who signs up through the app lands with a permission set that can actually create a project — not just a user.role label (B-011)npm run test --workspace @verjson/api -- src/test/signup-with-org.test.ts2026-08-05✅ 4/4. Verified as a regression test by reverting auth-v2/routes.ts: 3 of the 4 fail against the old code
npm run db:push, npm run seed and npm run rbac:backfill work from a clean clone with only cp .env.example .env done (B-013)npm run db:push · npm run seed · npm run rbac:backfill --workspace @verjson/api, each run with no env sourced into the shell2026-08-05✅ all three; the Prisma package.json#prisma deprecation warning is gone too
Lint really is at zero errors, boundary rules included (B-014)npm run lint2026-08-05✅ 0 errors, 15 pre-existing warnings
The full API and web suites passnpm run test --workspace @verjson/api · npm run test --workspace @verjson/web2026-08-05✅ 1634 API · 533 web. Note: the API suite reads the developer's own root .env, so a local WEB_URL that isn't http://localhost:3000 fails 4 oauth.test.ts cases — a test-isolation gap, not a defect in the code under test
All three workspaces typecheck, including across the contract boundarynpm run typecheck2026-07-29✅ api · web · contracts all clean
Lint passes, including the import-boundary rules that keep web away from the DB and core/ away from Hononpm run lint2026-07-29✅ 0 problems
The web app builds for productionnpm run build --workspace @verjson/web2026-07-29✅
The landing page and /dashboard render in a real browser with no console errorsPlaywright MCP against npm run dev2026-07-29✅ verified interactively
The dashboard is really driven by the JSON — its counts match data/dashboard/features.jsonnode -e "…" count check + on-screen totals2026-07-29✅ counts agree

| The landing page's interactive sections all work — palette, gate, tabs, filters, timeline | npm run test:e2e --workspace @verjson/web (landing.spec.ts) | 2026-07-29 | ✅ 12/12 | | A change cannot reach "applied" without an approval click, and the bypass control applies nothing | npm run test:e2e (landing › "there is no path around the gate") | 2026-07-29 | ✅ | | The approval demo records both actors — the agent that proposed and the human that approved | npm run test:e2e (landing › "propose → approve → audited") | 2026-07-29 | ✅ | | Delayed on-scroll reveals actually reach opacity 1 (the B-009 regression) | npm run test:e2e (landing › "delayed reveals actually become visible") | 2026-07-29 | ✅ asserts computed opacity, not presence | | The landing page never scrolls sideways at 1440 · 1280 · 1024 · 768 · 390 px | npm run test:e2e (landing › "the page never scrolls sideways") | 2026-07-29 | ✅ 0px overflow at all five | | The landing page renders with no console errors, desktop and mobile | headless Chromium against npm run dev, 1440px and 390px | 2026-07-29 | ✅ zero errors | | The brand retheme did not break the other surfaces | headless Chromium over /dashboard /login /docs /style-guide at 1440px | 2026-07-29 | ✅ 0 console errors, 0px overflow on all four | | Every image on the landing page is served from our own origin, not a third-party CDN | grep -r "images.unsplash.com" apps/web/src → no hits; all src are /img/* | 2026-07-29 | ✅ 12 files in public/img, 2.1 MB, all referenced |

| The API serves projects end to end against real Postgres — 30/30 cases | npm test --workspace @verjson/api | 2026-07-29 | ✅ 30 passed (2 files) | | A cross-organization project read returns 404, not 403 | npm test --workspace @verjson/api (T-008) | 2026-07-29 | ✅ | | An agency user sees only the projects they are a member of | npm test --workspace @verjson/api (T-009) | 2026-07-29 | ✅ | | Signup → create a project → pick a mix → set budgets → the roll-up reads $18,500 | Playwright MCP against npm run dev + npm run dev:api | 2026-07-29 | ✅ verified interactively, zero console errors |

| The whole gate passes: typecheck → lint → 50 API tests → 5 e2e → both builds | npm run verify (plus npm run test:e2e) | 2026-07-29 | ✅ | | A forged or expired OAuth state is rejected | npm test --workspace @verjson/api (T-017) | 2026-07-29 | ✅ | | The OAuth redirect cannot be pointed off-origin | npm test --workspace @verjson/api (T-018) | 2026-07-29 | ✅ | | A tampered encrypted secret fails to decrypt rather than yielding garbage | npm test --workspace @verjson/api (T-019) | 2026-07-29 | ✅ | | The API produces a runnable bundle | npm run build --workspace @verjson/api | 2026-07-29 | ✅ dist/main.js |

| The API and the worker both build from one image | npm run build --workspace @verjson/api → dist/main.js + dist/worker.js | 2026-07-29 | ✅ | | Every job kind has a handler | npm test --workspace @verjson/api | 2026-07-29 | ✅ |

| A validly-signed OAuth state without its browser cookie is rejected | npm test --workspace @verjson/api (oauth) | 2026-07-29 | ✅ | | An unrecognised role sees zero projects — the scope filter fails closed | npm test --workspace @verjson/api (projects) | 2026-07-29 | ✅ | | A tampered or wrongly-signed Stripe webhook is 400 and records nothing | npm test --workspace @verjson/api (billing) | 2026-07-29 | ✅ | | A replayed and a concurrently-delivered Stripe event each apply exactly once | npm test --workspace @verjson/api (billing) | 2026-07-29 | ✅ | | A job round-trips through RabbitMQ, and a failing job retries with attempt 2 via the broker TTL | docker compose up -d rabbitmq && npm test --workspace @verjson/api | 2026-07-29 | ✅ 80/80, no skips |

| Every audit rule fires on exactly its own defect, and a clean page produces none | npm test --workspace @verjson/api (growth-rules) | 2026-07-29 | ✅ 45 cases | | robots.txt precedence, longest-match and URL normalisation behave | npm test --workspace @verjson/api (growth-rules) | 2026-07-29 | ✅ | | The audit score normalises per page | npm test --workspace @verjson/api (growth-rules) | 2026-07-29 | ✅ |

Pass 7 — E9 channel adapters + E3 publisher + E5 metrics

ClaimProof (exact command)Last runResult
The whole API suite passes with the E9/E3/E5 additionsnpm test --workspace @verjson/api2026-08-03✅ 255/255 (17 files)
The E9 channel-adapter framework — registry, vault, OAuth harness — round-trips end-to-end via a mock adapternpm test --workspace @verjson/api -- src/test/channels.test.ts2026-08-03✅ 15/15
A forged or expired channel-OAuth state returns 401 (JWT audience check + TTL)npm test --workspace @verjson/api -- src/test/channels.test.ts (oauth harness)2026-08-03✅
A channel-OAuth state signed for one adapter but used on another's callback is rejectednpm test --workspace @verjson/api -- src/test/channels.test.ts (adapter-mismatch case)2026-08-03✅
Re-connecting the same social account rotates tokens rather than creating a duplicate rownpm test --workspace @verjson/api -- src/test/channels.test.ts (re-connect case)2026-08-03✅ upsert on (project, platform, account_platform_id)
OAuth tokens are stored encrypted at rest (raw text never in the DB); vault round-trips + preserves NULLnpm test --workspace @verjson/api -- src/test/channels.test.ts (vault)2026-08-03✅ AES-256-GCM, purpose-namespaced HKDF key
A tampered channel-OAuth state fails to verify rather than yielding garbagenpm test --workspace @verjson/api -- src/test/channels.test.ts (tampered-state)2026-08-03✅
The Instagram and LinkedIn adapters build correct authorize URLs and map platform responsesnpm test --workspace @verjson/api -- src/test/channels-adapters.test.ts2026-08-03✅ 14/14 (fetch mocked)
LinkedIn OAuth completes live against the real platform: connect → SocialAccount row with encrypted tokens + attributed userlive smoke: Sign in with LinkedIn on /projects/…/organic/linkedin, then select attributed, has_token, token_expires_at from social_accounts_connected where platform='linkedin'2026-08-03✅ real account connected, token expires ~60d out
The E3 publisher fires scheduled posts on time; rolls failures back to failed; token-death marks account disconnected; two ticks racing on the same post only publish oncenpm test --workspace @verjson/api -- src/test/publisher.test.ts2026-08-03✅ 8/8 (sole-writer claim proven)
The E5 metrics worker only polls due published posts, respects min-interval + retention, handles rate-limit + token-death correctly, and GET /posts/:id/metrics is tenant-scopednpm test --workspace @verjson/api -- src/test/metrics.test.ts2026-08-03✅ 10/10
Uploaded media (image + video) survives the round-trip: browser → /uploads → MinIO → /uploads/:key gatewaymanual smoke: attach image in composer, refresh, thumbnail renders from /uploads/:key2026-08-03✅
Both boot-time workers register and log their tick configgrep -E 'publisher|metrics|adapters' /tmp/marketing-studio-api.log2026-08-03✅ [publisher] tick 30000ms · [metrics] tick 900000ms min-interval 3600000ms retention 30d · [api] channel adapters registered: linkedin
C0 organic hub tile grid + C1 generic Connect modal (pure helpers behind both)npm run test --workspace @verjson/web -- social-connect.test.ts2026-08-06✅ 18/18 (platformCopy · platformScopes · describeAccountsCount · resolveConnectStatus · pickAdapterKey · groupAccountsByPlatform · getConnectGuide · ORGANIC_HUB_PLATFORMS)
A Google Ads campaign carries the settings it needs to run, and none of them survives as an unchecked stringcd apps/api && npx vitest run src/test/ads.test.ts2026-09-02✅ 56/56. google_settings is a first-class, .strict() field on the campaign contract that the domain folds into AdCampaign.extra — no migration, but a float daily_budget_minor, a zero budget, a repeated network, an invented network or language, a 01-01-2026 date, an end_date before its start_date, and the typo daily_budget_minors are each 422, not a bag key nobody reads. A campaign with no Google settings stores no google_settings key at all, so "never configured" stays distinguishable from "configured, then cleared". A rename does not drop the budget (the same regression special_ad_categories guards against, on the field where losing it means a campaign that silently stops serving), sending a new object replaces it wholesale because the form always sends what it is showing, {} clears it without touching the category declaration, and a duplicate carries it — a copy that lost its budget is a draft that cannot run.
Budget, campaign and targeting reach Google as one atomic write, in Google's vocabulary, or not at allcd apps/api && npx vitest run src/test/adapter-google-ads.test.ts src/test/adapter-meta-ads.test.ts2026-09-02✅ 67/67. A Google campaign is three resources — a CampaignBudget it points at, the campaign, and CampaignCriterion rows for geo and language — so createCampaign writes them in ONE googleAds:mutate with temporary negative resource names, asserted: the criteria hang off the campaign's temp name, and 5000 cents becomes "50000000" micros on a STANDARD, explicitlyShared: false budget. Sequential calls were the alternative, and their failure mode is an orphan campaign on the platform whose id we never stored, which the caller's retry then duplicates. For the same reason geoTargetConstants:suggest runs FIRST: an unresolvable name is invalid_request with only the lookup having happened (asserted calls length 1) rather than a campaign quietly targeting fewer places than the user listed. Global is a sentinel for "no location criteria" and skips the lookup entirely. Networks map to the flags Google actually has, pinned because the mix-up is invisible: our search is targetGoogleSearch, our search_partners is targetSearchNetwork, and targetPartnerSearchNetwork is a different, allow-listed network that is always false. No network chosen omits networkSettings rather than sending four falses. Neither google_settings nor special_ad_categories is ever posted as a campaign field by either adapter — Google drops Meta's declaration, Meta drops Google's settings — while a key that IS the platform's own vocabulary still passes through. updateCampaign sends only what lives on the campaign resource, with per-leaf snake_case mask paths (network_settings.target_google_search), and keeps the budget and the criteria out of the mask entirely.
A Google Ads flow never asks a Meta questioncd apps/api && npx vitest run src/test/ads.test.ts · npm run typecheck · manual: open Performance → Google Ads → New campaign, and → Creatives → New creative2026-09-02✅ Special Ad Categories are Meta's regulated-ad regime and the Facebook Page ID is Meta's publishing requirement; Google has neither. Both are now gated on platform, so a Google campaign form shows budget / networks / locations / languages / flight dates instead of a compliance declaration that goes nowhere, and the creative modal reads "New Google Ads creative" with no Page ID field — hidden rather than made optional, because an optional field nobody can fill in is still a question. The Page ID key is omitted from assets on Google rather than sent empty. platform is threaded from the hub's CreativesTab and derived on the campaign detail page from the campaign's ad account, defaulting to Meta where no platform is in hand.
An ad group can be created from the hub, and its keywords survive exactly as typedcd apps/api && npx vitest run src/test/ads.test.ts · npm run typecheck2026-09-02✅ 60/60. The Ad groups tab used to say ad-group creation was "a follow-up UI slice"; it now has a + New ad group button beside the campaign picker — beside it deliberately, since the campaign the picker shows is the parent the group lands in. keywords and cpc_bid_minor are validated fields on AdTargeting rather than .catchall bag keys: an empty keyword, one over 80 characters, and a bid of 0, -5 or 2.5 are each 422. Keywords are stored verbatim, asserted with all three match types (running shoes, "running shoes for men", [best running shoes]) — Google expresses match type IN the keyword, so normalising the quotes and brackets away would silently widen every phrase and exact keyword into a broad one, which is the most expensive default on the platform. An ad group created without them stores keywords: [] (list-shaped fields default to empty so consumers need no null check) and no cpc_bid_minor at all, because an explicit zero bid is a bid, not an absence. Neither key can reach Meta: toMetaTargeting builds from a named key list, asserted directly against a mixed spec. The form dedupes and drops blank lines client-side, and useCreateAdGroup invalidates the campaign's ad-group query, so the table behind the modal refetches with no manual refresh.
The rest of the build is unchanged by the Google Ads campaign / ad-group workcd apps/api && npx vitest run · cd apps/web && npx vitest run · npm run typecheck · npm run lint --workspaces --if-present2026-09-02✅ API 2457/2460 across 146 files; web 869/869 across 55 files; typecheck clean in all three workspaces; lint reporting only apps/web's 19 pre-existing warnings and nothing in the touched files. ⚠️ The 3 API failures are the same ads-credentials.test.ts ones recorded above (resolveAdAdapter returns Meta Ads (mock) where the test wants Meta Ads) — still pre-existing and unrelated, re-proven here by stashing every server-side change and re-running that file against the clean tree, which fails identically 3/6.
An ad carries its own copy, and the copy cannot be lost by editing something elsecd apps/api && npx vitest run src/test/ads.test.ts2026-09-02✅ 44/44. A Google responsive search ad is headlines + descriptions + a final URL + display paths, and none of it had anywhere to live: adCreateSchema took a name and a creative id. Adds Ad.content (JSONB, {} default, additive migration 20260902120000_ad_content) behind a .strict() adContentSchema. Pinned rejections: a 31-character headline, a 91-character description, a 16th headline, a 5th description, a repeated line, a 16-character display path, a non-URL destination, and the typo headline (no s) are each 422. An ad created with no content stores {} and status draft, so every pre-existing caller is unchanged. A rename does not wipe the copy — the regression adCampaignBase documents, since .partial() does not strip a .default(); content is deliberately undefaulted on the create schema so the derived update schema cannot carry an empty bag. Sending a new bag replaces it wholesale, asserted down to a dropped path1, because the form sends what it is showing and a merge would make a deleted headline unremovable.
The copy lives on the ad, not on the shared creativecd apps/api && npx vitest run src/test/ads.test.ts · sed -n '/^model Ad /,/^}/p' apps/api/prisma/schema.prisma2026-09-02✅ AdCreative is reusable across ads by design — publish.ts attaches one creative to many ads — so headlines stored there would be shared by every ad referencing that asset, and editing one ad's copy would silently rewrite another's with no error and no audit trail. content is therefore a column on ads, and the modal's creative picker stays a separate field from the copy fields: the creative is the picture, the content is the words. The migration is purely additive (one JSONB column with a {} default, no backfill), which is what makes it safe against a populated database — the failure mode CLAUDE.md flags for this repo.
Ads can be created, previewed, toggled and deleted from the hub, and the form cannot ask for something Google refusesnpm run typecheck · cd apps/api && npx vitest run src/test/ads.test.ts · manual: Performance → Ads → New ad2026-09-02✅ The Ads tab said ad creation was "a follow-up UI slice" and its table showed a raw creative UUID. It now has a + New ad button gated on BOTH pickers having resolved (an ad has exactly one parent ad group), and a table with Name, status Badge, a Preview showing the first headline over the display URL, and Pause/Resume · Edit · Delete. The toggle is offered only between active and paused — draft is where an ad starts and ended/removed are terminal enough that a one-click toggle would be a trap. Delete warns that a published ad keeps running on the platform after our row is gone. Headlines and descriptions are add/remove lists with live per-line counters driven by the SAME exported constants the schema validates on (AD_HEADLINE_MAX_CHARS and friends), so the counter and the server's 422 cannot disagree; remove is disabled at the minimum rather than the removal being undone afterwards, and the inputs carry no maxLength because truncating somebody's headline mid-word is worse than showing them it is four characters over. Rows whose copy is short of Google's 3-headline / 2-description threshold say so in the table via describeAdContentGaps — the one home for the minimums — rather than waiting for a publish that fails naming an ad nobody remembers writing.
A Google creative is whichever of five unrelated things it actually is, and its metadata is validated per kindcd apps/api && npx vitest run src/test/ads.test.ts2026-09-03✅ 50/50. Google has no single "creative" — it has an asset library where an image, a text block, a sitelink, a callout and a video share a name and a project and nothing else, so assets is shaped per kind rather than being one superset with everything nullable. All five round-trip with their own bag, asserted field for field. Rejections pinned at Google's limits: business name >25, short headline >30, long headline >90, description >90, sitelink text >25, either sitelink line >35, callout >25, an invented aspect ratio, a non-URL destination, and a video URL that is not YouTube — including https://evil.test/?u=youtube.com , which the host-anchored regex refuses (an mp4 URL is a video that would never play, and that failure would otherwise surface at publish time). youtu.be and m.youtube.com are accepted.
Validating the asset bag cannot break the kinds it does not model, or delete keys it does not knowcd apps/api && npx vitest run src/test/ads.test.ts src/test/ads-creatives-pipeline.test.ts src/test/ads-publish.test.ts2026-09-03✅ 50/50 + 73/73. kind stays FREE TEXT — the set is platform-defined and grows — so a carousel with an arbitrary bag is still a 201, keeping the historical opaque-bag behaviour for every kind this app has not modelled; a discriminated union would have made the first unmodelled platform kind a 422. For the five kinds that ARE modelled the schemas use .catchall(z.any()) rather than .strict(): media_url (the publish path's escape hatch for an externally hosted image) and any legacy key survive a round-trip, asserted. Zod's default — silently STRIPPING unknown keys — was the option to avoid: it deletes those keys on the next save with no error and no way to notice.
A rename cannot wipe a creative's assetscd apps/api && npx vitest run src/test/ads.test.ts2026-09-03✅ Pre-existing bug found while adding the above and fixed here. adCreativeUpdateSchema was adCreativeCreateSchema.partial(), and .partial() does NOT strip a .default() — so every PATCH that never mentioned assets arrived carrying {}, and updateAdCreative wrote it, silently deleting the destination URL, caption and Facebook Page ID of a creative somebody only meant to rename. Exactly the failure adCampaignBase documents, on a different model. Both schemas now derive from an undefaulted adCreativeBase; a name-only PATCH is asserted to leave the bag untouched.
The creative modal titles itself correctlynpm run typecheck · manual: Performance → Google Ads → Creatives → New creative2026-09-03✅ The Google title read "New Google Ads Ads creative" — a duplicated word introduced when the modal was made platform-aware, because the label already carried "Ads" and the template appended it again. Now "New Google Ads Creative" / "New Meta Ads Creative", with the same fix on the edit title.
The reports window is what it says it is, in the timezone the data is stored incd apps/web && npx vitest run src/lib/ads.test.ts2026-09-03✅ 35/35. The tab had one fixed 30-day window; it now offers 7 / 30 / this month / 90 days. this_month is month-TO-DATE, asserted separately because it cannot be expressed as a day count — on the 1st it is a single day and on the 30th it is thirty, so dateRangeLastNDays(n) would need a different n daily and would be wrong every month. Every preset resolves in UTC, pinned with a 23:30 UTC clock: AdMetricSnapshot.day is a UTC calendar date, and resolving in local time shifts the window a day for anyone east of UTC and silently drops the edge day. dateRangeLastNDays took an injectable clock so this is testable without freezing global time.
A breakdown nobody measured never renders as a breakdown of zerocd apps/web && npx vitest run src/lib/ads.test.ts2026-09-03✅ rollUpDevices and rollUpKeywords return null, not [], when no snapshot in the window carried segment data, and the widgets render a named "not collected yet" state for null and an empty state for []. These are different facts — "nobody collected this" versus "this campaign got no mobile traffic" — and only the second is a reason to move a budget; a zeroed chart asserts the second while meaning the first. A malformed segment bag (devices: "not-an-array", a keyword row missing its text) also reads as null rather than rendering a partial parse. Keyword CTR is recomputed from summed clicks ÷ summed impressions, pinned against averaging daily rates: averaging weights a 10-impression day the same as a 9,990-impression one, which is how a keyword nobody saw tops a "best CTR" list (10 bp, not ~2,500). The same text under EXACT and BROAD stays two rows, keyed on the JSON-encoded pair.
The CSV a human opens says what it meanscd apps/web && npx vitest run src/lib/ads.test.ts2026-09-03✅ Money is emitted in MAJOR units with two decimals — the one deliberate break from this codebase's minor-units-everywhere rule, asserted both ways (49.99 present, 4999 absent), because a spreadsheet column of 4999 under a heading that says spend is a number somebody pastes into a report as five thousand dollars. The currency code is in the header so the figure is never ambiguous. Rows are newest-first, matching the table on screen, so the file and the page cannot disagree about what was exported. Cost-per-conversion is BLANK, not 0.00, when nothing converted — 0.00 reads as free conversions. Cells are quoted only when they need it, pinned with goog,le"x. The button is disabled with no rows rather than downloading a header-only file, which reads as a broken export.
Six KPIs and a daily trend, from the snapshots already on the pagenpm run typecheck · cd apps/web && npx vitest run · manual: Performance → Reports2026-09-03✅ 881/881 web. Spend, impressions, clicks, CTR, avg CPC, and conversions with cost-per-conversion as the card's sub-line rather than a seventh card — a CPA of £40 beside 1 conversion is a different story from the same number beside 400, and splitting them invites reading the first as the second. The chart is inline SVG rather than a charting dependency every page importing the hub would pay for: spend as an area, clicks as a line, on independent axes because the units are unrelated and one scale renders the clicks flat on the floor — the caption says the crossings mean nothing. It scrolls inside its own container so 90 days of points stay readable, and carries an aria-label naming the window and both peaks.

Not yet verified

Claims that are not made until there is a command behind them:

ClaimWhat is missing
The approval gate cannot be bypassedT-010 — blocked on F-035
Stripe webhooks are idempotentT-016 — blocked on F-016
A live Google/GitHub round-trip completesNeeds real client credentials — the flow's security-carrying parts (state, redirect allow-list, verified-email linking) are covered by tests; the provider hop is not
A live Instagram OAuth round-trip completesAdapter is code-complete; needs a real Meta app + an IG Business account linked to a Facebook Page. LinkedIn is proven, IG waits on the setup.
A live LinkedIn image publish reaches the platformPublish path is code-complete + tested against a mocked fetch; a real image post to the connected feed hasn't been smoke-tested end-to-end this session.
A scheduled post fires at its scheduled time and hits LinkedInPublisher worker is running and the state-machine + failure paths are unit-tested; the "wait 60 seconds, watch it publish" live smoke hasn't been run this session.
The metrics worker actually pulls likes/comments on a real published postAdapter is tested against a mocked /v2/socialActions response; a real 15-min tick against LinkedIn hasn't been left running yet (METRICS_TICK_MS=15000 for a faster smoke).
The Docker images build and rundocker compose --profile apps up --build has not been run
Kubernetes manifests are correctThey do not exist yet (F-081)
Retry backoff does not head-of-line blockOne retry queue per kind means a message parked for 80s holds up a 5s retry behind it, because RabbitMQ only dead-letters from the head. Known; needs one queue per delay tier or the delayed-message-exchange plugin.
Plan caps hold under concurrencyassertWithinLimit is read-then-act, so two simultaneous creates can both pass. Needs a constraint or a transaction.
A leaked refresh token can be revokedThere is no denylist; logout only clears localStorage, so a stolen refresh token stays valid for 14 days. Needs a tokenVersion on User.

Rules

  • "It works" is not a claim — name the behaviour.
  • Paste the exact command, not a description of it.
  • Re-run before changing Last run. Stale rows are worse than missing ones.
  • If a suite is red, the row says red. A board that flatters the build is worse than no board.