start_month Missing Lets a Contract's Period Go NULL
This article may contain affiliate links. Its content is not affected by advertising.
In short
If a slot's start_month is missing when booking completes, the contract's period ends up NULL, so every process keyed on it — expiry, auto-renewal, creative expiration — silently never fires.
Conclusion
An ad slot (placement_loops) can be set published: true with start_month (the listing start month) left unset. Unless both the public listing and the booking form special-case that slot, a booking can still go through, and the resulting contract’s period_start / period_end end up NULL. The listing period is what expiry checks, auto-renewal, and creative-expiration logic key off of, so once those two columns are NULL, none of that downstream processing fires — silently.
Symptom (how it was found)
This wasn’t discovered from a production incident. On 2026-09-15, as part of a pre-production review gate, two independent reviewers both flagged the same spot as their top-priority finding. Their finding had two parts:
- Both the public listing (
getPublishedLoops) and the single-slot fetch used for booking (getLoopForBooking) returned a slot with nostart_monthexactly like any other, with no special case - The booking flow (
bookLoopSlotCore) also didn’t reject a booking when the listing period couldn’t be computed from the start month (i.e.rangewasnull) — it wrote the contract’speriod_start/period_endasNULLinstead
Cause
The public listing looked like this.
// src/lib/booking.ts:179-186 (before)
export async function getPublishedLoops(): Promise<PublicLoop[]> {
const admin = createSupabaseAdminClient();
const { data, error } = await admin
.from("placement_loops")
.select(PUBLIC_LOOP_COLS)
.eq("published", true)
.is("bought_out_by_company_id", null)
.order("created_at");
It only checked published: true and that the slot wasn’t exclusively bought out by another company — whether start_month existed played no part in the condition. The single-slot fetch used for direct URL access had the same gap.
// src/lib/booking.ts:219-227 (before)
export async function getLoopForBooking(
id: string,
opts: { requirePublished: boolean; allowOccupied: boolean }
): Promise<PublicLoop | null> {
const admin = createSupabaseAdminClient();
let q = admin.from("placement_loops").select(PUBLIC_LOOP_COLS).eq("id", id);
if (!opts.allowOccupied) q = q.is("bought_out_by_company_id", null);
if (opts.requirePublished) q = q.eq("published", true);
So any row with published: true but no start_month showed up in both the listing and the detail view exactly as-is. What happened once someone actually completed a booking on it was the more serious part.
// src/lib/booking.ts:1411-1415 (before)
const startYm = staffStartMonth(input.startMonth, loop.start_month, jstMonth());
const range = periodRange(startYm, effectiveTerm);
const periodStr = publicPeriodLabel(startYm, effectiveTerm);
range can end up null when loop.start_month is missing, but at this point there was no branch that stopped the booking. Processing continued straight into the contract insert.
// src/lib/booking.ts:1743 (before)
period_start: range ? range.start : null,
period_end: range ? range.end : null,
When range was null, this didn’t error — it simply created the contract with period_start / period_end set to NULL. Once the listing doesn’t special-case it, the detail fetch doesn’t special-case it, and booking doesn’t reject it either, a missing start_month reaches all the way into a contract with corrupted data, without a sound.
The fix
A condition requiring start_month to be non-NULL was added to both the listing and the single-slot fetch.
// src/lib/booking.ts:179-195 (after, excerpt)
.eq("published", true)
.is("bought_out_by_company_id", null)
.not("start_month", "is", null)
.order("created_at");
// src/lib/booking.ts:228-238 (after, excerpt)
if (opts.requirePublished)
q = q.eq("published", true).not("start_month", "is", null);
On top of that, an explicit rejection for a null range was added to the booking flow itself.
// src/lib/booking.ts:1425-1438 (after)
const range = periodRange(startYm, effectiveTerm);
if (!range)
return {
ok: false,
error: opts.byStaff
? "This slot has no listing start month set, so the contract period cannot be determined. Set a start month on the slot, or specify one in the proxy-booking form."
: "Sorry, this slot cannot be booked right now. Please contact us for assistance.",
};
With the listing, the single-slot fetch, and the booking flow all applying the same condition (start_month non-NULL), a slot missing its start month no longer appears on the shelf, can’t be reached through the booking form even by direct URL, and — if it somehow still got that far — is now explicitly rejected at final submission.
Making it less likely to recur
The fix’s own comment states the discipline directly.
This and
bookLoopSlotCoremust always use the same condition — if one side is looser than the other, you get a dead end where a slot is visible on the shelf but always rejected at final submission (this is the same shape of incident that actually happened in 0084).
In other words, the comment in the code itself records the judgment that getPublishedLoops (listing), getLoopForBooking (single-slot fetch), and bookLoopSlotCore (booking flow) must always be treated as a set when it comes to how they handle start_month. Loosening or tightening just one of the three doesn’t reproduce this incident’s missing-data outcome — it creates a different bug instead, where a slot is shown but can’t be booked. The very fact that the same condition has to be written in multiple places is what breeds this kind of mismatch.
Frequently asked questions
Q1Why doesn't a save-time guard alone prevent a slot with no start_month?
The save-time guard (validateLoopPublication in admin/loops) only applies to a slot being saved right now through the admin screen. It has no effect on rows already published, or rows made through a write path bypassing it — so listing, detail fetch, and booking each need it applied on their own.
Q2Is it enough to just fix the listing and leave booking alone?
No. If only the listing (getPublishedLoops) excludes NULL-start_month slots while booking (bookLoopSlotCore) still doesn't reject them, you get a new dead end: invisible on the shelf, yet still bookable and always failing at submission. All three paths need the same condition.
Q3What actually breaks when a contract's period is NULL?
Everything keyed on the contract's listing period (period_start/period_end) — expiry checks, auto-renewal, creative expiration — has no starting point to compute from, so none of it fires. The contract itself still looks like it went through fine, which makes the gap easy to miss.
Environment verified
- Next.js 16.2.7 (App Router) / kimiteras-portal
- Flagged 2026-09-15 by two independent reviewers in a pre-production review, fixed same day
What this article is based on
- TypeScript file lines 179-186commit 8a654cb
- TypeScript file lines 219-227commit 8a654cb
- TypeScript file lines 1411-1415commit 8a654cb
- TypeScript file lines 1743-1743commit 8a654cb
- TypeScript file lines 179-195commit 4f71bfa
- TypeScript file lines 228-238commit 4f71bfa
- TypeScript file lines 1425-1438commit 4f71bfa
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.