durable-workflow 0.4.75__tar.gz → 0.4.76__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. {durable_workflow-0.4.75/src/durable_workflow.egg-info → durable_workflow-0.4.76}/PKG-INFO +4 -2
  2. {durable_workflow-0.4.75 → durable_workflow-0.4.76}/README.md +3 -1
  3. {durable_workflow-0.4.75 → durable_workflow-0.4.76}/pyproject.toml +1 -1
  4. {durable_workflow-0.4.75 → durable_workflow-0.4.76}/src/durable_workflow/python_conformance.py +241 -34
  5. {durable_workflow-0.4.75 → durable_workflow-0.4.76/src/durable_workflow.egg-info}/PKG-INFO +4 -2
  6. {durable_workflow-0.4.75 → durable_workflow-0.4.76}/tests/test_python_conformance.py +86 -0
  7. {durable_workflow-0.4.75 → durable_workflow-0.4.76}/LICENSE +0 -0
  8. {durable_workflow-0.4.75 → durable_workflow-0.4.76}/setup.cfg +0 -0
  9. {durable_workflow-0.4.75 → durable_workflow-0.4.76}/src/durable_workflow/__init__.py +0 -0
  10. {durable_workflow-0.4.75 → durable_workflow-0.4.76}/src/durable_workflow/_avro.py +0 -0
  11. {durable_workflow-0.4.75 → durable_workflow-0.4.76}/src/durable_workflow/activity.py +0 -0
  12. {durable_workflow-0.4.75 → durable_workflow-0.4.76}/src/durable_workflow/auth_composition.py +0 -0
  13. {durable_workflow-0.4.75 → durable_workflow-0.4.76}/src/durable_workflow/client.py +0 -0
  14. {durable_workflow-0.4.75 → durable_workflow-0.4.76}/src/durable_workflow/errors.py +0 -0
  15. {durable_workflow-0.4.75 → durable_workflow-0.4.76}/src/durable_workflow/external_storage.py +0 -0
  16. {durable_workflow-0.4.75 → durable_workflow-0.4.76}/src/durable_workflow/external_task_input.py +0 -0
  17. {durable_workflow-0.4.75 → durable_workflow-0.4.76}/src/durable_workflow/external_task_result.py +0 -0
  18. {durable_workflow-0.4.75 → durable_workflow-0.4.76}/src/durable_workflow/history_bundle_verify.py +0 -0
  19. {durable_workflow-0.4.75 → durable_workflow-0.4.76}/src/durable_workflow/interceptors.py +0 -0
  20. {durable_workflow-0.4.75 → durable_workflow-0.4.76}/src/durable_workflow/invocable.py +0 -0
  21. {durable_workflow-0.4.75 → durable_workflow-0.4.76}/src/durable_workflow/metrics.py +0 -0
  22. {durable_workflow-0.4.75 → durable_workflow-0.4.76}/src/durable_workflow/py.typed +0 -0
  23. {durable_workflow-0.4.75 → durable_workflow-0.4.76}/src/durable_workflow/replay_conformance.py +0 -0
  24. {durable_workflow-0.4.75 → durable_workflow-0.4.76}/src/durable_workflow/replay_verify.py +0 -0
  25. {durable_workflow-0.4.75 → durable_workflow-0.4.76}/src/durable_workflow/retry_policy.py +0 -0
  26. {durable_workflow-0.4.75 → durable_workflow-0.4.76}/src/durable_workflow/serializer.py +0 -0
  27. {durable_workflow-0.4.75 → durable_workflow-0.4.76}/src/durable_workflow/sync.py +0 -0
  28. {durable_workflow-0.4.75 → durable_workflow-0.4.76}/src/durable_workflow/testing.py +0 -0
  29. {durable_workflow-0.4.75 → durable_workflow-0.4.76}/src/durable_workflow/worker.py +0 -0
  30. {durable_workflow-0.4.75 → durable_workflow-0.4.76}/src/durable_workflow/workflow.py +0 -0
  31. {durable_workflow-0.4.75 → durable_workflow-0.4.76}/src/durable_workflow.egg-info/SOURCES.txt +0 -0
  32. {durable_workflow-0.4.75 → durable_workflow-0.4.76}/src/durable_workflow.egg-info/dependency_links.txt +0 -0
  33. {durable_workflow-0.4.75 → durable_workflow-0.4.76}/src/durable_workflow.egg-info/entry_points.txt +0 -0
  34. {durable_workflow-0.4.75 → durable_workflow-0.4.76}/src/durable_workflow.egg-info/requires.txt +0 -0
  35. {durable_workflow-0.4.75 → durable_workflow-0.4.76}/src/durable_workflow.egg-info/top_level.txt +0 -0
  36. {durable_workflow-0.4.75 → durable_workflow-0.4.76}/tests/test_activity_context.py +0 -0
  37. {durable_workflow-0.4.75 → durable_workflow-0.4.76}/tests/test_auth_composition.py +0 -0
  38. {durable_workflow-0.4.75 → durable_workflow-0.4.76}/tests/test_client.py +0 -0
  39. {durable_workflow-0.4.75 → durable_workflow-0.4.76}/tests/test_control_plane_parity_fixtures.py +0 -0
  40. {durable_workflow-0.4.75 → durable_workflow-0.4.76}/tests/test_errors.py +0 -0
  41. {durable_workflow-0.4.75 → durable_workflow-0.4.76}/tests/test_external_storage.py +0 -0
  42. {durable_workflow-0.4.75 → durable_workflow-0.4.76}/tests/test_external_task_input.py +0 -0
  43. {durable_workflow-0.4.75 → durable_workflow-0.4.76}/tests/test_external_task_result.py +0 -0
  44. {durable_workflow-0.4.75 → durable_workflow-0.4.76}/tests/test_golden_history_replay.py +0 -0
  45. {durable_workflow-0.4.75 → durable_workflow-0.4.76}/tests/test_history_bundle_verify.py +0 -0
  46. {durable_workflow-0.4.75 → durable_workflow-0.4.76}/tests/test_history_event_contract.py +0 -0
  47. {durable_workflow-0.4.75 → durable_workflow-0.4.76}/tests/test_invocable.py +0 -0
  48. {durable_workflow-0.4.75 → durable_workflow-0.4.76}/tests/test_metrics.py +0 -0
  49. {durable_workflow-0.4.75 → durable_workflow-0.4.76}/tests/test_order_processing_example.py +0 -0
  50. {durable_workflow-0.4.75 → durable_workflow-0.4.76}/tests/test_public_boundary_scanner.py +0 -0
  51. {durable_workflow-0.4.75 → durable_workflow-0.4.76}/tests/test_queries.py +0 -0
  52. {durable_workflow-0.4.75 → durable_workflow-0.4.76}/tests/test_readme_quickstart.py +0 -0
  53. {durable_workflow-0.4.75 → durable_workflow-0.4.76}/tests/test_replay.py +0 -0
  54. {durable_workflow-0.4.75 → durable_workflow-0.4.76}/tests/test_replay_conformance.py +0 -0
  55. {durable_workflow-0.4.75 → durable_workflow-0.4.76}/tests/test_replay_verify.py +0 -0
  56. {durable_workflow-0.4.75 → durable_workflow-0.4.76}/tests/test_retry_policy.py +0 -0
  57. {durable_workflow-0.4.75 → durable_workflow-0.4.76}/tests/test_schedules.py +0 -0
  58. {durable_workflow-0.4.75 → durable_workflow-0.4.76}/tests/test_serializer.py +0 -0
  59. {durable_workflow-0.4.75 → durable_workflow-0.4.76}/tests/test_signals.py +0 -0
  60. {durable_workflow-0.4.75 → durable_workflow-0.4.76}/tests/test_sleep.py +0 -0
  61. {durable_workflow-0.4.75 → durable_workflow-0.4.76}/tests/test_standalone_activity_client.py +0 -0
  62. {durable_workflow-0.4.75 → durable_workflow-0.4.76}/tests/test_sync.py +0 -0
  63. {durable_workflow-0.4.75 → durable_workflow-0.4.76}/tests/test_testing_harness.py +0 -0
  64. {durable_workflow-0.4.75 → durable_workflow-0.4.76}/tests/test_updates.py +0 -0
  65. {durable_workflow-0.4.75 → durable_workflow-0.4.76}/tests/test_wait_condition.py +0 -0
  66. {durable_workflow-0.4.75 → durable_workflow-0.4.76}/tests/test_worker.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: durable-workflow
3
- Version: 0.4.75
3
+ Version: 0.4.76
4
4
  Summary: Python SDK for the Durable Workflow server (language-neutral HTTP protocol)
5
5
  Author: Durable Workflow Contributors
6
6
  License-Expression: MIT
@@ -316,7 +316,9 @@ artifact versions, protocol traces, a no-PHP-assumption audit, and the complete
316
316
  Python capability table. Host runners can feed their raw published-artifact
317
317
  observations to `--compose`; omitted parity cells become explicit
318
318
  `not_covered` entries so the gate reports the remaining scenario or capability
319
- instead of accepting a smoke-only result.
319
+ instead of accepting a smoke-only result. The composer accepts canonical
320
+ snake_case IDs and runbook-style hyphenated IDs such as `server-up` and
321
+ `result-returned`.
320
322
 
321
323
  ## External payload storage
322
324
 
@@ -280,7 +280,9 @@ artifact versions, protocol traces, a no-PHP-assumption audit, and the complete
280
280
  Python capability table. Host runners can feed their raw published-artifact
281
281
  observations to `--compose`; omitted parity cells become explicit
282
282
  `not_covered` entries so the gate reports the remaining scenario or capability
283
- instead of accepting a smoke-only result.
283
+ instead of accepting a smoke-only result. The composer accepts canonical
284
+ snake_case IDs and runbook-style hyphenated IDs such as `server-up` and
285
+ `result-returned`.
284
286
 
285
287
  ## External payload storage
286
288
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "durable-workflow"
7
- version = "0.4.75"
7
+ version = "0.4.76"
8
8
  description = "Python SDK for the Durable Workflow server (language-neutral HTTP protocol)"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -12,6 +12,7 @@ from __future__ import annotations
12
12
  import argparse
13
13
  import copy
14
14
  import json
15
+ import re
15
16
  import sys
16
17
  from collections.abc import Iterable, Mapping
17
18
  from pathlib import Path
@@ -70,6 +71,17 @@ PLACEHOLDER_VERSION_TOKENS = {
70
71
  "unresolved",
71
72
  }
72
73
 
74
+
75
+ def _normalize_identifier(value: str) -> str:
76
+ split = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", value)
77
+ normalized = re.sub(r"[^0-9A-Za-z]+", "_", split).strip("_").lower()
78
+ return re.sub(r"_+", "_", normalized)
79
+
80
+
81
+ def _kebabize(value: str) -> str:
82
+ return _normalize_identifier(value).replace("_", "-")
83
+
84
+
73
85
  REQUIRED_SCENARIOS = (
74
86
  PUBLISHED_ARTIFACT_INSTALL_ONLY_SCENARIO,
75
87
  "official_cli_install_start_result_path",
@@ -103,6 +115,35 @@ REQUIRED_CAPABILITIES = (
103
115
  "php_assumptions_absent",
104
116
  )
105
117
 
118
+ SCENARIO_ID_ALIASES: Mapping[str, tuple[str, ...]] = {
119
+ scenario: (_kebabize(scenario),)
120
+ for scenario in REQUIRED_SCENARIOS
121
+ }
122
+
123
+ CAPABILITY_ID_ALIASES: Mapping[str, tuple[str, ...]] = {
124
+ capability: (_kebabize(capability),)
125
+ for capability in REQUIRED_CAPABILITIES
126
+ }
127
+ CAPABILITY_ID_ALIASES = {
128
+ **CAPABILITY_ID_ALIASES,
129
+ "official_cli_installed": (
130
+ *CAPABILITY_ID_ALIASES["official_cli_installed"],
131
+ "cli-installed",
132
+ "cli_installed",
133
+ ),
134
+ "cli_reads_workflow_result": (
135
+ *CAPABILITY_ID_ALIASES["cli_reads_workflow_result"],
136
+ "cli-reads-result",
137
+ "cli_result",
138
+ "cli-result",
139
+ ),
140
+ "workflow_result_returned": (
141
+ *CAPABILITY_ID_ALIASES["workflow_result_returned"],
142
+ "result-returned",
143
+ "result_returned",
144
+ ),
145
+ }
146
+
106
147
  SCENARIO_REQUIRED_EVIDENCE: Mapping[str, tuple[str, ...]] = {
107
148
  "published_artifact_install_only": (
108
149
  "install_channels",
@@ -267,6 +308,16 @@ def host_evidence_spec() -> dict[str, Any]:
267
308
  "capability_table": list(CAPABILITY_TABLE_FIELDS),
268
309
  "declared_outcome": list(DECLARED_OUTCOME_FIELDS),
269
310
  },
311
+ "entry_id_aliases": {
312
+ "scenario_ids": {
313
+ scenario: list(SCENARIO_ID_ALIASES.get(scenario, ()))
314
+ for scenario in REQUIRED_SCENARIOS
315
+ },
316
+ "capability_ids": {
317
+ capability: list(CAPABILITY_ID_ALIASES.get(capability, ()))
318
+ for capability in REQUIRED_CAPABILITIES
319
+ },
320
+ },
270
321
  "top_level_evidence_fields": [
271
322
  "install_channels",
272
323
  "source_policy",
@@ -353,7 +404,7 @@ def compose_result(evidence: Mapping[str, Any], contract: Mapping[str, Any] | No
353
404
  "finding_links": _deepcopy_json_like(_first_present(evidence, ("finding_links", "findingLinks"))),
354
405
  }
355
406
 
356
- declared = _string_value(_first_present(evidence, DECLARED_OUTCOME_FIELDS))
407
+ declared = _status_value_or_raw(_first_present(evidence, DECLARED_OUTCOME_FIELDS))
357
408
  result["outcome"] = declared or _composed_outcome(result, contract)
358
409
  return result
359
410
 
@@ -365,8 +416,13 @@ def _compose_scenario_results(
365
416
  source_policy: Mapping[str, Any],
366
417
  ) -> dict[str, dict[str, Any]]:
367
418
  duplicates: dict[str, int] = {}
368
- raw_scenarios = _entries_by_id(evidence, SCENARIO_RESULTS_FIELDS, duplicates)
369
419
  required_scenarios = _string_list(contract.get("required_scenarios", []))
420
+ raw_scenarios = _entries_by_id(
421
+ evidence,
422
+ SCENARIO_RESULTS_FIELDS,
423
+ duplicates,
424
+ canonical_aliases=_entry_aliases(required_scenarios, SCENARIO_ID_ALIASES),
425
+ )
370
426
  required_evidence = _scenario_required_evidence(contract)
371
427
  scenario_results: dict[str, dict[str, Any]] = {}
372
428
 
@@ -374,14 +430,14 @@ def _compose_scenario_results(
374
430
  scenario = _copy_mapping(raw_scenarios.get(scenario_id))
375
431
  scenario["scenario_id"] = scenario_id
376
432
  _apply_top_level_scenario_evidence(scenario_id, scenario, evidence, artifacts, source_policy, contract)
377
- status = _string_value(scenario.get("status"))
433
+ status = _status_value_or_raw(scenario.get("status"), scenario.get("result"), scenario.get("outcome"))
378
434
  if status == "":
379
435
  status = (
380
436
  "pass"
381
437
  if _scenario_has_required_evidence(scenario, required_evidence.get(scenario_id, []))
382
438
  else "not_covered"
383
439
  )
384
- scenario["status"] = status
440
+ scenario["status"] = status
385
441
  if status == "pass" and not _has_observed_outputs(scenario):
386
442
  scenario["observed_outputs"] = {
387
443
  "summary": "required Python SDK conformance evidence recorded",
@@ -495,23 +551,22 @@ def _apply_top_level_scenario_evidence(
495
551
 
496
552
 
497
553
  def _compose_capability_table(evidence: Mapping[str, Any], contract: Mapping[str, Any]) -> list[dict[str, Any]]:
498
- raw_capabilities = _raw_capability_entries(evidence)
499
554
  required_capabilities = _required_capabilities(contract)
555
+ raw_capabilities = _raw_capability_entries(evidence, contract)
500
556
  capability_table: list[dict[str, Any]] = []
501
557
 
502
558
  for capability_id in required_capabilities:
503
559
  capability = _copy_mapping(raw_capabilities.get(capability_id))
504
560
  capability["id"] = capability_id
505
- status = _string_value(
506
- capability.get("status")
507
- or capability.get("result")
508
- or capability.get("outcome")
561
+ status = _status_value_or_raw(
562
+ capability.get("status"),
563
+ capability.get("result"),
564
+ capability.get("outcome"),
565
+ capability.get("verdict"),
509
566
  )
510
567
  if status == "":
511
568
  status = "not_covered"
512
- capability["status"] = status
513
- else:
514
- capability.setdefault("status", status)
569
+ capability["status"] = status
515
570
  capability_table.append(capability)
516
571
 
517
572
  for capability_id, capability in raw_capabilities.items():
@@ -524,8 +579,10 @@ def _compose_capability_table(evidence: Mapping[str, Any], contract: Mapping[str
524
579
  return capability_table
525
580
 
526
581
 
527
- def _raw_capability_entries(evidence: Mapping[str, Any]) -> dict[str, dict[str, Any]]:
582
+ def _raw_capability_entries(evidence: Mapping[str, Any], contract: Mapping[str, Any]) -> dict[str, dict[str, Any]]:
528
583
  raw = _first_present(evidence, CAPABILITY_TABLE_FIELDS)
584
+ required_capabilities = _required_capabilities(contract)
585
+ aliases = _entry_aliases(required_capabilities, CAPABILITY_ID_ALIASES)
529
586
  if isinstance(raw, Mapping):
530
587
  entries: dict[str, dict[str, Any]] = {}
531
588
  for key, value in raw.items():
@@ -539,12 +596,18 @@ def _raw_capability_entries(evidence: Mapping[str, Any]) -> dict[str, dict[str,
539
596
  entry = {"status": value}
540
597
  else:
541
598
  continue
542
- entry.setdefault("id", key)
543
- entries[key] = entry
599
+ capability_id = _canonical_entry_id(key, aliases)
600
+ entry.setdefault("id", capability_id)
601
+ entries[capability_id] = entry
544
602
  return entries
545
603
 
546
604
  duplicates: dict[str, int] = {}
547
- return _entries_by_id(evidence, CAPABILITY_TABLE_FIELDS, duplicates)
605
+ return _entries_by_id(
606
+ evidence,
607
+ CAPABILITY_TABLE_FIELDS,
608
+ duplicates,
609
+ canonical_aliases=aliases,
610
+ )
548
611
 
549
612
 
550
613
  def _scenario_has_required_evidence(scenario: Mapping[str, Any], required_fields: Iterable[str]) -> bool:
@@ -562,13 +625,18 @@ def _attach_scenario_findings(scenario: dict[str, Any], evidence: Mapping[str, A
562
625
  links = _first_present(evidence, ("finding_links", "findingLinks", "findings"))
563
626
  linked: Any = None
564
627
  if isinstance(links, Mapping):
565
- linked = links.get(scenario_id)
628
+ linked = _entry_mapping_value(links, scenario_id, SCENARIO_ID_ALIASES)
566
629
  elif isinstance(links, list):
630
+ aliases = _entry_aliases((scenario_id,), SCENARIO_ID_ALIASES)
567
631
  linked = [
568
632
  item
569
633
  for item in links
570
634
  if isinstance(item, Mapping)
571
- and _string_value(item.get("scenario_id") or item.get("scenario") or item.get("id")) == scenario_id
635
+ and _canonical_entry_id(
636
+ _string_value(item.get("scenario_id") or item.get("scenario") or item.get("id")),
637
+ aliases,
638
+ )
639
+ == _canonical_entry_id(scenario_id, aliases)
572
640
  ]
573
641
  if linked not in (None, "", [], {}):
574
642
  scenario["linked_findings"] = _deepcopy_json_like(linked)
@@ -581,10 +649,32 @@ def _filtered_traces(traces: Any, plane: str) -> Any:
581
649
  trace
582
650
  for trace in traces
583
651
  if isinstance(trace, Mapping)
584
- and _string_value(trace.get("plane")).lower() == plane
652
+ and _trace_plane_matches(trace.get("plane"), plane)
585
653
  ]
586
654
 
587
655
 
656
+ def _trace_plane_matches(value: Any, plane: str) -> bool:
657
+ normalized = _normalize_identifier(_string_value(value))
658
+ if plane == "control":
659
+ return normalized in {
660
+ "control",
661
+ "control_plane",
662
+ "control_protocol",
663
+ "control_plane_protocol",
664
+ "api",
665
+ "cli",
666
+ "client",
667
+ }
668
+ if plane == "worker":
669
+ return normalized in {
670
+ "worker",
671
+ "worker_plane",
672
+ "worker_protocol",
673
+ "worker_plane_protocol",
674
+ }
675
+ return normalized == _normalize_identifier(plane)
676
+
677
+
588
678
  def _timestamp_value(evidence: Mapping[str, Any], field: str) -> str:
589
679
  return _string_value(_first_present(evidence, (field, _camelize(field))))
590
680
 
@@ -620,7 +710,12 @@ def evaluate_result(result: Mapping[str, Any], contract: Mapping[str, Any] | Non
620
710
  required_capabilities = _required_capabilities(contract)
621
711
 
622
712
  duplicate_scenarios: dict[str, int] = {}
623
- scenario_results = _entries_by_id(result, SCENARIO_RESULTS_FIELDS, duplicate_scenarios)
713
+ scenario_results = _entries_by_id(
714
+ result,
715
+ SCENARIO_RESULTS_FIELDS,
716
+ duplicate_scenarios,
717
+ canonical_aliases=_entry_aliases(required_scenarios, SCENARIO_ID_ALIASES),
718
+ )
624
719
  scenario_statuses: dict[str, str] = {}
625
720
  missing_scenarios: list[str] = []
626
721
  non_pass_scenarios: list[str] = []
@@ -636,7 +731,12 @@ def evaluate_result(result: Mapping[str, Any], contract: Mapping[str, Any] | Non
636
731
  failures.append({"code": "missing_required_scenario", "scenario_id": scenario_id})
637
732
  continue
638
733
 
639
- status = _string_value(scenario_result.get("status"))
734
+ status = _status_value_or_raw(
735
+ scenario_result.get("status"),
736
+ scenario_result.get("result"),
737
+ scenario_result.get("outcome"),
738
+ scenario_result.get("verdict"),
739
+ )
640
740
  scenario_statuses[scenario_id] = status
641
741
  if status not in allowed_statuses:
642
742
  failures.append(
@@ -665,7 +765,12 @@ def evaluate_result(result: Mapping[str, Any], contract: Mapping[str, Any] | Non
665
765
  non_pass_scenarios.append(scenario_id)
666
766
 
667
767
  for scenario_id, scenario_result in scenario_results.items():
668
- status = _string_value(scenario_result.get("status"))
768
+ status = _status_value_or_raw(
769
+ scenario_result.get("status"),
770
+ scenario_result.get("result"),
771
+ scenario_result.get("outcome"),
772
+ scenario_result.get("verdict"),
773
+ )
669
774
  if status in NON_PASS_SCENARIO_STATUSES and not _has_linked_findings(scenario_result, result):
670
775
  failures.append(
671
776
  {
@@ -689,6 +794,7 @@ def evaluate_result(result: Mapping[str, Any], contract: Mapping[str, Any] | Non
689
794
  result,
690
795
  CAPABILITY_TABLE_FIELDS,
691
796
  duplicate_capabilities,
797
+ canonical_aliases=_entry_aliases(required_capabilities, CAPABILITY_ID_ALIASES),
692
798
  )
693
799
  capability_statuses: dict[str, str] = {}
694
800
  missing_capabilities: list[str] = []
@@ -704,7 +810,12 @@ def evaluate_result(result: Mapping[str, Any], contract: Mapping[str, Any] | Non
704
810
  failures.append({"code": "missing_required_capability", "capability_id": capability_id})
705
811
  continue
706
812
 
707
- status = _string_value(capability.get("status") or capability.get("result") or capability.get("outcome"))
813
+ status = _status_value_or_raw(
814
+ capability.get("status"),
815
+ capability.get("result"),
816
+ capability.get("outcome"),
817
+ capability.get("verdict"),
818
+ )
708
819
  capability_statuses[capability_id] = status
709
820
  if status != "pass":
710
821
  non_pass_capabilities.append(capability_id)
@@ -793,9 +904,12 @@ def _entries_by_id(
793
904
  result: Mapping[str, Any],
794
905
  field_names: Iterable[str],
795
906
  duplicates: dict[str, int],
907
+ *,
908
+ canonical_aliases: Mapping[str, str] | None = None,
796
909
  ) -> dict[str, dict[str, Any]]:
797
910
  entries: dict[str, dict[str, Any]] = {}
798
911
  seen: set[str] = set()
912
+ aliases = canonical_aliases or {}
799
913
  raw = _first_present(result, field_names)
800
914
  if isinstance(raw, Mapping):
801
915
  iterable: Iterable[tuple[Any, Any]] = raw.items()
@@ -813,7 +927,8 @@ def _entries_by_id(
813
927
  entry = {"status": value}
814
928
  else:
815
929
  continue
816
- entry_id = key if isinstance(key, str) else _entry_id(entry)
930
+ raw_entry_id = key if isinstance(key, str) else _entry_id(entry)
931
+ entry_id = _canonical_entry_id(raw_entry_id, aliases) if isinstance(raw_entry_id, str) else ""
817
932
  if not isinstance(entry_id, str) or entry_id == "":
818
933
  continue
819
934
  if entry_id in seen:
@@ -828,12 +943,52 @@ def _entry_id(entry: Mapping[str, Any]) -> str:
828
943
  return _string_value(
829
944
  entry.get("scenario_id")
830
945
  or entry.get("scenarioId")
946
+ or entry.get("scenario")
947
+ or entry.get("scenario_name")
948
+ or entry.get("scenarioName")
949
+ or entry.get("case_id")
950
+ or entry.get("caseId")
831
951
  or entry.get("capability_id")
832
952
  or entry.get("capabilityId")
953
+ or entry.get("capability")
954
+ or entry.get("capability_name")
955
+ or entry.get("capabilityName")
833
956
  or entry.get("id")
957
+ or entry.get("name")
958
+ or entry.get("key")
834
959
  )
835
960
 
836
961
 
962
+ def _entry_aliases(
963
+ canonical_ids: Iterable[str],
964
+ explicit_aliases: Mapping[str, Iterable[str]],
965
+ ) -> dict[str, str]:
966
+ aliases: dict[str, str] = {}
967
+ for canonical_id in canonical_ids:
968
+ aliases[_normalize_identifier(canonical_id)] = canonical_id
969
+ for alias in explicit_aliases.get(canonical_id, ()):
970
+ aliases[_normalize_identifier(alias)] = canonical_id
971
+ return aliases
972
+
973
+
974
+ def _canonical_entry_id(entry_id: str, aliases: Mapping[str, str]) -> str:
975
+ normalized = _normalize_identifier(entry_id)
976
+ return aliases.get(normalized, normalized)
977
+
978
+
979
+ def _entry_mapping_value(
980
+ values: Mapping[Any, Any],
981
+ canonical_id: str,
982
+ explicit_aliases: Mapping[str, Iterable[str]],
983
+ ) -> Any:
984
+ aliases = _entry_aliases((canonical_id,), explicit_aliases)
985
+ target = _canonical_entry_id(canonical_id, aliases)
986
+ for key, value in values.items():
987
+ if isinstance(key, str) and _canonical_entry_id(key, aliases) == target:
988
+ return value
989
+ return None
990
+
991
+
837
992
  def _run_record_failures(result: Mapping[str, Any], contract: Mapping[str, Any]) -> list[dict[str, Any]]:
838
993
  required = _string_list(
839
994
  _mapping_value(contract.get("artifact_policy")).get("required_run_record_fields", [])
@@ -917,7 +1072,8 @@ def _source_policy_failures(
917
1072
  scenario_id=scenario_id,
918
1073
  require_published_artifact_policy=(
919
1074
  scenario_id == PUBLISHED_ARTIFACT_INSTALL_ONLY_SCENARIO
920
- and _string_value(scenario.get("status")) == "pass"
1075
+ and _status_value_or_raw(scenario.get("status"), scenario.get("result"), scenario.get("outcome"))
1076
+ == "pass"
921
1077
  ),
922
1078
  )
923
1079
  )
@@ -1088,7 +1244,7 @@ def _php_assumption_audit_failures(
1088
1244
  if not audit:
1089
1245
  return [{"code": "missing_php_assumption_audit"}]
1090
1246
 
1091
- status = _string_value(audit.get("status") or audit.get("outcome") or audit.get("verdict"))
1247
+ status = _status_value_or_raw(audit.get("status"), audit.get("outcome"), audit.get("verdict"))
1092
1248
  failures: list[dict[str, Any]] = []
1093
1249
  if status != "pass":
1094
1250
  failures.append({"code": "non_pass_php_assumption_audit", "status": status})
@@ -1101,7 +1257,7 @@ def _php_assumption_audit_failures(
1101
1257
  "no_php_only_error_shapes",
1102
1258
  )
1103
1259
  for check in required_checks:
1104
- if checks.get(check) is not True:
1260
+ if not _pass_bool_value(_first_present(checks, (check, _camelize(check), _kebabize(check)))):
1105
1261
  failures.append({"code": "missing_php_assumption_check", "check": check})
1106
1262
  return failures
1107
1263
 
@@ -1236,9 +1392,7 @@ def _sdk_runtime_audit_fields() -> tuple[str, ...]:
1236
1392
 
1237
1393
 
1238
1394
  def _declared_outcome_failures(result: Mapping[str, Any], evaluated_status: str) -> list[dict[str, Any]]:
1239
- declared = _string_value(
1240
- _first_present(result, DECLARED_OUTCOME_FIELDS)
1241
- )
1395
+ declared = _status_value_or_raw(_first_present(result, DECLARED_OUTCOME_FIELDS))
1242
1396
  if declared == "":
1243
1397
  return [{"code": "missing_declared_outcome"}]
1244
1398
  if _normal_outcome(declared) != evaluated_status:
@@ -1253,7 +1407,7 @@ def _declared_outcome_failures(result: Mapping[str, Any], evaluated_status: str)
1253
1407
 
1254
1408
 
1255
1409
  def _normal_outcome(value: str) -> str:
1256
- return "pass" if value == "pass" else "non_passing"
1410
+ return "pass" if _status_value(value) == "pass" else "non_passing"
1257
1411
 
1258
1412
 
1259
1413
  def _has_observed_outputs(entry: Mapping[str, Any]) -> bool:
@@ -1284,7 +1438,7 @@ def _has_linked_findings(scenario_result: Mapping[str, Any], result: Mapping[str
1284
1438
  for field in ("finding_links", "findingLinks", "findings"):
1285
1439
  links = _first_present(result, (field,))
1286
1440
  if isinstance(links, Mapping):
1287
- value = links.get(scenario_id)
1441
+ value = _entry_mapping_value(links, scenario_id, SCENARIO_ID_ALIASES)
1288
1442
  if value not in (None, "", [], {}):
1289
1443
  return True
1290
1444
  elif isinstance(links, list):
@@ -1292,7 +1446,8 @@ def _has_linked_findings(scenario_result: Mapping[str, Any], result: Mapping[str
1292
1446
  if not isinstance(item, Mapping):
1293
1447
  continue
1294
1448
  linked = _string_value(item.get("scenario_id") or item.get("scenario") or item.get("id"))
1295
- if linked == scenario_id:
1449
+ aliases = _entry_aliases((scenario_id,), SCENARIO_ID_ALIASES)
1450
+ if _canonical_entry_id(linked, aliases) == _canonical_entry_id(scenario_id, aliases):
1296
1451
  return True
1297
1452
  return False
1298
1453
 
@@ -1337,6 +1492,58 @@ def _string_list(value: Any) -> list[str]:
1337
1492
  return [item for item in value if isinstance(item, str)]
1338
1493
 
1339
1494
 
1495
+ def _status_value(*values: Any) -> str:
1496
+ for value in values:
1497
+ normalized = _normal_status_value(value)
1498
+ if normalized:
1499
+ return normalized
1500
+ return ""
1501
+
1502
+
1503
+ def _status_value_or_raw(*values: Any) -> str:
1504
+ normalized = _status_value(*values)
1505
+ if normalized:
1506
+ return normalized
1507
+ for value in values:
1508
+ raw = _string_value(value)
1509
+ if raw:
1510
+ return raw
1511
+ return ""
1512
+
1513
+
1514
+ def _normal_status_value(value: Any) -> str:
1515
+ if isinstance(value, bool):
1516
+ return "pass" if value else "fail"
1517
+ normalized = _normalize_identifier(_string_value(value))
1518
+ return {
1519
+ "ok": "pass",
1520
+ "true": "pass",
1521
+ "yes": "pass",
1522
+ "passed": "pass",
1523
+ "pass": "pass",
1524
+ "success": "pass",
1525
+ "successful": "pass",
1526
+ "succeeded": "pass",
1527
+ "failed": "fail",
1528
+ "false": "fail",
1529
+ "no": "fail",
1530
+ "failure": "fail",
1531
+ "fail": "fail",
1532
+ "error": "fail",
1533
+ "non_pass": "fail",
1534
+ "non_passing": "fail",
1535
+ "unsupported": "unsupported",
1536
+ "not_covered": "not_covered",
1537
+ "uncovered": "not_covered",
1538
+ "runner_blocked": "runner_blocked",
1539
+ "blocked": "runner_blocked",
1540
+ }.get(normalized, "")
1541
+
1542
+
1543
+ def _pass_bool_value(value: Any) -> bool:
1544
+ return value is True or _status_value(value) == "pass"
1545
+
1546
+
1340
1547
  def _string_value(value: Any) -> str:
1341
1548
  if isinstance(value, str):
1342
1549
  return value
@@ -1346,7 +1553,7 @@ def _string_value(value: Any) -> str:
1346
1553
 
1347
1554
 
1348
1555
  def _has_non_empty_field(mapping: Mapping[str, Any], field: str) -> bool:
1349
- value = _first_present(mapping, (field, _camelize(field)))
1556
+ value = _first_present(mapping, (field, _camelize(field), _kebabize(field)))
1350
1557
  return value not in (None, "", [], {})
1351
1558
 
1352
1559
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: durable-workflow
3
- Version: 0.4.75
3
+ Version: 0.4.76
4
4
  Summary: Python SDK for the Durable Workflow server (language-neutral HTTP protocol)
5
5
  Author: Durable Workflow Contributors
6
6
  License-Expression: MIT
@@ -316,7 +316,9 @@ artifact versions, protocol traces, a no-PHP-assumption audit, and the complete
316
316
  Python capability table. Host runners can feed their raw published-artifact
317
317
  observations to `--compose`; omitted parity cells become explicit
318
318
  `not_covered` entries so the gate reports the remaining scenario or capability
319
- instead of accepting a smoke-only result.
319
+ instead of accepting a smoke-only result. The composer accepts canonical
320
+ snake_case IDs and runbook-style hyphenated IDs such as `server-up` and
321
+ `result-returned`.
320
322
 
321
323
  ## External payload storage
322
324
 
@@ -211,6 +211,7 @@ def test_manifest_names_full_python_parity_surface() -> None:
211
211
  assert "capability_table_complete" in payload["required_scenarios"]
212
212
  assert payload["host_evidence"]["schema"] == "durable-workflow.v2.python-sdk-parity.host-evidence"
213
213
  assert payload["host_evidence"]["missing_scenario_status"] == "not_covered"
214
+ assert "server-up" in payload["host_evidence"]["entry_id_aliases"]["capability_ids"]["server_up"]
214
215
 
215
216
 
216
217
  def test_evaluate_result_accepts_complete_published_artifact_evidence() -> None:
@@ -307,6 +308,91 @@ def test_compose_result_accepts_runner_native_host_evidence_aliases() -> None:
307
308
  assert evaluation["gate_failures"] == []
308
309
 
309
310
 
311
+ def test_compose_result_accepts_runbook_style_ids_and_statuses() -> None:
312
+ result = complete_result()
313
+ derived_scenarios = {
314
+ "published_artifact_install_only",
315
+ "official_cli_install_start_result_path",
316
+ "cold_first_user_setup",
317
+ "protocol_trace_capture",
318
+ "php_assumption_audit",
319
+ "capability_table_complete",
320
+ }
321
+ scenario_evidence = []
322
+ for scenario in REQUIRED_SCENARIOS:
323
+ if scenario in derived_scenarios:
324
+ continue
325
+ entry = dict(result["scenario_results"][scenario])
326
+ entry.pop("scenario_id", None)
327
+ entry["scenario"] = scenario.replace("_", "-")
328
+ entry["status"] = "succeeded"
329
+ scenario_evidence.append(entry)
330
+
331
+ def capability_alias(capability: str) -> str:
332
+ if capability == "official_cli_installed":
333
+ return "cli-installed"
334
+ if capability == "workflow_result_returned":
335
+ return "result-returned"
336
+ return capability.replace("_", "-")
337
+
338
+ evidence = {
339
+ "startedAt": result["started_at"],
340
+ "finishedAt": result["finished_at"],
341
+ "generatedAt": result["generated_at"],
342
+ "artifactVersions": result["artifact_versions"],
343
+ "sourcePolicy": result["source_policy"],
344
+ "installChannels": result["scenario_results"]["published_artifact_install_only"]["install_channels"],
345
+ "officialCli": {
346
+ "installCommand": "curl -fsSL https://durable-workflow.com/install.sh | sh",
347
+ "startCommand": "dw workflow:start --json",
348
+ "resultCommand": "dw workflow:result --json",
349
+ "jsonOutputs": [{"workflow_id": "py-parity", "status": "completed"}],
350
+ },
351
+ "firstUserFlow": {
352
+ "freshState": True,
353
+ "namespaceCreated": "default",
354
+ "firstWorkflowStarted": "py-parity",
355
+ "resultObserved": {"status": "completed"},
356
+ },
357
+ "traces": [
358
+ {"plane": "control-plane", "request": "POST /workflows", "response": 201},
359
+ {"plane": "worker-protocol", "request": "POST /worker/workflow-tasks/poll", "response": 200},
360
+ ],
361
+ "languageNeutralityAudit": {
362
+ "status": "succeeded",
363
+ "serverAudit": {"status": "ok"},
364
+ "sdkAudit": {"status": "ok"},
365
+ "checks": {
366
+ "noPhpRuntimeRequired": True,
367
+ "noPhpPathsRequired": True,
368
+ "noPhpSerializerRequired": True,
369
+ "noPhpOnlyErrorShapes": True,
370
+ },
371
+ },
372
+ "scenarioEvidence": scenario_evidence,
373
+ "capabilityResults": [
374
+ {
375
+ "capability": capability_alias(capability),
376
+ "status": "passed",
377
+ "evidence": {"observed": True},
378
+ }
379
+ for capability in REQUIRED_CAPABILITIES
380
+ ],
381
+ "findings": [],
382
+ "findingLinks": [],
383
+ }
384
+
385
+ composed = compose_result(evidence)
386
+ evaluation = evaluate_result(composed)
387
+
388
+ assert composed["outcome"] == "pass"
389
+ assert composed["scenario_results"]["worker_restart_activity_and_signal_state"]["status"] == "pass"
390
+ assert composed["scenario_results"]["protocol_trace_capture"]["worker_protocol_traces"] == [evidence["traces"][1]]
391
+ assert {entry["id"] for entry in composed["capability_table"]} == set(REQUIRED_CAPABILITIES)
392
+ assert evaluation["status"] == "pass"
393
+ assert evaluation["gate_failures"] == []
394
+
395
+
310
396
  def test_compose_result_rejects_protocol_trace_evidence_without_both_planes() -> None:
311
397
  evidence = complete_host_evidence()
312
398
  evidence["protocol_traces"] = [