diff --git a/semantica/context/decision_methods.py b/semantica/context/decision_methods.py index 5a339238..b1349cd4 100644 --- a/semantica/context/decision_methods.py +++ b/semantica/context/decision_methods.py @@ -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]]: diff --git a/semantica/context/decision_recorder.py b/semantica/context/decision_recorder.py index 7d340c6c..872005aa 100644 --- a/semantica/context/decision_recorder.py +++ b/semantica/context/decision_recorder.py @@ -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") diff --git a/tests/context/test_decision_methods_trace.py b/tests/context/test_decision_methods_trace.py index cf462c08..2c578a5c 100644 --- a/tests/context/test_decision_methods_trace.py +++ b/tests/context/test_decision_methods_trace.py @@ -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" diff --git a/tests/context/test_decision_recorder.py b/tests/context/test_decision_recorder.py index a4328501..2a32422d 100644 --- a/tests/context/test_decision_recorder.py +++ b/tests/context/test_decision_recorder.py @@ -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."""