# Audit Pipeline: Current State (Phase 19A.1 Discovery) **Date:** 2026-05-26 **Scope:** Read-only architectural discovery of the full audit pipeline as it exists post-Phase 17. --- ## 1. Three Distinct Audit Runners The codebase contains three independent audit runners with separate entry points, separate Prisma write targets, and separate triggering surfaces. They share no code. ### 1.1 Main SEO Audit Runner **File:** `src/lib/audit/audit-runner.ts` (1594 lines) **Export:** `runAudit(auditId: string): Promise` **Triggered by:** `POST /api/admin/audits/trigger` via Vercel `waitUntil` **Prisma write target:** `Audit.seoSection` (JSONB) **Model:** central to all Phase 11A-17 dimension output This is the primary audit pipeline. It orchestrates all dimension data sources, the two LLM synthesis calls, and the final DB write. All output lands in a single JSONB field (`Audit.seoSection`) on the `Audit` record. ### 1.2 Technical Audit Runner **File:** `src/lib/services/audit-runner.ts` **Export:** `runTechnicalAudit(brandId: string, domain: string): Promise` **Triggered by:** `POST /api/admin/audits/[id]/retry` and brand-health scheduler **Prisma write targets:** `TechnicalAudit`, `TechnicalIssue` (row-per-issue table) **Model:** independent crawler with headless fallback via Puppeteer/Chromium Crawls up to 100 pages, checks each for HTTP errors, broken links, redirect chains, missing meta, etc., then bulk-inserts findings as `TechnicalIssue` rows. ### 1.3 Performance Audit Runner **File:** `src/lib/services/performance-audit-runner.ts` **Export:** `runPerformanceAudit(brandId: string, domain: string): Promise` **Triggered by:** `POST /api/performance-audit/run` **Prisma write targets:** `PerformanceAudit`, `PerformancePageScore` (row-per-URL) **Model:** PageSpeed Insights API calls per URL, scored per strategy (mobile/desktop) Reads from `GscPage` and `TrackedEvent` to pick the top pages to test, then calls PageSpeed Insights for each, writing per-URL scores to `PerformancePageScore`. ### 1.4 Content Audit **API route:** `POST /api/content-audit/run` **Prisma write targets:** `ContentAuditResult`, `AuditScoreHistory` **Note:** Not a standalone runner file -- logic lives inline in the route handler. --- ## 2. Main Audit Runner: Phase Sequence The `runAudit` function orchestrates work in five labeled phases plus a degraded-mode path. ``` Phase 1 DFS site crawl + first-party data fetch + (3rd-party) backlink fetch [parallel] Phase 2 Firecrawl key-page content fetch + site analysis [parallel] Phase 3 Industry classification (Haiku, ~5 s) [sequential] Phase 4 SERP analysis + bilingual audit (3rd-party) [parallel] Phase 5 All dimension aggregators + two LLM synthesis calls [parallel where possible] Phase 12 Live visual annotations + competitor visual comparison [parallel, 180 s budget] ``` **Degraded mode** activates when the crawl returns 0 pages. It skips SERP-dependent dimensions (snippet capture) and runs a subset: Phase 12 (homepage-only), 13, and 15 in parallel using fallback data. --- ## 3. industryContext: Lifecycle and Constraints `industryContext` is computed in Phase 3 by `classifyIndustry()` and passed through the pipeline as an in-memory value. It is **never written to Prisma**. **Type (as of Phase 19.0):** ```typescript interface IndustryContext { industry: string; // backward-compat alias for industries[0] industries: string[]; // all applicable industries, primary first (max 3) subVertical: string; audienceFraming: string; relevantFrameworks: string[]; aiVisibilityBenchmark: string; schemaPriorities: string[]; suggestedQueries: string[]; // 5-7 queries; fed to Phase 4 SERP conversionPathExpectation: string[]; } ``` **Flow:** 1. Haiku classifies the brand using homepage markdown (first 1200 chars). 2. Result stored on `AuditInput.industryContext`. 3. `suggestedQueries` fed to `fetchSerpAnalysis` in Phase 4. 4. `industryContext` passed to all Phase 5 dimension aggregators that need it. 5. `inferredIndustry` (= `industries[0]`) stored in `seoSection.inferredIndustry` -- the only persisted trace of classification. **Models used:** - Industry classifier: `claude-haiku-4-5-20251001` - SEO narrative: `claude-sonnet-4-6` (with prompt caching) - Structured synthesis: `claude-haiku-4-5-20251001` --- ## 4. AuditInput: The Pipeline Accumulator `AuditInput` (defined in `src/lib/audit/types.ts`) is a mutable object assembled incrementally across Phases 1-5. Each phase appends its output to the relevant optional field. The synthesizer receives the fully-populated input at Phase 5. All `*Context` fields are optional. Absent fields signal "not available" -- dimension aggregators and the synthesizer each have fallback behavior when their input is null. **First-party-only fields** (absent on 3rd-party audits): `brandContext`, `behavioralContext`, `aiAttributionContext`, `webVitalsContext`, `crawlStatsContext`, `signalCenterContext` **Conditionally-present fields** (both audit types, depend on crawl success): All remaining `*Context` fields. --- ## 5. Dimension Data Sources: Pure Functions Every Phase 5 aggregator in `src/lib/audit/data-sources/` is a **pure function** -- no Prisma reads or writes. They receive `AuditInput` slices and return typed structs that get merged into `enrichedSeoSection` before the single final DB write. | Import name | File | Activates when | |---|---|---| | `aggregateSignalCenter` | data-sources/signal-center | signalCenterContext present | | `aggregateBilingualAudit` | data-sources/bilingual-audit | bilingualAuditContext present | | `aggregateCompetitorAnalysis` | data-sources/competitor-analysis | serpAnalysis present | | `aggregateSchemaOpportunityForecast` | data-sources/schema-opportunity-forecast | industryContext present | | `aggregateIndustryBenchmarkComparison` | data-sources/industry-benchmark-comparison | industryContext present | | `aggregatePageStrategicPass` | data-sources/page-strategic-pass | pages available | | `aggregateAiEnginePersonalityAnalysis` | data-sources/ai-engine-personality-analysis | always | | `aggregateAiSourceAttributionComparison` | data-sources/ai-source-attribution-comparison | aiAttributionContext present | | `aggregateBehavioralActionBridge` | data-sources/behavioral-action-bridge | behavioralContext present | | `aggregateEeatAudit` | data-sources/eeat-audit | pages + industryContext | | `runVoiceSearchEligibility` | data-sources/voice-search-eligibility | pages + contentSamples | | `aggregateAiSnippetEligibility` | data-sources/ai-snippet-eligibility | pages present | | `aggregateMultiTouchAiPath` | data-sources/multi-touch-ai-path | aiAttributionContext present | | `aggregateRevenueAttribution` | data-sources/revenue-attribution | first-party data | | `aggregatePredictiveConversionScoring` | data-sources/predictive-conversion-scoring | first-party data | | `aggregateConversionHeatMap` | data-sources/conversion-heat-map | behavioral data | | `aggregateAiSearchVerification` | data-sources/ai-search-verification | always (async agent) | | `aggregateLostOpportunity` | data-sources/lost-opportunity-calculator | findings present | | `aggregateLiveVisualAnnotations` | data-sources/live-visual-annotations | Phase 12, parallel | | `aggregateCompetitorVisualComparison` | data-sources/competitor-visual-comparison | Phase 12, parallel | | `aggregateBrandAuthorityScore` | data-sources/brand-authority | eeat + schema data | | `aggregateSnippetCaptureStrategy` | data-sources/snippet-capture-strategy | serpAnalysis present | | `aggregateMobileFirstAudit` | data-sources/mobile-first-audit | pages present | | `aggregatePageSpeedInsights` | data-sources/page-speed-deep-dive | webVitalsContext | --- ## 6. SeoSection: The Single JSONB Output `SeoSection` (defined in `src/lib/audit/types.ts`, ~155 lines) is the TypeScript shape of `Audit.seoSection` in Prisma. It is a wide JSON blob -- every dimension output lives as an optional field on this single object. **Core fields (always present on completed audits):** ``` healthScore, narrative, keyFindings, priorityActions, quickWins, scoringBreakdown ``` **Additive consulting fields:** ``` prioritizedFindings, additionalFindings, summaryThemes, inferredIndustry, displayName ``` **Conditional enrichments (field absent when not applicable):** ``` aiSearchPerformance, brandEcho, dimensionScores, siteTagContext, methodology, industryFramework, competitiveLandscape, backlinkProfile, publicAiVisibility, organicSearchSummary, aiConversionAttribution, signalCenter, bilingualAudit, competitorAnalysis, schemaOpportunityForecast, strategicRoadmap, industryBenchmarkComparison, pageStrategicPass, aiEnginePersonalityAnalysis, aiSourceAttributionComparison, behavioralActionBridge, eeatAudit, voiceSearchEligibility, aiSnippetEligibility, multiTouchAiPath, revenueAttribution, predictiveConversionScoring, conversionHeatMap, aiSearchVerification, lostOpportunityCalculation, liveVisualAnnotations, competitorVisualComparison, brandAuthorityScore, snippetCaptureStrategy, mobileFirstAudit, pageSpeedInsights, executiveBrief, lockedFirstPartyTeaser ``` **Engineering-only internal field:** ``` internal.dataSourcesUsed, internal.costLog, internal.auditType ``` Must not be rendered by dashboard components or PDF templates. **Single Prisma write pattern:** ```typescript await prisma.audit.update({ where: { id: auditId }, data: { seoSection: enrichedSeoSection as object }, }); ``` All 30+ dimension outputs are assembled in memory then written in one round trip. --- ## 7. Persistence: Write Targets by Runner | Runner | Table(s) written | Pattern | |---|---|---| | Main SEO (`audit-runner.ts`) | `Audit.seoSection` (JSONB) | Single update at end of run | | Main SEO (progress) | `Audit` (status, progress fields) | Multiple incremental updates during run | | Technical (`services/audit-runner.ts`) | `TechnicalAudit`, `TechnicalIssue` | Create + bulk createMany | | Performance (`performance-audit-runner.ts`) | `PerformanceAudit`, `PerformancePageScore` | Create + createMany per URL | | Content audit (`/api/content-audit/run`) | `ContentAuditResult`, `AuditScoreHistory` | Create per run | **Connection-leak mitigation (two-layer):** - Client-side: `boundedFireAndForget` uses `Promise.race` with a hard timeout so the serverless function is never blocked waiting on a Prisma query past its useful lifetime. - Server-side: `prisma.$transaction({ timeout })` enforces a server-enforced maximum on any transaction, releasing the connection-pool slot even if the JS promise chain is abandoned. --- ## 8. UI Rendering: Audit Report Surfaces The `seoSection` JSONB is read (never mutated) by three display surfaces: | Surface | Route | Notes | |---|---|---| | Public audit page | `src/app/audit/[id]/page.tsx` | Gated by `publicSlug`; shows subset of sections | | Admin audit page | `src/app/admin/audits/[id]/page.tsx` | Full section visibility | | Client portal | `src/app/client/[brandId]/audit/page.tsx` | Brand-linked first-party view | | PDF report | `src/lib/audit/pdf/AuditPdf.tsx` | Generated via `@react-pdf/renderer`; served from `/api/admin/audits/[id]/pdf` | **Section rendering convention:** Each dashboard section component checks `hasData` (or a null check on the parent object) and returns `null` when the dimension output is absent. The TOC is constructed by the page from the same boolean flags, so suppressed sections disappear from the table of contents automatically. **Separate performance/technical UIs:** `/technical-audit`, `/site-performance-audit`, and `/content-audit` are independent pages that read from their respective tables (`TechnicalAudit`, `PerformanceAudit`, `ContentAuditResult`) -- they do not read `Audit.seoSection`. --- ## 9. Key Architectural Constraints for Phase 19 Work 1. **All new dimension outputs must be appended to `SeoSection` as optional fields.** There is no separate table for Phase 11A-17 dimension data. The JSONB approach means zero migrations for new dimensions. 2. **Dimension aggregators must remain pure functions.** No Prisma reads or writes inside `src/lib/audit/data-sources/`. Data flows in via `AuditInput` slices; output is returned as a typed struct. 3. **industryContext is ephemeral.** If a dimension needs to activate conditionally on industry (e.g., HIPAA compliance when `industries.some(i => i.startsWith('healthcare-'))`), the check must happen inside the aggregator or inside the runner at Phase 5 gate -- not via a DB lookup. 4. **Any new DB write in Phase 19 (e.g., ComplianceAuditResult) requires both layers:** `boundedFireAndForget` at the call site and `$transaction({ timeout })` inside the write operation. The connection pool has 14 slots shared across all serverless function instances. 5. **Model names must never appear in user-facing output.** `SeoSection.internal` is the only field where model/vendor names are permitted. All other fields, dashboard copy, and PDF copy must use generic language. 6. **Primary industry stability.** The `industries[0]` value must equal what would have been returned in single-industry mode. Secondary industries are additive-only. Downstream gate logic checking `industryContext.industry` (backward-compat alias) remains correct without changes.