diff --git a/PLAN.md b/PLAN.md index 6bc0155..0ab0b34 100644 --- a/PLAN.md +++ b/PLAN.md @@ -242,7 +242,7 @@ Sequenced as **eight waves**; the critical path is `11.A → 11.B → 11.C → 1 - **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. +- **11.H — release GUID identifiers (terminal public-site wave).** Front the release `long` PK with an app-minted GUID-string `EntryKey` column — the **same pattern tracks use** (`TrackEntity.EntryKey` is `required string`, app-minted `Guid.NewGuid().ToString()`, keeping the int PK private). New `ReleaseEntity.EntryKey` (`string`, unique index, minted at `FindOrCreateRelease`) + EF migration that **backfills a GUID-string `EntryKey` for every existing release row at migration time** (**Daniel-gated apply**); `ReleaseDto.EntryKey`; `TrackConverter` round-trip; **re-type the public addressing surface from `long` to the `EntryKey` handle** — detail routes (`:long`→`{EntryKey}`), 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 `long` int PK (unused by the app), 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)) — RESOLVED (additive `EntryKey`, track-pattern):** additive app-level GUID-string `EntryKey` column matching tracks; the `long` PK stays DB-only and unused by the app; existing rows are backfilled at migration time (not a dev reset). Daniel's rationale (2026-06-16): "long at the DB level with an app-level guid `EntryKey` for the releases just like tracks; PK is not used by the app; migrate the existing data to provide the entry key at migration time." The true PK retype is **declined** (framework fork of `Cerebellum.BlazorBlocks.Models` — `BaseEntity.Id` hardwired `long` — plus full FK rewrite; recorded as considered-and-declined per file convention). Still open: 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); 11.C (2026-06-16); 11.E (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.D, 11.H remain open (11.H still gated behind 11.D). diff --git a/product-notes/phase-11-public-site-enhancements.md b/product-notes/phase-11-public-site-enhancements.md index 3ee1698..7a9db38 100644 --- a/product-notes/phase-11-public-site-enhancements.md +++ b/product-notes/phase-11-public-site-enhancements.md @@ -45,12 +45,15 @@ The nine: 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). + end-to-end. **The reframe (§3e), RESOLVED by Daniel 2026-06-16: tracks did *not* change their PK + type — they front the int PK with an app-minted GUID *string* (`EntryKey`) as the public handle + and keep the int `Id` private. Releases get the *same member*: a new `ReleaseEntity.EntryKey` + (`string`, matching tracks) backfilled at migration time; the `long` PK stays DB-only, unused by + the app.** This is the chosen path (not just recommended) because the int PK is `BaseEntity.Id` + from the `Cerebellum.BlazorBlocks.Models` framework and is hardwired to `long` across + `Repository<>` / `Manager<>`. See §3e for the surface map, the framework constraint, the decisions + (gating one now resolved), and the sequencing (this wave **must follow 11.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 @@ -692,15 +695,19 @@ and a Session/Mix touch.** > 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 +**RESOLVED (Daniel, 2026-06-16): do NOT change the release primary-key type. Front the existing +`long` PK with an app-minted GUID *string* — a new `EntryKey` column, the *same member name and type +tracks use* — and make *that* the only release identifier the public site and API expose. The int +`Id` stays the internal PK, unused by the app; the GUID string becomes the addressable handle. This +is exactly, mechanically, what tracks already do with `EntryKey`, and the chosen name/type mirror +that for genuine consistency.** Daniel's words: *"long at the DB level with an app-level guid +`EntryKey` for the releases just like tracks. PK is not used by the app. Be consistent with naming. +Migrate the existing data to provide the entry key at migration time."* The brief's literal "replace +all the int IDs with GUIDs from the entity up" was the right *intent* (kill the transparent sequential id in the URL) but, taken as "change the PK type to `Guid`," it collides head-on with a -framework constraint — and the "similar to how tracks are keyed" steer is the clue that the +framework constraint — and the "similar to how tracks are keyed" steer was the clue that the non-colliding path is the intended one. The reframe is the headline; the surface map and the -alternative (a true PK swap, if Daniel wants it despite the cost) follow. +declined alternative (a true PK swap) follow. ### 3e.1 How tracks are keyed today — the model to copy (read against live source, 2026-06-16) @@ -714,9 +721,10 @@ A track has **two** identifiers, and only one is public: 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.) +string handle and address the public site by it — not as "retype the PK."** (Note one divergence: +a track's `EntryKey` doubles as the FileDatabase vault entry id, so it has a second job. A release +has no vault entry, so its `EntryKey` is purely an identifier. Daniel's call (2026-06-16) is to use +the **same member name (`EntryKey`) and type (`string`)** anyway, for naming consistency — see §3e.4.) ### 3e.2 The framework constraint — why a true PK swap is not a local change @@ -747,71 +755,81 @@ exactly why tracks solved the transparency problem with `EntryKey` rather than b ### 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. +What the public-facing id touches today, and what each becomes under the **resolved additive +`EntryKey` (GUID-string) column** approach. "→ `EntryKey`" means "switch from the int `Id` to the new +app-level GUID-string handle"; "unchanged" means it stays on the internal int PK behind the ApiKey +wall. -| Layer | Site | Today | Under recommendation | +| Layer | Site | Today | Resolved (additive `EntryKey`, track-pattern) | |---|---|---|---| -| 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`) | +| Entity | `ReleaseEntity` | `Id : long` (PK) | **add** `EntryKey : string` (`required`, app-minted, unique index), mirroring `TrackEntity.EntryKey`; `Id` **unchanged** (DB-only, unused by app) | +| EF config | `ReleaseConfiguration` | `id` PK + columns | **add** `entry_key` column (snake_case, like `TrackConfiguration`) + unique index; PK unchanged (`ReleaseConfiguration.cs:9-93`) | | Satellites | `SessionMetadata.ReleaseId`, `MixMetadata.ReleaseId` | `long` FK to release PK | **unchanged** — internal FK stays on the int PK (1:1 join is server-side, never exposed) | | Track FK | `TrackEntity.ReleaseId` | `long?` FK | **unchanged** — internal join (`TrackConfiguration.cs:42-50`) | -| DTO | `ReleaseDto` | `Id : long` (from `BaseModel`) | **add** `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`) | +| DTO | `ReleaseDto` | `Id : long` (from `BaseModel`) | **add** `EntryKey : string`; `Id` still present but not used in public links | +| Converter | `TrackConverter.Convert(ReleaseEntity/ReleaseDto)` | maps `Id` | **add** `EntryKey` to both maps (`TrackConverter.cs:19-67`) | +| Detail routes | `/cuts/{id:long}`, `/sessions/{id:long}`, `/mixes/{id:long}`, `/tracks/{Id:long}` redirect | `:long` route constraints | **`{EntryKey}` string route params** (matching `/track/{EntryKey}`); pages load by `EntryKey` | +| Route resolver | `ReleaseRoutes.DetailHref(long id, ReleaseMedium)` + `(ReleaseDto)` overload | takes `long id` | **takes `string entryKey`** (overload reads `release.EntryKey`) — landed in 11.B, this wave re-types it (`ReleaseRoutes.cs:21-29`) | +| Share | `SharePopover.ReleaseId` (11.E, `long?`) | `long?` | **`string?` `EntryKey`** — 11.E just added it; this wave re-types it | +| Data service | `IReleaseDataService.GetById(long)` + the `releaseId`-filtered track page call | `long` | a **`GetByEntryKey(string)`** read path; the public track page filters by release `EntryKey` (or resolves `EntryKey`→int server-side) | +| **Public** API | `GET api/release/{id:long}`, `…/{id:long}/mix/waveform`, the `releaseId` query on `GET api/track/page` | `:long` route / `long?` query | **`{entryKey}` string route / `string?` query** — public reads address by `EntryKey` (`ReleaseController.cs:73/111/134/165`, `TrackController` `releaseId`) | | **CMS/ApiKey** API | `DELETE api/track/release/{id:long}`, `POST …/mix/waveform`, `…/session/hero-image` | `:long` | **judgment call (§3e.5(3))** — these sit behind the ApiKey wall and are not the transparency target. Recommend leaving them on the int PK to keep the blast radius at the public surface; the CMS already holds the full `ReleaseDto`. | -| JSON | `ReleaseDto` serialization | `Id` (number) | `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 | +| JSON | `ReleaseDto` serialization | `Id` (number) | `EntryKey` serializes as a string — case-insensitive client deserialization already configured; verify the client reads `EntryKey` | +| Rendered/parsed | Cut/Session/Mix detail page id parse; player-bar resolver; Archive/Cuts card hrefs | parse/compare `long` | carry/compare the `EntryKey` string at the public surface (no parse needed — string in, string out, like track routes) | -**The honest scope line:** the 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. +**The honest scope line:** the `EntryKey` handle reaches *every public addressing site* (routes, +resolver, share, the public read path, the public-facing API params) and **stops at the ApiKey wall** +— the internal FKs (`TrackEntity.ReleaseId`, the satellite `ReleaseId`s), the `long` PK, and the +CMS-only endpoints stay on the int. This is the minimal surface that satisfies "the id Daniel sees is +no longer transparent" while honoring the framework constraint and matching the track model exactly. -### 3e.4 GUID generation site — app-side, matching tracks +### 3e.4 EntryKey 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.) +Track `EntryKey` is minted **app-side** (`Guid.NewGuid().ToString()` in `TrackContentService`), not +by a DB default. Two reasons to follow that here rather than a Postgres `gen_random_uuid()` server +default: (1) **consistency** — "just like tracks" includes *where the value is born*; (2) the release +is created in app code (the `FindOrCreateRelease` path the upload flow runs), so the mint site is +already in hand. Set `EntryKey = Guid.NewGuid().ToString()` at release creation in that path — the +identical call tracks make. **No DB default, no `HasDefaultValueSql`** — the column is app-populated +and carries a unique index. -**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.) +**Naming/type — RESOLVED (Daniel, 2026-06-16): `EntryKey`, `string`.** The earlier draft proposed a +distinct `PublicId : Guid` column on the reasoning that a release has no vault entry, so a native +`uuid` column would index better. Daniel overrode that in favor of **genuine consistency with tracks**: +use the **same member name (`EntryKey`) and the same type (`string`)**, even though the release GUID +has no vault job. Verified against live source — `TrackEntity.EntryKey` is `required string` +(`DeepDrftModels/Entities/TrackEntity.cs:14`), column `entry_key` (snake_case, max 100, configured in +`TrackConfiguration`), minted as `Guid.NewGuid().ToString()`. `ReleaseEntity.EntryKey` mirrors all of +this: `required string`, `entry_key` column, app-minted GUID string, unique index. The minor +indexing edge of a native `uuid` column is not worth diverging the two entities' identifier model. -### 3e.5 The four open decisions (Daniel's calls — recommendations given, none decided unilaterally) +**Existing-data backfill — RESOLVED:** the migration mints a GUID-string `EntryKey` for every existing +release row at migration time (the same `Guid.NewGuid().ToString()` shape), then marks the column +non-null + unique. Not a dev reset. See §3e.5(2). -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.** +### 3e.5 The four decisions (two now RESOLVED by Daniel 2026-06-16; two remain open) -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.) +1. **PK strategy — additive GUID-string column vs. true PK retype. THE GATING DECISION — RESOLVED + (additive `EntryKey`, track-pattern).** Daniel chose the **additive app-level `EntryKey` (`string`) + column** (§3e.1–3e.2, §3e.4): it matches the track model exactly, avoids a framework fork, keeps the + migration trivial (add a column + backfill GUID strings, no FK rewrites), and the `long` int PK + stays DB-only/unused by the app. Daniel's words: *"long at the DB level with an app-level guid + `EntryKey` for the releases just like tracks. PK is not used by the app. … Migrate the existing + data to provide the entry key at migration time."* The true PK retype (§3e.6) is the literal reading + of the brief but costs a framework fork (or confirmation the package is generic-keyed) **and** a full + FK rewrite across two satellites + the track table — **declined** (recorded in §3e.6 per file + convention). Nothing about this gating call remains open. + +2. **Existing-data conversion strategy — RESOLVED (in-migration backfill at migration time).** Daniel: + *"Migrate the existing data to provide the entry key at migration time."* The migration mints a + GUID-string `EntryKey` for every existing release row (mirroring the track mint, `Guid.NewGuid() + .ToString()`), then marks the column non-null + unique — **not** a dev-DB reset. (Mechanically this + is the in-migration backfill the earlier draft recommended as option (a): the schema carries real + migration history — eight applied migrations, a normalization data-migration that back-filled + releases from tracks, a unique-title-artist constraint with a conflict-recovery path — i.e. a + database with actual content. A Postgres `gen_random_uuid()` backfill cast to text, or an app-pass, + both produce the GUID-string shape; staff-engineer picks the mechanism, but the *decision* — backfill + at migration time, no reset — is settled.) 3. **Public URL exposure — raw GUID vs. slug/short-id.** Daniel's stated motivation is "int IDs too transparent," and a raw GUID in the URL (`/cuts/9f8a…`) fully satisfies that — it leaks neither @@ -822,8 +840,8 @@ string; a release has no such constraint, so a native `uuid` column is cleaner a **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** — +4. **Migration apply is Daniel-gated** (consistent with 11.G). Authoring the migration (add `entry_key`, + backfill GUID strings, unique index, non-null) is **in scope**; running `dotnet ef database update` is **not** — it applies on Daniel's go, exactly as `20260616035252_AddReleaseDescription` (11.G) is staged but not applied. **Coordinate the two migrations:** 11.G's `AddReleaseDescription` is authored-not-applied and also touches the `release` table; whichever applies second must be generated *after* the first so @@ -831,9 +849,11 @@ string; a release has no such constraint, so a native `uuid` column is cleaner a **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) +### 3e.6 The alternative — true PK retype (considered and DECLINED, Daniel 2026-06-16) -For completeness, the literal "change the PK to GUID" path, so the rejection is on record: +For completeness, the literal "change the PK to GUID" path, so the rejection is on record (Daniel +firmly declined this 2026-06-16 in favor of the additive `EntryKey` column — "PK is not used by the +app"): - `ReleaseEntity.Id` becomes `Guid` (requires a generic-keyed framework base, or a fork of `Cerebellum.BlazorBlocks.Models`). @@ -843,12 +863,12 @@ For completeness, the literal "change the PK to GUID" path, so the rejection is 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). +*Why declined:* it is a framework fork (or a confirmation-gated generic-key assumption) plus a +full FK-rewrite migration, for an outcome the additive `EntryKey` column achieves at the public surface +with a single new column and a backfill — and the additive approach is *what tracks already do*. The PK +retype buys nothing the column doesn't, at materially higher risk. **Daniel chose §3e.1 (additive +`EntryKey` column) on 2026-06-16; §3e.6 is recorded as the considered-and-declined literal reading** — +kept visible per file convention in case the PK-retype question ever resurfaces. ### 3e.7 Sequencing — why 11.H must follow 11.B–11.E @@ -856,15 +876,15 @@ This wave **sweeps the very files 11.B–11.E create or edit**, so authoring it 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.H re-types both to the `string` `EntryKey` handle (`ReleaseRoutes.cs`, `TrackRedirect.razor`). + *(11.B is landed 2026-06-16, so this dependency is already satisfied.)* - **11.C** folds Archive + Cuts cards into `ReleaseGallery` with the `HrefResolver` that calls `ReleaseRoutes.DetailHref` — 11.H changes the id type flowing through that resolver. Sweeping the card markup while 11.C is mid-flight would collide. **11.C must land first.** - **11.D** edits `ArchiveView` (filters → URL); 11.H touches the same Archive card hrefs. **Sequence after 11.D** (or fold carefully — but cleaner to follow). -- **11.E** just added `SharePopover.ReleaseId` as `long?` — 11.H re-types it to `Guid?`. **Must follow - 11.E** or the type churns twice. +- **11.E** just added `SharePopover.ReleaseId` as `long?` — 11.H re-types it to `string?` (the + `EntryKey` handle). **Must follow 11.E** or the type churns twice. So the dependency is: **11.H follows 11.B (done), 11.C, 11.D, and 11.E.** It is the *last* public-site wave of the phase by construction — it re-types the addressing surface that every prior wave builds on. @@ -877,15 +897,18 @@ the `release` table / EF snapshot). 11.G is authored-not-applied; author 11.H's 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. +- `GET api/release/{entryKey}` returns the release; the legacy `GET api/release/{int}` no longer serves + the public read path (CMS/ApiKey endpoints' id type per decision 3e.5(3)). +- Every existing release row has a non-null, unique `EntryKey` (GUID string) after the (Daniel-applied) + migration — backfilled at migration time. - Release-level Share (11.E) copies a GUID URL. - Internal joins are unaffected: tracks still resolve to their release, satellites still 1:1-join, - the CMS still edits releases — none of which surfaces 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.) + the CMS still edits releases — none of which surfaces an `EntryKey` to a listener. +- New releases created via the upload/`FindOrCreateRelease` path receive a freshly minted `EntryKey` + (`Guid.NewGuid().ToString()`, the same call tracks make). +- `ReleaseEntity.EntryKey` is `required string` and `ReleaseDto.EntryKey` is `string`, mirroring + `TrackEntity.EntryKey` exactly (verified `required string` at `TrackEntity.cs:14`); the `long` PK is + unchanged and unused by the app. ### 3e.9 Wave placement — its own wave (11.H), last on the public-site critical path @@ -1155,19 +1178,23 @@ identifiers) re-types the public addressing surface that 11.B–11.E all build o 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 + app-minted GUID-string `EntryKey` column — the **same member name and type tracks use** (§3e.1, §3e.4); + make `EntryKey` the only release id the public site and public API expose. New `ReleaseEntity.EntryKey` + (`required string`, unique index, app-minted `Guid.NewGuid().ToString()` at `FindOrCreateRelease`, + mirroring `TrackEntity.EntryKey`) + EF migration that **backfills an `EntryKey` for every existing + release row at migration time** (**Daniel-gated apply**); `ReleaseDto.EntryKey`; `TrackConverter` + round-trip; **re-type the public addressing surface from `long` to the `EntryKey` handle**: detail + routes (`/cuts|sessions|mixes/{id}` → `{EntryKey}` string params, like `/track/{EntryKey}`), the `/tracks/{id}` redirect, `ReleaseRoutes.DetailHref`, `SharePopover.ReleaseId`, the public read path - (`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. + (`GetByEntryKey`, the `releaseId`-filtered track page), and the public release API params + (`GET api/release/{id}` + `releaseId` query). Internal FKs (track→release, satellite→release), the + `long` int PK (unused by the app), and the ApiKey-gated CMS endpoints **stay on the int** (§3e.3, + decision 3e.5(3)). **Depends on 11.B (landed), 11.C, 11.D, 11.E** — it sweeps the files those waves + create/edit, so it is the *last* public-site wave (§3e.7). **Gating decision RESOLVED (Daniel, + 2026-06-16): additive `EntryKey` (track-pattern); `long` PK unused by app; backfill at migration time** + (§3e.5(1)–(2)). Migration ordered after 11.G's (§3e.5(4)). **Retyping the PK itself is DECLINED** — + that is a framework fork (§3e.2, §3e.6); the additive `EntryKey` column achieves the transparency goal + at the public surface, exactly as tracks do. **Dependency shape:** @@ -1251,19 +1278,23 @@ linear, and apply in author order (§3e.5(4)). 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. +7. **Release id — additive GUID column vs. true PK retype (§3e.5(1)). THE GATING DECISION for 11.H — + RESOLVED (Daniel, 2026-06-16): additive `EntryKey` (track-pattern).** Releases get a new + `ReleaseEntity.EntryKey` (`required string`, app-minted GUID) — the *same member name and type tracks + use* — and the `long` PK stays DB-only, unused by the app. Daniel: *"long at the DB level with an + app-level guid `EntryKey` for the releases just like tracks. PK is not used by the app."* It avoids + forking the `Cerebellum.BlazorBlocks.Models` framework (whose `BaseEntity.Id` the consuming code shows + is hardwired `long`) and keeps the migration to a column + backfill rather than a full FK rewrite. The + literal "retype the PK to GUID" (§3e.6) is **declined** — recorded as considered-and-declined per file + convention (buys nothing the column doesn't, at framework-fork risk). + *(Staff-engineer should still confirm the package is non-generic-keyed at implementation — §3e.2 note — + though the decision stands regardless.)* +8. **Existing-data conversion for 11.H (§3e.5(2)) — RESOLVED (backfill at migration time).** Daniel: + *"Migrate the existing data to provide the entry key at migration time."* The migration mints a + GUID-string `EntryKey` for every existing release row (the `Guid.NewGuid().ToString()` shape tracks + use), then marks the column non-null + unique. **Not a dev-reset.** Staff-engineer picks the mechanism + (in-migration SQL backfill cast to text vs. an app pass); the *decision* — backfill at migration time — + is settled. 9. **Public URL form for 11.H (§3e.5(3)).** Raw GUID in the URL (`/cuts/{guid}`) vs. a slug/short-id. *Recommend the raw GUID* — it directly satisfies "int IDs too transparent" and matches `/track/{EntryKey}` (already a raw GUID string). A slug is a separate feature (slug column + collision handling); *flag as