2424 inventory_directory ,
2525 inventory_sha256 ,
2626 selection_policy ,
27+ sha256_file ,
28+ validate_audit_receipt ,
2729 validate_manifest ,
2830)
2931
@@ -130,26 +132,33 @@ def _build_receipt(
130132 valid_tokens : dict [str , int ],
131133 bad : dict [str , tuple [int , int ]] | None = None ,
132134 omit : set [str ] | None = None ,
135+ file_records : dict [str , list [dict [str , object ]]] | None = None ,
133136) -> Path :
134137 """Write a real audit receipt JSON with a ``by_kind_bucket`` map.
135138
136139 ``bad`` maps a bucket key to ``(bad_files, bad_rows)``; ``omit`` drops keys
137140 entirely (stale/narrow receipt). The aggregate ``total`` is kept green on
138141 purpose to prove the gate is per-selected-bucket, not the coarse aggregate.
142+ ``file_records`` maps a bucket key to per-file identity records
143+ (path/size/sha256) that bind the receipt to exact bytes.
139144 """
140145 bad = bad or {}
141146 omit = omit or set ()
142- by_kind_bucket : dict [str , dict [str , int ]] = {}
147+ file_records = file_records or {}
148+ by_kind_bucket : dict [str , dict [str , object ]] = {}
143149 for key , tokens in valid_tokens .items ():
144150 if key in omit :
145151 continue
146152 bf , br = bad .get (key , (0 , 0 ))
147- by_kind_bucket [ key ] = {
153+ entry : dict [ str , object ] = {
148154 "valid_tokens" : tokens ,
149155 "files" : 1 ,
150156 "bad_files" : bf ,
151157 "bad_rows" : br ,
152158 }
159+ if key in file_records :
160+ entry ["file_records" ] = file_records [key ]
161+ by_kind_bucket [key ] = entry
153162 payload = {
154163 "schema" : AUDIT_SCHEMA ,
155164 "status" : "verified" ,
@@ -258,18 +267,31 @@ def _download_fixture(
258267 return finalize_manifest (payload ), root
259268
260269
261- def _populate_upload_sources (selections : tuple [tuple [str , Path ], ...]) -> None :
270+ def _populate_upload_sources (
271+ selections : tuple [tuple [str , Path ], ...],
272+ ) -> dict [str , list [dict [str , object ]]]:
273+ """Create source directories and return per-bucket file identity records."""
274+ file_records : dict [str , list [dict [str , object ]]] = {}
262275 for index , (remote , local ) in enumerate (selections , 1 ):
263276 local .mkdir (parents = True , exist_ok = True )
264277 if remote .startswith ("parquet/" ):
265278 key = remote .removeprefix ("parquet/" )
266279 bucket = int (remote .rsplit ("/" , 1 )[1 ])
280+ shard_path = local / f"part-{ index :03d} .parquet"
267281 _write_green_shard (
268- local / f"part- { index :03d } .parquet" ,
282+ shard_path ,
269283 bucket = bucket ,
270284 valid_tokens = _BUCKET_VALID_TOKENS [key ],
271285 source_id = index ,
272286 )
287+ file_records .setdefault (key , []).append (
288+ {
289+ "path" : shard_path .name ,
290+ "size" : shard_path .stat ().st_size ,
291+ "sha256" : sha256_file (shard_path ),
292+ }
293+ )
294+ return file_records
273295
274296
275297def test_token_total_is_profile_aware_subset (tmp_path : Path ) -> None :
@@ -494,10 +516,14 @@ def test_upload_main_dry_run_writes_manifest_and_prints_targets(
494516 monkeypatch .chdir (tmp_path )
495517 # Every selected source dir must exist or _existing_sources RAISEs; this
496518 # also creates the audit dir that holds the receipt.
497- _populate_upload_sources (upload .ALL_VALID_SELECTIONS )
519+ fr = _populate_upload_sources (upload .ALL_VALID_SELECTIONS )
498520 # Real temp receipt at the relative path the script reads (_audit_receipts),
499521 # covering every selected parquet bucket and green.
500- _build_receipt (upload ._audit_receipts ()[0 ], valid_tokens = _BUCKET_VALID_TOKENS )
522+ _build_receipt (
523+ upload ._audit_receipts ()[0 ],
524+ valid_tokens = _BUCKET_VALID_TOKENS ,
525+ file_records = fr ,
526+ )
501527
502528 manifest_path = tmp_path / "manifest.json"
503529 rc = upload .main (
@@ -549,11 +575,12 @@ def test_upload_main_dry_run_does_not_gate_default_on_standalone_pr_bucket(
549575 tmp_path : Path , monkeypatch
550576) -> None :
551577 monkeypatch .chdir (tmp_path )
552- _populate_upload_sources (upload .ALL_VALID_SELECTIONS )
578+ fr = _populate_upload_sources (upload .ALL_VALID_SELECTIONS )
553579 _build_receipt (
554580 upload ._audit_receipts ()[0 ],
555581 valid_tokens = _BUCKET_VALID_TOKENS ,
556582 bad = {"pr/8192" : (1 , 0 )},
583+ file_records = fr ,
557584 )
558585
559586 rc = upload .main (
@@ -574,13 +601,14 @@ def test_upload_main_dry_run_raises_on_non_green_explicit_standalone_pr_bucket(
574601 tmp_path : Path , monkeypatch
575602) -> None :
576603 monkeypatch .chdir (tmp_path )
577- _populate_upload_sources (
604+ fr = _populate_upload_sources (
578605 upload .CODE_COMMIT_SELECTIONS + upload .STANDALONE_PR_SELECTIONS
579606 )
580607 _build_receipt (
581608 upload ._audit_receipts ()[0 ],
582609 valid_tokens = _BUCKET_VALID_TOKENS ,
583610 bad = {"pr/8192" : (1 , 0 )},
611+ file_records = fr ,
584612 )
585613
586614 with pytest .raises (SystemExit ) as exc :
@@ -777,3 +805,92 @@ def test_download_main_dry_run_copies_manifest_uri(
777805 # syncs code/commit buckets, but not standalone PR diagnostics.
778806 assert "s3://mybucket/myprefix/parquet/code/1024/" in out
779807 assert "parquet/pr/" not in out
808+
809+
810+ # --- CR-11: audit receipt must bind to per-file bytes -------------------------
811+
812+
813+ def test_audit_receipt_rejects_stale_file_records_after_byte_swap (
814+ tmp_path : Path ,
815+ ) -> None :
816+ """A green receipt whose file_records reference the original bytes must be
817+ rejected when the underlying parquet is replaced with different content
818+ (same file count). A fresh receipt matching the new bytes must pass.
819+ """
820+ shard_dir = tmp_path / "code" / "1024"
821+ shard_dir .mkdir (parents = True )
822+ shard = shard_dir / "part-001.parquet"
823+ _write_green_shard (shard , bucket = 1024 , valid_tokens = 42 , source_id = 1 )
824+
825+ original_sha = sha256_file (shard )
826+ original_size = shard .stat ().st_size
827+ original_records = [
828+ {"path" : "part-001.parquet" , "size" : original_size , "sha256" : original_sha }
829+ ]
830+
831+ receipt_path = tmp_path / "sidecar_parquet_audit.json"
832+ _build_receipt (
833+ receipt_path ,
834+ valid_tokens = {"code/1024" : 42 },
835+ file_records = {"code/1024" : original_records },
836+ )
837+ receipt = json .loads (receipt_path .read_text (encoding = "utf-8" ))
838+
839+ # Inventory matching the original bytes passes.
840+ inventory_ok = {"code/1024" : original_records }
841+ validate_audit_receipt (receipt , selected_keys = ["code/1024" ], inventory = inventory_ok )
842+
843+ # Replace the shard with different bytes (same file count).
844+ shard .write_bytes (b"\x00 " * original_size )
845+ swapped_sha = sha256_file (shard )
846+ assert swapped_sha != original_sha
847+ swapped_records = [
848+ {"path" : "part-001.parquet" , "size" : original_size , "sha256" : swapped_sha }
849+ ]
850+ inventory_swapped = {"code/1024" : swapped_records }
851+
852+ # The stale receipt must be rejected against the swapped inventory.
853+ with pytest .raises (ValueError , match = "file_records do not match inventory" ):
854+ validate_audit_receipt (
855+ receipt , selected_keys = ["code/1024" ], inventory = inventory_swapped
856+ )
857+
858+ # A fresh receipt binding to the new bytes must pass.
859+ fresh_receipt_path = tmp_path / "fresh_audit.json"
860+ _build_receipt (
861+ fresh_receipt_path ,
862+ valid_tokens = {"code/1024" : 42 },
863+ file_records = {"code/1024" : swapped_records },
864+ )
865+ fresh_receipt = json .loads (fresh_receipt_path .read_text (encoding = "utf-8" ))
866+ validate_audit_receipt (
867+ fresh_receipt , selected_keys = ["code/1024" ], inventory = inventory_swapped
868+ )
869+
870+
871+ def test_audit_receipt_rejects_missing_file_records_when_inventory_supplied (
872+ tmp_path : Path ,
873+ ) -> None :
874+ """When inventory is supplied but the receipt bucket lacks file_records,
875+ validation must fail rather than silently skipping the byte binding.
876+ """
877+ receipt_path = tmp_path / "sidecar_parquet_audit.json"
878+ _build_receipt (receipt_path , valid_tokens = {"code/1024" : 42 })
879+ receipt = json .loads (receipt_path .read_text (encoding = "utf-8" ))
880+
881+ shard_dir = tmp_path / "code" / "1024"
882+ shard_dir .mkdir (parents = True )
883+ shard = shard_dir / "part-001.parquet"
884+ _write_green_shard (shard , bucket = 1024 , valid_tokens = 42 , source_id = 1 )
885+ inv = {
886+ "code/1024" : [
887+ {
888+ "path" : "part-001.parquet" ,
889+ "size" : shard .stat ().st_size ,
890+ "sha256" : sha256_file (shard ),
891+ }
892+ ]
893+ }
894+
895+ with pytest .raises (ValueError , match = "lacks file_records" ):
896+ validate_audit_receipt (receipt , selected_keys = ["code/1024" ], inventory = inv )
0 commit comments