Make policy application version-aware and deterministic

This commit is contained in:
KaifAhmad1
2026-02-18 00:42:05 +05:30
parent 20b5f7c0ab
commit 3872ea75e1
4 changed files with 149 additions and 16 deletions
+44 -6
View File
@@ -221,7 +221,7 @@ def capture_decision_trace(
graph_store: Optional[GraphStore] = None,
entities: Optional[List[str]] = None,
source_documents: Optional[List[str]] = None,
policy_ids: Optional[List[str]] = None,
policy_ids: Optional[Union[str, Dict[str, str], List[Union[str, Dict[str, str]]]]] = None,
exceptions: Optional[List[Dict[str, Any]]] = None,
approvals: Optional[List[Dict[str, Any]]] = None,
precedents: Optional[List[Dict[str, str]]] = None,
@@ -236,7 +236,9 @@ def capture_decision_trace(
graph_store: Optional graph store used to persist full trace
entities: Optional list of linked entities
source_documents: Optional list of source documents
policy_ids: Optional list of policy IDs applied to the decision
policy_ids: Optional list of policy refs. Supports:
- "policy_id"
- {"policy_id": "...", "version": "..."}
exceptions: Optional list of exception records
approvals: Optional list of approval records
precedents: Optional list of precedent links
@@ -259,7 +261,7 @@ def capture_decision_trace(
recorder = DecisionRecorder(graph_store)
entities = _normalize_string_list(entities)
source_documents = _normalize_string_list(source_documents)
policy_ids = _normalize_string_list(policy_ids)
policy_refs = _normalize_policy_refs(policy_ids)
exceptions = _normalize_record_list(exceptions)
approvals = _normalize_record_list(approvals)
precedents = _normalize_precedents(precedents)
@@ -294,10 +296,16 @@ def capture_decision_trace(
}
)
if policy_ids:
recorder.apply_policies(decision_id, policy_ids)
if policy_refs:
applied_policies = recorder.apply_policies(decision_id, policy_refs)
trace_events.append(
{"event_type": "POLICIES_APPLIED", "payload": {"policy_ids": policy_ids}}
{
"event_type": "POLICIES_APPLIED",
"payload": {
"policy_ids": [p.get("policy_id") for p in policy_refs],
"applied_policies": applied_policies,
},
}
)
if exceptions:
@@ -481,6 +489,36 @@ def _normalize_record_list(
return []
def _normalize_policy_refs(
value: Optional[Union[str, Dict[str, str], List[Union[str, Dict[str, str]]]]]
) -> List[Dict[str, str]]:
"""Normalize policy refs to [{policy_id, version?}] for version-safe matching."""
if value is None:
return []
raw_items: List[Union[str, Dict[str, str]]]
if isinstance(value, (str, dict)):
raw_items = [value]
elif isinstance(value, list):
raw_items = value
else:
return []
normalized: List[Dict[str, str]] = []
for item in raw_items:
if isinstance(item, str) and item:
normalized.append({"policy_id": item})
elif isinstance(item, dict):
policy_id = item.get("policy_id")
if not policy_id:
continue
ref: Dict[str, str] = {"policy_id": str(policy_id)}
if item.get("version") is not None and str(item.get("version")):
ref["version"] = str(item.get("version"))
normalized.append(ref)
return normalized
def _normalize_precedents(
value: Optional[Union[str, Dict[str, str], List[Union[str, Dict[str, str]]]]]
) -> List[Dict[str, str]]:
+62 -9
View File
@@ -179,29 +179,82 @@ class DecisionRecorder:
self.logger.exception("Failed to link entities")
raise
def apply_policies(self, decision_id: str, policy_ids: List[str]) -> None:
def apply_policies(
self,
decision_id: str,
policy_ids: List[Union[str, Dict[str, str]]],
) -> List[Dict[str, str]]:
"""
Track policy applications for a decision.
Args:
decision_id: Decision ID
policy_ids: List of policy IDs that were applied
policy_ids: List of policy IDs or policy refs with explicit version
Returns:
Applied policy references with resolved versions
"""
try:
for policy_id in policy_ids:
# Create APPLIED_POLICY relationship
applied: List[Dict[str, str]] = []
for policy_ref in policy_ids:
if isinstance(policy_ref, dict):
policy_id = str(policy_ref.get("policy_id", ""))
policy_version = (
str(policy_ref.get("version"))
if policy_ref.get("version") is not None
else None
)
else:
policy_id = str(policy_ref)
policy_version = None
if not policy_id:
continue
# Resolve exactly one policy node:
# - explicit version when provided
# - latest available version for legacy callers
query = """
MATCH (d:Decision {decision_id: $decision_id})
MATCH (p:Policy {policy_id: $policy_id})
MERGE (d)-[:APPLIED_POLICY]->(p)
SET d.applied_at = timestamp()
WHERE $policy_version IS NULL OR p.version = $policy_version
WITH d, p
ORDER BY p.updated_at DESC, p.version DESC
LIMIT 1
MERGE (d)-[r:APPLIED_POLICY]->(p)
SET r.policy_id = $policy_id,
r.policy_version = p.version,
d.applied_at = timestamp()
RETURN p.policy_id as policy_id, p.version as version
"""
self.graph_store.execute_query(query, {
result = self.graph_store.execute_query(query, {
"decision_id": decision_id,
"policy_id": policy_id
"policy_id": policy_id,
"policy_version": policy_version,
})
records = (
result.get("records", [])
if isinstance(result, dict)
else (result if isinstance(result, list) else [])
)
if records:
record = records[0]
applied.append(
{
"policy_id": str(record.get("policy_id", policy_id)),
"version": str(record.get("version", policy_version or "")),
}
)
else:
self.logger.warning(
f"No policy match found for {policy_id}"
+ (f" version {policy_version}" if policy_version else "")
)
self.logger.info(f"Applied {len(policy_ids)} policies to decision {decision_id}")
self.logger.info(f"Applied {len(applied)} policies to decision {decision_id}")
return applied
except Exception as e:
self.logger.exception("Failed to apply policies")
@@ -78,3 +78,27 @@ def test_capture_decision_trace_accepts_legacy_payload_shapes():
assert decision_id == decision.decision_id
assert graph_store.execute_query.call_count > 0
def test_capture_decision_trace_accepts_versioned_policy_refs():
decision = _sample_decision()
graph_store = Mock()
graph_store.execute_query = Mock(
return_value={"records": [{"policy_id": "renewal_discount_policy", "version": "3.2"}]}
)
decision_id = capture_decision_trace(
decision=decision,
cross_system_context={"crm": {"arr": 120000}},
graph_store=graph_store,
policy_ids=[{"policy_id": "renewal_discount_policy", "version": "3.2"}],
immutable_audit_log=False,
)
assert decision_id == decision.decision_id
policy_calls = [
c for c in graph_store.execute_query.call_args_list
if "policy_version" in c[0][1]
]
assert policy_calls
assert policy_calls[0][0][1]["policy_version"] == "3.2"
+19 -1
View File
@@ -139,11 +139,29 @@ class TestDecisionRecorder:
"""Test applying policies to decision."""
decision_id = "decision_001"
policy_ids = ["policy_001", "policy_002"]
mock_graph_store.execute_query.return_value = {"records": [{"policy_id": "policy_001", "version": "2.0"}]}
decision_recorder.apply_policies(decision_id, policy_ids)
applied = decision_recorder.apply_policies(decision_id, policy_ids)
# Verify graph store was called for each policy
assert mock_graph_store.execute_query.call_count == len(policy_ids)
assert isinstance(applied, list)
def test_apply_policies_with_explicit_version(self, decision_recorder, mock_graph_store):
"""Test applying a specific policy version to avoid ambiguous linking."""
decision_id = "decision_001"
policy_refs = [{"policy_id": "policy_001", "version": "3.2"}]
mock_graph_store.execute_query.return_value = {
"records": [{"policy_id": "policy_001", "version": "3.2"}]
}
applied = decision_recorder.apply_policies(decision_id, policy_refs)
assert len(applied) == 1
assert applied[0]["policy_id"] == "policy_001"
assert applied[0]["version"] == "3.2"
call = mock_graph_store.execute_query.call_args_list[0]
assert call[0][1]["policy_version"] == "3.2"
def test_record_exception(self, decision_recorder, mock_graph_store):
"""Test recording policy exception."""