From 6f63fe7d7ca0179848c06b0e3b4a1893da2ec8b6 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Fri, 12 Jun 2026 21:15:36 -0400 Subject: [PATCH] =?UTF-8?q?docs:=20amend=20Phase=209=20spec=20=E2=80=94=20?= =?UTF-8?q?apply=20SOLID=20review=20fixes=20F0-F13?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PLAN.md | 58 ++-- product-notes/phase-9-release-medium-types.md | 261 +++++++++++++----- 2 files changed, 220 insertions(+), 99 deletions(-) diff --git a/PLAN.md b/PLAN.md index 56cf1ea..f68f8e6 100644 --- a/PLAN.md +++ b/PLAN.md @@ -157,30 +157,30 @@ The public home page **already** carries the three-medium framing as editorial c **Design discipline throughout: extension, not modification.** Where a per-medium mapping is unavoidable (card → browser, medium → API projection, medium → detail hero), keep it in **one table per concern** — never a scattered three-arm `switch`. Drive CMS cards and nav sub-items off `Enum.GetValues()` + a display-metadata lookup, so a new medium surfaces automatically. -**The `ReleaseType`-only-for-`Cut` invariant.** Single/EP/Album is meaningful only when `Medium == Cut`. Enforce as a **domain rule** (service layer ignores/resets `ReleaseType` for non-`Cut`; CMS hides the field unless `Cut`), **not** a DB constraint — EF cannot express a conditional constraint cleanly and a raw `CHECK` is migration-fragile. The column stays on `ReleaseEntity` with its default, simply unused for non-`Cut` media. +**The `ReleaseType`-only-for-`Cut` invariant.** Single/EP/Album is meaningful only when `Medium == Cut`. Enforce as a **domain rule** (service layer ignores/resets `ReleaseType` for non-`Cut`; CMS hides the field unless `Cut`; `ReleaseDto.ReleaseType` is **nullable**, nulled at the single entity→DTO mapping point for non-`Cut` so one producer enforces and no consumer needs the rule), **not** a DB constraint — **by choice, not necessity**: EF Core supports check constraints first-class (`HasCheckConstraint`, versioned in migrations, Npgsql-supported), but the invariant is advisory ("meaningless," not "invalid") and the read model enforces it at one point. The column stays on `ReleaseEntity` as a **named exception** to the metadata-table pattern: a `CutMetadata` table was considered and rejected because the `/cuts` hot path reads `ReleaseType` on every card and Phase 8 §8.0 just landed the column (see spec §1). Future media must not copy this — the default remains the metadata table. Sequenced as four waves. Wave 1 is a prerequisite for everything; within Waves 2–4 the lettered tracks are parallel. -**Dependency summary:** `1 → 2 → 3 → 4`. Wave 4 (public site) can begin once Wave 2's `api/release` family is stable; it does not wait on Wave 3 (CMS). +**Dependency summary:** `1 → 2 → 3 → 4`. Wave 4 (public site) can begin once Wave 2's `api/release` family is stable; both Wave 4 **build and acceptance** are independent of Wave 3 (CMS) — the body-less `POST api/release/{id}/mix/waveform` trigger (9.2.B) can seed real waveform datum for acceptance testing without any CMS in existence, and hero images seed via a script against 9.2.B likewise. --- ### 9.1 Wave 1 — Data model + migration `[prerequisite gate]` -- **What:** New `ReleaseMedium` enum (`Cut, Session, Mix`) in `DeepDrftModels/Enums/`. `ReleaseEntity` gains `ReleaseMedium Medium` (default `Cut`) plus 1:1 nav properties to two new metadata entities. New `SessionMetadata` (`HeroImagePath`) and `MixMetadata` (`WaveformEntryKey`) entities, each 1:1 with `ReleaseEntity`. EF configurations + migration. +- **What:** New `ReleaseMedium` enum (`Cut, Session, Mix`) in `DeepDrftModels/Enums/`. `ReleaseEntity` gains `ReleaseMedium Medium` (default `Cut`) plus 1:1 nav properties to two new metadata entities. New `SessionMetadata` (`HeroImageEntryKey`) and `MixMetadata` (`WaveformEntryKey`) entities, each 1:1 with `ReleaseEntity`. EF configurations + migration. - **Why:** Every other wave reads this schema. The discriminator-plus-optional-table shape is the load-bearing decision of the phase; it must land first and land right. - **Shape:** - `ReleaseMedium` enum with `Cut = 0` (default — existing/migrated releases stay studio cuts with no discriminator data migration). - - `Medium` column on `releases`; `ReleaseConfiguration` documents the `ReleaseType`-only-for-`Cut` invariant. - - `session_metadata` and `mix_metadata` tables, each with a unique FK to `releases` (1:1). `MixMetadata.WaveformEntryKey` is a vault entry key (recommended — see open question), not an inline blob. + - `Medium` column on `releases`; `ReleaseConfiguration` documents the `ReleaseType`-only-for-`Cut` invariant *and* the named `CutMetadata`-rejected exception (see the phase intro above). + - `session_metadata` and `mix_metadata` tables, each with a unique FK to `releases` (1:1). `MixMetadata.WaveformEntryKey` is a vault entry key (resolved — see open question), not an inline blob. - Migration is **additive only** — no data migration of existing rows beyond defaulting `Medium = Cut`. Lower risk than the Phase 8 normalization. - **Prerequisite:** Phase 8 §8.0 normalization (`ReleaseEntity` exists) — already landed. - **Acceptance criteria:** - `ReleaseMedium` enum exists; `ReleaseEntity.Medium` defaults to `Cut`. - `SessionMetadata` / `MixMetadata` entities + EF configs + migration applied; solution compiles and existing releases read back as `Cut`. - - The invariant is documented in `ReleaseConfiguration` (no DB constraint). + - The invariant is documented in `ReleaseConfiguration` (no DB constraint — a deliberate choice; EF supports check constraints, see the phase intro). - **Open questions:** - - **Waveform storage:** vault blob + `WaveformEntryKey` (recommended — keeps the high-res datum out of SQL, consistent with the dual-database split) vs. a JSON column on `MixMetadata`. Determines whether this wave touches the vault abstraction. Resolve before the migration is written. + - **Resolved — waveform storage:** vault blob + `WaveformEntryKey`. Settled by the server-side trigger design (9.2.B): the API computes and stores the datum vault-side; SQL holds only the entry key, so a JSON column never enters the flow. This wave adds only the SQL column — the vault write rides the existing vault abstraction server-side. --- @@ -189,15 +189,15 @@ Sequenced as four waves. Wave 1 is a prerequisite for everything; within Waves 2 A new `api/release` controller — the medium unit is the *release*, not the track, so medium browse and metadata uploads are release-cardinal rather than bolted onto `api/track/page`. - **9.2.A — Release read endpoints (data layer + controller).** - - **What:** `GET api/release?medium={cut|session|mix}&page=&pageSize=&sort=` (unauth, paginated, medium filter additive — omitting returns all) and `GET api/release/{id}` (unauth, single release + its medium metadata). Both `Include` the matching metadata table via a per-medium projection map. + - **What:** `GET api/release?medium={cut|session|mix}&page=&pageSize=&sort=` (unauth, paginated, medium filter additive — omitting returns all) and `GET api/release/{id}` (unauth, single release + medium metadata). The **list** read `Include`s the matching metadata table via a per-medium projection map; the **by-id** read always-`Include`s both metadata navs (two 1:1 unique-FK joins; non-matching media naturally yield nulls — no per-medium branching, no map). - **Why:** The public CUTS/SESSIONS/MIXES surfaces and the CMS browsers all read releases by medium. One cohesive release-read family keeps `api/track/page` focused on Phase 8's track-list cases. - - **Shape:** Repository/service join through the metadata tables only for the relevant medium; base release reads never touch them. Projection map is per-medium (extension-shaped), not an `if/else` chain in the controller. `ReleaseDto` gains `Medium` + optional nested `SessionMetadataDto?` / `MixMetadataDto?` (populated only for the matching medium — mirrors Phase 8's nested-`Release` choice, not denormalized flat fields). - - **Acceptance criteria:** `GET api/release?medium=session` returns Session releases with hero-image metadata included and no `MixMetadata`; `medium=cut` returns Cuts with neither metadata block; pagination + sort parity with `api/track/page`. -- **9.2.B — Metadata upload endpoints.** - - **What:** `POST api/release/session/hero-image` (ApiKey — hero image → image vault → set `SessionMetadata.HeroImagePath`) and `POST api/release/mix/waveform` (ApiKey — preprocessed waveform datum → vault → set `MixMetadata.WaveformEntryKey`). - - **Why:** The CMS authoring flows (Wave 3 B/C) need write paths for the medium-specific data. Splitting them from the track-upload endpoint keeps each endpoint single-responsibility. - - **Shape:** Hero-image upload mirrors the existing cover-art `UploadImageAsync` → image-vault → link pattern, targeting `HeroImagePath`. Waveform upload writes the datum to the vault and records its entry key. Both find-or-create the metadata row for the release. - - **Acceptance criteria:** Posting a hero image to a Session release sets `HeroImagePath` and the image is served back through the existing image proxy; posting a waveform datum to a Mix release sets `WaveformEntryKey` and the datum is retrievable. + - **Shape:** Repository/service join through the metadata tables only for the relevant medium on list reads; base release reads never touch them. The projection map carries a dual responsibility: per-medium `Include` selection *and* the single enforcement point of the medium↔metadata correlation (a metadata DTO is populated iff the medium matches) — which is why it is not inlined in the controller. The honest extensibility guarantee is "one entry, one file," not "zero controller changes." `ReleaseDto` gains `Medium`, a **nullable** `ReleaseType?` (nulled at the mapping point for non-`Cut`), and optional nested `SessionMetadataDto?` / `MixMetadataDto?` (populated only for the matching medium — mirrors Phase 8's nested-`Release` choice, not denormalized flat fields). + - **Acceptance criteria:** `GET api/release?medium=session` returns Session releases with hero-image metadata included and no `MixMetadata`; `medium=cut` returns Cuts with neither metadata block and a non-null `ReleaseType`; non-Cut releases serialize `ReleaseType: null`; pagination + sort parity with `api/track/page`. +- **9.2.B — Metadata write endpoints.** + - **What:** `POST api/release/{id}/session/hero-image` (ApiKey, multipart — hero image → image vault → set `SessionMetadata.HeroImageEntryKey`) and `POST api/release/{id}/mix/waveform` (ApiKey, **no request body** — a server-side trigger: the API fetches the mix audio from its own vault, computes the high-resolution waveform via `WaveformProfileService` parameterized by resolution, stores the datum in the vault, sets `MixMetadata.WaveformEntryKey`). Both routes are resource-addressed — the release id rides the route. + - **Why:** The CMS authoring flows (Wave 3 B/C) need write paths for the medium-specific data, and the waveform is a *derived* datum the server can compute from audio it already owns. Mirroring the existing body-less `POST api/track/{trackId}/waveform` idiom makes the datum correct by construction (no trusting a client blob) and keeps the CMS free of any in-process data layer (its standing constraint). Splitting these from the track-upload endpoint keeps each endpoint single-responsibility. + - **Shape:** Hero-image upload mirrors the existing cover-art `UploadImageAsync` → image-vault → link pattern, targeting `HeroImageEntryKey`. The waveform trigger includes the `WaveformProfileService` refactor: a per-call resolution/profile parameter (today fixed via injected `WaveformProfileOptions.BucketCount = 512`) plus a distinct entry-key/vault target for the high-res datum — one pipeline, two resolutions (*One source, multiple views*). Both endpoints find-or-create the metadata row for the release. + - **Acceptance criteria:** Posting a hero image to a Session release sets `HeroImageEntryKey` and the image is served back through the existing image proxy; the body-less waveform trigger on a Mix release computes + stores a high-res datum, sets `WaveformEntryKey`, and the datum is retrievable. - **Prerequisite:** 9.1. - **Open questions:** - **New endpoints vs. `api/track/page` query-param extension.** Recommend the new `api/release` family (release-cardinal browse, medium metadata `Include`); `api/track/page` can gain a cheap `medium=` passthrough later if a track-level filter is ever needed. @@ -209,22 +209,22 @@ A new `api/release` controller — the medium unit is the *release*, not the tra - **9.3.A — Release Archive tab + medium selector.** - **What:** Rename `TrackList.razor`'s third tab **Genre → Release Archive**. Inside it, render a **medium card group** (one card per `ReleaseMedium`, styled like the existing `CmsGenreBrowser` cards) where each card *navigates* to a medium-specific browser. Add a `ReleaseMedium` selector to `TrackNew` / `TrackEdit` / `BatchUpload` / `BatchEdit` / `AlbumHeaderFields`; show `ReleaseType` only when `Medium == Cut`, hide it (and surface medium-specific fields) for Session/Mix. - **Why:** The CMS needs to author medium per release and browse the archive by medium. The card-group-of-media is the CMS analogue of the home page's three-medium block. - - **Shape:** Cards driven by `Enum.GetValues()` + a display-metadata lookup (label/descriptor/swatch) — **no hardcoded card switch**. Cut card → `CmsAlbumBrowser` (reused, with a `MediumFilter`); Session card → `CmsSessionBrowser`; Mix card → `CmsMixBrowser`. Selector-driven conditional fields may be explicit `@if` per medium in the form (three genuinely different field sets — SOLID discipline matters at the data/service layer, not the form markup; do not over-abstract the UI). + - **Shape:** Cards driven by `Enum.GetValues()` + a display-metadata lookup (label/descriptor/swatch) — **no hardcoded card switch**. Cut card → `CmsAlbumBrowser` (reused, with a `MediumFilter`); Session card → `CmsSessionBrowser`; Mix card → `CmsMixBrowser`. Selector-driven conditional fields ride **per-medium section components** (`CutFields` / `SessionFields` / `MixFields` — plain explicit markup inside, no clever generics) behind a **single dispatch point** (a `MediumFields` component holding the one `@switch`) embedded by all five forms — one dispatch, not five scattered conditional blocks. A new medium is one section component + one dispatch entry. - **Acceptance criteria:** The third tab reads "Release Archive" and shows one card per medium; each card navigates to its browser; the upload/edit forms show `ReleaseType` only for `Cut`. - **9.3.B — `CmsSessionBrowser` + hero-image authoring.** - **What:** New `CmsSessionBrowser.razor` — a flat list of Session releases (`Medium == Session`) with cover + hero thumbnail, session name, artist; row Edit + hero-image management. Wire the Session upload/edit path to the hero-image upload endpoint (9.2.B). - **Why:** Sessions are single-track releases with a distinct hero image; the album parent/child expansion of `CmsAlbumBrowser` is the wrong shape for them. - - **Shape:** Reuse `CmsTrackGrid` parameterized by `MediumFilter` where the layout fits; the hero thumbnail is an additive column / thin wrapper, not a forked table. Hero upload reuses the cover-art one-shot pattern against `HeroImagePath`. + - **Shape:** Reuse `CmsTrackGrid` parameterized by `MediumFilter` where the layout fits; the hero thumbnail is an additive column / thin wrapper, not a forked table. Hero upload reuses the cover-art one-shot pattern against `HeroImageEntryKey`. - **Acceptance criteria:** Session browser lists only Session releases; uploading a hero image persists it and renders the thumbnail. -- **9.3.C — `CmsMixBrowser` + waveform pipeline.** - - **What:** New `CmsMixBrowser.razor` — a flat list of Mix releases (`Medium == Mix`) with an in-grid waveform-generation **status** column (mirroring Phase 8's `HasWaveformProfile` idiom) and a per-row **Generate Waveform** action. Wire the Mix upload to trigger high-resolution waveform preprocessing and upload the datum (9.2.B). - - **Why:** A Mix without a generated high-res waveform is incomplete; status-in-grid + generate-action is the Phase 8-established pattern for waveform readiness. - - **Shape:** Model the high-res preprocessor on the **existing player-bar waveform preprocessor** — the *same pipeline parameterized by resolution*, not a copy (player-bar peek = low-res, Mix = high-res; honours *One source, multiple views*). Upload flow: `UploadTrackAsync` → preprocess → `POST api/release/mix/waveform`. - - **Acceptance criteria:** Mix browser lists only Mix releases and shows per-row waveform status; uploading a Mix generates + stores a high-res waveform; the per-row Generate action recovers a missing waveform. +- **9.3.C — `CmsMixBrowser` + waveform trigger wiring.** + - **What:** New `CmsMixBrowser.razor` — a flat list of Mix releases (`Medium == Mix`) with an in-grid waveform-generation **status** column (mirroring Phase 8's `HasWaveformProfile` idiom) and a per-row **Generate Waveform** action. Wire the Mix upload to call the server-side waveform trigger (9.2.B) — the CMS never computes or carries the datum. + - **Why:** A Mix without a generated high-res waveform is incomplete; status-in-grid + generate-action is the Phase 8-established pattern for waveform readiness. The CMS has no in-process data layer by convention, so all it does is fire the trigger. + - **Shape:** Upload flow: `UploadTrackAsync` → `POST api/release/{id}/mix/waveform` (body-less; the API computes and stores server-side, 9.2.B). The per-row Generate action is the same trigger — recovery costs one POST, with no download/recompute/re-upload of the catalogue's longest audio files. + - **Acceptance criteria:** Mix browser lists only Mix releases and shows per-row waveform status; uploading a Mix fires the trigger and the stored high-res waveform appears as generated; the per-row Generate action recovers a missing waveform. - **Prerequisite:** 9.2. - **Open questions:** - **Genre browse fate.** Resolved: 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. - - **Waveform preprocessor reuse.** Is the existing preprocessor factored to accept a resolution parameter, or does 9.3.C include a refactor to share one pipeline across player-bar and Mix? Recommend one parameterized pipeline. + - **Waveform preprocessor reuse.** Resolved: one server-side parameterized pipeline (player-bar peek = low-res, Mix = high-res; *One source, multiple views*). The `WaveformProfileService` resolution-parameter refactor lands in **Wave 2 with the trigger endpoint (9.2.B)**, not in this wave. - **Single-track invariant.** Resolved: 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. --- @@ -234,7 +234,7 @@ A new `api/release` controller — the medium unit is the *release*, not the tra - **9.4.A — ARCHIVE nav + popover.** - **What:** Replace the current RELEASES / SESSIONS / MIXES nav items (in `DeepDrftPublic.Client/Layout/Pages.cs`) with a single **ARCHIVE** item. Desktop: hover shows a MudBlazor popover with CUTS / SESSIONS / MIXES → `/cuts`, `/sessions`, `/mixes`. Mobile / direct nav: ARCHIVE → an overview page `/archive` (three medium cards, reusing the §8.6 card idiom). Fixes the current **dead** Sessions/Mixes links. - **Why:** The nav must route into the new medium surfaces; today's Sessions/Mixes links point nowhere. - - **Shape:** `DeepDrftMenu.razor` renders `Pages.MenuPages` as a flat `` list today with no dropdown mechanism. Recommend extending the nav model with an optional `Children` collection (generalizes to future dropdowns; mobile renders children as indented sub-links) over a bespoke hardcoded popover. + - **Shape:** `DeepDrftMenu.razor` renders `Pages.MenuPages` as a flat `` list today with no dropdown mechanism. Recommend extending the nav model with an optional `Children` collection (generalizes to future dropdowns) over a bespoke hardcoded popover. Pinned semantics (spec §5.1): dual-role nodes — desktop hover opens children, desktop click navigates to the parent's route (`/archive`), mobile renders the parent as a link with children indented; depth cap of **one level** — deeper nesting is a redesign, not a recursion. - **Acceptance criteria:** ARCHIVE replaces the three flat items; desktop hover reveals the three sub-links; mobile routes to `/archive`; no dead links remain. - **9.4.B — CUTS (`/cuts`).** - **What:** New `/cuts` route reusing the existing `AlbumsView` layout, filtered to `Medium == Cut`. Studio Singles/EPs/Albums appear as they do on the current Releases page. @@ -248,14 +248,14 @@ A new `api/release` controller — the medium unit is the *release*, not the tra - **Shape:** Gallery borrows `AlbumsView`'s card-gallery skeleton with a session card face. Detail composes a shared `ReleaseDetailScaffold` (extracted common metadata + play + player wiring) with a hero-image hero slot — see 9.4.D open question. - **Acceptance criteria:** `/sessions` lists Session releases; `/sessions/{id}` renders hero-dominant with the play affordance intact. - **9.4.D — MIXES (`/mixes` + `/mixes/{id}`) + `MixWaveformVisualizer`.** - - **What:** Gallery at `/mixes`; detail at `/mixes/{id}` whose above-the-fold hero is a **`MixWaveformVisualizer`** component fed by the preprocessed waveform datum from `MixMetadata`. The visualizer is a **named, reusable** component. + - **What:** Gallery at `/mixes`; detail at `/mixes/{id}` whose defining visual is a **`MixWaveformVisualizer`** component fed by the preprocessed waveform datum from `MixMetadata`, rendered as the **full-page background** of the detail page. The visualizer is a **named, reusable** component. - **Why:** Mixes are long continuous sets; the waveform is their signature visual and the brief calls for a reusable visualizer. - - **Shape:** `MixWaveformVisualizer` takes the waveform datum (via `WaveformEntryKey` → content endpoint) + optional playback-position binding; renders SVG/canvas peak-bars in the established `SpectrumVisualizer` / `LevelMeterFab` visual language (don't invent a new idiom). Detail composes the same `ReleaseDetailScaffold` with the visualizer as its hero slot. - - **Acceptance criteria:** `/mixes` lists Mix releases; `/mixes/{id}` renders the waveform visualizer fed by real datum; the visualizer is a standalone reusable component. + - **Shape:** `MixWaveformVisualizer` takes the waveform datum (via `WaveformEntryKey` → content endpoint) + optional playback-position binding; renders a high-resolution, sophisticated **full-page background** visual in **its own visual language** — explicitly *not* the `SpectrumVisualizer` / `LevelMeterFab` peak-bar idiom, which is **reserved for the player bar**. The two are siblings in subject matter (waveforms) with entirely separate design treatments; they share a data pipeline (9.2.B), never a look. Detail composes the same `ReleaseDetailScaffold`, with the visualizer as the page-background layer. + - **Acceptance criteria:** `/mixes` lists Mix releases; `/mixes/{id}` renders the waveform visualizer as the page background fed by real datum (seedable via the 9.2.B trigger, no CMS required); the visualizer is a standalone reusable component visually distinct from the player-bar idiom. - **Open question:** Design the visualizer's seek-on-click position-binding seam now even if click-to-seek ships later? Recommend yes — design the seam, defer the feature (*Design for adaptability up front*). -- **Prerequisite:** 9.2 (the `api/release` read family). Independent of Wave 3. +- **Prerequisite:** 9.2 (the `api/release` read family). Independent of Wave 3 for both **build and acceptance** — the body-less 9.2.B waveform trigger seeds real Mix datum and a script can seed hero images, with no CMS in existence. - **Open questions:** - - **Detail-page strategy.** Three separate detail pages vs. one branching `TrackDetail` vs. a shared `ReleaseDetailScaffold` + per-medium hero slot. Recommend the scaffold (DRY-by-composition, the Phase 8 `BatchUpload`/`BatchEdit` extraction move; honours *One source, multiple views*). Sets the shape of 9.4.C and 9.4.D. + - **Detail-page strategy.** Three separate detail pages vs. one branching `TrackDetail` vs. a shared `ReleaseDetailScaffold` + per-medium hero slot. Recommend the scaffold (DRY-by-composition, the Phase 8 `BatchUpload`/`BatchEdit` extraction move; honours *One source, multiple views*). Sets the shape of 9.4.C and 9.4.D. Scaffold contract (spec §5.3): it owns exactly the invariant trio — metadata block, play affordance, player wiring; all per-medium variance rides slots (a boolean layout parameter on the scaffold is a design failure). `TrackDetail` is refactored onto the scaffold in this wave (it is the extraction source — nearly free); if deferred, record the fork as deliberate debt with a retirement note. --- diff --git a/product-notes/phase-9-release-medium-types.md b/product-notes/phase-9-release-medium-types.md index ada7ad3..71d8f32 100644 --- a/product-notes/phase-9-release-medium-types.md +++ b/product-notes/phase-9-release-medium-types.md @@ -50,7 +50,7 @@ ReleaseEntity ├── ReleaseType (valid only when Medium == Cut) ├── ...base release fields... │ -├── SessionMetadata? 1:1, present iff Medium == Session { HeroImagePath } +├── SessionMetadata? 1:1, present iff Medium == Session { HeroImageEntryKey } └── MixMetadata? 1:1, present iff Medium == Mix { WaveformEntryKey } ``` @@ -58,7 +58,7 @@ ReleaseEntity Three shapes were on the table: -**(A) Single wide table.** Add `HeroImagePath` and `WaveformEntryKey` (and every future medium's +**(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. @@ -88,9 +88,12 @@ detail), and the base release queries (the bulk of traffic) never touch the meta `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.** EF Core cannot express "this column is required -iff a sibling column equals a value" as a clean check constraint, and a raw SQL `CHECK` would be -opaque and migration-fragile. Instead: +**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. @@ -99,8 +102,23 @@ opaque and migration-fragile. Instead: - **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. -Document the invariant in `ReleaseConfiguration` and the service so future readers know `ReleaseType` -on a Session/Mix is meaningless, not missing. +**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. --- @@ -137,7 +155,7 @@ public class SessionMetadata : BaseEntity, IEntity { public long ReleaseId { get; set; } // FK + 1:1 (unique) public ReleaseEntity Release { get; set; } - public required string HeroImagePath { get; set; } // entry key in the image vault + public required string HeroImageEntryKey { get; set; } // entry key in the image vault } // MixMetadata — 1:1 with ReleaseEntity, present only for Mix releases @@ -149,7 +167,8 @@ public class MixMetadata : BaseEntity, IEntity } ``` -**Open question — waveform storage shape.** Two readings of "preprocessed waveform datum": +**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), @@ -163,13 +182,21 @@ public class MixMetadata : BaseEntity, IEntity rule. Only defensible if the datum is small (a few hundred points). *Not recommended* for a *high-resolution* waveform. -Recommend (i). Flag for Daniel — it determines whether Wave 1 touches the vault abstraction or just -adds a SQL column. +**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`. -- New `SessionMetadataDto { HeroImagePath }` and `MixMetadataDto { WaveformEntryKey }`. +- `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 @@ -239,38 +266,51 @@ Conditional fields driven by the selector: - `Medium == Mix` → **hide** `ReleaseType`; the upload triggers **waveform preprocessing** (§3.4). Constrain to a single track. -The conditional rendering should key off the enum, not a cascade of `@if (medium == X)` blocks where -avoidable — but with only three media and genuinely different field sets, a small `@if` per medium -in the form is acceptable and clearer than over-abstracting. The SOLID discipline matters most at the -*data/service* layer; a form is allowed to be explicit. Flag the tension, don't over-engineer the UI. +**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 (CMS-triggered) +### 3.4 Mix waveform pipeline (server-side trigger) -When a Mix is uploaded, the CMS triggers the **high-resolution waveform preprocessor** and uploads -the resulting datum to the vault. Model this on the **existing player-bar waveform preprocessing** -(the pipeline that already produces a byte-level waveform datum), but: +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. -- produce a **high-resolution** datum (more sample points than the player-bar peek), -- store it durably in the vault (not compute-per-play), -- record its `WaveformEntryKey` in `MixMetadata`. +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. -The CMS upload flow becomes: upload audio (existing `UploadTrackAsync`) → trigger waveform -preprocessing → `POST api/release/mix/waveform` with the datum → service writes datum to vault + -sets `MixMetadata.WaveformEntryKey`. The per-row "Generate Waveform" action in `CmsMixBrowser` is the -recovery path for a mix whose waveform failed or predates the feature. - -**Reuse point.** The existing waveform preprocessor should be the *same code path* parameterized by -resolution, not a copy. If the current preprocessor isn't factored to allow a resolution parameter, -that refactor is part of Wave 3 track C — flag it. (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.) +**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.HeroImagePath`. Flow: `POST api/release/session/hero-image` (multipart) → image -vault write → `SessionMetadata.HeroImagePath` set. Mirrors the existing cover-art +`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. @@ -283,10 +323,10 @@ 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 medium-specific metadata `Include`d. The medium filter is additive — omitting it returns all releases. | -| `GET api/release/{id}` | unauth | Single release + its medium metadata (hero image for Session, waveform key for Mix). Feeds the public detail views. | -| `POST api/release/session/hero-image` | ApiKey | Upload hero image → image vault → set `SessionMetadata.HeroImagePath`. | -| `POST api/release/mix/waveform` | ApiKey | Upload preprocessed waveform datum → vault → set `MixMetadata.WaveformEntryKey`. | +| `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`: @@ -302,10 +342,23 @@ Recommend a **new `api/release` family** rather than overloading `api/track/page This keeps each endpoint cohesive (SRP at the HTTP boundary) rather than growing `api/track/page` into the everything-endpoint. -**Extensibility note.** `GET api/release?medium=` should accept *any* `ReleaseMedium` value and -`Include` the matching metadata via a small per-medium projection map — not a hardcoded -`if session … else if mix …` chain in the controller. A future medium adds a projection entry, not a -new endpoint branch. Same Open/Closed discipline as the CMS cards. +**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. --- @@ -335,6 +388,15 @@ or (b) hardcode ARCHIVE as a distinct popover component in `DeepDrftMenu` alongs 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 / @@ -372,23 +434,45 @@ Session detail is hero-led; Mix detail is waveform-led. Three readings: 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 hero slot is a **`MixWaveformVisualizer`** component fed by the - preprocessed waveform datum from `MixMetadata.WaveformEntryKey`. Designed as a **named, reusable - component** (the brief is explicit) so it can be reused — e.g., a future inline waveform on the - player bar, or a mix card preview. +- **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:** SVG or canvas peak-bars, consistent with the existing `SpectrumVisualizer` / - `LevelMeterFab` visual language already in the player stack (don't invent a new visual idiom — - borrow the established peak-bar look). The §8 player-bar waveform is the low-res cousin; this is its - high-res, full-width sibling. +- **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 @@ -417,19 +501,27 @@ above-the-fold without polluting the others. 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 - (recommended) and hero/cover images live vault-side; medium discriminator + metadata-table rows - live SQL-side. + (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. **Waveform storage shape (§2.3).** Vault blob + `WaveformEntryKey` (recommended) vs. JSON column - on `MixMetadata`. Determines whether Wave 1 touches the vault abstraction. *Recommend vault blob.* +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. **Waveform preprocessor reuse (§3.4).** Is the existing player-bar waveform preprocessor factored - to accept a resolution parameter, or does Wave 3 track C include a refactor to share one pipeline - across player-bar (low-res) and Mix (high-res)? *Recommend one parameterized pipeline.* +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.* @@ -445,17 +537,46 @@ above-the-fold without polluting the others. ## 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`) costs: +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: -1. one new `ReleaseMedium` enum value, -2. *if* it needs extra data, one new metadata table + DTO, -3. one display-metadata entry (so the CMS card + nav sub-item appear automatically), -4. one projection entry in the `api/release?medium=` map, -5. its own hero-slot renderer for the detail scaffold. +**(a) What is never modified — the true Open/Closed payoff:** -It costs **zero** changes to: the base `ReleaseEntity` shape, the other media's tables, the existing -browse grids, or the existing detail scaffolding. That is the Open/Closed payoff of discriminator- -enum + optional-metadata-table over a wide table or a type hierarchy. Where the design *does* admit a -mapping (card → browser, medium → projection, medium → hero 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." +- 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.