fix: harden Flow v2 authoritative cutover semantics

This commit is contained in:
plx
2026-08-16 01:38:39 +00:00
parent ac385342f5
commit 213ba6b41b
7 changed files with 211 additions and 30 deletions

View File

@@ -17,14 +17,14 @@ CLASSIFICATIONS = {
"REPLACED_BY_CORRECT_ACTION", "LEGACY_ONLY", "UNSAFE_FALSE_NEGATIVE",
}
NAMED = {
"Instalbeira": ("5c33db95-fab8-477a-bddd-0b9cc8f91302", "CREATE_PROFORMA", "do_now"),
"Panoramic": ("fd79b9a1-07e6-4f61-95e8-09eab89c155e", "FOLLOW_UP_CUSTOMER_REVIEW", "do_now"),
"ENGEXICON": ("61f1c955-a372-4ea7-b9b0-b8528d74a141", "PREPARE_ORDER", "do_now"),
"CONSTRURECUP": ("e3b23ac5-84db-4763-8a31-a684e873032c", "PREPARE_ORDER", "do_now"),
"X MAT canonical": ("dc89a466-db24-401b-bfe9-d47644b2d0c8", None, "not_current"),
"X MAT duplicate": ("1816a06e-9a69-4a9b-9279-1263156892d3", None, "suppressed"),
"RZSOLAR canonical": ("fd221608-e007-4043-a23d-07e0c119a345", "REVIEW_REQUIRED", "review"),
"RZSOLAR duplicate": ("434124fb-ac19-4d78-909a-55761d7e8daa", None, "suppressed"),
"Instalbeira": ("5c33db95-fab8-477a-bddd-0b9cc8f91302", "CREATE_PROFORMA", "CREATE_PROFORMA", "do_now"),
"Panoramic": ("fd79b9a1-07e6-4f61-95e8-09eab89c155e", None, "FOLLOW_UP_CUSTOMER_REVIEW", "do_now"),
"ENGEXICON": ("61f1c955-a372-4ea7-b9b0-b8528d74a141", "PREPARE_ORDER", "PREPARE_ORDER", "do_now"),
"CONSTRURECUP": ("e3b23ac5-84db-4763-8a31-a684e873032c", "PREPARE_ORDER", "PREPARE_ORDER", "do_now"),
"X MAT canonical": ("dc89a466-db24-401b-bfe9-d47644b2d0c8", None, None, "not_current"),
"X MAT duplicate": ("1816a06e-9a69-4a9b-9279-1263156892d3", None, None, "suppressed"),
"RZSOLAR canonical": ("fd221608-e007-4043-a23d-07e0c119a345", "REVIEW_REQUIRED", "REVIEW_RECONSTRUCTED_PROCESS", "review"),
"RZSOLAR duplicate": ("434124fb-ac19-4d78-909a-55761d7e8daa", "REVIEW_REQUIRED", None, "suppressed"),
}
@@ -69,6 +69,11 @@ def _key(item: dict[str, Any]) -> str:
return str(item.get("work_item_key") or item.get("process_key") or item.get("opportunity_id") or item.get("id"))
def _material_identity(item: dict[str, Any]) -> str:
return str(item.get("material_process_key") or item.get("canonical_opportunity_id")
or item.get("opportunity_id") or item.get("process_key") or _key(item))
def _classification(old: dict[str, Any], new: dict[str, Any] | None) -> tuple[str, str]:
if new is not None:
return "REPLACED_BY_CORRECT_ACTION", "Flow v2 selected a different current action for the same work item."
@@ -90,23 +95,48 @@ def _classification(old: dict[str, Any], new: dict[str, Any] | None) -> tuple[st
def build_report(
*, identity: dict[str, Any], configured_mode: str, v1: dict[str, Any], v2: dict[str, Any],
named_decisions: dict[str, dict[str, Any]], missing_projections: int,
projection_state_counts: dict[str, int] | None = None,
) -> dict[str, Any]:
v1_current = {_key(item): item for item in _all(v1) if item.get("operational_queue") in CURRENT}
v2_current = {_key(item): item for item in _all(v2) if item.get("operational_queue") in CURRENT}
v1_current = {_material_identity(item): item for item in _all(v1) if item.get("operational_queue") in CURRENT}
v2_current = {_material_identity(item): item for item in _all(v2) if item.get("operational_queue") in CURRENT}
removed, changed = [], []
for key, old in v1_current.items():
new = v2_current.get(key)
if new is not None and new.get("action_code") == old.get("action_code"):
continue
classification, reason = _classification(old, new)
row = {"work_item_key": key, "opportunity_id": old.get("opportunity_id"),
row = {"material_process_identity": key, "work_item_key": _key(old), "opportunity_id": old.get("opportunity_id"),
"v1_action": old.get("action_code"), "simulated_v2_action": (new or {}).get("action_code"),
"classification": classification, "reason": reason,
"source_refs": old.get("source_refs") or []}
(changed if new is not None else removed).append(row)
added = [{"work_item_key": key, "opportunity_id": item.get("opportunity_id"),
"action": item.get("action_code"), "source_refs": item.get("source_refs") or []}
for key, item in v2_current.items() if key not in v1_current]
added = []
for key, item in v2_current.items():
if key in v1_current:
continue
decision = item.get("decision") if isinstance(item.get("decision"), dict) else {}
winning = list(decision.get("obligation_source_refs") or [])
reason_code = decision.get("reason_code")
obligation_kind = ("VALID_ACTIVE_OBLIGATION" if str(reason_code).startswith(("ACTIVE_", "DUE_", "EXPLICIT_"))
else "FACTUAL_V2_BUSINESS_ACTION")
added.append({
"material_process_identity": key, "work_item_key": _key(item),
"opportunity_id": item.get("opportunity_id"),
"canonical_opportunity_id": item.get("canonical_opportunity_id"),
"material_process_key": item.get("material_process_key"),
"business_state": decision.get("business_state"),
"business_next_action": decision.get("business_next_action"),
"effective_action": decision.get("effective_action") or item.get("action_code"),
"queue": item.get("operational_queue"), "reason_code": reason_code,
"winning_obligation_task_id": (winning[0].get("id") if winning else None),
"task_status": (winning[0].get("status") if winning else None),
"source_system": (winning[0].get("source_system") if winning else None),
"authoritative_basis": obligation_kind,
"why_absent_from_v1": "NO_V1_CURRENT_CARD_FOR_MATERIAL_PROCESS",
"source_refs": item.get("source_refs") or [],
"non_winning_obligations": [ref for ref in decision.get("obligation_audit_refs") or []
if ref.get("classification") != "VALID_ACTIVE_OBLIGATION"],
})
material_counts: dict[str, int] = {}
for item in v2_current.values():
@@ -115,13 +145,15 @@ def build_report(
duplicates = [{"material_process": key, "count": count} for key, count in material_counts.items() if count > 1]
named_cases = {}
for name, (oid, expected_action, expected_queue) in NAMED.items():
for name, (oid, expected_business, expected_action, expected_queue) in NAMED.items():
decision = named_decisions.get(oid) or {}
suppressed = decision.get("suppress_current_card") is True
actual_queue = "suppressed" if suppressed else decision.get("operational_queue")
actual_action = decision.get("effective_action")
passed = actual_action == expected_action and actual_queue == expected_queue
named_cases[name] = {"opportunity_id": oid, "expected_action": expected_action,
actual_business = decision.get("business_next_action")
passed = actual_business == expected_business and actual_action == expected_action and actual_queue == expected_queue
named_cases[name] = {"opportunity_id": oid, "expected_business_next_action": expected_business,
"actual_business_next_action": actual_business, "expected_action": expected_action,
"expected_queue": expected_queue, "actual_action": actual_action,
"actual_queue": actual_queue, "passed": passed}
@@ -134,6 +166,22 @@ def build_report(
"reason_code": decision.get("reason_code"),
"reason": decision.get("description")})
unsafe = sum(row["classification"] == "UNSAFE_FALSE_NEGATIVE" for row in removed)
invalid_obligation_cards = []
for item in v2_current.values():
decision = item.get("decision") if isinstance(item.get("decision"), dict) else {}
refs = decision.get("obligation_source_refs") or []
if refs and any(str(ref.get("status") or "").lower() != "pending" for ref in refs):
invalid_obligation_cards.append({"opportunity_id": item.get("opportunity_id"),
"effective_action": item.get("action_code"), "refs": refs})
grouped_added = {code: [row for row in added if row["effective_action"] == code]
for code in ("SEND_INFO", "SEND_QUOTE", "SEND_PROFORMA", "REVIEW_REQUIRED")}
state_counts = projection_state_counts or {}
waiting_projection_count = sum(state_counts.get(state, 0) for state in ("AWAITING_CUSTOMER", "AWAITING_PAYMENT"))
rendered_waiting = _metrics(v2)["waiting"]
represented_waiting_states = sum(
1 for item in _all(v2)
if (item.get("decision") or {}).get("business_state") in {"AWAITING_CUSTOMER", "AWAITING_PAYMENT"}
)
return {
"identity": identity, "configured_mode": configured_mode,
"v1_metrics": _metrics(v1), "authoritative_metrics": _metrics(v2),
@@ -142,6 +190,15 @@ def build_report(
"duplicate_cards": duplicates, "duplicate_current_cards": len(duplicates),
"missing_projections": missing_projections, "unsafe_false_negatives": unsafe,
"named_cases": named_cases, "fiscal_validation_cases": fiscal,
"added_card_diagnostics": added, "grouped_added_diagnostics": grouped_added,
"waiting_state_diagnostics": {
"projection_count": waiting_projection_count, "represented_processes": represented_waiting_states,
"rendered_waiting_cards": rendered_waiting,
"promoted_by_due_obligation": max(0, represented_waiting_states - rendered_waiting),
"rendering_policy": "Waiting states carry no invented internal action; they render in waiting only when selected as Operations processes.",
"lost": max(0, waiting_projection_count - represented_waiting_states),
},
"invalid_obligation_current_cards": invalid_obligation_cards,
}
@@ -165,7 +222,8 @@ def exit_code(report: dict[str, Any]) -> int:
unsafe = int(report.get("unsafe_false_negatives") or 0)
missing = int(report.get("missing_projections") or 0)
duplicates = int(report.get("duplicate_current_cards") or 0)
return 2 if unsafe or missing or duplicates or failed_named else 0
invalid_obligations = len(report.get("invalid_obligation_current_cards") or [])
return 2 if unsafe or missing or duplicates or failed_named or invalid_obligations else 0
def run(args: argparse.Namespace, *, runtime_loader=_load_runtime) -> dict[str, Any]:
@@ -199,6 +257,10 @@ def run(args: argparse.Namespace, *, runtime_loader=_load_runtime) -> dict[str,
SELECT (SELECT count(*) FROM opportunities) AS opportunities,
(SELECT count(*) FROM opportunity_flow_state_v2) AS projections
""").mappings().one()
state_rows = conn.exec_driver_sql("""
SELECT business_state, count(*) AS count
FROM opportunity_flow_state_v2 GROUP BY business_state
""").mappings().all()
named_ids = [value[0] for value in NAMED.values()]
named_decisions = get_decisions(named_ids, flow_mode="authoritative", connection=conn)
if production and os.environ.get("BLIF_FLOW_V2_MODE") != configured_mode:
@@ -207,6 +269,7 @@ def run(args: argparse.Namespace, *, runtime_loader=_load_runtime) -> dict[str,
identity=identity, configured_mode=configured_mode or "unset", v1=v1, v2=v2,
named_decisions=named_decisions,
missing_projections=max(0, int(projection_counts["opportunities"]) - int(projection_counts["projections"])),
projection_state_counts={str(row["business_state"]): int(row["count"]) for row in state_rows},
)
_write_reports(report)
return report