399 lines
23 KiB
Markdown
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.
|