Money Has a Clock

The previous five articles built the complete ad system, data model, management interface, rendering engine, analytics dashboard, and A/B testing. This final article addresses the operational layer: making sure campaigns start and stop on time, spend their budgets at the right pace, and transition through their lifecycle automatically.

Budget pacing is the single most impactful feature in a production ad system. Without it, a daily budget of $50 might be spent in the first hour of the day, leaving no ads for the remaining 23 hours. With pacing, that $50 is distributed evenly (roughly $2.08 per hour) so ads appear consistently throughout the day.

This article implements budget pacing algorithms, daily budget reset logic, campaign lifecycle automation, and the server-side scheduling infrastructure that ties everything together.


The Budget Pacing Problem

Without pacing, the budget check from Part 3 is binary: either the campaign has budget remaining, or it does not. This leads to front-loaded spending:

Loading diagram...

The pacing algorithm answers: “Given the remaining budget and remaining time, should this ad serve right now?”


Pacing Strategies

There are two common approaches to budget pacing:

1. Throttle-Based Pacing

The system calculates an ideal spend rate and throttles ad serving to match it. If the campaign is ahead of pace, it skips some opportunities. If it is behind, it serves more aggressively.

2. Probabilistic Pacing

Each ad request has a probability of being served based on the pacing ratio. If the campaign should spend 50% of its hourly budget and has spent 70%, the serve probability drops below 1.0.

We will implement probabilistic pacing because it is simpler, requires no background timers, and naturally smooths out traffic spikes.


The Pacing Algorithm

The algorithm revolves around a single number: the utilizationRatio, which is the ratio of actual spend to ideal spend at the current moment. If the daily budget is $50 and the campaign should have spent $10 by 2pm, but has actually spent $14, the ratio is 1.4 - 40% over pace. If it has spent $7, the ratio is 0.7 - 30% under pace.

The 80% to 120% tolerance band around 1.0 is deliberate. Responding to every tiny deviation by adjusting the serve probability would cause thrashing - constant oscillation between slightly over and slightly under as the system corrects and over-corrects. The band says: “if you’re within 20% of the ideal, treat it as fine and serve normally.” Only outside that band does the algorithm intervene, and when it does, the throttle is proportional: at 2x pace you serve every other request, at 3x pace every third.

Lifetime campaigns reuse the same algorithm through delegation. calculateLifetimePacing asks: “given the remaining budget and remaining days, what should today’s allocation be?” That daily target is handed to calculateDailyPacing as if it were a configured daily budget. This means lifetime campaigns benefit from all the same smoothing logic without duplicating it.

// src/lib/server/ads/pacing.ts

import type { Campaign } from '$lib/types/ads'

interface PacingContext {
	/** Current time */
	now: Date
	/** Today's spend so far (in cents) */
	todaySpend: number
	/** Total hours the campaign is active today (from schedule) */
	activeHoursToday: number
}

interface PacingDecision {
	/** Should this ad be served? */
	shouldServe: boolean
	/** The probability that was used to make the decision */
	serveProbability: number
	/** Current pacing status */
	status: 'under_pace' | 'on_pace' | 'over_pace'
	/** How much of the ideal budget has been spent (0–1+) */
	utilizationRatio: number
}

/**
 * Determine whether an ad should be served based on budget pacing.
 *
 * The algorithm:
 * 1. Calculate the ideal spend rate per hour
 * 2. Calculate how much should have been spent by now
 * 3. Compare actual spend to ideal spend
 * 4. If ahead of pace, reduce serve probability
 * 5. If behind pace, increase serve probability
 */
export function calculatePacing(campaign: Campaign, context: PacingContext): PacingDecision {
	const { now, todaySpend, activeHoursToday } = context

	if (campaign.budgetType === 'lifetime') {
		return calculateLifetimePacing(campaign, now, todaySpend)
	}

	return calculateDailyPacing(campaign, now, todaySpend, activeHoursToday)
}

/**
 * Daily budget pacing.
 * Distributes the daily budget evenly across active hours.
 */
function calculateDailyPacing(
	campaign: Campaign,
	now: Date,
	todaySpend: number,
	activeHoursToday: number
): PacingDecision {
	const dailyBudget = campaign.budgetAmount
	const currentHour = now.getUTCHours()

	// Calculate how many active hours have passed today
	// (simplified - in production, use the campaign's timezone)
	const hoursElapsed = Math.max(1, currentHour + 1)
	const hoursRemaining = Math.max(1, activeHoursToday - hoursElapsed)

	// Ideal spend by this point
	const idealSpendRate = dailyBudget / activeHoursToday
	const idealSpendSoFar = idealSpendRate * hoursElapsed

	// How much of the ideal have we actually spent?
	const utilizationRatio = idealSpendSoFar > 0 ? todaySpend / idealSpendSoFar : 0

	// Calculate serve probability
	let serveProbability: number
	let status: PacingDecision['status']

	if (utilizationRatio <= 0.8) {
		// Under pace - serve aggressively
		serveProbability = 1.0
		status = 'under_pace'
	} else if (utilizationRatio <= 1.2) {
		// On pace - serve normally
		serveProbability = 1.0
		status = 'on_pace'
	} else {
		// Over pace - throttle
		// The further over pace, the lower the probability
		// At 2x pace, probability drops to 0.5
		// At 3x pace, probability drops to 0.33
		serveProbability = 1 / utilizationRatio
		status = 'over_pace'
	}

	// Hard stop: never exceed 120% of daily budget
	if (todaySpend >= dailyBudget * 1.2) {
		serveProbability = 0
		status = 'over_pace'
	}

	// Make the probabilistic decision
	const shouldServe = Math.random() < serveProbability

	return { shouldServe, serveProbability, status, utilizationRatio }
}

/**
 * Lifetime budget pacing.
 * Distributes the remaining budget evenly across remaining days.
 */
function calculateLifetimePacing(
	campaign: Campaign,
	now: Date,
	todaySpend: number
): PacingDecision {
	const remaining = campaign.budgetAmount - campaign.budgetSpent
	if (remaining <= 0) {
		return {
			shouldServe: false,
			serveProbability: 0,
			status: 'over_pace',
			utilizationRatio: Infinity
		}
	}

	// Calculate remaining days
	const endDate = campaign.endDate ?? new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000)
	const msRemaining = endDate.getTime() - now.getTime()
	const daysRemaining = Math.max(1, msRemaining / (24 * 60 * 60 * 1000))

	// Target daily spend
	const targetDailySpend = remaining / daysRemaining

	// Use the daily pacing algorithm with the computed target
	const mockCampaign = {
		...campaign,
		budgetType: 'daily' as const,
		budgetAmount: Math.round(targetDailySpend)
	}

	return calculateDailyPacing(mockCampaign, now, todaySpend, 24)
}
The 120% Hard Cap

The hard cap at 120% of the daily budget provides a safety net. Even with probabilistic pacing, traffic bursts can push spend above the target. The hard cap ensures overspend never exceeds 20% of the daily budget. In production systems, this cap is configurable per campaign.

Integrating Pacing into the Resolver

Slotting pacing into Stage 3 of the resolver introduces the same N-queries issue seen with frequency caps: getDailySpend hits the database once per campaign candidate. For a system with ten active campaigns, that is ten queries before a single ad is selected. In Part 7 we migrate frequency caps to Redis; a production deployment would apply the same approach here, storing today’s spend per campaign in Redis and updating it atomically in the tracker after each event. For now, the database version establishes the correct behaviour.

Update the budget checking stage in the resolver to use pacing:

// Update resolver.ts - Stage 3

import { calculatePacing } from './pacing'
import { getDailySpend } from './budget'

// ─── Stage 3: Apply budget + pacing filters ────────────
const withinBudget: typeof targetedGroups = []

for (const group of targetedGroups) {
	// Basic budget check
	if (group.campaign.budgetSpent >= group.campaign.budgetAmount) continue

	// Pacing check
	const todaySpend = await getDailySpend(group.campaign.id)
	const pacing = calculatePacing(group.campaign, {
		now: context.now,
		todaySpend,
		activeHoursToday: 24 // Could be derived from ad group schedule
	})

	if (pacing.shouldServe) {
		withinBudget.push(group)
	}
}

Daily Budget Resets

For daily-budget campaigns, the spend counter needs to reset at midnight. This is simpler than it sounds: the pacing algorithm computes the utilization ratio from campaign.budgetSpent divided by the ideal spend so far. Zeroing budgetSpent at midnight resets that ratio to zero, giving the pacing algorithm a fresh denominator for the new day. The event history in ad_events is untouched - all past impressions remain queryable for analytics. Only the operational counter that the pacing algorithm reads is cleared.

The reset runs at UTC midnight as a simplification. A campaign in New York configured to run 9am–5pm Eastern time will have its budget counter reset at 7pm Eastern (UTC midnight), not at midnight Eastern. For most use cases this is acceptable. A production system would track per-campaign timezone and schedule resets accordingly.

A scheduled task handles this:

// src/lib/server/ads/daily-reset.ts

import { db } from '$lib/server/db/client'
import { campaigns } from '$lib/server/db/schema'
import { eq, and } from 'drizzle-orm'

/**
 * Reset daily spend counters for all active daily-budget campaigns.
 * Should be called once per day at midnight (UTC or per-campaign timezone).
 */
export async function resetDailyBudgets(): Promise<number> {
	const result = await db
		.update(campaigns)
		.set({
			budgetSpent: 0,
			updatedAt: new Date()
		})
		.where(and(eq(campaigns.status, 'active'), eq(campaigns.budgetType, 'daily')))
		.returning()

	console.log(`[daily-reset] Reset budgets for ${result.length} campaigns`)
	return result.length
}

Campaign Lifecycle Automation

Campaigns need automatic status transitions based on their schedule:

Loading diagram...

The lifecycle functions handle status transitions that require database writes to take effect immediately. Each one runs at a different interval, tuned to how time-sensitive it is: campaign activation needs minute-level precision so a campaign set to start at 9:00am does not miss its first hour; over-budget pausing can tolerate a five-minute lag since the pacing algorithm already throttles spend before a hard stop is needed.

An important asymmetry: pauseOverBudgetCampaigns only targets lifetime-budget campaigns. Daily-budget campaigns do not need explicit pausing when they exhaust their budget, because the pacing algorithm handles it probabilistically: once todaySpend >= dailyBudget * 1.2, the serve probability drops to zero and no ads are served. The campaign status stays active until midnight when the counter resets and serving resumes. Lifetime campaigns have no counter reset, so a database status change to paused is the only way to stop them from depleting further.

// src/lib/server/ads/lifecycle.ts

import { db } from '$lib/server/db/client'
import { campaigns, experiments } from '$lib/server/db/schema'
import { eq, and, lte, gte, sql } from 'drizzle-orm'
import { autoCompleteExperiment } from './experiment-analyzer'

/**
 * Activate campaigns whose start date has arrived.
 * Transitions: draft → active
 */
export async function activateScheduledCampaigns(): Promise<number> {
	const now = new Date()

	const result = await db
		.update(campaigns)
		.set({
			status: 'active',
			updatedAt: now
		})
		.where(and(eq(campaigns.status, 'draft'), lte(campaigns.startDate, now)))
		.returning()

	if (result.length > 0) {
		console.log(`[lifecycle] Activated ${result.length} campaigns`)
	}
	return result.length
}

/**
 * Complete campaigns whose end date has passed.
 * Transitions: active → completed
 */
export async function completeExpiredCampaigns(): Promise<number> {
	const now = new Date()

	const result = await db
		.update(campaigns)
		.set({
			status: 'completed',
			updatedAt: now
		})
		.where(and(eq(campaigns.status, 'active'), lte(campaigns.endDate, now)))
		.returning()

	if (result.length > 0) {
		console.log(`[lifecycle] Completed ${result.length} campaigns`)
	}
	return result.length
}

/**
 * Pause campaigns that have exhausted their lifetime budget.
 * Transitions: active → paused
 */
export async function pauseOverBudgetCampaigns(): Promise<number> {
	const result = await db
		.update(campaigns)
		.set({
			status: 'paused',
			updatedAt: new Date()
		})
		.where(
			and(
				eq(campaigns.status, 'active'),
				eq(campaigns.budgetType, 'lifetime'),
				sql`${campaigns.budgetSpent} >= ${campaigns.budgetAmount}`
			)
		)
		.returning()

	if (result.length > 0) {
		console.log(`[lifecycle] Paused ${result.length} over-budget campaigns`)
	}
	return result.length
}

/**
 * Check all running experiments for auto-completion.
 */
export async function checkExperiments(): Promise<number> {
	const runningExperiments = await db.query.experiments.findMany({
		where: eq(experiments.status, 'running')
	})

	let completed = 0
	for (const experiment of runningExperiments) {
		const wasCompleted = await autoCompleteExperiment(experiment.id)
		if (wasCompleted) completed++
	}

	if (completed > 0) {
		console.log(`[lifecycle] Auto-completed ${completed} experiments`)
	}
	return completed
}

The Scheduler

SvelteKit does not have built-in cron support. We implement scheduling using a server hook that runs periodic tasks:

// src/lib/server/ads/scheduler.ts

import { resetDailyBudgets } from './daily-reset'
import {
	activateScheduledCampaigns,
	completeExpiredCampaigns,
	pauseOverBudgetCampaigns,
	checkExperiments
} from './lifecycle'
import { invalidateAdCache } from './cache'

interface ScheduledTask {
	name: string
	intervalMs: number
	lastRun: number
	fn: () => Promise<unknown>
}

const tasks: ScheduledTask[] = [
	{
		name: 'activate-campaigns',
		intervalMs: 60_000, // every minute
		lastRun: 0,
		fn: activateScheduledCampaigns
	},
	{
		name: 'complete-campaigns',
		intervalMs: 60_000,
		lastRun: 0,
		fn: completeExpiredCampaigns
	},
	{
		name: 'pause-over-budget',
		intervalMs: 300_000, // every 5 minutes
		lastRun: 0,
		fn: pauseOverBudgetCampaigns
	},
	{
		name: 'check-experiments',
		intervalMs: 600_000, // every 10 minutes
		lastRun: 0,
		fn: checkExperiments
	},
	{
		name: 'daily-budget-reset',
		intervalMs: 3_600_000, // every hour (checks if midnight)
		lastRun: 0,
		fn: async () => {
			const hour = new Date().getUTCHours()
			if (hour === 0) {
				return resetDailyBudgets()
			}
		}
	}
]

let running = false

/**
 * Run all due scheduled tasks.
 * Call this from a server hook on each request,
 * or from a setInterval in your server startup.
 *
 * The `running` flag prevents concurrent execution. Without it, if a scheduler
 * tick takes longer than 30 seconds (e.g. a slow lifecycle database query),
 * the next setInterval fires while the first is still running. Two concurrent
 * ticks would both process the same tasks, potentially double-activating
 * campaigns, double-resetting budgets, or double-completing experiments.
 */
export async function runScheduledTasks(): Promise<void> {
	if (running) return // Prevent concurrent runs
	running = true

	const now = Date.now()
	let anyRan = false

	try {
		for (const task of tasks) {
			if (now - task.lastRun >= task.intervalMs) {
				try {
					await task.fn()
					task.lastRun = now
					anyRan = true
				} catch (error) {
					console.error(`[scheduler] Task "${task.name}" failed:`, error)
				}
			}
		}
	} finally {
		running = false
	}

	// Invalidate the ad cache if any lifecycle task ran
	if (anyRan) {
		invalidateAdCache()
	}
}

Hooking into SvelteKit

The scheduler runs as part of the SvelteKit server hook:

// src/hooks.server.ts (additions)

import type { Handle } from '@sveltejs/kit'
import { runScheduledTasks } from '$lib/server/ads/scheduler'

// Module-level flag. Survives across requests in a single server process.
// Vite may re-evaluate this module in dev mode, resetting the flag - that
// is acceptable; the scheduler simply restarts. In production it runs once.
let schedulerStarted = false

function initScheduler() {
	if (schedulerStarted) return
	schedulerStarted = true
	setInterval(() => {
		runScheduledTasks().catch((err) => console.error('[scheduler]', err))
	}, 30_000)
}

// Wire into your existing handle hook. Calling initScheduler() here rather
// than at the module top level keeps startup deterministic and easy to test.
export const handle: Handle = async ({ event, resolve }) => {
	initScheduler()
	return resolve(event)
}
Production Scheduling

The setInterval approach works for single-server deployments. For production systems with multiple instances, use a proper job scheduler:

  • Node.js: node-cron or Bree
  • Platform-native: Vercel Cron Jobs, Cloudflare Workers Cron Triggers
  • External: A dedicated job queue like BullMQ with Redis

The key requirement is that each task runs exactly once across all instances, not once per instance.


Pacing Visualization

A pacing chart shows advertisers how their budget is being spent relative to the ideal pace:

<!-- src/lib/components/ads/PacingChart.svelte -->

<script lang="ts">
	import { formatCurrency, formatPercent } from '$lib/utils/format'

	interface Props {
		dailyBudget: number // in cents
		todaySpend: number // in cents
		hourlyData: Array<{
			hour: number
			spend: number // cumulative spend in cents
		}>
	}

	let { dailyBudget, todaySpend, hourlyData }: Props = $props()

	let currentHour = $derived(new Date().getUTCHours())
	let idealSpendNow = $derived((dailyBudget / 24) * (currentHour + 1))
	let pacingRatio = $derived(idealSpendNow > 0 ? todaySpend / idealSpendNow : 0)

	let pacingStatus = $derived(
		pacingRatio <= 0.8 ? 'Under Pace' : pacingRatio <= 1.2 ? 'On Pace' : 'Over Pace'
	)

	let pacingColor = $derived(
		pacingRatio <= 0.8
			? 'var(--accent-yellow-base)'
			: pacingRatio <= 1.2
				? 'var(--accent-green-base)'
				: 'var(--accent-red-base)'
	)
</script>

<div class="pacing-chart">
	<!-- Pacing Status -->
	<div class="pacing-header">
		<div class="pacing-status">
			<span class="status-indicator" style="background: {pacingColor}"></span>
			<span class="status-text">{pacingStatus}</span>
		</div>
		<div class="pacing-numbers">
			<span>{formatCurrency(todaySpend)}</span>
			<span class="separator">/</span>
			<span>{formatCurrency(dailyBudget)}</span>
			<span class="ratio">({formatPercent(pacingRatio)} of ideal)</span>
		</div>
	</div>

	<!-- Progress Bar -->
	<div class="pacing-bar">
		<!-- Ideal pace marker -->
		<div
			class="ideal-marker"
			style="left: {(idealSpendNow / dailyBudget) * 100}%"
			title="Ideal spend: {formatCurrency(Math.round(idealSpendNow))}"
		></div>

		<!-- Actual spend bar -->
		<div
			class="spend-bar"
			style="
				width: {Math.min((todaySpend / dailyBudget) * 100, 100)}%;
				background: {pacingColor};
			"
		></div>
	</div>

	<!-- Hour labels -->
	<div class="hour-labels">
		<span>12am</span>
		<span>6am</span>
		<span>12pm</span>
		<span>6pm</span>
		<span>12am</span>
	</div>
</div>

<style>
	.pacing-chart {
		background: var(--surface-1, #fff);
		border: 1px solid var(--border-color, #e5e7eb);
		border-radius: 0.5rem;
		padding: 1.5rem;
	}

	.pacing-header {
		display: flex;
		justify-content: space-between;
		align-items: center;
		margin-bottom: 1rem;
	}

	.pacing-status {
		display: flex;
		align-items: center;
		gap: 0.5rem;
	}

	.status-indicator {
		width: 10px;
		height: 10px;
		border-radius: 50%;
	}

	.status-text {
		font-weight: 600;
	}

	.pacing-numbers {
		font-size: 0.9rem;
	}

	.separator {
		margin: 0 0.25rem;
		color: var(--text-muted);
	}

	.ratio {
		color: var(--text-muted);
		font-size: 0.8rem;
		margin-left: 0.5rem;
	}

	.pacing-bar {
		position: relative;
		height: 24px;
		background: var(--surface-2, #f3f4f6);
		border-radius: 12px;
		overflow: visible;
	}

	.spend-bar {
		height: 100%;
		border-radius: 12px;
		transition: width 0.3s ease;
	}

	.ideal-marker {
		position: absolute;
		top: -4px;
		width: 2px;
		height: 32px;
		background: var(--text-primary, #111827);
		z-index: 1;
	}

	.ideal-marker::after {
		content: '';
		position: absolute;
		top: -14px;
		left: 50%;
		transform: translateX(-50%);
		font-size: 8px;
		color: var(--text-muted);
	}

	.hour-labels {
		display: flex;
		justify-content: space-between;
		margin-top: 0.5rem;
		font-size: 0.7rem;
		color: var(--text-muted);
	}
</style>

Budget Forecasting

A useful addition for advertisers is budget forecasting - predicting when the campaign will exhaust its budget based on current spend velocity:

// src/lib/server/ads/forecasting.ts

import type { Campaign } from '$lib/types/ads'
import { getTimeseries } from './metrics'

interface BudgetForecast {
	/** Estimated date when budget will be exhausted */
	exhaustionDate: Date | null
	/** Days until exhaustion */
	daysRemaining: number | null
	/** Average daily spend (in cents) */
	averageDailySpend: number
	/** Projected total spend by end date (in cents) */
	projectedTotalSpend: number
	/** Whether the campaign will likely exhaust its budget before end date */
	willExhaustEarly: boolean
}

/**
 * Forecast budget exhaustion based on historical spend velocity.
 */
export async function forecastBudget(campaign: Campaign): Promise<BudgetForecast> {
	// Get the last 7 days of data
	const timeseries = await getTimeseries(campaign.id, 24 * 7)

	// Calculate average daily spend from recent data
	const dailySpends = groupByDay(timeseries)
	const averageDailySpend =
		dailySpends.length > 0 ? dailySpends.reduce((sum, d) => sum + d, 0) / dailySpends.length : 0

	if (averageDailySpend === 0) {
		return {
			exhaustionDate: null,
			daysRemaining: null,
			averageDailySpend: 0,
			projectedTotalSpend: 0,
			willExhaustEarly: false
		}
	}

	const remainingBudget = campaign.budgetAmount - campaign.budgetSpent
	const daysUntilExhaustion = remainingBudget / averageDailySpend
	const exhaustionDate = new Date(Date.now() + daysUntilExhaustion * 24 * 60 * 60 * 1000)

	// Check if exhaustion happens before end date
	const endDate = campaign.endDate
	const willExhaustEarly = endDate ? exhaustionDate < endDate : false

	// Project total spend by end date
	const daysUntilEnd = endDate
		? (endDate.getTime() - Date.now()) / (24 * 60 * 60 * 1000)
		: daysUntilExhaustion

	const projectedTotalSpend = campaign.budgetSpent + averageDailySpend * Math.max(0, daysUntilEnd)

	return {
		exhaustionDate,
		daysRemaining: Math.round(daysUntilExhaustion),
		averageDailySpend: Math.round(averageDailySpend),
		projectedTotalSpend: Math.round(projectedTotalSpend),
		willExhaustEarly
	}
}

function groupByDay(
	timeseries: Array<{ timestamp: string; impressions: number; clicks: number }>
): number[] {
	const days = new Map<string, number>()

	for (const point of timeseries) {
		const day = point.timestamp.slice(0, 10) // YYYY-MM-DD
		const current = days.get(day) ?? 0
		// Each event costs 1 cent (simplified model)
		days.set(day, current + point.impressions + point.clicks)
	}

	return Array.from(days.values())
}

The Complete Operational Flow

Here is how all the operational pieces connect:

Loading diagram...

Series Recap

Over six articles, we explored the architecture of a complete dynamic ad campaign system in SvelteKit. Part 7 then addressed the gap between working and production-ready:

Part 1 - Foundations: Domain modeling, TypeScript types, Drizzle schema, Valibot validation, project architecture.

Part 2 - Campaign CRUD: SvelteKit form actions, progressive enhancement, status machine, nested form handling for ad groups and creatives.

Part 3 - Ad Rendering: Resolution pipeline with targeting filters (placement, device, geo, schedule, URL), weighted selection, AdSlot component with IntersectionObserver tracking, in-memory caching.

Part 4 - Real-Time Dashboard: Event tracking, in-memory pub/sub, Server-Sent Events streaming, metrics aggregation, Canvas-based charts, live event feed.

Part 5 - A/B Testing: Deterministic bucket assignment with FNV-1a hashing, experiment data model, per-variant metrics, chi-squared statistical significance testing, Wilson confidence intervals, auto-completion.

Part 6 - Budget & Scheduling: Probabilistic pacing algorithm, daily budget resets, campaign lifecycle automation, server-side scheduling, budget forecasting, pacing visualization.

Part 7 - Production Hardening: Database indexing for the events table, Redis-backed frequency capping, rate limiting on the tracking endpoint, HTML creative sanitisation, SSRF prevention for impression pixels, GDPR consent gating and data retention.

Where to Go From Here

The core architecture from Parts 1–6 covers the domain through the operational layer. Part 7 addresses the production hardening topics - database indexing, Redis frequency capping, rate limiting, HTML sanitisation, SSRF prevention, and GDPR compliance. Beyond that, natural extensions include authentication and multi-tenant access control, CDN-level ad serving for sub-millisecond response times, advanced fraud detection, and real-time bidding integrations. The architecture is designed to support all of them without fundamental restructuring.