diff --git a/docs/source_freshness_evidence.md b/docs/source_freshness_evidence.md
new file mode 100644
index 0000000..3ca2e76
--- /dev/null
+++ b/docs/source_freshness_evidence.md
@@ -0,0 +1,24 @@
+# Source freshness evidence v1
+
+This producer-boundary contract records freshness evidence for one bounded feed
+response. It does not claim that a feed is globally complete, and it does not
+use event `published_at` as a provider freshness signal.
+
+Signal priority is fixed:
+
+1. Atom feed `updated`;
+2. RSS channel `lastBuildDate`;
+3. HTTP `Last-Modified`.
+
+HTTP `Date` is recorded as retrieval/server-time evidence only and never makes
+the source eligible. A present but invalid, future, or stale higher-priority
+signal fails closed; fallback is allowed only when that signal is absent. The
+selected signal must be no more than five minutes in the future and no older
+than eight days relative to the caller-supplied canonical UTC reference.
+
+The evidence is canonical JSON bytes containing only digests for the source URL
+identity and response body, all signal presence/value/kind records, the
+reference, policy version, and decision. It does not persist the source URL or
+request headers. Missing all selectable signals produces
+`source_freshness_unverified`. A period projection with zero selected events is
+not stale by itself; H2C feed quarantine remains a separate producer result.
diff --git a/src/political_event_tracking_research/rss_source_fetch.py b/src/political_event_tracking_research/rss_source_fetch.py
index 0884243..a1b9be0 100644
--- a/src/political_event_tracking_research/rss_source_fetch.py
+++ b/src/political_event_tracking_research/rss_source_fetch.py
@@ -70,6 +70,10 @@ def load_feed_config(path: str | Path) -> list[FeedConfig]:
def fetch_url(url: str) -> bytes:
+ return fetch_url_with_metadata(url)[0]
+
+
+def fetch_url_with_metadata(url: str) -> tuple[bytes, dict[str, str]]:
request = urllib.request.Request(
url,
headers={
@@ -79,9 +83,20 @@ def fetch_url(url: str) -> bytes:
)
with urllib.request.urlopen(request, timeout=20) as response:
payload = response.read(MAX_XML_BYTES + 1)
+ response_headers = getattr(response, "headers", None)
+ headers = {}
+ if response_headers is not None:
+ for name in ("Date", "Last-Modified"):
+ get_all = getattr(response_headers, "get_all", None)
+ values = get_all(name) if callable(get_all) else None
+ if values is not None and len(values) > 1:
+ raise FeedXmlError("feed_header_duplicate")
+ value = response_headers.get(name)
+ if value is not None:
+ headers[name] = value
if len(payload) > MAX_XML_BYTES:
raise FeedXmlError("feed_xml_oversize")
- return payload
+ return payload, headers
def strip_html(value: str) -> str:
diff --git a/src/political_event_tracking_research/source_freshness.py b/src/political_event_tracking_research/source_freshness.py
new file mode 100644
index 0000000..f7edeb2
--- /dev/null
+++ b/src/political_event_tracking_research/source_freshness.py
@@ -0,0 +1,320 @@
+"""Producer-boundary freshness evidence for one bounded feed snapshot."""
+
+from __future__ import annotations
+
+import datetime as dt
+import email.utils
+import hashlib
+import json
+import re
+from collections.abc import Mapping
+from urllib.parse import urlsplit
+
+import defusedxml.ElementTree as ET
+from defusedxml.common import DefusedXmlException
+
+
+EVIDENCE_VERSION = "pert.source_freshness_evidence.v1"
+POLICY_VERSION = "monday_weekly_8d_5m.v1"
+MAX_BODY_BYTES = 1024 * 1024
+MAX_HEADER_VALUE_BYTES = 4096
+MAX_FRESHNESS_AGE = dt.timedelta(days=8)
+FUTURE_TOLERANCE = dt.timedelta(minutes=5)
+_DIGEST_RE = re.compile(r"^[0-9a-f]{64}$")
+_TIMESTAMP_RE = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$")
+_EVIDENCE_KEYS = frozenset(
+ {
+ "evidence_version",
+ "policy_version",
+ "feed_id",
+ "source_identity_digest",
+ "body_sha256",
+ "reference_time",
+ "selected_signal",
+ "signals",
+ "decision",
+ }
+)
+_SIGNAL_KEYS = frozenset({"kind", "present", "valid", "value"})
+_SIGNAL_KINDS = (
+ "atom_feed_updated",
+ "rss_channel_last_build_date",
+ "http_last_modified",
+ "http_date",
+)
+_SELECTABLE = frozenset(_SIGNAL_KINDS[:3])
+
+
+class FreshnessError(ValueError):
+ """Stable, sanitized producer freshness contract error."""
+
+ def __init__(self, code: str) -> None:
+ self.code = code
+ super().__init__(code)
+
+
+def _fail(code: str) -> None:
+ raise FreshnessError(code)
+
+
+def _string(value: object, code: str, *, allow_empty: bool = False) -> str:
+ if type(value) is not str or (not allow_empty and not value) or any(ord(char) < 0x20 for char in value):
+ _fail(code)
+ return value
+
+
+def _timestamp(value: object, code: str = "freshness_time_invalid") -> dt.datetime:
+ if type(value) is not str or not _TIMESTAMP_RE.fullmatch(value):
+ _fail(code)
+ try:
+ return dt.datetime.fromisoformat(value[:-1] + "+00:00")
+ except ValueError:
+ _fail(code)
+
+
+def _canonical_timestamp(value: dt.datetime) -> str:
+ return value.astimezone(dt.UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z")
+
+
+def _parse_date_signal(value: object) -> tuple[bool, str | None]:
+ if type(value) is not str or not value or len(value) > MAX_HEADER_VALUE_BYTES:
+ return False, None
+ try:
+ parsed = email.utils.parsedate_to_datetime(value)
+ if parsed is None:
+ raise ValueError
+ if parsed.tzinfo is None:
+ return False, None
+ return True, _canonical_timestamp(parsed)
+ except (TypeError, ValueError, OverflowError):
+ try:
+ parsed = dt.datetime.fromisoformat(value.replace("Z", "+00:00"))
+ if parsed.tzinfo is None:
+ return False, None
+ return True, _canonical_timestamp(parsed)
+ except (TypeError, ValueError, OverflowError):
+ return False, None
+
+
+def _headers(value: Mapping[str, str]) -> dict[str, str]:
+ if not isinstance(value, Mapping):
+ _fail("freshness_headers_invalid")
+ result: dict[str, str] = {}
+ try:
+ items = list(value.items())
+ except (AttributeError, RuntimeError, TypeError, ValueError):
+ _fail("freshness_headers_invalid")
+ for key, item in items:
+ if (
+ type(key) is not str
+ or any(ord(char) < 0x20 for char in key)
+ or type(item) is not str
+ or len(item.encode("utf-8")) > MAX_HEADER_VALUE_BYTES
+ ):
+ _fail("freshness_headers_invalid")
+ normalized = key.lower()
+ if normalized in result:
+ _fail("freshness_headers_invalid")
+ result[normalized] = item
+ return result
+
+
+def _source_identity(source_url: object) -> str:
+ value = _string(source_url, "source_identity_invalid")
+ try:
+ parsed = urlsplit(value)
+ if parsed.scheme not in {"http", "https"} or not parsed.hostname or parsed.username or parsed.password:
+ _fail("source_identity_invalid")
+ except ValueError:
+ _fail("source_identity_invalid")
+ return hashlib.sha256(value.encode("utf-8")).hexdigest()
+
+
+def _xml_signal(body: bytes, kind: str) -> tuple[bool, str | None]:
+ try:
+ root = ET.fromstring(body, forbid_dtd=True, forbid_entities=True, forbid_external=True)
+ except (DefusedXmlException, ET.ParseError, LookupError, UnicodeError, ValueError, RecursionError):
+ _fail("source_freshness_invalid")
+ if root.tag == "rss":
+ channels = [child for child in root if child.tag == "channel"]
+ if len(channels) != 1:
+ _fail("source_freshness_invalid")
+ if kind == "rss_channel_last_build_date":
+ values = [child.text.strip() if child.text is not None else "" for child in channels[0] if child.tag == "lastBuildDate"]
+ if len(values) > 1:
+ _fail("source_freshness_invalid")
+ if not values:
+ return False, None
+ return True, _parse_date_signal(values[0])[1]
+ return False, None
+ if root.tag == "{http://www.w3.org/2005/Atom}feed":
+ if kind == "atom_feed_updated":
+ values = [child.text.strip() if child.text is not None else "" for child in root if child.tag == "{http://www.w3.org/2005/Atom}updated"]
+ if len(values) > 1:
+ _fail("source_freshness_invalid")
+ if not values:
+ return False, None
+ return True, _parse_date_signal(values[0])[1]
+ return False, None
+ _fail("source_freshness_invalid")
+
+
+def _signal(kind: str, present: bool, value: str | None, valid: bool) -> dict[str, object]:
+ return {"kind": kind, "present": present, "valid": valid, "value": value}
+
+
+def _validate_signal(value: object) -> dict[str, object]:
+ if not isinstance(value, Mapping) or set(value) != _SIGNAL_KEYS:
+ _fail("freshness_invalid")
+ kind = _string(value["kind"], "freshness_invalid")
+ if kind not in _SIGNAL_KINDS or type(value["present"]) is not bool or type(value["valid"]) is not bool:
+ _fail("freshness_invalid")
+ signal_value = value["value"]
+ if signal_value is not None:
+ _timestamp(signal_value, "freshness_invalid")
+ if not value["present"] and (signal_value is not None or value["valid"]):
+ _fail("freshness_invalid")
+ if value["valid"] and signal_value is None:
+ _fail("freshness_invalid")
+ return {key: value[key] for key in ("kind", "present", "valid", "value")}
+
+
+def _validate_evidence(value: object) -> dict[str, object]:
+ if not isinstance(value, Mapping) or set(value) != _EVIDENCE_KEYS:
+ _fail("freshness_invalid")
+ if value["evidence_version"] != EVIDENCE_VERSION or value["policy_version"] != POLICY_VERSION:
+ _fail("freshness_invalid")
+ _string(value["feed_id"], "freshness_invalid")
+ for key in ("source_identity_digest", "body_sha256"):
+ if type(value[key]) is not str or not _DIGEST_RE.fullmatch(value[key]):
+ _fail("freshness_invalid")
+ _timestamp(value["reference_time"], "freshness_invalid")
+ signals = value["signals"]
+ if not isinstance(signals, list) or len(signals) != len(_SIGNAL_KINDS):
+ _fail("freshness_invalid")
+ parsed = [_validate_signal(item) for item in signals]
+ if [item["kind"] for item in parsed] != list(_SIGNAL_KINDS):
+ _fail("freshness_invalid")
+ for item in parsed[:3]:
+ if item["present"] and not item["valid"]:
+ _fail("freshness_invalid")
+ first_present = next((item for item in parsed[:3] if item["present"]), None)
+ selected = value["selected_signal"]
+ if selected is not None:
+ selected = _validate_signal(selected)
+ if selected["kind"] not in _SELECTABLE or not selected["valid"]:
+ _fail("freshness_invalid")
+ matching = next(item for item in parsed if item["kind"] == selected["kind"])
+ if selected != matching:
+ _fail("freshness_invalid")
+ if selected != first_present:
+ _fail("freshness_invalid")
+ if selected is not None:
+ reference = _timestamp(value["reference_time"])
+ selected_time = _timestamp(selected["value"])
+ if selected_time > reference + FUTURE_TOLERANCE or reference - selected_time > MAX_FRESHNESS_AGE:
+ _fail("freshness_invalid")
+ if value["decision"] not in {"eligible", "source_freshness_unverified"}:
+ _fail("freshness_invalid")
+ if (value["decision"] == "eligible") != (selected is not None):
+ _fail("freshness_invalid")
+ return {
+ "evidence_version": value["evidence_version"],
+ "policy_version": value["policy_version"],
+ "feed_id": value["feed_id"],
+ "source_identity_digest": value["source_identity_digest"],
+ "body_sha256": value["body_sha256"],
+ "reference_time": value["reference_time"],
+ "selected_signal": selected,
+ "signals": parsed,
+ "decision": value["decision"],
+ }
+
+
+def _canonical(value: object) -> bytes:
+ try:
+ return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False).encode("utf-8")
+ except (TypeError, ValueError, UnicodeError, RecursionError):
+ _fail("freshness_serialization_invalid")
+
+
+def build_freshness_evidence(
+ *,
+ feed_id: str,
+ source_url: str,
+ body: bytes,
+ response_headers: Mapping[str, str],
+ reference_time: str,
+) -> bytes:
+ if type(body) is not bytes or len(body) > MAX_BODY_BYTES:
+ _fail("freshness_body_invalid")
+ reference = _timestamp(reference_time)
+ headers = _headers(response_headers)
+ atom_present, atom_value = _xml_signal(body, "atom_feed_updated")
+ rss_present, rss_value = _xml_signal(body, "rss_channel_last_build_date")
+ last_modified = headers.get("last-modified")
+ http_present = last_modified is not None
+ http_valid, http_value = _parse_date_signal(last_modified) if http_present else (False, None)
+ date_value = headers.get("date")
+ date_present = date_value is not None
+ date_valid, date_parsed = _parse_date_signal(date_value) if date_present else (False, None)
+ signals = [
+ _signal("atom_feed_updated", atom_present, atom_value, atom_present and atom_value is not None),
+ _signal("rss_channel_last_build_date", rss_present, rss_value, rss_present and rss_value is not None),
+ _signal("http_last_modified", http_present, http_value, http_present and http_valid),
+ _signal("http_date", date_present, date_parsed, date_present and date_valid),
+ ]
+ selected: dict[str, object] | None = None
+ for item in signals[:3]:
+ if item["present"]:
+ if not item["valid"]:
+ _fail("source_freshness_invalid")
+ selected = item
+ break
+ if selected is None:
+ decision = "source_freshness_unverified"
+ else:
+ selected_time = _timestamp(selected["value"])
+ if selected_time > reference + FUTURE_TOLERANCE:
+ _fail("source_freshness_future")
+ if reference - selected_time > MAX_FRESHNESS_AGE:
+ _fail("source_freshness_stale")
+ decision = "eligible"
+ payload = {
+ "evidence_version": EVIDENCE_VERSION,
+ "policy_version": POLICY_VERSION,
+ "feed_id": _string(feed_id, "freshness_invalid"),
+ "source_identity_digest": _source_identity(source_url),
+ "body_sha256": hashlib.sha256(body).hexdigest(),
+ "reference_time": _canonical_timestamp(reference),
+ "selected_signal": selected,
+ "signals": signals,
+ "decision": decision,
+ }
+ wire = _canonical(payload)
+ read_freshness_evidence(wire)
+ return wire
+
+
+def read_freshness_evidence(wire: bytes) -> dict[str, object]:
+ if type(wire) is not bytes:
+ _fail("freshness_wire_invalid")
+
+ def pairs(items: list[tuple[str, object]]) -> dict[str, object]:
+ result: dict[str, object] = {}
+ for key, value in items:
+ if key in result:
+ _fail("freshness_duplicate_key")
+ result[key] = value
+ return result
+
+ try:
+ value = json.loads(wire.decode("utf-8"), object_pairs_hook=pairs)
+ except FreshnessError:
+ raise
+ except (UnicodeError, json.JSONDecodeError, RecursionError):
+ _fail("freshness_wire_invalid")
+ parsed = _validate_evidence(value)
+ if _canonical(parsed) != wire:
+ _fail("freshness_noncanonical")
+ return parsed
diff --git a/tests/test_source_freshness.py b/tests/test_source_freshness.py
new file mode 100644
index 0000000..0f30fc6
--- /dev/null
+++ b/tests/test_source_freshness.py
@@ -0,0 +1,176 @@
+from __future__ import annotations
+
+import json
+from collections import OrderedDict
+
+import pytest
+
+from political_event_tracking_research import rss_source_fetch
+from political_event_tracking_research.source_freshness import (
+ FreshnessError,
+ build_freshness_evidence,
+ read_freshness_evidence,
+)
+
+
+REFERENCE = "2026-07-13T12:00:00Z"
+RSS = b"""
+Sun, 12 Jul 2026 11:00:00 GMT
+"""
+ATOM = b"""
+2026-07-13T11:00:00Z"""
+
+
+def build(body: bytes, headers: dict[str, str] | None = None) -> bytes:
+ return build_freshness_evidence(
+ feed_id="feed-a",
+ source_url="https://example.test/feed.xml",
+ body=body,
+ response_headers=headers or {},
+ reference_time=REFERENCE,
+ )
+
+
+def test_atom_signal_has_priority_over_rss_and_http() -> None:
+ wire = build(
+ ATOM,
+ {
+ "Last-Modified": "Sat, 11 Jul 2026 11:00:00 GMT",
+ "Date": "Mon, 13 Jul 2026 12:01:00 GMT",
+ },
+ )
+ value = read_freshness_evidence(wire)
+ assert value["decision"] == "eligible"
+ assert value["selected_signal"]["kind"] == "atom_feed_updated"
+ assert [item["kind"] for item in value["signals"]] == [
+ "atom_feed_updated",
+ "rss_channel_last_build_date",
+ "http_last_modified",
+ "http_date",
+ ]
+
+
+def test_rss_signal_is_used_when_atom_is_missing() -> None:
+ value = read_freshness_evidence(build(RSS, {"Last-Modified": "Sat, 11 Jul 2026 11:00:00 GMT"}))
+ assert value["selected_signal"]["kind"] == "rss_channel_last_build_date"
+
+
+def test_http_last_modified_is_last_fallback() -> None:
+ body = b""
+ value = read_freshness_evidence(build(body, {"Last-Modified": "Sun, 12 Jul 2026 11:00:00 GMT"}))
+ assert value["selected_signal"]["kind"] == "http_last_modified"
+ assert value["signals"][0]["present"] is False
+
+
+def test_http_date_alone_does_not_prove_content_freshness() -> None:
+ wire = build(
+ RSS.replace(b"Sun, 12 Jul 2026 11:00:00 GMT", b""),
+ {"Date": "Mon, 13 Jul 2026 12:00:00 GMT"},
+ )
+ assert read_freshness_evidence(wire)["decision"] == "source_freshness_unverified"
+
+
+def test_invalid_high_priority_signal_does_not_fallback() -> None:
+ body = ATOM.replace(b"2026-07-13T11:00:00Z", b"not-a-time")
+ with pytest.raises(FreshnessError, match="source_freshness_invalid"):
+ build(body, {"Last-Modified": "Sun, 12 Jul 2026 11:00:00 GMT"})
+
+
+def test_present_but_empty_high_priority_signal_does_not_fallback() -> None:
+ body = ATOM.replace(b"2026-07-13T11:00:00Z", b"")
+ with pytest.raises(FreshnessError, match="source_freshness_invalid"):
+ build(body, {"Last-Modified": "Sun, 12 Jul 2026 11:00:00 GMT"})
+
+
+def test_timezone_less_signal_is_invalid() -> None:
+ body = ATOM.replace(b"2026-07-13T11:00:00Z", b"2026-07-13T11:00:00")
+ with pytest.raises(FreshnessError, match="source_freshness_invalid"):
+ build(body, {"Last-Modified": "Sun, 12 Jul 2026 11:00:00 GMT"})
+
+
+@pytest.mark.parametrize(
+ "body,headers,error",
+ [
+ (RSS.replace(b"12 Jul 2026", b"01 Jul 2026"), {}, "source_freshness_stale"),
+ (ATOM.replace(b"2026-07-13T11:00:00Z", b"2026-07-13T12:06:00Z"), {}, "source_freshness_future"),
+ ],
+)
+def test_stale_and_future_signals_fail_closed(body: bytes, headers: dict[str, str], error: str) -> None:
+ with pytest.raises(FreshnessError, match=error):
+ build(body, headers)
+
+
+def test_zero_entry_body_can_be_fresh_and_is_not_inferred_stale() -> None:
+ value = read_freshness_evidence(build(RSS))
+ assert value["decision"] == "eligible"
+
+
+def test_evidence_is_canonical_and_tamper_resistant() -> None:
+ wire = build(RSS)
+ assert json.dumps(json.loads(wire), sort_keys=True, separators=(",", ":")).encode() == wire
+ assert read_freshness_evidence(wire)["body_sha256"]
+ with pytest.raises(FreshnessError, match="freshness_noncanonical"):
+ read_freshness_evidence(json.dumps(json.loads(wire), indent=2).encode())
+ with pytest.raises(FreshnessError, match="freshness_invalid"):
+ read_freshness_evidence(wire.replace(b"eligible", b"tampered", 1))
+
+
+def test_duplicate_and_non_mapping_headers_fail_closed() -> None:
+ with pytest.raises(FreshnessError, match="freshness_headers_invalid"):
+ build(RSS, OrderedDict((("Date", "x"), ("date", "y"))))
+
+
+def test_fetch_boundary_returns_body_and_only_freshness_headers() -> None:
+ class Headers:
+ def get(self, name: str) -> str | None:
+ return {"Date": "Mon, 13 Jul 2026 12:00:00 GMT", "Last-Modified": "Sun, 12 Jul 2026 11:00:00 GMT"}.get(name)
+
+ class Response:
+ headers = Headers()
+
+ def __enter__(self) -> "Response":
+ return self
+
+ def __exit__(self, *_args: object) -> bool:
+ return False
+
+ def read(self, _size: int) -> bytes:
+ return RSS
+
+ original = rss_source_fetch.urllib.request.urlopen
+ rss_source_fetch.urllib.request.urlopen = lambda *_args, **_kwargs: Response() # type: ignore[assignment]
+ try:
+ body, headers = rss_source_fetch.fetch_url_with_metadata("https://example.test/feed.xml")
+ finally:
+ rss_source_fetch.urllib.request.urlopen = original
+ assert body == RSS
+ assert headers == {"Date": "Mon, 13 Jul 2026 12:00:00 GMT", "Last-Modified": "Sun, 12 Jul 2026 11:00:00 GMT"}
+
+
+def test_fetch_boundary_rejects_duplicate_freshness_header() -> None:
+ class Headers:
+ def get_all(self, name: str) -> list[str]:
+ return ["Sun, 12 Jul 2026 11:00:00 GMT", "Sun, 12 Jul 2026 12:00:00 GMT"] if name == "Last-Modified" else []
+
+ def get(self, _name: str) -> str | None:
+ return None
+
+ class Response:
+ headers = Headers()
+
+ def __enter__(self) -> "Response":
+ return self
+
+ def __exit__(self, *_args: object) -> bool:
+ return False
+
+ def read(self, _size: int) -> bytes:
+ return RSS
+
+ original = rss_source_fetch.urllib.request.urlopen
+ rss_source_fetch.urllib.request.urlopen = lambda *_args, **_kwargs: Response() # type: ignore[assignment]
+ try:
+ with pytest.raises(ValueError, match="feed_header_duplicate"):
+ rss_source_fetch.fetch_url_with_metadata("https://example.test/feed.xml")
+ finally:
+ rss_source_fetch.urllib.request.urlopen = original