From 7eb5c175af02eb0b284fbe5c579998549edc80b4 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Fri, 31 Jul 2026 16:26:10 +0200 Subject: [PATCH] docs: tag the untagged verifications, and record the UR-007 conflict MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five tests and the schema constant implemented requirements without carrying a tag, so those requirements read as uncovered when they were not. No behaviour changes here — every edit is a comment. Also documents `static` as a real tier rather than an exemption: it is already in `ci_executable_tiers` and its checks run in CI, and it exists because DR-007's single binary and DR-012's licence policy are properties of the build that a unit test could only assert as theatre. The UR-007 note records a cross-repo status conflict rather than resolving it. `jRay` JR-025 is `Done` and claims UR-007, but the fetch path that would exercise it (`jRay` JR-031) is still `Planned`. Either JR-025 is scoped to selection alone or it over-claims; until that is settled neither register should be trusted for UR-007 coverage. The gate reports 0 orphan tags and 19/19 UR coverage. TRACES: DR-001, DR-014, UR-015, UR-016, UR-018 | SR-003, SR-004 --- docs/requirements.md | 24 ++++++++++++- docs/traceability.md | 85 ++++++++++++++++++++++++++++++++------------ src/db/mod.rs | 6 ++++ src/ingest.rs | 1 + src/model.rs | 1 + src/validate.rs | 1 + tests/api.rs | 3 ++ 7 files changed, 98 insertions(+), 23 deletions(-) diff --git a/docs/requirements.md b/docs/requirements.md index 30af13e..964d3a1 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -53,6 +53,14 @@ requirement and no fixture-generation step, unlike `scene-actor-extraction`. server list and its per-server trust settings, with the community instance pre-configured but disabled. The fetch path that consumes it does not exist yet. +> **The two registers disagree about this and the disagreement is unresolved.** +> `jRay` JR-025 is recorded `Done` and claims to satisfy UR-007, but `jRay` +> JR-031 — the fetch endpoints that would exercise the ordered list — is still +> `Planned`. Either JR-025 is scoped to the *selection* logic alone (in which +> case UR-007 stays open until JR-031 lands) or it over-claims. **Settle this +> before either register is trusted for coverage**; it is the only cross-repo +> status conflict currently outstanding. + **UR-008 is `Done` for the replication surface**: change feed, fetch by `content_id`, batch `have`, the peer directory, and a pull worker that re-validates everything it ingests. Peer *administration* — adding and enabling @@ -107,10 +115,18 @@ make the test suite hermetic. |---|---|---| | **T1 — unit** | Yes | Pure logic: validation, cut matching, cast-check scoring, canonicalisation, rate limiting | | **T2 — integration** | Yes | End-to-end through the real router against a temporary on-disk database | +| **static** | Yes | Build and dependency properties no runtime test can assert — `cargo deny check`, the musl release build, architectural greps | There is no tier that does not run. A requirement here is either verified or visibly not. +**`static` is a real tier, not an exemption.** `ci_executable_tiers` in +[`../traceability.toml`](../traceability.toml) already lists it, and the checks it +names execute in CI like any other. It exists because a handful of requirements +are properties of the *build* rather than of the running program — DR-007's single +binary, DR-012's licence and advisory policy — and asserting those from a unit +test would be theatre. + **Integration tests use an on-disk temporary database, not `:memory:`.** DR-003 specifies one writer connection plus a read pool, and in-memory SQLite is per-connection — the readers would see an empty database. Testing the real @@ -126,7 +142,7 @@ topology is the point, so this is a deliberate choice rather than an oversight. | UR-004 | T1 + T2 | Limits engage and carry the documented headers | Window reset; a rejected request does not extend its own lockout; surfaces have independent budgets | | UR-005 | T1 + T2 | Prank manifests rejected; no free-text channel | Uncredited cast rejected; name-only matches capped; automatic revocation needs a minimum sample | | UR-006 | T2 | Bundle accepted per-episode, non-atomically | One bad episode rejected while its neighbours are accepted; envelope errors are whole-request `400` | -| UR-007 | — | *No server-side test.* Plugin-side (`jRay` JR-025); the register there carries it | — | +| UR-007 | **external** | *No server-side test, and cannot have one.* The obligation is the plugin's: `jRay` JR-025, which is `Done` and tagged in that register | Verified there, not here — counted as covered by cross-reference, never by a test in this repo. **See the status note: JR-025 being `Done` does not by itself close UR-007**, because the fetch path (`jRay` JR-031) is still `Planned` | | UR-008 | T1 + T2 | Feed, fetch-by-hash, batch have, peer directory | **Cursor is strictly monotonic** — a ULID would sort out of write order within a millisecond and silently skip entries; a peer retraction flags rather than delists; only the opt-in abuse channel delists; `pending` is never replicated; **no endpoint can create a peering** | | UR-009 | T1 | Signature structurally validated | Fixed length; reserved high bit; **media < 120 s must send no signature at all** | | UR-010 | T1 + T2 | Actors persist as TMDB person ids | A name the upload invented does not round-trip | @@ -136,7 +152,10 @@ topology is the point, so this is a deliberate choice rather than an oversight. | UR-014 | T1 | Unknown `jmanifest_version` rejected | Version `2` and version `0` both refused, naming the field | | UR-019 | T2 | `POST /tokens` returns the licence and its terms | The terms **bound the grant to the manifest** — a reading that covers the underlying work is the failure, not a missing field | | DR-001 | T1 | Unknown field at any nesting depth fails to parse | `movie` and `jellyfin_id` named in the error | +| DR-002 | T1 | Every manifest field lands in a typed column; no JSON blob on the write path | A round-trip through storage reconstructs the manifest from columns alone | | DR-003 | T1 | Concurrent writes serialize rather than returning `SQLITE_BUSY` | Failed transaction rolls back fully | +| DR-004 | T1 | Handlers reach the database only through `db::repo` | No `Connection` or raw SQL outside the repository layer | +| DR-006 | T1 | Rate-limit counters live in process memory | Counters reset on restart — the documented trade-off, not a bug | | DR-005 | T1 | Jobs lease once, reschedule with backoff, survive restart | Stranded lease released at startup; future job not leased early | | DR-008 | T2 | Forged `X-Forwarded-For` cannot mint a fresh budget | Untrusted peer ignored; trusted proxy honoured; client-supplied entries to the left cannot spoof | | DR-009 | T2 | Oversized body rejected as `413` | **A lying `Content-Length` does not bypass the cap**; per-route limits differ | @@ -147,6 +166,9 @@ topology is the point, so this is a deliberate choice rather than an oversight. | 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` | +| DR-007 | **static** | The musl release target builds and links one binary needing only a database file | Build property, not a runtime one — a unit test asserting it would assert nothing | +| DR-012 | **static** | `cargo deny check` passes advisories, licences and sources | A copyleft-incompatible transitive dependency must fail the build, not be discovered later | +| DR-014 | **static** | `schema.sql` uses no SQLite-specific form where a standard one exists | `INSERT OR REPLACE` must not reappear in place of `INSERT ... ON CONFLICT` | Three are worth singling out, because each verifies a claim that would otherwise be an assertion: diff --git a/docs/traceability.md b/docs/traceability.md index bd3efe1..390f343 100644 --- a/docs/traceability.md +++ b/docs/traceability.md @@ -3,7 +3,7 @@ -**Generated:** 2026-07-31T08:06:49+00:00 +**Generated:** 2026-07-31T14:25:51+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 | 29 | -| TRACES tags found | 43 | +| TRACES tags found | 50 | | EXCEPTION tags found | 0 | | Requirements defined | 33 | -| Requirements covered | 26 | -| **Coverage** | **78.8%** (26/33) | -| Coverage of CI-executable scope | 78.8% (26/33) | +| Requirements covered | 31 | +| **Coverage** | **93.9%** (31/33) | +| Coverage of CI-executable scope | 93.9% (31/33) | | Tagged but unexecuted in CI | 0 | | Orphan tags | 0 | @@ -25,8 +25,8 @@ Denominators are read from [`requirements.md`](requirements.md) at run time, nev | Type | Covered | Tagged but unexecuted | Defined | |---|---|---|---| -| UR | 16 | 0 | 19 | -| DR | 10 | 0 | 14 | +| UR | 19 | 0 | 19 | +| DR | 12 | 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 @@ -73,28 +73,35 @@ _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/api/federation.rs`, `src/model.rs`, `src/validate.rs` | Reject an unknown `jmanifest_version` outright, never guess | -| 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-015 | Done | T2 | SR-003 | covered | `src/validate.rs`, `tests/api.rs` | Accept `extraction.extinction_sec` in place of `anneal_sec` | +| UR-016 | Done | T2 | SR-003 | covered | `src/validate.rs`, `tests/api.rs` | 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… | +| UR-018 | Done | T1 | SR-003 | covered | `src/ingest.rs` | Exclude belief and route from `content_id`, replicating them as attri… | | UR-019 | Done | T2 | PR-006 | covered | `src/api/upload.rs` | Contributed manifests are CC0 1.0; the grant is delivered with the to… | -| 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-001 | Done | T1 | SR-004 | covered | `src/model.rs`, `tests/api.rs` | Strict parse boundary: unknown fields rejected structurally, not by v… | +| DR-002 | Done | T1 | 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-004 | Done | T1 | 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-006 | Done | T1 | PR-004 | covered | `src/ratelimit.rs` | Rate-limit counters in process memory; no external counter store | +| DR-007 | Done | static | 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-012 | Done | static | 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 | +| DR-014 | Done | static | PR-004 | covered | `src/db/mod.rs` | Portable SQL — no SQLite-specific form where a standard one exists | ## Detailed mapping +### DR-001 + +**Locations:** 2 + +- [`src/model.rs:323`](../src/model.rs#L323) — `fn unknown_field_at_top_level_is_rejected()` +- [`tests/api.rs:278`](../tests/api.rs#L278) — `async fn unknown_field_anywhere_is_rejected_with_400()` + ### DR-002 **Locations:** 3 @@ -107,7 +114,7 @@ _None._ **Locations:** 1 -- [`src/db/mod.rs:30`](../src/db/mod.rs#L30) — `struct ReadPool` +- [`src/db/mod.rs:36`](../src/db/mod.rs#L36) — `struct ReadPool` ### DR-004 @@ -162,12 +169,19 @@ _None._ - [`src/app.rs:26`](../src/app.rs#L26) — `pub fn router(state: AppState) -> Router` - [`src/error.rs:8`](../src/error.rs#L8) — `Unknown` +### DR-014 + +**Locations:** 1 + +- [`src/db/mod.rs:30`](../src/db/mod.rs#L30) — `pub mod repo;` + ### PR-004 -**Locations:** 3 +**Locations:** 4 - [`src/config.rs:10`](../src/config.rs#L10) — `Unknown` -- [`src/db/mod.rs:30`](../src/db/mod.rs#L30) — `struct ReadPool` +- [`src/db/mod.rs:30`](../src/db/mod.rs#L30) — `pub mod repo;` +- [`src/db/mod.rs:36`](../src/db/mod.rs#L36) — `struct ReadPool` - [`src/db/repo.rs:702`](../src/db/repo.rs#L702) — `pub fn lease_jobs(tx: &Transaction<'_>, now: &str, limit: usize) -> anyhow::Result) -> ApiResult` - [`src/api/json.rs:100`](../src/api/json.rs#L100) — `fn require_utf8(bytes: &[u8]) -> Result<&str, ApiError>` - [`src/content_id.rs:57`](../src/content_id.rs#L57) — `pub fn canonical_json(` - [`src/content_id.rs:132`](../src/content_id.rs#L132) — `pub fn content_id(` - [`src/error.rs:8`](../src/error.rs#L8) — `Unknown` +- [`src/ingest.rs:387`](../src/ingest.rs#L387) — `fn content_id_excludes_belief_and_route()` - [`src/model.rs:191`](../src/model.rs#L191) — `Unknown` - [`src/model.rs:232`](../src/model.rs#L232) — `pub fn from_stored(s: &str) -> Option` - [`src/model.rs:258`](../src/model.rs#L258) — `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` - [`src/validate.rs:364`](../src/validate.rs#L364) — `pub fn validate_audio_signature(sig: &str, runtime_sec: f64) -> VResult<()>` +- [`src/validate.rs:731`](../src/validate.rs#L731) — `fn extinction_sec_replaces_anneal_sec()` +- [`tests/api.rs:323`](../tests/api.rs#L323) — `async fn the_withdrawn_anneal_sec_field_is_rejected()` +- [`tests/api.rs:347`](../tests/api.rs#L347) — `async fn the_schema_bump_fields_round_trip()` ### SR-004 -**Locations:** 16 +**Locations:** 18 - [`src/api/report.rs:56`](../src/api/report.rs#L56) — `pub async fn post_report(` - [`src/api/upload.rs:29`](../src/api/upload.rs#L29) — `pub async fn post_manifest(` @@ -244,11 +262,13 @@ _None._ - [`src/db/repo.rs:331`](../src/db/repo.rs#L331) — `pub fn insert_manifest(tx: &Transaction<'_>, m: &NewManifest<'_>) -> anyhow::Result<()>` - [`src/model.rs:150`](../src/model.rs#L150) — `Unknown` - [`src/model.rs:258`](../src/model.rs#L258) — `Unknown` +- [`src/model.rs:323`](../src/model.rs#L323) — `fn unknown_field_at_top_level_is_rejected()` - [`src/ratelimit.rs:93`](../src/ratelimit.rs#L93) — `impl Default for RateLimiter` - [`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` - [`src/validate.rs:364`](../src/validate.rs#L364) — `pub fn validate_audio_signature(sig: &str, runtime_sec: f64) -> VResult<()>` - [`src/worker.rs:150`](../src/worker.rs#L150) — `async fn run_cast_check(&self, payload: &str) -> Result<(), JobError>` +- [`tests/api.rs:278`](../tests/api.rs#L278) — `async fn unknown_field_anywhere_is_rejected_with_400()` ### SR-005 @@ -374,6 +394,21 @@ _None._ - [`src/model.rs:258`](../src/model.rs#L258) — `Unknown` - [`src/validate.rs:227`](../src/validate.rs#L227) — `pub fn validate_manifest(mut m: Jmanifest) -> VResult` +### UR-015 + +**Locations:** 3 + +- [`src/validate.rs:731`](../src/validate.rs#L731) — `fn extinction_sec_replaces_anneal_sec()` +- [`tests/api.rs:323`](../tests/api.rs#L323) — `async fn the_withdrawn_anneal_sec_field_is_rejected()` +- [`tests/api.rs:347`](../tests/api.rs#L347) — `async fn the_schema_bump_fields_round_trip()` + +### UR-016 + +**Locations:** 2 + +- [`src/validate.rs:731`](../src/validate.rs#L731) — `fn extinction_sec_replaces_anneal_sec()` +- [`tests/api.rs:347`](../tests/api.rs#L347) — `async fn the_schema_bump_fields_round_trip()` + ### UR-017 **Locations:** 2 @@ -381,6 +416,12 @@ _None._ - [`src/model.rs:191`](../src/model.rs#L191) — `Unknown` - [`src/model.rs:232`](../src/model.rs#L232) — `pub fn from_stored(s: &str) -> Option` +### UR-018 + +**Locations:** 1 + +- [`src/ingest.rs:387`](../src/ingest.rs#L387) — `fn content_id_excludes_belief_and_route()` + ### UR-019 **Locations:** 1 diff --git a/src/db/mod.rs b/src/db/mod.rs index b7fe649..7d93fcd 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -22,6 +22,12 @@ use std::sync::{Arc, Mutex}; use anyhow::Context; use rusqlite::Connection; +/// The whole schema, as portable SQL — it runs unchanged on Postgres, so +/// SQLite-specific forms (`INSERT OR REPLACE`) are avoided in favour of the +/// standard `INSERT ... ON CONFLICT` (§8). Keeping it in one `.sql` file rather +/// than scattered through the repository is what makes that reviewable. +/// +/// TRACES: DR-014 | PR-004 const SCHEMA: &str = include_str!("schema.sql"); /// Handle to the database: one serialized writer, plus read connections. diff --git a/src/ingest.rs b/src/ingest.rs index b42e413..601de4e 100644 --- a/src/ingest.rs +++ b/src/ingest.rs @@ -384,6 +384,7 @@ mod tests { assert_eq!(compute_content_id(&a), compute_content_id(&b)); } + /// TRACES: UR-018 | SR-003 #[test] fn content_id_excludes_belief_and_route() { // The trap in the SR-003 bump (UR-018). Belief is a producer-side diff --git a/src/model.rs b/src/model.rs index 5bf32a3..09b2066 100644 --- a/src/model.rs +++ b/src/model.rs @@ -320,6 +320,7 @@ pub const JMANIFEST_VERSION: u32 = 2; mod tests { use super::*; + /// TRACES: DR-001 | SR-004 #[test] fn unknown_field_at_top_level_is_rejected() { let json = r#"{"jmanifest_version":2,"identity":{"type":"movie","tmdb_id":"1"}, diff --git a/src/validate.rs b/src/validate.rs index fcf4dbd..15a7f31 100644 --- a/src/validate.rs +++ b/src/validate.rs @@ -728,6 +728,7 @@ mod tests { ); } + /// TRACES: UR-015, UR-016 | SR-003 #[test] fn extinction_sec_replaces_anneal_sec() { // The SR-003 withdrawal. `anneal_sec` cannot even be constructed here — diff --git a/tests/api.rs b/tests/api.rs index 1d7d344..088ccfd 100644 --- a/tests/api.rs +++ b/tests/api.rs @@ -275,6 +275,7 @@ async fn a_pending_manifest_is_not_served() { assert_eq!(body["status"], "pending"); } +/// TRACES: DR-001 | SR-004 #[tokio::test] async fn unknown_field_anywhere_is_rejected_with_400() { // §6 stage 2, enforced by `deny_unknown_fields` on every DTO. @@ -319,6 +320,7 @@ async fn contributor_local_identifiers_are_rejected_not_ignored() { assert!(body["message"].as_str().unwrap_or("").contains("jellyfin_id"), "{body}"); } +/// TRACES: UR-015 | SR-003 #[tokio::test] async fn the_withdrawn_anneal_sec_field_is_rejected() { // `anneal_sec` was withdrawn in the SR-003 bump: presence now follows track @@ -342,6 +344,7 @@ async fn the_withdrawn_anneal_sec_field_is_rejected() { ); } +/// TRACES: UR-015, UR-016 | SR-003 #[tokio::test] async fn the_schema_bump_fields_round_trip() { // `extinction_sec` and `gallery_scope` are the SR-003 additions. They are