Traceability: vendor the shared gate, annotate the source
Adds jray-project as a submodule at scripts/vendor/jray-project, so this repo runs the same extractor as every other component rather than its own copy, and gains the system spec that defines the PR/SR requirements its register traces up to. scripts/traceability-gate.sh is a thin wrapper holding only what is specific to this repo: UR/DR prefixes, .rs sources, and REPO_ROOT — which the shared gate cannot infer once vendored, since its default resolves to the submodule itself. Each override fails silently in a way that looks like "no work done" rather than "misconfigured", so the wrapper documents why each is needed. Annotates 35 units with TRACES tags, on the code that decides rather than every helper it calls. Coverage is 23/32 (71.9%) with no orphan tags. The nine untraced are genuinely unimplemented: UR-007 is plugin-side, UR-008 is federation, and UR-015..018 are the pending SR-003 schema bump. The gate caught a real error in the first pass: several tags separated IDs of different types with commas. A comma joins IDs within one type; a pipe separates types. Fixed, and the diagnostics are now clean. MIN_COVERAGE stays 0 deliberately. The gate still fails on orphan tags, a >100% ratio, a register parsing to nothing, or an empty source scan — raise the threshold as a ratchet once the remaining work lands. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
[submodule "scripts/vendor/jray-project"]
|
||||
path = scripts/vendor/jray-project
|
||||
url = git@gitea.tourolle.paris:dtourolle/jray-project.git
|
||||
@@ -110,10 +110,23 @@ curl -sX POST -H 'content-type: application/json' -d '{}' \
|
||||
## Tests
|
||||
|
||||
```sh
|
||||
cargo test # 178 tests
|
||||
cargo deny check # advisories, licences, bans, sources
|
||||
cargo test # 189 tests
|
||||
cargo deny check # advisories, licences, bans, sources
|
||||
scripts/traceability-gate.sh # requirement coverage
|
||||
```
|
||||
|
||||
The traceability gate needs the shared tooling submodule:
|
||||
|
||||
```sh
|
||||
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.
|
||||
|
||||
Unit tests per module, plus two integration suites:
|
||||
|
||||
- `tests/api.rs` — end-to-end through the real router: status codes, headers, and
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
# Requirements traceability matrix
|
||||
|
||||
<!-- GENERATED FILE - do not edit by hand. -->
|
||||
<!-- Regenerate: python3 scripts/traceability/extract_traces.py --format markdown --markdown-out docs/traceability.md -->
|
||||
|
||||
**Generated:** 2026-07-30T16:25:39+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 CI host can execute — CI is an Intel N100 with no discrete GPU.
|
||||
|
||||
## Summary
|
||||
|
||||
| Metric | Value |
|
||||
|---|---|
|
||||
| Source files scanned | 26 |
|
||||
| TRACES tags found | 35 |
|
||||
| EXCEPTION tags found | 0 |
|
||||
| Requirements defined | 32 |
|
||||
| Requirements covered | 23 |
|
||||
| **Coverage** | **71.9%** (23/32) |
|
||||
| Coverage of CI-executable scope | 71.9% (23/32) |
|
||||
| Tagged but unexecuted in CI (T4/GPU) | 0 |
|
||||
| Orphan tags | 0 |
|
||||
|
||||
### By type
|
||||
|
||||
| Type | Covered | Tagged but unexecuted | Defined |
|
||||
|---|---|---|---|
|
||||
| UR | 13 | 0 | 18 |
|
||||
| DR | 10 | 0 | 14 |
|
||||
|
||||
- **PR** tags present (separate taxonomy, not counted in coverage): PR-004, PR-005, PR-006
|
||||
- **SR** tags present (separate taxonomy, not counted in coverage): SR-001, SR-002, SR-003, SR-004, SR-005
|
||||
|
||||
## Not executable in CI
|
||||
|
||||
CI runs on an Intel N100 with no discrete GPU. These requirements have no verification tier that can run here, so a tag on them is evidence of *intent*, not of verification. They are never counted as covered.
|
||||
|
||||
_None._
|
||||
|
||||
## Orphan tags
|
||||
|
||||
A tag naming an ID `requirements.md` does not define. This is what renumbering produces, and what a typo produces.
|
||||
|
||||
_None._
|
||||
|
||||
## Requirements tracing up to nothing
|
||||
|
||||
A register row whose `Traces to` cell names no parent. Work serving no stated goal is how scope creeps in, and it is invisible unless something looks.
|
||||
|
||||
_None._
|
||||
|
||||
## Recorded exceptions
|
||||
|
||||
Deliberate, documented departures from an invariant (`EXCEPTION: AR-nnn <reason>`). Reported separately and never counted as coverage — an exception is a decision to be reviewed, not evidence a requirement is met.
|
||||
|
||||
_None._
|
||||
|
||||
## Register
|
||||
|
||||
| ID | Status | Tier | Traces to | Trace state | Tagged in | Requirement |
|
||||
|---|---|---|---|---|---|---|
|
||||
| UR-001 | Done | T2 | SR-001 | covered | `src/api/exists.rs`, `src/matching.rs` | Cheap existence probe, separate from the fetch, returning availabilit… |
|
||||
| UR-002 | Done | T2 | PR-006 | covered | `src/api/upload.rs`, `src/ingest.rs` | Accept a contributed manifest for a media item |
|
||||
| UR-003 | Done | T1, T2 | SR-004 | covered | `src/api/upload.rs`, `src/castcheck.rs`, `src/model.rs`, `src/validate.rs`, `src/worker.rs` | Content verification: strict schema, size caps, approximate TMDB cast… |
|
||||
| UR-004 | Done | T1, T2 | SR-004 | covered | `src/auth.rs`, `src/ratelimit.rs` | Rate limiting, per token where present and per source IP otherwise |
|
||||
| UR-005 | Done | T1, T2 | SR-004 | covered | `src/api/report.rs`, `src/api/upload.rs`, `src/auth.rs`, `src/castcheck.rs`, `src/worker.rs` | Trust without accounts: not usable as a content store, nor for prank … |
|
||||
| UR-006 | Done | T2 | PR-006 | covered | `src/api/fetch.rs`, `src/api/upload.rs`, `src/validate.rs` | Serve and accept a whole series in one operation |
|
||||
| UR-007 | In Progress | unset | PR-005 | covered | `src/api/exists.rs` | Plugin queries an ordered, configurable list of servers |
|
||||
| UR-008 | Planned | unset | PR-006 | untagged | - | Servers replicate manifests between each other |
|
||||
| UR-009 | In Progress | T1 | SR-003 | covered | `src/validate.rs` | Store an audio spectral-peak signature for content-based identificati… |
|
||||
| UR-010 | Done | T1, T2 | SR-001 | covered | `src/api/fetch.rs`, `src/castcheck.rs`, `src/db/repo.rs`, `src/model.rs` | Identity crossing the API boundary is TMDB/IMDB ids, never a name alo… |
|
||||
| UR-011 | Done | T2 | SR-004 | covered | `src/model.rs`, `src/validate.rs` | Reject any field capable of carrying binary or attacker-chosen content |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| DR-004 | Done | unset | PR-004 | covered | `src/db/repo.rs` | All database access behind a repository layer, not scattered through … |
|
||||
| DR-005 | Done | T1 | PR-004 | covered | `src/db/repo.rs` | Background work in-process, with the job queue as a table so it survi… |
|
||||
| DR-006 | Done | unset | PR-004 | covered | `src/ratelimit.rs` | Rate-limit counters in process memory; no external counter store |
|
||||
| DR-007 | Done | unset | PR-004 | untagged | - | Ship a single static binary plus one database file; container optional |
|
||||
| DR-008 | Done | T2 | SR-004 | covered | `src/auth.rs`, `src/config.rs` | `X-Forwarded-For` honoured only from explicitly configured proxies |
|
||||
| DR-009 | Done | T2 | SR-004 | covered | `src/app.rs` | Body caps enforced while streaming, before parsing, per route |
|
||||
| DR-010 | Done | T1 | SR-003 | covered | `src/api/json.rs` | Request bodies are UTF-8 only, rejected with a diagnosable error othe… |
|
||||
| DR-011 | Done | T1 | SR-003 | covered | `src/content_id.rs`, `src/validate.rs` | `content_id` canonical form is byte-stable and cross-implementation t… |
|
||||
| DR-012 | Done | unset | PR-004 | untagged | - | Dependency audit: advisories, licence policy, source policy |
|
||||
| DR-013 | Done | T1 | SR-003 | covered | `src/api/json.rs`, `src/app.rs`, `src/error.rs` | API errors use the status codes the spec names, not the framework's d… |
|
||||
| DR-014 | Done | unset | PR-004 | untagged | - | Portable SQL — no SQLite-specific form where a standard one exists |
|
||||
|
||||
## Detailed mapping
|
||||
|
||||
### DR-002
|
||||
|
||||
**Locations:** 3
|
||||
|
||||
- [`src/api/fetch.rs:267`](../src/api/fetch.rs#L267) — `Unknown`
|
||||
- [`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) — `Unknown`
|
||||
|
||||
### DR-003
|
||||
|
||||
**Locations:** 1
|
||||
|
||||
- [`src/db/mod.rs:30`](../src/db/mod.rs#L30) — `struct ReadPool`
|
||||
|
||||
### DR-004
|
||||
|
||||
**Locations:** 1
|
||||
|
||||
- [`src/db/repo.rs:337`](../src/db/repo.rs#L337) — `pub fn insert_manifest(tx: &Transaction<'_>, m: &NewManifest<'_>) -> anyhow::Result<()>`
|
||||
|
||||
### DR-005
|
||||
|
||||
**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…`
|
||||
|
||||
### DR-006
|
||||
|
||||
**Locations:** 1
|
||||
|
||||
- [`src/ratelimit.rs:76`](../src/ratelimit.rs#L76) — `struct Window`
|
||||
|
||||
### DR-008
|
||||
|
||||
**Locations:** 2
|
||||
|
||||
- [`src/auth.rs:76`](../src/auth.rs#L76) — `pub fn client_ip(headers: &HeaderMap, peer: Option<IpAddr>, trusted_proxies: &[IpAddr]) -…`
|
||||
- [`src/config.rs:10`](../src/config.rs#L10) — `Unknown`
|
||||
|
||||
### DR-009
|
||||
|
||||
**Locations:** 1
|
||||
|
||||
- [`src/app.rs:26`](../src/app.rs#L26) — `pub fn router(state: AppState) -> Router`
|
||||
|
||||
### DR-010
|
||||
|
||||
**Locations:** 1
|
||||
|
||||
- [`src/api/json.rs:100`](../src/api/json.rs#L100) — `fn require_utf8(bytes: &[u8]) -> Result<&str, ApiError>`
|
||||
|
||||
### DR-011
|
||||
|
||||
**Locations:** 3
|
||||
|
||||
- [`src/content_id.rs:52`](../src/content_id.rs#L52) — `Unknown`
|
||||
- [`src/content_id.rs:129`](../src/content_id.rs#L129) — `fn push_opt_num(s: &mut String, v: Option<i64>)`
|
||||
- [`src/validate.rs:78`](../src/validate.rs#L78) — `pub fn to_centiseconds(secs: f64) -> i64`
|
||||
|
||||
### DR-013
|
||||
|
||||
**Locations:** 3
|
||||
|
||||
- [`src/api/json.rs:100`](../src/api/json.rs#L100) — `fn require_utf8(bytes: &[u8]) -> Result<&str, ApiError>`
|
||||
- [`src/app.rs:26`](../src/app.rs#L26) — `pub fn router(state: AppState) -> Router`
|
||||
- [`src/error.rs:8`](../src/error.rs#L8) — `Unknown`
|
||||
|
||||
### PR-004
|
||||
|
||||
**Locations:** 3
|
||||
|
||||
- [`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…`
|
||||
|
||||
### PR-005
|
||||
|
||||
**Locations:** 1
|
||||
|
||||
- [`src/api/exists.rs:74`](../src/api/exists.rs#L74) — `Unknown`
|
||||
|
||||
### PR-006
|
||||
|
||||
**Locations:** 5
|
||||
|
||||
- [`src/api/fetch.rs:128`](../src/api/fetch.rs#L128) — `Unknown`
|
||||
- [`src/api/upload.rs:29`](../src/api/upload.rs#L29) — `Unknown`
|
||||
- [`src/api/upload.rs:101`](../src/api/upload.rs#L101) — `Unknown`
|
||||
- [`src/ingest.rs:50`](../src/ingest.rs#L50) — `Unknown`
|
||||
- [`src/validate.rs:540`](../src/validate.rs#L540) — `pub fn validate_bundle_envelope(b: &SeriesBundle) -> VResult<()>`
|
||||
|
||||
### SR-001
|
||||
|
||||
**Locations:** 7
|
||||
|
||||
- [`src/api/exists.rs:61`](../src/api/exists.rs#L61) — `Unknown`
|
||||
- [`src/api/exists.rs:74`](../src/api/exists.rs#L74) — `Unknown`
|
||||
- [`src/api/fetch.rs:267`](../src/api/fetch.rs#L267) — `Unknown`
|
||||
- [`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) — `Unknown`
|
||||
- [`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
|
||||
|
||||
- [`src/api/fetch.rs:267`](../src/api/fetch.rs#L267) — `Unknown`
|
||||
- [`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)>>`
|
||||
|
||||
### SR-003
|
||||
|
||||
**Locations:** 8
|
||||
|
||||
- [`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) — `Unknown`
|
||||
- [`src/content_id.rs:129`](../src/content_id.rs#L129) — `fn push_opt_num(s: &mut String, v: Option<i64>)`
|
||||
- [`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<()>`
|
||||
|
||||
### SR-004
|
||||
|
||||
**Locations:** 16
|
||||
|
||||
- [`src/api/report.rs:56`](../src/api/report.rs#L56) — `Unknown`
|
||||
- [`src/api/upload.rs:29`](../src/api/upload.rs#L29) — `Unknown`
|
||||
- [`src/api/upload.rs:223`](../src/api/upload.rs#L223) — `Unknown`
|
||||
- [`src/app.rs:26`](../src/app.rs#L26) — `pub fn router(state: AppState) -> Router`
|
||||
- [`src/auth.rs:22`](../src/auth.rs#L22) — `pub fn hash_token(token: &str) -> String`
|
||||
- [`src/auth.rs:76`](../src/auth.rs#L76) — `pub fn client_ip(headers: &HeaderMap, peer: Option<IpAddr>, trusted_proxies: &[IpAddr]) -…`
|
||||
- [`src/castcheck.rs:82`](../src/castcheck.rs#L82) — `pub fn evaluate(submitted: &[SubmittedActor], credits: &[CastMember]) -> CastCheckOutcome`
|
||||
- [`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/ratelimit.rs:76`](../src/ratelimit.rs#L76) — `struct Window`
|
||||
- [`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/worker.rs:96`](../src/worker.rs#L96) — `async fn run_cast_check(&self, payload: &str) -> Result<(), JobError>`
|
||||
|
||||
### SR-005
|
||||
|
||||
**Locations:** 2
|
||||
|
||||
- [`src/db/repo.rs:337`](../src/db/repo.rs#L337) — `pub fn insert_manifest(tx: &Transaction<'_>, m: &NewManifest<'_>) -> anyhow::Result<()>`
|
||||
- [`src/ingest.rs:50`](../src/ingest.rs#L50) — `Unknown`
|
||||
|
||||
### UR-001
|
||||
|
||||
**Locations:** 3
|
||||
|
||||
- [`src/api/exists.rs:61`](../src/api/exists.rs#L61) — `Unknown`
|
||||
- [`src/api/exists.rs:74`](../src/api/exists.rs#L74) — `Unknown`
|
||||
- [`src/matching.rs:52`](../src/matching.rs#L52) — `pub fn match_cut(client: &ClientCut, stored: &StoredCut) -> Option<CutMatch>`
|
||||
|
||||
### UR-002
|
||||
|
||||
**Locations:** 2
|
||||
|
||||
- [`src/api/upload.rs:29`](../src/api/upload.rs#L29) — `Unknown`
|
||||
- [`src/ingest.rs:50`](../src/ingest.rs#L50) — `Unknown`
|
||||
|
||||
### UR-003
|
||||
|
||||
**Locations:** 6
|
||||
|
||||
- [`src/api/upload.rs:29`](../src/api/upload.rs#L29) — `Unknown`
|
||||
- [`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/worker.rs:96`](../src/worker.rs#L96) — `async fn run_cast_check(&self, payload: &str) -> Result<(), JobError>`
|
||||
|
||||
### UR-004
|
||||
|
||||
**Locations:** 2
|
||||
|
||||
- [`src/auth.rs:76`](../src/auth.rs#L76) — `pub fn client_ip(headers: &HeaderMap, peer: Option<IpAddr>, trusted_proxies: &[IpAddr]) -…`
|
||||
- [`src/ratelimit.rs:76`](../src/ratelimit.rs#L76) — `struct Window`
|
||||
|
||||
### UR-005
|
||||
|
||||
**Locations:** 6
|
||||
|
||||
- [`src/api/report.rs:56`](../src/api/report.rs#L56) — `Unknown`
|
||||
- [`src/api/upload.rs:223`](../src/api/upload.rs#L223) — `Unknown`
|
||||
- [`src/auth.rs:22`](../src/auth.rs#L22) — `pub fn hash_token(token: &str) -> String`
|
||||
- [`src/castcheck.rs:82`](../src/castcheck.rs#L82) — `pub fn evaluate(submitted: &[SubmittedActor], credits: &[CastMember]) -> CastCheckOutcome`
|
||||
- [`src/castcheck.rs:215`](../src/castcheck.rs#L215) — `pub fn category_guard_violation(matched: &[MatchedActor], title_is_adult: bool) -> Option…`
|
||||
- [`src/worker.rs:96`](../src/worker.rs#L96) — `async fn run_cast_check(&self, payload: &str) -> Result<(), JobError>`
|
||||
|
||||
### UR-006
|
||||
|
||||
**Locations:** 3
|
||||
|
||||
- [`src/api/fetch.rs:128`](../src/api/fetch.rs#L128) — `Unknown`
|
||||
- [`src/api/upload.rs:101`](../src/api/upload.rs#L101) — `Unknown`
|
||||
- [`src/validate.rs:540`](../src/validate.rs#L540) — `pub fn validate_bundle_envelope(b: &SeriesBundle) -> VResult<()>`
|
||||
|
||||
### UR-007
|
||||
|
||||
**Locations:** 1
|
||||
|
||||
- [`src/api/exists.rs:74`](../src/api/exists.rs#L74) — `Unknown`
|
||||
|
||||
### UR-009
|
||||
|
||||
**Locations:** 1
|
||||
|
||||
- [`src/validate.rs:354`](../src/validate.rs#L354) — `pub fn validate_audio_signature(sig: &str, runtime_sec: f64) -> VResult<()>`
|
||||
|
||||
### UR-010
|
||||
|
||||
**Locations:** 4
|
||||
|
||||
- [`src/api/fetch.rs:267`](../src/api/fetch.rs#L267) — `Unknown`
|
||||
- [`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) — `Unknown`
|
||||
- [`src/model.rs:179`](../src/model.rs#L179) — `Unknown`
|
||||
|
||||
### UR-011
|
||||
|
||||
**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<()>`
|
||||
|
||||
### UR-012
|
||||
|
||||
**Locations:** 2
|
||||
|
||||
- [`src/db/repo.rs:337`](../src/db/repo.rs#L337) — `pub fn insert_manifest(tx: &Transaction<'_>, m: &NewManifest<'_>) -> anyhow::Result<()>`
|
||||
- [`src/ingest.rs:50`](../src/ingest.rs#L50) — `Unknown`
|
||||
|
||||
### UR-013
|
||||
|
||||
**Locations:** 3
|
||||
|
||||
- [`src/api/fetch.rs:267`](../src/api/fetch.rs#L267) — `Unknown`
|
||||
- [`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)>>`
|
||||
|
||||
### 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>`
|
||||
|
||||
Executable
+58
@@ -0,0 +1,58 @@
|
||||
#!/bin/sh
|
||||
#
|
||||
# Requirement traceability gate for JRay-public-server.
|
||||
#
|
||||
# A thin wrapper over the shared implementation in the `jray-project` submodule.
|
||||
# Everything repo-specific lives here; the tooling itself is identical across all
|
||||
# three components, so a fix to the gate benefits every repo rather than one.
|
||||
#
|
||||
# scripts/traceability-gate.sh
|
||||
#
|
||||
# The submodule must be checked out. If `scripts/vendor/jray-project` is empty:
|
||||
#
|
||||
# git submodule update --init --recursive
|
||||
#
|
||||
# Environment (passed through to the shared gate):
|
||||
# MIN_COVERAGE minimum overall coverage percent
|
||||
# ALLOW_ORPHANS set to 1 to report orphan tags without failing
|
||||
|
||||
set -eu
|
||||
|
||||
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||
REPO_ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd)
|
||||
VENDOR="$SCRIPT_DIR/vendor/jray-project"
|
||||
|
||||
if [ ! -f "$VENDOR/scripts/traceability/traceability-gate.sh" ]; then
|
||||
echo "FAILED: the jray-project submodule is not checked out." >&2
|
||||
echo " Run: git submodule update --init --recursive" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# Why each override is needed, since omitting any of them fails silently in a
|
||||
# way that looks like "no work done" rather than "misconfigured":
|
||||
#
|
||||
# REPO_ROOT the shared gate defaults to two levels above itself, which is
|
||||
# inside the submodule once vendored.
|
||||
# TYPES this repo's register uses UR/DR. The default is the extraction
|
||||
# pipeline's AR/DP/IR/GR/VR, under which the register parses to
|
||||
# ZERO requirements.
|
||||
# SUFFIXES this repo is Rust. The default is C++/Python, under which the
|
||||
# source tree scans to ZERO files.
|
||||
# SYSTEM_SPEC the PR/SR requirements live in the project home, which is the
|
||||
# submodule itself — so it ships with the tool that reads it.
|
||||
#
|
||||
# MIN_COVERAGE stays 0 until the TRACES annotation pass lands. That does not
|
||||
# make the gate toothless: orphan tags, a >100% ratio, a register parsing to
|
||||
# nothing and an empty source scan are all hard failures from day one. Raise it
|
||||
# as tags land, and treat every raise as a ratchet.
|
||||
REPO_ROOT="$REPO_ROOT" \
|
||||
REQUIREMENTS="$REPO_ROOT/docs/requirements.md" \
|
||||
SYSTEM_SPEC="$VENDOR/SPEC.md" \
|
||||
TYPES="UR,DR" \
|
||||
SUFFIXES=".rs" \
|
||||
SCAN_ROOTS="src,tests" \
|
||||
MIN_COVERAGE="${MIN_COVERAGE:-0}" \
|
||||
ALLOW_ORPHANS="${ALLOW_ORPHANS:-0}" \
|
||||
TRACES_JSON="${TRACES_JSON:-$REPO_ROOT/traces-report.json}" \
|
||||
TRACES_MD="${TRACES_MD:-$REPO_ROOT/docs/traceability.md}" \
|
||||
exec sh "$VENDOR/scripts/traceability/traceability-gate.sh"
|
||||
+1
Submodule scripts/vendor/jray-project added at 041961c8c6
@@ -58,6 +58,7 @@ pub struct BatchResponse {
|
||||
pub results: Vec<ExistsResponse>,
|
||||
}
|
||||
|
||||
/// TRACES: UR-001 | SR-001
|
||||
pub async fn exists(
|
||||
State(state): State<AppState>,
|
||||
peer: crate::state::PeerIp,
|
||||
@@ -70,6 +71,7 @@ pub async fn exists(
|
||||
Ok(with_quota_headers(Json(body).into_response(), quota))
|
||||
}
|
||||
|
||||
/// TRACES: UR-001, UR-007 | SR-001 | PR-005
|
||||
pub async fn exists_batch(
|
||||
State(state): State<AppState>,
|
||||
peer: crate::state::PeerIp,
|
||||
|
||||
@@ -125,6 +125,7 @@ async fn fetch_best(
|
||||
/// bundle with 9 of 13 episodes is a valid, useful response, not an error (§2).
|
||||
/// Episode-level cut matching is done client-side against the returned bundle,
|
||||
/// since a client pulling a whole series already knows its own runtimes.
|
||||
/// TRACES: UR-006 | PR-006
|
||||
pub async fn get_series(
|
||||
State(state): State<AppState>,
|
||||
peer: crate::state::PeerIp,
|
||||
@@ -263,6 +264,7 @@ fn title_of(conn: &rusqlite::Connection, title_id: &str) -> anyhow::Result<repo:
|
||||
/// Names come from `people` — populated from TMDB by the server — so `name` is
|
||||
/// server-authoritative on download and a name a contributor invented does not
|
||||
/// round-trip (§2, §5a).
|
||||
/// TRACES: UR-010, UR-013 | DR-002 | SR-001, SR-002
|
||||
pub fn reconstruct(
|
||||
conn: &rusqlite::Connection,
|
||||
row: &ManifestRow,
|
||||
|
||||
@@ -97,6 +97,7 @@ where
|
||||
/// serde_json would reject non-UTF-8 anyway; the value added here is a diagnosable
|
||||
/// error rather than a misleading one. A UTF-16 body otherwise fails with "key
|
||||
/// must be a string", which points an operator at the wrong problem entirely.
|
||||
/// TRACES: DR-010, DR-013 | SR-003
|
||||
fn require_utf8(bytes: &[u8]) -> Result<&str, ApiError> {
|
||||
// A BOM is not valid JSON (RFC 8259 §8.1: "implementations MUST NOT add a
|
||||
// byte order mark"), and it is the clearest signal of an encoding mistake, so
|
||||
|
||||
@@ -53,6 +53,7 @@ pub struct ReportAccepted {
|
||||
pub report_id: String,
|
||||
}
|
||||
|
||||
/// TRACES: UR-005 | SR-004
|
||||
pub async fn post_report(
|
||||
State(state): State<AppState>,
|
||||
peer: crate::state::PeerIp,
|
||||
|
||||
@@ -26,6 +26,7 @@ pub struct UploadAccepted {
|
||||
}
|
||||
|
||||
/// `POST /manifests` — UR-2.
|
||||
/// TRACES: UR-002, UR-003 | SR-004 | PR-006
|
||||
pub async fn post_manifest(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
@@ -97,6 +98,7 @@ pub struct BundleAccepted {
|
||||
///
|
||||
/// **One rate-limit unit**, so contributing a season is not punished relative to
|
||||
/// contributing a film (§2, §5).
|
||||
/// TRACES: UR-006 | PR-006
|
||||
pub async fn post_bundle(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
@@ -218,6 +220,7 @@ pub struct TokenIssued {
|
||||
/// only as a hash, so the server cannot enumerate who holds tokens. Discarding a
|
||||
/// token and requesting another is trivially easy — and that is fine, because the
|
||||
/// token is not the defence; the content checks are.
|
||||
/// TRACES: UR-005 | SR-004
|
||||
pub async fn post_token(
|
||||
State(state): State<AppState>,
|
||||
peer: crate::state::PeerIp,
|
||||
|
||||
@@ -23,6 +23,7 @@ use crate::validate::limits;
|
||||
/// 100 items.
|
||||
const SMALL_BODY_LIMIT: usize = 256 * 1024;
|
||||
|
||||
/// TRACES: DR-009, DR-013 | SR-004
|
||||
pub fn router(state: AppState) -> Router {
|
||||
let timeout = state.config.request_timeout;
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ use sha2::{Digest, Sha256};
|
||||
/// Plain SHA-256 rather than a password KDF is deliberate and sufficient here:
|
||||
/// tokens are 256 bits of server-generated randomness, not user-chosen secrets,
|
||||
/// so there is no dictionary to attack.
|
||||
/// TRACES: UR-005 | SR-004
|
||||
pub fn hash_token(token: &str) -> String {
|
||||
let mut h = Sha256::new();
|
||||
h.update(token.as_bytes());
|
||||
@@ -72,6 +73,7 @@ pub fn bearer_token(headers: &HeaderMap) -> Option<String> {
|
||||
/// Rate limiting and report attribution key on client IP, so a spoofable header
|
||||
/// defeats both — hence `trusted_proxies` is explicit configuration and an
|
||||
/// untrusted peer's header is ignored outright.
|
||||
/// TRACES: UR-004 | DR-008 | SR-004
|
||||
pub fn client_ip(headers: &HeaderMap, peer: Option<IpAddr>, trusted_proxies: &[IpAddr]) -> String {
|
||||
let peer_is_trusted = peer.is_some_and(|p| trusted_proxies.contains(&p));
|
||||
|
||||
|
||||
@@ -79,6 +79,7 @@ fn name_key(s: &str) -> String {
|
||||
///
|
||||
/// `credits` is the reference set *C*: for a movie, its credits; for an episode,
|
||||
/// the union of per-episode credits and the series' aggregate credits.
|
||||
/// TRACES: UR-003, UR-005, UR-010 | SR-001, SR-004
|
||||
pub fn evaluate(submitted: &[SubmittedActor], credits: &[CastMember]) -> CastCheckOutcome {
|
||||
let m = submitted.len();
|
||||
|
||||
@@ -211,6 +212,7 @@ fn classify(m: usize, matches: usize, ratio: f64) -> Verdict {
|
||||
///
|
||||
/// Rejects when a matched person is flagged adult by TMDB and the target title
|
||||
/// is not, which targets the stated prank without needing a blocklist of names.
|
||||
/// TRACES: UR-005 | SR-004
|
||||
pub fn category_guard_violation(matched: &[MatchedActor], title_is_adult: bool) -> Option<u64> {
|
||||
if title_is_adult {
|
||||
return None;
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
use std::net::IpAddr;
|
||||
use std::time::Duration;
|
||||
|
||||
/// TRACES: DR-008 | PR-004
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Config {
|
||||
pub bind: String,
|
||||
|
||||
@@ -49,6 +49,7 @@ pub struct CanonicalCut {
|
||||
///
|
||||
/// `extraction` metadata and all local state are excluded, so two servers that
|
||||
/// validated the same upload independently arrive at the same `content_id`.
|
||||
/// TRACES: DR-011 | SR-003
|
||||
pub fn canonical_json(
|
||||
identity: &CanonicalIdentity,
|
||||
cut: &CanonicalCut,
|
||||
@@ -125,6 +126,7 @@ fn push_opt_num(s: &mut String, v: Option<i64>) {
|
||||
}
|
||||
|
||||
/// `sha256:` over the canonical form (§9a).
|
||||
/// TRACES: DR-011 | SR-003
|
||||
pub fn content_id(
|
||||
identity: &CanonicalIdentity,
|
||||
cut: &CanonicalCut,
|
||||
|
||||
@@ -27,6 +27,7 @@ const SCHEMA: &str = include_str!("schema.sql");
|
||||
/// Handle to the database: one serialized writer, plus read connections.
|
||||
///
|
||||
/// Cloning is cheap and shares the same underlying connections.
|
||||
/// TRACES: DR-003 | PR-004
|
||||
#[derive(Clone)]
|
||||
pub struct Db {
|
||||
writer: Arc<Mutex<Connection>>,
|
||||
|
||||
@@ -334,6 +334,7 @@ pub struct NewManifest<'a> {
|
||||
pub created_at: &'a str,
|
||||
}
|
||||
|
||||
/// TRACES: UR-012 | DR-002, DR-004 | SR-004, SR-005
|
||||
pub fn insert_manifest(tx: &Transaction<'_>, m: &NewManifest<'_>) -> anyhow::Result<()> {
|
||||
tx.execute(
|
||||
"INSERT INTO manifests
|
||||
@@ -401,6 +402,7 @@ pub struct StoredActor {
|
||||
pub scenes_cs: Vec<(i64, i64)>,
|
||||
}
|
||||
|
||||
/// TRACES: UR-010 | DR-002 | SR-001
|
||||
pub fn actors_for_manifest(
|
||||
conn: &Connection,
|
||||
manifest_id: &str,
|
||||
@@ -686,6 +688,7 @@ pub fn enqueue_job(
|
||||
|
||||
/// Claims up to `limit` due jobs, marking them leased so a second worker tick
|
||||
/// cannot pick up the same work.
|
||||
/// TRACES: DR-005 | PR-004
|
||||
pub fn lease_jobs(tx: &Transaction<'_>, now: &str, limit: usize) -> anyhow::Result<Vec<Job>> {
|
||||
let jobs: Vec<Job> = {
|
||||
let mut stmt = tx.prepare(
|
||||
|
||||
@@ -5,6 +5,7 @@ use axum::response::{IntoResponse, Response};
|
||||
use axum::Json;
|
||||
use serde::Serialize;
|
||||
|
||||
/// TRACES: DR-013 | SR-003
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ApiError {
|
||||
/// §6 stage 2 — malformed, unrecognised or forbidden field. The message
|
||||
|
||||
@@ -47,6 +47,7 @@ pub const JOB_CAST_CHECK: &str = "cast_check";
|
||||
/// Persists a validated manifest and enqueues its cast check, all in one
|
||||
/// transaction — so a manifest is never left listed-but-unchecked, and its scene
|
||||
/// rows go in as a single transaction rather than one per row (§8).
|
||||
/// TRACES: UR-002, UR-012 | SR-005 | PR-006
|
||||
pub fn persist(
|
||||
tx: &rusqlite::Transaction<'_>,
|
||||
valid: &ValidManifest,
|
||||
|
||||
@@ -49,6 +49,7 @@ pub struct CutMatch {
|
||||
|
||||
/// Compares a client's cut against a stored one, returning the best tier that
|
||||
/// fires, or `None` for "beyond that: no match; do not serve" (§3).
|
||||
/// TRACES: UR-001 | SR-001
|
||||
pub fn match_cut(client: &ClientCut, stored: &StoredCut) -> Option<CutMatch> {
|
||||
// Tier 1 — same file. Checked first and unconditionally: an equal hash is
|
||||
// decisive regardless of what the runtimes say.
|
||||
|
||||
@@ -153,6 +153,7 @@ impl GalleryScope {
|
||||
/// than something silently ignored, which is deliberate: a manifest still
|
||||
/// carrying it was produced by a pipeline whose window semantics differ from
|
||||
/// what this server now assumes.
|
||||
/// TRACES: UR-003, UR-011 | SR-004
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Extraction {
|
||||
@@ -175,6 +176,7 @@ pub struct Extraction {
|
||||
/// Note there is no `jellyfin_id` field: `deny_unknown_fields` means its
|
||||
/// presence is a parse error, which is exactly the §2/§6 requirement that it be
|
||||
/// *rejected on upload* rather than merely ignored on download.
|
||||
/// TRACES: UR-010, UR-013 | SR-001, SR-002
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Actor {
|
||||
@@ -193,6 +195,7 @@ pub struct Actor {
|
||||
}
|
||||
|
||||
/// One shareable actor timeline for one cut of one title (§2).
|
||||
/// TRACES: UR-003, UR-011, UR-014 | SR-003, SR-004
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Jmanifest {
|
||||
|
||||
@@ -73,6 +73,7 @@ struct Window {
|
||||
count: u32,
|
||||
}
|
||||
|
||||
/// TRACES: UR-004 | DR-006 | SR-004
|
||||
pub struct RateLimiter {
|
||||
windows: Mutex<HashMap<(String, Surface), Window>>,
|
||||
}
|
||||
|
||||
@@ -75,6 +75,7 @@ pub struct ActorScenes {
|
||||
/// than dodging it: pipeline timings are *derived* by accumulating `1/fps`, so
|
||||
/// they carry accumulated error, and any value near a rounding boundary would
|
||||
/// otherwise hash differently on two servers.
|
||||
/// TRACES: DR-011 | SR-003
|
||||
pub fn to_centiseconds(secs: f64) -> i64 {
|
||||
(secs * 100.0).round() as i64
|
||||
}
|
||||
@@ -108,6 +109,7 @@ fn is_tmdb_id(s: &str) -> bool {
|
||||
///
|
||||
/// No digits and no `/ + =`, which is what **defeats base64/hex smuggling**. No
|
||||
/// control characters, and no zero-width or bidi-control codepoints.
|
||||
/// TRACES: UR-011 | SR-004
|
||||
fn is_allowed_text_char(c: char) -> bool {
|
||||
if matches!(c, '.' | '\'' | '-' | ',' | ' ') {
|
||||
return true;
|
||||
@@ -198,6 +200,7 @@ fn check_not_path_shaped(field: &str, value: &str) -> VResult<()> {
|
||||
// Manifest validation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// TRACES: UR-003, UR-014 | SR-003, SR-004
|
||||
pub fn validate_manifest(mut m: Jmanifest) -> VResult<ValidManifest> {
|
||||
if m.jmanifest_version != JMANIFEST_VERSION {
|
||||
return Err(err(
|
||||
@@ -348,6 +351,7 @@ fn validate_video_hash(h: &str) -> VResult<()> {
|
||||
///
|
||||
/// `runtime_sec` is needed because §3 shortens the window for very short items;
|
||||
/// see [`expected_min_frames`].
|
||||
/// TRACES: UR-009, UR-011 | SR-003, SR-004
|
||||
pub fn validate_audio_signature(sig: &str, runtime_sec: f64) -> VResult<()> {
|
||||
// IR-007: media shorter than the window emits **no signature**, and no sync
|
||||
// offset is applied to it. A signature present on such an item did not come
|
||||
@@ -479,6 +483,7 @@ fn validate_actors(m: &Jmanifest) -> VResult<Vec<ActorScenes>> {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// TRACES: UR-013 | SR-002
|
||||
fn validate_scenes(idx: usize, a: &Actor, runtime_sec: f64) -> VResult<Vec<(i64, i64)>> {
|
||||
if a.scenes.len() > limits::MAX_SCENES_PER_ACTOR {
|
||||
return Err(err(
|
||||
@@ -532,6 +537,7 @@ fn validate_scenes(idx: usize, a: &Actor, runtime_sec: f64) -> VResult<Vec<(i64,
|
||||
/// episodes are reported in the per-episode results list — the bundle is not
|
||||
/// atomic, because all-or-nothing would let one bad episode discard an entire
|
||||
/// season's compute (§2).
|
||||
/// TRACES: UR-006 | PR-006
|
||||
pub fn validate_bundle_envelope(b: &SeriesBundle) -> VResult<()> {
|
||||
if b.jmanifest_version != JMANIFEST_VERSION {
|
||||
return Err(err(
|
||||
|
||||
@@ -93,6 +93,7 @@ impl Worker {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// TRACES: UR-003, UR-005 | SR-004
|
||||
async fn run_cast_check(&self, payload: &str) -> Result<(), JobError> {
|
||||
let job: CastCheckJob =
|
||||
serde_json::from_str(payload).map_err(|e| JobError::Fatal(e.into()))?;
|
||||
|
||||
Reference in New Issue
Block a user