DeliveryAd Missing title Field Left Every Ad Untitled
This article may contain affiliate links. Its content is not affected by advertising.
In short
If the sending side's response type has no title field at all, a caption ?? title fallback on the receiving side just keeps delivering every record under its default label.
Even when the receiving side implements a caption ?? title fallback, if the sending side’s API response type has no title field at all, every record keeps delivering silently under the default “(Untitled Ad)” label.
The short version
The delivery target (v2) had already implemented a fallback — “if the ad title’s caption is absent, adopt title instead” — a full month earlier. But the sending side’s (portal’s) response type itself had no title field, so every ad being delivered arrived with caption: null, the default. The fallback logic itself was correctly designed, and nothing was broken on the receiving side. What was missing was a type-level failure to ever send the value the fallback was waiting for.
Symptom
Ad delivery on Kimiteras-v2 goes through K3 integration from portal to v2. As of 2026-06-14, v2’s receiving schema already accepted title as follows, with a fallback that adopts title as caption whenever caption is absent.
caption: nullableTrimmed(CAPTION_MAX),
// portal material title (ad name). "(Untitled Ad)" fix (ops cleanup §4 / K3): ads has no
// dedicated title column, so we **repurpose caption** (user decision 2026-06-14). If caption
// is explicitly set, prefer it; otherwise adopt title as caption (normalization below). The
// monthly report PDF only falls back to "(Untitled Ad)" when caption is unset.
title: nullableTrimmed(CAPTION_MAX),
The actual mapping was wired up in the same commit:
// "(Untitled Ad)" fix: adopt portal material title (title) as caption whenever caption is
// absent (no dedicated title column; unified into caption). If both are null, null (PDF falls
// back to "(Untitled Ad)").
caption: a.caption ?? a.title,
But the sending side, portal, had no title field at all on its DeliveryAd type until 2026-07-25.
displayOrder: number;
/** Supabase signed URL (valid 10 minutes). v2 re-hosts it to GCS */
assetFetchUrl: string;
caption: string | null;
linkUrl: string | null;
Because caption was always sent as null (more on this below), v2’s fallback a.caption ?? a.title evaluated null ?? undefined, leaving caption empty while the ad kept delivering. The types lined up, so no runtime error surfaced, and it passed zod validation cleanly too.
This caption: null was visible in two places: the internal ops dashboard’s engagement-aggregation table, and the monthly report PDF.
Engagement per ad (delivery assignment). The slot name is the ad's title
(caption); unset falls back to "(Untitled Ad)". Default sort is by engagement, descending.
...
<strong key="caption">{a.caption ?? "(Untitled Ad)"}</strong>,
/** Display name for ads with no caption set / deleted (kept in sync with the CSV / `/admin/reports` screen). */
const UNTITLED_AD_LABEL = "(Untitled Ad)";
...
return caption ?? UNTITLED_AD_LABEL;
No error surfaces in the customer-facing delivery itself, but looking at these two internal screens showed every single ad’s slot name as “(Untitled Ad)”.
Root cause
Tracing back through the sending side’s code, the DB creative-fetch query itself was not selecting the title column.
const { data: creative, error: creErr } = await admin
.from("creatives")
.select("id, company_id, media_type, duration_sec, file_path, title, status")
.eq("id", creativeId)
.maybeSingle();
Before this commit (cf19ac2), the query read .select("id, company_id, media_type, duration_sec, file_path") — title was not included. A value that was never fetched obviously cannot be folded into the response, and the send-side construction logic had caption: null hardcoded as well.
title: ((creative.title as string | null) ?? "").trim().slice(0, 60) || null,
caption: null,
linkUrl: null,
In other words, the path was: “the DB fetch query has no column → portal’s type has no title field → no value lands in the send payload → caption stays null on the v2 side” — a gap at step one propagating unchanged all the way to the end. Because v2’s receiving schema and conversion logic were already correctly implemented as of 2026-06-14, the state persisted for roughly a month where “the receiving side was ready, only the sending side wasn’t sending the value.”
The fix
We added the title column to the DB query, made the title: string | null field explicit on portal’s type, and added trimming logic on the sending side to match the contractual limit (60 characters).
.select("id, company_id, media_type, duration_sec, file_path, title, status")
/**
* Material title (ad name). The K3 contract (partner-api-contract.md §3.4) requires that
* "portal must always send the material title via title (or caption)". v2 repurposes it into
* ads.caption via caption ?? title, and limits the monthly report PDF's "(Untitled Ad)"
* fallback to only the case where both are null. ≤60 characters.
*/
title: string | null;
// v2's zod rejects title over 60 characters with a permanent 400, so we truncate to the
// contractual limit on the sending side.
title: ((creative.title as string | null) ?? "").trim().slice(0, 60) || null,
We made no changes at all to the receiving side’s fallback code. Once the sending side started sending title per the contract, the existing fallback simply started working.
Preventing a repeat
The source files contain no mention of a mechanism that would have caught this gap — neither a type-level schema-sync check nor a contract test between sender and receiver. The fix was part of a single audit commit that cleaned up nine defects together, and no individual prevention measure is documented for this one.
Structurally, what made this kind of gap hard to spot is that the receiving and sending sides live in separate repositories (v2 and portal), and neither side’s type checking verifies across the boundary what the other side sends or can receive. A field missing from the sending side’s type is not, by itself, a compile error on that side, and if the receiving side quietly absorbs the null through a fallback, one half of the contract can stay unimplemented, unnoticed, for a long time.
Frequently asked questions
Q1Why didn't any error or warning fire?
title did not exist as a field on portal's response type (DeliveryAd). Neither TypeScript's type checking nor zod validation treats a missing value as invalid, so a request with the field dropped went through to v2 looking like a perfectly normal request.
Q2Hadn't v2 anticipated this?
It had. As of 2026-06-14, v2 already implemented a fallback (caption ?? title) that adopts title as the caption whenever caption is absent. What was missing was not the fallback — it was the path for portal to send title at all.
Q3Where did "(Untitled Ad)" actually show up?
In two places: the internal ops dashboard's engagement-aggregation table, and the monthly report PDF. Both fall back to that fixed string whenever caption is null; the customer-facing delivery itself never throws an error.
Environment verified
- kimiteras-portal: next 16.2.7 / @supabase/supabase-js ^2.106.2 / typescript ^5
- Receiving side (v2) fallback implemented: 2026-06-14
- Sending side (portal) title field added: 2026-07-25
What this article is based on
- TypeScript file lines 60-68commit cf19ac2
- TypeScript file lines 113-151commit cf19ac2
- TypeScript file lines 475-489commit cf19ac2
- TypeScript file lines 105-109commit 2bd7c6f
- TypeScript file lines 195-202commit 2bd7c6f
- TypeScript file lines 283-284commit e840383
- TypeScript file lines 302-302commit e840383
- TypeScript file lines 64-65commit 0b764f1
- TypeScript file lines 107-107commit 0b764f1
Every claim in this article comes from the records above. The repositories we operate are private so we cannot link to them, but which file, which lines, and at which commit we read them is recorded for every article. Nothing here is written from guesswork.