Skip to content

Register the five-second streaming pre-roll format (work package A) - #272

Merged
ralyodio merged 1 commit into
masterfrom
worktree-crawlproof-video-preroll
Sep 24, 2026
Merged

ralyodio merged 1 commit into
masterfrom
worktree-crawlproof-video-preroll

Conversation

@ralyodio

Copy link
Copy Markdown
Contributor

Work package A of the Crawlproof five-second streaming ads spec: schema and format registration only. Nothing here renders, selects or serves an MP4 — those are packages B and D. Draft because the migration is unapplied and B–F remain.

The spec's exit condition for A: existing formats still work; unrendered video cannot serve.

Why registering the format wasn't enough on its own

creativesFromCopy() fanned out over the whole registry into one common design object. Adding video_preroll_5s to AD_FORMATS alone would have minted a video creative carrying a headline and a palette, marked ready beside its siblings, and handed an HTML renderer something it would draw as a banner — the one outcome a pre-roll must never have.

So the fan-out is now DESIGN_FORMAT_IDS, the complement of the new STREAMING_FORMAT_IDS. The dashboard preview strips and format editors iterate DESIGN_FORMATS for the same reason: they skip formats with no creative row today, which hides the problem only until the render pipeline lands.

Three refusals that don't depend on configuration

Where Refusal
fitAdFormat() The single gate in front of serveAd() refuses any streaming format outright — so it holds even for a slot that has somehow been given one.
cleanCreative() Allowlist is the design formats, so a crafted updateCreatives payload can't write a medialess video creative. The DB would refuse it anyway; this makes it a clean rejection rather than a constraint violation.
ad_creatives.published_revision Null until ffprobe has validated an encode — a video creative is unservable by construction, not by remembering to check.

The migration breaks from precedent on purpose

terminal_ascii and feed_item each widened the creative CHECK, added themselves to every slot's inventory, and backfilled a creative per campaign. Steps 2 and 3 are wrong for a media format:

  • Slot inventory drives serveAd(), which renders HTML and ASCII. Adding the format there would offer a video to the display path.
  • A backfill can clone a headline. It cannot clone a 150-frame H.264 encode. Cloning copy alone mints rows that look ready with no bytes behind them.

New tables: ad_video_jobs, ad_video_assets, ad_streaming_properties, ad_video_decisions, ad_video_events. The decision table's unique (property, session, placement) key is the per-session pre-roll rule — a reconnect, playlist reload or retry returns the row that exists instead of stacking a second ad.

Verification

  • npx tsc --noEmit clean.
  • Full suite on the rebased tree: 2467 passed, 1 failed — tests/contract/tracker-geo.test.ts, pre-existing and environmental (node_modules/@ip-location-db/geolite2-city-mmdb isn't installed, so lookupGeo returns undefined). Untouched by this diff.
  • npm run lint is broken repo-wide (next lint was removed in Next 16), unrelated to this change.
  • New tests/ads-video-format.test.ts pins the exit condition; tests/ads-template-copy.test.ts updated where it asserted the fan-out equalled the whole registry.

Not done here

The migration is not applied. Per the repo's own standing note prod history diverged, so it needs psql over the pooler, not supabase db push. The spec authorizes no database deployment itself. Migration was timestamped 20260924130000 to sit after master's existing 20260924120000_email_tracking.

Packages B–F (renderer, dashboard workflow, decisions/accounting, surface integration, rollout inventory) are untouched.

Schema and format registration for Crawlproof video pre-roll ads. No
rendering, selection or serving — those are packages B and D. The exit
condition this carries is the narrow one from the spec: existing formats
still work, and an unrendered video cannot serve.

Registering a media format in AD_FORMATS is not enough on its own, and is
actively unsafe: creativesFromCopy() fanned out over the whole registry
into one common design object, so the format would have minted a
video_preroll_5s creative carrying a headline and a palette, marked ready
beside its siblings, and handed an HTML renderer something it would draw
as a banner. So the fan-out is now DESIGN_FORMAT_IDS — the complement of
the new STREAMING_FORMAT_IDS — and the dashboard preview strips and format
editors iterate DESIGN_FORMATS for the same reason. They skip formats with
no creative row today, which hides the problem only until the render
pipeline lands.

Three places now refuse video rather than relying on configuration:

  * fitAdFormat(), the single gate in front of serveAd(), refuses any
    streaming format outright — so the refusal holds even for a slot that
    has somehow been given one.
  * cleanCreative()'s allowlist is the design formats, so a crafted
    updateCreatives payload cannot write a medialess video creative. The
    DB would have refused the write anyway; this makes it a clean
    rejection rather than a constraint violation.
  * ad_creatives.published_revision is null until ffprobe has validated an
    encode, so a video creative is unservable by construction rather than
    by remembering to check.

The migration deliberately breaks from the terminal_ascii and feed_item
precedents: it widens the creative CHECK but adds nothing to slot
inventory and backfills no creatives. A backfill can clone a headline; it
cannot clone a 150-frame H.264 encode.

Adds ad_video_jobs, ad_video_assets, ad_streaming_properties,
ad_video_decisions and ad_video_events. The decision table's unique
(property, session, placement) key is the whole per-session pre-roll rule:
a reconnect, a playlist reload or a retry returns the row that exists
instead of stacking a second ad.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

ThreatCrush Security Scan

48 finding(s)

HIGH/CRITICAL: 2 | MEDIUM: 31 | LOW: 15

Severity Rule Location
HIGH tls-verification-disabled lib/onion.ts:48
HIGH secret-generic-credential lib/sp/platforms/facebook.ts:32
MEDIUM js-unescaped-html-sink app/(app)/dashboard/admin/email-broadcast/EmailBroadcastForm.tsx:125
MEDIUM js-unescaped-html-sink app/(app)/dashboard/projects/[id]/autoblog/articles/[articleId]/page.tsx:214
MEDIUM js-unescaped-html-sink app/(marketing)/blog/[slug]/page.tsx:67
MEDIUM js-unescaped-html-sink app/(marketing)/blog/[slug]/page.tsx:97
MEDIUM js-unescaped-html-sink app/(marketing)/blog/[slug]/page.tsx:104
MEDIUM js-unescaped-html-sink app/(marketing)/blog/[slug]/page.tsx:110
MEDIUM js-unescaped-html-sink app/(marketing)/recent/page.tsx:186
MEDIUM js-unescaped-html-sink app/(marketing)/recent/page.tsx:190
MEDIUM js-unescaped-html-sink app/c/[project]/[slug]/page.tsx:77
MEDIUM js-unescaped-html-sink app/c/[project]/page.tsx:57
MEDIUM js-unescaped-html-sink app/careers.js/route.ts:228
MEDIUM js-unescaped-html-sink app/careers.js/route.ts:285
MEDIUM js-unescaped-html-sink app/layout.tsx:129
MEDIUM js-open-redirect app/login/form.tsx:39
MEDIUM js-unescaped-html-sink app/r/[token]/page.tsx:176
MEDIUM js-open-redirect app/signup/form.tsx:43
MEDIUM js-open-redirect components/billing/buy-credits-modal.tsx:98
MEDIUM js-unescaped-html-sink components/json-ld.tsx:8
MEDIUM js-unescaped-html-sink components/report/markdown-view.tsx:15
MEDIUM js-unescaped-html-sink lib/careers/page-templates.ts:198
MEDIUM js-dynamic-code-execution lib/crawl-limits.ts:67
MEDIUM redos-nested-quantifier lib/emailMarkdown.ts:41
MEDIUM redos-nested-quantifier lib/emailMarkdown.ts:324
MEDIUM redos-nested-quantifier lib/lx/articleGen.ts:99
MEDIUM redos-nested-quantifier lib/tracker/agent-gate.ts:61
MEDIUM sh-remote-script-execution prober/deploy/provision.sh:30
MEDIUM sql-template-interpolation scripts/detect-slot-themes.ts:31
MEDIUM sql-template-interpolation scripts/purge-constructed-keywords.ts:163
MEDIUM sql-template-interpolation scripts/purge-offniche-keywords.ts:124
MEDIUM js-dynamic-code-execution scripts/test-crawl-limits.mjs:14
MEDIUM js-dynamic-code-execution scripts/test-crawl-limits.mjs:24
LOW secret-generic-credential app/(marketing)/docs/autoblog-webhook/page.tsx:145
LOW secret-generic-credential lib/sp/platforms/linkedin.ts:25
LOW js-dynamic-code-execution tests/careers-page-templates.test.ts:21
LOW js-dynamic-code-execution tests/careers-widget-script.test.ts:19
LOW js-dynamic-code-execution tests/careers-widget-script.test.ts:69
LOW js-dynamic-code-execution tests/contract/ad-visitor-id.test.ts:51
LOW js-dynamic-code-execution tests/contract/ad-visitor-id.test.ts:52
LOW js-dynamic-code-execution tests/contract/ads-click-cooldown-redis.test.ts:20
LOW js-dynamic-code-execution tests/contract/ads-click-cooldown-redis.test.ts:24
LOW js-dynamic-code-execution tests/contract/ads-click-cooldown-redis.test.ts:25
LOW js-dynamic-code-execution tests/contract/ads-click-cooldown-redis.test.ts:26
LOW js-dynamic-code-execution tests/contract/ads-click-cooldown-redis.test.ts:31
LOW js-dynamic-code-execution tests/contract/ads-click-cooldown-redis.test.ts:35
LOW secret-generic-credential tests/contract/posthog-integration.test.ts:13
LOW secret-generic-credential tests/lead-campaign.test.ts:16

Snippets are redacted; ThreatCrush never prints matched credential material.

@ralyodio
ralyodio marked this pull request as ready for review September 24, 2026 12:16
@ralyodio
ralyodio merged commit 4e5ccef into master Sep 24, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant