@@ -103,6 +103,27 @@ def promotion_decision_for(verdict: str) -> str:
103103 }.get (verdict , PROMOTION_BLOCK_AND_INVESTIGATE )
104104
105105
106+ def promotion_decision_for_report (
107+ verdict : str , evidence : Mapping [str , Any ] | None = None
108+ ) -> str :
109+ """Map a report verdict and evidence block to a promotion decision.
110+
111+ A clean integrity-only report is useful, but it is not enough to
112+ claim replay-safe promotion. Keep the verdict tied to the checks that
113+ ran while reducing the recommendation to review when replay was
114+ intentionally skipped.
115+ """
116+
117+ decision = promotion_decision_for (verdict )
118+ if (
119+ decision == PROMOTION_SAFE_TO_PROMOTE
120+ and evidence is not None
121+ and evidence .get ("replay_skipped" ) is True
122+ ):
123+ return PROMOTION_REVIEW_BEFORE_PROMOTE
124+ return decision
125+
126+
106127def aggregate_verdicts (verdicts : Sequence [str ]) -> str :
107128 """Reduce a list of verdicts to the strictest one.
108129
@@ -252,12 +273,16 @@ class BundleEntry:
252273 reason : str | None = None
253274
254275 def to_dict (self ) -> dict [str , Any ]:
276+ evidence = dict (self .evidence ) if self .evidence is not None else None
255277 return {
256278 "bundle_path" : self .path ,
257279 "verdict" : self .verdict ,
258- "promotion_decision" : self .promotion_decision ,
280+ "promotion_decision" : promotion_decision_for_report (
281+ self .verdict ,
282+ evidence ,
283+ ),
259284 "reason" : self .reason ,
260- "evidence" : dict ( self . evidence ) if self . evidence is not None else None ,
285+ "evidence" : evidence ,
261286 "integrity" : dict (self .integrity ) if self .integrity is not None else None ,
262287 }
263288
@@ -301,12 +326,16 @@ def evidence(self) -> dict[str, Any]:
301326 }
302327
303328 def to_dict (self ) -> dict [str , Any ]:
329+ evidence = self .evidence
304330 payload : dict [str , Any ] = {
305331 "schema" : self .schema ,
306332 "schema_version" : self .schema_version ,
307333 "verdict" : self .verdict ,
308- "promotion_decision" : self .promotion_decision ,
309- "evidence" : self .evidence ,
334+ "promotion_decision" : promotion_decision_for_report (
335+ self .verdict ,
336+ evidence ,
337+ ),
338+ "evidence" : evidence ,
310339 "summary" : dict (self .summary ),
311340 "bundles" : [entry .to_dict () for entry in self .bundles ],
312341 "missing_bundles" : list (self .missing_bundles ),
@@ -760,27 +789,28 @@ def simulate_bundles(
760789
761790 integrity = history_bundle_verify .verify_bundle_json (payload , signing_key )
762791 verdict = _integrity_status_to_verdict (integrity , strict_warnings )
763- decision = promotion_decision_for (verdict )
792+
793+ evidence = {
794+ "integrity_checked" : True ,
795+ "integrity_status" : integrity .get ("status" ),
796+ "integrity_finding_count" : int (
797+ (integrity .get ("summary" ) or {}).get (
798+ "findings" , len (integrity .get ("findings" ) or [])
799+ )
800+ ),
801+ "replay_checked" : False ,
802+ "replay_status" : None ,
803+ "replay_skipped" : True ,
804+ "strict_warnings" : strict_warnings ,
805+ }
764806
765807 bundles .append (
766808 BundleEntry (
767809 path = str (path ),
768810 verdict = verdict ,
769- promotion_decision = decision ,
811+ promotion_decision = promotion_decision_for_report ( verdict , evidence ) ,
770812 integrity = integrity ,
771- evidence = {
772- "integrity_checked" : True ,
773- "integrity_status" : integrity .get ("status" ),
774- "integrity_finding_count" : int (
775- (integrity .get ("summary" ) or {}).get (
776- "findings" , len (integrity .get ("findings" ) or [])
777- )
778- ),
779- "replay_checked" : False ,
780- "replay_status" : None ,
781- "replay_skipped" : True ,
782- "strict_warnings" : strict_warnings ,
783- },
813+ evidence = evidence ,
784814 )
785815 )
786816 verdicts .append (verdict )
@@ -789,12 +819,14 @@ def simulate_bundles(
789819
790820 overall = aggregate_verdicts (verdicts )
791821
792- return SimulationReport (
822+ report = SimulationReport (
793823 verdict = overall ,
794824 promotion_decision = promotion_decision_for (overall ),
795825 summary = summary ,
796826 bundles = bundles ,
797827 )
828+ report .promotion_decision = promotion_decision_for_report (overall , report .evidence )
829+ return report
798830
799831
800832def _integrity_status_to_verdict (
@@ -974,6 +1006,7 @@ def main(argv: Sequence[str] | None = None) -> int:
9741006 "BundleEntry" ,
9751007 "SimulationReport" ,
9761008 "promotion_decision_for" ,
1009+ "promotion_decision_for_report" ,
9771010 "aggregate_verdicts" ,
9781011 "verify_replay" ,
9791012 "verify_golden_history" ,
0 commit comments