docs(plan): add Phase 11 commitment 9 (release GUID identifiers, wave 11.H)

This commit is contained in:
daniel-c-harvey
2026-06-16 11:43:11 -04:00
parent bef1e3adfb
commit d899bc9456
2 changed files with 289 additions and 23 deletions
+5 -4
View File
@@ -221,7 +221,7 @@ Heat→intensity and collision soft↔hard transfer functions are **staff-engine
## Phase 11 — Public Site Enhancements ## Phase 11 — Public Site Enhancements
The next pass over the public listening surface, after Phase 9 + Wave 8 moved the site to release-cardinal browse (`/archive`) and per-medium detail. The spine of the phase: **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). **Eight Daniel commitments** (the original four plus four added 2026-06-15 when he resolved the open questions and expanded scope): (1) a Cuts detail page `/cuts/{id}`; (2) the player-bar release-title resolves medium → dedicated detail page; (3) retire the **whole** track-cardinal stack **and** normalize release-card rendering into shared components; (4) encode Archive filters in the URL; (5) explicit track ordinal editable from the CMS; (6) release-level Share; (7) a play-queue system (absorbs the queue half of §1.3); (8) a release **Description** field — multiline free-text on the base release (all media), edited from the CMS add/edit forms and rendered as a text block on every detail page. Full design, framing corrections, wave decomposition, gap analysis: `product-notes/phase-11-public-site-enhancements.md`. The next pass over the public listening surface, after Phase 9 + Wave 8 moved the site to release-cardinal browse (`/archive`) and per-medium detail. The spine of the phase: **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). **Nine Daniel commitments** (the original four, plus four added 2026-06-15 when he resolved the open questions and expanded scope, plus a ninth added 2026-06-16): (1) a Cuts detail page `/cuts/{id}`; (2) the player-bar release-title resolves medium → dedicated detail page; (3) retire the **whole** track-cardinal stack **and** normalize release-card rendering into shared components; (4) encode Archive filters in the URL; (5) explicit track ordinal editable from the CMS; (6) release-level Share; (7) a play-queue system (absorbs the queue half of §1.3); (8) a release **Description** field — multiline free-text on the base release (all media), edited from the CMS add/edit forms and rendered as a text block on every detail page; (9) **release GUID identifiers** — front the release's transparent sequential int PK with an opaque app-minted GUID handle (the track-`EntryKey` model), swept across every public addressing site. Full design, framing corrections, wave decomposition, gap analysis: `product-notes/phase-11-public-site-enhancements.md`.
**State it inherits (verified 2026-06-15).** `/sessions/{id}` and `/mixes/{id}` detail pages **exist and are mature** (both inherit `ReleaseDetailBase`'s prerender bridge; `MixDetail` composes `ReleaseDetailScaffold`, `SessionDetail` deliberately diverges). `/archive` is **already** a release-cardinal searchable browser (search + medium + genre). `ReleaseGallery` is the shared release-card grid — but **only Sessions/Mixes use it**; Archive and Cuts re-implement equivalent card markup inline. The real gaps: **Cuts have no single-release detail page** (`/cuts` cards open `/tracks?album={title}`), and **`/archive` holds its filters in component fields, not the URL**. The queue/playlist **does not exist** (single-slot player). **State it inherits (verified 2026-06-15).** `/sessions/{id}` and `/mixes/{id}` detail pages **exist and are mature** (both inherit `ReleaseDetailBase`'s prerender bridge; `MixDetail` composes `ReleaseDetailScaffold`, `SessionDetail` deliberately diverges). `/archive` is **already** a release-cardinal searchable browser (search + medium + genre). `ReleaseGallery` is the shared release-card grid — but **only Sessions/Mixes use it**; Archive and Cuts re-implement equivalent card markup inline. The real gaps: **Cuts have no single-release detail page** (`/cuts` cards open `/tracks?album={title}`), and **`/archive` holds its filters in component fields, not the URL**. The queue/playlist **does not exist** (single-slot player).
@@ -233,7 +233,7 @@ The next pass over the public listening surface, after Phase 9 + Wave 8 moved th
**Design discipline.** The medium→route resolver is **one table** (`ReleaseRoutes.DetailHref`) consumed by the player bar, Archive, and Cuts cards. The shared `ReleaseGallery` becomes the **one** release-card grid across all four browse surfaces (Archive/Cuts fold in via a new per-card `HrefResolver`), not three inline copies (memory *One source, multiple views*). The `/cuts/{id}` page composes `ReleaseDetailScaffold` via a generalized `Header` slot + a `BodyContent` slot for the track list — **not** a boolean layout flag (Phase 9 §5.3). The queue is a separate `IQueueService` orchestrating above the single-slot player (strong steer; final call staff-engineer's). Header Play binds to a single handler that swaps single-track → `QueueService.PlayRelease` with no page change (memory *Design for adaptability up front*). **Design discipline.** The medium→route resolver is **one table** (`ReleaseRoutes.DetailHref`) consumed by the player bar, Archive, and Cuts cards. The shared `ReleaseGallery` becomes the **one** release-card grid across all four browse surfaces (Archive/Cuts fold in via a new per-card `HrefResolver`), not three inline copies (memory *One source, multiple views*). The `/cuts/{id}` page composes `ReleaseDetailScaffold` via a generalized `Header` slot + a `BodyContent` slot for the track list — **not** a boolean layout flag (Phase 9 §5.3). The queue is a separate `IQueueService` orchestrating above the single-slot player (strong steer; final call staff-engineer's). Header Play binds to a single handler that swaps single-track → `QueueService.PlayRelease` with no page change (memory *Design for adaptability up front*).
Sequenced as **seven waves**; the critical path is `11.A → 11.B → 11.C`, with 11.D / 11.E / 11.F / 11.G hanging off it. Sequenced as **eight waves**; the critical path is `11.A → 11.B → 11.C → 11.H`, with 11.D / 11.E / 11.F / 11.G hanging off the front and 11.H sitting at the tail (it re-types the public addressing surface that 11.B11.E build on).
- **11.A — `/cuts/{id}` album-detail page.** Left header (name, artist, genre, year, Play + Share), right cover with theme border, ordered track list (by `TrackNumber`) with per-row play, header Play. New `CutDetailViewModel`; reuses `GetById` + the `releaseId`-filtered track page (both exist). Ordinal is a **verification** (§3a), not a dependency. Header/row Play consume 11.F when present, else degrade to single-track (§3.4 seam). **Load-bearing prerequisite for 11.B's Cut resolution.** - **11.A — `/cuts/{id}` album-detail page.** Left header (name, artist, genre, year, Play + Share), right cover with theme border, ordered track list (by `TrackNumber`) with per-row play, header Play. New `CutDetailViewModel`; reuses `GetById` + the `releaseId`-filtered track page (both exist). Ordinal is a **verification** (§3a), not a dependency. Header/row Play consume 11.F when present, else degrade to single-track (§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 resolves to `/cuts/{id}` (needs 11.A); repoint player-bar title (→ release), Archive cards, `AlbumsView` cards; thin `/tracks/{id}` redirect page. **Depends on 11.A.** - **11.B — `ReleaseRoutes` resolver + repoint.** Promote `ArchiveView.DetailHref` to a shared `ReleaseRoutes.DetailHref`; Cut resolves to `/cuts/{id}` (needs 11.A); repoint player-bar title (→ release), Archive cards, `AlbumsView` cards; thin `/tracks/{id}` redirect page. **Depends on 11.A.**
@@ -242,10 +242,11 @@ Sequenced as **seven waves**; the critical path is `11.A → 11.B → 11.C`, wit
- **11.E — release-level Share.** `SharePopover` gains a release mode that copies `ReleaseRoutes.DetailHref(release)`; wire the Cut header Share to it. **Depends on 11.B** (resolver) + a release detail to share. - **11.E — release-level Share.** `SharePopover` gains a release mode that copies `ReleaseRoutes.DetailHref(release)`; wire the Cut header Share to it. **Depends on 11.B** (resolver) + a release detail to share.
- **11.F — queue model.** `IQueueService` above the single-slot player + one new player `TrackEnded` hook + player-bar skip controls. **Free-floating, can start cold day one.** Gates the Cuts "play album" affordance (11.A header Play). **Preload (§1.3 half b) stays OUT** — design the seam, defer the feature. - **11.F — queue model.** `IQueueService` above the single-slot player + one new player `TrackEnded` hook + player-bar skip controls. **Free-floating, can start cold day one.** Gates the Cuts "play album" affordance (11.A header Play). **Preload (§1.3 half b) stays OUT** — design the seam, defer the feature.
- **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), CMS `AlbumHeaderFields` multiline input (§3d). **Free-floating, can start cold day one** — 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 block is a small additive touch to those existing pages. Both degrade cleanly (null Description renders nothing), so render & schema can land in either order. - **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), CMS `AlbumHeaderFields` multiline input (§3d). **Free-floating, can start cold day one** — 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 block is a small additive touch to those existing pages. Both degrade cleanly (null Description renders nothing), so render & schema can land in either order.
- **11.H — release GUID identifiers (terminal public-site wave).** Front the release `long` PK with an app-minted `PublicId` GUID column (the track-`EntryKey` model — tracks already keep their int PK private and expose an app-minted GUID string). New `ReleaseEntity.PublicId` (`Guid`, unique index, minted at `FindOrCreateRelease`) + EF migration backfilling GUIDs for existing rows (**Daniel-gated apply**); `ReleaseDto.PublicId`; `TrackConverter` round-trip; **re-type the public addressing surface to `Guid`** — detail routes (`:long``:guid`), the `/tracks/{id}` redirect, `ReleaseRoutes.DetailHref`, `SharePopover.ReleaseId`, the public read path, and the public release API params (`GET api/release/{id}` + the `releaseId` track-page query). Internal FKs (track→release, satellite→release), the int PK, and the ApiKey-gated CMS endpoints **stay on the int**. **Depends on 11.B (landed), 11.C, 11.D, 11.E** — it sweeps the routes/resolver/share/cards those waves create or edit, so it is the **last** public-site wave (spec §3e.7). **Gating decision (Daniel, spec §3e.5(1)):** additive `PublicId` column (**recommended** — matches tracks, avoids forking `Cerebellum.BlazorBlocks.Models` whose `BaseEntity.Id` is hardwired `long`) vs. a true PK retype (recorded, declined — framework fork + full FK rewrite). Also Daniel-gated: existing-data conversion (recommend in-migration `gen_random_uuid()` backfill — the DB appears to hold real data, do **not** assume reset is safe), raw-GUID URL (recommended) vs. slug, and migration ordering after 11.G's snapshot.
**Landed:** 11.A (2026-06-16); 11.F (2026-06-16); 11.G (2026-06-16); 11.B (2026-06-16). The §3.4 PlayAlbum→`IQueueService` seam (deferred in 11.A, awaiting 11.F) is now closed: `CutDetail.razor` consumes the cascaded `IQueueService` — header Play calls `Queue.PlayRelease(ViewModel.Tracks, 0)`, per-row play calls `Queue.PlayRelease(ViewModel.Tracks, index)`, currently-playing row toggles play/pause, null-safe fallback to `SelectTrackStreaming` when the queue cascade is absent (2026-06-16). Migration `20260616035252_AddReleaseDescription` authored but not yet applied (Daniel-gated). Tracks 11.C, 11.D, 11.E remain open. **Landed:** 11.A (2026-06-16); 11.F (2026-06-16); 11.G (2026-06-16); 11.B (2026-06-16). The §3.4 PlayAlbum→`IQueueService` seam (deferred in 11.A, awaiting 11.F) is now closed: `CutDetail.razor` consumes the cascaded `IQueueService` — header Play calls `Queue.PlayRelease(ViewModel.Tracks, 0)`, per-row play calls `Queue.PlayRelease(ViewModel.Tracks, index)`, currently-playing row toggles play/pause, null-safe fallback to `SelectTrackStreaming` when the queue cascade is absent (2026-06-16). Migration `20260616035252_AddReleaseDescription` authored but not yet applied (Daniel-gated). Tracks 11.C, 11.D, 11.E, 11.H remain open (11.H is gated behind 11.C/11.D/11.E and carries the §3e.5(1) PK-strategy decision).
**Dependency shape:** `11.A → 11.B → 11.C`; `11.B → 11.E`; **11.D, 11.F, 11.G parallel** (11.D coordinates with 11.C on `ArchiveView`; 11.F's "play album" is consumed by 11.A; 11.G's Description render rides 11.A + a Session/Mix touch, degrading on null). The cold-start items are **11.A**, **11.F**, and **11.G** — kick 11.A + 11.F off first so "play album" works on first ship of the Cut page; 11.G runs alongside on its own track. **Dependency shape:** `11.A → 11.B → 11.C → 11.H`; `11.B → 11.E`; **11.D, 11.F, 11.G parallel** (11.D coordinates with 11.C on `ArchiveView`; 11.F's "play album" is consumed by 11.A; 11.G's Description render rides 11.A + a Session/Mix touch, degrading on null). **11.H is terminal** — it re-types the public release-addressing surface (routes, `ReleaseRoutes`, `SharePopover`, cards, public API params) that 11.B11.E create/edit, so it follows all of them; its migration is authored after 11.G's so the EF snapshot stays linear. The cold-start items are **11.A**, **11.F**, and **11.G** — kick 11.A + 11.F off first so "play album" works on first ship of the Cut page; 11.G runs alongside on its own track; 11.H waits for the addressing surface to settle.
**Resolved by Daniel (2026-06-15), kept visible per file convention:** player-bar title → release detail (was OQ1); track ordinal in scope **and already built** (was OQ4, reversed then found done); retire the **whole** track-cardinal stack (was OQ5, full cut chosen); release-level Share in scope; play-queue in scope (queue half of §1.3 absorbed; preload half stays deferred); release **Description** field in scope (commitment 8 — a real new column, lands as schema slice 11.G with the render on 11.A + a Session/Mix touch). **Still open (spec §7.2):** `/cuts/{id}` scaffold strategy (generalized `Header` slot — recommended — vs. bespoke); Cut header affordance idiom (icon vs. labeled buttons); queue architecture (separate `IQueueService` — strong steer; staff-engineer's final call); whether release-share keeps "Embed player" (recommend copy-link-only); Description render plain-text vs. markdown (recommend plain text + preserved line breaks for v1) and column max-length (recommend 20004000); `/genres` fate (out of scope, flag as adjacent). **Resolved by Daniel (2026-06-15), kept visible per file convention:** player-bar title → release detail (was OQ1); track ordinal in scope **and already built** (was OQ4, reversed then found done); retire the **whole** track-cardinal stack (was OQ5, full cut chosen); release-level Share in scope; play-queue in scope (queue half of §1.3 absorbed; preload half stays deferred); release **Description** field in scope (commitment 8 — a real new column, lands as schema slice 11.G with the render on 11.A + a Session/Mix touch). **Still open (spec §7.2):** `/cuts/{id}` scaffold strategy (generalized `Header` slot — recommended — vs. bespoke); Cut header affordance idiom (icon vs. labeled buttons); queue architecture (separate `IQueueService` — strong steer; staff-engineer's final call); whether release-share keeps "Embed player" (recommend copy-link-only); Description render plain-text vs. markdown (recommend plain text + preserved line breaks for v1) and column max-length (recommend 20004000); `/genres` fate (out of scope, flag as adjacent).
@@ -1,7 +1,8 @@
# Phase 11 — Public Site Enhancements # Phase 11 — Public Site Enhancements
Status: spec / design. Author: product-designer. Date: 2026-06-15 (revised same day after Daniel Status: spec / design. Author: product-designer. Date: 2026-06-15 (revised same day after Daniel
resolved the open questions and expanded scope). **Plan only — no code edits made by this doc.** 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 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 — **absorbed into this phase**), `product-notes/phase-9-release-medium-types.md` (the medium model —
@@ -21,10 +22,12 @@ 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 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 surfaced an initial four commitments; on 2026-06-15 he resolved the open questions and expanded the
scope to **eight**. They share one spine: **make the release the cardinal unit of the public site, scope to **eight**; on 2026-06-16 he added a **ninth** — switch releases to GUID identifiers so the
make every navigation an addressable, shareable URL, and make the album a first-class playable id is no longer a transparent sequential integer. They share one spine: **make the release the
object** (ordered, queue-able, shareable) — and now, **a release that can describe itself** in prose. cardinal unit of the public site, make every navigation an addressable, shareable URL, and make the
The eight: 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. 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. 2. **Player-bar release-title → release detail** via a medium→route resolver — structural.
@@ -39,6 +42,15 @@ The eight:
8. **Release Description field****new scope (2026-06-15)**; a multiline free-text field on the 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 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. 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): 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.
That same move is the recommendation here, 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 four open decisions, 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 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 pages already share a `ReleaseGallery` card component; the detail pages already share
@@ -674,6 +686,218 @@ 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."
**Recommendation: do NOT change the release primary-key type. Front the existing `long` PK with an
app-minted GUID *string* — a new `PublicId` column — and make *that* the only release identifier the
public site and API expose. The int `Id` stays the internal PK; the GUID becomes the addressable
handle. This is exactly, mechanically, what tracks already do with `EntryKey`.** The brief's literal
"replace all the int IDs with GUIDs from the entity up" is 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 is the clue that the
non-colliding path is the intended one. The reframe is the headline; the surface map and the
alternative (a true PK swap, if Daniel wants it despite the cost) 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 GUID is purely an identifier — see the column-name note in §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 **recommended `PublicId`
column** approach. "→ GUID" means "switch from the int `Id` to the new GUID handle"; "unchanged"
means it stays on the internal int PK behind the ApiKey wall.
| Layer | Site | Today | Under recommendation |
|---|---|---|---|
| Entity | `ReleaseEntity` | `Id : long` (PK) | **add** `PublicId : Guid` (app-minted, unique index); `Id` **unchanged** |
| EF config | `ReleaseConfiguration` | `id` PK + columns | **add** `public_id` column + 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** `PublicId : Guid`; `Id` still present but not used in public links |
| Converter | `TrackConverter.Convert(ReleaseEntity/ReleaseDto)` | maps `Id` | **add** `PublicId` 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 | **`:guid`** constraints; pages load by `PublicId` |
| Route resolver | `ReleaseRoutes.DetailHref(long id, ReleaseMedium)` + `(ReleaseDto)` overload | takes `long id` | **takes `Guid publicId`** (overload reads `release.PublicId`) — landed in 11.B, this wave re-types it (`ReleaseRoutes.cs:21-29`) |
| Share | `SharePopover.ReleaseId` (11.E, `long?`) | `long?` | **`Guid?`** — 11.E just added it; this wave re-types it |
| Data service | `IReleaseDataService.GetById(long)` + the `releaseId`-filtered track page call | `long` | a **`GetByPublicId(Guid)`** read path; the public track page filters by `PublicId` (or resolves PublicId→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 | **`:guid`** / `Guid?` — public reads address by `PublicId` (`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) | `PublicId` serializes as a GUID string — case-insensitive client deserialization already configured; verify the client reads `PublicId` |
| Rendered/parsed | Cut/Session/Mix detail page id parse; player-bar resolver; Archive/Cuts card hrefs | parse/compare `long` | parse/compare `Guid` at the public surface |
**The honest scope line:** the GUID 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 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 GUID generation site — app-side, matching tracks
`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** — "similar to how tracks are keyed" 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 `PublicId = Guid.NewGuid()` at release creation in that path. **No DB default,
no `HasDefaultValueSql`** — the column is app-populated and carries a unique index. (If Daniel prefers
a DB default for defense-in-depth, `gen_random_uuid()` via Npgsql is available, but it diverges from
the track model for no real gain — recommend app-side.)
**Column-name note:** tracks call this `EntryKey` because it doubles as the vault entry id. A release
GUID has no vault job, so name it for what it is — recommend **`PublicId`** (`public_id` column),
`Guid` type, not a stringified GUID. (Tracks store a *string* because the FileDatabase entry id is a
string; a release has no such constraint, so a native `uuid` column is cleaner and indexes better.)
### 3e.5 The four open decisions (Daniel's calls — recommendations given, none decided unilaterally)
1. **PK strategy — additive GUID column vs. true PK retype. THE GATING DECISION.** *Recommend the
additive `PublicId` column* (§3e.13e.2): it matches the track model, avoids a framework fork, keeps
the migration trivial (add a column + backfill GUIDs, no FK rewrites), and the int PK keeps serving
the internal joins it already serves well. 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. **This is the fork the whole wave hinges on —
Daniel must pick before anything is authored.**
2. **Existing-data conversion strategy.** Under the recommended column approach this is small but real:
**mint a GUID for every existing release row.** Either (a) a data-migration step (`UPDATE release
SET public_id = gen_random_uuid() WHERE public_id IS NULL` inside the same migration, then mark the
column non-null + unique — Postgres has `gen_random_uuid()` so the backfill needs no app pass), or
(b) if there is **no production data to preserve**, a clean dev-DB reset. *What I can infer about
live data:* 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) — this reads like a database with **actual content**, not a throwaway. So
*recommend (a) the in-migration backfill* and do **not** assume a reset is safe. **Confirm with
Daniel whether the target DB holds data worth preserving** — that single fact decides (a) vs. (b).
(Under the PK-retype alternative §3e.6, this decision is far heavier — it becomes a full FK
rewrite — which is another reason to prefer the column approach.)
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 `public_id`,
backfill, 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 considered — true PK retype (recorded, not recommended)
For completeness, the literal "change the PK to GUID" path, so the rejection is on record:
- `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 rejected:* it is a framework fork (or a confirmation-gated generic-key assumption) plus a
full FK-rewrite migration, for an outcome the additive 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. **Recommend §3e.1 (column); record §3e.6 as
the considered-and-declined literal reading** in case Daniel specifically wants the PK itself retyped
(in which case decision 3e.5(1) flips and the wave grows substantially).
### 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 `Guid` (`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 `Guid?`. **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/{guid}` 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 `PublicId` after the (Daniel-applied) migration.
- 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 a GUID to a listener.
- New releases created via the upload/`FindOrCreateRelease` path receive a freshly minted `PublicId`.
- (If decision 3e.5(1) chose the PK retype instead: the above, plus `ReleaseEntity.Id` is `Guid` and
every FK column is GUID — a different acceptance surface; the spec assumes the recommended column.)
### 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 ## 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), **DECIDED (2026-06-15):** retire the **whole** track-cardinal stack (not just the `?album` branch),
@@ -849,9 +1073,10 @@ seed-from-URL step just has to run before the restore decision (as §5.2 specifi
Sequenced so the structural chain (Cuts page → resolver → repoint → retire/normalize) is honored, 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 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 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 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). 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.
``` ```
┌──────────────────────────┐ ┌──────────────────────────┐ ┌──────────────────────────┐ ┌──────────────────────────┐ ┌──────────────────────────┐ ┌──────────────────────────┐
@@ -929,24 +1154,42 @@ only by Daniel's migration go-ahead).
is NOT in this wave** — the Cut text block rides 11.A; the Session/Mix text block is a small additive 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 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). 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 `PublicId` GUID column (the track-`EntryKey` model — §3e.1); make `PublicId` the only
release id the public site and public API expose. New `ReleaseEntity.PublicId` (`Guid`, unique index,
app-minted at `FindOrCreateRelease`) + EF migration with a GUID backfill for existing rows
(**Daniel-gated apply**); `ReleaseDto.PublicId`; `TrackConverter` round-trip; **re-type the public
addressing surface to `Guid`**: detail routes (`/cuts|sessions|mixes/{id}``:guid`), the
`/tracks/{id}` redirect, `ReleaseRoutes.DetailHref`, `SharePopover.ReleaseId`, the public read path
(`GetByPublicId`, the `releaseId`-filtered track page), and the public release API params
(`GET api/release/{id}` + `releaseId` query). Internal FKs (track→release, satellite→release), the int
PK, 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). Carries the gating PK-strategy decision (§3e.5(1)) and a migration
ordered after 11.G's (§3e.5(4)). **NOT recommended: retyping the PK itself** — that is a framework
fork (§3e.2, §3e.6); the column approach achieves the transparency goal at the public surface.
**Dependency shape:** **Dependency shape:**
``` ```
11.A ──► 11.B ──► 11.C 11.A ──► 11.B ──► 11.C ─┐
└────► 11.E (also needs a release detail to share) └────► 11.E ─┤
11.D (free-floating; coordinate with 11.C on ArchiveView) 11.D (free-floating; coordinate with 11.C on ArchiveView) ─┤
11.F (free-floating cold start; 11.A's "play album" consumes it) ├──► 11.H (re-types the public
11.G (free-floating cold start; gated only by Daniel's migration go-ahead. 11.F (free-floating cold start; 11.A's "play album" uses it)│ addressing surface that
Detail-page render rides 11.A + a Session/Mix touch — degrades on null, 11.G (free-floating cold start; Daniel-gated migration. │ 11.B11.E build on; terminal
so render & schema can land in either order) 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.D, 11.E, 11.F, 11.G hang off it** (11.E after 11.B; 11.D, **Critical path:** `11.A → 11.B → 11.C → 11.H`. **11.D, 11.E, 11.F, 11.G hang off the front** (11.E
11.F, 11.G fully parallel). The "can start immediately" items are **11.A**, **11.F**, and **11.G** after 11.B; 11.D, 11.F, 11.G fully parallel), but **11.H sits at the tail** — it depends on 11.B
kicking 11.A + 11.F off first shortens the wall-clock to a usable Cut page (page + queue arrive (landed), 11.C, 11.D, and 11.E because it re-types the routes/resolver/share/cards those waves touch.
together, so "play album" works on first ship of 11.A rather than as a later retrofit); 11.G runs The "can start immediately" items are still **11.A**, **11.F**, and **11.G**; **11.H starts last**, once
alongside on its own track, surfacing the Description on the detail pages as it lands. 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):** > **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 > - **Ordinal does *not* gate a wave.** §3a showed `TrackNumber` already exists end-to-end; the
@@ -1008,6 +1251,28 @@ alongside on its own track, surfacing the Description on the detail pages as it
breaks (`white-space: pre-line`) for v1 — no markdown dependency, matches "free-text field."* Trivial 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 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. 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.**
*Recommend the additive `PublicId` GUID column* (the track-`EntryKey` model): it matches "similar to
how tracks are keyed," 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 recorded as considered-and-declined
— it buys nothing the column doesn't, at framework-fork risk. **Daniel must pick before 11.H is authored.**
*(Staff-engineer should confirm the package is non-generic-keyed at implementation — §3e.2 note.)*
8. **Existing-data conversion for 11.H (§3e.5(2)).** Under the column approach: backfill a GUID into every
existing release row. *Recommend an in-migration `gen_random_uuid()` backfill* (no app pass needed) —
the schema's migration history (eight migrations, a back-fill normalization, a unique constraint with
conflict recovery) reads like a DB with **real content**, so do *not* assume a dev-reset is safe.
**Confirm with Daniel whether the target DB holds data worth preserving** — that fact decides backfill
vs. reset.
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) ### 7.3 Small things to get right (not decisions — implementer notes)