diff --git a/SEAMS.md b/SEAMS.md
index 0c7d47aa..8182fc0a 100644
--- a/SEAMS.md
+++ b/SEAMS.md
@@ -44,7 +44,7 @@
> environment- or flag-gated `Add*` branch exists today), so nothing is known to be missing
> from this map for that reason.
>
-> **77 seams across 6 projects.**
+> **79 seams across 6 projects.**
## GenWave.Context (2 seams)
@@ -89,7 +89,7 @@
| `GenWave.Core.Abstractions.IEnergyAnalyzer` | `GenWave.Loudness.FfmpegEnergyAnalyzer` | Singleton | — |
| `GenWave.Core.Abstractions.ILoudnessAnalyzer` | `GenWave.Loudness.FfmpegLoudnessAnalyzer` | Singleton | — |
-## GenWave.MediaLibrary (30 seams)
+## GenWave.MediaLibrary (32 seams)
| Port | Adapter | Lifetime | Notes |
|---|---|---|---|
@@ -121,6 +121,8 @@
| `GenWave.Core.Abstractions.IRequestCatalogProbe` | `GenWave.MediaLibrary.Catalog.RequestCatalogProbeRepository` | Singleton | — |
| `GenWave.Core.Abstractions.IRequestStore` | `GenWave.MediaLibrary.Station.RequestRepository` | Singleton | — |
| `GenWave.Core.Abstractions.IScheduleStore` | `GenWave.MediaLibrary.Station.ScheduleRepository` | Singleton | — |
+| `GenWave.Core.Abstractions.IShowImagingScope` | `GenWave.MediaLibrary.Catalog.ShowImagingScopeRepository` | Singleton | — |
+| `GenWave.Core.Abstractions.IShowStore` | `GenWave.MediaLibrary.Station.ShowRepository` | Singleton | — |
| `GenWave.Core.Abstractions.IThemeStore` | `GenWave.MediaLibrary.Station.ThemeRepository` | Singleton | — |
| `GenWave.Core.Abstractions.IYearLookup` | `GenWave.MediaLibrary.YearLookup.MusicBrainzYearLookup` | Singleton | — |
diff --git a/admin-ui/__specs__/catalog-show-shelf.spec.tsx b/admin-ui/__specs__/catalog-show-shelf.spec.tsx
new file mode 100644
index 00000000..f6ab86e0
--- /dev/null
+++ b/admin-ui/__specs__/catalog-show-shelf.spec.tsx
@@ -0,0 +1,16 @@
+// STORY-315 — Hire a show from the shelf (F118.2, F118.3) — shelf/modal half — PENDING
+// scaffold (T255, planned 2026-08-10). The import endpoint half is xUnit
+// (Host.Tests/Story315_ShowImport.cs).
+
+describe("Feature: The show shelf", () => {
+ describe("Scenario: browsing show cards", () => {
+ it.todo("show cards render name, tagline, and bestFor chips beside personas/themes/fonts");
+ it.todo("the detail modal shows the FULL card including flavor before confirm (F90 trust posture)");
+ });
+
+ describe("Scenario: the soft hire offer", () => {
+ it.todo("offers 'also hire' only when the suggested persona is on the shelf and not hired");
+ it.todo("declining the offer imports the show and hires nothing");
+ it.todo("an absent or unknown suggestion renders no offer and no error");
+ });
+});
diff --git a/admin-ui/__specs__/imaging-show-scope.spec.tsx b/admin-ui/__specs__/imaging-show-scope.spec.tsx
new file mode 100644
index 00000000..3dc1c493
--- /dev/null
+++ b/admin-ui/__specs__/imaging-show-scope.spec.tsx
@@ -0,0 +1,10 @@
+// STORY-313 — Span-assign & imaging scope (F119.4) — imaging half — PENDING scaffold
+// (T246, planned 2026-08-10). Minimal delta on the imaging editor: the scope picker only.
+
+describe("Feature: Imaging show scope", () => {
+ describe("Scenario: authoring with a scope", () => {
+ it.todo("the scope picker defaults to station-wide");
+ it.todo("selecting a show sends the scope with the authored insert");
+ it.todo("existing authored rows render their scope (station-wide | show name)");
+ });
+});
diff --git a/admin-ui/__specs__/schedule-show-picker.spec.tsx b/admin-ui/__specs__/schedule-show-picker.spec.tsx
new file mode 100644
index 00000000..37f3e4ae
--- /dev/null
+++ b/admin-ui/__specs__/schedule-show-picker.spec.tsx
@@ -0,0 +1,15 @@
+// STORY-313 — Span-assign & imaging scope (F119.2) — grid picker half — PENDING scaffold
+// (T245, planned 2026-08-10). The imaging-scope half is imaging-show-scope.spec.tsx.
+
+describe("Feature: Grid show picker with span-assign", () => {
+ describe("Scenario: the run rule", () => {
+ it.todo("assigning from any block applies to the whole contiguous same-persona run by default");
+ it.todo("the narrow checkbox limits assignment to the single clicked block");
+ it.todo("the picker lists shows by name with a clear-none option");
+ });
+
+ describe("Scenario: runs end honestly", () => {
+ it.todo("span-assign stops at a music-only block (the pinned span rule)");
+ it.todo("span-assign stops at an other-persona block");
+ });
+});
diff --git a/admin-ui/__specs__/schedule-specials-form.spec.tsx b/admin-ui/__specs__/schedule-specials-form.spec.tsx
new file mode 100644
index 00000000..72699c1d
--- /dev/null
+++ b/admin-ui/__specs__/schedule-specials-form.spec.tsx
@@ -0,0 +1,14 @@
+// STORY-317 — Dated specials shadow the grid (F120.3) — form half — PENDING scaffold
+// (T259, planned 2026-08-10). 🪂 DROPPABLE SLICE: dropping PR 5 removes these todos with it.
+// Deliberately a dated-list form, NOT a second paint grid.
+
+describe("Feature: The specials form", () => {
+ describe("Scenario: authoring a dated special", () => {
+ it.todo("creates a special with date, span, persona, show, and envelope");
+ it.todo("lists upcoming specials by date with edit/delete");
+ });
+
+ describe("Scenario: rejections surface honestly", () => {
+ it.todo("an overlapping span on the same date surfaces the EXCLUDE rejection in place");
+ });
+});
diff --git a/admin-ui/__specs__/shows-page.spec.tsx b/admin-ui/__specs__/shows-page.spec.tsx
new file mode 100644
index 00000000..45ef53c2
--- /dev/null
+++ b/admin-ui/__specs__/shows-page.spec.tsx
@@ -0,0 +1,22 @@
+// STORY-312 — The Shows page (F119.1, F119.3) — PENDING scaffold (T244, planned 2026-08-10)
+//
+// it.todo until /build-loop implements the page; UI-flow ACs beyond these are browser
+// acceptance per the T92 precedent.
+
+describe("Feature: The Shows page", () => {
+ describe("Scenario: authoring in place", () => {
+ it.todo("renders the show list with the provenance line on imported shows");
+ it.todo("creates a show with name/tagline/flavor under budget maxlengths (60/120/400)");
+ it.todo("edits an authored show and round-trips every field");
+ it.todo("supports several shows referencing the same persona's blocks (one DJ, many shows)");
+ });
+
+ describe("Scenario: guarded delete UX", () => {
+ it.todo("surfaces the 409 refusal naming the referencing schedule blocks");
+ it.todo("deletes an unreferenced show after confirm");
+ });
+
+ describe("Scenario: coverage stays neutral", () => {
+ it.todo("shows no nudge, badge, or warning anywhere for unnamed blocks (F119.3)");
+ });
+});
diff --git a/db/01-library.sh b/db/01-library.sh
index f70237ca..2aa1ed57 100755
--- a/db/01-library.sh
+++ b/db/01-library.sh
@@ -226,6 +226,15 @@ psql -v ON_ERROR_STOP=1 -v pw="$LIBRARY_DB_PASSWORD" \
add column imaging_kind text
check (imaging_kind is null or imaging_kind in ('liner', 'station_id', 'jingle', 'promo'));
+ -- show_id (SPEC F119.4, STORY-305/STORY-310, PLAN T238): scopes an authored imaging row to a
+ -- station.show. Crosses the db/22 schema-role boundary (station_svc has no grant into library) the
+ -- same way booth_log.media_id already crosses it in the other direction -- plain int, deliberately
+ -- NO FK, resolved by the app at its own edge, never a cross-schema join. NULL = station-wide
+ -- (every row today); set only on an authored imaging row scoped to a show once T246 wires the
+ -- write path. NO CONSUMER YET (T238): the pool query gains the show filter at T250.
+ alter table library.media
+ add column show_id int;
+
-- Composite partial index: scope-filtered random-ready pick (replaces scalar media_ready).
create index media_scope_ready on library.media (library_id, state) where state = 'ready';
create index media_artist on library.media (artist); -- ready for criteria queries
diff --git a/db/06-station-settings-migration.sh b/db/06-station-settings-migration.sh
index 48327079..18777999 100755
--- a/db/06-station-settings-migration.sh
+++ b/db/06-station-settings-migration.sh
@@ -184,7 +184,15 @@ psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-'
-- would otherwise force this migration to keep pace with. NO CONSUMER YET (T219): T220 wires
-- the write path. See db/33-show-and-segment-kind-migration.sh for the in-place upgrade path
-- this column also ships as.
- segment_kind text
+ segment_kind text,
+ -- SPEC F121.1, STORY-310, PLAN T238/T242: the air-time show stamp, written the same
+ -- synchronous-at-write-time way as persona_id/artist/pick/segment_kind above. NULL for every
+ -- row aired outside a show or predating this column. Deliberately NO FK — history must outlive
+ -- the entity; a deleted show must never rewrite or block on past airings (the exact media_id/
+ -- segment_kind precedent already on this table). NO CONSUMER YET (T238): T242 wires the write
+ -- path. See db/35-show-identity-migration.sh for the in-place upgrade path this column also
+ -- ships as.
+ show_id int
);
-- Keyset paging spine (SPEC F72.2): newest-first (occurred_at DESC, id DESC) with no OFFSET —
@@ -244,20 +252,40 @@ psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-'
CREATE INDEX IF NOT EXISTS request_pending
ON station.request (status, expires_at);
- -- Shows (SPEC F114, gh-#383 — the later slice, schema ruled at STORY-304/T219): a first-class
- -- entity, singular like every other table in this schema (station.persona precedent) — renaming a
- -- show touches one row, and identity is what patter/idents/spectator will reference once the
- -- F114 slice lands. Defined here, ahead of station.segment_schedule below, purely so that table's
- -- show_id column has something to reference — the two tables carry no other ordering relationship.
- -- NO CONSUMER YET (T219): station.show stays dormant by design until F114 wires a writer/reader,
- -- the same "seam before consumer" way station.persona_taste (T59), station.theme (T181), and
- -- station.font_pack (T198) all shipped. See db/33-show-and-segment-kind-migration.sh for the
- -- in-place upgrade path this table also ships as.
+ -- Shows (SPEC F114/F115, gh-#383 — the later slice, schema ruled at STORY-304/T219 then widened
+ -- at STORY-305/STORY-310/T238): a first-class entity, singular like every other table in this
+ -- schema (station.persona precedent) — renaming a show touches one row, and identity is what
+ -- patter/idents/spectator will reference once the F114/F115 slices land. Defined here, ahead of
+ -- station.segment_schedule below, purely so that table's show_id column has something to
+ -- reference — the two tables carry no other ordering relationship.
+ --
+ -- slug is the import identity (a catalog slug for an import, the house Slugify output for an
+ -- authored show — T239), UNIQUE and NOT NULL — safe with no backfill because this table is still
+ -- empty on every install (NO CONSUMER YET below). tagline is public (broadcast-shaped); flavor is
+ -- prompt-only and NEVER public (F115.3 — the persona-soul precedent). imported_from/imported_at
+ -- mirror station.persona's own db/25 provenance pair exactly.
+ --
+ -- persona_id/envelope are DORMANT bundle columns (ARCHITECTURE.md ruled 2026-08-10): UNREAD until
+ -- the deferred schedulable-bundle slice. Future semantics recorded there, not enforced here:
+ -- effective assignment = block ?? show ?? none, block always wins.
+ --
+ -- NO CONSUMER YET (T219, still true after T238's widening): station.show stays dormant by design
+ -- until F114/F115 wire a writer/reader, the same "seam before consumer" way station.persona_taste
+ -- (T59), station.theme (T181), and station.font_pack (T198) all shipped. See
+ -- db/33-show-and-segment-kind-migration.sh and db/35-show-identity-migration.sh for the in-place
+ -- upgrade paths this table also ships as.
CREATE TABLE IF NOT EXISTS station.show (
- id serial PRIMARY KEY,
- name text NOT NULL CHECK (length(btrim(name)) > 0),
- created_at timestamptz NOT NULL DEFAULT now(),
- updated_at timestamptz NOT NULL DEFAULT now()
+ id serial PRIMARY KEY,
+ name text NOT NULL CHECK (length(btrim(name)) > 0),
+ slug text NOT NULL CONSTRAINT show_slug_key UNIQUE,
+ tagline text,
+ flavor text,
+ imported_from text,
+ imported_at timestamptz,
+ persona_id int REFERENCES station.persona (id),
+ envelope jsonb,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ updated_at timestamptz NOT NULL DEFAULT now()
);
-- The weekly format-clock grid (SPEC F91.1, F91.2; STORY-240, STORY-242; PLAN T118) that replaces
diff --git a/db/35-show-identity-migration.sh b/db/35-show-identity-migration.sh
new file mode 100755
index 00000000..e5b7cc54
--- /dev/null
+++ b/db/35-show-identity-migration.sh
@@ -0,0 +1,75 @@
+#!/bin/bash
+# 35-show-identity-migration.sh — idempotent in-place upgrade for existing DBs.
+# Widens station.show into the F115 identity package (name/slug/tagline/flavor/provenance) plus the
+# DORMANT F115.2/F121 bundle columns, stamps station.booth_log and library.media with an air-time
+# show_id (F121.1, F119.4); STORY-305, STORY-310, PLAN T238. ARCHITECTURE.md "Dayparting: named
+# shows" -> "Data model" has this DDL verbatim.
+#
+# station.show shipped DORMANT in db/33 (T219) with no writer anywhere — every install has an empty
+# table today, which is what makes `slug text NOT NULL` safe to add here without a backfill or a
+# DEFAULT. slug is the import identity (a catalog slug for an imported show, the house Slugify output
+# for an authored one — T239) and carries a named UNIQUE constraint so a fresh-init table and an
+# upgraded one converge on the identical constraint name. tagline is public (broadcast-shaped, the
+# spectator DTO and ceremony read it once F115 wires a reader); flavor is prompt-only and NEVER public
+# (F115.3 — the persona-soul precedent). imported_from/imported_at mirror station.persona's own db/25
+# provenance pair exactly: the catalog entry slug for an import, the literal 'file' for an upload,
+# NULL for an authored-in-place show.
+#
+# persona_id/envelope are the DORMANT bundle columns (ARCHITECTURE.md ruled 2026-08-10): UNREAD until
+# the deferred schedulable-bundle slice. Future semantics recorded there, not enforced by this schema:
+# effective assignment = block ?? show ?? none, block always wins. persona_id is a plain nullable FK
+# (no ON DELETE override needed — the column has no consumer to make deletion a live concern yet);
+# envelope is jsonb for the same open-ended-shape reason station.theme/font_pack definitions are.
+# NO CONSUMER YET for either column, same "seam before consumer" precedent as station.show itself.
+#
+# booth_log.show_id is the air-time stamp (F121.1, STORY-310) — the same synchronous-at-write-time
+# discipline persona_id/artist/pick/segment_kind on this table already use (db/17/18/33). Deliberately
+# NO FK: history must outlive the entity (the exact booth_log.persona_id-would-need vs media_id/
+# segment_kind precedent already on this table) — a deleted show must never rewrite or block on past
+# airings.
+#
+# library.media.show_id crosses the db/22 schema-role boundary (station_svc has no grant into
+# library) the same way booth_log.media_id already crosses it in the other direction — plain int, NO
+# FK, resolved by the app at its own edge, never a cross-schema join. NULL = station-wide (today's
+# only meaning); set only on an authored imaging row scoped to a show (T246).
+#
+# Safe to run multiple times: ADD COLUMN IF NOT EXISTS is a no-op on an already-migrated database.
+set -euo pipefail
+
+: "${POSTGRES_USER:?POSTGRES_USER must be set}" "${POSTGRES_DB:?POSTGRES_DB must be set}"
+
+psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-'SQL'
+ set role station_svc;
+ set search_path = station;
+
+ -- The identity package + provenance (station.show shipped dormant in db/33; empty everywhere,
+ -- so NOT NULL is safe to add with no backfill). Named UNIQUE so a fresh-init table (db/06's own
+ -- CREATE, already widened) and this upgraded one land on the identical constraint name.
+ alter table station.show
+ add column if not exists slug text not null constraint show_slug_key unique,
+ add column if not exists tagline text,
+ add column if not exists flavor text,
+ add column if not exists imported_from text,
+ add column if not exists imported_at timestamptz,
+ -- Dormant bundle columns (ruled 2026-08-10): UNREAD until the schedulable-bundle slice.
+ -- Future semantics recorded: effective = block ?? show ?? none; block always wins.
+ add column if not exists persona_id int references station.persona (id),
+ add column if not exists envelope jsonb;
+
+ -- Air-time stamp (F121.1). NO FK — history outlives the entity, same as media_id/segment_kind
+ -- already on this table.
+ alter table station.booth_log
+ add column if not exists show_id int;
+ SQL
+
+# library schema (separate connection/grant — the db/22 boundary): its own psql invocation, its own
+# role, mirroring how db/34 talks to library.media.
+psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-'SQL'
+ set role library_svc;
+ set search_path = library;
+
+ -- NO FK across the grant boundary (db/22 precedent). NULL = station-wide; set only on
+ -- authored imaging rows scoped to a show (T246).
+ alter table library.media
+ add column if not exists show_id int;
+ SQL
diff --git a/src/GenWave.Abstractions/Abstractions/IActivePersonaAccessor.cs b/src/GenWave.Abstractions/Abstractions/IActivePersonaAccessor.cs
index e0cc0587..67ff71bc 100644
--- a/src/GenWave.Abstractions/Abstractions/IActivePersonaAccessor.cs
+++ b/src/GenWave.Abstractions/Abstractions/IActivePersonaAccessor.cs
@@ -3,12 +3,20 @@
namespace GenWave.Core.Abstractions;
///
-/// SEAM (SPEC F35.2, F35.5) — the thin Core-visible accessor between
+/// SEAM (SPEC F35.2, F35.5, F121.1) — the thin Core-visible ON-AIR IDENTITY accessor between
/// GenWave.Orchestration (which cannot see the Host's IOptionsMonitor<StationOptions>
/// or directly) and the Host's live station configuration + persona
/// storage. Mirrors 's seam shape one level up: both sides of the
/// boundary depend on this one interface instead of inventing separate idioms.
///
+/// Answers BOTH halves of "who/what is on air right now" — the persona (,
+/// ) and, since PLAN T242, the show () — because
+/// a single implementation already owns the one on-air resolve both facts are read off (see
+/// OnAirPersonaAccessor's own remarks for how). Show identity rides THIS seam rather than a
+/// second one for that reason: a dedicated "active show" seam would just duplicate the same resolver
+/// dependency and the same never-throws/degrade discipline for no gain, when the implementation that
+/// already exists can name both.
+///
/// Implementations MUST re-evaluate the active persona id fresh on every call — never cache it in a
/// field — so a live activate/deactivate (the F19 overlay write) is visible to the very next render
/// with no process restart.
@@ -79,4 +87,24 @@ public interface IActivePersonaAccessor
///
///
string? TryGetCachedName(long personaId) => null;
+
+ ///
+ /// Synchronous, in-memory read of the on-air show's id (SPEC F121.1, STORY-310, PLAN T242) — the
+ /// SAME resolver-backed "who/what is on air right now" answer
+ /// already reads, off the SAME cached snapshot source, exposed on this seam rather than a second
+ /// one so the booth log's hot-path publish (BoothLogWriter.Publish) draws both stamps off
+ /// ONE dependency, the same "never a store round trip" way already
+ /// does. NOT literally one read at one instant: each property getter independently re-resolves
+ /// against the current wall clock (see the concrete resolver's own remarks), so a schedule
+ /// boundary landing between Publish's two reads can split the pair on at most one narrative
+ /// row — accepted, not a new risk this member introduces. No store round trip, no awaiting.
+ ///
+ /// Same never-throws, same null contract as :
+ /// for a grid gap, an unnamed block, or any resolver fault — the default "no show" state, not a
+ /// degradation. Default-implemented as "no show" for the same additive reason
+ /// is: every pre-F121 implementer (a test double, an older SDK
+ /// consumer) keeps compiling unchanged and simply reports "no show on air" until it opts in with a
+ /// real override.
+ ///
+ long? ActiveShowId => null;
}
diff --git a/src/GenWave.Abstractions/Domain/ScheduleSegment.cs b/src/GenWave.Abstractions/Domain/ScheduleSegment.cs
index 21a33ddc..2946a308 100644
--- a/src/GenWave.Abstractions/Domain/ScheduleSegment.cs
+++ b/src/GenWave.Abstractions/Domain/ScheduleSegment.cs
@@ -14,6 +14,17 @@ namespace GenWave.Core.Domain;
/// station-default envelope" (F91.4) — each is independently nullable, so a segment may override only
/// one of the three while the others fall back to the station default.
///
+///
+///
+/// (SPEC F116.1, STORY-306, PLAN T241) is this block's own named show, or
+/// for an unnamed (painted-persona-only or music-only) block —
+/// GenWave.MediaLibrary.Station.ScheduleRepository resolves it at LOAD time (a join against
+/// station.show keyed by segment_schedule.show_id), never a per-tick lookup, so the week
+/// snapshot this record is part of already carries every block's show identity in memory before
+/// ScheduleResolver ever runs. Defaults to so every pre-T241
+/// construction site (test fixtures included) stays diff-free — the additive-null-member shape SPEC
+/// F116.1's own "showless station byte-identical" test leans on.
+///
///
public sealed record ScheduleSegment(
long? Id,
@@ -23,4 +34,5 @@ public sealed record ScheduleSegment(
long? PersonaId,
IReadOnlyList? Genres,
double? EnergyMin,
- double? EnergyMax);
+ double? EnergyMax,
+ ShowSummary? Show = null);
diff --git a/src/GenWave.Abstractions/Domain/ScheduleWeekVersion.cs b/src/GenWave.Abstractions/Domain/ScheduleWeekVersion.cs
index ff6b1f76..64771d92 100644
--- a/src/GenWave.Abstractions/Domain/ScheduleWeekVersion.cs
+++ b/src/GenWave.Abstractions/Domain/ScheduleWeekVersion.cs
@@ -14,11 +14,19 @@ namespace GenWave.Core.Domain;
///
///
/// Pure CONTENT hash: SHA-256 over an invariant-culture rendering of every segment's
-/// day/start/end/persona/envelope, ordered by day then start minute. Row ids are deliberately
-/// EXCLUDED — ReplaceWeekAsync is delete-then-insert, so ids churn on every write even when
-/// the content is identical; a version that changed with them would 409 an editor whose grid still
-/// matches the stored week exactly. Two weeks with the same content are, for staleness purposes,
-/// the same week.
+/// day/start-minute/end-minute/persona-id/genres/energy-min/energy-max, ordered by day then start
+/// minute. Row ids are deliberately EXCLUDED — ReplaceWeekAsync is delete-then-insert, so ids
+/// churn on every write even when the content is identical; a version that changed with them would
+/// 409 an editor whose grid still matches the stored week exactly. Two weeks with the same content
+/// are, for staleness purposes, the same week.
+///
+///
+///
+/// (SPEC F116.1, PLAN T241) is ALSO deliberately excluded — show
+/// assignment is read-only through this epic, so this hash has nothing to protect yet. T243's writer
+/// (the first caller that lets an editor SET Show) must add it to the fields rendered here, or
+/// this staleness guard goes blind to concurrent show-assignment writes — the exact gh-#255
+/// save-loss class this type exists to prevent, just for a field it doesn't yet know about.
///
///
public static class ScheduleWeekVersion
diff --git a/src/GenWave.Abstractions/Domain/ShowSummary.cs b/src/GenWave.Abstractions/Domain/ShowSummary.cs
new file mode 100644
index 00000000..60244017
--- /dev/null
+++ b/src/GenWave.Abstractions/Domain/ShowSummary.cs
@@ -0,0 +1,35 @@
+namespace GenWave.Core.Domain;
+
+///
+/// The show identity a schedule block can carry (SPEC F115.1, F116.1; STORY-306, PLAN T241): the
+/// narrow, resolver-facing projection of station.show that rides
+/// and, from there,
+/// . Lives beside
+/// in the published GenWave.Abstractions contract surface (same reason that type does — a
+/// caller building against the SDK needs to see show identity riding the resolver's own snapshot),
+/// NOT beside the full Show CRUD entity (GenWave.Core.Domain.Show, unpublished), which
+/// this type is not.
+///
+///
+/// Deliberately excludes every station.show column beyond identity: Slug/
+/// ImportedFrom/ImportedAt/CreatedAt/UpdatedAt are Show's own CRUD
+/// concern, never needed on the air. Above all, this type has NO member for the DORMANT
+/// persona_id/envelope bundle columns (SPEC F115.2 — unread this epic, a law not an
+/// oversight): no query that projects a row into this type can even ACCIDENTALLY carry them forward,
+/// and no consumer reading can observe them
+/// either — the dormant-columns-unread pin is enforced by this type's own SHAPE, not merely by a
+/// query that happens not to select them today.
+///
+///
+/// Public, broadcast-shaped (SPEC F115.3) — safe for the spectator surface.
+/// when the show carries none.
+/// Prompt-only, private forever (SPEC F115.3 — the persona-soul precedent):
+/// despite riding this same internal snapshot, a consumer building a PUBLIC payload (spectator, T251)
+/// must never forward this field. The same law covers log lines (F115.3's "no log line" half): this
+/// record's compiler-generated ToString() renders verbatim, so no
+/// {Segment}/{Snapshot}-style structured-log placeholder may ever bind an
+/// or — or this type
+/// directly — on any public-adjacent logging path; log the identity fields
+/// (/) by name instead. when the show
+/// carries none.
+public sealed record ShowSummary(long Id, string Name, string? Tagline, string? Flavor);
diff --git a/src/GenWave.Abstractions/Playout/OnAirSnapshot.cs b/src/GenWave.Abstractions/Playout/OnAirSnapshot.cs
index 3c857c90..615784bb 100644
--- a/src/GenWave.Abstractions/Playout/OnAirSnapshot.cs
+++ b/src/GenWave.Abstractions/Playout/OnAirSnapshot.cs
@@ -29,9 +29,19 @@ namespace GenWave.Abstractions.Playout;
/// when a gap follows. Same F92.3 ruling as : a
/// same-persona successor (e.g. the F91.6 seeded grid's own midnight roll) is still reported here, never
/// collapsed away just because it will air no handoff ceremony.
+/// SPEC F116.1 (STORY-306, PLAN T241) — the on-air show identity, or
+/// on a grid gap or an unnamed block. Resolver-sourced through
+/// GenWave.Orchestration.EffectiveAssignment, the ONE identity chokepoint (SPEC F115.2): always
+/// exactly 's own , carried
+/// here for the same reason already is — a caller never has to null-check
+/// first just to learn which show (if any) is on. Every downstream consumer this
+/// epic adds (ceremony T248, the flavor line T249, show idents T250, spectator T251, the booth-log
+/// stamp T242) reads identity through THIS member, never re-deriving it from or
+/// re-querying station.show itself.
public sealed record OnAirSnapshot(
ScheduleSegment? Segment,
long? PersonaId,
SegmentEnvelope Envelope,
DateTimeOffset? BoundaryAt,
- ScheduleSegment? NextSegment);
+ ScheduleSegment? NextSegment,
+ ShowSummary? Show = null);
diff --git a/src/GenWave.Core/Abstractions/IBoothLogAppender.cs b/src/GenWave.Core/Abstractions/IBoothLogAppender.cs
index a8e16bd0..064e180c 100644
--- a/src/GenWave.Core/Abstractions/IBoothLogAppender.cs
+++ b/src/GenWave.Core/Abstractions/IBoothLogAppender.cs
@@ -1,3 +1,5 @@
+using GenWave.Core.Domain;
+
namespace GenWave.Core.Abstractions;
///
@@ -11,26 +13,9 @@ namespace GenWave.Core.Abstractions;
public interface IBoothLogAppender
{
///
- /// Appends one narrative row (, ), stamped
- /// now(). (SPEC F84.6, STORY-215) is the persona active on
- /// air at write time for a TRACK-START row — for every other kind, or a
- /// persona-less airing. (SPEC F84.1, STORY-215, PLAN T70) is that same
- /// track's artist, captured the same way and for the same reason: the accrual write path needs a
- /// STRUCTURED artist to build an artist-predicate rule from, never a regex over
- /// 's narrative prose. Never surfaced through
- /// — read directly by the accrual store only. for every non-track row or a
- /// track aired with no known artist. (SPEC F86.1, STORY-217, PLAN T73) is
- /// that same track's persona-pick stamp — the caller's already-serialized jsonb text (see
- /// GenWave.Core.Domain.BoothLogPickStampSerializer), or for every
- /// non-track row, an engine-initiated play, or a persona-off pick. Never backfilled.
- /// (gh-#99) is the aired row's numeric catalog id — captured the same
- /// way, for every non-track row or a non-catalog id. It exists so the
- /// Host can resolve safe-scope membership for the taste-thumb exclusion on the library
- /// connection; station.booth_log itself can never join library.media.
- /// (SPEC F113.1, STORY-304, PLAN T220) is that same track's
- /// air-time SegmentKind token name (e.g. "StationId"), captured the same way —
- /// for a music row or a non-track row. This is the demo-hour
- /// observability instrument's own column; never inferred from summary.
+ /// Appends one narrative row, stamped now(). See 's own
+ /// field docs for what each field carries, and when it is — this method
+ /// derives nothing from beyond what is already stamped on it.
///
- Task AppendAsync(string kind, string summary, long? personaId, string? artist, string? pick, long? mediaId, string? segmentKind, CancellationToken ct);
+ Task AppendAsync(BoothLogAppendRequest request, CancellationToken ct);
}
diff --git a/src/GenWave.Core/Abstractions/IScheduleStore.cs b/src/GenWave.Core/Abstractions/IScheduleStore.cs
index b4c84a05..ecdcd572 100644
--- a/src/GenWave.Core/Abstractions/IScheduleStore.cs
+++ b/src/GenWave.Core/Abstractions/IScheduleStore.cs
@@ -55,4 +55,16 @@ Task ReplaceWeekAsync(
/// .
///
event Action? WeekChanged;
+
+ ///
+ /// Every station.segment_schedule row naming , ordered by day
+ /// then start minute — the show delete guard's own detail read (SPEC F115.4, PLAN T240):
+ /// stays a bare singleton at the store seam (see
+ /// its own remarks), so ShowsController.Delete calls this directly to NAME the blocking
+ /// slots in its 409 body, mirroring PersonaRepository.DeleteAsync's own pre-T121
+ /// query-for-detail shape but at the endpoint layer instead — IShowStore never pre-queries
+ /// this table itself (PLAN T239's own deliberate choice). An empty result means nothing in
+ /// station.segment_schedule currently names this show.
+ ///
+ Task> GetSlotsByShowIdAsync(long showId, CancellationToken ct);
}
diff --git a/src/GenWave.Core/Abstractions/IShowImagingScope.cs b/src/GenWave.Core/Abstractions/IShowImagingScope.cs
new file mode 100644
index 00000000..7019b231
--- /dev/null
+++ b/src/GenWave.Core/Abstractions/IShowImagingScope.cs
@@ -0,0 +1,29 @@
+using GenWave.Core.Domain;
+
+namespace GenWave.Core.Abstractions;
+
+///
+/// SPEC F115.4 — the narrow cross-schema answer the show delete guard needs: which
+/// library.media rows are scoped (show_id, F117.1 — no FK) to a show that has just been
+/// deleted? station_svc deliberately has no grant on library.media (the db/22 boundary —
+/// the same reason resolves ITS cross-schema question on the
+/// library connection instead of a join), so this seam does too.
+///
+/// Deliberately its own narrow interface, mirroring 's own "one
+/// question, its own seam" posture: ShowsController's delete guard is the only consumer, and
+/// the question — "which rows, and clear them" — never grows read-amplification temptations onto a
+/// wider media interface.
+///
+public interface IShowImagingScope
+{
+ ///
+ /// Best-effort orphan prevention (SPEC F115.4): clears show_id on every library.media
+ /// row currently scoped to and returns exactly the rows cleared, in
+ /// id order — the delete guard's response names them in the SAME round trip that unscopes
+ /// them (an UPDATE ... RETURNING, atomic — no separate SELECT-then-UPDATE). Idempotent: a
+ /// repeat call against an already-cleared matches nothing and returns an
+ /// empty list, never an error — library.media.show_id carries no FK to violate, so there is
+ /// nothing for a second write to conflict with.
+ ///
+ Task> UnscopeAsync(long showId, CancellationToken ct);
+}
diff --git a/src/GenWave.Core/Abstractions/IShowStore.cs b/src/GenWave.Core/Abstractions/IShowStore.cs
new file mode 100644
index 00000000..17400d44
--- /dev/null
+++ b/src/GenWave.Core/Abstractions/IShowStore.cs
@@ -0,0 +1,61 @@
+using GenWave.Core.Domain;
+
+namespace GenWave.Core.Abstractions;
+
+///
+/// SEAM (SPEC F115.1, STORY-305, PLAN T239) — CRUD access to station.show: the named-show
+/// identity package (name/tagline/flavor/provenance) an hour of airtime can carry. Deliberately never
+/// maps, reads, or writes persona_id/envelope (SPEC F115.2 — a law of the epic, not an
+/// oversight); a future schedulable-bundle slice adds that seam separately. No DI registration and no
+/// consumer land with this seam — /api/shows (PLAN T240) is the first.
+///
+public interface IShowStore
+{
+ /// Returns every show row, ordered by name.
+ Task> GetAllAsync(CancellationToken ct);
+
+ /// Returns the show identified by , or null if no such row exists.
+ Task GetByIdAsync(long id, CancellationToken ct);
+
+ /// Returns the show identified by , or null if no such row exists
+ /// — the primitive a slug-addressed route (/api/shows/{slug}) resolves through.
+ Task GetBySlugAsync(string slug, CancellationToken ct);
+
+ ///
+ /// Creates a new authored show from : slug is derived from
+ /// 's Name via the house Slugify, imported_from/
+ /// imported_at stay null. Returns with the new row on
+ /// success, if Name is blank/whitespace-only or
+ /// its Slugify output equals the fallback literal "persona" — whether by Slugify's own
+ /// empty-slug rescue (an emoji-only name) or the ordinary path landing on that same string (e.g.
+ /// the literal name "Persona" itself; see 's own
+ /// remarks, PLAN T240 review A1), if a field exceeds its SPEC F115.1 1×
+ /// budget (checked before the write ever reaches Postgres), or
+ /// if the derived slug collides with an existing show
+ /// (enforced by the DB's UNIQUE(slug), not a pre-read).
+ ///
+ Task CreateAsync(ShowDraft draft, CancellationToken ct);
+
+ ///
+ /// Updates the show identified by with 's fields —
+ /// re-derives slug from the new Name the same way does, and
+ /// never touches imported_from/imported_at. Returns
+ /// with the row after the write (updated_at advanced) on success,
+ /// if no such show exists,
+ /// / the same
+ /// way does, or if another show
+ /// already holds the re-derived slug.
+ ///
+ Task UpdateAsync(long id, ShowDraft draft, CancellationToken ct);
+
+ ///
+ /// Deletes the show identified by . Returns
+ /// on success, if no
+ /// such show exists, or if
+ /// station.segment_schedule.show_id still names it (the FK's own ON DELETE RESTRICT,
+ /// caught as SQLSTATE 23503) — this seam's own case carries no detail beyond "referenced". Naming
+ /// the format-clock blocks (and scoped imaging rows) a still-referenced show cannot be deleted
+ /// through is the endpoint-layer guard PLAN T240 builds on top of that case.
+ ///
+ Task DeleteAsync(long id, CancellationToken ct);
+}
diff --git a/src/GenWave.Core/Domain/BoothLogAppendRequest.cs b/src/GenWave.Core/Domain/BoothLogAppendRequest.cs
new file mode 100644
index 00000000..7a2893c2
--- /dev/null
+++ b/src/GenWave.Core/Domain/BoothLogAppendRequest.cs
@@ -0,0 +1,36 @@
+namespace GenWave.Core.Domain;
+
+///
+/// One station.booth_log row awaiting persistence — 's
+/// parameter object (PLAN T220 review carry-forward: "IBoothLogAppender 8-param positional call wants
+/// a Core-side parameter object"; closed here at PLAN T242, where would have
+/// pushed that positional call to nine). Every field mirrors a stamp BoothLogWriter.Publish
+/// already captured SYNCHRONOUSLY, at AIR time, before the request was ever queued for
+/// BoothLogDrainService to drain — this type only regroups them into one argument; it derives
+/// nothing of its own and enforces no invariant beyond the shape.
+///
+/// The narrative kind (e.g. "track-started", "patter-aired").
+/// The operator-readable narrative line — human language, never a JSON dump.
+/// SPEC F84.6, STORY-215 — the persona on air at write time for a
+/// TRACK-START row. for every other kind, or a persona-less airing.
+/// SPEC F84.1, STORY-215, PLAN T70 — that same track's artist, captured the same
+/// way and for the same reason: the accrual write path needs a STRUCTURED artist to build an
+/// artist-predicate rule from, never a regex over 's narrative prose.
+/// for every non-track row or a track aired with no known artist.
+/// SPEC F86.1, STORY-217, PLAN T73 — that same track's persona-pick stamp, the
+/// caller's already-serialized jsonb text, or for every non-track row, an
+/// engine-initiated play, or a persona-off pick. Never backfilled.
+/// gh-#99 — the aired row's numeric catalog id, or for
+/// every non-track row or a non-catalog id.
+/// SPEC F113.1, STORY-304, PLAN T220 — that same track's air-time
+/// SegmentKind token name (e.g. "StationId"), or for a music row
+/// or a non-track row.
+/// SPEC F121.1, STORY-310, PLAN T242 — the show on air at write time for a
+/// TRACK-START row, music and kinded alike, captured the SAME way and for the SAME reason as
+/// : the resolver's on-air answer at the exact instant the row aired,
+/// never re-derived later. for every non-track row or a showless airing. No
+/// FK — history must outlive the entity, so a show deleted later never rewrites or blocks on a past
+/// airing.
+public sealed record BoothLogAppendRequest(
+ string Kind, string Summary, long? PersonaId, string? Artist, string? Pick, long? MediaId,
+ string? SegmentKind, long? ShowId);
diff --git a/src/GenWave.Core/Domain/ScheduledSlot.cs b/src/GenWave.Core/Domain/ScheduledSlot.cs
index 76cf4c36..95792d46 100644
--- a/src/GenWave.Core/Domain/ScheduledSlot.cs
+++ b/src/GenWave.Core/Domain/ScheduledSlot.cs
@@ -1,13 +1,18 @@
namespace GenWave.Core.Domain;
///
-/// One station.segment_schedule row naming a persona among the offending slots blocking a
-/// delete (SPEC F91.9; STORY-247, PLAN T121) — the payload
-/// carries so PersonaController.Delete's
-/// 409 body can name every slot rather than staying generic (PLAN T120 scaffolding). Deliberately a
-/// narrower read projection than — a caller blocked from deleting a
-/// persona needs to know WHEN it is still on-air, never the row's id or its genre/energy envelope —
-/// so this carries only //, the
-/// same day-of-week/minute vocabulary and db/27 both already use.
+/// One station.segment_schedule row naming a block among the offenders blocking a delete —
+/// shared by two consumers with the identical naming need: the persona guard (SPEC F91.9; STORY-247,
+/// PLAN T121), the payload carries so
+/// PersonaController.Delete's 409 body can name every slot rather than staying generic (PLAN
+/// T120 scaffolding); and the show guard (SPEC F115.4; STORY-305, PLAN T240), where
+/// ShowsController.Delete queries IScheduleStore.GetSlotsByShowIdAsync directly for the
+/// same day/time naming — stays a bare singleton at the
+/// store seam (see its own remarks), so the endpoint re-queries rather than the store pre-fetching
+/// detail neither persona nor show writes always need. Deliberately a narrower read projection than
+/// — a caller blocked from deleting something needs to know WHEN it is
+/// still on-air, never the row's id or its genre/energy envelope — so this carries only
+/// //, the same day-of-week/minute
+/// vocabulary and db/27 both already use.
///
public sealed record ScheduledSlot(DayOfWeek Day, int StartMinute, int EndMinute);
diff --git a/src/GenWave.Core/Domain/ScopedImagingRow.cs b/src/GenWave.Core/Domain/ScopedImagingRow.cs
new file mode 100644
index 00000000..767ed02f
--- /dev/null
+++ b/src/GenWave.Core/Domain/ScopedImagingRow.cs
@@ -0,0 +1,10 @@
+namespace GenWave.Core.Domain;
+
+///
+/// One library.media row unscoped from a deleted show (SPEC F115.4, STORY-305, PLAN T240) —
+/// what both names and clears. The show
+/// delete guard's response names these rows so an operator can see exactly what branding just went
+/// station-wide again. is null exactly when the row's own tag is (enrichment may
+/// not have run yet) — a caller names the row by in that case.
+///
+public sealed record ScopedImagingRow(long MediaId, string? Title);
diff --git a/src/GenWave.Core/Domain/Show.cs b/src/GenWave.Core/Domain/Show.cs
new file mode 100644
index 00000000..511abfc3
--- /dev/null
+++ b/src/GenWave.Core/Domain/Show.cs
@@ -0,0 +1,44 @@
+namespace GenWave.Core.Domain;
+
+///
+/// A named show row (SPEC F115.1, STORY-305, PLAN T239): the identity package — name, tagline,
+/// flavor, provenance — an hour of airtime can carry, authored once via IShowStore and
+/// referenced across the format clock. Deliberately excludes persona_id/envelope — the
+/// DORMANT schedulable-bundle columns db/35 ships alongside the rest of this table (SPEC F115.2, a
+/// law of this epic, not an oversight): no type or query in this epic maps, reads, or writes them: the
+/// deferred bundle slice adds that seam separately.
+///
+///
+/// Public, broadcast-shaped (SPEC F115.3) — joins the F67 disclosure inventory as a pinned field.
+/// null when the show carries none.
+///
+///
+/// Prompt-only, private forever (SPEC F115.3 — the persona-soul precedent): never appears in a public
+/// payload, spectator surface, or log line. null when the show carries none.
+///
+///
+/// Provenance stamp (SPEC F115.1, the F90/db-25 pattern): null for a show authored in place via
+/// /,
+/// "file" for a file-uploaded import, or the catalog entry's slug for a catalog import. No
+/// writer for the import path exists yet (PLAN T254) — every row this seam can itself produce carries
+/// null here.
+///
+/// The moment was last stamped; null exactly
+/// when is.
+///
+/// The stored station.show.slug column — re-derived from via the house
+/// Slugify (LegacyPersonaCardMapper.Slugify, the T68 golden-table contract) on every authored
+/// create/edit, mirroring Persona.Slug's own re-derive-on-every-write rule. An imported show
+/// instead keeps whatever slug the import route was given (T254), same caveat as
+/// Persona.Slug's own remarks.
+///
+public sealed record Show(
+ long Id,
+ string Name,
+ string Slug,
+ string? Tagline,
+ string? Flavor,
+ string? ImportedFrom,
+ DateTime? ImportedAt,
+ DateTime CreatedAt,
+ DateTime UpdatedAt);
diff --git a/src/GenWave.Core/Domain/ShowBudgetField.cs b/src/GenWave.Core/Domain/ShowBudgetField.cs
new file mode 100644
index 00000000..f2987e21
--- /dev/null
+++ b/src/GenWave.Core/Domain/ShowBudgetField.cs
@@ -0,0 +1,10 @@
+namespace GenWave.Core.Domain;
+
+/// Which field a outcome
+/// names (SPEC F115.1, STORY-305, PLAN T239).
+public enum ShowBudgetField
+{
+ Name,
+ Tagline,
+ Flavor,
+}
diff --git a/src/GenWave.Core/Domain/ShowBudgets.cs b/src/GenWave.Core/Domain/ShowBudgets.cs
new file mode 100644
index 00000000..25c141ec
--- /dev/null
+++ b/src/GenWave.Core/Domain/ShowBudgets.cs
@@ -0,0 +1,30 @@
+namespace GenWave.Core.Domain;
+
+///
+/// SPEC F115.1's field-length budgets for an authored/edited — the app seam's 1×
+/// hard line (name ≤60, tagline ≤120, flavor ≤400 chars; reasoned-not-fitted, the F89.5 posture).
+/// /
+/// enforce these before the write ever reaches Postgres. The manifest parser's own 2× headroom (PLAN
+/// T254) and catalog lint's WARN-over-1× posture are separate, later concerns — not this seam's.
+///
+public static class ShowBudgets
+{
+ public const int NameMaxChars = 60;
+ public const int TaglineMaxChars = 120;
+ public const int FlavorMaxChars = 400;
+
+ ///
+ /// The first budget violates, checked in field order (name, tagline,
+ /// flavor) so a draft violating more than one field always reports the same one first — the rule
+ /// lives beside its own constants (PLAN T239 review) so later writers (T240/T244/T254) share the
+ /// identical check order instead of each re-deriving it. null when every field is within
+ /// budget.
+ ///
+ public static ShowBudgetField? FirstViolation(ShowDraft draft)
+ {
+ if (draft.Name.Length > NameMaxChars) return ShowBudgetField.Name;
+ if (draft.Tagline is { Length: > TaglineMaxChars }) return ShowBudgetField.Tagline;
+ if (draft.Flavor is { Length: > FlavorMaxChars }) return ShowBudgetField.Flavor;
+ return null;
+ }
+}
diff --git a/src/GenWave.Core/Domain/ShowDraft.cs b/src/GenWave.Core/Domain/ShowDraft.cs
new file mode 100644
index 00000000..386f62ac
--- /dev/null
+++ b/src/GenWave.Core/Domain/ShowDraft.cs
@@ -0,0 +1,15 @@
+namespace GenWave.Core.Domain;
+
+///
+/// Caller-supplied fields for authoring or editing a (SPEC F115.1, STORY-305, PLAN
+/// T239). Always an AUTHORED write —
+/// /
+/// never set imported_from/imported_at from this draft, mirroring
+/// 's own posture; the import write path is a separate, later seam (PLAN
+/// T254).
+///
+/// SPEC F115.1's ≤120-char budget () —
+/// checked at the write seam, not this record. null/empty means no tagline.
+/// SPEC F115.1's ≤400-char budget () —
+/// checked at the write seam, not this record. null/empty means no flavor.
+public sealed record ShowDraft(string Name, string? Tagline = null, string? Flavor = null);
diff --git a/src/GenWave.Core/Domain/ShowWriteResult.cs b/src/GenWave.Core/Domain/ShowWriteResult.cs
new file mode 100644
index 00000000..6706220d
--- /dev/null
+++ b/src/GenWave.Core/Domain/ShowWriteResult.cs
@@ -0,0 +1,63 @@
+namespace GenWave.Core.Domain;
+
+///
+/// Discriminated union expressing every outcome of an write
+/// (SPEC F115.1, STORY-305, PLAN T239). Mirrors 's own closed-hierarchy
+/// shape: cases that carry data (, ,
+/// ) are sealed records with a positional payload; singleton cases
+/// (, , , ,
+/// ) carry none. The private constructor on the abstract base closes the
+/// hierarchy so callers can write exhaustive pattern-match switches without a discard arm.
+///
+public abstract record ShowWriteResult
+{
+ private ShowWriteResult() { }
+
+ /// The show was created; is the new row.
+ public sealed record Created(Show Show) : ShowWriteResult;
+
+ /// The show was updated; is the row after the write (updated_at advanced).
+ public sealed record Updated(Show Show) : ShowWriteResult;
+
+ /// The show was successfully deleted.
+ public sealed record Deleted : ShowWriteResult;
+
+ /// No show with the requested id exists.
+ public sealed record NotFound : ShowWriteResult;
+
+ /// Another show already holds the derived slug (UNIQUE(slug), SPEC F115.1) — the unique
+ /// constraint surfacing as a conflict, never a silent overwrite.
+ public sealed record SlugConflict : ShowWriteResult;
+
+ ///
+ /// exceeds its SPEC F115.1 1× budget (name ≤60, tagline ≤120, flavor ≤400
+ /// chars, ) — rejected at the app seam before the write ever reaches
+ /// Postgres.
+ ///
+ public sealed record BudgetExceeded(ShowBudgetField Field) : ShowWriteResult;
+
+ ///
+ /// Name was blank/whitespace-only, or its house-Slugify output equals the fallback literal
+ /// "persona" (LegacyPersonaCardMapper.FallbackSlug) — rejected regardless of HOW the
+ /// slug got there: an emoji-only name that hits Slugify's own empty-slug rescue, but also
+ /// (PLAN T240 review A1) a name that slugifies to "persona" the ordinary way, e.g. the
+ /// literal name "Persona" itself (lowercases unchanged — the rescue never fires; the
+ /// ordinary path just lands on the same string). Rejected at the app seam before the write ever
+ /// reaches Postgres. Mirrors PersonaController's import-slug REJECT-not-autocorrect posture
+ /// (never silently coerce a bad name into something plausible-looking), enforced here at the
+ /// store seam so every future caller of inherits the guard
+ /// rather than each needing its own.
+ ///
+ public sealed record InvalidName : ShowWriteResult;
+
+ ///
+ /// The delete was rejected because station.segment_schedule.show_id still names this show
+ /// — the FK's own ON DELETE RESTRICT (db/06, SPEC F114), caught here as SQLSTATE 23503.
+ /// Unlike , this store does not pre-query the
+ /// schedule for slot detail before deleting: this case stays a bare singleton, "referenced" and
+ /// nothing more — PLAN T240's endpoint-layer guard (SPEC F115.4) is what names the offending
+ /// blocks in the 409 body, the same way PLAN T121 once replaced
+ /// 's own T120 scaffolding.
+ ///
+ public sealed record Referenced : ShowWriteResult;
+}
diff --git a/src/GenWave.Host/Api/PersonaController.cs b/src/GenWave.Host/Api/PersonaController.cs
index fea24743..d13eb02c 100644
--- a/src/GenWave.Host/Api/PersonaController.cs
+++ b/src/GenWave.Host/Api/PersonaController.cs
@@ -1,4 +1,3 @@
-using System.Globalization;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
@@ -658,23 +657,10 @@ static PersonaDraft ToDraft(PersonaRequest request) =>
Status = StatusCodes.Status409Conflict,
Title = "Persona is scheduled.",
Detail = slots.Count > 0
- ? $"Persona {id} is still scheduled and cannot be deleted: {string.Join(", ", slots.Select(FormatSlot))}."
+ ? $"Persona {id} is still scheduled and cannot be deleted: {string.Join(", ", slots.Select(ScheduledSlotText.FormatSlot))}."
: $"Persona {id} still appears in the format-clock schedule and cannot be deleted while scheduled.",
};
- // Invariant-culture abbreviated day name ("Mon", "Tue", ...) — never a station-configurable
- // locale; this is an operator-facing admin message, not station-facing broadcast copy.
- static string FormatSlot(ScheduledSlot slot) =>
- $"{CultureInfo.InvariantCulture.DateTimeFormat.GetAbbreviatedDayName(slot.Day)} " +
- $"{FormatMinutes(slot.StartMinute)}–{FormatMinutes(slot.EndMinute)}";
-
- // Minutes-since-midnight as HH:mm — plain arithmetic, not TimeSpan's "hh" format specifier: a
- // 1440-minute end (midnight, the grid's own maximum) rolls into TimeSpan's Days component, which
- // "hh" ignores entirely, silently printing "00:00" for what is actually the end of the day.
- static string FormatMinutes(int minutesSinceMidnight) =>
- $"{(minutesSinceMidnight / 60).ToString("D2", CultureInfo.InvariantCulture)}:" +
- $"{(minutesSinceMidnight % 60).ToString("D2", CultureInfo.InvariantCulture)}";
-
static ProblemDetails UnknownSlugProblem(string slug) => new()
{
Status = StatusCodes.Status404NotFound,
diff --git a/src/GenWave.Host/Api/ScheduledSlotText.cs b/src/GenWave.Host/Api/ScheduledSlotText.cs
new file mode 100644
index 00000000..305e26cb
--- /dev/null
+++ b/src/GenWave.Host/Api/ScheduledSlotText.cs
@@ -0,0 +1,32 @@
+using System.Globalization;
+using GenWave.Core.Domain;
+
+namespace GenWave.Host.Api;
+
+///
+/// Formats a for an operator-facing 409 detail message — shared by
+/// (SPEC F91.9, PLAN T121) and
+/// (SPEC F115.4, PLAN T240), the two delete guards that both name every blocking day/time slot the
+/// identical "Mon 09:00–12:00" way. Extracted here (PLAN T240 review) rather than left as two
+/// hand-copies: the copy on had silently dropped the load-bearing
+/// comment below on the minutes-to-HH:mm conversion — a single shared implementation makes that
+/// impossible to drop a second time, on either side.
+///
+internal static class ScheduledSlotText
+{
+ ///
+ /// "Mon 09:00–12:00" — invariant-culture abbreviated day name (never a station-configurable
+ /// locale; this is an operator-facing admin message, not station-facing broadcast copy) plus the
+ /// HH:mm span from .
+ ///
+ internal static string FormatSlot(ScheduledSlot slot) =>
+ $"{CultureInfo.InvariantCulture.DateTimeFormat.GetAbbreviatedDayName(slot.Day)} " +
+ $"{FormatMinutes(slot.StartMinute)}–{FormatMinutes(slot.EndMinute)}";
+
+ // Minutes-since-midnight as HH:mm — plain arithmetic, not TimeSpan's "hh" format specifier: a
+ // 1440-minute end (midnight, the grid's own maximum) rolls into TimeSpan's Days component, which
+ // "hh" ignores entirely, silently printing "00:00" for what is actually the end of the day.
+ internal static string FormatMinutes(int minutesSinceMidnight) =>
+ $"{(minutesSinceMidnight / 60).ToString("D2", CultureInfo.InvariantCulture)}:" +
+ $"{(minutesSinceMidnight % 60).ToString("D2", CultureInfo.InvariantCulture)}";
+}
diff --git a/src/GenWave.Host/Api/ScopedImagingRowDto.cs b/src/GenWave.Host/Api/ScopedImagingRowDto.cs
new file mode 100644
index 00000000..545f5faf
--- /dev/null
+++ b/src/GenWave.Host/Api/ScopedImagingRowDto.cs
@@ -0,0 +1,9 @@
+namespace GenWave.Host.Api;
+
+///
+/// One library.media row the show delete guard named and unscoped (SPEC F115.4). Mirrors
+/// field-for-field — kept as its own wire type anyway
+/// (house convention: a domain record never serializes directly across the wire, even when its own
+/// field names already happen to match the shape the response wants).
+///
+public sealed record ScopedImagingRowDto(long MediaId, string? Title);
diff --git a/src/GenWave.Host/Api/ShowDeleteResponse.cs b/src/GenWave.Host/Api/ShowDeleteResponse.cs
new file mode 100644
index 00000000..4c5d8bc3
--- /dev/null
+++ b/src/GenWave.Host/Api/ShowDeleteResponse.cs
@@ -0,0 +1,8 @@
+namespace GenWave.Host.Api;
+
+///
+/// Body for a successful DELETE /api/shows/{slug} that unscoped one or more show-scoped
+/// imaging rows (SPEC F115.4) — present only on that path; a delete that unscoped nothing answers
+/// plain 204 No Content instead (nothing to name).
+///
+public sealed record ShowDeleteResponse(IReadOnlyList UnscopedImaging);
diff --git a/src/GenWave.Host/Api/ShowDto.cs b/src/GenWave.Host/Api/ShowDto.cs
new file mode 100644
index 00000000..700d5e7b
--- /dev/null
+++ b/src/GenWave.Host/Api/ShowDto.cs
@@ -0,0 +1,13 @@
+namespace GenWave.Host.Api;
+
+///
+/// Wire shape for a show row (SPEC F115.1, F115.3, F115.4): the whole identity package this ADMIN
+/// surface edits — name/slug/tagline/flavor plus provenance (importedFrom/importedAt,
+/// the db/25 pattern already carries). is prompt-only
+/// and private FOREVER on every OTHER surface (SPEC F115.3, the persona-soul precedent) — but this
+/// DTO backs the admin editor that AUTHORS it, so it is the one deliberate exception; the public/
+/// spectator show projection (PLAN T251) is a separate, narrower DTO that never adds this field.
+///
+public sealed record ShowDto(
+ long Id, string Name, string Slug, string? Tagline, string? Flavor,
+ string? ImportedFrom, DateTime? ImportedAt);
diff --git a/src/GenWave.Host/Api/ShowRequest.cs b/src/GenWave.Host/Api/ShowRequest.cs
new file mode 100644
index 00000000..6c1bee6d
--- /dev/null
+++ b/src/GenWave.Host/Api/ShowRequest.cs
@@ -0,0 +1,12 @@
+namespace GenWave.Host.Api;
+
+///
+/// Request body for POST /api/shows (create) and PATCH /api/shows/{slug} (edit) (SPEC
+/// F115.1, F115.4). is required, non-blank — 's
+/// own rejects a blank/whitespace one, or one
+/// whose derived slug equals the reserved fallback literal; /
+/// are optional and clear to null when omitted or blank. All fields are nullable here, mirroring
+/// 's own all-nullable shape, so the controller produces a typed 400 for a
+/// blank/missing name instead of an ASP.NET model-binder 400.
+///
+public sealed record ShowRequest(string? Name, string? Tagline, string? Flavor);
diff --git a/src/GenWave.Host/Api/ShowsController.cs b/src/GenWave.Host/Api/ShowsController.cs
new file mode 100644
index 00000000..1e85e1fe
--- /dev/null
+++ b/src/GenWave.Host/Api/ShowsController.cs
@@ -0,0 +1,353 @@
+using System.Diagnostics;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using GenWave.Core.Abstractions;
+using GenWave.Core.Domain;
+using GenWave.Core.Logging;
+using GenWave.Host.Catalog;
+
+namespace GenWave.Host.Api;
+
+///
+/// Show CRUD for the Admin UI (SPEC F115.1, F115.4, F115.5; STORY-305, PLAN T240):
+/// GET/POST/PATCH/DELETE /api/shows over . Slug-addressed
+/// ({slug}, never {id}) — 's own remarks name it
+/// as "the primitive a slug-addressed route resolves through"; every write still resolves through the
+/// store's id-keyed /
+/// internally, one extra read ahead of each.
+///
+///
+/// Delete guard (SPEC F115.4) — two independent reference kinds, two different postures.
+/// 's own case fires only
+/// for a real FK (station.segment_schedule.show_id, ON DELETE RESTRICT) —
+/// re-queries that table () to NAME the blocking
+/// slots in the 409 body the store's own bare case can't carry (mirrors PersonaController.Delete's
+/// pre-T121 posture — see 's own remarks).
+/// library.media.show_id (show-scoped imaging, F117.1) carries NO FK at all, so it can never
+/// block the DELETE statement itself — it is handled entirely on the OTHER side of a successful
+/// delete instead: only ever runs after
+/// comes back, clearing every imaging row that named the
+/// now-gone show and naming what it cleared in the response body.
+///
+///
+///
+/// Ordering is deliberate, not incidental. A block-referenced show refuses with NOTHING
+/// touched — no delete, no imaging unscope — the least-surprising outcome: the show still exists, so
+/// an imaging row scoped to it is still correctly scoped, and unscoping it anyway would silently
+/// orphan-clear branding off a show an operator just failed to remove. Only once the row is confirmed
+/// gone does a stale show_id on an imaging row become the actual problem this guard exists to
+/// prevent — so the unscope write happens strictly after, never before or racing the delete.
+///
+///
+///
+/// SPEC F115.5 — mirrors ThemeWriteGate's fail-closed posture, deliberately NOT a shared
+/// type (PLAN T240, "your call"). An authored targeting a slug whose row
+/// already carries a non-null refuses 409 before ever calling
+/// — provenance is never even offered a chance to change.
+/// ThemeWriteGate (PLAN T207) earns its own type because it guards TWO separate controllers
+/// (ThemesImportController/ThemesSaveAsOwnController) through a genuinely multi-phase
+/// pipeline (bounded body read, schema-major, deserialize-as-validation, font-law) that a hand-copy
+/// between two files had already drifted once. Show writes have neither: ONE controller owns both
+/// verbs (no second file to drift against), and the whole authored-vs-imported gate is a single
+/// predicate on an already-loaded — extracting a "ShowWriteGate" type for one
+/// if would be indirection with nothing behind it (YAGNI). What THIS controller does borrow
+/// from that precedent is the shared-refusal-mapping idiom: maps every
+/// non-success case and can
+/// BOTH produce, so the SAME case always yields the SAME HTTP body regardless of which write route
+/// hit it — proven by this controller's own gate-parity table (mirrors PLAN T207's 7-row
+/// BadBodyTable precedent, narrowed to this store's five app-seam gates: blank name,
+/// fallback-slug name, and the three SPEC F115.1 budgets).
+///
+///
+/// Security: deny-by-default cookie auth, the same AdminSurface/Settings pairing as
+/// / (this is station configuration,
+/// the same admin plane). Writes require Content-Type: application/json (415 otherwise,
+/// F18.7). rides this admin DTO deliberately (SPEC F115.3: it MAY appear
+/// here, the page that authors it) — the public/spectator show projection (PLAN T251) is its own,
+/// narrower DTO that never adds this field.
+///
+[ApiController]
+[Route("api/shows")]
+[AdminSurface]
+[Authorize(Policy = AuthorizationPolicies.Settings)]
+public sealed class ShowsController(
+ IShowStore showStore,
+ IScheduleStore scheduleStore,
+ IShowImagingScope imagingScope,
+ ILogger logger) : ControllerBase
+{
+ /// GET /api/shows — every show row, ordered by name (F115.1).
+ [HttpGet]
+ public async Task List(CancellationToken ct)
+ {
+ var shows = await showStore.GetAllAsync(ct);
+ return Ok(shows.Select(ToDto).ToArray());
+ }
+
+ /// GET /api/shows/{slug} — a single show. 404 for an unknown slug.
+ [HttpGet("{slug}")]
+ public async Task Get(string slug, CancellationToken ct)
+ {
+ var show = await showStore.GetBySlugAsync(slug, ct);
+ return show is null ? NotFound(NotFoundProblem(slug)) : Ok(ToDto(show));
+ }
+
+ ///
+ /// POST /api/shows — create an authored show. 201 with the row on success; 400 for a blank/
+ /// invalid name or an over-budget field; 409 for a slug collision (F115.1).
+ ///
+ [HttpPost]
+ [Consumes("application/json")]
+ public async Task Create([FromBody] ShowRequest request, CancellationToken ct)
+ {
+ var draft = ToDraft(request);
+ var result = await showStore.CreateAsync(draft, ct);
+
+ if (result is ShowWriteResult.Created created)
+ logger.LogInformation(
+ "Show created id={ShowId} name={ShowName}", created.Show.Id, LogSanitize.Strip(created.Show.Name));
+
+ return result switch
+ {
+ ShowWriteResult.Created c => StatusCode(StatusCodes.Status201Created, ToDto(c.Show)),
+ _ => WriteProblem(result, draft.Name),
+ };
+ }
+
+ ///
+ /// PATCH /api/shows/{slug} — edit an existing authored show. 200 with the row on success; 404 for
+ /// an unknown slug; 409 when the target is imported (SPEC F115.5 — see this class's own remarks)
+ /// or when the edit's derived slug collides with another show; 400 for a blank/invalid name or an
+ /// over-budget field.
+ ///
+ [HttpPatch("{slug}")]
+ [Consumes("application/json")]
+ public async Task Update(string slug, [FromBody] ShowRequest request, CancellationToken ct)
+ {
+ var existing = await showStore.GetBySlugAsync(slug, ct);
+ if (existing is null)
+ return NotFound(NotFoundProblem(slug));
+
+ // SPEC F115.5 — the ThemeWriteGate fail-closed posture, mirrored (see class remarks): an
+ // authored write never lands on an imported show's slug, so provenance is never even offered
+ // a chance to change.
+ //
+ // Structurally this is the same read-then-write shape gh-#394 flags (GetBySlugAsync's read
+ // above, then this gate's decision, then UpdateAsync's write — nothing holds a lock across
+ // the gap): a concurrent import could land on this exact slug between the read and the write.
+ // Provenance itself survives that race regardless — UpdateAsync never names imported_from/
+ // imported_at, so it cannot overwrite whatever an interleaved import just stamped. The
+ // residual exposure is narrower than #394's own: an authored edit that was ALREADY in flight
+ // landing on a row that became imported a moment earlier, silently editing what a user would
+ // now expect to be gate-refused. The fix rides #394, not a second one here.
+ if (existing.ImportedFrom is not null)
+ {
+ logger.LogWarning(
+ "Show update refused slug={Slug} reason=imported importedFrom={ImportedFrom}",
+ LogSafeText.Sanitize(slug), LogSanitize.Strip(existing.ImportedFrom));
+ return Conflict(ImportedTargetProblem(slug, existing.ImportedFrom));
+ }
+
+ var draft = ToDraft(request);
+ var result = await showStore.UpdateAsync(existing.Id, draft, ct);
+
+ if (result is ShowWriteResult.Updated updated)
+ logger.LogInformation(
+ "Show updated id={ShowId} name={ShowName}", updated.Show.Id, LogSanitize.Strip(updated.Show.Name));
+
+ return result switch
+ {
+ ShowWriteResult.Updated u => Ok(ToDto(u.Show)),
+ ShowWriteResult.NotFound => NotFound(NotFoundProblem(slug)),
+ _ => WriteProblem(result, draft.Name),
+ };
+ }
+
+ ///
+ /// DELETE /api/shows/{slug} — remove a show (SPEC F115.4; see this class's own remarks for the
+ /// full guard). 404 for an unknown slug. 409, naming every referencing schedule block, when
+ /// station.segment_schedule still names it — nothing deleted, nothing unscoped. Otherwise:
+ /// 204 when nothing else named it either, or 200 naming every show-scoped imaging row this call
+ /// unscoped.
+ ///
+ [HttpDelete("{slug}")]
+ public async Task Delete(string slug, CancellationToken ct)
+ {
+ var existing = await showStore.GetBySlugAsync(slug, ct);
+ if (existing is null)
+ return NotFound(NotFoundProblem(slug));
+
+ var result = await showStore.DeleteAsync(existing.Id, ct);
+
+ switch (result)
+ {
+ case ShowWriteResult.Deleted:
+ var unscoped = await UnscopeBestEffortAsync(existing.Id, slug);
+ logger.LogInformation(
+ "Show deleted id={ShowId} slug={Slug} unscopedImagingCount={Count}",
+ existing.Id, LogSafeText.Sanitize(slug), unscoped.Count);
+ return unscoped.Count == 0
+ ? NoContent()
+ : Ok(new ShowDeleteResponse(unscoped.Select(ToImagingDto).ToArray()));
+
+ case ShowWriteResult.Referenced:
+ var blocks = await scheduleStore.GetSlotsByShowIdAsync(existing.Id, ct);
+ logger.LogWarning(
+ "Show delete refused slug={Slug} blockCount={Count}", LogSafeText.Sanitize(slug), blocks.Count);
+ return Conflict(ReferencedProblem(slug, blocks));
+
+ case ShowWriteResult.NotFound:
+ // Race backstop: gone between the GetBySlugAsync read above and this DeleteAsync call.
+ return NotFound(NotFoundProblem(slug));
+
+ default:
+ // Mirrors PersonaController's own unmapped-case posture (e.g. PersonaController.cs:150)
+ // rather than throwing: an unmapped ShowWriteResult case here is a store/controller
+ // drift bug, not a client error, but the delete itself already committed by the time
+ // this runs (unlike Create/Update's WriteProblem, called before any write) — a 500
+ // that still completes the response is more honest than crashing the request pipeline
+ // over a case this switch cannot even reach today (ShowWriteResult's hierarchy is
+ // closed to exactly the four cases handled above).
+ return StatusCode(StatusCodes.Status500InternalServerError);
+ }
+ }
+
+ ///
+ /// Best-effort post-commit cleanup (SPEC F115.4) for 's Deleted case —
+ /// the show row is ALREADY gone by the time this runs (see class remarks, "ordering is
+ /// deliberate"), so a failure here must never turn an already-successful delete into a 500, and
+ /// must never be silently skippable either.
+ ///
+ ///
+ /// , deliberately never 's own request
+ /// ct: the delete already committed, so a client disconnecting between the delete and this
+ /// call must not skip cleanup — an aborted request is not a reason to leave orphaned
+ /// library.media.show_id rows behind.
+ ///
+ ///
+ ///
+ /// Broad catch (Exception), not a specific Npgsql exception type: controllers in this
+ /// codebase never import Npgsql (PLAN T120 review F4 — that mapping belongs to the store/
+ /// repository seam), and this is a single post-commit boundary where "the write failed, for any
+ /// reason" is the only distinction this action can act on anyway. The failure is logged with the
+ /// show id (SPEC F115.4) so an operator can hand-recover — a one-off UPDATE library.media SET
+ /// show_id = NULL WHERE show_id = <id> — rather than retrying (a retry 404s: the show is
+ /// already gone). The delete itself still reports success either way (mirrors
+ /// 's own broad-catch-at-a-seam-boundary precedent).
+ ///
+ ///
+ async Task> UnscopeBestEffortAsync(long showId, string slug)
+ {
+ try
+ {
+ return await imagingScope.UnscopeAsync(showId, CancellationToken.None);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(
+ ex,
+ "Show imaging unscope failed after delete id={ShowId} slug={Slug} — library.media rows " +
+ "may still name a deleted show; hand-recover via UPDATE library.media SET show_id = NULL",
+ showId, LogSafeText.Sanitize(slug));
+ return [];
+ }
+ }
+
+ // ── Helpers ──────────────────────────────────────────────────────────────
+
+ ///
+ /// Maps every non-success case BOTH write actions can produce
+ /// (//
+ /// ) to the identical ProblemDetails shape — ONE method,
+ /// called from 's switch AND 's switch, so the two write
+ /// routes can never drift the way PLAN T207 found the two theme-write controllers had (this
+ /// controller's own gate-parity table proves it stays that way — see class remarks).
+ /// is deliberately NOT here: only can
+ /// produce it, and it needs the target's slug (unavailable to a failure), so
+ /// it stays inline at that one call site.
+ ///
+ IActionResult WriteProblem(ShowWriteResult result, string name) => result switch
+ {
+ ShowWriteResult.InvalidName => BadRequest(InvalidNameProblem()),
+ ShowWriteResult.BudgetExceeded budget => BadRequest(BudgetExceededProblem(budget.Field)),
+ ShowWriteResult.SlugConflict => Conflict(SlugConflictProblem(name)),
+ _ => StatusCode(StatusCodes.Status500InternalServerError),
+ };
+
+ static ShowDto ToDto(Show show) =>
+ new(show.Id, show.Name, show.Slug, show.Tagline, show.Flavor, show.ImportedFrom, show.ImportedAt);
+
+ static ShowDraft ToDraft(ShowRequest request) =>
+ new(request.Name?.Trim() ?? string.Empty, request.Tagline, request.Flavor);
+
+ static ScopedImagingRowDto ToImagingDto(ScopedImagingRow row) => new(row.MediaId, row.Title);
+
+ static ProblemDetails NotFoundProblem(string slug) => new()
+ {
+ Status = StatusCodes.Status404NotFound,
+ Title = "Not found.",
+ Detail = $"No show with slug \"{slug}\" exists.",
+ };
+
+ static ProblemDetails InvalidNameProblem() => new()
+ {
+ Status = StatusCodes.Status400BadRequest,
+ Title = "Invalid name.",
+ Detail = "name must not be blank or whitespace, and must not slugify to the reserved value \"persona\".",
+ };
+
+ static ProblemDetails BudgetExceededProblem(ShowBudgetField field) => new()
+ {
+ Status = StatusCodes.Status400BadRequest,
+ Title = "Validation error.",
+ Detail = $"{FieldName(field)} must be at most {BudgetFor(field)} characters.",
+ };
+
+ static string FieldName(ShowBudgetField field) => field switch
+ {
+ ShowBudgetField.Name => "name",
+ ShowBudgetField.Tagline => "tagline",
+ ShowBudgetField.Flavor => "flavor",
+ _ => throw new UnreachableException($"Unhandled {nameof(ShowBudgetField)} value."),
+ };
+
+ static int BudgetFor(ShowBudgetField field) => field switch
+ {
+ ShowBudgetField.Name => ShowBudgets.NameMaxChars,
+ ShowBudgetField.Tagline => ShowBudgets.TaglineMaxChars,
+ ShowBudgetField.Flavor => ShowBudgets.FlavorMaxChars,
+ _ => throw new UnreachableException($"Unhandled {nameof(ShowBudgetField)} value."),
+ };
+
+ static ProblemDetails SlugConflictProblem(string name) => new()
+ {
+ Status = StatusCodes.Status409Conflict,
+ Title = "Slug conflict.",
+ Detail = $"Another show already uses the slug derived from \"{name}\".",
+ };
+
+ static ProblemDetails ImportedTargetProblem(string slug, string importedFrom) => new()
+ {
+ Status = StatusCodes.Status409Conflict,
+ Title = "Show is imported.",
+ Detail =
+ $"\"{slug}\" was imported (from \"{importedFrom}\") and cannot be edited as an authored " +
+ "show; its provenance is left untouched.",
+ };
+
+ // SPEC F115.4 — names every referencing block the same day/time shape PersonaController.Delete's
+ // own ScheduledPersonaProblem uses. The PROBLEM-BODY builders stay two separate methods
+ // (mirrored, not shared: each carries its own title/detail wording, and ScheduledPersonaProblem
+ // is private to PersonaController — see this controller's own class remarks on mirror-vs-share).
+ // Slot FORMATTING is a different claim — ScheduledSlotText.FormatSlot is the one shared
+ // implementation both this method and ScheduledPersonaProblem call into (PLAN T240 review).
+ static ProblemDetails ReferencedProblem(string slug, IReadOnlyList blocks) => new()
+ {
+ Status = StatusCodes.Status409Conflict,
+ Title = "Show is scheduled.",
+ Detail = blocks.Count > 0
+ ? $"\"{slug}\" is still scheduled and cannot be deleted: " +
+ $"{string.Join(", ", blocks.Select(ScheduledSlotText.FormatSlot))}."
+ : $"\"{slug}\" still appears in the format-clock schedule and cannot be deleted while scheduled.",
+ };
+}
diff --git a/src/GenWave.Host/Configuration/StationSettingsHostingExtensions.cs b/src/GenWave.Host/Configuration/StationSettingsHostingExtensions.cs
index 0e6058df..46d16829 100644
--- a/src/GenWave.Host/Configuration/StationSettingsHostingExtensions.cs
+++ b/src/GenWave.Host/Configuration/StationSettingsHostingExtensions.cs
@@ -128,6 +128,12 @@ public static WebApplicationBuilder AddGenWaveStationSettings(this WebApplicatio
// FontPackController's install route (PLAN T199) is the first Host call site.
builder.Services.AddFontPackStore(stationConnStr);
+ // Show store (SPEC F115.1, STORY-305, PLAN T239) — same station_svc connection string as
+ // every registration above; station.show lives in the same schema. ShowRepository shipped
+ // dark at T239 (AddShowStore itself registers no consumer): ShowsController (PLAN T240) is
+ // the first Host call site.
+ builder.Services.AddShowStore(stationConnStr);
+
return builder;
}
}
diff --git a/src/GenWave.MediaLibrary/Catalog/ShowImagingScopeRepository.cs b/src/GenWave.MediaLibrary/Catalog/ShowImagingScopeRepository.cs
new file mode 100644
index 00000000..81251657
--- /dev/null
+++ b/src/GenWave.MediaLibrary/Catalog/ShowImagingScopeRepository.cs
@@ -0,0 +1,36 @@
+using Dapper;
+using GenWave.Core.Abstractions;
+using GenWave.Core.Domain;
+using Npgsql;
+
+namespace GenWave.MediaLibrary.Catalog;
+
+///
+/// The in-process implementation of (SPEC F115.4, STORY-305, PLAN
+/// T240) over library.media. Connection-per-call against the library's own
+/// , mirroring 's wiring —
+/// singleton-safe with no captive dependency.
+///
+sealed class ShowImagingScopeRepository(NpgsqlDataSource dataSource) : IShowImagingScope
+{
+ ///
+ /// One statement: UPDATE ... RETURNING both clears show_id and names what it
+ /// cleared in the SAME round trip — no separate SELECT-then-UPDATE. There is no FK here for a
+ /// second writer to race against (F117.1), so the single statement isn't closing a TOCTOU gap so
+ /// much as it is simply the smallest honest shape for a seam that does one thing.
+ ///
+ public async Task> UnscopeAsync(long showId, CancellationToken ct)
+ {
+ await using var conn = await dataSource.OpenConnectionAsync(ct);
+ var rows = await conn.QueryAsync<(long Id, string? Title)>(new CommandDefinition(
+ """
+ update library.media set show_id = null
+ where show_id = @showId
+ returning id, title
+ """,
+ new { showId },
+ cancellationToken: ct));
+
+ return rows.OrderBy(r => r.Id).Select(r => new ScopedImagingRow(r.Id, r.Title)).ToList();
+ }
+}
diff --git a/src/GenWave.MediaLibrary/MediaLibraryServiceCollectionExtensions.cs b/src/GenWave.MediaLibrary/MediaLibraryServiceCollectionExtensions.cs
index 01303cc0..4cfdd853 100644
--- a/src/GenWave.MediaLibrary/MediaLibraryServiceCollectionExtensions.cs
+++ b/src/GenWave.MediaLibrary/MediaLibraryServiceCollectionExtensions.cs
@@ -86,6 +86,11 @@ public static IServiceCollection AddMediaLibrary(this IServiceCollection service
// on library.media.
services.AddSingleton();
+ // SPEC F115.4, STORY-305, PLAN T240: the show delete guard's own cross-schema answer — which
+ // library.media rows are scoped to a show, and clearing them — same station_svc-has-no-grant
+ // rationale as IMediaLibraryMembership immediately above. First consumer: ShowsController.
+ services.AddSingleton();
+
// SPEC F87.5, STORY-226, PLAN T89: the listener-request matcher's catalog probe — same
// cross-schema-boundary rationale as IMediaLibraryMembership above (station_svc has no grant
// on library.media). First consumer: GenWave.Host.Requests.RequestMatcher.
diff --git a/src/GenWave.MediaLibrary/Station/BoothLogDrainService.cs b/src/GenWave.MediaLibrary/Station/BoothLogDrainService.cs
index 6c1c53f8..b8e8cf57 100644
--- a/src/GenWave.MediaLibrary/Station/BoothLogDrainService.cs
+++ b/src/GenWave.MediaLibrary/Station/BoothLogDrainService.cs
@@ -1,5 +1,6 @@
using System.Threading.Channels;
using GenWave.Core.Abstractions;
+using GenWave.Core.Domain;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
@@ -29,6 +30,12 @@ namespace GenWave.MediaLibrary.Station;
/// (SPEC F113.1, STORY-304, PLAN T220) arrives the
/// same way — already stringified (or ) by
/// — and is persisted verbatim.
+///
+/// (SPEC F121.1, STORY-310, PLAN T242) arrives the same
+/// way — already resolved off IActivePersonaAccessor.ActiveShowId by
+/// at air time — and is persisted verbatim; no FK to degrade
+/// (F121.1: history outlives the entity), so unlike it
+/// has no append-time retry concern for BoothLogRepository to handle either.
///
sealed class BoothLogDrainService(
ChannelReader queue,
@@ -50,7 +57,16 @@ internal async Task ProcessAsync(BoothLogEntryRequest request, CancellationToken
{
try
{
- await store.AppendAsync(request.Kind, request.Summary, request.PersonaId, request.Artist, request.Pick, request.MediaId, request.SegmentKind, ct);
+ // Recorded carry-forward (T220 review, still only half-closed): BoothLogEntryRequest and
+ // BoothLogAppendRequest duplicate these same 8 fields positionally — this mapping exists
+ // only because the two types are two records, not one. Not merged now (out of this task's
+ // scope); merge candidate is passing BoothLogEntryRequest straight onto the channel and
+ // dropping BoothLogAppendRequest as a separate type.
+ await store.AppendAsync(
+ new BoothLogAppendRequest(
+ request.Kind, request.Summary, request.PersonaId, request.Artist, request.Pick,
+ request.MediaId, request.SegmentKind, request.ShowId),
+ ct);
}
catch (OperationCanceledException)
{
diff --git a/src/GenWave.MediaLibrary/Station/BoothLogEntryRequest.cs b/src/GenWave.MediaLibrary/Station/BoothLogEntryRequest.cs
index ffe45e7c..c7209ba4 100644
--- a/src/GenWave.MediaLibrary/Station/BoothLogEntryRequest.cs
+++ b/src/GenWave.MediaLibrary/Station/BoothLogEntryRequest.cs
@@ -40,7 +40,15 @@ namespace GenWave.MediaLibrary.Station;
/// "StationId") before this request ever reaches the queue. for a music
/// row or an engine-initiated advance — the demo-hour instrument's whole point is that this is the
/// genuine AIR-time signal, never a render-time guess.
+///
+/// (SPEC F121.1, STORY-310, PLAN T242) rides the SAME track-start-only,
+/// captured-at-publish-time path as —
+/// reads it off IActivePersonaAccessor.ActiveShowId, the SAME resolver-backed on-air answer
+/// reads ActivePersonaId off, at the SAME instant — never off
+/// itself (a show is a schedule-grid fact, not a pushed-item one, so it rides
+/// the persona's capture mechanism, not the kind's). for every non-track row or
+/// a showless airing.
///
sealed record BoothLogEntryRequest(
string Kind, string Summary, long? PersonaId, string? Artist = null, string? Pick = null, long? MediaId = null,
- string? SegmentKind = null);
+ string? SegmentKind = null, long? ShowId = null);
diff --git a/src/GenWave.MediaLibrary/Station/BoothLogRepository.cs b/src/GenWave.MediaLibrary/Station/BoothLogRepository.cs
index 0a3f4106..3bbfc506 100644
--- a/src/GenWave.MediaLibrary/Station/BoothLogRepository.cs
+++ b/src/GenWave.MediaLibrary/Station/BoothLogRepository.cs
@@ -45,49 +45,54 @@ from station.booth_log
""";
///
- /// (SPEC F84.6, STORY-215) was captured SYNCHRONOUSLY by
- /// at air time, well before this append ever runs — a new
- /// edge that drain-time resolution never had: the persona can be DELETED in the gap between air
- /// and this call, leaving a dangling reference. That insert fails
- /// the persona_id FK (23503) even though booth_log.persona_id's own ON DELETE SET
- /// NULL already protects every row persisted BEFORE the delete — SET NULL cannot help a row
- /// that has not been inserted yet. Caught here specifically and retried unstamped: the booth-log
- /// row itself must never be dropped over a stamp that went stale mid-flight.
- /// (SPEC F84.1, STORY-215, PLAN T70) is plain text — no FK, no degrade path of its own.
- /// (SPEC F86.1, STORY-217, PLAN T73) is likewise plain text (pre-serialized
- /// jsonb, no FK) — the persona-id retry below never touches it either way.
- /// (SPEC F113.1, STORY-304, PLAN T220) is likewise plain text (the
- /// SegmentKind enum's token name, or null) — no FK, no degrade path; the persona-id retry never
- /// touches it either.
+ /// 's (SPEC F84.6,
+ /// STORY-215) was captured SYNCHRONOUSLY by at air time, well
+ /// before this append ever runs — a new edge that drain-time resolution never had: the persona can
+ /// be DELETED in the gap between air and this call, leaving it a dangling reference. That insert
+ /// fails the persona_id FK (23503) even though booth_log.persona_id's own
+ /// ON DELETE SET NULL already protects every row persisted BEFORE the delete — SET NULL
+ /// cannot help a row that has not been inserted yet. Caught here specifically and retried
+ /// unstamped: the booth-log row itself must never be dropped over a stamp that went stale
+ /// mid-flight. (SPEC F84.1, STORY-215, PLAN T70) is
+ /// plain text — no FK, no degrade path of its own. (SPEC
+ /// F86.1, STORY-217, PLAN T73) is likewise plain text (pre-serialized jsonb, no FK) — the
+ /// persona-id retry below never touches it either way.
+ /// (SPEC F113.1, STORY-304, PLAN T220) is likewise plain text (the SegmentKind enum's token name,
+ /// or null) — no FK, no degrade path. (SPEC F121.1,
+ /// STORY-310, PLAN T242) is likewise no-FK-by-design (history outlives the entity) — the
+ /// persona-id retry never touches any of these three either.
///
- public async Task AppendAsync(string kind, string summary, long? personaId, string? artist, string? pick, long? mediaId, string? segmentKind, CancellationToken ct)
+ public async Task AppendAsync(BoothLogAppendRequest request, CancellationToken ct)
{
await using var conn = await dataSource.Value.OpenConnectionAsync(ct);
try
{
- await InsertAndEvictAsync(conn, kind, summary, personaId, artist, pick, mediaId, segmentKind, ct);
+ await InsertAndEvictAsync(conn, request, ct);
}
- catch (PostgresException ex) when (ex.SqlState == ForeignKeyViolation && personaId is not null)
+ catch (PostgresException ex) when (ex.SqlState == ForeignKeyViolation && request.PersonaId is not null)
{
// The failed attempt's `await using var tx` already rolled back (disposal runs as the
// exception unwinds InsertAndEvictAsync, before it reaches this catch) — the connection
// is clean, so retrying a fresh transaction on the SAME conn is safe.
- await InsertAndEvictAsync(conn, kind, summary, personaId: null, artist, pick, mediaId, segmentKind, ct);
+ await InsertAndEvictAsync(conn, request with { PersonaId = null }, ct);
}
}
- async Task InsertAndEvictAsync(
- NpgsqlConnection conn, string kind, string summary, long? personaId, string? artist, string? pick, long? mediaId, string? segmentKind, CancellationToken ct)
+ async Task InsertAndEvictAsync(NpgsqlConnection conn, BoothLogAppendRequest request, CancellationToken ct)
{
await using var tx = await conn.BeginTransactionAsync(ct);
await conn.ExecuteAsync(new CommandDefinition(
"""
- insert into station.booth_log (kind, summary, persona_id, artist, pick, media_id, segment_kind)
- values (@Kind, @Summary, @PersonaId, @Artist, @Pick::jsonb, @MediaId, @SegmentKind)
+ insert into station.booth_log (kind, summary, persona_id, artist, pick, media_id, segment_kind, show_id)
+ values (@Kind, @Summary, @PersonaId, @Artist, @Pick::jsonb, @MediaId, @SegmentKind, @ShowId)
""",
- new { Kind = kind, Summary = summary, PersonaId = personaId, Artist = artist, Pick = pick, MediaId = mediaId, SegmentKind = segmentKind },
+ new
+ {
+ request.Kind, request.Summary, request.PersonaId, request.Artist, request.Pick,
+ request.MediaId, request.SegmentKind, request.ShowId,
+ },
transaction: tx,
cancellationToken: ct));
diff --git a/src/GenWave.MediaLibrary/Station/BoothLogWriter.cs b/src/GenWave.MediaLibrary/Station/BoothLogWriter.cs
index ccded3ee..f826aebb 100644
--- a/src/GenWave.MediaLibrary/Station/BoothLogWriter.cs
+++ b/src/GenWave.MediaLibrary/Station/BoothLogWriter.cs
@@ -45,6 +45,18 @@ public void Publish(StationEvent evt)
// track-start with whatever persona is active once the backlog clears, not the one that was
// actually on air when the track started. Only a track-start row is ever a stamp candidate;
// patter/mode-change rows always publish PersonaId: null.
+ //
+ // The show stamp (SPEC F121.1, STORY-310, PLAN T242) rides the IDENTICAL discipline off the
+ // SAME dependency: personaAccessor.ActiveShowId reads off the same cached snapshot source
+ // ActivePersonaId already reads (see IActivePersonaAccessor's own remarks), captured here
+ // rather than at drain time for the exact same backlog-mis-stamp reason. Not one read at one
+ // instant, though: each property getter independently re-resolves against the current wall
+ // clock, so a schedule boundary landing between these two reads can split the pair on this
+ // one row at most — accepted (see IActivePersonaAccessor.ActiveShowId's own remarks). It is
+ // captured HERE — never carried on TrackAired itself — because a show is a schedule-grid fact
+ // (who is on air right now), not a pushed-item one: SegmentKind/PersonaPick ride TrackAired
+ // because PlayoutFeeder already captured them off the pushed MediaItem at push time; a show
+ // has no such per-item origin, only the on-air answer at the moment this event publishes.
var request = evt switch
{
// Artist (SPEC F84.1, STORY-215, PLAN T70) rides the same capture-at-publish-time
@@ -56,10 +68,12 @@ public void Publish(StationEvent evt)
// T220) rides the SAME discipline: t.SegmentKind is PlayoutFeeder's own forwarded
// MediaItem.SegmentKind, stringified to its enum token name (or null for music/engine-
// initiated) — the demo-hour instrument's genuine AIR-time stamp, never patter-aired's
- // render-time one.
+ // render-time one. ShowId rides personaAccessor.ActiveShowId, same capture-at-publish-time,
+ // never-re-derived discipline — the ONE chokepoint this switch arm already is covers
+ // music and kinded TrackAired alike (PLAN T242's own "verify one chokepoint" note).
TrackAired t => new BoothLogEntryRequest(
"track-started", Summarize(t), personaAccessor.ActivePersonaId, t.Artist, BuildPickStamp(t.PersonaPick),
- ParseMediaId(t.MediaId), SegmentKind: t.SegmentKind?.ToString()),
+ ParseMediaId(t.MediaId), SegmentKind: t.SegmentKind?.ToString(), ShowId: personaAccessor.ActiveShowId),
SegmentGenerated s => new BoothLogEntryRequest("patter-aired", Summarize(s), PersonaId: null),
DegradationModeChanged d => new BoothLogEntryRequest("mode-changed", Summarize(d), PersonaId: null),
HandoffPieceDropped h => new BoothLogEntryRequest("handoff-dropped", Summarize(h), PersonaId: null),
diff --git a/src/GenWave.MediaLibrary/Station/LegacyPersonaCardMapper.cs b/src/GenWave.MediaLibrary/Station/LegacyPersonaCardMapper.cs
index 3079065b..17bca5b6 100644
--- a/src/GenWave.MediaLibrary/Station/LegacyPersonaCardMapper.cs
+++ b/src/GenWave.MediaLibrary/Station/LegacyPersonaCardMapper.cs
@@ -59,9 +59,22 @@ public static string BuildSoul(string backstory, string style)
public static string Slugify(string name)
{
var slug = NonAlphaNumeric().Replace(name.Trim().ToLowerInvariant(), "-").Trim('-');
- return slug.Length == 0 ? "persona" : slug;
+ return slug.Length == 0 ? FallbackSlug : slug;
}
+ ///
+ /// The empty-slug rescue literal (PLAN T240 review A3 — promoted from a hand-copied string
+ /// constant in ShowRepository). Returned whenever 's regex strips a
+ /// name down to nothing (an emoji-only name, for instance) — but a caller that REJECTS any
+ /// resulting slug equal to this literal (rather than only ever reaching it via that empty-input
+ /// path) also catches the plainer case of a name that slugifies to it directly, e.g. the literal
+ /// name "Persona" itself (lowercases to "persona" unchanged — no character is
+ /// non-alphanumeric, so the empty-slug rescue never even fires; the ordinary path just happens to
+ /// land on the same string). 's own ValidateName is exactly
+ /// that caller.
+ ///
+ public const string FallbackSlug = "persona";
+
[GeneratedRegex("[^a-z0-9]+")]
private static partial Regex NonAlphaNumeric();
}
diff --git a/src/GenWave.MediaLibrary/Station/ScheduleRepository.cs b/src/GenWave.MediaLibrary/Station/ScheduleRepository.cs
index 5171bfa6..5875a6e1 100644
--- a/src/GenWave.MediaLibrary/Station/ScheduleRepository.cs
+++ b/src/GenWave.MediaLibrary/Station/ScheduleRepository.cs
@@ -28,6 +28,14 @@ sealed class ScheduleRepository(Lazy dataSource) : IScheduleSt
/// column (genres) as the general CLR type, which Dapper's stricter
/// positional-record constructor matching rejects; the property-setter fallback this shape uses
/// coerces it instead.
+ ///
+ ///
+ /// ShowId/ShowName/ShowTagline/ShowFlavor (SPEC F116.1, PLAN T241) are
+ /// 's own LEFT JOIN against station.show — deliberately never
+ /// show.persona_id/show.envelope (SPEC F115.2's dormant-columns-unread pin: this row
+ /// shape has no property to even receive them). All four are null together on an unnamed block
+ /// (no matching join row).
+ ///
///
sealed record ScheduleRow
{
@@ -39,14 +47,27 @@ sealed record ScheduleRow
public string[]? Genres { get; init; }
public double? EnergyMin { get; init; }
public double? EnergyMax { get; init; }
+ public long? ShowId { get; init; }
+ public string? ShowName { get; init; }
+ public string? ShowTagline { get; init; }
+ public string? ShowFlavor { get; init; }
}
+ // SPEC F116.1/F91.3 (PLAN T241): the LEFT JOIN resolves every block's show identity at THIS one
+ // load (LoadWeekAsync/ReplaceWeekAsync's own post-write reload both share this constant) rather
+ // than a per-tick lookup — ScheduleResolver only ever sees the already-joined ScheduleWeekSnapshot
+ // (ARCHITECTURE.md "the 3s feeder tick performs no schedule query", now extended to show identity
+ // too). Selects ONLY show.id/name/tagline/flavor — never show.persona_id/envelope (SPEC F115.2's
+ // dormant-columns-unread pin), enforced here at the query itself, not merely by ScheduleRow's own
+ // shape above.
const string SelectColumns =
"""
- select id::bigint as id, day_of_week, start_minute, end_minute,
- persona_id::bigint as persona_id, genres,
- energy_min::double precision as energy_min, energy_max::double precision as energy_max
- from station.segment_schedule
+ select s.id::bigint as id, s.day_of_week, s.start_minute, s.end_minute,
+ s.persona_id::bigint as persona_id, s.genres,
+ s.energy_min::double precision as energy_min, s.energy_max::double precision as energy_max,
+ sh.id::bigint as show_id, sh.name as show_name, sh.tagline as show_tagline, sh.flavor as show_flavor
+ from station.segment_schedule s
+ left join station.show sh on sh.id = s.show_id
""";
public async Task LoadWeekAsync(CancellationToken ct)
@@ -86,6 +107,9 @@ await conn.ExecuteAsync(new CommandDefinition(
if (week.Count > 0)
{
+ // ScheduleSegment.Show (SPEC F116.1, PLAN T241) is deliberately absent from this column
+ // list — it is LOAD-only through SelectColumns' LEFT JOIN, never written here. show_id is
+ // read-only through this epic; T243's writer owns adding it to this insert.
await conn.ExecuteAsync(new CommandDefinition(
"""
insert into station.segment_schedule
@@ -122,7 +146,7 @@ static async Task> LoadSegmentsAsync(
NpgsqlConnection conn, NpgsqlTransaction? transaction, CancellationToken ct)
{
var rows = await conn.QueryAsync(new CommandDefinition(
- $"{SelectColumns} order by day_of_week, start_minute",
+ $"{SelectColumns} order by s.day_of_week, s.start_minute",
transaction: transaction,
cancellationToken: ct));
return rows.Select(ToSegment).ToList();
@@ -204,5 +228,58 @@ static ScheduleCellError Error(int rowIndex, ScheduleSegment seg, ScheduleCellEr
static ScheduleSegment ToSegment(ScheduleRow row) => new(
row.Id, (DayOfWeek)row.DayOfWeek, row.StartMinute, row.EndMinute, row.PersonaId,
- row.Genres, row.EnergyMin, row.EnergyMax);
+ row.Genres, row.EnergyMin, row.EnergyMax, ToShowSummary(row));
+
+ /// SPEC F116.1 (PLAN T241) — when the block names no show (the
+ /// LEFT JOIN found no matching station.show row); otherwise the four identity columns
+ /// selected, never persona_id/envelope (SPEC F115.2's
+ /// dormant-columns-unread pin — this method has no row data to even attempt reading either from).
+ /// ShowName is checked alongside ShowId purely as belt-and-suspenders null-safety
+ /// (never the actual guard in practice — station.show.name is NOT NULL, so any row
+ /// the join finds by id always carries one): this avoids ever needing the null-forgiving operator
+ /// to construct from a row Dapper types every column of as nullable.
+ ///
+ static ShowSummary? ToShowSummary(ScheduleRow row) =>
+ row.ShowId is { } showId && row.ShowName is { } showName
+ ? new ShowSummary(showId, showName, row.ShowTagline, row.ShowFlavor)
+ : null;
+
+ ///
+ /// Ephemeral Dapper projection for — settable properties, not
+ /// a positional record, mirrors 's own identically-shaped
+ /// ScheduledSlotRow: kept as a plain here and cast to
+ /// only when building the public , rather than
+ /// trusting Dapper's constructor-based binding to coerce an integer column straight into an
+ /// enum-typed positional-record parameter.
+ ///
+ sealed record ScheduledSlotRow
+ {
+ public int DayOfWeek { get; init; }
+ public int StartMinute { get; init; }
+ public int EndMinute { get; init; }
+ }
+
+ ///
+ /// The show delete guard's own detail read (SPEC F115.4, PLAN T240) — mirrors
+ /// 's own QueryScheduledSlotsAsync shape exactly, just
+ /// against show_id instead of persona_id and as a public seam member rather than a
+ /// private helper, since deliberately never
+ /// pre-queries this table itself (that store's own remarks) — ShowsController calls this
+ /// directly instead.
+ ///
+ public async Task> GetSlotsByShowIdAsync(long showId, CancellationToken ct)
+ {
+ await using var conn = await dataSource.Value.OpenConnectionAsync(ct);
+ var rows = await conn.QueryAsync(new CommandDefinition(
+ """
+ select day_of_week, start_minute, end_minute
+ from station.segment_schedule
+ where show_id = @showId
+ order by day_of_week, start_minute
+ """,
+ new { showId },
+ cancellationToken: ct));
+
+ return rows.Select(row => new ScheduledSlot((DayOfWeek)row.DayOfWeek, row.StartMinute, row.EndMinute)).ToList();
+ }
}
diff --git a/src/GenWave.MediaLibrary/Station/ShowRepository.cs b/src/GenWave.MediaLibrary/Station/ShowRepository.cs
new file mode 100644
index 00000000..7c609c0c
--- /dev/null
+++ b/src/GenWave.MediaLibrary/Station/ShowRepository.cs
@@ -0,0 +1,206 @@
+using Dapper;
+using GenWave.Core.Abstractions;
+using GenWave.Core.Domain;
+using Npgsql;
+
+namespace GenWave.MediaLibrary.Station;
+
+///
+/// The in-process implementation of (SPEC F115.1, STORY-305, PLAN T239) over
+/// station.show. Connection-per-query against a station_svc-scoped
+/// — mirrors 's own wiring and slug-conflict-via-unique-violation
+/// posture exactly (the closest existing station repository: name + house-Slugify'd slug + provenance
+/// pair). Never selects/binds persona_id/envelope — SPEC F115.2's "unread this epic" law
+/// — so has no way to carry either even by accident.
+///
+/// is a , the same "resolving must never be enough
+/// to trigger a connection attempt" reason every other station-schema store in this file's directory
+/// carries one (see 's own remarks in full).
+///
+sealed class ShowRepository(Lazy dataSource) : IShowStore
+{
+ // Postgres SQLSTATE for unique_violation — mirrors PersonaRepository's NameConflict mapping; here
+ // the only UNIQUE constraint on station.show is show_slug_key (db/06, db/35), so any 23505 this
+ // repository can trigger is a slug collision.
+ const string UniqueViolation = "23505";
+
+ // Postgres SQLSTATE for foreign_key_violation — station.segment_schedule.show_id's ON DELETE
+ // RESTRICT (db/06, SPEC F114) fires here when DeleteAsync targets a show a schedule row still
+ // names. Mirrors PersonaRepository's own ForeignKeyViolation mapping (PLAN T120 review F4: this
+ // mapping lives in the store, never a controller, so no controller ever imports Npgsql). Unlike
+ // PersonaRepository.DeleteAsync's query-then-delete idiom, this store does not pre-query the
+ // schedule for slot detail — PLAN T240's endpoint-layer guard (SPEC F115.4) names the offending
+ // blocks; ShowWriteResult.Referenced stays a bare singleton here.
+ const string ForeignKeyViolation = "23503";
+
+ // id is `serial` (int4) at rest — mirrors PersonaRepository's own SelectColumns comment: every id
+ // in this codebase is `long` (bigint) in C#, so it is cast on the way out for a consistent,
+ // single-width C# id type.
+ const string SelectColumns =
+ "select id::bigint as id, name, slug, tagline, flavor, imported_from, imported_at, " +
+ "created_at, updated_at from station.show";
+
+ public async Task> GetAllAsync(CancellationToken ct)
+ {
+ await using var conn = await dataSource.Value.OpenConnectionAsync(ct);
+ var rows = await conn.QueryAsync(new CommandDefinition(
+ $"{SelectColumns} order by name",
+ cancellationToken: ct));
+ return rows.ToList();
+ }
+
+ public async Task GetByIdAsync(long id, CancellationToken ct)
+ {
+ await using var conn = await dataSource.Value.OpenConnectionAsync(ct);
+ return await conn.QuerySingleOrDefaultAsync(new CommandDefinition(
+ $"{SelectColumns} where id = @id",
+ new { id },
+ cancellationToken: ct));
+ }
+
+ public async Task GetBySlugAsync(string slug, CancellationToken ct)
+ {
+ await using var conn = await dataSource.Value.OpenConnectionAsync(ct);
+ return await conn.QuerySingleOrDefaultAsync(new CommandDefinition(
+ $"{SelectColumns} where slug = @slug",
+ new { slug },
+ cancellationToken: ct));
+ }
+
+ ///
+ /// Single-statement insert (SPEC F115.1). The insert IS the uniqueness check — a colliding slug
+ /// raises a 23505 unique_violation on station.show's UNIQUE(slug) constraint, caught
+ /// here rather than pre-checked with a SELECT (no TOCTOU gap, no wasted round trip on the common
+ /// path) — mirrors . Deliberately does not name
+ /// imported_from/imported_at in the column list (SPEC F90.7's pattern, applied here
+ /// per F115.1): an authored show keeps both NULL by construction; only a future import write path
+ /// (PLAN T254) ever sets them. tagline/flavor bind through
+ /// — an empty/whitespace-only value persists as NULL, matching 's own
+ /// "null when the show carries none" contract instead of a stray ''.
+ ///
+ public async Task CreateAsync(ShowDraft draft, CancellationToken ct)
+ {
+ var slug = LegacyPersonaCardMapper.Slugify(draft.Name);
+ if (ValidateName(draft, slug) is { } invalidName) return invalidName;
+ if (ValidateBudgets(draft) is { } violation) return violation;
+
+ try
+ {
+ await using var conn = await dataSource.Value.OpenConnectionAsync(ct);
+ var show = await conn.QuerySingleAsync(new CommandDefinition(
+ $"""
+ insert into station.show (name, slug, tagline, flavor)
+ values (@Name, @Slug, @Tagline, @Flavor)
+ returning id::bigint as id, name, slug, tagline, flavor, imported_from, imported_at,
+ created_at, updated_at
+ """,
+ new { draft.Name, Slug = slug, Tagline = NullIfBlank(draft.Tagline), Flavor = NullIfBlank(draft.Flavor) },
+ cancellationToken: ct));
+ return new ShowWriteResult.Created(show);
+ }
+ catch (PostgresException ex) when (ex.SqlState == UniqueViolation)
+ {
+ return new ShowWriteResult.SlugConflict();
+ }
+ }
+
+ ///
+ /// Plain UPDATE (SPEC F115.1) — re-derives slug from the draft's Name the same way
+ /// does, mirroring 's own
+ /// "re-derive on every authored edit" rule for Persona.Slug. updated_at advances in
+ /// SQL (now()), never in C#. Like , never names
+ /// imported_from/imported_at — an authored edit leaves an imported show's provenance
+ /// stamp exactly as the last import left it (the endpoint-layer gate that refuses an authored edit
+ /// to an imported show entirely is PLAN T240's SPEC F115.5, not this seam's) — and, like
+ /// , binds tagline/flavor through
+ /// so clearing either field to "" in an edit persists NULL, not an empty string.
+ ///
+ public async Task UpdateAsync(long id, ShowDraft draft, CancellationToken ct)
+ {
+ var slug = LegacyPersonaCardMapper.Slugify(draft.Name);
+ if (ValidateName(draft, slug) is { } invalidName) return invalidName;
+ if (ValidateBudgets(draft) is { } violation) return violation;
+
+ try
+ {
+ await using var conn = await dataSource.Value.OpenConnectionAsync(ct);
+ var show = await conn.QuerySingleOrDefaultAsync(new CommandDefinition(
+ $"""
+ update station.show
+ set name = @Name, slug = @Slug, tagline = @Tagline, flavor = @Flavor, updated_at = now()
+ where id = @Id
+ returning id::bigint as id, name, slug, tagline, flavor, imported_from, imported_at,
+ created_at, updated_at
+ """,
+ new { draft.Name, Slug = slug, Tagline = NullIfBlank(draft.Tagline), Flavor = NullIfBlank(draft.Flavor), Id = id },
+ cancellationToken: ct));
+ return show is null ? new ShowWriteResult.NotFound() : new ShowWriteResult.Updated(show);
+ }
+ catch (PostgresException ex) when (ex.SqlState == UniqueViolation)
+ {
+ return new ShowWriteResult.SlugConflict();
+ }
+ }
+
+ ///
+ /// Plain DELETE (SPEC F115.1). station.segment_schedule.show_id's ON DELETE RESTRICT
+ /// (db/06, SPEC F114) already gives the database its own teeth against deleting a still-referenced
+ /// show — surfaced here as SQLSTATE 23503, caught and mapped to
+ /// (PLAN T120 review F4: the mapping lives in the store,
+ /// not a controller). Unlike , this method does not
+ /// pre-query the schedule for slot detail — naming which blocks reference it for a 409 body is
+ /// PLAN T240's endpoint-layer guard (SPEC F115.4); this seam's own case only says "referenced".
+ ///
+ public async Task DeleteAsync(long id, CancellationToken ct)
+ {
+ try
+ {
+ await using var conn = await dataSource.Value.OpenConnectionAsync(ct);
+ var affected = await conn.ExecuteAsync(new CommandDefinition(
+ "delete from station.show where id = @id",
+ new { id },
+ cancellationToken: ct));
+ return affected == 0 ? new ShowWriteResult.NotFound() : new ShowWriteResult.Deleted();
+ }
+ catch (PostgresException ex) when (ex.SqlState == ForeignKeyViolation)
+ {
+ return new ShowWriteResult.Referenced();
+ }
+ }
+
+ ///
+ /// SPEC F115.1's name-shape guard — pure C#, evaluated before either write method ever opens a
+ /// connection. Rejects a blank/whitespace-only Name outright (station.show's own check
+ /// (length(btrim(name)) > 0), db/33, would otherwise surface as an unhandled 23514
+ /// check_violation) and a equal to
+ /// — REJECTED regardless of how it got there:
+ /// an emoji-only name that hits Slugify's own empty-slug rescue AND a name that slugifies to
+ /// "persona" the ordinary way (the literal name "Persona", for instance — see
+ /// 's own remarks, PLAN T240 review A1) both
+ /// land here. See 's own remarks for the
+ /// REJECT-not-autocorrect rationale. is passed in rather than re-derived
+ /// so the two call sites (/) compute
+ /// LegacyPersonaCardMapper.Slugify exactly once each.
+ ///
+ static ShowWriteResult.InvalidName? ValidateName(ShowDraft draft, string slug) =>
+ string.IsNullOrWhiteSpace(draft.Name) || slug == LegacyPersonaCardMapper.FallbackSlug
+ ? new ShowWriteResult.InvalidName()
+ : null;
+
+ ///
+ /// SPEC F115.1's 1× budget check — delegates to (the rule
+ /// now lives beside its own constants in Core so T240/T244/T254 can reuse the identical check
+ /// order without re-deriving it).
+ ///
+ static ShowWriteResult.BudgetExceeded? ValidateBudgets(ShowDraft draft) =>
+ ShowBudgets.FirstViolation(draft) is { } field ? new ShowWriteResult.BudgetExceeded(field) : null;
+
+ ///
+ /// Empty/whitespace-only optional text collapses to null at this write seam (SPEC F115.1;
+ /// /'s own docs promise null "when the show
+ /// carries none"). An editor that clears a tagline/flavor field sends "", not null —
+ /// binding that verbatim would persist an empty string forever rather than the documented absent
+ /// state.
+ ///
+ static string? NullIfBlank(string? value) => string.IsNullOrWhiteSpace(value) ? null : value;
+}
diff --git a/src/GenWave.MediaLibrary/Station/ShowServiceCollectionExtensions.cs b/src/GenWave.MediaLibrary/Station/ShowServiceCollectionExtensions.cs
new file mode 100644
index 00000000..a33864db
--- /dev/null
+++ b/src/GenWave.MediaLibrary/Station/ShowServiceCollectionExtensions.cs
@@ -0,0 +1,32 @@
+using Microsoft.Extensions.DependencyInjection;
+using GenWave.Core.Abstractions;
+using Npgsql;
+
+namespace GenWave.MediaLibrary.Station;
+
+///
+/// DI wiring for (SPEC F115.1, STORY-305, PLAN T239). Deliberately separate
+/// from : station.show
+/// lives in the station schema/role (station_svc), not library — the same "own
+/// connection string, own data source" shape
+/// 's and 's
+/// registrations use.
+///
+/// T239 ships this registration deliberately without a Host call site (mirrors
+/// 's own original T181 shape — "no
+/// consumer lands with this seam"): /api/shows (PLAN T240) is the first consumer.
+///
+public static class ShowServiceCollectionExtensions
+{
+ ///
+ /// Registers as a singleton over a dedicated
+ /// built from . The data source
+ /// build is wrapped in a — mirrors
+ /// 's own remarks: merely resolving
+ /// must never be enough to trigger a connection attempt against an
+ /// empty/dev-mode connection string.
+ ///
+ public static IServiceCollection AddShowStore(this IServiceCollection services, string connectionString) =>
+ services.AddSingleton(
+ _ => new ShowRepository(new Lazy(() => new NpgsqlDataSourceBuilder(connectionString).Build())));
+}
diff --git a/src/GenWave.Orchestration/EffectiveAssignment.cs b/src/GenWave.Orchestration/EffectiveAssignment.cs
new file mode 100644
index 00000000..49c1c34f
--- /dev/null
+++ b/src/GenWave.Orchestration/EffectiveAssignment.cs
@@ -0,0 +1,52 @@
+using GenWave.Core.Domain;
+
+namespace GenWave.Orchestration;
+
+///
+/// SPEC F115.2 (STORY-306, PLAN T241) — the ONE identity chokepoint every on-air identity concern
+/// resolves through, so ARCHITECTURE.md's "📛 Dayparting: named shows" design-for-change spine holds:
+/// the deferred schedulable-bundle slice widens exactly this one function's own
+/// logic, and every v1 consumer — reading identity off ,
+/// which builds through this type — is untouched by construction.
+///
+///
+/// v1 rule (implemented here): BLOCK-LEVEL PERSONA ONLY. always returns
+/// 's own PersonaId — is never consulted for it.
+/// This is not an oversight: structurally carries no persona_id/
+/// envelope member at all (SPEC F115.2's dormant-columns-unread pin — see that type's own
+/// remarks), so there is nothing on this function COULD read even if it tried.
+///
+///
+///
+/// Future rule (recorded, NOT implemented here): once the deferred schedulable-bundle slice
+/// widens station.show's dormant persona_id/envelope columns into a real reader,
+/// the effective persona/envelope becomes block ?? show ?? none — block always wins. That
+/// widening touches only this function's own body: the type it takes for would
+/// grow the bundle fields, but and every downstream consumer of
+/// stay diff-free.
+///
+///
+///
+/// is taken as its own parameter rather than reached via
+/// 's own property deliberately (Law of
+/// Demeter): this function only ever needs the two pieces of already-resolved state its caller hands
+/// it, never 's full shape — the same decoupling that lets a future
+/// specials rung (PLAN T258, "dated rows shadow the grid") substitute a DIFFERENT resolved show for a
+/// given block without this function changing at all.
+///
+///
+///
+/// Envelope resolution is deliberately OUT of this function's scope: 's
+/// own BuildSegmentEnvelope already implements the unrelated "segment fields ?? station-default"
+/// fallback (SPEC F91.4) — that chain never involves a show today, so moving it here would be a diff
+/// with no behavior change. The bundle slice's own envelope widening lands in
+/// alongside the persona widening described above, not before.
+///
+///
+public sealed record EffectiveAssignment(long? PersonaId, ShowSummary? Show)
+{
+ /// Resolves / into the identity
+ /// folds into every
+ /// it builds — see this type's own remarks for the v1-vs-future rule.
+ public static EffectiveAssignment Resolve(ScheduleSegment block, ShowSummary? show) => new(block.PersonaId, show);
+}
diff --git a/src/GenWave.Orchestration/OnAirPersonaAccessor.cs b/src/GenWave.Orchestration/OnAirPersonaAccessor.cs
index 74b20582..ac46e73e 100644
--- a/src/GenWave.Orchestration/OnAirPersonaAccessor.cs
+++ b/src/GenWave.Orchestration/OnAirPersonaAccessor.cs
@@ -220,6 +220,14 @@ public sealed class OnAirPersonaAccessor(
public string? TryGetCachedName(long personaId) =>
cachedNames.TryGetValue(personaId, out var name) ? name : null;
+ ///
+ /// SPEC F121.1 (STORY-310, PLAN T242) — reads the SAME cached snapshot
+ /// does, off instead of : no
+ /// second resolve, no new I/O, the identical "before the first resolve, or an empty grid, answers
+ /// null" boot-window behavior.
+ ///
+ public long? ActiveShowId => scheduleResolver.TryGetCurrent()?.Show?.Id;
+
///
/// Resolves the on-air snapshot, degrading to on any
/// fault (F12.4) — most notably an unconfigured
diff --git a/src/GenWave.Orchestration/ScheduleResolver.cs b/src/GenWave.Orchestration/ScheduleResolver.cs
index f18da348..5a41518b 100644
--- a/src/GenWave.Orchestration/ScheduleResolver.cs
+++ b/src/GenWave.Orchestration/ScheduleResolver.cs
@@ -73,20 +73,23 @@ OnAirSnapshot ResolveCurrent(
var (boundaryDay, boundaryMinute) = NormalizeMinute(current.Day, current.EndMinute);
var boundaryAt = ResolveBoundaryInstant(todayDate, today, nowMinute, boundaryDay, boundaryMinute, zone, now);
var next = FindAdjacent(segments, boundaryDay, boundaryMinute);
- return new OnAirSnapshot(current, current.PersonaId, envelope, boundaryAt, next);
+ var assignment = EffectiveAssignment.Resolve(current, current.Show);
+ return new OnAirSnapshot(current, assignment.PersonaId, envelope, boundaryAt, next, assignment.Show);
}
OnAirSnapshot ResolveGap(
IReadOnlyList segments, TimeZoneInfo zone, DateTime todayDate, DayOfWeek today, int nowMinute,
DateTimeOffset now)
{
+ // No block is on air (SPEC F91.4) — nothing for EffectiveAssignment to resolve: persona and
+ // show are both unconditionally none, the only honest answer for a grid gap.
var envelope = defaultEnvelopeSource.Current;
var next = FindNextUpcoming(segments, today, nowMinute);
if (next is null)
- return new OnAirSnapshot(Segment: null, PersonaId: null, envelope, BoundaryAt: null, NextSegment: null);
+ return new OnAirSnapshot(Segment: null, PersonaId: null, envelope, BoundaryAt: null, NextSegment: null, Show: null);
var boundaryAt = ResolveBoundaryInstant(todayDate, today, nowMinute, next.Day, next.StartMinute, zone, now);
- return new OnAirSnapshot(Segment: null, PersonaId: null, envelope, boundaryAt, next);
+ return new OnAirSnapshot(Segment: null, PersonaId: null, envelope, boundaryAt, next, Show: null);
}
SegmentEnvelope BuildSegmentEnvelope(ScheduleSegment segment)
diff --git a/tests/GenWave.Host.Tests/Fakes/FakeScheduleStore.cs b/tests/GenWave.Host.Tests/Fakes/FakeScheduleStore.cs
index 89f57118..44e7952c 100644
--- a/tests/GenWave.Host.Tests/Fakes/FakeScheduleStore.cs
+++ b/tests/GenWave.Host.Tests/Fakes/FakeScheduleStore.cs
@@ -44,6 +44,12 @@ sealed class FakeScheduleStore(ScheduleWeekSnapshot? initial = null) : ISchedule
public int ReplaceWeekAsyncCallCount { get; private set; }
+ /// What answers for a given show id (PLAN T240's
+ /// show delete guard) — keyed by showId so a scenario can seed the exact referencing blocks a
+ /// -scripted delete should be named by, without
+ /// re-deriving ScheduleRepository's own SQL. Unseeded ids answer empty, never an error.
+ public Dictionary> SlotsByShowId { get; } = [];
+
/// Counts every call (SPEC F93.4, STORY-244, PLAN T125) — the
/// structural proof that
/// never reloads: only an explicit
@@ -102,4 +108,7 @@ public Task ReplaceWeekAsync(
WeekChanged?.Invoke();
return Task.FromResult(new ScheduleReplaceResult.Replaced(current));
}
+
+ public Task> GetSlotsByShowIdAsync(long showId, CancellationToken ct) =>
+ Task.FromResult(SlotsByShowId.TryGetValue(showId, out var slots) ? slots : []);
}
diff --git a/tests/GenWave.Host.Tests/Fakes/FakeShowImagingScope.cs b/tests/GenWave.Host.Tests/Fakes/FakeShowImagingScope.cs
new file mode 100644
index 00000000..c8e8ef42
--- /dev/null
+++ b/tests/GenWave.Host.Tests/Fakes/FakeShowImagingScope.cs
@@ -0,0 +1,45 @@
+using GenWave.Core.Abstractions;
+using GenWave.Core.Domain;
+
+namespace GenWave.Host.Tests.Fakes;
+
+///
+/// In-memory double (STORY-305, PLAN T240) for
+/// ShowsController's wire-layer specs — mirrors 's own
+/// posture: a fixed (show id → scoped rows) map, no re-implementation of
+/// GenWave.MediaLibrary.Catalog.ShowImagingScopeRepository's own SQL. The no-args construction
+/// knows no rows — every unscope answer is the empty list, the "nothing was ever scoped to this show"
+/// default every pre-existing spec would assume.
+///
+sealed class FakeShowImagingScope(IReadOnlyDictionary>? scopedByShowId = null)
+ : IShowImagingScope
+{
+ readonly IReadOnlyDictionary> scopedByShowId =
+ scopedByShowId ?? new Dictionary>();
+
+ /// Every showId this double's was actually called with, in
+ /// call order — proves the show delete guard calls it AFTER a successful delete, never before or
+ /// on a refused one (SPEC F115.4's own ordering rule).
+ public List UnscopeCalls { get; } = [];
+
+ /// Scripts the NEXT call to throw this exception instead of
+ /// returning (PLAN T240 review — proves ShowsController.Delete's own best-effort posture:
+ /// a library-connection failure here still reports the delete's own success, logged rather than
+ /// surfaced as a 500). Cleared after one use; the call is still recorded in
+ /// before the throw, matching a real repository call that fails mid
+ /// round-trip after already being dispatched.
+ public Exception? NextThrow { get; set; }
+
+ public Task> UnscopeAsync(long showId, CancellationToken ct)
+ {
+ UnscopeCalls.Add(showId);
+
+ if (NextThrow is { } toThrow)
+ {
+ NextThrow = null;
+ throw toThrow;
+ }
+
+ return Task.FromResult(scopedByShowId.TryGetValue(showId, out var rows) ? rows : []);
+ }
+}
diff --git a/tests/GenWave.Host.Tests/Fakes/FakeShowStore.cs b/tests/GenWave.Host.Tests/Fakes/FakeShowStore.cs
new file mode 100644
index 00000000..a4243c93
--- /dev/null
+++ b/tests/GenWave.Host.Tests/Fakes/FakeShowStore.cs
@@ -0,0 +1,114 @@
+using GenWave.Core.Abstractions;
+using GenWave.Core.Domain;
+
+namespace GenWave.Host.Tests.Fakes;
+
+///
+/// In-memory double (STORY-305, PLAN T240) for ShowsController's
+/// wire-layer specs — mirrors FakeScheduleStore's own posture: this double is deliberately
+/// NOT a re-implementation of GenWave.MediaLibrary.Station.ShowRepository's own InvalidName/
+/// budget/slug-conflict validation (that validation is proven for real, against a real Postgres
+/// fixture, in GenWave.MediaLibrary.Tests/Specs/Story305_ShowRepository.cs — a
+/// re-implementation here would be a lookalike double). The default (unscripted) behavior simply
+/// echoes whatever is submitted — a fresh id, a locally-derived slug, blank/whitespace
+/// tagline/flavor coerced to null (the one bit of that repository's own contract cheap enough to
+/// reproduce faithfully without re-deriving any REJECTION logic) — and every write method can be
+/// SCRIPTED via its own Next*Result property to return an exact
+/// instead, for the sad-path/gate-parity facts that need one without a real repository behind them.
+///
+sealed class FakeShowStore : IShowStore
+{
+ readonly Dictionary byId;
+ long nextId;
+
+ /// Seeds the store with pre-existing rows (e.g. an IMPORTED show — no writer through
+ /// this interface can ever produce one, mirrors how the real repository's own provenance tests
+ /// insert directly rather than going through ShowRepository.CreateAsync).
+ public FakeShowStore(IEnumerable? seed = null)
+ {
+ byId = (seed ?? []).ToDictionary(s => s.Id);
+ nextId = byId.Count == 0 ? 1 : byId.Keys.Max() + 1;
+ }
+
+ /// Scripts the NEXT call's outcome verbatim, bypassing the
+ /// default echo-and-store behavior. Cleared after one use.
+ public ShowWriteResult? NextCreateResult { get; set; }
+
+ /// Scripts the NEXT call's outcome verbatim. Cleared after one
+ /// use.
+ public ShowWriteResult? NextUpdateResult { get; set; }
+
+ /// Scripts the NEXT call's outcome verbatim — the guard-path
+ /// facts need without a real
+ /// station.segment_schedule FK to trigger it. Cleared after one use.
+ public ShowWriteResult? NextDeleteResult { get; set; }
+
+ public Task> GetAllAsync(CancellationToken ct) =>
+ Task.FromResult>(byId.Values.OrderBy(s => s.Name, StringComparer.Ordinal).ToList());
+
+ public Task GetByIdAsync(long id, CancellationToken ct) =>
+ Task.FromResult(byId.GetValueOrDefault(id));
+
+ public Task GetBySlugAsync(string slug, CancellationToken ct) =>
+ Task.FromResult(byId.Values.FirstOrDefault(s => s.Slug == slug));
+
+ public Task CreateAsync(ShowDraft draft, CancellationToken ct)
+ {
+ if (NextCreateResult is { } scripted)
+ {
+ NextCreateResult = null;
+ return Task.FromResult(scripted);
+ }
+
+ var now = DateTime.UtcNow;
+ var show = new Show(
+ nextId++, draft.Name, Slugify(draft.Name), NullIfBlank(draft.Tagline), NullIfBlank(draft.Flavor),
+ ImportedFrom: null, ImportedAt: null, now, now);
+ byId[show.Id] = show;
+ return Task.FromResult(new ShowWriteResult.Created(show));
+ }
+
+ public Task UpdateAsync(long id, ShowDraft draft, CancellationToken ct)
+ {
+ if (NextUpdateResult is { } scripted)
+ {
+ NextUpdateResult = null;
+ return Task.FromResult(scripted);
+ }
+
+ if (!byId.TryGetValue(id, out var existing))
+ return Task.FromResult(new ShowWriteResult.NotFound());
+
+ var updated = existing with
+ {
+ Name = draft.Name,
+ Slug = Slugify(draft.Name),
+ Tagline = NullIfBlank(draft.Tagline),
+ Flavor = NullIfBlank(draft.Flavor),
+ UpdatedAt = DateTime.UtcNow,
+ };
+ byId[id] = updated;
+ return Task.FromResult(new ShowWriteResult.Updated(updated));
+ }
+
+ public Task DeleteAsync(long id, CancellationToken ct)
+ {
+ if (NextDeleteResult is { } scripted)
+ {
+ NextDeleteResult = null;
+ return Task.FromResult(scripted);
+ }
+
+ return Task.FromResult(
+ byId.Remove(id) ? new ShowWriteResult.Deleted() : new ShowWriteResult.NotFound());
+ }
+
+ // A deterministic, display-only stand-in for the production house Slugify (never accessible from
+ // this project — internal to GenWave.MediaLibrary) — good enough for round-trip routing through
+ // GetBySlugAsync-addressed routes; this double never needs to match LegacyPersonaCardMapper.Slugify
+ // byte-for-byte, since that algorithm is already proven against Story305_ShowRepository.cs.
+ static string Slugify(string name) =>
+ string.Join('-', name.Trim().ToLowerInvariant().Split(' ', StringSplitOptions.RemoveEmptyEntries));
+
+ static string? NullIfBlank(string? value) => string.IsNullOrWhiteSpace(value) ? null : value;
+}
diff --git a/tests/GenWave.Host.Tests/Specs/Story217_BoothLogPickStamp.cs b/tests/GenWave.Host.Tests/Specs/Story217_BoothLogPickStamp.cs
index a3f62bd6..6fa4ec2a 100644
--- a/tests/GenWave.Host.Tests/Specs/Story217_BoothLogPickStamp.cs
+++ b/tests/GenWave.Host.Tests/Specs/Story217_BoothLogPickStamp.cs
@@ -39,20 +39,20 @@ namespace GenWave.Host.Tests.Specs;
///
/// In-memory double: records every call's
-/// arguments instead of touching Postgres — the repository seam
-/// fakes (mirrors FakeBoothLogReader's idiom on the read side, Story195_BoothLog.cs). Releases
-/// a signal per call so a scenario can await exactly as many appends as it published, with no
-/// arbitrary sleep.
+/// instead of touching Postgres — the repository seam
+/// fakes (mirrors FakeBoothLogReader's idiom on the read
+/// side, Story195_BoothLog.cs). Releases a signal per call so a scenario can await exactly as many
+/// appends as it published, with no arbitrary sleep.
///
file sealed class FakeBoothLogAppender : IBoothLogAppender
{
readonly SemaphoreSlim appended = new(0);
- public List<(string Kind, string Summary, long? PersonaId, string? Artist, string? Pick, long? MediaId, string? SegmentKind)> Calls { get; } = [];
+ public List Calls { get; } = [];
- public Task AppendAsync(string kind, string summary, long? personaId, string? artist, string? pick, long? mediaId, string? segmentKind, CancellationToken ct)
+ public Task AppendAsync(BoothLogAppendRequest request, CancellationToken ct)
{
- lock (Calls) Calls.Add((kind, summary, personaId, artist, pick, mediaId, segmentKind));
+ lock (Calls) Calls.Add(request);
appended.Release();
return Task.CompletedTask;
}
diff --git a/tests/GenWave.Host.Tests/Specs/Story305_ShowsApi.cs b/tests/GenWave.Host.Tests/Specs/Story305_ShowsApi.cs
new file mode 100644
index 00000000..be46eefd
--- /dev/null
+++ b/tests/GenWave.Host.Tests/Specs/Story305_ShowsApi.cs
@@ -0,0 +1,454 @@
+// STORY-305 — The show entity & API (F115.1, F115.4, F115.5) — endpoint half
+//
+// BDD specification — xUnit. WIRED T240 — every Fact below drives the real production
+// /api/shows routes (F79/F90 AdminSurface + Settings posture) through WebApplicationFactory
+// with real cookie auth (real POST /api/auth/login — mirrors Story240_GridHoldsTheWeek.cs's own
+// idiom), against FakeShowStore/FakeScheduleStore/FakeShowImagingScope doubles — no live Postgres,
+// this project has none for Host.Tests. The repository half (real Postgres, ShowRepository's own
+// InvalidName/budget/slug-conflict validation) lives in
+// GenWave.MediaLibrary.Tests/Specs/Story305_ShowRepository.cs — this file never re-derives that
+// validation, only the WIRE mapping a scripted GenWave.Core.Domain.ShowWriteResult produces
+// (mirrors FakeScheduleStore's own posture, see that Fake's remarks).
+//
+// ScenarioGateParityAcrossCreateAndUpdate extends PLAN T207's own 7-row BadBodyTable precedent
+// (Story287_SaveAsOwn.cs): narrowed to this store's five app-seam gates (blank/fallback-slug name,
+// and the three SPEC F115.1 budgets) since Show writes have no multi-phase manifest pipeline to
+// drift the way the two theme-write routes once did — see ShowsController's own class remarks for
+// why that gate lives as one shared WriteProblem mapping instead of a second ThemeWriteGate-shaped
+// type.
+
+using System.Net;
+using System.Net.Http.Json;
+using System.Text.Json;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.Mvc.Testing;
+using Microsoft.AspNetCore.TestHost;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using GenWave.Core.Abstractions;
+using GenWave.Core.Domain;
+using GenWave.Host.Api;
+using GenWave.Host.Tests.Fakes;
+
+namespace GenWave.Host.Tests.Specs;
+
+// ── In-process tests ──────────────────────────────────────────────────────────────────────────────
+
+public static class FeatureShowsApi
+{
+ public sealed class ScenarioCrudThroughTheProductionSurface
+ {
+ [Fact]
+ public async Task CrudRoundTripsThroughTheEndpoints()
+ {
+ // Given an authenticated admin session
+ var store = new FakeShowStore();
+ await using var factory = new ShowsApiWebFactory(store);
+ var client = await ShowsApiWebFactory.LoggedInClientAsync(factory);
+
+ // When a show is created, listed, edited, and fetched via /api/shows
+ var createResponse = await client.PostAsJsonAsync(
+ "/api/shows", new { name = "Night Moves", tagline = "Late-night deep cuts", flavor = "moody, sparse" });
+ var created = await createResponse.Content.ReadFromJsonAsync();
+ Assert.NotNull(created);
+
+ var list = await (await client.GetAsync("/api/shows")).Content.ReadFromJsonAsync();
+
+ var editResponse = await client.PatchAsJsonAsync(
+ $"/api/shows/{created.Slug}",
+ new { name = "Night Moves", tagline = "Revisited", flavor = "moodier, sparser" });
+ var edited = await editResponse.Content.ReadFromJsonAsync();
+
+ var fetched = await (await client.GetAsync($"/api/shows/{created.Slug}")).Content.ReadFromJsonAsync();
+
+ // Then every field round-trips end to end: creation succeeds, the list carries the new
+ // row, the edit lands, and a fresh read agrees with the edit response — reachable at all
+ // only because LoggedInClientAsync's real cookie round trip passed the AdminSurface/
+ // Settings gate every action above sits behind
+ Assert.Equal(
+ (Create: HttpStatusCode.Created, Listed: true, Edit: HttpStatusCode.OK,
+ EditedFields: (Name: "Night Moves", Tagline: "Revisited", Flavor: "moodier, sparser"),
+ FetchMatchesEdit: true),
+ (Create: createResponse.StatusCode, Listed: list!.Any(s => s.Slug == created.Slug),
+ Edit: editResponse.StatusCode, EditedFields: (edited!.Name, edited.Tagline, edited.Flavor),
+ FetchMatchesEdit: fetched == edited));
+ }
+
+ [Fact]
+ public async Task UnreferencedShowDeletesClean()
+ {
+ // Given a show no block, special, or imaging row references
+ var store = new FakeShowStore();
+ var imagingScope = new FakeShowImagingScope();
+ await using var factory = new ShowsApiWebFactory(store, imagingScope: imagingScope);
+ var client = await ShowsApiWebFactory.LoggedInClientAsync(factory);
+ var created = await (await client.PostAsJsonAsync("/api/shows", new { name = "To Be Deleted" }))
+ .Content.ReadFromJsonAsync();
+
+ // When DELETE /api/shows/{slug} runs
+ var response = await client.DeleteAsync($"/api/shows/{created!.Slug}");
+
+ // Then 204, the row is gone, and the unscope seam was called exactly once — proving a
+ // clean delete still runs the best-effort cleanup step (never skipped just because there
+ // was nothing scoped to find)
+ var afterDelete = await client.GetAsync($"/api/shows/{created.Slug}");
+ Assert.Equal(
+ (Delete: HttpStatusCode.NoContent, Gone: HttpStatusCode.NotFound, UnscopeCalls: 1),
+ (Delete: response.StatusCode, Gone: afterDelete.StatusCode, UnscopeCalls: imagingScope.UnscopeCalls.Count));
+ }
+ }
+
+ public sealed class ScenarioGuardedDelete
+ {
+ [Fact]
+ public async Task DeleteWithReferencesFails409NamingBlocks()
+ {
+ // Given a show referenced by schedule blocks — the store's own FK case (Referenced) is
+ // scripted (this project has no Postgres fixture for Host.Tests to trigger the real
+ // segment_schedule FK), and the referencing block detail is seeded on FakeScheduleStore
+ // the same way ShowsController.Delete itself re-queries it (SPEC F115.4)
+ var show = new Show(1, "Scheduled Show", "scheduled-show", null, null, null, null, DateTime.UtcNow, DateTime.UtcNow);
+ var store = new FakeShowStore([show]) { NextDeleteResult = new ShowWriteResult.Referenced() };
+ var scheduleStore = new FakeScheduleStore();
+ scheduleStore.SlotsByShowId[show.Id] = [new ScheduledSlot(DayOfWeek.Monday, 540, 720)];
+ var imagingScope = new FakeShowImagingScope();
+ await using var factory = new ShowsApiWebFactory(store, scheduleStore, imagingScope);
+ var client = await ShowsApiWebFactory.LoggedInClientAsync(factory);
+
+ // When DELETE runs
+ var response = await client.DeleteAsync($"/api/shows/{show.Slug}");
+
+ // Then 409 whose body names the referencing blocks (the F104 guard precedent) — and
+ // nothing was touched on the imaging side: a block-refused delete unscopes nothing (this
+ // action's own "ordering is deliberate" rule)
+ var detail = await DetailAsync(response);
+ Assert.Equal(
+ (Status: HttpStatusCode.Conflict, NamesTheDay: true, NamesTheTime: true, UnscopeCalls: 0),
+ (Status: response.StatusCode,
+ NamesTheDay: detail.Contains("Mon", StringComparison.Ordinal),
+ NamesTheTime: detail.Contains("09:00", StringComparison.Ordinal),
+ UnscopeCalls: imagingScope.UnscopeCalls.Count));
+ }
+
+ [Fact]
+ public async Task ScopedImagingRowsAreNamedAndUnscopedBestEffort()
+ {
+ // Given a show referenced only by a scoped imaging row (no FK — F117.1)
+ var show = new Show(1, "Imaging-Scoped Show", "imaging-scoped-show", null, null, null, null, DateTime.UtcNow, DateTime.UtcNow);
+ var store = new FakeShowStore([show]);
+ var imagingScope = new FakeShowImagingScope(
+ new Dictionary> { [show.Id] = [new ScopedImagingRow(42, "Sunset Ident")] });
+ await using var factory = new ShowsApiWebFactory(store, imagingScope: imagingScope);
+ var client = await ShowsApiWebFactory.LoggedInClientAsync(factory);
+
+ // When DELETE runs
+ var response = await client.DeleteAsync($"/api/shows/{show.Slug}");
+
+ // Then the response names the row and the library-connection unscope write is issued
+ // (idempotent second write — F115.4): the delete itself succeeded (nothing blocked it —
+ // library.media.show_id carries no FK). Array/list members don't compare structurally
+ // inside a bundled tuple (ValueTuple.Equals uses reference equality per component), so
+ // the "called exactly once, with this show's id" claim gets its own Assert.Equal call.
+ var body = await response.Content.ReadFromJsonAsync();
+ Assert.Equal(
+ (Status: HttpStatusCode.OK, NamedRow: "Sunset Ident"),
+ (Status: response.StatusCode, NamedRow: body!.UnscopedImaging.Single().Title));
+ Assert.Equal([show.Id], imagingScope.UnscopeCalls);
+ }
+
+ [Fact]
+ public async Task UnscopeFailureStillReportsDeleteSuccessAndLogsTheError()
+ {
+ // Given a show whose post-delete imaging unscope will fail (e.g. the library connection
+ // drops mid round-trip) — the failure is scripted on the fake rather than reproduced for
+ // real, mirroring FakeScheduleStore's own NextThrow idiom
+ var show = new Show(1, "Fragile Unscope Show", "fragile-unscope-show", null, null, null, null, DateTime.UtcNow, DateTime.UtcNow);
+ var store = new FakeShowStore([show]);
+ var imagingScope = new FakeShowImagingScope { NextThrow = new InvalidOperationException("library connection dropped") };
+ var logs = new CapturingWarningLoggerProvider();
+ await using var factory = new ShowsApiWebFactory(store, imagingScope: imagingScope, logs: logs);
+ var client = await ShowsApiWebFactory.LoggedInClientAsync(factory);
+
+ // When DELETE runs and the best-effort unscope throws
+ var response = await client.DeleteAsync($"/api/shows/{show.Slug}");
+
+ // Then the delete itself still reports success (204 — nothing was actually reported as
+ // unscoped, since the failed call never got to name what it cleared), the failure is
+ // logged naming the show so an operator can hand-recover, and the row is genuinely gone
+ // despite the cleanup failure — a post-commit cleanup fault never surfaces as a 500
+ // (this action's own "best-effort" contract, see ShowsController's own class remarks)
+ var afterDelete = await client.GetAsync($"/api/shows/{show.Slug}");
+ Assert.Equal(
+ (Delete: HttpStatusCode.NoContent, Gone: HttpStatusCode.NotFound, UnscopeAttempted: 1, ErrorLogged: true),
+ (Delete: response.StatusCode, Gone: afterDelete.StatusCode, UnscopeAttempted: imagingScope.UnscopeCalls.Count,
+ ErrorLogged: logs.Messages.Any(m =>
+ m.Contains("unscope failed", StringComparison.OrdinalIgnoreCase) &&
+ m.Contains(show.Slug, StringComparison.Ordinal))));
+ }
+
+ [Fact]
+ public async Task DeleteNamesAMidnightEndingBlockAsTwentyFourHundredNotZero()
+ {
+ // Given a show referenced by a block running to the grid's own maximum end minute (1440 —
+ // midnight) — the exact value FormatMinutes's own load-bearing comment (now shared via
+ // ScheduledSlotText, PLAN T240 review) warns TimeSpan's "hh" format specifier would
+ // silently misrender as "00:00"
+ var show = new Show(1, "Overnight Show", "overnight-show", null, null, null, null, DateTime.UtcNow, DateTime.UtcNow);
+ var store = new FakeShowStore([show]) { NextDeleteResult = new ShowWriteResult.Referenced() };
+ var scheduleStore = new FakeScheduleStore();
+ scheduleStore.SlotsByShowId[show.Id] = [new ScheduledSlot(DayOfWeek.Sunday, 1380, 1440)];
+ await using var factory = new ShowsApiWebFactory(store, scheduleStore);
+ var client = await ShowsApiWebFactory.LoggedInClientAsync(factory);
+
+ // When DELETE runs
+ var response = await client.DeleteAsync($"/api/shows/{show.Slug}");
+
+ // Then the 409 body names the block's end as 24:00, never 00:00
+ var detail = await DetailAsync(response);
+ Assert.Equal(
+ (Status: HttpStatusCode.Conflict, EndsAtTwentyFourHundred: true, NeverZeroZero: true),
+ (Status: response.StatusCode,
+ EndsAtTwentyFourHundred: detail.Contains("23:00–24:00", StringComparison.Ordinal),
+ NeverZeroZero: !detail.Contains("00:00", StringComparison.Ordinal)));
+ }
+
+ [Fact]
+ public async Task DeleteWithEmptyReferencedBlocksStillRefusesWithAGenericDetail()
+ {
+ // Given the store reports Referenced but the endpoint's own re-query names nothing — the
+ // documented rare race in ReferencedProblem's own remarks (the FK fired, but the block
+ // that caused it is gone by the time this action re-queries station.segment_schedule)
+ var show = new Show(1, "Racy Show", "racy-show", null, null, null, null, DateTime.UtcNow, DateTime.UtcNow);
+ var store = new FakeShowStore([show]) { NextDeleteResult = new ShowWriteResult.Referenced() };
+ var scheduleStore = new FakeScheduleStore(); // SlotsByShowId left empty for show.Id
+ var imagingScope = new FakeShowImagingScope();
+ await using var factory = new ShowsApiWebFactory(store, scheduleStore, imagingScope);
+ var client = await ShowsApiWebFactory.LoggedInClientAsync(factory);
+
+ // When DELETE runs
+ var response = await client.DeleteAsync($"/api/shows/{show.Slug}");
+
+ // Then it still refuses 409 with the generic fallback wording (never a crash, never a
+ // silently-empty-looking detail claiming nothing blocks it) — and, as with any Referenced
+ // outcome, nothing was unscoped
+ var detail = await DetailAsync(response);
+ Assert.Equal(
+ (Status: HttpStatusCode.Conflict, GenericWording: true, UnscopeCalls: 0),
+ (Status: response.StatusCode,
+ GenericWording: detail.Contains("still appears in the format-clock schedule", StringComparison.Ordinal),
+ UnscopeCalls: imagingScope.UnscopeCalls.Count));
+ }
+ }
+
+ public sealed class ScenarioProvenanceProtection
+ {
+ [Fact]
+ public async Task AuthoredSaveNeverErasesImportedProvenance()
+ {
+ // Given an imported show
+ var imported = new Show(
+ 1, "Retro Nights", "retro-nights", "Old tagline", null,
+ ImportedFrom: "midnight-drive-catalog-entry", ImportedAt: DateTime.UtcNow,
+ CreatedAt: DateTime.UtcNow, UpdatedAt: DateTime.UtcNow);
+ var store = new FakeShowStore([imported]);
+ await using var factory = new ShowsApiWebFactory(store);
+ var client = await ShowsApiWebFactory.LoggedInClientAsync(factory);
+
+ // When an authored save targets its slug
+ var response = await client.PatchAsJsonAsync(
+ "/api/shows/retro-nights", new { name = "Retro Nights", tagline = "Hijacked tagline" });
+
+ // Then 409 — the ThemeWriteGate two-phase posture (F115.5); imported_from survives, and so
+ // does every other field — the write never even reaches IShowStore.UpdateAsync
+ var detail = await DetailAsync(response);
+ var stillStored = await store.GetBySlugAsync("retro-nights", CancellationToken.None);
+ Assert.Equal(
+ (Status: HttpStatusCode.Conflict, NamesTheSlug: true,
+ ImportedFromSurvives: "midnight-drive-catalog-entry", TaglineUntouched: "Old tagline"),
+ (Status: response.StatusCode, NamesTheSlug: detail.Contains("retro-nights", StringComparison.Ordinal),
+ ImportedFromSurvives: stillStored?.ImportedFrom, TaglineUntouched: stillStored?.Tagline));
+ }
+ }
+
+ public sealed class ScenarioGateParityAcrossCreateAndUpdate
+ {
+ public static TheoryData Rows
+ {
+ get
+ {
+ var data = new TheoryData();
+ foreach (var row in GateParityTable.Rows)
+ data.Add(row);
+
+ return data;
+ }
+ }
+
+ [Theory]
+ [MemberData(nameof(Rows))]
+ public async Task BothWriteRoutesRefuseWithTheIdenticalStatusAndDetail(GateParityRow row)
+ {
+ // Given an existing authored show to PATCH onto (POST always targets a fresh row) and the
+ // SAME request body submitted to both routes (SlugConflictProblem's own detail embeds the
+ // submitted name, so byte-identity across routes needs the same name either side)
+ var existing = new Show(1, "Existing Show", "existing-show", null, null, null, null, DateTime.UtcNow, DateTime.UtcNow);
+ var store = new FakeShowStore([existing]);
+ await using var factory = new ShowsApiWebFactory(store);
+ var client = await ShowsApiWebFactory.LoggedInClientAsync(factory);
+ var body = new { name = "Night Moves", tagline = (string?)null, flavor = (string?)null };
+
+ // When the SAME app-seam gate outcome is scripted for both the create and the update write
+ store.NextCreateResult = row.Result;
+ var createResponse = await client.PostAsJsonAsync("/api/shows", body);
+ store.NextUpdateResult = row.Result;
+ var updateResponse = await client.PatchAsJsonAsync("/api/shows/existing-show", body);
+
+ // Then both refuse with the row's own expected status and byte-identical, content-bearing
+ // detail text — proving ShowsController.WriteProblem produces the SAME body regardless of
+ // which write route hit it (mirrors PLAN T207's own byte-identical-copy proof format)
+ var createDetail = await DetailAsync(createResponse);
+ var updateDetail = await DetailAsync(updateResponse);
+ Assert.Equal(
+ (CreateStatus: row.ExpectedStatus, UpdateStatus: row.ExpectedStatus,
+ DetailsMatch: true, NamesTheExpectedContent: true),
+ (CreateStatus: createResponse.StatusCode, UpdateStatus: updateResponse.StatusCode,
+ DetailsMatch: createDetail == updateDetail,
+ NamesTheExpectedContent: updateDetail.Contains(row.ExpectedFragment, StringComparison.Ordinal)));
+ }
+ }
+
+ static async Task DetailAsync(HttpResponseMessage response)
+ {
+ using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
+ return document.RootElement.GetProperty("detail").GetString() ?? "";
+ }
+}
+
+// ── Gate-parity table (mirrors Story287_SaveAsOwn.cs's own BadBodyRow/BadBodyTable) ────────────────
+
+/// One row of 's own
+/// table (PLAN T240, extending the PLAN T207 precedent). is what xUnit's test
+/// explorer shows per row, so it names the gate under test, not the row's own field values.
+public sealed record GateParityRow(string Label, ShowWriteResult Result, HttpStatusCode ExpectedStatus, string ExpectedFragment)
+{
+ public override string ToString() => Label;
+}
+
+///
+/// The five app-seam gates ShowRepository.CreateAsync/UpdateAsync share (proven for
+/// real, against a real Postgres fixture, in Story305_ShowRepository.cs) — this table proves
+/// the SEPARATE claim that ShowsController's own HTTP mapping of each outcome never drifts
+/// between the two write routes. Each row's is content
+/// the refusal MUST name, never a substring the base refusal's own boilerplate alone would already
+/// satisfy (mirrors Story287_SaveAsOwn.cs's own N2 "NamesTheMissingFace pattern", generalized here).
+///
+static class GateParityTable
+{
+ public static readonly IReadOnlyList Rows =
+ [
+ new("blank or fallback-slug name",
+ new ShowWriteResult.InvalidName(), HttpStatusCode.BadRequest, "blank"),
+
+ new("name over budget",
+ new ShowWriteResult.BudgetExceeded(ShowBudgetField.Name), HttpStatusCode.BadRequest, "name must be at most 60"),
+
+ new("tagline over budget",
+ new ShowWriteResult.BudgetExceeded(ShowBudgetField.Tagline), HttpStatusCode.BadRequest, "tagline must be at most 120"),
+
+ new("flavor over budget",
+ new ShowWriteResult.BudgetExceeded(ShowBudgetField.Flavor), HttpStatusCode.BadRequest, "flavor must be at most 400"),
+
+ new("slug conflict",
+ new ShowWriteResult.SlugConflict(), HttpStatusCode.Conflict, "Night Moves"),
+ ];
+}
+
+// ── Test harness ───────────────────────────────────────────────────────────────────────────────────
+
+/// Captures every log entry of Warning or above so a spec can assert on
+/// ShowsController's own output — mirrors Story164_FailClosedWithoutPassword's own
+/// CapturingWarningLoggerProvider idiom (a file-scoped copy per spec file that needs one,
+/// rather than a fifth shared Fakes/CapturingLogger.cs alongside the four already in
+/// MediaLibrary.Tests/Tts.Tests/Orchestration.Tests/Context.Tests — this project's own precedent for
+/// wire-level log capture is already this per-file shape, not that per-project one).
+file sealed class CapturingWarningLoggerProvider : ILoggerProvider
+{
+ readonly List messages = [];
+ public IReadOnlyList Messages { get { lock (messages) return messages.ToList(); } }
+
+ public ILogger CreateLogger(string categoryName) => new Logger(this);
+ public void Dispose() { }
+
+ void Add(string message) { lock (messages) messages.Add(message); }
+
+ sealed class Logger(CapturingWarningLoggerProvider owner) : ILogger
+ {
+ public IDisposable? BeginScope(TState state) where TState : notnull => null;
+ public bool IsEnabled(LogLevel logLevel) => logLevel >= LogLevel.Warning;
+ public void Log(
+ LogLevel logLevel, EventId eventId, TState state, Exception? exception,
+ Func formatter)
+ {
+ if (IsEnabled(logLevel)) owner.Add(formatter(state, exception));
+ }
+ }
+}
+
+///
+/// for this file's own Facts — mirrors
+/// Story240_GridHoldsTheWeek.cs's own ScheduleApiWebFactory idiom: ,
+/// , and all replaced by stateful fakes
+/// (defaulted to empty ones when a Fact doesn't need to script them). is
+/// wired only when a Fact actually needs to assert on logged output (PLAN T240 review) — every other
+/// Fact leaves it null and gets the host's ordinary logging pipeline, untouched.
+///
+file sealed class ShowsApiWebFactory(
+ FakeShowStore store,
+ FakeScheduleStore? scheduleStore = null,
+ FakeShowImagingScope? imagingScope = null,
+ bool withAdminPassword = true,
+ CapturingWarningLoggerProvider? logs = null)
+ : WebApplicationFactory
+{
+ internal const string Password = "test-password-story305-showsapi";
+
+ protected override void ConfigureWebHost(IWebHostBuilder builder)
+ {
+ builder.UseEnvironment("Development");
+ builder.UseSetting("ConnectionStrings:Library", "Host=nowhere;Database=test");
+
+ if (withAdminPassword)
+ {
+ builder.UseSetting("Admin:Password", Password);
+ }
+
+ if (logs is not null)
+ builder.ConfigureLogging(logging => logging.AddProvider(logs));
+
+ builder.ConfigureTestServices(services =>
+ {
+ // No Liquidsoap/DB connections during this test.
+ services.RemoveAll();
+
+ services.RemoveAll();
+ services.AddSingleton(store);
+
+ services.RemoveAll();
+ services.AddSingleton(scheduleStore ?? new FakeScheduleStore());
+
+ services.RemoveAll();
+ services.AddSingleton(imagingScope ?? new FakeShowImagingScope());
+ });
+ }
+
+ /// Logs in via the real POST /api/auth/login round trip (mirrors Story240's own helper) and returns the cookie-bearing client.
+ public static async Task LoggedInClientAsync(WebApplicationFactory factory)
+ {
+ var client = factory.CreateClient();
+ var login = await client.PostAsJsonAsync("/api/auth/login", new { password = Password });
+ Assert.Equal(HttpStatusCode.NoContent, login.StatusCode);
+ return client;
+ }
+}
diff --git a/tests/GenWave.Host.Tests/Specs/Story311_SpectatorShowFields.cs b/tests/GenWave.Host.Tests/Specs/Story311_SpectatorShowFields.cs
new file mode 100644
index 00000000..446ed83f
--- /dev/null
+++ b/tests/GenWave.Host.Tests/Specs/Story311_SpectatorShowFields.cs
@@ -0,0 +1,52 @@
+// STORY-311 — The public face names the show (F116.4, F115.3)
+//
+// BDD specification — xUnit, PENDING scaffold (planned 2026-08-10). Comment-bodied on
+// purpose: the spectator DTO fields land at T251. Disclosure follows the F67.6 idiom —
+// complete-property-set assertions so an unblessed field fails the build. The one hard
+// law: flavor is prompt config and NEVER appears on a public surface.
+
+namespace GenWave.Host.Tests.Specs;
+
+using Xunit;
+
+public static class FeatureSpectatorShowFields
+{
+ public sealed class ScenarioTheFieldsRide
+ {
+ [Fact(Skip = "Pending (T251)")]
+ public void NowPlayingCarriesShowNameAndTagline()
+ {
+ // Given a show on the air
+ // When /spectator/api/now-playing is read via the public listener
+ // Then show { name, tagline } is present on the payload
+ }
+
+ [Fact(Skip = "Pending (T251)")]
+ public void UpNextCarriesTheShowName()
+ {
+ // Given a named next segment
+ // When now-playing is read
+ // Then upNext.show carries the name (name only — F116.4)
+ }
+
+ [Fact(Skip = "Pending (T251)")]
+ public void UnnamedBlocksReadNull()
+ {
+ // Given no show on the air and an unnamed next segment
+ // When now-playing is read
+ // Then show and upNext.show are null — the page renders exactly as today
+ }
+ }
+
+ public sealed class ScenarioDisclosureHoldsTheLine
+ {
+ [Fact(Skip = "Pending (T251)")]
+ public void FlavorIsStructurallyAbsentFromPublicPayloads()
+ {
+ // Given every spectator payload DTO
+ // When the disclosure-contract suite enumerates complete property sets
+ // Then flavor appears nowhere (F115.3 — the persona-soul precedent);
+ // name/tagline are the pinned public additions to the F67 inventory
+ }
+ }
+}
diff --git a/tests/GenWave.Host.Tests/Specs/Story315_ShowImport.cs b/tests/GenWave.Host.Tests/Specs/Story315_ShowImport.cs
new file mode 100644
index 00000000..19c12c8a
--- /dev/null
+++ b/tests/GenWave.Host.Tests/Specs/Story315_ShowImport.cs
@@ -0,0 +1,70 @@
+// STORY-315 — Hire a show from the shelf (F118.2, F118.3, F118.5)
+//
+// BDD specification — xUnit, PENDING scaffold (planned 2026-08-10). Comment-bodied on
+// purpose: the import endpoint lands at T254 through the F79 shell (caps, schema-major
+// reject, transactional no-partial upsert, provenance). The shelf/modal UI half is jest
+// (catalog-show-shelf.spec.tsx). Cross-repo golden parity follows the T107 precedent.
+
+namespace GenWave.Host.Tests.Specs;
+
+using Xunit;
+
+public static class FeatureShowImport
+{
+ public sealed class ScenarioImportThroughTheShell
+ {
+ [Fact(Skip = "Pending (T254)")]
+ public void ImportUpsertsTransactionallyWithProvenance()
+ {
+ // Given a valid show card fetched by catalogSlug
+ // When POST /api/shows/{slug}/import runs
+ // Then the show lands whole with imported_from = catalogSlug, imported_at set
+ }
+
+ [Fact(Skip = "Pending (T254)")]
+ public void FileUploadStampsFile()
+ {
+ // Given a direct file upload of a show manifest
+ // When the import runs
+ // Then imported_from = "file" (the F103.6 provenance triple)
+ }
+
+ [Fact(Skip = "Pending (T254)")]
+ public void GoldenParityPinsTheCrossRepoContract()
+ {
+ // Given fixtures/golden.show.json (the catalog repo pins the same bytes)
+ // When the manifest parser consumes it
+ // Then it round-trips — the T107/T193 two-repo drift guard extended to shows
+ }
+ }
+
+ public sealed class ScenarioSoftSuggestion
+ {
+ [Fact(Skip = "Pending (T254)")]
+ public void ImportSucceedsWithSuggestionAbsentUnknownOrHired()
+ {
+ // Given a card whose suggestedPersona is missing, unknown, or already hired
+ // When the show imports
+ // Then it succeeds with no offer and no error (soft means soft — F118.3)
+ }
+ }
+
+ public sealed class ScenarioRejectingBadImports
+ {
+ [Fact(Skip = "Pending (T254)")]
+ public void SchemaMajorAndSizeCapRejectAsTheShellDoes()
+ {
+ // Given a newer-major manifest (or an over-cap body / >2× flavor)
+ // When the import runs
+ // Then it fails closed naming both versions / the cap — no partial write
+ }
+
+ [Fact(Skip = "Pending (T254)")]
+ public void SpectatorIsByteIdenticalWithCatalogUnreachable()
+ {
+ // Given the catalog disabled or unreachable
+ // When spectator payloads are read
+ // Then byte-identical (F103.12 inherited verbatim — F118.5)
+ }
+ }
+}
diff --git a/tests/GenWave.MediaLibrary.Tests/DatabaseFixture.cs b/tests/GenWave.MediaLibrary.Tests/DatabaseFixture.cs
index 866c80aa..f3c5325e 100644
--- a/tests/GenWave.MediaLibrary.Tests/DatabaseFixture.cs
+++ b/tests/GenWave.MediaLibrary.Tests/DatabaseFixture.cs
@@ -1,3 +1,4 @@
+using System.Collections.Frozen;
using System.Diagnostics;
using Dapper;
using Npgsql;
@@ -29,6 +30,29 @@ public sealed class DatabaseFixture : IAsyncLifetime
public NpgsqlDataSource StationDataSource { get; private set; } = null!;
+ ///
+ /// (data_type, is_nullable, column_default) for every column of the station and
+ /// library schemas, snapshotted in right after
+ /// — the one instant that is the PURE db/01+db/06 fresh-init
+ /// world, since db-compose.yaml mounts only those two files as Postgres init scripts. No
+ /// test class has run yet, so nothing here can have been shaped by db/35 (or any other in-place
+ /// migration script) rather than by db/06's own CREATE. Story305_ShowRepository.cs's fresh-init
+ /// facts assert against this snapshot instead of re-running a migration script, which is the only
+ /// way a dropped db/06 mirror of a db/35 column can actually turn a fact red.
+ ///
+ public IReadOnlyDictionary<(string Schema, string Table, string Column), (string DataType, string IsNullable, string? ColumnDefault)> InitialSchema
+ { get; private set; } = new Dictionary<(string, string, string), (string, string, string?)>();
+
+ ///
+ /// The UNIQUE constraint names (pg_constraint.conname, contype = 'u') present on
+ /// each station-schema table at the same fresh-init instant
+ /// captures, keyed by bare table name. A table can carry more than one UNIQUE constraint (e.g.
+ /// station.persona has both name and slug), hence a list per table rather
+ /// than a single name.
+ ///
+ public IReadOnlyDictionary> InitialUniqueConstraints
+ { get; private set; } = new Dictionary>();
+
string composeFile = "";
public async Task InitializeAsync()
@@ -43,6 +67,7 @@ public async Task InitializeAsync()
DataSource = new NpgsqlDataSourceBuilder(ConnectionString).Build();
StationDataSource = new NpgsqlDataSourceBuilder(StationConnectionString).Build();
await WaitForSchemaAsync();
+ await SnapshotInitialSchemaAsync();
}
public async Task DisposeAsync()
@@ -161,6 +186,25 @@ public async Task ResetFontPackAsync()
await cmd.ExecuteNonQueryAsync();
}
+ ///
+ /// Truncate station.show and reset its identity (SPEC F115.1, STORY-305, PLAN T239).
+ /// CASCADE (mirrors 's own remarks): station.segment_schedule's
+ /// show_id FK (db/06, SPEC F114) — ON DELETE RESTRICT though it is — still makes
+ /// Postgres refuse a plain TRUNCATE, since TRUNCATE's own FK check is stricter than any single
+ /// row's ON DELETE action; CASCADE follows it and sweeps that table's rows along with
+ /// station.show's, same as it already does for the persona-referencing tables
+ /// truncates. station.booth_log.show_id/
+ /// library.media.show_id carry no FK (db/35 — history/imaging must outlive the entity), so
+ /// neither is touched by this reset.
+ ///
+ public async Task ResetShowAsync()
+ {
+ await using var conn = await StationDataSource.OpenConnectionAsync();
+ await using var cmd = conn.CreateCommand();
+ cmd.CommandText = "truncate table station.show restart identity cascade";
+ await cmd.ExecuteNonQueryAsync();
+ }
+
async Task WaitForSchemaAsync()
{
for (var attempt = 0; attempt < 30; attempt++)
@@ -182,6 +226,63 @@ async Task WaitForSchemaAsync()
throw new InvalidOperationException("library schema not ready on the test database");
}
+ ///
+ /// Populates and — must run
+ /// only once, immediately after and before any test class gets a
+ /// chance to run a migration script or otherwise mutate the schema (see those properties' own
+ /// remarks for why that instant matters). Reads the station schema over
+ /// and the library schema over — the two
+ /// roles have no cross-schema grants, the same reason QueryColumnAsync/
+ /// QueryLibraryMediaColumnAsync in Story305_ShowRepository.cs split the same way.
+ ///
+ async Task SnapshotInitialSchemaAsync()
+ {
+ var schema = new Dictionary<(string, string, string), (string, string, string?)>();
+
+ await AddColumnsAsync(StationDataSource, "station");
+ await AddColumnsAsync(DataSource, "library");
+
+ async Task AddColumnsAsync(NpgsqlDataSource dataSource, string schemaName)
+ {
+ await using var conn = await dataSource.OpenConnectionAsync();
+ var rows = await conn.QueryAsync<(string TableName, string ColumnName, string DataType, string IsNullable, string? ColumnDefault)>(
+ """
+ select table_name, column_name, data_type, is_nullable, column_default
+ from information_schema.columns
+ where table_schema = @schemaName
+ """,
+ new { schemaName });
+ foreach (var row in rows)
+ schema[(schemaName, row.TableName, row.ColumnName)] = (row.DataType, row.IsNullable, row.ColumnDefault);
+ }
+
+ // Frozen so no spec can mutate the shared snapshot via a cast back to a mutable dictionary type.
+ InitialSchema = schema.ToFrozenDictionary();
+
+ var uniqueConstraints = new Dictionary>();
+ await using (var conn = await StationDataSource.OpenConnectionAsync())
+ {
+ var rows = await conn.QueryAsync<(string TableName, string ConstraintName)>(
+ """
+ select c.relname as table_name, con.conname as constraint_name
+ from pg_constraint con
+ join pg_class c on c.oid = con.conrelid
+ join pg_namespace n on n.oid = c.relnamespace
+ where con.contype = 'u' and n.nspname = 'station'
+ """);
+ foreach (var row in rows)
+ {
+ if (!uniqueConstraints.TryGetValue(row.TableName, out var names))
+ uniqueConstraints[row.TableName] = names = [];
+ names.Add(row.ConstraintName);
+ }
+ }
+
+ // Each value is a defensive copy (not the live List) so no spec can mutate the shared snapshot.
+ InitialUniqueConstraints = uniqueConstraints.ToDictionary(
+ entry => entry.Key, entry => (IReadOnlyList)entry.Value.ToArray());
+ }
+
///
/// Absolute path to the repository root (the directory containing GenWave.sln).
/// Populated during ; use to resolve files like db/*.sh.
diff --git a/tests/GenWave.MediaLibrary.Tests/Specs/Gh099_SafeContentRatingRepository.cs b/tests/GenWave.MediaLibrary.Tests/Specs/Gh099_SafeContentRatingRepository.cs
index f7ab2e88..ec63d5e9 100644
--- a/tests/GenWave.MediaLibrary.Tests/Specs/Gh099_SafeContentRatingRepository.cs
+++ b/tests/GenWave.MediaLibrary.Tests/Specs/Gh099_SafeContentRatingRepository.cs
@@ -227,8 +227,10 @@ public async Task AnAppendedMediaIdReadsBackThroughBothReadSeams()
var repo = Repo(db);
await repo.AppendAsync(
- "track-started", "Started 'Song' by Someone", personaId: null, artist: "Someone",
- pick: null, mediaId: 42, segmentKind: null, CancellationToken.None);
+ new BoothLogAppendRequest(
+ "track-started", "Started 'Song' by Someone", PersonaId: null, Artist: "Someone",
+ Pick: null, MediaId: 42, SegmentKind: null, ShowId: null),
+ CancellationToken.None);
var page = await repo.ReadAsync(before: null, take: 1, CancellationToken.None);
var entry = Assert.Single(page.Entries);
@@ -243,8 +245,10 @@ public async Task AnUnstampedRowAnswersNullFromBothReadSeams()
var repo = Repo(db);
await repo.AppendAsync(
- "patter-aired", "Patter aired (station-id)", personaId: null, artist: null,
- pick: null, mediaId: null, segmentKind: null, CancellationToken.None);
+ new BoothLogAppendRequest(
+ "patter-aired", "Patter aired (station-id)", PersonaId: null, Artist: null,
+ Pick: null, MediaId: null, SegmentKind: null, ShowId: null),
+ CancellationToken.None);
var page = await repo.ReadAsync(before: null, take: 1, CancellationToken.None);
var entry = Assert.Single(page.Entries);
diff --git a/tests/GenWave.MediaLibrary.Tests/Specs/Gh255_ScheduleVersionGuard.cs b/tests/GenWave.MediaLibrary.Tests/Specs/Gh255_ScheduleVersionGuard.cs
index 5861186b..836daa6c 100644
--- a/tests/GenWave.MediaLibrary.Tests/Specs/Gh255_ScheduleVersionGuard.cs
+++ b/tests/GenWave.MediaLibrary.Tests/Specs/Gh255_ScheduleVersionGuard.cs
@@ -16,8 +16,20 @@ namespace GenWave.MediaLibrary.Tests.Specs;
public static class FeatureScheduleVersionGuard
{
- static ScheduleRepository Repo(DatabaseFixture db) =>
- new(new Lazy(() => db.StationDataSource));
+ ///
+ /// PLAN T241 review: mirrors Story240_ScheduleStore.cs's own identically-named helper — see its
+ /// own remarks in full. 's load query now LEFT JOINs
+ /// station.show keyed on segment_schedule.show_id (SPEC F116.1), so this file also
+ /// needs BOTH idempotent migration scripts (db/33 then db/35) re-run before every fact's own
+ /// connection, regardless of xUnit's class scheduling against Story242_UpgradeChangesNothing.cs's
+ /// and Story305_ShowRepository.cs's own in-place scenarios.
+ ///
+ static ScheduleRepository Repo(DatabaseFixture db)
+ {
+ db.RunFileInContainer(Path.Combine(db.RepoRoot, "db", "33-show-and-segment-kind-migration.sh"));
+ db.RunFileInContainer(Path.Combine(db.RepoRoot, "db", "35-show-identity-migration.sh"));
+ return new(new Lazy(() => db.StationDataSource));
+ }
static ScheduleSegment MusicOnly(DayOfWeek day, int start, int end) =>
new(null, day, start, end, PersonaId: null, Genres: null, EnergyMin: null, EnergyMax: null);
diff --git a/tests/GenWave.MediaLibrary.Tests/Specs/Story195_BoothLogStore.cs b/tests/GenWave.MediaLibrary.Tests/Specs/Story195_BoothLogStore.cs
index 6e7f6699..a190f762 100644
--- a/tests/GenWave.MediaLibrary.Tests/Specs/Story195_BoothLogStore.cs
+++ b/tests/GenWave.MediaLibrary.Tests/Specs/Story195_BoothLogStore.cs
@@ -140,7 +140,11 @@ public async Task Insert_evicts_rows_older_than_the_retention_window()
var store = Store(db, retentionDays: 1);
// When a new row is inserted...
- await store.AppendAsync("track-started", "Started 'New Song' by New Artist", personaId: null, artist: null, pick: null, mediaId: null, segmentKind: null, ct: CancellationToken.None);
+ await store.AppendAsync(
+ new BoothLogAppendRequest(
+ "track-started", "Started 'New Song' by New Artist", PersonaId: null, Artist: null,
+ Pick: null, MediaId: null, SegmentKind: null, ShowId: null),
+ CancellationToken.None);
// Then the expired rows are gone and only the new row remains — the table stays bounded.
var rows = await AllRowsAsync(db);
diff --git a/tests/GenWave.MediaLibrary.Tests/Specs/Story215_TasteAccrualRepository.cs b/tests/GenWave.MediaLibrary.Tests/Specs/Story215_TasteAccrualRepository.cs
index a467f13d..64cddf3a 100644
--- a/tests/GenWave.MediaLibrary.Tests/Specs/Story215_TasteAccrualRepository.cs
+++ b/tests/GenWave.MediaLibrary.Tests/Specs/Story215_TasteAccrualRepository.cs
@@ -51,7 +51,11 @@ static async Task CreatePersonaAsync(DatabaseFixture db, string name)
static async Task SeedTrackRowAsync(DatabaseFixture db, long? personaId, string? artist)
{
var repo = BoothLogRepo(db);
- await repo.AppendAsync("track-started", "Started 'Song' by Someone", personaId, artist, pick: null, mediaId: null, segmentKind: null, CancellationToken.None);
+ await repo.AppendAsync(
+ new BoothLogAppendRequest(
+ "track-started", "Started 'Song' by Someone", personaId, artist, Pick: null, MediaId: null,
+ SegmentKind: null, ShowId: null),
+ CancellationToken.None);
var page = await repo.ReadAsync(before: null, take: 1, CancellationToken.None);
return page.Entries.Single().Id;
}
diff --git a/tests/GenWave.MediaLibrary.Tests/Specs/Story240_ScheduleStore.cs b/tests/GenWave.MediaLibrary.Tests/Specs/Story240_ScheduleStore.cs
index 0b15567c..3664955b 100644
--- a/tests/GenWave.MediaLibrary.Tests/Specs/Story240_ScheduleStore.cs
+++ b/tests/GenWave.MediaLibrary.Tests/Specs/Story240_ScheduleStore.cs
@@ -21,7 +21,29 @@ public static class FeatureScheduleStore
// Helpers
// ---------------------------------------------------------------------
- static ScheduleRepository Repo(DatabaseFixture db) => new(new Lazy(() => db.StationDataSource));
+ ///
+ /// PLAN T241 review: 's own load query now LEFT JOINs
+ /// station.show keyed on segment_schedule.show_id (SPEC F116.1), so every fact in
+ /// this file gained an implicit dependency on BOTH columns existing that it never had before. This
+ /// class carries no ordering guarantee against two sibling files' own in-place scenarios in the
+ /// same DatabaseCollection: Story242_UpgradeChangesNothing.cs's several scenarios drop
+ /// station.segment_schedule and rebuild it via db/27 ALONE (predates show_id
+ /// entirely — see that file's own header, which already documents this exact hazard for
+ /// Story304_AiredKindStamp.cs and names db/33 as the guard); Story305_ShowRepository.cs's own
+ /// in-place scenario drops station.show's db/35 columns (no tagline/flavor).
+ /// Running BOTH idempotent migration scripts here, in order — db/33 first (restores
+ /// segment_schedule.show_id and a bare station.show if either is missing), db/35
+ /// second (widens station.show to its full identity shape) — right before the repository's
+ /// own connection is even built, makes every fact in this file self-sufficient regardless of
+ /// xUnit's class scheduling, mirroring Story304's own "(re)running db/33 in its own Arrange before
+ /// every assertion" guard and Story305_ShowRepository.cs's own db/35 guard, combined.
+ ///
+ static ScheduleRepository Repo(DatabaseFixture db)
+ {
+ db.RunFileInContainer(Path.Combine(db.RepoRoot, "db", "33-show-and-segment-kind-migration.sh"));
+ db.RunFileInContainer(Path.Combine(db.RepoRoot, "db", "35-show-identity-migration.sh"));
+ return new ScheduleRepository(new Lazy(() => db.StationDataSource));
+ }
static ScheduleSegment MusicOnly(DayOfWeek day, int start, int end) =>
new(null, day, start, end, PersonaId: null, Genres: null, EnergyMin: null, EnergyMax: null);
@@ -143,6 +165,101 @@ await repo.ReplaceWeekAsync(
}
}
+ // ---------------------------------------------------------------------
+ // HAPPY PATH — show identity rides the load, dormant bundle columns unread (SPEC F115.2, F116.1,
+ // STORY-306, PLAN T241)
+ //
+ // ARCHITECTURE.md's own guidance for this pin: "put the live pin where it can be real ... extend
+ // the schedule repository's spec: populate dormant columns via SQL, reload the week, assert the
+ // loaded model is identical." ScheduleRepository has no writer for show_id/station.show at all
+ // (T243/T239 are the write-side seams), so both are populated by direct SQL here, mirroring this
+ // file's own ScenarioPersonaForeignKeyHasTeeth idiom.
+ // ---------------------------------------------------------------------
+
+ [Collection(DatabaseCollection.Name)]
+ [Trait("Category", "Integration")]
+ public sealed class ScenarioShowIdentityRidesTheLoad(DatabaseFixture db)
+ {
+ [Fact]
+ public async Task HandPopulatingShowPersonaIdAndEnvelopeChangesNothingAboutTheLoadedWeek()
+ {
+ // Given a show (tagline + flavor set) referenced by one schedule block naming its OWN
+ // block-level persona, and a SECOND real persona standing by to hand-populate the show's
+ // own DORMANT persona_id column with.
+ await db.ResetShowAsync();
+ await db.ResetScheduleAsync();
+ var blockPersonaId = await ScheduleTestPersonas.InsertAsync(db, "Block DJ");
+ var dormantShowPersonaId = await ScheduleTestPersonas.InsertAsync(db, "Dormant Show DJ");
+
+ long showId;
+ await using (var conn = await db.StationDataSource.OpenConnectionAsync())
+ {
+ showId = await conn.ExecuteScalarAsync(
+ """
+ insert into station.show (name, slug, tagline, flavor)
+ values ('Night Moves', 'night-moves', 'Late-night deep cuts', 'moody, sparse')
+ returning id
+ """);
+ await conn.ExecuteAsync(
+ """
+ insert into station.segment_schedule (day_of_week, start_minute, end_minute, persona_id, show_id)
+ values (1, 540, 720, @blockPersonaId, @showId)
+ """,
+ new { blockPersonaId, showId });
+ }
+ var repo = Repo(db);
+
+ // When the week is loaded BEFORE the dormant columns are ever touched...
+ var before = await repo.LoadWeekAsync(CancellationToken.None);
+
+ // ...then station.show's own DORMANT persona_id/envelope columns are hand-populated
+ // directly (SPEC F115.2's pin — ShowRepository has no parameter for either; a raw UPDATE
+ // is the only way to even attempt setting them)...
+ await using (var conn = await db.StationDataSource.OpenConnectionAsync())
+ await conn.ExecuteAsync(
+ """
+ update station.show
+ set persona_id = @dormantShowPersonaId,
+ envelope = '{"genres": ["Jazz"], "energyMin": 0.1, "energyMax": 0.9}'::jsonb
+ where id = @showId
+ """,
+ new { dormantShowPersonaId, showId });
+
+ // ...and the week is loaded again.
+ var after = await repo.LoadWeekAsync(CancellationToken.None);
+
+ // Then the loaded model is IDENTICAL — hand-populating the dormant bundle columns changed
+ // NO v1 behavior (sequence-compared, not whole-snapshot: ScheduleWeekSnapshot's own
+ // compiler-generated Equals compares its Segments list by reference, the same
+ // Genres-by-reference gotcha Story241_StationFollowsTheClock.cs's own facts document).
+ Assert.Equal(before.Segments, after.Segments);
+
+ // And the loaded show identity itself carries only the four public fields (SPEC F115.2's
+ // pin enforced by ShowSummary's own shape — there is no PersonaId/Envelope member to have
+ // picked either dormant value up even if the query tried).
+ var block = Assert.Single(after.Segments);
+ Assert.Equal(blockPersonaId, block.PersonaId);
+ Assert.NotNull(block.Show);
+ Assert.Equal(new ShowSummary(showId, "Night Moves", "Late-night deep cuts", "moody, sparse"), block.Show);
+ }
+
+ [Fact]
+ public async Task UnnamedBlockLoadsWithNoShow()
+ {
+ // Given a schedule block with no show_id at all
+ await db.ResetScheduleAsync();
+ var repo = Repo(db);
+
+ await repo.ReplaceWeekAsync([MusicOnly(DayOfWeek.Monday, 0, 1440)], expectedVersion: null, CancellationToken.None);
+
+ // When the week is loaded
+ var snapshot = await repo.LoadWeekAsync(CancellationToken.None);
+
+ // Then the block's own Show is null — the LEFT JOIN finds no matching station.show row
+ Assert.Null(Assert.Single(snapshot.Segments).Show);
+ }
+ }
+
// ---------------------------------------------------------------------
// HAPPY PATH — the whole-week replace is atomic (SPEC F91.8)
// ---------------------------------------------------------------------
diff --git a/tests/GenWave.MediaLibrary.Tests/Specs/Story247_BenchTransition.cs b/tests/GenWave.MediaLibrary.Tests/Specs/Story247_BenchTransition.cs
index 050a98c5..c76fc53e 100644
--- a/tests/GenWave.MediaLibrary.Tests/Specs/Story247_BenchTransition.cs
+++ b/tests/GenWave.MediaLibrary.Tests/Specs/Story247_BenchTransition.cs
@@ -31,13 +31,29 @@ public sealed class ScenarioBenchingByUnpainting(DatabaseFixture db)
// Given a DJ scheduled in one slot, When that slot is removed by replacing the week without
// it (ScheduleRepository.ReplaceWeekAsync — the store beneath T122's PUT /api/schedule).
+ ///
+ /// PLAN T241 review: mirrors Story240_ScheduleStore.cs's own identically-purposed helper —
+ /// see its own remarks in full. 's load query now LEFT JOINs
+ /// station.show keyed on segment_schedule.show_id (SPEC F116.1), so this file
+ /// also needs BOTH idempotent migration scripts (db/33 then db/35) re-run before every fact's
+ /// own connection, regardless of xUnit's class scheduling against
+ /// Story242_UpgradeChangesNothing.cs's and Story305_ShowRepository.cs's own in-place
+ /// scenarios.
+ ///
+ static ScheduleRepository ScheduleRepo(DatabaseFixture db)
+ {
+ db.RunFileInContainer(Path.Combine(db.RepoRoot, "db", "33-show-and-segment-kind-migration.sh"));
+ db.RunFileInContainer(Path.Combine(db.RepoRoot, "db", "35-show-identity-migration.sh"));
+ return new(new Lazy(() => db.StationDataSource));
+ }
+
[Fact]
public async Task PersonaRecordIsUntouched()
{
await db.ResetStationAsync();
await db.ResetScheduleAsync();
var personaId = await ScheduleTestPersonas.InsertAsync(db, "Bench Transition DJ");
- var scheduleRepo = new ScheduleRepository(new Lazy(() => db.StationDataSource));
+ var scheduleRepo = ScheduleRepo(db);
var personaRepo = new PersonaRepository(new Lazy(() => db.StationDataSource));
await scheduleRepo.ReplaceWeekAsync(
[new ScheduleSegment(null, DayOfWeek.Monday, 0, 600, personaId, Genres: null, EnergyMin: null, EnergyMax: null)],
@@ -60,7 +76,7 @@ public async Task PersonaNoLongerAppearsInAnyScheduleRow()
await db.ResetStationAsync();
await db.ResetScheduleAsync();
var personaId = await ScheduleTestPersonas.InsertAsync(db, "Bench Transition DJ");
- var scheduleRepo = new ScheduleRepository(new Lazy(() => db.StationDataSource));
+ var scheduleRepo = ScheduleRepo(db);
await scheduleRepo.ReplaceWeekAsync(
[new ScheduleSegment(null, DayOfWeek.Monday, 0, 600, personaId, Genres: null, EnergyMin: null, EnergyMax: null)],
expectedVersion: null, CancellationToken.None);
diff --git a/tests/GenWave.MediaLibrary.Tests/Specs/Story305_ShowRepository.cs b/tests/GenWave.MediaLibrary.Tests/Specs/Story305_ShowRepository.cs
new file mode 100644
index 00000000..89d85f11
--- /dev/null
+++ b/tests/GenWave.MediaLibrary.Tests/Specs/Story305_ShowRepository.cs
@@ -0,0 +1,1010 @@
+// STORY-305 — The show entity & API (F115.1, F115.4, F115.5) — repository half
+//
+// PLAN T238 (this file's schema half — the T238 acceptance criterion "fresh init and in-place
+// upgrade converge; dormant columns exist") is REAL, live-Postgres coverage (Category=Integration
+// via DatabaseCollection): a fresh-init scenario (ScenarioTheIdentityPackageFlowsEndToEnd) and an
+// in-place scenario (ScenarioMigrationAddsTheColumnsInPlace, proving db/35's own DDL directly by
+// dropping the nine T238 columns and re-running the migration script), mirroring
+// Story304_AiredKindStamp.cs's own two-scenario shape.
+//
+// The fresh-init facts assert against DatabaseFixture.InitialSchema/InitialUniqueConstraints — a
+// snapshot the fixture takes once, immediately after Postgres finishes running db/01+db/06 (the only
+// two files db-compose.yaml mounts as init scripts) and before any spec class runs. That is the one
+// instant a fact can observe db/06's own CREATE in isolation from db/35: an earlier revision of this
+// file instead (re-)ran db/35 in each fact's own Arrange, which passed even with the db/06 mirror of
+// db/35 deleted entirely — db/35 itself would then silently supply the columns db/06 failed to,
+// leaving the acceptance word "converge" untested. Because the snapshot is taken once at fixture
+// init, ordering against the in-place scenario below (which drops columns then re-runs db/35) no
+// longer matters either way.
+//
+// ScenarioDormantBundleColumns is likewise real: persona_id/envelope exist with no DEFAULT (read from
+// the same snapshot) and read back NULL on an inserted row — the table has no writer anywhere this
+// epic (F115.2).
+//
+// ScenarioAuthoredCrud and ScenarioRejectingInvalidShows are now real too (PLAN T239: the Show domain
+// type, IShowStore, and ShowRepository — GenWave.Core.Domain/Abstractions,
+// GenWave.MediaLibrary.Station). Both are Category=Integration via DatabaseCollection, mirroring the
+// two scenarios above: RunMigrationScript(db) first (the convergence idiom — this file's own live
+// database may have had station.show's T238 columns dropped and re-added mid-suite by
+// ScenarioMigrationAddsTheColumnsInPlace, with no ordering guarantee against it), then
+// DatabaseFixture.ResetShowAsync() for a clean table before every fact. The endpoint half lives in
+// GenWave.Host.Tests/Specs/Story305_ShowsApi.cs (PLAN T240, still pending).
+
+using Dapper;
+using GenWave.Core.Domain;
+using GenWave.MediaLibrary.Catalog;
+using GenWave.MediaLibrary.Station;
+using Npgsql;
+
+namespace GenWave.MediaLibrary.Tests.Specs;
+
+public static class FeatureShowRepository
+{
+ // ---------------------------------------------------------------------
+ // Helpers (T238 schema facts) — mirror Story304_AiredKindStamp's own idioms.
+ // ---------------------------------------------------------------------
+
+ /// Returns (data_type, is_nullable) for the named column on the given station table,
+ /// or null when the column does not exist. Mirrors Story304's own QueryColumnAsync helper.
+ static async Task<(string DataType, string IsNullable)?> QueryColumnAsync(
+ DatabaseFixture db, string table, string column)
+ {
+ await using var conn = await db.StationDataSource.OpenConnectionAsync();
+ var row = await conn.QuerySingleOrDefaultAsync<(string data_type, string is_nullable)>(
+ """
+ select data_type, is_nullable from information_schema.columns
+ where table_schema = 'station' and table_name = @table and column_name = @column
+ """,
+ new { table, column });
+
+ return row == default ? null : (row.data_type, row.is_nullable);
+ }
+
+ /// Returns (data_type, is_nullable) for the named column on library.media, or null when
+ /// the column does not exist — the library_svc-rooted counterpart to ,
+ /// needed because library.media.show_id crosses the db/22 schema-role boundary (Story030's own
+ /// QueryColumnAsync helper is the precedent for reading library schema this way).
+ static async Task<(string DataType, string IsNullable)?> QueryLibraryMediaColumnAsync(
+ DatabaseFixture db, string column)
+ {
+ await using var conn = await db.DataSource.OpenConnectionAsync();
+ var row = await conn.QuerySingleOrDefaultAsync<(string data_type, string is_nullable)>(
+ """
+ select data_type, is_nullable from information_schema.columns
+ where table_schema = 'library' and table_name = 'media' and column_name = @column
+ """,
+ new { column });
+
+ return row == default ? null : (row.data_type, row.is_nullable);
+ }
+
+ /// The name(s) of the UNIQUE constraint(s) on the given fully-qualified station table —
+ /// used by the in-place scenario below to pin that db/35's ALTER lands the show.slug constraint on
+ /// the same literal name (show_slug_key) the fresh-init snapshot observes from db/06's
+ /// CREATE. QuerySingleOrDefaultAsync's own single-row contract additionally proves exactly one
+ /// UNIQUE constraint exists on the table — this says nothing about whether either name is
+ /// auto-generated, since Postgres's own auto-generated name for a bare UNIQUE here would in fact
+ /// also be show_slug_key.
+ static async Task QueryUniqueConstraintNameAsync(DatabaseFixture db, string qualifiedTable)
+ {
+ await using var conn = await db.StationDataSource.OpenConnectionAsync();
+ return await conn.QuerySingleOrDefaultAsync(
+ "select conname from pg_constraint where conrelid = @table::regclass and contype = 'u'",
+ new { table = qualifiedTable });
+ }
+
+ /// Runs db/35-show-identity-migration.sh against the test database via the fixture.
+ /// Mirrors Story304's own RunMigrationScript helper. Safe to call unconditionally — the script is
+ /// idempotent (ADD COLUMN IF NOT EXISTS). Used only by the in-place scenario below — the fresh-init
+ /// facts assert against DatabaseFixture.InitialSchema instead (see file header).
+ static void RunMigrationScript(DatabaseFixture db) =>
+ db.RunFileInContainer(Path.Combine(db.RepoRoot, "db", "35-show-identity-migration.sh"));
+
+ /// (data_type, is_nullable, column_default) for the named station-schema column, read
+ /// from the fixture's fresh-init snapshot () rather
+ /// than a live query — see that property's own remarks and this file's header for why that is the
+ /// only way a dropped db/06 mirror of a db/35 column can actually turn a fresh-init fact red.
+ /// Fails the calling fact immediately if the column is missing from the snapshot.
+ static (string DataType, string IsNullable, string? ColumnDefault) InitialStationColumn(
+ DatabaseFixture db, string table, string column)
+ {
+ var found = db.InitialSchema.TryGetValue(("station", table, column), out var value);
+ Assert.True(found, $"station.{table}.{column} missing from the fresh-init schema snapshot");
+ return value;
+ }
+
+ /// Same as but for library.media, crossing the db/22
+ /// schema-role boundary the same way already does for
+ /// the in-place scenario.
+ static (string DataType, string IsNullable, string? ColumnDefault) InitialLibraryMediaColumn(
+ DatabaseFixture db, string column)
+ {
+ var found = db.InitialSchema.TryGetValue(("library", "media", column), out var value);
+ Assert.True(found, $"library.media.{column} missing from the fresh-init schema snapshot");
+ return value;
+ }
+
+ /// The UNIQUE constraint names on the given bare station table name at the same
+ /// fresh-init instant reads from — empty when the table carries
+ /// none (mirrors 's own remarks on why this
+ /// is a list rather than a single name).
+ static IReadOnlyList InitialUniqueConstraintNames(DatabaseFixture db, string table) =>
+ db.InitialUniqueConstraints.GetValueOrDefault(table, []);
+
+ /// The T239 repository under spec, wired the same "Lazy over the fixture's own
+ /// StationDataSource" way Story118_PersonaStorage.cs's own Repo helper wires
+ /// PersonaRepository.
+ static ShowRepository Repo(DatabaseFixture db) => new(new Lazy(() => db.StationDataSource));
+
+ /// The show delete guard's own detail-read repository (PLAN T240 review — this method
+ /// carried zero live-DB coverage before 's two round-trip
+ /// facts below), wired the same "Lazy over the fixture's own StationDataSource" way as
+ /// ; mirrors Story240_ScheduleStore.cs's own identically-shaped helper.
+ static ScheduleRepository ScheduleRepo(DatabaseFixture db) => new(new Lazy(() => db.StationDataSource));
+
+ // ---------------------------------------------------------------------
+ // HAPPY PATH — fresh init (db/06's mirror of db/35)
+ // ---------------------------------------------------------------------
+
+ [Collection(DatabaseCollection.Name)]
+ [Trait("Category", "Integration")]
+ public sealed class ScenarioTheIdentityPackageFlowsEndToEnd(DatabaseFixture db)
+ {
+ // Every fact below reads DatabaseFixture.InitialSchema/InitialUniqueConstraints — the
+ // snapshot taken once, at fixture init, of the PURE db/01+db/06 fresh-init world (see file
+ // header). No fact here ever runs db/35: that is precisely what makes dropping a column from
+ // db/06's own CREATE turn the matching fact red.
+
+ [Fact]
+ public void ShowSlugColumnExistsAsTextNotNull()
+ {
+ var column = InitialStationColumn(db, "show", "slug");
+
+ Assert.Equal("text", column.DataType);
+ Assert.Equal("NO", column.IsNullable);
+ }
+
+ [Fact]
+ public void ShowTaglineColumnExistsAsTextNullable()
+ {
+ var column = InitialStationColumn(db, "show", "tagline");
+
+ Assert.Equal("text", column.DataType);
+ Assert.Equal("YES", column.IsNullable);
+ }
+
+ [Fact]
+ public void ShowFlavorColumnExistsAsTextNullable()
+ {
+ var column = InitialStationColumn(db, "show", "flavor");
+
+ Assert.Equal("text", column.DataType);
+ Assert.Equal("YES", column.IsNullable);
+ }
+
+ [Fact]
+ public void ShowImportedFromColumnExistsAsTextNullable()
+ {
+ var column = InitialStationColumn(db, "show", "imported_from");
+
+ Assert.Equal("text", column.DataType);
+ Assert.Equal("YES", column.IsNullable);
+ }
+
+ [Fact]
+ public void ShowImportedAtColumnExistsAsTimestamptzNullable()
+ {
+ var column = InitialStationColumn(db, "show", "imported_at");
+
+ Assert.Equal("timestamp with time zone", column.DataType);
+ Assert.Equal("YES", column.IsNullable);
+ }
+
+ [Fact]
+ public void ShowPersonaIdColumnExistsAsIntegerNullable()
+ {
+ var column = InitialStationColumn(db, "show", "persona_id");
+
+ Assert.Equal("integer", column.DataType);
+ Assert.Equal("YES", column.IsNullable);
+ }
+
+ [Fact]
+ public void ShowEnvelopeColumnExistsAsJsonbNullable()
+ {
+ var column = InitialStationColumn(db, "show", "envelope");
+
+ Assert.Equal("jsonb", column.DataType);
+ Assert.Equal("YES", column.IsNullable);
+ }
+
+ [Fact]
+ public void BoothLogShowIdColumnExistsAsIntegerNullable()
+ {
+ // The air-time stamp (F121.1) — deliberately NO FK, history must outlive the entity.
+ var column = InitialStationColumn(db, "booth_log", "show_id");
+
+ Assert.Equal("integer", column.DataType);
+ Assert.Equal("YES", column.IsNullable);
+ }
+
+ [Fact]
+ public void LibraryMediaShowIdColumnExistsAsIntegerNullable()
+ {
+ // Crosses the db/22 schema-role boundary — read from the library half of the snapshot,
+ // not the station half (the two roles have no grants into each other's schema).
+ var column = InitialLibraryMediaColumn(db, "show_id");
+
+ Assert.Equal("integer", column.DataType);
+ Assert.Equal("YES", column.IsNullable);
+ }
+
+ [Fact]
+ public void ShowSlugUniqueConstraintIsNamedShowSlugKey()
+ {
+ // db/06's fresh-init CREATE names this constraint explicitly (CONSTRAINT show_slug_key
+ // UNIQUE); the list-equality assertion below also proves exactly one UNIQUE constraint
+ // exists on the table.
+ var constraintNames = InitialUniqueConstraintNames(db, "show");
+
+ Assert.Equal(["show_slug_key"], constraintNames);
+ }
+ }
+
+ // ---------------------------------------------------------------------
+ // HAPPY PATH — in-place migration (db/35-show-identity-migration.sh)
+ // ---------------------------------------------------------------------
+
+ [Collection(DatabaseCollection.Name)]
+ [Trait("Category", "Integration")]
+ public sealed class ScenarioMigrationAddsTheColumnsInPlace(DatabaseFixture db)
+ {
+ [Fact]
+ public async Task MigrationAddsTheSevenShowColumnsAndBothShowIdColumnsInPlace()
+ {
+ // T239 review: station.show shipped dormant through T238 (never a single row, any test
+ // order), but PLAN T239's own repository specs (ScenarioAuthoredCrud/
+ // ScenarioRejectingInvalidShows below) now insert real rows and leave them behind for
+ // whichever sibling class runs next — this class carries no ordering guarantee against
+ // them. Re-adding `slug text not null` below with no DEFAULT fails outright if a leftover
+ // row exists at that instant (NOT NULL against an unbackfillable new column), so clear the
+ // table first — this scenario's own DROP COLUMN a few lines down removes any pre-existing
+ // slug value anyway, so a row surviving this reset would only ever break the re-ADD, never
+ // help prove anything about it.
+ await db.ResetShowAsync();
+
+ // Simulate a pre-T238 database by dropping the seven station.show columns db/35 adds
+ // (DROP COLUMN also drops the show_slug_key UNIQUE constraint the slug column carries)
+ // plus the two show_id stamp columns it adds on the other two tables.
+ await using (var conn = await db.StationDataSource.OpenConnectionAsync())
+ {
+ await conn.ExecuteAsync(
+ """
+ alter table station.show
+ drop column if exists slug,
+ drop column if exists tagline,
+ drop column if exists flavor,
+ drop column if exists imported_from,
+ drop column if exists imported_at,
+ drop column if exists persona_id,
+ drop column if exists envelope
+ """);
+ await conn.ExecuteAsync("alter table station.booth_log drop column if exists show_id");
+ }
+ await using (var conn = await db.DataSource.OpenConnectionAsync())
+ await conn.ExecuteAsync("alter table library.media drop column if exists show_id");
+
+ Assert.Null(await QueryColumnAsync(db, "show", "slug"));
+ Assert.Null(await QueryColumnAsync(db, "show", "tagline"));
+ Assert.Null(await QueryColumnAsync(db, "show", "flavor"));
+ Assert.Null(await QueryColumnAsync(db, "show", "imported_from"));
+ Assert.Null(await QueryColumnAsync(db, "show", "imported_at"));
+ Assert.Null(await QueryColumnAsync(db, "show", "persona_id"));
+ Assert.Null(await QueryColumnAsync(db, "show", "envelope"));
+ Assert.Null(await QueryColumnAsync(db, "booth_log", "show_id"));
+ Assert.Null(await QueryLibraryMediaColumnAsync(db, "show_id"));
+
+ RunMigrationScript(db);
+
+ var slug = await QueryColumnAsync(db, "show", "slug");
+ Assert.NotNull(slug);
+ Assert.Equal("text", slug.Value.DataType);
+ Assert.Equal("NO", slug.Value.IsNullable);
+
+ var tagline = await QueryColumnAsync(db, "show", "tagline");
+ Assert.NotNull(tagline);
+ Assert.Equal("text", tagline.Value.DataType);
+ Assert.Equal("YES", tagline.Value.IsNullable);
+
+ var flavor = await QueryColumnAsync(db, "show", "flavor");
+ Assert.NotNull(flavor);
+ Assert.Equal("text", flavor.Value.DataType);
+ Assert.Equal("YES", flavor.Value.IsNullable);
+
+ var importedFrom = await QueryColumnAsync(db, "show", "imported_from");
+ Assert.NotNull(importedFrom);
+ Assert.Equal("text", importedFrom.Value.DataType);
+ Assert.Equal("YES", importedFrom.Value.IsNullable);
+
+ var importedAt = await QueryColumnAsync(db, "show", "imported_at");
+ Assert.NotNull(importedAt);
+ Assert.Equal("timestamp with time zone", importedAt.Value.DataType);
+ Assert.Equal("YES", importedAt.Value.IsNullable);
+
+ var personaId = await QueryColumnAsync(db, "show", "persona_id");
+ Assert.NotNull(personaId);
+ Assert.Equal("integer", personaId.Value.DataType);
+ Assert.Equal("YES", personaId.Value.IsNullable);
+
+ var envelope = await QueryColumnAsync(db, "show", "envelope");
+ Assert.NotNull(envelope);
+ Assert.Equal("jsonb", envelope.Value.DataType);
+ Assert.Equal("YES", envelope.Value.IsNullable);
+
+ var boothLogShowId = await QueryColumnAsync(db, "booth_log", "show_id");
+ Assert.NotNull(boothLogShowId);
+ Assert.Equal("integer", boothLogShowId.Value.DataType);
+ Assert.Equal("YES", boothLogShowId.Value.IsNullable);
+
+ var libraryMediaShowId = await QueryLibraryMediaColumnAsync(db, "show_id");
+ Assert.NotNull(libraryMediaShowId);
+ Assert.Equal("integer", libraryMediaShowId.Value.DataType);
+ Assert.Equal("YES", libraryMediaShowId.Value.IsNullable);
+
+ // The convergence guard (see file header): the ALTER path lands on the identical literal
+ // constraint name (show_slug_key) the fresh-init snapshot observes from db/06's CREATE
+ // path. QueryUniqueConstraintNameAsync's own single-row contract additionally proves
+ // exactly one UNIQUE constraint exists on the table.
+ var constraintName = await QueryUniqueConstraintNameAsync(db, "station.show");
+ Assert.Equal("show_slug_key", constraintName);
+ }
+ }
+
+ // ---------------------------------------------------------------------
+ // T239 — repository CRUD, now real (Integration)
+ // ---------------------------------------------------------------------
+
+ [Collection(DatabaseCollection.Name)]
+ [Trait("Category", "Integration")]
+ public sealed class ScenarioAuthoredCrud(DatabaseFixture db)
+ {
+ [Fact]
+ public async Task RoundTripsEveryField()
+ {
+ // Given an authored show "Night Moves" (tagline + flavor within budgets)
+ RunMigrationScript(db);
+ await db.ResetShowAsync();
+ var repo = Repo(db);
+ var created = Assert.IsType(await repo.CreateAsync(
+ new ShowDraft("Night Moves", "Late-night deep cuts", "moody, sparse, past midnight"),
+ CancellationToken.None));
+
+ // When it is created, edited, and re-read through the repository
+ var edited = Assert.IsType(await repo.UpdateAsync(
+ created.Show.Id,
+ new ShowDraft("Night Moves", "Late-night deep cuts, revisited", "moodier, sparser, past 1am"),
+ CancellationToken.None));
+ var read = await repo.GetByIdAsync(created.Show.Id, CancellationToken.None);
+
+ // Then name, slug, tagline, and flavor all round-trip; provenance stays NULL — pinned in
+ // one assertion via full-record equality against the literal expected values (CreatedAt/
+ // UpdatedAt are server-set, so those two are taken from the write results themselves).
+ var expected = new Show(
+ created.Show.Id,
+ "Night Moves",
+ created.Show.Slug,
+ "Late-night deep cuts, revisited",
+ "moodier, sparser, past 1am",
+ ImportedFrom: null,
+ ImportedAt: null,
+ created.Show.CreatedAt,
+ edited.Show.UpdatedAt);
+ Assert.Equal(expected, read);
+ }
+
+ [Fact]
+ public async Task SlugDerivesViaHouseSlugify()
+ {
+ // Given an authored create with name "Night Moves"
+ RunMigrationScript(db);
+ await db.ResetShowAsync();
+ var repo = Repo(db);
+
+ // When the row lands
+ var created = Assert.IsType(
+ await repo.CreateAsync(new ShowDraft("Night Moves"), CancellationToken.None));
+
+ // Then slug is the house Slugify output (the T68 golden-table contract) — compared
+ // against the real LegacyPersonaCardMapper.Slugify call, not a hand-typed literal, so a
+ // future Slugify change can never silently disagree with what CreateAsync actually wrote.
+ Assert.Equal(LegacyPersonaCardMapper.Slugify("Night Moves"), created.Show.Slug);
+ }
+
+ [Fact]
+ public async Task OneDjManyShows()
+ {
+ // Given one persona — deliberately never created here: persona linkage is block-level
+ // (later tasks; F115.2's dormant persona_id stays unread this epic), so this fact stays
+ // honest to its own GWT comment by proving only the show side: three shows authored with
+ // nothing referencing a persona at all.
+ RunMigrationScript(db);
+ await db.ResetShowAsync();
+ var repo = Repo(db);
+
+ // When three shows are authored (later assignable across their blocks), deliberately out
+ // of alphabetical order
+ await repo.CreateAsync(new ShowDraft("Morning Drive"), CancellationToken.None);
+ await repo.CreateAsync(new ShowDraft("Afternoon Session"), CancellationToken.None);
+ await repo.CreateAsync(new ShowDraft("Night Moves"), CancellationToken.None);
+
+ // Then nothing structural objects — shows-per-DJ is unbounded by design (STORY-305 AC3):
+ // all three land, none rejected, and GetAllAsync's own IShowStore contract ("ordered by
+ // name") reads back alphabetically regardless of the create order above.
+ var all = await repo.GetAllAsync(CancellationToken.None);
+ Assert.Equal(["Afternoon Session", "Morning Drive", "Night Moves"], all.Select(s => s.Name));
+ }
+
+ [Fact]
+ public async Task UpdateAdvancesUpdatedAt()
+ {
+ // Given an authored show (mirrors Story118_PersonaStorage's own UpdateAdvancesUpdatedAt)
+ RunMigrationScript(db);
+ await db.ResetShowAsync();
+ var repo = Repo(db);
+ var created = Assert.IsType(
+ await repo.CreateAsync(new ShowDraft("Original Name"), CancellationToken.None));
+
+ // When it is renamed via an edit
+ var updated = Assert.IsType(await repo.UpdateAsync(
+ created.Show.Id, new ShowDraft("Renamed"), CancellationToken.None));
+
+ // Then updated_at moves forward and the read reflects the rename
+ Assert.True(updated.Show.UpdatedAt > created.Show.UpdatedAt);
+ var read = await repo.GetByIdAsync(created.Show.Id, CancellationToken.None);
+ Assert.NotNull(read);
+ Assert.Equal("Renamed", read.Name);
+ }
+
+ [Fact]
+ public async Task BudgetsAcceptAtExactlyTheLimit()
+ {
+ // Given a draft with every field at its exact SPEC F115.1 1× budget — the boundary is
+ // inclusive (BudgetsRejectAtOneTimes, below, proves budget+1 rejects)
+ RunMigrationScript(db);
+ await db.ResetShowAsync();
+ var repo = Repo(db);
+ var draft = new ShowDraft(
+ new string('n', ShowBudgets.NameMaxChars),
+ new string('t', ShowBudgets.TaglineMaxChars),
+ new string('f', ShowBudgets.FlavorMaxChars));
+
+ // When the repository write is attempted
+ var outcome = await repo.CreateAsync(draft, CancellationToken.None);
+
+ // Then it is accepted, not rejected
+ var created = Assert.IsType(outcome);
+ Assert.Equal(draft.Name, created.Show.Name);
+ Assert.Equal(draft.Tagline, created.Show.Tagline);
+ Assert.Equal(draft.Flavor, created.Show.Flavor);
+ }
+
+ [Fact]
+ public async Task EmptyOrWhitespaceTaglineAndFlavorPersistAsNull()
+ {
+ // Given a show authored with a blank tagline and a whitespace-only flavor (create path)
+ RunMigrationScript(db);
+ await db.ResetShowAsync();
+ var repo = Repo(db);
+ var created = Assert.IsType(await repo.CreateAsync(
+ new ShowDraft("Night Moves", Tagline: "", Flavor: " "), CancellationToken.None));
+
+ // Then both coerce to null on create — Show/ShowDraft's own docs promise null "when the
+ // show carries none", never a stray empty/whitespace string
+ Assert.Null(created.Show.Tagline);
+ Assert.Null(created.Show.Flavor);
+
+ // When an edit first gives the show a real tagline/flavor, then a later edit clears both
+ // back to blank/whitespace (update path)
+ var withValues = Assert.IsType(await repo.UpdateAsync(
+ created.Show.Id,
+ new ShowDraft("Night Moves", "Real tagline", "Real flavor"),
+ CancellationToken.None));
+ Assert.Equal("Real tagline", withValues.Show.Tagline);
+ Assert.Equal("Real flavor", withValues.Show.Flavor);
+
+ var cleared = Assert.IsType(await repo.UpdateAsync(
+ created.Show.Id,
+ new ShowDraft("Night Moves", Tagline: "", Flavor: " "),
+ CancellationToken.None));
+
+ // Then the cleared fields read back null through UpdateAsync too, never the empty/
+ // whitespace string verbatim
+ Assert.Null(cleared.Show.Tagline);
+ Assert.Null(cleared.Show.Flavor);
+ var read = await repo.GetByIdAsync(created.Show.Id, CancellationToken.None);
+ Assert.NotNull(read);
+ Assert.Null(read.Tagline);
+ Assert.Null(read.Flavor);
+ }
+ }
+
+ // ---------------------------------------------------------------------
+ // T239 — GetBySlugAsync (Integration)
+ // ---------------------------------------------------------------------
+
+ [Collection(DatabaseCollection.Name)]
+ [Trait("Category", "Integration")]
+ public sealed class ScenarioGetBySlugAsync(DatabaseFixture db)
+ {
+ [Fact]
+ public async Task ReturnsTheShowWhenTheSlugExists()
+ {
+ // Given an authored show
+ RunMigrationScript(db);
+ await db.ResetShowAsync();
+ var repo = Repo(db);
+ var created = Assert.IsType(
+ await repo.CreateAsync(new ShowDraft("Night Moves"), CancellationToken.None));
+
+ // When it is looked up by its derived slug
+ var read = await repo.GetBySlugAsync(created.Show.Slug, CancellationToken.None);
+
+ // Then the same row comes back
+ Assert.Equal(created.Show, read);
+ }
+
+ [Fact]
+ public async Task ReturnsNullWhenTheSlugIsUnknown()
+ {
+ // Given an empty table
+ RunMigrationScript(db);
+ await db.ResetShowAsync();
+ var repo = Repo(db);
+
+ // When a slug naming no row is looked up
+ var read = await repo.GetBySlugAsync("no-such-show", CancellationToken.None);
+
+ // Then it misses cleanly
+ Assert.Null(read);
+ }
+ }
+
+ // ---------------------------------------------------------------------
+ // T239 — DeleteAsync (Integration)
+ // ---------------------------------------------------------------------
+
+ [Collection(DatabaseCollection.Name)]
+ [Trait("Category", "Integration")]
+ public sealed class ScenarioDeleteAsync(DatabaseFixture db)
+ {
+ [Fact]
+ public async Task DeletesAnExistingShow()
+ {
+ // Given an authored show with no references
+ RunMigrationScript(db);
+ await db.ResetShowAsync();
+ var repo = Repo(db);
+ var created = Assert.IsType(
+ await repo.CreateAsync(new ShowDraft("To Be Deleted"), CancellationToken.None));
+
+ // When it is deleted
+ var outcome = await repo.DeleteAsync(created.Show.Id, CancellationToken.None);
+
+ // Then it succeeds and the row is gone
+ Assert.IsType(outcome);
+ Assert.Null(await repo.GetByIdAsync(created.Show.Id, CancellationToken.None));
+ }
+
+ [Fact]
+ public async Task ReturnsNotFoundForAMissingShow()
+ {
+ // Given an empty table (no show with this id has ever existed)
+ RunMigrationScript(db);
+ await db.ResetShowAsync();
+ var repo = Repo(db);
+
+ // When a delete targets an id that names no row
+ var outcome = await repo.DeleteAsync(999_999, CancellationToken.None);
+
+ // Then it reports not found, never a silent no-op success
+ Assert.IsType(outcome);
+ }
+ }
+
+ // ---------------------------------------------------------------------
+ // T239 — DeleteAsync's FK guard (SPEC F115.4): station.segment_schedule.show_id's own ON DELETE
+ // RESTRICT, surfaced as ShowWriteResult.Referenced (mirrors Story118_PersonaStorage's own
+ // ScenarioDeleteFkGuard idiom — direct SQL insert into segment_schedule, bypassing ScheduleRepository
+ // entirely, so this fact exercises the FK itself rather than any other seam).
+ // ---------------------------------------------------------------------
+
+ [Collection(DatabaseCollection.Name)]
+ [Trait("Category", "Integration")]
+ public sealed class ScenarioDeleteFkGuard(DatabaseFixture db)
+ {
+ static async Task InsertScheduleRowReferencingShowAsync(
+ DatabaseFixture db, long showId, int dayOfWeek = 1, int startMinute = 540, int endMinute = 720)
+ {
+ await using var conn = await db.StationDataSource.OpenConnectionAsync();
+ await conn.ExecuteAsync(
+ """
+ insert into station.segment_schedule (day_of_week, start_minute, end_minute, show_id)
+ values (@dayOfWeek, @startMinute, @endMinute, @showId)
+ """,
+ new { dayOfWeek, startMinute, endMinute, showId });
+ }
+
+ [Fact]
+ public async Task DeletingAReferencedShowIsRejectedThenSucceedsOnceUnreferenced()
+ {
+ // Given an authored show referenced by one schedule row
+ RunMigrationScript(db);
+ await db.ResetShowAsync();
+ var repo = Repo(db);
+ var created = Assert.IsType(
+ await repo.CreateAsync(new ShowDraft("Scheduled Show"), CancellationToken.None));
+ await InsertScheduleRowReferencingShowAsync(db, created.Show.Id);
+
+ // When delete is attempted while the reference stands
+ var whileReferenced = await repo.DeleteAsync(created.Show.Id, CancellationToken.None);
+
+ // Then the FK's own RESTRICT rejects it — the store-level case names nothing beyond
+ // "referenced" (PLAN T240 enriches this with block-naming at the endpoint layer)
+ Assert.IsType(whileReferenced);
+
+ // When the reference is removed and delete is retried
+ await using (var conn = await db.StationDataSource.OpenConnectionAsync())
+ await conn.ExecuteAsync(
+ "delete from station.segment_schedule where show_id = @showId", new { showId = created.Show.Id });
+ var onceUnreferenced = await repo.DeleteAsync(created.Show.Id, CancellationToken.None);
+
+ // Then it succeeds
+ Assert.IsType(onceUnreferenced);
+ }
+
+ // PLAN T240 review — ScheduleRepository.GetSlotsByShowIdAsync itself (the show delete guard's
+ // own endpoint-layer detail read, ShowsController.Delete's Referenced case) carried zero live
+ // Postgres coverage before the two facts below: only the FK's own RESTRICT, above, was proven
+ // for real. Mirrors Story118_PersonaStorage.cs's own ScenarioDeleteFkGuard round-trip facts,
+ // just against ScheduleRepository directly rather than through a ShowRepository.DeleteAsync
+ // FK trip (this method is never called from that seam — ShowsController calls it directly).
+
+ [Fact]
+ public async Task GetSlotsByShowIdAsyncRoundTripsASingleSlot()
+ {
+ // Given an authored show referenced by exactly one schedule row
+ RunMigrationScript(db);
+ await db.ResetShowAsync();
+ var repo = Repo(db);
+ var created = Assert.IsType(
+ await repo.CreateAsync(new ShowDraft("Slot Round Trip Show"), CancellationToken.None));
+ await InsertScheduleRowReferencingShowAsync(
+ db, created.Show.Id, dayOfWeek: 1, startMinute: 540, endMinute: 720);
+
+ // When ScheduleRepository.GetSlotsByShowIdAsync reads it back
+ var slots = await ScheduleRepo(db).GetSlotsByShowIdAsync(created.Show.Id, CancellationToken.None);
+
+ // Then day/start/end round-trip through the real SQL + Dapper mapping exactly — not just
+ // scripted on a fake, the way ShowsController's own wire specs cover this shape
+ var slot = Assert.Single(slots);
+ Assert.Equal(DayOfWeek.Monday, slot.Day);
+ Assert.Equal(540, slot.StartMinute);
+ Assert.Equal(720, slot.EndMinute);
+ }
+
+ [Fact]
+ public async Task GetSlotsByShowIdAsyncOrdersMultipleSlotsByDayThenStartMinute()
+ {
+ // Given an authored show referenced by two schedule rows, inserted deliberately out of
+ // order (Tuesday before Monday) — proves the query orders by day/start_minute, not
+ // insert order
+ RunMigrationScript(db);
+ await db.ResetShowAsync();
+ var repo = Repo(db);
+ var created = Assert.IsType(
+ await repo.CreateAsync(new ShowDraft("Busy Show"), CancellationToken.None));
+ await InsertScheduleRowReferencingShowAsync(
+ db, created.Show.Id, dayOfWeek: 2, startMinute: 840, endMinute: 960);
+ await InsertScheduleRowReferencingShowAsync(
+ db, created.Show.Id, dayOfWeek: 1, startMinute: 540, endMinute: 720);
+
+ // When ScheduleRepository.GetSlotsByShowIdAsync reads them back
+ var slots = await ScheduleRepo(db).GetSlotsByShowIdAsync(created.Show.Id, CancellationToken.None);
+
+ // Then the result comes back ordered day-then-start-minute
+ Assert.Equal(2, slots.Count);
+ Assert.Equal(DayOfWeek.Monday, slots[0].Day);
+ Assert.Equal(540, slots[0].StartMinute);
+ Assert.Equal(DayOfWeek.Tuesday, slots[1].Day);
+ Assert.Equal(840, slots[1].StartMinute);
+ }
+ }
+
+ // ---------------------------------------------------------------------
+ // PLAN T240 review — ShowImagingScopeRepository.UnscopeAsync (SPEC F115.4): the only writer of
+ // library.media.show_id, over the library connection (station_svc has no grant on library.media —
+ // the same db/22 cross-schema boundary IMediaLibraryMembership's own remarks already carry).
+ // Deliberately no station.show row involved: F117.1's own show_id column carries NO FK, by
+ // design, so this seam is provably reachable with any long id, real show or not.
+ // ---------------------------------------------------------------------
+
+ [Collection(DatabaseCollection.Name)]
+ [Trait("Category", "Integration")]
+ public sealed class ScenarioShowImagingScopeRepository(DatabaseFixture db)
+ {
+ static async Task InsertScopedMediaRowAsync(DatabaseFixture db, long showId, string title)
+ {
+ await using var conn = await db.DataSource.OpenConnectionAsync();
+ return await conn.ExecuteScalarAsync(
+ """
+ insert into library.media (path, format, size_bytes, mtime, title, show_id)
+ values (@path, 'flac', 100, now(), @title, @showId)
+ returning id
+ """,
+ new { path = $"/imaging/{Guid.NewGuid()}.flac", title, showId });
+ }
+
+ [Fact]
+ public async Task UnscopeAsyncClearsShowIdAndNamesTheRowItCleared()
+ {
+ // Given a library.media row scoped to a show
+ await db.ResetAsync();
+ const long showId = 4242L;
+ var mediaId = await InsertScopedMediaRowAsync(db, showId, "Sunset Ident");
+ var repo = new ShowImagingScopeRepository(db.DataSource);
+
+ // When UnscopeAsync clears every row scoped to that show
+ var unscoped = await repo.UnscopeAsync(showId, CancellationToken.None);
+
+ // Then the single UPDATE ... RETURNING named exactly the row it cleared, and a fresh read
+ // confirms show_id genuinely persists as NULL — not just what the RETURNING clause claims
+ var row = Assert.Single(unscoped);
+ await using var conn = await db.DataSource.OpenConnectionAsync();
+ var showIdAfter = await conn.ExecuteScalarAsync(
+ "select show_id from library.media where id = @mediaId", new { mediaId });
+ Assert.Equal(
+ (MediaId: mediaId, Title: "Sunset Ident", ShowIdAfter: (long?)null),
+ (MediaId: row.MediaId, Title: row.Title, ShowIdAfter: showIdAfter));
+ }
+ }
+
+ // ---------------------------------------------------------------------
+ // T238 dormant bundle columns — now real (Integration)
+ // ---------------------------------------------------------------------
+
+ [Collection(DatabaseCollection.Name)]
+ [Trait("Category", "Integration")]
+ public sealed class ScenarioDormantBundleColumns(DatabaseFixture db)
+ {
+ [Fact]
+ public async Task DormantColumnsExistAndDefaultNull()
+ {
+ // Given the fresh-init schema snapshot (see file header)...
+ var personaId = InitialStationColumn(db, "show", "persona_id");
+ var envelope = InitialStationColumn(db, "show", "envelope");
+
+ // ...persona_id and envelope carry NO DEFAULT — an implicit value here would be as much
+ // an out-of-band write as a stray non-NULL row below (this column_default assertion was
+ // previously missing despite the fact's own name).
+ Assert.Null(personaId.ColumnDefault);
+ Assert.Null(envelope.ColumnDefault);
+
+ // Converge the live schema before writing: the two assertions above deliberately read the
+ // frozen fresh-init snapshot (InitialSchema), never the live database, so this call cannot
+ // affect their falsifiability. But the insert below needs station.show.slug to actually
+ // exist on the live connection, and this class carries no ordering guarantee against
+ // Story304_AiredKindStamp's own in-place scenario, which drops and recreates station.show
+ // via db/33's pre-T238 shape (no slug column) elsewhere in the same run. Running the
+ // idempotent migration script here — unconditionally, right before the live connection
+ // opens — makes this fact's live-schema dependency self-sufficient regardless of xUnit's
+ // class scheduling.
+ RunMigrationScript(db);
+
+ // When a row is inserted through the bare name/slug columns — deliberately NOT through
+ // ShowRepository (PLAN T239's real writer of this table as of this task, but it has no
+ // parameter for persona_id/envelope at all — SPEC F115.2's "unread this epic" law — so a
+ // raw INSERT is the only way to even attempt setting them, and this one doesn't either) —
+ // inside a transaction that always rolls back so this fact leaves nothing behind for its
+ // siblings...
+ await using var conn = await db.StationDataSource.OpenConnectionAsync();
+ await using var tx = await conn.BeginTransactionAsync();
+
+ var id = await conn.ExecuteScalarAsync(
+ "insert into station.show (name, slug) values ('Dormant Probe', 'dormant-probe') returning id",
+ transaction: tx);
+
+ var row = await conn.QuerySingleAsync<(bool PersonaIdSet, bool EnvelopeSet)>(
+ """
+ select persona_id is not null as persona_id_set, envelope is not null as envelope_set
+ from station.show where id = @id
+ """,
+ new { id }, tx);
+
+ await tx.RollbackAsync();
+
+ // Then persona_id and envelope both read back NULL on it — a falsifiable guard: a column
+ // DEFAULT or an INSERT trigger/rule that set either column would turn this red, unlike the
+ // prior `count(*) where … is not null` scan over a table no writer ever touches, which
+ // was structurally 0 and could never fail.
+ Assert.False(row.PersonaIdSet);
+ Assert.False(row.EnvelopeSet);
+ }
+ }
+
+ // ---------------------------------------------------------------------
+ // T239 — validation & conflicts, now real (Integration)
+ // ---------------------------------------------------------------------
+
+ [Collection(DatabaseCollection.Name)]
+ [Trait("Category", "Integration")]
+ public sealed class ScenarioRejectingInvalidShows(DatabaseFixture db)
+ {
+ [Fact]
+ public async Task BudgetsRejectAtOneTimes()
+ {
+ // Given a show whose flavor exceeds 400 chars (the same 1x line also covers name > 60,
+ // tagline > 120 — ShowRepository.ValidateBudgets checks all three; flavor is the
+ // representative case here, mirroring the GWT comment's own primary example)
+ RunMigrationScript(db);
+ await db.ResetShowAsync();
+ var repo = Repo(db);
+ var draft = new ShowDraft("Night Moves", Flavor: new string('a', ShowBudgets.FlavorMaxChars + 1));
+
+ // When the repository write is attempted
+ var outcome = await repo.CreateAsync(draft, CancellationToken.None);
+
+ // Then it rejects at the seam — the 1× budget is the app-side hard line (F115.1)
+ Assert.Equal(new ShowWriteResult.BudgetExceeded(ShowBudgetField.Flavor), outcome);
+ }
+
+ [Fact]
+ public async Task NameOverBudgetRejected()
+ {
+ // Given a show whose name exceeds 60 chars (PLAN T240 review A2 — a dedicated name-budget
+ // fact beside the tagline/flavor ones above; ShowBudgets.FirstViolation checks name first,
+ // so this also pins that field-order precedence rather than trusting it un-Fact-pinned)
+ RunMigrationScript(db);
+ await db.ResetShowAsync();
+ var repo = Repo(db);
+ var draft = new ShowDraft(new string('n', ShowBudgets.NameMaxChars + 1));
+
+ // When the repository write is attempted
+ var outcome = await repo.CreateAsync(draft, CancellationToken.None);
+
+ // Then it rejects at the seam, naming the name field specifically
+ Assert.Equal(new ShowWriteResult.BudgetExceeded(ShowBudgetField.Name), outcome);
+ }
+
+ [Fact]
+ public async Task DuplicateSlugRejected()
+ {
+ // Given an existing show slug
+ RunMigrationScript(db);
+ await db.ResetShowAsync();
+ var repo = Repo(db);
+ await repo.CreateAsync(new ShowDraft("Night Moves"), CancellationToken.None);
+
+ // When a second show would land on the same slug
+ var outcome = await repo.CreateAsync(new ShowDraft("Night Moves"), CancellationToken.None);
+
+ // Then the unique constraint surfaces as a conflict, not a silent overwrite
+ Assert.IsType(outcome);
+ }
+
+ [Fact]
+ public async Task UpdateRejectsABudgetViolation()
+ {
+ // Given an existing show
+ RunMigrationScript(db);
+ await db.ResetShowAsync();
+ var repo = Repo(db);
+ var created = Assert.IsType(
+ await repo.CreateAsync(new ShowDraft("Night Moves"), CancellationToken.None));
+
+ // When it is edited with a tagline over its SPEC F115.1 1× budget
+ var outcome = await repo.UpdateAsync(
+ created.Show.Id,
+ new ShowDraft("Night Moves", Tagline: new string('a', ShowBudgets.TaglineMaxChars + 1)),
+ CancellationToken.None);
+
+ // Then it rejects at the seam the same way CreateAsync does — checked before the write
+ // ever reaches Postgres, the row left unchanged
+ Assert.Equal(new ShowWriteResult.BudgetExceeded(ShowBudgetField.Tagline), outcome);
+ }
+
+ [Fact]
+ public async Task UpdateRejectsASlugConflict()
+ {
+ // Given two existing shows
+ RunMigrationScript(db);
+ await db.ResetShowAsync();
+ var repo = Repo(db);
+ await repo.CreateAsync(new ShowDraft("Night Moves"), CancellationToken.None);
+ var second = Assert.IsType(
+ await repo.CreateAsync(new ShowDraft("Day Drift"), CancellationToken.None));
+
+ // When the second is renamed onto the first's slug
+ var outcome = await repo.UpdateAsync(
+ second.Show.Id, new ShowDraft("Night Moves"), CancellationToken.None);
+
+ // Then the unique constraint surfaces as a conflict, not a silent overwrite
+ Assert.IsType(outcome);
+ }
+
+ [Fact]
+ public async Task UpdateReturnsNotFoundForAMissingShow()
+ {
+ // Given an empty table (no show with this id has ever existed)
+ RunMigrationScript(db);
+ await db.ResetShowAsync();
+ var repo = Repo(db);
+
+ // When an edit targets an id that names no row
+ var outcome = await repo.UpdateAsync(999_999, new ShowDraft("Night Moves"), CancellationToken.None);
+
+ // Then it reports not found, never a silent insert
+ Assert.IsType(outcome);
+ }
+
+ [Theory]
+ [InlineData("")]
+ [InlineData(" ")]
+ public async Task BlankOrWhitespaceNameRejected(string name)
+ {
+ // Given a draft whose name is blank or whitespace-only
+ RunMigrationScript(db);
+ await db.ResetShowAsync();
+ var repo = Repo(db);
+
+ // When the repository write is attempted
+ var outcome = await repo.CreateAsync(new ShowDraft(name), CancellationToken.None);
+
+ // Then it rejects at the seam, never reaching station.show's own NOT NULL/check
+ // (length(btrim(name)) > 0) constraint (db/33)
+ Assert.IsType(outcome);
+ }
+
+ [Theory]
+ [InlineData("🎧🎶")]
+ // PLAN T240 review A1: ValidateName rejects on the RESULTING slug equaling the fallback
+ // literal, not on "how" it got there — the literal name "Persona" slugifies to "persona" the
+ // ORDINARY way (lowercases unchanged; Slugify's own empty-slug rescue never fires, since no
+ // character is non-alphanumeric) and must reject exactly the same as the emoji-only case above.
+ [InlineData("Persona")]
+ public async Task NameThatSlugifiesToTheFallbackLiteralRejected(string name)
+ {
+ // Given a name whose Slugify output is the bare literal "persona" — either via the
+ // empty-slug rescue (an emoji-only name) or the ordinary lowercase-unchanged path
+ RunMigrationScript(db);
+ await db.ResetShowAsync();
+ var repo = Repo(db);
+ Assert.Equal("persona", LegacyPersonaCardMapper.Slugify(name));
+
+ // When the repository write is attempted
+ var outcome = await repo.CreateAsync(new ShowDraft(name), CancellationToken.None);
+
+ // Then it rejects — REJECT, not silently autocorrect into a misleadingly-named show
+ Assert.IsType(outcome);
+ }
+
+ [Fact]
+ public async Task UpdateRejectsABlankName()
+ {
+ // Given an existing show
+ RunMigrationScript(db);
+ await db.ResetShowAsync();
+ var repo = Repo(db);
+ var created = Assert.IsType(
+ await repo.CreateAsync(new ShowDraft("Night Moves"), CancellationToken.None));
+
+ // When it is edited with a blank name
+ var outcome = await repo.UpdateAsync(created.Show.Id, new ShowDraft(""), CancellationToken.None);
+
+ // Then it rejects at the seam the same way CreateAsync does, the row left unchanged
+ Assert.IsType(outcome);
+ var read = await repo.GetByIdAsync(created.Show.Id, CancellationToken.None);
+ Assert.NotNull(read);
+ Assert.Equal("Night Moves", read.Name);
+ }
+ }
+}
diff --git a/tests/GenWave.MediaLibrary.Tests/Specs/Story309_ScopedImagingPool.cs b/tests/GenWave.MediaLibrary.Tests/Specs/Story309_ScopedImagingPool.cs
new file mode 100644
index 00000000..8a42c28e
--- /dev/null
+++ b/tests/GenWave.MediaLibrary.Tests/Specs/Story309_ScopedImagingPool.cs
@@ -0,0 +1,51 @@
+// STORY-309 — Show-branded idents (F117) — pool-query + authored-scope half
+//
+// BDD specification — xUnit, PENDING scaffold (planned 2026-08-10). Comment-bodied on
+// purpose: library.media.show_id lands at T238, the scoped query at T250, the authored
+// insert scope at T246. No FK across the schema/grant boundary (the db/22 precedent).
+// The drain-preference half lives in Orchestration.Tests/Story309_ShowIdentDrain.cs.
+
+namespace GenWave.MediaLibrary.Tests.Specs;
+
+using Xunit;
+
+public static class FeatureScopedImagingPool
+{
+ public sealed class ScenarioScopedQuery
+ {
+ [Fact(Skip = "Pending (T250)")]
+ public void ScopedRowsPreferredWhenAShowIsActive()
+ {
+ // Given ready station_id rows both scoped to show 7 and unscoped
+ // When the pool query runs for show 7
+ // Then only scoped rows are candidates in the scoped-first pass
+ }
+
+ [Fact(Skip = "Pending (T250)")]
+ public void UnscopedFallbackWhenNoScopedRows()
+ {
+ // Given only unscoped ready station_id rows
+ // When the pool query runs for a show
+ // Then the unscoped fallback pass serves them (the station-wide pool survives)
+ }
+
+ [Fact(Skip = "Pending (T250)")]
+ public void ScopedRowsNeverServeOutsideTheirShow()
+ {
+ // Given a ready row scoped to show 7
+ // When the pool query runs with no show (or another show) active
+ // Then the scoped row is not a candidate — scoped means scoped (F117.1)
+ }
+ }
+
+ public sealed class ScenarioAuthoringWithScope
+ {
+ [Fact(Skip = "Pending (T246)")]
+ public void InsertAuthoredAcceptsAShowScope()
+ {
+ // Given the authoring path with a show scope selected
+ // When InsertAuthoredAsync runs
+ // Then the row lands with show_id set; the default remains station-wide NULL
+ }
+ }
+}
diff --git a/tests/GenWave.MediaLibrary.Tests/Specs/Story310_ShowStamp.cs b/tests/GenWave.MediaLibrary.Tests/Specs/Story310_ShowStamp.cs
new file mode 100644
index 00000000..b039803a
--- /dev/null
+++ b/tests/GenWave.MediaLibrary.Tests/Specs/Story310_ShowStamp.cs
@@ -0,0 +1,208 @@
+// STORY-310 — Show airings are countable (F121.1)
+//
+// BDD specification — xUnit, Postgres-backed (Category=Integration, shared DatabaseFixture) — the
+// F113.1 pattern exactly, and the SAME production pipeline Story215_BoothLogPersonaStamp.cs/
+// Story304_AiredKindStamp.cs's own DriveThroughAsync drive: real StationEvents through the REAL
+// BoothLogWriter/BoothLogDrainService into the real (test) database, because the write-side types
+// (BoothLogWriter, BoothLogDrainService, BoothLogEntryRequest) are internal to GenWave.MediaLibrary,
+// and a fake store would never prove the real INSERT column-list wiring honestly. `show_id` is
+// deliberately read back with a raw query rather than through BoothLogRepository.ReadAsync/
+// BoothLogEntry — F113.3's precedent (segment_kind) keeps the read path untouched this cycle too, so
+// the column has no projection to assert against yet.
+//
+// No FK on booth_log.show_id (SPEC F121.1 — history outlives the entity), so these facts stamp an
+// arbitrary show id with no need for a real station.show row to exist.
+
+using System.Threading.Channels;
+using Dapper;
+using GenWave.Core.Abstractions;
+using GenWave.Core.Domain;
+using GenWave.Core.Events;
+using GenWave.MediaLibrary.Station;
+using Microsoft.Extensions.Logging.Abstractions;
+using Npgsql;
+
+namespace GenWave.MediaLibrary.Tests.Specs;
+
+public static class FeatureShowStamp
+{
+ // ---------------------------------------------------------------------
+ // Helpers
+ // ---------------------------------------------------------------------
+
+ ///
+ /// Scriptable double, scoped to this file's own concern
+ /// ( — not persona attribution, Story215_BoothLogPersonaStamp.cs's own
+ /// territory): reports no active persona (the interface's own default). is
+ /// settable — mirrors Story215's own FakeActivePersonaAccessor — so a scenario can flip the
+ /// on-air show MID-TEST (the F121.1 "stamped at air time" claim is only provable if the answer
+ /// can change AFTER already captured it).
+ ///
+ sealed class FakeActiveShowAccessor(long? showId) : IActivePersonaAccessor
+ {
+ public long? ShowId { get; set; } = showId;
+
+ public Task ResolveAsync(CancellationToken ct) => Task.FromResult(null);
+
+ public long? ActiveShowId => ShowId;
+ }
+
+ static BoothLogRepository Store(DatabaseFixture db) =>
+ new(new Lazy(() => db.StationDataSource),
+ Microsoft.Extensions.Options.Options.Create(new BoothLogOptions()));
+
+ ///
+ /// Publishes every through the real — which
+ /// captures SYNCHRONOUSLY at publish time (F121.1), off the SAME
+ /// dependency the persona stamp already reads — and drains
+ /// each through the real , the same production
+ /// pipeline this file's sibling specs drive.
+ ///
+ static async Task DriveThroughAsync(DatabaseFixture db, long? activeShowId, params StationEvent[] events)
+ {
+ var channel = Channel.CreateBounded(16);
+ var writer = new BoothLogWriter(channel.Writer, new FakeActiveShowAccessor(activeShowId), NullLogger.Instance);
+ var drain = new BoothLogDrainService(channel.Reader, Store(db), NullLogger.Instance);
+
+ foreach (var evt in events)
+ writer.Publish(evt);
+
+ for (var i = 0; i < events.Length; i++)
+ await drain.ProcessAsync(await channel.Reader.ReadAsync(), CancellationToken.None);
+ }
+
+ /// The persisted `show_id` for every `track-started` row, newest first — a raw query
+ /// rather than because the column has no projection on
+ /// yet (F113.3's precedent, carried to F121.1).
+ static async Task> TrackStartedShowIdsAsync(DatabaseFixture db)
+ {
+ await using var conn = await db.StationDataSource.OpenConnectionAsync();
+ var rows = await conn.QueryAsync(
+ """
+ select show_id from station.booth_log
+ where kind = 'track-started'
+ order by occurred_at desc, id desc
+ """);
+ return rows.ToList();
+ }
+
+ static TrackAired AKindedAiring() => new(
+ "tts:abc123", "GenWave", "GenWave", -2.0, DateTimeOffset.UtcNow, 4_000,
+ SegmentKind: SegmentKind.StationId);
+
+ static TrackAired AMusicAiring() => new("42", "Night Drive", "The Waveforms", -2.5, DateTimeOffset.UtcNow, 214_000);
+
+ // ---------------------------------------------------------------------
+ // HAPPY PATH — rows during a show carry show_id (F121.1)
+ // ---------------------------------------------------------------------
+
+ [Collection(DatabaseCollection.Name)]
+ [Trait("Category", "Integration")]
+ public sealed class ScenarioRowsDuringAShow(DatabaseFixture db)
+ {
+ [Fact]
+ public async Task KindedRowsCarryTheShowId()
+ {
+ // Given a show on the air...
+ await db.ResetBoothLogAsync();
+ const long showId = 501;
+
+ // When a kinded (tts) airing writes its track-started row — SegmentKind.StationId, the
+ // demo-hour gate's own F121.2(b) evidence...
+ await DriveThroughAsync(db, showId, AKindedAiring());
+
+ // Then show_id carries the snapshot's show.
+ var showIds = await TrackStartedShowIdsAsync(db);
+ Assert.Equal([showId], showIds);
+ }
+
+ [Fact]
+ public async Task MusicRowsCarryItToo()
+ {
+ // Given a show on the air, and the SAME BoothLogWriter instance/chokepoint a kinded
+ // airing stamps through above — not a second writer, not a second accessor...
+ await db.ResetBoothLogAsync();
+ const long showId = 777;
+ var channel = Channel.CreateBounded(16);
+ var writer = new BoothLogWriter(channel.Writer, new FakeActiveShowAccessor(showId), NullLogger.Instance);
+ var drain = new BoothLogDrainService(channel.Reader, Store(db), NullLogger.Instance);
+
+ // When a kinded airing and a plain music airing both flow through it, in order...
+ writer.Publish(AKindedAiring());
+ writer.Publish(AMusicAiring());
+ await drain.ProcessAsync(await channel.Reader.ReadAsync(), CancellationToken.None);
+ await drain.ProcessAsync(await channel.Reader.ReadAsync(), CancellationToken.None);
+
+ // Then show_id is stamped from the same chokepoint on BOTH rows — verifying ONE stamp
+ // point covers music and kinded alike (the /design TODO made a fact, PLAN T242).
+ var showIds = await TrackStartedShowIdsAsync(db);
+ Assert.Equal([showId, showId], showIds);
+ }
+ }
+
+ // ---------------------------------------------------------------------
+ // HAPPY PATH — the stamp reflects AIR time, not DRAIN time, across a channel backlog (F121.1)
+ //
+ // Mirrors Story215_BoothLogPersonaStamp.cs's own ScenarioPersonaSwitchesDuringBacklog exactly —
+ // the T60 review finding this epic's own PLAN task calls out: resolving the on-air show at DRAIN
+ // time (rather than capturing it synchronously at PUBLISH time) would mis-stamp a row already
+ // queued behind a bounded-channel backlog once a show change lands before the drain catches up.
+ // BoothLogWriter.Publish must have already captured the answer before this test ever flips the
+ // active show.
+ // ---------------------------------------------------------------------
+
+ [Collection(DatabaseCollection.Name)]
+ [Trait("Category", "Integration")]
+ public sealed class ScenarioShowSwitchesDuringBacklog(DatabaseFixture db)
+ {
+ [Fact]
+ public async Task QueuedTrackStartStaysStampedWithTheAirTimeShowDespiteALaterSwitch()
+ {
+ // Given show A on the air, and a track-start event published through the real writer —
+ // captured synchronously, at air time, while A is still on air...
+ await db.ResetBoothLogAsync();
+ const long showAId = 111;
+ const long showBId = 222;
+ var accessor = new FakeActiveShowAccessor(showAId);
+
+ var channel = Channel.CreateBounded(16);
+ var writer = new BoothLogWriter(channel.Writer, accessor, NullLogger.Instance);
+ var drain = new BoothLogDrainService(channel.Reader, Store(db), NullLogger.Instance);
+
+ writer.Publish(AMusicAiring());
+
+ // When the on-air show switches to B BEFORE the drain ever processes the entry sitting
+ // in the queue — the exact bounded-queue-backlog window the finding described...
+ accessor.ShowId = showBId;
+ await drain.ProcessAsync(await channel.Reader.ReadAsync(), CancellationToken.None);
+
+ // Then the persisted row is stamped with A — the show on air when the track STARTED,
+ // never B, which only became active after the row had already queued.
+ var showIds = await TrackStartedShowIdsAsync(db);
+ Assert.Equal([showAId], showIds);
+ }
+ }
+
+ // ---------------------------------------------------------------------
+ // HAPPY PATH — a showless airing stays unstamped (F121.1)
+ // ---------------------------------------------------------------------
+
+ [Collection(DatabaseCollection.Name)]
+ [Trait("Category", "Integration")]
+ public sealed class ScenarioShowlessRows(DatabaseFixture db)
+ {
+ [Fact]
+ public async Task NoShowMeansNullStamp()
+ {
+ // Given no show on the air (a grid gap, or an unnamed block)...
+ await db.ResetBoothLogAsync();
+
+ // When rows are written — kinded and music alike...
+ await DriveThroughAsync(db, activeShowId: null, AKindedAiring(), AMusicAiring());
+
+ // Then show_id stays NULL on every row — pre-F121 and showless rows are indistinguishable.
+ var showIds = await TrackStartedShowIdsAsync(db);
+ Assert.Equal([null, null], showIds);
+ }
+ }
+}
diff --git a/tests/GenWave.MediaLibrary.Tests/Specs/Story317_SpecialsStore.cs b/tests/GenWave.MediaLibrary.Tests/Specs/Story317_SpecialsStore.cs
new file mode 100644
index 00000000..e38205cc
--- /dev/null
+++ b/tests/GenWave.MediaLibrary.Tests/Specs/Story317_SpecialsStore.cs
@@ -0,0 +1,36 @@
+// STORY-317 — Dated specials shadow the grid (F120) — store half · 🪂 DROPPABLE SLICE
+//
+// BDD specification — xUnit, PENDING scaffold (planned 2026-08-10). Comment-bodied on
+// purpose: db/36 + the specials repository land at T258 — and this slice is the epic's
+// ONE ruled-droppable tail; dropping it removes these pendings with it. The resolver
+// rung half lives in Orchestration.Tests/Story317_SpecialsRung.cs.
+
+namespace GenWave.MediaLibrary.Tests.Specs;
+
+using Xunit;
+
+public static class FeatureSpecialsStore
+{
+ public sealed class ScenarioDatedRows
+ {
+ [Fact(Skip = "Pending (T258)")]
+ public void ASpecialRoundTripsWithDateSpanPersonaShowEnvelope()
+ {
+ // Given a special for 2026-12-24 19:00–21:00 with persona/show/envelope
+ // When it is written and re-read
+ // Then every field round-trips; minutes obey the 30-min steps (F91 mirrored)
+ }
+ }
+
+ public sealed class ScenarioRejectingOverlap
+ {
+ [Fact(Skip = "Pending (T258)")]
+ public void OverlappingSpecialsOnADateAreRejectedByTheDatabase()
+ {
+ // Given a special already covering a span on a date
+ // When a second special overlaps it
+ // Then the per-date EXCLUDE guard rejects at the database (F120.1) —
+ // the weekly table's own invariant is untouched by construction
+ }
+ }
+}
diff --git a/tests/GenWave.Orchestration.Tests/Fakes/FakeScheduleStore.cs b/tests/GenWave.Orchestration.Tests/Fakes/FakeScheduleStore.cs
index 87737866..da27d7dd 100644
--- a/tests/GenWave.Orchestration.Tests/Fakes/FakeScheduleStore.cs
+++ b/tests/GenWave.Orchestration.Tests/Fakes/FakeScheduleStore.cs
@@ -45,6 +45,11 @@ public Task ReplaceWeekAsync(
IReadOnlyList week, string? expectedVersion, CancellationToken ct) =>
throw new NotSupportedException("FakeScheduleStore is a read-only double for T119 specs.");
+ // PLAN T240's show delete guard read — not exercised by any T119 spec (this double never carries
+ // show_id-scoped rows), mirrors ReplaceWeekAsync's own NotSupportedException posture above.
+ public Task> GetSlotsByShowIdAsync(long showId, CancellationToken ct) =>
+ throw new NotSupportedException("FakeScheduleStore is a read-only double for T119 specs.");
+
public void RaiseWeekChanged() => WeekChanged?.Invoke();
/// Arms a gate so the NEXT call returns an incomplete task,
diff --git a/tests/GenWave.Orchestration.Tests/Specs/Story306_IdentityChokepoint.cs b/tests/GenWave.Orchestration.Tests/Specs/Story306_IdentityChokepoint.cs
new file mode 100644
index 00000000..72d90264
--- /dev/null
+++ b/tests/GenWave.Orchestration.Tests/Specs/Story306_IdentityChokepoint.cs
@@ -0,0 +1,163 @@
+// STORY-306 — One identity chokepoint (F115.2, F116.1)
+//
+// BDD specification — xUnit, real GenWave.Orchestration.ScheduleResolver/EffectiveAssignment (no
+// stores needed: every fact here builds a ScheduleWeekSnapshot by hand, mirroring
+// Story241_StationFollowsTheClock.cs's own ScenarioResolvingTheCurrentSegment idiom — a pure
+// (snapshot, wall clock) function needs no IScheduleStore double at all). Orchestration.Tests carries
+// no live Postgres (fakes only — see project header); the live-PG half of the dormant-columns-unread
+// pin (SPEC F115.2 — hand-populating station.show.persona_id/envelope through raw SQL and proving the
+// loaded ScheduleWeekSnapshot is unaffected) lives in GenWave.MediaLibrary.Tests'
+// Story240_ScheduleStore.cs instead, where it can be real.
+
+using GenWave.Abstractions.Playout;
+using GenWave.Core.Domain;
+using GenWave.Orchestration.Tests.Fakes;
+
+namespace GenWave.Orchestration.Tests.Specs;
+
+public static class FeatureIdentityChokepoint
+{
+ static readonly ShowSummary NightMoves = new(1, "Night Moves", "Late-night deep cuts", "moody, sparse, past midnight");
+
+ public sealed class ScenarioSnapshotCarriesTheShow
+ {
+ // Given a block assigned show "Night Moves".
+
+ [Fact]
+ public void ShowRidesTheSnapshotDuringItsBlock()
+ {
+ var now = new DateTimeOffset(2026, 3, 2, 10, 30, 0, TimeSpan.Zero);
+ var time = new FakeTimeProvider(now);
+ var day = now.DayOfWeek;
+ var block = new ScheduleSegment(
+ Id: 1, Day: day, StartMinute: 540, EndMinute: 720,
+ PersonaId: 7, Genres: null, EnergyMin: null, EnergyMax: null, Show: NightMoves);
+ var resolver = new ScheduleResolver(time, new FakeStationDefaultEnvelopeSource(SegmentEnvelope.StationDefault));
+
+ // When the resolver snapshot is read during that block
+ var result = resolver.Resolve(new ScheduleWeekSnapshot([block]));
+
+ // Then show id/name/tagline/flavor ride OnAirSnapshot — both at the top-level convenience
+ // member (mirrors PersonaId's own "no null-check Segment first" rationale) and on the
+ // resolved Segment itself, since ScheduleResolver never rebuilds/clones the block.
+ Assert.Equal(NightMoves, result.Show);
+ Assert.Equal(NightMoves, result.Segment!.Show);
+ }
+
+ [Fact]
+ public void UnnamedBlocksCarryNullEndToEnd()
+ {
+ var now = new DateTimeOffset(2026, 3, 2, 10, 30, 0, TimeSpan.Zero);
+ var time = new FakeTimeProvider(now);
+ var day = now.DayOfWeek;
+
+ // Given a block with no show (the default — every pre-T241 construction site's own shape)
+ var unnamedBlock = new ScheduleSegment(
+ Id: 1, Day: day, StartMinute: 540, EndMinute: 720,
+ PersonaId: 7, Genres: null, EnergyMin: null, EnergyMax: null);
+ var resolver = new ScheduleResolver(time, new FakeStationDefaultEnvelopeSource(SegmentEnvelope.StationDefault));
+
+ // When the snapshot is read during that block
+ var staffedResult = resolver.Resolve(new ScheduleWeekSnapshot([unnamedBlock]));
+
+ // Then the show member is null end to end: the snapshot's own member, the resolved
+ // Segment, and NextSegment (a second unnamed block immediately following) all agree.
+ var nextUnnamedBlock = new ScheduleSegment(
+ Id: 2, Day: day, StartMinute: 720, EndMinute: 900,
+ PersonaId: 9, Genres: null, EnergyMin: null, EnergyMax: null);
+ var withNext = resolver.Resolve(new ScheduleWeekSnapshot([unnamedBlock, nextUnnamedBlock]));
+
+ Assert.Null(staffedResult.Show);
+ Assert.Null(staffedResult.Segment!.Show);
+ Assert.Null(withNext.NextSegment!.Show);
+
+ // ...and a grid gap (no block on air at all) reports the same honest null.
+ var gapResult = resolver.Resolve(new ScheduleWeekSnapshot([]));
+ Assert.Null(gapResult.Show);
+ }
+ }
+
+ public sealed class ScenarioDormantMeansDormant
+ {
+ [Fact]
+ public void HandPopulatedBundleColumnsChangeNothing()
+ {
+ // Given station.show rows with persona_id/envelope hand-populated (F115.2's pin). This
+ // project carries no live Postgres (see file header) — ShowSummary itself structurally
+ // enforces the pin (no PersonaId/Envelope member exists to even hand-populate at the C#
+ // level; the live-PG half proving a REAL station.show row survives this unread lives in
+ // GenWave.MediaLibrary.Tests/Specs/Story240_ScheduleStore.cs). What THIS fact pins is the
+ // resolver-side half of the same law: EffectiveAssignment.Resolve/ScheduleResolver never
+ // let a show's presence — named, tagline'd, flavor'd, whatever it carries — influence which
+ // persona resolves. Two otherwise-identical blocks, differing ONLY in PersonaId, carry the
+ // exact SAME show; the show identity is proven fully independent of persona resolution.
+ var now = new DateTimeOffset(2026, 3, 2, 10, 30, 0, TimeSpan.Zero);
+ var time = new FakeTimeProvider(now);
+ var day = now.DayOfWeek;
+ var staffedBlock = new ScheduleSegment(
+ Id: 1, Day: day, StartMinute: 540, EndMinute: 720,
+ PersonaId: 7, Genres: null, EnergyMin: null, EnergyMax: null, Show: NightMoves);
+ var musicOnlyBlock = staffedBlock with { PersonaId = null };
+ var resolver = new ScheduleResolver(time, new FakeStationDefaultEnvelopeSource(SegmentEnvelope.StationDefault));
+
+ // When any v1 path resolves identity
+ var staffedResult = resolver.Resolve(new ScheduleWeekSnapshot([staffedBlock]));
+ var musicOnlyResult = resolver.Resolve(new ScheduleWeekSnapshot([musicOnlyBlock]));
+
+ // Then behavior is UNCHANGED — block-level persona only, and the SAME show identity rides
+ // both snapshots regardless of which persona (if any) the block itself names.
+ Assert.Equal(7L, staffedResult.PersonaId);
+ Assert.Null(musicOnlyResult.PersonaId);
+ Assert.Equal(NightMoves, staffedResult.Show);
+ Assert.Equal(NightMoves, musicOnlyResult.Show);
+ }
+ }
+
+ public sealed class ScenarioShowlessStationsAreUntouched
+ {
+ [Fact]
+ public void ShowlessSnapshotIsByteIdentical()
+ {
+ // Given a station with zero shows — a two-segment week built the exact same way
+ // Story241_StationFollowsTheClock.cs's own SnapshotCarriesSegmentPersonaEnvelopeBoundaryAndNext
+ // fact does, with no Show argument passed anywhere (the pre-F116 construction shape,
+ // verbatim).
+ var now = new DateTimeOffset(2026, 3, 2, 10, 30, 0, TimeSpan.Zero);
+ var time = new FakeTimeProvider(now);
+ var day = now.DayOfWeek;
+
+ var onAir = new ScheduleSegment(
+ Id: 1, Day: day, StartMinute: 540, EndMinute: 720,
+ PersonaId: 7, Genres: ["Rock"], EnergyMin: 0.2, EnergyMax: 0.8);
+ var upNext = new ScheduleSegment(
+ Id: 2, Day: day, StartMinute: 720, EndMinute: 900,
+ PersonaId: 9, Genres: null, EnergyMin: null, EnergyMax: null);
+ var snapshot = new ScheduleWeekSnapshot([onAir, upNext]);
+ var stationDefault = new FakeStationDefaultEnvelopeSource(
+ new SegmentEnvelope(TimeOnly.MinValue, TimeOnly.MaxValue, ["Jazz"], new EnergyRange(0.0, 1.0)));
+ var resolver = new ScheduleResolver(time, stationDefault);
+
+ // When the resolver produces snapshots across a full week (the boundary crossing below
+ // stands in for "across the week" — the SAME assertions Story241's own pre-F116 fact
+ // makes, at the SAME instant, the honest way to compare "byte-identical to pre-F116
+ // behavior" without duplicating that file's own full DST/boundary coverage here)
+ var result = resolver.Resolve(snapshot);
+
+ // Then output matches pre-F116 behavior exactly — every field Story241's own fact already
+ // pins, unchanged (compared field-by-field for Envelope.Genres, same reference-equality
+ // caveat that file's own fact documents)...
+ Assert.Equal(onAir, result.Segment);
+ Assert.Equal(7L, result.PersonaId);
+ Assert.Equal(new TimeOnly(9, 0), result.Envelope.StartsAt);
+ Assert.Equal(new TimeOnly(12, 0), result.Envelope.EndsAt);
+ Assert.Equal(["Rock"], result.Envelope.Genres);
+ Assert.Equal(new EnergyRange(0.2, 0.8), result.Envelope.EnergyRange);
+ Assert.Equal(new DateTimeOffset(2026, 3, 2, 12, 0, 0, TimeSpan.Zero), result.BoundaryAt);
+ Assert.Equal(upNext, result.NextSegment);
+
+ // ...plus the ONE additive member this epic introduces, honestly null (the epic's null
+ // hypothesis: a showless station gains a field that is always empty, nothing else).
+ Assert.Null(result.Show);
+ }
+ }
+}
diff --git a/tests/GenWave.Orchestration.Tests/Specs/Story307_CeremonyNamesTheShow.cs b/tests/GenWave.Orchestration.Tests/Specs/Story307_CeremonyNamesTheShow.cs
new file mode 100644
index 00000000..76f6f0d5
--- /dev/null
+++ b/tests/GenWave.Orchestration.Tests/Specs/Story307_CeremonyNamesTheShow.cs
@@ -0,0 +1,46 @@
+// STORY-307 — Ceremony names the show (F116.2) — boundary/dedupe half
+//
+// BDD specification — xUnit, PENDING scaffold (planned 2026-08-10). Comment-bodied on
+// purpose: show-aware ceremony lands at T248. The prompt-content half lives in
+// GenWave.Tts.Tests/Specs/Story307_ShowCeremonyCopy.cs (the Story243/Story303 split).
+
+namespace GenWave.Orchestration.Tests.Specs;
+
+using Xunit;
+
+public static class FeatureCeremonyNamesTheShow
+{
+ public sealed class ScenarioSameDjShowFlip
+ {
+ [Fact(Skip = "Pending (T248)")]
+ public void ExactlyOneTransitionPieceAirs()
+ {
+ // Given adjacent blocks: same persona, different shows
+ // When the boundary drains
+ // Then exactly ONE ceremony piece airs — the transition-styled sign-on
+ // (the F92.4 incoming-welcome rung as designed behavior, not degrade)
+ }
+ }
+
+ public sealed class ScenarioDjBoundariesUnchanged
+ {
+ [Fact(Skip = "Pending (T248)")]
+ public void DifferentPersonaBoundariesKeepTheTwoPieceCeremony()
+ {
+ // Given adjacent blocks with different personas (shows named or not)
+ // When the boundary drains
+ // Then the F92 two-piece ceremony behaves exactly as shipped
+ }
+ }
+
+ public sealed class ScenarioAmendedDedupe
+ {
+ [Fact(Skip = "Pending (T248)")]
+ public void SamePersonaSameShowStaysSilent()
+ {
+ // Given adjacent blocks with the same persona AND the same show
+ // When the boundary passes
+ // Then no ceremony airs — F92.3 dedupes on persona AND show (F114.3 as ruled)
+ }
+ }
+}
diff --git a/tests/GenWave.Orchestration.Tests/Specs/Story309_ShowIdentDrain.cs b/tests/GenWave.Orchestration.Tests/Specs/Story309_ShowIdentDrain.cs
new file mode 100644
index 00000000..a06de525
--- /dev/null
+++ b/tests/GenWave.Orchestration.Tests/Specs/Story309_ShowIdentDrain.cs
@@ -0,0 +1,63 @@
+// STORY-309 — Show-branded idents (F117) — drain-preference half
+//
+// BDD specification — xUnit, PENDING scaffold (planned 2026-08-10). Comment-bodied on
+// purpose: the StationId drain's show preference lands at T250. Ladder under spec:
+// scoped authored row → templated show line → F110.2 exactly. ⚠️ Drain-region caution
+// recorded in PLAN: serialize behind T248 if both touch the drain switch.
+
+namespace GenWave.Orchestration.Tests.Specs;
+
+using Xunit;
+
+public static class FeatureShowIdentDrain
+{
+ public sealed class ScenarioScopedPoolFirst
+ {
+ [Fact(Skip = "Pending (T250)")]
+ public void ScopedAuthoredRowAirsDuringItsShow()
+ {
+ // Given a ready authored station_id row scoped to the current show
+ // When the StationId drain fires during that show
+ // Then the scoped row airs (authored voice preserved — it is rendered audio)
+ }
+ }
+
+ public sealed class ScenarioTemplatedFloor
+ {
+ [Fact(Skip = "Pending (T250)")]
+ public void TemplatedShowLineAirsWhenNoScopedRows()
+ {
+ // Given a show with zero scoped authored rows
+ // When the drain fires
+ // Then "You're listening to {show} on {station}." renders — station-voiced,
+ // zero LLM (the gate-countable floor, F117.2)
+ }
+
+ [Fact(Skip = "Pending (T250)")]
+ public void SecondAiringIsACacheHit()
+ {
+ // Given the templated show line rendered once
+ // When the drain fires again for the same show name
+ // Then the render is a forever-cache hit (keyed on the rendered text)
+ }
+
+ [Fact(Skip = "Pending (T250)")]
+ public void RenameRekeysTheCache()
+ {
+ // Given the show is renamed
+ // When the next drain fires
+ // Then a fresh render occurs by construction — the key IS the text
+ }
+ }
+
+ public sealed class ScenarioOutsideShowsUntouched
+ {
+ [Fact(Skip = "Pending (T250)")]
+ public void NoShowMeansF110Exactly()
+ {
+ // Given no show on the air
+ // When the StationId drain fires
+ // Then behavior is byte-identical to F110.2 (station pool → template)
+ }
+ }
+}
diff --git a/tests/GenWave.Orchestration.Tests/Specs/Story317_SpecialsRung.cs b/tests/GenWave.Orchestration.Tests/Specs/Story317_SpecialsRung.cs
new file mode 100644
index 00000000..b2f005e1
--- /dev/null
+++ b/tests/GenWave.Orchestration.Tests/Specs/Story317_SpecialsRung.cs
@@ -0,0 +1,43 @@
+// STORY-317 — Dated specials shadow the grid (F120) — resolver-rung half · 🪂 DROPPABLE SLICE
+//
+// BDD specification — xUnit, PENDING scaffold (planned 2026-08-10). Comment-bodied on
+// purpose: the specials-first rung lands at T258. Downstream consumers (ceremony, idents,
+// stamps, spectator) are unchanged by construction — they read the resolver, which is
+// exactly why the rung is the ONLY orchestration change this slice makes.
+
+namespace GenWave.Orchestration.Tests.Specs;
+
+using Xunit;
+
+public static class FeatureSpecialsRung
+{
+ public sealed class ScenarioTheShadow
+ {
+ [Fact(Skip = "Pending (T258)")]
+ public void TheResolverServesTheSpecialForItsSpan()
+ {
+ // Given a special covering 19:00–21:00 today over a differently-staffed weekly block
+ // When the resolver snapshot is read inside the span
+ // Then persona/show/envelope come from the special (specials-first rung, F120.2)
+ }
+
+ [Fact(Skip = "Pending (T258)")]
+ public void DownstreamConsumersFollowWithZeroSpecialCasing()
+ {
+ // Given the special on the air
+ // When ceremony context, ident preference, and the booth stamp read identity
+ // Then all read the same snapshot — no consumer knows specials exist
+ }
+ }
+
+ public sealed class ScenarioTheDayAfter
+ {
+ [Fact(Skip = "Pending (T258)")]
+ public void TheWeeklyGridServesExactlyAsBefore()
+ {
+ // Given the special's date has passed
+ // When the same wall-clock span arrives next day/week
+ // Then the weekly grid resolves byte-identically to pre-special behavior
+ }
+ }
+}
diff --git a/tests/GenWave.Tts.Tests/Specs/Story307_ShowCeremonyCopy.cs b/tests/GenWave.Tts.Tests/Specs/Story307_ShowCeremonyCopy.cs
new file mode 100644
index 00000000..83667321
--- /dev/null
+++ b/tests/GenWave.Tts.Tests/Specs/Story307_ShowCeremonyCopy.cs
@@ -0,0 +1,43 @@
+// STORY-307 — Ceremony names the show (F116.2) — prompt-content half
+//
+// BDD specification — xUnit, PENDING scaffold (planned 2026-08-10). Comment-bodied on
+// purpose: the show fields in ceremony prompts land at T248. Golden-string idiom follows
+// Story243/Story303. The boundary/dedupe half lives in Orchestration.Tests.
+
+namespace GenWave.Tts.Tests.Specs;
+
+using Xunit;
+
+public static class FeatureShowCeremonyCopy
+{
+ public sealed class ScenarioSignOnCarriesTheShow
+ {
+ [Fact(Skip = "Pending (T248)")]
+ public void SignOnPromptCarriesIncomingShowNameAndFlavor()
+ {
+ // Given a boundary into a block with show "The Breakfast Show" (tagline + flavor set)
+ // When the sign-on prompt is built
+ // Then it carries the incoming show's name and flavor (flavor reaches the
+ // prompt ONLY — never any public payload; F115.3)
+ }
+
+ [Fact(Skip = "Pending (T248)")]
+ public void SignOffMayNameTheEndingAndNextShows()
+ {
+ // Given a boundary between two named shows
+ // When the sign-off prompt is built
+ // Then both show names are available to the copywriter (F114.3's "may name")
+ }
+ }
+
+ public sealed class ScenarioShowlessCeremonyUntouched
+ {
+ [Fact(Skip = "Pending (T248)")]
+ public void ShowlessCeremonyPromptIsByteIdentical()
+ {
+ // Given a boundary between blocks with no shows
+ // When sign-on/sign-off prompts are built
+ // Then output matches the pre-F116 golden byte-for-byte
+ }
+ }
+}
diff --git a/tests/GenWave.Tts.Tests/Specs/Story308_FlavorLineSharedSlot.cs b/tests/GenWave.Tts.Tests/Specs/Story308_FlavorLineSharedSlot.cs
new file mode 100644
index 00000000..f2807b20
--- /dev/null
+++ b/tests/GenWave.Tts.Tests/Specs/Story308_FlavorLineSharedSlot.cs
@@ -0,0 +1,54 @@
+// STORY-308 — The flavor line shares the slot (F116.3, amends F107.5)
+//
+// BDD specification — xUnit, PENDING scaffold (planned 2026-08-10). Comment-bodied on
+// purpose: the shared-slot arbitration lands at T249 (serialized behind T248 — both
+// touch prompt assembly; the T224→T225 precedent). The one law this file exists to hold:
+// a break's prompt carries AT MOST ONE extra line, and the ceiling never grows past F107.
+
+namespace GenWave.Tts.Tests.Specs;
+
+using Xunit;
+
+public static class FeatureFlavorLineSharedSlot
+{
+ public sealed class ScenarioTheShowLineAirs
+ {
+ [Fact(Skip = "Pending (T249)")]
+ public void ShowLineAppearsWhenDueAndNoContextFact()
+ {
+ // Given Station:Shows:PatterCadenceMinutes elapsed, a show on the air, no due fact
+ // When a lead-in prompt is built
+ // Then exactly one show-flavor line is present
+ }
+ }
+
+ public sealed class ScenarioContextWinsTheSlot
+ {
+ [Fact(Skip = "Pending (T249)")]
+ public void ContextLineAppearsAndShowLineDoesNot()
+ {
+ // Given a due context fact AND a due show line
+ // When the prompt is built
+ // Then the context line appears and the show line does not (facts beat identity)
+ }
+
+ [Fact(Skip = "Pending (T249)")]
+ public void ShowGateStaysOpenAfterLosingTheSlot()
+ {
+ // Given the show line lost the slot to a context fact
+ // When the next eligible break's prompt is built (no fact due)
+ // Then the show line appears — losing the slot never consumed the cadence
+ }
+ }
+
+ public sealed class ScenarioClosedGateIsByteIdentical
+ {
+ [Fact(Skip = "Pending (T249)")]
+ public void ClosedGateMatchesTheF107Golden()
+ {
+ // Given cadence not elapsed, or the setting at its 0 default, or no show on air
+ // When the prompt is built
+ // Then output matches the F107 golden byte-for-byte (the Story298 pin extended)
+ }
+ }
+}
diff --git a/tools/demo_hour_gate.sql b/tools/demo_hour_gate.sql
index 80e1dc4c..3b2329a9 100644
--- a/tools/demo_hour_gate.sql
+++ b/tools/demo_hour_gate.sql
@@ -55,3 +55,134 @@ having
and bool_or(segment_kind = 'ContextSegment')
and bool_or(segment_kind is not null and segment_kind not in ('StationId', 'ContextSegment'))
order by broadcast_hour;
+
+-- ============================================================================
+-- F121.2 extension (SPEC F121, STORY-310, PLAN T242) — show observability.
+--
+-- The query above is BYTE-IDENTICAL to the F113.2 shape (F121.2c: zero regression to the existing
+-- counts) — both halves below are ADDITIONAL statements in the same batch, reading
+-- `station.booth_log.show_id` (F121.1's air-time stamp) ALONE, never `station.segment_schedule`: a
+-- grid repaint after the window closes can never change what already aired.
+--
+-- MEASURED (EXPLAIN ANALYZE, 60k track-started rows, ~120 shows) — the two halves do NOT share one
+-- access pattern:
+-- * Half (a): the planner DOES walk `booth_log_paging` — an Index Scan Backward feeds the
+-- `track_rows`/`show_transitions` window functions (they need `occurred_at`-ordered input, and
+-- the index already provides it), and the correlated `exists` subquery's own
+-- `occurred_at <= transition_at` bound is itself an index condition (Bitmap Index/Heap Scan on the
+-- same index) run once per transition. Measured ~590ms at 60k rows / ~120 transitions — dominated
+-- by that once-per-transition subquery cost (bounded by transition COUNT, not row count) and JIT
+-- compilation, not by an unindexed scan.
+-- * Half (b): `hourly_show_occupancy` groups by `date_trunc('hour', occurred_at)`, an expression the
+-- index cannot serve — the planner does a Seq Scan on `station.booth_log` feeding an explicit
+-- Sort. Measured ~34ms at 60k rows.
+-- Both bounded by SPEC F72.3's 14-day retention (this table never grows past a hobby-station's few
+-- weeks of rows) — neither number, nor a new index, is a concern for a manual close-out query at that
+-- scale.
+--
+-- Narrowing to a specific window: half (a)'s `where` predicate MUST go on the FINAL select (filtering
+-- `transition_at`), never inside `track_rows`/`show_transitions` — those two CTEs must always see the
+-- table's FULL history so `prev_show_id`/`prev_transition_at` stay correct at the window's own edges;
+-- narrowing the source rows would make the first transition inside the window look like it has no
+-- predecessor (exactly the unbounded-below shape this rewrite exists to close). Half (b) has no such
+-- constraint — its CTE groups each hour independently with no cross-row window function — so its
+-- predicate goes inside `hourly_show_occupancy`'s `where`, the same place `hourly_kind_counts` above
+-- takes one.
+--
+-- Run the same way as above (piped over stdin); psql prints one result table per statement in the
+-- batch, in order — this file now prints three: the F113.2 hourly mix, half (a), then half (b).
+-- ============================================================================
+
+-- Half (a): every SHOW TRANSITION in the window has a show-stamped `SignOn` row.
+--
+-- A transition is the first `track-started` row of a run of consecutive same-show rows — the moment
+-- a new show's own airings actually start (`show_id` distinct from the immediately preceding
+-- track-started row's `show_id`, and this row itself names a show). That show is COVERED when a
+-- `SignOn` row (kind='track-started', segment_kind='SignOn') stamped with the SAME `show_id` aired
+-- STRICTLY AFTER the immediately PRECEDING transition (any show) and at or before THIS transition's
+-- own instant — ceremony fires before the incoming show's first track, never after (SPEC F116.2), so
+-- in the ordinary case the SignOn row IS this transition's own row (occurred_at equal).
+--
+-- The lower bound is load-bearing, not cosmetic: an `exists` with only the upper bound
+-- (`sign_on.occurred_at <= transition_at`) is satisfied by ANY historical SignOn ever stamped for that
+-- show_id, no matter how long ago — a RECURRING show's second (and every later) airing then
+-- false-passes against a SignOn from an earlier, unrelated day, since that show never re-signs-on and
+-- its own genuinely-missing-today SignOn is masked by the stale match. Proven on a seeded probe (show
+-- airs day 1 with a SignOn, then airs again day 2 with none): the old query rendered day 2 `true`; this
+-- one renders `f`. `show_transitions_windowed.prev_transition_at` — a `lag()` over the TRANSITIONS
+-- themselves, not over raw booth_log rows — carries that lower edge, bounding the match to the
+-- transition's own run. It is NULL only for the very first transition this database has ever recorded,
+-- where there is by definition no earlier data to false-match against, so the bound is correctly open
+-- there alone. `has_show_stamped_sign_on = false` on any row here is the gate failing this half.
+with track_rows as (
+ select
+ occurred_at,
+ segment_kind,
+ show_id,
+ lag(show_id) over (order by occurred_at, id) as prev_show_id
+ from station.booth_log
+ where kind = 'track-started'
+),
+show_transitions as (
+ select occurred_at, show_id
+ from track_rows
+ where show_id is not null
+ and show_id is distinct from prev_show_id
+),
+show_transitions_windowed as (
+ select
+ occurred_at as transition_at,
+ show_id,
+ lag(occurred_at) over (order by occurred_at) as prev_transition_at
+ from show_transitions
+)
+select
+ transition_at,
+ show_id,
+ exists (
+ select 1
+ from station.booth_log sign_on
+ where sign_on.kind = 'track-started'
+ and sign_on.segment_kind = 'SignOn'
+ and sign_on.show_id = show_transitions_windowed.show_id
+ and sign_on.occurred_at <= show_transitions_windowed.transition_at
+ and (
+ show_transitions_windowed.prev_transition_at is null
+ or sign_on.occurred_at > show_transitions_windowed.prev_transition_at
+ )
+ ) as has_show_stamped_sign_on
+from show_transitions_windowed
+order by transition_at;
+
+-- Half (b): every FULL HOUR inside a show has >= 1 show-stamped `StationId` row.
+--
+-- "Full hour inside a show" is read from booth_log's own air-time stamp alone (never
+-- segment_schedule): an hour whose `track-started` rows are ALL stamped with the SAME `show_id` — no
+-- showless row, no show change — within that hour. An hour with no track-started rows at all is
+-- structurally absent from this result (nothing to group), the same "silence isn't this gate's
+-- concern" posture the F113.2 header above already states.
+--
+-- `bool_or` returns NULL, not `false`, when every input row's own predicate evaluates to NULL rather
+-- than false — exactly what an all-music show hour does, since `segment_kind = 'StationId'` is itself
+-- NULL (SQL three-valued logic, not false) on every row where `segment_kind` is NULL. That is the EXACT
+-- failure case this half exists to catch, so a bare `bool_or` renders it as a blank cell instead of the
+-- `f` a human running this manually needs to see — indistinguishable from "not applicable" rather than
+-- "gate failed here". `coalesce(..., false)` closes that gap. `has_show_stamped_station_id = false` on
+-- any row here is the gate failing this half.
+with hourly_show_occupancy as (
+ select
+ date_trunc('hour', occurred_at) as broadcast_hour,
+ count(*) filter (where show_id is null) as showless_row_count,
+ count(distinct show_id) filter (where show_id is not null) as distinct_show_count,
+ min(show_id) as show_id,
+ coalesce(bool_or(segment_kind = 'StationId' and show_id is not null), false)
+ as has_show_stamped_station_id
+ from station.booth_log
+ where kind = 'track-started'
+ group by date_trunc('hour', occurred_at)
+)
+select broadcast_hour, show_id, has_show_stamped_station_id
+from hourly_show_occupancy
+where showless_row_count = 0
+ and distinct_show_count = 1
+order by broadcast_hour;