Withdraw the file-hash tier; document the legal posture
CI / static musl binary (push) Has been skipped
CI / fmt, clippy, test (push) Failing after 2m0s
CI / advisories and licences (push) Successful in 27s

Removes `cut.video_hash` and the `exact` match tier on legal grounds. The
OpenSubtitles hash was the strongest technical signal available — it identifies
a specific file, so it cannot produce a false positive — and that is exactly
the problem.

Every tier must be a claim about a *cut*, never about a copy. A TMDB id
discloses "some copy of this film", which is what a library catalogue
discloses. A file hash discloses "this exact release": it made a read endpoint
into a release-level oracle, and made an instance's database a mapping from
file fingerprints to the instances holding them. That is a far more specific
disclosure than PR-005 permits, and a dataset no volunteer operator should be
asked to hold. The audio signature is the replacement: derived from content, it
identifies the cut rather than the copy, so two encodes of the same edit agree.

The field is deleted rather than kept as a vestigial null, on the same
reasoning §2 applied to `anneal_sec` — a key naming a signal the format no
longer has is actively misleading — so an upload carrying one is now an
unknown-field 400, with a test asserting it.

**Every content_id changes**, including for manifests that never carried a
hash, because the canonical `cut` object lost a key. The golden vector is
regenerated and re-verified against an independent Python implementation; the
plugin and extraction repos must adopt the new value or federation
deduplication silently breaks. Free now, pre-release; not free later.

Adds docs/legal-posture.md, the operator-facing half of what §5a asks for:
what an instance holds exhaustively, what it structurally cannot do, and how
that sits against the intermediary-liability regimes that plausibly apply.

208 tests. Coverage 25/32.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

TRACES: UR-011 | SR-004, PR-005
This commit is contained in:
2026-07-31 09:52:03 +02:00
co-authored by Claude Opus 5
parent 545c7d92a2
commit 0ff1018bcc
17 changed files with 779 additions and 289 deletions
+17 -31
View File
@@ -20,7 +20,6 @@ pub struct ManifestRow {
pub season: Option<i64>,
pub episode: Option<i64>,
pub runtime_sec: f64,
pub video_hash: Option<String>,
pub audio_signature: Option<Vec<u8>>,
pub sample_fps: Option<f64>,
pub extinction_sec: Option<f64>,
@@ -31,7 +30,7 @@ pub struct ManifestRow {
pub content_id: Option<String>,
}
const MANIFEST_COLUMNS: &str = "id, title_id, season, episode, runtime_sec, video_hash, \
const MANIFEST_COLUMNS: &str = "id, title_id, season, episode, runtime_sec, \
audio_signature, sample_fps, extinction_sec, pipeline_version, gallery_scope, \
status, cast_match_ratio, \
content_id";
@@ -43,15 +42,14 @@ fn map_manifest(row: &rusqlite::Row<'_>) -> rusqlite::Result<ManifestRow> {
season: row.get(2)?,
episode: row.get(3)?,
runtime_sec: row.get(4)?,
video_hash: row.get(5)?,
audio_signature: row.get(6)?,
sample_fps: row.get(7)?,
extinction_sec: row.get(8)?,
pipeline_version: row.get(9)?,
gallery_scope: row.get(10)?,
status: row.get(11)?,
cast_match_ratio: row.get(12)?,
content_id: row.get(13)?,
audio_signature: row.get(5)?,
sample_fps: row.get(6)?,
extinction_sec: row.get(7)?,
pipeline_version: row.get(8)?,
gallery_scope: row.get(9)?,
status: row.get(10)?,
cast_match_ratio: row.get(11)?,
content_id: row.get(12)?,
})
}
@@ -283,24 +281,21 @@ pub fn duplicate_from_contributor(
season: Option<i64>,
episode: Option<i64>,
runtime_sec: f64,
video_hash: Option<&str>,
contributor_id: &str,
) -> anyhow::Result<Option<String>> {
let mut stmt = tx.prepare_cached(
"SELECT id FROM manifests
WHERE title_id = ?1 AND contributor_id = ?6
WHERE title_id = ?1 AND contributor_id = ?5
AND status <> 'rejected'
AND ( (?2 IS NULL AND season IS NULL) OR season = ?2 )
AND ( (?3 IS NULL AND episode IS NULL) OR episode = ?3 )
AND ABS(runtime_sec - ?4) < 0.001
AND ( (?5 IS NULL AND video_hash IS NULL) OR video_hash = ?5 )
LIMIT 1",
)?;
Ok(stmt
.query_row(
params![title_id, season, episode, runtime_sec, video_hash, contributor_id],
|r| r.get::<_, String>(0),
)
.query_row(params![title_id, season, episode, runtime_sec, contributor_id], |r| {
r.get::<_, String>(0)
})
.optional()?)
}
@@ -319,7 +314,6 @@ pub struct NewManifest<'a> {
pub season: Option<i64>,
pub episode: Option<i64>,
pub runtime_sec: f64,
pub video_hash: Option<&'a str>,
pub audio_signature: Option<&'a [u8]>,
pub audio_sig_coarse: Option<&'a [u8]>,
pub sample_fps: Option<f64>,
@@ -338,18 +332,17 @@ pub struct NewManifest<'a> {
pub fn insert_manifest(tx: &Transaction<'_>, m: &NewManifest<'_>) -> anyhow::Result<()> {
tx.execute(
"INSERT INTO manifests
(id, title_id, season, episode, runtime_sec, video_hash,
(id, title_id, season, episode, runtime_sec,
audio_signature, audio_sig_coarse, sample_fps, extinction_sec, pipeline_version,
gallery_scope, contributor_id, status, content_id, origin, ingested_from,
created_at)
VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16,?17,?18)",
VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16,?17)",
params![
m.id,
m.title_id,
m.season,
m.episode,
m.runtime_sec,
m.video_hash,
m.audio_signature,
m.audio_sig_coarse,
m.sample_fps,
@@ -840,7 +833,6 @@ mod tests {
season: None,
episode: None,
runtime_sec: 6420.5,
video_hash: Some("opensubtitles:8e245d9679d31e12"),
audio_signature: None,
audio_sig_coarse: None,
sample_fps: Some(5.0),
@@ -903,7 +895,6 @@ mod tests {
season: None,
episode: None,
runtime_sec: 100.0,
video_hash: None,
audio_signature: None,
audio_sig_coarse: None,
sample_fps: None,
@@ -949,7 +940,6 @@ mod tests {
season: None,
episode: None,
runtime_sec: 6420.5,
video_hash: None,
audio_signature: None,
audio_sig_coarse: None,
sample_fps: None,
@@ -964,11 +954,10 @@ mod tests {
created_at: NOW,
},
)?;
let same = duplicate_from_contributor(tx, &title_id, None, None, 6420.5, None, &a)?;
let same = duplicate_from_contributor(tx, &title_id, None, None, 6420.5, &a)?;
// §7: multiple manifests for the same cut from *different*
// contributors are allowed, and ranked.
let other =
duplicate_from_contributor(tx, &title_id, None, None, 6420.5, None, &b)?;
let other = duplicate_from_contributor(tx, &title_id, None, None, 6420.5, &b)?;
Ok((same.is_some(), other.is_some()))
})
.await
@@ -1002,7 +991,6 @@ mod tests {
season: None,
episode: None,
runtime_sec: 100.0,
video_hash: None,
audio_signature: None,
audio_sig_coarse: None,
sample_fps: None,
@@ -1108,7 +1096,6 @@ mod tests {
season: None,
episode: None,
runtime_sec: 100.0,
video_hash: None,
audio_signature: None,
audio_sig_coarse: None,
sample_fps: None,
@@ -1402,7 +1389,6 @@ mod federation_tests {
season: None,
episode: None,
runtime_sec: 100.0,
video_hash: None,
audio_signature: None,
audio_sig_coarse: None,
sample_fps: None,
+2 -2
View File
@@ -41,7 +41,8 @@ CREATE TABLE IF NOT EXISTS manifests (
season INTEGER,
episode INTEGER,
runtime_sec REAL NOT NULL,
video_hash TEXT,
-- No video_hash: withdrawn (§3). It fingerprinted an individual file rather
-- than a cut, which is the one thing this schema deliberately cannot record.
audio_signature BLOB, -- §3, ~1290 bytes
audio_sig_coarse BLOB, -- candidate-generation index key
sample_fps REAL,
@@ -160,7 +161,6 @@ CREATE INDEX IF NOT EXISTS idx_federation_log_content ON federation_log(content_
CREATE INDEX IF NOT EXISTS idx_titles_tmdb ON titles(tmdb_id);
CREATE INDEX IF NOT EXISTS idx_titles_imdb ON titles(imdb_id);
CREATE INDEX IF NOT EXISTS idx_manifests_title_runtime ON manifests(title_id, runtime_sec);
CREATE INDEX IF NOT EXISTS idx_manifests_video_hash ON manifests(video_hash);
CREATE INDEX IF NOT EXISTS idx_manifests_episode ON manifests(title_id, season, episode);
CREATE INDEX IF NOT EXISTS idx_scenes_manifest_person ON scenes(manifest_id, tmdb_person_id);