Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions axonflow/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -224,6 +228,7 @@
SimulationDailyUsage,
TimelineEntry,
TokenUsage,
TransferBasis,
UpdateBudgetRequest,
UpdateMediaGovernanceConfigRequest,
UpdatePlanRequest,
Expand Down Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion axonflow/_version.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""Single source of truth for the AxonFlow SDK version."""

__version__ = "8.3.0"
__version__ = "8.4.0"
19 changes: 19 additions & 0 deletions axonflow/decisions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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):
Expand All @@ -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)
Expand All @@ -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):
Expand Down
29 changes: 28 additions & 1 deletion axonflow/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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."
),
)


Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
Expand Down
24 changes: 24 additions & 0 deletions runtime-e2e/decision_context_transfer_basis/README.md
Original file line number Diff line number Diff line change
@@ -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.
131 changes: 131 additions & 0 deletions runtime-e2e/decision_context_transfer_basis/test.py
Original file line number Diff line number Diff line change
@@ -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())
8 changes: 8 additions & 0 deletions tests/fixtures/wire_shape_baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
Loading
Loading