23 KiB
Phase 9 — Per-Medium Track-Cardinality Invariant (Wave 7)
Status: spec / design. Author: product-designer. Date: 2026-06-13. Plan only — no code edits made by this doc.
Cross-references: PLAN.md §9.7 (the concise wave entry this note backs),
product-notes/phase-9-release-medium-types.md (the medium taxonomy and the ReleaseType-only-for-Cut
invariant this note generalises from), COMPLETED.md §9.5.A (the first-upload-authoritative write path
whose gap this closes), COMPLETED.md §9.6.B (the form-level single-track collapse this note demotes
to a UI convenience over a real invariant). Memory: One source, multiple views,
Design for adaptability up front.
1. The gap, stated plainly
Phase 9 resolved (open question §8, phase-9-release-medium-types.md) that Session and Mix are
single-track media — one track per release — and Cut is many-track. Daniel ruled this a hard
constraint, not a soft preference.
But the constraint is enforced only in the CMS form layer today:
BatchUpload.OnMediumChangedcollapses the multi-track master list to one row for Session/Mix (landed Wave 3).BatchEditmirrors that collapse (landed §9.6.B).
Below the form there is no enforcement. Specifically:
UnifiedTrackService.UploadAsyncresolves a release viaFindOrCreateRelease, then calls_sqlTrackService.Create(trackDto)with the resolvedReleaseId. Neither path checks how many tracks the release already holds, nor whether its medium permits more than one.- §9.5.A made the first upload authoritative for a release's medium: a second upload carrying the
same
(title, artist)links to the existing release and its medium is not re-evaluated. That is correct for the medium value — but it means a second track can be linked to a Session or Mix release with no cardinality gate.
Two concrete ways to reach a multi-track Session/Mix today:
- Repeated single-track uploads. Upload track 1 as a Session (CMS sends
medium=session, creates the release). Upload track 2 with the same album+artist — the CMS form would not offer this for a Session, but the upload endpoint accepts it:FindOrCreateReleasefinds the existing Session release,Createadds a second track. The form collapse is bypassed because each upload is a separate request, not one multi-row batch. - Any non-CMS API caller.
POST api/track/uploadis an ApiKey endpoint. A script, a future intake tool, or a re-run of a seeding job canPOSTa second track at an existing Session/Mix release directly. The form layer is not in the path at all.
The form collapse is a good authoring affordance — it stops the common case at the friendliest point. But it is a UI convention dressed as an invariant. The invariant Daniel asked for must live at the domain model level, where every writer passes.
This note specs that hardening.
2. The rule, generalised — medium → cardinality as first-class data
Frame per-medium track-count not as three special cases but as a declared property of each medium, in exactly the "one table per concern, no scattered switch" discipline Phase 9 already established for medium → card, medium → projection, medium → route.
2.1 The cardinality declaration
A single lookup, keyed by ReleaseMedium, declaring each medium's allowed track count as a range:
Cut → 1..N (many-track: Single is 1, EP/Album are many)
Session → 1..1 (exactly one)
Mix → 1..1 (exactly one)
Expressed as a small value type + a single source-of-truth lookup, e.g.:
// DeepDrftModels/Enums/ — sibling to ReleaseMedium, the medium's structural metadata
public readonly record struct MediumCardinality(int Min, int Max) // Max = int.MaxValue for unbounded
{
public bool Allows(int trackCount) => trackCount >= Min && trackCount <= Max;
public bool IsSingleTrack => Max == 1;
}
public static class MediumRules
{
private static readonly IReadOnlyDictionary<ReleaseMedium, MediumCardinality> Cardinalities =
new Dictionary<ReleaseMedium, MediumCardinality>
{
[ReleaseMedium.Cut] = new(1, int.MaxValue),
[ReleaseMedium.Session] = new(1, 1),
[ReleaseMedium.Mix] = new(1, 1),
};
public static MediumCardinality CardinalityOf(ReleaseMedium medium) => Cardinalities[medium];
}
Why a lookup, not a three-arm switch buried in UnifiedTrackService: the same rule is consumed
in at least three places — the upload service (reject an over-limit add), the CMS form (decide whether
to collapse the master list), and potentially the BatchEdit warning path (§9.6.B's "release holds N,
should hold 1" reconciliation). Three consumers of one rule is precisely the scattered-switch smell
the phase forbids. One declaration, three readers. A future medium declares its cardinality in one
place and every consumer honours it automatically — the same Open/Closed payoff the rest of Phase 9
is built around. The CMS form collapse (OnMediumChanged) should also be refactored to read
MediumRules.CardinalityOf(medium).IsSingleTrack rather than its current hardcoded
medium is Session or Mix test, so the form and the domain agree by construction (see §5).
Placement of MediumRules: DeepDrftModels is the natural home — it is referenced by every
project, it already owns ReleaseMedium, and the rule is a pure declaration with no dependencies.
This keeps the rule visible to the CMS form layer (DeepDrftManager) and the API service layer
(DeepDrftAPI) from one shared origin, with no duplication.
2.2 What "track count" means here
The count is live tracks on the release (not soft-deleted). CountLiveTracksByRelease already
exists on ITrackService / TrackManager — it backs the delete-cascade's empty-release cleanup
(UnifiedTrackService.TrySoftDeleteEmptyReleaseAsync). The cardinality check reuses it; no new
counting primitive is needed. (Verified: TrackManager.CountLiveTracksByRelease →
Repository.CountLiveTracksByReleaseAsync.)
3. Where enforcement should live — the layered options
Three candidate layers, not mutually exclusive. The real decision is service-only vs. service + DB backstop (§3.4, the open question).
3.1 Layer A — the upload service (the load-bearing layer)
UnifiedTrackService.UploadAsync is where the second-track-add becomes reachable, so it is the
primary enforcement point. After FindOrCreateRelease returns an existing release (the find
path, not the create path — a freshly created release has zero tracks and cannot violate), and before
Create, gate the add:
release resolved (existing) →
medium = release.Medium
cardinality = MediumRules.CardinalityOf(medium)
if cardinality.Max == 1:
liveCount = CountLiveTracksByRelease(release.Id)
if liveCount >= 1:
return ResultContainer<TrackDto>.CreateFailResult(
"A {medium} release holds a single track; '{release.Title}' already has one.")
// ⚠ and DO NOT orphan the vault write — see §3.3
Notes:
- Only the find path needs the check. The create path (release did not exist) yields a 0-track
release; the first track is always within
1..1. Checking only the find branch keeps the cost off the common first-upload path. - The check is medium-driven, not Session/Mix-hardcoded.
cardinality.Max == 1(orIsSingleTrack) — a future single-track medium is covered with no edit here. - General form, not just
Max == 1. Although today onlyMax == 1can be violated by a single add (Cut'sMaxis unbounded), express the guard asif (liveCount + 1) > cardinality.Maxso a future bounded-but-not-single medium (say a hypothetical "Split" capped at 2) is covered by the same line. Cheap generality, honours Design for adaptability up front.
3.2 Layer B — TrackManager.Create (rejected as the primary home, noted for completeness)
One could push the check into TrackManager.Create / FindOrCreateRelease so the SQL service itself
refuses. Tempting (it is "the domain model"), but:
Createdoes not today know the release's medium without an extra read, and it is called on the loose-track path (no release) too. Threading cardinality through it muddies a method that is currently medium-agnostic.- The orphaned-vault-write concern (§3.3) is a
UnifiedTrackServiceconcern — it owns the two-store ordering. The cardinality check must run before the vault write to avoid orphaning, and onlyUnifiedTrackServicesees both stores. That argues for the check living in the orchestrator, not the SQL service.
Recommendation: enforce in UnifiedTrackService, not TrackManager. The orchestrator is the
true domain boundary for a track-add-to-a-release operation; TrackManager.Create is a lower-level
SQL primitive.
3.3 Ordering — check before the vault write, or you trade a bad row for an orphan
UploadAsync today does: vault write (AddTrackAsync) → resolve release → SQL Create. If the
cardinality check sits where §3.1 places it (after release resolution), the vault write has already
happened — a rejection there orphans the audio in the tracks vault, the exact failure mode §4.3
(Dual-write rollback / dead-letter log) exists to worry about.
Move the cardinality pre-check earlier. The check needs only (album, artist) to resolve the
release and read its medium + count — none of which require the audio to be in the vault. Restructure
UploadAsync to:
- If
albumpresent: resolve-or-peek the release, read its medium + live count, reject here if the add would exceed cardinality — beforeAddTrackAsyncwrites the vault. - Only then process + vault-write the audio.
- Then
Create.
This is a real change to the method's shape (today resolution happens after the vault write). It is worth it: the alternative is a guaranteed orphan on every rejected over-limit upload. Flag for the implementer that the resolve-before-vault-write reordering is part of this wave, not an afterthought.
Subtlety: "resolve" on the pre-check should be a read, not a create — a GET-shaped
GetReleaseByTitleAndArtist peek, so the pre-check does not create a release for an upload it is
about to reject. The actual FindOrCreateRelease still runs at its normal point for the accepted
path. (GetReleaseByTitleAndArtistAsync already exists on the repository — verified, it backs
FindOrCreateRelease's own find.)
3.4 Layer C — DB backstop (the open question)
A database-level guarantee so even a writer that bypasses UnifiedTrackService (a raw SQL insert, a
future second service, a botched migration) cannot create a multi-track Session/Mix. The natural shape:
A partial unique index on
track(release_id)filtered to releases whose medium is single-track.
The catch: the filter predicate references release.medium, which lives on a different table
(release, not track). Postgres partial-index predicates cannot reference another table. So the
clean "partial unique index" is not directly expressible. Three real options for a DB backstop:
- (C1) Denormalise medium (or a
is_single_trackboolean) ontotrack. ThenCREATE UNIQUE INDEX ... ON track(release_id) WHERE is_single_track. Cost: a denormalised column kept in sync with the release's medium (a trigger or app-level write discipline) — the kind of duplication Phase 8's normalization fought. Not recommended — the cure reintroduces the disease. - (C2) A deferrable constraint trigger (
CREATE CONSTRAINT TRIGGER) that counts sibling tracks on insert/update and raises if a single-track release would exceed one. Expressible, enforced at the DB, no denormalised column. Cost: a hand-written PL/pgSQL trigger in a migration — a maintenance surface EF does not model, and a thing the team must remember exists. Honest but heavy. - (C3) No DB backstop. Service-layer enforcement only, documented as the single chokepoint, with
a test asserting it. Accept that a writer bypassing
UnifiedTrackServicecould violate the invariant — and rely onUnifiedTrackServicebeing the only track-add path (it is, today).
The phase's documented stance favours domain rules over DB constraints — the
ReleaseType-only-for-Cut invariant chose service enforcement over HasCheckConstraint by choice,
not necessity (phase-9-release-medium-types.md §1). That precedent points at (C3).
But Daniel said "domain model level," which is genuinely ambiguous between:
- "Make it a real domain invariant rather than a UI convention" — which (C3) satisfies: the domain service is the model's gatekeeper, the rule is declared as data, every domain writer passes it. The DB is not the domain model; the service layer is.
- "Push it down to the storage layer so it cannot be violated" — which argues for (C2).
Recommendation: (C3) — service-layer enforcement, no DB backstop — but flag (C2) as a deliberate deferral, not an oversight. Reasons:
- Consistency with the phase's own ruling.
ReleaseType-only-for-Cut is exactly this shape (an advisory-vs-storage invariant) and Phase 9 already chose the service layer, in writing, with a named rationale. Choosing the DB here would split the phase's philosophy down the middle. - Single writer today.
UnifiedTrackServiceis the only path that adds a track to a release. The "non-CMS caller" threat (§1.2) is also a caller ofUnifiedTrackService(it goes throughPOST api/track/upload→UploadAsync). The bypass that (C2) defends against — a writer that skipsUnifiedTrackServiceentirely — does not exist in the codebase. (C2) defends a door no one can currently walk through. - The migration is free either way (§6). There is no violating data, so (C2) could be added later with zero data cleanup if a second writer ever appears. Deferring it costs nothing; adding it now buys protection against a hypothetical.
- EF-modelled vs. hand-rolled. (C2) is a raw PL/pgSQL trigger EF does not track — a standing maintenance and "why does this exist" cost. The phase has been disciplined about not adding storage-layer machinery the ORM cannot see.
This is the one genuine Daniel decision in the wave. It is not an implementation detail — it is a philosophy call about where the system's structural truth lives. Surface it explicitly (§8, Q1).
4. Behaviour on violation
4.1 API layer
The upload service returns a NetBlocks failure result — the existing pattern, no new mechanism:
return ResultContainer<TrackDto>.CreateFailResult(
$"A {medium} release holds a single track. '{title}' already has one — " +
"edit the existing track or choose a different release.");
TrackController.UploadTrack already maps a failed ResultContainer to a non-200 response. The
cardinality rejection is a client error (the request is well-formed but violates a rule), so a
409 Conflict is the honest status — distinct from the 400 used for malformed input and the
500 used for processing failure. Recommend the controller distinguish the cardinality failure as
409; if that is more plumbing than the wave wants, a 400 with the clear message is acceptable
(the message carries the meaning either way). Minor — flag, do not block on it.
4.2 CMS presentation
The CMS already surfaces upload failures (the ResultContainer message bubbles to the form). For the
cardinality rejection specifically:
- The common case never reaches the API — the form collapse (§5) means an admin authoring a
Session/Mix through
BatchUpload/BatchEditcannot assemble a multi-track payload in the first place. The API rejection is the backstop for the paths the form does not cover (repeated separate single uploads, scripted callers). - When the rejection does surface (e.g. an admin uploads a second single track to an existing
Session via the single-track
TrackNewform), show the failure message inline on the upload form, same channel as any other upload error. No bespoke UI — the message is self-explanatory.
5. Relationship to the existing rules — fold the form collapse, leave ReleaseType alone
Two adjacent rules already exist. Recommendation on each:
5.1 Form-level single-track collapse (§9.6.B) — fold into the cardinality rule
BatchUpload.OnMediumChanged and BatchEdit currently test medium is Session or Mix (or
equivalent) to decide whether to collapse the master list. Refactor both to read
MediumRules.CardinalityOf(medium).IsSingleTrack. This makes the form a consumer of the same
declaration the service enforces, so the two cannot drift — the day a fourth single-track medium is
added, the form collapses for it automatically and the service rejects over-limit adds for it
automatically, from one edited line (MediumRules). This is the One source, multiple views
preference applied to a rule rather than a view: one cardinality declaration, read by the form (to
shape the UI) and the service (to enforce the limit).
This is a refactor of landed code, so it is in-scope for the wave only as a consume-the-new-rule change — the behaviour is unchanged, the source of the truth moves. Frame it that way in the PLAN item so it is not mistaken for re-litigating §9.6.B.
5.2 ReleaseType-only-for-Cut — leave separate, do not merge
Tempting to declare a grand "MediumRules owns every per-medium structural invariant" — both the cardinality and the ReleaseType-applicability rule. Recommend against merging them into one mechanism. They are different kinds of rule:
- Cardinality is a count constraint on a relationship (tracks-per-release).
- ReleaseType-applicability is a field-relevance rule (which columns are meaningful for which medium).
Forcing both into one abstraction would produce a MediumRules that is half "how many tracks" and
half "which fields apply" — a grab-bag keyed by medium, not a cohesive concept. They can both live
in the MediumRules static class as separate, clearly-named members (CardinalityOf(...) and,
if ever wanted, UsesReleaseType(...)) without being a single fused rule. But do not build a generic
"medium invariant engine." Two named rules sharing a home is fine; one rule pretending to be both is
the over-abstraction trap. (Today ReleaseType-applicability is enforced at the converter + the
controller reset and works; this wave should not disturb it. Only cardinality is new.)
Net: fold the form collapse into the cardinality rule (they are the same rule at two layers);
keep ReleaseType-applicability as its own thing, co-located but not merged.
6. Migration / back-compat reality (code-verified, stated honestly)
There is no violating data. Verified this session:
- Phase 9 is a single unmerged feature on
dev; themediumcolumn was added withHasDefaultValue(ReleaseMedium.Cut), so every pre-existing release migrated toCut(ReleaseConfigurationline 69). Cutis many-track, so no migrated release can violate the cardinality rule.- Zero
Session/Mixreleases exist with multiple tracks (none were authored before the rule, and the only authoring path — the CMS form — already collapses them).
Consequences:
- A DB backstop (C2), if chosen, can be added with no data-cleanup migration — no pre-flight query for offending rows, no remediation step. The constraint goes on clean.
- The service-layer check (C3) likewise has no historical data to reconcile.
- The §9.6.B open question (how
BatchEditshould render an existing multi-track Session/Mix) is moot in practice today — no such release exists — but its safe-reconciliation reasoning still stands as defensive design and should not be removed. The cardinality enforcement here makes the hypothetical even less reachable: the only way to create a multi-track Session/Mix is the gap this wave closes.
This is the honest contrast to an earlier mistaken belief that a DB constraint already rejects this:
it does not. ReleaseConfiguration has a unique index on (title, artist) and the is_deleted
index — no cardinality or medium-correlated constraint exists today. Closing that absence is the
whole point of the wave.
7. Test coverage this wave should carry
MediumWritePathTests (the §9.5 fixture, EF in-memory) is the natural home. The cardinality rule is
exactly the kind of pure SQL-layer predicate that fixture already exercises. Add:
- A Session release with one track rejects a second track-add (the find-path-over-limit case).
- A Mix release with one track rejects a second track-add.
- A Cut release accepts a second (and Nth) track-add (the many-track path is unaffected).
- The first track on a new Session/Mix release succeeds (create-path, 0→1 is within
1..1). MediumRules.CardinalityOfreturns the declared ranges for each medium (guards the declaration).
The orphan-avoidance reordering (§3.3) is a UnifiedTrackService behaviour and harder to unit-test in
isolation (it spans the vault + SQL), but at minimum the pre-check rejects before any vault write
should be asserted if the test seam allows — otherwise call it out as an integration gap.
This sits inside the project-wide "test coverage outside FileDatabase" backlog item (PLAN.md
miscellaneous) — the cardinality tests are an attached cost of this wave, not a separate initiative.
8. Open questions (need Daniel before build)
-
Service-only vs. DB backstop (§3.4) — the real decision. Enforce the cardinality invariant in the
UnifiedTrackServicedomain layer only (C3, recommended — consistent with the phase'sReleaseTypeprecedent and the single-writer reality), or also add a Postgres constraint-trigger backstop (C2) so a future writer that bypasses the service cannot violate it? Recommend C3, defer C2 as a free-to-add-later option — the migration is clean either way (§6), so deferring costs nothing and adding now defends a door no caller can currently walk through. This is a philosophy call about where structural truth lives, not an implementation detail — it is Daniel's. -
Violation HTTP status (§4.1).
409 Conflict(honest — well-formed request, rule violation) vs.400 Bad Request(less plumbing, message still carries the meaning). Recommend 409 if cheap, accept 400 otherwise — minor, should not block the wave. -
MediumRulesplacement.DeepDrftModels(recommended — referenced everywhere, ownsReleaseMedium, pure declaration) vs. a service-layer home. RecommendDeepDrftModels; flag only because it determines which project the CMS form and the API service both import the rule from.