docs(plan): lock P11 11.H decision — additive EntryKey string, track-pattern, migration-time backfill

This commit is contained in:
daniel-c-harvey
2026-06-16 12:19:25 -04:00
parent f35cbc82fe
commit e6a80b6086
2 changed files with 147 additions and 116 deletions
+146 -115
View File
@@ -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.B11.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.B11.E** — it sweeps the
files they create/edit).
This is not a greenfield phase — most of the scaffolding it needs already exists (the medium browse
pages already share a `ReleaseGallery` card component; the detail pages already share
@@ -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 callsrecommendations 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.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.**
### 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.13e.2, §3e.4): it matches the track model exactly, avoids a framework fork, keeps the
migration trivial (add a column + backfill GUID strings, no FK rewrites), and the `long` int PK
stays DB-only/unused by the app. Daniel's words: *"long at the DB level with an app-level guid
`EntryKey` for the releases just like tracks. PK is not used by the app. … Migrate the existing
data to provide the entry key at migration time."* The true PK retype (§3e.6) is the literal reading
of the brief but costs a framework fork (or confirmation the package is generic-keyed) **and** a full
FK rewrite across two satellites + the track table — **declined** (recorded in §3e.6 per file
convention). Nothing about this gating call remains open.
2. **Existing-data conversion strategy — RESOLVED (in-migration backfill at migration time).** Daniel:
*"Migrate the existing data to provide the entry key at migration time."* The migration mints a
GUID-string `EntryKey` for every existing release row (mirroring the track mint, `Guid.NewGuid()
.ToString()`), then marks the column non-null + unique — **not** a dev-DB reset. (Mechanically this
is the in-migration backfill the earlier draft recommended as option (a): the schema carries real
migration history — eight applied migrations, a normalization data-migration that back-filled
releases from tracks, a unique-title-artist constraint with a conflict-recovery path — i.e. a
database with actual content. A Postgres `gen_random_uuid()` backfill cast to text, or an app-pass,
both produce the GUID-string shape; staff-engineer picks the mechanism, but the *decision* — backfill
at migration time, no reset — is settled.)
3. **Public URL exposure — raw GUID vs. slug/short-id.** Daniel's stated motivation is "int IDs too
transparent," and a raw GUID in the URL (`/cuts/9f8a…`) fully satisfies that — it leaks neither
@@ -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.B11.E
@@ -856,15 +876,15 @@ This wave **sweeps the very files 11.B11.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.B11.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.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
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 (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.
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