DeliveryAd型のAPIレスポンスにフィールド名を送信漏れし、全ての広告が「(無題の広告)」のまま配信された
※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。
結論
受信側がcaption ?? titleのフォールバックを実装していても、送信側のAPIレスポンス型にtitleフィールド自体が無ければ、全件が「(無題の広告)」のまま無言で配信され続ける。
受信側がcaption ?? titleのフォールバックを実装していても、送信側のAPIレスポンス型にtitleフィールド自体が無ければ、全件が「(無題の広告)」のまま無言で配信され続ける。
結論
配信先(v2)は「広告タイトルの caption が無ければ title を採用する」というフォールバックを1ヶ月前から実装済みだったが、送信元(portal)のレスポンス型そのものに title フィールドが存在せず、配信される広告は全件が caption: null =既定値のまま届いていた。 フォールバック自体は正しく動く設計で、受信側のコードは何も壊れていない。欠けていたのは、フォールバックに渡す値を送信側が一度も送っていなかったという、型レベルの送信漏れだった。
症状
キミテラス-v2 の広告配信は portal → v2 への K3 連携経由で行われる。v2 側の受信スキーマは、2026-06-14 の時点で次のように title を受理し、caption が無ければ title を caption として採用するフォールバックをすでに備えていた。
caption: nullableTrimmed(CAPTION_MAX),
// portal 素材タイトル(広告名)。「(無題の広告)」修正(運営整理 §4 / K3): ads に専用の title 列は持たず
// **caption に流用**する(ユーザー判断 2026-06-14)。caption が明示指定されていればそれを優先し、無ければ
// title を caption として採用する(下の正規化)。月次レポート PDF は caption 未設定時のみ「(無題の広告)」に倒す。
title: nullableTrimmed(CAPTION_MAX),
実際のマッピングも、同じコミットで次の通り組まれている。
// 「(無題の広告)」修正: caption が無ければ portal 素材タイトル(title)を caption として採用する
// (title 専用列は持たず caption に一本化)。両方 null なら null(PDF が「(無題の広告)」に倒す)。
caption: a.caption ?? a.title,
ところが送信元の portal 側は、2026-07-25 まで DeliveryAd 型に title フィールドそのものを持っていなかった。
displayOrder: number;
/** Supabase 署名 URL(10 分有効)。v2 が GCS に再ホストする */
assetFetchUrl: string;
caption: string | null;
linkUrl: string | null;
caption は常に null で送られていたため(後述)、v2 側のフォールバック a.caption ?? a.title は null ?? undefined を評価することになり、caption は空のまま広告が配信されていた。型が合っているのでランタイムエラーは出ず、zod のバリデーションも通る。
この caption: null は、社内向け ops ダッシュボードの反応集計テーブルと、月次レポート PDF の両方で可視化される。
広告 (配信割当) ごとの反応。枠名は広告のタイトル
(caption)、未設定は「(無題の広告)」。既定は反応が多い順。
...
<strong key="caption">{a.caption ?? "(無題の広告)"}</strong>,
/** caption 未設定 / 削除済 広告の表示名 (CSV / 画面 `/admin/reports` と揃える)。 */
const UNTITLED_AD_LABEL = "(無題の広告)";
...
return caption ?? UNTITLED_AD_LABEL;
顧客向けの配信そのものにエラーは出ないが、この2つの社内画面を見ると、広告の枠名が全件「(無題の広告)」になっていることが分かる。
原因
送信側のコードを遡ると、DB からの creative 取得クエリ自体が title 列を選択していなかった。
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();
このコミット(cf19ac2)より前は、.select("id, company_id, media_type, duration_sec, file_path") と title を含んでいなかった。取得していない値は当然レスポンスにも組み込めず、送信時の構築処理でも caption: null が固定で設定されていた。
title: ((creative.title as string | null) ?? "").trim().slice(0, 60) || null,
caption: null,
linkUrl: null,
つまり経路は「DB からの取得クエリに列が無い → portal の型に title フィールドが無い → 送信 payload に値が乗らない → v2 側で caption が null のまま」という、1ステップ目の欠落がそのまま最後まで伝播する構造だった。v2 側の受信スキーマと変換ロジックは2026-06-14の時点で正しく実装されていたため、この間の約1ヶ月間、「受信側は対応済み、送信側だけが値を送っていない」という状態が続いていたことになる。
直し方
DB クエリに title 列を追加し、portal の型にも title: string | null を明示した上で、契約上限(60字)に合わせてトリムする処理を送信側に追加した。
.select("id, company_id, media_type, duration_sec, file_path, title, status")
/**
* 素材タイトル(広告名)。K3 契約(partner-api-contract.md §3.4)は「portal は素材タイトルを
* 必ず title(または caption)で送ること」。v2 は caption ?? title で ads.caption に流用し、
* 月次レポート PDF の「(無題の広告)」フォールバックを両方 null の場合だけに限定する。≤60字。
*/
title: string | null;
// v2 の zod は title >60 字を 400(恒久)で弾くため、送信側で契約上限に切り詰める。
title: ((creative.title as string | null) ?? "").trim().slice(0, 60) || null,
受信側のフォールバックコードは一切変更していない。送信側が契約通りに title を送るようになった時点で、既存のフォールバックがそのまま機能し始めている。
再発防止
根拠ファイルには、この欠落を検知する仕組み(型レベルのスキーマ同期チェックや、送受信間の契約テストなど)についての記述は無い。修正はこの1点を含む9件の欠陥をまとめて棚卸しした監査コミットの一部として行われており、個別の再発防止策は明記されていない。
構造的に見れば、この種の欠落が見つかりにくいのは、受信側と送信側が別リポジトリ(v2 と portal)に分かれており、どちらの型チェックも「相手が何を送ってくるか/相手が何を受け取れるか」を跨いで検証しないためだ。送信側の型にフィールドが無くても、それ自体は単体のコンパイルエラーにならず、受信側が null をフォールバックで静かに吸収してしまうと、契約の片側だけが未実装のまま長期間気づかれずに残る。
よくある質問
Q1なぜエラーも警告も出なかったのですか?
title は portal 側のレスポンス型(DeliveryAd)にフィールドとして存在しなかった。TypeScript の型チェックも zod のバリデーションも『無い値』を異常とは判定しないため、該当フィールドが欠落したリクエストがそのまま正常系として v2 に届いていた。
Q2v2 側はこの事態を想定していなかったのですか?
想定していた。v2 側は2026-06-14の時点で、caption が無ければ title を caption として採用するフォールバック(caption ?? title)をすでに実装していた。足りなかったのはフォールバックではなく、portal 側が title を送る経路そのものだった。
Q3実際どこに「(無題の広告)」が表示されていたのですか?
社内向けの ops ダッシュボードの反応集計テーブルと、月次レポート PDF の両方。どちらも caption が null のときにこの固定文字列へフォールバックする実装になっており、顧客向けの配信そのものにはエラーは出ない。
確認した環境
- kimiteras-portal: next 16.2.7 / @supabase/supabase-js ^2.106.2 / typescript ^5
- 受信側(v2)フォールバック実装: 2026-06-14
- 送信側(portal)title フィールド追加: 2026-07-25
この記事の根拠
- TypeScriptファイル 60〜68行目コミット cf19ac2
- TypeScriptファイル 113〜151行目コミット cf19ac2
- TypeScriptファイル 475〜489行目コミット cf19ac2
- TypeScriptファイル 105〜109行目コミット 2bd7c6f
- TypeScriptファイル 195〜202行目コミット 2bd7c6f
- TypeScriptファイル 283〜284行目コミット e840383
- TypeScriptファイル 302〜302行目コミット e840383
- TypeScriptファイル 64〜65行目コミット 0b764f1
- TypeScriptファイル 107〜107行目コミット 0b764f1
本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。