# 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.:

```csharp
// 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` (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 cardinality** — *before* `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/upload` → `UploadAsync`). 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:

```csharp
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.
