Skip to content

fix(ads): demote self-owned campaigns instead of dropping them (serving outage) - #177

Merged
ralyodio merged 1 commit into
masterfrom
fix/self-deal-blackout
Jul 31, 2026
Merged

ralyodio merged 1 commit into
masterfrom
fix/self-deal-blackout

Conversation

@ralyodio

Copy link
Copy Markdown
Contributor

Production: paid serving has been dark since ~06:04 UTC today. Every request, on every format, falls through to the house ad. Because house fills are unmetered, nothing records an impression — so /ads reads as zero delivery rather than as an outage.

Cause

The self-deal guard filters any campaign whose owner matches the slot owner out of the candidate list, then bails to the house ad when the list is empty:

const candidates = creatives.filter((row) => {
  const c = oneCampaign(row.ad_campaigns);
  if (!c) return false;
  return !(slot.owner_id && c.owner_id === slot.owner_id);   // <-- drops it
});
if (candidates.length === 0) return houseFill(format);

Correct on a network with other advertisers. Fatal on one without. Confirmed in production:

  • Every active slot is owned by 47ba37b2…
  • All 34 active terminal_ascii campaigns are owned by 47ba37b2…
  • other_campaigns = 0 for every slot

So the filter removes 100% of inventory on every request. Raw SQL shows 34 eligible creatives; the serving path sees none.

Fix

The intent is sound — ad_charge_click refuses to bill or accrue on a self-click, so a self-owned campaign must never win paid inventory ahead of an advertiser who would actually pay. Dropping it outright was the wrong lever.

It's now demoted to the free tier, the same place a campaign that has run out of funds goes. Between a real advertiser's creative and the house ad — neither of which can earn on this request — the real creative is strictly the better fill, and it records an impression, so delivery is visible again.

Verification

  • Full suite 1257 passed, 7 skipped (1251 + 6 new). npm run typecheck clean.
  • The three blackout tests fail on master, reproducing the outage exactly.
  • The three guard tests pass both before and after, confirming the self-deal protection is not weakened: a self-owned campaign still never takes paid inventory from a third-party advertiser.

Notes

Not caused by #174/#175/#176 — those deployed at 11:37 and later, hours after serving stopped. The regression is in the #171/#172 window, which matches the last recorded impression at 06:04:17.

Separately and still outstanding: 20260731130000_ad_impression_short_codes.sql from #176 has not been applied, so short codes remain inert (running their intended fallback).

🤖 Generated with Claude Code

Paid serving has been dark since ~06:04 UTC today. Every request on every
format fell through to the CrawlProof house ad, and because house fills
are unmetered, nothing recorded an impression — so /ads reads as zero
delivery rather than as an outage.

Cause: the self-deal guard filtered any campaign whose owner matches the
slot owner out of the candidate list, then bailed to the house ad when
the list came back empty. That is correct on a network with other
advertisers. It is fatal on one without: today every active slot and all
34 active campaigns belong to the same profile, so the filter removed
100% of inventory on every request.

The intent of the guard is sound — ad_charge_click refuses to bill or
accrue on a self-click, so a self-owned campaign must never win PAID
inventory ahead of an advertiser who would actually pay. Dropping it
outright was the wrong lever. It is now demoted to the free tier, the
same place a campaign that has run out of funds goes.

Between a real advertiser's creative and the house ad — neither of which
can earn on this request — the real creative is strictly the better fill,
and it records an impression, so delivery is visible again.

Tests cover both halves: a single-tenant network serves and meters again
(these three fail on master, reproducing the blackout), and a self-owned
campaign still never takes paid inventory from a third-party advertiser.

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

Copy link
Copy Markdown

vu1nz Security Review

0 finding(s) in PR #?

No security issues found.

@ralyodio
ralyodio merged commit f1f87be into master Jul 31, 2026
7 of 8 checks passed
@ralyodio
ralyodio deleted the fix/self-deal-blackout branch July 31, 2026 13:10
ralyodio added a commit that referenced this pull request Aug 18, 2026
)

The four tiles on /dashboard/ads read 0 for 1W and every shorter range
while the chart directly beneath them drew thousands of impressions.
Nothing failed to load. The tiles counted paid inventory only, and since
2026-07-31 there has been no paid inventory: PR #177 demotes a self-owned
campaign to the free tier rather than dropping it, and while every slot
and every campaign belong to one account, every single fill is a
self-deal. 1M and wider still reached back to genuinely paid days, which
is exactly why the break looked like a short-range bug.

Three fixes, one per layer:

* The tiles now report delivery — paid plus free — with the split named
  underneath, and CTR is computed on the same totals. Spend stays
  strictly paid, because it is money. The per-campaign rows follow the
  same rule so a row can't contradict the header above it.

* ad_charge_click recorded a self-deal click as `valid=false,
  tier='paid'`. Reporting counts `valid` as billed clicks and
  `not valid and tier='free'` as real-but-unbillable ones, so that
  combination — the one the bot/duplicate/forged path writes — made every
  self-deal click invisible. The branches either side of it already write
  'free' for the same situation. Migration fixes the branch and
  reclassifies the 661 rows it mislabelled, scoped so no genuine fraud
  row moves.

* bucketAxis stopped one bucket short of the window start. The RPC
  filters on `ts >= p_since` then date_bins, so it emits a partial
  leading bucket; getAccountSeries skips any row without a matching
  point, so up to a full bucket of real delivery was dropped from both
  the chart and the totals.

Verified against prod: 1W now reports 16,220 impressions and 154 clicks
where it reported 1 and 0.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
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