Files
deepdrft/product-notes/phase-11-public-site-enhancements.md

1410 lines
107 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Phase 11 — Public Site Enhancements
Status: spec / design. Author: product-designer. Date: 2026-06-15 (revised same day after Daniel
resolved the open questions and expanded scope); **2026-06-16 — added commitment 9 (release GUID
identifiers; §3e, wave 11.H).** **Plan only — no code edits made by this doc.**
Cross-references: `PLAN.md §11` (the concise phase entry), `PLAN.md §1.3` (preload/queue — now
**absorbed into this phase**), `product-notes/phase-9-release-medium-types.md` (the medium model —
`ReleaseMedium` enum, the per-medium detail-page strategy, the `ReleaseDetailScaffold` contract),
`COMPLETED.md §9.8` (Wave 8 remediation — the `/archive` release-cardinal browser, the inline nav,
the `TracksView` demotion), memory *One source, multiple views* and *Design for adaptability up
front*.
---
## 0. Why this phase exists, and the state it inherits
Phase 9 made the medium taxonomy real end-to-end and gave each medium a browse + detail surface.
Wave 8 then remediated the public side: `/archive` became a release-cardinal searchable browser,
the nav flattened to ARCHIVE + Cuts/Sessions/Mixes, and the track-cardinal `/tracks` gallery was
demoted from the nav (route kept). That work landed and is stable on dev (2026-06-13/14).
Phase 11 is the **next coherent pass over the public listening surface**. Daniel's hands-on use
surfaced an initial four commitments; on 2026-06-15 he resolved the open questions and expanded the
scope to **eight**; on 2026-06-16 he added a **ninth** — switch releases to GUID identifiers so the
id is no longer a transparent sequential integer. They share one spine: **make the release the
cardinal unit of the public site, make every navigation an addressable, shareable URL, and make the
album a first-class playable object** (ordered, queue-able, shareable) — and now, **a release that
can describe itself** in prose and **identify itself by an opaque, non-enumerable handle**.
The nine:
1. **Cuts detail page** (`/cuts/{id}`) — structural; the phase's center of gravity.
2. **Player-bar release-title → release detail** via a medium→route resolver — structural.
3. **Retire the whole track-cardinal stack** *and* **normalize release-card rendering into shared
components** — reduction + normalization (the heart of 11.C).
4. **Archive filters in the URL** — addressability polish.
5. **Explicit track-ordinal column** on the track model, editable from the CMS — **new scope**;
data-model + migration; gates correct `/cuts/{id}` ordering.
6. **Release-level Share****new scope**; a Cut/Session/Mix header Share shares the release URL.
7. **Play-queue system****new scope**; absorbs the deferred `PLAN.md §1.3`. The Cuts "play album"
affordance is its first consumer.
8. **Release Description field****new scope (2026-06-15)**; a multiline free-text field on the
base release (all media), edited from the CMS add/edit forms and rendered as a text block on every
release detail page. Unlike commitment 5, this **is** a genuinely new column + migration. See §3d.
9. **Release GUID identifiers****new scope (2026-06-16)**; the release id Daniel sees in URLs and
the API is a transparent sequential integer (`/cuts/1`, `/cuts/2`, …) that leaks catalogue size
and ordering. Switch releases to a GUID identifier "similar to how tracks are keyed," swept
end-to-end. **The reframe (§3e), RESOLVED by Daniel 2026-06-16: tracks did *not* change their PK
type — they front the int PK with an app-minted GUID *string* (`EntryKey`) as the public handle
and keep the int `Id` private. Releases get the *same member*: a new `ReleaseEntity.EntryKey`
(`string`, matching tracks) backfilled at migration time; the `long` PK stays DB-only, unused by
the app.** This is the chosen path (not just recommended) because the int PK is `BaseEntity.Id`
from the `Cerebellum.BlazorBlocks.Models` framework and is hardwired to `long` across
`Repository<>` / `Manager<>`. See §3e for the surface map, the framework constraint, the decisions
(gating one now resolved), and the sequencing (this wave **must follow 11.B11.E** — it sweeps the
files they create/edit).
This is not a greenfield phase — most of the scaffolding it needs already exists (the medium browse
pages already share a `ReleaseGallery` card component; the detail pages already share
`ReleaseDetailScaffold`; the Archive already has all filter state). The structural new work is the
**Cuts detail page** and the **queue model** (the one real architecture decision). Everything else
is closing asymmetries and consolidating rendering that drifted into per-surface copies. The one
piece of genuinely new persisted state the phase introduces is the **release Description column**
(commitment 8) — a small, self-contained vertical slice (column → migration → DTO → write path →
CMS form input → detail-page text block) that touches no existing wave's surface and follows the
**exact channel Genre already uses** (release-cardinal field carried on the track update/upload
request, projected onto the linked release row — there is no dedicated release-update endpoint).
> **Headline correction on commitment 5 (read against live source, 2026-06-15).** Daniel asked for
> "an explicit ordinal column, editable from the CMS, with a Daniel-gated migration." **That column
> already exists and shipped in Phase 8.** `TrackEntity.TrackNumber` is an explicit 1-based, non-null
> ordinal (default 1) — *not* insertion-order — with its migration (`20260611005700`) **already
> applied**, its DTO mirror, its API write path (validated `> 0`), and CMS reorder
> (`BatchEdit` assigns ordinal from list position on submit). The read path already sorts on it
> (`ReleaseRepository.OrderBy(t => t.TrackNumber)`). **So commitment 5 carries no new column, no new
> migration, and no Daniel-gated apply step.** What remains is *verify-and-consume*: confirm
> `TrackDto.TrackNumber` is populated on the public read path the Cut page uses, and ensure
> `CutDetailViewModel` orders by it. The reorder-UX question Daniel asked me to surface is also
> already answered in the CMS — `BatchEdit` reorders by list position, not a numeric field. This
> reframing is the most important single change in this revision; §3a carries the detail. If hands-on
> use reveals a gap (e.g. the *public* track read does not project `TrackNumber`), that is a small
> wiring fix, not the schema project the brief anticipated.
> **The mirror-image note on commitment 8 (release Description; read against live source, 2026-06-15).**
> Where commitment 5 turned out *already built*, commitment 8 is the opposite: **no `Description`
> column exists** on `ReleaseEntity` or `ReleaseDto` (greps return nothing; the entity carries
> `Title`/`Artist`/`Genre`/`ReleaseDate`/`ImagePath`/`ReleaseType`/`Medium` and the two metadata
> satellites, no description). So commitment 8 **is** the real cross-stack schema project the brief's
> commitment-5 framing anticipated — just for a different field. The honest scope: a new
> `ReleaseEntity.Description` column + EF migration (**Daniel-gated apply**), the `ReleaseDto` mirror,
> the `TrackConverter` round-trip (both directions), the write-path plumbing (the
> release-cardinal-fields thread through `UpdateTrackMetadataRequest` / the upload form / the
> `UnifiedTrackService` and `UnifiedReleaseService` write — the **same path Genre travels**), the CMS
> `AlbumHeaderFields` multiline input, and the detail-page text block. §3d carries the full spec. This
> is a clean vertical slice that gates the detail-page render but shares no surface with 11.AF — which
> is why it lands as its own wave (11.G), not a graft onto an existing one.
### What already exists (verified against live source, 2026-06-15)
| Surface | State | File |
|---|---|---|
| `/sessions/{id}` detail | **Exists, mature.** Hero-dominant overlay; bridged prerender; play wiring. | `Pages/SessionDetail.razor` |
| `/mixes/{id}` detail | **Exists, mature.** Full-page WebGL waveform background + scaffold; bridged prerender. | `Pages/MixDetail.razor` |
| `ReleaseDetailScaffold` | **Exists.** Invariant trio (back link, masthead, play/share) + `Hero`/`MetaContent` slots. Composed by `TrackDetail` and `MixDetail`. | `Controls/ReleaseDetailScaffold.razor` |
| `ReleaseDetailBase` | **Exists.** Shared load + prerender-bridge for single-release detail pages (id-addressed, resolves the playable track via `releaseId`-filtered track page). | `Pages/ReleaseDetailBase.cs` |
| `/cuts` gallery | **Exists** as `AlbumsView` (medium-parameterized card grid). Cards open `/tracks?album={title}`. **Renders its card markup inline (`album-card`), does NOT use `ReleaseGallery`.** | `Pages/AlbumsView.razor` |
| `/sessions`, `/mixes` galleries | **Exist** as `SessionsView`/`MixesView`, both inheriting `MediumBrowseBase` and **both composing the shared `ReleaseGallery` card grid** (with a `DetailRoute` param). | `Pages/{Sessions,Mixes}View.razor` |
| `ReleaseGallery` | **Exists.** The shared release-card grid — cover (+ `--fallback`), title, artist. Cards link `<a href="/{DetailRoute}/{id}">`. **Consumed only by Sessions/Mixes today**; Archive and Cuts re-implement equivalent markup inline. | `Controls/ReleaseGallery.razor` |
| `/archive` browser | **Exists, release-cardinal.** Debounced search + medium chips + genre filter. Cards route per-medium via a private `DetailHref` switch. **Renders card markup inline (`archive-release-card`), does NOT use `ReleaseGallery`. Filters held in component fields, NOT in the URL.** | `Pages/ArchiveView.razor(.cs)` |
| `/track/{EntryKey}` detail | **Exists** as `TrackDetail` — the *track-cardinal* detail the player-bar title links to. | `Pages/TrackDetail.razor` |
| `/albums` | **Exists** as a permanent redirect → `/cuts`. | `Pages/AlbumsRedirect.razor` |
| `SharePopover` | **Exists, track-keyed.** Takes an `EntryKey`; "Copy link" + "Embed player". No release-level share target. | `Controls/SharePopover.razor` |
| Play queue / playlist | **Does not exist.** Player is single-slot (`StreamingAudioPlayerService` holds one `CurrentTrack`). No notion of "next." `PLAN.md §1.3` (preload + queue) is deferred — **now absorbed here**. | — |
| `TrackEntity` ordinal | **ALREADY EXISTS — landed in Phase 8.** `TrackEntity.TrackNumber` (int, 1-based, default 1, non-null), column `track_number`, migration `20260611005700_AddReleaseTypeAndTrackNumber` **already applied**. `TrackDto.TrackNumber` mirrors it; `TrackConverter` round-trips it; `UpdateTrackMetadataRequest.TrackNumber` + `TrackController` validate (`> 0`) and persist it; `BatchEdit` already sets it from reorderable list position on submit; `ReleaseRepository.GetTracks` already `.OrderBy(t => t.TrackNumber)`. **Commitment 5 is not new schema — it is verify-and-consume.** | `TrackEntity.cs:17`, `TrackConfiguration.cs:37`, `BatchEdit.razor:192/225` |
| `ReleaseEntity` Description | **DOES NOT EXIST.** No `Description` member on `ReleaseEntity` or `ReleaseDto` (greps return nothing). The entity carries `Title`/`Artist`/`Genre`/`ReleaseDate`/`ImagePath`/`ReleaseType`/`Medium` + `SessionMetadata`/`MixMetadata` — no description. **Commitment 8 is the real new-column project** (column + migration + DTO + converter + write path + CMS input + detail block). | `ReleaseEntity.cs:13-30`, `ReleaseDto.cs:10-33` |
### Three framing corrections (the brief's vocabulary vs. the live routes)
The brief's framing is directionally right but uses route names that do not match the code. Naming
these up front so the implementer is not misled:
1. **There is no `/tracks/{id}` route.** The track-cardinal detail route is **`/track/{EntryKey}`**
(`TrackDetail`), keyed by the FileDatabase entry key (a string), not a numeric id. The player-bar
title links to `/track/{Track.EntryKey}` (`TrackMetaLabel.razor` line 9). When the brief says
"`/tracks/{id}` becomes a pure router," the *intent* is: **the thing a release-title click lands
on should resolve medium → the correct dedicated detail page.** See §2 for what this actually
means against the live routes — the brief's "router" is better realized as a small **medium→route
resolver** than as a literal `/tracks/{id}` page, because the player bar carries a track, not a
release id.
2. **Cuts genuinely have no single-release detail page.** This is the real asymmetry. `/cuts`
(`AlbumsView`) cards and `/archive` Cut cards both open `/tracks?album={title}` — the track
gallery filtered to the album title. Sessions and Mixes route to dedicated `/sessions/{id}` /
`/mixes/{id}` pages. The new `/cuts/{id}` page (§3) closes this gap and is the heart of the phase.
3. **`/archive` filters are already in component state but not the URL.** `ArchiveView` holds
`_selectedMedium`, `_selectedGenre`, `SearchText` as private fields with no
`[SupplyParameterFromQuery]` and no `NavigateTo` on filter change. Requirement 4 is therefore a
**URL-binding pass over an existing browser**, not a new feature. The pattern to borrow already
lives next door: `TracksView` reads `?album=`/`?genre=`/`?q=` via `[SupplyParameterFromQuery]`
(`TracksView.razor.cs` lines 2123).
---
## 1. The eight commitments (Daniel, faithful capture; decisions of 2026-06-15 folded in)
The original four (14) plus four Daniel added on 2026-06-15: three when he resolved the open
questions (57), and the release Description field (8) as a focused addition the same day.
1. **Player-bar release-title → release detail, via a medium→route resolver.** **DECIDED
(2026-06-15):** the release-title click resolves the release's `ReleaseMedium` → the correct
dedicated detail page (`/cuts/{id}` | `/sessions/{id}` | `/mixes/{id}`). Realized as a resolver
helper at click sites (the player bar already carries release id + medium), plus a thin
`/tracks/{id}` redirect page for bare-release-id deep links. (Was OQ1; resolved — see §2.)
2. **New `/cuts/{id}` page — album view.**
- Header section, **left-aligned**: release name, artist, genre, release year, plus **Play** and
**Share** buttons.
- Cover art **large-ish, right**, with a **theme border** around the image.
- Below: an **ordered track list** (ordered by the new ordinal column — commitment 5), each row
with a play button.
- Header Play **enqueues the whole album in ordinal order** (commitment 7) and starts track 1.
3. **Retire the whole track-cardinal stack + normalize release-card rendering.** **DECIDED
(2026-06-15):** retire `TracksView` / `TrackDetail` / `TrackCard` / `TracksGallery` and the
`/tracks` + `/track/{EntryKey}` routes entirely (not just the `?album` branch). **And** normalize
release-card presentation into shared component(s) consumed across `/archive`, `/cuts`,
`/sessions`, `/mixes` (and the Cuts detail track-row where applicable). The shared
`ReleaseGallery` already exists but only Sessions/Mixes use it — Archive and Cuts re-implement
equivalent markup inline. This is the normalization heart of 11.C. (Was OQ5; resolved — see §4.)
4. **Archive search params in the URL.** Search term, medium filter, genre filter all encoded in
`/archive?…` so filter actions create navigable history anchors (back/forward, shareable links).
5. **Explicit track-ordinal column, editable from the CMS.** **IN SCOPE (2026-06-15) — but
ALREADY SATISFIED by Phase 8.** Daniel asked for an explicit ordinal (NOT insertion-order),
editable from the CMS. The column (`TrackEntity.TrackNumber`, 1-based, non-null), its migration
(already applied), DTO mirror, API write path, and CMS reorder (`BatchEdit`) **all already exist**.
No new column, no new migration, no Daniel-gated apply step. Remaining work is *verify-and-consume*:
confirm the public read path projects `TrackNumber` and that `CutDetailViewModel` orders by it.
(Was OQ4 "do not assume into Phase 11"; Daniel reversed to in-scope — and the read confirms it is
already built. See §3a.)
6. **Release-level Share.** **NEW SCOPE (2026-06-15):** a Cut/Session/Mix header Share shares the
*release* URL, not a single track's embed. `SharePopover` is track-keyed today; add a
release-level share target. (Was an adjacent gap; promoted to scope. See §3b.)
7. **Play-queue system.** **NEW SCOPE (2026-06-15):** Daniel — "now is the natural time for that."
Absorbs the deferred `PLAN.md §1.3` (preload/queue). A queue model the player consumes; the Cuts
"play album" affordance (header Play → enqueue the release's tracks in ordinal order) is the first
consumer. Carries an unresolved architecture question (queue inside `IPlayerService` vs. a
separate orchestrating service) — framed with a recommendation in §3c; the final call is
staff-engineer's at implementation. (Was an adjacent gap; promoted to scope. See §3c.)
8. **Release Description field.** **NEW SCOPE (2026-06-15):** every release medium (Cut, Session, Mix)
gains a **Description** — a multiline / paragraph free-text field describing the release. It is a
**base `ReleaseEntity` field** (applies to all media uniformly; per the Phase 9 spine it belongs on
the base release, **not** a per-medium metadata satellite). Two surfaces: (a) the **CMS add/edit
forms** gain a multiline text input (in `AlbumHeaderFields`, alongside Genre/Release Date — base
fields, all media); (b) the **release detail pages** (`/cuts/{id}`, `/sessions/{id}`, `/mixes/{id}`)
gain a **text block** rendering it. Confirmed against live source: **the column does not exist**, so
this carries a real EF migration (Daniel-gated apply), DTO mirror, converter round-trip, and
write-path plumbing — the same channel Genre travels. See §3d. (Focused addition; lands as its own
schema slice, wave 11.G, with the render folded into 11.A + a small touch to Session/Mix detail.)
---
## 2. Requirement 1 reframed — the medium→detail resolver
**Recommendation: do not build a literal `/tracks/{id}` page. Build a `medium → detail route`
resolver and apply it at the two click sites that need it.** Here is why, and the three shapes
considered.
### What the player-bar title actually carries
The player bar holds a **`TrackDto`** (`TrackMetaLabel.razor`). A `TrackDto` carries a nested
`Release` (with `Id` and `Medium`). So at the click site we already know the release id *and* the
medium — there is no need for a round-trip "router page" that fetches the release to discover its
medium. The resolver is a pure function:
```
Release { Id, Medium } → "/cuts/{Id}" | "/sessions/{Id}" | "/mixes/{Id}"
```
### Three shapes
- **(i) Literal `/tracks/{id}` router page.** A routable component that fetches the release by id,
reads `Medium`, and `NavigateTo`s the dedicated page (`replace: true` so the router never sits in
history). *Cost:* a network round-trip and a flash of an empty page on every release-title click,
to rediscover a medium the caller already knew. Justified **only** if some entry point has *only*
a release id and not the medium (e.g. an external deep link to `/tracks/{id}`). *Keep this as a
thin fallback, not the primary path.*
- **(ii) Pure resolver helper, applied at click sites (RECOMMENDED).** A single
`ReleaseRoutes.DetailHref(ReleaseDto)` (or `(long id, ReleaseMedium medium)`) helper — **one
table, one location** — that every release-title / release-card click consumes. `ArchiveView`
already has a private `DetailHref` switch (lines 121126); **promote it to the shared helper** so
the Archive, the player bar, and the new Cuts cards all route through one source. No round-trip,
no flash. This is the *One source, multiple views* discipline applied to routing.
- **(iii) Both.** The resolver helper (ii) is the primary path; a thin `/tracks/{id}` redirect page
(i) exists as the addressable fallback for bare-release-id deep links and for honoring the brief's
literal route. The redirect page consumes the *same* resolver helper.
**Recommend (iii): resolver helper as the spine, plus a thin `/tracks/{id}` redirect page that
reuses it.** This satisfies the brief's literal "`/tracks/{id}` is a pure router" wording *and* gives
the common case (player-bar click, where the medium is already in hand) a zero-round-trip path. The
redirect page is ~15 lines and shares the resolver, so it is not a second source of truth.
> **DECIDED (Daniel, 2026-06-15):** the player-bar title click now points at the **release detail**
> (via the resolver), **not** the track detail. `TrackMetaLabel`'s `<a href>` changes from
> `/track/{Track.EntryKey}` to `ReleaseRoutes.DetailHref(Track.Release)`. Implementation note: the
> link today sits on the **track name** (`TrackMetaLabel.razor` line 9). With the repoint it
> resolves to the release page; the artist/genre/year already render from `Track.Release`. This also
> removes `TrackDetail`'s last inbound link — with commitment 3 retiring the whole track-cardinal
> stack, `TrackDetail` and `/track/{EntryKey}` are deleted outright (§4).
### What "resolver" means for Cut
`DetailHref` for a Cut today returns `/tracks?album={title}`. After §3 lands, a Cut resolves to
**`/cuts/{Id}`** (id-addressed, consistent with Session/Mix). This repoint is the hinge between
requirements 1, 2, and 3 — see the dependency note in §6.
---
## 3. Requirement 2 — the `/cuts/{id}` album-detail page
This is the phase's center of gravity: the first **multi-track** release detail page. Sessions and
Mixes are single-track (their detail pages show one play affordance); a Cut is an album/EP/single
with an ordered track list.
### 3.1 Layout (Daniel's spec, literal)
```
┌─────────────────────────────────────────────────────────────┐
│ ← All cuts │
│ │
│ ┌─────────────────────────────┐ ┌──────────────────────┐ │
│ │ RELEASE NAME (h3) │ │ │ │
│ │ Artist (h6, primary) │ │ COVER ART │ │
│ │ Genre · 2025 │ │ (large-ish, │ │
│ │ │ │ theme border) │ │
│ │ [ ▶ Play ] [ ⤴ Share ] │ │ │ │
│ └─────────────────────────────┘ └──────────────────────┘ │
│ ↑ header content LEFT ↑ cover RIGHT │
│ │
│ ───────────────────────────────────────────────────────────│
│ 1. ▶ Track One 3:42 │
│ 2. ▶ Track Two 4:18 │
│ 3. ▶ Track Three 2:55 │
│ … │
└─────────────────────────────────────────────────────────────┘
```
- **Header left:** release name, artist, genre, release year, **Play** + **Share** buttons.
- **Cover right:** large, **theme border** around the image (a `deepdrft-`-prefixed border using a
palette token — mirror the existing cover treatments but with an explicit framed border, which is
the new visual element).
- **Track list below:** ordered rows, each with a play button. Row click / row play streams that
track.
- **Header Play** starts **track 1**.
### 3.2 Compose `ReleaseDetailScaffold`, or not? — the load-bearing design call
Phase 9 §5.3 established `ReleaseDetailScaffold` as the shared detail scaffold and committed to
"refactor `TrackDetail` onto it; per-medium variance rides slots." The scaffold owns the **invariant
trio**: back link, masthead (title + artist), play/share affordance. `MixDetail` composes it;
`SessionDetail` deliberately diverges (overlay layout). The question for Cuts:
- **(i) Compose the scaffold.** The Cut header *is* the invariant trio (title, artist, play, share) —
almost exactly what the scaffold provides. The cover goes in the `Hero` slot; the genre/year go in
`MetaContent`; the **track list** rides a new `BodyContent` slot (the scaffold has no body slot
today — adding one is the cheap, correct extension Phase 9 §5.3 anticipated: *"named slots are fine
where genuinely needed, e.g. `BodyContent` for the Cut/Album multi-track listing"*).
- **(ii) Bespoke page** (like `SessionDetail`). Full control over the left/right header split, but
duplicates the play/share wiring and the back-link/masthead the scaffold already owns.
**Recommend (i): compose the scaffold, add one `BodyContent` slot.** Two caveats that decide whether
this is clean or a fight:
1. **The header layout is left-content / right-cover; the scaffold's masthead is a top row.** The
scaffold today renders masthead-then-Hero vertically (`MixDetail` stacks cover below the
masthead). The Cut layout wants masthead and cover **side by side**. This is a **layout variance**,
and Phase 9 §5.3 is explicit: *"a boolean layout parameter on the scaffold is a design failure —
that variance belongs in a slot."* So the right answer is **not** a `HeroBesideMeta` flag on the
scaffold. Two clean options:
- **(a)** The Cut page supplies its whole left+right header as the page content and uses the
scaffold only for the back link + play/share wiring + body slot — i.e. the scaffold's masthead
is *one* arrangement and the Cut wants a different one, so the Cut composes a richer header into
a slot. Risk: the scaffold's built-in masthead then competes with the Cut's own header.
- **(b)** Generalize the scaffold's header region into a `Header` slot with the current
masthead+play row as the default content, so `MixDetail`/`TrackDetail` are unchanged (default)
and `CutDetail` supplies a left/right `Header`. This is the cleaner *One source* move but
touches the scaffold's shared contract.
- **Recommend (b)**, but flag it: it is a scaffold-contract change that ripples to every composer.
If Wave pressure makes (b) risky, **(ii) bespoke page** is the honest fallback — record it as
deliberate divergence (as `SessionDetail` already is) rather than bending the scaffold with a
boolean.
2. **The play/share affordance differs.** The scaffold renders a `PlayStateIcon` (icon toggle) +
`SharePopover`. Daniel's Cut spec says **Play and Share buttons** (labeled buttons, per the
mockup). If the Cut wants text buttons rather than the icon idiom, that is a **slot for the
affordance row**, not a scaffold edit. Minor — flag for Daniel whether the Cut header keeps the
icon idiom (consistency with Session/Mix) or uses labeled buttons (the literal spec wording).
### 3.3 Data path — the track list
The Cut page needs the release **and its ordered tracks**. Both primitives exist:
- **Release:** `IReleaseDataService.GetById(id)``ReleaseDto` (with `Title`, `Artist`, `Genre`,
`ReleaseDate`, `ImagePath`, `Medium`, `ReleaseType`, `TrackCount`).
- **Tracks:** the track-data service already supports a **`releaseId`-filtered** track page —
`ReleaseDetailViewModel.Load` uses `GetPage(pageNumber: 1, pageSize: 1, releaseId: …)` to resolve
the single track for Session/Mix. The Cut page issues the same call with a **larger page size**
(cover the whole album — `pageSize: 100` matches the gallery convention) to get the ordered list.
**Ordering. DECIDED (2026-06-15): order by the new explicit ordinal column** (commitment 5, §3a),
not insertion order. The Cut track list reads tracks for the release sorted ascending by ordinal.
This makes the ordinal column a **hard dependency of correct `/cuts/{id}` ordering** — 11.A's track
list cannot render in the right order until the column exists and the read sorts on it. See §3a for
the column's full cross-stack spec and §6 for the wave dependency (the ordinal work gates 11.A's
ordering, so it sequences first).
**New `CutDetailViewModel` vs. extend `ReleaseDetailViewModel`.** `ReleaseDetailViewModel` resolves
*one* track. The Cut page needs *many*. Two options:
- Extend `ReleaseDetailViewModel` with an optional `Tracks` collection populated when the medium is
Cut. Risk: the VM grows a medium conditional — the smell Phase 9 fought.
- A dedicated `CutDetailViewModel` (loads release + full track list). Cleaner SRP; the Cut detail is
genuinely a different shape (multi-track) from the single-release VM.
**Recommend a dedicated `CutDetailViewModel`** + a `CutDetail` page deriving the same prerender-bridge
discipline `ReleaseDetailBase` encodes (persist release + tracks across the prerender→WASM seam,
guard restore on id). If the bridge logic is substantial, consider generalizing `ReleaseDetailBase`'s
bridge into a shared base both single- and multi-track details use — but only if it doesn't force a
medium conditional into the base. Flag the choice; don't pre-commit the implementer.
### 3.4 Play wiring
- **Row play:** stream the row's track (the `TracksView.PlayTrack` idiom — toggle if already current,
else `SelectTrackStreaming`). Row play **also sets the queue context** to the album from that row
forward (so the queue continues into the rest of the album after a mid-album row start) — see §3c
for the enqueue semantics. If the queue work lands after 11.A's first cut, row play degrades
cleanly to single-track streaming (the `Design for adaptability up front` seam).
- **Header Play: enqueue the whole album in ordinal order, start track 1.** **DECIDED (2026-06-15):**
this is the first consumer of the queue system (commitment 7, §3c). Header Play calls the queue's
`PlayRelease(tracks-in-ordinal-order)` rather than a bare `SelectTrackStreaming(track1)`. Because
11.A and the queue (11.F) may land in either order, **design header Play as a single handler call
that swaps from `SelectTrackStreaming(Tracks.First())` to `Queue.PlayRelease(Tracks)` with no
other change to the page** — the seam Phase 11's earlier draft already anticipated, now made real
by bringing the queue into scope.
---
## 3a. Commitment 5 — the track-ordinal column (already built; verify-and-consume)
**Recommendation: do not spec a schema project. Verify the existing `TrackNumber` reaches the public
Cut read, and consume it.** The brief asked to "spec the implications across the stack" for a new
ordinal column. The honest answer from the read is that the stack is already wired end to end; the
only open risk is the *public* read projection.
### 3a.1 What already exists (each verified, 2026-06-15)
| Layer | State | Evidence |
|---|---|---|
| Entity | `TrackEntity.TrackNumber``int`, 1-based, non-null, default 1. Explicit ordinal, **not** insertion-order. | `TrackEntity.cs:17` |
| EF mapping | `track_number` column, configured. | `TrackConfiguration.cs:37` |
| Migration | `20260611005700_AddReleaseTypeAndTrackNumber`**already applied** (it is in the snapshot and predates current `dev`). | `Migrations/` |
| DTO | `TrackDto.TrackNumber` mirrors it; `TrackConverter` maps both directions. | `TrackDto.cs:18`, `TrackConverter.cs:75/91` |
| API write | `UpdateTrackMetadataRequest.TrackNumber` (`int?`); `TrackController` validates `> 0` (400 otherwise), persists to the track row. Upload path resolves to 1 when unset. | `TrackController.cs:263/395-403` |
| CMS edit/reorder | `BatchEdit` loads tracks `sortColumn: "TrackNumber"`, holds them as a reorderable row list (`BatchRowModel.TrackNumber`), and **assigns each row its ordinal from list position on submit**. | `BatchEdit.razor:192/225` |
| Read sort (release tracks) | `ReleaseRepository.GetTracks` already `.OrderBy(t => t.TrackNumber)`. `TrackManager` sort switch includes `"TrackNumber"`. | `ReleaseRepository.cs:117`, `TrackManager.cs:122` |
### 3a.2 The reorder-UX question Daniel asked me to surface — already answered
Daniel asked: drag-reorder vs. numeric field, recommend the simplest shippable. **It is already
shipped, and it is neither a bare numeric field nor drag-and-drop — it is list-position ordinal
assignment:** `BatchEdit` presents the release's tracks as an ordered row list and writes
`TrackNumber = position` on submit. Editing order = reordering rows. That is the simplest correct
model (the ordinal is derived, never hand-typed, so it cannot collide or skip). **No change wanted
here** unless Daniel specifically wants drag affordance polish in the CMS — which is out of Phase 11
(CMS, not the public site) and should be raised separately if desired.
### 3a.3 The one real verify step — the *public* read projection
The CMS read (`CmsTrackService.GetPagedAsync`) sorts and carries `TrackNumber`. The Cut page uses the
**public** track-data service's `releaseId`-filtered page. The single concrete task for 11.A is to
**confirm that public read both sorts by `TrackNumber` and projects it onto `TrackDto`**, so
`CutDetailViewModel` renders rows in saved order and can label the track number. If the public read
already orders by `TrackNumber` (likely — it shares `TrackManager`'s sort switch), 11.A's ordering is
free. If it does not, the fix is a one-line sort argument, not a migration. **This is why the "ordinal
gates `/cuts/{id}` ordering" dependency in §6 collapses to a verification, not a wave.**
> **Net effect on the waves:** commitment 5 does **not** become its own wave. It folds into 11.A as a
> verification checklist item ("public read projects + sorts `TrackNumber`"). The brief's worry about
> a Daniel-gated migration does not apply — the migration already ran.
---
## 3b. Commitment 6 — release-level Share
**Recommendation: add a release-keyed mode to `SharePopover`, sharing the resolved release URL; keep
the track-keyed mode for any surface that still shares a single track.** This is an additive
extension of an existing control, not a new control.
### 3b.1 What `SharePopover` does today
`SharePopover` takes an `EntryKey` (a track) and offers "Copy link" + "Embed player." Both targets
are track-scoped. A Cut/Session/Mix header has no way to share *the release page* — only a track's
embed. After §2 every release has a canonical detail URL (`ReleaseRoutes.DetailHref`), so a
release-level share target now has a well-defined thing to copy.
### 3b.2 Three shapes
- **(i) New `ReleaseSharePopover` control.** Clean separation, but duplicates the popover chrome,
copy-to-clipboard plumbing, and styling. Two controls to keep in visual sync.
- **(ii) `SharePopover` gains a release mode (RECOMMENDED).** Add an optional release target
(`ReleaseDto` or `(long id, ReleaseMedium medium)`) alongside the existing `EntryKey`. When the
release target is set, "Copy link" copies `ReleaseRoutes.DetailHref(release)` (absolute URL); the
"Embed player" affordance is hidden or repurposed (a release page is not a single-track embed —
see the open sub-question below). One control, two modes, one source of clipboard/chrome logic —
the *One source, multiple views* discipline applied to share.
- **(iii) Make `SharePopover` polymorphic over a share-target abstraction** (`IShareTarget` with
`Title`, `Url`, `EmbedMarkup?`). Most general; over-built for two cases today. Note as the shape to
reach for **only if** a third share target appears (e.g. a playlist/queue share once §3c lands).
**Recommend (ii)** now, with (iii) noted as the refactor target if a third target appears. The Cut
header's Share button (the labeled-button-vs-icon question in §3.2.2 applies here too) opens the
popover in release mode.
### 3b.3 Open sub-question (surface, recommend)
- **Does release-share keep an "Embed player" option?** A release page is multi-track (Cut) or a
single hero track (Session/Mix). For Cuts there is no single embeddable track, so "Embed player"
should be hidden in release mode (copy-link only). For Session/Mix the release *is* effectively one
track, so embed could still make sense — but to keep the contract simple, **recommend release mode
is copy-link-only across all media** and the per-track embed stays available only where a track is
the share subject. Flag for Daniel; trivial either way.
---
## 3c. Commitment 7 — the play-queue system (absorbs `PLAN.md §1.3`)
This is the phase's **one real architecture decision** and Daniel has explicitly asked for a strong
steer while leaving the final call to staff-engineer. The brief's framing — "queue inside
`IPlayerService` vs. a separate orchestrating service" — is the right axis. Recommendation first,
then the two shapes, then the seams to the existing player and the §1.3 preload relationship.
**Recommendation: a separate `IQueueService` that orchestrates *above* the single-slot
`StreamingAudioPlayerService`. The player stays a single-slot device; the queue owns "what plays
next" and drives the player via its existing select-and-stream API.** This is the cleaner separation
and it matches the deferred §1.3's own framing ("the player is a single-slot device that a future
`PlaylistService` orchestrates above").
### 3c.1 The two shapes
- **(i) Queue inside `IPlayerService` (single-slot player gains a queue).** The player holds
`CurrentTrack` *and* a `Queue<TrackDto>` and a position pointer; `Next`/`Previous`/`enqueue` are
player methods. *Pro:* one service, no coordination; the player already owns the end-of-stream
event that would advance the queue. *Con:* the player's responsibility balloons from "stream one
track" to "manage a playlist," which is the SRP smell — and it forecloses the case where the queue
outlives a player instance or where a non-audio surface (a visible up-next list) wants the queue
state without coupling to the streaming device.
- **(ii) Separate `IQueueService` orchestrating above the player (RECOMMENDED).** `QueueService`
owns the ordered list, the current index, and `PlayRelease` / `Enqueue` / `Next` / `Previous` /
`Clear`. It drives playback by calling the player's existing `SelectTrackStreaming(track)`. It
subscribes to the player's end-of-stream / track-ended signal to auto-advance. *Pro:* the player
stays single-purpose (the load-bearing streaming seam in `CLAUDE.md` is untouched); the queue is
independently testable and independently observable (the player bar and a future up-next panel both
read `QueueService` state); it is the natural home for the §1.3 preload trigger (the queue knows the
next track, so it owns the "prefetch next at threshold" decision). *Con:* a coordination seam — the
queue must observe the player's track-ended event reliably (the player must expose one; verify the
current `OnTrackEnded`/equivalent hook exists or spec adding it).
### 3c.2 The player seam (what the queue needs from the player)
The queue orchestrator needs three things from `StreamingAudioPlayerService`, all of which are either
present or a small addition:
1. **A way to start a track**`SelectTrackStreaming(track)` exists (the Cut row-play and
`TracksView` already call it). The queue calls the same path; **no new player surface.**
2. **A track-ended signal** — the queue auto-advances on natural end-of-stream. Verify the player
raises a track-ended/playback-complete event today; if it only exposes position/state, **the one
genuine player addition is a `TrackEnded` event** (or the queue polls "state == ended"). Spec this
as the single new player-side hook.
3. **State for the player bar** — "is there a next?", "is there a previous?" so the bar can enable
skip-forward / skip-back. The bar reads `QueueService.HasNext` / `HasPrevious`.
### 3c.3 Queue data model (minimal, shippable)
```
QueueService
IReadOnlyList<TrackDto> Items // ordered; the release's tracks for "play album"
int CurrentIndex // -1 when empty
TrackDto? Current => Items[CurrentIndex]
bool HasNext / HasPrevious
PlayRelease(IEnumerable<TrackDto> tracks, int startIndex = 0) // Cuts header Play / row Play
Enqueue(TrackDto) / EnqueueRange(IEnumerable<TrackDto>)
Next() / Previous() // advance index, drive player.SelectTrackStreaming
Clear()
event Action QueueChanged // player bar re-renders skip affordances
```
`PlayRelease(tracks)` is the Cuts "play album" entry point (commitment 2's header Play): pass the
release's tracks **in ordinal order** (already sorted, §3a), `startIndex: 0`. Row play is
`PlayRelease(tracks, startIndex: clickedRow)` — start mid-album, queue continues to the end (§3.4).
### 3c.4 Player-bar UI
The player bar today shows the current track + transport. The queue adds **skip-forward** and
**skip-back** controls (enabled per `HasNext`/`HasPrevious`), wired to `QueueService.Next/Previous`.
Optional, deferred-within-phase: a visible **up-next list** (the queue's `Items` past `CurrentIndex`).
Recommend skip controls in scope; the up-next panel as a `[speculative]` follow-on so the queue
work ships without waiting on a new UI surface.
### 3c.5 Relationship to §1.3 preload — IN or OUT?
`PLAN.md §1.3` is two things bundled: (a) a **queue model** ("a notion of next track") and (b)
**preload/prefetch** (begin the next track's bytes during the current track's tail). Commitment 7
brings **(a) the queue model fully into Phase 11**. **Recommend (b) preload stays OUT of Phase 11**
ship the queue (correct next-track semantics, skip controls, play-album) first; preload is a
*perceived-latency optimization on top of* a working queue, and it is the prerequisite for crossfade
(§1.4) and gapless (§1.5), which are their own later work. Bringing preload in now couples the queue
ship to a staged-decoder change in the streaming path (the most load-bearing seam in the system).
**Design the seam, defer the feature** (memory *Design for adaptability up front*): `QueueService`
knows the next track, so it is the natural owner of a future "prefetch next at threshold" trigger.
Spec the queue so that adding preload later is *adding a subscriber to the queue's "next is known"
state*, not restructuring the queue. Note in `PLAN.md §1.3` that the **queue half is absorbed into
Phase 11; the preload half remains deferred** there (and gates 1.4/1.5 as before).
---
## 3d. Commitment 8 — the release Description field (a real new column)
**Recommendation: add `Description` to the base `ReleaseEntity`, thread it through the existing
release-cardinal write channel exactly as `Genre` is threaded, and render it as a text block via the
detail-page slots already in scope.** This is a clean vertical slice — no new architecture, no new
endpoint — but unlike commitment 5, it **is** a genuine new column with a real migration. The Phase 9
spine decides its placement without debate: it applies to **all media uniformly**, so it lives on the
**base release**, not a per-medium satellite.
### 3d.1 Why the base release, not a satellite (the placement is not a judgment call)
Phase 9 (`ReleaseConfiguration` comment, lines 4556) is explicit: the default home for medium-varying
data is a satellite metadata table; `ReleaseType` is the *one* allowed exception, justified solely by
`/cuts` read volume, and **"Future media MUST NOT copy this pattern."** Description is the easy case —
it does **not** vary by medium (every Cut, Session, and Mix has the same kind of prose blurb), so the
satellite question never arises. A field that is uniform across media belongs on the base table by the
same logic that puts `Genre` and `ReleaseDate` there. **No new satellite, no medium conditional, no
converter null-for-non-matching-medium dance** (contrast `ReleaseType`, which the converter nulls for
Session/Mix — Description needs none of that; it is carried verbatim for all media).
### 3d.2 What does NOT exist today (verified against live source, 2026-06-15)
| Layer | State | Evidence |
|---|---|---|
| Entity | **No `Description`.** `ReleaseEntity` has `Title`, `Artist`, `Genre?`, `ReleaseDate?`, `ImagePath?`, `ReleaseType`, `Medium`, `CreatedByUserId?`, `Tracks`, `SessionMetadata?`, `MixMetadata?`. | `ReleaseEntity.cs:13-30` |
| DTO | **No `Description`.** `ReleaseDto` mirrors the above + read-model `TrackCount`. | `ReleaseDto.cs:10-33` |
| EF config | No `description` column mapped. | `ReleaseConfiguration.cs:24-72` |
| Converter | `TrackConverter.Convert(ReleaseEntity)` / `Convert(ReleaseDto)` map every base field both directions; no `Description` line. | `TrackConverter.cs:19-65` |
So the column is genuinely new. **A grep for `[Dd]escription` across `ReleaseEntity.cs` returns
nothing** — there is no field to verify-and-consume (the commitment-5 outcome); this is the build.
### 3d.3 The write path — Description rides the Genre channel exactly
This is the load-bearing realization that keeps the slice small: **there is no dedicated
release-update endpoint.** Release-cardinal fields are carried on the *track* update/upload request and
projected onto the linked release row by the unified services. The CMS edits the whole release through
`BatchEdit`, whose `AlbumHeaderFields` owns `AlbumName`/`Artist`/`Genre`/`ReleaseDate`, and on submit
each track's `CmsTrackService.UpdateAsync(...)` / `UploadTrackAsync(...)` carries those
release-cardinal fields; `TrackController` then routes them to the linked release (see the
`PUT api/track/meta/{id}` contract: *"release-cardinal fields … update the linked release"*).
So Description travels the **identical thread** as Genre:
1. **`ReleaseEntity.Description` (string?, nullable)** — base table; EF `description` column, `HasMaxLength`
generous for paragraph prose (e.g. 20004000; pick a ceiling, mirror the `Genre`/`Title` `HasMaxLength`
idiom in `ReleaseConfiguration`). **Nullable** — existing rows migrate with `NULL`, no data migration.
2. **EF migration**`dotnet ef migrations add AddReleaseDescription`. **Daniel-gated apply** (do not
auto-run `database update`; the migration is generated and committed, applied on Daniel's go).
3. **`ReleaseDto.Description` (string?)** — DTO mirror.
4. **`TrackConverter`** — add `Description = entity.Description` to `Convert(ReleaseEntity)` and
`Description = dto.Description` to `Convert(ReleaseDto)`. No null-for-medium dance (it is uniform).
5. **Write request plumbing** — add `Description` to the release-cardinal field set carried on the
track update + upload path: `UpdateTrackMetadataRequest` gains `string? Description`; the upload form
gains a `description` field; `TrackController` and `UnifiedTrackService` / `UnifiedReleaseService`
thread it onto the linked release **wherever `Genre` is already threaded** (the cleanest diff is
"find every `Genre` in the write path, add a sibling `Description`"). *Note the tri-state question:*
Genre is passed as a plain nullable today (whitespace → null). Description should follow the **same
posent** — empty input → `null`, no special tri-state (it is not the cover-art `ImagePath` case).
6. **CMS input**`AlbumHeaderFields` gains a `MudTextField` with `Lines="4"` (multiline) labeled
"Description", bound via a `Description`/`DescriptionChanged` parameter pair, wired through
`BatchEdit` (`_description` field, seeded from `release?.Description` on load — mirror `_genre` at
`BatchEdit.razor:213`) and the `BatchUpload` create form. It sits in the base-fields block alongside
Genre/Release Date, **not** inside `MediumFields` (it is base, not medium-specific).
> **One honest call to surface (not a blocker):** the write path projects release-cardinal fields from
> *each track row* onto the shared release. For a multi-track Cut, every row carries the same
> Description, and the last write wins — which is already exactly how Genre/ReleaseDate behave, so
> Description inherits the existing semantics with no new edge case. No change wanted; just naming that
> the "release field carried per-track" model already in place covers Description for free.
### 3d.4 The read path — the detail-page text block
The detail pages already load a `ReleaseDto` (the Cut page via the new `CutDetailViewModel` §3.3;
Session/Mix via `ReleaseDetailBase`). Once `ReleaseDto.Description` is populated (3d.3 step 4), the
render is a **conditional text block** — show a paragraph when `Description` is non-empty, render
nothing when null (most existing rows will be null until re-edited). Placement per surface:
- **`/cuts/{id}` (11.A):** the Cut page is new, so the text block is part of its first build — a
paragraph block in the header column or just below it (Daniel's layout §3.1 has room below the
header / above the track list; recommend **below the header masthead, above the track-list divider**
so the prose introduces the album before the tracks). Folds into 11.A's `BodyContent`/header
composition with no extra wave.
- **`/sessions/{id}` and `/mixes/{id}` (existing pages):** a small additive touch — a description
text block in each page's `MetaContent` (or equivalent body region). `SessionDetail` is the
overlay-diverged page and `MixDetail` composes the scaffold, so the block lands slightly differently
in each, but both are a few lines of conditional markup, not a structural change.
**Styling:** a quiet paragraph — `Typo.body1`/`body2`, muted, respecting `white-space: pre-line` so
authored line breaks survive (the field is "multiline / paragraph"). Surface for Daniel only if he
wants markdown rendering vs. plain prose; **recommend plain text with preserved line breaks** for v1
(no markdown dependency, matches the "free-text field" framing).
### 3d.5 Wave placement — its own schema slice (11.G), render folded into 11.A
**Honest call on whether this rides existing waves or warrants its own.** It splits cleanly:
- The **schema + write path + CMS input** is a self-contained vertical that **shares no surface with
11.AF** (it touches `ReleaseEntity`/`ReleaseDto`/`TrackConverter`/the write request/`AlbumHeaderFields`
— none of which the six existing waves modify) and carries the one Daniel-gated migration in the
phase. Grafting it onto 11.A (the Cut page) would muddy 11.A's dependency story (11.A depends only on
existing data primitives; bolting a migration onto it makes the Cut page wait on a schema apply it
doesn't otherwise need). So the schema slice is **its own wave, 11.G** — independent, can start cold,
and the *only* thing that gates it is Daniel's migration go-ahead.
- The **detail-page render** is a thin consumer that **rides 11.A** (the Cut block is part of the Cut
page's first build) **plus a small additive touch to the existing Session/Mix detail pages**. It
depends on 11.G having populated `ReleaseDto.Description`, but the render degrades cleanly (a null
Description simply renders nothing), so 11.A can ship its Cut page before 11.G lands and gain the
description block the moment 11.G does — the same **design-the-seam** discipline used for the queue.
This is the truthful decomposition: a standalone schema wave is warranted (clean vertical + the one
migration), but inventing a *second* wave for the render would be over-decomposition — the render is a
few lines folded into work already scoped. **11.G = the schema/write/CMS slice; the render rides 11.A
and a Session/Mix touch.**
---
## 3e. Commitment 9 — release GUID identifiers (the transparency reframe)
> **Daniel, verbatim (2026-06-16):** "The integer IDs for releases are too transparent — switch to
> GUID identifiers for releases instead of the int IDs, similar to how tracks are keyed. Implement
> this from the entity up and replace all the int IDs with the GUIDs."
**RESOLVED (Daniel, 2026-06-16): do NOT change the release primary-key type. Front the existing
`long` PK with an app-minted GUID *string* — a new `EntryKey` column, the *same member name and type
tracks use* — and make *that* the only release identifier the public site and API expose. The int
`Id` stays the internal PK, unused by the app; the GUID string becomes the addressable handle. This
is exactly, mechanically, what tracks already do with `EntryKey`, and the chosen name/type mirror
that for genuine consistency.** Daniel's words: *"long at the DB level with an app-level guid
`EntryKey` for the releases just like tracks. PK is not used by the app. Be consistent with naming.
Migrate the existing data to provide the entry key at migration time."* The brief's literal "replace
all the int IDs with GUIDs from the entity up" was the right *intent* (kill the transparent
sequential id in the URL) but, taken as "change the PK type to `Guid`," it collides head-on with a
framework constraint — and the "similar to how tracks are keyed" steer was the clue that the
non-colliding path is the intended one. The reframe is the headline; the surface map and the
declined alternative (a true PK swap) follow.
### 3e.1 How tracks are keyed today — the model to copy (read against live source, 2026-06-16)
A track has **two** identifiers, and only one is public:
| Identifier | Type | Origin | Exposure | Evidence |
|---|---|---|---|---|
| `TrackEntity.Id` | `long` (framework `BaseEntity.Id`) | DB identity column (`bigint`, `IdentityByDefaultColumn`) | **Private.** Used only by ApiKey-gated CMS ops (`PUT/DELETE api/track/{id:long}`). Never in a public URL. | `TrackConfiguration.cs:18`, `NormalizeReleaseTrack.cs:20` |
| `TrackEntity.EntryKey` | `string` | **App-minted at creation**`Guid.NewGuid().ToString()` | **Public.** The track-detail route is `/track/{EntryKey}`; the streaming/waveform/by-key endpoints are all `EntryKey`-addressed. | `TrackContentService.cs:55`, `TrackConfiguration.cs:23` |
So tracks never put their sequential int in front of a listener. The opaque GUID string *is* the
public handle; the int PK is an implementation detail behind the ApiKey wall. **"Similar to how
tracks are keyed" therefore reads most faithfully as: give releases their own opaque app-minted GUID
string handle and address the public site by it — not as "retype the PK."** (Note one divergence:
a track's `EntryKey` doubles as the FileDatabase vault entry id, so it has a second job. A release
has no vault entry, so its `EntryKey` is purely an identifier. Daniel's call (2026-06-16) is to use
the **same member name (`EntryKey`) and type (`string`)** anyway, for naming consistency — see §3e.4.)
### 3e.2 The framework constraint — why a true PK swap is not a local change
The release PK is **not defined in this repo.** `ReleaseEntity : BaseEntity, IEntity` inherits `Id`
from `Cerebellum.BlazorBlocks.Models` (NuGet package, version 10.3.30 — ships as a compiled
`Models.dll`, no source). The same package supplies `Repository<DeepDrftContext, ReleaseEntity>` and
`Manager<…>`, whose CRUD surface (`GetByIdAsync`, `Delete`, etc.) is typed on the base `Id`. Three
hard signals that the framework hardwires `Id` to `long`:
1. **`TrackManager.cs:307`** — comment: *"Delete(long) → Result is inherited from `Manager<>` and
satisfies `ITrackService.Delete`."* The inherited delete takes `long`.
2. **`ReleaseManager.GetByIdAsync(long id)`** and **`SetSessionHeroImageAsync(long releaseId)`** etc.
are all typed `long` (`ReleaseManager.cs:81/97/110/130`).
3. **The migration** creates `id` as `bigint` / `IdentityByDefaultColumn` (`NormalizeReleaseTrack.cs:20`).
Unless the package exposes a *generic-keyed* base (e.g. `BaseEntity<TKey>` / `Repository<…, TKey>`)
— which the consuming code shows no sign of, and which would itself be a breaking generic-arity change
across `TrackEntity`, `SessionMetadata`, `MixMetadata`, and every `Manager<>`/`Repository<>` derived
type — **changing `ReleaseEntity.Id` to `Guid` is a framework fork, not an entity edit.** This is the
load-bearing reason the recommendation is the additive GUID *column*, not a PK retype. It is also
exactly why tracks solved the transparency problem with `EntryKey` rather than by retyping their own PK.
> **One thing to verify before finalizing the wave (staff-engineer, at implementation):** decompile or
> inspect `Cerebellum.BlazorBlocks.Models` 10.3.30 to confirm `BaseEntity.Id`/`BaseModel.Id` are
> non-generic `long`. If the package *does* offer a generic key, the PK-swap alternative (§3e.6)
> becomes viable and the decision in §3e.5(1) reopens. The recommendation assumes the non-generic case
> the consuming code strongly implies.
### 3e.3 The cross-stack surface map (every place a release id appears)
What the public-facing id touches today, and what each becomes under the **resolved additive
`EntryKey` (GUID-string) column** approach. "→ `EntryKey`" means "switch from the int `Id` to the new
app-level GUID-string handle"; "unchanged" means it stays on the internal int PK behind the ApiKey
wall.
| Layer | Site | Today | Resolved (additive `EntryKey`, track-pattern) |
|---|---|---|---|
| Entity | `ReleaseEntity` | `Id : long` (PK) | **add** `EntryKey : string` (`required`, app-minted, unique index), mirroring `TrackEntity.EntryKey`; `Id` **unchanged** (DB-only, unused by app) |
| EF config | `ReleaseConfiguration` | `id` PK + columns | **add** `entry_key` column (snake_case, like `TrackConfiguration`) + unique index; PK unchanged (`ReleaseConfiguration.cs:9-93`) |
| Satellites | `SessionMetadata.ReleaseId`, `MixMetadata.ReleaseId` | `long` FK to release PK | **unchanged** — internal FK stays on the int PK (1:1 join is server-side, never exposed) |
| Track FK | `TrackEntity.ReleaseId` | `long?` FK | **unchanged** — internal join (`TrackConfiguration.cs:42-50`) |
| DTO | `ReleaseDto` | `Id : long` (from `BaseModel`) | **add** `EntryKey : string`; `Id` still present but not used in public links |
| Converter | `TrackConverter.Convert(ReleaseEntity/ReleaseDto)` | maps `Id` | **add** `EntryKey` to both maps (`TrackConverter.cs:19-67`) |
| Detail routes | `/cuts/{id:long}`, `/sessions/{id:long}`, `/mixes/{id:long}`, `/tracks/{Id:long}` redirect | `:long` route constraints | **`{EntryKey}` string route params** (matching `/track/{EntryKey}`); pages load by `EntryKey` |
| Route resolver | `ReleaseRoutes.DetailHref(long id, ReleaseMedium)` + `(ReleaseDto)` overload | takes `long id` | **takes `string entryKey`** (overload reads `release.EntryKey`) — landed in 11.B, this wave re-types it (`ReleaseRoutes.cs:21-29`) |
| Share | `SharePopover.ReleaseId` (11.E, `long?`) | `long?` | **`string?` `EntryKey`** — 11.E just added it; this wave re-types it |
| Data service | `IReleaseDataService.GetById(long)` + the `releaseId`-filtered track page call | `long` | a **`GetByEntryKey(string)`** read path; the public track page filters by release `EntryKey` (or resolves `EntryKey`→int server-side) |
| **Public** API | `GET api/release/{id:long}`, `…/{id:long}/mix/waveform`, the `releaseId` query on `GET api/track/page` | `:long` route / `long?` query | **`{entryKey}` string route / `string?` query** — public reads address by `EntryKey` (`ReleaseController.cs:73/111/134/165`, `TrackController` `releaseId`) |
| **CMS/ApiKey** API | `DELETE api/track/release/{id:long}`, `POST …/mix/waveform`, `…/session/hero-image` | `:long` | **judgment call (§3e.5(3))** — these sit behind the ApiKey wall and are not the transparency target. Recommend leaving them on the int PK to keep the blast radius at the public surface; the CMS already holds the full `ReleaseDto`. |
| JSON | `ReleaseDto` serialization | `Id` (number) | `EntryKey` serializes as a string — case-insensitive client deserialization already configured; verify the client reads `EntryKey` |
| Rendered/parsed | Cut/Session/Mix detail page id parse; player-bar resolver; Archive/Cuts card hrefs | parse/compare `long` | carry/compare the `EntryKey` string at the public surface (no parse needed — string in, string out, like track routes) |
**The honest scope line:** the `EntryKey` handle reaches *every public addressing site* (routes,
resolver, share, the public read path, the public-facing API params) and **stops at the ApiKey wall**
— the internal FKs (`TrackEntity.ReleaseId`, the satellite `ReleaseId`s), the `long` PK, and the
CMS-only endpoints stay on the int. This is the minimal surface that satisfies "the id Daniel sees is
no longer transparent" while honoring the framework constraint and matching the track model exactly.
### 3e.4 EntryKey generation site — app-side, matching tracks
Track `EntryKey` is minted **app-side** (`Guid.NewGuid().ToString()` in `TrackContentService`), not
by a DB default. Two reasons to follow that here rather than a Postgres `gen_random_uuid()` server
default: (1) **consistency** — "just like tracks" includes *where the value is born*; (2) the release
is created in app code (the `FindOrCreateRelease` path the upload flow runs), so the mint site is
already in hand. Set `EntryKey = Guid.NewGuid().ToString()` at release creation in that path — the
identical call tracks make. **No DB default, no `HasDefaultValueSql`** — the column is app-populated
and carries a unique index.
**Naming/type — RESOLVED (Daniel, 2026-06-16): `EntryKey`, `string`.** The earlier draft proposed a
distinct `PublicId : Guid` column on the reasoning that a release has no vault entry, so a native
`uuid` column would index better. Daniel overrode that in favor of **genuine consistency with tracks**:
use the **same member name (`EntryKey`) and the same type (`string`)**, even though the release GUID
has no vault job. Verified against live source — `TrackEntity.EntryKey` is `required string`
(`DeepDrftModels/Entities/TrackEntity.cs:14`), column `entry_key` (snake_case, max 100, configured in
`TrackConfiguration`), minted as `Guid.NewGuid().ToString()`. `ReleaseEntity.EntryKey` mirrors all of
this: `required string`, `entry_key` column, app-minted GUID string, unique index. The minor
indexing edge of a native `uuid` column is not worth diverging the two entities' identifier model.
**Existing-data backfill — RESOLVED:** the migration mints a GUID-string `EntryKey` for every existing
release row at migration time (the same `Guid.NewGuid().ToString()` shape), then marks the column
non-null + unique. Not a dev reset. See §3e.5(2).
### 3e.5 The four decisions (two now RESOLVED by Daniel 2026-06-16; two remain open)
1. **PK strategy — additive GUID-string column vs. true PK retype. THE GATING DECISION — RESOLVED
(additive `EntryKey`, track-pattern).** Daniel chose the **additive app-level `EntryKey` (`string`)
column** (§3e.13e.2, §3e.4): it matches the track model exactly, avoids a framework fork, keeps the
migration trivial (add a column + backfill GUID strings, no FK rewrites), and the `long` int PK
stays DB-only/unused by the app. Daniel's words: *"long at the DB level with an app-level guid
`EntryKey` for the releases just like tracks. PK is not used by the app. … Migrate the existing
data to provide the entry key at migration time."* The true PK retype (§3e.6) is the literal reading
of the brief but costs a framework fork (or confirmation the package is generic-keyed) **and** a full
FK rewrite across two satellites + the track table — **declined** (recorded in §3e.6 per file
convention). Nothing about this gating call remains open.
2. **Existing-data conversion strategy — RESOLVED (in-migration backfill at migration time).** Daniel:
*"Migrate the existing data to provide the entry key at migration time."* The migration mints a
GUID-string `EntryKey` for every existing release row (mirroring the track mint, `Guid.NewGuid()
.ToString()`), then marks the column non-null + unique — **not** a dev-DB reset. (Mechanically this
is the in-migration backfill the earlier draft recommended as option (a): the schema carries real
migration history — eight applied migrations, a normalization data-migration that back-filled
releases from tracks, a unique-title-artist constraint with a conflict-recovery path — i.e. a
database with actual content. A Postgres `gen_random_uuid()` backfill cast to text, or an app-pass,
both produce the GUID-string shape; staff-engineer picks the mechanism, but the *decision* — backfill
at migration time, no reset — is settled.)
3. **Public URL exposure — raw GUID vs. slug/short-id.** Daniel's stated motivation is "int IDs too
transparent," and a raw GUID in the URL (`/cuts/9f8a…`) fully satisfies that — it leaks neither
count nor order. A slug (`/cuts/midnight-drift`) or short-id would be prettier but is a **separate
feature** (needs a slug column, collision handling, and a slugify step) and is *not* what the brief
asked for. *Recommend the raw GUID in the URL for this wave* (it is the direct, track-consistent
answer — `/track/{EntryKey}` is already a raw GUID string and nobody has asked to prettify it), and
**flag the slug as an adjacent future option** if Daniel later wants shareable-pretty URLs. Naming
it so the door stays open without widening this wave.
4. **Migration apply is Daniel-gated** (consistent with 11.G). Authoring the migration (add `entry_key`,
backfill GUID strings, unique index, non-null) is **in scope**; running `dotnet ef database update` is **not**
it applies on Daniel's go, exactly as `20260616035252_AddReleaseDescription` (11.G) is staged but
not applied. **Coordinate the two migrations:** 11.G's `AddReleaseDescription` is authored-not-applied
and also touches the `release` table; whichever applies second must be generated *after* the first so
the EF model snapshot is linear (no divergent-snapshot conflict). Recommend authoring 11.H's migration
**on top of** 11.G's (i.e. after 11.G is in the tree), and applying them in author order. Surface
this ordering to Daniel as part of the migration go-ahead.
### 3e.6 The alternative — true PK retype (considered and DECLINED, Daniel 2026-06-16)
For completeness, the literal "change the PK to GUID" path, so the rejection is on record (Daniel
firmly declined this 2026-06-16 in favor of the additive `EntryKey` column — "PK is not used by the
app"):
- `ReleaseEntity.Id` becomes `Guid` (requires a generic-keyed framework base, or a fork of
`Cerebellum.BlazorBlocks.Models`).
- `TrackEntity.ReleaseId`, `SessionMetadata.ReleaseId`, `MixMetadata.ReleaseId` all become `Guid` /
`Guid?` FKs; the migration mints a GUID per release and **rewrites every FK value** to match
(a multi-table data migration, ordered: mint release GUIDs → rewrite track FKs → rewrite satellite
FKs → swap PK).
- Every `Manager<>`/`Repository<>` `long`-typed CRUD call across tracks *and* releases shifts to `Guid`.
*Why declined:* it is a framework fork (or a confirmation-gated generic-key assumption) plus a
full FK-rewrite migration, for an outcome the additive `EntryKey` column achieves at the public surface
with a single new column and a backfill — and the additive approach is *what tracks already do*. The PK
retype buys nothing the column doesn't, at materially higher risk. **Daniel chose §3e.1 (additive
`EntryKey` column) on 2026-06-16; §3e.6 is recorded as the considered-and-declined literal reading** —
kept visible per file convention in case the PK-retype question ever resurfaces.
### 3e.7 Sequencing — why 11.H must follow 11.B11.E
This wave **sweeps the very files 11.B11.E create or edit**, so authoring it concurrently would
guarantee conflicts:
- **11.B** created `ReleaseRoutes.DetailHref(long id, …)` and the `/tracks/{Id:long}` redirect page —
11.H re-types both to the `string` `EntryKey` handle (`ReleaseRoutes.cs`, `TrackRedirect.razor`).
*(11.B is landed 2026-06-16, so this dependency is already satisfied.)*
- **11.C** folds Archive + Cuts cards into `ReleaseGallery` with the `HrefResolver` that calls
`ReleaseRoutes.DetailHref` — 11.H changes the id type flowing through that resolver. Sweeping the
card markup while 11.C is mid-flight would collide. **11.C must land first.**
- **11.D** edits `ArchiveView` (filters → URL); 11.H touches the same Archive card hrefs. **Sequence
after 11.D** (or fold carefully — but cleaner to follow).
- **11.E** just added `SharePopover.ReleaseId` as `long?` — 11.H re-types it to `string?` (the
`EntryKey` handle). **Must follow 11.E** or the type churns twice.
So the dependency is: **11.H follows 11.B (done), 11.C, 11.D, and 11.E.** It is the *last* public-site
wave of the phase by construction — it re-types the addressing surface that every prior wave builds on.
It is **independent of 11.G** except for the migration-ordering coordination in §3e.5(4) (both touch
the `release` table / EF snapshot). 11.G is authored-not-applied; author 11.H's migration after it.
### 3e.8 Observable acceptance criteria
- A release detail URL is `/cuts/{guid}` (e.g. `/cuts/9f8a3c2e-…`) — **no sequential integer appears
in any public release URL**; `/cuts/1` no longer resolves a release.
- The player-bar release-title click, Archive cards, and Cuts cards all navigate to the GUID URL
(via the re-typed `ReleaseRoutes.DetailHref`).
- `GET api/release/{entryKey}` returns the release; the legacy `GET api/release/{int}` no longer serves
the public read path (CMS/ApiKey endpoints' id type per decision 3e.5(3)).
- Every existing release row has a non-null, unique `EntryKey` (GUID string) after the (Daniel-applied)
migration — backfilled at migration time.
- Release-level Share (11.E) copies a GUID URL.
- Internal joins are unaffected: tracks still resolve to their release, satellites still 1:1-join,
the CMS still edits releases — none of which surfaces an `EntryKey` to a listener.
- New releases created via the upload/`FindOrCreateRelease` path receive a freshly minted `EntryKey`
(`Guid.NewGuid().ToString()`, the same call tracks make).
- `ReleaseEntity.EntryKey` is `required string` and `ReleaseDto.EntryKey` is `string`, mirroring
`TrackEntity.EntryKey` exactly (verified `required string` at `TrackEntity.cs:14`); the `long` PK is
unchanged and unused by the app.
### 3e.9 Wave placement — its own wave (11.H), last on the public-site critical path
11.H is a **standalone wave**: it is a cross-cutting re-type of the public release-addressing surface
plus a Daniel-gated migration, sharing files with (and therefore gated behind) 11.B11.E. It is not a
graft onto another wave — it touches the entity, the DTO, the converter, the routes, the resolver, the
share control, the public API params, and the public read path in one coherent sweep. Like 11.G it
carries a Daniel-gated migration; unlike 11.G it is **not** free-floating — its file overlap makes it
the terminal public-site wave. See §6 for the wave entry and the updated dependency shape.
---
## 4. Requirement 3 — full stack retirement + shared release-card normalization
**DECIDED (2026-06-15):** retire the **whole** track-cardinal stack (not just the `?album` branch),
*and* normalize release-card rendering into shared component(s) consumed across `/archive`, `/cuts`,
`/sessions`, `/mixes`. This is two moves — a **reduction** (delete dead surface) and a
**normalization** (collapse duplicated card markup into one component). The normalization is the
heart of 11.C; the reduction is its precondition cleanup.
### 4.1 The reduction — retire the track-cardinal stack (DECIDED, no longer a Daniel question)
Daniel confirmed the full retirement. The surface that goes:
| Surface | Route | Why it is now dead |
|---|---|---|
| `TracksView` | `/tracks` (+ `?album`/`?genre`/`?q`) | Archive subsumes browse/search; `/cuts/{id}` subsumes album view; nav-demoted since Phase 9 §8.I. |
| `TrackDetail` | `/track/{EntryKey}` | Loses its last inbound link once §2 repoints the player-bar title to the release. |
| `TrackCard` | — | Only consumer is `TracksGallery`, which only `TracksView` uses. |
| `TracksGallery` | — | Only consumer is `TracksView`. |
| `GalleryViewMode` | — | Only the `TracksView` filter-mode enum. |
**Ordering of the retirement (the dependency chain):**
1. §3 — `/cuts/{id}` exists.
2. §2 — `ReleaseRoutes` resolves Cut → `/cuts/{id}`; the resolver repoints the player-bar title to
the release (removing `TrackDetail`'s inbound link) and repoints `AlbumsView` + Archive Cut cards
off `/tracks?album`.
3. §4.1 — with no inbound links remaining, delete the whole stack above.
So the retirement is **gated on §2 and §3 landing first** — you cannot delete `TrackDetail` while the
player bar still links to it, and you cannot delete the `?album` branch while `AlbumsView`/Archive
still point at it. This sequencing is honored in §6.
> **One inbound-link audit before deletion (verify, don't assume):** grep for every `href`/`NavigateTo`
> targeting `/tracks` and `/track/`. The known ones are the player-bar title (`TrackMetaLabel`),
> `AlbumsView.OpenAlbum`, and `ArchiveView.DetailHref` — all repointed by §2. The `/albums → /cuts`
> redirect (`AlbumsRedirect`) stays. The CMS `/tracks/{id}/edit` family is a **different route tree**
> (`DeepDrftManager`) and is untouched — do not confuse the public `/tracks` gallery with the CMS
> track-edit routes.
### 4.2 The normalization — one shared release-card component (the heart of 11.C)
**Recommendation: make `ReleaseGallery` the single release-card grid, and give its card a per-card
href resolver so it can serve Archive's per-medium routing. Repoint `/archive` and `/cuts` onto it;
delete the inline `archive-release-card` and `album-card` markup.** This is the *One source, multiple
views* discipline (memory) applied to card rendering.
#### 4.2.1 What's redundant today (audited against live source, 2026-06-15)
| Surface | Card rendering | Verdict |
|---|---|---|
| `/sessions`, `/mixes` (`SessionsView`/`MixesView`) | Compose **`ReleaseGallery`** with a fixed `DetailRoute`. | **Canonical.** Already shared. |
| `/archive` (`ArchiveView`) | Inline `archive-release-card` markup — cover (+`--fallback`), title, artist. **Byte-for-byte the same structure as `ReleaseGallery`'s `release-card`**, only the CSS class prefix and the per-card `DetailHref` differ. | **Redundant copy.** Fold into `ReleaseGallery`. |
| `/cuts` (`AlbumsView`) | Inline `album-card` markup, card click → `OpenAlbum``/tracks?album`. | **Redundant copy + wrong target.** Fold into `ReleaseGallery`, repoint to `/cuts/{id}`. |
Three near-identical card implementations across four browse surfaces. The structure is identical
(cover with `--fallback`, body with truncated title + artist); the only real divergence is **how a
card computes its href**: Sessions/Mixes use a fixed route segment; Archive resolves per-medium.
#### 4.2.2 The shared component contract
`ReleaseGallery` already owns the grid, skeleton-loading, empty-state, and card markup. Generalize its
**href computation** so one component serves both the fixed-route case and the per-medium case:
- **Today:** `[Parameter] string DetailRoute` → card links `/{DetailRoute}/{id}`.
- **Add:** an optional per-card href resolver, `[Parameter] Func<ReleaseDto, string>? HrefResolver`.
When supplied, the card links `HrefResolver(release)`; otherwise it falls back to the
`DetailRoute`-based href (Sessions/Mixes unchanged). Archive passes
`HrefResolver="@ReleaseRoutes.DetailHref"` (the §2 resolver) — so each Archive card routes by its
own medium through the **same one table**.
- **Optionally fold the medium-route default into the resolver itself:** since `ReleaseRoutes` already
knows medium→route, Sessions/Mixes could *also* drop `DetailRoute` and just pass the resolver. But
keeping `DetailRoute` as the simple default avoids churning two working pages — **recommend adding
`HrefResolver` as the new path, leaving `DetailRoute` as the back-compat default**, and migrating
Sessions/Mixes only if it's free.
After this: `ReleaseGallery` is the one card grid. `archive-release-card` and `album-card` markup
(and their CSS) are deleted; `ArchiveView` keeps **only** its search/filter chrome above the grid
(the chrome is genuinely Archive-specific — it does not belong in the shared card component).
> **CSS consolidation:** the inline copies carry parallel CSS (`archive-release-*`, `album-card-*`).
> When the markup folds into `ReleaseGallery`, those rules collapse into the `release-card-*` rules.
> Verify the visual treatments match before deleting (cover sizing, fallback styling, truncation) —
> if Archive/Cuts cards were tuned differently, fold the differences into `ReleaseGallery` via a
> modifier class, not by keeping the duplicate.
#### 4.2.3 The Cuts-detail track row — same component, or different?
The brief asks whether the shared card extends to "the Cuts detail track list where applicable." The
Cut detail's track *rows* (§3.1 — `1. ▶ Track One … 3:42`) are a **different shape** from a release
*card* (a horizontal row with ordinal + play + duration, not a cover-forward card). **Recommend a
separate small `TrackRow` component** for the Cut track list rather than forcing it into
`ReleaseGallery` — they share nothing structurally. If a `TrackRow` is extracted, it is the natural
shared row for any future track-list surface, but it is *not* the release-card component. Flag this so
the normalization doesn't over-reach into forcing two unlike things into one component.
### 4.3 Residual redundancy (opportunistic, surface don't sprawl)
1. **`ArchiveView.MediumLabel`** — a medium→label lookup. Check whether it duplicates a CMS
`MediumTypeLabels` or the Archive's own medium-chip labels; if the public side has two
medium-label lookups, **consolidate to one**. **Medium confidence; opportunistic.**
2. **`GenresView` (`/genres`)** — already nav-demoted (§8.I); Archive has genre filtering. Likely
retire-able, but **out of this phase's stated scope** — flag as adjacent, low urgency. **Not in
Phase 11** unless Daniel pulls it in.
> Land the full stack retirement (§4.1) and the card normalization (§4.2) — both decided. Treat 4.3
> as tidy-ups: do them if they fall out of the normalization for free, surface `/genres` as adjacent.
---
## 5. Requirement 4 — Archive filters in the URL
**This is a URL-binding pass over the existing `ArchiveView`, borrowing the `TracksView` pattern
verbatim.** No new browser, no new data path — the filter state already drives `LoadReleases`; the
change is making that state **enter and leave via the query string**.
### 5.1 Target URL scheme
```
/archive?q={search}&medium={cut|session|mix}&genre={genre}
```
- All three params optional; omitting one means "no filter on that axis" (matches the current
null-means-all semantics).
- `medium` uses the same lowercase enum token the data service already speaks
(`Medium.ToString().ToLowerInvariant()`), parsed back with `Enum.TryParse(ignoreCase:true)` +
`Enum.IsDefined` — the exact posture `BatchUpload` (§8.E) and the API already use.
- Plain `/archive` (no params) = the unfiltered first page, which is the bridged/prerendered state.
### 5.2 The binding mechanics (borrow `TracksView`)
`TracksView.razor.cs` is the template:
- **In (URL → state):** add `[SupplyParameterFromQuery]` for `q`, `medium`, `genre`. Seed
`SearchText` / `_selectedMedium` / `_selectedGenre` from them in `OnInitializedAsync` **before**
the restore/fetch decision (so a direct nav to a filtered URL fetches filtered, and the bridge
restore is skipped when a filter is active — `ArchiveView` already has `HasActiveFilter` gating
exactly this).
- **Out (state → URL):** each of `OnSearchInput` / `OnMediumSelected` / `OnGenreSelected` calls
`Navigation.NavigateTo($"/archive?{composed query}")` **instead of** (or before) calling
`LoadReleases` directly. The query-param change drives the re-fetch.
### 5.3 The one real subtlety — same-route query change does not re-run `OnInitialized`
Blazor reuses the component on a same-route query-string change and fires `OnParametersSet`, **not**
`OnInitializedAsync` (the `TracksView.ClearFilter` comment, lines 117120, documents exactly this
trap). So the filter→fetch reaction must live where it sees the change:
- **Option A (history-driven):** filter handlers only `NavigateTo` the new URL; move the
state-seeding + `LoadReleases` into `OnParametersSet`/`OnParametersSetAsync` keyed off the query
params. Cleanest — the URL is the single source of truth; back/forward "just works" because each
nav re-runs the same seed-and-fetch. **Recommended.**
- **Option B (dual-write):** handlers both `NavigateTo` *and* `LoadReleases` directly. Simpler diff
but the URL and the fetch are two writes that can drift, and back/forward needs separate handling.
**Recommend Option A.** It makes the URL the source of truth (which is the whole point of the
requirement) and gets shareable links + back/forward correctness as a structural consequence rather
than as bolted-on handling. Guard against the debounce/nav interplay: the search field debounces
(400ms) before firing; ensure a debounced search nav doesn't fight a rapid medium-chip nav (the
`OnParametersSet` reaction should be idempotent on identical param sets — mirror the
`_loadedEntryKey` guard idiom).
### 5.4 Persistence interaction
The bridged unfiltered first page (`PersistKey = "archive-releases"`) must keep restoring **only**
when no filter is active — `ArchiveView` already gates persist + restore on `HasActiveFilter`. The
URL-binding pass must preserve that gate: a `/archive?medium=mix` direct load must **fetch**, not
restore the unfiltered bridge. The existing `HasActiveFilter` check already expresses this; the
seed-from-URL step just has to run before the restore decision (as §5.2 specifies).
---
## 6. Wave decomposition
Sequenced so the structural chain (Cuts page → resolver → repoint → retire/normalize) is honored,
and the genuinely independent tracks (Archive URL; the queue model; the Description schema slice) can
run in parallel. Nine commitments, **eight waves**. Two work items can start cold on day one: the
queue (11.F, gate for the Cuts "play album" affordance) and the Description schema slice (11.G, gated
only by Daniel's migration go-ahead). One wave is **terminal by construction**: 11.H (release GUID
identifiers) re-types the public addressing surface that 11.B11.E all build on, so it follows them.
```
┌──────────────────────────┐ ┌──────────────────────────┐ ┌──────────────────────────┐
│ 11.A /cuts/{id} page │ │ 11.D Archive filters→URL │ │ 11.F Queue model │
│ + CutDetailViewModel │ │ (TracksView-pattern) │ │ (IQueueService above the │
│ + cover theme border │ │ INDEPENDENT │ │ single-slot player) │
│ + ordered track list │ └──────────────────────────┘ │ + player TrackEnded hook │
│ (verify public read │ │ + player-bar skip controls│
│ projects+sorts │ │ INDEPENDENT (cold start) │
│ TrackNumber — §3a) │ └────────────┬─────────────┘
└──────────┬───────────────┘ │
│ (11.A header Play & row Play
▼ consume 11.F's PlayRelease;
┌──────────────────────────┐ degrade to single-track if 11.F
│ 11.B ReleaseRoutes │◄── needs 11.A (Cut→/cuts/id) later — §3.4 seam)
│ resolver + repoint │
│ (player-bar title→release,│
│ Archive + AlbumsView │ ┌──────────────────────────┐
│ cards) + thin /tracks/id │ │ 11.E Release-level Share │
│ redirect │ │ (SharePopover release │
└──────────┬───────────────┘ │ mode, copy ReleaseRoutes │
│ │ URL) — needs 11.B resolver│
│◄────────────────────┤ + a release detail to │
▼ │ share (11.A/Session/Mix) │
┌──────────────────────────┐ └──────────────────────────┘
│ 11.C Retire + normalize │ ┌──────────────────────────┐
│ • delete track-cardinal │ │ 11.G Release Description │
│ stack (Tracks*/Track*) │ │ schema slice (col + EF │
│ • fold Archive+Cuts cards │ │ migration [Daniel-gated], │
│ into shared ReleaseGallery│ │ DTO/converter/write path, │
│ • consolidate medium-label│ │ CMS multiline input) │
└──────────────────────────┘ │ INDEPENDENT (cold start) │
│ render rides 11.A + a │
│ Session/Mix detail touch │
└──────────────────────────┘
```
- **11.A — `/cuts/{id}` album-detail page.** The page, `CutDetailViewModel`, cover theme border,
ordered track list (ordered by `TrackNumber` — §3a), per-row play, header Play, Share button.
**Ordinal is a verification, not a dependency:** §3a confirmed `TrackNumber` already exists and the
read likely already sorts on it; 11.A's one checklist item is *confirm the public read projects +
sorts `TrackNumber`* (one-line fix if not). **Depends only on existing `GetById` + `releaseId`-
filtered track page (both exist).** Header/row Play call into 11.F when present, else degrade to
single-track streaming (§3.4 seam). **Load-bearing prerequisite for 11.B's Cut resolution.**
- **11.B — `ReleaseRoutes` resolver + repoint.** Promote `ArchiveView.DetailHref` to a shared
`ReleaseRoutes.DetailHref`; Cut now resolves to `/cuts/{id}` (needs 11.A); repoint player-bar
title (→ release), Archive cards, `AlbumsView` cards; add the thin `/tracks/{id}` redirect page.
**Depends on 11.A.** Verify `TrackDto.Release` is always populated on the player bar (so the
resolver has a medium — §7 gap).
- **11.C — retire + normalize (the heart).** With §2 having removed every inbound link: **delete the
whole track-cardinal stack** (`TracksView`/`TrackDetail`/`TrackCard`/`TracksGallery`/
`GalleryViewMode` + `/tracks`, `/track/{EntryKey}` routes — §4.1) **and** fold the Archive + Cuts
inline card markup into the shared `ReleaseGallery` via the new `HrefResolver` (§4.2); consolidate
the medium-label lookup (§4.3). **Depends on 11.B.** (The Cut track-row is a separate small
`TrackRow`, not `ReleaseGallery` — §4.2.3.)
- **11.D — Archive filters in the URL.** `/archive?q=&medium=&genre=`, history-driven (§5). Fully
independent — touches only `ArchiveView`. **No dependency; free-floating.** *Note:* 11.D and 11.C
both touch `ArchiveView` — sequence them so they don't collide (do 11.D's URL-binding and 11.C's
card-fold in one `ArchiveView` pass, or land 11.D first and rebase 11.C onto it).
- **11.E — release-level Share.** `SharePopover` gains a release mode that copies
`ReleaseRoutes.DetailHref(release)` (§3b). **Depends on 11.B** (needs the resolver for the URL) and
on a release detail existing to share (11.A for Cuts; Session/Mix already exist). Wire the Cut
header Share (and optionally Session/Mix headers) to open the popover in release mode.
- **11.F — queue model.** `IQueueService` orchestrating above the single-slot player; the one new
player-side hook (`TrackEnded`); player-bar skip controls (§3c). **Independent — can start cold on
day one**, in parallel with 11.A/11.D. It is the **gate for the Cuts "play album" affordance**:
11.A's header Play calls `QueueService.PlayRelease(tracks)` once 11.F lands (degrading to
single-track before then). **Preload (§1.3b) is OUT of this wave** — design the seam, defer the
feature (§3c.5).
- **11.G — release Description schema slice.** New `ReleaseEntity.Description` column + EF migration
(**Daniel-gated apply**), `ReleaseDto` mirror, `TrackConverter` round-trip, write-path plumbing
(`UpdateTrackMetadataRequest` + upload form + the unified services — threaded wherever `Genre` is),
and the CMS `AlbumHeaderFields` multiline input (§3d). **Independent — can start cold on day one**,
in parallel with everything; the only gate is Daniel's migration go-ahead. The **detail-page render
is NOT in this wave** — the Cut text block rides 11.A; the Session/Mix text block is a small additive
touch to those existing pages. Both degrade cleanly (a null Description renders nothing), so the
render can land before or after 11.G applies (§3d.43d.5).
- **11.H — release GUID identifiers (terminal public-site wave).** Front the release `long` PK with an
app-minted GUID-string `EntryKey` column — the **same member name and type tracks use** (§3e.1, §3e.4);
make `EntryKey` the only release id the public site and public API expose. New `ReleaseEntity.EntryKey`
(`required string`, unique index, app-minted `Guid.NewGuid().ToString()` at `FindOrCreateRelease`,
mirroring `TrackEntity.EntryKey`) + EF migration that **backfills an `EntryKey` for every existing
release row at migration time** (**Daniel-gated apply**); `ReleaseDto.EntryKey`; `TrackConverter`
round-trip; **re-type the public addressing surface from `long` to the `EntryKey` handle**: detail
routes (`/cuts|sessions|mixes/{id}``{EntryKey}` string params, like `/track/{EntryKey}`), the
`/tracks/{id}` redirect, `ReleaseRoutes.DetailHref`, `SharePopover.ReleaseId`, the public read path
(`GetByEntryKey`, the `releaseId`-filtered track page), and the public release API params
(`GET api/release/{id}` + `releaseId` query). Internal FKs (track→release, satellite→release), the
`long` int PK (unused by the app), and the ApiKey-gated CMS endpoints **stay on the int** (§3e.3,
decision 3e.5(3)). **Depends on 11.B (landed), 11.C, 11.D, 11.E** — it sweeps the files those waves
create/edit, so it is the *last* public-site wave (§3e.7). **Gating decision RESOLVED (Daniel,
2026-06-16): additive `EntryKey` (track-pattern); `long` PK unused by app; backfill at migration time**
(§3e.5(1)(2)). Migration ordered after 11.G's (§3e.5(4)). **Retyping the PK itself is DECLINED**
that is a framework fork (§3e.2, §3e.6); the additive `EntryKey` column achieves the transparency goal
at the public surface, exactly as tracks do.
**Dependency shape:**
```
11.A ──► 11.B ──► 11.C ─┐
└────► 11.E ─┤
11.D (free-floating; coordinate with 11.C on ArchiveView) ─┤
├──► 11.H (re-types the public
11.F (free-floating cold start; 11.A's "play album" uses it)│ addressing surface that
11.G (free-floating cold start; Daniel-gated migration. │ 11.B11.E build on; terminal
Render rides 11.A + a Session/Mix touch — degrades │ public-site wave. Migration
on null, so render & schema land in either order) │ ordered after 11.G's snapshot.)
─┘
```
**Critical path:** `11.A → 11.B → 11.C → 11.H`. **11.D, 11.E, 11.F, 11.G hang off the front** (11.E
after 11.B; 11.D, 11.F, 11.G fully parallel), but **11.H sits at the tail** — it depends on 11.B
(landed), 11.C, 11.D, and 11.E because it re-types the routes/resolver/share/cards those waves touch.
The "can start immediately" items are still **11.A**, **11.F**, and **11.G**; **11.H starts last**, once
the public addressing surface it rewrites has stopped moving. 11.G and 11.H both carry Daniel-gated
migrations against the `release` table — author 11.H's *after* 11.G's so the EF model snapshot stays
linear, and apply in author order (§3e.5(4)).
> **Honest dependency notes (the brief asked to keep these straight):**
> - **Ordinal does *not* gate a wave.** §3a showed `TrackNumber` already exists end-to-end; the
> "ordering" dependency is a one-line verification inside 11.A, not sequencing.
> - **Queue gates the "play album" affordance, not the Cut page.** 11.A ships with a degradable Play
> (single-track) if 11.F is not yet in; the affordance becomes "enqueue album" the moment 11.F
> lands, via the §3.4 handler swap. So 11.A and 11.F are parallel, with a clean late-binding seam.
> - **Shared cards parallel the stack retirement.** Folding Archive/Cuts cards into `ReleaseGallery`
> (§4.2) and deleting the track-cardinal stack (§4.1) are both in 11.C and both depend on 11.B's
> repoint — they are siblings, not sequential.
> - **Description schema (11.G) gates the Description *render*, not the Cut page.** 11.A ships its Cut
> page regardless; the Description block (on 11.A and on Session/Mix) renders nothing until 11.G's
> column carries data, then lights up with no rework. So 11.G is a cold-start free-floater whose only
> hard gate is Daniel's migration go-ahead — *not* a dependency of any other wave. Unlike commitment
> 5 (already-built, a verification), commitment 8 is a real migration; unlike the queue, it has no
> architecture question — it is a mechanical vertical that rides the existing `Genre` write channel.
---
## 7. Resolved decisions and remaining open questions
### 7.1 Resolved by Daniel (2026-06-15) — kept visible as *decided*, not deleted
1. **Player-bar title target (§2).** **DECIDED:** the title click resolves **release** detail via the
resolver, replacing the `/track/{EntryKey}` link. (Was OQ1.)
2. **Track ordinal (§3a).** **DECIDED:** explicit ordinal column, editable from the CMS — *and the
read confirms it already exists* (`TrackEntity.TrackNumber`, migration applied, CMS reorder live).
No new schema; verify the public read projects/sorts it. (Was OQ4 "do not assume into Phase 11";
Daniel reversed to in-scope, and it turned out already-built.)
3. **`/tracks` retirement scope (§4.1).** **DECIDED:** retire the **whole** track-cardinal stack
(`TracksView`/`TrackDetail`/`TrackCard`/`TracksGallery`/`GalleryViewMode` + routes), not just the
`?album` branch. (Was OQ5; the full-cut option chosen.)
4. **Release-level Share (§3b).** **DECIDED:** in scope. `SharePopover` gains a release-keyed mode.
(Was an adjacent gap; promoted.)
5. **Play-queue system (§3c).** **DECIDED:** in scope; absorbs the queue half of `PLAN.md §1.3`. The
Cuts "play album" affordance is its first consumer. (Was an adjacent gap; promoted.) Preload half
stays deferred.
6. **Release Description field (§3d).** **DECIDED:** in scope. A multiline base-`ReleaseEntity` field
(all media), edited from the CMS add/edit forms and rendered as a text block on every detail page.
Confirmed against live source — the column does **not** exist, so this carries a real EF migration
(Daniel-gated apply) + DTO/converter/write-path/CMS plumbing. Lands as schema slice 11.G; render
rides 11.A + a Session/Mix touch. (Focused addition, 2026-06-15.)
### 7.2 Still open (need Daniel — recommendations given)
1. **`/cuts/{id}` scaffold strategy (§3.2).** Compose `ReleaseDetailScaffold` with a generalized
`Header` slot (recommended) vs. bespoke page like `SessionDetail`. Sets whether the scaffold
contract changes. *Recommend the `Header`-slot generalization; bespoke as fallback.*
2. **Cut header affordance idiom (§3.2.2).** Keep the icon `PlayStateIcon`+`SharePopover` (consistent
with Session/Mix) vs. labeled **Play/Share buttons** (the literal spec wording). *Minor — flag.*
3. **Queue architecture (§3c).** Queue inside `IPlayerService` vs. a separate `IQueueService`
orchestrating above the single-slot player. *Strong steer: the separate `IQueueService` (§3c.1).*
**Final call is staff-engineer's at implementation** — the spec gives the steer, not the verdict.
4. **Release-share keeps "Embed player"? (§3b.3).** *Recommend copy-link-only in release mode across
all media; per-track embed stays where a track is the subject.* Trivial either way.
5. **`/genres` fate (§4.3).** Already nav-demoted; Archive has genre filtering. Retire `/genres` too?
*Out of stated scope — flag as adjacent, low urgency.* **Not in Phase 11** unless Daniel pulls it.
6. **Description render: plain text or markdown? (§3d.4).** *Recommend plain text with preserved line
breaks (`white-space: pre-line`) for v1 — no markdown dependency, matches "free-text field."* Trivial
to revisit if Daniel wants formatting. Also minor: the `HasMaxLength` ceiling for the column (§3d.3
step 1) — recommend generous (20004000) for paragraph prose; not a decision, just pick and note.
7. **Release id — additive GUID column vs. true PK retype (§3e.5(1)). THE GATING DECISION for 11.H —
RESOLVED (Daniel, 2026-06-16): additive `EntryKey` (track-pattern).** Releases get a new
`ReleaseEntity.EntryKey` (`required string`, app-minted GUID) — the *same member name and type tracks
use* — and the `long` PK stays DB-only, unused by the app. Daniel: *"long at the DB level with an
app-level guid `EntryKey` for the releases just like tracks. PK is not used by the app."* It avoids
forking the `Cerebellum.BlazorBlocks.Models` framework (whose `BaseEntity.Id` the consuming code shows
is hardwired `long`) and keeps the migration to a column + backfill rather than a full FK rewrite. The
literal "retype the PK to GUID" (§3e.6) is **declined** — recorded as considered-and-declined per file
convention (buys nothing the column doesn't, at framework-fork risk).
*(Staff-engineer should still confirm the package is non-generic-keyed at implementation — §3e.2 note —
though the decision stands regardless.)*
8. **Existing-data conversion for 11.H (§3e.5(2)) — RESOLVED (backfill at migration time).** Daniel:
*"Migrate the existing data to provide the entry key at migration time."* The migration mints a
GUID-string `EntryKey` for every existing release row (the `Guid.NewGuid().ToString()` shape tracks
use), then marks the column non-null + unique. **Not a dev-reset.** Staff-engineer picks the mechanism
(in-migration SQL backfill cast to text vs. an app pass); the *decision* — backfill at migration time —
is settled.
9. **Public URL form for 11.H (§3e.5(3)).** Raw GUID in the URL (`/cuts/{guid}`) vs. a slug/short-id.
*Recommend the raw GUID* — it directly satisfies "int IDs too transparent" and matches `/track/{EntryKey}`
(already a raw GUID string). A slug is a separate feature (slug column + collision handling); *flag as
an adjacent future option* if Daniel later wants pretty shareable URLs.
10. **CMS/ApiKey endpoint id type for 11.H (§3e.3, §3e.5(3)).** The ApiKey-gated release endpoints
(`DELETE api/track/release/{id}`, the mix-waveform / session-hero POSTs) are behind the auth wall and
are *not* the transparency target. *Recommend leaving them on the int PK* to keep 11.H's blast radius at
the public surface (the CMS already holds the full `ReleaseDto`). Flag — retyping them too is defensible
but widens the wave for no transparency gain.
### 7.3 Small things to get right (not decisions — implementer notes)
- **`TrackDto.Release` nullability on the player bar (11.B).** `TrackMetaLabel` guards `Track.Release?`
— if a track loads without its release populated, the resolver has no medium to route on. Verify the
streaming select path always carries `Release`. *Low risk; a verification line in 11.B.*
- **Public read projects/sorts `TrackNumber` (11.A).** The §3a verify step — confirm the public
`releaseId`-filtered track page sorts ascending by `TrackNumber` and carries it onto `TrackDto`.
- **Player `TrackEnded` hook (11.F).** The queue auto-advances on natural end-of-stream; verify the
player raises a track-ended event or add one (the single new player-side surface — §3c.2).
- **Year-only display (§3.1).** The Cut header shows just the year; `MixDetail`/`SessionDetail` show
"MMMM yyyy". Don't copy the month-year format into the Cut header.
- **Description is a base field, not medium-specific (11.G).** Put the CMS input in
`AlbumHeaderFields` (the base-fields block alongside Genre/Release Date), **not** in `MediumFields`.
Thread the write wherever `Genre` already threads (entity → DTO → converter → `UpdateTrackMetadataRequest`
→ upload form → unified services). No converter null-for-medium dance — it is uniform across media.
- **Description render degrades on null (11.G).** Most existing release rows will have `NULL`
Description until re-edited; the detail-page block must render nothing (not an empty heading) when
null. The Cut block lands in 11.A; the Session/Mix blocks are a small touch to those existing pages.
---
## 8. Why this is consistent with the system's grain
- **Release-cardinal everywhere.** Phase 9 + Wave 8 moved the public site to release-cardinal browse
(`/archive`) and per-medium detail. Phase 11 closes the one hole (Cuts had no detail page) and
makes the player-bar→detail path release-cardinal too. After this, the track-cardinal stack is
vestigial — which is *why* the reduction (§4) is available.
- **One source, multiple views.** The medium→route resolver (§2) is one table consumed by the player
bar, Archive, and Cuts cards — not three `DetailHref` switches. The shared `ReleaseGallery` (§4.2)
becomes the one release-card grid across all four browse surfaces, not three inline copies. The
queue (§3c) is one orchestrator the player bar and a future up-next panel both observe. The Cut
detail reuses the scaffold's invariant trio and the existing `GetById` + `releaseId`-filtered track
page — no new data path. (Memory: *One source, multiple views*.)
- **Design the seam, defer the feature.** Header Play binds to a single handler that swaps from
single-track to `QueueService.PlayRelease` with no page change (§3.4); preload subscribes to the
queue's "next is known" state rather than restructuring it (§3c.5). (Memory: *Design for
adaptability up front*.)
- **URL as source of truth (§5).** Borrowing the `TracksView` `[SupplyParameterFromQuery]` pattern
makes the Archive's filters addressable, which is the same shareable-link discipline the embed
player and `/tracks?album` deep links already established.
- **Extension, not modification.** The resolver and the `ReleaseDetailScaffold` `Header`/`BodyContent`
slots are additive; a future medium's detail page composes the same scaffold and the resolver gains
one entry — the Phase 9 Open/Closed discipline, unchanged.
- **Uniform release data lives on the base release (§3d).** The Description column lands on the base
`ReleaseEntity` because it is uniform across media — the same logic that puts `Genre`/`ReleaseDate`
there, and the explicit Phase 9 rule that satellites are for *medium-varying* data only
(`ReleaseType` being the one volume-justified exception). Description introduces no satellite, no
medium conditional, and rides the existing release-cardinal write channel — schema growth that
honors the spine rather than bending it.
---
## 9. Verified facts (read against live source 2026-06-15)
- **No `/tracks/{id}` route exists.** Track-cardinal detail is `/track/{EntryKey}` (`TrackDetail.razor`
line 1). Player-bar title links to `/track/{Track.EntryKey}` (`TrackMetaLabel.razor` line 9).
- **`/sessions/{id}` and `/mixes/{id}` exist and are mature** (`SessionDetail.razor`, `MixDetail.razor`,
both id-addressed, both inherit `ReleaseDetailBase`'s prerender bridge; `MixDetail` composes
`ReleaseDetailScaffold`, `SessionDetail` deliberately diverges).
- **Cuts have no single-release detail page.** `/cuts` is `AlbumsView` (medium-parameterized card
grid); cards open `/tracks?album={title}` (`AlbumsView.razor.cs` line 62). `/albums``/cuts`
redirect exists (`AlbumsRedirect.razor`).
- **`/archive` is release-cardinal, filters held in component fields not the URL.** `ArchiveView`
has `_selectedMedium`, `_selectedGenre`, `SearchText` as private state; no `[SupplyParameterFromQuery]`,
no `NavigateTo` on filter change (`ArchiveView.razor.cs`). It has a private `DetailHref` switch
(lines 121126) routing Session→`/sessions/{id}`, Mix→`/mixes/{id}`, Cut→`/tracks?album={title}`.
- **`TracksView` already reads `?album=`/`?genre=`/`?q=` from the URL** via `[SupplyParameterFromQuery]`
(`TracksView.razor.cs` lines 2123) — the pattern requirement 4 borrows. It documents the
same-route-query-change trap (`OnParametersSet` not `OnInitialized`, lines 117120).
- **`ReleaseDetailScaffold`** owns the invariant trio (back link, masthead, play/share) + `Hero` and
`MetaContent` slots; **no body/track-list slot today** (`ReleaseDetailScaffold.razor`).
- **Data primitives for the Cut page both exist:** `IReleaseDataService.GetById(id)` returns a full
`ReleaseDto`; the track-data service supports `releaseId`-filtered paging (`ReleaseDetailViewModel.Load`
uses `GetPage(…, releaseId: …)`). `ReleaseDto` carries `TrackCount` but the **track list needs the
filtered track page** (the DTO has no nested track collection).
- **`TrackEntity` ALREADY HAS an explicit track-number ordinal.** `TrackEntity.TrackNumber` (`int`,
1-based, non-null, default 1 — `TrackEntity.cs:17`); column `track_number` (`TrackConfiguration.cs:37`);
migration `20260611005700_AddReleaseTypeAndTrackNumber` **already applied**; `TrackDto.TrackNumber`
mirrors it (`TrackDto.cs:18`); `TrackConverter` round-trips it; `UpdateTrackMetadataRequest.TrackNumber`
+ `TrackController` validate (`> 0`) and persist; `BatchEdit` assigns ordinal from reorderable list
position on submit (`BatchEdit.razor:225`); `ReleaseRepository.GetTracks` already
`.OrderBy(t => t.TrackNumber)` (`ReleaseRepository.cs:117`). **Commitment 5 is verify-and-consume,
not new schema.** (This corrects the prior draft's "data-model gap" claim — the gap does not exist.)
- **Release-card markup is triplicated.** `ReleaseGallery` (`release-card`) is the canonical card
grid, used only by Sessions/Mixes. `ArchiveView` (`archive-release-card`) and `AlbumsView`
(`album-card`) re-implement the **same cover/title/artist structure inline** — the only real
divergence is per-card href computation (Archive resolves per-medium, the others use a fixed route).
- **`Pages.cs` `MenuPages`** = ARCHIVE (→ `/archive`) with Cuts/Sessions/Mixes children; `/tracks`
and `/genres` are absent from nav, routes reachable (§8.I).
- **`SharePopover` is track-keyed** (takes `EntryKey`) — sharing a release is a new target.
- **No release `Description` column exists.** `ReleaseEntity` (`ReleaseEntity.cs:13-30`) carries
`Title`, `Artist`, `Genre?`, `ReleaseDate?`, `ImagePath?`, `ReleaseType`, `Medium`, `CreatedByUserId?`,
`Tracks`, `SessionMetadata?`, `MixMetadata?` — no description; `ReleaseDto` (`ReleaseDto.cs:10-33`)
mirrors it + read-model `TrackCount`; `TrackConverter` (`TrackConverter.cs:19-65`) maps every base
field both directions with no `Description` line. A grep for `[Dd]escription` across the entity
returns nothing. **Commitment 8 is a real new column** (contrast commitment 5, already-built).
- **Release-cardinal fields are written through the track update/upload path, not a release endpoint.**
There is no `PUT api/release/{id}` metadata endpoint. `BatchEdit`'s `AlbumHeaderFields` owns
`Genre`/`ReleaseDate`/etc.; on submit each track's `CmsTrackService.UpdateAsync`/`UploadTrackAsync`
carries those fields and `TrackController` (`PUT api/track/meta/{id}`) projects the release-cardinal
ones onto the linked release. **Description follows this exact channel** (`UpdateTrackMetadataRequest.cs:14-23`,
`BatchEdit.razor:380-488`, `AlbumHeaderFields.razor:16-19/80-81`).