Skip to content

Commit dd15bb8

Browse files
Pigbibicodex
andcommitted
Harden decision evidence immutability
Co-Authored-By: Codex <noreply@openai.com>
1 parent 5641e94 commit dd15bb8

2 files changed

Lines changed: 30 additions & 4 deletions

File tree

src/political_event_tracking_research/feed_status_decision.py

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from collections.abc import Iterable, Mapping
77
from dataclasses import dataclass
88
from enum import Enum
9+
from types import MappingProxyType
910

1011

1112
STATUS_VERSION = "pert.feed_status_decision.v1"
@@ -62,7 +63,7 @@ def wire(self) -> dict[str, str]:
6263

6364
@dataclass(frozen=True)
6465
class StatusEvidence:
65-
status: dict[str, object]
66+
status: Mapping[str, object]
6667
canonical_bytes: bytes
6768

6869

@@ -133,8 +134,16 @@ def _parse_outcome(value: object) -> tuple[dict[str, object], list[_Row]]:
133134
return {"feed_id": feed_id, "feed_url": feed_url, "kind": kind, "state": state, "error_code": error}, rows
134135

135136

136-
def _row_key(row: _Row) -> tuple[str, str]:
137-
return row.published_at, row.item_id
137+
def _row_key(row: _Row) -> tuple[str, ...]:
138+
return tuple(getattr(row, key) for key in _ROW_KEYS)
139+
140+
141+
def _freeze(value: object) -> object:
142+
if isinstance(value, dict):
143+
return MappingProxyType({key: _freeze(child) for key, child in value.items()})
144+
if isinstance(value, list):
145+
return tuple(_freeze(child) for child in value)
146+
return value
138147

139148

140149
def _digest(rows: list[_Row]) -> str:
@@ -216,7 +225,7 @@ def build_status_decision(outcomes: Iterable[Mapping[str, object]]) -> StatusDec
216225
if len(set(ids)) != len(ids):
217226
_fail("feed_duplicate")
218227
status = _build_status(parsed)
219-
evidence = StatusEvidence(status, _canonical(status))
228+
evidence = StatusEvidence(_freeze(status), _canonical(status))
220229
if status["failed_feed_count"]:
221230
kind = DecisionKind.HARD_FAIL
222231
elif status["quarantined_feed_count"]:

tests/test_feed_status_decision.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,3 +101,20 @@ def test_wire_is_canonical_and_deterministic() -> None:
101101
second = build_status_decision([outcome("a", "accepted"), outcome("b", "accepted")])
102102
assert first.evidence.canonical_bytes == second.evidence.canonical_bytes
103103
assert json.loads(first.evidence.canonical_bytes)["feeds"][0]["feed_id"] == "a"
104+
105+
106+
def test_status_evidence_is_deeply_immutable_and_bound_to_bytes() -> None:
107+
result = build_status_decision([outcome("a", "accepted")])
108+
with pytest.raises(TypeError):
109+
result.evidence.status["feed_count"] = 99
110+
with pytest.raises(TypeError):
111+
result.evidence.status["feeds"][0]["feed_id"] = "tampered"
112+
assert json.loads(result.evidence.canonical_bytes)["feed_count"] == result.evidence.status["feed_count"]
113+
114+
115+
def test_digest_sort_is_total_for_equal_published_at_and_item_id() -> None:
116+
first = {**ROW, "text": "first"}
117+
second = {**ROW, "text": "second"}
118+
left = build_status_decision([outcome("a", "accepted", rows=[first, second])])
119+
right = build_status_decision([outcome("a", "accepted", rows=[second, first])])
120+
assert left.evidence.canonical_bytes == right.evidence.canonical_bytes

0 commit comments

Comments
 (0)