# 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<void>`
**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<AuditResult>`
**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<PerfAuditResult>`
**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.
