Skip to content
Merged
6 changes: 4 additions & 2 deletions SEAMS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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 |
|---|---|---|---|
Expand Down Expand Up @@ -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 | β€” |

Expand Down
16 changes: 16 additions & 0 deletions admin-ui/__specs__/catalog-show-shelf.spec.tsx
Original file line number Diff line number Diff line change
@@ -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");
});
});
10 changes: 10 additions & 0 deletions admin-ui/__specs__/imaging-show-scope.spec.tsx
Original file line number Diff line number Diff line change
@@ -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)");
});
});
15 changes: 15 additions & 0 deletions admin-ui/__specs__/schedule-show-picker.spec.tsx
Original file line number Diff line number Diff line change
@@ -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");
});
});
14 changes: 14 additions & 0 deletions admin-ui/__specs__/schedule-specials-form.spec.tsx
Original file line number Diff line number Diff line change
@@ -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");
});
});
22 changes: 22 additions & 0 deletions admin-ui/__specs__/shows-page.spec.tsx
Original file line number Diff line number Diff line change
@@ -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)");
});
});
9 changes: 9 additions & 0 deletions db/01-library.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
56 changes: 42 additions & 14 deletions db/06-station-settings-migration.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 β€”
Expand Down Expand Up @@ -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
Expand Down
75 changes: 75 additions & 0 deletions db/35-show-identity-migration.sh
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,20 @@
namespace GenWave.Core.Abstractions;

/// <summary>
/// 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
/// <c>GenWave.Orchestration</c> (which cannot see the Host's <c>IOptionsMonitor&lt;StationOptions&gt;</c>
/// or <see cref="IPersonaStore"/> directly) and the Host's live station configuration + persona
/// storage. Mirrors <see cref="IStationScopeProvider"/>'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 (<see cref="ResolveAsync"/>,
/// <see cref="ActivePersonaId"/>) and, since PLAN T242, the show (<see cref="ActiveShowId"/>) β€” because
/// a single implementation already owns the one on-air resolve both facts are read off (see
/// <c>OnAirPersonaAccessor</c>'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.
Expand Down Expand Up @@ -79,4 +87,24 @@ public interface IActivePersonaAccessor
/// </para>
/// </summary>
string? TryGetCachedName(long personaId) => null;

/// <summary>
/// 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 <see cref="ActivePersonaId"/>
/// 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 (<c>BoothLogWriter.Publish</c>) draws both stamps off
/// ONE dependency, the same "never a store round trip" way <see cref="ActivePersonaId"/> 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 <c>Publish</c>'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 <see cref="ActivePersonaId"/>: <see langword="null"/>
/// 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
/// <see cref="ActivePersonaId"/> 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.
/// </summary>
long? ActiveShowId => null;
}
Loading
Loading