# Phase 9 — Release Medium Types

Status: spec / design. Author: product-designer. Date: 2026-06-12.
**Plan only — no code edits made by this doc.**

Cross-references: `PLAN.md §9` (the concise phase entry, four waves), `COMPLETED.md §8.6`
("Music through Every Medium" home-page section — the public-facing three-medium framing already
landed and these browse surfaces are where those cards point), `product-notes/phase-8-cms-track-browser.md`
(the `ReleaseEntity` normalization and the CMS browser components this phase extends), memory
*One source, multiple views* and *Design for adaptability up front*.

---

## 0. Why this phase exists, and what already landed

Phase 8 normalized the schema into `ReleaseEntity` + slim `TrackEntity`, and built the CMS browse
surface (`CmsTrackGrid`, `CmsAlbumBrowser`, `CmsGenreBrowser`) plus the public `AlbumsView` /
`GenresView`. COMPLETED §8.6 then pivoted the public home page from a genre-taxonomy block to a
**three-medium** editorial block: **Studio / Live / DJ Mix**. So the medium vocabulary is *already
user-visible* — but it is purely presentational copy on the home page today. Nothing in the data
model, the CMS, or the browse routes knows what a "medium" is.

Phase 9 makes the medium real: a top-level discriminator on every release, medium-specific metadata
where a medium needs more than a Studio cut does, and the CMS + public surfaces to author and browse
by medium. The home-page cards from §8.6 get real destinations.

**The three media (Daniel's framing):**

| Medium | Enum | What it is | Extra data beyond a Release |
|---|---|---|---|
| Studio CUTS | `Cut` | Studio recordings. **The only medium that uses `ReleaseType`** (Single/EP/Album). | none — a Cut *is* the base `ReleaseEntity` |
| Live SESSIONS | `Session` | A single live track with a distinct **hero image** (separate from cover art). | `SessionMetadata` (hero image) |
| DJ MIXES | `Mix` | A single long track with a preprocessed **high-resolution waveform** datum. | `MixMetadata` (waveform datum ref) |

---

## 1. The core design move — discriminator enum + optional metadata table

This is the SOLID spine of the phase, and the thing most worth getting right because "there may be
additional release varieties in the future."

**The pattern:** `ReleaseMedium` is a top-level enum discriminator on `ReleaseEntity`. Each medium
that needs data *beyond* the base release fields gets its **own 1:1 metadata table** keyed to the
release. A medium that needs nothing extra (Studio `Cut`) gets no table — it *is* the base
`ReleaseEntity`.

```
ReleaseEntity
├── Medium: ReleaseMedium   (Cut | Session | Mix)   ← the discriminator
├── ReleaseType             (valid only when Medium == Cut)
├── ...base release fields...
│
├── SessionMetadata?   1:1, present iff Medium == Session   { HeroImageEntryKey }
└── MixMetadata?       1:1, present iff Medium == Mix        { WaveformEntryKey }
```

### Why this over the alternatives

Three shapes were on the table:

**(A) Single wide table.** Add `HeroImageEntryKey` and `WaveformEntryKey` (and every future medium's
columns) directly onto `ReleaseEntity`, nullable. *Rejected.* Every new medium widens the central
table with columns that are null for every other medium. The table becomes a union of all media,
and the "valid only when Medium == X" invariant multiplies across columns with no structural support.
This is the "god table" anti-pattern — exactly what Phase 8 normalized *away* from.

**(B) Table-per-Hierarchy (EF TPH discriminator with subclasses).** `SessionRelease : ReleaseEntity`,
`MixRelease : ReleaseEntity`, EF maps them into one table with a discriminator column. *Rejected for
now.* TPH still collapses to one wide table at the storage layer (same null-column sprawl as A), and
TPT (table-per-type) forces a subclass even for media that add nothing, plus complicates the
`Tracks` collection and every query that doesn't care about medium. The polymorphism buys little
when most reads are "give me releases of medium X" — a discriminator column answers that without a
type hierarchy.

**(C) Discriminator enum + optional sibling metadata tables (RECOMMENDED).** The medium is a plain
enum column — cheap to filter, cheap to index, trivially extensible (add an enum value). Medium-
specific data lives in its own table, joined only when that medium is queried. Adding a future
medium is: add an enum value; *if* it needs extra data, add one metadata table. The base
`ReleaseEntity` never changes shape. This is Open/Closed at the schema level — extension by addition,
not modification.

**The cost of (C):** medium-specific reads need a join (or an `Include`) to pull the metadata. That
is the right cost to pay — it is paid only on the medium-specific surfaces (Session detail, Mix
detail), and the base release queries (the bulk of traffic) never touch the metadata tables.

### The `ReleaseType`-only-for-Cut invariant

`ReleaseType` (Single/EP/Album) is semantically meaningful **only when `Medium == Cut`**. For
Session and Mix it is noise. The question is how to enforce it.

**Recommendation: domain rule, not DB constraint — by choice, not necessity.** EF Core *can*
express this constraint: check-constraint support is first-class
(`ToTable(t => t.HasCheckConstraint(...))`), generated and versioned in migrations, and fully
supported by Npgsql. The honest position is that we **choose** not to constrain, because the
invariant is advisory ("meaningless," not "invalid" — a stale `Single` on a Session row harms
nothing) and the read model enforces it at a single mapping point (§2.4). Instead:

- **Service layer** is the enforcement point. `ReleaseType` is *ignored* (or reset to its default)
  when `Medium != Cut` on write; readers of a non-Cut release should not surface it.
- **CMS validation** hides the `ReleaseType` field entirely unless `Medium == Cut` (so an admin
  cannot set a contradictory value in the first place).
- **Leave the column on `ReleaseEntity`** with its existing default. It is simply unused for non-Cut
  media. This keeps the schema flat for the base entity and avoids a nullable-everywhere refactor.

**Named exception — `ReleaseType` is Cut-specific data on the base table.** By this spec's own
option-(A) rejection, `ReleaseType` is "a column that is meaningless for every other medium" and the
rigorous application of the pattern would be a `CutMetadata { ReleaseType }` table, making Cut
symmetric with Session and Mix. That option was considered and **deliberately rejected**:

- **Hot path.** The `/cuts` gallery (the highest-traffic browse read) displays Single/EP/Album on
  every card. Moving `ReleaseType` into a metadata table would put a join on that hot path — the
  very cost this design otherwise pays only on rare, single-row medium-specific reads.
- **Churn.** Phase 8 §8.0 just landed `ReleaseEntity` with `ReleaseType`; relocating it weeks after
  a breaking normalization is a data migration for structural purity with no behavioral win.

This is a **named, reasoned exception, not a precedent**. Future medium designers must not
cargo-cult a base-table column for *their* medium's data — the default remains the metadata table —
nor "fix" `ReleaseType` into a table without weighing the read-path cost above.

Document the invariant *and this exception* in `ReleaseConfiguration` and the service so future
readers know `ReleaseType` on a Session/Mix is meaningless, not missing.

---

## 2. Data contracts

### 2.1 New enum

```csharp
// DeepDrftModels/Enums/ReleaseMedium.cs
public enum ReleaseMedium
{
    Cut,      // Studio recording — uses ReleaseType (Single/EP/Album)
    Session,  // Single live track + hero image
    Mix       // Single long track + preprocessed waveform datum
}
```

`Cut` is value 0 so it is the default, matching the brief and keeping existing/migrated releases as
studio cuts without a data migration of the discriminator itself.

### 2.2 `ReleaseEntity` gains

```csharp
public ReleaseMedium Medium { get; set; } = ReleaseMedium.Cut;
public SessionMetadata? SessionMetadata { get; set; }   // nav, 1:1, present iff Medium == Session
public MixMetadata? MixMetadata { get; set; }           // nav, 1:1, present iff Medium == Mix
```

### 2.3 New metadata entities

```csharp
// SessionMetadata — 1:1 with ReleaseEntity, present only for Session releases
public class SessionMetadata : BaseEntity, IEntity
{
    public long ReleaseId { get; set; }            // FK + 1:1 (unique)
    public ReleaseEntity Release { get; set; }
    public required string HeroImageEntryKey { get; set; }  // entry key in the image vault
}

// MixMetadata — 1:1 with ReleaseEntity, present only for Mix releases
public class MixMetadata : BaseEntity, IEntity
{
    public long ReleaseId { get; set; }            // FK + 1:1 (unique)
    public ReleaseEntity Release { get; set; }
    public required string WaveformEntryKey { get; set; }  // entry key for the waveform datum in the vault
}
```

**Waveform storage shape — resolved (see §7.1).** Two readings of "preprocessed waveform datum"
were on the table:

- **(i) Vault blob + entry-key reference (RECOMMENDED).** The high-resolution waveform is a binary
  datum stored in a FileDatabase vault (mirroring how audio binaries live in the `tracks` vault),
  and `MixMetadata.WaveformEntryKey` is the lookup key. Keeps the (potentially large) datum out of
  the SQL row, consistent with the dual-database split (SQL = metadata, vault = binary). This is the
  recommendation — the existing player-bar waveform preprocessing already produces a byte-level
  datum; the Mix variant is the same pipeline at higher resolution, stored durably in the vault
  rather than computed per-play.
- **(ii) JSON column on `MixMetadata`.** Store the waveform points as a JSON array directly in SQL.
  Simpler (no vault round-trip) but bloats the SQL row and breaks the "binary lives in the vault"
  rule. Only defensible if the datum is small (a few hundred points). *Not recommended* for a
  *high-resolution* waveform.

**Resolved: (i) vault blob.** Settled by the server-side waveform-trigger design (§3.4) — the API
computes and stores the datum vault-side and records only the entry key in SQL, so a JSON column
never enters the flow. Wave 1 adds only the SQL column; the vault write rides the existing vault
abstraction server-side.

### 2.4 DTOs

- `ReleaseDto` gains `ReleaseMedium Medium`.
- `ReleaseDto.ReleaseType` becomes **nullable** (`ReleaseType?`), nulled at the single entity→DTO
  mapping point when `Medium != Cut`. A non-nullable mirror of the entity would serialize a
  confidently wrong `Single` on every Session/Mix and turn the "readers should not surface it" rule
  into a discipline imposed on every consumer. Nullable-at-the-mapping-point means **one producer
  enforces the invariant; zero consumers need to know the rule**. (The *entity* column stays
  non-nullable — this is a read-model fix.)
- New `SessionMetadataDto { HeroImageEntryKey }` and `MixMetadataDto { WaveformEntryKey }`.
- `ReleaseDto` gains optional `SessionMetadata? SessionMetadata` / `MixMetadata? MixMetadata`
  (populated on reads of the relevant medium, null otherwise — mirroring the nested-`Release`
  pattern Phase 8 chose for `TrackDto`). Do **not** denormalize hero-image / waveform onto every
  `ReleaseDto`; they ride the nested metadata object, present only for the matching medium.

---

## 3. CMS surface

### 3.1 "Release Archive" tab (was "Genre")

The third tab in `TrackList.razor` is renamed **Genre → Release Archive**. Today that tab renders
`CmsGenreBrowser` (a card grid + accordion). The Release Archive tab instead renders a **medium
card group** — one card per `ReleaseMedium` value, styled like the existing `CmsGenreBrowser` cards
(same `MudCard` + swatch idiom, see the verified markup in §6). The medium cards do *not* expand
inline; each **navigates** into a medium-specific browser.

**SOLID note — drive the cards off the enum, not a hardcoded list.** Render one card per
`Enum.GetValues<ReleaseMedium>()` (with a small display-metadata lookup for label / descriptor /
swatch — a `static IReadOnlyDictionary<ReleaseMedium, MediumCardInfo>` or a `[Display]` attribute
read via reflection). Adding a future medium then surfaces a new card automatically. **Do not write a
three-arm `switch` in the markup** — that is the modification-not-extension trap the phase is meant
to avoid. The *navigation target* per medium is the one place a mapping is unavoidable; keep that
mapping in one table, not scattered.

| Medium card | Navigates to | Browser component |
|---|---|---|
| Studio (Cut) | existing album browse, filtered `Medium == Cut` | `CmsAlbumBrowser` (reused, with a `MediumFilter`) |
| Live (Session) | new | `CmsSessionBrowser` (new) |
| DJ Mix (Mix) | new | `CmsMixBrowser` (new) |

What the Genre browse becomes: the genre card grid does not disappear from the product — genre is
still a meaningful axis — but it is no longer the *third top-level tab*. Decide with Daniel whether
genre browsing (a) moves under the Studio/Cut browser as a secondary filter, (b) stays reachable via
a route but loses its tab, or (c) is retired in the CMS. **Recommend (b)** — keep `CmsGenreBrowser`
reachable (it's built and works) but give the top-level third tab to Release Archive. *Open question
for Daniel — the brief says "rename," which implies the genre tab's slot is taken; confirm genre
browse isn't being dropped wholesale.*

### 3.2 New browsers

- **`CmsSessionBrowser.razor`** — single-track sessions. Card/row per Session release showing cover
  + hero thumbnail, session name, artist. Reuses `CmsTrackGrid`-style data access filtered to
  `Medium == Session`; each session is one track, so the album-parent/child expansion of
  `CmsAlbumBrowser` is unnecessary — a flat list is the right shape. Row Edit → `TrackEdit` (or a
  session-aware edit); row actions include hero-image management.
- **`CmsMixBrowser.razor`** — single-track mixes. Flat list filtered to `Medium == Mix`. Each row
  shows waveform-generation status (mirroring the Phase 8 `HasWaveformProfile` in-grid status idiom —
  a Mix without a generated high-res waveform is incomplete). Per-row "Generate Waveform" action.

Both reuse `CmsTrackGrid` where the layout fits, parameterized by `MediumFilter` — same
"one grid, parameterized" DRY move Phase 8 established for genre. Where a medium's list genuinely
differs (hero thumb column for sessions, waveform-status column for mixes), those are additive
columns on a shared grid or a thin medium-specific wrapper — **not** a forked table.

### 3.3 Medium selector on the upload/edit forms

`TrackNew.razor`, `TrackEdit.razor`, `BatchUpload.razor`, `BatchEdit.razor`, and the shared
`AlbumHeaderFields.razor` gain a **`ReleaseMedium` selector** (`MudSelect<ReleaseMedium>`).

Conditional fields driven by the selector:

- `Medium == Cut` → show the existing `ReleaseType` (Single/EP/Album) field. Show album-header
  multi-track ergonomics as today.
- `Medium == Session` → **hide** `ReleaseType`; show a **hero-image upload** field (in addition to
  cover art). Constrain to a single track.
- `Medium == Mix` → **hide** `ReleaseType`; the upload triggers **waveform preprocessing** (§3.4).
  Constrain to a single track.

**One dispatch point, not five scattered conditionals.** A small `@if` per medium would be fine in
*one* form — but the same medium-conditional logic appears in five files, which is exactly the
scattered-`switch` smell §8 forbids, and the resolved single-track invariant (§7.8) raises the
stakes: the conditionals gate structural form shape (multi-track master list present/absent), not
just a field or two. Instead, extract **per-medium field-section components** — `CutFields`,
`SessionFields`, `MixFields` — with plain explicit markup inside (no clever generics; the
anti-over-abstraction instinct is right *within* a section). **One dispatch point** — a
`MediumFields` component (or equivalent) holding the single `@switch` — is embedded by all five
forms. Adding a fourth medium is then one new section component + one dispatch entry, and §8 can
cite that cost honestly.

### 3.4 Mix waveform pipeline (server-side trigger)

When a Mix is uploaded, the CMS calls a **server-side waveform trigger** — it does **not** compute
or carry the datum itself. `POST api/release/{id}/mix/waveform` (ApiKey, **no request body**): the
API fetches the mix audio from its own vault, computes the **high-resolution** waveform via
`WaveformProfileService` parameterized by resolution, stores the datum durably in the vault, and
sets `MixMetadata.WaveformEntryKey`. This mirrors the existing low-res idiom
(`POST api/track/{trackId}/waveform` is body-less; the server computes from vault audio it already
owns) and makes the derived datum **correct by construction** — no trusting an ApiKey holder's
blob, and no inverting the authority by having a network client compute what the server can derive
from its own data.

The CMS upload flow becomes: upload audio (existing `UploadTrackAsync`) → call the trigger. No
datum computation client-side — the CMS has no in-process data layer by standing convention and
must not grow one (or a copy of the preprocessor) for this. The per-row "Generate Waveform" action
in `CmsMixBrowser` is the recovery path for a mix whose waveform failed or predates the feature —
the same body-less trigger, with no download/recompute/re-upload round-trip of the catalogue's
longest audio files.

**Reuse point — resolved (see §7.3).** The existing player-bar waveform preprocessor is the *same
code path* parameterized by resolution, not a copy. `WaveformProfileService.ComputeAndStoreAsync`
currently takes its resolution from injected `WaveformProfileOptions` (`BucketCount = 512`); the
refactor is a per-call resolution/profile parameter plus a distinct entry-key/vault target for the
high-res datum — small, server-side, and part of Wave 2 alongside the trigger endpoint. (This
honours the *One source, multiple views* preference: the player-bar peek and the Mix high-res datum
are two resolutions of one pipeline, not two pipelines.)

### 3.5 Session hero-image pipeline (CMS)

Session uploads provide a **hero-image** upload path (distinct from cover art). The hero image is
stored in the **image vault** (same vault as cover art, different entry key) and recorded in
`SessionMetadata.HeroImageEntryKey`. Flow: `POST api/release/{id}/session/hero-image` (multipart,
resource-addressed — the release id rides the route, consistent with the waveform trigger's shape) →
image vault write → `SessionMetadata.HeroImageEntryKey` set. Mirrors the existing cover-art
`UploadImageAsync` + link-via-`UpdateAsync` pattern Phase 8 documented; the only difference is the
target field.

---

## 4. API surface

The phase prefers **a new `release` controller** over bolting medium concerns onto the track
endpoints, because the unit of medium is the *release*, not the track. Endpoints:

| Endpoint | Auth | Purpose |
|---|---|---|
| `GET api/release?medium={cut\|session\|mix}&page=&pageSize=&sort=` | unauth (public reads) | Paginated releases of a medium, with the matching medium's metadata `Include`d via the projection map. The medium filter is additive — omitting it returns all releases. |
| `GET api/release/{id}` | unauth | Single release with **both** metadata navs always-`Include`d (nulls for non-matching media). Feeds the public detail views. |
| `POST api/release/{id}/session/hero-image` | ApiKey | Multipart hero-image upload → image vault → set `SessionMetadata.HeroImageEntryKey`. Resource-addressed: the release id is in the route. |
| `POST api/release/{id}/mix/waveform` | ApiKey | **Server-side trigger, no body.** API fetches the mix audio from its own vault, computes the high-res waveform via the parameterized `WaveformProfileService`, stores the datum in the vault, sets `MixMetadata.WaveformEntryKey` (§3.4). |

**Decision — new endpoints vs. query-param extension of `api/track/page`.** The brief offers either.
Recommend a **new `api/release` family** rather than overloading `api/track/page`:

- The browse axis is now the *release* (medium lives on the release, and Session/Mix are
  single-track releases where "list of tracks" is the wrong primary shape).
- Medium-specific metadata (`Include` of `SessionMetadata` / `MixMetadata`) belongs on a release
  read, not a track-page read.
- `api/track/page` stays focused on the track-list use cases Phase 8 built; it can *gain* a
  `medium=` passthrough filter cheaply if a track-level medium filter is ever needed, but the
  primary medium-browse path is release-cardinal.

This keeps each endpoint cohesive (SRP at the HTTP boundary) rather than growing `api/track/page`
into the everything-endpoint.

**Read shape + extensibility — two reads, two strategies.**

- **`GET api/release/{id}`** — **always-`Include` both metadata navs.** The medium is unknown until
  the row is fetched, so a per-medium projection map here would either double-query or be bypassed.
  With exactly two 1:1 navs behind unique FK indexes, including both is cheap and naturally yields
  nulls for non-matching media — no per-medium branching, no map needed on the by-id read.
- **`GET api/release?medium=`** — here the **per-medium projection map** earns its keep: it avoids
  joining every metadata table on every page query. The map carries a **dual responsibility**: (1)
  selecting the right `Include` per medium, and (2) acting as the **single enforcement point for
  the medium↔metadata correlation** — a metadata DTO is populated iff the medium matches (§2.4).
  That second responsibility is the reason the map must not be inlined into the controller. Not a
  hardcoded `if session … else if mix …` chain — though honesty demands noting a dictionary keyed
  by an enum *is* a switch, data-structured.

The extensibility guarantee, stated honestly, is **"one entry, one file"** — a future medium adds
one declaration in one known place and the read *logic* is untouched — not "zero changes to the
controller." Same Open/Closed discipline as the CMS cards.

---

## 5. Public site surface

### 5.1 ARCHIVE nav with sub-items

Replace the current **RELEASES / SESSIONS / MIXES** nav links (defined in
`DeepDrftPublic.Client/Layout/Pages.cs` — note: `Pages.cs` lives in `Layout/`, not `Pages/`) with a
single **ARCHIVE** item.

- **Desktop:** hovering ARCHIVE shows a **MudBlazor popover** (`MudMenu` on hover, or a `MudPopover`
  triggered on mouse-enter) with three sub-items: **CUTS / SESSIONS / MIXES** → `/cuts`,
  `/sessions`, `/mixes`.
- **Mobile / direct nav:** ARCHIVE links to an **overview page** (`/archive`) — the three media as
  large cards, the mobile-friendly equivalent of the desktop popover. (The home-page §8.6 three-card
  block is the design precedent for this overview; `/archive` can reuse that card idiom.)

This fixes the current **dead links** — today's "Sessions" and "Mixes" nav items point nowhere. They
resolve to the new `/sessions` and `/mixes` routes.

**Nav data note.** `DeepDrftMenu.razor` renders `Pages.MenuPages` as a flat `<a>` list (verified).
ARCHIVE-with-popover needs a nav item that can carry *children*. Either (a) extend the `MenuPages`
model with an optional `Children` collection and special-case rendering of items that have children,
or (b) hardcode ARCHIVE as a distinct popover component in `DeepDrftMenu` alongside the flat list.
**Recommend (a)** — a `Children` collection on the nav model generalizes (a future "About" dropdown
gets it free) and keeps the menu data-driven. Mobile renders children as indented sub-links inside
the existing hamburger panel.

**`Children` semantics — pinned down now, or the model grows warts:**

1. **Dual-role nodes.** ARCHIVE carries both a `Route` (`/archive`) *and* `Children`. The semantics,
   defined once: **desktop hover** opens the children popover; **desktop click** navigates to
   `/archive`; **mobile** renders the parent as a link with its children indented below it. Do not
   leave this to the implementer.
2. **Depth cap: one level only.** Children do not themselves have children. A future need for
   deeper nesting is a redesign of the nav, not a recursion in the model.

### 5.2 CUTS — `/cuts`

Reuses the existing **`AlbumsView`** layout, filtered to `Medium == Cut`. Studio Singles / EPs /
Albums all appear here exactly as the current Releases page shows them. Lowest-effort of the three —
it is the current `AlbumsView` with a medium filter on its data source.

**Recommendation:** parameterize `AlbumsView`'s data load with a medium filter rather than forking a
new component. `/cuts` is `AlbumsView` with `Medium == Cut`; if a future "/all releases" view wants
the unfiltered set, the same component serves it. (Whether the *old* `/releases`-style route
redirects to `/cuts` or is retired is a small routing call — flag.)

### 5.3 SESSIONS — `/sessions` + `/sessions/{id}`

- **Gallery (`/sessions`):** card grid of session cards — cover image, session name, artist. New
  component, but borrow `AlbumsView`'s card-gallery skeleton (it's the same gallery shape with a
  different card face).
- **Detail (`/sessions/{id}`):** mirrors `TrackDetail` but the **hero image is the dominant
  above-the-fold visual**, cover art secondary. New `SessionDetail` page (or `TrackDetail`
  parameterized with a "hero-dominant" layout variant — see below).

**Reuse decision — new pages vs. parameterized `TrackDetail`.** `TrackDetail` today is cover-led.
Session detail is hero-led; Mix detail is waveform-led. Three readings:

- **(i) Three separate detail pages.** Clearest per-medium, most duplication of the shared scaffolding
  (play affordance, metadata block, player wiring).
- **(ii) One `TrackDetail` with a layout-variant switch.** Least duplication, but a single component
  branching on medium for above-the-fold layout gets busy.
- **(iii) Shared scaffolding component + per-medium "hero slot" (RECOMMENDED).** Extract the common
  detail scaffolding (metadata, play control, player wiring) into a `ReleaseDetailScaffold` that
  takes a `RenderFragment HeroContent`. `CutDetail`/`SessionDetail`/`MixDetail` each supply their
  hero (cover, hero image, waveform visualizer respectively) and compose the shared scaffold. This is
  the same DRY-by-composition move Phase 8 used for `BatchUpload`/`BatchEdit` sub-component
  extraction — and it honours *One source, multiple views*: one scaffold, three hero renderings.

Recommend (iii). It bounds the duplication while letting each medium own its distinctive
above-the-fold without polluting the others.

**`ReleaseDetailScaffold` contract (written down, or a god-component grows here):**

- The scaffold owns exactly the **invariant trio: metadata block, play affordance, player wiring**.
  Nothing else.
- *Every* per-medium variance rides a slot (`RenderFragment`) supplied by the page. Named slots are
  fine where genuinely needed (e.g. `BodyContent` for the Cut/Album multi-track listing); **a
  boolean layout parameter on the scaffold is a design failure — that variance belongs in a slot.**
  If the scaffold accumulates flags (`ShowTrackList`, `HeroAboveMeta`, …), it has become option (ii)
  wearing a composition costume.

**`TrackDetail`'s fate — decided explicitly, not by silence.** `TrackDetail` is **refactored onto
the scaffold in Wave 4** (recommended). It is the scaffold's extraction source, so this is nearly
free — and it prevents two sources of detail-page truth (the existing track-cardinal `TrackDetail`
that `AlbumsView`/`/cuts` cards land on vs. the scaffold-composed Session/Mix details), which *One
source, multiple views* forbids. If Wave 4 pressure forces a deferral, the fork must be recorded as
deliberate debt with a retirement note; silence here guarantees the fork happens by accident.

### 5.4 MIXES — `/mixes` + `/mixes/{id}`

- **Gallery (`/mixes`):** card grid like sessions.
- **Detail (`/mixes/{id}`):** the page's defining visual is a **`MixWaveformVisualizer`** component
  fed by the preprocessed waveform datum from `MixMetadata.WaveformEntryKey`, rendered as the
  **full-page background** of the detail page (see the rendering note below — a background
  treatment, not a hero-slot block). Designed as a **named, reusable component** (the brief is
  explicit) so it can be reused — e.g., a future mix card preview.

**`MixWaveformVisualizer` design notes.**

- **Input:** the waveform datum (fetched via the `WaveformEntryKey` → vault read, served through a
  content endpoint like the existing audio/image proxies). Component takes the datum (or a URL to
  it) + optional playback-position binding.
- **Rendering — its own visual language, full-page background (Daniel's condition).**
  `MixWaveformVisualizer` is a dedicated **full-page background visual** for the Mix detail page —
  it is explicitly **not** a player-bar component. The `SpectrumVisualizer` / `LevelMeterFab`
  peak-bar idiom is **reserved for the player bar only**; this component must **not** borrow it.
  Instead it **establishes its own visual language**: a high-resolution, sophisticated visualizer
  rendered as the page background behind the detail content. The two components are siblings in
  subject matter (waveforms) but carry **entirely separate design treatments** — the player-bar
  peek and the Mix background share a data pipeline (§3.4), never a look.
- **Interactivity (optional, flag):** clicking the waveform could seek (the streaming player already
  supports seek-beyond-buffer). Worth designing the component's position-binding seam *now* even if
  seek-on-waveform-click is deferred — designing the seam costs little, backfilling it costs a
  rewrite. (Memory: *Design for adaptability up front* — defer the feature, design the seam.)

---

## 6. Verified facts (read against live source 2026-06-12)

- `ReleaseEntity` exists (`DeepDrftModels/Entities/ReleaseEntity.cs`), inherits `BaseEntity`, has
  `Title, Artist, Genre?, ReleaseDate?, ImagePath?, ReleaseType (default Single), CreatedByUserId?,
  Tracks`. **No `Medium` field yet.**
- `ReleaseType` enum (`DeepDrftModels/Enums/ReleaseType.cs`): `Single, EP, Album`.
- CMS browser components **already exist** (Phase 8 landed): `CmsTrackGrid.razor`,
  `CmsAlbumBrowser.razor`, `CmsGenreBrowser.razor` in `DeepDrftManager/Components/Pages/Tracks/`.
- `CmsGenreBrowser` card idiom (verified): `MudGrid Spacing=3` → `MudItem xs=12 sm=6 md=4` →
  `MudCard` with an `@onclick` toggle, a swatch `<div>`, and `MudCardContent` (`Typo.h6` name +
  `Typo.body2` count). The Release Archive medium cards should match this idiom. `CmsTrackGrid`
  already takes `GenreFilter` / `ShowAddButton` parameters — the precedent for a `MediumFilter`.
- Public nav: `DeepDrftMenu.razor` renders `Pages.MenuPages` as a **flat `<a>` list** (desktop) and
  inside a hamburger panel (mobile). `Pages.cs` lives in **`DeepDrftPublic.Client/Layout/`** (the
  root `CLAUDE.md` for the client confirms `Layout/Pages.cs`, "MenuPages for header, AllPages for
  exhaustive list"). The nav has **no popover/dropdown mechanism today** — ARCHIVE introduces the
  first one.
- Public home page **already** carries the three-medium framing (Studio / Live / DJ Mix) as
  editorial cards — COMPLETED §8.6, landed 2026-06-12. Those cards currently have no destinations;
  Phase 9's `/cuts`, `/sessions`, `/mixes` (or `/archive`) are where they should point.
- The dual-database split: SQL = metadata (EF), vault = binary (FileDatabase). Waveform datum
  (resolved — vault blob, §7.1) and hero/cover images live vault-side; medium discriminator +
  metadata-table rows live SQL-side.
- The existing low-res waveform pipeline is **server-side**: `POST api/track/{trackId}/waveform`
  takes no body — `TrackController` pulls the audio from its own vault and computes via
  `WaveformProfileService.ComputeAndStoreAsync` (resolution from injected `WaveformProfileOptions`,
  `BucketCount = 512`). The Mix trigger (§3.4) mirrors this idiom.

---

## 7. Open questions (need Daniel before build)

1. **Resolved: Waveform storage shape (§2.3).** Vault blob + `WaveformEntryKey` confirmed. Settled
   by the SOLID-review F1 endpoint redesign: the server-side trigger (§3.4) computes and stores the
   datum in the vault and records only the entry key in SQL — a JSON column never enters the flow.
   Wave 1 adds only the SQL column; the vault write rides the existing vault abstraction server-side.
2. **Resolved: Genre browse fate (§3.1).** Daniel's decision: the Genre tab slot is taken by Release Archive (Wave 3A as specced); the existing genre browse functionality is deprioritized and stays route-reachable as-is — no active development, no retirement. The team should not remove it.
3. **Resolved: Waveform preprocessor reuse (§3.4).** One server-side parameterized pipeline — not a
   CMS-side copy. Settled by the SOLID-review F1 endpoint redesign: `WaveformProfileService` gains a
   per-call resolution/profile parameter (today it reads `WaveformProfileOptions.BucketCount = 512`)
   plus a distinct entry-key/vault target for the high-res datum. The refactor lands in Wave 2
   alongside the trigger endpoint, not Wave 3.
4. **Detail-page strategy (§5.3).** Three separate detail pages vs. one branching `TrackDetail` vs.
   shared `ReleaseDetailScaffold` + per-medium hero slot (recommended). Sets the public-site Wave 4
   shape. *Recommend the scaffold.*
5. **Resolved: Old `/albums` route fate (§5.2).** Daniel's decision: when `/cuts` lands, the existing `/albums` route issues a redirect to `/cuts`. Old URLs keep working; no hard 404.
6. **Nav model children (§5.1).** Extend `MenuPages` with an optional `Children` collection
   (recommended, generalizes) vs. hardcode ARCHIVE as a bespoke popover component. *Recommend the
   model extension.*
7. **`MixWaveformVisualizer` seek-on-click (§5.4).** Design the position-binding seam now even if
   click-to-seek ships later? *Recommend design the seam, defer the feature.*
8. **Resolved: Single-track invariant for Session/Mix (§3.3).** Daniel's decision: hard constraint. One track per Session/Mix release is enforced at upload — the CMS form for those media drops the multi-track master list entirely.

---

## 8. SOLID summary — why this is extension-shaped

The phase is designed so a **fourth medium** (say, "Video," already hinted in `PLAN.md §3.1`) is an
**addition, not a modification**. Two honest lists — what never changes, and what the addition
actually costs:

**(a) What is never modified — the true Open/Closed payoff:**

- the base `ReleaseEntity` shape,
- the other media's metadata tables,
- the existing browse grids,
- the existing endpoints (read *logic* included — the list read grows one projection entry, §4),
- the existing detail scaffolding (`ReleaseDetailScaffold` and the other media's detail pages).

**(b) The full additive surface — each artifact with its single known location:**

1. one `ReleaseMedium` enum value (`DeepDrftModels/Enums/ReleaseMedium.cs`);
2. *if* the medium needs extra data: one metadata entity + EF config + migration;
3. *if* extra data: one metadata DTO;
4. one display-metadata entry in the CMS card lookup (one location in `DeepDrftManager`);
5. one nav `Children` entry in `Pages.cs` (`DeepDrftPublic.Client/Layout/`);
6. one medium→route mapping entry (one location);
7. one per-medium form-section component, registered at the single `MediumFields` dispatch point
   (§3.3) — one new component + one dispatch entry, not five form edits;
8. one CMS browser component (or a grid-parameterization entry where the shared grid fits, §3.2);
9. a public gallery page + detail page + routes (new routable components — Blazor routes are
   attribute-based, so a new medium means new pages);
10. one projection entry in the `api/release?medium=` map (§4);
11. its own hero-slot / background renderer composed onto the detail scaffold (§5.3, §5.4).

That is more than the "five items" an earlier draft claimed — the additive surface is real and
recurring, and understating it is precisely the "extensible on paper" failure this section exists
to warn against. The claim worth defending is (a): a new medium modifies **zero existing tables,
zero existing media's components, zero existing endpoints**. Where the design admits a mapping
(card → browser, medium → projection, medium → form section, medium → route, medium → detail
renderer), that mapping is kept in **one table per concern**, never duplicated as scattered
`switch`/`if` chains. That single-table-of-mappings discipline is the difference between
"extensible" and "extensible on paper."

**LSP rationale — why there is no type hierarchy to violate.** Rejecting option (B) in §1 (EF TPH
subclasses) was the correct Liskov move, not merely a storage-layer call. `SessionRelease` /
`MixRelease` subtypes would promise a substitutability that is a lie — most code paths ("give me
releases") don't care about medium, and the ones that do would downcast. In this design there is
**no inheritance to downcast through**: medium variance rides *data* (the metadata rows) and
*composition* (the detail slots), not subtypes.
