# PLAN.md — DeepDrftHome forward roadmap

Forward-looking roadmap. Sits alongside `CONTEXT.md` (architecture orientation) and `COMPLETED.md` (history). Per `CONTEXT.md §6`, items move from here to `COMPLETED.md` when work lands; do not delete completed entries.

Organised by **theme**, not by date. Themes are roughly ordered by current product weight, not commitment. Nothing here carries a timeline unless it explicitly says so.

---

## 0. Baseline — what just landed

A two-part audit (design + streaming) ran on 2026-05-17 and the fixes for Critical, Major, and Minor findings are now on `dev`. The remainder of this plan assumes that baseline. In summary the audit-pass fixed:

- **Index concurrency** — `VaultIndexDirectory` no longer drops the lock before its async disk write; the index file can no longer be clobbered by interleaved writers.
- **Repository semantics** — `TrackRepository.Update` now fails-fast when an `Id` is not found instead of silently issuing an `INSERT`.
- **Streaming Criticals** — concurrent-seek race in the client, dirty trailing bytes leaking out of the `ArrayPool`-rented buffer, final-tail audio dropped at EOF below the minimum decode frame, and the assumption that the first network chunk contains the whole WAV header.
- **17 design and streaming Majors/Minors** across all eight projects — format-validation alignment between processor/offset/decoder, `IAsyncDisposable` on the player provider, cancellation tokens threaded through the HTTP path, structured logging into the FileDatabase subsystem, sort-sentinel cleanup, sundry DRY/SRP tightenings.

What this means for the roadmap: the streaming substrate is solid. Future work can build on top of it rather than around it. The remaining items in `TODO-V2.md` that did not land are **deferred as features, not bugs** — they are captured below under Phase 1.

---

## Phase 1 — Streaming features deferred from the audit

These were flagged during the audit but classified as feature work, not defect fixes. They are listed in rough order of user-visible impact.

### 1.3 Preload / prefetch of the next track

> **Split as of 2026-06-15.** This item bundled two things: (a) a **queue model** ("a notion of next
> track") and (b) **preload/prefetch** (begin the next track's bytes during the current tail). The
> **queue half (a) is now absorbed into Phase 11** (commitment 7 — Daniel: "now is the natural time
> for that"; full spec in `product-notes/phase-11-public-site-enhancements.md §3c`). The **preload
> half (b) remains deferred here** and still gates crossfade (1.4) and gapless (1.5). The open
> question below — queue in `IPlayerService` vs. a separate orchestrator — is **answered in the
> Phase 11 spec** (strong steer: a separate `IQueueService` above the single-slot player; final call
> staff-engineer's at implementation). When Phase 11's queue lands, the preload below becomes "add a
> subscriber to the queue's already-known next track," not a fresh queue design.

- **What (deferred — preload only):** No mechanism to begin the next track's stream during the tail of the current. Each play is a cold fetch.
- **Why it matters:** Prerequisite for both crossfade (1.4) and gapless (1.5). Also a perceived-latency win on its own — track-change feels instant when the bytes are already in flight.
- **Shape:** A second `HttpClient` request kicked off when the current track passes a configurable threshold (e.g. last 10 seconds). Bytes accumulate into a staged `StreamDecoder` instance rather than the live one. Promotion to "current" happens at end-of-stream or on user-selected next. **The "next track" it prefetches comes from Phase 11's `IQueueService`** — that dependency is now satisfied by the queue work, not an open question.

### 1.4 Crossfade

- **What:** Smooth A→B transition with overlapping fade-out / fade-in.
- **Why it matters:** DJ/mix aesthetic that fits the DeepDrft collective's electronic-music context. Distinguishing UX from generic "next track."
- **Shape:** Architecturally two simultaneous `PlaybackScheduler` instances suffice — each owns its own gain node, crossfaded via `GainNode.gain.linearRampToValueAtTime`. The wiring is the work, not the audio graph itself.
- **Prerequisite:** **1.3 (Preload)** — there is nothing to fade *into* without prefetch.

### 1.5 Gapless playback

- **What:** Eliminate the inter-track silence that exists today.
- **Why it matters:** Important for live-set rips, mix tapes, anything authored to flow continuously.
- **Shape:** The decoder must be able to start the next track's first buffer scheduled exactly at the end of the current one's last buffer (sample-accurate, not wall-clock). With `PlaybackScheduler`'s existing 500 ms lookahead this is mechanically achievable — the next track's first `AudioBufferSourceNode.start(t)` is set to the previous track's end time.
- **Prerequisite:** **1.3 (Preload)**. Also needs to play nicely with **1.2** because gapless across formats is hard (encoder padding/priming on MP3 in particular).
- **Constraint:** Truly sample-accurate gapless requires knowing the priming/padding sample counts of the source format. Out of scope for WAV-only; revisit when format diversity lands.

### 1.6 Track-skip on error

- **What:** A failed `processStreamingChunk` aborts the entire load with no recovery path.
- **Why it matters:** One corrupt frame at byte 4M of a 100 MB stream currently means the listener loses the entire track. Should at minimum surface a clear error and (optionally) skip past the bad region.
- **Shape:** Two-level response.
  - Cheap: catch in the streaming loop, surface a user-visible error, advance the gallery to the next track if a queue exists.
  - Richer: byte-scan forward to the next valid frame header for the format and resume. Format-dependent — only worth doing once **1.2** lands.

### 1.7 Safari compatibility

- **What:** Two known Safari edge cases.
  - `webkitAudioContext.close()` is async-but-not-Promise on older Safari (≤ ~14); `await` resolves immediately and the next `initialize()` can run against a not-yet-closed context.
  - iOS Safari < 15 had streaming-fetch quirks; `HttpCompletionOption.ResponseHeadersRead` behaviour is not guaranteed there.
- **Why it matters:** Real listener share. iOS in particular is a primary listening surface for music.
- **Shape:** For the `close()` race — detect `webkitAudioContext` and poll `state === "closed"` with a short timeout instead of trusting the `await`. For the fetch quirks — first decide the minimum supported iOS version; if pre-15 is in scope, fall back to a non-streaming fetch path and accept the latency.
- **Open question:** What's the floor? Decide before designing the fallback. iOS 15+ as the floor would let us drop the second concern entirely.

---

## Phase 2 — Product surface: gallery, browsing, ingestion

These follow from `CONTEXT.md §5`. Direction is strongly implied but no specific UI has been committed.

---

## Phase 6 — CMS Enhancements (Completed)

See `COMPLETED.md` for Phase 6 (§6.1, §6.3) and entity-prep (§6.2 model layer) which landed on dev in June 2026.

---

### 6.2 Card-contextual filtering of the Tracks page — `[superseded by §8]`

- **What:** Make the Album and Genre dashboard cards navigate into a *filtered* `/tracks` view (e.g. clicking an album card shows only that album's tracks), rather than the unfiltered table.
- **Why:** Turns the dashboard from a read-only summary into a navigation hub — the natural next step once the cards exist.
- **Why deferred:** The dashboard cards aggregate *across all* albums/genres — there is no single album/genre to filter to from a top-level count card. Meaningful per-album/per-genre navigation needs an intermediate browse surface (a list of albums, a list of genres) for the admin to pick from — i.e. it's really a CMS analogue of the public `AlbumsView`/`GenresView`, not a property of the summary cards. That's a larger surface than the dashboard itself and shouldn't be smuggled in. The `GET api/track/page` endpoint already accepts `album=` and `genre=` query filters, so the API substrate is ready; the missing piece is the CMS browse UI and the filter plumbing in `TrackList`.
- **Superseded:** **§8 (CMS Track Browser)** builds exactly the intermediate browse surface this item was waiting on — Album Mode and Genre Mode *are* the CMS analogue of `AlbumsView`/`GenresView`, and the filter plumbing into `GetPagedAsync` is part of §8's data contract. This item folds into §8; do not implement it separately.

---

## Phase 3 — New content kinds

### 3.1 Live / session content

- **What:** The home page advertises "Live Sessions" and "Video Content (coming soon)". No data model exists for these.
- **Why it matters:** Honour the home page copy. Also differentiates the site from a generic track gallery — live sessions and video are the collective's authored output.
- **Shape:** Speculative; no commitment yet.
  - Likely new entity table(s) sibling to `TrackEntity` (`SessionEntity`, `VideoEntity`?) — or a polymorphic `MediaEntity` with discriminator. The choice affects how much code in `TrackService` / `TrackController` can be reused.
  - New vault type(s). `MediaVaultType.Media` exists and is the obvious home for video; sessions are probably still `Audio`.
  - New routes, new UI surfaces, new player considerations (video has its own playback element and does not go through the WAV decoder).
- **Prerequisite:** Probably **2.1** (vault wiring proof) and a decision on the entity model before any code lands.
- **`[speculative]`** — direction inferred from home-page copy, not a Daniel-confirmed commitment.

---

## Phase 4 — Infrastructure / delivery

### 4.3 Dual-write rollback / dead-letter log

- **What:** If content-side write succeeds and SQL-side write fails, audio is orphaned in the vault. No compensating mechanism exists.
- **Why it matters:** A latent data-integrity issue. Materially riskier once web upload (2.4) exists.
- **Shape:** Audit suggested a `DeadLetterLog` recording orphaned `entryKey`s for a periodic maintenance pass. Lighter than full transactional rollback (which the dual-database split fundamentally cannot give us).
- **Prerequisite:** None. Worth landing alongside or just before 2.4.

---

## Phase 5 — Documentation backlog

### 5.1 Folder-level CLAUDE.md sweep

- **What:** Eight folder-level `CLAUDE.md` files need writing/rewriting per the brief in `DOC_PLAN.md`. Five are rewrites (drift from the `.NET 10` upgrade and structural moves); three are new (`DeepDrftWeb.Services`, `DeepDrftContent.Services` — the two libraries where most domain logic now lives — plus the open question on `DeepDrftContent.Services/FileDatabase/README.md`).
- **Why it matters:** The agent guidance files are how every future implementer (human or agent) gets oriented in a directory. They are currently misleading in ways that will cause wrong assumptions on first contact — claiming `.NET 9`, referencing `MediaPath` that has been `EntryKey` for two migrations, describing a `FileDatabase/` tree inside `DeepDrftContent` that has moved out, and missing entirely for the two `*.Services` libraries.
- **Shape:** Doc-keeper executes against `DOC_PLAN.md`. Order of operations and the per-folder briefs are already specified there.
- **Prerequisite:** None. Can run fully in parallel with any feature work.
- **Constraint:** Wait on Daniel for the `DeepDrftContent.Services/FileDatabase/README.md` judgement call before that file changes (retire, keep + refresh, or replace with a CLAUDE.md). The other seven can proceed without that decision.

---

## Phase 7 — Shared UI Components

Reusable presentational components in `DeepDrftShared.Client` (the RCL consumed by both the public site and the CMS). Distinct from the player stack and CMS surfaces — these are host-agnostic building blocks both apps compose.

---

## Phase 8 — CMS Track Browser

Three browse modes for the CMS `/tracks` page — **Track**, **Album**, **Genre** — selected by a toggle, each deep-linkable so the public home page can link straight into a mode. One view-model (DI-scoped, matching the `TracksViewModel` pattern) feeds all three views; the divergence is in rendering, not data paths (per the standing "same data, different uses" preference). This supersedes the deferred §6.2 — Album and Genre modes *are* the intermediate browse surface that item was waiting on. Full spec: `product-notes/phase-8-cms-track-browser.md` (normalization gate, component decomposition, VM design, URL scheme, data contracts, open questions).

**§8.0 landed on 2026-06-11** — a breaking `TrackEntity` normalization has been completed and is stable on dev. §8.1–§8.5 are now unblocked. The Waveform Pre-Processing tab is **removed**, folded into an in-grid status column + per-row/page-level generate actions (see §8.2).

---


A small set of items that are real but don't fit a phase yet. Surface them when they become relevant rather than committing now.

- **Identity / accounts.** Currently no user concept. Needed before web upload (2.4); also a precondition for favourites, listening history, per-user playlists. Decide the shape before any of those lands. `[speculative]` until Daniel signals interest.
- **`ITrackService` interface.** Audit-suggested. Low value today (one consumer pair); higher value when the test surface expands beyond FileDatabase.
- **Test coverage outside FileDatabase.** Tests today cover the FileDatabase subsystem comprehensively and nothing else. As features in Phases 1–4 land, test scope should expand — at minimum `WavOffsetService`, `AudioProcessor`, `TrackService` (both sides), and the streaming player services. Not a phase of its own; an attached cost to feature work.
  - *Real-Postgres integration harness (deferred per Daniel — big lift).* The Phase 16 distinct-listener aggregation LINQ (`EventRepository.CountDistinctListenersAsync` / `...ForTrackAsync` / `...ForReleaseAsync`) is currently exercised only against the EF in-memory provider, which does not validate real Npgsql SQL translation — the distinct-count queries want translation verification against an actual Postgres instance. More broadly, an integration-test harness against a real Postgres is the deferred prerequisite for trusting any non-trivial LINQ across the EF surface. **Explicitly deferred by Daniel (big lift); a note for now, not committed work — no timeline.**

---

## Phase 9 — Release Medium Types

Releases gain a top-level **medium** discriminator above the existing `ReleaseType`. Three media: **Studio CUTS** (`Cut` — the only medium that uses Single/EP/Album), **Live SESSIONS** (`Session` — a single live track with a distinct hero image), **DJ MIXES** (`Mix` — a single long track with a preprocessed high-resolution waveform datum). This touches the data model, the API, the CMS, and the public site.

The public home page **already** carries the three-medium framing as editorial cards (Studio / Live / DJ Mix — `COMPLETED.md §8.6`, landed 2026-06-12), but those cards have no destinations and nothing below the copy layer knows what a medium is. Phase 9 makes the medium real and gives those cards somewhere to point.

**Architectural spine — discriminator enum + optional metadata table.** `ReleaseMedium` is a plain enum column on `ReleaseEntity`. A medium that needs data beyond the base release (Session's hero image, Mix's waveform datum) gets its own 1:1 metadata table; a medium that needs nothing extra (`Cut`) *is* the base `ReleaseEntity`. This is Open/Closed at the schema level — a future medium (e.g. Video, `§3.1`) adds an enum value and *optionally* one metadata table, and changes **zero** existing tables. The alternatives (one wide nullable table; an EF type hierarchy) both collapse to the god-table the Phase 8 normalization moved away from — rejected. Full design, contracts, and the SOLID rationale: `product-notes/phase-9-release-medium-types.md`.

**Design discipline throughout: extension, not modification.** Where a per-medium mapping is unavoidable (card → browser, medium → API projection, medium → detail hero), keep it in **one table per concern** — never a scattered three-arm `switch`. Drive CMS cards and nav sub-items off `Enum.GetValues<ReleaseMedium>()` + a display-metadata lookup, so a new medium surfaces automatically.

**The `ReleaseType`-only-for-`Cut` invariant.** Single/EP/Album is meaningful only when `Medium == Cut`. Enforce as a **domain rule** (service layer ignores/resets `ReleaseType` for non-`Cut`; CMS hides the field unless `Cut`; `ReleaseDto.ReleaseType` is **nullable**, nulled at the single entity→DTO mapping point for non-`Cut` so one producer enforces and no consumer needs the rule), **not** a DB constraint — **by choice, not necessity**: EF Core supports check constraints first-class (`HasCheckConstraint`, versioned in migrations, Npgsql-supported), but the invariant is advisory ("meaningless," not "invalid") and the read model enforces it at one point. The column stays on `ReleaseEntity` as a **named exception** to the metadata-table pattern: a `CutMetadata` table was considered and rejected because the `/cuts` hot path reads `ReleaseType` on every card and Phase 8 §8.0 just landed the column (see spec §1). Future media must not copy this — the default remains the metadata table.

Sequenced as four waves. Wave 1 is a prerequisite for everything; within Waves 2–4 the lettered tracks are parallel.

**Dependency summary:** `1 → 2 → 3 → 4`. Wave 4 (public site) can begin once Wave 2's `api/release` family is stable; both Wave 4 **build and acceptance** are independent of Wave 3 (CMS) — the body-less `POST api/release/{id}/mix/waveform` trigger (9.2.B) can seed real waveform datum for acceptance testing without any CMS in existence, and hero images seed via a script against 9.2.B likewise.

Waves 1–7 are landed (`COMPLETED.md §9`). Wave 6 closes two functional gaps a post-landing smoke-test survey surfaced — surfaces the medium taxonomy did *not* reach, not regressions. Wave 7 hardens the single-track-per-medium rule from a CMS-form convention into a real domain invariant — the one place the medium taxonomy is *declared but not enforced* below the UI.

### 9.8 Wave 8 — Remediation (fully landed; all tracks complete)

Daniel tested the landed Phase 9 surface end-to-end and produced a punch-list of corrections before the phase is called complete. These are **not new features** — they are the gap between what the Wave 1–7 specs *built* and what hands-on use *wants*. The theme is the same one Phase 9 has carried throughout: the medium taxonomy reaching every surface it should, and the browse surfaces matching the mental model rather than the implementation's first cut.

Two surfaces dominated: the **CMS Release Archive** (the card-grid landing is the wrong shape — Daniel wants medium *tabs*, not navigate-away cards) and the **public Archive** (the three-card overview is dead weight; the searchable all-**releases** view *is* the archive — release-cardinal, decided). The **Mix Visualizer redesign (8.K)** was pulled out of Phase-9-completion scope and ran as a post-Phase-9 wave from a finished spec (`product-notes/phase-9-mix-visualizer-redesign.md`); it has now also landed.

**Open questions resolved (Daniel, 2026-06-13):** 8.H is decided **H2** (a new release-cardinal searchable browser at `/archive`; cascade: `/tracks` demoted from nav, route kept; mobile ARCHIVE → the browser; three-card overview fully retired); 8.I drops GENRES from the nav only (route kept); 8.F makes the Session hero optional-but-warn-if-missing; 8.E defaults the `ALL`-tab Add Track to Cut with the medium selector staying user-changeable. A new track **8.L** consolidates the release-name/track-name pair into a single name for single-track media (derived track name **kept synced**, decided), and **8.M** (split off 8.L) retires the legacy `TrackNew`/`TrackEdit` forms by folding them into the batch forms to reduce code surface.

Full track decomposition, acceptance criteria, and parallel/dependent analysis: `product-notes/phase-9-wave-8-remediation.md`.

**Dependency shape:** 8.B is the foundation for the CMS tab work (8.A consumes the shared grid; 8.C/8.E layer on once 8.A lands). 8.L follows 8.G and coordinates with 8.E/8.F (same forms). 8.M (legacy-form retirement) follows 8.L and is architectural (route map + addressing decision). On the public side, 8.H (decided H2 — the new release-cardinal archive) gates 8.I. **All Wave 8 tracks are landed** — Phase-9-completion gate (8.A–8.J + 8.L), 8.M, and the post-Phase-9 8.K Mix Visualizer redesign. **Landed tracks:** 8.A, 8.B, 8.C, 8.D, 8.E, 8.F, 8.G, 8.H, 8.I, 8.J, 8.L (2026-06-13); 8.M (2026-06-14); 8.K (2026-06-14).


## Phase 10 — Mix Visualizer WebGL2 Renderer

The landed Canvas 2D Mix visualizer (8.K) renders at 1–2 FPS and **cannot afford the planned effects** — a staff-engineer analysis found the per-frame killers (full-viewport `shadowBlur`, CSS `backdrop-filter`, per-frame `getBoundingClientRect`) structural to the approach, and the planned effects (bulge, lava-lamp detach, a morphing 2D color field, glass) are all per-pixel/per-frame work — exactly what Canvas 2D is worst at and a fragment shader is best at.

**Decision (Daniel, explicit): rebuild as a WebGL2 fragment-shader renderer. No Canvas 2D stopgap** — "WebGL as step 1, no pussyfooting." This supersedes 8.K §E's Canvas-2D-default recommendation; the "industry-standard, well-commented, no tricks" discipline carries forward as *textbook WebGL2 with a commented shader*. Target a smooth **60 FPS**. Strictly read-only (no playback-control changes); the duration-derived ~333 samples/sec datum (8.K §F) and the existing Blazor↔JS bridge are both preserved — the datum now lands as a GPU texture rather than a CPU-walked array.

Adds a **controls row** above the mix details / below the back button: four continuous, session-persistent sliders — **resolution** (relocated 8.K zoom), **bubblyness** (box→liquid bulge), **detach** ("unleash the lava lamp" — blobs pinch off and rise), **color-shift speed** (gradient morph rate). The headline visual is a living 2D **navy↔moss** gradient field (theme tokens from `DeepDrftPalettes`) that varies per-bar *and* shifts along time, never static; plus an in-shader **glass** treatment (specular/Fresnel/frosted/refraction — no CPU backdrop-filter). Persistence mirrors `MixVisualizerZoomState` (widen to a `MixVisualizerControlState` holding all four).

Full design, renderer architecture, the four effects, acceptance criteria, and phasing: `product-notes/mix-visualizer-webgl-renderer.md`.

**Sequenced as four waves.** Wave 1 (renderer swap at parity — prove WebGL2 on screen at 60 FPS, bridge intact, no new effects) is the load-bearing prerequisite. Wave 2 (controls row + widened state) and Wave 3 (the four effects in the shader) both follow Wave 1; the four effects within Wave 3 are independently shippable and tunable. **Deferred (Daniel):** control-range guards and motion-speed coupling to bubblyness — he tunes bad ranges by hand once on screen. **Landed:** Wave 1 (2026-06-15). Wave 2 (2026-06-15). Wave 3 (2026-06-15).

**Wave 4 — detail-page polish + controls rework (presentation only; the final wave).** A UI/placement pass over the Mix detail page — **no renderer, state, bridge, or mapping change.** (1) The four controls move out of the always-visible row into a **popover** (`MudPopover`, `SharePopover`-idiom) opened by a new bespoke **lava-lamp icon button** anchored **top-right of the body, across from the `← Back` link** (recommend a new `TopRightAction` slot on `ReleaseDetailScaffold`, laid as a SpaceBetween row with the back link). (2) The lava-lamp SVG lives in `DeepDrftShared.Client/Common/DDIcons.cs` in the hand-rolled gas-lamp style (`currentColor`, 24×24 viewBox, raw-string const) — a recognizable lamp with two-three suspended blobs. (3) The four `MudSlider`s become four **`RadialKnob`s** (`DeepDrftShared.Client/Components/RadialKnob.razor`) **in a row in the popover**, each carrying its existing Material icon (`ZoomIn`/`BubbleChart`/`Air`/`Palette`) **as an adjacent `MudIcon` caption** — RadialKnob has **no icon slot** (its `Label` is SVG text), so icons sit beside each knob. Knobs bind `Value`/`ValueChanged` to the **unchanged** `MixVisualizerControlState` via the **same `OnXChanged` handlers + `NotifyChanged()` seam** the sliders use today (resolution via `MixZoomMapping` fraction; other three normalized [0,1]; `HoldValue=false` for live feel). (4) **Widen the Mix body** to match the Sessions detail page — `MudContainer MaxWidth="Large"` (~1280px, up from the scaffold's 760px), Mix-scoped so Track detail is unaffected. **Depends on Wave 3 merged** (the knobs drive the Wave 3 effects) and **supersedes the controls-row design** (`product-notes/mix-visualizer-webgl-renderer.md` §3 → §7). Read-only contract intact; no knob is a seek surface. Full design + acceptance: that spec's **§7**.

### Phase 10 — Reframe (Lava): Waves R1–R4

**Landed:** 2026-06-17 on dev. See `COMPLETED.md` for the full completion record.

---

## 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). **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).

**Headline correction — commitment 5 is already built.** The brief framed the track ordinal as a new column + EF migration + a Daniel-gated apply step. **The read shows it already shipped in Phase 8:** `TrackEntity.TrackNumber` (1-based, non-null), migration `20260611005700` **already applied**, `TrackDto` mirror, API write path (validated `> 0`), CMS reorder (`BatchEdit` assigns ordinal from list position on submit), and the read already `.OrderBy(t => t.TrackNumber)`. **No new schema, no migration to gate.** Commitment 5 collapses to *verify-and-consume*: confirm the public read projects/sorts `TrackNumber` and that `CutDetailViewModel` orders by it (a one-line fix if not). See spec §3a.

**Mirror-image on commitment 8 — the Description column is genuinely new.** Where commitment 5 turned out already-built, commitment 8 is the opposite: **no `Description` member exists** on `ReleaseEntity` or `ReleaseDto` (greps return nothing; the entity carries `Title`/`Artist`/`Genre`/`ReleaseDate`/`ImagePath`/`ReleaseType`/`Medium` + the two metadata satellites). So commitment 8 **is** the real cross-stack schema project — just for a different field: a new base-`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**, since there is no dedicated release-update endpoint; release-cardinal fields ride the track update/upload path), the CMS `AlbumHeaderFields` multiline input, and the detail-page text block. It is a base field (uniform across media) so it lives on the base release, **not** a per-medium satellite (Phase 9 spine). See spec §3d.

**Framing corrections (brief vocabulary vs. live routes).** (1) There is **no `/tracks/{id}` route** — the track-cardinal detail is `/track/{EntryKey}`. The brief's "`/tracks/{id}` becomes a router" is best realized as a **medium→route resolver** at click sites (the player bar already carries release id + medium — no round-trip), plus a thin `/tracks/{id}` redirect page for deep links. (2) The new `/cuts/{id}` album page is the phase's center of gravity — the first **multi-track** release detail. (3) Requirement 4 is a **URL-binding pass over the existing `ArchiveView`**, borrowing the `TracksView` `[SupplyParameterFromQuery]` pattern — not a new browser.

**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 **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.**
- **11.C — retire + normalize (the heart).** With §2 removing every inbound link: **delete the whole track-cardinal stack** (`TracksView`/`TrackDetail`/`TrackCard`/`TracksGallery`/`GalleryViewMode` + `/tracks`, `/track/{EntryKey}` routes) **and** fold Archive + Cuts inline cards into the shared `ReleaseGallery` (new `HrefResolver`); consolidate the medium-label lookup. **Depends on 11.B.** (Cut track-row is a separate small `TrackRow`, not `ReleaseGallery`.)
- **11.D — Archive filters in the URL.** `/archive?q=&medium=&genre=`, history-driven (§5). Touches only `ArchiveView`. **Free-floating — but coordinate with 11.C** (both edit `ArchiveView`).
- **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 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); 11.D (2026-06-16); 11.H (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). **All Phase 11 tracks (11.A–11.H) are now landed; Phase 11 is complete.** Two release-table migrations are authored but not yet applied (Daniel-gated, apply in author order): `20260616035252_AddReleaseDescription` (11.G) then `20260616210143_AddReleaseEntryKey` (11.H).

**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).

---

## Phase 16 — Anonymous Play & Share Tracking

The phase deferred behind the home-hero **Plays** stat card (`NowPlayingStats.razor`'s third card, today a static `XXX / Plays (Coming Soon)` odometer placeholder). Adds a **privacy-light, anonymous** telemetry layer to the public site: counting **plays** (bucketed by completion) and **shares**, tied to individual **tracks and releases**, plus an optional **unique-listener** "plus" metric. Hard constraint: **no accounts, no PII, anonymous identification only** — the unique-listener metric in particular is solved within that constraint, not around it. Full design, metric definitions, instrumentation seam, anonymity-mechanism options, storage model, the card payoff, and wave decomposition: `product-notes/phase-16-play-share-tracking.md`.

**Architectural spine.** Plays are instrumented at **one seam** — the `StreamingAudioPlayerService` playback lifecycle (not the UI, not the HTTP/media-client layer, which fires multiple times per play via seek-beyond-buffer). A small play-session tracker opens on playback-start, advances a high-water position on the existing progress callback, and closes (classifying the §1 completion bucket) on track-switch / stop / organic-end / page-unload. Shares instrument at the **real** share surface (`SharePopover`'s Copy-link / Copy-embed actions — clipboard writes that record nothing today). Events ship **fire-and-forget via `sendBeacon`** to new `POST api/event/{play,share}` endpoints (proxied through `DeepDrftPublic`, same hop as `api/track/*`), land in an **append-only SQL event log + incremental counter rollup** in `DeepDrftData`, and the home card reads the total through the **existing** `GET api/stats/home` / `HomeStatsDto` / `IStatsDataService` path (the same single persistent-state-bridged round-trip the other two cards use). Release attribution is **resolved server-side** from the track→release join (client sends only the track key); release plays are **derived** (sum of their tracks' plays). All SQL — the FileDatabase vault is not involved.

**Completion buckets (settled).** `partial` < 30%, `sampled` 30–80%, `complete` > 80% — exhaustive, non-overlapping; headline plays = sum of all three (D1 resolved). The engagement floor (D2) drops trivial skips: a listen counts only at **≥3s OR ≥5% of duration, whichever smaller**.

**Sequenced BOTTOM-UP (Daniel directive 2026-06-19): foundation first, the live card LAST.** Reverses the earlier "visible win early" framing — Daniel does not care about the live card until the whole substrate and all metrics are finished. Strict chain: `16.1 → 16.2 → 16.3 → (16.4 optional) → 16.5`. 16.1 is the only cold-start wave; 16.5 (the card) is the capstone built last.

- **16.1 — Foundation: capture seam + transport + event log (nothing reads it yet).** Player-service play tracker (high-water mark + engagement floor), share tracker in `SharePopover` (debounced), `sendBeacon` interop + unload handler, `POST api/event/{play,share}` (proxied, rate-limited), append-only `play_event`/`share_event` log + incremental `play_counter` rollup, server-side release resolution + derived release totals. **No `anonId` yet; no card consumption.** Cold-start wave. **Landed:** 2026-06-19 on dev. Migration `20260619155610_AddPlayShareTelemetry` authored but not applied (Daniel-gated).
- **16.2 — Completion-bucket classification + shares.** Three-bucket classification (D1) correct and exhaustive end to end (tracker → payload → log → per-bucket counter columns); share-channel split (link/embed). **Depends on 16.1.** **Landed:** absorbed into 16.1 — all §4.1 deliverables shipped inside the wave 16.1 foundation: `PlayBucket` enum (`Partial`/`Sampled`/`Complete`, exhaustive/non-overlapping) wired tracker → payload → log → per-bucket counter columns (`PlayCounter.PartialCount`/`SampledCount`/`CompleteCount`, `TotalPlays` computed sum); `ShareChannel` enum (`Link`/`Embed`) on `ShareEvent.Channel`; API-boundary bucket validation. The only §4.2 item not built is the optional `share_count` rollup on `play_counter` — correctly deferred (shares are not on the home-card hot path; per-target reads are speculative wave 16.4).
- **16.3 — Unique-listener `anonId` layer (lowest-priority metric, D5).** Option A: mint/read client first-party `localStorage` id, thread `anonId` onto payloads (nullable in the log), count distinct server-side (all-time, D3). **The last metric layer** — folded into "everything finished," explicitly last-built of the substrate. **Depends on 16.1; builds on 16.2.** **Landed:** 2026-06-19 on dev (merge 297805b). No migration — `anon_id varchar(64)` columns and `IX_play_event_anon_id`/`IX_share_event_anon_id` indexes already shipped in the 16.1 migration.
- **16.4 — Per-target / CMS stats surfaces.** `[speculative — not built; passed over]` — `GET api/stats/{track,release}/{key}` + CMS analytics views (bucket/channel splits, leaderboards). Not committed; the event log already supports it. **Skipped as speculative per Daniel (2026-06-19); available to build later if a surface wants it.** Off the critical path to the card. **Depends on 16.1–16.3.**
- **16.5 — Home Plays-card payoff (CAPSTONE, built LAST).** Extend `HomeStatsDto` + `GetHomeStatsAsync` with `TotalPlays` (+ secondary line = unique listeners, D7); flip `NowPlayingStats`'s third card from placeholder to live through the existing `GET api/stats/home` round-trip. **The final wave — built only once 16.1–16.3 are in place.** **Landed:** 2026-06-19 on dev.

**Phase 16 is complete.** Waves 16.1, 16.2 (absorbed into 16.1), 16.3, and 16.5 all landed on dev (2026-06-19). Wave 16.4 was speculative and deliberately skipped. Privacy footer line (`DeepDrftFooter.razor` `.deepdrft-footer-privacy` disclosure) also landed. The anonymous play/share substrate, unique-listener metric, and live Plays card are in place.

**Product decisions D1–D7 — RESOLVED (Daniel 2026-06-19, spec §10).** D1 (three-bucket sampled), D2 (engagement floor on, ≥3s/≥5%), D4 (release plays derived server-side), D5 (Option A — client-minted first-party `localStorage` id, metric labelled "listeners," fingerprinting rejected) resolved on explicit pick. D3 (all-time window), D6 (incremental-on-write rollup), D7 (card secondary = unique listeners) resolved-by-default — low-risk to revisit during their wave. Road-not-taken preserved in the spec.

**Adjacency to deferred Identity / accounts (the un-phased backlog item above).** This phase is the deliberate **anonymous** answer to "how many plays" — it does **not** need the accounts/identity work and must not be entangled with it. If identity ever lands, per-user listening history is an additive layer above this anonymous substrate, not a replacement.

---

## Phase 17 — Player-Bar Queue View

Phase 11 (wave 11.F) built the queue **engine** (`IQueueService`/`QueueService` — ordered playback,
auto-advance, skip-prev/next, armed-idle release embeds) but gave it **no UI**. A listener can start
an album and skip through it, but cannot see what is next, reorder it, drop a track, or hand-build a
queue. Phase 17 surfaces the queue as a first-class, visible, editable object in the player bar —
**without** moving any player/queue state out of the layout-level `AudioPlayerProvider` (project
memory; the cascade stays at layout level) and **without** changing the streaming/playback seam.

Four Daniel-stated capabilities, gated by a single new **Queue** toggle button (shown only when a
queue is loaded, mirroring the skip-affordance gating so single-track play is byte-for-byte its
pre-queue self):

1. **Queue button** in the bar — below the transport controls, left of the timestamp.
2. **Non-Fixed (overlay) mode** — a centered, mostly-square panel (borrowing the Phase 15
   visualizer-popover idiom) listing the queue with **drag-reorder + per-track removal**.
3. **Fixed (embed) mode** — the queue list is **always shown below** the bar controls (not a toggle),
   **read-only** for shared release queues (no reorder, no remove); the embed iframe is resized to fit
   the panel.
4. **Add to Queue** affordance — an icon button + tooltip beside every detail-page play button for a
   release (→ `EnqueueRange`) or track (→ `Enqueue`), lighting up the dormant `Enqueue` path. Scoped
   to the detail-page play sites (Cut header, Cut track rows, Session/Mix hero); **`ReleaseGallery`
   browse-grid cards are excluded** (no play button today — deferred per OQ10, captured in `TODO.md`).

**Architectural spine.** Engine grows **two additive members** — `Move(from, to)` and
`RemoveAt(index)` — interop-free state mutations that re-emit `QueueChanged` and **never re-stream or
interrupt the playing track** (the engine's stated open/closed posture; existing members untouched),
**plus a small additive `Enqueue`-into-dormant affordance** (OQ8: append leaves a coherent
`CurrentIndex` so the next play/skip is correct, without auto-playing).
Both view modes render **one shared `QueueList` presentational component** off the same cascaded
`IQueueService.Items`, differing only in presentation + an `Editable` flag (project memory: *one
source, multiple views*). Reorder/remove run safely during prerender (no JS) — only playback
transitions touch interop.

**Sequenced as three waves.** `17.1 → {17.2, 17.3}`. **17.1 (engine `Move`/`RemoveAt` + the shared
`QueueList` view) is the cold-start prerequisite**, settled and independent of the UI decisions —
it can begin immediately. **Landed:** 2026-06-19 on dev. 17.2 (docked overlay, editable,
`MudDropContainer` reorder) and 17.3 (Fixed embed panel + snippet resize — **the OQ1
Option-A-vs-B feasibility call is made here**) hang off it and are largely parallel. Add-to-Queue
split to a standalone 17.4 (needs only the existing `Enqueue`/`EnqueueRange`, not 17.1's new
members). **Landed (17.2):** 2026-06-19 on dev. **Landed (17.4):** 2026-06-19 on dev. **Landed
(17.3):** 2026-06-19 on dev.

**Phase 17 is complete.** All four waves (17.1 engine additions + shared `QueueList`, 17.2 docked
overlay, 17.3 Fixed embed panel + iframe resize handshake, 17.4 Add-to-Queue affordance) landed on
dev 2026-06-19. See `COMPLETED.md §17` for the full completion records.

Full design — goal, constraints, use cases, acceptance criteria, test cases, wave decomposition, and
the open-question set: `product-notes/phase-17-player-queue-view.md`.

**Open questions — all 11 resolved (Daniel, 2026-06-19; spec §10).**

- **OQ1** → **Option A, confirmed (17.3)** — collapse/expand toggle with `postMessage` → host resize
  handshake implemented. `EmbedSnippetBuilder.ForRelease` carries the host-side listener; `embed-frame.ts`
  posts height from the iframe. Degrades safely to Option B behaviour if the host strips the script.
- **OQ2** → **yes, both modes** — clicking a queued row jumps playback to that track in the docked
  overlay *and* the read-only embed; reuses `PlayRelease(Items, index)`.
- **OQ3 + OQ11** (jointly) → **the currently-playing track cannot be removed at all** — no "remove
  current" action; the × is suppressed on the current row. The queue empties only **organically** (the
  current track ends with nothing queued after). The engine's `RemoveAt`-of-current path (17.1) stays as
  defensive, UI-unreachable behavior.
- **OQ4** → **`MudDropContainer` for now** (C6 softened — touch-viability is a known risk with a planned
  pivot path, not a pre-ship blocker).
- **OQ5** → **yes, Clear in the overlay header** — but Clear must **not** stop or remove the
  currently-playing track (it keeps playing and stays in the queue; only the other queued tracks clear).
- **OQ6** → **fixed sensible height with internal scroll past N rows** (not grow-to-cap; affects
  `EmbedSnippetBuilder.ForRelease`).
- **OQ7** → **Material icons for now** (`QueueMusic` / `PlaylistAdd`; bespoke `DDIcons` glyph not pursued
  in Phase 17).
- **OQ8** → **pure append** (add ≠ play; first add into a dormant queue leaves a coherent `CurrentIndex`
  via the 17.1 engine affordance, no auto-play).
- **OQ9** → **exclude `StreamNowButton`** (no fixed track until one resolves).
- **OQ10** → **deferred** (cards get no Add-to-Queue in Phase 17; deferred card work captured in
  `TODO.md`).
- **None block 17.1.**

---

## Phase 19 — AuthBlocks User Management (CMS-only: admin surfaces + public self-registration)

Wire **all three** AuthBlocks account-creation paths into the `DeepDrftManager` CMS — the admin
user-administration surface (provision users, manage accounts, manage registration invites, manage role
permissions) **and** the public-facing self-service registration form. **All three paths live on
`DeepDrftManager` (the CMS app); there are NO changes to `DeepDrftPublic` in this phase.** Daniel's
framing: *"already part of the AuthBlocks library so we just wire it up."* Correct — and **further along
than it implies**: almost everything landed by side-effect of the prior startup separation. Full design,
the verified three-path model, the already-done-vs-remaining split, the SkipperHaven pattern + concrete
deltas, scope boundaries, and open questions: `product-notes/phase-19-user-management-cms.md`.

**The three account-creation paths (verified against AuthBlocks source 2026-06-19) — ALL CMS routes:**
1. **Admin provisions directly** — `SuperRegister.razor` → `/account/superregister` → `POST
   api/auth/admin-register` (UserAdmin-gated, **working**). Creates a live account now.
2. **Public self-service** — `Register.razor` → `/account/register` → `POST api/auth/register`
   (**unauthenticated, no role gate, working**). A **public-facing CMS route, exactly like the CMS
   `/account/login` page** — invited user redeems a code (pre-filled from the invite email's deep link)
   and self-registers, all on the CMS host.
3. **Admin provisions a token + triggers the invite email** — `NewRegistration(Form).razor` →
   `/useradmin/registrations/new` → `POST api/pendingregistration/create` (UserAdmin-gated). **Sends a
   real email server-side** via Mailtrap (`RegistrationEmailTemplate` + `IGeneralEmailSender`, configured
   in DeepDrftAPI from `environment/authblocks.json`) — **not stubbed.**

**Host-model correction (Daniel, 2026-06-19).** A prior revision placed public registration (path 2) on
`DeepDrftPublic` as a cold-start integration. **Wrong — there are NO `DeepDrftPublic` changes.** Public
registration is an unauthenticated route *on the CMS app*, mirroring the CMS's already-public
`/account/login`. The only genuinely stubbed surface is **Reset Password** (`Users.razor`, `// todo`; **no
backing endpoint** in `AuthRoutes`) — handled separately by Daniel in the AuthBlocks repo (see
`product-notes/authblocks-password-reset-brief.md`).

**Most wiring already landed by side-effect.** The AuthBlocks startup separation
(`PLAN_authblocks_trackmanager.md`, 2026-05-25) + login/logout integration already put the entire surface
in place on `DeepDrftManager`: `Cerebellum.AuthBlocks.Web` referenced, `ConfigureAuthServices` registers
every client + ViewModel **and** the `JwtAuthenticationStateProvider` path 2 needs, the router discovers
every page (`AdditionalAssemblies`) — **including the public `/account/register`** — and the DeepDrft
`Admin` role **inherits** `UserAdmin` (the seeded admin passes the gate with no change). The pages ship in
a published **RCL**, so the worried-about "extract pages into an RCL" fork **does not arise**.

**Two real gaps remain.** (a) **No nav** — `CmsLayout` is just an app bar + Home button, so nothing links
to `/useradmin/*` or `/account/superregister` (admin surface invisible). (b) **Wrong layout for public
pages** — `Routes.razor` uses a **static** `DefaultLayout="typeof(CmsLayout)"`, so an unauthenticated
visitor to `/account/register` (or `/account/login`) lands in the authenticated app shell instead of the
lean splash.

**SkipperHaven is the canonical pattern.** `SkipperHaven` (same AuthBlocks library) exposes login +
register as public/unauthenticated routes correctly by making `Routes.razor`'s `DefaultLayout`
**auth-state-driven** — unauthenticated → home/lean layout, authenticated → app shell (resolved in
`OnParametersSetAsync` off the cascaded `AuthenticationState`). **The concrete delta DeepDrftManager
needs is exactly one change** (spec §2c): make its `DefaultLayout` auth-state-driven, resolving
`CmsHomeLayout` (unauth) vs. `CmsLayout` (auth). Everything else SkipperHaven does — service wiring, page
discovery, both layouts — DeepDrftManager **already has** (it even already ships `CmsHomeLayout`, used by
the `/` home splash). So path 2 is **one router edit**, not a host integration.

**One host (`DeepDrftManager`), two parallel tracks** (different files), then verify + theme.

- **19.1 — CmsLayout navigation (admin-nav track; the main code wave). DECIDED nav shape: G1-b.** Add a
  `MudDrawer` + toggle to `CmsLayout.razor`; mount the shipped `UserAdminMenu` fragment (self-gates to
  `UserAdmin`+) alongside the existing CMS destinations (Catalogue / Releases / Upload); surface **both**
  admin account paths (path 1 `SuperRegister` + path 3 via the Registrations link); do **not** surface the
  redundant bare `NewUser` (OQ2 resolved). Scope: `CmsLayout.razor`. **No service, API, data, or
  AuthBlocks-source change.** **Landed:** 2026-06-19 on dev.
- **19.2 — Public-route layout (public-route track; parallel to 19.1). DECIDED: G0-a.** Make
  `Routes.razor`'s `DefaultLayout` auth-state-driven (mirroring SkipperHaven, spec §2c D1): cascade
  `Task<AuthenticationState>`, resolve `_currentLayout = authed ? CmsLayout : CmsHomeLayout`, bind
  `DefaultLayout="@_currentLayout"`. This renders `/account/register` (path 2) **and** `/account/login` in
  the lean `CmsHomeLayout` for unauthenticated visitors. Scope: `Routes.razor` only. **No new layout (both
  exist), no package, no service, no AuthBlocks-source change.** **Landed:** 2026-06-19 on dev.
- **19.3 — End-to-end verification (after 19.1 + 19.2).** Exercise provision-now (path 1), **invite-email
  send (path 3) incl. that the invite link `{ReturnHost}` points at the CMS origin**, list/deactivate
  users, permissions against a running DeepDrftAPI; confirm cross-host token + CORS, and **the full
  path-3→path-2 loop on the single CMS host** (admin provisions → email arrives → invitee redeems on the
  CMS `/account/register` in the lean layout). Mostly test; any break is likely a one-line config fix
  (esp. Mailtrap creds + return host) or an upstream AuthBlocks issue.
- **19.4 — Theming legibility sweep (after 19.1 + 19.2, parallel-ok with 19.3).** Accept the CMS palette
  for the MudBlazor-default grids and the public pages now in `CmsHomeLayout`; fix only contrast/legibility
  breaks. Bespoke restyle deferred.

**Deferred (note, don't build):** admin dashboard landing (G1-c); working **Reset Password** (separate
AuthBlocks-repo effort); bespoke restyle of the AuthBlocks grids; a visible public Register nav link
(invite-only — the email deep link is the entry point); bumping `Cerebellum.AuthBlocks.Web` 10.3.33 →
10.3.35 (housekeeping).

**Explicitly not needed:** any change to `DeepDrftPublic` (corrected host model — all three paths are CMS);
extracting AuthBlocks pages into a new RCL; new DI/service wiring, role seeding, or Auth connection string
(all present); editing the AuthBlocks `Login`/`Register` pages' layout (impossible without forking the
RCL — G0-a fixes layout host-side instead).

**Open questions for Daniel (spec §6).** *Resolved:* (1) nav shape **G1-b**; (2) surface path 1 + path 3,
hide bare `NewUser`; (5) Reset Password non-functional in v1, handled separately; (6) **host model — all
three on the CMS, no `DeepDrftPublic` changes**; (7) **public-route layout G0-a** (auth-state-driven
`DefaultLayout`, reusing `CmsHomeLayout`). *Still open:* (3) admin dashboard defer (recommend defer); (4)
package bump (recommend leave); (8) a logged-in admin visiting `/account/register` sees it in the app
shell under G0-a (recommend accept). None block 19.1 or 19.2.

**Adjacency to the deferred Identity / accounts backlog item (below).** That item is about *public,
per-user* identity (favourites, listening history, playlists). This phase is *CMS* account management only
(admin surfaces + invite-based self-registration) — same AuthBlocks substrate, different surface. They are
not the same work; this phase does not satisfy or depend on that one.

---

## Phase 18 — Opus Low-Data Streaming (dual-format lossless + Opus delivery)

The concrete realization of the long-deferred **"Non-WAV formats"** intent (`CONTEXT.md §5`). Daniel's
direction (2026-06-23): **two delivery formats per track — the existing lossless WAV path, and a new
low-data Ogg Opus (fullband, 320 kbps) path — so the listener gets a choice, with Opus the
bandwidth-friendly default-candidate.** Lossless streaming becomes *optional*, not the only path. The
bespoke Web Audio decode→schedule graph is **retained by deliberate choice** — Opus feeds the same
`IFormatDecoder` seam, not an HTML `<media>` element or MSE (the decision shared with Phase 21 OQ5).
**Sequenced BEFORE Phase 21** — windowing must work across both formats. Surfaces: ingest/preprocessing
in `DeepDrftContent` (`AudioProcessor`/router/`WaveformProfileService`) + `DeepDrftAPI`
(`UnifiedTrackService.UploadAsync`, replace-audio); delivery/decode in `DeepDrftAPI` (stream endpoint +
`Range`) + `DeepDrftPublic` proxy + `DeepDrftPublic.Client` player stack + `DeepDrftPublic/Interop/audio`
TS decoders. Full design, the three directions with SOLID/road-not-taken rationale, the storage and
delivery options, the Opus decoder + seek math, acceptance criteria, open questions, and wave
decomposition: `product-notes/phase-18-opus-low-data-streaming.md`.

**Much further along than the backlog line implies (verified 2026-06-23).** The multi-format *substrate*
already exists on both sides: the producer-side `AudioProcessorRouter` routes `.wav`/`.mp3`/`.flac` and
`TrackContentService.AddTrackAsync` is format-agnostic (it **stores originals**, no transcode); the
decoder-side `AudioPlayer.createFormatDecoder` is a **wired** strategy registry dispatching on
`Content-Type` (WAV/MP3/FLAC decoders all present — correcting the Phase 21 spec's stale
"implemented-not-wired" note). **The actual gap is Daniel's specific ask:** (1) a **transcode-at-ingest**
step that *derives* an Opus 320 artifact per track (nothing derives Opus today), and (2) a **per-format
delivery selection** so one track serves as either WAV or Opus on request.

**Open questions RESOLVED (Daniel, 2026-06-23).** OQ1 selection UX → **global, via a new public-site
Settings menu** (not a bare app-bar control); OQ2 default → **Opus by default, capability-gated** (defer
network-awareness); OQ3 remembered → **persisted via the dark-mode seam** (cookie → prerender-read →
`PersistentComponentState` → client cookie service); OQ4 → **always-on Opus + Backfill-Opus**; OQ5 →
**Ogg Opus**; OQ6 transcode model → **background job after the file is available, with a visible
Post-Processing phase on the CMS upload meter.** OQ7 (seek-index granularity) → **0.5 s (half-second)
buckets** (~115 KB index for a 1-hour mix).

**Architectural spine — a derived artifact set + a delivery param + one new decoder + a precomputed
accurate seek index; leaf implementations only, zero changes to existing format code (the strong OCP
signal).** Transcode is a new processor sibling in `DeepDrftContent`, invoked post-store alongside
`WaveformProfileService` **as a background job** (a 1 GB WAV transcode must not block the upload; the source
is stored and the track plays lossless *first*, then Opus is derived) — mirroring the landed waveform-datum
pattern (derive at ingest, regenerate via a CMS bulk action + ApiKey endpoint). The Opus bytes are a
**derived artifact** stored like the high-res waveform datum (recommend a dedicated `track-opus` vault, the
`track-waveforms` precedent; final call staff-engineer's). Delivery adds a **`?format=opus|lossless` param**
(mirroring the existing `offset` param threading through `TrackProxyController`) resolved server-side to the
right artifact + content-type, with a **lossless fallback** when no Opus artifact exists (additive, never
404/silence). The player gains one `OpusFormatDecoder` (`IFormatDecoder`): Ogg-page-aligned segmenting
(`OggS` scan — the FLAC frame-sync analogue) and `OpusHead`/`OpusTags` setup-bytes carry (the FLAC
`streamInfoBytes` analogue). **Browser constraint flagged:** Ogg-Opus `decodeAudioData` is Safari-18.4+ only
(Chrome/FF long-standing), so the Opus default is **capability-gated** — fall back to the universal lossless
path on browsers that can't decode it.

**VBR-safe ACCURATE seeking (Daniel, 2026-06-23 — supersedes the earlier "approximate" hand-wave).** Raw
byte-offset seek and rough page interpolation are inadequate for VBR Opus — there is no linear time↔byte
relationship. The fix is an **accurate transfer function built at transcode time** (the one moment the
whole encoded stream is walked): a precomputed **seek index** mapping Ogg-page `granulepos` (48 kHz sample
counts → time) → exact byte offset (**0.5 s buckets** snapped to page starts — OQ7; ~7,200 entries ×
16 bytes ≈ ~115 KB for a 1-hour mix). The decode **setup header** (`OpusHead`/`OpusTags`, needed to decode any mid-stream slice) is made
available too. Recommended concrete design: **one sidecar artifact per track = `[setup header][seek
index]`, built at transcode, stored beside the Opus bytes, fetched once on track load**, parsed into
`OpusSeekData`. Client seek flow: `calculateByteOffset(t)` binary-searches the index for the exact page
offset → `Range: bytes=X-` fetch (landed Phase 4 primitive, unchanged) → prepend the cached setup header →
decode → fine re-sync to `t` within the bucket. **The listener lands at the correct time, not
approximately** (AC9), **without** the full PCM in memory — so it composes with Phase 21 windowed refill,
which calls the **same** index resolver. The earlier "approximate page-interpolation" language is rejected.

**Constraints/invariants:** keep the bespoke graph (no MSE); preprocessing is **additive** (WAV path
untouched, byte-for-byte; a track with no Opus artifact still plays losslessly); reuse the landed
`Range`/offset seek path; no format branches leak outside the new decoder + one selection arm + the
transcode/delivery seam; transcode failure must not block ingest; format selection is a delivery-time
decision resolving one `EntryKey` to one of two artifacts (one source, two views — **not** a second
`TrackEntity` row, which would fracture share/queue/play-count/release identity).

Sequenced as six waves. `18.1 → 18.2 → {18.3, 18.4} → 18.5`, with `18.6` (Settings menu) able to run in
parallel (it needs only 18.3's format mechanism before its toggle is live). **18.1 (ingest transcode +
seek-index + setup-header derived artifacts) is the cold-start prerequisite** — nothing downstream has
bytes to serve, decode, or seek against until those artifacts exist.

- **18.1 — Ingest transcode + seek-index + setup-header (cold-start; load-bearing).** New
  `OpusTranscodeService`/processor in `DeepDrftContent`, invoked post-store from
  `UnifiedTrackService.UploadAsync` alongside `WaveformProfileService` **as a background job** (OQ6);
  produces Ogg Opus fullband 320; **walks the encoded stream once to build the granule→byte seek index and
  extract the `OpusHead`/`OpusTags` setup header**; stores the Opus bytes **and** the combined seek/setup
  **sidecar** as derived artifacts (recommend a `track-opus` vault). Failure-tolerant. **Independent of the
  delivery/decoder waves — can begin immediately.**
- **18.2 — Storage + lookup contract.** The derived-artifact key/vault convention (Opus bytes + sidecar) +
  server-side "given `EntryKey` + format, return the right `AudioBinary` + content-type (+ the sidecar),"
  including the lossless fallback. **Depends on 18.1.**
- **18.3 — Delivery: `?format=opus|lossless` param + sidecar serving + proxy threading.** On the
  `DeepDrftAPI` stream endpoint (resolves via 18.2), forwarded through `TrackProxyController` (mirror
  `offset`), `Range` serving the chosen artifact; **plus serving the seek/setup sidecar**; player sends the
  format param via `TrackMediaClient`. **Depends on 18.2; parallel-ok with 18.4.**
- **18.4 — `OpusFormatDecoder` + index-based seek resolver in the player stack.** New `IFormatDecoder`
  (Ogg-page segmenting via `OggS` scan, `OpusHead`/`OpusTags` setup carry from the cached sidecar,
  **`calculateByteOffset` that binary-searches the precomputed seek index** — NOT interpolation — with an
  `OpusSeekData` accelerator holding the parsed index + setup bytes, and the one-time sidecar fetch+parse on
  track load) + one arm in `createFormatDecoder` on `audio/ogg`/`audio/opus`; capability detection for the
  lossless fallback. **Depends on 18.2; parallel-ok with 18.3.**
- **18.5 — Backfill + replace-audio + end-to-end validation (incl. seek accuracy).** "Backfill Opus" CMS
  bulk action (third sibling to Generate-Profiles / Backfill-High-res), rebuilding Opus bytes + sidecar for
  existing tracks; replace-audio Opus + sidecar regeneration; the AC1–AC10 acceptance pass **including AC9
  (an Opus seek lands at the correct time, not approximately)** and the Phase-21 handshake (Opus windowable
  via the index resolver + sidecar setup header). **Depends on 18.1–18.4.**
- **18.6 — Public Settings menu + quality toggle (the listener selection UX).** New public-site
  Settings-menu shell (app-bar trigger + MudBlazor menu + a settings-item abstraction + a
  `PublicSiteSettings`/`ListenerSettings` object + the dark-mode-pattern persistence seam: `streamQuality`
  cookie, a `DeepDrftPublic` prerender-read service, `PersistentComponentState` bridge, client cookie
  service); the **quality toggle is its first occupant** (Low-data/Lossless, Opus default, capability-gated)
  + the CMS upload meter's **Post-Processing phase** (OQ6). Built design-for-adaptability so dark mode can
  plug in later without restructuring (not migrated now). **Depends on 18.3** for the toggle; the menu shell
  can be built ahead. *Splittable* (shell, then toggle) if Daniel wants the shell proven first.

**Dependency shape:** `18.1 → 18.2 → {18.3 ∥ 18.4} → 18.5`; `18.6 ∥` (needs 18.3 for the live toggle);
18.1 is the only cold-start wave. **Phase-level: 18 precedes Phase 21** (windowed refill consumes the Phase
18 seek-index resolver). **OQ1–OQ7 RESOLVED (above); OQ7 (seek-index granularity) = 0.5 s buckets.** None
block 18.1.

---

## Phase 21 — Windowed Streaming Buffer (bounded client memory for long streams)

Bound the **client memory** a playing track consumes to a small, configurable forward window —
**independent of total stream length** — so a 1 GB+ DJ MIX (Phase 9 `Mix` medium: a single long track)
plays without the whole decoded PCM accumulating in the browser. **Public listener site only**
(`DeepDrftPublic.Client` player stack + `DeepDrftPublic` TypeScript audio interop); no CMS, no API
endpoint, no schema change.

**Sequenced AFTER Phase 18 (Opus Low-Data Streaming) — Daniel, 2026-06-23.** Format support (the
derived Ogg Opus 320 low-data path, Phase 18) is a prerequisite that comes first; windowing must work
across **both** delivery formats. Phase 21's C5 invariant already anticipated this ("must not foreclose
MP3/FLAC"); **Opus is now the concrete VBR/paged driver** — windowing an Opus stream uses the decoder's
**accurate index-based** byte↔time mapping (`OpusFormatDecoder.calculateByteOffset`, a binary search in the
Phase 18 precomputed seek index — *not* the exact CBR-WAV `byteRate` math, and *not* approximate page
interpolation: VBR-safe and exact, per the Phase 18 seek-model resolution 2026-06-23). The windowed refill
controller calls the **same** index resolver an explicit seek does, and a window opening away from byte 0
still decodes via the Phase 18 sidecar setup header. Build the window machinery format-agnostically so it
inherits Opus for free.

The network path already streams in adaptive 16–64 KB chunks. The accumulation is on the **decode
side**: `PlaybackScheduler` holds an `AudioBuffer[]` it **never evicts** ("Supports pause/resume/seek by
retaining all buffers" — its own doc comment). Decoded PCM is larger than the source (Web Audio is
32-bit float per sample/channel — a 16-bit stereo WAV roughly doubles once decoded), so a 1 GB WAV
becomes ~2 GB of retained float data. That is the OOM. The fix: hold only a sliding forward window plus a
small back-retain, discard already-played buffers, and refill on demand.

**Architectural spine — a sliding window keyed on playback position, built as a generalization of the
landed seek-beyond-buffer path.** The Phase 4 HTTP `Range: bytes=X-` → 206 primitive already does every
plumbing primitive the window needs (discard-buffers-keep-offset via `clearForSeek`/`setPlaybackOffset`;
fetch-from-offset via `TrackMediaClient`; decode-header-less-body via
`StreamDecoder.reinitializeForRangeContinuation`; time→byte via `IFormatDecoder.calculateByteOffset`),
just triggered manually and one-shot. The only genuinely new mechanisms are **partial eviction** on the
scheduler and **back-pressure** on the forward read loop (stop calling `ReadAsync` above a high-water
mark, resume below low-water). Recommended **Direction A** (sliding window on the existing single forward
stream); **Direction B** (discrete Range-fetched segments — the HLS/DASH/MSE-eviction analogue) held as
the documented fallback; **Direction C** (adopt MSE and let the browser manage the buffer) **rejected
(OQ5 = NO, Daniel 2026-06-23)** — the bespoke Web Audio graph is a deliberate long-term commitment, and
the compressed-delivery move that would have justified MSE is met instead by **Phase 18 (Opus) feeding
the same bespoke graph** through the `IFormatDecoder` seam. Direction A is therefore the permanent
destination, not a stopgap MSE would retire.

**Invariants that must hold (the §3.5 seam contract).** Reuse the Range path, don't fork it; playback-
start latency at parity; the `IFormatDecoder` abstraction untouched (windowing is format-agnostic, so
wiring MP3/FLAC later inherits it free); read-only playback (no new control); the single-instance JS
decoder stays single-writer (every refill routes through the existing cancellation/drain discipline). The
**Mix visualizer is provably unaffected** — it renders from the preprocessed per-track high-res datum
(Phase 10/12), never from live decoded PCM, so evicting played buffers cannot starve it. The 1 GB mix is
both the canonical case *and* the proof the eviction is safe.

**Interaction with deferred Phase 1 features (same seam):** windowing should land **before** preload
(1.3) — it makes preload of long tracks memory-safe by construction (a staged next-track decoder inherits
the bounded scheduler); it makes crossfade (1.4) between two long mixes affordable (the overlap doubles
the *window*, not the track); it adds a minor "don't evict the final window before the gapless boundary"
care point for 1.5. It **enlarges the error surface** (1.6): windowed refill issues mid-stream fetches
the listener didn't initiate, one of which can fail deep into a 1 GB mix — so the *cheap* half of 1.6
(clean refill-failure handling, no wedged player) is folded into this phase's acceptance criteria, not
left fully to 1.6.

Full design, the three directions with SOLID/road-not-taken rationale, use cases, acceptance criteria,
the open-question set, and the wave decomposition: `product-notes/phase-21-windowed-streaming-buffer.md`.

Sequenced as four waves. `21.1 → 21.2 → 21.3`, with `21.4` validating the whole. **21.1 is the cold-start
prerequisite and the load-bearing change** — independent of the open questions (window *sizes* are
parameters fed in later).

- **21.1 — Partial eviction in `PlaybackScheduler` (cold-start; load-bearing).** Drop already-played
  buffers while keeping the position/index/time-anchor bookkeeping exact against a buffer array that no
  longer begins at absolute time 0 (today `getCurrentPosition`/`playFromPosition`/the schedule loop all
  assume `buffers[0]` is the track start). The hardest correctness work in the phase. No refill yet.
  **Independent of the open questions — can begin immediately.**
- **21.2 — Back-pressure on the forward read loop.** Stop `ReadAsync` above the high-water mark, resume
  below low-water; together with 21.1 this bounds *both* the played and unplayed regions (the AC1
  guarantee). Routes resume/pause through the existing single-loop cancellation discipline. **Depends on
  21.1.**
- **21.3 — Seek-back-past-window refill.** When a backward seek lands earlier than the retained tail,
  refetch via the existing seek-beyond-buffer Range path pointed at the earlier offset; plus the minimal
  clean refill-failure handling (the 1.6 adjacency). Mostly reuse of the landed seek path. **Depends on
  21.1 + 21.2.**
- **21.4 — Validation against the 1 GB target (acceptance).** Memory profiling (bounded under 1 GB is the
  headline), latency parity, edge-to-edge playback, the seek matrix, induced refill failure, visualizer-
  running, rapid-seek concurrency. Largely measurement; breaks are tuning fixes in 21.1's anchor math or
  21.2's water-marks. **Depends on 21.1–21.3.**

**Dependency shape:** `21.1 → 21.2 → 21.3 → 21.4`; 21.1 is the only cold-start wave. **Phase-level
prerequisite: Phase 18 (Opus) lands first** so windowing is built against both formats. **Open questions
for Daniel (spec §6):** window-size policy axis (time-based window + memory guard — recommended); seek-
back-past-window re-buffer acceptable (recommend yes, symmetric to forward); a hard total in-flight
memory cap as a guard rail (recommend yes); window everything vs. only long tracks (recommend everything
— one path, short tracks never hit a refill). **OQ5 (adopt MSE) — RESOLVED NO (Daniel 2026-06-23): the
bespoke graph stays by deliberate choice; recorded considered-and-declined, kept visible per file
convention.** None block 21.1.

---

## Phase 22 — SEO Metadata Component (parameterized head/meta injection)

Give every public page a **single reusable, parameterized component** (`SeoHead`) that emits the full
modern-SEO head surface — standard meta, canonical, robots, Open Graph, Twitter Card, and schema.org
JSON-LD — so crawlers and social unfurlers see correct, page-specific metadata **in the prerendered
HTML**, with no per-page boilerplate. **Public listener site only** (`DeepDrftPublic` host +
`DeepDrftPublic.Client`); the CMS (`DeepDrftManager`) is an authenticated admin surface and is
**explicitly out of scope**. No data-model/schema change, no new API endpoint — every value is already
on `ReleaseDto` / `TrackDto` / `HomeStatsDto`.

**The gap today.** `App.razor` has a `<HeadOutlet @rendermode="InteractiveAuto">` and a static `<head>`,
but **no** description, canonical, OG, Twitter, or JSON-LD anywhere; pages set only an ad-hoc
`<PageTitle>` (and the suffix is already inconsistent — `- DeepDrft` vs `- Electronic Music Collective`).
A shared `/mixes/{key}` link unfurls as a bare title + URL.

**Shape — one component + a typed model + a config (SOLID).** `SeoHead.razor` (presentational, in
`DeepDrftPublic.Client`, renders a `<PageTitle>` + `<HeadContent>`; owns no fetch); a `SeoModel` typed
per-page input with **named factories** (`ForRelease`/`ForHome`/`ForAbout`/`ForBrowse`) that encode the
medium→schema mapping in one place; and `SeoOptions` site-wide defaults (site name, suffix, default
description, canonical `BaseUrl`, default OG image, social handles) registered via the static-`Startup`
seam that runs in both server and WASM `Program.cs`. Each page touches **one line** — the boilerplate
(~15 tags) lives once in `SeoHead` + the factories. The component is **presentational and parameter-fed**
exactly like `ReleaseHeroOverlay`/`ReleaseDescription`; the page's ViewModel already holds the DTO.

**Music-domain JSON-LD (the high-leverage part).** Per-medium schema.org mapping: a **cut** →
`MusicAlbum` with an ordered `track` list (`MusicRecording`s); a **session** → `MusicAlbum`/`LiveAlbum`
(treated as a release, not a calendar event — OQ6); a **mix** → a single `MusicRecording` with ISO-8601
`duration`; home/about → the `MusicGroup` entity; browse → `CollectionPage`. Recommend a **typed JSON-LD
builder** (small schema-shaped C# records, serialized) over passed raw fragments — it is the only option
that keeps DRY and makes Rich-Results validity a unit test (OQ5).

**Render-mode correctness (the load-bearing requirement).** Crawlers read prerendered HTML and do not run
WASM, so the tags must be present at **prerender time**. This works because the SEO data is a *projection
of the same `ReleaseDto` the detail pages already resolve during prerender and bridge across the WASM seam
via `PersistentComponentState`* — `SeoHead` rides that existing bridge, no new fetch. Two care points,
both spelled out in the spec: (1) the `InteractiveAuto` double-render must produce **identical** head
content across the prerender and WASM passes (fed from bridged state, not a fresh client fetch — guard on
id/key equality like the detail pages do), and (2) absolute canonical/`og:url`/`og:image` origins come
from `SeoOptions.BaseUrl` (config), **not** a browser-only `window.location` — there is no `window` at
server prerender, and the origin can't be reliably derived behind the nginx proxy.

**Open questions for Daniel (spec §7):** canonical production origin/`BaseUrl` (OQ1 — must be config, can't
be derived behind the proxy); default OG share image asset (OQ2); social handles / `sameAs` (OQ3); title
suffix + composition (OQ4 — resolves the existing inconsistency); typed JSON-LD builder vs. passed
fragment (OQ5 — recommend typed); session schema type (OQ6 — recommend `MusicAlbum`/`LiveAlbum`).
**Adjacent but separate (flagged, not in this component):** `robots.txt` (static, disallow the CMS host),
`sitemap.xml` (needs a small public-host endpoint enumerating releases — reuses the existing paged read),
and CMS `noindex` (a CMS-side robots/meta change). Daniel to call whether sitemap folds into Phase 22 as a
second wave or tracks as its own phase.

Full design — the metadata-surface table, the component contract, the build-vs-pass JSON-LD fork, the
render-mode analysis, use cases, acceptance criteria, and wave decomposition:
`product-notes/phase-22-seo-metadata-component.md`.

Sequenced as four waves. `22.1 → 22.2 → 22.3`, with `22.4` validating. **22.1 is the cold-start
prerequisite.**

- **22.1 — Core component + config + model, on the static pages (cold-start).** Build `SeoHead`,
  `SeoModel` (+ standard/OG/Twitter rendering), and `SeoOptions`; wire **Home and About first** (data
  trivially available at prerender, no double-render subtlety). Proves prerender emission end to end.
  **Needs OQ1/OQ2/OQ4 config values — can stub and swap in Daniel's answers.**
- **22.2 — Release detail pages + per-medium JSON-LD (the rich case).** Add the `MusicGroup`/`MusicAlbum`/
  `MusicRecording` builders and the `ForRelease` factory's medium→type mapping; wire `CutDetail`/
  `SessionDetail`/`MixDetail` from their already-bridged `ReleaseDto`. Exercises the schema, double-render
  identity, and canonical correctness ACs. **Depends on 22.1; needs OQ5/OQ6.**
- **22.3 — Browse + 404 + remaining pages.** `CollectionPage` for browse; `noindex` for 404. **Depends on
  22.1.**
- **22.4 — Validation pass.** No-JS crawler-view fetch (tags present), Rich Results validator, double-
  render-identity check, canonical/alias matrix, partial-data releases. Largely measurement. **Depends on
  22.1–22.3.**
- **(Adjacent, separate) — `robots.txt` + `sitemap.xml`.** Endpoint-shaped follow-on, not a component
  wave; tracked separately pending Daniel's fold-in vs. separate-phase call.

**Dependency shape:** `22.1 → 22.2 → 22.3 → 22.4`; 22.1 is the only cold-start wave. None of the open
questions block 22.1 (config values can be stubbed). **No CMS change in any wave (hard constraint C1/AC9).**

---

## Working with this file

- **Add items by extending an existing phase first**; only create a new phase when the addition genuinely doesn't fit any of 1–5. Phase numbers are organisational, not sequencing.
- **When something lands, move it to `COMPLETED.md`** rather than deleting it. Keep the original "What / Why / Shape" body intact so the history reads as a record of the decision, not just the outcome.
- **Mark genuinely uncertain items `[speculative]`** so future readers can tell what is direction vs. commitment.
- **Open questions belong in the item that raises them**, not in a separate "questions" list — they expire when the item does.

