diff --git a/CHANGELOG.md b/CHANGELOG.md index 2672d0c..8450f42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 and tag v{X.Y.Z}. The release workflow's preflight checks the section header matches the tag. --> +## [8.4.0] - 2026-05-30 — Decision request context + Pasal 56(b) transfer basis + +Targets AxonFlow platform **v8.5.0**. + +### Added + +- **`context` field on `DecisionSummary` and `DecisionExplanation`** — + `dict[str, str] | None`. Surfaces the sanitized request context a PEP attaches + to a Decision Mode call (canonical `lower_snake_case` keys such as `x_ai_agent`, + `x_session_id`, `x_leader_identity`, and `x-bukuwarung-*`), persisted by the + platform at the audit row's `policy_details->'context'`. `list_decisions()` + returns the platform-truncated summary (5 keys); `explain_decision()` returns + the full map. `None` for pre-v8.4.0 audit rows. +- **`context_truncated` field on `DecisionExplanation`** — `bool | None`. True + when the agent dropped surplus context keys at write time. +- **`TransferBasis` Literal alias and `TRANSFER_BASIS_*` constants** + (`TRANSFER_BASIS_ADEQUACY`, `TRANSFER_BASIS_SAFEGUARDS`, + `TRANSFER_BASIS_PASAL_56B_DPA` = `"pasal_56b_dpa"`, `TRANSFER_BASIS_CONSENT`), + exported from the package root. Type-safe access to the Indonesia UU PDP + Pasal 56 legal bases. + +### Changed + +- **`AuditLogEntry.transfer_basis` documentation** now records `pasal_56b_dpa` + (Pasal 56(b) explicit DPA tag) alongside `adequacy`, `safeguards`, and + `consent`. The field stays `str | None` (not a closed `Literal`) so existing + code passing `safeguards` is unaffected and the SDK never rejects a value a + newer platform may add on an audit read. + ## [8.3.0] - 2026-05-27 — Indonesia PII category + cross-border audit fields ### Added diff --git a/axonflow/__init__.py b/axonflow/__init__.py index 5ce1da8..9fe28ea 100644 --- a/axonflow/__init__.py +++ b/axonflow/__init__.py @@ -132,6 +132,10 @@ CATEGORY_MEDIA_DOCUMENT, CATEGORY_MEDIA_PII, CATEGORY_MEDIA_SAFETY, + TRANSFER_BASIS_ADEQUACY, + TRANSFER_BASIS_CONSENT, + TRANSFER_BASIS_PASAL_56B_DPA, + TRANSFER_BASIS_SAFEGUARDS, AuditLogEntry, AuditQueryOptions, AuditResult, @@ -224,6 +228,7 @@ SimulationDailyUsage, TimelineEntry, TokenUsage, + TransferBasis, UpdateBudgetRequest, UpdateMediaGovernanceConfigRequest, UpdatePlanRequest, @@ -335,6 +340,12 @@ "AuditSearchResponse", "AuditLogEntry", "AuditQueryOptions", + # Cross-border transfer basis (UU PDP Pasal 56) + "TransferBasis", + "TRANSFER_BASIS_ADEQUACY", + "TRANSFER_BASIS_SAFEGUARDS", + "TRANSFER_BASIS_PASAL_56B_DPA", + "TRANSFER_BASIS_CONSENT", # Audit Tool Call types (Issue #1260) "AuditToolCallRequest", "AuditToolCallResponse", diff --git a/axonflow/_version.py b/axonflow/_version.py index 27ace3a..e0dc035 100644 --- a/axonflow/_version.py +++ b/axonflow/_version.py @@ -1,3 +1,3 @@ """Single source of truth for the AxonFlow SDK version.""" -__version__ = "8.3.0" +__version__ = "8.4.0" diff --git a/axonflow/decisions.py b/axonflow/decisions.py index c61c3d1..048c6f9 100644 --- a/axonflow/decisions.py +++ b/axonflow/decisions.py @@ -59,6 +59,15 @@ class DecisionExplanation(BaseModel): * ``policy_source_link`` — URL to the policy definition (optional). * ``tool_signature`` — the tool signature the decision was scoped to, if any. + * ``context`` — the FULL sanitized request context the PEP attached to the + decision (canonical ``lower_snake_case`` keys, string values), e.g. + ``x_ai_agent`` / ``x_session_id`` / ``x_leader_identity`` / + ``x-bukuwarung-*``. Unlike :class:`DecisionSummary` (truncated to 5 keys), + explain returns every persisted key up to the platform's 10-key cap. + ``None`` for pre-v8.4.0 audit rows or decisions with no context. + (platform #2509 / epic #2508) + * ``context_truncated`` — True when the agent dropped surplus context keys + at write time; ``None`` when the platform did not report the flag. """ decision_id: str @@ -73,6 +82,8 @@ class DecisionExplanation(BaseModel): historical_hit_count_session: int = 0 policy_source_link: str | None = None tool_signature: str | None = None + context: dict[str, str] | None = None + context_truncated: bool | None = None class DecisionSummary(BaseModel): @@ -83,6 +94,13 @@ class DecisionSummary(BaseModel): are non-breaking per ADR-043 §"Versioning"; arbitrary unknown fields on the wire are accepted via ``extra='ignore'``. + ``context`` (v8.4.0) is the sanitized request context the PEP attached to + the decision (canonical ``lower_snake_case`` keys, string values), + surfaced from the audit row's ``policy_details->'context'``. The list + summary is truncated by the platform to the 5 most-correlated keys; the + full map is available via :meth:`AxonFlow.explain_decision`. ``None`` for + pre-v8.4.0 audit rows or decisions with no context. (platform #2509) + Cross-SDK parity: Go: axonflow-sdk-go/decisions.go (DecisionSummary) @@ -98,6 +116,7 @@ class DecisionSummary(BaseModel): decision: str # allow | deny | require_approval policy_id: str | None = None tool_signature: str | None = None + context: dict[str, str] | None = None class ListDecisionsOptions(BaseModel): diff --git a/axonflow/types.py b/axonflow/types.py index 9846cb3..8ac7465 100644 --- a/axonflow/types.py +++ b/axonflow/types.py @@ -785,6 +785,28 @@ class AuditQueryOptions(BaseModel): offset: int = Field(default=0, ge=0, description="Pagination offset") +# Cross-border transfer-basis values recognized under Indonesia UU PDP Pasal 56. +# These name the legal bases the platform records for ``AuditLogEntry.transfer_basis``: +# +# - "adequacy" → Pasal 56(a): destination with adequate protection +# - "safeguards" → Pasal 56(b): binding legal instrument (generic label) +# - "pasal_56b_dpa" → Pasal 56(b): binding legal instrument, explicit DPA tag +# - "consent" → Pasal 56(c): explicit data-subject consent +# +# "safeguards" and "pasal_56b_dpa" are semantic equivalents; the platform +# surfaces whichever was recorded at decision time, verbatim, never translated. +TRANSFER_BASIS_ADEQUACY = "adequacy" +TRANSFER_BASIS_SAFEGUARDS = "safeguards" +TRANSFER_BASIS_PASAL_56B_DPA = "pasal_56b_dpa" +TRANSFER_BASIS_CONSENT = "consent" + +# Type alias for the recognized transfer-basis set, for callers that want a +# typed hint on their own variables. The ``AuditLogEntry.transfer_basis`` field +# itself stays ``str | None`` (not a closed Literal) so the SDK never rejects an +# audit row carrying a value a newer platform may add. +TransferBasis = Literal["adequacy", "safeguards", "pasal_56b_dpa", "consent"] + + class AuditLogEntry(BaseModel): """A single audit log entry. @@ -831,7 +853,12 @@ class AuditLogEntry(BaseModel): default=None, description="ISO 3166-1 alpha-2 data residency code" ) transfer_basis: str | None = Field( - default=None, description="Cross-border transfer legal basis" + default=None, + description=( + "Cross-border transfer legal basis under Indonesia UU PDP Pasal 56: " + "adequacy, safeguards, pasal_56b_dpa, or consent. Surfaced verbatim. " + "See the TRANSFER_BASIS_* constants / TransferBasis alias." + ), ) diff --git a/pyproject.toml b/pyproject.toml index 4ecc1b7..6e7a7ab 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "axonflow" -version = "8.3.0" +version = "8.4.0" description = "AxonFlow Python SDK - Enterprise AI Governance in 3 Lines of Code" readme = "README.md" license = {text = "MIT"} diff --git a/runtime-e2e/decision_context_transfer_basis/README.md b/runtime-e2e/decision_context_transfer_basis/README.md new file mode 100644 index 0000000..4cfd9a4 --- /dev/null +++ b/runtime-e2e/decision_context_transfer_basis/README.md @@ -0,0 +1,24 @@ +# decision_context_transfer_basis (v8.4.0) + +Real-stack proof for the v8.4.0 SDK surface (platform epic #2508): + +- **`DecisionSummary.context` / `DecisionExplanation.context`** — the sanitized + request context a PEP attaches to a Decision Mode call is surfaced back + through `list_decisions` and `explain_decision`. +- **`AuditLogEntry.transfer_basis = "pasal_56b_dpa"`** — the Pasal 56(b) explicit + DPA tag round-trips verbatim. + +The driver acts as the PEP (raw `POST /api/v1/decide` — that endpoint is not +SDK-wrapped per ADR-056), then reads the decision back through the SDK against a +real running agent and asserts `context` is populated with the forwarded keys. + +## Run + +``` +export AXONFLOW_AGENT_URL=http://localhost:8080 +export AXONFLOW_TENANT_ID=buku-e-py-e2e +export AXONFLOW_TENANT_SECRET=buku-e-secret +python runtime-e2e/decision_context_transfer_basis/test.py +``` + +Exits non-zero if the SDK does not surface the new fields. diff --git a/runtime-e2e/decision_context_transfer_basis/test.py b/runtime-e2e/decision_context_transfer_basis/test.py new file mode 100644 index 0000000..81f89a5 --- /dev/null +++ b/runtime-e2e/decision_context_transfer_basis/test.py @@ -0,0 +1,131 @@ +"""Real-stack assertion for the v8.4.0 SDK surface (platform epic #2508). + +Drives the production code path against a real running AxonFlow agent — no +test doubles — and asserts: + + * ``DecisionSummary.context`` / ``DecisionExplanation.context`` surface the + sanitized request context a PEP attaches to a Decision Mode call. We act as + the PEP via a raw ``POST /api/v1/decide`` (that endpoint is intentionally + not SDK-wrapped per ADR-056), then read the decision back through the SDK's + ``list_decisions`` + ``explain_decision`` and confirm ``context`` is + populated with the forwarded keys. + * ``AuditLogEntry.transfer_basis = "pasal_56b_dpa"`` (Pasal 56(b) explicit DPA + tag) round-trips through serialize → deserialize verbatim. + +Usage:: + + export AXONFLOW_AGENT_URL=http://localhost:8080 + export AXONFLOW_TENANT_ID=buku-e-py-e2e + export AXONFLOW_TENANT_SECRET=buku-e-secret + python runtime-e2e/decision_context_transfer_basis/test.py + +Exits non-zero if the SDK does not surface the new fields. Companion +mock-free unit coverage lives in ``tests/test_decisions.py`` + +``tests/test_indonesia_pii_audit.py``. +""" + +from __future__ import annotations + +import asyncio +import base64 +import json +import os +import sys + +import httpx + +from axonflow import AxonFlow +from axonflow.decisions import ListDecisionsOptions +from axonflow.types import TRANSFER_BASIS_PASAL_56B_DPA, AuditLogEntry + +AGENT_URL = os.environ.get("AXONFLOW_AGENT_URL", "http://localhost:8080") +CLIENT_ID = os.environ.get("AXONFLOW_TENANT_ID", "buku-e-py-e2e") +SECRET = os.environ.get("AXONFLOW_TENANT_SECRET", "buku-e-secret") + +WANT_CONTEXT = { + "x_ai_agent": "refund-bot", + "x_session_id": "sess-buku-42", + "x_leader_identity": "ops-lead", +} + + +def _fail(msg: str) -> None: + print(f"FAIL: {msg}", file=sys.stderr) + sys.exit(1) + + +def create_decision_with_context() -> str: + """Act as the PEP: the request context lives in the body's ``context`` map.""" + auth = base64.b64encode(f"{CLIENT_ID}:{SECRET}".encode()).decode() + body = { + "stage": "llm", + "query": "summarize this support ticket", + "target": {"type": "llm", "model": "gpt-4", "provider": "openai"}, + "context": { + "x-ai-agent": "refund-bot", + "x-session-id": "sess-buku-42", + "x-leader-identity": "ops-lead", + }, + } + resp = httpx.post( + f"{AGENT_URL}/api/v1/decide", + json=body, + headers={"X-Client-ID": CLIENT_ID, "Authorization": f"Basic {auth}"}, + timeout=15.0, + ) + if resp.status_code != 200: + _fail(f"decide HTTP {resp.status_code}: {resp.text}") + print(f"server /decide response: {resp.text}") + decision_id = resp.json().get("decision_id") + if not decision_id: + _fail(f"no decision_id in response: {resp.text}") + return decision_id + + +async def main() -> None: + decision_id = create_decision_with_context() + print(f"PEP decide -> decision_id={decision_id}") + + async with AxonFlow(endpoint=AGENT_URL, client_id=CLIENT_ID, client_secret=SECRET) as client: + rows = await client.list_decisions(ListDecisionsOptions(limit=5)) + found = next((r for r in rows if r.decision_id == decision_id), None) + if found is None: + _fail(f"list_decisions did not return {decision_id} (got {len(rows)} rows)") + print(f"SDK list_decisions -> {json.dumps(found.model_dump(mode='json'))}") + if found.context != WANT_CONTEXT: + _fail(f"list_decisions context = {found.context}, want {WANT_CONTEXT}") + print( + f"PASS: list_decisions DecisionSummary.context populated " + f"with {len(found.context)} PEP-forwarded keys" + ) + + exp = await client.explain_decision(decision_id) + print( + f"SDK explain_decision -> context={json.dumps(exp.context)} " + f"context_truncated={exp.context_truncated}" + ) + if exp.context != WANT_CONTEXT: + _fail(f"explain_decision context = {exp.context}, want {WANT_CONTEXT}") + print( + f"PASS: explain_decision returned full context " + f"(context_truncated={exp.context_truncated})" + ) + + # transfer_basis = pasal_56b_dpa round-trip (Pasal 56(b)). + entry = AuditLogEntry( + id="e2e-audit", + timestamp="2026-05-30T10:00:00Z", + data_residency="ID", + transfer_basis=TRANSFER_BASIS_PASAL_56B_DPA, + ) + restored = AuditLogEntry.model_validate_json(entry.model_dump_json()) + if restored.transfer_basis != "pasal_56b_dpa": + _fail(f"transfer_basis round-trip = {restored.transfer_basis!r}, want pasal_56b_dpa") + print(f"SDK AuditLogEntry round-trip -> {entry.model_dump_json()}") + print(f"PASS: AuditLogEntry.transfer_basis = {restored.transfer_basis!r} round-trips verbatim") + + print("ALL PASS: v8.4.0 context + pasal_56b_dpa verified through SDK runtime") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/tests/fixtures/wire_shape_baseline.json b/tests/fixtures/wire_shape_baseline.json index 3b6683b..04ed62a 100644 --- a/tests/fixtures/wire_shape_baseline.json +++ b/tests/fixtures/wire_shape_baseline.json @@ -244,6 +244,14 @@ ], "spec_only": [] }, + "DecisionExplanation": { + "note": "acknowledged-sdk-superset: context + context_truncated are surfaced by the SDK ahead of the OpenAPI spec (platform #2509 / epic #2508); the spec will declare them in the v8.5.0 sync.", + "sdk_only": [ + "context", + "context_truncated" + ], + "spec_only": [] + }, "DynamicPolicy": { "note": "spec-bug-pending: #1745 \u2014 policy-api.yaml DynamicPolicy omits 7 fields (category, created_at, organization_id, priority, tier, type, updated_at) every policy-CRUD caller needs.", "sdk_only": [ diff --git a/tests/test_decisions.py b/tests/test_decisions.py index 1f97a01..6846120 100644 --- a/tests/test_decisions.py +++ b/tests/test_decisions.py @@ -247,6 +247,60 @@ def test_full_fields_round_trip(self) -> None: assert d2.decision_id == "dec-x" +class TestDecisionContextV85: + """v8.4.0 (platform #2509): request context surfaced on decision reads.""" + + def test_summary_context_absent_is_none(self) -> None: + d = DecisionSummary( + decision_id="dec-noctx", + timestamp=datetime(2026, 5, 30, tzinfo=timezone.utc), + decision="allow", + ) + assert d.context is None + + def test_summary_context_round_trip(self) -> None: + raw = { + "decision_id": "dec-ctx", + "timestamp": "2026-05-30T12:00:00Z", + "decision": "deny", + "context": { + "x_ai_agent": "refund-bot", + "x_session_id": "sess-42", + "x_leader_identity": "ops-lead", + }, + } + d = DecisionSummary.model_validate(raw) + assert d.context == { + "x_ai_agent": "refund-bot", + "x_session_id": "sess-42", + "x_leader_identity": "ops-lead", + } + # re-serialize → re-parse without loss + back = DecisionSummary.model_validate(d.model_dump()) + assert back.context["x_leader_identity"] == "ops-lead" + + def test_explanation_full_context_and_truncated_flag(self) -> None: + raw = { + "decision_id": "dec-x", + "timestamp": "2026-05-30T12:00:00Z", + "decision": "deny", + "context": {"x_ai_agent": "a", "x_session_id": "s"}, + "context_truncated": True, + } + exp = DecisionExplanation.model_validate(raw) + assert exp.context == {"x_ai_agent": "a", "x_session_id": "s"} + assert exp.context_truncated is True + + def test_explanation_context_defaults_none(self) -> None: + exp = DecisionExplanation( + decision_id="dec-1", + timestamp=datetime(2026, 5, 30, tzinfo=timezone.utc), + decision="allow", + ) + assert exp.context is None + assert exp.context_truncated is None + + class TestListDecisions: """Tests for AxonFlowClient.list_decisions.""" diff --git a/tests/test_indonesia_pii_audit.py b/tests/test_indonesia_pii_audit.py index fa40889..7e24fe0 100644 --- a/tests/test_indonesia_pii_audit.py +++ b/tests/test_indonesia_pii_audit.py @@ -5,7 +5,13 @@ from datetime import datetime, timezone from axonflow.policies import PolicyCategory -from axonflow.types import AuditLogEntry +from axonflow.types import ( + TRANSFER_BASIS_ADEQUACY, + TRANSFER_BASIS_CONSENT, + TRANSFER_BASIS_PASAL_56B_DPA, + TRANSFER_BASIS_SAFEGUARDS, + AuditLogEntry, +) class TestPIIIndonesiaCategory: @@ -99,3 +105,45 @@ def test_json_round_trip(self) -> None: restored = AuditLogEntry.model_validate_json(json_str) assert restored.data_residency == "ID" assert restored.transfer_basis == "adequacy" + + +class TestTransferBasisPasal56b: + """v8.4.0 (platform #2513): pasal_56b_dpa accepted, existing values kept.""" + + def test_constants_wire_values(self) -> None: + assert TRANSFER_BASIS_ADEQUACY == "adequacy" + assert TRANSFER_BASIS_SAFEGUARDS == "safeguards" + assert TRANSFER_BASIS_PASAL_56B_DPA == "pasal_56b_dpa" + assert TRANSFER_BASIS_CONSENT == "consent" + + def test_pasal_56b_dpa_round_trips_verbatim(self) -> None: + entry = AuditLogEntry( + id="aud-56b", + timestamp=datetime(2026, 5, 30, 10, 0, 0, tzinfo=timezone.utc), + data_residency="ID", + transfer_basis=TRANSFER_BASIS_PASAL_56B_DPA, + ) + restored = AuditLogEntry.model_validate_json(entry.model_dump_json()) + # never auto-translated to "safeguards" + assert restored.transfer_basis == "pasal_56b_dpa" + + def test_pasal_56b_dpa_from_wire(self) -> None: + entry = AuditLogEntry.model_validate( + { + "id": "aud-56b-wire", + "timestamp": "2026-05-30T10:00:00Z", + "transfer_basis": "pasal_56b_dpa", + } + ) + assert entry.transfer_basis == "pasal_56b_dpa" + + def test_backward_compat_safeguards_still_parses(self) -> None: + # Existing v8.3.0-shaped rows using "safeguards" are unaffected by the widening. + entry = AuditLogEntry.model_validate( + { + "id": "aud-sg", + "timestamp": "2026-05-26T10:00:00Z", + "transfer_basis": "safeguards", + } + ) + assert entry.transfer_basis == TRANSFER_BASIS_SAFEGUARDS