Skip to content

Commit 62151ce

Browse files
feat(release): v8.5.0 — decision request context + pasal_56b_dpa transfer basis
Targets AxonFlow platform v8.5.0 (epic #2508). - DecisionSummary + DecisionExplanation gain a Context map[string]string (json:"context,omitempty") surfacing the sanitized request context a PEP attaches to a Decision Mode call (platform #2509). ListDecisions returns the platform-truncated 5-key summary; ExplainDecision returns the full map plus a ContextTruncated bool. - ojk.go adds TransferBasis* constants (adequacy, safeguards, pasal_56b_dpa, consent) for type-safe access to the UU PDP Pasal 56 legal bases. The AuditLogEntry/CrossBorderTransferRecord TransferBasis field stays a plain string surfaced verbatim, so existing 'safeguards' consumers are unaffected. - version 8.3.0 -> 8.5.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 ee59ecb commit 62151ce

10 files changed

Lines changed: 462 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,32 @@ All notable changes to the AxonFlow Go SDK will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [8.5.0] - 2026-05-30 — Decision request context + Pasal 56(b) transfer basis
9+
10+
Targets AxonFlow platform **v8.5.0**.
11+
12+
### Added
13+
14+
- **`Context` field on `DecisionSummary` and `DecisionExplanation`**`map[string]string`,
15+
`json:"context,omitempty"`. Surfaces the sanitized request context a PEP attaches to a
16+
Decision Mode call (canonical `lower_snake_case` keys such as `x_ai_agent`, `x_session_id`,
17+
`x_leader_identity`, and `x-bukuwarung-*`), persisted by the platform at the audit row's
18+
`policy_details->'context'`. `ListDecisions` returns the platform-truncated summary (the 5
19+
most-correlated keys); `ExplainDecision` returns the full map up to the platform's 10-key cap.
20+
- **`ContextTruncated` field on `DecisionExplanation`**`bool`, `json:"context_truncated,omitempty"`.
21+
Reports whether the agent dropped surplus context keys at write time.
22+
- **`TransferBasis*` constants** (`ojk.go`): `TransferBasisAdequacy`, `TransferBasisSafeguards`,
23+
`TransferBasisPasal56bDPA` (`"pasal_56b_dpa"`), `TransferBasisConsent`. Type-safe access to the
24+
Indonesia UU PDP Pasal 56 legal bases recognized for the `TransferBasis` field on
25+
`CrossBorderTransferRecord` and `AuditLogEntry`.
26+
27+
### Changed
28+
29+
- **`AuditLogEntry.TransferBasis` documentation** now records `pasal_56b_dpa` (Pasal 56(b) explicit
30+
DPA tag) as an accepted value alongside `adequacy`, `safeguards`, and `consent`. The field stays a
31+
plain `string` (surfaced verbatim), so existing code switching on `safeguards` is unaffected and the
32+
SDK never rejects a value a newer platform may add.
33+
834
## [8.3.0] - 2026-05-27 — Indonesia PII category + cross-border audit fields + OJK types
935

1036
### Added

audit.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,10 @@ type AuditLogEntry struct {
9191
Metadata map[string]interface{} `json:"metadata,omitempty"`
9292
// DataResidency is the ISO 3166-1 alpha-2 country code where data is stored
9393
DataResidency string `json:"data_residency,omitempty"`
94-
// TransferBasis is the legal basis for cross-border data transfer: adequacy, safeguards, or consent
94+
// TransferBasis is the legal basis for cross-border data transfer under
95+
// Indonesia UU PDP Pasal 56: adequacy, safeguards, pasal_56b_dpa, or consent.
96+
// Surfaced verbatim — see the TransferBasis* constants in ojk.go for the
97+
// recognized set and their Pasal 56 mapping.
9598
TransferBasis string `json:"transfer_basis,omitempty"`
9699
}
97100

audit_test.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"encoding/json"
77
"net/http"
88
"net/http/httptest"
9+
"strings"
910
"testing"
1011
"time"
1112
)
@@ -1000,6 +1001,41 @@ func TestAuditLogEntryCrossBorderFields(t *testing.T) {
10001001
}
10011002
})
10021003

1004+
t.Run("pasal_56b_dpa transfer basis (v8.5.0)", func(t *testing.T) {
1005+
jsonData := `{
1006+
"id": "aud-56b",
1007+
"timestamp": "2026-05-30T10:00:00Z",
1008+
"data_residency": "ID",
1009+
"transfer_basis": "pasal_56b_dpa"
1010+
}`
1011+
var entry AuditLogEntry
1012+
if err := json.Unmarshal([]byte(jsonData), &entry); err != nil {
1013+
t.Fatalf("unmarshal: %v", err)
1014+
}
1015+
if entry.TransferBasis != TransferBasisPasal56bDPA {
1016+
t.Errorf("transfer_basis = %q, want %q", entry.TransferBasis, TransferBasisPasal56bDPA)
1017+
}
1018+
// Round-trips verbatim — never auto-translated to "safeguards".
1019+
out, err := json.Marshal(entry)
1020+
if err != nil {
1021+
t.Fatalf("marshal: %v", err)
1022+
}
1023+
if !strings.Contains(string(out), `"transfer_basis":"pasal_56b_dpa"`) {
1024+
t.Errorf("expected pasal_56b_dpa verbatim on the wire: %s", out)
1025+
}
1026+
})
1027+
1028+
t.Run("backward compat - safeguards still parses (v8.5.0 widening)", func(t *testing.T) {
1029+
jsonData := `{"id":"aud-sg","timestamp":"2026-05-26T10:00:00Z","transfer_basis":"safeguards"}`
1030+
var entry AuditLogEntry
1031+
if err := json.Unmarshal([]byte(jsonData), &entry); err != nil {
1032+
t.Fatalf("unmarshal: %v", err)
1033+
}
1034+
if entry.TransferBasis != TransferBasisSafeguards {
1035+
t.Errorf("transfer_basis = %q, want safeguards", entry.TransferBasis)
1036+
}
1037+
})
1038+
10031039
t.Run("backward compat - fields absent", func(t *testing.T) {
10041040
jsonData := `{
10051041
"id": "aud-002",

decisions.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,18 @@ type DecisionExplanation struct {
3434
HistoricalHitCountSession int `json:"historical_hit_count_session"`
3535
PolicySourceLink string `json:"policy_source_link,omitempty"`
3636
ToolSignature string `json:"tool_signature,omitempty"`
37+
38+
// Context is the FULL sanitized request context the PEP attached to the
39+
// decision (canonical lower_snake_case keys, string values), read from
40+
// the audit row's policy_details->'context'. Unlike ListDecisions (which
41+
// truncates to the 5 most-correlated keys), Explain returns every
42+
// persisted key up to the platform's 10-key cap, so an auditor gets the
43+
// complete correlation set (x_ai_agent / x_session_id / x_leader_identity,
44+
// x-bukuwarung-*). ContextTruncated reports whether the agent dropped
45+
// surplus keys at write time. Both omitempty so pre-v8.5.0 audit rows keep
46+
// their original byte-shape. (platform #2509 / epic #2508)
47+
Context map[string]string `json:"context,omitempty"`
48+
ContextTruncated bool `json:"context_truncated,omitempty"`
3749
}
3850

3951
// ExplainPolicy is a policy reference inside an explanation.
@@ -137,6 +149,15 @@ type DecisionSummary struct {
137149
Decision string `json:"decision"` // "allow"|"deny"|"require_approval"
138150
PolicyID string `json:"policy_id,omitempty"`
139151
ToolSignature string `json:"tool_signature,omitempty"`
152+
153+
// Context is the sanitized request context the PEP attached to the
154+
// decision (canonical lower_snake_case keys, string values), surfaced
155+
// from the audit row's policy_details->'context'. The list summary is
156+
// truncated by the platform to the 5 most-correlated keys; the full map
157+
// (up to the 10-key cap) is available via ExplainDecision. omitempty so
158+
// pre-v8.5.0 audit rows + decisions with no context keep the original
159+
// byte-shape. (platform #2509 / epic #2508)
160+
Context map[string]string `json:"context,omitempty"`
140161
}
141162

142163
// ListDecisionsOptions carries the 5 optional filters for ListDecisions.

decisions_test.go

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -428,3 +428,91 @@ func TestDecisionSummary_OptionalFieldsRoundTrip(t *testing.T) {
428428
t.Errorf("omitempty must drop unset optionals: %s", s)
429429
}
430430
}
431+
432+
// TestDecisionSummary_ContextRoundTrip — v8.5.0 (platform #2509): the LIST
433+
// row carries the sanitized request context the PEP attached to the decision.
434+
// The map must parse and re-serialize without information loss.
435+
func TestDecisionSummary_ContextRoundTrip(t *testing.T) {
436+
raw := []byte(`{"decision_id":"dec-ctx","timestamp":"2026-05-30T12:00:00Z","decision":"deny",` +
437+
`"context":{"x_ai_agent":"refund-bot","x_session_id":"sess-42","x_leader_identity":"ops-lead"}}`)
438+
var d DecisionSummary
439+
if err := json.Unmarshal(raw, &d); err != nil {
440+
t.Fatal(err)
441+
}
442+
if len(d.Context) != 3 {
443+
t.Fatalf("expected 3 context keys, got %d (%+v)", len(d.Context), d.Context)
444+
}
445+
if d.Context["x_ai_agent"] != "refund-bot" || d.Context["x_session_id"] != "sess-42" {
446+
t.Errorf("context values not preserved: %+v", d.Context)
447+
}
448+
out, err := json.Marshal(d)
449+
if err != nil {
450+
t.Fatal(err)
451+
}
452+
var back DecisionSummary
453+
if err := json.Unmarshal(out, &back); err != nil {
454+
t.Fatal(err)
455+
}
456+
if back.Context["x_leader_identity"] != "ops-lead" {
457+
t.Errorf("round-trip lost context: %s", out)
458+
}
459+
}
460+
461+
// TestDecisionSummary_ContextOmittedWhenEmpty — a decision with no request
462+
// context (or a pre-v8.5.0 audit row) must keep its original byte-shape:
463+
// omitempty drops the absent map so the wire payload is unchanged.
464+
func TestDecisionSummary_ContextOmittedWhenEmpty(t *testing.T) {
465+
raw := []byte(`{"decision_id":"dec-noctx","timestamp":"2026-05-30T12:00:00Z","decision":"allow"}`)
466+
var d DecisionSummary
467+
if err := json.Unmarshal(raw, &d); err != nil {
468+
t.Fatal(err)
469+
}
470+
if d.Context != nil {
471+
t.Errorf("expected nil context, got %+v", d.Context)
472+
}
473+
out, err := json.Marshal(d)
474+
if err != nil {
475+
t.Fatal(err)
476+
}
477+
if strings.Contains(string(out), "context") {
478+
t.Errorf("omitempty must drop unset context: %s", out)
479+
}
480+
}
481+
482+
// TestDecisionExplanation_ContextAndTruncated — v8.5.0 (platform #2509): the
483+
// explain payload returns the FULL context map plus the context_truncated flag.
484+
func TestDecisionExplanation_ContextAndTruncated(t *testing.T) {
485+
raw := []byte(`{"decision_id":"dec-x","timestamp":"2026-05-30T12:00:00Z","decision":"deny",` +
486+
`"reason":"pii","override_available":false,"historical_hit_count_session":0,` +
487+
`"policy_matches":[],"context":{"x_ai_agent":"a","x_session_id":"s"},"context_truncated":true}`)
488+
var e DecisionExplanation
489+
if err := json.Unmarshal(raw, &e); err != nil {
490+
t.Fatal(err)
491+
}
492+
if len(e.Context) != 2 || e.Context["x_ai_agent"] != "a" {
493+
t.Errorf("explain context not parsed: %+v", e.Context)
494+
}
495+
if !e.ContextTruncated {
496+
t.Errorf("expected context_truncated=true")
497+
}
498+
out, err := json.Marshal(e)
499+
if err != nil {
500+
t.Fatal(err)
501+
}
502+
if !strings.Contains(string(out), `"context_truncated":true`) {
503+
t.Errorf("context_truncated must serialize when true: %s", out)
504+
}
505+
}
506+
507+
// TestDecisionExplanation_ContextTruncatedOmittedWhenFalse — the common case
508+
// (nothing dropped) must not emit context_truncated, preserving byte-shape.
509+
func TestDecisionExplanation_ContextTruncatedOmittedWhenFalse(t *testing.T) {
510+
e := DecisionExplanation{DecisionID: "d", Decision: "allow"}
511+
out, err := json.Marshal(e)
512+
if err != nil {
513+
t.Fatal(err)
514+
}
515+
if strings.Contains(string(out), "context_truncated") {
516+
t.Errorf("omitempty must drop context_truncated when false: %s", out)
517+
}
518+
}

ojk.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,3 +77,24 @@ type CrossBorderTransferRecord struct {
7777
Purpose string `json:"purpose,omitempty"`
7878
Status string `json:"status"`
7979
}
80+
81+
// Cross-border transfer-basis values recognized under Indonesia UU PDP Pasal 56.
82+
// These are the legal bases the platform accepts for the TransferBasis field on
83+
// CrossBorderTransferRecord and AuditLogEntry. The field remains a plain string
84+
// so the SDK never rejects a value a newer platform may add; these constants give
85+
// callers type-safe access to the known set.
86+
//
87+
// - TransferBasisAdequacy → Pasal 56(a): destination with adequate protection
88+
// - TransferBasisSafeguards → Pasal 56(b): binding legal instrument (generic label)
89+
// - TransferBasisPasal56bDPA → Pasal 56(b): binding legal instrument, explicit DPA tag
90+
// - TransferBasisConsent → Pasal 56(c): explicit data-subject consent
91+
//
92+
// "safeguards" and "pasal_56b_dpa" are semantic equivalents; both are surfaced
93+
// verbatim and never auto-translated, so an auditor sees the value recorded at
94+
// decision time. (platform #2513 / epic #2508)
95+
const (
96+
TransferBasisAdequacy = "adequacy"
97+
TransferBasisSafeguards = "safeguards"
98+
TransferBasisPasal56bDPA = "pasal_56b_dpa"
99+
TransferBasisConsent = "consent"
100+
)

ojk_test.go

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package axonflow
22

33
import (
44
"encoding/json"
5+
"strings"
56
"testing"
67
"time"
78
)
@@ -198,6 +199,63 @@ func TestCrossBorderTransferRecordOmitsEmpty(t *testing.T) {
198199
}
199200
}
200201

202+
// TestTransferBasisConstants pins the wire values of the UU PDP Pasal 56
203+
// transfer-basis constants added in v8.5.0 (platform #2513).
204+
func TestTransferBasisConstants(t *testing.T) {
205+
cases := map[string]string{
206+
TransferBasisAdequacy: "adequacy",
207+
TransferBasisSafeguards: "safeguards",
208+
TransferBasisPasal56bDPA: "pasal_56b_dpa",
209+
TransferBasisConsent: "consent",
210+
}
211+
for got, want := range cases {
212+
if got != want {
213+
t.Errorf("transfer-basis constant = %q, want %q", got, want)
214+
}
215+
}
216+
}
217+
218+
// TestCrossBorderTransferRecord_Pasal56bDPA — the new Pasal 56(b) explicit DPA
219+
// tag must round-trip on CrossBorderTransferRecord without information loss.
220+
func TestCrossBorderTransferRecord_Pasal56bDPA(t *testing.T) {
221+
record := CrossBorderTransferRecord{
222+
ID: "xfer-56b",
223+
SourceCountry: "ID",
224+
DestCountry: "SG",
225+
TransferBasis: TransferBasisPasal56bDPA,
226+
TransferDate: time.Now().Truncate(time.Second),
227+
Status: "approved",
228+
}
229+
data, err := json.Marshal(record)
230+
if err != nil {
231+
t.Fatalf("marshal: %v", err)
232+
}
233+
if !strings.Contains(string(data), `"transfer_basis":"pasal_56b_dpa"`) {
234+
t.Errorf("expected pasal_56b_dpa on the wire: %s", data)
235+
}
236+
var decoded CrossBorderTransferRecord
237+
if err := json.Unmarshal(data, &decoded); err != nil {
238+
t.Fatalf("unmarshal: %v", err)
239+
}
240+
if decoded.TransferBasis != "pasal_56b_dpa" {
241+
t.Errorf("transfer_basis = %q, want pasal_56b_dpa", decoded.TransferBasis)
242+
}
243+
}
244+
245+
// TestTransferBasis_BackwardCompatSafeguards — existing v8.3.0-shaped records
246+
// using "safeguards" must continue to parse unchanged after the widening.
247+
func TestTransferBasis_BackwardCompatSafeguards(t *testing.T) {
248+
raw := []byte(`{"id":"xfer-old","source_country":"ID","dest_country":"NL",` +
249+
`"transfer_basis":"safeguards","transfer_date":"2026-05-26T00:00:00Z","status":"approved"}`)
250+
var decoded CrossBorderTransferRecord
251+
if err := json.Unmarshal(raw, &decoded); err != nil {
252+
t.Fatalf("unmarshal: %v", err)
253+
}
254+
if decoded.TransferBasis != TransferBasisSafeguards {
255+
t.Errorf("transfer_basis = %q, want safeguards", decoded.TransferBasis)
256+
}
257+
}
258+
201259
func TestOJKAuditExportRoundTrip(t *testing.T) {
202260
now := time.Now().Truncate(time.Second)
203261
req := OJKAuditExportRequest{
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# decision_context_transfer_basis (v8.5.0)
2+
3+
Real-wire test for the v8.5.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 `ListDecisions` and `ExplainDecision`.
8+
- **`AuditLogEntry.TransferBasis = "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
12+
intentionally not SDK-wrapped per ADR-056), then reads the decision back through
13+
the SDK and asserts `Context` is populated with the PEP-forwarded keys.
14+
15+
## Run
16+
17+
```
18+
# Requires a running agent at AXONFLOW_AGENT_URL (default http://localhost:8080).
19+
# In community mode any client id/secret is accepted and maps to a tenant.
20+
export AXONFLOW_AGENT_URL=http://localhost:8080
21+
export AXONFLOW_TENANT_ID=buku-e-go-e2e
22+
export AXONFLOW_TENANT_SECRET=buku-e-secret
23+
24+
go run runtime-e2e/decision_context_transfer_basis/main.go
25+
```
26+
27+
Exits non-zero if the SDK does not surface the new fields.

0 commit comments

Comments
 (0)