Files
deepdrft/PLAN.md
T

72 KiB
Raw Blame History

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 concurrencyVaultIndexDirectory no longer drops the lock before its async disk write; the index file can no longer be clobbered by interleaved writers.
  • Repository semanticsTrackRepository.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.

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 entryKeys 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 14 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 24 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 17 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 17 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.A8.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 12 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 MudSliders become four RadialKnobs (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 R1R4

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.B11.E build on).

  • 11.A — /cuts/{id} album-detail page. Left header (name, artist, genre, year, Play + Share), right cover with theme border, ordered track list (by TrackNumber) with per-row play, header Play. New CutDetailViewModel; reuses GetById + the releaseId-filtered track page (both exist). Ordinal is a verification (§3a), not a dependency. Header/row Play consume 11.F when present, else degrade to single-track (§3.4 seam). Load-bearing prerequisite for 11.B's Cut resolution.
  • 11.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.ModelsBaseEntity.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.A11.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.B11.E create/edit, so it follows all of them; its migration is authored after 11.G's so the EF snapshot stays linear. The cold-start items are 11.A, 11.F, and 11.G — kick 11.A + 11.F off first so "play album" works on first ship of the Cut page; 11.G runs alongside on its own track; 11.H waits for the addressing surface to settle.

Resolved by Daniel (2026-06-15), kept visible per file convention: player-bar title → release detail (was OQ1); track ordinal in scope and already built (was OQ4, reversed then found done); retire the whole track-cardinal stack (was OQ5, full cut chosen); release-level Share in scope; play-queue in scope (queue half of §1.3 absorbed; preload half stays deferred); release Description field in scope (commitment 8 — a real new column, lands as schema slice 11.G with the render on 11.A + a Session/Mix touch). Still open (spec §7.2): /cuts/{id} scaffold strategy (generalized Header slot — recommended — vs. bespoke); Cut header affordance idiom (icon vs. labeled buttons); queue architecture (separate IQueueService — strong steer; staff-engineer's final call); whether release-share keeps "Embed player" (recommend copy-link-only); Description render plain-text vs. markdown (recommend plain text + preserved line breaks for v1) and column max-length (recommend 20004000); /genres fate (out of scope, flag as adjacent).


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 3080%, 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.116.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.116.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 D1D7 — 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 membersMove(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).

  • OQ1Option 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.
  • OQ2yes, 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.
  • OQ4MudDropContainer for now (C6 softened — touch-viability is a known risk with a planned pivot path, not a pre-ship blocker).
  • OQ5yes, 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).
  • OQ6fixed sensible height with internal scroll past N rows (not grow-to-cap; affects EmbedSnippetBuilder.ForRelease).
  • OQ7Material icons for now (QueueMusic / PlaylistAdd; bespoke DDIcons glyph not pursued in Phase 17).
  • OQ8pure append (add ≠ play; first add into a dormant queue leaves a coherent CurrentIndex via the 17.1 engine affordance, no auto-play).
  • OQ9exclude StreamNowButton (no fixed track until one resolves).
  • OQ10deferred (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 directlySuperRegister.razor/account/superregisterPOST api/auth/admin-register (UserAdmin-gated, working). Creates a live account now.
  2. Public self-serviceRegister.razor/account/registerPOST 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 emailNewRegistration(Form).razor/useradmin/registrations/newPOST 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 navCmsLayout is just an app bar + Home button, so nothing links to /useradmin/* or /account/superregister (admin surface invisible). (b) Wrong layout for public pagesRoutes.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 (Completed)

See COMPLETED.md for Phase 18 — dual-format lossless + Opus delivery, including the as-built WebCodecs decoder divergence — which landed on streaming-overhaul (2026-06-23).


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.

Phase 18 (Opus Low-Data Streaming) has landed — Phase 21 is the next pickup. The derived Ogg Opus 320 low-data path (Phase 18, COMPLETED.md) is the prerequisite; 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 1664 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.121.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 cutMusicAlbum with an ordered track list (MusicRecordings); a sessionMusicAlbum/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 PersistentComponentStateSeoHead 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.122.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 15. 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.