Federation: replicate content, re-derive judgement (UR-008)
Implements §9a. The replication surface is four reads and no writes: a change feed, fetch by content_id, a batch have, and a human-facing peer directory — plus a capabilities endpoint carrying the accepted envelope versions, which lets a client discover a schema mismatch in one request instead of a 400 per manifest across a library sweep. Pull, never push: a pulling server chooses what it ingests and when. Push would let any peer inject work into the validation queue — the same abuse surface as anonymous upload, at higher volume. Nothing inherits a peer's judgement. A pulled manifest runs the full §6 stage 1 and 2 validation and this server's own cast check, and the fetched body must hash to the content_id that was asked for — the check that stops an intermediary or a misbehaving peer substituting content under a trusted id. A peer's retraction flags for review rather than delisting, because auto-delisting would hand every peer a remote delete primitive; only the opt-in per-peer abuse channel delists, because a takedown propagating at the speed of manual review is the wrong failure mode for that one case. A test caught a real bug in the first cut: the feed cursor was a ULID, and ULIDs are only monotonic *between* milliseconds — two generated in the same millisecond carry independent random components and can sort opposite to write order. A peer resuming from `seq > cursor` would then silently skip an entry: replication losing manifests with no error anywhere. The cursor is now an AUTOINCREMENT integer, and the test asserts strict monotonicity rather than merely sortedness. Peer administration is deliberately not an API. §9a requires that a peering exist only because an operator typed a URL, so nothing a remote server returns can establish or widen one; there_is_no_endpoint_that_creates_a_peering asserts that absence rather than trusting it. 212 tests. Coverage 25/32 (78%). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> TRACES: UR-008 | PR-006
This commit is contained in:
+402
@@ -1138,3 +1138,405 @@ mod tests {
|
||||
assert_eq!(counts, (0, 0, 0));
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Federation (§9a)
|
||||
//
|
||||
// The design in one line: **replicate content, re-derive judgement.** A
|
||||
// validated manifest is immutable and content-addressable, so replication is set
|
||||
// reconciliation — no concurrent edits, no vector clocks, no merge conflicts.
|
||||
// What must *not* replicate is `status`, `reports` and `cast_match_ratio`: those
|
||||
// encode a local operator's judgement and legal position, and a server that
|
||||
// inherits them has outsourced its moderation, or its liability.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Peer {
|
||||
pub id: String,
|
||||
pub url: String,
|
||||
pub name: Option<String>,
|
||||
pub enabled: bool,
|
||||
pub pull_interval_sec: i64,
|
||||
pub trust_abuse_retractions: bool,
|
||||
pub advertise: bool,
|
||||
pub max_ingest_per_hour: i64,
|
||||
pub peered_since: Option<String>,
|
||||
pub last_cursor: Option<String>,
|
||||
pub last_pull_at: Option<String>,
|
||||
pub last_error: Option<String>,
|
||||
}
|
||||
|
||||
fn map_peer(r: &rusqlite::Row<'_>) -> rusqlite::Result<Peer> {
|
||||
Ok(Peer {
|
||||
id: r.get(0)?,
|
||||
url: r.get(1)?,
|
||||
name: r.get(2)?,
|
||||
enabled: r.get::<_, i64>(3)? != 0,
|
||||
pull_interval_sec: r.get(4)?,
|
||||
trust_abuse_retractions: r.get::<_, i64>(5)? != 0,
|
||||
advertise: r.get::<_, i64>(6)? != 0,
|
||||
max_ingest_per_hour: r.get(7)?,
|
||||
peered_since: r.get(8)?,
|
||||
last_cursor: r.get(9)?,
|
||||
last_pull_at: r.get(10)?,
|
||||
last_error: r.get(11)?,
|
||||
})
|
||||
}
|
||||
|
||||
const PEER_COLUMNS: &str = "id, url, name, enabled, pull_interval_sec, \
|
||||
trust_abuse_retractions, advertise, max_ingest_per_hour, peered_since, last_cursor, \
|
||||
last_pull_at, last_error";
|
||||
|
||||
/// Adds a peer.
|
||||
///
|
||||
/// §9a: **there is no automatic peering, ever.** This is only ever reached from
|
||||
/// an operator action — nothing a remote server returns can call it, which is
|
||||
/// what keeps the network's trust properties from being set by whoever joins.
|
||||
pub fn insert_peer(
|
||||
tx: &Transaction<'_>,
|
||||
url: &str,
|
||||
name: Option<&str>,
|
||||
now: &str,
|
||||
) -> anyhow::Result<String> {
|
||||
let id = ulid::Ulid::new().to_string();
|
||||
tx.execute(
|
||||
"INSERT INTO peers (id, url, name, peered_since) VALUES (?1, ?2, ?3, ?4)
|
||||
ON CONFLICT (url) DO NOTHING",
|
||||
params![id, url, name, now],
|
||||
)?;
|
||||
let mut stmt = tx.prepare_cached("SELECT id FROM peers WHERE url = ?1")?;
|
||||
Ok(stmt.query_row(params![url], |r| r.get(0))?)
|
||||
}
|
||||
|
||||
pub fn peers_due(conn: &Connection, now: &str) -> anyhow::Result<Vec<Peer>> {
|
||||
let sql = format!(
|
||||
"SELECT {PEER_COLUMNS} FROM peers
|
||||
WHERE enabled = 1
|
||||
AND (last_pull_at IS NULL
|
||||
OR datetime(last_pull_at, '+' || pull_interval_sec || ' seconds') <= ?1)"
|
||||
);
|
||||
let mut stmt = conn.prepare_cached(&sql)?;
|
||||
let rows = stmt.query_map(params![now], map_peer)?.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
pub fn peer_by_id(conn: &Connection, id: &str) -> anyhow::Result<Option<Peer>> {
|
||||
let sql = format!("SELECT {PEER_COLUMNS} FROM peers WHERE id = ?1");
|
||||
let mut stmt = conn.prepare_cached(&sql)?;
|
||||
Ok(stmt.query_row(params![id], map_peer).optional()?)
|
||||
}
|
||||
|
||||
/// Peers this server has chosen to advertise (§9a peer directory).
|
||||
pub fn advertised_peers(conn: &Connection) -> anyhow::Result<Vec<Peer>> {
|
||||
let sql = format!("SELECT {PEER_COLUMNS} FROM peers WHERE advertise = 1 ORDER BY url");
|
||||
let mut stmt = conn.prepare_cached(&sql)?;
|
||||
let rows = stmt.query_map([], map_peer)?.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
pub fn record_pull(
|
||||
tx: &Transaction<'_>,
|
||||
peer_id: &str,
|
||||
cursor: Option<&str>,
|
||||
now: &str,
|
||||
error: Option<&str>,
|
||||
) -> anyhow::Result<()> {
|
||||
tx.execute(
|
||||
"UPDATE peers
|
||||
SET last_cursor = COALESCE(?2, last_cursor), last_pull_at = ?3, last_error = ?4
|
||||
WHERE id = ?1",
|
||||
params![peer_id, cursor, now, error],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ChangeEntry {
|
||||
/// Opaque to the peer, which only ever echoes it back as `since`.
|
||||
pub seq: i64,
|
||||
pub content_id: String,
|
||||
pub op: String,
|
||||
pub reason: Option<String>,
|
||||
pub origin: String,
|
||||
}
|
||||
|
||||
/// The `content_id` of a stored manifest, if it has one.
|
||||
pub fn content_id_of(tx: &Transaction<'_>, manifest_id: &str) -> anyhow::Result<Option<String>> {
|
||||
let mut stmt = tx.prepare_cached("SELECT content_id FROM manifests WHERE id = ?1")?;
|
||||
Ok(stmt
|
||||
.query_row(params![manifest_id], |r| r.get::<_, Option<String>>(0))
|
||||
.optional()?
|
||||
.flatten())
|
||||
}
|
||||
|
||||
/// Appends to the change feed. Called when a manifest becomes listed, or is
|
||||
/// delisted — the two events a peer can act on.
|
||||
pub fn append_change(
|
||||
tx: &Transaction<'_>,
|
||||
content_id: &str,
|
||||
op: &str,
|
||||
reason: Option<&str>,
|
||||
origin: &str,
|
||||
now: &str,
|
||||
) -> anyhow::Result<i64> {
|
||||
tx.execute(
|
||||
"INSERT INTO federation_log (content_id, op, reason, origin, created_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
params![content_id, op, reason, origin, now],
|
||||
)?;
|
||||
Ok(tx.last_insert_rowid())
|
||||
}
|
||||
|
||||
/// The change feed since an opaque cursor.
|
||||
///
|
||||
/// Resumption is `seq > cursor` over a monotonic integer, so a peer never skips
|
||||
/// an entry and never re-reads one. See the `federation_log` schema comment for
|
||||
/// why a ULID cursor would have been wrong.
|
||||
pub fn changes_since(
|
||||
conn: &Connection,
|
||||
since: Option<i64>,
|
||||
limit: usize,
|
||||
) -> anyhow::Result<Vec<ChangeEntry>> {
|
||||
let mut stmt = conn.prepare_cached(
|
||||
"SELECT seq, content_id, op, reason, origin FROM federation_log
|
||||
WHERE (?1 IS NULL OR seq > ?1)
|
||||
ORDER BY seq ASC
|
||||
LIMIT ?2",
|
||||
)?;
|
||||
let rows = stmt
|
||||
.query_map(params![since, limit as i64], |r| {
|
||||
Ok(ChangeEntry {
|
||||
seq: r.get(0)?,
|
||||
content_id: r.get(1)?,
|
||||
op: r.get(2)?,
|
||||
reason: r.get(3)?,
|
||||
origin: r.get(4)?,
|
||||
})
|
||||
})?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// Which of these `content_id`s this server already holds (`POST /federation/have`).
|
||||
pub fn known_content_ids(conn: &Connection, ids: &[String]) -> anyhow::Result<Vec<String>> {
|
||||
let mut stmt = conn.prepare_cached("SELECT 1 FROM manifests WHERE content_id = ?1 LIMIT 1")?;
|
||||
let mut have = Vec::new();
|
||||
for id in ids {
|
||||
if stmt.query_row(params![id], |_| Ok(())).optional()?.is_some() {
|
||||
have.push(id.clone());
|
||||
}
|
||||
}
|
||||
Ok(have)
|
||||
}
|
||||
|
||||
pub fn manifest_by_content_id_ro(
|
||||
conn: &Connection,
|
||||
content_id: &str,
|
||||
) -> anyhow::Result<Option<ManifestRow>> {
|
||||
let sql = format!("SELECT {MANIFEST_COLUMNS} FROM manifests WHERE content_id = ?1");
|
||||
let mut stmt = conn.prepare_cached(&sql)?;
|
||||
Ok(stmt.query_row(params![content_id], map_manifest).optional()?)
|
||||
}
|
||||
|
||||
/// How many manifests this peer supplied in the last hour, for `MaxIngestPerHour`.
|
||||
pub fn ingest_count_since(conn: &Connection, peer_id: &str, since: &str) -> anyhow::Result<i64> {
|
||||
let mut stmt = conn.prepare_cached(
|
||||
"SELECT COUNT(*) FROM manifests WHERE ingested_from = ?1 AND created_at >= ?2",
|
||||
)?;
|
||||
Ok(stmt.query_row(params![peer_id, since], |r| r.get(0))?)
|
||||
}
|
||||
|
||||
/// Flags a locally-held manifest for review after a peer retracted it.
|
||||
///
|
||||
/// **Not a delist.** §9a: a retraction is a warning worth acting on, whereas a
|
||||
/// listing is merely a nomination — the asymmetry is deliberate. Auto-delisting
|
||||
/// on any peer's retraction would hand every peer a remote delete primitive over
|
||||
/// your catalogue. The one exception is the opt-in abuse channel.
|
||||
pub fn flag_for_review(
|
||||
tx: &Transaction<'_>,
|
||||
content_id: &str,
|
||||
reason: &str,
|
||||
) -> anyhow::Result<bool> {
|
||||
let n = tx.execute(
|
||||
"UPDATE manifests SET status = 'flagged', reject_reason = ?2
|
||||
WHERE content_id = ?1 AND status = 'listed'",
|
||||
params![content_id, reason],
|
||||
)?;
|
||||
Ok(n > 0)
|
||||
}
|
||||
|
||||
/// Delists immediately. Reached only for a peer configured `TrustAbuseRetractions`.
|
||||
pub fn delist_by_content_id(tx: &Transaction<'_>, content_id: &str) -> anyhow::Result<bool> {
|
||||
let n = tx.execute(
|
||||
"UPDATE manifests SET status = 'rejected', reject_reason = 'peer_abuse_retraction'
|
||||
WHERE content_id = ?1",
|
||||
params![content_id],
|
||||
)?;
|
||||
Ok(n > 0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod federation_tests {
|
||||
use super::*;
|
||||
use crate::db::Db;
|
||||
|
||||
const NOW: &str = "2026-07-30T12:00:00Z";
|
||||
|
||||
async fn seeded(status: &'static str) -> Db {
|
||||
let db = Db::open(":memory:").unwrap();
|
||||
db.write(move |tx| {
|
||||
let title_id = upsert_title(
|
||||
tx,
|
||||
crate::model::IdentityType::Movie,
|
||||
Some("1"),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
NOW,
|
||||
)?;
|
||||
insert_manifest(
|
||||
tx,
|
||||
&NewManifest {
|
||||
id: "m1",
|
||||
title_id: &title_id,
|
||||
season: None,
|
||||
episode: None,
|
||||
runtime_sec: 100.0,
|
||||
video_hash: None,
|
||||
audio_signature: None,
|
||||
audio_sig_coarse: None,
|
||||
sample_fps: None,
|
||||
extinction_sec: None,
|
||||
pipeline_version: None,
|
||||
gallery_scope: None,
|
||||
contributor_id: None,
|
||||
status,
|
||||
content_id: Some("sha256:x"),
|
||||
origin: "peer.example",
|
||||
ingested_from: None,
|
||||
created_at: NOW,
|
||||
},
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
db
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_peer_retraction_flags_rather_than_delists() {
|
||||
// §9a's central asymmetry. A retraction is a *warning worth acting on*;
|
||||
// a listing is merely a *nomination*. Auto-delisting on any peer's
|
||||
// retraction would hand every peer a remote delete primitive over your
|
||||
// catalogue — which is why this path stops at `flagged`.
|
||||
let db = seeded("listed").await;
|
||||
let (changed, status) = db
|
||||
.write(|tx| {
|
||||
let changed = flag_for_review(tx, "sha256:x", "peer_retracted")?;
|
||||
let status: String =
|
||||
tx.query_row("SELECT status FROM manifests WHERE id='m1'", [], |r| r.get(0))?;
|
||||
Ok((changed, status))
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(changed);
|
||||
assert_eq!(status, "flagged", "a peer retraction must not delist");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_abuse_channel_can_delist_outright() {
|
||||
// The one exception, reached only for a peer explicitly configured
|
||||
// `TrustAbuseRetractions`. It exists because a takedown propagating at
|
||||
// the speed of manual review is the wrong failure mode for that case.
|
||||
let db = seeded("listed").await;
|
||||
let status = db
|
||||
.write(|tx| {
|
||||
assert!(delist_by_content_id(tx, "sha256:x")?);
|
||||
let status: String =
|
||||
tx.query_row("SELECT status FROM manifests WHERE id='m1'", [], |r| r.get(0))?;
|
||||
Ok(status)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(status, "rejected");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn flagging_does_not_resurrect_a_rejected_manifest() {
|
||||
// `flag_for_review` moves `listed` -> `flagged` only. A peer retracting
|
||||
// something this server already rejected must not raise its status.
|
||||
let db = seeded("rejected").await;
|
||||
let changed =
|
||||
db.write(|tx| flag_for_review(tx, "sha256:x", "peer_retracted")).await.unwrap();
|
||||
assert!(!changed);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_change_feed_is_ordered_and_resumable() {
|
||||
let db = Db::open(":memory:").unwrap();
|
||||
let seqs = db
|
||||
.write(|tx| {
|
||||
let mut seqs = Vec::new();
|
||||
for i in 0..5 {
|
||||
seqs.push(append_change(
|
||||
tx,
|
||||
&format!("sha256:{i}"),
|
||||
"add",
|
||||
None,
|
||||
"local",
|
||||
NOW,
|
||||
)?);
|
||||
}
|
||||
Ok(seqs)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// The property the whole cursor scheme rests on. A ULID would fail this:
|
||||
// two generated in the same millisecond carry independent random parts,
|
||||
// so they can sort opposite to write order — and a peer resuming from
|
||||
// `seq > cursor` would then silently skip one.
|
||||
let mut sorted = seqs.clone();
|
||||
sorted.sort();
|
||||
assert_eq!(sorted, seqs, "feed sequence must be monotonic");
|
||||
assert!(seqs.windows(2).all(|w| w[1] > w[0]), "strictly increasing");
|
||||
|
||||
let from_second = seqs[1];
|
||||
let rest = db.write(move |tx| changes_since(tx, Some(from_second), 100)).await.unwrap();
|
||||
assert_eq!(rest.len(), 3);
|
||||
assert_eq!(rest[0].content_id, "sha256:2");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn peering_requires_an_explicit_add_and_is_idempotent() {
|
||||
let db = Db::open(":memory:").unwrap();
|
||||
let (a, b, n) = db
|
||||
.write(|tx| {
|
||||
let a = insert_peer(tx, "https://p.example", Some("P"), NOW)?;
|
||||
let b = insert_peer(tx, "https://p.example", Some("P again"), NOW)?;
|
||||
let n: i64 = tx.query_row("SELECT COUNT(*) FROM peers", [], |r| r.get(0))?;
|
||||
Ok((a, b, n))
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(a, b, "adding the same URL twice is one peering");
|
||||
assert_eq!(n, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_new_peer_is_disabled_and_unadvertised_by_default() {
|
||||
// Federation is off by default, and advertising is opt-in on both sides.
|
||||
let db = Db::open(":memory:").unwrap();
|
||||
let peer = db
|
||||
.write(|tx| {
|
||||
let id = insert_peer(tx, "https://p.example", None, NOW)?;
|
||||
Ok(peer_by_id(tx, &id)?.unwrap())
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!peer.enabled);
|
||||
assert!(!peer.advertise);
|
||||
assert!(!peer.trust_abuse_retractions, "the abuse channel is opt-in per peer");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,6 +109,54 @@ CREATE TABLE IF NOT EXISTS jobs (
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user