|
| 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()) |
0 commit comments