feat(db): let a waiver name the rule it relaxed

`releases` forbade a rule name on anything but a rejection, so §9.3's
deck showed a bare `waived` beside rejections that each named their own,
and §5.7's "watchable but not what was asked" lost the half that says
what was not asked for. Since #210 that is the ordinary outcome of
waiving a size rejection, not a rare one.

0032 rebuilds the table with `CHECK (verdict != 'rejected' OR
rejected_rule IS NOT NULL)`, and the daemon and arr-api's
reclassification both store the waived rule. Existing rows keep NULL and
read as they do today.

`releases` is a parent — `grabs`, `movie_releases`, `episode_releases`
and `season_releases` point at it, three ON DELETE CASCADE — so the
rebuild runs `-- no-transaction` with foreign keys off around one
explicit transaction, per SQLite's own procedure. Verified against a
real database: the pre-0032 binary created and populated it, this build
migrated a copy, and every release row, child row and created_at came
through byte-identical with `PRAGMA foreign_key_check` clean.

Refs #211
This commit is contained in:
Miguel Palhas
2026-08-25 11:42:59 +01:00
parent c454b3ec11
commit a0dc07f085
6 changed files with 281 additions and 8 deletions
+10
View File
@@ -76,6 +76,12 @@ pub struct Release {
pub parsed: serde_json::Value,
pub score: Option<f64>,
pub verdict: Option<String>,
// The rule behind the verdict: the one that killed a `rejected` row, or
// the one a `waived` row relaxed (#211). Null on an `eligible` row, and
// on a `waived` row stored before migration 0032, which could not record
// it. A plain comment, not a doc comment: doc comments here become
// OpenAPI descriptions and would put the generated client in web/ out of
// date, which #227 and #232 own.
pub rejected_rule: Option<String>,
}
@@ -1303,6 +1309,10 @@ mod tests {
.expect("releases json");
assert_eq!(releases.len(), 1);
assert_eq!(releases[0]["verdict"], "waived");
// #211: and it says which rule the waiver relaxed, rather than
// sitting in the deck as a bare `waived` beside rejections that each
// name their own.
assert_eq!(releases[0]["rejected_rule"], "size");
}
/// #241: moving a title to a root with a different policy re-derives its
+4 -4
View File
@@ -208,11 +208,11 @@ async fn apply(
episodes,
runtime_minutes,
);
// `releases` allows a rule name only on a rejected row
// (`CHECK ((verdict = 'rejected') = (rejected_rule IS NOT NULL))`),
// which is also how the daemon writes a waiver.
// A waiver records the rule it relaxed, the same as a rejection
// (#211). Migration 0032 relaxed `releases` to
// `CHECK (verdict != 'rejected' OR rejected_rule IS NOT NULL)` so it
// can, and the daemon writes waivers the same way.
let (verdict, rule) = verdict_columns(&evaluation.verdict);
let rule = if verdict == "rejected" { rule } else { None };
if release.verdict.as_deref() == Some(verdict) && release.rejected_rule == rule {
continue;
}
+4 -3
View File
@@ -2014,9 +2014,10 @@ mod tests {
releases[0]["verdict"], "waived",
"a waived grab stays a waiver; nothing here makes it eligible"
);
// The row's rule name goes with the rejection; what survives is the
// dashed `waived` verdict the deck reads (§9.3).
assert!(releases[0]["rejected_rule"].is_null());
// #211: the waiver keeps the rule it relaxed, so the deck names it
// the way it names a rejection (§9.3) instead of showing a bare
// `waived`.
assert_eq!(releases[0]["rejected_rule"], "size");
// The grab the deck's one click sends is now accepted.
let response = reqwest::Client::new()