@@ -429,22 +429,151 @@ def _safe_finalize_verify_receipt(
429429 return receipt , rc
430430
431431
432- VERIFY_RUNS_KEEP = 50
432+ VERIFY_RUNS_KEEP = config . DEFAULT_VERIFY_RUNS_KEEP
433433
434+ VERIFY_ARCHIVE_INDEX_NAME = "index.jsonl"
435+ VERIFY_ARCHIVE_INDEX_SCHEMA = "brigade.verify_archive_index.v1"
436+ VERIFY_ARCHIVE_INDEX_SCHEMA_VERSION = 1
434437
435- def _prune_verify_runs (target : Path , keep : int = VERIFY_RUNS_KEEP ) -> int :
436- """Cap retained verify-run directories so receipts + raw logs don't grow without bound.
438+
439+ def _verify_archive_root (target : Path , archive_root : Path | None = None ) -> Path | None :
440+ """Resolve the verify-evidence archive root, or None when archival is disabled."""
441+ if archive_root is not None :
442+ return archive_root
443+ enabled , resolved = config .resolve_verify_archive (target )
444+ if not enabled :
445+ return None
446+ return resolved
447+
448+
449+ def _verify_archive_index_entry (
450+ run_dir : Path ,
451+ dest : Path ,
452+ receipt_file_sha256 : str | None ,
453+ * ,
454+ already_archived : bool ,
455+ ) -> dict [str , Any ]:
456+ receipt = _verify_read_receipt (run_dir )
457+ digests = receipt .get ("digests" ) if receipt is not None and isinstance (receipt .get ("digests" ), dict ) else {}
458+ return {
459+ "schema" : VERIFY_ARCHIVE_INDEX_SCHEMA ,
460+ "schema_version" : VERIFY_ARCHIVE_INDEX_SCHEMA_VERSION ,
461+ "run_id" : run_dir .name ,
462+ "archived_at" : helpers ._now ().isoformat (),
463+ "source_run_dir" : str (run_dir ),
464+ "archive_run_dir" : str (dest ),
465+ "already_archived" : already_archived ,
466+ "receipt_file_sha256" : receipt_file_sha256 ,
467+ "receipt_schema_version" : receipt .get ("schema_version" ) if receipt is not None else None ,
468+ "receipt_sha256" : digests .get ("receipt_sha256" ),
469+ "signature" : digests .get ("signature" ),
470+ "key_id" : digests .get ("key_id" ),
471+ "status" : receipt .get ("status" ) if receipt is not None else None ,
472+ "started_at" : receipt .get ("started_at" ) if receipt is not None else None ,
473+ "completed_at" : receipt .get ("completed_at" ) if receipt is not None else None ,
474+ }
475+
476+
477+ def _append_verify_archive_index (archive_root : Path , entry : dict [str , Any ]) -> None :
478+ index_path = archive_root / VERIFY_ARCHIVE_INDEX_NAME
479+ with index_path .open ("a" , encoding = "utf-8" ) as handle :
480+ handle .write (json .dumps (entry , sort_keys = True , default = str ) + "\n " )
481+
482+
483+ def _assert_archived_receipt_integrity (archived_receipt : Path ) -> None :
484+ """Re-verify a copied receipt's self-declared digest against its archived bytes."""
485+ try :
486+ payload = json .loads (archived_receipt .read_text ())
487+ except (OSError , json .JSONDecodeError ) as exc :
488+ raise OSError (f"verify archive receipt is unreadable: { archived_receipt } : { exc } " ) from exc
489+ if not isinstance (payload , dict ):
490+ raise OSError (f"verify archive receipt is not a JSON object: { archived_receipt } " )
491+ digests = payload .get ("digests" )
492+ if not isinstance (digests , dict ):
493+ return # legacy receipts without a digests block have no self-digest to re-verify
494+ expected = digests .get ("receipt_sha256" )
495+ if not isinstance (expected , str ) or not expected :
496+ return
497+ actual = localio .canonical_json_digest (payload , exclude_keys = {"digests" })
498+ if actual != expected :
499+ raise OSError (f"verify archive receipt digest mismatch: { archived_receipt } " )
500+
501+
502+ def _archive_verify_run (run_dir : Path , archive_root : Path ) -> dict [str , Any ]:
503+ """Copy one run dir into the archive and append an index entry. Raises on failure.
504+
505+ Integrity is checked twice: the archived receipt bytes must match the source
506+ bytes, and a receipt that carries ``digests.receipt_sha256`` must still
507+ re-hash to that value after the copy. Callers must treat any exception as
508+ "do not delete the original".
509+ """
510+ run_id = run_dir .name
511+ dest = archive_root / run_id
512+ receipt_path = run_dir / "receipt.json"
513+ source_receipt_sha = localio .file_sha256 (receipt_path ) if receipt_path .is_file () else None
514+ if dest .exists ():
515+ # Re-archive of the same run id is only safe when the evidence is identical.
516+ dest_receipt = dest / "receipt.json"
517+ dest_sha = localio .file_sha256 (dest_receipt ) if dest_receipt .is_file () else None
518+ if source_receipt_sha is None or dest_sha != source_receipt_sha :
519+ raise OSError (f"verify archive conflict: { dest } already exists with different evidence" )
520+ entry = _verify_archive_index_entry (run_dir , dest , source_receipt_sha , already_archived = True )
521+ _append_verify_archive_index (archive_root , entry )
522+ return entry
523+ archive_root .mkdir (parents = True , exist_ok = True )
524+ staging = archive_root / f".{ run_id } .staging-{ uuid4 ().hex [:8 ]} "
525+ shutil .copytree (run_dir , staging )
526+ try :
527+ if source_receipt_sha is not None :
528+ copied_sha = localio .file_sha256 (staging / "receipt.json" )
529+ if copied_sha != source_receipt_sha :
530+ raise OSError (f"verify archive copy integrity check failed: { run_id } " )
531+ _assert_archived_receipt_integrity (staging / "receipt.json" )
532+ os .rename (staging , dest )
533+ except BaseException :
534+ shutil .rmtree (staging , ignore_errors = True )
535+ raise
536+ entry = _verify_archive_index_entry (run_dir , dest , source_receipt_sha , already_archived = False )
537+ _append_verify_archive_index (archive_root , entry )
538+ return entry
539+
540+
541+ def _prune_verify_runs (target : Path , keep : int | None = None , archive_root : Path | None = None ) -> int :
542+ """Cap retained verify-run directories, archiving receipt evidence before deletion.
437543
438544 Run dirs are timestamp-prefixed (sortable by name); the newest ``keep`` are
439- retained and older ones removed. Best-effort: a removal error never aborts a
440- verify run.
545+ retained locally. Older dirs are first copied into the verify archive (with
546+ an append-only index entry carrying the receipt digest, signature, and
547+ schema version) and only then removed, so pruning never destroys receipt
548+ evidence. A run dir whose archival fails is kept locally. Best-effort: an
549+ archival or removal error never aborts a verify run.
441550 """
551+ if keep is None :
552+ try :
553+ keep = config .resolve_verify_runs_keep (target )
554+ except Exception :
555+ keep = VERIFY_RUNS_KEEP
442556 root = helpers ._verify_runs_root (target )
443557 if not root .is_dir ():
444558 return 0
559+ try :
560+ resolved_archive = _verify_archive_root (target , archive_root )
561+ except Exception :
562+ resolved_archive = None
445563 run_dirs = sorted ((child for child in root .iterdir () if child .is_dir ()), key = lambda p : p .name , reverse = True )
446564 removed = 0
447- for stale in run_dirs [keep :]:
565+ # Oldest first so the append-only archive index grows in chronological order.
566+ for stale in reversed (run_dirs [keep :]):
567+ if resolved_archive is not None :
568+ try :
569+ resolved_archive .relative_to (stale )
570+ continue # the archive root itself lives under the runs root; never prune it
571+ except ValueError :
572+ pass
573+ try :
574+ _archive_verify_run (stale , resolved_archive )
575+ except Exception :
576+ continue # prune safety: never delete evidence that failed to archive
448577 try :
449578 shutil .rmtree (stale )
450579 removed += 1
@@ -1055,7 +1184,10 @@ def _write_reused_receipt(
10551184 receipt ["digests" ]["key_id" ] = key_id
10561185 helpers ._write_json (run_dir / "receipt.json" , receipt )
10571186 _write_verify_markdown (run_dir , receipt )
1058- _prune_verify_runs (target )
1187+ try :
1188+ _prune_verify_runs (target )
1189+ except Exception :
1190+ pass
10591191 return receipt , 0
10601192
10611193
0 commit comments