Skip to content

Commit 938b97b

Browse files
[cross-repo from workflow#395] server + workflow + sdk-python: make replay verification a first-class platform contract (#24)
1 parent 5ad36bb commit 938b97b

6 files changed

Lines changed: 197 additions & 24 deletions

File tree

README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -223,7 +223,10 @@ durable-workflow-history-bundle-verify exported-history-bundles/run-001.json \
223223
`promotion_decision` vocabulary as the platform replay contract. Golden-history
224224
mode replays cross-runtime fixtures against registered workflow classes;
225225
`--simulate-bundles` integrity-checks every exported history bundle in a
226-
directory and reports missing bundle evidence as a blocking result.
226+
directory and reports missing bundle evidence as a blocking result. Because
227+
bundle simulation does not execute workflow code in Python, a clean
228+
integrity-only simulation recommends `review_before_promote` rather than
229+
`safe_to_promote`.
227230

228231
## External payload storage
229232

src/durable_workflow/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,7 @@
141141
SimulationReport,
142142
aggregate_verdicts,
143143
promotion_decision_for,
144+
promotion_decision_for_report,
144145
simulate_bundles,
145146
verify_golden_history,
146147
verify_replay,
@@ -294,6 +295,7 @@
294295
"SimulationReport",
295296
"aggregate_verdicts",
296297
"promotion_decision_for",
298+
"promotion_decision_for_report",
297299
"simulate_bundles",
298300
"verify_golden_history",
299301
"verify_replay",

src/durable_workflow/history_bundle_verify.py

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -350,17 +350,31 @@ def _check_commands(bundle: Mapping[str, Any], findings: list[dict[str, Any]]) -
350350
continue
351351

352352
applied = _string_or_none(command.get("applied_at"))
353+
rejected = _string_or_none(command.get("rejected_at"))
353354
status = _string_or_none(command.get("status"))
354-
if applied is not None and event_command_ids and command_id not in event_command_ids:
355+
outcome = _string_or_none(command.get("outcome"))
356+
settled = (
357+
status in {"applied", "rejected"}
358+
or outcome in {"applied", "rejected"}
359+
or applied is not None
360+
or rejected is not None
361+
)
362+
363+
if settled and command_id not in event_command_ids:
355364
findings.append(
356365
_finding(
357366
"commands.history_event_missing",
358367
SEVERITY_WARNING,
359368
(
360-
f"commands[{index}] (id={command_id}, status={status or 'unknown'}) was applied "
369+
f"commands[{index}] (id={command_id}, status={status or 'unknown'}) was settled "
361370
"but no history event references it."
362371
),
363-
{"index": index, "command_id": command_id, "command_status": status},
372+
{
373+
"index": index,
374+
"command_id": command_id,
375+
"command_status": status,
376+
"command_outcome": outcome,
377+
},
364378
)
365379
)
366380

src/durable_workflow/replay_verify.py

Lines changed: 53 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
106127
def 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

800832
def _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",

tests/test_history_bundle_verify.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,27 @@ def test_required_evidence_sections_are_enforced() -> None:
283283
assert report["status"] == STATUS_FAILED
284284

285285

286+
def test_settled_command_without_history_event_warns_even_when_no_events_reference_commands() -> None:
287+
signing_key = "secret"
288+
bundle = _base_bundle()
289+
bundle["commands"].append(
290+
{
291+
"id": "cmd-orphan",
292+
"sequence": 2,
293+
"type": "workflow.start",
294+
"status": "applied",
295+
"outcome": "applied",
296+
"applied_at": "2026-04-09T12:00:01.000000Z",
297+
}
298+
)
299+
bundle = _sign(bundle, signing_key, "key-1")
300+
301+
report = verify_bundle(bundle, signing_key=signing_key)
302+
303+
assert report["status"] == STATUS_WARNING
304+
assert "commands.history_event_missing" in _rule_names(report)
305+
306+
286307
def test_payload_marked_available_but_missing_is_failed() -> None:
287308
signing_key = "secret"
288309
bundle = _base_bundle()

tests/test_replay_verify.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@
22

33
from __future__ import annotations
44

5+
import hashlib
56
import json
67
from pathlib import Path
8+
from typing import Any
79

810
from durable_workflow import workflow
911
from durable_workflow.replay_verify import (
@@ -28,6 +30,7 @@
2830
aggregate_verdicts,
2931
main as replay_verify_main,
3032
promotion_decision_for,
33+
promotion_decision_for_report,
3134
simulate_bundles,
3235
verify_golden_history,
3336
verify_replay,
@@ -350,6 +353,17 @@ def test_promotion_decision_for_unknown_verdict_blocks() -> None:
350353
assert promotion_decision_for("totally-bogus") == PROMOTION_BLOCK_AND_INVESTIGATE
351354

352355

356+
def test_promotion_decision_for_report_reviews_skipped_replay() -> None:
357+
assert (
358+
promotion_decision_for_report(VERDICT_OK, {"replay_skipped": True})
359+
== PROMOTION_REVIEW_BEFORE_PROMOTE
360+
)
361+
assert (
362+
promotion_decision_for_report(VERDICT_OK, {"replay_skipped": False})
363+
== PROMOTION_SAFE_TO_PROMOTE
364+
)
365+
366+
353367
def test_aggregate_verdicts_picks_strictest() -> None:
354368
assert aggregate_verdicts([VERDICT_OK, VERDICT_OK]) == VERDICT_OK
355369
assert aggregate_verdicts([VERDICT_OK, "warning"]) == "warning"
@@ -478,6 +492,24 @@ def test_simulate_bundles_aggregates_per_bundle_verdicts(tmp_path: Path) -> None
478492
assert entry["evidence"]["integrity_checked"] is True
479493

480494

495+
def test_simulate_bundles_clean_integrity_only_recommends_review(tmp_path: Path) -> None:
496+
bundle_dir = tmp_path / "bundles"
497+
bundle_dir.mkdir()
498+
(bundle_dir / "clean.json").write_text(
499+
json.dumps(_history_export_bundle()),
500+
encoding="utf-8",
501+
)
502+
503+
report = simulate_bundles(bundle_dir)
504+
payload = report.to_dict()
505+
506+
assert report.verdict == VERDICT_OK
507+
assert report.promotion_decision == PROMOTION_REVIEW_BEFORE_PROMOTE
508+
assert payload["promotion_decision"] == PROMOTION_REVIEW_BEFORE_PROMOTE
509+
assert payload["evidence"]["replay_skipped"] is True
510+
assert payload["bundles"][0]["promotion_decision"] == PROMOTION_REVIEW_BEFORE_PROMOTE
511+
512+
481513
def test_simulate_bundles_cli(tmp_path: Path) -> None:
482514
bundle_dir = tmp_path / "bundles"
483515
bundle_dir.mkdir()
@@ -511,3 +543,71 @@ def test_cli_requires_workflows_when_not_simulating(tmp_path: Path) -> None:
511543
replay_verify_main([str(fixture_dir)])
512544

513545
assert excinfo.value.code == 2
546+
547+
548+
def _history_export_bundle() -> dict[str, Any]:
549+
bundle: dict[str, Any] = {
550+
"schema": "durable-workflow.v2.history-export",
551+
"schema_version": 1,
552+
"exported_at": "2026-04-09T12:00:00.000000Z",
553+
"dedupe_key": "run-1:1:2026-04-09T12:00:00.000000Z",
554+
"history_complete": True,
555+
"workflow": {
556+
"instance_id": "instance-1",
557+
"run_id": "run-1",
558+
"run_number": 1,
559+
"workflow_type": "history.export",
560+
"workflow_class": "tests.Replay",
561+
"last_history_sequence": 1,
562+
},
563+
"payloads": {
564+
"codec": "json",
565+
"arguments": {"available": False, "data": None},
566+
"output": {"available": False, "data": None},
567+
},
568+
"history_events": [
569+
{
570+
"id": "evt-1",
571+
"sequence": 1,
572+
"type": "WorkflowStarted",
573+
"workflow_task_id": None,
574+
"workflow_command_id": None,
575+
"recorded_at": "2026-04-09T12:00:00.000000Z",
576+
"payload": {},
577+
}
578+
],
579+
"commands": [],
580+
"signals": [],
581+
"updates": [],
582+
"tasks": [],
583+
"activities": [],
584+
"timers": [],
585+
"failures": [],
586+
"links": {"projection_source": "rebuilt", "parents": [], "children": []},
587+
"codec_schemas": {},
588+
"payload_manifest": {"version": 1, "entries": []},
589+
"redaction": {"applied": False, "policy": None, "paths": []},
590+
}
591+
cloned = {key: value for key, value in bundle.items() if key != "integrity"}
592+
canonical = json.dumps(
593+
_canonicalize(cloned),
594+
separators=(",", ":"),
595+
ensure_ascii=False,
596+
)
597+
bundle["integrity"] = {
598+
"canonicalization": "json-recursive-ksort-v1",
599+
"checksum_algorithm": "sha256",
600+
"checksum": hashlib.sha256(canonical.encode("utf-8")).hexdigest(),
601+
"signature_algorithm": None,
602+
"signature": None,
603+
"key_id": None,
604+
}
605+
return bundle
606+
607+
608+
def _canonicalize(value: Any) -> Any:
609+
if isinstance(value, dict):
610+
return {key: _canonicalize(value[key]) for key in sorted(value)}
611+
if isinstance(value, list):
612+
return [_canonicalize(item) for item in value]
613+
return value

0 commit comments

Comments
 (0)