From d899bc94566246f9ed3b3021227b3d3b91e7c934 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Tue, 16 Jun 2026 11:43:11 -0400 Subject: [PATCH] docs(plan): add Phase 11 commitment 9 (release GUID identifiers, wave 11.H) --- PLAN.md | 9 +- .../phase-11-public-site-enhancements.md | 303 ++++++++++++++++-- 2 files changed, 289 insertions(+), 23 deletions(-) diff --git a/PLAN.md b/PLAN.md index 985f285..523b0a4 100644 --- a/PLAN.md +++ b/PLAN.md @@ -221,7 +221,7 @@ Heat→intensity and collision soft↔hard transfer functions are **staff-engine ## 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). @@ -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*). -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.B–11.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.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.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.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.B–11.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 2000–4000); `/genres` fate (out of scope, flag as adjacent). diff --git a/product-notes/phase-11-public-site-enhancements.md b/product-notes/phase-11-public-site-enhancements.md index b1af76a..3ee1698 100644 --- a/product-notes/phase-11-public-site-enhancements.md +++ b/product-notes/phase-11-public-site-enhancements.md @@ -1,7 +1,8 @@ # 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). **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 **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 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, -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. -The eight: +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. @@ -39,6 +42,15 @@ The eight: 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): 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.B–11.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 @@ -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` 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` / `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.1–3e.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.B–11.E + +This wave **sweeps the very files 11.B–11.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.B–11.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), @@ -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, 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 -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.B–11.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 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.4–3d.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:** ``` -11.A ──► 11.B ──► 11.C - └────► 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) +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.B–11.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.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. +**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 @@ -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 to revisit if Daniel wants formatting. Also minor: the `HasMaxLength` ceiling for the column (§3d.3 step 1) — recommend generous (2000–4000) 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)