Persona testing as a service — the middle layer
Status: DESIGN (2026-07-28), Sandon-directed. The productization of persona testing: from "we test our own site + one partner" to any site owner can request persona testing for their own goals and get honest findings back. This doc is the plan; nothing here is built yet.
The engine already exists (persona-testing-core.ts: browse → extract → calibrate, first-person + absence-grounded, battle-tested against QuickSites). The middle layer is everything between a request and that engine: intake, authorization, the runner, and delivery.
Decisions made (2026-07-28)
- Intake: verified self-serve — an owner proves they control the domain, then can request testing themselves.
- Authorization: both — domain-ownership verification (proves it's their site) and a human approves the scope before any browse. Belt-and-suspenders, because pointing our browsers at a third-party site is the entire risk surface.
- Delivery: a shareable report page — a per-request public link (
/persona-report/<token>) that renders the findings, first-person and honestly scored. Doubles as the deliverable and the marketing artifact.
Why the caution is load-bearing
Testing your own site is safe. Testing someone else's on request opens real abuse vectors that the whole design exists to close:
- DDoS-by-proxy — someone points our fleet at a victim URL. → rate limits + cost caps + robots.txt.
- Testing a site they don't own — recon/harassment dressed as "testing." → domain-ownership verification + human approval.
- Destructive actions — form submits, checkout, account creation on a stranger's site. → read-only enforcement (the existing browse constraints, hardened).
- "Human testing" misrepresentation — the honesty rule travels: every finding is an AI-persona observation, labeled at delivery, never sold as human QA.
The safety spine (every request passes all of these): verified domain · human-approved scope · read-only only · robots.txt respected · rate-limited + per-request cost-capped · AI-persona labeled.
Architecture
Owner ──▶ [1] Request form ──▶ persona_test_requests (status: pending_verification)
│
[2] Verify domain ────────┤ (meta tag / DNS TXT / /.well-known file)
▼ status: verified
[3] Human approves scope ─┤ (super-admin reviews site + goals)
▼ status: approved
[4] Runner ──────────────┤ reuses browseAndExtract, third-party-safe
(personas browse the │ → persona_test_findings (request_id)
site with the goals) ▼
[5] Report page ──────────┘ /persona-report/<token> (shareable, public)
1. Intake — persona_test_requests
{ id, owner_user_id (nullable — could be a logged-out requester), contact_email, site_url, goals[] (free-text goals the owner cares about), audience_hint (which persona archetype), status, verification_token, verification_method, report_token, created_at, approved_by, approved_at }.
Status lifecycle: pending_verification → verified → approved → running → delivered (+ rejected, failed).
2. Verification (net-new — the keystone)
On request, mint a random verification_token. The owner proves control via any one of:
- Meta tag:
<meta name="persona-testing-verification" content="TOKEN">on the site root. - DNS TXT:
persona-testing-verification=TOKEN. - Well-known file:
/.well-known/persona-testing/TOKEN.
A POST /verify fetches the site/DNS and confirms. Verified domains are remembered (a persona_test_domains table keyed by host) so repeat requests skip re-verification. No existing HJ pattern for this — it's the main new build.
3. Approval
A super-admin sees verified requests in the cockpit (extends today's /dashboard/admin/persona-testing), reads the site + goals, and approves or rejects. Approval is where a human catches "verified but sketchy" (a competitor's site they happen to control, goals that push toward writes, etc.).
4. Runner (third-party-hardened)
Reuses browseAndExtract per goal, but adds third-party safety the self-test never needed:
- robots.txt fetched + respected for our user-agent (net-new — the browse path has none today).
- A distinct, honest user-agent identifying the persona-testing bot + a contact URL.
- Per-request caps: max N browses, max total cost, on top of the existing 20-step / 25¢-per-browse cap.
- Read-only, hardened: the goals themselves are constrained/validated to non-destructive intents (no "sign up", "buy", "submit") at approval time.
Findings land in
persona_test_findings(same shape aspersona_self_findings, plusrequest_id).
5. Delivery — the shareable report (+ Slack / webhook)
A public page /persona-report/<report_token> (unguessable token, not enumerable) rendering the request's findings — first-person, per-goal, honestly scored (the same scoreboard: browses / frictions / severity). Mirrors /persona-testing's design. The owner can share it; it's also our best sales artifact ("here's what our personas found on your site").
Delivered where you work (net-new, an advertised differentiator). Per request, the owner can pick a notify_channel + notify_target: a Slack message to their team channel (an Incoming Webhook URL) or a generic webhook (POST of the report JSON to their own systems). Both are SSRF-guarded at intake AND at send time (assertSafePublicHost), https-only, best-effort (a failed ping never sinks the run), and stamped notified_at. Service: (persona-test-notify.tssendReportNotification, notifyRequestDelivered) — the runner calls notifyRequestDelivered(requestId, summary) right after it delivers a report.
6. Monetization — credits, pay-per-use (built 2026-07-28)
Model decided: credits + a funnel-first free tier (a site owner who lets our personas browse their live site is a warm lead for the whole mesh; marginal cost of a browse is pennies, hard-capped by persona_browse_throttle).
- 1 credit = one persona goal-browse. A report costs
estimateCredits(goals) = goals × 1. - Free tier:
FREE_MONTHLY_CREDITS = 10granted every calendar month, idempotently (≈3 three-goal reports). No card. - Credit packs (Stripe Checkout, per-credit price falls with size): Small 25/$9, Medium 100/$29, Large 300/$69.
- Ledger is the source of truth —
balance = SUM(delta)overpersona_test_credit_ledger; no mutable balance column. Debits are idempotent per request (uq_ptcl_report_run), monthly grants idempotent per owner-month (uq_ptcl_monthly_grant). Stripe grants use the same atomic pending→paid claim asdrift-coin-purchasesso at-least-once redelivery never double-credits. - Service:
. Migrationpersona-test-credits.ts599_persona_test_credits.sql. Webhook branch inroutes/payment.ts(kind: 'persona_test_credit_purchase'). Routes:GET /api/persona-testing/credits(owner summary),GET /credits/packages(public price list),POST /credits/checkout. - The debit fires from the runner (Phase 4): call
debitForReport(ownerUserId, requestId, goalCount)when a run actually starts;{ok:false, shortfall}routes the owner to buy a pack. Requesting + verifying stays free (funnel-first).
What we reuse
| Need | Reuse |
|---|---|
| Browse → first-person finding | persona-testing-core.ts (browseAndExtract, the calibrated extractor, absence-grounding) |
| Owner identity / keys | the partner-provisioning pattern (about-that-provisioning-core, coaching-provision) |
| Findings + curation UI | today's persona_self_findings + /dashboard/admin/persona-testing cockpit |
| Report page design | /persona-testing (painterly hero, honest scoreboard, first-person cards) |
| Delivery-to-partner (later) | the QS receiver model (crosstalk/contracts/persona-testing.md) as an optional 2nd delivery channel |
Build order (each slice ships behind the super-admin gate first)
- ✅ Data model —
persona_test_requests,persona_test_domains,persona_test_findings(migration 598). - ✅ Verification — token mint +
POST /verify(meta/DNS/well-known) + instructions surface.persona-test-service.ts. - ✅ Intake form — owner surface
/dashboard/persona-testing: request form (site + goals + audience + contact + Slack/webhook delivery), credit balance + buy-pack, inline verification instructions + "Verify now". Entry points from the public case study ("Test your own site — free to start"). - ✅ Approval + runner —
: the cockpitpersona-test-runner.ts/dashboard/admin/persona-testingshows a request queue (approve / reject / re-run); on approval the third-party-hardened runner (robots.txt respected,PERSONA_TEST_UA, inherited 20-step/25¢ caps + kill switch) debits credits once, runs one persona per goal, stores findings, delivers, and pings Slack/webhook. - ✅ Report page —
/persona-report/<token>(shareable, noindex, token-gated public routeGET /api/persona-testing/report/:token). - ✅ Owner status view — the same
/dashboard/persona-testinglists the requester's requests with a live status label + "Verify" / "View report" per row. Ondelivered, the report link is also emailed to the request'scontact_email(or the owner's account email) viasendReportEmailinpersona-test-notify.ts(Resend, best-effort, synthetic-address-safe) — alongside the Slack/webhook push.
All six phases shipped. The service is self-serve end-to-end: request → verify → approve → hardened browse (credits debited) → delivered report, pushed to Slack/webhook + emailed.
Open questions (not blockers for Phase 0 data model)
Business model:DECIDED 2026-07-28 — credits, pay-per-use, funnel-first free tier. See §6 Monetization. Built: ledger + free monthly grant + Stripe packs + notify (Slack/webhook) delivery. Remaining: the debit only bites once the Phase-4 runner callsdebitForReport.- Logged-out requesters: allow anonymous requests (email-only) or require an HJ account? Recommend: require an account — ties verification + report ownership to a user, cuts abuse.
- Goal safety: auto-validate goals against a non-destructive allowlist at intake, or rely on human approval? Recommend: both — a soft check at intake, the human as backstop.
- Findings visibility: report public-by-token vs owner-only-until-they-share. Recommend: unguessable token, owner controls sharing.
Positioning
This is the capability the QuickSites test was proving out ([[project_persona_qs_testing]]), generalized into an app-neutral service ([[feedback_generalize_backend_apis]]). It's a natural top-of-funnel for the whole mesh: a site owner who lets our personas test their site is a warm lead for everything else — and the honesty discipline (a harness that can come out against you) is exactly the trust signal that makes it worth trying.