Skip to content

Commit c45265a

Browse files
feat(release): v8.4.0 — decision request context + pasal_56b_dpa transfer basis (#210)
Targets AxonFlow platform v8.5.0 (epic #2508). - DecisionSummary + DecisionExplanation gain a context: dict[str, str] | None surfacing the sanitized request context a PEP attaches to a Decision Mode call (platform #2509). list_decisions() returns the platform-truncated 5-key summary; explain_decision() returns the full map plus a context_truncated: bool | None flag. - types.py adds a TransferBasis Literal alias + TRANSFER_BASIS_* constants (adequacy, safeguards, pasal_56b_dpa, consent), exported from the package root. The AuditLogEntry.transfer_basis field stays str | None (not a closed Literal) so existing 'safeguards' code is unaffected and the SDK never rejects a value a newer platform may add on an audit read. - version 8.3.0 -> 8.4.0, CHANGELOG entry, unit tests, and a runtime-e2e driver that creates a decision via the PEP path and reads context back through the SDK. Signed-off-by: Saurabh Jain <saurabh.jain@getaxonflow.com>
1 parent 49572fc commit c45265a

11 files changed

Lines changed: 355 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99
and tag v{X.Y.Z}. The release workflow's preflight checks the section
1010
header matches the tag. -->
1111

12+
## [8.4.0] - 2026-05-30 — Decision request context + Pasal 56(b) transfer basis
13+
14+
Targets AxonFlow platform **v8.5.0**.
15+
16+
### Added
17+
18+
- **`context` field on `DecisionSummary` and `DecisionExplanation`**
19+
`dict[str, str] | None`. Surfaces the sanitized request context a PEP attaches
20+
to a Decision Mode call (canonical `lower_snake_case` keys such as `x_ai_agent`,
21+
`x_session_id`, `x_leader_identity`, and `x-bukuwarung-*`), persisted by the
22+
platform at the audit row's `policy_details->'context'`. `list_decisions()`
23+
returns the platform-truncated summary (5 keys); `explain_decision()` returns
24+
the full map. `None` for pre-v8.4.0 audit rows.
25+
- **`context_truncated` field on `DecisionExplanation`**`bool | None`. True
26+
when the agent dropped surplus context keys at write time.
27+
- **`TransferBasis` Literal alias and `TRANSFER_BASIS_*` constants**
28+
(`TRANSFER_BASIS_ADEQUACY`, `TRANSFER_BASIS_SAFEGUARDS`,
29+
`TRANSFER_BASIS_PASAL_56B_DPA` = `"pasal_56b_dpa"`, `TRANSFER_BASIS_CONSENT`),
30+
exported from the package root. Type-safe access to the Indonesia UU PDP
31+
Pasal 56 legal bases.
32+
33+
### Changed
34+
35+
- **`AuditLogEntry.transfer_basis` documentation** now records `pasal_56b_dpa`
36+
(Pasal 56(b) explicit DPA tag) alongside `adequacy`, `safeguards`, and
37+
`consent`. The field stays `str | None` (not a closed `Literal`) so existing
38+
code passing `safeguards` is unaffected and the SDK never rejects a value a
39+
newer platform may add on an audit read.
40+
1241
## [8.3.0] - 2026-05-27 — Indonesia PII category + cross-border audit fields
1342

1443
### Added

axonflow/__init__.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,10 @@
132132
CATEGORY_MEDIA_DOCUMENT,
133133
CATEGORY_MEDIA_PII,
134134
CATEGORY_MEDIA_SAFETY,
135+
TRANSFER_BASIS_ADEQUACY,
136+
TRANSFER_BASIS_CONSENT,
137+
TRANSFER_BASIS_PASAL_56B_DPA,
138+
TRANSFER_BASIS_SAFEGUARDS,
135139
AuditLogEntry,
136140
AuditQueryOptions,
137141
AuditResult,
@@ -224,6 +228,7 @@
224228
SimulationDailyUsage,
225229
TimelineEntry,
226230
TokenUsage,
231+
TransferBasis,
227232
UpdateBudgetRequest,
228233
UpdateMediaGovernanceConfigRequest,
229234
UpdatePlanRequest,
@@ -335,6 +340,12 @@
335340
"AuditSearchResponse",
336341
"AuditLogEntry",
337342
"AuditQueryOptions",
343+
# Cross-border transfer basis (UU PDP Pasal 56)
344+
"TransferBasis",
345+
"TRANSFER_BASIS_ADEQUACY",
346+
"TRANSFER_BASIS_SAFEGUARDS",
347+
"TRANSFER_BASIS_PASAL_56B_DPA",
348+
"TRANSFER_BASIS_CONSENT",
338349
# Audit Tool Call types (Issue #1260)
339350
"AuditToolCallRequest",
340351
"AuditToolCallResponse",

axonflow/_version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
"""Single source of truth for the AxonFlow SDK version."""
22

3-
__version__ = "8.3.0"
3+
__version__ = "8.4.0"

axonflow/decisions.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,15 @@ class DecisionExplanation(BaseModel):
5959
* ``policy_source_link`` — URL to the policy definition (optional).
6060
* ``tool_signature`` — the tool signature the decision was scoped to,
6161
if any.
62+
* ``context`` — the FULL sanitized request context the PEP attached to the
63+
decision (canonical ``lower_snake_case`` keys, string values), e.g.
64+
``x_ai_agent`` / ``x_session_id`` / ``x_leader_identity`` /
65+
``x-bukuwarung-*``. Unlike :class:`DecisionSummary` (truncated to 5 keys),
66+
explain returns every persisted key up to the platform's 10-key cap.
67+
``None`` for pre-v8.4.0 audit rows or decisions with no context.
68+
(platform #2509 / epic #2508)
69+
* ``context_truncated`` — True when the agent dropped surplus context keys
70+
at write time; ``None`` when the platform did not report the flag.
6271
"""
6372

6473
decision_id: str
@@ -73,6 +82,8 @@ class DecisionExplanation(BaseModel):
7382
historical_hit_count_session: int = 0
7483
policy_source_link: str | None = None
7584
tool_signature: str | None = None
85+
context: dict[str, str] | None = None
86+
context_truncated: bool | None = None
7687

7788

7889
class DecisionSummary(BaseModel):
@@ -83,6 +94,13 @@ class DecisionSummary(BaseModel):
8394
are non-breaking per ADR-043 §"Versioning"; arbitrary unknown fields
8495
on the wire are accepted via ``extra='ignore'``.
8596
97+
``context`` (v8.4.0) is the sanitized request context the PEP attached to
98+
the decision (canonical ``lower_snake_case`` keys, string values),
99+
surfaced from the audit row's ``policy_details->'context'``. The list
100+
summary is truncated by the platform to the 5 most-correlated keys; the
101+
full map is available via :meth:`AxonFlow.explain_decision`. ``None`` for
102+
pre-v8.4.0 audit rows or decisions with no context. (platform #2509)
103+
86104
Cross-SDK parity:
87105
88106
Go: axonflow-sdk-go/decisions.go (DecisionSummary)
@@ -98,6 +116,7 @@ class DecisionSummary(BaseModel):
98116
decision: str # allow | deny | require_approval
99117
policy_id: str | None = None
100118
tool_signature: str | None = None
119+
context: dict[str, str] | None = None
101120

102121

103122
class ListDecisionsOptions(BaseModel):

axonflow/types.py

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -785,6 +785,28 @@ class AuditQueryOptions(BaseModel):
785785
offset: int = Field(default=0, ge=0, description="Pagination offset")
786786

787787

788+
# Cross-border transfer-basis values recognized under Indonesia UU PDP Pasal 56.
789+
# These name the legal bases the platform records for ``AuditLogEntry.transfer_basis``:
790+
#
791+
# - "adequacy" → Pasal 56(a): destination with adequate protection
792+
# - "safeguards" → Pasal 56(b): binding legal instrument (generic label)
793+
# - "pasal_56b_dpa" → Pasal 56(b): binding legal instrument, explicit DPA tag
794+
# - "consent" → Pasal 56(c): explicit data-subject consent
795+
#
796+
# "safeguards" and "pasal_56b_dpa" are semantic equivalents; the platform
797+
# surfaces whichever was recorded at decision time, verbatim, never translated.
798+
TRANSFER_BASIS_ADEQUACY = "adequacy"
799+
TRANSFER_BASIS_SAFEGUARDS = "safeguards"
800+
TRANSFER_BASIS_PASAL_56B_DPA = "pasal_56b_dpa"
801+
TRANSFER_BASIS_CONSENT = "consent"
802+
803+
# Type alias for the recognized transfer-basis set, for callers that want a
804+
# typed hint on their own variables. The ``AuditLogEntry.transfer_basis`` field
805+
# itself stays ``str | None`` (not a closed Literal) so the SDK never rejects an
806+
# audit row carrying a value a newer platform may add.
807+
TransferBasis = Literal["adequacy", "safeguards", "pasal_56b_dpa", "consent"]
808+
809+
788810
class AuditLogEntry(BaseModel):
789811
"""A single audit log entry.
790812
@@ -831,7 +853,12 @@ class AuditLogEntry(BaseModel):
831853
default=None, description="ISO 3166-1 alpha-2 data residency code"
832854
)
833855
transfer_basis: str | None = Field(
834-
default=None, description="Cross-border transfer legal basis"
856+
default=None,
857+
description=(
858+
"Cross-border transfer legal basis under Indonesia UU PDP Pasal 56: "
859+
"adequacy, safeguards, pasal_56b_dpa, or consent. Surfaced verbatim. "
860+
"See the TRANSFER_BASIS_* constants / TransferBasis alias."
861+
),
835862
)
836863

837864

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "axonflow"
7-
version = "8.3.0"
7+
version = "8.4.0"
88
description = "AxonFlow Python SDK - Enterprise AI Governance in 3 Lines of Code"
99
readme = "README.md"
1010
license = {text = "MIT"}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# decision_context_transfer_basis (v8.4.0)
2+
3+
Real-stack proof for the v8.4.0 SDK surface (platform epic #2508):
4+
5+
- **`DecisionSummary.context` / `DecisionExplanation.context`** — the sanitized
6+
request context a PEP attaches to a Decision Mode call is surfaced back
7+
through `list_decisions` and `explain_decision`.
8+
- **`AuditLogEntry.transfer_basis = "pasal_56b_dpa"`** — the Pasal 56(b) explicit
9+
DPA tag round-trips verbatim.
10+
11+
The driver acts as the PEP (raw `POST /api/v1/decide` — that endpoint is not
12+
SDK-wrapped per ADR-056), then reads the decision back through the SDK against a
13+
real running agent and asserts `context` is populated with the forwarded keys.
14+
15+
## Run
16+
17+
```
18+
export AXONFLOW_AGENT_URL=http://localhost:8080
19+
export AXONFLOW_TENANT_ID=buku-e-py-e2e
20+
export AXONFLOW_TENANT_SECRET=buku-e-secret
21+
python runtime-e2e/decision_context_transfer_basis/test.py
22+
```
23+
24+
Exits non-zero if the SDK does not surface the new fields.
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
"""Real-stack assertion for the v8.4.0 SDK surface (platform epic #2508).
2+
3+
Drives the production code path against a real running AxonFlow agent — no
4+
test doubles — and asserts:
5+
6+
* ``DecisionSummary.context`` / ``DecisionExplanation.context`` surface the
7+
sanitized request context a PEP attaches to a Decision Mode call. We act as
8+
the PEP via a raw ``POST /api/v1/decide`` (that endpoint is intentionally
9+
not SDK-wrapped per ADR-056), then read the decision back through the SDK's
10+
``list_decisions`` + ``explain_decision`` and confirm ``context`` is
11+
populated with the forwarded keys.
12+
* ``AuditLogEntry.transfer_basis = "pasal_56b_dpa"`` (Pasal 56(b) explicit DPA
13+
tag) round-trips through serialize → deserialize verbatim.
14+
15+
Usage::
16+
17+
export AXONFLOW_AGENT_URL=http://localhost:8080
18+
export AXONFLOW_TENANT_ID=buku-e-py-e2e
19+
export AXONFLOW_TENANT_SECRET=buku-e-secret
20+
python runtime-e2e/decision_context_transfer_basis/test.py
21+
22+
Exits non-zero if the SDK does not surface the new fields. Companion
23+
mock-free unit coverage lives in ``tests/test_decisions.py`` +
24+
``tests/test_indonesia_pii_audit.py``.
25+
"""
26+
27+
from __future__ import annotations
28+
29+
import asyncio
30+
import base64
31+
import json
32+
import os
33+
import sys
34+
35+
import httpx
36+
37+
from axonflow import AxonFlow
38+
from axonflow.decisions import ListDecisionsOptions
39+
from axonflow.types import TRANSFER_BASIS_PASAL_56B_DPA, AuditLogEntry
40+
41+
AGENT_URL = os.environ.get("AXONFLOW_AGENT_URL", "http://localhost:8080")
42+
CLIENT_ID = os.environ.get("AXONFLOW_TENANT_ID", "buku-e-py-e2e")
43+
SECRET = os.environ.get("AXONFLOW_TENANT_SECRET", "buku-e-secret")
44+
45+
WANT_CONTEXT = {
46+
"x_ai_agent": "refund-bot",
47+
"x_session_id": "sess-buku-42",
48+
"x_leader_identity": "ops-lead",
49+
}
50+
51+
52+
def _fail(msg: str) -> None:
53+
print(f"FAIL: {msg}", file=sys.stderr)
54+
sys.exit(1)
55+
56+
57+
def create_decision_with_context() -> str:
58+
"""Act as the PEP: the request context lives in the body's ``context`` map."""
59+
auth = base64.b64encode(f"{CLIENT_ID}:{SECRET}".encode()).decode()
60+
body = {
61+
"stage": "llm",
62+
"query": "summarize this support ticket",
63+
"target": {"type": "llm", "model": "gpt-4", "provider": "openai"},
64+
"context": {
65+
"x-ai-agent": "refund-bot",
66+
"x-session-id": "sess-buku-42",
67+
"x-leader-identity": "ops-lead",
68+
},
69+
}
70+
resp = httpx.post(
71+
f"{AGENT_URL}/api/v1/decide",
72+
json=body,
73+
headers={"X-Client-ID": CLIENT_ID, "Authorization": f"Basic {auth}"},
74+
timeout=15.0,
75+
)
76+
if resp.status_code != 200:
77+
_fail(f"decide HTTP {resp.status_code}: {resp.text}")
78+
print(f"server /decide response: {resp.text}")
79+
decision_id = resp.json().get("decision_id")
80+
if not decision_id:
81+
_fail(f"no decision_id in response: {resp.text}")
82+
return decision_id
83+
84+
85+
async def main() -> None:
86+
decision_id = create_decision_with_context()
87+
print(f"PEP decide -> decision_id={decision_id}")
88+
89+
async with AxonFlow(endpoint=AGENT_URL, client_id=CLIENT_ID, client_secret=SECRET) as client:
90+
rows = await client.list_decisions(ListDecisionsOptions(limit=5))
91+
found = next((r for r in rows if r.decision_id == decision_id), None)
92+
if found is None:
93+
_fail(f"list_decisions did not return {decision_id} (got {len(rows)} rows)")
94+
print(f"SDK list_decisions -> {json.dumps(found.model_dump(mode='json'))}")
95+
if found.context != WANT_CONTEXT:
96+
_fail(f"list_decisions context = {found.context}, want {WANT_CONTEXT}")
97+
print(
98+
f"PASS: list_decisions DecisionSummary.context populated "
99+
f"with {len(found.context)} PEP-forwarded keys"
100+
)
101+
102+
exp = await client.explain_decision(decision_id)
103+
print(
104+
f"SDK explain_decision -> context={json.dumps(exp.context)} "
105+
f"context_truncated={exp.context_truncated}"
106+
)
107+
if exp.context != WANT_CONTEXT:
108+
_fail(f"explain_decision context = {exp.context}, want {WANT_CONTEXT}")
109+
print(
110+
f"PASS: explain_decision returned full context "
111+
f"(context_truncated={exp.context_truncated})"
112+
)
113+
114+
# transfer_basis = pasal_56b_dpa round-trip (Pasal 56(b)).
115+
entry = AuditLogEntry(
116+
id="e2e-audit",
117+
timestamp="2026-05-30T10:00:00Z",
118+
data_residency="ID",
119+
transfer_basis=TRANSFER_BASIS_PASAL_56B_DPA,
120+
)
121+
restored = AuditLogEntry.model_validate_json(entry.model_dump_json())
122+
if restored.transfer_basis != "pasal_56b_dpa":
123+
_fail(f"transfer_basis round-trip = {restored.transfer_basis!r}, want pasal_56b_dpa")
124+
print(f"SDK AuditLogEntry round-trip -> {entry.model_dump_json()}")
125+
print(f"PASS: AuditLogEntry.transfer_basis = {restored.transfer_basis!r} round-trips verbatim")
126+
127+
print("ALL PASS: v8.4.0 context + pasal_56b_dpa verified through SDK runtime")
128+
129+
130+
if __name__ == "__main__":
131+
asyncio.run(main())

tests/fixtures/wire_shape_baseline.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,14 @@
244244
],
245245
"spec_only": []
246246
},
247+
"DecisionExplanation": {
248+
"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.",
249+
"sdk_only": [
250+
"context",
251+
"context_truncated"
252+
],
253+
"spec_only": []
254+
},
247255
"DynamicPolicy": {
248256
"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.",
249257
"sdk_only": [

0 commit comments

Comments
 (0)