Files
deepdrft/product-notes/phase-9-medium-cardinality-invariant.md
T

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.OnMediumChanged collapses the multi-track master list to one row for Session/Mix (landed Wave 3).
  • BatchEdit mirrors that collapse (landed §9.6.B).

Below the form there is no enforcement. Specifically:

  • UnifiedTrackService.UploadAsync resolves a release via FindOrCreateRelease, then calls _sqlTrackService.Create(trackDto) with the resolved ReleaseId. 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:

  1. 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: FindOrCreateRelease finds the existing Session release, Create adds a second track. The form collapse is bypassed because each upload is a separate request, not one multi-row batch.
  2. Any non-CMS API caller. POST api/track/upload is an ApiKey endpoint. A script, a future intake tool, or a re-run of a seeding job can POST a 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.CountLiveTracksByReleaseRepository.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 (or IsSingleTrack) — a future single-track medium is covered with no edit here.
  • General form, not just Max == 1. Although today only Max == 1 can be violated by a single add (Cut's Max is unbounded), express the guard as if (liveCount + 1) > cardinality.Max so 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:

  • Create does 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 UnifiedTrackService concern — it owns the two-store ordering. The cardinality check must run before the vault write to avoid orphaning, and only UnifiedTrackService sees 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:

  1. If album present: resolve-or-peek the release, read its medium + live count, reject here if the add would exceed cardinalitybefore AddTrackAsync writes the vault.
  2. Only then process + vault-write the audio.
  3. 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_track boolean) onto track. Then CREATE 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 UnifiedTrackService could violate the invariant — and rely on UnifiedTrackService being 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:

  1. 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.
  2. Single writer today. UnifiedTrackService is the only path that adds a track to a release. The "non-CMS caller" threat (§1.2) is also a caller of UnifiedTrackService (it goes through POST api/track/uploadUploadAsync). The bypass that (C2) defends against — a writer that skips UnifiedTrackService entirely — does not exist in the codebase. (C2) defends a door no one can currently walk through.
  3. 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.
  4. 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/BatchEdit cannot 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 TrackNew form), 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; the medium column was added with HasDefaultValue(ReleaseMedium.Cut), so every pre-existing release migrated to Cut (ReleaseConfiguration line 69).
  • Cut is many-track, so no migrated release can violate the cardinality rule.
  • Zero Session / Mix releases 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 BatchEdit should 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.CardinalityOf returns 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)

  1. Service-only vs. DB backstop (§3.4) — the real decision. Enforce the cardinality invariant in the UnifiedTrackService domain layer only (C3, recommended — consistent with the phase's ReleaseType precedent 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.

  2. 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.

  3. MediumRules placement. DeepDrftModels (recommended — referenced everywhere, owns ReleaseMedium, pure declaration) vs. a service-layer home. Recommend DeepDrftModels; flag only because it determines which project the CMS form and the API service both import the rule from.