Ship the SR-003 schema bump: jmanifest_version 2
CI / fmt, clippy, test (push) Failing after 1m22s
CI / static musl binary (push) Has been skipped
CI / advisories and licences (push) Successful in 26s

Moves the exchange envelope to version 2 in lockstep with the truth file's
schema_version, per SR-003's requirement that breaking changes be batched and
ship together rather than piecemeal. The plugin had already moved to
schema_version 2; the server declaring 1 while accepting the new fields
defeated the point of having a version at all.

Flag day, not dual-accept (JR-003): version 1 is now rejected outright. All
three components are pre-release, and a v1 read path would be the one nobody
exercises, so it is the one that would rot while being dragged through every
later change to the reader. A pipeline still emitting v1 is incompatible until
updated — stated plainly rather than papered over with a shim nobody tests.

scenes become objects carrying belief and route (extraction AR-017) instead of
float pairs. Belief is bounded to [0, 1] rather than merely stored: §5a's
Threat 1 argument rests on every accepted value being bounded, and an unbounded
float is a 64-bit channel however harmless it looks. route is a closed enum, so
an invented value cannot be stored.

UR-018 is the requirement with the trap in it, and the reason content_id.rs is
untouched by this commit: belief is a producer-side estimate that may
legitimately differ between pipeline versions for identical timings, so
including it in the canonical form would give two servers different ids for the
same content — the exact failure mode §9a quantises centiseconds to avoid,
reintroduced one field along. It replicates as an attribute, exactly as
audio_signature does. The golden vector still passes unchanged, which is the
evidence rather than the claim.

191 tests. UR-015..018 move from Planned to Done; coverage 24/32 (75%).

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

TRACES: UR-014, UR-015, UR-016, UR-017, UR-018 | SR-003
This commit is contained in:
2026-07-31 09:13:14 +02:00
co-authored by Claude Opus 5
parent c73f417d45
commit 88c7264094
12 changed files with 494 additions and 179 deletions
+72 -18
View File
@@ -164,8 +164,16 @@ pub fn compute_content_id(valid: &ValidManifest) -> String {
.actor_scenes_cs
.iter()
.filter_map(|a| {
a.tmdb_id
.map(|id| CanonicalActor { tmdb_person_id: id, scenes_cs: a.scenes_cs.clone() })
a.tmdb_id.map(|id| CanonicalActor {
tmdb_person_id: id,
// Timings only. §9a excludes belief and route from identity:
// they are producer-side estimates that may differ between
// pipeline versions for identical content, so hashing them
// would give two servers different ids for the same
// manifest — the failure mode centisecond quantisation
// exists to remove. They replicate as attributes instead.
scenes_cs: a.scenes_cs.iter().map(|s| (s.start_cs, s.end_cs)).collect(),
})
})
.collect();
@@ -188,12 +196,12 @@ mod tests {
fn movie_json(tmdb: &str, runtime: f64) -> String {
format!(
r#"{{"jmanifest_version":1,
r#"{{"jmanifest_version":2,
"identity":{{"type":"movie","tmdb_id":"{tmdb}","title":"A Film"}},
"cut":{{"runtime_sec":{runtime}}},
"extraction":{{"sample_fps":5,"pipeline_version":"test 0.1"}},
"actors":[{{"name":"Steve Buscemi","tmdb_id":"884","scenes":[[10.0,20.0]]}},
{{"name":"Michael Palin","tmdb_id":"11007","scenes":[[30.0,40.0]]}}]}}"#
"actors":[{{"name":"Steve Buscemi","tmdb_id":"884","scenes":[{{"start":10.0,"end":20.0}}]}},
{{"name":"Michael Palin","tmdb_id":"11007","scenes":[{{"start":30.0,"end":40.0}}]}}]}}"#
)
}
@@ -270,10 +278,10 @@ mod tests {
// so this exercises the per-contributor 409 path specifically.
let a = valid_from(&movie_json("504172", 6420.5));
let b = valid_from(
r#"{"jmanifest_version":1,
r#"{"jmanifest_version":2,
"identity":{"type":"movie","tmdb_id":"504172","title":"A Film"},
"cut":{"runtime_sec":6420.5},
"actors":[{"name":"Steve Buscemi","tmdb_id":"884","scenes":[[11.0,21.0]]}]}"#,
"actors":[{"name":"Steve Buscemi","tmdb_id":"884","scenes":[{"start":11.0,"end":21.0}]}]}"#,
);
let second = db
@@ -315,11 +323,11 @@ mod tests {
async fn episode_manifests_carry_their_coordinates() {
let db = Db::open(":memory:").unwrap();
let valid = valid_from(
r#"{"jmanifest_version":1,
r#"{"jmanifest_version":2,
"identity":{"type":"episode","series_tmdb_id":"1396","title":"Breaking Bad",
"season":2,"episode":5},
"cut":{"runtime_sec":2820.0},
"actors":[{"name":"Bryan Cranston","tmdb_id":"17419","scenes":[[10.0,20.0]]}]}"#,
"actors":[{"name":"Bryan Cranston","tmdb_id":"17419","scenes":[{"start":10.0,"end":20.0}]}]}"#,
);
let row = db
.write(move |tx| {
@@ -357,13 +365,13 @@ mod tests {
// servers validating the same upload agree.
let a = valid_from(&movie_json("504172", 6420.5));
let b = valid_from(
r#"{"jmanifest_version":1,
r#"{"jmanifest_version":2,
"identity":{"type":"movie","tmdb_id":"504172","title":"A Film"},
"cut":{"runtime_sec":6420.5},
"extraction":{"sample_fps":1,"extinction_sec":9,"pipeline_version":"other 9.9",
"gallery_size":5},
"actors":[{"name":"Steve Buscemi","tmdb_id":"884","scenes":[[10.0,20.0]]},
{"name":"Michael Palin","tmdb_id":"11007","scenes":[[30.0,40.0]]}]}"#,
"actors":[{"name":"Steve Buscemi","tmdb_id":"884","scenes":[{"start":10.0,"end":20.0}]},
{"name":"Michael Palin","tmdb_id":"11007","scenes":[{"start":30.0,"end":40.0}]}]}"#,
);
assert_eq!(compute_content_id(&a), compute_content_id(&b));
}
@@ -375,29 +383,75 @@ mod tests {
let a = valid_from(&movie_json("504172", 6420.5));
let sig = format!("v1:{}", "A".repeat(1720));
let with_sig = format!(
r#"{{"jmanifest_version":1,
r#"{{"jmanifest_version":2,
"identity":{{"type":"movie","tmdb_id":"504172","title":"A Film"}},
"cut":{{"runtime_sec":6420.5,"audio_signature":"{sig}"}},
"extraction":{{"sample_fps":5,"pipeline_version":"test 0.1"}},
"actors":[{{"name":"Steve Buscemi","tmdb_id":"884","scenes":[[10.0,20.0]]}},
{{"name":"Michael Palin","tmdb_id":"11007","scenes":[[30.0,40.0]]}}]}}"#
"actors":[{{"name":"Steve Buscemi","tmdb_id":"884","scenes":[{{"start":10.0,"end":20.0}}]}},
{{"name":"Michael Palin","tmdb_id":"11007","scenes":[{{"start":30.0,"end":40.0}}]}}]}}"#
);
let b = valid_from(&with_sig);
assert_eq!(compute_content_id(&a), compute_content_id(&b));
}
#[test]
fn content_id_excludes_belief_and_route() {
// The trap in the SR-003 bump (UR-018). Belief is a producer-side
// estimate that may legitimately differ between pipeline versions for
// identical timings, so hashing it would give two servers different ids
// for the same manifest — the exact failure mode §9a quantises
// centiseconds to avoid, reintroduced one field along.
//
// Two manifests, same windows, wildly different confidence and routes.
let bare = r#"{"jmanifest_version":2,
"identity":{"type":"movie","tmdb_id":"504172","title":"A Film"},
"cut":{"runtime_sec":6420.5},
"actors":[{"name":"Steve Buscemi","tmdb_id":"884",
"scenes":[{"start":10.0,"end":20.0}]},
{"name":"Michael Palin","tmdb_id":"11007",
"scenes":[{"start":30.0,"end":40.0}]}]}"#;
let believed = r#"{"jmanifest_version":2,
"identity":{"type":"movie","tmdb_id":"504172","title":"A Film"},
"cut":{"runtime_sec":6420.5},
"actors":[{"name":"Steve Buscemi","tmdb_id":"884",
"scenes":[{"start":10.0,"end":20.0,"belief":0.98,"route":"live"}]},
{"name":"Michael Palin","tmdb_id":"11007",
"scenes":[{"start":30.0,"end":40.0,"belief":0.31,"route":"deferred"}]}]}"#;
assert_eq!(
compute_content_id(&valid_from(bare)),
compute_content_id(&valid_from(believed)),
"belief and route must not enter identity"
);
// And the same content at a *different* belief still deduplicates.
let other_belief = believed.replace("0.98", "0.42").replace("live", "pooled");
assert_eq!(
compute_content_id(&valid_from(believed)),
compute_content_id(&valid_from(&other_belief)),
);
// Sanity: a genuine timing change *does* alter the id, so the test above
// is not passing because the hash ignores everything.
let shifted = bare.replace("\"end\":20.0", "\"end\":21.0");
assert_ne!(
compute_content_id(&valid_from(bare)),
compute_content_id(&valid_from(&shifted))
);
}
#[test]
fn content_id_excludes_submitted_names() {
// Names are not persisted, so they must not be part of identity either —
// otherwise a renamed resubmission would evade deduplication.
let a = valid_from(&movie_json("504172", 6420.5));
let b = valid_from(
r#"{"jmanifest_version":1,
r#"{"jmanifest_version":2,
"identity":{"type":"movie","tmdb_id":"504172","title":"A Film"},
"cut":{"runtime_sec":6420.5},
"extraction":{"sample_fps":5,"pipeline_version":"test 0.1"},
"actors":[{"name":"Someone Else","tmdb_id":"884","scenes":[[10.0,20.0]]},
{"name":"Another Person","tmdb_id":"11007","scenes":[[30.0,40.0]]}]}"#,
"actors":[{"name":"Someone Else","tmdb_id":"884","scenes":[{"start":10.0,"end":20.0}]},
{"name":"Another Person","tmdb_id":"11007","scenes":[{"start":30.0,"end":40.0}]}]}"#,
);
assert_eq!(compute_content_id(&a), compute_content_id(&b));
}