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):
placements+week_placementstables (migration 00009)- Transactional finalize with confirmation step and precise conflict list
- Round-robin position assignment
- Real
SoldCount(replaces the B3 stub atorders.go:401) - Sea of red: red badges on the orders list, red lines in the editor, finalize blocked while conflicted
- The status-gating matrix (§4) — void orders become read-only; date/week toggles and line deletes gated on finalized orders
- Episode-cancel guard (refuse while placements exist — §6 Q3)
- 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)
- Void is reachable only from
pending. An accidentally-finalized order (Grammarly 2018) is un-finalized first, then voided — two deliberate clicks, and "returns future slots" has exactly one owner (un-finalize). Nofinalized → voidshortcut. - Un-finalize deletes ALL the order's placements and week placements and flips status back to pending. It is refused if any placement is aired (§6 Q1 defines "aired") — a mid-term client cancellation is not an un-finalize, it's amend-in-place removal of the remaining future dates; the order stays finalized and the aired spots stay billed.
- Void is terminal. No un-void; resurrect via Duplicate. Advertiser-delete's "cascade dead proposals" (spec §3) collects
pendingandvoidorders only — and with this state machine, no order that ever aired can be voided, so history is structurally safe. - An order with aired placements can never leave
finalized. That IS the past-is-immutable invariant at the header level.
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()
);
ON DELETE RESTRICTon both — a placed line can never be silently deleted; the DB backstops the handler gate (§4).order_line_dates' CASCADE stays as-is (it's the proposal-side record; placements are the claimed-inventory record).- Placement rows exist iff the owning order is finalized — created at finalize, deleted at un-finalize, individually deleted by amend.
SoldCountis then simplycount(*) FROM placements WHERE episode_id=$1, finalized-only by construction, exactly matchingClassifyDate's doc contract. ad_copy_idis not added now — C2 adds it (nullable) when ad_copy exists. Forward-only migrations make that a clean ALTER.- No
position <= max_adsCHECK is possible cross-table; it's enforced in Go inside the lock (§3.3) with the UNIQUE constraint as the safety net, plus the max_ads-reduction guard (§4, item 5).
3.3 The finalize transaction (the protocol, exactly)
One pgx.BeginFunc transaction:
- 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 withErrConflict. This single statement is the double-finalize guard and the optimistic-lock check. - Lock episodes in deterministic order: collect every episode_id across the order's
episode_adline 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. TheORDER BY e.idis load-bearing — it's the deadlock preventer when two finalizes overlap on multiple episodes in different iteration orders. - 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. - 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".
- 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. - 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)
- A pending order is conflicted if any of its episode dates has
RemainingSlots(max_ads, SoldCount) == 0, or any of its billboard weeks exists inweek_placements. Computed live at render (computed-over-stored; 7 users, trivial cost). Orders list: red badge via one batched query (not N+1 — same batching idiom asStore.Lines). Editor: conflicted dates renderDateConflictred; a banner names the conflicting lines; the Finalize button is disabled/blocked server-side while any conflict exists. - Billboard picker counting rule changes at B5a (this is the B4 Domain Decision #2 revisit, on the record):
soldfor a week becomesweek_placementsexistence (finalized-only — consistent with episodes and with "proposals hold nothing"), and the pending-claims visibility Leo asked for in B4 is preserved as a distinct signal: the week button gets a "pending: order #N" hint (tooltip/parenthetical), not a red.BillboardWeekSoldCount's pending-counting query survives, renamed to feed the hint. Red = a signature holds it; hint = someone else is proposing it. §6 Q4 confirms capacity=1.
3.5 Amend-in-place (B5b)
- Remove future date from a finalized order's line: one transaction — verify the date is not aired (§6 Q1), delete the placement row AND the
order_line_datesrow, audit with the released slot's full identity. Slots return to inventory instantly (the FLOSS 2020 operation, self-service at last). - Add future date to a finalized order's line: the machinery is a single-date finalize — same lock/validate/claim protocol (§3.3 steps 2–5) scoped to one episode. Build the store operation in B5b regardless of Q5's answer (it's also what a date swap decomposes into); whether the UI exposes "add to a signed order" vs. "makegoods/new order only" is decided by Lisa's Q5 answer — the mechanism is identical either way, so the open question blocks nothing.
- Aired dates: removal refused by rule, in the store layer (not just the UI), with a test proving it. The boundary definition is §6 Q1; whatever the answer, implement it as a pure function
Aired(episode, now) boolinordermath(injectablenow— the fixedtoday()helper atadmin_shows.go:30is fine for handlers but tests need the parameter; the b4-b5-handoff's hardcoded-date lesson applies). - Whole-line delete on finalized orders stays refused (§4) — amend removes dates, never lines; a line whose dates are all removed renders at $0 quantity 0 and stays for history.
3.6 Makegoods (B5b)
ALTER TABLE order_lines ADD COLUMN makegood_for_line_id bigint REFERENCES order_lines(id)(nullable) — the line-level half of spec §3 item 3 (orders.makegood_for_order_idhas existed since B2).- "Create makegood" action on a finalized order: creates a new pending order titled from the original ("Makegood —
"), makegood_for_order_idset, same advertiser/agency/salesperson; lines added through the normal editor at rate $0, CPM $0 (real impressions, real placements — a makegood delivers inventory, it is NOT a value-add line;is_value_addstays false so guaranteed impressions count). It finalizes through the exact same §3.3 protocol — no special path, which is the point: the makegood never edits the original order (the FLOSS lesson).
3.7 Delete guards (B5b)
- Advertiser delete (
sponsors.go:106): refuse when anyfinalizedorder references the advertiser. Otherwise show the confirm listing its pending/void orders, then cascade-delete those orders (their lines/dates cascade via existing FKs — verify order_lines needsON DELETE CASCADEfrom orders or do it explicitly in a tx; migration 00007 currently has plainREFERENCES orders(id), so the cascade must be explicit in Go. Fine — explicit is better here anyway, it audits). - Agency delete (
sponsors.go:179): refuse when ANY order references it, full stop (pending proposals carry its commission % in live totals). - Ad-copy guard is C2's — but record the rule now: ad_copy referenced by any placement is soft-deleted/blocked, never hard-deleted (the 2019 rundown outage).
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):
max_adsreduction guard (admin_showsupdate): refuse settingmax_adsbelow 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.)- Episode cancel guard (
internal/showscancel): 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 referenceepisode_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):
- The W2 race: orders A and B share the last open slot of one episode; two goroutines finalize concurrently. Exactly one succeeds; the loser's error carries the precise conflict;
count(placements)for the episode == max_ads. Run the pair many times in a loop (both orderings must occur). - Deadlock ordering: two orders each claiming episodes {X, Y} with lines built in opposite orders; concurrent finalize; no deadlock (ordered locking), one winner per slot.
- Double-finalize: same order, two concurrent finalize POSTs (stale-version and same-version variants) — one wins, one gets ErrConflict.
- Billboard race: two orders, same week, concurrent finalize — UNIQUE backstop yields exactly one week_placement, loser gets the week named in its conflict list.
- Invariant hammer: N goroutines running randomized finalize / un-finalize / amend-remove against a shared fixture; after the dust: no episode over max_ads, every placement's order is
finalized, every placement has a matchingorder_line_datesrow (and vice versa for finalized orders).
Functional:
- Un-finalize returns slots end-to-end: finalize A → B goes red → un-finalize A → B's conflict clears → B finalizes.
- Un-finalize refused with an aired placement (injectable clock, not sleep/fixture-date tricks).
- Amend: remove future date releases the slot same-transaction; remove aired date refused at the store layer; add-date claims transactionally and conflicts precisely.
- Every cell of the §4 matrix, including all ❌s (especially: void order rejects every mutation; finalized rejects date toggle, line add, line delete, advertiser change).
- Episode-cancel guard; max_ads-reduction guard; makegood $0 math (guaranteed impressions still count — a golden test through
ordermath.Compute); advertiser/agency delete guards incl. the cascade path. - Audit rows for finalize/un-finalize/amend carry the full placement delta.
6. Questions for Leo (answer before the B5a plan is written)
- 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.) - 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.
- 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.)
- 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_placementsUNIQUE bakes it in. (If some future world sells 2, it's a migration + constraint change — fine, but say so now.) - 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)
orders.SoldCountstub atorders.go:401returns 0 by design (B3 D.D. #1) — B5a replaces it; the date picker starts showing real red/grey the moment it lands, no UI change needed (ClassifyDatewas built for this).BillboardWeekSoldCountcounts pending claims on purpose (B4 D.D. #2, Leo's call) — do not "fix" it; §3.4 defines its B5a evolution into the pending-hint. The picker'sClassifyDate(1, sold, isSel)call atorders_ui.go:716is where the finalized-only count swaps in.week_start_dateweeks are Mondays (upcomingMondays) — week_placements inherits that convention; normalize any inbound date to its Monday before insert.- Migration 00007's FKs:
order_lines → ordersandorder_line_dates → episodeshave no CASCADE — the advertiser-delete cascade (§3.7) must delete explicitly in a tx; don't add CASCADE FKs in 00009 (RESTRICT-by-default is the correct posture now that placements exist). orders.Updatecurrently round-trips ALL header fields including advertiser/agency — the §4 matrix means the finalized-order edit form must drop those fields from the UPDATE, not just hide them in the template.today()(admin_shows.go:30) is uninjectable; the aired-boundary and hammer tests neednowas a parameter (pure function inordermath), per the hardcoded-date lesson inb4-b5-handoff.md.- Test DB runs
-p 1with TRUNCATE helpers — the concurrency tests must create their own fixtures and not assume seed rows survive (phase-a-review.mdgotchas, still in force).