Initial implementation: core vertical slice
CI / fmt, clippy, test (push) Failing after 2m46s
CI / static musl binary (push) Has been skipped
CI / advisories and licences (push) Successful in 4m22s

Implements the core of SPEC.md — the manifest exchange, less audio-tier
matching (§3) and federation (§9a), both of which the spec sequences as
later work.

- §2 Jmanifest format and series bundles
- §3 cut matching: exact / runtime / loose tiers
- §4 API, less POST /manifests/search
- §5 rate limiting; §5a trust model, anonymous bearer tokens
- §6 upload validation, all four stages
- §7 relational storage, no JSON blob on the write path
- §8 Rust + Axum + SQLite, single serialized writer, in-process job queue
- §9a content addressing, computed on upload

Reconciled against the system spec:

- anneal_sec removed, withdrawn upstream by AR-012/AR-013. Presence follows
  track extent, so a track survives its own gaps and there is nothing to
  anneal. Its successor extinction_sec and the new gallery_scope are accepted
  and stored; scope enters the §7 ranking. A manifest still carrying
  anneal_sec is a hard 400, not silently ignored — it came from a pipeline
  whose window semantics differ from what this server assumes.
- Audio signature: media under 120 s now emits no signature at all, matching
  scene-actor-extraction IR-007. The earlier §3 draft allowed a shortened
  window under 150 s, which was the weaker rule — a caller-varying length is
  the property SR-004 forbids.
- UR IDs regularised to UR-nnn; docs/requirements.md registers 32
  requirements, each tracing to an SR-nnn or PR-nnn.

189 tests: unit, end-to-end through the real router, and an injection suite
covering SQL, JSON, header and Unicode payloads. Writing that suite found two
real gaps, both fixed here: compatibility homoglyphs passed the §5a character
class, and a one-frame audio signature was accepted on a feature-length item.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-30 18:14:02 +02:00
co-authored by Claude Opus 5
commit a848750a65
38 changed files with 13014 additions and 0 deletions
+119
View File
@@ -0,0 +1,119 @@
-- §7 Storage. Fully relational, no JSON blobs on the write path: the database
-- can only represent what the schema models, so there is physically nowhere for
-- an unexpected field or a smuggled string to live (§5a Threat 1).
--
-- Portable SQL — runs unchanged on Postgres. Avoid SQLite-specific forms
-- (`INSERT OR REPLACE`); use `INSERT ... ON CONFLICT` (§8 deployment notes).
CREATE TABLE IF NOT EXISTS contributors (
id TEXT PRIMARY KEY,
token_hash TEXT NOT NULL UNIQUE,
created_at TEXT NOT NULL,
revoked_at TEXT,
accepted_count INTEGER NOT NULL DEFAULT 0,
rejected_count INTEGER NOT NULL DEFAULT 0,
flagged_count INTEGER NOT NULL DEFAULT 0
);
-- Server-side, TMDB-derived. `name` never comes from an upload (§5a).
CREATE TABLE IF NOT EXISTS people (
tmdb_person_id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
adult INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS titles (
id TEXT PRIMARY KEY,
kind TEXT NOT NULL, -- movie | series
tmdb_id TEXT,
imdb_id TEXT,
name TEXT,
year INTEGER,
adult INTEGER NOT NULL DEFAULT 0,
certification TEXT,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS manifests (
id TEXT PRIMARY KEY,
title_id TEXT NOT NULL REFERENCES titles(id),
season INTEGER,
episode INTEGER,
runtime_sec REAL NOT NULL,
video_hash TEXT,
audio_signature BLOB, -- §3, ~1290 bytes
audio_sig_coarse BLOB, -- candidate-generation index key
sample_fps REAL,
extinction_sec REAL, -- successor to the withdrawn anneal_sec
gallery_scope TEXT, -- limited | global; ranking signal (§2, §7)
pipeline_version TEXT,
contributor_id TEXT REFERENCES contributors(id),
status TEXT NOT NULL, -- pending | listed | flagged | rejected
reject_reason TEXT,
cast_match_ratio REAL,
content_id TEXT UNIQUE, -- §9a, sha256 over canonical form
origin TEXT, -- server_id of first acceptance
ingested_from TEXT, -- peer id, NULL if uploaded directly
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS manifest_actors (
manifest_id TEXT NOT NULL REFERENCES manifests(id) ON DELETE CASCADE,
tmdb_person_id INTEGER NOT NULL,
PRIMARY KEY (manifest_id, tmdb_person_id)
);
-- Integer centiseconds, not floats — the same quantisation used for
-- `content_id`, so stored values and hashed values cannot diverge (§7, §9a).
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
);
CREATE TABLE IF NOT EXISTS reports (
id TEXT PRIMARY KEY,
manifest_id TEXT NOT NULL REFERENCES manifests(id) ON DELETE CASCADE,
reason TEXT NOT NULL,
note TEXT,
created_at TEXT NOT NULL,
source_ip_hash TEXT
);
-- The sole JSON column, and it holds TMDB's responses, not users' (§7).
CREATE TABLE IF NOT EXISTS tmdb_cache (
tmdb_id TEXT NOT NULL,
kind TEXT NOT NULL,
credits TEXT NOT NULL,
fetched_at TEXT NOT NULL,
PRIMARY KEY (tmdb_id, kind)
);
-- Background queue as a table rather than an external broker, so pending work
-- survives a restart (§7, §8).
CREATE TABLE IF NOT EXISTS jobs (
id TEXT PRIMARY KEY,
kind TEXT NOT NULL, -- cast_check | federation_pull
payload TEXT NOT NULL,
run_after TEXT NOT NULL,
attempts INTEGER NOT NULL DEFAULT 0,
last_error TEXT,
leased_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_titles_tmdb ON titles(tmdb_id);
CREATE INDEX IF NOT EXISTS idx_titles_imdb ON titles(imdb_id);
CREATE INDEX IF NOT EXISTS idx_manifests_title_runtime ON manifests(title_id, runtime_sec);
CREATE INDEX IF NOT EXISTS idx_manifests_video_hash ON manifests(video_hash);
CREATE INDEX IF NOT EXISTS idx_manifests_episode ON manifests(title_id, season, episode);
CREATE INDEX IF NOT EXISTS idx_scenes_manifest_person ON scenes(manifest_id, tmdb_person_id);
-- All read queries filter `status IN ('listed','flagged')`, so a partial index
-- on that predicate keeps the hot path small (§7).
CREATE INDEX IF NOT EXISTS idx_manifests_served
ON manifests(title_id, season, episode)
WHERE status IN ('listed', 'flagged');
CREATE INDEX IF NOT EXISTS idx_jobs_ready ON jobs(run_after);