1313 build_publication_evidence ,
1414 read_input_policy_evidence ,
1515 read_publication_evidence ,
16+ recompute_source_items_binding ,
1617)
18+ from political_event_tracking_research .feed_status_canonical_h2c import build_decision
1719
1820
1921def write_inputs (root : Path ) -> None :
@@ -171,6 +173,62 @@ def test_debug_evidence_can_readback_without_becoming_publishable(tmp_path: Path
171173 assert evidence ["status_eligible_for_live_publication" ] is True
172174
173175
176+ def test_source_items_binding_recomputes_count_digest_and_publishability () -> None :
177+ row = {
178+ "item_id" : "a-1" ,
179+ "published_at" : "2026-05-01T12:30:00Z" ,
180+ "source_type" : "official" ,
181+ "source_url" : "https://example.test/a" ,
182+ "author" : "" ,
183+ "text" : "event" ,
184+ }
185+ status = build_decision (
186+ [
187+ {
188+ "feed_id" : "feed-a" ,
189+ "feed_url" : "https://example.test/feed-a" ,
190+ "kind" : "rss2" ,
191+ "state" : "accepted" ,
192+ "rows" : [row ],
193+ "error_code" : None ,
194+ }
195+ ]
196+ )
197+ source = (
198+ "item_id,published_at,source_type,source_url,author,text\n "
199+ "a-1,2026-05-01T12:30:00Z,official,https://example.test/a,,event\n "
200+ ).encode ()
201+ count , digest , eligible = recompute_source_items_binding (source , status .status_bytes )
202+ assert (count , digest , eligible ) == (1 , json .loads (status .status_bytes )["aggregate_row_digest" ], True )
203+
204+
205+ def test_source_items_binding_rejects_count_or_digest_drift () -> None :
206+ status = build_decision (
207+ [
208+ {
209+ "feed_id" : "feed-a" ,
210+ "feed_url" : "https://example.test/feed-a" ,
211+ "kind" : "rss2" ,
212+ "state" : "accepted" ,
213+ "rows" : [
214+ {
215+ "item_id" : "a-1" ,
216+ "published_at" : "2026-05-01T12:30:00Z" ,
217+ "source_type" : "official" ,
218+ "source_url" : "https://example.test/a" ,
219+ "author" : "" ,
220+ "text" : "event" ,
221+ }
222+ ],
223+ "error_code" : None ,
224+ }
225+ ]
226+ )
227+ tampered = b"item_id,published_at,source_type,source_url,author,text\n a-1,2026-05-01T12:30:00Z,official,https://example.test/a,,changed\n "
228+ with pytest .raises (PublishInputPolicyError , match = "source_items_status_mismatch" ):
229+ recompute_source_items_binding (tampered , status .status_bytes )
230+
231+
174232def test_workflow_guard_and_evidence_precede_fetch_and_publish () -> None :
175233 workflow = Path (__file__ ).parents [1 ].joinpath (".github/workflows/rss_source_pipeline.yml" ).read_text (
176234 encoding = "utf-8"
@@ -182,4 +240,5 @@ def test_workflow_guard_and_evidence_precede_fetch_and_publish() -> None:
182240 assert workflow .index ("Upload RSS source artifact" ) < workflow .index ("Build and validate publication evidence" )
183241 assert workflow .index ("Build and validate publication evidence" ) < workflow .index ("Publish live CSV outputs" )
184242 assert '--max-items-per-feed "${MAX_ITEMS_PER_FEED}"' in workflow
243+ assert workflow .count ("ref: ${{ github.sha }}" ) == 2
185244 assert "git push origin HEAD:refs/heads/main" in workflow
0 commit comments