Files
deepdrft/PLAN.md
T

32 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

  • What: 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.
  • Prerequisite: Requires a notion of "next track" — today the player only knows the current one. That implies either a playlist/queue model in IPlayerService or a passive "what was the next row in the gallery" inference.
  • Open question: Does a queue model belong in IPlayerService, or is the player a single-slot device that a future PlaylistService orchestrates above? Worth a design note before implementation. Capture in product notes when picked up.

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.

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 15 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.6 Wave 6 — Gap Closure

Two functional gaps the landed Phase 9 surface left open. Both are real (medium intent not honoured at a surface that should honour it), neither is debt. A is a product decision (which destination the home-page cards take) and is gated on Daniel — its build is one line of markup either way, but the shape of the answer is his to pick. B is clear-cut (mirror an existing collapse already proven on the upload path into the edit path). A and B are independent; B can land immediately, A waits on the open question below.

9.6.A — Home-page editorial cards have no medium destinations

  • What: The three "Music through Every Medium" editorial cards on Home.razor (Studio / Live / DJ Mix — landed §8.6) still render as non-navigating <div>s. They carry a deferral comment — @* TODO Phase 3.x: wire each card to its format-filtered browse route once /tracks?format= exists *@ — written before the medium browse routes existed. Today /cuts, /sessions, /mixes are live and working (§9.4); the only thing that points anywhere from this section is the section CTA "Explore the Archive" → /tracks. The cards are the most prominent medium framing on the public site and they are dead ends.
  • Why it matters: This section is the home page's pitch of the three-medium taxonomy. Leaving the cards inert undercuts the whole Phase 9 narrative — a visitor reads "Studio / Live / DJ Mix," clicks the most prominent thing on the page, and nothing happens. The destinations now exist; the only question is which destination is right. The TODO's /tracks?format= premise is very likely obsolete — it predates the medium browsers, which already give each card a real home.
  • Shape: Depends on the open question. Either is small:
    • (a) Link the three cards to the existing medium browsers — Studio → /cuts, Live → /sessions, Mix → /mixes. Promote each .medium-card <div> to an <a href> (the §8.6 spec already anticipated this: "promoting to <a> later is a one-line change" — the hover styles assume the affordance). Zero new surface; the routes exist today. Removes the stale TODO.
    • (b) Build a /tracks?format=<medium> filtered gallery first, then point the cards there — a flat cross-medium gallery pre-filtered by medium (grid/list toggle, the TracksView ergonomics), distinct from the medium-specific browsers. Honours the original TODO's literal premise but adds a surface that does not exist yet: a format/medium query param on TracksView + its VM, plus the routing. The cards then deep-link into that one gallery, pre-filtered.
  • Acceptance criteria: Each of the three editorial cards navigates to a live medium destination on click (desktop and mobile); the stale /tracks?format= TODO is resolved (removed under (a), or satisfied under (b)); no card remains a dead <div>.
  • Open question (Daniel — product decision, do not pre-empt): Should the cards point at the existing medium browsers (/cuts / /sessions / /mixes, shape (a)) or at a new /tracks?format= filtered gallery (shape (b))?
    • (a) is trivial and honest about what the site already offers — the medium browsers are the canonical per-medium surfaces, and a card that says "Studio Releases" landing on /cuts is exactly truthful. The TODO that asked for /tracks?format= was written before those browsers existed and is plausibly just stale.
    • (b) adds a surface but unifies the browse experience under one flat gallery the visitor can re-filter in place — the card is an entry point into a single explorable gallery rather than three sibling destinations. Worth it only if Daniel wants the flat cross-medium gallery to be the primary public browse model rather than the medium-specific browsers.
    • Note: (a) requires no new code beyond the three hrefs (and the <div><a> promotion the §8.6 spec pre-authorised); the /cuts, /sessions, /mixes routes already satisfy it. (b) is a genuine new view. The choice is Daniel's — it is a question of which browse model the home page should funnel into, not an implementation detail.

9.6.B — BatchEdit single-track form-shape collapse not applied on the edit path

  • What: BatchUpload.razor enforces the single-track invariant (§9.3 resolved: Session/Mix are one-track-per-release) by collapsing its multi-track master list to a single row when the medium is Session or Mix — OnMediumChanged trims the form to row 1. The edit path BatchEdit.razor (/tracks/album/{AlbumName}/edit) was not given the same collapse; a code comment flags the deferral. Opening a Session or Mix release in BatchEdit today shows the full multi-track master list — a form shape that, by the phase's own resolved invariant, should not exist for those media.
  • Why it matters: The edit form contradicts the data model it edits. Sessions and Mixes are single-track by design and the upload path already enforces that; the edit path showing a multi-track list invites an admin to add tracks to a release that is not supposed to have them, and presents an inconsistent authoring experience between create and edit for the same medium. It is the upload-path invariant left half-applied.
  • Shape: Mirror BatchUpload's OnMediumChanged collapse logic into BatchEdit. When the loaded (or selected) medium is Session or Mix, collapse the master list to a single track row and hide the add-track affordance, exactly as BatchUpload does — BatchUpload.OnMediumChanged is the reference implementation; reuse its shape rather than authoring a second one (the collapse logic is a candidate to lift into a shared helper or the MediumFields dispatch if it reads cleanly, but parity with upload is the requirement, shared extraction is the nicety). The medium selector wiring into BatchEdit's submit path already landed in §9.5.B; this is the form-shape half that did not.
  • Acceptance criteria: Opening a Session or Mix release in BatchEdit shows a single-track form with no add-track affordance, matching BatchUpload for the same medium; opening a Cut release is unchanged (full multi-track list); switching the medium selector to Session/Mix within BatchEdit collapses the list live, the same gesture BatchUpload performs.
  • Open question: How should BatchEdit render an existing Session/Mix release that already holds multiple tracks (e.g. one created before the §9.3 single-track invariant landed, or mis-authored)? Collapsing the form to row 1 would visually hide tracks 2..n without deleting them — the admin sees one track, the DB holds several, and a save could silently orphan the editing of the hidden tracks. Recommend the safe reading: if a non-Cut release loads with >1 live track, do not silently collapse — show the full list with an inline warning ("Sessions and Mixes are single-track; this release has N — remove extras to conform") and let the admin reconcile, only enforcing the single-row collapse once the release is already conformant. This keeps the invariant from destroying data it was added after. Flag for Daniel; the collapse-on-conformant-release behaviour (the common case) is unambiguous and can land regardless.

Dependency summary for Wave 6: A and B are independent. B is unblocked and clear-cut (mirror the proven BatchUpload collapse). A is blocked only on the Daniel product decision above — once the destination is chosen, its build is trivial. Neither depends on the other.

9.7 Wave 7 — Domain Invariant Hardening: per-medium track cardinality

The single-track-per-release rule for Session/Mix is enforced only in the CMS form layer today (the BatchUpload/BatchEdit master-list collapse, §9.6.B). Below the form there is no enforcement: UnifiedTrackService.UploadAsync links a track to a release without checking the release's medium or its existing track count, and §9.5.A's first-upload-authoritative behaviour links a second track to an existing (album, artist) release with no cardinality gate. A multi-track Session/Mix is therefore reachable via repeated separate uploads or any non-CMS POST api/track/upload caller. This wave makes per-medium cardinality a real domain invariant rather than a UI convention. Full design — the generalised rule, the enforcement-layer trade-offs, the orphan-avoidance reordering, the relationship to the existing rules, and the back-compat reality — lives in product-notes/phase-9-medium-cardinality-invariant.md. One item, gated on one Daniel decision (the open question below).

  • What: Promote per-medium track-count from a form convention to a domain invariant enforced at the upload-service boundary. Declare each medium's allowed cardinality as data — Cut → 1..N, Session → 1..1, Mix → 1..1 — in a single ReleaseMedium-keyed lookup (MediumRules, in DeepDrftModels), extensible by one entry per future medium. UnifiedTrackService.UploadAsync reads the resolved release's medium + live track count and rejects a track-add that would exceed the medium's Max (only the find path — a freshly created release is always within range). The existing CountLiveTracksByRelease (already on ITrackService, backs the delete cascade) supplies the count; no new counting primitive.
  • Why: Daniel ruled single-track-per-Session/Mix a hard constraint (§9.5/§9.6, resolved). Today it is form-deep only — the upload endpoint and any scripted ApiKey caller bypass it, and the first-upload-authoritative write path adds a second track to an existing non-Cut release with no check. The data model itself does not forbid what the product forbids. Hardening it at the service layer makes every domain writer pass the rule, closes the gap, and — by declaring cardinality as one shared rule both the form and the service read — guarantees the UI and the domain cannot drift.
  • Shape:
    • The rule as data. MediumRules.CardinalityOf(medium) returns a (Min, Max) value type; no three-arm switch in any service. The same lookup the upload service enforces is the one the CMS form collapse reads (refactor OnMediumChanged from its hardcoded medium is Session or Mix to MediumRules.CardinalityOf(medium).IsSingleTrack) — one source, two consumers (form shapes the UI, service enforces the limit), so they cannot diverge. This is a consume-the-new-rule refactor of §9.6.B's landed collapse, not a re-litigation of it.
    • Enforcement in the orchestrator, not TrackManager. The check lives in UnifiedTrackService (the true boundary for a track-add-to-a-release operation), not the lower-level SQL Create. Express the guard generally — if (liveCount + 1) > cardinality.Max — so a future bounded-but-not-single medium is covered by the same line.
    • Reorder to avoid orphaning the vault write. Today UploadAsync writes the vault before resolving the release. A rejection at that point orphans the audio. Move the cardinality pre-check before AddTrackAsync: peek the release by (album, artist) (a read via the existing GetReleaseByTitleAndArtistAsync, not a create), read its medium + count, reject early — then vault-write only the accepted upload. This reordering is part of the wave, not an afterthought.
    • Violation behaviour. Return a NetBlocks ResultContainer failure with a clear message ("A {medium} release holds a single track; '{title}' already has one"). The controller surfaces it as a 409 Conflict (honest — well-formed request, rule violation) if cheap, 400 otherwise. The CMS already bubbles upload-failure messages inline; no bespoke UI — the common case never reaches the API because the form collapse stops it first, so this is the backstop for the paths the form does not cover.
    • Leave ReleaseType-applicability alone. Do not merge the cardinality rule with the ReleaseType-only-for-Cut invariant — they are different kinds of rule (count constraint vs. field relevance). They may co-locate as separate named members of MediumRules, but no generic "medium invariant engine." Only cardinality is new this wave.
    • Tests. Extend MediumWritePathTests (the §9.5 EF in-memory fixture): Session/Mix reject a second track-add; Cut accepts the Nth; first track on a new Session/Mix succeeds; MediumRules.CardinalityOf returns the declared ranges.
  • Acceptance criteria: A second track-add to an existing Session or Mix release is rejected at POST api/track/upload with a clear failure message and no vault orphan; a Cut release accepts many tracks unchanged; the first track on any medium succeeds; the CMS form collapse and the service enforcement both read MediumRules (no duplicated cardinality logic); the existing ReleaseType-only-for-Cut enforcement is untouched.
  • Back-compat (verified): No violating data exists — Phase 9 is unmerged, every release migrated to Cut (many-track), zero multi-track Session/Mix releases exist. A DB backstop (if chosen, see open question) goes on clean with no data-cleanup migration; the service check has nothing to reconcile. Note honestly: no DB-level cardinality or medium constraint exists today (ReleaseConfiguration carries only the (title, artist) unique index and the is_deleted index) — closing that absence is the wave.
  • Open question (Daniel — philosophy call, do not pre-empt): Enforce the cardinality invariant in the UnifiedTrackService domain layer only (recommended), or also add a Postgres constraint-trigger DB backstop so a future writer that bypasses the service cannot violate it?
    • Service-only (recommended). Consistent with the phase's own documented stance — the ReleaseType-only-for-Cut invariant chose service enforcement over HasCheckConstraint by choice, not necessity (phase-9-release-medium-types.md §1); cardinality is the same advisory-vs-storage shape and choosing the DB here would split the phase's philosophy. UnifiedTrackService is the only track-add path today — the "non-CMS caller" still goes through it (POST api/track/upload). The bypass a DB backstop defends against (a writer skipping the service entirely) does not exist in the codebase. And the migration is clean either way, so the backstop is free to add later if a second writer ever appears.
    • DB backstop (defer). A partial unique index cannot express this directly (the medium lives on the release table, not track; Postgres partial predicates can't cross tables). The expressible form is a hand-written PL/pgSQL constraint-trigger EF does not model — a standing maintenance surface. Defensible only if Daniel wants storage-layer immutability over service-layer truth.
    • Recommendation: service-only (C3), defer the DB backstop (C2) as a free-to-add-later option. This is a decision about where the system's structural truth lives — the service layer vs. the storage layer — not an implementation detail. It is Daniel's to make. Two minor sub-questions ride along (409 vs 400 status; MediumRules in DeepDrftModels) — both have clear recommendations and should not block.

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.