Skip to content

Commit 72af715

Browse files
Pigbibicodex
andcommitted
Add producer source freshness evidence
Co-Authored-By: Codex <noreply@openai.com>
1 parent b7ee631 commit 72af715

4 files changed

Lines changed: 492 additions & 1 deletion

File tree

docs/source_freshness_evidence.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# Source freshness evidence v1
2+
3+
This producer-boundary contract records freshness evidence for one bounded feed
4+
response. It does not claim that a feed is globally complete, and it does not
5+
use event `published_at` as a provider freshness signal.
6+
7+
Signal priority is fixed:
8+
9+
1. Atom feed `updated`;
10+
2. RSS channel `lastBuildDate`;
11+
3. HTTP `Last-Modified`.
12+
13+
HTTP `Date` is recorded as retrieval/server-time evidence only and never makes
14+
the source eligible. A present but invalid, future, or stale higher-priority
15+
signal fails closed; fallback is allowed only when that signal is absent. The
16+
selected signal must be no more than five minutes in the future and no older
17+
than eight days relative to the caller-supplied canonical UTC reference.
18+
19+
The evidence is canonical JSON bytes containing only digests for the source URL
20+
identity and response body, all signal presence/value/kind records, the
21+
reference, policy version, and decision. It does not persist the source URL or
22+
request headers. Missing all selectable signals produces
23+
`source_freshness_unverified`. A period projection with zero selected events is
24+
not stale by itself; H2C feed quarantine remains a separate producer result.

src/political_event_tracking_research/rss_source_fetch.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,10 @@ def load_feed_config(path: str | Path) -> list[FeedConfig]:
7070

7171

7272
def fetch_url(url: str) -> bytes:
73+
return fetch_url_with_metadata(url)[0]
74+
75+
76+
def fetch_url_with_metadata(url: str) -> tuple[bytes, dict[str, str]]:
7377
request = urllib.request.Request(
7478
url,
7579
headers={
@@ -79,9 +83,17 @@ def fetch_url(url: str) -> bytes:
7983
)
8084
with urllib.request.urlopen(request, timeout=20) as response:
8185
payload = response.read(MAX_XML_BYTES + 1)
86+
response_headers = getattr(response, "headers", None)
87+
headers = {}
88+
if response_headers is not None:
89+
headers = {
90+
name: value
91+
for name in ("Date", "Last-Modified")
92+
if (value := response_headers.get(name)) is not None
93+
}
8294
if len(payload) > MAX_XML_BYTES:
8395
raise FeedXmlError("feed_xml_oversize")
84-
return payload
96+
return payload, headers
8597

8698

8799
def strip_html(value: str) -> str:
Lines changed: 320 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,320 @@
1+
"""Producer-boundary freshness evidence for one bounded feed snapshot."""
2+
3+
from __future__ import annotations
4+
5+
import datetime as dt
6+
import email.utils
7+
import hashlib
8+
import json
9+
import re
10+
from collections.abc import Mapping
11+
from urllib.parse import urlsplit
12+
13+
import defusedxml.ElementTree as ET
14+
from defusedxml.common import DefusedXmlException
15+
16+
17+
EVIDENCE_VERSION = "pert.source_freshness_evidence.v1"
18+
POLICY_VERSION = "monday_weekly_8d_5m.v1"
19+
MAX_BODY_BYTES = 1024 * 1024
20+
MAX_HEADER_VALUE_BYTES = 4096
21+
MAX_FRESHNESS_AGE = dt.timedelta(days=8)
22+
FUTURE_TOLERANCE = dt.timedelta(minutes=5)
23+
_DIGEST_RE = re.compile(r"^[0-9a-f]{64}$")
24+
_TIMESTAMP_RE = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$")
25+
_EVIDENCE_KEYS = frozenset(
26+
{
27+
"evidence_version",
28+
"policy_version",
29+
"feed_id",
30+
"source_identity_digest",
31+
"body_sha256",
32+
"reference_time",
33+
"selected_signal",
34+
"signals",
35+
"decision",
36+
}
37+
)
38+
_SIGNAL_KEYS = frozenset({"kind", "present", "valid", "value"})
39+
_SIGNAL_KINDS = (
40+
"atom_feed_updated",
41+
"rss_channel_last_build_date",
42+
"http_last_modified",
43+
"http_date",
44+
)
45+
_SELECTABLE = frozenset(_SIGNAL_KINDS[:3])
46+
47+
48+
class FreshnessError(ValueError):
49+
"""Stable, sanitized producer freshness contract error."""
50+
51+
def __init__(self, code: str) -> None:
52+
self.code = code
53+
super().__init__(code)
54+
55+
56+
def _fail(code: str) -> None:
57+
raise FreshnessError(code)
58+
59+
60+
def _string(value: object, code: str, *, allow_empty: bool = False) -> str:
61+
if type(value) is not str or (not allow_empty and not value) or any(ord(char) < 0x20 for char in value):
62+
_fail(code)
63+
return value
64+
65+
66+
def _timestamp(value: object, code: str = "freshness_time_invalid") -> dt.datetime:
67+
if type(value) is not str or not _TIMESTAMP_RE.fullmatch(value):
68+
_fail(code)
69+
try:
70+
return dt.datetime.fromisoformat(value[:-1] + "+00:00")
71+
except ValueError:
72+
_fail(code)
73+
74+
75+
def _canonical_timestamp(value: dt.datetime) -> str:
76+
return value.astimezone(dt.UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z")
77+
78+
79+
def _parse_date_signal(value: object) -> tuple[bool, str | None]:
80+
if type(value) is not str or not value or len(value) > MAX_HEADER_VALUE_BYTES:
81+
return False, None
82+
try:
83+
parsed = email.utils.parsedate_to_datetime(value)
84+
if parsed is None:
85+
raise ValueError
86+
if parsed.tzinfo is None:
87+
parsed = parsed.replace(tzinfo=dt.UTC)
88+
return True, _canonical_timestamp(parsed)
89+
except (TypeError, ValueError, OverflowError):
90+
try:
91+
parsed = dt.datetime.fromisoformat(value.replace("Z", "+00:00"))
92+
if parsed.tzinfo is None:
93+
parsed = parsed.replace(tzinfo=dt.UTC)
94+
return True, _canonical_timestamp(parsed)
95+
except (TypeError, ValueError, OverflowError):
96+
return False, None
97+
98+
99+
def _headers(value: Mapping[str, str]) -> dict[str, str]:
100+
if not isinstance(value, Mapping):
101+
_fail("freshness_headers_invalid")
102+
result: dict[str, str] = {}
103+
try:
104+
items = list(value.items())
105+
except (AttributeError, RuntimeError, TypeError, ValueError):
106+
_fail("freshness_headers_invalid")
107+
for key, item in items:
108+
if (
109+
type(key) is not str
110+
or any(ord(char) < 0x20 for char in key)
111+
or type(item) is not str
112+
or len(item.encode("utf-8")) > MAX_HEADER_VALUE_BYTES
113+
):
114+
_fail("freshness_headers_invalid")
115+
normalized = key.lower()
116+
if normalized in result:
117+
_fail("freshness_headers_invalid")
118+
result[normalized] = item
119+
return result
120+
121+
122+
def _source_identity(source_url: object) -> str:
123+
value = _string(source_url, "source_identity_invalid")
124+
try:
125+
parsed = urlsplit(value)
126+
if parsed.scheme not in {"http", "https"} or not parsed.hostname or parsed.username or parsed.password:
127+
_fail("source_identity_invalid")
128+
except ValueError:
129+
_fail("source_identity_invalid")
130+
return hashlib.sha256(value.encode("utf-8")).hexdigest()
131+
132+
133+
def _xml_signal(body: bytes, kind: str) -> tuple[bool, str | None]:
134+
try:
135+
root = ET.fromstring(body, forbid_dtd=True, forbid_entities=True, forbid_external=True)
136+
except (DefusedXmlException, ET.ParseError, LookupError, UnicodeError, ValueError, RecursionError):
137+
_fail("source_freshness_invalid")
138+
if root.tag == "rss":
139+
channels = [child for child in root if child.tag == "channel"]
140+
if len(channels) != 1:
141+
_fail("source_freshness_invalid")
142+
if kind == "rss_channel_last_build_date":
143+
values = [child.text.strip() for child in channels[0] if child.tag == "lastBuildDate" and child.text]
144+
if len(values) > 1:
145+
_fail("source_freshness_invalid")
146+
if not values:
147+
return False, None
148+
return True, _parse_date_signal(values[0])[1]
149+
return False, None
150+
if root.tag == "{http://www.w3.org/2005/Atom}feed":
151+
if kind == "atom_feed_updated":
152+
values = [child.text.strip() for child in root if child.tag == "{http://www.w3.org/2005/Atom}updated" and child.text]
153+
if len(values) > 1:
154+
_fail("source_freshness_invalid")
155+
if not values:
156+
return False, None
157+
return True, _parse_date_signal(values[0])[1]
158+
return False, None
159+
_fail("source_freshness_invalid")
160+
161+
162+
def _signal(kind: str, present: bool, value: str | None, valid: bool) -> dict[str, object]:
163+
return {"kind": kind, "present": present, "valid": valid, "value": value}
164+
165+
166+
def _validate_signal(value: object) -> dict[str, object]:
167+
if not isinstance(value, Mapping) or set(value) != _SIGNAL_KEYS:
168+
_fail("freshness_invalid")
169+
kind = _string(value["kind"], "freshness_invalid")
170+
if kind not in _SIGNAL_KINDS or type(value["present"]) is not bool or type(value["valid"]) is not bool:
171+
_fail("freshness_invalid")
172+
signal_value = value["value"]
173+
if signal_value is not None:
174+
_timestamp(signal_value, "freshness_invalid")
175+
if not value["present"] and (signal_value is not None or value["valid"]):
176+
_fail("freshness_invalid")
177+
if value["valid"] and signal_value is None:
178+
_fail("freshness_invalid")
179+
return {key: value[key] for key in ("kind", "present", "valid", "value")}
180+
181+
182+
def _validate_evidence(value: object) -> dict[str, object]:
183+
if not isinstance(value, Mapping) or set(value) != _EVIDENCE_KEYS:
184+
_fail("freshness_invalid")
185+
if value["evidence_version"] != EVIDENCE_VERSION or value["policy_version"] != POLICY_VERSION:
186+
_fail("freshness_invalid")
187+
_string(value["feed_id"], "freshness_invalid")
188+
for key in ("source_identity_digest", "body_sha256"):
189+
if type(value[key]) is not str or not _DIGEST_RE.fullmatch(value[key]):
190+
_fail("freshness_invalid")
191+
_timestamp(value["reference_time"], "freshness_invalid")
192+
signals = value["signals"]
193+
if not isinstance(signals, list) or len(signals) != len(_SIGNAL_KINDS):
194+
_fail("freshness_invalid")
195+
parsed = [_validate_signal(item) for item in signals]
196+
if [item["kind"] for item in parsed] != list(_SIGNAL_KINDS):
197+
_fail("freshness_invalid")
198+
for item in parsed[:3]:
199+
if item["present"] and not item["valid"]:
200+
_fail("freshness_invalid")
201+
first_present = next((item for item in parsed[:3] if item["present"]), None)
202+
selected = value["selected_signal"]
203+
if selected is not None:
204+
selected = _validate_signal(selected)
205+
if selected["kind"] not in _SELECTABLE or not selected["valid"]:
206+
_fail("freshness_invalid")
207+
matching = next(item for item in parsed if item["kind"] == selected["kind"])
208+
if selected != matching:
209+
_fail("freshness_invalid")
210+
if selected != first_present:
211+
_fail("freshness_invalid")
212+
if selected is not None:
213+
reference = _timestamp(value["reference_time"])
214+
selected_time = _timestamp(selected["value"])
215+
if selected_time > reference + FUTURE_TOLERANCE or reference - selected_time > MAX_FRESHNESS_AGE:
216+
_fail("freshness_invalid")
217+
if value["decision"] not in {"eligible", "source_freshness_unverified"}:
218+
_fail("freshness_invalid")
219+
if (value["decision"] == "eligible") != (selected is not None):
220+
_fail("freshness_invalid")
221+
return {
222+
"evidence_version": value["evidence_version"],
223+
"policy_version": value["policy_version"],
224+
"feed_id": value["feed_id"],
225+
"source_identity_digest": value["source_identity_digest"],
226+
"body_sha256": value["body_sha256"],
227+
"reference_time": value["reference_time"],
228+
"selected_signal": selected,
229+
"signals": parsed,
230+
"decision": value["decision"],
231+
}
232+
233+
234+
def _canonical(value: object) -> bytes:
235+
try:
236+
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False).encode("utf-8")
237+
except (TypeError, ValueError, UnicodeError, RecursionError):
238+
_fail("freshness_serialization_invalid")
239+
240+
241+
def build_freshness_evidence(
242+
*,
243+
feed_id: str,
244+
source_url: str,
245+
body: bytes,
246+
response_headers: Mapping[str, str],
247+
reference_time: str,
248+
) -> bytes:
249+
if type(body) is not bytes or len(body) > MAX_BODY_BYTES:
250+
_fail("freshness_body_invalid")
251+
reference = _timestamp(reference_time)
252+
headers = _headers(response_headers)
253+
atom_present, atom_value = _xml_signal(body, "atom_feed_updated")
254+
rss_present, rss_value = _xml_signal(body, "rss_channel_last_build_date")
255+
last_modified = headers.get("last-modified")
256+
http_present = last_modified is not None
257+
http_valid, http_value = _parse_date_signal(last_modified) if http_present else (False, None)
258+
date_value = headers.get("date")
259+
date_present = date_value is not None
260+
date_valid, date_parsed = _parse_date_signal(date_value) if date_present else (False, None)
261+
signals = [
262+
_signal("atom_feed_updated", atom_present, atom_value, atom_present and atom_value is not None),
263+
_signal("rss_channel_last_build_date", rss_present, rss_value, rss_present and rss_value is not None),
264+
_signal("http_last_modified", http_present, http_value, http_present and http_valid),
265+
_signal("http_date", date_present, date_parsed, date_present and date_valid),
266+
]
267+
selected: dict[str, object] | None = None
268+
for item in signals[:3]:
269+
if item["present"]:
270+
if not item["valid"]:
271+
_fail("source_freshness_invalid")
272+
selected = item
273+
break
274+
if selected is None:
275+
decision = "source_freshness_unverified"
276+
else:
277+
selected_time = _timestamp(selected["value"])
278+
if selected_time > reference + FUTURE_TOLERANCE:
279+
_fail("source_freshness_future")
280+
if reference - selected_time > MAX_FRESHNESS_AGE:
281+
_fail("source_freshness_stale")
282+
decision = "eligible"
283+
payload = {
284+
"evidence_version": EVIDENCE_VERSION,
285+
"policy_version": POLICY_VERSION,
286+
"feed_id": _string(feed_id, "freshness_invalid"),
287+
"source_identity_digest": _source_identity(source_url),
288+
"body_sha256": hashlib.sha256(body).hexdigest(),
289+
"reference_time": _canonical_timestamp(reference),
290+
"selected_signal": selected,
291+
"signals": signals,
292+
"decision": decision,
293+
}
294+
wire = _canonical(payload)
295+
read_freshness_evidence(wire)
296+
return wire
297+
298+
299+
def read_freshness_evidence(wire: bytes) -> dict[str, object]:
300+
if type(wire) is not bytes:
301+
_fail("freshness_wire_invalid")
302+
303+
def pairs(items: list[tuple[str, object]]) -> dict[str, object]:
304+
result: dict[str, object] = {}
305+
for key, value in items:
306+
if key in result:
307+
_fail("freshness_duplicate_key")
308+
result[key] = value
309+
return result
310+
311+
try:
312+
value = json.loads(wire.decode("utf-8"), object_pairs_hook=pairs)
313+
except FreshnessError:
314+
raise
315+
except (UnicodeError, json.JSONDecodeError, RecursionError):
316+
_fail("freshness_wire_invalid")
317+
parsed = _validate_evidence(value)
318+
if _canonical(parsed) != wire:
319+
_fail("freshness_noncanonical")
320+
return parsed

0 commit comments

Comments
 (0)