From a545dbf981f9d87e323a0dd42d05501bce92ed6c Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Tue, 18 Aug 2026 19:19:13 +0000 Subject: [PATCH] Stop an all-owned campaign posting its own links as fallbacks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found in production minutes after the sources migration landed. The "lives" campaign — 39 hand-pasted owned links, no content sources — posted six links and flagged every one of them via_fallback. maxFallbackItemsPerDay is 3, so the following tick would have selected nothing and the campaign would have gone silent with no error anywhere. The migration backfills every existing list with the 70/30 default mix. A campaign with no shared content then reads that as "30% short on shared", finds no shared inventory, and covers with owned content — which is, by the letter of the policy, a fallback. It is not one. A class the campaign has no inventory and no source for is not starved; it is simply not part of that campaign's mix. So the mix is now narrowed to the classes a campaign can actually supply: a class survives if it has a link ready or an enabled source feeding it. An all-owned campaign ends up with a 100%-owned effective mix and posts on target forever, exactly as it did before sources existed. The moment the user adds a keyword source, shared re-enters the mix and the ratio starts applying — and then covering for an empty shared queue genuinely is a fallback, and the daily cap genuinely should bound it. Four existing blend tests encoded the old assumption that an empty class is always starved. They now pass hasSource explicitly, which is what those scenarios always meant. Production data was repaired alongside this: lists with no sources were set to a 100%-owned mix, and the six mislabelled posts had via_fallback cleared so the rolling cap stopped counting them. Co-Authored-By: Claude Opus 5 (1M context) --- lib/promote/blend.ts | 37 +++++++++- lib/promote/selectLink.ts | 16 ++++ tests/promote/blend.test.ts | 118 +++++++++++++++++++++++++++++- tests/promote/select-link.test.ts | 42 ++++++++++- 4 files changed, 206 insertions(+), 7 deletions(-) diff --git a/lib/promote/blend.ts b/lib/promote/blend.ts index 76af6de..6f16c8e 100644 --- a/lib/promote/blend.ts +++ b/lib/promote/blend.ts @@ -95,11 +95,45 @@ export type BlendInput = { posted: Partial>; /** Which classes have a link ready to post right now. */ available: Partial>; + /** + * Which classes the campaign has an enabled source feeding. A class with no + * inventory *and* no source is not starved — the campaign simply does not + * publish that kind of content. + */ + hasSource?: Partial>; fallback: FallbackPolicy; /** Fallback posts already made today, against maxFallbackItemsPerDay. */ fallbackUsedToday?: number; }; +/** + * Narrow a mix to the classes this campaign can actually supply. + * + * Without this, a campaign of nothing but the user's own pasted links reads its + * 70/30 default as "30% short on shared content", finds none, and posts its own + * links as *fallbacks* — every tick, until maxFallbackItemsPerDay stops the + * campaign dead. A class the campaign has no inventory and no source for is not + * a starved class; it is not part of this campaign's mix at all. + * + * Returns the original mix when nothing qualifies, so the caller still gets a + * ranking to reason about rather than an empty one. + */ +export function effectiveMix( + mix: BlendMix, + available: Partial>, + hasSource: Partial> = {}, +): BlendMix { + const restricted: BlendMix = { owned: 0, partner: 0, shared: 0 }; + let total = 0; + for (const key of OWNERSHIPS) { + if (mix[key] <= 0) continue; + if (!available[key] && !hasSource[key]) continue; + restricted[key] = mix[key]; + total += mix[key]; + } + return total > 0 ? restricted : mix; +} + /** * Rank the ownership classes by how far each is below its target share. * Exported for the preview UI, which shows why a given item is next. @@ -124,7 +158,8 @@ export function rankByDeficit(mix: BlendMix, posted: Partial> = {}; + for (const row of (sourceRows ?? []) as Array<{ ownership: string }>) { + const key = row.ownership as Ownership; + if (OWNERSHIPS.includes(key)) hasSource[key] = true; + } + const fallbackUsedToday = await countFallbackToday(supabase, list.id, now); const decision = chooseOwnership({ mix, posted, available, + hasSource, fallback, fallbackUsedToday, }); diff --git a/tests/promote/blend.test.ts b/tests/promote/blend.test.ts index d784701..8b38393 100644 --- a/tests/promote/blend.test.ts +++ b/tests/promote/blend.test.ts @@ -4,6 +4,7 @@ import { parseFallback, parseMix, rankByDeficit, + effectiveMix, DEFAULT_MIX, type BlendMix, type FallbackPolicy, @@ -102,9 +103,19 @@ describe("chooseOwnership", () => { describe("when the owned queue is empty", () => { const available = { owned: false, shared: true }; + // The campaign has an owned source; it just has nothing to post from it + // right now. That is what makes owned genuinely starved, rather than + // simply not part of this campaign's mix. + const hasSource = { owned: true, shared: true }; it("uses shared content by default, flagged as a fallback", () => { - const decision = chooseOwnership({ mix, posted: {}, available, fallback: permissive }); + const decision = chooseOwnership({ + mix, + posted: {}, + available, + hasSource, + fallback: permissive, + }); expect(decision.ownership).toBe("shared"); expect(decision.viaFallback).toBe(true); expect(decision.reason).toBe("fallback"); @@ -115,6 +126,7 @@ describe("chooseOwnership", () => { mix, posted: {}, available, + hasSource, fallback: { ...permissive, whenOwnedQueueEmpty: "pause" }, }); expect(decision.ownership).toBeNull(); @@ -124,13 +136,14 @@ describe("chooseOwnership", () => { it("stops once the daily fallback cap is reached", () => { const fallback = { ...permissive, maxFallbackItemsPerDay: 3 }; expect( - chooseOwnership({ mix, posted: {}, available, fallback, fallbackUsedToday: 2 }) + chooseOwnership({ mix, posted: {}, available, hasSource, fallback, fallbackUsedToday: 2 }) .ownership, ).toBe("shared"); const capped = chooseOwnership({ mix, posted: {}, available, + hasSource, fallback, fallbackUsedToday: 3, }); @@ -156,6 +169,7 @@ describe("chooseOwnership", () => { mix, posted: { owned: 9, shared: 0 }, available: { owned: true, shared: false }, + hasSource: { shared: true }, fallback: permissive, }); expect(decision.ownership).toBe("owned"); @@ -164,15 +178,22 @@ describe("chooseOwnership", () => { it("reaches an unweighted class only under use_any_available", () => { const onlyPartner = { owned: false, shared: false, partner: true }; + const hasSource = { owned: true, shared: true }; expect( - chooseOwnership({ mix, posted: {}, available: onlyPartner, fallback: permissive }) - .ownership, + chooseOwnership({ + mix, + posted: {}, + available: onlyPartner, + hasSource, + fallback: permissive, + }).ownership, ).toBeNull(); expect( chooseOwnership({ mix, posted: {}, available: onlyPartner, + hasSource, fallback: { ...permissive, whenOwnedQueueEmpty: "use_any_available" }, }).ownership, ).toBe("partner"); @@ -190,6 +211,95 @@ describe("chooseOwnership", () => { }); }); +describe("a campaign that only has its own content", () => { + // Regression, found in production on 2026-08-18. A list of 39 hand-pasted + // owned links carried the 70/30 default mix. The blend read it as "30% short + // on shared content", found none, and posted the user's own links as + // *fallbacks* — six in one tick. maxFallbackItemsPerDay is 3, so the next + // tick would have posted nothing and the campaign would have looked dead. + const mix: BlendMix = { owned: 70, partner: 0, shared: 30 }; + const onlyOwned = { owned: true }; + + it("posts its own content on target, not as a fallback", () => { + const decision = chooseOwnership({ + mix, + posted: { owned: 50 }, + available: onlyOwned, + fallback: { ...permissive, maxFallbackItemsPerDay: 3 }, + }); + expect(decision.ownership).toBe("owned"); + expect(decision.viaFallback).toBe(false); + expect(decision.reason).toBe("on_target"); + }); + + it("keeps posting indefinitely, never tripping the fallback cap", () => { + const posted: Partial> = {}; + for (let i = 0; i < 40; i++) { + const decision = chooseOwnership({ + mix, + posted, + available: onlyOwned, + fallback: { ...permissive, maxFallbackItemsPerDay: 3 }, + // Every previous post was on-target, so nothing accumulates here. + fallbackUsedToday: 0, + }); + expect(decision.ownership).toBe("owned"); + expect(decision.viaFallback).toBe(false); + posted.owned = (posted.owned ?? 0) + 1; + } + }); + + it("does start honouring the ratio once a shared source exists", () => { + // Same campaign, now subscribed to a keyword source. Shared is genuinely + // starved, so covering for it IS a fallback and the cap should apply. + const decision = chooseOwnership({ + mix, + posted: { owned: 50 }, + available: onlyOwned, + hasSource: { shared: true }, + fallback: { ...permissive, maxFallbackItemsPerDay: 3 }, + }); + expect(decision.ownership).toBe("owned"); + expect(decision.viaFallback).toBe(true); + }); + + it("prefers the shared source's content as soon as it has any", () => { + const decision = chooseOwnership({ + mix, + posted: { owned: 50 }, + available: { owned: true, shared: true }, + hasSource: { shared: true }, + fallback: permissive, + }); + expect(decision.ownership).toBe("shared"); + expect(decision.viaFallback).toBe(false); + }); +}); + +describe("effectiveMix", () => { + const mix: BlendMix = { owned: 70, partner: 0, shared: 30 }; + + it("drops a class the campaign can neither stock nor fetch", () => { + expect(effectiveMix(mix, { owned: true }, {})).toEqual({ + owned: 70, + partner: 0, + shared: 0, + }); + }); + + it("keeps a class that has a source but no inventory right now", () => { + expect(effectiveMix(mix, { owned: true }, { shared: true })).toEqual(mix); + }); + + it("keeps a class that has inventory but no source", () => { + expect(effectiveMix(mix, { owned: true, shared: true }, {})).toEqual(mix); + }); + + it("returns the original mix when nothing qualifies", () => { + expect(effectiveMix(mix, {}, {})).toEqual(mix); + }); +}); + describe("rankByDeficit", () => { it("puts the class furthest below its target first", () => { const mix: BlendMix = { owned: 70, partner: 0, shared: 30 }; diff --git a/tests/promote/select-link.test.ts b/tests/promote/select-link.test.ts index 0ebd369..f433641 100644 --- a/tests/promote/select-link.test.ts +++ b/tests/promote/select-link.test.ts @@ -23,9 +23,16 @@ function link(over: Record = {}) { } function db(over: Partial = {}): FakeDb { - return { promo_link: [], promo_post: [], ...over }; + return { promo_link: [], promo_post: [], promo_source: [], ...over }; } +// A campaign only treats a class as *starved* if something is meant to be +// feeding it. Without a source, an empty class is simply not part of the mix — +// which is what stops an all-owned campaign posting its own links as fallbacks. +const sourceFor = (ownership: string) => [ + { id: `src-${ownership}`, list_id: "list-a", ownership, enabled: true }, +]; + const list = { id: "list-a", source_mix: { owned: 70, partner: 0, shared: 30 }, @@ -102,6 +109,7 @@ describe("selectNextLink", () => { link({ id: "off", ownership: "owned", enabled: false }), link({ id: "on", ownership: "shared" }), ], + promo_source: sourceFor("owned"), }), ); const selection = await selectNextLink(client, list, NOW); @@ -111,7 +119,10 @@ describe("selectNextLink", () => { it("falls back to shared when the owned queue is empty", async () => { const { client } = makeFakeSupabase( - db({ promo_link: [link({ id: "shared-1", ownership: "shared" })] }), + db({ + promo_link: [link({ id: "shared-1", ownership: "shared" })], + promo_source: sourceFor("owned"), + }), ); const selection = await selectNextLink(client, list, NOW); expect(selection.link?.id).toBe("shared-1"); @@ -123,6 +134,7 @@ describe("selectNextLink", () => { const { client } = makeFakeSupabase( db({ promo_link: [link({ id: "shared-1", ownership: "shared" })], + promo_source: sourceFor("owned"), promo_post: Array.from({ length: 3 }, (_, i) => ({ id: `fb-${i}`, list_id: "list-a", @@ -142,6 +154,7 @@ describe("selectNextLink", () => { const { client } = makeFakeSupabase( db({ promo_link: [link({ id: "shared-1", ownership: "shared" })], + promo_source: sourceFor("owned"), promo_post: Array.from({ length: 3 }, (_, i) => ({ id: `fb-${i}`, list_id: "list-a", @@ -201,6 +214,31 @@ describe("selectNextLink", () => { expect((await selectNextLink(client, list, NOW)).link?.id).toBe("shared-1"); }); + it("does not treat an all-owned campaign's posts as fallbacks", async () => { + // Production regression, 2026-08-18. The "lives" campaign: 39 hand-pasted + // owned links, no sources, carrying the 70/30 default. Every post came out + // flagged via_fallback, and maxFallbackItemsPerDay=3 would have stopped the + // campaign entirely on the following tick. + const { client } = makeFakeSupabase( + db({ + promo_link: [link({ id: "owned-1", ownership: "owned" })], + promo_source: [], + promo_post: Array.from({ length: 50 }, (_, i) => ({ + id: `legacy-${i}`, + list_id: "list-a", + ownership: "owned", + status: "posted", + via_fallback: false, + created_at: "2026-08-18T10:00:00.000Z", + })), + }), + ); + const selection = await selectNextLink(client, list, NOW); + expect(selection.link?.id).toBe("owned-1"); + expect(selection.decision.viaFallback).toBe(false); + expect(selection.decision.reason).toBe("on_target"); + }); + it("ignores another list's history", async () => { const { client } = makeFakeSupabase( db({