Files
JRay-public-server/src/db/schema.sql
T
dtourolleandClaude Opus 5 0ff1018bcc
CI / static musl binary (push) Has been skipped
CI / fmt, clippy, test (push) Failing after 2m0s
CI / advisories and licences (push) Successful in 27s
Withdraw the file-hash tier; document the legal posture
Removes `cut.video_hash` and the `exact` match tier on legal grounds. The
OpenSubtitles hash was the strongest technical signal available — it identifies
a specific file, so it cannot produce a false positive — and that is exactly
the problem.

Every tier must be a claim about a *cut*, never about a copy. A TMDB id
discloses "some copy of this film", which is what a library catalogue
discloses. A file hash discloses "this exact release": it made a read endpoint
into a release-level oracle, and made an instance's database a mapping from
file fingerprints to the instances holding them. That is a far more specific
disclosure than PR-005 permits, and a dataset no volunteer operator should be
asked to hold. The audio signature is the replacement: derived from content, it
identifies the cut rather than the copy, so two encodes of the same edit agree.

The field is deleted rather than kept as a vestigial null, on the same
reasoning §2 applied to `anneal_sec` — a key naming a signal the format no
longer has is actively misleading — so an upload carrying one is now an
unknown-field 400, with a test asserting it.

**Every content_id changes**, including for manifests that never carried a
hash, because the canonical `cut` object lost a key. The golden vector is
regenerated and re-verified against an independent Python implementation; the
plugin and extraction repos must adopt the new value or federation
deduplication silently breaks. Free now, pre-release; not free later.

Adds docs/legal-posture.md, the operator-facing half of what §5a asks for:
what an instance holds exhaustively, what it structurally cannot do, and how
that sits against the intermediary-liability regimes that plausibly apply.

208 tests. Coverage 25/32.

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

TRACES: UR-011 | SR-004, PR-005
2026-07-31 09:52:03 +02:00

174 lines
7.7 KiB
SQL

-- §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,
-- No video_hash: withdrawn (§3). It fingerprinted an individual file rather
-- than a cut, which is the one thing this schema deliberately cannot record.
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,
-- 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 (
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
);
-- §9a federation. Peering is trust-by-configuration: a row exists only because
-- an operator typed a URL. Nothing a remote server says can create one.
CREATE TABLE IF NOT EXISTS peers (
id TEXT PRIMARY KEY,
url TEXT NOT NULL UNIQUE,
name TEXT,
enabled INTEGER NOT NULL DEFAULT 0,
pull_interval_sec INTEGER NOT NULL DEFAULT 3600,
-- Auto-delist on a peer's legal retraction. Off by default: a retraction
-- that delists automatically is a remote delete primitive over your
-- catalogue, so it is opt-in per peer between operators who know each other.
trust_abuse_retractions INTEGER NOT NULL DEFAULT 0,
-- Publishing a peering is opt-in on BOTH sides (§9a): peering with someone
-- must not advertise their existence against their wishes.
advertise INTEGER NOT NULL DEFAULT 0,
max_ingest_per_hour INTEGER NOT NULL DEFAULT 500,
peered_since TEXT,
last_cursor TEXT,
last_pull_at TEXT,
last_error TEXT
);
-- The change feed. Append-only, so a peer can resume from an opaque cursor and
-- the feed is idempotent. A separate table rather than deriving the feed from
-- `manifests` because a *retraction* is an event with no surviving row.
CREATE TABLE IF NOT EXISTS federation_log (
-- A genuinely monotonic sequence, NOT a ULID.
--
-- ULIDs are only monotonic *between* milliseconds: two generated in the same
-- millisecond carry independent random components, so they can sort in the
-- opposite order to which they were written. A peer resuming from `seq >
-- cursor` would then silently skip an entry — replication losing manifests
-- with no error anywhere, which is the worst shape a bug can take here.
--
-- AUTOINCREMENT (rather than plain rowid) additionally guarantees the value
-- never decreases even after deletions. Portability note (§8): Postgres
-- spells this `BIGSERIAL PRIMARY KEY`; it is the one place a monotonic
-- sequence has no fully portable form, and it is worth the exception.
seq INTEGER PRIMARY KEY AUTOINCREMENT,
content_id TEXT NOT NULL,
op TEXT NOT NULL, -- add | retract
reason TEXT, -- retract only; 'abuse' is the one that may auto-delist
origin TEXT NOT NULL, -- server_id that first accepted it
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_federation_log_content ON federation_log(content_id);
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_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);