TWiT-Ads v2 Rebuild

B5 Design Review — Finalize & the Sea of Red (pre-implementation)

Fable 5, 2026-07-08. This is the design pass required by phase-a-review.md ("B5 design + post-implementation adversarial review") and b4-b5-handoff.md before any B5 plan or code is written. Sources read in full: Specification §3/§8 (2026-07-07 revision), Legacy Email Findings, phase-a-review.md, b-phase-handoff.md, B4's Decisions & deviations, and the live code (migrations 00003/00006/00007/00008, internal/orders, internal/ordermath/availability.go, internal/server/orders_ui.go).

Status: DESIGN SET, pending Leo's answers to the five questions in §6. The plan writer must treat §3–§5 as binding and must not start until §6 is resolved. B5 remains the highest-risk stage — concurrency bugs here cost real revenue.


1. Verdict in three sentences

Split B5 into B5a (finalize core) and B5b (modification family) — endorsing b-phase-handoff.md's recommendation; the combined scope is two stages' worth of risk. The oversell-impossibility design is sound and cheap: UNIQUE (episode_id, position) as the invariant backstop, per-episode row locks as the serialization mechanism, one transaction, precise conflict lists. The dangerous part is not finalize itself but the edges: existing mutations that are currently ungated (date toggles, line deletes, void orders fully editable), the aired-date boundary definition, and episode-cancel interacting with placements — all enumerated below so none arrives as a surprise mid-implementation.

2. Scope inventory & the split

Everything the spec assigns B5, divided:

B5a — Finalize & sea of red (the inventory engine):

  1. placements + week_placements tables (migration 00009)
  2. Transactional finalize with confirmation step and precise conflict list
  3. Round-robin position assignment
  4. Real SoldCount (replaces the B3 stub at orders.go:401)
  5. Sea of red: red badges on the orders list, red lines in the editor, finalize blocked while conflicted
  6. The status-gating matrix (§4) — void orders become read-only; date/week toggles and line deletes gated on finalized orders
  7. Episode-cancel guard (refuse while placements exist — §6 Q3)
  8. Concurrency test suite (§5)

B5b — The modification family (everything the legacy system made a developer ticket): 9. Un-finalize (first-class, audited, returns slots) 10. Void transitions per the state machine (§3.1) 11. Amend-in-place: remove/add future line-dates on a finalized order; aired dates refused 12. Makegood line-level link (order_lines.makegood_for_line_id) + "create makegood" action 13. Delete guards: advertiser (the TODO(B5) at sponsors.go:106), agency (sponsors.go:179) 14. Modification test suite

Each half gets its own just-in-time plan file and its own Leo gate. B5a ships value alone (W2 works); B5b builds entirely on B5a's primitives.

3. Binding design decisions

3.1 Order state machine

pending ──finalize──▶ finalized          (confirmation step; transactional)
pending ──void─────▶ void                (terminal)
finalized ──un-finalize──▶ pending       (allowed ONLY when zero aired placements)

3.2 Schema (migration 00009, B5a)

CREATE TABLE placements (
    id            bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    episode_id    bigint NOT NULL REFERENCES episodes(id),
    position      int    NOT NULL CHECK (position >= 1),
    order_line_id bigint NOT NULL REFERENCES order_lines(id) ON DELETE RESTRICT,
    created_at    timestamptz NOT NULL DEFAULT now(),
    UNIQUE (episode_id, position),        -- THE oversell-impossibility constraint
    UNIQUE (episode_id, order_line_id)    -- one line claims one slot per episode
);
CREATE INDEX placements_line_idx ON placements (order_line_id);

CREATE TABLE week_placements (
    id              bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    week_start_date date   NOT NULL UNIQUE,   -- billboard capacity: 1/week (§6 Q4)
    order_line_id   bigint NOT NULL REFERENCES order_lines(id) ON DELETE RESTRICT,
    created_at      timestamptz NOT NULL DEFAULT now()
);

3.3 The finalize transaction (the protocol, exactly)

One pgx.BeginFunc transaction:

  1. Claim the order row first: UPDATE orders SET status='finalized', finalized_at=now(), finalized_by=$user, version=version+1 WHERE id=$1 AND version=$2 AND status='pending'. Zero rows → stale version or already-finalized/void → abort with ErrConflict. This single statement is the double-finalize guard and the optimistic-lock check.
  2. Lock episodes in deterministic order: collect every episode_id across the order's episode_ad line dates; SELECT e.id, e.status, s.max_ads FROM episodes e JOIN shows s ON s.id=e.show_id WHERE e.id = ANY($ids) ORDER BY e.id FOR UPDATE OF e. The ORDER BY e.id is load-bearing — it's the deadlock preventer when two finalizes overlap on multiple episodes in different iteration orders.
  3. Validate everything, collecting ALL failures (never fail-fast — the spec demands "a precise conflict list"): episode not cancelled; per-episode demand (this order may have >1 line claiming the same episode) ≤ max_ads − count(existing placements). Billboard weeks: SELECT week_start_date FROM week_placements WHERE week_start_date = ANY($weeks) under the same tx — any hit is a conflict.
  4. Any conflict → return the full typed list, transaction rolls back (the status UPDATE from step 1 rolls back with it). The UI renders it: "SN 7/22 — full (3/3)", "Billboard week of 7/14 — sold to order #12".
  5. Claim: insert placements at the smallest unused positions per episode (round-robin auto-fill per W4); insert week_placements sorted by date. A UNIQUE violation here (possible only via a race the locks should have serialized) maps to ErrConflict, not a 500 — the constraint is the backstop, not the primary mechanism.
  6. Commit. Handler writes the audit row.

Audit placement: keep the house idiom — handler writes audit after commit — for consistency with every other mutation, BUT the finalize/un-finalize/amend audit detail JSON must carry the complete placement delta (episode ids, positions, week dates) so the audit log alone can reconstruct inventory history. The crash-window (commit succeeds, audit insert doesn't) is accepted consciously; if Leo prefers in-tx audit for the inventory-bearing trio, it's a one-line signature change (Record gains a variant taking pgx.Tx) — decide at plan time, either is defensible. (Recorded so the plan writer doesn't relitigate it.)

Confirmation step (spec §3 item 1): finalize is GET-confirm → POST-execute. The confirm page shows exactly what will be claimed (every episode date with current remaining count, every billboard week) and carries the order version through a hidden field so the POST's step-1 check catches anything that changed while the human read the page. No JavaScript-only confirm.

3.4 Sea of red (computed, never stored)

3.5 Amend-in-place (B5b)

3.6 Makegoods (B5b)

3.7 Delete guards (B5b)

4. The status-gating matrix (B5a) — existing code that MUST change

Today nothing is gated on order status: ToggleLineDate, ToggleLineWeek, DeleteLine, UpdateCatalogLine, UpdateLineRate, ToggleLineValueAdd, and even header Update all run against finalized and void orders. B3/B4's "no order-status gate" Domain Decision #11 was correct when no inventory existed; B5a is where the line gets drawn. The matrix:

Mutation pending finalized void
Header update (title/terms/notes/discount/presentation/quarters/salesperson) ✅ audited (D.D. #11 holds — legacy changed terms forward-only) ❌ refuse
Advertiser/agency change ❌ refuse (billing identity of a signed order; §6 Q2 confirms)
Line rate/CPM/impressions override ✅ audited (D.D. #11 holds)
Catalog quantity, value-add toggle ✅ audited
Date/week toggle (lineDateToggle, lineWeekToggle) ❌ refuse → "use Amend" (B5b's transactional path)
Line add / line delete ❌ refuse (B5b amend/Q5 owns additions; RESTRICT FK backstops deletes)
Duplicate ✅ (copy is born pending, no placements copied)
Finalize ❌ (step-1 UPDATE refuses)
Un-finalize ✅ B5b, zero-aired only
Void ❌ (un-finalize first)

Implementation shape: one helper in the handlers (requireOrderStatus(order, "pending")-style) + the store-layer checks for the inventory-bearing ops (defense in depth — the store refuses too, since handlers aren't the only future caller). Every ❌ is a friendly 4xx naming the rule, and every row of this table gets a test.

Also in B5a (guards that exist only because placements now exist):

  1. max_ads reduction guard (admin_shows update): refuse setting max_ads below the current placement count of any non-aired episode of that show — otherwise position 3 placements exist on a max-2 show and availability math goes negative. (Aired episodes are history; they don't block.)
  2. Episode cancel guard (internal/shows cancel): refuse while the episode has placements, listing the affected orders — the operator amends those orders (or makegoods) first, then cancels. Episode move needs no guard: placements reference episode_id, so sold dates travel with the episode automatically — spec §3 item 2 is satisfied by construction, and "moved out of the paid window" stays a C4 warning. (§6 Q3 confirms the cancel behavior.)

5. Required tests (the plan must include ALL of these)

Concurrency (all against real Postgres, just test-db, remembering -p 1 — these tests share the DB and must self-contain):

Functional:

6. Questions for Leo (answer before the B5a plan is written)

  1. What does "aired" mean, exactly? The immutability boundary. An episode records Tuesday and publishes Thursday; on Wednesday, is its date amendable? The ad read already happened at record time. Recommendation: aired = record_date < today (LA) — once recorded, frozen; publish_date is the fallback for any episode without a meaningful record date. (Spec §3 says "aired"; the FLOSS mail says "the date has passed" without specifying which date.)
  2. Advertiser/agency swap on a finalized order: refuse, or allow-with-audit? §4 recommends refusing the advertiser swap (it's the billing identity of a signed document; legacy never allowed it) while agency… also refusing, honestly — a signed IO's payer shouldn't drift either; changing payer = un-finalize or new order. Loose-role philosophy argues allow-everything-audited. Recommendation: refuse both once finalized.
  3. Episode cancel while sold: hard-block (amend the orders first), or allow-and-orphan (placements flagged on C4's attention list)? Recommendation: hard-block with the affected-orders list — no silent revenue changes, and it matches "the answer was makegoods" from the archive. (Show retirement / "retire from date X" is NOT in B5 — it rides with C-phase or its own slice; B5a's cancel guard is what makes retirement safe to build later. Flagging so the §3-item-4 language isn't read as B5 scope.)
  4. Billboard capacity is 1 per week, network-wide — confirm. B4's picker already hardcodes capacity 1 and the rate card sells "the billboard week" singular; the week_placements UNIQUE bakes it in. (If some future world sells 2, it's a migration + constraint change — fine, but say so now.)
  5. The B5a/B5b split itself, and gates: two plans, two executions, Leo gate after each. OK?

Q5 (upsell) stays with Lisa and blocks nothing (§3.5 explains why). The due-upon-receipt terms text stays with Lisa, independent of B5, per the B4 close-out.

7. Landmines the plan writer inherits (so nobody rediscovers them)