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

399 lines
23 KiB
Markdown

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