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
+18 -4
View File
@@ -39,6 +39,12 @@ Implemented:
- §8 Rust + Axum + SQLite, single serialized writer, in-process job queue
- §9a content addressing (`content_id`), computed on upload
**Schema version 2** (SR-003). `jmanifest_version` moved to 2 in lockstep with
the truth file's `schema_version` — breaking changes are batched and ship
together across all three repos. It is a **flag day**: version 1 is rejected
outright rather than carried alongside, because a v1 read path would be the one
nobody exercises and so the one that rots.
Reconciled with the system spec (see `docs/requirements.md` for the detail):
- **`anneal_sec` removed.** Withdrawn upstream by AR-012/AR-013 — presence now
@@ -48,6 +54,14 @@ Reconciled with the system spec (see `docs/requirements.md` for the detail):
carrying `anneal_sec` is now a hard `400`, not silently ignored: it was
produced by a pipeline whose window semantics differ from what this server
assumes.
- **Windows carry belief and route.** `scenes` are objects rather than float
pairs: `{ "start", "end", "belief", "route" }`, where belief is the posterior
that justified the claim and route is `live`/`deferred`/`pooled`. Both are
**excluded from `content_id`** — belief is a producer-side estimate that may
differ between pipeline versions for identical timings, so hashing it would
give two servers different ids for the same content. `src/content_id.rs` is
unchanged by the bump and its golden vector still passes, which is the
evidence rather than the claim.
- **Audio signature: the 120 s rule now matches both producers.** An earlier
draft of §3 allowed a shortened window for items under 150 s; that conflicted
with `scene-actor-extraction` IR-007 and was the weaker rule, since a
@@ -110,7 +124,7 @@ curl -sX POST -H 'content-type: application/json' -d '{}' \
## Tests
```sh
cargo test # 189 tests
cargo test # 191 tests
cargo deny check # advisories, licences, bans, sources
scripts/traceability-gate.sh # requirement coverage
```
@@ -123,9 +137,9 @@ git submodule update --init --recursive
It reports coverage against [`docs/requirements.md`](docs/requirements.md),
flags orphan tags (an ID no register defines), and fails on a >100% ratio — the
signal that the computation itself is broken. Currently **23/32 (71.9%)**; the
untraced nine are UR-007 (plugin-side), UR-008 (federation) and UR-015..018 (the
pending SR-003 bump), none of which is implemented yet.
signal that the computation itself is broken. Currently **24/32 (75%)**; the
untraced remainder is UR-007 (plugin-side) and UR-008 (federation), neither of
which is implemented here yet.
Unit tests per module, plus two integration suites:
+25 -11
View File
@@ -107,7 +107,7 @@ and a cut fingerprint; the actor timeline payload is unchanged.
```json
{
"jmanifest_version": 1,
"jmanifest_version": 2,
"identity": {
"type": "movie",
"tmdb_id": "504172",
@@ -133,7 +133,10 @@ and a cut fingerprint; the actor timeline payload is unchanged.
"name": "Steve Buscemi",
"imdb_id": "nm0000114",
"tmdb_id": "884",
"scenes": [[191.6, 209.2], [438.2, 465.6]]
"scenes": [
{ "start": 191.6, "end": 209.2, "belief": 0.98, "route": "live" },
{ "start": 438.2, "end": 465.6, "belief": 0.81, "route": "deferred" }
]
}
]
}
@@ -154,8 +157,10 @@ For an episode, `identity` is:
Field notes:
- `jmanifest_version` — separate from the plugin's `schema_version`; this
versions the *exchange* envelope.
- `jmanifest_version` — **currently 2.** Separate from the plugin's
`schema_version`; this versions the *exchange* envelope, and the two remain
independent by design. They coincide at 2 only because the SR-003 bump touched
both. An unknown version is rejected outright (UR-014), never guessed at.
- `identity.tmdb_id` / `imdb_id` — at least one required. These are the
lookup keys.
- `cut.runtime_sec` — **required**, the decoded duration of the media the
@@ -173,7 +178,10 @@ Field notes:
- `actors[].jellyfin_id` — **must not appear.** The server rejects uploads
containing it (see §6).
- `movie` (absolute path) — **must not appear.** Rejected likewise.
- `actors[].scenes` — `[start_sec, end_sec]` inclusive, sorted. **A window is a
- `actors[].scenes` — objects, not float pairs. `start`/`end` in seconds,
inclusive, sorted. `belief` is the accumulated posterior that justified the
claim, in `[0, 1]`; `route` is `live`, `deferred` or `pooled` (extraction
AR-017). Both are optional and both are **excluded from `content_id`** (§9a). **A window is a
claim about scene membership, not a recognition event** (UR-013, system spec
SR-002): an actor who turns away or is off-camera during a reverse shot is
still present. The server therefore never reinterprets, merges, splits or
@@ -188,12 +196,13 @@ Field notes:
server-authoritative. Contributors should not expect a name they invented to
round-trip.
### Pending schema bump — SR-003
### Schema bump — SR-003, shipped at version 2
The truth file and the Jmanifest are consumed by components that ship
independently, so breaking changes are **batched into one `schema_version`
bump** coordinated across all three repos (system spec SR-003). One bump is
currently pending, and this server must accept the new shape when it lands:
bump** coordinated across all three repos (system spec SR-003). One bump has
shipped, moving `jmanifest_version` to **2** in lockstep with the truth file's
`schema_version`:
| Change | Effect here |
|---|---|
@@ -219,8 +228,13 @@ bump rather than after it:
as an attribute, exactly as `audio_signature` is (§9a).
- Quantisation is unchanged: integer centiseconds, for the reasons in §9a.
Until the bump ships, this server accepts `jmanifest_version: 1` and rejects
anything else outright (UR-014) rather than guessing at an unknown shape.
**Flag day, not dual-accept.** This server accepts `jmanifest_version: 2` and
rejects everything else outright (UR-014), including version 1. 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 carried through every
later change to the reader. The consequence is that a pipeline still emitting v1
is incompatible until it is updated, which is stated plainly rather than papered
over with a compatibility shim nobody tests.
### Series bundles
@@ -232,7 +246,7 @@ A bundle is a thin wrapper, not a new format:
```json
{
"jmanifest_version": 1,
"jmanifest_version": 2,
"series": {
"series_tmdb_id": "1396",
"series_imdb_id": "tt0903747",
+23 -16
View File
@@ -41,6 +41,10 @@ requirement and no fixture-generation step, unlike `scene-actor-extraction`.
| UR-012 | Never accept, store, or serve gallery data — reference faces or embeddings | SR-005 | High | Done |
| UR-013 | Windows are scene-scoped claims; never reinterpret their boundaries | SR-002 | High | Done |
| UR-014 | Reject an unknown `jmanifest_version` outright, never guess | SR-003 | High | Done |
| UR-015 | Accept `extraction.extinction_sec` in place of `anneal_sec` | SR-003 | High | Done |
| UR-016 | Accept and store `extraction.gallery_scope`; rank on it (§7) | SR-003 | Medium | Done |
| UR-017 | Accept per-window belief and identification route; `scenes` are objects | SR-003 | High | Done |
| UR-018 | Exclude belief and route from `content_id`, replicating them as attributes | SR-003 | High | Done |
### Notes on status
@@ -128,6 +132,10 @@ topology is the point, so this is a deliberate choice rather than an oversight.
| DR-009 | T2 | Oversized body rejected as `413` | **A lying `Content-Length` does not bypass the cap**; per-route limits differ |
| DR-010 | T1 | Non-UTF-8 rejected by name | UTF-16 with and without BOM; UTF-8 BOM; declared `charset=utf-16` |
| DR-011 | T1 | Canonical form is stable and order-independent | Accumulated float error hashes identically; `audio_signature` and `extraction` excluded; **golden vector verified against an independent Python implementation** |
| UR-015 | T2 | `extinction_sec` accepted and range-checked | A manifest still carrying `anneal_sec` is a hard `400` naming the field |
| UR-016 | T2 | `gallery_scope` stored and served | An unrecognised scope is a closed-vocabulary `400`, not a free string |
| UR-017 | T1 + T2 | Windows carry belief and route through storage | Belief outside `[0, 1]` rejected; an invented `route` rejected |
| UR-018 | **T1** | Belief and route absent from `content_id` | Same windows at different belief hash identically; **a real timing change still does not**, so the test cannot pass vacuously |
| DR-013 | T1 | Schema mismatch is `400`, not the framework's `422` | §4 names `400` for a forbidden field, and a client checking for it would mishandle `422` |
Three are worth singling out, because each verifies a claim that would otherwise
@@ -162,27 +170,26 @@ requirement — so nothing is orphaned by its removal.
---
## Pending — the SR-003 schema bump
## The SR-003 schema bump — shipped at version 2
These are `Planned` rather than absent, because the bump is coordinated across
three repos and this register should show the work rather than imply the server
is finished.
`jmanifest_version` moved to **2** in lockstep with the truth file's
`schema_version`, per SR-003's requirement that breaking changes be batched and
ship together. The two fields stay independent by design; they coincide at 2
only because this bump touched both.
| ID | Requirement | Traces to | Priority | Status |
|---|---|---|---|---|
| UR-015 | Accept `extraction.extinction_sec` in place of `anneal_sec` | SR-003 | High | Planned |
| UR-016 | Accept and store `extraction.gallery_scope`; rank on it (§7) | SR-003 | Medium | Planned |
| UR-017 | Accept per-window belief and identification route; `scenes` becomes objects | SR-003 | High | Planned |
| UR-018 | Exclude belief from `content_id`, replicating it as an attribute | SR-003 | High | Planned |
**Flag day, not dual-accept** (`jRay` JR-003): version 1 is 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.
**UR-018 is the one with a trap in it.** Belief is a producer-side estimate that
may legitimately differ between pipeline versions for identical timings, so
**UR-018 was the one with a trap in it.** 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 `content_id`s
for the same content — the exact failure mode §9a quantises centiseconds to
avoid. It follows `audio_signature`'s precedent: replicated as an attribute, not
part of identity.
---
avoid, reintroduced one field along. It follows `audio_signature`'s precedent:
replicated as an attribute, never as identity. `src/content_id.rs` is unchanged
by the bump, and its golden vector still passes — which is the evidence, not the
claim.
## Notes on coverage
+50 -39
View File
@@ -3,7 +3,7 @@
<!-- GENERATED FILE - do not edit by hand. -->
<!-- Regenerate: scripts/traceability/traceability-gate.sh -->
**Generated:** 2026-07-30T16:55:59+00:00
**Generated:** 2026-07-31T07:12:27+00:00
Denominators are read from [`requirements.md`](requirements.md) at run time, never hardcoded. Coverage counts a requirement only when it is tagged in source **and** has a verification tier this repo's CI host can execute (`T1, T2, static`).
@@ -12,12 +12,12 @@ Denominators are read from [`requirements.md`](requirements.md) at run time, nev
| Metric | Value |
|---|---|
| Source files scanned | 26 |
| TRACES tags found | 35 |
| TRACES tags found | 37 |
| EXCEPTION tags found | 0 |
| Requirements defined | 32 |
| Requirements covered | 23 |
| **Coverage** | **71.9%** (23/32) |
| Coverage of CI-executable scope | 71.9% (23/32) |
| Requirements covered | 24 |
| **Coverage** | **75.0%** (24/32) |
| Coverage of CI-executable scope | 75.0% (24/32) |
| Tagged but unexecuted in CI | 0 |
| Orphan tags | 0 |
@@ -25,7 +25,7 @@ Denominators are read from [`requirements.md`](requirements.md) at run time, nev
| Type | Covered | Tagged but unexecuted | Defined |
|---|---|---|---|
| UR | 13 | 0 | 18 |
| UR | 14 | 0 | 18 |
| DR | 10 | 0 | 14 |
- **PR** tags present (separate taxonomy, not counted in coverage): PR-004, PR-005, PR-006
@@ -73,10 +73,10 @@ _None._
| UR-012 | Done | T2 | SR-005 | covered | `src/db/repo.rs`, `src/ingest.rs` | Never accept, store, or serve gallery data — reference faces or embed… |
| UR-013 | Done | T1 | SR-002 | covered | `src/api/fetch.rs`, `src/model.rs`, `src/validate.rs` | Windows are scene-scoped claims; never reinterpret their boundaries |
| UR-014 | Done | T1 | SR-003 | covered | `src/model.rs`, `src/validate.rs` | Reject an unknown `jmanifest_version` outright, never guess |
| UR-015 | Planned | unset | SR-003 | untagged | - | Accept `extraction.extinction_sec` in place of `anneal_sec` |
| UR-016 | Planned | unset | SR-003 | untagged | - | Accept and store `extraction.gallery_scope`; rank on it (§7) |
| UR-017 | Planned | unset | SR-003 | untagged | - | Accept per-window belief and identification route; `scenes` becomes o… |
| UR-018 | Planned | unset | SR-003 | untagged | - | Exclude belief from `content_id`, replicating it as an attribute |
| UR-015 | Done | T2 | SR-003 | untagged | - | Accept `extraction.extinction_sec` in place of `anneal_sec` |
| UR-016 | Done | T2 | SR-003 | untagged | - | Accept and store `extraction.gallery_scope`; rank on it (§7) |
| UR-017 | Done | T1, T2 | SR-003 | covered | `src/model.rs` | Accept per-window belief and identification route; `scenes` are objec… |
| UR-018 | Done | T1 | SR-003 | untagged | - | Exclude belief and route from `content_id`, replicating them as attri… |
| DR-001 | Done | T1 | SR-004 | untagged | - | Strict parse boundary: unknown fields rejected structurally, not by v… |
| DR-002 | Done | unset | SR-004 | covered | `src/api/fetch.rs`, `src/db/repo.rs` | Fully relational storage — no JSON blob on the write path |
| DR-003 | Done | T1 | PR-004 | covered | `src/db/mod.rs` | Single serialized writer connection, with a read pool alongside |
@@ -100,7 +100,7 @@ _None._
- [`src/api/fetch.rs:267`](../src/api/fetch.rs#L267) — `pub fn reconstruct(`
- [`src/db/repo.rs:337`](../src/db/repo.rs#L337) — `pub fn insert_manifest(tx: &Transaction<'_>, m: &NewManifest<'_>) -> anyhow::Result<()>`
- [`src/db/repo.rs:405`](../src/db/repo.rs#L405) — `pub fn actors_for_manifest(`
- [`src/db/repo.rs:412`](../src/db/repo.rs#L412) — `pub fn actors_for_manifest(`
### DR-003
@@ -118,7 +118,7 @@ _None._
**Locations:** 1
- [`src/db/repo.rs:691`](../src/db/repo.rs#L691) — `pub fn lease_jobs(tx: &Transaction<'_>, now: &str, limit: usize) -> anyhow::Result<Vec<Jo…`
- [`src/db/repo.rs:709`](../src/db/repo.rs#L709) — `pub fn lease_jobs(tx: &Transaction<'_>, now: &str, limit: usize) -> anyhow::Result<Vec<Jo…`
### DR-006
@@ -151,7 +151,7 @@ _None._
- [`src/content_id.rs:52`](../src/content_id.rs#L52) — `pub fn canonical_json(`
- [`src/content_id.rs:129`](../src/content_id.rs#L129) — `pub fn content_id(`
- [`src/validate.rs:78`](../src/validate.rs#L78) — `pub fn to_centiseconds(secs: f64) -> i64`
- [`src/validate.rs:102`](../src/validate.rs#L102) — `pub fn to_centiseconds(secs: f64) -> i64`
### DR-013
@@ -167,7 +167,7 @@ _None._
- [`src/config.rs:10`](../src/config.rs#L10) — `Unknown`
- [`src/db/mod.rs:30`](../src/db/mod.rs#L30) — `struct ReadPool`
- [`src/db/repo.rs:691`](../src/db/repo.rs#L691) — `pub fn lease_jobs(tx: &Transaction<'_>, now: &str, limit: usize) -> anyhow::Result<Vec<Jo…`
- [`src/db/repo.rs:709`](../src/db/repo.rs#L709) — `pub fn lease_jobs(tx: &Transaction<'_>, now: &str, limit: usize) -> anyhow::Result<Vec<Jo…`
### PR-005
@@ -183,7 +183,7 @@ _None._
- [`src/api/upload.rs:29`](../src/api/upload.rs#L29) — `pub async fn post_manifest(`
- [`src/api/upload.rs:101`](../src/api/upload.rs#L101) — `pub async fn post_bundle(`
- [`src/ingest.rs:50`](../src/ingest.rs#L50) — `pub fn persist(`
- [`src/validate.rs:540`](../src/validate.rs#L540) — `pub fn validate_bundle_envelope(b: &SeriesBundle) -> VResult<()>`
- [`src/validate.rs:586`](../src/validate.rs#L586) — `pub fn validate_bundle_envelope(b: &SeriesBundle) -> VResult<()>`
### SR-001
@@ -193,30 +193,33 @@ _None._
- [`src/api/exists.rs:74`](../src/api/exists.rs#L74) — `pub async fn exists_batch(`
- [`src/api/fetch.rs:267`](../src/api/fetch.rs#L267) — `pub fn reconstruct(`
- [`src/castcheck.rs:82`](../src/castcheck.rs#L82) — `pub fn evaluate(submitted: &[SubmittedActor], credits: &[CastMember]) -> CastCheckOutcome`
- [`src/db/repo.rs:405`](../src/db/repo.rs#L405) — `pub fn actors_for_manifest(`
- [`src/db/repo.rs:412`](../src/db/repo.rs#L412) — `pub fn actors_for_manifest(`
- [`src/matching.rs:52`](../src/matching.rs#L52) — `pub fn match_cut(client: &ClientCut, stored: &StoredCut) -> Option<CutMatch>`
- [`src/model.rs:179`](../src/model.rs#L179) — `Unknown`
### SR-002
**Locations:** 3
**Locations:** 4
- [`src/api/fetch.rs:267`](../src/api/fetch.rs#L267) — `pub fn reconstruct(`
- [`src/model.rs:179`](../src/model.rs#L179) — `Unknown`
- [`src/validate.rs:486`](../src/validate.rs#L486) — `fn validate_scenes(idx: usize, a: &Actor, runtime_sec: f64) -> VResult<Vec<(i64, i64)>>`
- [`src/model.rs:233`](../src/model.rs#L233) — `pub fn from_str(s: &str) -> Option<Self>`
- [`src/validate.rs:510`](../src/validate.rs#L510) — `fn validate_scenes(idx: usize, a: &Actor, runtime_sec: f64) -> VResult<Vec<SceneCs>>`
### SR-003
**Locations:** 8
**Locations:** 10
- [`src/api/json.rs:100`](../src/api/json.rs#L100) — `fn require_utf8(bytes: &[u8]) -> Result<&str, ApiError>`
- [`src/content_id.rs:52`](../src/content_id.rs#L52) — `pub fn canonical_json(`
- [`src/content_id.rs:129`](../src/content_id.rs#L129) — `pub fn content_id(`
- [`src/error.rs:8`](../src/error.rs#L8) — `Unknown`
- [`src/model.rs:198`](../src/model.rs#L198) — `Unknown`
- [`src/validate.rs:78`](../src/validate.rs#L78) — `pub fn to_centiseconds(secs: f64) -> i64`
- [`src/validate.rs:203`](../src/validate.rs#L203) — `pub fn validate_manifest(mut m: Jmanifest) -> VResult<ValidManifest>`
- [`src/validate.rs:354`](../src/validate.rs#L354) — `pub fn validate_audio_signature(sig: &str, runtime_sec: f64) -> VResult<()>`
- [`src/model.rs:197`](../src/model.rs#L197) — `Unknown`
- [`src/model.rs:233`](../src/model.rs#L233) — `pub fn from_str(s: &str) -> Option<Self>`
- [`src/model.rs:259`](../src/model.rs#L259) — `Unknown`
- [`src/validate.rs:102`](../src/validate.rs#L102) — `pub fn to_centiseconds(secs: f64) -> i64`
- [`src/validate.rs:227`](../src/validate.rs#L227) — `pub fn validate_manifest(mut m: Jmanifest) -> VResult<ValidManifest>`
- [`src/validate.rs:378`](../src/validate.rs#L378) — `pub fn validate_audio_signature(sig: &str, runtime_sec: f64) -> VResult<()>`
### SR-004
@@ -232,11 +235,11 @@ _None._
- [`src/castcheck.rs:215`](../src/castcheck.rs#L215) — `pub fn category_guard_violation(matched: &[MatchedActor], title_is_adult: bool) -> Option…`
- [`src/db/repo.rs:337`](../src/db/repo.rs#L337) — `pub fn insert_manifest(tx: &Transaction<'_>, m: &NewManifest<'_>) -> anyhow::Result<()>`
- [`src/model.rs:156`](../src/model.rs#L156) — `Unknown`
- [`src/model.rs:198`](../src/model.rs#L198) — `Unknown`
- [`src/model.rs:259`](../src/model.rs#L259) — `Unknown`
- [`src/ratelimit.rs:76`](../src/ratelimit.rs#L76) — `impl Default for RateLimiter`
- [`src/validate.rs:112`](../src/validate.rs#L112) — `fn is_allowed_text_char(c: char) -> bool`
- [`src/validate.rs:203`](../src/validate.rs#L203) — `pub fn validate_manifest(mut m: Jmanifest) -> VResult<ValidManifest>`
- [`src/validate.rs:354`](../src/validate.rs#L354) — `pub fn validate_audio_signature(sig: &str, runtime_sec: f64) -> VResult<()>`
- [`src/validate.rs:136`](../src/validate.rs#L136) — `fn is_allowed_text_char(c: char) -> bool`
- [`src/validate.rs:227`](../src/validate.rs#L227) — `pub fn validate_manifest(mut m: Jmanifest) -> VResult<ValidManifest>`
- [`src/validate.rs:378`](../src/validate.rs#L378) — `pub fn validate_audio_signature(sig: &str, runtime_sec: f64) -> VResult<()>`
- [`src/worker.rs:96`](../src/worker.rs#L96) — `async fn run_cast_check(&self, payload: &str) -> Result<(), JobError>`
### SR-005
@@ -268,8 +271,8 @@ _None._
- [`src/api/upload.rs:29`](../src/api/upload.rs#L29) — `pub async fn post_manifest(`
- [`src/castcheck.rs:82`](../src/castcheck.rs#L82) — `pub fn evaluate(submitted: &[SubmittedActor], credits: &[CastMember]) -> CastCheckOutcome`
- [`src/model.rs:156`](../src/model.rs#L156) — `Unknown`
- [`src/model.rs:198`](../src/model.rs#L198) — `Unknown`
- [`src/validate.rs:203`](../src/validate.rs#L203) — `pub fn validate_manifest(mut m: Jmanifest) -> VResult<ValidManifest>`
- [`src/model.rs:259`](../src/model.rs#L259) — `Unknown`
- [`src/validate.rs:227`](../src/validate.rs#L227) — `pub fn validate_manifest(mut m: Jmanifest) -> VResult<ValidManifest>`
- [`src/worker.rs:96`](../src/worker.rs#L96) — `async fn run_cast_check(&self, payload: &str) -> Result<(), JobError>`
### UR-004
@@ -296,7 +299,7 @@ _None._
- [`src/api/fetch.rs:128`](../src/api/fetch.rs#L128) — `pub async fn get_series(`
- [`src/api/upload.rs:101`](../src/api/upload.rs#L101) — `pub async fn post_bundle(`
- [`src/validate.rs:540`](../src/validate.rs#L540) — `pub fn validate_bundle_envelope(b: &SeriesBundle) -> VResult<()>`
- [`src/validate.rs:586`](../src/validate.rs#L586) — `pub fn validate_bundle_envelope(b: &SeriesBundle) -> VResult<()>`
### UR-007
@@ -308,7 +311,7 @@ _None._
**Locations:** 1
- [`src/validate.rs:354`](../src/validate.rs#L354) — `pub fn validate_audio_signature(sig: &str, runtime_sec: f64) -> VResult<()>`
- [`src/validate.rs:378`](../src/validate.rs#L378) — `pub fn validate_audio_signature(sig: &str, runtime_sec: f64) -> VResult<()>`
### UR-010
@@ -316,7 +319,7 @@ _None._
- [`src/api/fetch.rs:267`](../src/api/fetch.rs#L267) — `pub fn reconstruct(`
- [`src/castcheck.rs:82`](../src/castcheck.rs#L82) — `pub fn evaluate(submitted: &[SubmittedActor], credits: &[CastMember]) -> CastCheckOutcome`
- [`src/db/repo.rs:405`](../src/db/repo.rs#L405) — `pub fn actors_for_manifest(`
- [`src/db/repo.rs:412`](../src/db/repo.rs#L412) — `pub fn actors_for_manifest(`
- [`src/model.rs:179`](../src/model.rs#L179) — `Unknown`
### UR-011
@@ -324,9 +327,9 @@ _None._
**Locations:** 4
- [`src/model.rs:156`](../src/model.rs#L156) — `Unknown`
- [`src/model.rs:198`](../src/model.rs#L198) — `Unknown`
- [`src/validate.rs:112`](../src/validate.rs#L112) — `fn is_allowed_text_char(c: char) -> bool`
- [`src/validate.rs:354`](../src/validate.rs#L354) — `pub fn validate_audio_signature(sig: &str, runtime_sec: f64) -> VResult<()>`
- [`src/model.rs:259`](../src/model.rs#L259) — `Unknown`
- [`src/validate.rs:136`](../src/validate.rs#L136) — `fn is_allowed_text_char(c: char) -> bool`
- [`src/validate.rs:378`](../src/validate.rs#L378) — `pub fn validate_audio_signature(sig: &str, runtime_sec: f64) -> VResult<()>`
### UR-012
@@ -337,16 +340,24 @@ _None._
### UR-013
**Locations:** 3
**Locations:** 4
- [`src/api/fetch.rs:267`](../src/api/fetch.rs#L267) — `pub fn reconstruct(`
- [`src/model.rs:179`](../src/model.rs#L179) — `Unknown`
- [`src/validate.rs:486`](../src/validate.rs#L486) — `fn validate_scenes(idx: usize, a: &Actor, runtime_sec: f64) -> VResult<Vec<(i64, i64)>>`
- [`src/model.rs:233`](../src/model.rs#L233) — `pub fn from_str(s: &str) -> Option<Self>`
- [`src/validate.rs:510`](../src/validate.rs#L510) — `fn validate_scenes(idx: usize, a: &Actor, runtime_sec: f64) -> VResult<Vec<SceneCs>>`
### UR-014
**Locations:** 2
- [`src/model.rs:198`](../src/model.rs#L198) — `Unknown`
- [`src/validate.rs:203`](../src/validate.rs#L203) — `pub fn validate_manifest(mut m: Jmanifest) -> VResult<ValidManifest>`
- [`src/model.rs:259`](../src/model.rs#L259) — `Unknown`
- [`src/validate.rs:227`](../src/validate.rs#L227) — `pub fn validate_manifest(mut m: Jmanifest) -> VResult<ValidManifest>`
### UR-017
**Locations:** 2
- [`src/model.rs:197`](../src/model.rs#L197) — `Unknown`
- [`src/model.rs:233`](../src/model.rs#L233) — `pub fn from_str(s: &str) -> Option<Self>`
+7 -2
View File
@@ -17,7 +17,7 @@ use crate::error::{ApiError, ApiResult};
use crate::matching::{self, StoredCut};
use crate::model::{
Actor, Coverage, Cut, Extraction, GalleryScope, Identity, IdentityType, Jmanifest, MatchTier,
SeriesBundle, SeriesRef, JMANIFEST_VERSION,
Scene, SeriesBundle, SeriesRef, JMANIFEST_VERSION,
};
use crate::ratelimit::Surface;
use crate::state::{with_quota_headers, AppState};
@@ -282,7 +282,12 @@ pub fn reconstruct(
scenes: a
.scenes_cs
.into_iter()
.map(|(s, e)| [s as f64 / 100.0, e as f64 / 100.0])
.map(|s| Scene {
start: s.start_cs as f64 / 100.0,
end: s.end_cs as f64 / 100.0,
belief: s.belief,
route: s.route,
})
.collect(),
})
.collect();
+38 -11
View File
@@ -374,7 +374,7 @@ pub fn insert_actor_scenes(
tx: &Transaction<'_>,
manifest_id: &str,
tmdb_person_id: u64,
scenes_cs: &[(i64, i64)],
scenes_cs: &[crate::validate::SceneCs],
) -> anyhow::Result<()> {
tx.execute(
"INSERT INTO manifest_actors (manifest_id, tmdb_person_id) VALUES (?1, ?2)
@@ -382,11 +382,18 @@ pub fn insert_actor_scenes(
params![manifest_id, tmdb_person_id as i64],
)?;
let mut stmt = tx.prepare_cached(
"INSERT INTO scenes (manifest_id, tmdb_person_id, start_cs, end_cs)
VALUES (?1, ?2, ?3, ?4)",
"INSERT INTO scenes (manifest_id, tmdb_person_id, start_cs, end_cs, belief, route)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
)?;
for (start, end) in scenes_cs {
stmt.execute(params![manifest_id, tmdb_person_id as i64, start, end])?;
for scene in scenes_cs {
stmt.execute(params![
manifest_id,
tmdb_person_id as i64,
scene.start_cs,
scene.end_cs,
scene.belief,
scene.route.map(|r| r.as_str()),
])?;
}
Ok(())
}
@@ -399,7 +406,7 @@ pub fn insert_actor_scenes(
pub struct StoredActor {
pub tmdb_person_id: u64,
pub name: Option<String>,
pub scenes_cs: Vec<(i64, i64)>,
pub scenes_cs: Vec<crate::validate::SceneCs>,
}
/// TRACES: UR-010 | DR-002 | SR-001
@@ -421,7 +428,7 @@ pub fn actors_for_manifest(
.collect::<rusqlite::Result<Vec<_>>>()?;
let mut scene_stmt = conn.prepare_cached(
"SELECT start_cs, end_cs FROM scenes
"SELECT start_cs, end_cs, belief, route FROM scenes
WHERE manifest_id = ?1 AND tmdb_person_id = ?2
ORDER BY start_cs ASC",
)?;
@@ -429,7 +436,18 @@ pub fn actors_for_manifest(
let mut out = Vec::with_capacity(people.len());
for (id, name) in people {
let scenes_cs = scene_stmt
.query_map(params![manifest_id, id as i64], |r| Ok((r.get(0)?, r.get(1)?)))?
.query_map(params![manifest_id, id as i64], |r| {
Ok(crate::validate::SceneCs {
start_cs: r.get(0)?,
end_cs: r.get(1)?,
belief: r.get(2)?,
// An unrecognised stored route means a row from a schema
// this build does not know; report absent rather than guess.
route: r
.get::<_, Option<String>>(3)?
.and_then(|v| crate::model::Route::from_stored(&v)),
})
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
out.push(StoredActor { tmdb_person_id: id, name, scenes_cs });
}
@@ -742,6 +760,7 @@ pub fn release_all_leases(tx: &Transaction<'_>) -> anyhow::Result<usize> {
mod tests {
use super::*;
use crate::db::Db;
use crate::validate::SceneCs;
async fn db() -> Db {
Db::open(":memory:").unwrap()
@@ -837,7 +856,12 @@ mod tests {
},
)?;
upsert_person(tx, 884, "Steve Buscemi", false, NOW)?;
insert_actor_scenes(tx, &id, 884, &[(19160, 20920), (43820, 46560)])?;
insert_actor_scenes(
tx,
&id,
884,
&[SceneCs::plain(19160, 20920), SceneCs::plain(43820, 46560)],
)?;
Ok(id)
})
.await
@@ -857,7 +881,10 @@ mod tests {
assert_eq!(actors[0].tmdb_person_id, 884);
// §7: names come from `people`, populated from TMDB, never from upload.
assert_eq!(actors[0].name.as_deref(), Some("Steve Buscemi"));
assert_eq!(actors[0].scenes_cs, vec![(19160, 20920), (43820, 46560)]);
assert_eq!(
actors[0].scenes_cs,
vec![SceneCs::plain(19160, 20920), SceneCs::plain(43820, 46560)]
);
}
#[tokio::test]
@@ -1097,7 +1124,7 @@ mod tests {
},
)?;
upsert_person(tx, 1, "A", false, NOW)?;
insert_actor_scenes(tx, "m", 1, &[(0, 100)])?;
insert_actor_scenes(tx, "m", 1, &[SceneCs::plain(0, 100)])?;
delete_manifest(tx, "m")?;
let scenes: i64 = tx.query_row("SELECT COUNT(*) FROM scenes", [], |r| r.get(0))?;
let actors: i64 =
+7 -1
View File
@@ -70,7 +70,13 @@ CREATE TABLE IF NOT EXISTS scenes (
manifest_id TEXT NOT NULL REFERENCES manifests(id) ON DELETE CASCADE,
tmdb_person_id INTEGER NOT NULL,
start_cs INTEGER NOT NULL,
end_cs INTEGER NOT NULL
end_cs INTEGER NOT NULL,
-- Per-window provenance (SR-003, extraction AR-017). Deliberately NOT part
-- of `content_id`: belief is a producer-side estimate that may differ
-- between pipeline versions for identical timings, so hashing it would give
-- two servers different ids for the same content (§9a).
belief REAL,
route TEXT -- live | deferred | pooled
);
CREATE TABLE IF NOT EXISTS reports (
+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));
}
+92 -9
View File
@@ -190,8 +190,74 @@ pub struct Actor {
/// The **primary** actor join key (§2, §6 stage 3).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tmdb_id: Option<String>,
/// `[start_sec, end_sec]` inclusive, sorted.
pub scenes: Vec<[f64; 2]>,
/// Presence windows, inclusive and sorted by start.
pub scenes: Vec<Scene>,
}
/// TRACES: UR-017 | SR-003
/// How an actor was identified for a given window (`scene-actor-extraction`
/// AR-017: every presence claim carries its belief and identification route).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Route {
/// Identified while the track was live.
Live,
/// Resolved by the deferred pass, after the final frame was read.
Deferred,
/// Resolved from the per-subject embedding pool.
Pooled,
}
impl Route {
pub fn as_str(self) -> &'static str {
match self {
Route::Live => "live",
Route::Deferred => "deferred",
Route::Pooled => "pooled",
}
}
/// Parses a value read back from storage. Returns `None` for anything
/// unrecognised rather than guessing — a row written by a future schema
/// means something this build does not know, and inventing a route would
/// misreport provenance.
///
/// Deliberately not `FromStr`: that trait is for parsing *input*, and this
/// reads a value the server itself wrote from a closed enum. Keeping them
/// distinct stops a future refactor pointing user input at this path.
pub fn from_stored(s: &str) -> Option<Self> {
match s {
"live" => Some(Route::Live),
"deferred" => Some(Route::Deferred),
"pooled" => Some(Route::Pooled),
_ => None,
}
}
}
/// TRACES: UR-013, UR-017 | SR-002, SR-003
/// One presence window.
///
/// **A window is a claim about scene membership, not a recognition event**
/// (SR-002, UR-013). An actor who turns away or is off-camera during a reverse
/// shot is still present, so the server never merges, splits or trims these —
/// it stores what it was given, quantised but not reshaped.
///
/// `belief` and `route` are **excluded from `content_id`** (§9a). Belief is a
/// producer-side estimate that may legitimately differ between pipeline versions
/// for identical timings, so including it would give two servers different ids
/// for the same content — the exact failure mode §9a quantises centiseconds to
/// avoid. They replicate as attributes, exactly as `audio_signature` does.
#[derive(Debug, Clone, Copy, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct Scene {
pub start: f64,
pub end: f64,
/// Accumulated posterior that justified the claim, in `[0, 1]`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub belief: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub route: Option<Route>,
}
/// One shareable actor timeline for one cut of one title (§2).
@@ -240,7 +306,21 @@ pub struct Coverage {
}
/// The current `jmanifest_version` this server speaks (§2).
pub const JMANIFEST_VERSION: u32 = 1;
///
/// Bumped to 2 with the SR-003 schema change, in lockstep with the truth file's
/// `schema_version` — breaking changes are batched and ship together across all
/// three repos, so a component moving alone is the defect this coordination
/// exists to prevent.
///
/// **Flag day, not dual-accept** (SR-003, `jRay` JR-003). Version 1 is rejected
/// outright rather than carried alongside: 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.
///
/// The two version fields remain *independent by design* — `jmanifest_version`
/// versions the exchange envelope, `schema_version` the truth file — and they
/// coincide at 2 only because this bump touched both.
pub const JMANIFEST_VERSION: u32 = 2;
#[cfg(test)]
mod tests {
@@ -248,7 +328,7 @@ mod tests {
#[test]
fn unknown_field_at_top_level_is_rejected() {
let json = r#"{"jmanifest_version":1,"identity":{"type":"movie","tmdb_id":"1"},
let json = r#"{"jmanifest_version":2,"identity":{"type":"movie","tmdb_id":"1"},
"cut":{"runtime_sec":100.0},"actors":[],"surprise":"x"}"#;
let err = serde_json::from_str::<Jmanifest>(json).unwrap_err().to_string();
assert!(err.contains("surprise"), "error should name the field: {err}");
@@ -258,7 +338,7 @@ mod tests {
fn jellyfin_id_on_an_actor_is_a_parse_error() {
// §2: `actors[].jellyfin_id` must not appear. `deny_unknown_fields`
// makes this structural rather than a validator's responsibility.
let json = r#"{"jmanifest_version":1,"identity":{"type":"movie","tmdb_id":"1"},
let json = r#"{"jmanifest_version":2,"identity":{"type":"movie","tmdb_id":"1"},
"cut":{"runtime_sec":100.0},
"actors":[{"name":"A","tmdb_id":"2","jellyfin_id":"guid","scenes":[]}]}"#;
let err = serde_json::from_str::<Jmanifest>(json).unwrap_err().to_string();
@@ -267,7 +347,7 @@ mod tests {
#[test]
fn movie_path_field_is_a_parse_error() {
let json = r#"{"jmanifest_version":1,"movie":"/data/movies/x.mkv",
let json = r#"{"jmanifest_version":2,"movie":"/data/movies/x.mkv",
"identity":{"type":"movie","tmdb_id":"1"},
"cut":{"runtime_sec":100.0},"actors":[]}"#;
let err = serde_json::from_str::<Jmanifest>(json).unwrap_err().to_string();
@@ -276,7 +356,7 @@ mod tests {
#[test]
fn unknown_field_nested_in_cut_is_rejected() {
let json = r#"{"jmanifest_version":1,"identity":{"type":"movie","tmdb_id":"1"},
let json = r#"{"jmanifest_version":2,"identity":{"type":"movie","tmdb_id":"1"},
"cut":{"runtime_sec":100.0,"payload":"x"},"actors":[]}"#;
assert!(serde_json::from_str::<Jmanifest>(json).is_err());
}
@@ -284,7 +364,7 @@ mod tests {
#[test]
fn spec_example_manifest_parses() {
let json = r#"{
"jmanifest_version": 1,
"jmanifest_version": 2,
"identity": { "type": "movie", "tmdb_id": "504172", "imdb_id": "tt4686844",
"title": "The Death of Stalin", "year": 2017 },
"cut": { "runtime_sec": 6420.5, "container_duration_sec": 6420.5,
@@ -293,7 +373,10 @@ mod tests {
"pipeline_version": "scene-actor-extraction 0.4.1",
"gallery_size": 1820, "gallery_scope": "global" },
"actors": [ { "name": "Steve Buscemi", "imdb_id": "nm0000114", "tmdb_id": "884",
"scenes": [[191.6, 209.2], [438.2, 465.6]] } ]
"scenes": [
{ "start": 191.6, "end": 209.2, "belief": 0.98, "route": "live" },
{ "start": 438.2, "end": 465.6, "belief": 0.81, "route": "deferred" }
] } ]
}"#;
let m: Jmanifest = serde_json::from_str(json).unwrap();
assert_eq!(m.actors.len(), 1);
+85 -28
View File
@@ -7,7 +7,9 @@
use unicode_general_category::{get_general_category, GeneralCategory};
use unicode_normalization::{is_nfc, UnicodeNormalization};
use crate::model::{Actor, Identity, IdentityType, Jmanifest, SeriesBundle, JMANIFEST_VERSION};
use crate::model::{
Actor, Identity, IdentityType, Jmanifest, Route, SeriesBundle, JMANIFEST_VERSION,
};
/// §6 stage 1 caps. Body-size limits are applied as a layer (see [`crate::app`]);
/// these are the structural counts the schema layer enforces.
@@ -60,13 +62,35 @@ pub struct ValidManifest {
pub actor_scenes_cs: Vec<ActorScenes>,
}
/// One validated window, quantised and carrying its provenance.
///
/// `belief` and `route` ride alongside `start_cs`/`end_cs` rather than being
/// folded into them, because §9a hashes only the timings: belief is a
/// producer-side estimate that may differ between pipeline versions for
/// identical content, so it is replicated as an attribute, never as identity.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SceneCs {
pub start_cs: i64,
pub end_cs: i64,
pub belief: Option<f64>,
pub route: Option<Route>,
}
impl SceneCs {
/// A window carrying timings only — the shape a producer that has not yet
/// adopted per-window belief emits, and the one `content_id` hashes.
pub fn plain(start_cs: i64, end_cs: i64) -> Self {
Self { start_cs, end_cs, belief: None, route: None }
}
}
#[derive(Debug, Clone)]
pub struct ActorScenes {
/// NFC-normalised name, used only for matching in stage 3 and then dropped.
pub name: Option<String>,
pub tmdb_id: Option<u64>,
pub imdb_id: Option<String>,
pub scenes_cs: Vec<(i64, i64)>,
pub scenes_cs: Vec<SceneCs>,
}
/// Quantises seconds to whole centiseconds (§9a).
@@ -484,7 +508,7 @@ fn validate_actors(m: &Jmanifest) -> VResult<Vec<ActorScenes>> {
}
/// TRACES: UR-013 | SR-002
fn validate_scenes(idx: usize, a: &Actor, runtime_sec: f64) -> VResult<Vec<(i64, i64)>> {
fn validate_scenes(idx: usize, a: &Actor, runtime_sec: f64) -> VResult<Vec<SceneCs>> {
if a.scenes.len() > limits::MAX_SCENES_PER_ACTOR {
return Err(err(
format!("actors[{idx}].scenes"),
@@ -494,8 +518,9 @@ fn validate_scenes(idx: usize, a: &Actor, runtime_sec: f64) -> VResult<Vec<(i64,
let max_t = runtime_sec + limits::RUNTIME_TOLERANCE_SEC;
let mut out = Vec::with_capacity(a.scenes.len());
for (j, [start, end]) in a.scenes.iter().copied().enumerate() {
for (j, scene) in a.scenes.iter().copied().enumerate() {
let field = format!("actors[{idx}].scenes[{j}]");
let (start, end) = (scene.start, scene.end);
// §6: non-finite values (NaN/Infinity), negative times, `end < start`,
// or times beyond `runtime_sec` + tolerance.
if !start.is_finite() || !end.is_finite() {
@@ -516,12 +541,33 @@ fn validate_scenes(idx: usize, a: &Actor, runtime_sec: f64) -> VResult<Vec<(i64,
),
));
}
out.push((to_centiseconds(start), to_centiseconds(end)));
// A posterior outside scene(0, 1) is not a probability. Bounded here rather
// than merely stored, because §5a's Threat 1 argument rests on every
// accepted value being a *bounded* number — an unbounded float is a
// 64-bit channel, however harmless it looks.
if let Some(belief) = scene.belief {
if !belief.is_finite() || !(0.0..=1.0).contains(&belief) {
return Err(err(
format!("actors[{idx}].scenes[{j}].belief"),
"must be a finite probability in scene(0, 1)",
));
}
}
// `route` is a closed enum, so an unrecognised value is already a parse
// error — nothing to check here.
out.push(SceneCs {
start_cs: to_centiseconds(start),
end_cs: to_centiseconds(end),
belief: scene.belief,
route: scene.route,
});
}
// §2: scenes are sorted. Checked on the quantised values so the stored form
// is the one guaranteed ordered.
if out.windows(2).any(|w| w[1].0 < w[0].0) {
if out.windows(2).any(|w| w[1].start_cs < w[0].start_cs) {
return Err(err(format!("actors[{idx}].scenes"), "windows must be sorted by start time"));
}
Ok(out)
@@ -641,13 +687,19 @@ fn base64_decode(s: &str) -> Result<Vec<u8>, &'static str> {
#[cfg(test)]
mod tests {
use super::*;
use crate::model::Scene;
/// A window with timings only — belief and route are exercised separately.
fn scene(start: f64, end: f64) -> Scene {
Scene { start, end, belief: None, route: None }
}
fn base_manifest() -> Jmanifest {
serde_json::from_str(
r#"{"jmanifest_version":1,
r#"{"jmanifest_version":2,
"identity":{"type":"movie","tmdb_id":"504172","title":"The Death of Stalin"},
"cut":{"runtime_sec":6420.5},
"actors":[{"name":"Steve Buscemi","tmdb_id":"884","scenes":[[191.6,209.2]]}]}"#,
"actors":[{"name":"Steve Buscemi","tmdb_id":"884","scenes":[{"start":191.6,"end":209.2}]}]}"#,
)
.unwrap()
}
@@ -656,7 +708,7 @@ mod tests {
fn accepts_a_realistic_manifest() {
let v = validate_manifest(base_manifest()).unwrap();
assert_eq!(v.actor_scenes_cs.len(), 1);
assert_eq!(v.actor_scenes_cs[0].scenes_cs, vec![(19160, 20920)]);
assert_eq!(v.actor_scenes_cs[0].scenes_cs, vec![SceneCs::plain(19160, 20920)]);
}
#[test]
@@ -672,15 +724,20 @@ mod tests {
// left and returned (two windows).
let mut m = base_manifest();
m.actors[0].scenes = vec![
[10.0, 20.0],
[20.0, 30.0], // exactly adjacent — must stay separate
[30.01, 40.0], // a hair's gap — likewise
[100.0, 100.0], // zero-length — a real producer emits these
scene(10.0, 20.0),
scene(20.0, 30.0), // exactly adjacent — must stay separate
scene(30.01, 40.0), // a hair's gap — likewise
scene(100.0, 100.0), // zero-length — a real producer emits these
];
let v = validate_manifest(m).unwrap();
assert_eq!(
v.actor_scenes_cs[0].scenes_cs,
vec![(1000, 2000), (2000, 3000), (3001, 4000), (10000, 10000)],
vec![
SceneCs::plain(1000, 2000),
SceneCs::plain(2000, 3000),
SceneCs::plain(3001, 4000),
SceneCs::plain(10000, 10000)
],
"windows must survive validation unchanged apart from quantisation"
);
}
@@ -723,7 +780,7 @@ mod tests {
#[test]
fn rejects_scene_beyond_runtime_tolerance() {
let mut m = base_manifest();
m.actors[0].scenes = vec![[10.0, 6500.0]];
m.actors[0].scenes = vec![scene(10.0, 6500.0)];
let e = validate_manifest(m).unwrap_err();
assert!(e.field.starts_with("actors[0].scenes"), "got {}", e.field);
}
@@ -731,17 +788,17 @@ mod tests {
#[test]
fn accepts_scene_within_runtime_tolerance() {
let mut m = base_manifest();
m.actors[0].scenes = vec![[10.0, 6424.0]];
m.actors[0].scenes = vec![scene(10.0, 6424.0)];
assert!(validate_manifest(m).is_ok());
}
#[test]
fn rejects_end_before_start_and_negative_and_nonfinite() {
for scenes in [
vec![[50.0, 10.0]],
vec![[-1.0, 10.0]],
vec![[f64::NAN, 10.0]],
vec![[0.0, f64::INFINITY]],
vec![scene(50.0, 10.0)],
vec![scene(-1.0, 10.0)],
vec![scene(f64::NAN, 10.0)],
vec![scene(0.0, f64::INFINITY)],
] {
let mut m = base_manifest();
m.actors[0].scenes = scenes;
@@ -752,7 +809,7 @@ mod tests {
#[test]
fn rejects_unsorted_scenes() {
let mut m = base_manifest();
m.actors[0].scenes = vec![[100.0, 120.0], [10.0, 20.0]];
m.actors[0].scenes = vec![scene(100.0, 120.0), scene(10.0, 20.0)];
let e = validate_manifest(m).unwrap_err();
assert_eq!(e.field, "actors[0].scenes");
}
@@ -791,10 +848,10 @@ mod tests {
#[test]
fn episode_identity_requires_season_and_episode() {
let json = r#"{"jmanifest_version":1,
let json = r#"{"jmanifest_version":2,
"identity":{"type":"episode","series_tmdb_id":"1396","title":"Breaking Bad"},
"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 m: Jmanifest = serde_json::from_str(json).unwrap();
let e = validate_manifest(m).unwrap_err();
assert_eq!(e.field, "identity.season");
@@ -802,11 +859,11 @@ mod tests {
#[test]
fn valid_episode_identity_is_accepted() {
let json = r#"{"jmanifest_version":1,
let json = r#"{"jmanifest_version":2,
"identity":{"type":"episode","series_tmdb_id":"1396","series_imdb_id":"tt0903747",
"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 m: Jmanifest = serde_json::from_str(json).unwrap();
assert!(validate_manifest(m).is_ok());
}
@@ -1040,12 +1097,12 @@ mod tests {
#[test]
fn bundle_envelope_checks() {
let ok = r#"{"jmanifest_version":1,
let ok = r#"{"jmanifest_version":2,
"series":{"series_tmdb_id":"1396","title":"Breaking Bad"},
"episodes":[{"jmanifest_version":1,
"episodes":[{"jmanifest_version":2,
"identity":{"type":"episode","series_tmdb_id":"1396","season":1,"episode":1},
"cut":{"runtime_sec":2820.0},
"actors":[{"tmdb_id":"17419","scenes":[[1.0,2.0]]}]}]}"#;
"actors":[{"tmdb_id":"17419","scenes":[{"start":1.0,"end":2.0}]}]}]}"#;
let b: SeriesBundle = serde_json::from_str(ok).unwrap();
assert!(validate_bundle_envelope(&b).is_ok());
+57 -20
View File
@@ -149,7 +149,7 @@ impl TestServer {
fn movie_manifest(tmdb_id: &str, runtime: f64) -> Value {
json!({
"jmanifest_version": 1,
"jmanifest_version": 2,
"identity": { "type": "movie", "tmdb_id": tmdb_id, "title": "The Death of Stalin",
"year": 2017 },
"cut": { "runtime_sec": runtime, "video_hash": "opensubtitles:8e245d9679d31e12" },
@@ -157,8 +157,8 @@ fn movie_manifest(tmdb_id: &str, runtime: f64) -> Value {
"pipeline_version": "scene-actor-extraction 0.4.1",
"gallery_scope": "global" },
"actors": [
{ "name": "Steve Buscemi", "tmdb_id": "884", "scenes": [[191.6, 209.2], [438.2, 465.6]] },
{ "name": "Michael Palin", "tmdb_id": "11007", "scenes": [[300.0, 320.0]] }
{ "name": "Steve Buscemi", "tmdb_id": "884", "scenes": [{"start":191.6,"end":209.2},{"start":438.2,"end":465.6}] },
{ "name": "Michael Palin", "tmdb_id": "11007", "scenes": [{"start":300.0,"end":320.0}] }
]
})
}
@@ -341,6 +341,39 @@ async fn the_schema_bump_fields_round_trip() {
assert_eq!(status, StatusCode::BAD_REQUEST, "gallery_scope is a closed enum");
}
#[tokio::test]
async fn per_window_belief_and_route_round_trip() {
// SR-003 gives each window its posterior and identification route
// (extraction AR-017). They are stored and served — a consumer needs them to
// know how much to trust a window — but they are never part of identity.
let s = TestServer::new("belief");
let token = s.token().await;
let mut m = movie_manifest("504172", 6420.5);
m["actors"][0]["scenes"] = json!([
{ "start": 191.6, "end": 209.2, "belief": 0.98, "route": "live" },
{ "start": 438.2, "end": 465.6, "belief": 0.81, "route": "deferred" }
]);
let (status, body, _) = s.post_json_auth("/api/v1/manifests", &token, &m).await;
assert_eq!(status, StatusCode::ACCEPTED, "{body}");
// A belief outside [0, 1] is not a probability. Bounded rather than merely
// stored, because §5a's Threat 1 argument rests on every accepted value
// being bounded — an unbounded float is a 64-bit channel.
for bad in [-0.1, 1.5] {
let mut m = movie_manifest("504173", 6420.5);
m["actors"][0]["scenes"] = json!([{ "start": 1.0, "end": 2.0, "belief": bad }]);
let (status, body, _) = s.post_json_auth("/api/v1/manifests", &token, &m).await;
assert_eq!(status, StatusCode::BAD_REQUEST, "belief {bad}: {body}");
}
// `route` is a closed vocabulary, so an invented value cannot be stored.
let mut m = movie_manifest("504174", 6420.5);
m["actors"][0]["scenes"] = json!([{ "start": 1.0, "end": 2.0, "route": "telepathy" }]);
let (status, _, _) = s.post_json_auth("/api/v1/manifests", &token, &m).await;
assert_eq!(status, StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn an_unknown_manifest_version_is_rejected() {
// UR-014 / SR-003: a consumer encountering an unknown `schema_version`
@@ -348,7 +381,11 @@ async fn an_unknown_manifest_version_is_rejected() {
let s = TestServer::new("version");
let token = s.token().await;
for version in [0, 2, 99] {
// Version 1 is the one that matters: SR-003 settled on a **flag day**, not a
// dual-accept period, so the immediately-previous version is refused exactly
// like a nonsensical one. A v1 read path would be the one nobody exercises,
// and so the one that rots while being dragged through every later change.
for version in [0, 1, 3, 99] {
let mut m = movie_manifest("504172", 6420.5);
m["jmanifest_version"] = json!(version);
let (status, body, _) = s.post_json_auth("/api/v1/manifests", &token, &m).await;
@@ -560,7 +597,7 @@ async fn exceeding_a_limit_returns_429_with_retry_after() {
// The bundle surface has the tightest write limit (20/hour), so it is the
// cheapest to exhaust.
let bundle = json!({
"jmanifest_version": 1,
"jmanifest_version": 2,
"series": { "series_tmdb_id": "1396", "title": "Breaking Bad" },
"episodes": []
});
@@ -688,24 +725,24 @@ async fn bundle_upload_is_not_atomic() {
let good = |ep: i64| {
json!({
"jmanifest_version": 1,
"jmanifest_version": 2,
"identity": { "type": "episode", "series_tmdb_id": "1396", "title": "Breaking Bad",
"season": 1, "episode": ep },
"cut": { "runtime_sec": 2820.0 },
"actors": [ { "name": "Bryan Cranston", "tmdb_id": "17419",
"scenes": [[10.0, 20.0]] } ]
"scenes": [{"start":10.0,"end":20.0}] } ]
})
};
// Invalid: a scene window beyond the runtime tolerance (§6).
let bad = json!({
"jmanifest_version": 1,
"jmanifest_version": 2,
"identity": { "type": "episode", "series_tmdb_id": "1396", "season": 1, "episode": 3 },
"cut": { "runtime_sec": 2820.0 },
"actors": [ { "tmdb_id": "17419", "scenes": [[10.0, 99999.0]] } ]
"actors": [ { "tmdb_id": "17419", "scenes": [{"start":10.0,"end":99999.0}] } ]
});
let bundle = json!({
"jmanifest_version": 1,
"jmanifest_version": 2,
"series": { "series_tmdb_id": "1396", "title": "Breaking Bad" },
"episodes": [ good(1), bad, good(2) ]
});
@@ -731,7 +768,7 @@ async fn bundle_envelope_errors_are_whole_request_400s() {
.post_json_auth(
"/api/v1/manifests/bundle",
&token,
&json!({ "jmanifest_version": 1, "series": {}, "episodes": [] }),
&json!({ "jmanifest_version": 2, "series": {}, "episodes": [] }),
)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST, "series needs an identifier");
@@ -740,7 +777,7 @@ async fn bundle_envelope_errors_are_whole_request_400s() {
.post_json_auth(
"/api/v1/manifests/bundle",
&token,
&json!({ "jmanifest_version": 1,
&json!({ "jmanifest_version": 2,
"series": { "series_tmdb_id": "1396" },
"episodes": [], "extra": 1 }),
)
@@ -754,13 +791,13 @@ async fn bundle_rejects_an_episode_contradicting_the_envelope() {
let s = TestServer::new("bundle-mismatch");
let token = s.token().await;
let bundle = json!({
"jmanifest_version": 1,
"jmanifest_version": 2,
"series": { "series_tmdb_id": "1396" },
"episodes": [ {
"jmanifest_version": 1,
"jmanifest_version": 2,
"identity": { "type": "episode", "series_tmdb_id": "9999", "season": 1, "episode": 1 },
"cut": { "runtime_sec": 2820.0 },
"actors": [ { "tmdb_id": "17419", "scenes": [[1.0, 2.0]] } ]
"actors": [ { "tmdb_id": "17419", "scenes": [{"start":1.0,"end":2.0}] } ]
} ]
});
let (status, body, _) = s.post_json_auth("/api/v1/manifests/bundle", &token, &bundle).await;
@@ -776,16 +813,16 @@ async fn bundle_beyond_the_episode_cap_is_413() {
let episodes: Vec<Value> = (0..501)
.map(|i| {
json!({
"jmanifest_version": 1,
"jmanifest_version": 2,
"identity": { "type": "episode", "series_tmdb_id": "1396",
"season": 1, "episode": i },
"cut": { "runtime_sec": 2820.0 },
"actors": [ { "tmdb_id": "17419", "scenes": [[1.0, 2.0]] } ]
"actors": [ { "tmdb_id": "17419", "scenes": [{"start":1.0,"end":2.0}] } ]
})
})
.collect();
let bundle = json!({
"jmanifest_version": 1,
"jmanifest_version": 2,
"series": { "series_tmdb_id": "1396" },
"episodes": episodes
});
@@ -805,7 +842,7 @@ async fn bundle_route_accepts_a_body_larger_than_the_single_manifest_cap() {
.map(|ep| {
let scenes: Vec<Value> = (0..600).map(|j| json!([j as f64, (j + 1) as f64])).collect();
json!({
"jmanifest_version": 1,
"jmanifest_version": 2,
"identity": { "type": "episode", "series_tmdb_id": "1396",
"season": 1, "episode": ep },
"cut": { "runtime_sec": 2820.0 },
@@ -816,7 +853,7 @@ async fn bundle_route_accepts_a_body_larger_than_the_single_manifest_cap() {
})
.collect();
let bundle = json!({
"jmanifest_version": 1,
"jmanifest_version": 2,
"series": { "series_tmdb_id": "1396" },
"episodes": episodes
});
+20 -20
View File
@@ -261,10 +261,10 @@ async fn sql_payloads_in_identifier_fields_are_rejected() {
for payload in SQL_PAYLOADS {
let manifest = json!({
"jmanifest_version": 1,
"jmanifest_version": 2,
"identity": { "type": "movie", "tmdb_id": payload },
"cut": { "runtime_sec": 100.0 },
"actors": [ { "tmdb_id": "884", "scenes": [[1.0, 2.0]] } ]
"actors": [ { "tmdb_id": "884", "scenes": [{"start":1.0,"end":2.0}] } ]
});
let (status, body) = s.post("/api/v1/manifests", Some(&token), &manifest).await;
assert_eq!(
@@ -288,16 +288,16 @@ async fn sql_payloads_in_free_text_fields_are_rejected() {
for payload in SQL_PAYLOADS {
for manifest in [
json!({
"jmanifest_version": 1,
"jmanifest_version": 2,
"identity": { "type": "movie", "tmdb_id": "504172", "title": payload },
"cut": { "runtime_sec": 100.0 },
"actors": [ { "tmdb_id": "884", "scenes": [[1.0, 2.0]] } ]
"actors": [ { "tmdb_id": "884", "scenes": [{"start":1.0,"end":2.0}] } ]
}),
json!({
"jmanifest_version": 1,
"jmanifest_version": 2,
"identity": { "type": "movie", "tmdb_id": "504172" },
"cut": { "runtime_sec": 100.0 },
"actors": [ { "name": payload, "tmdb_id": "884", "scenes": [[1.0, 2.0]] } ]
"actors": [ { "name": payload, "tmdb_id": "884", "scenes": [{"start":1.0,"end":2.0}] } ]
}),
] {
let (status, body) = s.post("/api/v1/manifests", Some(&token), &manifest).await;
@@ -326,10 +326,10 @@ async fn sql_payloads_in_a_report_note_cannot_escape() {
"/api/v1/manifests",
Some(&token),
&json!({
"jmanifest_version": 1,
"jmanifest_version": 2,
"identity": { "type": "movie", "tmdb_id": "504172" },
"cut": { "runtime_sec": 100.0 },
"actors": [ { "tmdb_id": "884", "scenes": [[1.0, 2.0]] } ]
"actors": [ { "tmdb_id": "884", "scenes": [{"start":1.0,"end":2.0}] } ]
}),
)
.await;
@@ -375,10 +375,10 @@ async fn sql_payloads_in_a_bearer_token_are_inert() {
.header("authorization", format!("Bearer {payload}"))
.body(Body::from(
json!({
"jmanifest_version": 1,
"jmanifest_version": 2,
"identity": { "type": "movie", "tmdb_id": "504172" },
"cut": { "runtime_sec": 100.0 },
"actors": [ { "tmdb_id": "884", "scenes": [[1.0, 2.0]] } ]
"actors": [ { "tmdb_id": "884", "scenes": [{"start":1.0,"end":2.0}] } ]
})
.to_string(),
))
@@ -422,16 +422,16 @@ async fn json_structure_abuse_is_rejected_cleanly() {
let cases: Vec<(&str, String)> = vec![
("deep nesting", format!("{}{}", "[".repeat(20_000), "]".repeat(20_000))),
("unterminated", "{\"identity\": {\"type\": \"movie\"".to_string()),
("duplicate keys", r#"{"jmanifest_version":1,"jmanifest_version":2}"#.to_string()),
("duplicate keys", r#"{"jmanifest_version":2,"jmanifest_version":2}"#.to_string()),
("null bytes", "{\"jmanifest_version\":\u{0}1}".to_string()),
("huge number", format!("{{\"jmanifest_version\":{}}}", "9".repeat(5000))),
("nan literal", r#"{"jmanifest_version":1,"cut":{"runtime_sec":NaN}}"#.to_string()),
("nan literal", r#"{"jmanifest_version":2,"cut":{"runtime_sec":NaN}}"#.to_string()),
("bare array", "[1,2,3]".to_string()),
("bare string", "\"just a string\"".to_string()),
("empty body", String::new()),
(
"prototype-style key",
r#"{"__proto__":{"admin":true},"jmanifest_version":1}"#.to_string(),
r#"{"__proto__":{"admin":true},"jmanifest_version":2}"#.to_string(),
),
];
@@ -463,7 +463,7 @@ async fn non_finite_scene_times_are_rejected() {
// Sent as raw JSON text rather than via `json!`, because rustc refuses an
// out-of-range float literal — and the point is to make the *server's* parser
// handle it, which is the real attack path.
let raw = r#"{"jmanifest_version":1,
let raw = r#"{"jmanifest_version":2,
"identity":{"type":"movie","tmdb_id":"504172"},
"cut":{"runtime_sec":100.0},
"actors":[{"tmdb_id":"884","scenes":[[1.0,1e400]]}]}"#;
@@ -479,10 +479,10 @@ async fn non_finite_scene_times_are_rejected() {
assert_eq!(status, StatusCode::BAD_REQUEST, "{body}");
// Likewise an overflowing runtime.
let raw = r#"{"jmanifest_version":1,
let raw = r#"{"jmanifest_version":2,
"identity":{"type":"movie","tmdb_id":"504172"},
"cut":{"runtime_sec":1e400},
"actors":[{"tmdb_id":"884","scenes":[[1.0,2.0]]}]}"#;
"actors":[{"tmdb_id":"884","scenes":[{"start":1.0,"end":2.0}]}]}"#;
let req = Request::builder()
.method("POST")
.uri("/api/v1/manifests")
@@ -583,10 +583,10 @@ async fn unicode_tricks_cannot_smuggle_text_past_the_character_class() {
"/api/v1/manifests",
Some(&token),
&json!({
"jmanifest_version": 1,
"jmanifest_version": 2,
"identity": { "type": "movie", "tmdb_id": "504172" },
"cut": { "runtime_sec": 100.0 },
"actors": [ { "name": payload, "tmdb_id": "884", "scenes": [[1.0, 2.0]] } ]
"actors": [ { "name": payload, "tmdb_id": "884", "scenes": [{"start":1.0,"end":2.0}] } ]
}),
)
.await;
@@ -617,10 +617,10 @@ async fn the_audio_signature_field_cannot_carry_arbitrary_bytes() {
"/api/v1/manifests",
Some(&token),
&json!({
"jmanifest_version": 1,
"jmanifest_version": 2,
"identity": { "type": "movie", "tmdb_id": "504172" },
"cut": { "runtime_sec": 6420.5, "audio_signature": sig },
"actors": [ { "tmdb_id": "884", "scenes": [[1.0, 2.0]] } ]
"actors": [ { "tmdb_id": "884", "scenes": [{"start":1.0,"end":2.0}] } ]
}),
)
.await;