docs(plan): add release Description field as commitment 8 / wave 11.G

Verified no Description column exists on ReleaseEntity/ReleaseDto (mirror
image of commitment 5, which was already built). Specs the new base-release
column + EF migration (Daniel-gated), DTO/converter/write-path plumbing,
CMS multiline input, and detail-page text block. Schema lands as 11.G;
render rides 11.A plus a Session/Mix touch.
This commit is contained in:
daniel-c-harvey
2026-06-15 23:38:51 -04:00
parent 31e00e6abd
commit 56e205082d
2 changed files with 235 additions and 23 deletions
@@ -21,9 +21,10 @@ demoted from the nav (route kept). That work landed and is stable on dev (2026-0
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 **seven**. They share one spine: **make the release the cardinal unit of the public site,
scope to **eight**. 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). The seven:
object** (ordered, queue-able, shareable) — and now, **a release that can describe itself** in prose.
The eight:
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.
@@ -35,12 +36,20 @@ object** (ordered, queue-able, shareable). The seven:
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.
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.
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
@@ -57,6 +66,20 @@ is closing asymmetries and consolidating rendering that drifted into per-surface
> 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 |
@@ -74,6 +97,7 @@ is closing asymmetries and consolidating rendering that drifted into per-surface
| `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)
@@ -103,9 +127,10 @@ these up front so the implementer is not misled:
---
## 1. The seven commitments (Daniel, faithful capture; decisions of 2026-06-15 folded in)
## 1. The eight commitments (Daniel, faithful capture; decisions of 2026-06-15 folded in)
The original four (14) plus three Daniel added when he resolved the open questions (57).
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
@@ -152,6 +177,17 @@ The original four (14) plus three Daniel added when he resolved the open ques
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
@@ -521,6 +557,123 @@ 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.**
---
## 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),
@@ -695,9 +848,10 @@ seed-from-URL step just has to run before the restore decision (as §5.2 specifi
## 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) can run in parallel. Seven
commitments, six waves. The queue (11.F) is the one work item that can start cold on day one and is
the gate for the Cuts "play album" affordance.
and the genuinely independent tracks (Archive URL; the queue model; the Description schema slice) can
run in parallel. Eight commitments, **seven 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).
```
┌──────────────────────────┐ ┌──────────────────────────┐ ┌──────────────────────────┐
@@ -723,13 +877,16 @@ the gate for the Cuts "play album" affordance.
│◄────────────────────┤ + a release detail to │
▼ │ share (11.A/Session/Mix) │
┌──────────────────────────┐ └──────────────────────────┘
│ 11.C Retire + normalize │
│ • delete track-cardinal │
│ stack (Tracks*/Track*) │
│ • fold Archive+Cuts cards │
│ into shared ReleaseGallery│
│ • consolidate medium-label│
└──────────────────────────┘
│ 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,
@@ -764,6 +921,14 @@ 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).
**Dependency shape:**
@@ -772,12 +937,16 @@ the gate for the Cuts "play album" affordance.
└────► 11.E (also needs a release detail to share)
11.D (free-floating; coordinate with 11.C on ArchiveView)
11.F (free-floating cold start; 11.A's "play album" consumes it)
11.G (free-floating cold start; gated only by Daniel's migration go-ahead.
Detail-page render rides 11.A + a Session/Mix touch — degrades on null,
so render & schema can land in either order)
```
**Critical path:** `11.A → 11.B → 11.C`. **11.D, 11.E, 11.F hang off it** (11.E after 11.B; 11.D and
11.F fully parallel). The two "can start immediately" items are **11.A** and **11.F** kicking both
off first shortens the wall-clock to a usable Cut page (page + queue arrive together, so "play album"
works on first ship of 11.A rather than as a later retrofit).
**Critical path:** `11.A → 11.B → 11.C`. **11.D, 11.E, 11.F, 11.G hang off it** (11.E after 11.B; 11.D,
11.F, 11.G fully parallel). The "can start immediately" items are **11.A**, **11.F**, and **11.G**
kicking 11.A + 11.F off first shortens the wall-clock to a usable Cut page (page + queue arrive
together, so "play album" works on first ship of 11.A rather than as a later retrofit); 11.G runs
alongside on its own track, surfacing the Description on the detail pages as it lands.
> **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
@@ -788,6 +957,12 @@ works on first ship of 11.A rather than as a later retrofit).
> - **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.
---
@@ -809,6 +984,11 @@ works on first ship of 11.A rather than as a later retrofit).
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)
@@ -824,6 +1004,10 @@ works on first ship of 11.A rather than as a later retrofit).
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.3 Small things to get right (not decisions — implementer notes)
@@ -836,6 +1020,13 @@ works on first ship of 11.A rather than as a later retrofit).
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.
---
@@ -861,6 +1052,12 @@ works on first ship of 11.A rather than as a later retrofit).
- **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.
---
@@ -902,3 +1099,15 @@ works on first ship of 11.A rather than as a later retrofit).
- **`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`).