22 KiB
Phase 9 SOLID Review — Release Medium Types
Status: design-quality audit of product-notes/phase-9-release-medium-types.md + PLAN.md §9.
Author: product-designer. Date: 2026-06-12. Review only — no spec or code edits made.
Scope: SOLID discipline as this project frames it — Open/Closed at the schema level, SRP at the HTTP boundary, one mapping table per concern, DRY-by-composition, design-the-seam-up-front. Findings are grouped by the principle at stake. Each carries a verdict: accept, tighten, or redesign. Facts marked (verified) were read against live source on 2026-06-12.
Findings — Single Responsibility / ownership
F1 — Mix waveform compute is placed in the wrong tier [CRITICAL — redesign]
Spec says (§3.4, §4, PLAN 9.2.B): the CMS triggers high-res waveform preprocessing and uploads
the resulting datum: "upload audio → trigger waveform preprocessing → POST api/release/mix/waveform
with the datum → service writes datum to vault." The endpoint takes a client-computed blob.
The tension — four problems, one root cause:
- It contradicts the established pattern (verified). The existing low-res pipeline is
server-side:
POST api/track/{trackId}/waveformtakes no body —TrackControllerpulls the audio from its own vault (GetAudioBinaryAsync) and computes viaWaveformProfileService.ComputeAndStoreAsync. Upload-time profiling also runs in the API. The spec's flow inverts the authority: a network client computes a derived datum the server can derive itself from data it already owns. - It is architecturally unreachable as written (verified). §3.4's own "Reuse point" demands
the same code path as the existing preprocessor — but
WaveformProfileServicelives inDeepDrftContent, referenced byDeepDrftAPI.DeepDrftManagerhas no in-process data layer by explicit convention (rootCLAUDE.md: all track operations are HTTP proxies). The CMS cannot run the shared preprocessor without breaking that constraint; running a copy breaks the reuse mandate. The spec's two requirements are mutually exclusive. - Trust boundary. An ApiKey holder POSTing arbitrary "waveform" bytes gives the server no way to validate the datum against the audio it purports to describe. Server-side derivation makes the datum correct by construction.
- The recovery path is punitive. The per-row "Generate Waveform" action in
CmsMixBrowserwould require the CMS to download the full mix audio (mixes are the longest tracks in the catalogue), recompute, and re-upload. The existing pattern's recovery is a body-less POST.
Recommendation — redesign 9.2.B: make the Mix waveform endpoint a server-side trigger,
mirroring the existing idiom: POST api/release/{id}/mix/waveform (ApiKey, no body) — API
fetches audio from the vault, computes at high resolution via WaveformProfileService
parameterized by resolution, stores the datum in the vault, sets MixMetadata.WaveformEntryKey.
CMS upload flow becomes: upload track → call trigger. (Verified: ComputeAndStoreAsync(wavBytes, entryKey) currently takes resolution from injected WaveformProfileOptions.BucketCount = 512; the
refactor is a per-call resolution/profile parameter plus a distinct entry-key/vault target for the
high-res datum — small, and it resolves open question §7.3 in favour of the one-pipeline answer.)
This single change also repairs F11 (the Wave 4 dependency claim) and removes the largest ApiKey upload surface from the new controller.
F2 — Hero-image endpoint route omits the release identity [minor — tighten]
Spec says (§4): POST api/release/session/hero-image. The release id is nowhere in the route;
presumably it rides the multipart form. Recommendation: POST api/release/{id}/session/hero-image
— resource-addressed, consistent with F1's trigger shape and the existing
api/track/{trackId}/waveform idiom. SRP at the endpoint is otherwise right: splitting hero-image
and waveform writes from track upload is correct.
Findings — Open/Closed
F3 — §8's "what a fourth medium costs" list is extensible-on-paper [major — tighten]
Spec says (§8): a fourth medium costs five items (enum value, optional metadata table + DTO, display-metadata entry, projection entry, hero-slot renderer) and zero changes to anything existing.
The tension: the list omits real, recurring costs that the spec itself describes elsewhere:
- Form field sections — §3.3 puts medium-conditional
@ifblocks in five files (TrackNew,TrackEdit,BatchUpload,BatchEdit,AlbumHeaderFields). A new medium with authoring fields touches all five (see F4). - A CMS browser — Session and Mix each get a new browser component (§3.2). Medium #4 gets one too, or at least a grid-parameterization entry.
- A public gallery page + detail page + routes —
/sessions,/sessions/{id}etc. are per-medium pages; Blazor routes are attribute-based, so a new medium is new routable components. - Per-host mapping tables — the "display-metadata entry" is not one entry: the CMS card lookup
lives in
DeepDrftManager, the navChildrenlist lives inDeepDrftPublic.Client/Layout/Pages.cs, and the medium→route mapping is a third. One table per concern is the right discipline, but §8 counts three tables as one entry.
The meaningful OCP claim survives: a new medium modifies zero existing tables, zero existing media's components, zero existing endpoints. That is the claim worth defending. The "five items" framing undersells the additive surface and is exactly the "extensible on paper" failure §8 warns against — by the spec's own standard.
Recommendation: rewrite §8 as two honest lists: (a) what is never modified (the true OCP payoff), and (b) the full set of additive artifacts, each with its single known location. A roadmap that understates extension cost will be trusted less the second time it's read.
F4 — The form conditional is fine once; the spec scatters it five times [major — tighten]
Spec says (§3.3): "a small @if per medium in the form is acceptable and clearer than
over-abstracting… SOLID discipline matters most at the data/service layer; a form is allowed to be
explicit."
The tension: the principle is sound for one form. But the same medium-conditional logic is
specified across five form files. That is precisely the "scattered switch/if chains" the spec's
own §8 forbids — the concern (which fields does medium X author?) gets five copies that must be kept
in sync. The resolved single-track invariant (§7.8) raises the stakes: the conditionals now also
gate structural form shape (multi-track master list present/absent), not just a field or two.
Recommendation — middle path, not over-abstraction: extract per-medium field-section components
(CutFields, SessionFields, MixFields — explicit markup inside, no clever generics) and one
dispatch point (a single mapping or a MediumFields component with the one @switch) embedded by
all five forms. Inside each section, plain explicit markup — the spec's anti-over-abstraction
instinct is right within a section. This keeps "one table per concern" without inventing a form
framework. A new medium then adds one section component + one dispatch entry, and F3's list can
cite it honestly.
F5 — The projection map's extensibility guarantee should be stated honestly [moderate — tighten]
Spec says (§4): include medium metadata "via a small per-medium projection map — not a hardcoded
if session … else if mix … chain. A future medium adds a projection entry, not a new endpoint
branch."
The tension: a dictionary keyed by enum is a switch, data-structured. The honest guarantee is not "zero changes to the controller" but "the change is one declaration in one known place, and the read logic is untouched." That is still worth having — but two refinements:
- For
GET api/release/{id}the medium is unknown until the row is fetched, so a per-medium projection map either double-queries or is bypassed. With exactly two 1:1 navs behind unique FK indexes, always-Includeboth on single-release reads is simpler and naturally yields nulls for non-matching media — no per-medium branching at all. The map earns its keep on the list endpoint (?medium=), where joining every metadata table on every page query is the thing to avoid. - The map is also the natural single point that enforces the F8 correlation (metadata DTO populated iff medium matches). Say so in the spec — that is the map's second responsibility and the reason it should not be inlined.
Recommendation: keep the map for the list read; permit always-Include for the by-id read;
restate the guarantee as "one entry, one file" rather than "zero changes."
F6 — Nav Children collection: right call, two semantics to pin down [accept, with conditions]
Spec says (§5.1): extend the nav model with an optional Children collection rather than
hardcoding an ARCHIVE popover. (Verified: PageRoute is a flat POCO — Name, Route, Icon —
rendered as a flat <a> list; the Sessions/Mixes entries are literal "#" dead links today.)
Assessment: genuinely more extensible, and the complexity does not merely move to the model — the model change is a nullable list; the render branch (has-children vs. leaf) exists under either option, and option (a) keeps desktop popover and mobile indented-sublist driven by the same data. Two things to pin down in the spec, or the model will grow warts:
- Dual-role nodes. ARCHIVE has both a
Route(/archive) andChildren. Define the semantics once: desktop hover opens children, desktop click navigates to the overview; mobile renders the parent as a link with children indented. Don't leave it to the implementer. - Depth cap. One level only. If a future need wants nesting, that's a redesign, not a recursion.
Findings — Liskov Substitution
F7 — Rejecting the type hierarchy was the right LSP move [good — accept]
Option (B) in §1 (EF TPH subclasses) would have created SessionRelease/MixRelease types whose
substitutability is a lie — most code paths ("give me releases") don't care about medium, and the
ones that do would downcast. The discriminator-enum design has no inheritance to violate; medium
variance rides data (metadata rows) and composition (hero slots), not subtypes. This is the spec's
quiet best decision and worth keeping in §8 as the LSP rationale.
Findings — Interface Segregation / contract honesty
F8 — Optional nested metadata DTOs: acceptable, with the discipline named [accept]
Spec says (§2.4): ReleaseDto gains SessionMetadata? / MixMetadata?, populated only for the
matching medium; no denormalized flat fields.
Assessment: this is the right call, not an ISP violation, for three reasons: (1) it mirrors the
Phase 8 nested-Release precedent on TrackDto — consistency beats purity; (2) consumers that
don't care (gallery cards, grids) never touch the fields — null-checking burden lands only on the
medium-specific detail pages, which are medium-aware by definition; (3) the polymorphic-DTO
alternative (discriminated JSON hierarchy) collides with the documented BlazorBlocks new()
constraint on DTOs (verified in ReleaseDto.cs header) and buys nothing the enum doesn't.
Invalid states are representable (Medium == Cut with a populated MixMetadata) — acceptable for a
serialization contract iff exactly one producer maintains the correlation: the projection map
(F5). Name that in the spec.
ISP done right downstream — preserve it: §5.4 has MixWaveformVisualizer take the datum (or a
URL) + optional position binding, not a ReleaseDto. That is the segregation that matters — the
reusable component depends only on what it consumes. Hold that line for the hero slots generally:
slots receive what they render, not the whole release.
F9 — ReleaseDto.ReleaseType lies for non-Cut releases [moderate — tighten]
Spec says (§1): "readers of a non-Cut release should not surface it [ReleaseType]."
The tension (verified): ReleaseDto.ReleaseType is non-nullable, defaulting Single. A
Session release therefore serializes as ReleaseType: Single — a confidently wrong value — and the
"readers should not surface it" rule becomes a discipline imposed on every consumer instead of
a property of the contract. That is the DTO-level version of the scattered-switch smell.
Recommendation: make ReleaseDto.ReleaseType nullable (ReleaseType?), nulled at the single
entity→DTO mapping point when Medium != Cut. One producer enforces; zero consumers need to know
the rule. (The entity column staying non-nullable is fine per F10 — this is a read-model fix.)
Findings — the ReleaseType invariant (schema-level OCP consistency)
F10 — The spec's own §1 argument indicts its invariant handling — name the exception [major — tighten, then accept]
Spec says (§1): Cut "needs nothing extra" so it gets no metadata table; ReleaseType stays on
ReleaseEntity, unused for non-Cut media, enforced by service discipline.
The tension: ReleaseType is Cut-specific data living on the base table — "a column that is
meaningless for every other medium," which is verbatim the sin for which §1 rejected option (A).
The rigorous application of the spec's own pattern is a CutMetadata { ReleaseType } table, making
Cut symmetric with Session and Mix.
Why the spec's pragmatic call still wins — but must say so out loud:
- The hot path needs it.
/cuts(the existingAlbumsView) displays Single/EP/Album on every card — the highest-traffic browse read would pay a join that Session/Mix detail reads (rare, single-row) never mind paying. The spec's "metadata joins are paid only on medium-specific surfaces" argument cuts against movingReleaseTypeout. - Churn cost. Phase 8 §8.0 just landed
ReleaseEntitywithReleaseType; relocating it is a data migration weeks after a breaking normalization, for structural purity with no behavioral win. - The invariant is "meaningless," not "invalid." A stale
Singleon a Session row harms nothing if the read model nulls it (F9). Low-stakes invariants warrant low-ceremony enforcement.
Two corrections required before accepting:
- The spec's factual claim is wrong. "EF Core cannot express a conditional constraint cleanly /
a raw SQL CHECK would be migration-fragile" — EF Core has first-class check-constraint support
(
ToTable(t => t.HasCheckConstraint(...))), generated and versioned in migrations, fully supported by Npgsql. The honest position is "we choose not to constrain, because the invariant is advisory and the read model enforces it at the mapping point" — a defensible choice. "EF can't" is not true and will erode trust when an implementer checks. - Document the asymmetry as a deliberate exception in §1 and
ReleaseConfiguration: name the rejectedCutMetadataoption and the hot-path reason, so the next medium designer doesn't either (a) cargo-cult a base-table column for their medium's data, or (b) "fix"ReleaseTypeinto a table without seeing the read-path cost.
Findings — DRY-by-composition
F11 — ReleaseDetailScaffold is the right shape; its contract is undefined and one elision hides a fork [moderate — tighten]
Spec says (§5.3): extract common detail scaffolding into ReleaseDetailScaffold taking a
RenderFragment HeroContent; CutDetail/SessionDetail/MixDetail compose it.
Assessment: composition over both duplication (option i) and a branching god-page (option ii) is correct. Two gaps:
- The contract is unstated, and that's how god-components are born. Variance already exceeds
the hero: a Cut/Album detail has a multi-track listing; Session/Mix are single-track (resolved
§7.8). If that variance arrives as scaffold parameters (
ShowTrackList,HeroAboveMeta…), the scaffold becomes option (ii) wearing a composition costume. Define the contract in the spec: the scaffold owns exactly the invariant trio — metadata block, play affordance, player wiring — and every per-medium variance rides a slot supplied by the page. Rule of thumb to write down: a boolean layout parameter on the scaffold is a design failure; it belongs in a slot. If a second slot is genuinely needed (e.g.,BodyContentfor the Cut track list), a named slot is fine; a flag is not. - Where does
CutDetaillive, and what happens toTrackDetail? §5.2 routes/cutsthrough the existingAlbumsView, whose cards land on the existing track-cardinalTrackDetail— but §5.3 namesCutDetailas a scaffold consumer. IfTrackDetailstays as-is while Session/Mix details use the scaffold, the product has two sources of detail-page truth — exactly what One source, multiple views forbids. Decide explicitly: eitherTrackDetailis refactored onto the scaffold in Wave 4 (recommended — it is the scaffold's extraction source, so this is nearly free), or the fork is accepted and recorded as deliberate debt with a retirement note. Silence here guarantees the fork happens by accident.
Findings — sequencing honesty
F12 — "Wave 4 does not wait on Wave 3" is true for build, false for acceptance — as specced [major — tighten via F1]
Spec says (PLAN §9 dependency summary): Wave 4 can begin once Wave 2's api/release family is
stable; it does not wait on Wave 3.
The tension: Wave 4's 9.4.D acceptance criterion is "/mixes/{id} renders the waveform
visualizer fed by real datum." Under the spec's current 9.2.B design, the only producer of
that datum is the Wave 3C CMS pipeline (the endpoint receives a client-computed blob, and no other
tool computes one). Sessions are fine — POST …/hero-image is self-contained, so hero images can
be seeded with a script against Wave 2B. Mixes are not. As written, Wave 4's Mix acceptance has a
hidden dependency on Wave 3C.
Recommendation: adopt F1's redesign (server-side trigger) and the claim becomes true — a single ApiKey'd POST against Wave 2 generates real high-res data with no CMS in existence. If F1 is rejected, amend the dependency summary instead: "Wave 4 build is parallel with Wave 3; 9.4.D acceptance is gated on 9.3.C (or a manual datum-seeding script)." Either fix is acceptable; shipping the claim as-is is not — a parallelization promise that fails at acceptance time is the kind of roadmap debt that gets discovered mid-wave.
Findings — minor
F13 — HeroImagePath is an entry key wearing a path's name [minor — tighten]
§2.3's comment says it: "entry key in the image vault." MixMetadata calls its key
WaveformEntryKey; ReleaseEntity.ImagePath is a genuine path-ish field. Recommend
HeroImageEntryKey — the inconsistency will otherwise propagate into the DTO, the endpoint, and
the CMS form labels, and someone will eventually pass a URL into it.
What the spec gets right (keep these)
- The spine (§1, option C). Discriminator enum + optional 1:1 metadata tables is the correct shape, and the §1 comparison against the wide table and the type hierarchy is genuine analysis, not decoration. Base reads never join; medium reads pay one targeted join. Sound.
- Enum-driven CMS cards (§3.1) with an explicit prohibition on the three-arm markup switch — the right discipline stated at the right layer.
api/releaseas a new family (§4) rather than overloadingapi/track/page— correct SRP at the HTTP boundary, and the release-cardinality argument is the real reason, not just taste.- Additive-only migration with
Cut = 0default (§2.1) — zero data migration of existing rows. - The visualizer's position-binding seam designed now, seek deferred (§5.4) — textbook design for adaptability up front.
- Resolved §7.8 (hard single-track constraint) — kills a whole class of half-valid states the forms would otherwise have to police.
Verdict
The spine is SOLID-sound; the spec ships with one architectural defect and two honesty gaps. The discriminator-plus-optional-table design, the new API family, the scaffold composition, and the enum-driven CMS surfaces are all the right shapes and genuinely extension-friendly. But:
Must change before implementation begins (blocks Wave 2 design freeze):
- F1 — redesign
POST api/release/mix/waveformas a body-less server-side trigger (POST api/release/{id}/mix/waveform); the CMS-computes flow contradicts the verified existing pattern and is unreachable under the CMS's no-data-layer constraint. Also resolves the preprocessor-reuse open question (§7.3) and repairs the Wave 4 dependency claim (F12). - F9 —
ReleaseDto.ReleaseTypebecomes nullable, nulled at the mapping point for non-Cut. - F10 — correct the false EF check-constraint claim; document the
ReleaseType-on-base-table asymmetry as a named, reasoned exception (rejectedCutMetadata, hot-path join cost).
Should change before the relevant wave:
4. F4 — per-medium form-section components with one dispatch point, replacing five scattered
conditional blocks (Wave 3).
5. F11 — write the scaffold's contract (owns the invariant trio; variance rides slots; no
boolean layout parameters) and decide TrackDetail's fate explicitly (Wave 4).
6. F3 — rewrite §8 with the honest two-list extension cost.
7. F2, F6 semantics, F13 — small tightenings, cheap now, annoying later.
None of these findings disturb Daniel's four resolved decisions (§7.2, §7.5, §7.8, and the genre tab) — they all survive intact. F1 effectively answers open question §7.3 (one parameterized server-side pipeline) and strengthens §7.1's vault-blob recommendation; both still need Daniel's sign-off before the spec is amended.