From 5d99c686590290a1b72717451236463169ade5ba Mon Sep 17 00:00:00 2001 From: Saurabh Jain Date: Tue, 4 Aug 2026 00:15:49 +0200 Subject: [PATCH 1/3] feat(audit): add real wire fields policy_decision/policy_details/response_time_ms, deprecate the seven fiction fields (#3254) Additive interim per the operator decision on getaxonflow/axonflow-enterprise#3254: the orchestrator has never served query_summary/success/blocked/risk_score/latency_ms/policy_violations/ metadata on the 9.x line; the real wire carries policy_decision, policy_details and response_time_ms. AuditSearchRequest gains action (the filter the 9.x server actually reads); request_type is deprecated on the search request (silently ignored server-side). - AuditLogEntry: three new defaulted fields; docstring deprecation notes on the seven fiction fields (kept, still parse, removal rides the next major); policy_details/metadata/policy_violations are null-tolerant (the orchestrator marshals a nil Go map/slice as JSON null - observed live, caught by the runtime-e2e suite) - AuditSearchRequest.action wired into _build_audit_search_body; request_type still sent when set (harmless, ignored) - tests: real v9.13.0 capture fixture (session 3254) + old-server tolerance + both-present + null-tolerance, action filter on the wire - runtime-e2e/audit_real_wire_fields: real-stack proof (typed parse of fresh rows, action filter read server-side, request_type no-op) - wire_shape_baseline: curated sdk_only entries for the new fields against the pre-v9 spec pin, notes name #3254 and the PR #214 pin bump that clears them - falsey_clobber baseline: line-number refresh only (comment insertion in client.py shifted 15 entries; finding set unchanged at 51) Signed-off-by: Saurabh Jain --- .lint_baselines/falsey_clobber.json | 30 +-- CHANGELOG.md | 265 ++++++++++--------- axonflow/client.py | 5 + axonflow/types.py | 176 ++++++++++-- runtime-e2e/audit_real_wire_fields/README.md | 34 +++ runtime-e2e/audit_real_wire_fields/test.py | 191 +++++++++++++ tests/fixtures/audit_search_live_v9130.json | 1 + tests/fixtures/wire_shape_baseline.json | 8 +- tests/test_audit.py | 168 ++++++++++++ 9 files changed, 719 insertions(+), 159 deletions(-) create mode 100644 runtime-e2e/audit_real_wire_fields/README.md create mode 100644 runtime-e2e/audit_real_wire_fields/test.py create mode 100644 tests/fixtures/audit_search_live_v9130.json diff --git a/.lint_baselines/falsey_clobber.json b/.lint_baselines/falsey_clobber.json index 1246857..1015322 100644 --- a/.lint_baselines/falsey_clobber.json +++ b/.lint_baselines/falsey_clobber.json @@ -22,24 +22,24 @@ "axonflow/adapters/tool_wrapper.py:190:20", "axonflow/adapters/tool_wrapper.py:208:20", "axonflow/adapters/tool_wrapper.py:220:20", - "axonflow/client.py:1111:16", - "axonflow/client.py:1188:16", - "axonflow/client.py:1686:37", - "axonflow/client.py:1727:18", - "axonflow/client.py:1785:37", - "axonflow/client.py:2309:24", - "axonflow/client.py:2330:33", - "axonflow/client.py:2331:31", - "axonflow/client.py:2343:25", - "axonflow/client.py:2404:28", - "axonflow/client.py:2445:69", + "axonflow/client.py:1116:16", + "axonflow/client.py:1193:16", + "axonflow/client.py:1691:37", + "axonflow/client.py:1732:18", + "axonflow/client.py:1790:37", + "axonflow/client.py:2314:24", + "axonflow/client.py:2335:33", + "axonflow/client.py:2336:31", + "axonflow/client.py:2348:25", + "axonflow/client.py:2409:28", + "axonflow/client.py:2450:69", "axonflow/client.py:300:14", "axonflow/client.py:305:24", "axonflow/client.py:306:20", - "axonflow/client.py:529:44", - "axonflow/client.py:6486:25", - "axonflow/client.py:845:20", - "axonflow/client.py:931:20", + "axonflow/client.py:534:44", + "axonflow/client.py:6491:25", + "axonflow/client.py:850:20", + "axonflow/client.py:936:20", "axonflow/execution.py:205:19", "axonflow/masfeat.py:296:23", "axonflow/masfeat.py:297:24", diff --git a/CHANGELOG.md b/CHANGELOG.md index 25f2eac..f1e6257 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Real wire fields `policy_decision`, `policy_details`, `response_time_ms` on + the audit read model (`AuditLogEntry`), and `action` on audit search + (`AuditSearchRequest`). + +### Deprecated + +- `query_summary`/`success`/`blocked`/`risk_score`/`latency_ms`/ + `policy_violations`/`metadata` (read model) and `request_type` (search + request) - never served/read on the 9.x line (#3254). Removal rides the + next major. + ## [9.0.0] - 2026-07-18 ### Changed (BREAKING) @@ -19,14 +32,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 identity as two separate wire fields instead of concatenating them into `connector_type`.** `mcp_check_input`/`mcp_check_output` (and their `check_tool_input`/`check_tool_output` aliases) gain an optional `tool` - parameter, sent alongside `connector_type` on the wire — the platform's + parameter, sent alongside `connector_type` on the wire - the platform's two-field (server, tool) identity contract. The tool name is never folded back into `connector_type`. - **LangGraph** (`mcp_tool_interceptor`) now sends `connector_type = request.server_name` and `tool = request.name` instead of `f"{server_name}.{name}"`; the default `connector_type_fn` returns the - bare `server_name`. `connector_type_fn` is the compatibility lever — a + bare `server_name`. `connector_type_fn` is the compatibility lever - a caller can restore any prior `connector_type` value (including the old concatenated form, `lambda req: f"{req.server_name}.{req.name}"`) without losing the separate `tool` field. The human-readable `statement` is now @@ -34,7 +47,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 with a custom `connector_type_fn` its shape shifts from `"{custom}(args)"` to `"{custom}.{tool}(args)"`. With the default resolver, a tool whose `server_name` is empty sends `connector_type=""`, which the platform - rejects with HTTP 400 — the call raises `ConnectorError` and is blocked + rejects with HTTP 400 - the call raises `ConnectorError` and is blocked (fail-closed); supply a `connector_type_fn` for server-less MCP tools. - **Computer Use** (`ComputerUseGovernor`) now sends the constant @@ -46,22 +59,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 migration path. **Migration.** Policies or per-connector settings matching the old - concatenated value — e.g. `connector_type == "filesystem.read_file"` or - `"computer_use.left_click"` — stop matching after upgrade. Re-scope them to + concatenated value - e.g. `connector_type == "filesystem.read_file"` or + `"computer_use.left_click"` - stop matching after upgrade. Re-scope them to match `connector_type` (the bare server name, or `"computer_use"`) together with the `tool` field (e.g. `tool == "read_file"`). **Minimum platform.** The `tool` field is consumed on `POST /api/v1/mcp/check-input` by **AxonFlow platform v9.10.0+**. On older platforms it is silently dropped and identity degrades to the bare - `connector_type` — upgrade the platform to v9.10.0+ before adopting this SDK + `connector_type` - upgrade the platform to v9.10.0+ before adopting this SDK major. Response-plane (`check-output`) `tool` scoping requires **AxonFlow platform v9.11.0+**; until then the SDK sends it forward-compatibly and older platforms ignore it. ### Added -- **`AuditToolCallRequest.caller_name`** — identifies which client made a +- **`AuditToolCallRequest.caller_name`** - identifies which client made a non-LLM tool call (e.g. `claude_code`, `codex`, `cursor`, `openclaw`). Replaces the misleadingly-named `tool_type` field, which every real caller actually used to identify the calling client rather than any property of the @@ -69,7 +82,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 server resolves `caller_name` if supplied, else the legacy `tool_type`, else a default. -## [8.5.1] - 2026-07-09 — Interceptor sync bridge + async-client detection + example fixes +## [8.5.1] - 2026-07-09 - Interceptor sync bridge + async-client detection + example fixes Hostile-testing sweep ahead of the BukuWarung integration (getaxonflow/axonflow-enterprise#2861). @@ -81,7 +94,7 @@ Hostile-testing sweep ahead of the BukuWarung integration gemini, ollama) ran the async governance check with `loop.run_until_complete`, which raises `RuntimeError: This event loop is already running` whenever the caller sits inside a running loop (FastAPI - handler, Jupyter, an async app driving a sync provider client) — the + handler, Jupyter, an async app driving a sync provider client) - the async-adapter-bypass class: the governance check crashed instead of completing. The shared `run_coroutine_sync` bridge now executes governance on one persistent background loop (daemon thread) and blocks @@ -99,35 +112,35 @@ Hostile-testing sweep ahead of the BukuWarung integration `quickstart`, `gateway_mode` and `openai_integration` passed hardcoded non-JWT literals and 401'd. All three now read `AXONFLOW_USER_TOKEN`. `gateway_mode`'s blocked-request demo uses a stacked-SQLi query (blocked - on every stack posture — PII policies default to redact, not block) and + on every stack posture - PII policies default to redact, not block) and exits non-zero if unexpectedly approved; `openai_integration` prints the outcome of its policy-block probe instead of ending silently. ### Added -- `runtime-e2e/interceptor_sync_bridge/` — live-agent assertion driving a +- `runtime-e2e/interceptor_sync_bridge/` - live-agent assertion driving a REAL `openai.OpenAI` client through `wrap_openai_client` from inside a running loop and from plain sync code (blocked verdict enforced both ways, no RuntimeError), plus async-detection assertions for `AsyncOpenAI`. -## [8.5.0] - 2026-06-09 — Decision Mode PEP: decide → fulfill → forward +## [8.5.0] - 2026-06-09 - Decision Mode PEP: decide → fulfill → forward Adds the SDK analog of the platform PEP client (`platform/shared/pep`, -ADR-056, epic #2563). A Policy Enforcement Point now follows one path — -**decide → fulfill → forward** — and the SDK makes the engine-fulfillable +ADR-056, epic #2563). A Policy Enforcement Point now follows one path - +**decide → fulfill → forward** - and the SDK makes the engine-fulfillable obligation contract impossible to misuse: there is **no local redaction path**, so a `redact_pii` obligation can only be discharged by round-tripping content through the engine endpoint the obligation names. ### Added -- **`AxonFlow.decide(DecideRequest)` / `SyncAxonFlow.decide`** — the PDP step. +- **`AxonFlow.decide(DecideRequest)` / `SyncAxonFlow.decide`** - the PDP step. `POST /api/v1/decide` returns a `DecideResponse` whose `obligations` is a list of self-describing `Obligation`s. Decision Mode auth is HTTP Basic (org:license), which the client already sends; wrong/demo credentials are refused with `AuthenticationError`. -- **`AxonFlow.fulfill_request(decision, statement)`** — discharges every +- **`AxonFlow.fulfill_request(decision, statement)`** - discharges every request-phase `redact_pii` obligation by POSTing the statement to the engine's `check-input` endpoint and returning the **engine-redacted** statement. Fails closed with `ObligationNotFulfillableError` when an @@ -135,7 +148,7 @@ content through the engine endpoint the obligation names. PEP is not holding, names an endpoint the client will not call, the engine call fails, or the engine reports `redaction_evaluated=false`. Never redacts locally. -- **`AxonFlow.decide_and_fulfill(DecideRequest)`** — the blessed one-call path +- **`AxonFlow.decide_and_fulfill(DecideRequest)`** - the blessed one-call path (decide, then fulfill any request-phase obligation); fail-closed by construction. - **New types**: `DecideRequest`, `DecideResponse`, `Obligation`, @@ -147,10 +160,10 @@ content through the engine endpoint the obligation names. endpoint-path constants). - **`redacted` / `redacted_statement` / `redaction_evaluated` on `MCPCheckInputResponse`** and **`redaction_evaluated` on - `MCPCheckOutputResponse`** — the request-redaction contract fields the agent + `MCPCheckOutputResponse`** - the request-redaction contract fields the agent emits (ADR-056). A PEP fulfilling an obligation fails closed when `redaction_evaluated` is false. -- **`content_type` on `MCPCheckInputRequest` / `mcp_check_input(...)`** — +- **`content_type` on `MCPCheckInputRequest` / `mcp_check_input(...)`** - selects the request-redaction detector (defaults to `text/plain`). ### Notes @@ -160,20 +173,20 @@ content through the engine endpoint the obligation names. platforms). The wire-shape baseline records the new fields as an acknowledged SDK superset pending the OpenAPI spec catching up. -## [8.4.0] - 2026-05-30 — Decision request context + Pasal 56(b) transfer basis +## [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`** — +- **`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 +- **`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`, @@ -189,7 +202,7 @@ Targets AxonFlow platform **v8.5.0**. 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 +## [8.3.0] - 2026-05-27 - Indonesia PII category + cross-border audit fields ### Added @@ -205,7 +218,7 @@ Targets AxonFlow platform **v8.5.0**. demonstrates NIK detection, audit log querying with cross-border fields, and policy filtering by the new `pii-indonesia` category. -## [8.2.0] - 2026-05-23 — `create_hitl_request` for explicit HITL row creation +## [8.2.0] - 2026-05-23 - `create_hitl_request` for explicit HITL row creation Enables agent-framework plugins (Google ADK, n8n, OpenAI Agents SDK) to implement the full 4-step HITL approval flow against AxonFlow: @@ -233,7 +246,7 @@ new `notify_url` outbound-webhook field fields: `client_id`, `original_query`, `request_type`. Optional fields cover policy attribution, severity, compliance framework, and an expiry override. `X-Org-ID` / `X-Tenant-ID` are derived from the SDK - client's configured credentials by the platform's auth middleware — + client's configured credentials by the platform's auth middleware - callers do not pass them through this method. - **`notify_url` field on `HITLCreateInput` and `HITLApprovalRequest` (forward-look).** Accepted on the wire today but platform-side @@ -257,12 +270,12 @@ existing `get_hitl_request` / `approve_hitl_request` / Requires AxonFlow platform >= 8.1.0 for `notify_url` webhook delivery and `Idempotency-Key` request deduplication. -## [8.1.0] - 2026-05-22 — `X-Client-ID` header on every outbound request + `org_id` in telemetry heartbeat +## [8.1.0] - 2026-05-22 - `X-Client-ID` header on every outbound request + `org_id` in telemetry heartbeat Companion release to the v9 identity cleanup on the platform. Every governed request now carries an `X-Client-ID: ` header alongside the existing Basic Auth + `X-Axonflow-Client` headers. -Value matches the SDK's Basic Auth username — smart default `community` +Value matches the SDK's Basic Auth username - smart default `community` when no `client_id` is configured. ### Added @@ -272,7 +285,7 @@ when no `client_id` is configured. middleware overwrites the header with its own auth-derived value, so caller-supplied values are harmless (no spoofing surface). - **`org_id` field in the telemetry heartbeat body.** Brings the Python - SDK telemetry up to parity with the platform — every heartbeat now + SDK telemetry up to parity with the platform - every heartbeat now identifies which deployment-organization emitted it. Two sources in precedence order: 1. The `ORG_ID` env var when set (the explicit configuration @@ -290,7 +303,7 @@ when no `client_id` is configured. - **Telemetry-enabled log line** softened from "anonymous telemetry enabled" to "telemetry enabled" to stay coherent with the `org_id` - addition — the configured `ORG_ID` on self-hosted deployments is not + addition - the configured `ORG_ID` on self-hosted deployments is not anonymized; only the `instance_id` and `cs_` Community SaaS identifier remain anonymous-by-design. @@ -298,23 +311,23 @@ when no `client_id` is configured. - Backward-compatible against v8 and v9 platforms: v8 agents ignore the unknown header; v9 agents derive identity from Basic Auth regardless. -- `org_id` is an additive field — older receivers ignore it cleanly, +- `org_id` is an additive field - older receivers ignore it cleanly, legacy SDK builds keep working unchanged. - No SDK config changes. No removed fields. No changed defaults. -## [8.0.0] - 2026-05-09 — Decision History API + policy_version recorded on every decision + telemetry simplification +## [8.0.0] - 2026-05-09 - Decision History API + policy_version recorded on every decision + telemetry simplification **Major release.** The headline feature is the new decision-history client API: `list_decisions` for paging through recorded decisions, alongside the -`get_decision_explain` method shipped in v7.4.0 — callers can now both list +`get_decision_explain` method shipped in v7.4.0 - callers can now both list and drill in. Bundled into a major because the v8 line also tightens the -telemetry contract — see `Removed` at the bottom of this entry for that. +telemetry contract - see `Removed` at the bottom of this entry for that. ### Added - **`client.list_decisions(opts)` method.** Pages over recorded decision history from the orchestrator, mirroring `GET /api/v1/decisions`. - Companion to the v7.4.0 `get_decision_explain` method — callers can + Companion to the v7.4.0 `get_decision_explain` method - callers can now both list and drill in. Already shipped on `main` and graduated into the v8.0 line with this release. See type `ListDecisionsOptions` and `DecisionListItem` in `axonflow.decisions`. @@ -326,7 +339,7 @@ telemetry contract — see `Removed` at the bottom of this entry for that. `AxonFlow(..., telemetry=False)` will raise `TypeError` at construction time. Migration: - If you were using it to disable telemetry, set - `AXONFLOW_TELEMETRY=off` in the environment instead — that's the + `AXONFLOW_TELEMETRY=off` in the environment instead - that's the sole opt-out lever as of v8.0. - If you were using it to force-enable, the default is now ON for every mode so the argument is no longer needed. @@ -337,9 +350,9 @@ telemetry contract — see `Removed` at the bottom of this entry for that. ### Telemetry - **`AXONFLOW_TELEMETRY=off` is the sole opt-out.** `AxonFlow(..., telemetry=...)` keyword argument + `AxonFlowConfig.telemetry` field both removed; sandbox-mode clients now fire on the same 7-day heartbeat schedule as production (was suppressed pre-v8), tagged `stream="sandbox"` so dev pings stay distinguishable. -- **Heartbeat payload v1 schema additions** on the wire: new `telemetry_type` and `deployment_mode` fields. Existing receivers continue working unchanged — strictly additive. +- **Heartbeat payload v1 schema additions** on the wire: new `telemetry_type` and `deployment_mode` fields. Existing receivers continue working unchanged - strictly additive. -## [7.1.0] - 2026-05-06 — X-Axonflow-Client header + scope-aware license validation +## [7.1.0] - 2026-05-06 - X-Axonflow-Client header + scope-aware license validation **Companion release to platform v7.7.0.** The Python SDK now sends an `X-Axonflow-Client` identification header on every governed request, which @@ -368,23 +381,23 @@ license token's audience claim per the license matrix. ### Companion releases (same day) -- **Platform v7.7.0** — V1 SaaS Plugin Pro launch, license matrix, +- **Platform v7.7.0** - V1 SaaS Plugin Pro launch, license matrix, per-tenant tier resolution, GDPR right-to-erasure ([CHANGELOG](https://github.com/getaxonflow/axonflow/blob/main/CHANGELOG.md)) - **Go SDK v7.1.0** / **TypeScript SDK v7.1.0** / - **Java SDK v7.1.0** — same `X-Axonflow-Client` injection -- **Plugins** — Claude Code / Cursor / Codex v1.2.0; OpenClaw v2.2.0 + **Java SDK v7.1.0** - same `X-Axonflow-Client` injection +- **Plugins** - Claude Code / Cursor / Codex v1.2.0; OpenClaw v2.2.0 with Pro license token paste activating Pro features axonflow-sdk-rust remains at v0.1.0 (preview); SDK-Rust will gain the header in a future preview release. -## [7.0.0] - 2026-04-29 — Production, quality, and security hardening — upgrade encouraged +## [7.0.0] - 2026-04-29 - Production, quality, and security hardening - upgrade encouraged -**Upgrade strongly recommended.** Over the past month we've shipped substantial production, quality, and security hardening across the AxonFlow SDKs and platform — upgrade to the latest major for a more secure, reliable, and bug-free experience. +**Upgrade strongly recommended.** Over the past month we've shipped substantial production, quality, and security hardening across the AxonFlow SDKs and platform - upgrade to the latest major for a more secure, reliable, and bug-free experience. **Security highlights from this release cycle:** -- **Webhook signing-key now exposed by SDK response type** (this release). The `secret` (HMAC-SHA256) field on `WebhookSubscription` — returned by `create_webhook` — was missing from the SDK type, so callers had no way to retrieve the signing key and webhook signature verification was effectively un-implementable. The field is now wired through end-to-end. Documented in [`GHSA-7f4h-6264-89fr`](https://github.com/getaxonflow/axonflow-sdk-python/security/advisories/GHSA-7f4h-6264-89fr). +- **Webhook signing-key now exposed by SDK response type** (this release). The `secret` (HMAC-SHA256) field on `WebhookSubscription` - returned by `create_webhook` - was missing from the SDK type, so callers had no way to retrieve the signing key and webhook signature verification was effectively un-implementable. The field is now wired through end-to-end. Documented in [`GHSA-7f4h-6264-89fr`](https://github.com/getaxonflow/axonflow-sdk-python/security/advisories/GHSA-7f4h-6264-89fr). - **`DO_NOT_TRACK` opt-out removed in favor of `AXONFLOW_TELEMETRY=off`** (this release). `DO_NOT_TRACK` was unreliable because host CLIs and runtimes commonly inject `DO_NOT_TRACK=1` regardless of user intent; an explicit AxonFlow-scoped opt-out is the only signal we honor now. - **Nightly integration in strict mode against `try.getaxonflow.com`** (this release). A canary that catches platform-side regressions affecting the SDK before they reach a release; failures auto-file a GitHub issue. @@ -401,54 +414,54 @@ Major release across the AxonFlow SDK family. Companion releases ship the same d ### Changed -- **Telemetry switched to a 7-day delivered-heartbeat.** At most one anonymous ping per environment every 7 days, with the stamp advanced only after the POST returns 2xx — a transient network failure doesn't silence telemetry until the next window. Concurrent threads are de-duplicated by an in-flight gate. Restricted environments where no cache dir is available (e.g. AWS Lambda) fall back transparently to the previous "one ping per process" behavior. -- `StaticPolicy` and `PolicyVersion` now serialize wire fields in snake_case to match the OpenAPI spec (`created_at`, `updated_at`, `organization_id`, `tenant_id`, `has_override`, `changed_at`, `changed_by`, `change_type`). camelCase aliases remain accepted on input via `validation_alias=AliasChoices(...)`. **Round-trip identity is no longer preserved** for callers that built these models from camelCase dicts — code that signs, hashes, or byte-compares serialized model bodies will see a one-time shape change. +- **Telemetry switched to a 7-day delivered-heartbeat.** At most one anonymous ping per environment every 7 days, with the stamp advanced only after the POST returns 2xx - a transient network failure doesn't silence telemetry until the next window. Concurrent threads are de-duplicated by an in-flight gate. Restricted environments where no cache dir is available (e.g. AWS Lambda) fall back transparently to the previous "one ping per process" behavior. +- `StaticPolicy` and `PolicyVersion` now serialize wire fields in snake_case to match the OpenAPI spec (`created_at`, `updated_at`, `organization_id`, `tenant_id`, `has_override`, `changed_at`, `changed_by`, `change_type`). camelCase aliases remain accepted on input via `validation_alias=AliasChoices(...)`. **Round-trip identity is no longer preserved** for callers that built these models from camelCase dicts - code that signs, hashes, or byte-compares serialized model bodies will see a one-time shape change. ### Added -- `ClientRequest.skip_llm` — optional flag to run policy evaluation only and return without invoking the LLM. +- `ClientRequest.skip_llm` - optional flag to run policy evaluation only and return without invoking the LLM. ### Fixed - The `DO_NOT_TRACK=1 is deprecated.` `logger.warning` is no longer emitted on every client construction when `DO_NOT_TRACK=1` is set. -## [6.9.0] - 2026-04-28 — list_providers() + LLMProvider full shape +## [6.9.0] - 2026-04-28 - list_providers() + LLMProvider full shape Minor release. New LLM-provider listing API + pagination wrappers, plus full surfacing of the `LLMProvider` wire shape that previous SDK versions silently dropped on parse. Coordinated cycle: TypeScript v6.2.0 / Go v6.0.0 (major: see SDKCompatibility breaking type change in that release) / Java v6.2.0 ship same day. ### Added -- **`client.list_providers()`** — list configured LLM providers and their health status. Calls `GET /api/v1/llm-providers`, returns a list of `LLMProvider` records (each with optional `LLMProviderHealth`). Supports `provider_type` and `enabled` filters. Both async and sync entry points. Closes the parity gap with the Java SDK and the in-platform listing endpoint that's been live since v4.4. +- **`client.list_providers()`** - list configured LLM providers and their health status. Calls `GET /api/v1/llm-providers`, returns a list of `LLMProvider` records (each with optional `LLMProviderHealth`). Supports `provider_type` and `enabled` filters. Both async and sync entry points. Closes the parity gap with the Java SDK and the in-platform listing endpoint that's been live since v4.4. - **`LLMProvider`** now surfaces the full provider shape: `endpoint`, `model`, `region`, `rate_limit`, `timeout_seconds`, and `settings`. Previously these fields were silently dropped on parse, so deployments couldn't introspect provider configuration via the SDK. -- **`client.list_providers_paged()`** — same arguments as `list_providers()` plus `page` / `page_size`, returns the full `LLMProviderListResponse` with `pagination` metadata. Use this when you need to walk multi-page responses or display pagination controls. -- **`client.list_all_providers()`** — convenience wrapper that walks every page (default `page_size=100`, the server-side cap) and returns the combined list. Closes the silent-truncation-at-20-providers bug in `list_providers()`. +- **`client.list_providers_paged()`** - same arguments as `list_providers()` plus `page` / `page_size`, returns the full `LLMProviderListResponse` with `pagination` metadata. Use this when you need to walk multi-page responses or display pagination controls. +- **`client.list_all_providers()`** - convenience wrapper that walks every page (default `page_size=100`, the server-side cap) and returns the combined list. Closes the silent-truncation-at-20-providers bug in `list_providers()`. ### Fixed - A single malformed `health` snapshot on one provider in a `list_providers()` response no longer crashes the entire call. The bad provider's `health` is set to `None` and a warning is logged; well-formed siblings parse normally. - `health_check_detailed()` no longer crashes with `AttributeError: 'dict' object has no attribute 'split'` when the platform returns per-language `min_sdk_version` and `recommended_sdk_version` maps (the actual on-the-wire shape since v4.8.0). `SDKCompatibility` now declares both fields as `dict[str, str]` and exposes `min_sdk_version_for(language)` / `recommended_sdk_version_for(language)` helpers, matching the Java + TypeScript SDKs. Legacy bare-string responses from older platforms are normalised to a python-keyed dict so callers don't have to branch on platform version. -- **`examples/openai_integration.py`** — replaced two bare `except Exception:` blocks with narrow handlers (`openai.OpenAIError` / `PolicyViolationError`). The old broad catch masked SDK regressions, schema drift, and governance failures. -- **`examples/wcp_retry_idempotency.py`** — env-var name corrected from `AXONFLOW_BASE_URL` to `AXONFLOW_AGENT_URL` to match the rest of the SDK and the other examples. +- **`examples/openai_integration.py`** - replaced two bare `except Exception:` blocks with narrow handlers (`openai.OpenAIError` / `PolicyViolationError`). The old broad catch masked SDK regressions, schema drift, and governance failures. +- **`examples/wcp_retry_idempotency.py`** - env-var name corrected from `AXONFLOW_BASE_URL` to `AXONFLOW_AGENT_URL` to match the rest of the SDK and the other examples. -## [6.8.0] - 2026-04-25 — Plugin Batch 1 explainability fields on MCP responses +## [6.8.0] - 2026-04-25 - Plugin Batch 1 explainability fields on MCP responses -Minor release. Surfaces fields the AxonFlow agent has emitted since v7.1.0 (Plugin Batch 1) but the SDK didn't declare. Pure field-additions on existing methods — no new SDK methods, no breaking changes. Documented in OpenAPI via platform v7.4.3. +Minor release. Surfaces fields the AxonFlow agent has emitted since v7.1.0 (Plugin Batch 1) but the SDK didn't declare. Pure field-additions on existing methods - no new SDK methods, no breaking changes. Documented in OpenAPI via platform v7.4.3. Coordinated cycle: TypeScript v6.1.0 / Go v5.8.0 / Java v6.1.0 ship same day with the same field set. ### Added - **`MCPCheckInputResponse`** gains 5 optional Plugin Batch 1 fields: - - `decision_id: str | None` — audit correlator + - `decision_id: str | None` - audit correlator - `risk_level: Literal["low", "medium", "high", "critical"] | None` - - `policy_matches: list[ExplainPolicy] | None` — per-policy explainability records - - `override_available: bool | None` — whether session override is permitted for the matched policies - - `override_existing_id: str | None` — already-active override consumed by this decision (if any) + - `policy_matches: list[ExplainPolicy] | None` - per-policy explainability records + - `override_available: bool | None` - whether session override is permitted for the matched policies + - `override_existing_id: str | None` - already-active override consumed by this decision (if any) - **`MCPCheckOutputResponse`** gains 3 optional fields: - `decision_id` - `policy_matches: list[ExplainPolicy] | None` - - `redacted_message: str | None` — text-redaction counterpart to `redacted_data` (used when the connector returned a string message rather than tabular rows; e.g. execute-style responses) -- **`ExplainPolicy`** is now re-exported from `axonflow.types` (it was previously only in `axonflow.decisions`). Same Pydantic model — Python's snake_case convention naturally aligns wire-shape and SDK types, so no separate model is needed. + - `redacted_message: str | None` - text-redaction counterpart to `redacted_data` (used when the connector returned a string message rather than tabular rows; e.g. execute-style responses) +- **`ExplainPolicy`** is now re-exported from `axonflow.types` (it was previously only in `axonflow.decisions`). Same Pydantic model - Python's snake_case convention naturally aligns wire-shape and SDK types, so no separate model is needed. All fields default to `None`. Pre-v7.1.0 platforms return `None` for every field; callers should treat absence as "context not available" rather than an error. @@ -456,43 +469,43 @@ All fields default to `None`. Pre-v7.1.0 platforms return `None` for every field `client.explain_decision(decision_id)` and the full `ExplainRule` / `DecisionExplanation` type surface are tracked separately as feature work. This release ships only field-surfacing on existing methods. -## [6.7.0] - 2026-04-25 — Wire-shape canonicalization +## [6.7.0] - 2026-04-25 - Wire-shape canonicalization -Minor release. Purely additive — new fields default to `None`, deprecated aliases preserved for compile-time compat. Coordinated with TypeScript v6.0.0 / Java v6.0.0 / Go v5.7.0 SDK releases. The wire-shape contract gate's pinned OpenAPI spec SHA bumps with the platform v7.4.2 spec corrections; one baseline drift entry (`DynamicPolicyInfo`) auto-resolves. +Minor release. Purely additive - new fields default to `None`, deprecated aliases preserved for compile-time compat. Coordinated with TypeScript v6.0.0 / Java v6.0.0 / Go v5.7.0 SDK releases. The wire-shape contract gate's pinned OpenAPI spec SHA bumps with the platform v7.4.2 spec corrections; one baseline drift entry (`DynamicPolicyInfo`) auto-resolves. ### Added -- **`WebhookSubscription.secret`** — HMAC-SHA256 signing key now exposed on the response from `create_webhook`. Required to verify the `X-AxonFlow-Signature` header on inbound webhook deliveries; without it, callers couldn't validate payload authenticity. Also adds `org_id` and `tenant_id` (ownership scoping). +- **`WebhookSubscription.secret`** - HMAC-SHA256 signing key now exposed on the response from `create_webhook`. Required to verify the `X-AxonFlow-Signature` header on inbound webhook deliveries; without it, callers couldn't validate payload authenticity. Also adds `org_id` and `tenant_id` (ownership scoping). - **`StepGateRequest`** carries `tokens_in`, `tokens_out`, `cost_usd` so budget-based policies can evaluate gate-time cost estimates. -- **`StepGateResponse.decision_id`** — unique audit correlator that links a gate response to its audit row. -- **`ListWorkflowsResponse.limit` / `offset`** — pagination echo, surfaced on the response. -- **`StaticPolicy.policy_id` / `priority`** — wire-canonical fields surfaced. -- **`CreateStaticPolicyRequest.priority` / `tags`** and **`UpdateStaticPolicyRequest.priority` / `tags`** — match the spec. -- **`UpdatePlanRequest.metadata`** — accept arbitrary plan metadata, opaque to the platform. -- **`UsageBreakdownItem.group_by`** — dimension name (provider/model/agent/etc.) is now exposed on each item. -- **`BudgetAlert.acknowledged`** — alert dismissal flag. -- **`Budget.org_id` / `tenant_id`** — ownership scoping. +- **`StepGateResponse.decision_id`** - unique audit correlator that links a gate response to its audit row. +- **`ListWorkflowsResponse.limit` / `offset`** - pagination echo, surfaced on the response. +- **`StaticPolicy.policy_id` / `priority`** - wire-canonical fields surfaced. +- **`CreateStaticPolicyRequest.priority` / `tags`** and **`UpdateStaticPolicyRequest.priority` / `tags`** - match the spec. +- **`UpdatePlanRequest.metadata`** - accept arbitrary plan metadata, opaque to the platform. +- **`UsageBreakdownItem.group_by`** - dimension name (provider/model/agent/etc.) is now exposed on each item. +- **`BudgetAlert.acknowledged`** - alert dismissal flag. +- **`Budget.org_id` / `tenant_id`** - ownership scoping. - **`UsageRecord`** gains `created_at`, `success`, `error_message`, `latency_ms`, `team_id`, `tenant_id`, `user_id`, `workflow_id` to match the wire. Legacy `timestamp` field is `DEPRECATED` (orphan read; the wire emits `created_at`). -- **`WorkflowStatusResponse.metadata`** — arbitrary workflow metadata. -- **`CreateWorkflowResponse.started_at`** — wire-canonical timestamp. Legacy `created_at` and `source` are `DEPRECATED` (orphan reads on the create response). -- **`ExecutionSnapshot.retry_count`** — number of retry attempts on a step. -- **`Finding.article`** — regulatory article reference (e.g. MAS FEAT principle number). -- **`PolicyOverride.id` / `enabled_override`** — wire-canonical fields. `active` is `DEPRECATED` (orphan read). -- **`PolicyVersion.id` / `policy_id` / `change_summary` / `snapshot`** — match the wire shape (versions are immutable snapshots, not before/after diffs). `change_description`, `previous_values`, `new_values` are `DEPRECATED` orphan reads. -- **`DynamicPolicyMatch.message`** — wire-canonical name. `reason` is `DEPRECATED` (orphan read). -- **`ExfiltrationCheckInfo.exceeded` / `limit_type`** — match the wire. `within_limits` is `DEPRECATED`. -- **`CancelPlanResponse.success`** — wire-canonical boolean. `message` is `DEPRECATED` (orphan read). +- **`WorkflowStatusResponse.metadata`** - arbitrary workflow metadata. +- **`CreateWorkflowResponse.started_at`** - wire-canonical timestamp. Legacy `created_at` and `source` are `DEPRECATED` (orphan reads on the create response). +- **`ExecutionSnapshot.retry_count`** - number of retry attempts on a step. +- **`Finding.article`** - regulatory article reference (e.g. MAS FEAT principle number). +- **`PolicyOverride.id` / `enabled_override`** - wire-canonical fields. `active` is `DEPRECATED` (orphan read). +- **`PolicyVersion.id` / `policy_id` / `change_summary` / `snapshot`** - match the wire shape (versions are immutable snapshots, not before/after diffs). `change_description`, `previous_values`, `new_values` are `DEPRECATED` orphan reads. +- **`DynamicPolicyMatch.message`** - wire-canonical name. `reason` is `DEPRECATED` (orphan read). +- **`ExfiltrationCheckInfo.exceeded` / `limit_type`** - match the wire. `within_limits` is `DEPRECATED`. +- **`CancelPlanResponse.success`** - wire-canonical boolean. `message` is `DEPRECATED` (orphan read). - **`PlanResponse`** gains the wire top-level fields `success`, `version`, `result`, `error`, `workflow_execution_id`, `policy_info`. -- **`ResumePlanResponse.result`** — final aggregated result (canonical wire field). Six fields (`workflow_id`, `message`, `step_result`, `next_step`, `next_step_name`, `total_steps`) are now `DEPRECATED` — none of them were populated by the resume decoder against the actual server response. -- **`MCPCheckInputRequest.client_id` / `tenant_id` / `user_id` / `user_role` / `user_token`** and **`MCPCheckOutputRequest.client_id` / `tenant_id` / `user_id` / `user_token`** — match the spec scoping fields. +- **`ResumePlanResponse.result`** - final aggregated result (canonical wire field). Six fields (`workflow_id`, `message`, `step_result`, `next_step`, `next_step_name`, `total_steps`) are now `DEPRECATED` - none of them were populated by the resume decoder against the actual server response. +- **`MCPCheckInputRequest.client_id` / `tenant_id` / `user_id` / `user_role` / `user_token`** and **`MCPCheckOutputRequest.client_id` / `tenant_id` / `user_id` / `user_token`** - match the spec scoping fields. ### Notes The above is an audit-driven sweep against the wire-shape contract gate. All changes are additive (new fields default to `None`) or `DEPRECATED`-marked alias fields kept for compile-time compat. Removal scheduled for v7. -The earlier overnight claim that "Python baseline is clean" was wrong — that was a key-name confusion (Python uses `per_model_drift`, the others use `per_type_drift`); a proper audit found 36 drift entries similar in pattern to the TS+Go SDK sweeps. After this sweep, 26 drift entries remain (mostly `DEPRECATED` aliases retained for source-compat + Cat C entries to file separately + Plugin Batch 1 SDK additions pending platform-side spec coverage). +The earlier overnight claim that "Python baseline is clean" was wrong - that was a key-name confusion (Python uses `per_model_drift`, the others use `per_type_drift`); a proper audit found 36 drift entries similar in pattern to the TS+Go SDK sweeps. After this sweep, 26 drift entries remain (mostly `DEPRECATED` aliases retained for source-compat + Cat C entries to file separately + Plugin Batch 1 SDK additions pending platform-side spec coverage). -Two platform-side spec corrections filed alongside this work, for issues the audit surfaced where the spec was wrong (server emits the SDK's name): `AISystemRegistry.materiality_classification` and `DynamicPolicyInfo` schema. No SDK change for those — the SDK is correct. +Two platform-side spec corrections filed alongside this work, for issues the audit surfaced where the spec was wrong (server emits the SDK's name): `AISystemRegistry.materiality_classification` and `DynamicPolicyInfo` schema. No SDK change for those - the SDK is correct. ## [6.6.2] - 2026-04-25 @@ -507,7 +520,7 @@ Two platform-side spec corrections filed alongside this work, for issues the aud so install/upgrade worked fine; the drift only affected code that read `axonflow.__version__` at runtime (telemetry self-identification, version-gated feature detection in user code, log output). No functional - changes — this release ships the same binary behavior as v6.6.1 with + changes - this release ships the same binary behavior as v6.6.1 with the runtime version correctly set to `6.6.2`. ## [6.6.1] - 2026-04-24 @@ -540,7 +553,7 @@ Two platform-side spec corrections filed alongside this work, for issues the aud ### Added -- **Rich `ApproveStepResponse` / `RejectStepResponse`** — both pydantic models +- **Rich `ApproveStepResponse` / `RejectStepResponse`** - both pydantic models now carry the same shape as the step-gate response: `decision` resolves to `"allow"` / `"block"`, `retry_context` mirrors the gate response retry state, `approved_by` / `approved_at` / `rejected_by` / `rejected_at` carry reviewer @@ -548,17 +561,17 @@ Two platform-side spec corrections filed alongside this work, for issues the aud `policies_matched` reconstructs the governance trail. Legacy fields (`workflow_id`, `step_id`, `status`) remain for back-compat; every new field is optional so older server responses still deserialize cleanly. -- **`plan_id` on approve/reject responses** — populated when the response +- **`plan_id` on approve/reject responses** - populated when the response comes from the MAP plan-scoped endpoint; empty on WCP plane responses. Same models work across both endpoints. -- **`get_pending_plan_approvals`** — new client method that lists MAP-plane +- **`get_pending_plan_approvals`** - new client method that lists MAP-plane pending approvals (`GET /api/v1/plans/approvals/pending`), the counterpart of `get_pending_approvals` for the WCP plane. Accepts an optional `plan_id` argument so reviewer tools can scope the listing to one plan. Available on Evaluation+ licenses (same tier gate as the MAP step approve/reject endpoints). Sync wrapper exposed via `SyncAxonFlow.get_pending_plan_approvals`. -- **`PendingApproval.plan_id`** — populated on MAP-plane entries, `None` on +- **`PendingApproval.plan_id`** - populated on MAP-plane entries, `None` on WCP-plane entries. Mirrors the approve/reject asymmetry. `PendingApproval` also gains `step_index`, `decision`, `decision_reason`, `policies_matched`, `step_input`, and `approval_status` so reviewer tools can render the full @@ -566,14 +579,14 @@ Two platform-side spec corrections filed alongside this work, for issues the aud ### Fixed -- **`approve_step` / `reject_step` / `get_pending_approvals` endpoint URLs** — +- **`approve_step` / `reject_step` / `get_pending_approvals` endpoint URLs** - all three previously targeted non-existent paths under `/api/v1/workflow-control/` and would fail against a real AxonFlow server. Corrected to the canonical `/api/v1/workflows/{id}/steps/{step_id}/(approve|reject)` and `/api/v1/workflows/approvals/pending` routes. Customers using these methods against a live deployment were receiving 404s; this release makes them work. -- **`PendingApprovalsResponse` field names aligned with the wire shape** — +- **`PendingApprovalsResponse` field names aligned with the wire shape** - the model previously declared `approvals` and `total`, which never matched the server response (`pending_approvals` and `count`). Renamed fields. Callers that read `response.approvals` or `response.total` must update to @@ -581,77 +594,77 @@ Two platform-side spec corrections filed alongside this work, for issues the aud ### Deprecated -- `DO_NOT_TRACK=1` as an AxonFlow telemetry opt-out — scheduled for removal after 2026-05-05 in the next major release. Use `AXONFLOW_TELEMETRY=off` instead. The SDK emits a one-line migration warning when `DO_NOT_TRACK=1` is the active control and `AXONFLOW_TELEMETRY=off` is not also set. +- `DO_NOT_TRACK=1` as an AxonFlow telemetry opt-out - scheduled for removal after 2026-05-05 in the next major release. Use `AXONFLOW_TELEMETRY=off` instead. The SDK emits a one-line migration warning when `DO_NOT_TRACK=1` is the active control and `AXONFLOW_TELEMETRY=off` is not also set. ### Unchanged - `approve_step(workflow_id, step_id)` / `reject_step(workflow_id, step_id, reason)` - method signatures are unchanged — only the response fields grew. + method signatures are unchanged - only the response fields grew. ## [6.5.0] - 2026-04-21 ### Added -- **`retry_context` and `idempotency_key` support on the step gate** — +- **`retry_context` and `idempotency_key` support on the step gate** - `StepGateResponse` now carries a `retry_context` object on every gate call with the true `(workflow_id, step_id)` lifecycle: `gate_count`, `completion_count`, - `prior_completion_status` (`PriorCompletionStatus` enum — + `prior_completion_status` (`PriorCompletionStatus` enum - `NONE` / `COMPLETED` / `GATED_NOT_COMPLETED`), `prior_output_available`, `prior_output`, `prior_completion_at`, `first_attempt_at`, `last_attempt_at`, `last_decision`, and `idempotency_key`. Prefer these fields to the legacy `cached` / `decision_source` fields. -- **`client.step_gate(..., include_prior_output=False)`** — new keyword-only argument. +- **`client.step_gate(..., include_prior_output=False)`** - new keyword-only argument. When `True`, the SDK sends `?include_prior_output=true` on the gate call and `retry_context.prior_output` is populated when a prior `/complete` has landed. Existing callers that omit the kwarg behave unchanged. -- **`StepGateRequest.idempotency_key`** — caller-supplied opaque business-level key +- **`StepGateRequest.idempotency_key`** - caller-supplied opaque business-level key (max 255 chars). Immutable once recorded on the first gate call for a `(workflow_id, step_id)`; subsequent gate/complete calls must pass the same key. -- **`MarkStepCompletedRequest.idempotency_key`** — must match the key set on the +- **`MarkStepCompletedRequest.idempotency_key`** - must match the key set on the corresponding gate call, if any. Mismatch (including missing-vs-set on either side) surfaces as a typed `IdempotencyKeyMismatchError`. -- **`IdempotencyKeyMismatchError`** — typed exception raised by `step_gate` and +- **`IdempotencyKeyMismatchError`** - typed exception raised by `step_gate` and `mark_step_completed` when the platform returns HTTP 409 with `error.code == "IDEMPOTENCY_KEY_MISMATCH"`. Surfaces `workflow_id`, `step_id`, `expected_idempotency_key`, `received_idempotency_key`, and the human-readable `message`. Exported from `axonflow` top-level. -- **`RetryContext`, `PriorCompletionStatus`** — exported pydantic model + enum. +- **`RetryContext`, `PriorCompletionStatus`** - exported pydantic model + enum. ### Deprecated -- **`StepGateResponse.cached`** and **`StepGateResponse.decision_source`** — still +- **`StepGateResponse.cached`** and **`StepGateResponse.decision_source`** - still populated but deprecated in favor of `retry_context.gate_count > 1` and `retry_context.prior_completion_status`. Planned for removal in a future major version. ### Compatibility Companion to the platform change that introduces `retry_context` on -`POST /api/v1/workflows/{workflow_id}/steps/{step_id}/gate`. Additive only — existing +`POST /api/v1/workflows/{workflow_id}/steps/{step_id}/gate`. Additive only - existing callers that never set `idempotency_key` or `include_prior_output` see no behavior change. ## [6.4.0] - 2026-04-18 ### Added -- **Execution boundary semantics** — `RetryPolicy` enum with `IDEMPOTENT` +- **Execution boundary semantics** - `RetryPolicy` enum with `IDEMPOTENT` (default) and `REEVALUATE` values. Step gate requests accept `retry_policy` to control cached vs fresh evaluation behavior. -- **Step gate response metadata** — `cached` (bool) and `decision_source` +- **Step gate response metadata** - `cached` (bool) and `decision_source` (str) fields on `StepGateResponse` indicate decision provenance. -- **Workflow checkpoints** — `get_checkpoints(workflow_id)` lists step-gate +- **Workflow checkpoints** - `get_checkpoints(workflow_id)` lists step-gate checkpoints. `resume_from_checkpoint(workflow_id, checkpoint_id)` resumes from a specific checkpoint with fresh policy evaluation (Enterprise). -- **Checkpoint types** — `Checkpoint`, `CheckpointListResponse`, and +- **Checkpoint types** - `Checkpoint`, `CheckpointListResponse`, and `ResumeFromCheckpointResponse` models. -- **`AxonFlow.explain_decision(decision_id)`** — fetches the full explanation for a +- **`AxonFlow.explain_decision(decision_id)`** - fetches the full explanation for a previously-made policy decision via `GET /api/v1/decisions/:id/explain`. Returns a `DecisionExplanation` with matched policies, risk level, reason, override availability, existing override ID (if any), and a rolling-24h session hit count for the matched rule. Shape is frozen; additive-only fields ensure forward compatibility. -- **`DecisionExplanation`, `ExplainPolicy`, `ExplainRule`** — new Pydantic +- **`DecisionExplanation`, `ExplainPolicy`, `ExplainRule`** - new Pydantic models exported from `axonflow.decisions`. -- **`AuditSearchRequest.decision_id`, `policy_name`, `override_id`** — three +- **`AuditSearchRequest.decision_id`, `policy_name`, `override_id`** - three new optional filter fields on `search_audit_logs`. Use `decision_id` to gather every record tied to one decision; `policy_name` to find everything matched by a specific policy; `override_id` to reconstruct an override's @@ -689,7 +702,7 @@ behavior. ### Changed -- Examples and documentation updated to reflect the new AxonFlow platform v6.2.0 defaults for `PII_ACTION` (now `warn` — was `redact`) and the new `AXONFLOW_PROFILE` env var. No SDK API changes; the SDK continues to pass `PII_ACTION` through unchanged. +- Examples and documentation updated to reflect the new AxonFlow platform v6.2.0 defaults for `PII_ACTION` (now `warn` - was `redact`) and the new `AXONFLOW_PROFILE` env var. No SDK API changes; the SDK continues to pass `PII_ACTION` through unchanged. --- @@ -697,7 +710,7 @@ behavior. ### Added -- **`check_tool_input()` / `check_tool_output()`** — generic aliases for tool governance. Existing `mcp_check_input()` / `mcp_check_output()` remain supported. +- **`check_tool_input()` / `check_tool_output()`** - generic aliases for tool governance. Existing `mcp_check_input()` / `mcp_check_output()` remain supported. ### Changed @@ -754,15 +767,15 @@ behavior. ### Added -- `simulate_policies()` — dry-run all active policies against an input query. Returns allowed/blocked status, applied policies, risk score, and daily usage. Requires Evaluation tier or above. -- `get_policy_impact_report()` — test a single policy against multiple inputs and get aggregate match/block statistics. -- `detect_policy_conflicts()` — analyze active policies for contradictions, shadows, and redundancies. Optionally filter to conflicts involving a specific policy. -- `AxonFlowLangGraphAdapter.tool_output_wrapper()` — returns an async wrapper for LangGraph `ToolNode(awrap_tool_call=...)` that enforces input and output policy checks on local `@tool` functions. Fixes a gap where locally defined tools bypassed `mcp_tool_interceptor` policy enforcement. +- `simulate_policies()` - dry-run all active policies against an input query. Returns allowed/blocked status, applied policies, risk score, and daily usage. Requires Evaluation tier or above. +- `get_policy_impact_report()` - test a single policy against multiple inputs and get aggregate match/block statistics. +- `detect_policy_conflicts()` - analyze active policies for contradictions, shadows, and redundancies. Optionally filter to conflicts involving a specific policy. +- `AxonFlowLangGraphAdapter.tool_output_wrapper()` - returns an async wrapper for LangGraph `ToolNode(awrap_tool_call=...)` that enforces input and output policy checks on local `@tool` functions. Fixes a gap where locally defined tools bypassed `mcp_tool_interceptor` policy enforcement. - Types: `SimulatePoliciesRequest`, `SimulatePoliciesResponse`, `SimulationDailyUsage`, `ImpactReportInput`, `ImpactReportRequest`, `ImpactReportResult`, `ImpactReportResponse`, `PolicyConflictRef`, `PolicyConflict`, `PolicyConflictResponse` -- `wrap_langgraph()` — 1-line wrapper for compiled LangGraph StateGraphs. Transparently enforces AxonFlow governance at every node transition without modifying the graph definition. Uses langchain-core's `AsyncCallbackHandler` to intercept node execution via `metadata["langgraph_node"]`. -- `GovernedGraph` class — returned by `wrap_langgraph()`, exposes `ainvoke()`, `invoke()`, `astream()`. Each invocation creates a new AxonFlow workflow. Reusable across multiple invocations. -- `NodeConfig` dataclass — per-node configuration overrides (`step_type`, `model`, `provider`, `skip`) for fine-grained control over how individual nodes are governed. -- `govern_tools` parameter — when `True` (default), individual tool calls within LangGraph nodes are automatically gate-checked via `check_tool_gate()` / `tool_completed()`. +- `wrap_langgraph()` - 1-line wrapper for compiled LangGraph StateGraphs. Transparently enforces AxonFlow governance at every node transition without modifying the graph definition. Uses langchain-core's `AsyncCallbackHandler` to intercept node execution via `metadata["langgraph_node"]`. +- `GovernedGraph` class - returned by `wrap_langgraph()`, exposes `ainvoke()`, `invoke()`, `astream()`. Each invocation creates a new AxonFlow workflow. Reusable across multiple invocations. +- `NodeConfig` dataclass - per-node configuration overrides (`step_type`, `model`, `provider`, `skip`) for fine-grained control over how individual nodes are governed. +- `govern_tools` parameter - when `True` (default), individual tool calls within LangGraph nodes are automatically gate-checked via `check_tool_gate()` / `tool_completed()`. - `langchain-core>=0.3.0` added to the `langgraph` optional extra (`pip install axonflow[langgraph]`). --- @@ -796,10 +809,10 @@ behavior. ### Added -- `get_circuit_breaker_status()` — query active circuit breaker circuits and emergency stop state -- `get_circuit_breaker_history(limit)` — retrieve circuit breaker trip/reset audit trail -- `get_circuit_breaker_config(tenant_id)` — get effective circuit breaker config (global or tenant-specific) -- `update_circuit_breaker_config(config)` — update per-tenant circuit breaker thresholds +- `get_circuit_breaker_status()` - query active circuit breaker circuits and emergency stop state +- `get_circuit_breaker_history(limit)` - retrieve circuit breaker trip/reset audit trail +- `get_circuit_breaker_config(tenant_id)` - get effective circuit breaker config (global or tenant-specific) +- `update_circuit_breaker_config(config)` - update per-tenant circuit breaker thresholds --- @@ -807,9 +820,9 @@ behavior. ### Added -- `audit_tool_call()` — record non-LLM tool calls (API, MCP, function) in the audit trail. Returns audit ID, status, and timestamp. Requires Platform v5.1.0+ -- `get_audit_logs_by_tenant()` — retrieve audit logs for a tenant with optional pagination -- `search_audit_logs()` — search audit logs with filters (client ID, request type, limit) +- `audit_tool_call()` - record non-LLM tool calls (API, MCP, function) in the audit trail. Returns audit ID, status, and timestamp. Requires Platform v5.1.0+ +- `get_audit_logs_by_tenant()` - retrieve audit logs for a tenant with optional pagination +- `search_audit_logs()` - search audit logs with filters (client ID, request type, limit) ### Fixed diff --git a/axonflow/client.py b/axonflow/client.py index b72c65e..cdeb9da 100644 --- a/axonflow/client.py +++ b/axonflow/client.py @@ -444,6 +444,11 @@ def _build_audit_search_body(request: AuditSearchRequest) -> dict[str, Any]: body["start_time"] = request.start_time.isoformat() if request.end_time: body["end_time"] = request.end_time.isoformat() + if request.action: + body["action"] = request.action + # Deprecated (#3254): the 9.x server does not read request_type as a + # search filter. Still sent when set (harmless, ignored) until the next + # major removes the field. if request.request_type: body["request_type"] = request.request_type if request.decision_id: diff --git a/axonflow/types.py b/axonflow/types.py index f4b9f35..26ddaeb 100644 --- a/axonflow/types.py +++ b/axonflow/types.py @@ -891,7 +891,11 @@ class AuditSearchRequest(BaseModel): client_id: Filter by client/application ID start_time: Start of time range to search end_time: End of time range to search - request_type: Filter by request type (e.g., "llm_chat", "policy_check") + action: Filters by action/request type with verdict normalization on + the server side. + request_type: Deprecated: the 9.x server does not read this filter; a + search filtered only by it returns unfiltered results. Use + ``action``. Scheduled for removal in the next major (#3254). limit: Maximum results to return (default: 100, max: 1000) offset: Pagination offset (default: 0) """ @@ -900,7 +904,21 @@ class AuditSearchRequest(BaseModel): client_id: str | None = Field(default=None, description="Filter by client ID") start_time: datetime | None = Field(default=None, description="Start of time range") end_time: datetime | None = Field(default=None, description="End of time range") - request_type: str | None = Field(default=None, description="Filter by request type") + action: str | None = Field( + default=None, + description=( + "Filters by action/request type with verdict normalization on the server side." + ), + ) + request_type: str | None = Field( + default=None, + description=( + "Deprecated: the 9.x server does not read this filter; a search " + "filtered only by it returns unfiltered results. Use `action`. " + "Scheduled for removal in the next major (#3254). Still sent when " + "set (harmless, ignored)." + ), + ) # ADR-043: explainability + audit cross-reference filters. decision_id: str | None = Field(default=None, description="Filter by decision ID") policy_name: str | None = Field(default=None, description="Filter by matched policy name") @@ -957,16 +975,63 @@ class AuditLogEntry(BaseModel): client_id: Client/application that made the request tenant_id: Tenant identifier request_type: Type of request (e.g., "llm_chat", "sql", "mcp-query") - query_summary: Summary of the query/request - success: Whether the request succeeded - blocked: Whether the request was blocked by policy - risk_score: Calculated risk score (0.0-1.0) + policy_decision: Policy verdict for the request. Open string set, not + an enum: "allowed", "blocked", "redacted" observed in code and + "error" observed live; newer platforms may add values. + policy_details: Policy evaluation context (object with arbitrary + keys, e.g. tool_name, success, error_message). + response_time_ms: Server-measured response time in milliseconds. + query_summary: Deprecated: never populated on the 9.x line - the + server has never sent this field + (getaxonflow/axonflow-enterprise#3254); the wire carries + `query`/`query_hash`, not modeled in this interim. Read + `policy_decision` for the verdict ("blocked" replaces + `blocked=true`; "allowed" replaces `success=true`), + `policy_details` for violation context, and `response_time_ms` + for latency. Scheduled for removal in the next major. + success: Deprecated: never populated on the 9.x line - the server has + never sent this field (getaxonflow/axonflow-enterprise#3254). + Read `policy_decision` for the verdict ("blocked" replaces + `blocked=true`; "allowed" replaces `success=true`), + `policy_details` for violation context, and `response_time_ms` + for latency. Scheduled for removal in the next major. + blocked: Deprecated: never populated on the 9.x line - the server has + never sent this field (getaxonflow/axonflow-enterprise#3254). + Read `policy_decision` for the verdict ("blocked" replaces + `blocked=true`; "allowed" replaces `success=true`), + `policy_details` for violation context, and `response_time_ms` + for latency. Scheduled for removal in the next major. + risk_score: Deprecated: never populated on the 9.x line - the server + has never sent this field + (getaxonflow/axonflow-enterprise#3254); no wire equivalent. Read + `policy_decision` for the verdict ("blocked" replaces + `blocked=true`; "allowed" replaces `success=true`), + `policy_details` for violation context, and `response_time_ms` + for latency. Scheduled for removal in the next major. provider: LLM provider used (if applicable) model: Model used (if applicable) tokens_used: Total tokens consumed - latency_ms: Request latency in milliseconds - policy_violations: List of violated policy IDs (if any) - metadata: Additional context + latency_ms: Deprecated: never populated on the 9.x line - the server + has never sent this field (getaxonflow/axonflow-enterprise#3254). + Read `policy_decision` for the verdict ("blocked" replaces + `blocked=true`; "allowed" replaces `success=true`), + `policy_details` for violation context, and `response_time_ms` + for latency. Scheduled for removal in the next major. + policy_violations: Deprecated: never populated on the 9.x line - the + server has never sent this field + (getaxonflow/axonflow-enterprise#3254). Read `policy_decision` + for the verdict ("blocked" replaces `blocked=true`; "allowed" + replaces `success=true`), `policy_details` for violation context, + and `response_time_ms` for latency. Scheduled for removal in the + next major. + metadata: Deprecated: never populated on the 9.x line - the server + has never sent this field + (getaxonflow/axonflow-enterprise#3254); the wire carries + `policy_details`/`security_metrics` instead. Read + `policy_decision` for the verdict ("blocked" replaces + `blocked=true`; "allowed" replaces `success=true`), + `policy_details` for violation context, and `response_time_ms` + for latency. Scheduled for removal in the next major. """ id: str = Field(..., description="Unique audit log ID") @@ -976,16 +1041,95 @@ class AuditLogEntry(BaseModel): client_id: str = Field(default="", description="Client ID") tenant_id: str = Field(default="", description="Tenant ID") request_type: str = Field(default="", description="Request type") - query_summary: str = Field(default="", description="Query summary") - success: bool = Field(default=True, description="Request succeeded") - blocked: bool = Field(default=False, description="Request was blocked") - risk_score: float = Field(default=0.0, ge=0.0, le=1.0, description="Risk score") + policy_decision: str = Field( + default="", + description=( + "Policy verdict. Open string set, not an enum: 'allowed', " + "'blocked', 'redacted' observed in code and 'error' observed " + "live; newer platforms may add values." + ), + ) + policy_details: dict[str, Any] = Field( + default_factory=dict, + description="Policy evaluation context (arbitrary keys)", + ) + response_time_ms: int = Field( + default=0, ge=0, description="Server-measured response time in ms" + ) + query_summary: str = Field( + default="", + description=( + "Deprecated: never populated on the 9.x line (#3254); the wire " + "carries query/query_hash. Removal rides the next major." + ), + ) + success: bool = Field( + default=True, + description=( + "Deprecated: never populated on the 9.x line (#3254); read " + "policy_decision ('allowed' replaces success=true). Removal " + "rides the next major." + ), + ) + blocked: bool = Field( + default=False, + description=( + "Deprecated: never populated on the 9.x line (#3254); read " + "policy_decision ('blocked' replaces blocked=true). Removal " + "rides the next major." + ), + ) + risk_score: float = Field( + default=0.0, + ge=0.0, + le=1.0, + description=( + "Deprecated: never populated on the 9.x line (#3254); no wire " + "equivalent. Removal rides the next major." + ), + ) provider: str = Field(default="", description="LLM provider") model: str = Field(default="", description="Model used") tokens_used: int = Field(default=0, ge=0, description="Tokens consumed") - latency_ms: int = Field(default=0, ge=0, description="Latency in ms") - policy_violations: list[str] = Field(default_factory=list, description="Violated policies") - metadata: dict[str, Any] = Field(default_factory=dict, description="Additional metadata") + latency_ms: int = Field( + default=0, + ge=0, + description=( + "Deprecated: never populated on the 9.x line (#3254); read " + "response_time_ms. Removal rides the next major." + ), + ) + policy_violations: list[str] = Field( + default_factory=list, + description=( + "Deprecated: never populated on the 9.x line (#3254); read " + "policy_details for violation context. Removal rides the next " + "major." + ), + ) + metadata: dict[str, Any] = Field( + default_factory=dict, + description=( + "Deprecated: never populated on the 9.x line (#3254); the wire " + "carries policy_details/security_metrics instead. Removal rides " + "the next major." + ), + ) + + @field_validator("policy_details", "metadata", mode="before") + @classmethod + def _coerce_none_to_dict(cls, v: object) -> object: + # The orchestrator marshals a nil Go map as JSON null (observed + # live on real /api/v1/audit/search rows, #3254): null-tolerant, + # not merely absence-tolerant. + return v if v is not None else {} + + @field_validator("policy_violations", mode="before") + @classmethod + def _coerce_none_to_list(cls, v: object) -> object: + # Same class as above for a nil Go slice, defensively. + return v if v is not None else [] + data_residency: str | None = Field( default=None, description="ISO 3166-1 alpha-2 data residency code" ) diff --git a/runtime-e2e/audit_real_wire_fields/README.md b/runtime-e2e/audit_real_wire_fields/README.md new file mode 100644 index 0000000..73e322a --- /dev/null +++ b/runtime-e2e/audit_real_wire_fields/README.md @@ -0,0 +1,34 @@ +# audit_real_wire_fields (#3254) + +Real-stack proof for the audit read model's real wire fields +(getaxonflow/axonflow-enterprise#3254 additive interim). + +The orchestrator has never served `query_summary`/`success`/`blocked`/ +`risk_score`/`latency_ms`/`policy_violations`/`metadata` on the 9.x +line; the real wire carries `policy_decision`, `policy_details` and +`response_time_ms`, and the search filter the server reads is `action` +(not `request_type`). + +## What this proves + +Drives the real SDK (`client.audit_tool_call` + `client.search_audit_logs`) +against a real running agent: + +1. Freshly written success/failure rows come back with `policy_decision` + populated (`allowed` / `error`) and `policy_details` carrying the tool + name and error message, on the TYPED `AuditLogEntry`. +2. The seven deprecated fiction fields stay at their defaults on real rows. +3. `AuditSearchRequest(action=...)` filters server-side (returns only the + matching verdict, including this run's row). +4. A search filtered only by a nonsense `request_type` returns unfiltered + results - the 9.x server does not read that filter (the deprecation + claim). + +## Run + +``` +export AXONFLOW_AGENT_URL=http://localhost:8080 +export AXONFLOW_TENANT_ID= +export AXONFLOW_TENANT_SECRET= +python runtime-e2e/audit_real_wire_fields/test.py +``` diff --git a/runtime-e2e/audit_real_wire_fields/test.py b/runtime-e2e/audit_real_wire_fields/test.py new file mode 100644 index 0000000..4e62d20 --- /dev/null +++ b/runtime-e2e/audit_real_wire_fields/test.py @@ -0,0 +1,191 @@ +"""Real-stack assertion: the audit read model parses the REAL 9.x wire +shape (getaxonflow/axonflow-enterprise#3254 additive interim). + +The orchestrator's audit_logger.go AuditEntry has never served +query_summary/success/blocked/risk_score/latency_ms/policy_violations/ +metadata on the 9.x line - the SDK's pre-#3254 model was built to spec +fiction and silently parsed real audit rows into zero-values. This test +drives the real SDK end to end against a real running agent: + + 1. Write two fresh audit rows via `client.audit_tool_call` (one + success-shaped, one failure-shaped) with a per-run tool-name nonce. + 2. Poll `client.search_audit_logs` (real POST /api/v1/audit/search + through the agent proxy) until both rows land (the orchestrator's + AuditLogger batches writes, flush every 10s). + 3. Assert on the TYPED AuditLogEntry: `policy_decision` is populated + ("allowed" on the success row, "error" on the failure row - the + verdict set is open), `policy_details` carries the tool name and + error message, `response_time_ms` parses; while the seven + deprecated fiction fields stay at their defaults on every row. + 4. Prove the new `action` search filter is READ server-side: a search + for the failure row's verdict returns only entries with that + verdict, including our row. + 5. Prove the `request_type` deprecation claim: a search filtered ONLY + by a nonsense request_type returns unfiltered results (the 9.x + server does not read the filter - silent no-op). + +Usage:: + + export AXONFLOW_AGENT_URL=http://localhost:8080 + export AXONFLOW_TENANT_ID= # e.g. demo-client + export AXONFLOW_TENANT_SECRET= # e.g. demo-secret + python runtime-e2e/audit_real_wire_fields/test.py + +Community-mode note: audit reads are tenant-scoped to "community" while +tool-call writes through the agent proxy land under the same scope, so +write and read agree with any registered credential. See ../README.md. +""" + +from __future__ import annotations + +import asyncio +import os +import sys +import time +import uuid + +from axonflow import AxonFlow +from axonflow.types import AuditLogEntry, AuditSearchRequest, AuditToolCallRequest + +AGENT_URL = os.environ.get("AXONFLOW_AGENT_URL", "http://localhost:8080") +CLIENT_ID = os.environ.get("AXONFLOW_TENANT_ID", "demo-client") +SECRET = os.environ.get("AXONFLOW_TENANT_SECRET", "demo-secret") + +_RUN_ID = uuid.uuid4().hex[:12] +OK_TOOL = f"e2e-real-wire-ok-{_RUN_ID}" +FAIL_TOOL = f"e2e-real-wire-fail-{_RUN_ID}" +FAIL_ERROR = f"e2e-real-wire-error-{_RUN_ID}" + +# The orchestrator's AuditLogger batches writes (flush every 10s); give +# generous headroom under CI/local load. +POLL_DEADLINE_SECONDS = 45.0 +POLL_INTERVAL_SECONDS = 2.0 +SEARCH_LIMIT = 100 + + +def _fail(msg: str) -> None: + sys.stderr.write(f"FAIL: {msg}\n") + sys.exit(1) + + +def _tool_name(entry: AuditLogEntry) -> str: + return str(entry.policy_details.get("tool_name", "")) + + +async def _poll_for_rows(client: AxonFlow) -> tuple[AuditLogEntry, AuditLogEntry]: + deadline = time.monotonic() + POLL_DEADLINE_SECONDS + ok_row = fail_row = None + while time.monotonic() < deadline: + result = await client.search_audit_logs(AuditSearchRequest(limit=SEARCH_LIMIT)) + for entry in result.entries: + if _tool_name(entry) == OK_TOOL: + ok_row = entry + elif _tool_name(entry) == FAIL_TOOL: + fail_row = entry + if ok_row is not None and fail_row is not None: + return ok_row, fail_row + await asyncio.sleep(POLL_INTERVAL_SECONDS) + _fail( + f"rows did not land within {POLL_DEADLINE_SECONDS}s " + f"(ok={ok_row is not None} fail={fail_row is not None}); " + "is the orchestrator's audit batch writer running?" + ) + raise AssertionError # unreachable; _fail exits + + +def _assert_fiction_fields_default(entry: AuditLogEntry, label: str) -> None: + """The seven deprecated fields are never served on 9.x - they must + stay at their model defaults on a REAL row.""" + checks = [ + ("query_summary", entry.query_summary, ""), + ("success", entry.success, True), + ("blocked", entry.blocked, False), + ("risk_score", entry.risk_score, 0.0), + ("latency_ms", entry.latency_ms, 0), + ("policy_violations", entry.policy_violations, []), + ("metadata", entry.metadata, {}), + ] + for name, got, want in checks: + if got != want: + _fail(f"{label}: fiction field {name} = {got!r}, expected default {want!r}") + + +async def main() -> int: + async with AxonFlow( + endpoint=AGENT_URL, + client_id=CLIENT_ID, + client_secret=SECRET, + ) as client: + # 1. Write one success-shaped and one failure-shaped row. + ok_write = await client.audit_tool_call( + AuditToolCallRequest( + tool_name=OK_TOOL, + caller_name="sdk-python-runtime-e2e", + success=True, + duration_ms=12, + ) + ) + fail_write = await client.audit_tool_call( + AuditToolCallRequest( + tool_name=FAIL_TOOL, + caller_name="sdk-python-runtime-e2e", + success=False, + error_message=FAIL_ERROR, + ) + ) + print(f"wrote rows: ok={ok_write.audit_id} fail={fail_write.audit_id}") + + # 2. Poll the real search endpoint until both rows land. + ok_row, fail_row = await _poll_for_rows(client) + + # 3. Typed assertions on the real wire shape. + if ok_row.policy_decision != "allowed": + _fail(f"success row policy_decision = {ok_row.policy_decision!r}, want 'allowed'") + if fail_row.policy_decision != "error": + _fail(f"failure row policy_decision = {fail_row.policy_decision!r}, want 'error'") + if fail_row.policy_details.get("error_message") != FAIL_ERROR: + _fail( + "failure row policy_details.error_message = " + f"{fail_row.policy_details.get('error_message')!r}, want {FAIL_ERROR!r}" + ) + if not isinstance(ok_row.response_time_ms, int) or ok_row.response_time_ms < 0: + _fail(f"response_time_ms did not parse: {ok_row.response_time_ms!r}") + _assert_fiction_fields_default(ok_row, "success row") + _assert_fiction_fields_default(fail_row, "failure row") + print( + f"typed parse OK: ok.policy_decision={ok_row.policy_decision!r} " + f"fail.policy_decision={fail_row.policy_decision!r} " + "fiction fields at defaults on both rows" + ) + + # 4. The action filter is read server-side. + filtered = await client.search_audit_logs( + AuditSearchRequest(action="error", limit=SEARCH_LIMIT) + ) + wrong = [e.policy_decision for e in filtered.entries if e.policy_decision != "error"] + if wrong: + _fail(f"action='error' returned non-error verdicts: {wrong}") + if not any(_tool_name(e) == FAIL_TOOL for e in filtered.entries): + _fail("action='error' did not return this run's failure row") + print(f"action filter OK: {len(filtered.entries)} entries, all verdict 'error'") + + # 5. request_type is a server-side no-op (#3254 deprecation claim). + noop = await client.search_audit_logs( + AuditSearchRequest(request_type=f"nonexistent-type-{_RUN_ID}", limit=SEARCH_LIMIT) + ) + if not any(_tool_name(e) == OK_TOOL for e in noop.entries): + _fail( + "request_type= filtered rows out - the server appears to " + "read request_type after all; re-check the #3254 deprecation" + ) + print( + f"request_type no-op confirmed: nonsense filter still returned " + f"{len(noop.entries)} rows including this run's" + ) + + print("PASS: audit_real_wire_fields") + return 0 + + +if __name__ == "__main__": + sys.exit(asyncio.run(main())) diff --git a/tests/fixtures/audit_search_live_v9130.json b/tests/fixtures/audit_search_live_v9130.json new file mode 100644 index 0000000..c2fd68a --- /dev/null +++ b/tests/fixtures/audit_search_live_v9130.json @@ -0,0 +1 @@ +{"entries":[{"id":"audit_1785794706_23m371y7","request_id":"","timestamp":"2026-08-03T22:05:06.947296Z","user_id":0,"user_email":"","user_role":"","client_id":"community","tenant_id":"community","org_id":"","request_type":"tool_call_audit","query":"Tool: s3254_blocked_probe","query_hash":"","policy_decision":"error","policy_details":{"caller_name":"unknown","error_message":"blocked by policy sys_sqli_or_true","success":false,"tool_name":"s3254_blocked_probe"},"provider":"","model":"","response_time_ms":0,"tokens_used":0,"cost":0,"redacted_fields":null,"error_message":"blocked by policy sys_sqli_or_true","response_sample":"","compliance_flags":null,"security_metrics":null},{"id":"audit_1785794693_wiccqrjt","request_id":"","timestamp":"2026-08-03T22:04:53.408794Z","user_id":0,"user_email":"","user_role":"","client_id":"community","tenant_id":"community","org_id":"","request_type":"tool_call_audit","query":"Tool: s3254_capture_probe","query_hash":"","policy_decision":"allowed","policy_details":{"caller_name":"unknown","success":true,"tool_name":"s3254_capture_probe"},"provider":"","model":"","response_time_ms":0,"tokens_used":0,"cost":0,"redacted_fields":null,"response_sample":"","compliance_flags":null,"security_metrics":null}],"total":2,"limit":10,"offset":0} diff --git a/tests/fixtures/wire_shape_baseline.json b/tests/fixtures/wire_shape_baseline.json index 527aba5..31e5e3f 100644 --- a/tests/fixtures/wire_shape_baseline.json +++ b/tests/fixtures/wire_shape_baseline.json @@ -180,19 +180,23 @@ "openapi_specs_sha": "0bd9256237ebbffb9c0101126da71f2c940a1695", "per_model_drift": { "AuditLogEntry": { - "note": "spec-bug-pending: #1745 \u2014 agent-api.yaml AuditLogEntry omits metadata/model/policy_violations the agent emits on every audit-log read.", + "note": "spec-bug-pending: #1745 \u2014 agent-api.yaml AuditLogEntry omits metadata/model/policy_violations the agent emits on every audit-log read. Plus getaxonflow/axonflow-enterprise#3254 additive interim: policy_decision/policy_details/response_time_ms are the REAL 9.x wire fields (platform/orchestrator/audit_logger.go AuditEntry serves them at v9.6.1 and v9.13.0); this pre-v9 spec pin predates them, so they read as sdk_only until the pin moves to v9.13.0 (PR #214).", "sdk_only": [ "data_residency", "metadata", "model", + "policy_decision", + "policy_details", "policy_violations", + "response_time_ms", "transfer_basis" ], "spec_only": [] }, "AuditSearchRequest": { - "note": "acknowledged-sdk-superset: tracked in #1745. SDK accepts decision_id/offset/override_id/policy_name as query params; agent-api.yaml AuditSearchRequest doesn't yet declare them.", + "note": "acknowledged-sdk-superset: tracked in #1745. SDK accepts decision_id/offset/override_id/policy_name as query params; agent-api.yaml AuditSearchRequest doesn't yet declare them. Plus getaxonflow/axonflow-enterprise#3254: `action` is the search filter the 9.x server actually reads (audit_read_handlers.go); this pre-v9 spec pin predates it, so it reads as sdk_only until the pin moves to v9.13.0 (PR #214).", "sdk_only": [ + "action", "decision_id", "offset", "override_id", diff --git a/tests/test_audit.py b/tests/test_audit.py index e3cead8..37bae63 100644 --- a/tests/test_audit.py +++ b/tests/test_audit.py @@ -2,7 +2,9 @@ from __future__ import annotations +import json from datetime import datetime, timezone +from pathlib import Path from typing import Any import pytest @@ -442,3 +444,169 @@ def test_audit_search_response_structure(self) -> None: assert len(response.entries) == 2 assert response.total == 100 assert response.limit == 10 + + +# Real capture: captured 2026-08-03 from an isolated community v9.13.0 stack +# (getaxonflow/axonflow tag v9.13.0 = df027c788), session 3254. Raw +# POST /api/v1/audit/search response through the agent proxy, verbatim. +REAL_CAPTURE_PATH = Path(__file__).parent / "fixtures" / "audit_search_live_v9130.json" + + +class TestAuditRealWireShape: + """#3254: the audit read model against the REAL 9.x wire shape. + + The seven fiction fields (query_summary, success, blocked, risk_score, + latency_ms, policy_violations, metadata) have never been served on the + 9.x line; the real wire carries policy_decision, policy_details and + response_time_ms. These tests pin the additive interim: real fields + populate, fiction fields stay at defaults, nothing throws. + """ + + def test_real_capture_parses_with_new_fields_populated(self) -> None: + """Deserialize the REAL captured payload, unmodified. + + Fixture provenance: captured 2026-08-03 from an isolated community + v9.13.0 stack, session 3254 (see REAL_CAPTURE_PATH comment). + """ + payload = json.loads(REAL_CAPTURE_PATH.read_text()) + response = AuditSearchResponse.model_validate(payload) + + assert response.total == 2 + assert len(response.entries) == 2 + + error_entry = response.entries[0] + allowed_entry = response.entries[1] + + # New real-wire fields are populated from the capture. + assert error_entry.policy_decision == "error" + assert error_entry.policy_details["tool_name"] == "s3254_blocked_probe" + expected_error = "blocked by policy sys_sqli_or_true" + assert error_entry.policy_details["error_message"] == expected_error + assert allowed_entry.policy_decision == "allowed" + assert allowed_entry.policy_details["success"] is True + assert allowed_entry.response_time_ms == 0 + + # The verdict set is OPEN: "error" is not in the code-documented + # allowed/blocked/redacted set and must still parse as a plain string. + assert isinstance(error_entry.policy_decision, str) + + # The seven fiction fields are ABSENT from the real wire and must + # stay at their defaults, silently. + for entry in response.entries: + assert entry.query_summary == "" + assert entry.success is True + assert entry.blocked is False + assert entry.risk_score == 0.0 + assert entry.latency_ms == 0 + assert entry.policy_violations == [] + assert entry.metadata == {} + + # Real fields that were already modeled keep parsing. + assert error_entry.request_type == "tool_call_audit" + assert error_entry.tenant_id == "community" + + def test_old_server_payload_without_new_fields_defaults(self) -> None: + """Old-server tolerance: a payload WITHOUT the three new fields + parses and the new fields default (absence-tolerant contract). + + Hand-modified capture: the real 2026-08-03 session-3254 capture with + policy_decision, policy_details and response_time_ms removed. + """ + payload = json.loads(REAL_CAPTURE_PATH.read_text()) + for raw in payload["entries"]: + del raw["policy_decision"] + del raw["policy_details"] + del raw["response_time_ms"] + + response = AuditSearchResponse.model_validate(payload) + + assert len(response.entries) == 2 + for entry in response.entries: + assert entry.policy_decision == "" + assert entry.policy_details == {} + assert entry.response_time_ms == 0 + + def test_both_fiction_and_real_fields_in_one_payload(self) -> None: + """Fictional AND real fields together parse with no collision. + + Hand-modified capture: the real 2026-08-03 session-3254 capture with + the seven fiction fields injected alongside the real ones. + """ + payload = json.loads(REAL_CAPTURE_PATH.read_text()) + fiction = { + "query_summary": "legacy summary", + "success": False, + "blocked": True, + "risk_score": 0.75, + "latency_ms": 1234, + "policy_violations": ["legacy-policy-1"], + "metadata": {"legacy": True}, + } + for raw in payload["entries"]: + raw.update(fiction) + + response = AuditSearchResponse.model_validate(payload) + + for entry in response.entries: + # Fiction fields parse when present (kept for compatibility). + assert entry.query_summary == "legacy summary" + assert entry.success is False + assert entry.blocked is True + assert entry.risk_score == 0.75 + assert entry.latency_ms == 1234 + assert entry.policy_violations == ["legacy-policy-1"] + assert entry.metadata == {"legacy": True} + # Real fields are untouched by the fiction fields' presence. + assert response.entries[0].policy_decision == "error" + assert response.entries[1].policy_decision == "allowed" + assert response.entries[0].policy_details["tool_name"] == "s3254_blocked_probe" + + def test_null_policy_details_parses(self) -> None: + """The orchestrator marshals a nil Go map/slice as JSON null - + observed live on real /api/v1/audit/search rows (session 3254, + runtime-e2e/audit_real_wire_fields). Null-tolerant, not merely + absence-tolerant. + + Hand-modified capture: the real 2026-08-03 session-3254 capture + with policy_details/metadata/policy_violations set to null. + """ + payload = json.loads(REAL_CAPTURE_PATH.read_text()) + for raw in payload["entries"]: + raw["policy_details"] = None + raw["metadata"] = None + raw["policy_violations"] = None + + response = AuditSearchResponse.model_validate(payload) + + for entry in response.entries: + assert entry.policy_details == {} + assert entry.metadata == {} + assert entry.policy_violations == [] + assert response.entries[0].policy_decision == "error" + + def test_search_request_action_field(self) -> None: + """AuditSearchRequest.action is optional and defaults to None.""" + request = AuditSearchRequest() + assert request.action is None + + request = AuditSearchRequest(action="blocked") + assert request.action == "blocked" + + @pytest.mark.asyncio + async def test_search_sends_action_filter( + self, + client: AxonFlow, + httpx_mock: HTTPXMock, + ) -> None: + """The action filter is sent on the wire; request_type is still + sent when set (deprecated but harmless, #3254).""" + httpx_mock.add_response(json={"entries": [], "total": 0, "limit": 100, "offset": 0}) + + await client.search_audit_logs( + AuditSearchRequest(action="error", request_type="legacy_filter") + ) + + sent = httpx_mock.get_requests()[-1] + body = json.loads(sent.content) + assert body["action"] == "error" + assert body["request_type"] == "legacy_filter" From ea1af6ee81f6845c5ca15ad8d33d4ef1b9e0f2e1 Mon Sep 17 00:00:00 2001 From: Saurabh Jain Date: Tue, 4 Aug 2026 00:49:39 +0200 Subject: [PATCH 2/3] fix(review): restore historic CHANGELOG entries byte-for-byte; fail loudly on a set-but-missing specs dir R3 round 1 follow-ups on #223: - CHANGELOG.md: a global editor hook had rewritten 129 em/en dashes across ~250 lines of already-published release entries (9.0.0, 8.5.1, ...) - retroactive mutation of dated release records. Restored from main byte-for-byte; the diff vs main is now exactly the 13-line Unreleased hunk (13 insertions, 0 deletions). - tests/test_wire_shape.py _specs_dir(): when AXONFLOW_OPENAPI_SPECS_DIR was SET but pointed at a missing directory, all 7 gate tests skipped silently with exit 0 - a broken CI specs checkout read as a green wire-shape gate. That case now pytest.fail()s with a clear message (7 errors, exit 1); an UNSET variable keeps the designed local-dev skip. Three guard tests pin all directions (unset skips, set+missing fails naming the variable, set+valid resolves); they run in the regular suite too. Signed-off-by: Saurabh Jain --- CHANGELOG.md | 252 +++++++++++++++++++-------------------- tests/test_wire_shape.py | 58 ++++++++- 2 files changed, 180 insertions(+), 130 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f1e6257..e5b3228 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,14 +32,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 identity as two separate wire fields instead of concatenating them into `connector_type`.** `mcp_check_input`/`mcp_check_output` (and their `check_tool_input`/`check_tool_output` aliases) gain an optional `tool` - parameter, sent alongside `connector_type` on the wire - the platform's + parameter, sent alongside `connector_type` on the wire — the platform's two-field (server, tool) identity contract. The tool name is never folded back into `connector_type`. - **LangGraph** (`mcp_tool_interceptor`) now sends `connector_type = request.server_name` and `tool = request.name` instead of `f"{server_name}.{name}"`; the default `connector_type_fn` returns the - bare `server_name`. `connector_type_fn` is the compatibility lever - a + bare `server_name`. `connector_type_fn` is the compatibility lever — a caller can restore any prior `connector_type` value (including the old concatenated form, `lambda req: f"{req.server_name}.{req.name}"`) without losing the separate `tool` field. The human-readable `statement` is now @@ -47,7 +47,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 with a custom `connector_type_fn` its shape shifts from `"{custom}(args)"` to `"{custom}.{tool}(args)"`. With the default resolver, a tool whose `server_name` is empty sends `connector_type=""`, which the platform - rejects with HTTP 400 - the call raises `ConnectorError` and is blocked + rejects with HTTP 400 — the call raises `ConnectorError` and is blocked (fail-closed); supply a `connector_type_fn` for server-less MCP tools. - **Computer Use** (`ComputerUseGovernor`) now sends the constant @@ -59,22 +59,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 migration path. **Migration.** Policies or per-connector settings matching the old - concatenated value - e.g. `connector_type == "filesystem.read_file"` or - `"computer_use.left_click"` - stop matching after upgrade. Re-scope them to + concatenated value — e.g. `connector_type == "filesystem.read_file"` or + `"computer_use.left_click"` — stop matching after upgrade. Re-scope them to match `connector_type` (the bare server name, or `"computer_use"`) together with the `tool` field (e.g. `tool == "read_file"`). **Minimum platform.** The `tool` field is consumed on `POST /api/v1/mcp/check-input` by **AxonFlow platform v9.10.0+**. On older platforms it is silently dropped and identity degrades to the bare - `connector_type` - upgrade the platform to v9.10.0+ before adopting this SDK + `connector_type` — upgrade the platform to v9.10.0+ before adopting this SDK major. Response-plane (`check-output`) `tool` scoping requires **AxonFlow platform v9.11.0+**; until then the SDK sends it forward-compatibly and older platforms ignore it. ### Added -- **`AuditToolCallRequest.caller_name`** - identifies which client made a +- **`AuditToolCallRequest.caller_name`** — identifies which client made a non-LLM tool call (e.g. `claude_code`, `codex`, `cursor`, `openclaw`). Replaces the misleadingly-named `tool_type` field, which every real caller actually used to identify the calling client rather than any property of the @@ -82,7 +82,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 server resolves `caller_name` if supplied, else the legacy `tool_type`, else a default. -## [8.5.1] - 2026-07-09 - Interceptor sync bridge + async-client detection + example fixes +## [8.5.1] - 2026-07-09 — Interceptor sync bridge + async-client detection + example fixes Hostile-testing sweep ahead of the BukuWarung integration (getaxonflow/axonflow-enterprise#2861). @@ -94,7 +94,7 @@ Hostile-testing sweep ahead of the BukuWarung integration gemini, ollama) ran the async governance check with `loop.run_until_complete`, which raises `RuntimeError: This event loop is already running` whenever the caller sits inside a running loop (FastAPI - handler, Jupyter, an async app driving a sync provider client) - the + handler, Jupyter, an async app driving a sync provider client) — the async-adapter-bypass class: the governance check crashed instead of completing. The shared `run_coroutine_sync` bridge now executes governance on one persistent background loop (daemon thread) and blocks @@ -112,35 +112,35 @@ Hostile-testing sweep ahead of the BukuWarung integration `quickstart`, `gateway_mode` and `openai_integration` passed hardcoded non-JWT literals and 401'd. All three now read `AXONFLOW_USER_TOKEN`. `gateway_mode`'s blocked-request demo uses a stacked-SQLi query (blocked - on every stack posture - PII policies default to redact, not block) and + on every stack posture — PII policies default to redact, not block) and exits non-zero if unexpectedly approved; `openai_integration` prints the outcome of its policy-block probe instead of ending silently. ### Added -- `runtime-e2e/interceptor_sync_bridge/` - live-agent assertion driving a +- `runtime-e2e/interceptor_sync_bridge/` — live-agent assertion driving a REAL `openai.OpenAI` client through `wrap_openai_client` from inside a running loop and from plain sync code (blocked verdict enforced both ways, no RuntimeError), plus async-detection assertions for `AsyncOpenAI`. -## [8.5.0] - 2026-06-09 - Decision Mode PEP: decide → fulfill → forward +## [8.5.0] - 2026-06-09 — Decision Mode PEP: decide → fulfill → forward Adds the SDK analog of the platform PEP client (`platform/shared/pep`, -ADR-056, epic #2563). A Policy Enforcement Point now follows one path - -**decide → fulfill → forward** - and the SDK makes the engine-fulfillable +ADR-056, epic #2563). A Policy Enforcement Point now follows one path — +**decide → fulfill → forward** — and the SDK makes the engine-fulfillable obligation contract impossible to misuse: there is **no local redaction path**, so a `redact_pii` obligation can only be discharged by round-tripping content through the engine endpoint the obligation names. ### Added -- **`AxonFlow.decide(DecideRequest)` / `SyncAxonFlow.decide`** - the PDP step. +- **`AxonFlow.decide(DecideRequest)` / `SyncAxonFlow.decide`** — the PDP step. `POST /api/v1/decide` returns a `DecideResponse` whose `obligations` is a list of self-describing `Obligation`s. Decision Mode auth is HTTP Basic (org:license), which the client already sends; wrong/demo credentials are refused with `AuthenticationError`. -- **`AxonFlow.fulfill_request(decision, statement)`** - discharges every +- **`AxonFlow.fulfill_request(decision, statement)`** — discharges every request-phase `redact_pii` obligation by POSTing the statement to the engine's `check-input` endpoint and returning the **engine-redacted** statement. Fails closed with `ObligationNotFulfillableError` when an @@ -148,7 +148,7 @@ content through the engine endpoint the obligation names. PEP is not holding, names an endpoint the client will not call, the engine call fails, or the engine reports `redaction_evaluated=false`. Never redacts locally. -- **`AxonFlow.decide_and_fulfill(DecideRequest)`** - the blessed one-call path +- **`AxonFlow.decide_and_fulfill(DecideRequest)`** — the blessed one-call path (decide, then fulfill any request-phase obligation); fail-closed by construction. - **New types**: `DecideRequest`, `DecideResponse`, `Obligation`, @@ -160,10 +160,10 @@ content through the engine endpoint the obligation names. endpoint-path constants). - **`redacted` / `redacted_statement` / `redaction_evaluated` on `MCPCheckInputResponse`** and **`redaction_evaluated` on - `MCPCheckOutputResponse`** - the request-redaction contract fields the agent + `MCPCheckOutputResponse`** — the request-redaction contract fields the agent emits (ADR-056). A PEP fulfilling an obligation fails closed when `redaction_evaluated` is false. -- **`content_type` on `MCPCheckInputRequest` / `mcp_check_input(...)`** - +- **`content_type` on `MCPCheckInputRequest` / `mcp_check_input(...)`** — selects the request-redaction detector (defaults to `text/plain`). ### Notes @@ -173,20 +173,20 @@ content through the engine endpoint the obligation names. platforms). The wire-shape baseline records the new fields as an acknowledged SDK superset pending the OpenAPI spec catching up. -## [8.4.0] - 2026-05-30 - Decision request context + Pasal 56(b) transfer basis +## [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`** - +- **`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 +- **`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`, @@ -202,7 +202,7 @@ Targets AxonFlow platform **v8.5.0**. 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 +## [8.3.0] - 2026-05-27 — Indonesia PII category + cross-border audit fields ### Added @@ -218,7 +218,7 @@ Targets AxonFlow platform **v8.5.0**. demonstrates NIK detection, audit log querying with cross-border fields, and policy filtering by the new `pii-indonesia` category. -## [8.2.0] - 2026-05-23 - `create_hitl_request` for explicit HITL row creation +## [8.2.0] - 2026-05-23 — `create_hitl_request` for explicit HITL row creation Enables agent-framework plugins (Google ADK, n8n, OpenAI Agents SDK) to implement the full 4-step HITL approval flow against AxonFlow: @@ -246,7 +246,7 @@ new `notify_url` outbound-webhook field fields: `client_id`, `original_query`, `request_type`. Optional fields cover policy attribution, severity, compliance framework, and an expiry override. `X-Org-ID` / `X-Tenant-ID` are derived from the SDK - client's configured credentials by the platform's auth middleware - + client's configured credentials by the platform's auth middleware — callers do not pass them through this method. - **`notify_url` field on `HITLCreateInput` and `HITLApprovalRequest` (forward-look).** Accepted on the wire today but platform-side @@ -270,12 +270,12 @@ existing `get_hitl_request` / `approve_hitl_request` / Requires AxonFlow platform >= 8.1.0 for `notify_url` webhook delivery and `Idempotency-Key` request deduplication. -## [8.1.0] - 2026-05-22 - `X-Client-ID` header on every outbound request + `org_id` in telemetry heartbeat +## [8.1.0] - 2026-05-22 — `X-Client-ID` header on every outbound request + `org_id` in telemetry heartbeat Companion release to the v9 identity cleanup on the platform. Every governed request now carries an `X-Client-ID: ` header alongside the existing Basic Auth + `X-Axonflow-Client` headers. -Value matches the SDK's Basic Auth username - smart default `community` +Value matches the SDK's Basic Auth username — smart default `community` when no `client_id` is configured. ### Added @@ -285,7 +285,7 @@ when no `client_id` is configured. middleware overwrites the header with its own auth-derived value, so caller-supplied values are harmless (no spoofing surface). - **`org_id` field in the telemetry heartbeat body.** Brings the Python - SDK telemetry up to parity with the platform - every heartbeat now + SDK telemetry up to parity with the platform — every heartbeat now identifies which deployment-organization emitted it. Two sources in precedence order: 1. The `ORG_ID` env var when set (the explicit configuration @@ -303,7 +303,7 @@ when no `client_id` is configured. - **Telemetry-enabled log line** softened from "anonymous telemetry enabled" to "telemetry enabled" to stay coherent with the `org_id` - addition - the configured `ORG_ID` on self-hosted deployments is not + addition — the configured `ORG_ID` on self-hosted deployments is not anonymized; only the `instance_id` and `cs_` Community SaaS identifier remain anonymous-by-design. @@ -311,23 +311,23 @@ when no `client_id` is configured. - Backward-compatible against v8 and v9 platforms: v8 agents ignore the unknown header; v9 agents derive identity from Basic Auth regardless. -- `org_id` is an additive field - older receivers ignore it cleanly, +- `org_id` is an additive field — older receivers ignore it cleanly, legacy SDK builds keep working unchanged. - No SDK config changes. No removed fields. No changed defaults. -## [8.0.0] - 2026-05-09 - Decision History API + policy_version recorded on every decision + telemetry simplification +## [8.0.0] - 2026-05-09 — Decision History API + policy_version recorded on every decision + telemetry simplification **Major release.** The headline feature is the new decision-history client API: `list_decisions` for paging through recorded decisions, alongside the -`get_decision_explain` method shipped in v7.4.0 - callers can now both list +`get_decision_explain` method shipped in v7.4.0 — callers can now both list and drill in. Bundled into a major because the v8 line also tightens the -telemetry contract - see `Removed` at the bottom of this entry for that. +telemetry contract — see `Removed` at the bottom of this entry for that. ### Added - **`client.list_decisions(opts)` method.** Pages over recorded decision history from the orchestrator, mirroring `GET /api/v1/decisions`. - Companion to the v7.4.0 `get_decision_explain` method - callers can + Companion to the v7.4.0 `get_decision_explain` method — callers can now both list and drill in. Already shipped on `main` and graduated into the v8.0 line with this release. See type `ListDecisionsOptions` and `DecisionListItem` in `axonflow.decisions`. @@ -339,7 +339,7 @@ telemetry contract - see `Removed` at the bottom of this entry for that. `AxonFlow(..., telemetry=False)` will raise `TypeError` at construction time. Migration: - If you were using it to disable telemetry, set - `AXONFLOW_TELEMETRY=off` in the environment instead - that's the + `AXONFLOW_TELEMETRY=off` in the environment instead — that's the sole opt-out lever as of v8.0. - If you were using it to force-enable, the default is now ON for every mode so the argument is no longer needed. @@ -350,9 +350,9 @@ telemetry contract - see `Removed` at the bottom of this entry for that. ### Telemetry - **`AXONFLOW_TELEMETRY=off` is the sole opt-out.** `AxonFlow(..., telemetry=...)` keyword argument + `AxonFlowConfig.telemetry` field both removed; sandbox-mode clients now fire on the same 7-day heartbeat schedule as production (was suppressed pre-v8), tagged `stream="sandbox"` so dev pings stay distinguishable. -- **Heartbeat payload v1 schema additions** on the wire: new `telemetry_type` and `deployment_mode` fields. Existing receivers continue working unchanged - strictly additive. +- **Heartbeat payload v1 schema additions** on the wire: new `telemetry_type` and `deployment_mode` fields. Existing receivers continue working unchanged — strictly additive. -## [7.1.0] - 2026-05-06 - X-Axonflow-Client header + scope-aware license validation +## [7.1.0] - 2026-05-06 — X-Axonflow-Client header + scope-aware license validation **Companion release to platform v7.7.0.** The Python SDK now sends an `X-Axonflow-Client` identification header on every governed request, which @@ -381,23 +381,23 @@ license token's audience claim per the license matrix. ### Companion releases (same day) -- **Platform v7.7.0** - V1 SaaS Plugin Pro launch, license matrix, +- **Platform v7.7.0** — V1 SaaS Plugin Pro launch, license matrix, per-tenant tier resolution, GDPR right-to-erasure ([CHANGELOG](https://github.com/getaxonflow/axonflow/blob/main/CHANGELOG.md)) - **Go SDK v7.1.0** / **TypeScript SDK v7.1.0** / - **Java SDK v7.1.0** - same `X-Axonflow-Client` injection -- **Plugins** - Claude Code / Cursor / Codex v1.2.0; OpenClaw v2.2.0 + **Java SDK v7.1.0** — same `X-Axonflow-Client` injection +- **Plugins** — Claude Code / Cursor / Codex v1.2.0; OpenClaw v2.2.0 with Pro license token paste activating Pro features axonflow-sdk-rust remains at v0.1.0 (preview); SDK-Rust will gain the header in a future preview release. -## [7.0.0] - 2026-04-29 - Production, quality, and security hardening - upgrade encouraged +## [7.0.0] - 2026-04-29 — Production, quality, and security hardening — upgrade encouraged -**Upgrade strongly recommended.** Over the past month we've shipped substantial production, quality, and security hardening across the AxonFlow SDKs and platform - upgrade to the latest major for a more secure, reliable, and bug-free experience. +**Upgrade strongly recommended.** Over the past month we've shipped substantial production, quality, and security hardening across the AxonFlow SDKs and platform — upgrade to the latest major for a more secure, reliable, and bug-free experience. **Security highlights from this release cycle:** -- **Webhook signing-key now exposed by SDK response type** (this release). The `secret` (HMAC-SHA256) field on `WebhookSubscription` - returned by `create_webhook` - was missing from the SDK type, so callers had no way to retrieve the signing key and webhook signature verification was effectively un-implementable. The field is now wired through end-to-end. Documented in [`GHSA-7f4h-6264-89fr`](https://github.com/getaxonflow/axonflow-sdk-python/security/advisories/GHSA-7f4h-6264-89fr). +- **Webhook signing-key now exposed by SDK response type** (this release). The `secret` (HMAC-SHA256) field on `WebhookSubscription` — returned by `create_webhook` — was missing from the SDK type, so callers had no way to retrieve the signing key and webhook signature verification was effectively un-implementable. The field is now wired through end-to-end. Documented in [`GHSA-7f4h-6264-89fr`](https://github.com/getaxonflow/axonflow-sdk-python/security/advisories/GHSA-7f4h-6264-89fr). - **`DO_NOT_TRACK` opt-out removed in favor of `AXONFLOW_TELEMETRY=off`** (this release). `DO_NOT_TRACK` was unreliable because host CLIs and runtimes commonly inject `DO_NOT_TRACK=1` regardless of user intent; an explicit AxonFlow-scoped opt-out is the only signal we honor now. - **Nightly integration in strict mode against `try.getaxonflow.com`** (this release). A canary that catches platform-side regressions affecting the SDK before they reach a release; failures auto-file a GitHub issue. @@ -414,54 +414,54 @@ Major release across the AxonFlow SDK family. Companion releases ship the same d ### Changed -- **Telemetry switched to a 7-day delivered-heartbeat.** At most one anonymous ping per environment every 7 days, with the stamp advanced only after the POST returns 2xx - a transient network failure doesn't silence telemetry until the next window. Concurrent threads are de-duplicated by an in-flight gate. Restricted environments where no cache dir is available (e.g. AWS Lambda) fall back transparently to the previous "one ping per process" behavior. -- `StaticPolicy` and `PolicyVersion` now serialize wire fields in snake_case to match the OpenAPI spec (`created_at`, `updated_at`, `organization_id`, `tenant_id`, `has_override`, `changed_at`, `changed_by`, `change_type`). camelCase aliases remain accepted on input via `validation_alias=AliasChoices(...)`. **Round-trip identity is no longer preserved** for callers that built these models from camelCase dicts - code that signs, hashes, or byte-compares serialized model bodies will see a one-time shape change. +- **Telemetry switched to a 7-day delivered-heartbeat.** At most one anonymous ping per environment every 7 days, with the stamp advanced only after the POST returns 2xx — a transient network failure doesn't silence telemetry until the next window. Concurrent threads are de-duplicated by an in-flight gate. Restricted environments where no cache dir is available (e.g. AWS Lambda) fall back transparently to the previous "one ping per process" behavior. +- `StaticPolicy` and `PolicyVersion` now serialize wire fields in snake_case to match the OpenAPI spec (`created_at`, `updated_at`, `organization_id`, `tenant_id`, `has_override`, `changed_at`, `changed_by`, `change_type`). camelCase aliases remain accepted on input via `validation_alias=AliasChoices(...)`. **Round-trip identity is no longer preserved** for callers that built these models from camelCase dicts — code that signs, hashes, or byte-compares serialized model bodies will see a one-time shape change. ### Added -- `ClientRequest.skip_llm` - optional flag to run policy evaluation only and return without invoking the LLM. +- `ClientRequest.skip_llm` — optional flag to run policy evaluation only and return without invoking the LLM. ### Fixed - The `DO_NOT_TRACK=1 is deprecated.` `logger.warning` is no longer emitted on every client construction when `DO_NOT_TRACK=1` is set. -## [6.9.0] - 2026-04-28 - list_providers() + LLMProvider full shape +## [6.9.0] - 2026-04-28 — list_providers() + LLMProvider full shape Minor release. New LLM-provider listing API + pagination wrappers, plus full surfacing of the `LLMProvider` wire shape that previous SDK versions silently dropped on parse. Coordinated cycle: TypeScript v6.2.0 / Go v6.0.0 (major: see SDKCompatibility breaking type change in that release) / Java v6.2.0 ship same day. ### Added -- **`client.list_providers()`** - list configured LLM providers and their health status. Calls `GET /api/v1/llm-providers`, returns a list of `LLMProvider` records (each with optional `LLMProviderHealth`). Supports `provider_type` and `enabled` filters. Both async and sync entry points. Closes the parity gap with the Java SDK and the in-platform listing endpoint that's been live since v4.4. +- **`client.list_providers()`** — list configured LLM providers and their health status. Calls `GET /api/v1/llm-providers`, returns a list of `LLMProvider` records (each with optional `LLMProviderHealth`). Supports `provider_type` and `enabled` filters. Both async and sync entry points. Closes the parity gap with the Java SDK and the in-platform listing endpoint that's been live since v4.4. - **`LLMProvider`** now surfaces the full provider shape: `endpoint`, `model`, `region`, `rate_limit`, `timeout_seconds`, and `settings`. Previously these fields were silently dropped on parse, so deployments couldn't introspect provider configuration via the SDK. -- **`client.list_providers_paged()`** - same arguments as `list_providers()` plus `page` / `page_size`, returns the full `LLMProviderListResponse` with `pagination` metadata. Use this when you need to walk multi-page responses or display pagination controls. -- **`client.list_all_providers()`** - convenience wrapper that walks every page (default `page_size=100`, the server-side cap) and returns the combined list. Closes the silent-truncation-at-20-providers bug in `list_providers()`. +- **`client.list_providers_paged()`** — same arguments as `list_providers()` plus `page` / `page_size`, returns the full `LLMProviderListResponse` with `pagination` metadata. Use this when you need to walk multi-page responses or display pagination controls. +- **`client.list_all_providers()`** — convenience wrapper that walks every page (default `page_size=100`, the server-side cap) and returns the combined list. Closes the silent-truncation-at-20-providers bug in `list_providers()`. ### Fixed - A single malformed `health` snapshot on one provider in a `list_providers()` response no longer crashes the entire call. The bad provider's `health` is set to `None` and a warning is logged; well-formed siblings parse normally. - `health_check_detailed()` no longer crashes with `AttributeError: 'dict' object has no attribute 'split'` when the platform returns per-language `min_sdk_version` and `recommended_sdk_version` maps (the actual on-the-wire shape since v4.8.0). `SDKCompatibility` now declares both fields as `dict[str, str]` and exposes `min_sdk_version_for(language)` / `recommended_sdk_version_for(language)` helpers, matching the Java + TypeScript SDKs. Legacy bare-string responses from older platforms are normalised to a python-keyed dict so callers don't have to branch on platform version. -- **`examples/openai_integration.py`** - replaced two bare `except Exception:` blocks with narrow handlers (`openai.OpenAIError` / `PolicyViolationError`). The old broad catch masked SDK regressions, schema drift, and governance failures. -- **`examples/wcp_retry_idempotency.py`** - env-var name corrected from `AXONFLOW_BASE_URL` to `AXONFLOW_AGENT_URL` to match the rest of the SDK and the other examples. +- **`examples/openai_integration.py`** — replaced two bare `except Exception:` blocks with narrow handlers (`openai.OpenAIError` / `PolicyViolationError`). The old broad catch masked SDK regressions, schema drift, and governance failures. +- **`examples/wcp_retry_idempotency.py`** — env-var name corrected from `AXONFLOW_BASE_URL` to `AXONFLOW_AGENT_URL` to match the rest of the SDK and the other examples. -## [6.8.0] - 2026-04-25 - Plugin Batch 1 explainability fields on MCP responses +## [6.8.0] - 2026-04-25 — Plugin Batch 1 explainability fields on MCP responses -Minor release. Surfaces fields the AxonFlow agent has emitted since v7.1.0 (Plugin Batch 1) but the SDK didn't declare. Pure field-additions on existing methods - no new SDK methods, no breaking changes. Documented in OpenAPI via platform v7.4.3. +Minor release. Surfaces fields the AxonFlow agent has emitted since v7.1.0 (Plugin Batch 1) but the SDK didn't declare. Pure field-additions on existing methods — no new SDK methods, no breaking changes. Documented in OpenAPI via platform v7.4.3. Coordinated cycle: TypeScript v6.1.0 / Go v5.8.0 / Java v6.1.0 ship same day with the same field set. ### Added - **`MCPCheckInputResponse`** gains 5 optional Plugin Batch 1 fields: - - `decision_id: str | None` - audit correlator + - `decision_id: str | None` — audit correlator - `risk_level: Literal["low", "medium", "high", "critical"] | None` - - `policy_matches: list[ExplainPolicy] | None` - per-policy explainability records - - `override_available: bool | None` - whether session override is permitted for the matched policies - - `override_existing_id: str | None` - already-active override consumed by this decision (if any) + - `policy_matches: list[ExplainPolicy] | None` — per-policy explainability records + - `override_available: bool | None` — whether session override is permitted for the matched policies + - `override_existing_id: str | None` — already-active override consumed by this decision (if any) - **`MCPCheckOutputResponse`** gains 3 optional fields: - `decision_id` - `policy_matches: list[ExplainPolicy] | None` - - `redacted_message: str | None` - text-redaction counterpart to `redacted_data` (used when the connector returned a string message rather than tabular rows; e.g. execute-style responses) -- **`ExplainPolicy`** is now re-exported from `axonflow.types` (it was previously only in `axonflow.decisions`). Same Pydantic model - Python's snake_case convention naturally aligns wire-shape and SDK types, so no separate model is needed. + - `redacted_message: str | None` — text-redaction counterpart to `redacted_data` (used when the connector returned a string message rather than tabular rows; e.g. execute-style responses) +- **`ExplainPolicy`** is now re-exported from `axonflow.types` (it was previously only in `axonflow.decisions`). Same Pydantic model — Python's snake_case convention naturally aligns wire-shape and SDK types, so no separate model is needed. All fields default to `None`. Pre-v7.1.0 platforms return `None` for every field; callers should treat absence as "context not available" rather than an error. @@ -469,43 +469,43 @@ All fields default to `None`. Pre-v7.1.0 platforms return `None` for every field `client.explain_decision(decision_id)` and the full `ExplainRule` / `DecisionExplanation` type surface are tracked separately as feature work. This release ships only field-surfacing on existing methods. -## [6.7.0] - 2026-04-25 - Wire-shape canonicalization +## [6.7.0] - 2026-04-25 — Wire-shape canonicalization -Minor release. Purely additive - new fields default to `None`, deprecated aliases preserved for compile-time compat. Coordinated with TypeScript v6.0.0 / Java v6.0.0 / Go v5.7.0 SDK releases. The wire-shape contract gate's pinned OpenAPI spec SHA bumps with the platform v7.4.2 spec corrections; one baseline drift entry (`DynamicPolicyInfo`) auto-resolves. +Minor release. Purely additive — new fields default to `None`, deprecated aliases preserved for compile-time compat. Coordinated with TypeScript v6.0.0 / Java v6.0.0 / Go v5.7.0 SDK releases. The wire-shape contract gate's pinned OpenAPI spec SHA bumps with the platform v7.4.2 spec corrections; one baseline drift entry (`DynamicPolicyInfo`) auto-resolves. ### Added -- **`WebhookSubscription.secret`** - HMAC-SHA256 signing key now exposed on the response from `create_webhook`. Required to verify the `X-AxonFlow-Signature` header on inbound webhook deliveries; without it, callers couldn't validate payload authenticity. Also adds `org_id` and `tenant_id` (ownership scoping). +- **`WebhookSubscription.secret`** — HMAC-SHA256 signing key now exposed on the response from `create_webhook`. Required to verify the `X-AxonFlow-Signature` header on inbound webhook deliveries; without it, callers couldn't validate payload authenticity. Also adds `org_id` and `tenant_id` (ownership scoping). - **`StepGateRequest`** carries `tokens_in`, `tokens_out`, `cost_usd` so budget-based policies can evaluate gate-time cost estimates. -- **`StepGateResponse.decision_id`** - unique audit correlator that links a gate response to its audit row. -- **`ListWorkflowsResponse.limit` / `offset`** - pagination echo, surfaced on the response. -- **`StaticPolicy.policy_id` / `priority`** - wire-canonical fields surfaced. -- **`CreateStaticPolicyRequest.priority` / `tags`** and **`UpdateStaticPolicyRequest.priority` / `tags`** - match the spec. -- **`UpdatePlanRequest.metadata`** - accept arbitrary plan metadata, opaque to the platform. -- **`UsageBreakdownItem.group_by`** - dimension name (provider/model/agent/etc.) is now exposed on each item. -- **`BudgetAlert.acknowledged`** - alert dismissal flag. -- **`Budget.org_id` / `tenant_id`** - ownership scoping. +- **`StepGateResponse.decision_id`** — unique audit correlator that links a gate response to its audit row. +- **`ListWorkflowsResponse.limit` / `offset`** — pagination echo, surfaced on the response. +- **`StaticPolicy.policy_id` / `priority`** — wire-canonical fields surfaced. +- **`CreateStaticPolicyRequest.priority` / `tags`** and **`UpdateStaticPolicyRequest.priority` / `tags`** — match the spec. +- **`UpdatePlanRequest.metadata`** — accept arbitrary plan metadata, opaque to the platform. +- **`UsageBreakdownItem.group_by`** — dimension name (provider/model/agent/etc.) is now exposed on each item. +- **`BudgetAlert.acknowledged`** — alert dismissal flag. +- **`Budget.org_id` / `tenant_id`** — ownership scoping. - **`UsageRecord`** gains `created_at`, `success`, `error_message`, `latency_ms`, `team_id`, `tenant_id`, `user_id`, `workflow_id` to match the wire. Legacy `timestamp` field is `DEPRECATED` (orphan read; the wire emits `created_at`). -- **`WorkflowStatusResponse.metadata`** - arbitrary workflow metadata. -- **`CreateWorkflowResponse.started_at`** - wire-canonical timestamp. Legacy `created_at` and `source` are `DEPRECATED` (orphan reads on the create response). -- **`ExecutionSnapshot.retry_count`** - number of retry attempts on a step. -- **`Finding.article`** - regulatory article reference (e.g. MAS FEAT principle number). -- **`PolicyOverride.id` / `enabled_override`** - wire-canonical fields. `active` is `DEPRECATED` (orphan read). -- **`PolicyVersion.id` / `policy_id` / `change_summary` / `snapshot`** - match the wire shape (versions are immutable snapshots, not before/after diffs). `change_description`, `previous_values`, `new_values` are `DEPRECATED` orphan reads. -- **`DynamicPolicyMatch.message`** - wire-canonical name. `reason` is `DEPRECATED` (orphan read). -- **`ExfiltrationCheckInfo.exceeded` / `limit_type`** - match the wire. `within_limits` is `DEPRECATED`. -- **`CancelPlanResponse.success`** - wire-canonical boolean. `message` is `DEPRECATED` (orphan read). +- **`WorkflowStatusResponse.metadata`** — arbitrary workflow metadata. +- **`CreateWorkflowResponse.started_at`** — wire-canonical timestamp. Legacy `created_at` and `source` are `DEPRECATED` (orphan reads on the create response). +- **`ExecutionSnapshot.retry_count`** — number of retry attempts on a step. +- **`Finding.article`** — regulatory article reference (e.g. MAS FEAT principle number). +- **`PolicyOverride.id` / `enabled_override`** — wire-canonical fields. `active` is `DEPRECATED` (orphan read). +- **`PolicyVersion.id` / `policy_id` / `change_summary` / `snapshot`** — match the wire shape (versions are immutable snapshots, not before/after diffs). `change_description`, `previous_values`, `new_values` are `DEPRECATED` orphan reads. +- **`DynamicPolicyMatch.message`** — wire-canonical name. `reason` is `DEPRECATED` (orphan read). +- **`ExfiltrationCheckInfo.exceeded` / `limit_type`** — match the wire. `within_limits` is `DEPRECATED`. +- **`CancelPlanResponse.success`** — wire-canonical boolean. `message` is `DEPRECATED` (orphan read). - **`PlanResponse`** gains the wire top-level fields `success`, `version`, `result`, `error`, `workflow_execution_id`, `policy_info`. -- **`ResumePlanResponse.result`** - final aggregated result (canonical wire field). Six fields (`workflow_id`, `message`, `step_result`, `next_step`, `next_step_name`, `total_steps`) are now `DEPRECATED` - none of them were populated by the resume decoder against the actual server response. -- **`MCPCheckInputRequest.client_id` / `tenant_id` / `user_id` / `user_role` / `user_token`** and **`MCPCheckOutputRequest.client_id` / `tenant_id` / `user_id` / `user_token`** - match the spec scoping fields. +- **`ResumePlanResponse.result`** — final aggregated result (canonical wire field). Six fields (`workflow_id`, `message`, `step_result`, `next_step`, `next_step_name`, `total_steps`) are now `DEPRECATED` — none of them were populated by the resume decoder against the actual server response. +- **`MCPCheckInputRequest.client_id` / `tenant_id` / `user_id` / `user_role` / `user_token`** and **`MCPCheckOutputRequest.client_id` / `tenant_id` / `user_id` / `user_token`** — match the spec scoping fields. ### Notes The above is an audit-driven sweep against the wire-shape contract gate. All changes are additive (new fields default to `None`) or `DEPRECATED`-marked alias fields kept for compile-time compat. Removal scheduled for v7. -The earlier overnight claim that "Python baseline is clean" was wrong - that was a key-name confusion (Python uses `per_model_drift`, the others use `per_type_drift`); a proper audit found 36 drift entries similar in pattern to the TS+Go SDK sweeps. After this sweep, 26 drift entries remain (mostly `DEPRECATED` aliases retained for source-compat + Cat C entries to file separately + Plugin Batch 1 SDK additions pending platform-side spec coverage). +The earlier overnight claim that "Python baseline is clean" was wrong — that was a key-name confusion (Python uses `per_model_drift`, the others use `per_type_drift`); a proper audit found 36 drift entries similar in pattern to the TS+Go SDK sweeps. After this sweep, 26 drift entries remain (mostly `DEPRECATED` aliases retained for source-compat + Cat C entries to file separately + Plugin Batch 1 SDK additions pending platform-side spec coverage). -Two platform-side spec corrections filed alongside this work, for issues the audit surfaced where the spec was wrong (server emits the SDK's name): `AISystemRegistry.materiality_classification` and `DynamicPolicyInfo` schema. No SDK change for those - the SDK is correct. +Two platform-side spec corrections filed alongside this work, for issues the audit surfaced where the spec was wrong (server emits the SDK's name): `AISystemRegistry.materiality_classification` and `DynamicPolicyInfo` schema. No SDK change for those — the SDK is correct. ## [6.6.2] - 2026-04-25 @@ -520,7 +520,7 @@ Two platform-side spec corrections filed alongside this work, for issues the aud so install/upgrade worked fine; the drift only affected code that read `axonflow.__version__` at runtime (telemetry self-identification, version-gated feature detection in user code, log output). No functional - changes - this release ships the same binary behavior as v6.6.1 with + changes — this release ships the same binary behavior as v6.6.1 with the runtime version correctly set to `6.6.2`. ## [6.6.1] - 2026-04-24 @@ -553,7 +553,7 @@ Two platform-side spec corrections filed alongside this work, for issues the aud ### Added -- **Rich `ApproveStepResponse` / `RejectStepResponse`** - both pydantic models +- **Rich `ApproveStepResponse` / `RejectStepResponse`** — both pydantic models now carry the same shape as the step-gate response: `decision` resolves to `"allow"` / `"block"`, `retry_context` mirrors the gate response retry state, `approved_by` / `approved_at` / `rejected_by` / `rejected_at` carry reviewer @@ -561,17 +561,17 @@ Two platform-side spec corrections filed alongside this work, for issues the aud `policies_matched` reconstructs the governance trail. Legacy fields (`workflow_id`, `step_id`, `status`) remain for back-compat; every new field is optional so older server responses still deserialize cleanly. -- **`plan_id` on approve/reject responses** - populated when the response +- **`plan_id` on approve/reject responses** — populated when the response comes from the MAP plan-scoped endpoint; empty on WCP plane responses. Same models work across both endpoints. -- **`get_pending_plan_approvals`** - new client method that lists MAP-plane +- **`get_pending_plan_approvals`** — new client method that lists MAP-plane pending approvals (`GET /api/v1/plans/approvals/pending`), the counterpart of `get_pending_approvals` for the WCP plane. Accepts an optional `plan_id` argument so reviewer tools can scope the listing to one plan. Available on Evaluation+ licenses (same tier gate as the MAP step approve/reject endpoints). Sync wrapper exposed via `SyncAxonFlow.get_pending_plan_approvals`. -- **`PendingApproval.plan_id`** - populated on MAP-plane entries, `None` on +- **`PendingApproval.plan_id`** — populated on MAP-plane entries, `None` on WCP-plane entries. Mirrors the approve/reject asymmetry. `PendingApproval` also gains `step_index`, `decision`, `decision_reason`, `policies_matched`, `step_input`, and `approval_status` so reviewer tools can render the full @@ -579,14 +579,14 @@ Two platform-side spec corrections filed alongside this work, for issues the aud ### Fixed -- **`approve_step` / `reject_step` / `get_pending_approvals` endpoint URLs** - +- **`approve_step` / `reject_step` / `get_pending_approvals` endpoint URLs** — all three previously targeted non-existent paths under `/api/v1/workflow-control/` and would fail against a real AxonFlow server. Corrected to the canonical `/api/v1/workflows/{id}/steps/{step_id}/(approve|reject)` and `/api/v1/workflows/approvals/pending` routes. Customers using these methods against a live deployment were receiving 404s; this release makes them work. -- **`PendingApprovalsResponse` field names aligned with the wire shape** - +- **`PendingApprovalsResponse` field names aligned with the wire shape** — the model previously declared `approvals` and `total`, which never matched the server response (`pending_approvals` and `count`). Renamed fields. Callers that read `response.approvals` or `response.total` must update to @@ -594,77 +594,77 @@ Two platform-side spec corrections filed alongside this work, for issues the aud ### Deprecated -- `DO_NOT_TRACK=1` as an AxonFlow telemetry opt-out - scheduled for removal after 2026-05-05 in the next major release. Use `AXONFLOW_TELEMETRY=off` instead. The SDK emits a one-line migration warning when `DO_NOT_TRACK=1` is the active control and `AXONFLOW_TELEMETRY=off` is not also set. +- `DO_NOT_TRACK=1` as an AxonFlow telemetry opt-out — scheduled for removal after 2026-05-05 in the next major release. Use `AXONFLOW_TELEMETRY=off` instead. The SDK emits a one-line migration warning when `DO_NOT_TRACK=1` is the active control and `AXONFLOW_TELEMETRY=off` is not also set. ### Unchanged - `approve_step(workflow_id, step_id)` / `reject_step(workflow_id, step_id, reason)` - method signatures are unchanged - only the response fields grew. + method signatures are unchanged — only the response fields grew. ## [6.5.0] - 2026-04-21 ### Added -- **`retry_context` and `idempotency_key` support on the step gate** - +- **`retry_context` and `idempotency_key` support on the step gate** — `StepGateResponse` now carries a `retry_context` object on every gate call with the true `(workflow_id, step_id)` lifecycle: `gate_count`, `completion_count`, - `prior_completion_status` (`PriorCompletionStatus` enum - + `prior_completion_status` (`PriorCompletionStatus` enum — `NONE` / `COMPLETED` / `GATED_NOT_COMPLETED`), `prior_output_available`, `prior_output`, `prior_completion_at`, `first_attempt_at`, `last_attempt_at`, `last_decision`, and `idempotency_key`. Prefer these fields to the legacy `cached` / `decision_source` fields. -- **`client.step_gate(..., include_prior_output=False)`** - new keyword-only argument. +- **`client.step_gate(..., include_prior_output=False)`** — new keyword-only argument. When `True`, the SDK sends `?include_prior_output=true` on the gate call and `retry_context.prior_output` is populated when a prior `/complete` has landed. Existing callers that omit the kwarg behave unchanged. -- **`StepGateRequest.idempotency_key`** - caller-supplied opaque business-level key +- **`StepGateRequest.idempotency_key`** — caller-supplied opaque business-level key (max 255 chars). Immutable once recorded on the first gate call for a `(workflow_id, step_id)`; subsequent gate/complete calls must pass the same key. -- **`MarkStepCompletedRequest.idempotency_key`** - must match the key set on the +- **`MarkStepCompletedRequest.idempotency_key`** — must match the key set on the corresponding gate call, if any. Mismatch (including missing-vs-set on either side) surfaces as a typed `IdempotencyKeyMismatchError`. -- **`IdempotencyKeyMismatchError`** - typed exception raised by `step_gate` and +- **`IdempotencyKeyMismatchError`** — typed exception raised by `step_gate` and `mark_step_completed` when the platform returns HTTP 409 with `error.code == "IDEMPOTENCY_KEY_MISMATCH"`. Surfaces `workflow_id`, `step_id`, `expected_idempotency_key`, `received_idempotency_key`, and the human-readable `message`. Exported from `axonflow` top-level. -- **`RetryContext`, `PriorCompletionStatus`** - exported pydantic model + enum. +- **`RetryContext`, `PriorCompletionStatus`** — exported pydantic model + enum. ### Deprecated -- **`StepGateResponse.cached`** and **`StepGateResponse.decision_source`** - still +- **`StepGateResponse.cached`** and **`StepGateResponse.decision_source`** — still populated but deprecated in favor of `retry_context.gate_count > 1` and `retry_context.prior_completion_status`. Planned for removal in a future major version. ### Compatibility Companion to the platform change that introduces `retry_context` on -`POST /api/v1/workflows/{workflow_id}/steps/{step_id}/gate`. Additive only - existing +`POST /api/v1/workflows/{workflow_id}/steps/{step_id}/gate`. Additive only — existing callers that never set `idempotency_key` or `include_prior_output` see no behavior change. ## [6.4.0] - 2026-04-18 ### Added -- **Execution boundary semantics** - `RetryPolicy` enum with `IDEMPOTENT` +- **Execution boundary semantics** — `RetryPolicy` enum with `IDEMPOTENT` (default) and `REEVALUATE` values. Step gate requests accept `retry_policy` to control cached vs fresh evaluation behavior. -- **Step gate response metadata** - `cached` (bool) and `decision_source` +- **Step gate response metadata** — `cached` (bool) and `decision_source` (str) fields on `StepGateResponse` indicate decision provenance. -- **Workflow checkpoints** - `get_checkpoints(workflow_id)` lists step-gate +- **Workflow checkpoints** — `get_checkpoints(workflow_id)` lists step-gate checkpoints. `resume_from_checkpoint(workflow_id, checkpoint_id)` resumes from a specific checkpoint with fresh policy evaluation (Enterprise). -- **Checkpoint types** - `Checkpoint`, `CheckpointListResponse`, and +- **Checkpoint types** — `Checkpoint`, `CheckpointListResponse`, and `ResumeFromCheckpointResponse` models. -- **`AxonFlow.explain_decision(decision_id)`** - fetches the full explanation for a +- **`AxonFlow.explain_decision(decision_id)`** — fetches the full explanation for a previously-made policy decision via `GET /api/v1/decisions/:id/explain`. Returns a `DecisionExplanation` with matched policies, risk level, reason, override availability, existing override ID (if any), and a rolling-24h session hit count for the matched rule. Shape is frozen; additive-only fields ensure forward compatibility. -- **`DecisionExplanation`, `ExplainPolicy`, `ExplainRule`** - new Pydantic +- **`DecisionExplanation`, `ExplainPolicy`, `ExplainRule`** — new Pydantic models exported from `axonflow.decisions`. -- **`AuditSearchRequest.decision_id`, `policy_name`, `override_id`** - three +- **`AuditSearchRequest.decision_id`, `policy_name`, `override_id`** — three new optional filter fields on `search_audit_logs`. Use `decision_id` to gather every record tied to one decision; `policy_name` to find everything matched by a specific policy; `override_id` to reconstruct an override's @@ -702,7 +702,7 @@ behavior. ### Changed -- Examples and documentation updated to reflect the new AxonFlow platform v6.2.0 defaults for `PII_ACTION` (now `warn` - was `redact`) and the new `AXONFLOW_PROFILE` env var. No SDK API changes; the SDK continues to pass `PII_ACTION` through unchanged. +- Examples and documentation updated to reflect the new AxonFlow platform v6.2.0 defaults for `PII_ACTION` (now `warn` — was `redact`) and the new `AXONFLOW_PROFILE` env var. No SDK API changes; the SDK continues to pass `PII_ACTION` through unchanged. --- @@ -710,7 +710,7 @@ behavior. ### Added -- **`check_tool_input()` / `check_tool_output()`** - generic aliases for tool governance. Existing `mcp_check_input()` / `mcp_check_output()` remain supported. +- **`check_tool_input()` / `check_tool_output()`** — generic aliases for tool governance. Existing `mcp_check_input()` / `mcp_check_output()` remain supported. ### Changed @@ -767,15 +767,15 @@ behavior. ### Added -- `simulate_policies()` - dry-run all active policies against an input query. Returns allowed/blocked status, applied policies, risk score, and daily usage. Requires Evaluation tier or above. -- `get_policy_impact_report()` - test a single policy against multiple inputs and get aggregate match/block statistics. -- `detect_policy_conflicts()` - analyze active policies for contradictions, shadows, and redundancies. Optionally filter to conflicts involving a specific policy. -- `AxonFlowLangGraphAdapter.tool_output_wrapper()` - returns an async wrapper for LangGraph `ToolNode(awrap_tool_call=...)` that enforces input and output policy checks on local `@tool` functions. Fixes a gap where locally defined tools bypassed `mcp_tool_interceptor` policy enforcement. +- `simulate_policies()` — dry-run all active policies against an input query. Returns allowed/blocked status, applied policies, risk score, and daily usage. Requires Evaluation tier or above. +- `get_policy_impact_report()` — test a single policy against multiple inputs and get aggregate match/block statistics. +- `detect_policy_conflicts()` — analyze active policies for contradictions, shadows, and redundancies. Optionally filter to conflicts involving a specific policy. +- `AxonFlowLangGraphAdapter.tool_output_wrapper()` — returns an async wrapper for LangGraph `ToolNode(awrap_tool_call=...)` that enforces input and output policy checks on local `@tool` functions. Fixes a gap where locally defined tools bypassed `mcp_tool_interceptor` policy enforcement. - Types: `SimulatePoliciesRequest`, `SimulatePoliciesResponse`, `SimulationDailyUsage`, `ImpactReportInput`, `ImpactReportRequest`, `ImpactReportResult`, `ImpactReportResponse`, `PolicyConflictRef`, `PolicyConflict`, `PolicyConflictResponse` -- `wrap_langgraph()` - 1-line wrapper for compiled LangGraph StateGraphs. Transparently enforces AxonFlow governance at every node transition without modifying the graph definition. Uses langchain-core's `AsyncCallbackHandler` to intercept node execution via `metadata["langgraph_node"]`. -- `GovernedGraph` class - returned by `wrap_langgraph()`, exposes `ainvoke()`, `invoke()`, `astream()`. Each invocation creates a new AxonFlow workflow. Reusable across multiple invocations. -- `NodeConfig` dataclass - per-node configuration overrides (`step_type`, `model`, `provider`, `skip`) for fine-grained control over how individual nodes are governed. -- `govern_tools` parameter - when `True` (default), individual tool calls within LangGraph nodes are automatically gate-checked via `check_tool_gate()` / `tool_completed()`. +- `wrap_langgraph()` — 1-line wrapper for compiled LangGraph StateGraphs. Transparently enforces AxonFlow governance at every node transition without modifying the graph definition. Uses langchain-core's `AsyncCallbackHandler` to intercept node execution via `metadata["langgraph_node"]`. +- `GovernedGraph` class — returned by `wrap_langgraph()`, exposes `ainvoke()`, `invoke()`, `astream()`. Each invocation creates a new AxonFlow workflow. Reusable across multiple invocations. +- `NodeConfig` dataclass — per-node configuration overrides (`step_type`, `model`, `provider`, `skip`) for fine-grained control over how individual nodes are governed. +- `govern_tools` parameter — when `True` (default), individual tool calls within LangGraph nodes are automatically gate-checked via `check_tool_gate()` / `tool_completed()`. - `langchain-core>=0.3.0` added to the `langgraph` optional extra (`pip install axonflow[langgraph]`). --- @@ -809,10 +809,10 @@ behavior. ### Added -- `get_circuit_breaker_status()` - query active circuit breaker circuits and emergency stop state -- `get_circuit_breaker_history(limit)` - retrieve circuit breaker trip/reset audit trail -- `get_circuit_breaker_config(tenant_id)` - get effective circuit breaker config (global or tenant-specific) -- `update_circuit_breaker_config(config)` - update per-tenant circuit breaker thresholds +- `get_circuit_breaker_status()` — query active circuit breaker circuits and emergency stop state +- `get_circuit_breaker_history(limit)` — retrieve circuit breaker trip/reset audit trail +- `get_circuit_breaker_config(tenant_id)` — get effective circuit breaker config (global or tenant-specific) +- `update_circuit_breaker_config(config)` — update per-tenant circuit breaker thresholds --- @@ -820,9 +820,9 @@ behavior. ### Added -- `audit_tool_call()` - record non-LLM tool calls (API, MCP, function) in the audit trail. Returns audit ID, status, and timestamp. Requires Platform v5.1.0+ -- `get_audit_logs_by_tenant()` - retrieve audit logs for a tenant with optional pagination -- `search_audit_logs()` - search audit logs with filters (client ID, request type, limit) +- `audit_tool_call()` — record non-LLM tool calls (API, MCP, function) in the audit trail. Returns audit ID, status, and timestamp. Requires Platform v5.1.0+ +- `get_audit_logs_by_tenant()` — retrieve audit logs for a tenant with optional pagination +- `search_audit_logs()` — search audit logs with filters (client ID, request type, limit) ### Fixed diff --git a/tests/test_wire_shape.py b/tests/test_wire_shape.py index 53b3f78..d0cdafb 100644 --- a/tests/test_wire_shape.py +++ b/tests/test_wire_shape.py @@ -72,12 +72,26 @@ def _specs_dir() -> Path | None: - """Return the OpenAPI specs directory, or None if not set / missing.""" + """Return the OpenAPI specs directory, or None when the env var is unset. + + Unset AXONFLOW_OPENAPI_SPECS_DIR is the designed local-dev skip. A + variable that IS set but points at a missing directory is a + misconfiguration and fails loudly instead: previously it produced 7 + silent skips and exit 0, so a broken CI checkout read as green. + """ env = os.environ.get("AXONFLOW_OPENAPI_SPECS_DIR") if not env: return None p = Path(env) - return p if p.is_dir() else None + if not p.is_dir(): + pytest.fail( + f"AXONFLOW_OPENAPI_SPECS_DIR is set to {env!r} but that is not an " + "existing directory. Refusing to skip: with the variable set, a " + "silent skip would make a broken specs checkout read as a green " + "wire-shape gate. Fix the path, or unset the variable to skip " + "locally." + ) + return p def _wire_fields(model: type[BaseModel]) -> list[str]: @@ -230,8 +244,8 @@ def loaded_specs() -> tuple[dict[str, list[str]], dict[str, dict[str, list[str]] spec_dir = _specs_dir() if spec_dir is None: pytest.skip( - "AXONFLOW_OPENAPI_SPECS_DIR not set to an existing directory; " - "wire-shape contract tests skipped. The dedicated CI job clones " + "AXONFLOW_OPENAPI_SPECS_DIR not set; wire-shape contract tests " + "skipped. The dedicated CI job clones " "https://github.com/getaxonflow/axonflow and exports the specs " "dir before running this file." ) @@ -527,3 +541,39 @@ def test_unmapped_spec_schemas_are_tracked( print(f"\n{len(unmapped)} OpenAPI schema(s) have no matching Python SDK model:") for name in unmapped: print(f" - {name}") + + +# --------------------------------------------------------------------------- +# _specs_dir misconfiguration guard (#3254 review follow-up) +# --------------------------------------------------------------------------- +# These do not use the loaded_specs fixture, so they run in the regular +# suite too (the module's other tests skip there via the fixture). + + +def test_specs_dir_unset_keeps_designed_skip(monkeypatch: pytest.MonkeyPatch) -> None: + """Env unset -> None, which the loaded_specs fixture turns into the + designed local-dev skip.""" + monkeypatch.delenv("AXONFLOW_OPENAPI_SPECS_DIR", raising=False) + assert _specs_dir() is None + + +def test_specs_dir_set_but_missing_fails_loudly( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Env SET but pointing at a missing directory must FAIL, not skip. + + Before this guard, that misconfiguration produced 7 silent skips and + exit 0 - a broken CI specs checkout read as a green gate. + """ + missing = tmp_path / "no-such-specs-dir" + monkeypatch.setenv("AXONFLOW_OPENAPI_SPECS_DIR", str(missing)) + with pytest.raises(pytest.fail.Exception, match="AXONFLOW_OPENAPI_SPECS_DIR"): + _specs_dir() + + +def test_specs_dir_set_and_present_resolves( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Env set to an existing directory resolves to that path.""" + monkeypatch.setenv("AXONFLOW_OPENAPI_SPECS_DIR", str(tmp_path)) + assert _specs_dir() == tmp_path From 20adacb999ba74953c3f33a849bd8eba4bc08a59 Mon Sep 17 00:00:00 2001 From: Saurabh Jain Date: Tue, 4 Aug 2026 01:14:20 +0200 Subject: [PATCH 3/3] fix(review): AXONFLOW_OPENAPI_SPECS_DIR set-but-empty fails loudly; pin the set-to-a-file direction R3 round 2 on #223: an EMPTY (or whitespace-only) value read as unset and produced the same 7 silent skips + exit 0 the round-1 fix targeted - a CI consumer wiring the variable from an expression that evaluates empty would get a green gate. Choice: FAIL on set-but-empty (rather than pinning empty-as-unset), stated in the test comment; unset keeps the designed local-dev skip. Also pins the set-to-a-FILE direction (behaviorally covered by is_dir(), previously unpinned) so a refactor to exists() cannot reopen the class. Proven end-to-end: empty env = 7 errors exit 1; unset = 7 skips; valid dir = 12 passed. Signed-off-by: Saurabh Jain --- tests/test_wire_shape.py | 52 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 48 insertions(+), 4 deletions(-) diff --git a/tests/test_wire_shape.py b/tests/test_wire_shape.py index d0cdafb..e26d98d 100644 --- a/tests/test_wire_shape.py +++ b/tests/test_wire_shape.py @@ -75,13 +75,25 @@ def _specs_dir() -> Path | None: """Return the OpenAPI specs directory, or None when the env var is unset. Unset AXONFLOW_OPENAPI_SPECS_DIR is the designed local-dev skip. A - variable that IS set but points at a missing directory is a - misconfiguration and fails loudly instead: previously it produced 7 - silent skips and exit 0, so a broken CI checkout read as green. + variable that IS set - but empty, or pointing at a missing directory + or a non-directory - is a misconfiguration and fails loudly instead: + previously those cases produced 7 silent skips and exit 0, so a + broken CI checkout read as green. Set-but-empty fails (rather than + reading as unset) because a CI consumer wiring the var from an + expression that evaluates empty is exactly the broken-checkout class. """ env = os.environ.get("AXONFLOW_OPENAPI_SPECS_DIR") - if not env: + if env is None: return None + if not env.strip(): + pytest.fail( + "AXONFLOW_OPENAPI_SPECS_DIR is set but empty. Refusing to treat " + "it as unset: an empty value usually means the CI expression " + "that was supposed to produce the specs path evaluated to " + "nothing, and a silent skip would make that read as a green " + "wire-shape gate. Fix the expression, or unset the variable to " + "skip locally." + ) p = Path(env) if not p.is_dir(): pytest.fail( @@ -571,6 +583,38 @@ def test_specs_dir_set_but_missing_fails_loudly( _specs_dir() +def test_specs_dir_set_but_empty_fails_loudly(monkeypatch: pytest.MonkeyPatch) -> None: + """Env SET but EMPTY must FAIL, not read as unset. + + Choice (of fail-on-empty vs empty-as-unset): fail. A CI consumer + wiring the variable from an expression that evaluates empty is the + same broken-checkout class as a missing directory - treating it as + the designed local-dev skip would green-light a gate that checked + nothing. Whitespace-only counts as empty. + """ + monkeypatch.setenv("AXONFLOW_OPENAPI_SPECS_DIR", "") + with pytest.raises(pytest.fail.Exception, match="set but empty"): + _specs_dir() + monkeypatch.setenv("AXONFLOW_OPENAPI_SPECS_DIR", " ") + with pytest.raises(pytest.fail.Exception, match="set but empty"): + _specs_dir() + + +def test_specs_dir_set_to_a_file_fails_loudly( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Env SET but pointing at a FILE (not a directory) must FAIL. + + Behaviorally covered by the is_dir() check; pinned here so a future + refactor to exists() cannot silently reopen the class. + """ + a_file = tmp_path / "specs.yaml" + a_file.write_text("openapi: 3.0.0\n") + monkeypatch.setenv("AXONFLOW_OPENAPI_SPECS_DIR", str(a_file)) + with pytest.raises(pytest.fail.Exception, match="not an existing directory"): + _specs_dir() + + def test_specs_dir_set_and_present_resolves( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: