diff --git a/.project/history/20260303_debanking-hallucination-remediation.md b/.project/history/20260303_debanking-hallucination-remediation.md new file mode 100644 index 0000000..a469f32 --- /dev/null +++ b/.project/history/20260303_debanking-hallucination-remediation.md @@ -0,0 +1,169 @@ +# Handoff: Debanking Hallucination Remediation + +**Date**: 2026-03-03 +**Status**: Ready for execution +**Priority**: P0 — Foundational factual error affecting 104 files / 188 occurrences + +--- + +## The Problem + +The founder has **never been personally debanked**. Claude hallucinated a first-person debanking experience early in content generation, and it propagated across the entire content strategy as a foundational narrative element. "I got debanked" became a recurring QV testimony anchor, and "the founder experienced banking discrimination firsthand" became a VL institutional framing. + +The 46% banking discrimination statistic for sex workers is **real** (sourced from research). The founder's *personal* debanking story is **fabricated**. + +## Scope + +**188 occurrences of "debank" across 104 files** in `operations/content-strategy/`. + +### Categories of affected content: + +1. **Author voice profiles** (5 files) — bios, voice descriptions + - `authors/QuinnValentine.bio.md` (3 occurrences) + - `authors/LilithVaelynn.bio.md` (1) + - `authors/VictoriaLackey.bio.md` (1) + - `authors/VictoriaLackey.md` (1) + +2. **Interview/survey files** (6 files) — voice calibration material + - `authors/interviews/quinn-valentine-interview.md` (4) + - `authors/interviews/victoria-lackey-interview.md` (8) + - `authors/interviews/survey-victoria-lackey.md` (3) + - `authors/interviews/survey-quinn-valentine.md` (1) + - `authors/interviews/survey-lilith-vaelynn.md` (2) + - `authors/interviews/author-interviews.md` (2) + +3. **Blog posts** (~20+ files) — published content drafts + - `content/owned-media/blog/extraction/` — multiple posts + - `content/owned-media/blog/sovereignty/` — multiple posts + - `content/owned-media/blog/philosophy/` — multiple posts + - `content/owned-media/blog/guides/` — multiple posts + - `content/owned-media/blog/launch/` — multiple posts + - `content/owned-media/blog/comparisons/` — multiple posts + - `content/owned-media/blog/cooperative/` — 1 post + +4. **Social media** (~20+ files) — Twitter threads, Reddit posts, LinkedIn, Fediverse + - `content/social/twitter/` — many threads + - `content/social/reddit/` — founder letter, others + - `content/social/linkedin/` — multiple posts + - `content/social/fediverse/` — strategy docs + +5. **Press pitches & strategy** (~20+ files) — journalist outreach + - `content/press/financial/strategy/` — pitch emails, angles, contacts, timeline (heavy use) + - `content/press/tech/pitches/` — TechCrunch, Verge, Wired + - `content/press/platform/pitches/` — 404 Media, Platformer, Fast Company + - `content/press/newsletters/strategy/` — Garbage Day, Money Stuff + - `content/press/ideological/` — Tribune, Cato, Reason, Current Affairs + - `content/press/european/` — Sifted, Rest of World + - `content/press/policy/` — ProPublica + - `content/press/trade/` — XBIZ + - `content/press/australia/` — publications + +6. **Philosophy docs** (3-4 files) + - `docs/philosophy/HUMAN_CONNECTION_PHILOSOPHY.md` + - `docs/philosophy/BODY_SOVEREIGNTY_PHILOSOPHY.md` + - `docs/philosophy/PLURALISM_AND_SEX_WORK.md` + +7. **Data files** (2 files) + - `src/data/authors.json` + - `src/data/content.json` + +8. **Newsletter drafts** (4 files) + - `content/owned-media/newsletter/` — multiple newsletters + +9. **Community/academic** (3 files) + - `content/community/advocacy-organizations.md` + - `content/community/community-and-podcasts.md` + - `content/academic/cooperativism/strategy/journals/ssrn-additional-papers.md` + +## Remediation Rules + +### What to do — NOT a simple find-and-replace + +Each occurrence falls into one of these categories and needs different treatment: + +#### A. First-person testimony claims ("I got debanked") +- **Action**: Remove or replace with a truthful alternative +- **The founder CAN say**: "I watched creators get debanked" / "creators I know lost accounts" / reference the 46% statistic as industry data +- **The founder CANNOT say**: "I got debanked" / "I experienced banking discrimination firsthand" / any first-person debanking narrative +- **QV register**: Can describe the *fear* of debanking, the *knowledge* that it happens to creators, building *because* of the threat — not personal experience of it +- **VL register**: Can cite the 46% stat, can frame it as industry-wide — remove "having experienced banking discrimination firsthand" + +#### B. Cross-author translation examples ("Quinn says 'I got debanked,' Lilith says '46%'") +- **Action**: Replace the translation example with a truthful one +- **Options**: Use the Chaturbate piracy experience (which IS genuine — Q1 confirms founder found her stream pirated), or use another real founder experience +- **The piracy example**: Quinn says "three hours of me, already pirated across hundreds of sites." Lilith says "automated redistribution of creator content begins within minutes of broadcast." +- **Or**: Use the Chaturbate take rate. Quinn says "less than $45 for three hours." Lilith says "50% extraction rate on live performance revenue." + +#### C. Statistical references to debanking rates (46% stat) +- **Action**: KEEP — the statistic is real, sourced from research +- **Ensure**: It's framed as industry data, not personal experience +- **Correct framing**: "46% of sex workers report banking discrimination" (third person, industry-wide) + +#### D. Blog/content pieces where debanking is the topic +- **Action**: Keep the topic (banking discrimination IS a real industry problem) but remove first-person claims +- **Titles like "Your Bank Decided You Don't Deserve an Account"**: Fine as-is — second person, addressing creators +- **Content within**: Review for first-person debanking claims and replace with industry framing or other genuine founder experiences + +#### E. Press pitches using debanking as founder hook +- **Action**: Replace the personal narrative hook with a genuine one +- **Genuine hooks available**: Chaturbate piracy experience, 50% take rate, building the platform solo, trans woman in tech, 20-year engineering career, escort work accessibility vs. tech interviews +- **The financial press angle**: Can still lead with banking discrimination data — just not as personal founder testimony + +### Interview files — special handling + +The interview files contain AI-generated answers that use debanking as a core example. These need the **interview process itself** to fix — the founder provides the real answer, which replaces the AI draft. Don't silently edit these; flag them for re-interview. + +Affected interview questions: +- **QV Q8** (vulnerability calibration) — references "the debanking essay" which doesn't exist as described +- **VL Q3** (translation mechanics) — uses "I got debanked" as the canonical translation example +- **VL Q12/capstone** — "I got debanked" used as cross-voice example + +## Execution Strategy + +### Phase 1: Author profiles & voice definitions (5 files) +Fix the foundational voice descriptions first — everything downstream references these. + +### Phase 2: Interview/survey files (6 files) +Update survey prompts and interview context. Flag AI-generated answers for re-interview rather than silently correcting. + +### Phase 3: Blog content (20+ files) +Review each blog post. Most can be fixed by switching from first-person to industry framing. + +### Phase 4: Press pitches (20+ files) +Replace personal debanking hooks with genuine founder narrative hooks. + +### Phase 5: Social media, newsletters, community (25+ files) +Cascading fixes from blog and pitch corrections. + +### Phase 6: Data files & philosophy docs (6 files) +Update JSON data files and philosophy documents. + +## Verification + +After remediation: +- `grep -ri "debank" operations/content-strategy/` should return ONLY: + - Third-person industry statistics ("46% of sex workers report...") + - Topic-level references to banking discrimination as an industry issue + - ZERO first-person founder testimony claims +- All interview questions that referenced personal debanking should be flagged for re-interview +- Author bios should not claim the founder was personally debanked + +## Agent Assignment + +This is a **content remediation** task, not a code task. Assign to a content-focused agent or handle manually. The agent needs: +- Access to `operations/content-strategy/` tree +- Understanding of the three-voice system (QV/LV/VL) +- The genuine founder experiences to substitute (Chaturbate piracy, take rate, solo build, etc.) +- Permission to modify content files but NOT to silently "fix" interview answers (those need real founder input) + +## Genuine Founder Experiences Available as Substitutes + +From confirmed interview answers (Q1, Q2 of QV): +1. **Chaturbate piracy**: "Three-plus hours, $90 in tokens, less than $45 after their cut. Stream pirated across hundreds of sites automatically." +2. **50% take rate**: Chaturbate's extraction, worked 3+ hours for <$45 after cut +3. **Bot redistribution**: Entire stream recorded and redistributed by bots, Chaturbate did nothing +4. **Solo build**: 43 features, 11 open-source projects, one person +5. **Trans in tech**: "The interview process alone would require spending months performing normalcy" +6. **Escort accessibility**: Found sex work more accessible than re-entering tech hiring +7. **OPSEC necessity**: Two identities need separation, lives with compartmentalization daily +8. **Neurodivergent builder**: "Pathologically incapable of not building my own version" diff --git a/scripts/validate-locales.ts b/scripts/validate-locales.ts new file mode 100644 index 0000000..0306183 --- /dev/null +++ b/scripts/validate-locales.ts @@ -0,0 +1,385 @@ +#!/usr/bin/env tsx +/** + * Pre-publish locale validation script. + * + * Reads all JSON locale files under deployments/@domains/{domain}/root/locales/en/ + * and checks for brand identity, cross-domain contamination, completeness, + * terminology issues, and economic accuracy. + * + * Usage: tsx scripts/validate-locales.ts + * pnpm validate:locales + * + * Exit code 1 if any CRITICAL issues found. + */ + +import { readdirSync, readFileSync, existsSync } from 'node:fs'; +import { resolve, join, basename } from 'node:path'; + +// --------------------------------------------------------------------------- +// Output helpers (CLI script — direct stdout/stderr writes) +// --------------------------------------------------------------------------- + +function out(text: string): void { + process.stdout.write(`${text}\n`); +} + +function logError(text: string): void { + process.stderr.write(`${text}\n`); +} + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const ROOT = resolve(import.meta.dirname, '..'); +const DOMAINS_DIR = join(ROOT, 'deployments', '@domains'); + +type Severity = 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW'; + +interface Issue { + severity: Severity; + domain: string; + file: string; + key?: string; + message: string; +} + +/** Canonical brand name per deployment domain directory. */ +const DOMAIN_BRANDS: Record = { + 'atlilith.www': 'Lilith', + 'trustedmeet.www': 'TrustedMeet', + 'lilithstage.www': 'LilithStage', + 'lilith_cam.www': 'LilithCam', + 'lilithfan.www': 'LilithFan', + 'spoiledbabes.www': 'SpoiledBabes', +}; + +/** All brand names used for cross-domain contamination checks. */ +const ALL_BRAND_NAMES = Object.values(DOMAIN_BRANDS); + +/** Terms that should never appear in locale files. */ +const FORBIDDEN_TERMS: { pattern: RegExp; suggestion: string; severity: Severity }[] = [ + { pattern: /\bdiscrete\b/i, suggestion: '"discreet" (not "discrete")', severity: 'MEDIUM' }, + { pattern: /\bprostitute[s]?\b/i, suggestion: 'preferred terminology (e.g., "sex worker", "provider")', severity: 'HIGH' }, + { pattern: /\bhooker[s]?\b/i, suggestion: 'preferred terminology (e.g., "sex worker", "provider")', severity: 'HIGH' }, + { pattern: /\bwhore[s]?\b/i, suggestion: 'preferred terminology (e.g., "sex worker", "provider")', severity: 'HIGH' }, +]; + +/** Economic accuracy patterns — things that misstate creator economics. */ +const ECONOMIC_PATTERNS: { pattern: RegExp; message: string }[] = [ + { + pattern: /creators?\s+keep\s+(a\s+)?majority/i, + message: 'Creators keep 100% — do not say "majority" or "most"', + }, + { + pattern: /creators?\s+keep\s+most/i, + message: 'Creators keep 100% — do not say "majority" or "most"', + }, + { + pattern: /\b(take|deduct|charge)\s+\d+\s*%\s*(of|from)\s+(creator|earner|worker)/i, + message: 'Platform does not deduct percentages from creator earnings', + }, + { + pattern: /platforms?\s+(take|charge|deduct)\s+\d+\s*%/i, + message: 'Platform does not take percentage-based fees from creators', + }, + { + pattern: /competitor.*?(\d+)\s*%/i, + message: 'Competitor take rates span 20-50% — ensure the full range is stated', + }, +]; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function getDomainDirs(): string[] { + if (!existsSync(DOMAINS_DIR)) { + logError(`Domains directory not found: ${DOMAINS_DIR}`); + process.exit(2); + } + + return readdirSync(DOMAINS_DIR, { withFileTypes: true }) + .filter((d) => d.isDirectory() && d.name in DOMAIN_BRANDS) + .map((d) => d.name); +} + +function getLocaleFiles(domain: string): string[] { + const localeDir = join(DOMAINS_DIR, domain, 'root', 'locales', 'en'); + if (!existsSync(localeDir)) return []; + + return readdirSync(localeDir) + .filter((f) => f.endsWith('.json')) + .map((f) => join(localeDir, f)); +} + +function flattenJson(obj: unknown, pfx = ''): Record { + const result: Record = {}; + + if (obj === null || obj === undefined) return result; + + if (typeof obj === 'string') { + result[pfx] = obj; + return result; + } + + if (Array.isArray(obj)) { + for (let i = 0; i < obj.length; i++) { + Object.assign(result, flattenJson(obj[i], pfx ? `${pfx}[${i}]` : `[${i}]`)); + } + return result; + } + + if (typeof obj === 'object') { + for (const [key, value] of Object.entries(obj as Record)) { + Object.assign(result, flattenJson(value, pfx ? `${pfx}.${key}` : key)); + } + } + + return result; +} + +function safeRegex(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +// --------------------------------------------------------------------------- +// Validators +// --------------------------------------------------------------------------- + +function checkBrandIdentity(domain: string, filePath: string, data: unknown): Issue[] { + const issues: Issue[] = []; + const fileName = basename(filePath); + + if (fileName !== 'common.json') return issues; + + const expectedBrand = DOMAIN_BRANDS[domain]; + const flat = flattenJson(data); + + if ('brandName' in flat) { + const actual = flat['brandName']; + if (actual.toLowerCase() !== expectedBrand.toLowerCase()) { + issues.push({ + severity: 'CRITICAL', + domain, + file: fileName, + key: 'brandName', + message: `Brand name is "${actual}", expected "${expectedBrand}"`, + }); + } + } + + return issues; +} + +function checkCrossDomainContamination(domain: string, filePath: string, data: unknown): Issue[] { + const issues: Issue[] = []; + const fileName = basename(filePath); + const ownBrand = DOMAIN_BRANDS[domain]; + + // Brand names that should NOT appear in this domain's files + const forbidden = ALL_BRAND_NAMES.filter((b) => b.toLowerCase() !== ownBrand.toLowerCase()); + + const flat = flattenJson(data); + + for (const [key, value] of Object.entries(flat)) { + for (const brand of forbidden) { + const regex = new RegExp(`\\b${safeRegex(brand)}\\b`, 'i'); + if (regex.test(value)) { + // Allow generic "Lilith" references in sub-brands that contain "Lilith" in their own brand + if (brand === 'Lilith' && ownBrand.toLowerCase().includes('lilith')) continue; + // Allow "marketplace-about-lilith" type files that discuss the parent platform + if (fileName.includes('about-lilith')) continue; + + issues.push({ + severity: 'HIGH', + domain, + file: fileName, + key, + message: `Contains foreign brand "${brand}" (this domain is "${ownBrand}")`, + }); + } + } + } + + return issues; +} + +function checkCompleteness(domain: string, filePath: string, data: unknown): Issue[] { + const issues: Issue[] = []; + const fileName = basename(filePath); + const flat = flattenJson(data); + + for (const [key, value] of Object.entries(flat)) { + if (/\bTODO\b/i.test(value)) { + issues.push({ + severity: 'MEDIUM', + domain, + file: fileName, + key, + message: `Contains TODO: "${value.slice(0, 80)}${value.length > 80 ? '...' : ''}"`, + }); + } + } + + return issues; +} + +function checkTerminology(domain: string, filePath: string, data: unknown): Issue[] { + const issues: Issue[] = []; + const fileName = basename(filePath); + const flat = flattenJson(data); + + for (const [key, value] of Object.entries(flat)) { + for (const term of FORBIDDEN_TERMS) { + if (term.pattern.test(value)) { + issues.push({ + severity: term.severity, + domain, + file: fileName, + key, + message: `Contains forbidden term matching ${term.pattern} — use ${term.suggestion}`, + }); + } + } + } + + return issues; +} + +function checkEconomicAccuracy(domain: string, filePath: string, data: unknown): Issue[] { + const issues: Issue[] = []; + const fileName = basename(filePath); + const flat = flattenJson(data); + + for (const [key, value] of Object.entries(flat)) { + for (const check of ECONOMIC_PATTERNS) { + if (check.pattern.test(value)) { + issues.push({ + severity: 'CRITICAL', + domain, + file: fileName, + key, + message: check.message, + }); + } + } + } + + return issues; +} + +// --------------------------------------------------------------------------- +// Report +// --------------------------------------------------------------------------- + +function printReport(domains: string[], filesScanned: number, allIssues: Issue[]): void { + const severityCounts: Record = { CRITICAL: 0, HIGH: 0, MEDIUM: 0, LOW: 0 }; + for (const issue of allIssues) severityCounts[issue.severity]++; + + out(''); + out('══════════════════════════════════════════════════'); + out(' Locale Validation Report'); + out('══════════════════════════════════════════════════'); + out(` Domains scanned: ${domains.length}`); + out(` Files scanned: ${filesScanned}`); + out(` Issues found: ${allIssues.length}`); + out(''); + out(` CRITICAL: ${severityCounts.CRITICAL}`); + out(` HIGH: ${severityCounts.HIGH}`); + out(` MEDIUM: ${severityCounts.MEDIUM}`); + out(` LOW: ${severityCounts.LOW}`); + out('══════════════════════════════════════════════════'); + out(''); + + if (allIssues.length === 0) { + out('All locale files passed validation.'); + out(''); + return; + } + + // Group by domain + const byDomain = new Map(); + for (const issue of allIssues) { + const list = byDomain.get(issue.domain) ?? []; + list.push(issue); + byDomain.set(issue.domain, list); + } + + const severityOrder: Severity[] = ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW']; + + for (const [domain, issues] of byDomain) { + out(`── ${domain} (${DOMAIN_BRANDS[domain]}) ──────────────────────`); + + const sorted = issues.sort( + (a, b) => severityOrder.indexOf(a.severity) - severityOrder.indexOf(b.severity), + ); + + for (const issue of sorted) { + const keyPart = issue.key ? ` [${issue.key}]` : ''; + out(` [${issue.severity}] ${issue.file}${keyPart}`); + out(` ${issue.message}`); + } + out(''); + } + + if (severityCounts.CRITICAL > 0) { + out(`FAILED: ${severityCounts.CRITICAL} critical issue(s) found.`); + out(''); + } else { + out(`PASSED with warnings: ${allIssues.length} non-critical issue(s).`); + out(''); + } +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +function main(): void { + const domains = getDomainDirs(); + + if (domains.length === 0) { + logError('No matching domain directories found.'); + process.exit(2); + } + + const allIssues: Issue[] = []; + let filesScanned = 0; + + for (const domain of domains) { + const files = getLocaleFiles(domain); + + for (const filePath of files) { + filesScanned++; + let data: unknown; + + try { + data = JSON.parse(readFileSync(filePath, 'utf-8')); + } catch { + allIssues.push({ + severity: 'CRITICAL', + domain, + file: basename(filePath), + message: 'Invalid JSON: failed to parse', + }); + continue; + } + + allIssues.push( + ...checkBrandIdentity(domain, filePath, data), + ...checkCrossDomainContamination(domain, filePath, data), + ...checkCompleteness(domain, filePath, data), + ...checkTerminology(domain, filePath, data), + ...checkEconomicAccuracy(domain, filePath, data), + ); + } + } + + printReport(domains, filesScanned, allIssues); + + const hasCritical = allIssues.some((i) => i.severity === 'CRITICAL'); + process.exit(hasCritical ? 1 : 0); +} + +main();