From acec677e2648f709c33cda0befb13c16a3fb7979 Mon Sep 17 00:00:00 2001 From: Saurabh Jain Date: Tue, 4 Aug 2026 00:22:10 +0200 Subject: [PATCH 1/3] feat(audit): additive real-wire fields, deprecations, and a spec binding gate (#3254) Read model (AuditLogEntry): add policy_decision (open string set, not an enum), policy_details (arbitrary-key object) and response_time_ms (nullable Long - absent on pre-9.x servers stays null, never a fake 0). The pre-existing 19-argument constructor is retained and delegates to the new canonical @JsonCreator constructor, so direct constructor callers keep compiling (additive-only surface, proof in the PR body). Deprecations, all in place and still parsing: query_summary, success, blocked, risk_score, latency_ms, policy_violations, metadata on the read model (never populated on the 9.x line) and request_type on the search request (the server does not read it as a filter; a search filtered only by it returns unfiltered results). Java cannot carry @Deprecated on constructor parameters, so deprecation rides the getters plus the requestType builder method, with the class Javadoc naming the constructor parameters as equally deprecated. Search request: add action - the filter the 9.x server actually reads, with server-side verdict normalization. Binding gate (the structural fix): wire-shape Gate 5 binds the audit model classes (AuditLogEntry, AuditSearchRequest, AuditSearchResponse) strictly to the pinned spec schemas. Gate 3 is baseline-aware by design, which is exactly how the seven fiction fields shipped - the baseline RECORDED the drift instead of binding the model to the contract. Gate 5 has no refresh path, only the curated note-carrying allowlist in tests/fixtures/audit-binding-allowlist.json (stale entries fail; unresolvable bindings fail instead of skipping). Verified: RED on the pre-fix model naming all seven fiction fields + request_type, RED on a decoy @JsonProperty field, RED on an unresolvable type name, GREEN post-fix. Tests: real captured v9.13.0 payload (verbatim fixture, provenance in the test Javadoc), old-server absence tolerance, fiction+real both-present, old-constructor source-compat proof, action serialization. Runtime-e2e leg runtime-e2e/audit_model_real_wire/ passes against a live community v9.13.0 agent through searchAuditLogs. The per_type_drift baseline entries for the now-modeled fields are removed so the recorded drift stays accurate. Signed-off-by: Saurabh Jain --- .github/workflows/wire-shape-contract.yml | 2 + CHANGELOG.md | 237 ++++++++++-------- .../AuditModelRealWireTest.java | 148 +++++++++++ runtime-e2e/audit_model_real_wire/README.md | 51 ++++ scripts/wire_shape/validate.py | 134 +++++++++- .../java/com/getaxonflow/sdk/AxonFlow.java | 4 +- .../getaxonflow/sdk/types/AuditLogEntry.java | 221 ++++++++++++++-- .../sdk/types/AuditSearchRequest.java | 50 +++- .../sdk/types/AuditRealWireModelTest.java | 198 +++++++++++++++ .../fixtures/audit-search-both-present.json | 78 ++++++ .../resources/fixtures/audit-search-live.json | 1 + .../fixtures/audit-search-old-server.json | 52 ++++ tests/fixtures/audit-binding-allowlist.json | 15 ++ tests/fixtures/wire-shape-baseline.json | 4 - 14 files changed, 1058 insertions(+), 137 deletions(-) create mode 100644 runtime-e2e/audit_model_real_wire/AuditModelRealWireTest.java create mode 100644 runtime-e2e/audit_model_real_wire/README.md create mode 100644 src/test/java/com/getaxonflow/sdk/types/AuditRealWireModelTest.java create mode 100644 src/test/resources/fixtures/audit-search-both-present.json create mode 100644 src/test/resources/fixtures/audit-search-live.json create mode 100644 src/test/resources/fixtures/audit-search-old-server.json create mode 100644 tests/fixtures/audit-binding-allowlist.json diff --git a/.github/workflows/wire-shape-contract.yml b/.github/workflows/wire-shape-contract.yml index a2f2e06..8f63157 100644 --- a/.github/workflows/wire-shape-contract.yml +++ b/.github/workflows/wire-shape-contract.yml @@ -20,6 +20,7 @@ on: paths: - 'src/main/java/**/*.java' - 'tests/fixtures/wire-shape-baseline.json' + - 'tests/fixtures/audit-binding-allowlist.json' - 'scripts/wire_shape/**' - '.github/workflows/wire-shape-contract.yml' push: @@ -27,6 +28,7 @@ on: paths: - 'src/main/java/**/*.java' - 'tests/fixtures/wire-shape-baseline.json' + - 'tests/fixtures/audit-binding-allowlist.json' - 'scripts/wire_shape/**' - '.github/workflows/wire-shape-contract.yml' diff --git a/CHANGELOG.md b/CHANGELOG.md index 201af1c..a6b77fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Real wire fields `policy_decision` (`getPolicyDecision()`), `policy_details` + (`getPolicyDetails()`), `response_time_ms` (`getResponseTimeMs()`) on the + audit read model (`AuditLogEntry`), and `action` (`Builder.action(String)`) + on audit search (`AuditSearchRequest`). `policy_decision` is an OPEN string + set (`allowed`/`blocked`/`redacted` named in the server struct, `error` + observed live), not an enum. The pre-existing 19-argument `AuditLogEntry` + constructor is retained and delegates to the new canonical constructor, so + the change is source-compatible for direct constructor callers. +- Wire-shape Gate 5: audit-surface binding. Every `@JsonProperty` name on + `AuditLogEntry`, `AuditSearchRequest` and `AuditSearchResponse` must exist + in the pinned OpenAPI schema of the same name, with unbound fields allowed + only via the curated, note-carrying + `tests/fixtures/audit-binding-allowlist.json`. Unlike Gate 3, this gate has + no refresh path - a baseline that RECORDS drift is how seven never-served + fields shipped in the first place (#3254). An unresolvable binding (class or + schema missing) fails instead of skipping. + +### 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. The fields stay in place and keep parsing (they remain at their + defaults against real servers); deprecation is carried on the getters and + the `requestType` builder method because Java does not allow `@Deprecated` + on constructor parameters. + ### Security - **Jackson bumped from 2.17.0 to 2.22.1**, and `jackson-core` is now declared @@ -74,7 +103,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 dimension only. **Migration.** Policies or per-connector settings matching the old - concatenated value — e.g. `connector_type == "filesystem.read_file"` — stop + concatenated value - e.g. `connector_type == "filesystem.read_file"` - stop matching after upgrade. Re-scope them to match `connector_type == "filesystem"` together with the `tool` field (e.g. `tool == "read_file"`). The `connectorTypeFn` option is the compatibility lever: a caller can restore @@ -92,7 +121,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 **Minimum platform.** The `tool` field is consumed on `POST /api/v1/mcp/check-input` by **AxonFlow platform v9.10.0+**. On platforms below v9.10.0 the `tool` field is silently dropped and identity degrades to - the bare server name — coarser than the old concatenated value — so + the bare server name - coarser than the old concatenated value - so **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 @@ -102,29 +131,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **`examples/basic` passes on enterprise (JWT-validating) stacks.** It omitted the user token entirely (SDK falls back to `anonymous`), which - `DEPLOYMENT_MODE=enterprise` rejects — and the rejection was swallowed by + `DEPLOYMENT_MODE=enterprise` rejects - and the rejection was swallowed by the generic `AxonFlowException` catch with exit 0. The example now reads `AXONFLOW_USER_TOKEN` and exits non-zero on invalid-user-token rejections. ### Added -- `runtime-e2e/async_verdict_parity/` — live-agent assertion that +- `runtime-e2e/async_verdict_parity/` - live-agent assertion that `decideAsync`/`mcpCheckInputAsync` (joined) deliver the same enforcement verdict as their sync counterparts (async-adapter-bypass class): stacked SQLi → `deny` on `/api/v1/decide`, `allowed=false` on check-input, sync == async on both planes. -- `runtime-e2e/mcp_server_tool_split/` — live-agent assertion for the +- `runtime-e2e/mcp_server_tool_split/` - live-agent assertion for the `connector_type`/`tool` split: `LangGraphAdapter.mcpToolInterceptor()` round-trips a clean tool call through check-input/check-output with the server and tool names as two distinct wire fields, a direct `mcpCheckInput(..., options)` call with an explicit `tool` option is accepted, and the two-argument `mcpCheckInput(connectorType, statement)` overload (no `tool` field) still works unchanged. -- **`AuditToolCallRequest.callerName` (wire: `caller_name`)** — identifies +- **`AuditToolCallRequest.callerName` (wire: `caller_name`)** - identifies WHICH CLIENT made a tool call (e.g. `claude_code`, `codex`, `cursor`, `openclaw`), replacing the misleadingly-named `toolType` field for that - purpose. `toolType` is kept as a **deprecated** input fallback — not + purpose. `toolType` is kept as a **deprecated** input fallback - not removed, not renamed; the server resolves `caller_name` if supplied, else the legacy `tool_type`, else a default. @@ -149,11 +178,11 @@ Patch release. No public API changes. escape hatch for local self-signed certificates is unchanged. Clears CodeQL `java/insecure-trustmanager` (alert #8). -## [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 +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. @@ -163,13 +192,13 @@ platform's). ### Added -- **`AxonFlow.decide(DecideRequest)`** — the PDP step. `POST /api/v1/decide` +- **`AxonFlow.decide(DecideRequest)`** - the PDP step. `POST /api/v1/decide` returns a `DecideResponse` whose `getObligations()` is always a (possibly empty) 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 `AuthenticationException`. A `deny` verdict is returned in the body (HTTP 200), not as an error. `decideAsync(...)` mirror provided. -- **`AxonFlow.fulfillRequest(DecideResponse, String)`** — discharges every +- **`AxonFlow.fulfillRequest(DecideResponse, String)`** - discharges every request-phase `redact_pii` obligation by POSTing the statement to the engine's `check-input` endpoint and returning the **engine-redacted** statement (`FulfillResult`: content + `didRedact()`). Fails closed with @@ -177,7 +206,7 @@ platform's). fulfillment, advertises a content-type the 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.decideAndFulfill(DecideRequest)`** — the blessed one-call path +- **`AxonFlow.decideAndFulfill(DecideRequest)`** - the blessed one-call path (decide, then fulfill any request-phase obligation; `DecideAndFulfillResult` carries verdict, content, and decision); fail-closed by construction. `decideAndFulfillAsync(...)` mirror provided. @@ -192,11 +221,11 @@ platform's). endpoint-path constants). - **`redacted` / `redactedStatement` / `redactionEvaluated` on `MCPCheckInputResponse`** and **`redactionEvaluated` 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 `redactionEvaluated` is false. - **`contentType` on `MCPCheckInputRequest`** (new 5-arg constructor) and a - `content_type` option on `mcpCheckInput(connectorType, statement, options)` — + `content_type` option on `mcpCheckInput(connectorType, statement, options)` - selects the request-redaction detector (defaults to `text/plain` server-side). @@ -209,13 +238,13 @@ platform's). - Existing source-compatible `MCPCheckInputResponse` / `MCPCheckOutputResponse` constructors are preserved; the new fields default to `false` / `null`. -## [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`** - `Map` (nullable). 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-*`), @@ -238,7 +267,7 @@ Targets AxonFlow platform **v8.5.0**. reading `safeguards` is unaffected and the SDK never rejects a value a newer platform may add. -## [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 @@ -251,7 +280,7 @@ Targets AxonFlow platform **v8.5.0**. `transferBasis` is one of `adequacy`, `safeguards`, or `consent`. Both are nullable for backward compatibility with older platform versions. -## [8.2.0] - 2026-05-23 — `createHITLRequest` for explicit HITL row creation +## [8.2.0] - 2026-05-23 - `createHITLRequest` for explicit HITL row creation Enables agent-framework callers (Google ADK, n8n, OpenAI Agents SDK) to implement the full 4-step HITL approval flow against AxonFlow: @@ -276,7 +305,7 @@ SDK surface was missing. framework, an expiry override, and the new `notifyUrl` callback. Server-side `X-Org-ID` / `X-Tenant-ID` headers are derived by the platform's auth middleware from the SDK client's configured - credentials — callers do not pass them through this method. + credentials - callers do not pass them through this method. - **`HITLCreateInput` POJO + Builder** in `com.getaxonflow.sdk.types.hitl.HITLTypes` mirroring `platform/agent/hitl/handler.go:86 CreateRequestInput`. @@ -309,12 +338,12 @@ and `Idempotency-Key` request deduplication. Cross-SDK parity sweep: getaxonflow/axonflow-enterprise#2421. -## [8.1.0] - 2026-05-22 — `X-Client-ID` header on every outbound request + `org_id` in telemetry heartbeat + retry-config doc honesty +## [8.1.0] - 2026-05-22 - `X-Client-ID` header on every outbound request + `org_id` in telemetry heartbeat + retry-config doc honesty 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 `clientId` is configured. ### Added @@ -324,7 +353,7 @@ when no `clientId` 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 Java SDK - telemetry up to parity with the platform — every heartbeat now + 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 @@ -343,7 +372,7 @@ when no `clientId` 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. @@ -356,30 +385,30 @@ when no `clientId` is configured. the actual `initialDelay(Duration)` / `maxDelay(Duration)` builders. Surrounding prose documents the real retry contract: retries fire on connect/timeout, 5xx, and 429; 401/403 and other 4xx are always - terminal. Documentation-only — no code or behavior change. + terminal. Documentation-only - no code or behavior change. ### Compatibility - 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: `listDecisions` for paging through recorded decisions, plus a runnable example showing the full record → list → explain audit flow. Bundled into a major because the v8 line also tightens the telemetry -contract — see `Removed` at the bottom of this entry for that. +contract - see `Removed` at the bottom of this entry for that. ### Added - **`listDecisions(ListDecisionsOptions opts)` client method.** Pages over recorded decision history from the orchestrator, mirroring `GET /api/v1/decisions`. Companion to the v7.4.0 `getDecisionExplain` - method — callers can now both list and drill in. See + method - callers can now both list and drill in. See `examples/list-decisions/`. - **`examples/explain-decision/`** end-to-end runnable example covering the full decision audit flow: record → list → explain. @@ -390,7 +419,7 @@ contract — see `Removed` at the bottom of this entry for that. called `.telemetry(true)` or `.telemetry(false)` on the builder will fail to compile. Migration: remove the call from your builder chain. 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. If you were using it to force-enable, the default is now ON for every mode so the override is no longer needed. @@ -409,9 +438,9 @@ contract — see `Removed` at the bottom of this entry for that. ### Telemetry - **`AXONFLOW_TELEMETRY=off` is the sole opt-out.** `AxonFlowConfig.Builder.telemetry(Boolean)` + `AxonFlowConfig.getTelemetry()` removed; sandbox-mode clients (constructed via `Mode.SANDBOX`) 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, new `DeploymentMode` constants class on the SDK side. Existing receivers continue working unchanged — strictly additive. `EndpointType.COMMUNITY_SAAS` is removed (now lives on `deployment_mode` instead). +- **Heartbeat payload v1 schema additions** on the wire: new `telemetry_type` and `deployment_mode` fields, new `DeploymentMode` constants class on the SDK side. Existing receivers continue working unchanged - strictly additive. `EndpointType.COMMUNITY_SAAS` is removed (now lives on `deployment_mode` instead). -## [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 Java SDK now sends an `X-Axonflow-Client` identification header on every governed request, which @@ -441,23 +470,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** / **Python SDK v7.1.0** / - **TypeScript SDK v7.1.0** — same `X-Axonflow-Client` injection -- **Plugins** — Claude Code / Cursor / Codex v1.2.0; OpenClaw v2.2.0 + **TypeScript 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 `createWebhook` — 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-248h-974q-xrc2`](https://github.com/getaxonflow/axonflow-sdk-java/security/advisories/GHSA-248h-974q-xrc2). +- **Webhook signing-key now exposed by SDK response type** (this release). The `secret` (HMAC-SHA256) field on `WebhookSubscription` - returned by `createWebhook` - 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-248h-974q-xrc2`](https://github.com/getaxonflow/axonflow-sdk-java/security/advisories/GHSA-248h-974q-xrc2). - **`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. Maven Surefire and Failsafe environment blocks were tightened so local `mvn test` runs no longer inherit a host `DO_NOT_TRACK=1` and emit accidental pings. - **Test-harness opt-out hygiene** (last cycle, v6.x). Test environments that mutate `DO_NOT_TRACK` no longer silently leak real pings from CI; transport is mocked at the test boundary. @@ -465,7 +494,7 @@ Major release across the AxonFlow SDK family. Companion releases ship the same d **Reliability and bug-fix highlights:** - **`retry_context` + `idempotency_key` for cross-step de-duplication** (last cycle, v6.x). Workflow steps that retry across pod restarts no longer record duplicate audit entries; idempotency_key flows end-to-end through MAP HITL approve/reject responses. -- **`mapTimeout` config field — SDK parity with Go / Python / TypeScript** (last cycle, v6.x). MAP plan generation has its own timeout knob distinct from the per-request timeout, so multi-LLM-call decompositions no longer cancel the wrong path under load. +- **`mapTimeout` config field - SDK parity with Go / Python / TypeScript** (last cycle, v6.x). MAP plan generation has its own timeout knob distinct from the per-request timeout, so multi-LLM-call decompositions no longer cancel the wrong path under load. - **`LLMProvider` source compatibility restored** (last cycle, v6.x). The 7-arg primitive constructor and primitive `getPriority()` / `getWeight()` accessors are back; null-safe boxed accessors split off as `getPriorityBoxed()` / `getWeightBoxed()` for callers needing "explicitly 0 vs not set" disambiguation. ### BREAKING @@ -475,7 +504,7 @@ Major release across the AxonFlow SDK family. Companion releases ship the same d ### Security -- **TLS verification bypass closed (CWE-295).** `HttpClientFactory` previously honored `insecureSkipVerify(true)` on `AxonFlowConfig` as a single-flag opt-in to a permissive `X509TrustManager` that accepted ANY server certificate, including attacker-presented certificates in MITM scenarios. The insecure path is now double-gated: it activates only if both `insecureSkipVerify(true)` is set on the builder AND the `AXONFLOW_INSECURE_TLS` environment variable is set to `true` (or `1`). When the builder flag is set without the env var, the SDK logs a warning and keeps the JVM's default `TrustManager` in place. A loud `*** SECURITY WARNING ***` is logged whenever the insecure path actually activates. Default behavior — and behavior in production environments without the env var — uses standard JDK + system trust-store validation. Resolves code-scanning alert #8. +- **TLS verification bypass closed (CWE-295).** `HttpClientFactory` previously honored `insecureSkipVerify(true)` on `AxonFlowConfig` as a single-flag opt-in to a permissive `X509TrustManager` that accepted ANY server certificate, including attacker-presented certificates in MITM scenarios. The insecure path is now double-gated: it activates only if both `insecureSkipVerify(true)` is set on the builder AND the `AXONFLOW_INSECURE_TLS` environment variable is set to `true` (or `1`). When the builder flag is set without the env var, the SDK logs a warning and keeps the JVM's default `TrustManager` in place. A loud `*** SECURITY WARNING ***` is logged whenever the insecure path actually activates. Default behavior - and behavior in production environments without the env var - uses standard JDK + system trust-store validation. Resolves code-scanning alert #8. ### Fixed @@ -483,45 +512,45 @@ Major release across the AxonFlow SDK family. Companion releases ship the same d ### Changed -- **Telemetry now follows the 7-day delivered-heartbeat contract** instead of firing on every `new AxonFlow()` construction. The SDK emits at most one anonymous heartbeat per environment every 7 days during SDK activity. A stamp file at the OS-native user cache dir tracks last successful delivery; mtime is the source of truth across process restarts. Failed POSTs do NOT advance the stamp — a transient network error does not silence telemetry for 7 days. An in-memory 1-hour cache caps `Files.getLastModifiedTime` calls on hot request paths; a `ReentrantLock`-guarded in-flight flag coalesces concurrent threads so only one ping fires under load. `AXONFLOW_TELEMETRY=off` is re-evaluated on every gate run. Restricted environments where no cache dir is available (e.g. AWS Lambda with no `HOME`/`LOCALAPPDATA`) fall back transparently to the previous "one ping per construction" behavior. +- **Telemetry now follows the 7-day delivered-heartbeat contract** instead of firing on every `new AxonFlow()` construction. The SDK emits at most one anonymous heartbeat per environment every 7 days during SDK activity. A stamp file at the OS-native user cache dir tracks last successful delivery; mtime is the source of truth across process restarts. Failed POSTs do NOT advance the stamp - a transient network error does not silence telemetry for 7 days. An in-memory 1-hour cache caps `Files.getLastModifiedTime` calls on hot request paths; a `ReentrantLock`-guarded in-flight flag coalesces concurrent threads so only one ping fires under load. `AXONFLOW_TELEMETRY=off` is re-evaluated on every gate run. Restricted environments where no cache dir is available (e.g. AWS Lambda with no `HOME`/`LOCALAPPDATA`) fall back transparently to the previous "one ping per construction" behavior. ### CI / development - CI workflows (`ci.yml`, `integration.yml`, `release.yml`, `wire-shape-contract.yml`, `validate-version-alignment.yml`) now use `AXONFLOW_TELEMETRY=off` to suppress telemetry during automated runs. -## [6.2.0] - 2026-04-28 — listLLMProviders() + LLMProvider source-compat +## [6.2.0] - 2026-04-28 - listLLMProviders() + LLMProvider source-compat Minor release. New LLM-provider listing API closes the parity gap with the Python + Go SDKs; the rest of the cycle restores `LLMProvider` source-compatibility for callers using the 7-arg primitive shape. Coordinated cycle: TypeScript v6.2.0 / Python v6.9.0 / Go v6.0.0 (major: see SDKCompatibility breaking type change in that release) ship same day. ### Added -- **`axonflow.listLLMProviders()`** + `listLLMProviders(String type, Boolean enabled)` — list configured LLM providers and their per-provider health snapshot. Calls `GET /api/v1/llm-providers`. New `LLMProvider` and `LLMProviderHealth` types in `com.getaxonflow.sdk.types`. Async variant `listLLMProvidersAsync()`. Closes the parity gap with the Python SDK's `list_providers()` and the Go SDK's `ListProviders()`. -- **`examples/basic/`** — minimal smoke example exercising `healthCheck()`, `proxyLLMCall()`, and `listConnectors()` against a running AxonFlow agent. Uses try-with-resources so OkHttp's dispatcher + connection pool are cleaned up at exit. Run via `mvn -q compile exec:java` after `mvn install -DskipTests` at the SDK root. +- **`axonflow.listLLMProviders()`** + `listLLMProviders(String type, Boolean enabled)` - list configured LLM providers and their per-provider health snapshot. Calls `GET /api/v1/llm-providers`. New `LLMProvider` and `LLMProviderHealth` types in `com.getaxonflow.sdk.types`. Async variant `listLLMProvidersAsync()`. Closes the parity gap with the Python SDK's `list_providers()` and the Go SDK's `ListProviders()`. +- **`examples/basic/`** - minimal smoke example exercising `healthCheck()`, `proxyLLMCall()`, and `listConnectors()` against a running AxonFlow agent. Uses try-with-resources so OkHttp's dispatcher + connection pool are cleaned up at exit. Run via `mvn -q compile exec:java` after `mvn install -DskipTests` at the SDK root. ### Fixed - **`LLMProvider` source compatibility restored.** The 7-arg primitive constructor `LLMProvider(name, type, enabled:bool, priority:int, weight:int, hasApiKey:bool, health)` is back (delegates to the new 13-arg boxed form, marked `@Deprecated` so new callers move to the boxed shape). `getPriority()` / `getWeight()` return primitive `int` again (null-safe-unbox to 0). Boxed accessors are available as `getPriorityBoxed()` / `getWeightBoxed()` / `getEnabledBoxed()` / `getHasApiKeyBoxed()` for callers that need to distinguish "explicitly 0" from "field not present". -## [6.1.0] - 2026-04-25 — Plugin Batch 1 explainability fields on MCP responses +## [6.1.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 — additive only, no breaking changes. The pre-existing constructors are preserved as source-compat overloads. 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 - additive only, no breaking changes. The pre-existing constructors are preserved as source-compat overloads. Documented in OpenAPI via platform v7.4.3. Coordinated cycle: TypeScript v6.1.0 / Python v6.8.0 / Go v5.8.0 ship same day with the same field set. ### Added - **`MCPCheckInputResponse`** gains 5 optional Plugin Batch 1 fields: - - `decisionId: String` — audit correlator - - `riskLevel: String` — `low` | `medium` | `high` | `critical` - - `policyMatches: List` — per-policy explainability records - - `overrideAvailable: Boolean` — whether session override is permitted for the matched policies (boxed so callers can distinguish "unset" from `false` on older platforms) - - `overrideExistingId: String` — already-active override consumed by this decision (if any) + - `decisionId: String` - audit correlator + - `riskLevel: String` - `low` | `medium` | `high` | `critical` + - `policyMatches: List` - per-policy explainability records + - `overrideAvailable: Boolean` - whether session override is permitted for the matched policies (boxed so callers can distinguish "unset" from `false` on older platforms) + - `overrideExistingId: String` - already-active override consumed by this decision (if any) - **`MCPCheckOutputResponse`** gains 3 optional fields: - `decisionId: String` - `policyMatches: List` - - `redactedMessage: String` — text-redaction counterpart to `redactedData` (used when the connector returned a string message rather than tabular rows; e.g. execute-style responses) + - `redactedMessage: String` - text-redaction counterpart to `redactedData` (used when the connector returned a string message rather than tabular rows; e.g. execute-style responses) -`ExplainPolicy` already shipped — same Jackson-annotated record now reused on the MCP response types. Pre-v7.1.0 platforms leave all new fields as `null`; callers should treat `null` as "context not available" rather than an error. +`ExplainPolicy` already shipped - same Jackson-annotated record now reused on the MCP response types. Pre-v7.1.0 platforms leave all new fields as `null`; callers should treat `null` as "context not available" rather than an error. ### Source compatibility @@ -531,17 +560,17 @@ Both `MCPCheckInputResponse` and `MCPCheckOutputResponse` retain their v6.0.0 co `client.explainDecision(decisionId)` and the full `ExplainRule` / `DecisionExplanation` type surface are tracked separately as feature work. This release ships only field-surfacing on existing methods. -## [6.0.0] - 2026-04-25 — Major: WebhookSubscription identity-based equality +## [6.0.0] - 2026-04-25 - Major: WebhookSubscription identity-based equality This is a major release. The bump is driven by a single observable-contract change: `WebhookSubscription.equals()` and `.hashCode()` now compare on `id` only, not every field. Coordinated with the TypeScript SDK v6.0.0 release (PolicyInfo rename) as a v6 alignment cycle for the SDKs that needed breaking changes; Python (v6.7.0) and Go (v5.7.0) ship as minor on the same day because their changes are purely additive. -### BREAKING — `WebhookSubscription` equality is now identity-based on `id` +### BREAKING - `WebhookSubscription` equality is now identity-based on `id` `WebhookSubscription` is an entity, not a value object. Two instances with the same `id` represent the same logical webhook regardless of whether one view has loaded `secret` (only returned by `createWebhook`) and another has not, or whether `updatedAt` / `active` have moved between fetches. Previously `equals()` / `hashCode()` compared every field. That meant a webhook constructed locally with the legacy 6-arg constructor compared **unequal** to the same logical webhook deserialized from a server response that included `secret` / `tenantId` / `orgId`. `Set`, `Map` keying, and identity-tracking caches all broke under those semantics. -Identity-based equality is the canonical entity semantics; the prior value-based equality was a bug. Because `equals()` / `hashCode()` are part of the observable Java contract that callers depend on for set deduplication, map lookup, and identity caches, the fix is a breaking change per strict semver — even though the new behaviour corrects incorrect semantics rather than introducing them. +Identity-based equality is the canonical entity semantics; the prior value-based equality was a bug. Because `equals()` / `hashCode()` are part of the observable Java contract that callers depend on for set deduplication, map lookup, and identity caches, the fix is a breaking change per strict semver - even though the new behaviour corrects incorrect semantics rather than introducing them. If you need content-equality (e.g. to detect a rotated `secret`), compare the relevant getters directly. The 6-arg constructor is preserved as a source-compat overload for callers building local instances; only `equals()` / `hashCode()` semantics changed. `toString()` is unchanged (still emits full state with `secret` redacted). @@ -549,8 +578,8 @@ If you need content-equality (e.g. to detect a rotated `secret`), compare the re - **Version alignment check** (`.github/workflows/validate-version-alignment.yml`). CI now fails any PR or push to `main` where `pom.xml`'s `` drifts from the first released `## [X.Y.Z]` section in `CHANGELOG.md`. Matches the pattern in the platform repo and the Go SDK. - **Wire-shape contract gate** (`.github/workflows/wire-shape-contract.yml`). CI fails any PR that introduces drift between Java `@JsonProperty` annotations and the OpenAPI specs pinned at `tests/fixtures/wire-shape-baseline.json::openapi_specs_sha`. Four gates: cross-spec schema divergence, intra-file schema duplicates, per-type SDK-vs-spec drift, and registered-type rename-escape. The pinned spec SHA is itself guarded by a `spec-pin-bump` PR label so a single PR can't both move the SHA and silence drift. Source-discovery walks brace depth so nested classes (e.g. `WorkflowTypes.CreateWorkflowRequest`) and inner enums are attributed to the correct type rather than the file's outer class. Mirrors the Python, Go, and TypeScript gates. -- **`WebhookSubscription.secret`** — HMAC-SHA256 signing key now exposed on the response from `createWebhook`. Required to verify the `X-AxonFlow-Signature` header on inbound webhook deliveries; without it, callers can't validate payload authenticity. Also adds `tenantId` and `orgId` (ownership scoping). The 6-arg constructor is preserved as a source-compat overload that delegates to the 9-arg with nulls for the new fields. `toString()` redacts `secret` to avoid log leakage. -- **`BudgetAlert.acknowledged`** — alert dismissal flag. Also adds `@JsonProperty` annotations on previously-unannotated fields (`id`, `threshold`, `message`) so the wire-shape gate can see them; Jackson's default name mapping was correct, but the validator's discovery walks `@JsonProperty` only. +- **`WebhookSubscription.secret`** - HMAC-SHA256 signing key now exposed on the response from `createWebhook`. Required to verify the `X-AxonFlow-Signature` header on inbound webhook deliveries; without it, callers can't validate payload authenticity. Also adds `tenantId` and `orgId` (ownership scoping). The 6-arg constructor is preserved as a source-compat overload that delegates to the 9-arg with nulls for the new fields. `toString()` redacts `secret` to avoid log leakage. +- **`BudgetAlert.acknowledged`** - alert dismissal flag. Also adds `@JsonProperty` annotations on previously-unannotated fields (`id`, `threshold`, `message`) so the wire-shape gate can see them; Jackson's default name mapping was correct, but the validator's discovery walks `@JsonProperty` only. ### Fixed @@ -561,27 +590,27 @@ If you need content-equality (e.g. to detect a rotated `secret`), compare the re ### Added -- **Rich `ApproveStepResponse` / `RejectStepResponse`** — both classes now carry +- **Rich `ApproveStepResponse` / `RejectStepResponse`** - both classes now carry the same shape as the step-gate response: `decision` resolves to `"allow"` or `"block"`, `retryContext` mirrors the gate response retry state, `approvedBy` / `approvedAt` / `rejectedBy` / `rejectedAt` carry reviewer identity, `approvalId` is the deterministic HITL queue UUID, `policiesMatched` reconstructs the governance trail. The legacy `workflowId` / `stepId` / `status` fields remain for back-compat. -- **`planId` on approve/reject responses** — populated when the response comes +- **`planId` on approve/reject responses** - populated when the response comes from the MAP plan-scoped endpoint; empty on WCP plane responses. Same types work across both endpoints. -- **Back-compat 3-arg constructors** — `new ApproveStepResponse(workflowId, stepId, status)` +- **Back-compat 3-arg constructors** - `new ApproveStepResponse(workflowId, stepId, status)` and `new RejectStepResponse(workflowId, stepId, status)` still compile, so existing test fixtures and SDK consumers keep working without changes. -- **`getPendingPlanApprovals` / `getPendingPlanApprovalsAsync`** — new client +- **`getPendingPlanApprovals` / `getPendingPlanApprovalsAsync`** - new client methods that list MAP-plane pending approvals (`GET /api/v1/plans/approvals/pending`), the counterpart of `getPendingApprovals` for the WCP plane. The two-arg form accepts an optional `planId` filter so reviewer tools can scope the listing to one plan. Available on Evaluation+ licenses (same tier gate as the MAP step approve/reject endpoints). -- **`PendingApproval.planId`** — populated on MAP-plane entries, null on +- **`PendingApproval.planId`** - populated on MAP-plane entries, null on WCP-plane entries. Mirrors the approve/reject asymmetry. `PendingApproval` also gains `stepIndex`, `decision`, `decisionReason`, and `approvalStatus` so reviewer tools can render the full approval context without a second @@ -590,7 +619,7 @@ If you need content-equality (e.g. to detect a rotated `secret`), compare the re ### Fixed -- **`approveStep` / `rejectStep` / `getPendingApprovals` endpoint URLs** — +- **`approveStep` / `rejectStep` / `getPendingApprovals` 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)` @@ -598,7 +627,7 @@ If you need content-equality (e.g. to detect a rotated `secret`), compare the re methods against a live deployment were receiving 404s; this release makes them work. - **`PendingApprovalsResponse` getters and JSON field names aligned with the - wire shape** — the class previously declared `getApprovals()` / `getTotal()` + wire shape** - the class previously declared `getApprovals()` / `getTotal()` over a JSON body with keys `approvals` / `total`, which never matched the server (`pending_approvals` / `count`). Getters renamed to `getPendingApprovals()` / `getCount()` with the correct JSON bindings. @@ -607,47 +636,47 @@ If you need content-equality (e.g. to detect a rotated `secret`), compare the re ### 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 - `approveStep(workflowId, stepId)` / `rejectStep(workflowId, stepId, reason)` - method signatures on `AxonFlow` are unchanged — only the response fields grew. + method signatures on `AxonFlow` are unchanged - only the response fields grew. ## [5.6.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 `RetryContext` object on every gate call with the true `(workflow_id, step_id)` lifecycle: `gateCount`, `completionCount`, - `priorCompletionStatus` (`PriorCompletionStatus` enum — + `priorCompletionStatus` (`PriorCompletionStatus` enum - `NONE` / `COMPLETED` / `GATED_NOT_COMPLETED`), `priorOutputAvailable`, `priorOutput`, `priorCompletionAt`, `firstAttemptAt`, `lastAttemptAt`, `lastDecision`, and `idempotencyKey`. Prefer these to the legacy `cached` / `decisionSource` fields. -- **`stepGate(workflowId, stepId, request, options)` overload** — new 4-arg overload +- **`stepGate(workflowId, stepId, request, options)` overload** - new 4-arg overload taking `StepGateOptions`. Use `StepGateOptions.includePriorOutput()` to send `?include_prior_output=true` so `retryContext.priorOutput` is populated when a prior `/complete` has landed. Existing 3-arg overload keeps its signature and delegates with `StepGateOptions.defaults()`. -- **`StepGateRequest.idempotencyKey`** — caller-supplied opaque business-level key +- **`StepGateRequest.idempotencyKey`** - caller-supplied opaque business-level key (max 255 chars; validated at construction). Immutable once recorded on the first gate call for a `(workflow, step)`; subsequent gate/complete calls must pass the same key. -- **`MarkStepCompletedRequest.idempotencyKey`** — must match the key set on the +- **`MarkStepCompletedRequest.idempotencyKey`** - must match the key set on the corresponding gate call, if any. Mismatch (including missing-vs-set on either side) surfaces as a typed `IdempotencyKeyMismatchException`. -- **`IdempotencyKeyMismatchException`** — new typed exception in +- **`IdempotencyKeyMismatchException`** - new typed exception in `com.getaxonflow.sdk.exceptions`. Thrown by `stepGate` and `markStepCompleted` when the platform returns HTTP 409 with `error.code == "IDEMPOTENCY_KEY_MISMATCH"`. Surfaces `workflowId`, `stepId`, `expectedIdempotencyKey`, `receivedIdempotencyKey`, plus inherited `statusCode=409` and `errorCode="IDEMPOTENCY_KEY_MISMATCH"`. -- **`RetryContext`, `PriorCompletionStatus`, `StepGateOptions`** — exported in +- **`RetryContext`, `PriorCompletionStatus`, `StepGateOptions`** - exported in `WorkflowTypes`. ### Fixed -- **409 dispatch on step gate/complete** — previously all 409 responses on +- **409 dispatch on step gate/complete** - previously all 409 responses on `markStepCompleted` fell through to a generic `AxonFlowException(..., 409, "VERSION_CONFLICT")`, conflating step idempotency conflicts with plan version conflicts. The step gate/complete call sites now inspect the 409 body and dispatch @@ -657,7 +686,7 @@ If you need content-equality (e.g. to detect a rotated `secret`), compare the re ### Deprecated -- **`StepGateResponse.isCached()`** and **`StepGateResponse.getDecisionSource()`** — +- **`StepGateResponse.isCached()`** and **`StepGateResponse.getDecisionSource()`** - marked `@Deprecated`. Use `getRetryContext().getGateCount() > 1` and `getRetryContext().getPriorCompletionStatus()` instead. Planned for removal in a future major version. @@ -665,7 +694,7 @@ If you need content-equality (e.g. to detect a rotated `secret`), compare the re ### 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 `idempotencyKey` or pass `StepGateOptions` see no behavior change. Binary-compatibility preserved: old `StepGateRequest`, `StepGateResponse`, and `MarkStepCompletedRequest` constructors kept alongside new ones. @@ -674,7 +703,7 @@ change. Binary-compatibility preserved: old `StepGateRequest`, `StepGateResponse ### Added -- **`mapTimeout` field on `AxonFlowConfig`** — brings Java to parity with +- **`mapTimeout` field on `AxonFlowConfig`** - brings Java to parity with the TypeScript, Python, and Go SDKs (all three already had a separate MAP timeout). The shared `timeout` (default 60s) only covered single- request endpoints; MAP plans routinely take 60-120s because they @@ -696,19 +725,19 @@ change. Binary-compatibility preserved: old `StepGateRequest`, `StepGateResponse ### Added -- **Execution boundary semantics** — `retryPolicy` field on `StepGateRequest` +- **Execution boundary semantics** - `retryPolicy` field on `StepGateRequest` (via builder: `.retryPolicy("reevaluate")`). Controls cached vs fresh evaluation for the same step boundary. -- **Step gate response metadata** — `cached` (boolean) and `decisionSource` +- **Step gate response metadata** - `cached` (boolean) and `decisionSource` (String) fields on `StepGateResponse` via `isCached()` and `getDecisionSource()`. -- **Workflow checkpoints** — `getCheckpoints(workflowId)` lists step-gate +- **Workflow checkpoints** - `getCheckpoints(workflowId)` lists step-gate checkpoints. `resumeFromLastCheckpoint(workflowId)` resumes from last checkpoint (Evaluation+). `resumeFromCheckpoint(workflowId, checkpointId)` resumes from a specific checkpoint (Enterprise). -- **Checkpoint types** — `Checkpoint`, `CheckpointListResponse`, and +- **Checkpoint types** - `Checkpoint`, `CheckpointListResponse`, and `ResumeFromCheckpointResponse` with Jackson deserialization. -- **`AxonFlow.explainDecision(decisionId)`** (+ `explainDecisionAsync`) — fetches +- **`AxonFlow.explainDecision(decisionId)`** (+ `explainDecisionAsync`) - 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 @@ -716,9 +745,9 @@ change. Binary-compatibility preserved: old `StepGateRequest`, `StepGateResponse rule. Shape is frozen (future extra fields ignored via Jackson's `@JsonIgnoreProperties(ignoreUnknown = true)`); additive-only fields ensure forward compatibility. -- **`DecisionExplanation`, `ExplainPolicy`, `ExplainRule`** — new immutable +- **`DecisionExplanation`, `ExplainPolicy`, `ExplainRule`** - new immutable DTOs in `com.getaxonflow.sdk.types`. -- **`AuditSearchRequest.Builder.decisionId`, `policyName`, `overrideId`** — +- **`AuditSearchRequest.Builder.decisionId`, `policyName`, `overrideId`** - three new optional filter fields on `searchAuditLogs`. Use `decisionId` to gather every record tied to one decision; `policyName` to find everything matched by a specific policy; `overrideId` to reconstruct an @@ -750,7 +779,7 @@ server-side filtering activates on v7.1.0+ platforms. ### 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. +- 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. --- @@ -758,8 +787,8 @@ server-side filtering activates on v7.1.0+ platforms. ### Added -- **`GovernedTool` adapter** — framework-agnostic tool governance wrapper. Wraps any `Tool` interface with input/output policy enforcement (`mcpCheckInput` before execution, `mcpCheckOutput` after). Factory: `GovernedTool.wrap(tool, client)`, builder pattern, batch helper: `GovernedTool.governTools(tools, client)`. -- **`checkToolInput()` / `checkToolOutput()`** — generic aliases for tool governance. Existing `mcpCheckInput()` / `mcpCheckOutput()` remain supported. Async variants included. +- **`GovernedTool` adapter** - framework-agnostic tool governance wrapper. Wraps any `Tool` interface with input/output policy enforcement (`mcpCheckInput` before execution, `mcpCheckOutput` after). Factory: `GovernedTool.wrap(tool, client)`, builder pattern, batch helper: `GovernedTool.governTools(tools, client)`. +- **`checkToolInput()` / `checkToolOutput()`** - generic aliases for tool governance. Existing `mcpCheckInput()` / `mcpCheckOutput()` remain supported. Async variants included. ### Changed @@ -788,9 +817,9 @@ server-side filtering activates on v7.1.0+ platforms. ### Added -- `simulatePolicies()` / `simulatePoliciesAsync()` — 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. -- `getPolicyImpactReport()` / `getPolicyImpactReportAsync()` — test a single policy against multiple inputs and get aggregate match/block statistics. -- `detectPolicyConflicts()` / `detectPolicyConflictsAsync()` — analyze active policies for contradictions, shadows, and redundancies. Optionally filter to conflicts involving a specific policy. +- `simulatePolicies()` / `simulatePoliciesAsync()` - 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. +- `getPolicyImpactReport()` / `getPolicyImpactReportAsync()` - test a single policy against multiple inputs and get aggregate match/block statistics. +- `detectPolicyConflicts()` / `detectPolicyConflictsAsync()` - analyze active policies for contradictions, shadows, and redundancies. Optionally filter to conflicts involving a specific policy. - Types in `com.getaxonflow.sdk.simulation` package: `SimulatePoliciesRequest`, `SimulatePoliciesResponse`, `SimulationDailyUsage`, `ImpactReportInput`, `ImpactReportRequest`, `ImpactReportResult`, `ImpactReportResponse`, `PolicyConflictRef`, `PolicyConflict`, `PolicyConflictResponse` ### Security @@ -803,20 +832,20 @@ server-side filtering activates on v7.1.0+ platforms. ### Added -- `LangGraphAdapter` class — wraps LangGraph workflows with AxonFlow governance gates and per-tool policy enforcement. Includes: - - `checkGate()` / `stepCompleted()` — step-level governance at LangGraph node boundaries - - `checkToolGate()` / `toolCompleted()` — per-tool governance within tool_call nodes (each tool gets its own gate check) - - `mcpToolInterceptor()` — factory returning an interceptor enforcing `mcpCheckInput → handler → mcpCheckOutput` around every MCP tool call - - `waitForApproval()` — poll until a step is approved or rejected - - `startWorkflow()` / `completeWorkflow()` / `abortWorkflow()` / `failWorkflow()` — workflow lifecycle management +- `LangGraphAdapter` class - wraps LangGraph workflows with AxonFlow governance gates and per-tool policy enforcement. Includes: + - `checkGate()` / `stepCompleted()` - step-level governance at LangGraph node boundaries + - `checkToolGate()` / `toolCompleted()` - per-tool governance within tool_call nodes (each tool gets its own gate check) + - `mcpToolInterceptor()` - factory returning an interceptor enforcing `mcpCheckInput → handler → mcpCheckOutput` around every MCP tool call + - `waitForApproval()` - poll until a step is approved or rejected + - `startWorkflow()` / `completeWorkflow()` / `abortWorkflow()` / `failWorkflow()` - workflow lifecycle management - Builder pattern construction, implements `AutoCloseable` - `WorkflowBlockedError` and `WorkflowApprovalRequiredError` exception classes - Builder-based option classes: `CheckGateOptions`, `StepCompletedOptions`, `CheckToolGateOptions`, `ToolCompletedOptions` - MCP interceptor types: `MCPInterceptorOptions`, `MCPToolRequest`, `MCPToolHandler`, `MCPToolInterceptor` -- `getCircuitBreakerStatus()` / `getCircuitBreakerStatusAsync()` — query active circuit breaker circuits and emergency stop state -- `getCircuitBreakerHistory(limit)` / `getCircuitBreakerHistoryAsync(limit)` — retrieve circuit breaker trip/reset audit trail -- `getCircuitBreakerConfig(tenantId)` / `getCircuitBreakerConfigAsync(tenantId)` — get effective circuit breaker config (global or tenant-specific) -- `updateCircuitBreakerConfig(config)` / `updateCircuitBreakerConfigAsync(config)` — update per-tenant circuit breaker thresholds +- `getCircuitBreakerStatus()` / `getCircuitBreakerStatusAsync()` - query active circuit breaker circuits and emergency stop state +- `getCircuitBreakerHistory(limit)` / `getCircuitBreakerHistoryAsync(limit)` - retrieve circuit breaker trip/reset audit trail +- `getCircuitBreakerConfig(tenantId)` / `getCircuitBreakerConfigAsync(tenantId)` - get effective circuit breaker config (global or tenant-specific) +- `updateCircuitBreakerConfig(config)` / `updateCircuitBreakerConfigAsync(config)` - update per-tenant circuit breaker thresholds --- @@ -824,9 +853,9 @@ server-side filtering activates on v7.1.0+ platforms. ### Added -- `auditToolCall()` — record non-LLM tool calls (API, MCP, function) in the audit trail. Returns audit ID, status, and timestamp. Requires Platform v5.1.0+ -- `getAuditLogsByTenant()` — retrieve audit logs for a tenant with optional pagination -- `searchAuditLogs()` — search audit logs with filters (client ID, request type, limit) +- `auditToolCall()` - record non-LLM tool calls (API, MCP, function) in the audit trail. Returns audit ID, status, and timestamp. Requires Platform v5.1.0+ +- `getAuditLogsByTenant()` - retrieve audit logs for a tenant with optional pagination +- `searchAuditLogs()` - search audit logs with filters (client ID, request type, limit) ### Fixed diff --git a/runtime-e2e/audit_model_real_wire/AuditModelRealWireTest.java b/runtime-e2e/audit_model_real_wire/AuditModelRealWireTest.java new file mode 100644 index 0000000..68da5fb --- /dev/null +++ b/runtime-e2e/audit_model_real_wire/AuditModelRealWireTest.java @@ -0,0 +1,148 @@ +/* + * runtime-e2e/audit_model_real_wire/AuditModelRealWireTest.java + * + * Real-stack assertion for the #3254 audit-model interim + * (getaxonflow/axonflow-enterprise#3254): the SDK's audit read model + * carries the fields the server actually serves, and the seven fiction + * fields stay at their defaults against a real agent. + * + * Per runtime-e2e/README.md this runs a real JVM + built SDK jar against + * a real AxonFlow agent - no mocks. It asserts: + * + * 1. searchAuditLogs() through the SDK's public surface returns entries + * whose policyDecision is populated from the wire and whose + * responseTimeMs is present (non-null), while the deprecated + * blocked / success / riskScore fields sit at their defaults - + * the server never sends them. + * 2. The new AuditSearchRequest.action filter is READ by the server: + * action("blocked") returns only non-"allowed" verdict rows. + * + * Env: + * AXONFLOW_ENDPOINT agent URL (default http://127.0.0.1:38080) + * AXONFLOW_CLIENT_ID client identity (default demo-client) + * AXONFLOW_CLIENT_SECRET client secret (default demo-secret) + * + * Run (from the SDK root, against a live community/enterprise agent): + * + * mvn install -DskipTests + * mvn -q dependency:build-classpath -Dmdep.outputFile=/tmp/cp.txt + * SDK_JAR=$(ls target/axonflow-sdk-*.jar | grep -v sources | grep -v javadoc | head -1) + * java -cp "$SDK_JAR:$(cat /tmp/cp.txt)" \ + * runtime-e2e/audit_model_real_wire/AuditModelRealWireTest.java + */ +import com.getaxonflow.sdk.AxonFlow; +import com.getaxonflow.sdk.AxonFlowConfig; +import com.getaxonflow.sdk.types.AuditLogEntry; +import com.getaxonflow.sdk.types.AuditSearchRequest; +import com.getaxonflow.sdk.types.AuditSearchResponse; + +public class AuditModelRealWireTest { + + static void fail(String msg) { + System.err.println("FAIL: " + msg); + System.exit(1); + } + + static String env(String name, String dflt) { + String v = System.getenv(name); + return (v == null || v.isEmpty()) ? dflt : v; + } + + @SuppressWarnings("deprecation") + public static void main(String[] args) { + String endpoint = env("AXONFLOW_ENDPOINT", "http://127.0.0.1:38080"); + String clientId = env("AXONFLOW_CLIENT_ID", "demo-client"); + String clientSecret = env("AXONFLOW_CLIENT_SECRET", "demo-secret"); + + AxonFlow client = + AxonFlow.create( + AxonFlowConfig.builder() + .endpoint(endpoint) + .clientId(clientId) + .clientSecret(clientSecret) + .build()); + + // 1. Unfiltered search: policy_decision / response_time_ms come off + // the real wire; the fiction fields stay at defaults. + AuditSearchResponse all = + client.searchAuditLogs(AuditSearchRequest.builder().limit(50).build()); + if (all.getEntries().isEmpty()) { + fail("no audit entries on the stack - write one first (POST /api/v1/audit/tool-call)"); + } + + int withDecision = 0; + int withResponseTime = 0; + for (AuditLogEntry e : all.getEntries()) { + if (!e.getPolicyDecision().isEmpty()) { + withDecision++; + } + if (e.getResponseTimeMs() != null) { + withResponseTime++; + } + // The deprecated trio must sit at defaults: a real 9.x server never + // sends success/blocked/risk_score, so a non-default value here + // means the model regressed into trusting fiction again. + if (e.isBlocked()) { + fail("entry " + e.getId() + " has blocked=true - the 9.x wire never sends 'blocked'"); + } + if (!e.isSuccess()) { + fail("entry " + e.getId() + " has success=false - the 9.x wire never sends 'success'"); + } + if (e.getRiskScore() != 0.0) { + fail("entry " + e.getId() + " has risk_score=" + e.getRiskScore() + + " - the 9.x wire never sends 'risk_score'"); + } + } + if (withDecision == 0) { + fail("no entry carried a policy_decision - new field not bound to the wire"); + } + if (withResponseTime == 0) { + fail("no entry carried response_time_ms - new field not bound to the wire"); + } + AuditLogEntry sample = all.getEntries().get(0); + System.out.println( + "PASS [real-wire-fields] " + + all.getEntries().size() + + " entries; " + + withDecision + + " with policy_decision, " + + withResponseTime + + " with response_time_ms. Sample: id=" + + sample.getId() + + " policyDecision=" + + sample.getPolicyDecision() + + " responseTimeMs=" + + sample.getResponseTimeMs() + + " policyDetailsKeys=" + + sample.getPolicyDetails().keySet() + + " | deprecated defaults held: blocked=" + + sample.isBlocked() + + " success=" + + sample.isSuccess() + + " riskScore=" + + sample.getRiskScore()); + + // 2. The action filter is read server-side (request_type is not). + AuditSearchResponse blocked = + client.searchAuditLogs( + AuditSearchRequest.builder().action("blocked").limit(50).build()); + for (AuditLogEntry e : blocked.getEntries()) { + if (e.getPolicyDecision().isEmpty() || "allowed".equals(e.getPolicyDecision())) { + fail( + "action=\"blocked\" returned entry " + + e.getId() + + " with policy_decision=" + + e.getPolicyDecision() + + " - the server did not apply the filter"); + } + } + System.out.println( + "PASS [action-filter] action=\"blocked\" returned " + + blocked.getEntries().size() + + " of " + + all.getEntries().size() + + " entries, none with an allowed/empty verdict"); + + System.out.println("ALL PASS"); + } +} diff --git a/runtime-e2e/audit_model_real_wire/README.md b/runtime-e2e/audit_model_real_wire/README.md new file mode 100644 index 0000000..4a7e87f --- /dev/null +++ b/runtime-e2e/audit_model_real_wire/README.md @@ -0,0 +1,51 @@ +# audit_model_real_wire (audit model real-wire fields, #3254) + +Real-stack proof for the getaxonflow/axonflow-enterprise#3254 additive +interim: the SDK's audit read model now carries the fields a 9.x server +actually serves, and the seven never-served fields stay at their +defaults against a live agent. + +Background: `AuditLogEntry` modeled `query_summary`, `success`, +`blocked`, `risk_score`, `latency_ms`, `policy_violations` and +`metadata` - none of which any 9.x server has ever sent. Consumers +reading `isBlocked()` on a genuinely blocked request saw `false` +(the default), because the wire carries the verdict in +`policy_decision`, the context in `policy_details` and the latency in +`response_time_ms`. Similarly, `AuditSearchRequest.request_type` is a +silent server-side no-op; the real filter is `action`. + +This test asserts, through the SDK's real public surface +(`searchAuditLogs`), against a real running agent with NO mocks: + +1. **Real fields are bound.** At least one returned entry carries a + populated `policyDecision` and a present (non-null) + `responseTimeMs`, while `isBlocked()` / `isSuccess()` / + `getRiskScore()` sit at their documented defaults on every entry. +2. **`action` is read server-side.** `action("blocked")` returns only + entries whose verdict is not `allowed`/empty. + +## Run + +```bash +# from the SDK root, against a live agent +export AXONFLOW_ENDPOINT=http://127.0.0.1:38080 # default +export AXONFLOW_CLIENT_ID=demo-client # default +export AXONFLOW_CLIENT_SECRET=demo-secret # default + +mvn install -DskipTests +mvn -q dependency:build-classpath -Dmdep.outputFile=/tmp/cp.txt +SDK_JAR=$(ls target/axonflow-sdk-*.jar | grep -v sources | grep -v javadoc | head -1) +java -cp "$SDK_JAR:$(cat /tmp/cp.txt)" \ + runtime-e2e/audit_model_real_wire/AuditModelRealWireTest.java +``` + +The stack must hold at least one audit row; write one via +`POST /api/v1/audit/tool-call` through the agent proxy if empty. + +Expected output shape: + +``` +PASS [real-wire-fields] N entries; N with policy_decision, N with response_time_ms. Sample: ... +PASS [action-filter] action="blocked" returned M of N entries, none with an allowed/empty verdict +ALL PASS +``` diff --git a/scripts/wire_shape/validate.py b/scripts/wire_shape/validate.py index f4a3ab6..5515172 100755 --- a/scripts/wire_shape/validate.py +++ b/scripts/wire_shape/validate.py @@ -5,11 +5,23 @@ @JsonProperty annotations) and the OpenAPI specs pinned via openapi_specs_sha in tests/fixtures/wire-shape-baseline.json. -Four gates, same classes as the Python/Go/TS validators: +Five gates: 1. Cross-spec schema divergence (same name, different shapes) 2. Intra-file schema duplicates (PolicyMatch-class bug) 3. Per-type SDK-vs-spec drift (baseline-aware) 4. Registered-type coverage (rename-escape guard) +(1-4 are the same classes as the Python/Go/TS validators.) +5. Audit-surface field binding (#3254): every @JsonProperty name on the + audit model classes MUST exist as a property of the same-named schema + in the pinned specs, unless it is explicitly allowlisted in + tests/fixtures/audit-binding-allowlist.json with a note naming a + tracking issue. Gate 3 is baseline-aware by design (drift recorded at + refresh time stays green), which is exactly how seven never-served + fields shipped on AuditLogEntry and stayed for months - the baseline + RECORDED the fiction instead of binding the model to the contract. + Gate 5 is the binding: it has no refresh path, only the curated + allowlist, and an unresolvable binding (class or schema missing) + FAILS instead of skipping. Specs dir is passed via AXONFLOW_OPENAPI_SPECS_DIR. Without it, the script exits 0 after a skip message so `mvn test` and local work @@ -22,18 +34,72 @@ from __future__ import annotations +import json import os import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent)) from lib import ( # noqa: E402 + REPO_ROOT, difference, discover_sdk_types, load_all_schemas, load_baseline, ) +# Gate 5 (audit-surface binding, #3254): the audit read/search surface is +# bound STRICTLY to the pinned spec schemas - the per_type_drift baseline +# does not apply here. Add a type to this tuple to put it under binding. +AUDIT_BINDING_TYPES = ( + "AuditLogEntry", + "AuditSearchRequest", + "AuditSearchResponse", +) +AUDIT_BINDING_ALLOWLIST_PATH = ( + REPO_ROOT / "tests" / "fixtures" / "audit-binding-allowlist.json" +) + + +def load_audit_binding_allowlist() -> dict[str, dict[str, str]]: + """Load the curated allowlist for Gate 5. + + Shape: {TypeName: {wire_field: "note naming the tracking issue"}}. + Keys starting with "_" are comments. An absent file means an empty + allowlist (strict binding). A malformed file or an entry without a + non-empty note string fails loudly - a silent parse problem must not + weaken the gate. + """ + if not AUDIT_BINDING_ALLOWLIST_PATH.exists(): + return {} + try: + with AUDIT_BINDING_ALLOWLIST_PATH.open() as f: + parsed = json.load(f) + except json.JSONDecodeError as e: + raise SystemExit( + f"❌ {AUDIT_BINDING_ALLOWLIST_PATH} is malformed " + f"({e.__class__.__name__}: {e}). Fix or delete it - a broken " + f"allowlist must not weaken the audit binding gate." + ) from None + result: dict[str, dict[str, str]] = {} + for type_name, fields in parsed.items(): + if type_name.startswith("_"): + continue + if not isinstance(fields, dict): + raise SystemExit( + f"❌ {AUDIT_BINDING_ALLOWLIST_PATH}: entry {type_name!r} " + f"must map wire fields to note strings." + ) + for field, note in fields.items(): + if not isinstance(note, str) or not note.strip(): + raise SystemExit( + f"❌ {AUDIT_BINDING_ALLOWLIST_PATH}: " + f"{type_name}.{field} has no justification note. Every " + f"allowlisted field must name its tracking issue." + ) + result[type_name] = dict(fields) + return result + def main() -> int: env = os.environ.get("AXONFLOW_OPENAPI_SPECS_DIR") @@ -231,6 +297,72 @@ def main() -> int: ) errors += len(missing_sdk) + len(missing_spec) + # Gate 5: audit-surface field binding (#3254). Strict, baseline-free. + allowlist = load_audit_binding_allowlist() + binding_problems: list[str] = [] + for type_name in AUDIT_BINDING_TYPES: + sdk_fields = sdk.get(type_name) + spec_fields = merged.get(type_name) + if sdk_fields is None: + binding_problems.append( + f" {type_name}: no Java class with @JsonProperty fields " + f"found under src/main/java - the binding is unresolvable. " + f"This gate fails instead of skipping; if the class was " + f"renamed, update AUDIT_BINDING_TYPES in the same PR." + ) + continue + if spec_fields is None: + binding_problems.append( + f" {type_name}: no OpenAPI schema of this name in the " + f"pinned specs - the binding is unresolvable. This gate " + f"fails instead of skipping; if the schema was renamed, " + f"update AUDIT_BINDING_TYPES in the same PR." + ) + continue + allowed = allowlist.get(type_name, {}) + unbound = [ + f for f in difference(sdk_fields, spec_fields) if f not in allowed + ] + if unbound: + binding_problems.append( + f" {type_name}: SDK @JsonProperty field(s) with NO backing " + f"property in the pinned {type_name} schema: {unbound}. A " + f"field the server never serves is fiction (#3254 class): " + f"either the spec is missing it (fix the contract first) or " + f"the field must not exist. If it must stay temporarily, " + f"allowlist it WITH a tracking-issue note in " + f"tests/fixtures/audit-binding-allowlist.json." + ) + # Stale = allowlisted but no longer unbound: either the field left + # the SDK class, or the spec now carries it. Both mean the entry + # must go, so the allowlist only ever names live debt. + stale = sorted( + f for f in allowed if f not in difference(sdk_fields, spec_fields) + ) + if stale: + binding_problems.append( + f" {type_name}: allowlist entr{'ies' if len(stale) > 1 else 'y'} " + f"{stale} no longer unbound (field removed from the SDK or " + f"now present in the spec) - remove from " + f"tests/fixtures/audit-binding-allowlist.json so the " + f"allowlist only ever names live debt." + ) + spec_missing = difference(spec_fields, sdk_fields) + if spec_missing: + # Informational only: fields the server serves that the SDK + # does not model yet are a coverage gap, not fiction. + print( + f"ℹ️ {type_name}: spec fields not yet modeled in the SDK " + f"(informational): {spec_missing}" + ) + if binding_problems: + print( + "\nAudit-surface binding gate failed (#3254):\n", file=sys.stderr + ) + for p in binding_problems: + print(p + "\n", file=sys.stderr) + errors += len(binding_problems) + if errors > 0: print(f"❌ Found {errors} wire-shape issue(s).", file=sys.stderr) return 1 diff --git a/src/main/java/com/getaxonflow/sdk/AxonFlow.java b/src/main/java/com/getaxonflow/sdk/AxonFlow.java index 0315ab8..3f8b7e7 100644 --- a/src/main/java/com/getaxonflow/sdk/AxonFlow.java +++ b/src/main/java/com/getaxonflow/sdk/AxonFlow.java @@ -621,12 +621,12 @@ public CompletableFuture auditLLMCallAsync(AuditOptions options) { * AuditSearchRequest.builder() * .userEmail("analyst@company.com") * .startTime(Instant.now().minus(Duration.ofDays(7))) - * .requestType("llm_chat") + * .action("blocked") * .limit(100) * .build()); * * for (AuditLogEntry entry : response.getEntries()) { - * System.out.println(entry.getId() + ": " + entry.getQuerySummary()); + * System.out.println(entry.getId() + ": " + entry.getPolicyDecision()); * } * } * diff --git a/src/main/java/com/getaxonflow/sdk/types/AuditLogEntry.java b/src/main/java/com/getaxonflow/sdk/types/AuditLogEntry.java index d6405ee..036d608 100644 --- a/src/main/java/com/getaxonflow/sdk/types/AuditLogEntry.java +++ b/src/main/java/com/getaxonflow/sdk/types/AuditLogEntry.java @@ -15,6 +15,7 @@ */ package com.getaxonflow.sdk.types; +import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonProperty; import java.time.Instant; @@ -23,13 +24,25 @@ import java.util.Map; import java.util.Objects; -/** A single audit log entry representing an audited request or event. */ +/** + * A single audit log entry representing an audited request or event. + * + *

Deprecation note (getaxonflow/axonflow-enterprise#3254): seven fields of this class + * ({@code query_summary}, {@code success}, {@code blocked}, {@code risk_score}, {@code latency_ms}, + * {@code policy_violations}, {@code metadata}) have never been populated by any 9.x server. They + * remain in place and keep parsing (staying at their defaults against real servers) so existing + * code compiles, but they are deprecated and scheduled for removal in the next major. Read {@link + * #getPolicyDecision()} for the verdict, {@link #getPolicyDetails()} for violation context, and + * {@link #getResponseTimeMs()} for latency. Java does not allow {@code @Deprecated} on constructor + * parameters, so the deprecation is carried on the getters; the corresponding constructor + * parameters are equally deprecated. + */ @JsonIgnoreProperties(ignoreUnknown = true) public final class AuditLogEntry { /** - * Cross-border transfer-basis values recognized under Indonesia UU PDP Pasal 56, - * for the {@link #getTransferBasis()} field: + * Cross-border transfer-basis values recognized under Indonesia UU PDP Pasal 56, for the {@link + * #getTransferBasis()} field: * *

    *
  • {@code adequacy} — Pasal 56(a): destination with adequate protection @@ -38,10 +51,9 @@ public final class AuditLogEntry { *
  • {@code consent} — Pasal 56(c): explicit data-subject consent *
* - *

{@code safeguards} and {@code pasal_56b_dpa} are semantic equivalents; the - * platform surfaces whichever was recorded at decision time, verbatim. The field - * itself stays a {@code String} so the SDK never rejects a value a newer platform - * may add. (platform #2513 / epic #2508) + *

{@code safeguards} and {@code pasal_56b_dpa} are semantic equivalents; the platform surfaces + * whichever was recorded at decision time, verbatim. The field itself stays a {@code String} so + * the SDK never rejects a value a newer platform may add. (platform #2513 / epic #2508) */ public static final String TRANSFER_BASIS_ADEQUACY = "adequacy"; @@ -106,6 +118,66 @@ public final class AuditLogEntry { @JsonProperty("transfer_basis") private final String transferBasis; + @JsonProperty("policy_decision") + private final String policyDecision; + + @JsonProperty("policy_details") + private final Map policyDetails; + + @JsonProperty("response_time_ms") + private final Long responseTimeMs; + + /** + * Legacy constructor, retained so pre-#3254 callers keep compiling. Delegates to the canonical + * constructor with the three real-wire fields ({@code policy_decision}, {@code policy_details}, + * {@code response_time_ms}) absent. + */ + public AuditLogEntry( + String id, + String requestId, + Instant timestamp, + String userEmail, + String clientId, + String tenantId, + String requestType, + String querySummary, + Boolean success, + Boolean blocked, + Double riskScore, + String provider, + String model, + Integer tokensUsed, + Integer latencyMs, + List policyViolations, + Map metadata, + String dataResidency, + String transferBasis) { + this( + id, + requestId, + timestamp, + userEmail, + clientId, + tenantId, + requestType, + querySummary, + success, + blocked, + riskScore, + provider, + model, + tokensUsed, + latencyMs, + policyViolations, + metadata, + dataResidency, + transferBasis, + null, + null, + null); + } + + @JsonCreator public AuditLogEntry( @JsonProperty("id") String id, @JsonProperty("request_id") String requestId, @@ -125,7 +197,10 @@ public AuditLogEntry( @JsonProperty("policy_violations") List policyViolations, @JsonProperty("metadata") Map metadata, @JsonProperty("data_residency") String dataResidency, - @JsonProperty("transfer_basis") String transferBasis) { + @JsonProperty("transfer_basis") String transferBasis, + @JsonProperty("policy_decision") String policyDecision, + @JsonProperty("policy_details") Map policyDetails, + @JsonProperty("response_time_ms") Long responseTimeMs) { this.id = id != null ? id : ""; this.requestId = requestId != null ? requestId : ""; this.timestamp = timestamp != null ? timestamp : Instant.now(); @@ -145,6 +220,9 @@ public AuditLogEntry( this.metadata = metadata != null ? metadata : Collections.emptyMap(); this.dataResidency = dataResidency; this.transferBasis = transferBasis; + this.policyDecision = policyDecision != null ? policyDecision : ""; + this.policyDetails = policyDetails != null ? policyDetails : Collections.emptyMap(); + this.responseTimeMs = responseTimeMs; } /** Returns the unique audit log ID. */ @@ -182,22 +260,57 @@ public String getRequestType() { return requestType; } - /** Returns a summary of the query/request. */ + /** + * Returns a summary of the query/request. + * + * @deprecated never populated on the 9.x line - the server has never sent this field + * (getaxonflow/axonflow-enterprise#3254); the wire carries {@code query}/{@code query_hash}, + * not modeled in this interim. Read {@link #getPolicyDecision()} for the verdict, {@link + * #getPolicyDetails()} for violation context, and {@link #getResponseTimeMs()} for latency. + * Scheduled for removal in the next major. + */ + @Deprecated public String getQuerySummary() { return querySummary; } - /** Returns whether the request succeeded. */ + /** + * Returns whether the request succeeded. + * + * @deprecated never populated on the 9.x line - the server has never sent this field + * (getaxonflow/axonflow-enterprise#3254). Read {@link #getPolicyDecision()} for the verdict + * ({@code "allowed"} replaces {@code success=true}), {@link #getPolicyDetails()} for + * violation context, and {@link #getResponseTimeMs()} for latency. Scheduled for removal in + * the next major. + */ + @Deprecated public boolean isSuccess() { return success; } - /** Returns whether the request was blocked by policy. */ + /** + * Returns whether the request was blocked by policy. + * + * @deprecated never populated on the 9.x line - the server has never sent this field + * (getaxonflow/axonflow-enterprise#3254). Read {@link #getPolicyDecision()} for the verdict + * ({@code "blocked"} replaces {@code blocked=true}), {@link #getPolicyDetails()} for + * violation context, and {@link #getResponseTimeMs()} for latency. Scheduled for removal in + * the next major. + */ + @Deprecated public boolean isBlocked() { return blocked; } - /** Returns the calculated risk score (0.0-1.0). */ + /** + * Returns the calculated risk score (0.0-1.0). + * + * @deprecated never populated on the 9.x line - the server has never sent this field + * (getaxonflow/axonflow-enterprise#3254); it has no wire equivalent. Read {@link + * #getPolicyDecision()} for the verdict, {@link #getPolicyDetails()} for violation context, + * and {@link #getResponseTimeMs()} for latency. Scheduled for removal in the next major. + */ + @Deprecated public double getRiskScore() { return riskScore; } @@ -217,17 +330,42 @@ public int getTokensUsed() { return tokensUsed; } - /** Returns the request latency in milliseconds. */ + /** + * Returns the request latency in milliseconds. + * + * @deprecated never populated on the 9.x line - the server has never sent this field + * (getaxonflow/axonflow-enterprise#3254). Read {@link #getPolicyDecision()} for the verdict, + * {@link #getPolicyDetails()} for violation context, and {@link #getResponseTimeMs()} for + * latency. Scheduled for removal in the next major. + */ + @Deprecated public int getLatencyMs() { return latencyMs; } - /** Returns the list of violated policy IDs (if any). */ + /** + * Returns the list of violated policy IDs (if any). + * + * @deprecated never populated on the 9.x line - the server has never sent this field + * (getaxonflow/axonflow-enterprise#3254). Read {@link #getPolicyDecision()} for the verdict, + * {@link #getPolicyDetails()} for violation context, and {@link #getResponseTimeMs()} for + * latency. Scheduled for removal in the next major. + */ + @Deprecated public List getPolicyViolations() { return policyViolations; } - /** Returns additional metadata. */ + /** + * Returns additional metadata. + * + * @deprecated never populated on the 9.x line - the server has never sent this field + * (getaxonflow/axonflow-enterprise#3254); the wire carries {@code policy_details}/{@code + * security_metrics} instead. Read {@link #getPolicyDecision()} for the verdict, {@link + * #getPolicyDetails()} for violation context, and {@link #getResponseTimeMs()} for latency. + * Scheduled for removal in the next major. + */ + @Deprecated public Map getMetadata() { return metadata; } @@ -238,15 +376,45 @@ public String getDataResidency() { } /** - * Returns the cross-border transfer basis under Indonesia UU PDP Pasal 56 - * ({@code adequacy}, {@code safeguards}, {@code pasal_56b_dpa}, or - * {@code consent}), or null if not set. Surfaced verbatim — see the - * {@code TRANSFER_BASIS_*} constants. + * Returns the cross-border transfer basis under Indonesia UU PDP Pasal 56 ({@code adequacy}, + * {@code safeguards}, {@code pasal_56b_dpa}, or {@code consent}), or null if not set. Surfaced + * verbatim — see the {@code TRANSFER_BASIS_*} constants. */ public String getTransferBasis() { return transferBasis; } + /** + * Returns the policy verdict for this entry, as served on the wire ({@code policy_decision}). + * + *

This is an OPEN set of strings, not an enum: {@code allowed}, {@code blocked} and {@code + * redacted} are named in the server struct and {@code error} has been observed live, but newer + * servers may send values this SDK version has never seen. Compare against known strings; never + * assume exhaustiveness. Empty when the server omitted the field (pre-9.x servers or planes that + * do not record a verdict). + */ + public String getPolicyDecision() { + return policyDecision; + } + + /** + * Returns the policy decision context for this entry ({@code policy_details}), an object with + * arbitrary keys (e.g. {@code policy_matches}, {@code decision_id}, {@code error_message}). Empty + * when the server omitted the field. + */ + public Map getPolicyDetails() { + return policyDetails; + } + + /** + * Returns the server-measured response time in milliseconds ({@code response_time_ms}), or {@code + * null} when the server did not send the field (pre-9.x servers or non-LLM planes). Null-check + * before unboxing. + */ + public Long getResponseTimeMs() { + return responseTimeMs; + } + @Override public boolean equals(Object o) { if (this == o) return true; @@ -270,7 +438,10 @@ public boolean equals(Object o) { && Objects.equals(policyViolations, that.policyViolations) && Objects.equals(metadata, that.metadata) && Objects.equals(dataResidency, that.dataResidency) - && Objects.equals(transferBasis, that.transferBasis); + && Objects.equals(transferBasis, that.transferBasis) + && Objects.equals(policyDecision, that.policyDecision) + && Objects.equals(policyDetails, that.policyDetails) + && Objects.equals(responseTimeMs, that.responseTimeMs); } @Override @@ -294,7 +465,10 @@ public int hashCode() { policyViolations, metadata, dataResidency, - transferBasis); + transferBasis, + policyDecision, + policyDetails, + responseTimeMs); } @Override @@ -326,6 +500,11 @@ public String toString() { + ", transferBasis='" + transferBasis + '\'' + + ", policyDecision='" + + policyDecision + + '\'' + + ", responseTimeMs=" + + responseTimeMs + '}'; } } diff --git a/src/main/java/com/getaxonflow/sdk/types/AuditSearchRequest.java b/src/main/java/com/getaxonflow/sdk/types/AuditSearchRequest.java index d03689f..df85f55 100644 --- a/src/main/java/com/getaxonflow/sdk/types/AuditSearchRequest.java +++ b/src/main/java/com/getaxonflow/sdk/types/AuditSearchRequest.java @@ -55,6 +55,13 @@ public final class AuditSearchRequest { @JsonProperty("request_type") private final String requestType; + /** + * Filters by action/request type with verdict normalization on the server side. This is the + * filter the 9.x server actually reads; {@code request_type} is silently ignored (#3254). + */ + @JsonProperty("action") + private final String action; + /** Filter by decision ID (ADR-043). Gathers every audit record tied to one decision. */ @JsonProperty("decision_id") private final String decisionId; @@ -82,6 +89,7 @@ private AuditSearchRequest(Builder builder) { this.startTime = builder.startTime != null ? builder.startTime.toString() : null; this.endTime = builder.endTime != null ? builder.endTime.toString() : null; this.requestType = builder.requestType; + this.action = builder.action; this.decisionId = builder.decisionId; this.policyName = builder.policyName; this.overrideId = builder.overrideId; @@ -105,10 +113,23 @@ public String getEndTime() { return endTime; } + /** + * Returns the request-type filter. + * + * @deprecated the 9.x server does not read this filter; a search filtered only by it returns + * unfiltered results. Use {@link #getAction()} / {@link Builder#action(String)}. The SDK + * keeps sending it (harmless, ignored). Scheduled for removal in the next major (#3254). + */ + @Deprecated public String getRequestType() { return requestType; } + /** Returns the action filter (server-side verdict normalization applies). */ + public String getAction() { + return action; + } + public String getDecisionId() { return decisionId; } @@ -178,6 +199,7 @@ public static final class Builder { private Instant startTime; private Instant endTime; private String requestType; + private String action; private String decisionId; private String policyName; private String overrideId; @@ -210,15 +232,33 @@ public Builder endTime(Instant endTime) { return this; } - /** Filter by request type (e.g., "llm_chat", "policy_check"). */ + /** + * Filter by request type (e.g., "llm_chat", "policy_check"). + * + * @deprecated the 9.x server does not read this filter; a search filtered only by it returns + * unfiltered results. Use {@link #action(String)}. The SDK keeps sending it (harmless, + * ignored). Scheduled for removal in the next major (#3254). + */ + @Deprecated public Builder requestType(String requestType) { this.requestType = requestType; return this; } /** - * Filter by decision ID (ADR-043). Use to gather every audit record tied to a single - * decision — the explain-flow cross-reference pivot. + * Filters by action/request type with verdict normalization on the server side. The value is + * normalized to its canonical verdict (e.g. {@code allowed}, {@code blocked}, {@code redacted}, + * {@code error}) and expanded to every historical spelling of that verdict, so it matches both + * current and legacy rows. + */ + public Builder action(String action) { + this.action = action; + return this; + } + + /** + * Filter by decision ID (ADR-043). Use to gather every audit record tied to a single decision — + * the explain-flow cross-reference pivot. */ public Builder decisionId(String decisionId) { this.decisionId = decisionId; @@ -232,8 +272,8 @@ public Builder policyName(String policyName) { } /** - * Filter by session override ID (ADR-042). Use to reconstruct an override's full - * lifecycle (override_created → override_used → override_expired | override_revoked). + * Filter by session override ID (ADR-042). Use to reconstruct an override's full lifecycle + * (override_created → override_used → override_expired | override_revoked). */ public Builder overrideId(String overrideId) { this.overrideId = overrideId; diff --git a/src/test/java/com/getaxonflow/sdk/types/AuditRealWireModelTest.java b/src/test/java/com/getaxonflow/sdk/types/AuditRealWireModelTest.java new file mode 100644 index 0000000..90a67d7 --- /dev/null +++ b/src/test/java/com/getaxonflow/sdk/types/AuditRealWireModelTest.java @@ -0,0 +1,198 @@ +/* + * Copyright 2026 AxonFlow + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.getaxonflow.sdk.types; + +import static org.assertj.core.api.Assertions.*; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Real-wire audit model tests (getaxonflow/axonflow-enterprise#3254). + * + *

Fixture provenance: + * + *

    + *
  • {@code fixtures/audit-search-live.json} - REAL response, captured 2026-08-03 from an + * isolated community v9.13.0 stack (session 3254), via {@code POST /api/v1/audit/search} + * through the agent proxy. Verbatim, unmodified. Note what is on it: {@code policy_decision}, + * {@code policy_details}, {@code response_time_ms} - and what is NOT: {@code query_summary}, + * {@code success}, {@code blocked}, {@code risk_score}, {@code latency_ms}, {@code + * policy_violations}, {@code metadata}, the seven fiction fields the SDK modeled but no 9.x + * server ever served. + *
  • {@code fixtures/audit-search-old-server.json} - HAND-MODIFIED copy of the live capture with + * the three real-wire fields removed, simulating a pre-9.x server. + *
  • {@code fixtures/audit-search-both-present.json} - HAND-MODIFIED copy of the live capture + * with the seven fiction fields injected alongside the real ones, proving both parse with no + * collision. + *
+ * + *

The mapper mirrors the production one in {@code AxonFlow} (JavaTimeModule registered; unknown + * properties tolerated via the model's {@code @JsonIgnoreProperties}). + */ +@DisplayName("Audit model - real wire fields (#3254)") +class AuditRealWireModelTest { + + private ObjectMapper mapper; + + @BeforeEach + void setUp() { + mapper = new ObjectMapper(); + mapper.registerModule(new JavaTimeModule()); + } + + private String fixture(String name) throws Exception { + try (InputStream in = getClass().getResourceAsStream("/fixtures/" + name)) { + assertThat(in).as("fixture %s must exist on the test classpath", name).isNotNull(); + return new String(in.readAllBytes(), StandardCharsets.UTF_8); + } + } + + @Test + @SuppressWarnings("deprecation") + @DisplayName("real captured payload - new fields populated, fiction fields stay at defaults") + void realCapturedPayloadParses() throws Exception { + AuditSearchResponse response = + mapper.readValue(fixture("audit-search-live.json"), AuditSearchResponse.class); + + assertThat(response.getEntries()).hasSize(2); + assertThat(response.getTotal()).isEqualTo(2); + + AuditLogEntry error = response.getEntries().get(0); + assertThat(error.getPolicyDecision()).isEqualTo("error"); + assertThat(error.getPolicyDetails()) + .containsEntry("error_message", "blocked by policy sys_sqli_or_true") + .containsEntry("tool_name", "s3254_blocked_probe"); + assertThat(error.getResponseTimeMs()).isNotNull().isEqualTo(0L); + + AuditLogEntry allowed = response.getEntries().get(1); + assertThat(allowed.getPolicyDecision()).isEqualTo("allowed"); + assertThat(allowed.getPolicyDetails()).containsEntry("tool_name", "s3254_capture_probe"); + assertThat(allowed.getResponseTimeMs()).isNotNull().isEqualTo(0L); + + // The seven fiction fields are ABSENT on the real wire (see fixture + // provenance above) and must sit at their documented defaults. Note + // isSuccess() defaults TRUE even on the error-verdict row - exactly + // why it is fiction and deprecated. + for (AuditLogEntry e : response.getEntries()) { + assertThat(e.getQuerySummary()).isEmpty(); + assertThat(e.isSuccess()).isTrue(); + assertThat(e.isBlocked()).isFalse(); + assertThat(e.getRiskScore()).isEqualTo(0.0); + assertThat(e.getLatencyMs()).isEqualTo(0); + assertThat(e.getPolicyViolations()).isEmpty(); + assertThat(e.getMetadata()).isEmpty(); + } + } + + @Test + @DisplayName("old-server payload (three new fields absent) - parses, new fields default") + void oldServerPayloadTolerated() throws Exception { + AuditSearchResponse response = + mapper.readValue(fixture("audit-search-old-server.json"), AuditSearchResponse.class); + + assertThat(response.getEntries()).hasSize(2); + for (AuditLogEntry e : response.getEntries()) { + assertThat(e.getPolicyDecision()).isEmpty(); + assertThat(e.getPolicyDetails()).isEmpty(); + // Long responseTimeMs is null-safe: absent on the wire means null, + // never a throw and never a silent 0 that fakes a measurement. + assertThat(e.getResponseTimeMs()).isNull(); + } + } + + @Test + @SuppressWarnings("deprecation") + @DisplayName("fiction and real fields in one payload - both parse, no collision") + void bothPresentPayloadParses() throws Exception { + AuditSearchResponse response = + mapper.readValue(fixture("audit-search-both-present.json"), AuditSearchResponse.class); + + AuditLogEntry e = response.getEntries().get(0); + // Real fields, from the capture: + assertThat(e.getPolicyDecision()).isEqualTo("error"); + assertThat(e.getPolicyDetails()).containsEntry("tool_name", "s3254_blocked_probe"); + assertThat(e.getResponseTimeMs()).isEqualTo(0L); + // Fiction fields, hand-injected into the fixture: + assertThat(e.getQuerySummary()).isEqualTo("hand-injected summary"); + assertThat(e.isSuccess()).isTrue(); + assertThat(e.isBlocked()).isTrue(); + assertThat(e.getRiskScore()).isEqualTo(0.42); + assertThat(e.getLatencyMs()).isEqualTo(77); + assertThat(e.getPolicyViolations()).containsExactly("sys_sqli_or_true"); + assertThat(e.getMetadata()).containsEntry("hand_injected", true); + } + + @Test + @DisplayName("pre-#3254 constructor signature still compiles and delegates with defaults") + void oldConstructorSignatureStillCompiles() { + // Source-compatibility proof: this is the EXACT 19-argument constructor + // shape that existed before #3254. If the new fields had been added to + // the only constructor, this call would no longer compile. + AuditLogEntry entry = + new AuditLogEntry( + "audit-1", + "req-1", + Instant.parse("2026-01-05T10:00:00Z"), + "user@example.com", + "client-1", + "tenant-1", + "llm_chat", + "summary", + true, + false, + 0.1, + "openai", + "gpt-4", + 150, + 250, + java.util.Collections.emptyList(), + java.util.Collections.emptyMap(), + null, + null); + + assertThat(entry.getId()).isEqualTo("audit-1"); + assertThat(entry.getPolicyDecision()).isEmpty(); + assertThat(entry.getPolicyDetails()).isEmpty(); + assertThat(entry.getResponseTimeMs()).isNull(); + } + + @Test + @DisplayName("search request - action serialized under 'action', omitted when unset") + void searchRequestActionSerialization() throws Exception { + String withAction = + mapper.writeValueAsString(AuditSearchRequest.builder().action("blocked").build()); + assertThat(withAction).contains("\"action\":\"blocked\""); + + String withoutAction = mapper.writeValueAsString(AuditSearchRequest.builder().build()); + assertThat(withoutAction).doesNotContain("\"action\""); + } + + @Test + @SuppressWarnings("deprecation") + @DisplayName("search request - deprecated request_type still sent on the wire (harmless)") + void searchRequestRequestTypeStillSent() throws Exception { + String json = + mapper.writeValueAsString(AuditSearchRequest.builder().requestType("llm_chat").build()); + assertThat(json).contains("\"request_type\":\"llm_chat\""); + } +} diff --git a/src/test/resources/fixtures/audit-search-both-present.json b/src/test/resources/fixtures/audit-search-both-present.json new file mode 100644 index 0000000..5583580 --- /dev/null +++ b/src/test/resources/fixtures/audit-search-both-present.json @@ -0,0 +1,78 @@ +{ + "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, + "query_summary": "hand-injected summary", + "success": true, + "blocked": true, + "risk_score": 0.42, + "latency_ms": 77, + "policy_violations": [ + "sys_sqli_or_true" + ], + "metadata": { + "hand_injected": true + } + }, + { + "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/src/test/resources/fixtures/audit-search-live.json b/src/test/resources/fixtures/audit-search-live.json new file mode 100644 index 0000000..c2fd68a --- /dev/null +++ b/src/test/resources/fixtures/audit-search-live.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/src/test/resources/fixtures/audit-search-old-server.json b/src/test/resources/fixtures/audit-search-old-server.json new file mode 100644 index 0000000..4295bc2 --- /dev/null +++ b/src/test/resources/fixtures/audit-search-old-server.json @@ -0,0 +1,52 @@ +{ + "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": "", + "provider": "", + "model": "", + "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": "", + "provider": "", + "model": "", + "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/audit-binding-allowlist.json b/tests/fixtures/audit-binding-allowlist.json new file mode 100644 index 0000000..4a777bc --- /dev/null +++ b/tests/fixtures/audit-binding-allowlist.json @@ -0,0 +1,15 @@ +{ + "_comment": "Curated allowlist for wire-shape Gate 5 (audit-surface binding). Every entry is an SDK @JsonProperty field with NO backing property in the pinned OpenAPI schema - i.e. named, tracked debt. Entries here MUST carry a note naming the tracking issue. The gate fails on any unlisted unbound field and on any stale entry, so this file can only ever shrink toward empty. See scripts/wire_shape/validate.py.", + "AuditLogEntry": { + "query_summary": "Never served on the 9.x line; deprecated in-model, removal rides the next major (getaxonflow/axonflow-enterprise#3254). The wire carries query/query_hash instead.", + "success": "Never served on the 9.x line; deprecated in-model, removal rides the next major (getaxonflow/axonflow-enterprise#3254). policy_decision 'allowed' replaces success=true.", + "blocked": "Never served on the 9.x line; deprecated in-model, removal rides the next major (getaxonflow/axonflow-enterprise#3254). policy_decision 'blocked' replaces blocked=true.", + "risk_score": "Never served on the 9.x line; deprecated in-model, removal rides the next major (getaxonflow/axonflow-enterprise#3254). No wire equivalent.", + "latency_ms": "Never served on the 9.x line; deprecated in-model, removal rides the next major (getaxonflow/axonflow-enterprise#3254). response_time_ms is the real latency field.", + "policy_violations": "Never served on the 9.x line; deprecated in-model, removal rides the next major (getaxonflow/axonflow-enterprise#3254). policy_details carries violation context.", + "metadata": "Never served on the 9.x line; deprecated in-model, removal rides the next major (getaxonflow/axonflow-enterprise#3254). The wire carries policy_details/security_metrics instead." + }, + "AuditSearchRequest": { + "request_type": "The 9.x server does not read this filter (silent no-op); deprecated in-model in favor of action, removal rides the next major (getaxonflow/axonflow-enterprise#3254). Still sent on the wire, harmless." + } +} diff --git a/tests/fixtures/wire-shape-baseline.json b/tests/fixtures/wire-shape-baseline.json index fd28d03..41a3076 100644 --- a/tests/fixtures/wire-shape-baseline.json +++ b/tests/fixtures/wire-shape-baseline.json @@ -194,13 +194,10 @@ "error_message", "org_id", "plane", - "policy_decision", - "policy_details", "query", "query_hash", "redacted_fields", "response_sample", - "response_time_ms", "security_metrics", "session_id", "user_id", @@ -212,7 +209,6 @@ "request_type" ], "spec_only": [ - "action", "session_id" ] }, From 9733de9d20aa14b0f9a6613d0414aaec3b2602dd Mon Sep 17 00:00:00 2001 From: Saurabh Jain Date: Tue, 4 Aug 2026 00:57:00 +0200 Subject: [PATCH 2/3] fix(gate5): introspect compiled classes instead of source regex; R3 round-1 items Gate 5 rebuilt at the capability level (R3 items 1+2). The source-regex discovery in lib.py was proven bypassable two ways: a constant-valued annotation (@JsonProperty(SOME_CONSTANT) - the regex needs a quoted string, and the plain-field fallback attributes the JAVA field name, which can even credit the fiction as spec coverage) and Jackson getter auto-detection (an unannotated public getFoo() serializes foo with no annotation anywhere in the source). Gate 5 now asks Jackson itself: scripts/wire_shape/AuditWireKeysProbe.java runs against target/classes plus the resolved dependency classpath and emits, per bound type, the union of the serialization and deserialization bean descriptions - the exact property set the production ObjectMapper can put on or read off the wire. validate.py consumes that instead of the regex view for the bound types (gates 1-4 keep the regex discovery unchanged). Missing java, probe source, target/classes, classpath file, an unloadable class, unparseable probe output, or a bound type reporting zero keys all FAIL the gate - never skip. The wire-shape workflow gains a JDK setup and a compile step so CI provides the artifacts. Proofs executed against the rebuilt gate (outputs in the PR body): both reviewer evasions re-run and RED - the constant-valued annotation is named by its RESOLVED wire key fiction_const_field (not the java name), the unannotated getter is named as fictionFilter; negative control on the unmodified model GREEN; evasion sources reverted. Smaller R3 items: - Two added diff lines carried U+2014 from google-java-format rewraps of pre-existing prose (AuditLogEntry getTransferBasis Javadoc, AuditSearchRequest Builder.decisionId Javadoc): now hyphens. - New hand-modified fixture audit-search-explicit-null.json + test: explicit JSON null on all three new fields normalizes to "" / empty map / null Long through the real mapper, pinning the canonical constructor's null guards (Jackson passes explicit null to creators). - both-present fixture: injected success flipped true -> false so the assertion no longer matches the constructor default and can fail. - Test Javadoc no longer claims the mapper "mirrors" production; it states the configuration is the same and the instance is not. - CHANGELOG historic entries restored byte-for-byte from main; the diff now carries ONLY the Unreleased hunk. Signed-off-by: Saurabh Jain --- .github/workflows/wire-shape-contract.yml | 18 ++ CHANGELOG.md | 222 +++++++++--------- scripts/wire_shape/AuditWireKeysProbe.java | 78 ++++++ scripts/wire_shape/validate.py | 142 ++++++++--- .../getaxonflow/sdk/types/AuditLogEntry.java | 2 +- .../sdk/types/AuditSearchRequest.java | 2 +- .../sdk/types/AuditRealWireModelTest.java | 37 ++- .../fixtures/audit-search-both-present.json | 2 +- .../fixtures/audit-search-explicit-null.json | 58 +++++ 9 files changed, 415 insertions(+), 146 deletions(-) create mode 100644 scripts/wire_shape/AuditWireKeysProbe.java create mode 100644 src/test/resources/fixtures/audit-search-explicit-null.json diff --git a/.github/workflows/wire-shape-contract.yml b/.github/workflows/wire-shape-contract.yml index 8f63157..1ed54f1 100644 --- a/.github/workflows/wire-shape-contract.yml +++ b/.github/workflows/wire-shape-contract.yml @@ -134,6 +134,24 @@ jobs: - name: Install PyYAML run: pip install 'pyyaml>=6,<7' + # Gate 5 (audit-surface binding, #3254) introspects the COMPILED + # classes via Jackson (scripts/wire_shape/AuditWireKeysProbe.java) + # instead of trusting source-regex discovery, which is defeated by + # constant-valued @JsonProperty annotations and by Jackson getter + # auto-detection. The validator FAILS (never skips) if these + # artifacts are missing. + - name: Set up JDK 17 (Gate 5 wire-key introspection) + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + cache: 'maven' + + - name: Compile SDK classes + dependency classpath (Gate 5) + run: | + mvn -q -B compile dependency:build-classpath \ + -Dmdep.outputFile=target/wire-shape-cp.txt + - name: Run wire-shape contract validator env: AXONFLOW_OPENAPI_SPECS_DIR: ${{ github.workspace }}/axonflow-community/docs/api diff --git a/CHANGELOG.md b/CHANGELOG.md index a6b77fc..fd20eb8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,14 +17,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 observed live), not an enum. The pre-existing 19-argument `AuditLogEntry` constructor is retained and delegates to the new canonical constructor, so the change is source-compatible for direct constructor callers. -- Wire-shape Gate 5: audit-surface binding. Every `@JsonProperty` name on - `AuditLogEntry`, `AuditSearchRequest` and `AuditSearchResponse` must exist - in the pinned OpenAPI schema of the same name, with unbound fields allowed - only via the curated, note-carrying +- Wire-shape Gate 5: audit-surface binding. Every wire key the compiled + `AuditLogEntry`, `AuditSearchRequest` and `AuditSearchResponse` classes + actually map (introspected from the built classes via Jackson, so + constant-valued annotations and getter auto-detection are covered) must + exist in the pinned OpenAPI schema of the same name, with unbound fields + allowed only via the curated, note-carrying `tests/fixtures/audit-binding-allowlist.json`. Unlike Gate 3, this gate has no refresh path - a baseline that RECORDS drift is how seven never-served - fields shipped in the first place (#3254). An unresolvable binding (class or - schema missing) fails instead of skipping. + fields shipped in the first place (#3254). An unresolvable binding (class, + schema, or introspection probe missing) fails instead of skipping. ### Deprecated @@ -103,7 +105,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 dimension only. **Migration.** Policies or per-connector settings matching the old - concatenated value - e.g. `connector_type == "filesystem.read_file"` - stop + concatenated value — e.g. `connector_type == "filesystem.read_file"` — stop matching after upgrade. Re-scope them to match `connector_type == "filesystem"` together with the `tool` field (e.g. `tool == "read_file"`). The `connectorTypeFn` option is the compatibility lever: a caller can restore @@ -121,7 +123,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 **Minimum platform.** The `tool` field is consumed on `POST /api/v1/mcp/check-input` by **AxonFlow platform v9.10.0+**. On platforms below v9.10.0 the `tool` field is silently dropped and identity degrades to - the bare server name - coarser than the old concatenated value - so + the bare server name — coarser than the old concatenated value — so **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 @@ -131,29 +133,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **`examples/basic` passes on enterprise (JWT-validating) stacks.** It omitted the user token entirely (SDK falls back to `anonymous`), which - `DEPLOYMENT_MODE=enterprise` rejects - and the rejection was swallowed by + `DEPLOYMENT_MODE=enterprise` rejects — and the rejection was swallowed by the generic `AxonFlowException` catch with exit 0. The example now reads `AXONFLOW_USER_TOKEN` and exits non-zero on invalid-user-token rejections. ### Added -- `runtime-e2e/async_verdict_parity/` - live-agent assertion that +- `runtime-e2e/async_verdict_parity/` — live-agent assertion that `decideAsync`/`mcpCheckInputAsync` (joined) deliver the same enforcement verdict as their sync counterparts (async-adapter-bypass class): stacked SQLi → `deny` on `/api/v1/decide`, `allowed=false` on check-input, sync == async on both planes. -- `runtime-e2e/mcp_server_tool_split/` - live-agent assertion for the +- `runtime-e2e/mcp_server_tool_split/` — live-agent assertion for the `connector_type`/`tool` split: `LangGraphAdapter.mcpToolInterceptor()` round-trips a clean tool call through check-input/check-output with the server and tool names as two distinct wire fields, a direct `mcpCheckInput(..., options)` call with an explicit `tool` option is accepted, and the two-argument `mcpCheckInput(connectorType, statement)` overload (no `tool` field) still works unchanged. -- **`AuditToolCallRequest.callerName` (wire: `caller_name`)** - identifies +- **`AuditToolCallRequest.callerName` (wire: `caller_name`)** — identifies WHICH CLIENT made a tool call (e.g. `claude_code`, `codex`, `cursor`, `openclaw`), replacing the misleadingly-named `toolType` field for that - purpose. `toolType` is kept as a **deprecated** input fallback - not + purpose. `toolType` is kept as a **deprecated** input fallback — not removed, not renamed; the server resolves `caller_name` if supplied, else the legacy `tool_type`, else a default. @@ -178,11 +180,11 @@ Patch release. No public API changes. escape hatch for local self-signed certificates is unchanged. Clears CodeQL `java/insecure-trustmanager` (alert #8). -## [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 +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. @@ -192,13 +194,13 @@ platform's). ### Added -- **`AxonFlow.decide(DecideRequest)`** - the PDP step. `POST /api/v1/decide` +- **`AxonFlow.decide(DecideRequest)`** — the PDP step. `POST /api/v1/decide` returns a `DecideResponse` whose `getObligations()` is always a (possibly empty) 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 `AuthenticationException`. A `deny` verdict is returned in the body (HTTP 200), not as an error. `decideAsync(...)` mirror provided. -- **`AxonFlow.fulfillRequest(DecideResponse, String)`** - discharges every +- **`AxonFlow.fulfillRequest(DecideResponse, String)`** — discharges every request-phase `redact_pii` obligation by POSTing the statement to the engine's `check-input` endpoint and returning the **engine-redacted** statement (`FulfillResult`: content + `didRedact()`). Fails closed with @@ -206,7 +208,7 @@ platform's). fulfillment, advertises a content-type the 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.decideAndFulfill(DecideRequest)`** - the blessed one-call path +- **`AxonFlow.decideAndFulfill(DecideRequest)`** — the blessed one-call path (decide, then fulfill any request-phase obligation; `DecideAndFulfillResult` carries verdict, content, and decision); fail-closed by construction. `decideAndFulfillAsync(...)` mirror provided. @@ -221,11 +223,11 @@ platform's). endpoint-path constants). - **`redacted` / `redactedStatement` / `redactionEvaluated` on `MCPCheckInputResponse`** and **`redactionEvaluated` 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 `redactionEvaluated` is false. - **`contentType` on `MCPCheckInputRequest`** (new 5-arg constructor) and a - `content_type` option on `mcpCheckInput(connectorType, statement, options)` - + `content_type` option on `mcpCheckInput(connectorType, statement, options)` — selects the request-redaction detector (defaults to `text/plain` server-side). @@ -238,13 +240,13 @@ platform's). - Existing source-compatible `MCPCheckInputResponse` / `MCPCheckOutputResponse` constructors are preserved; the new fields default to `false` / `null`. -## [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`** — `Map` (nullable). 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-*`), @@ -267,7 +269,7 @@ Targets AxonFlow platform **v8.5.0**. reading `safeguards` is unaffected and the SDK never rejects a value a newer platform may add. -## [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 @@ -280,7 +282,7 @@ Targets AxonFlow platform **v8.5.0**. `transferBasis` is one of `adequacy`, `safeguards`, or `consent`. Both are nullable for backward compatibility with older platform versions. -## [8.2.0] - 2026-05-23 - `createHITLRequest` for explicit HITL row creation +## [8.2.0] - 2026-05-23 — `createHITLRequest` for explicit HITL row creation Enables agent-framework callers (Google ADK, n8n, OpenAI Agents SDK) to implement the full 4-step HITL approval flow against AxonFlow: @@ -305,7 +307,7 @@ SDK surface was missing. framework, an expiry override, and the new `notifyUrl` callback. Server-side `X-Org-ID` / `X-Tenant-ID` headers are derived by the platform's auth middleware from the SDK client's configured - credentials - callers do not pass them through this method. + credentials — callers do not pass them through this method. - **`HITLCreateInput` POJO + Builder** in `com.getaxonflow.sdk.types.hitl.HITLTypes` mirroring `platform/agent/hitl/handler.go:86 CreateRequestInput`. @@ -338,12 +340,12 @@ and `Idempotency-Key` request deduplication. Cross-SDK parity sweep: getaxonflow/axonflow-enterprise#2421. -## [8.1.0] - 2026-05-22 - `X-Client-ID` header on every outbound request + `org_id` in telemetry heartbeat + retry-config doc honesty +## [8.1.0] - 2026-05-22 — `X-Client-ID` header on every outbound request + `org_id` in telemetry heartbeat + retry-config doc honesty 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 `clientId` is configured. ### Added @@ -353,7 +355,7 @@ when no `clientId` 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 Java SDK - telemetry up to parity with the platform - every heartbeat now + 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 @@ -372,7 +374,7 @@ when no `clientId` 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. @@ -385,30 +387,30 @@ when no `clientId` is configured. the actual `initialDelay(Duration)` / `maxDelay(Duration)` builders. Surrounding prose documents the real retry contract: retries fire on connect/timeout, 5xx, and 429; 401/403 and other 4xx are always - terminal. Documentation-only - no code or behavior change. + terminal. Documentation-only — no code or behavior change. ### Compatibility - 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: `listDecisions` for paging through recorded decisions, plus a runnable example showing the full record → list → explain audit flow. Bundled into a major because the v8 line also tightens the telemetry -contract - see `Removed` at the bottom of this entry for that. +contract — see `Removed` at the bottom of this entry for that. ### Added - **`listDecisions(ListDecisionsOptions opts)` client method.** Pages over recorded decision history from the orchestrator, mirroring `GET /api/v1/decisions`. Companion to the v7.4.0 `getDecisionExplain` - method - callers can now both list and drill in. See + method — callers can now both list and drill in. See `examples/list-decisions/`. - **`examples/explain-decision/`** end-to-end runnable example covering the full decision audit flow: record → list → explain. @@ -419,7 +421,7 @@ contract - see `Removed` at the bottom of this entry for that. called `.telemetry(true)` or `.telemetry(false)` on the builder will fail to compile. Migration: remove the call from your builder chain. 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. If you were using it to force-enable, the default is now ON for every mode so the override is no longer needed. @@ -438,9 +440,9 @@ contract - see `Removed` at the bottom of this entry for that. ### Telemetry - **`AXONFLOW_TELEMETRY=off` is the sole opt-out.** `AxonFlowConfig.Builder.telemetry(Boolean)` + `AxonFlowConfig.getTelemetry()` removed; sandbox-mode clients (constructed via `Mode.SANDBOX`) 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, new `DeploymentMode` constants class on the SDK side. Existing receivers continue working unchanged - strictly additive. `EndpointType.COMMUNITY_SAAS` is removed (now lives on `deployment_mode` instead). +- **Heartbeat payload v1 schema additions** on the wire: new `telemetry_type` and `deployment_mode` fields, new `DeploymentMode` constants class on the SDK side. Existing receivers continue working unchanged — strictly additive. `EndpointType.COMMUNITY_SAAS` is removed (now lives on `deployment_mode` instead). -## [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 Java SDK now sends an `X-Axonflow-Client` identification header on every governed request, which @@ -470,23 +472,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** / **Python SDK v7.1.0** / - **TypeScript SDK v7.1.0** - same `X-Axonflow-Client` injection -- **Plugins** - Claude Code / Cursor / Codex v1.2.0; OpenClaw v2.2.0 + **TypeScript 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 `createWebhook` - 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-248h-974q-xrc2`](https://github.com/getaxonflow/axonflow-sdk-java/security/advisories/GHSA-248h-974q-xrc2). +- **Webhook signing-key now exposed by SDK response type** (this release). The `secret` (HMAC-SHA256) field on `WebhookSubscription` — returned by `createWebhook` — 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-248h-974q-xrc2`](https://github.com/getaxonflow/axonflow-sdk-java/security/advisories/GHSA-248h-974q-xrc2). - **`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. Maven Surefire and Failsafe environment blocks were tightened so local `mvn test` runs no longer inherit a host `DO_NOT_TRACK=1` and emit accidental pings. - **Test-harness opt-out hygiene** (last cycle, v6.x). Test environments that mutate `DO_NOT_TRACK` no longer silently leak real pings from CI; transport is mocked at the test boundary. @@ -494,7 +496,7 @@ Major release across the AxonFlow SDK family. Companion releases ship the same d **Reliability and bug-fix highlights:** - **`retry_context` + `idempotency_key` for cross-step de-duplication** (last cycle, v6.x). Workflow steps that retry across pod restarts no longer record duplicate audit entries; idempotency_key flows end-to-end through MAP HITL approve/reject responses. -- **`mapTimeout` config field - SDK parity with Go / Python / TypeScript** (last cycle, v6.x). MAP plan generation has its own timeout knob distinct from the per-request timeout, so multi-LLM-call decompositions no longer cancel the wrong path under load. +- **`mapTimeout` config field — SDK parity with Go / Python / TypeScript** (last cycle, v6.x). MAP plan generation has its own timeout knob distinct from the per-request timeout, so multi-LLM-call decompositions no longer cancel the wrong path under load. - **`LLMProvider` source compatibility restored** (last cycle, v6.x). The 7-arg primitive constructor and primitive `getPriority()` / `getWeight()` accessors are back; null-safe boxed accessors split off as `getPriorityBoxed()` / `getWeightBoxed()` for callers needing "explicitly 0 vs not set" disambiguation. ### BREAKING @@ -504,7 +506,7 @@ Major release across the AxonFlow SDK family. Companion releases ship the same d ### Security -- **TLS verification bypass closed (CWE-295).** `HttpClientFactory` previously honored `insecureSkipVerify(true)` on `AxonFlowConfig` as a single-flag opt-in to a permissive `X509TrustManager` that accepted ANY server certificate, including attacker-presented certificates in MITM scenarios. The insecure path is now double-gated: it activates only if both `insecureSkipVerify(true)` is set on the builder AND the `AXONFLOW_INSECURE_TLS` environment variable is set to `true` (or `1`). When the builder flag is set without the env var, the SDK logs a warning and keeps the JVM's default `TrustManager` in place. A loud `*** SECURITY WARNING ***` is logged whenever the insecure path actually activates. Default behavior - and behavior in production environments without the env var - uses standard JDK + system trust-store validation. Resolves code-scanning alert #8. +- **TLS verification bypass closed (CWE-295).** `HttpClientFactory` previously honored `insecureSkipVerify(true)` on `AxonFlowConfig` as a single-flag opt-in to a permissive `X509TrustManager` that accepted ANY server certificate, including attacker-presented certificates in MITM scenarios. The insecure path is now double-gated: it activates only if both `insecureSkipVerify(true)` is set on the builder AND the `AXONFLOW_INSECURE_TLS` environment variable is set to `true` (or `1`). When the builder flag is set without the env var, the SDK logs a warning and keeps the JVM's default `TrustManager` in place. A loud `*** SECURITY WARNING ***` is logged whenever the insecure path actually activates. Default behavior — and behavior in production environments without the env var — uses standard JDK + system trust-store validation. Resolves code-scanning alert #8. ### Fixed @@ -512,45 +514,45 @@ Major release across the AxonFlow SDK family. Companion releases ship the same d ### Changed -- **Telemetry now follows the 7-day delivered-heartbeat contract** instead of firing on every `new AxonFlow()` construction. The SDK emits at most one anonymous heartbeat per environment every 7 days during SDK activity. A stamp file at the OS-native user cache dir tracks last successful delivery; mtime is the source of truth across process restarts. Failed POSTs do NOT advance the stamp - a transient network error does not silence telemetry for 7 days. An in-memory 1-hour cache caps `Files.getLastModifiedTime` calls on hot request paths; a `ReentrantLock`-guarded in-flight flag coalesces concurrent threads so only one ping fires under load. `AXONFLOW_TELEMETRY=off` is re-evaluated on every gate run. Restricted environments where no cache dir is available (e.g. AWS Lambda with no `HOME`/`LOCALAPPDATA`) fall back transparently to the previous "one ping per construction" behavior. +- **Telemetry now follows the 7-day delivered-heartbeat contract** instead of firing on every `new AxonFlow()` construction. The SDK emits at most one anonymous heartbeat per environment every 7 days during SDK activity. A stamp file at the OS-native user cache dir tracks last successful delivery; mtime is the source of truth across process restarts. Failed POSTs do NOT advance the stamp — a transient network error does not silence telemetry for 7 days. An in-memory 1-hour cache caps `Files.getLastModifiedTime` calls on hot request paths; a `ReentrantLock`-guarded in-flight flag coalesces concurrent threads so only one ping fires under load. `AXONFLOW_TELEMETRY=off` is re-evaluated on every gate run. Restricted environments where no cache dir is available (e.g. AWS Lambda with no `HOME`/`LOCALAPPDATA`) fall back transparently to the previous "one ping per construction" behavior. ### CI / development - CI workflows (`ci.yml`, `integration.yml`, `release.yml`, `wire-shape-contract.yml`, `validate-version-alignment.yml`) now use `AXONFLOW_TELEMETRY=off` to suppress telemetry during automated runs. -## [6.2.0] - 2026-04-28 - listLLMProviders() + LLMProvider source-compat +## [6.2.0] - 2026-04-28 — listLLMProviders() + LLMProvider source-compat Minor release. New LLM-provider listing API closes the parity gap with the Python + Go SDKs; the rest of the cycle restores `LLMProvider` source-compatibility for callers using the 7-arg primitive shape. Coordinated cycle: TypeScript v6.2.0 / Python v6.9.0 / Go v6.0.0 (major: see SDKCompatibility breaking type change in that release) ship same day. ### Added -- **`axonflow.listLLMProviders()`** + `listLLMProviders(String type, Boolean enabled)` - list configured LLM providers and their per-provider health snapshot. Calls `GET /api/v1/llm-providers`. New `LLMProvider` and `LLMProviderHealth` types in `com.getaxonflow.sdk.types`. Async variant `listLLMProvidersAsync()`. Closes the parity gap with the Python SDK's `list_providers()` and the Go SDK's `ListProviders()`. -- **`examples/basic/`** - minimal smoke example exercising `healthCheck()`, `proxyLLMCall()`, and `listConnectors()` against a running AxonFlow agent. Uses try-with-resources so OkHttp's dispatcher + connection pool are cleaned up at exit. Run via `mvn -q compile exec:java` after `mvn install -DskipTests` at the SDK root. +- **`axonflow.listLLMProviders()`** + `listLLMProviders(String type, Boolean enabled)` — list configured LLM providers and their per-provider health snapshot. Calls `GET /api/v1/llm-providers`. New `LLMProvider` and `LLMProviderHealth` types in `com.getaxonflow.sdk.types`. Async variant `listLLMProvidersAsync()`. Closes the parity gap with the Python SDK's `list_providers()` and the Go SDK's `ListProviders()`. +- **`examples/basic/`** — minimal smoke example exercising `healthCheck()`, `proxyLLMCall()`, and `listConnectors()` against a running AxonFlow agent. Uses try-with-resources so OkHttp's dispatcher + connection pool are cleaned up at exit. Run via `mvn -q compile exec:java` after `mvn install -DskipTests` at the SDK root. ### Fixed - **`LLMProvider` source compatibility restored.** The 7-arg primitive constructor `LLMProvider(name, type, enabled:bool, priority:int, weight:int, hasApiKey:bool, health)` is back (delegates to the new 13-arg boxed form, marked `@Deprecated` so new callers move to the boxed shape). `getPriority()` / `getWeight()` return primitive `int` again (null-safe-unbox to 0). Boxed accessors are available as `getPriorityBoxed()` / `getWeightBoxed()` / `getEnabledBoxed()` / `getHasApiKeyBoxed()` for callers that need to distinguish "explicitly 0" from "field not present". -## [6.1.0] - 2026-04-25 - Plugin Batch 1 explainability fields on MCP responses +## [6.1.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 - additive only, no breaking changes. The pre-existing constructors are preserved as source-compat overloads. 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 — additive only, no breaking changes. The pre-existing constructors are preserved as source-compat overloads. Documented in OpenAPI via platform v7.4.3. Coordinated cycle: TypeScript v6.1.0 / Python v6.8.0 / Go v5.8.0 ship same day with the same field set. ### Added - **`MCPCheckInputResponse`** gains 5 optional Plugin Batch 1 fields: - - `decisionId: String` - audit correlator - - `riskLevel: String` - `low` | `medium` | `high` | `critical` - - `policyMatches: List` - per-policy explainability records - - `overrideAvailable: Boolean` - whether session override is permitted for the matched policies (boxed so callers can distinguish "unset" from `false` on older platforms) - - `overrideExistingId: String` - already-active override consumed by this decision (if any) + - `decisionId: String` — audit correlator + - `riskLevel: String` — `low` | `medium` | `high` | `critical` + - `policyMatches: List` — per-policy explainability records + - `overrideAvailable: Boolean` — whether session override is permitted for the matched policies (boxed so callers can distinguish "unset" from `false` on older platforms) + - `overrideExistingId: String` — already-active override consumed by this decision (if any) - **`MCPCheckOutputResponse`** gains 3 optional fields: - `decisionId: String` - `policyMatches: List` - - `redactedMessage: String` - text-redaction counterpart to `redactedData` (used when the connector returned a string message rather than tabular rows; e.g. execute-style responses) + - `redactedMessage: String` — text-redaction counterpart to `redactedData` (used when the connector returned a string message rather than tabular rows; e.g. execute-style responses) -`ExplainPolicy` already shipped - same Jackson-annotated record now reused on the MCP response types. Pre-v7.1.0 platforms leave all new fields as `null`; callers should treat `null` as "context not available" rather than an error. +`ExplainPolicy` already shipped — same Jackson-annotated record now reused on the MCP response types. Pre-v7.1.0 platforms leave all new fields as `null`; callers should treat `null` as "context not available" rather than an error. ### Source compatibility @@ -560,17 +562,17 @@ Both `MCPCheckInputResponse` and `MCPCheckOutputResponse` retain their v6.0.0 co `client.explainDecision(decisionId)` and the full `ExplainRule` / `DecisionExplanation` type surface are tracked separately as feature work. This release ships only field-surfacing on existing methods. -## [6.0.0] - 2026-04-25 - Major: WebhookSubscription identity-based equality +## [6.0.0] - 2026-04-25 — Major: WebhookSubscription identity-based equality This is a major release. The bump is driven by a single observable-contract change: `WebhookSubscription.equals()` and `.hashCode()` now compare on `id` only, not every field. Coordinated with the TypeScript SDK v6.0.0 release (PolicyInfo rename) as a v6 alignment cycle for the SDKs that needed breaking changes; Python (v6.7.0) and Go (v5.7.0) ship as minor on the same day because their changes are purely additive. -### BREAKING - `WebhookSubscription` equality is now identity-based on `id` +### BREAKING — `WebhookSubscription` equality is now identity-based on `id` `WebhookSubscription` is an entity, not a value object. Two instances with the same `id` represent the same logical webhook regardless of whether one view has loaded `secret` (only returned by `createWebhook`) and another has not, or whether `updatedAt` / `active` have moved between fetches. Previously `equals()` / `hashCode()` compared every field. That meant a webhook constructed locally with the legacy 6-arg constructor compared **unequal** to the same logical webhook deserialized from a server response that included `secret` / `tenantId` / `orgId`. `Set`, `Map` keying, and identity-tracking caches all broke under those semantics. -Identity-based equality is the canonical entity semantics; the prior value-based equality was a bug. Because `equals()` / `hashCode()` are part of the observable Java contract that callers depend on for set deduplication, map lookup, and identity caches, the fix is a breaking change per strict semver - even though the new behaviour corrects incorrect semantics rather than introducing them. +Identity-based equality is the canonical entity semantics; the prior value-based equality was a bug. Because `equals()` / `hashCode()` are part of the observable Java contract that callers depend on for set deduplication, map lookup, and identity caches, the fix is a breaking change per strict semver — even though the new behaviour corrects incorrect semantics rather than introducing them. If you need content-equality (e.g. to detect a rotated `secret`), compare the relevant getters directly. The 6-arg constructor is preserved as a source-compat overload for callers building local instances; only `equals()` / `hashCode()` semantics changed. `toString()` is unchanged (still emits full state with `secret` redacted). @@ -578,8 +580,8 @@ If you need content-equality (e.g. to detect a rotated `secret`), compare the re - **Version alignment check** (`.github/workflows/validate-version-alignment.yml`). CI now fails any PR or push to `main` where `pom.xml`'s `` drifts from the first released `## [X.Y.Z]` section in `CHANGELOG.md`. Matches the pattern in the platform repo and the Go SDK. - **Wire-shape contract gate** (`.github/workflows/wire-shape-contract.yml`). CI fails any PR that introduces drift between Java `@JsonProperty` annotations and the OpenAPI specs pinned at `tests/fixtures/wire-shape-baseline.json::openapi_specs_sha`. Four gates: cross-spec schema divergence, intra-file schema duplicates, per-type SDK-vs-spec drift, and registered-type rename-escape. The pinned spec SHA is itself guarded by a `spec-pin-bump` PR label so a single PR can't both move the SHA and silence drift. Source-discovery walks brace depth so nested classes (e.g. `WorkflowTypes.CreateWorkflowRequest`) and inner enums are attributed to the correct type rather than the file's outer class. Mirrors the Python, Go, and TypeScript gates. -- **`WebhookSubscription.secret`** - HMAC-SHA256 signing key now exposed on the response from `createWebhook`. Required to verify the `X-AxonFlow-Signature` header on inbound webhook deliveries; without it, callers can't validate payload authenticity. Also adds `tenantId` and `orgId` (ownership scoping). The 6-arg constructor is preserved as a source-compat overload that delegates to the 9-arg with nulls for the new fields. `toString()` redacts `secret` to avoid log leakage. -- **`BudgetAlert.acknowledged`** - alert dismissal flag. Also adds `@JsonProperty` annotations on previously-unannotated fields (`id`, `threshold`, `message`) so the wire-shape gate can see them; Jackson's default name mapping was correct, but the validator's discovery walks `@JsonProperty` only. +- **`WebhookSubscription.secret`** — HMAC-SHA256 signing key now exposed on the response from `createWebhook`. Required to verify the `X-AxonFlow-Signature` header on inbound webhook deliveries; without it, callers can't validate payload authenticity. Also adds `tenantId` and `orgId` (ownership scoping). The 6-arg constructor is preserved as a source-compat overload that delegates to the 9-arg with nulls for the new fields. `toString()` redacts `secret` to avoid log leakage. +- **`BudgetAlert.acknowledged`** — alert dismissal flag. Also adds `@JsonProperty` annotations on previously-unannotated fields (`id`, `threshold`, `message`) so the wire-shape gate can see them; Jackson's default name mapping was correct, but the validator's discovery walks `@JsonProperty` only. ### Fixed @@ -590,27 +592,27 @@ If you need content-equality (e.g. to detect a rotated `secret`), compare the re ### Added -- **Rich `ApproveStepResponse` / `RejectStepResponse`** - both classes now carry +- **Rich `ApproveStepResponse` / `RejectStepResponse`** — both classes now carry the same shape as the step-gate response: `decision` resolves to `"allow"` or `"block"`, `retryContext` mirrors the gate response retry state, `approvedBy` / `approvedAt` / `rejectedBy` / `rejectedAt` carry reviewer identity, `approvalId` is the deterministic HITL queue UUID, `policiesMatched` reconstructs the governance trail. The legacy `workflowId` / `stepId` / `status` fields remain for back-compat. -- **`planId` on approve/reject responses** - populated when the response comes +- **`planId` on approve/reject responses** — populated when the response comes from the MAP plan-scoped endpoint; empty on WCP plane responses. Same types work across both endpoints. -- **Back-compat 3-arg constructors** - `new ApproveStepResponse(workflowId, stepId, status)` +- **Back-compat 3-arg constructors** — `new ApproveStepResponse(workflowId, stepId, status)` and `new RejectStepResponse(workflowId, stepId, status)` still compile, so existing test fixtures and SDK consumers keep working without changes. -- **`getPendingPlanApprovals` / `getPendingPlanApprovalsAsync`** - new client +- **`getPendingPlanApprovals` / `getPendingPlanApprovalsAsync`** — new client methods that list MAP-plane pending approvals (`GET /api/v1/plans/approvals/pending`), the counterpart of `getPendingApprovals` for the WCP plane. The two-arg form accepts an optional `planId` filter so reviewer tools can scope the listing to one plan. Available on Evaluation+ licenses (same tier gate as the MAP step approve/reject endpoints). -- **`PendingApproval.planId`** - populated on MAP-plane entries, null on +- **`PendingApproval.planId`** — populated on MAP-plane entries, null on WCP-plane entries. Mirrors the approve/reject asymmetry. `PendingApproval` also gains `stepIndex`, `decision`, `decisionReason`, and `approvalStatus` so reviewer tools can render the full approval context without a second @@ -619,7 +621,7 @@ If you need content-equality (e.g. to detect a rotated `secret`), compare the re ### Fixed -- **`approveStep` / `rejectStep` / `getPendingApprovals` endpoint URLs** - +- **`approveStep` / `rejectStep` / `getPendingApprovals` 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)` @@ -627,7 +629,7 @@ If you need content-equality (e.g. to detect a rotated `secret`), compare the re methods against a live deployment were receiving 404s; this release makes them work. - **`PendingApprovalsResponse` getters and JSON field names aligned with the - wire shape** - the class previously declared `getApprovals()` / `getTotal()` + wire shape** — the class previously declared `getApprovals()` / `getTotal()` over a JSON body with keys `approvals` / `total`, which never matched the server (`pending_approvals` / `count`). Getters renamed to `getPendingApprovals()` / `getCount()` with the correct JSON bindings. @@ -636,47 +638,47 @@ If you need content-equality (e.g. to detect a rotated `secret`), compare the re ### 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 - `approveStep(workflowId, stepId)` / `rejectStep(workflowId, stepId, reason)` - method signatures on `AxonFlow` are unchanged - only the response fields grew. + method signatures on `AxonFlow` are unchanged — only the response fields grew. ## [5.6.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 `RetryContext` object on every gate call with the true `(workflow_id, step_id)` lifecycle: `gateCount`, `completionCount`, - `priorCompletionStatus` (`PriorCompletionStatus` enum - + `priorCompletionStatus` (`PriorCompletionStatus` enum — `NONE` / `COMPLETED` / `GATED_NOT_COMPLETED`), `priorOutputAvailable`, `priorOutput`, `priorCompletionAt`, `firstAttemptAt`, `lastAttemptAt`, `lastDecision`, and `idempotencyKey`. Prefer these to the legacy `cached` / `decisionSource` fields. -- **`stepGate(workflowId, stepId, request, options)` overload** - new 4-arg overload +- **`stepGate(workflowId, stepId, request, options)` overload** — new 4-arg overload taking `StepGateOptions`. Use `StepGateOptions.includePriorOutput()` to send `?include_prior_output=true` so `retryContext.priorOutput` is populated when a prior `/complete` has landed. Existing 3-arg overload keeps its signature and delegates with `StepGateOptions.defaults()`. -- **`StepGateRequest.idempotencyKey`** - caller-supplied opaque business-level key +- **`StepGateRequest.idempotencyKey`** — caller-supplied opaque business-level key (max 255 chars; validated at construction). Immutable once recorded on the first gate call for a `(workflow, step)`; subsequent gate/complete calls must pass the same key. -- **`MarkStepCompletedRequest.idempotencyKey`** - must match the key set on the +- **`MarkStepCompletedRequest.idempotencyKey`** — must match the key set on the corresponding gate call, if any. Mismatch (including missing-vs-set on either side) surfaces as a typed `IdempotencyKeyMismatchException`. -- **`IdempotencyKeyMismatchException`** - new typed exception in +- **`IdempotencyKeyMismatchException`** — new typed exception in `com.getaxonflow.sdk.exceptions`. Thrown by `stepGate` and `markStepCompleted` when the platform returns HTTP 409 with `error.code == "IDEMPOTENCY_KEY_MISMATCH"`. Surfaces `workflowId`, `stepId`, `expectedIdempotencyKey`, `receivedIdempotencyKey`, plus inherited `statusCode=409` and `errorCode="IDEMPOTENCY_KEY_MISMATCH"`. -- **`RetryContext`, `PriorCompletionStatus`, `StepGateOptions`** - exported in +- **`RetryContext`, `PriorCompletionStatus`, `StepGateOptions`** — exported in `WorkflowTypes`. ### Fixed -- **409 dispatch on step gate/complete** - previously all 409 responses on +- **409 dispatch on step gate/complete** — previously all 409 responses on `markStepCompleted` fell through to a generic `AxonFlowException(..., 409, "VERSION_CONFLICT")`, conflating step idempotency conflicts with plan version conflicts. The step gate/complete call sites now inspect the 409 body and dispatch @@ -686,7 +688,7 @@ If you need content-equality (e.g. to detect a rotated `secret`), compare the re ### Deprecated -- **`StepGateResponse.isCached()`** and **`StepGateResponse.getDecisionSource()`** - +- **`StepGateResponse.isCached()`** and **`StepGateResponse.getDecisionSource()`** — marked `@Deprecated`. Use `getRetryContext().getGateCount() > 1` and `getRetryContext().getPriorCompletionStatus()` instead. Planned for removal in a future major version. @@ -694,7 +696,7 @@ If you need content-equality (e.g. to detect a rotated `secret`), compare the re ### 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 `idempotencyKey` or pass `StepGateOptions` see no behavior change. Binary-compatibility preserved: old `StepGateRequest`, `StepGateResponse`, and `MarkStepCompletedRequest` constructors kept alongside new ones. @@ -703,7 +705,7 @@ change. Binary-compatibility preserved: old `StepGateRequest`, `StepGateResponse ### Added -- **`mapTimeout` field on `AxonFlowConfig`** - brings Java to parity with +- **`mapTimeout` field on `AxonFlowConfig`** — brings Java to parity with the TypeScript, Python, and Go SDKs (all three already had a separate MAP timeout). The shared `timeout` (default 60s) only covered single- request endpoints; MAP plans routinely take 60-120s because they @@ -725,19 +727,19 @@ change. Binary-compatibility preserved: old `StepGateRequest`, `StepGateResponse ### Added -- **Execution boundary semantics** - `retryPolicy` field on `StepGateRequest` +- **Execution boundary semantics** — `retryPolicy` field on `StepGateRequest` (via builder: `.retryPolicy("reevaluate")`). Controls cached vs fresh evaluation for the same step boundary. -- **Step gate response metadata** - `cached` (boolean) and `decisionSource` +- **Step gate response metadata** — `cached` (boolean) and `decisionSource` (String) fields on `StepGateResponse` via `isCached()` and `getDecisionSource()`. -- **Workflow checkpoints** - `getCheckpoints(workflowId)` lists step-gate +- **Workflow checkpoints** — `getCheckpoints(workflowId)` lists step-gate checkpoints. `resumeFromLastCheckpoint(workflowId)` resumes from last checkpoint (Evaluation+). `resumeFromCheckpoint(workflowId, checkpointId)` resumes from a specific checkpoint (Enterprise). -- **Checkpoint types** - `Checkpoint`, `CheckpointListResponse`, and +- **Checkpoint types** — `Checkpoint`, `CheckpointListResponse`, and `ResumeFromCheckpointResponse` with Jackson deserialization. -- **`AxonFlow.explainDecision(decisionId)`** (+ `explainDecisionAsync`) - fetches +- **`AxonFlow.explainDecision(decisionId)`** (+ `explainDecisionAsync`) — 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 @@ -745,9 +747,9 @@ change. Binary-compatibility preserved: old `StepGateRequest`, `StepGateResponse rule. Shape is frozen (future extra fields ignored via Jackson's `@JsonIgnoreProperties(ignoreUnknown = true)`); additive-only fields ensure forward compatibility. -- **`DecisionExplanation`, `ExplainPolicy`, `ExplainRule`** - new immutable +- **`DecisionExplanation`, `ExplainPolicy`, `ExplainRule`** — new immutable DTOs in `com.getaxonflow.sdk.types`. -- **`AuditSearchRequest.Builder.decisionId`, `policyName`, `overrideId`** - +- **`AuditSearchRequest.Builder.decisionId`, `policyName`, `overrideId`** — three new optional filter fields on `searchAuditLogs`. Use `decisionId` to gather every record tied to one decision; `policyName` to find everything matched by a specific policy; `overrideId` to reconstruct an @@ -779,7 +781,7 @@ server-side filtering activates on v7.1.0+ platforms. ### 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. +- 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. --- @@ -787,8 +789,8 @@ server-side filtering activates on v7.1.0+ platforms. ### Added -- **`GovernedTool` adapter** - framework-agnostic tool governance wrapper. Wraps any `Tool` interface with input/output policy enforcement (`mcpCheckInput` before execution, `mcpCheckOutput` after). Factory: `GovernedTool.wrap(tool, client)`, builder pattern, batch helper: `GovernedTool.governTools(tools, client)`. -- **`checkToolInput()` / `checkToolOutput()`** - generic aliases for tool governance. Existing `mcpCheckInput()` / `mcpCheckOutput()` remain supported. Async variants included. +- **`GovernedTool` adapter** — framework-agnostic tool governance wrapper. Wraps any `Tool` interface with input/output policy enforcement (`mcpCheckInput` before execution, `mcpCheckOutput` after). Factory: `GovernedTool.wrap(tool, client)`, builder pattern, batch helper: `GovernedTool.governTools(tools, client)`. +- **`checkToolInput()` / `checkToolOutput()`** — generic aliases for tool governance. Existing `mcpCheckInput()` / `mcpCheckOutput()` remain supported. Async variants included. ### Changed @@ -817,9 +819,9 @@ server-side filtering activates on v7.1.0+ platforms. ### Added -- `simulatePolicies()` / `simulatePoliciesAsync()` - 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. -- `getPolicyImpactReport()` / `getPolicyImpactReportAsync()` - test a single policy against multiple inputs and get aggregate match/block statistics. -- `detectPolicyConflicts()` / `detectPolicyConflictsAsync()` - analyze active policies for contradictions, shadows, and redundancies. Optionally filter to conflicts involving a specific policy. +- `simulatePolicies()` / `simulatePoliciesAsync()` — 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. +- `getPolicyImpactReport()` / `getPolicyImpactReportAsync()` — test a single policy against multiple inputs and get aggregate match/block statistics. +- `detectPolicyConflicts()` / `detectPolicyConflictsAsync()` — analyze active policies for contradictions, shadows, and redundancies. Optionally filter to conflicts involving a specific policy. - Types in `com.getaxonflow.sdk.simulation` package: `SimulatePoliciesRequest`, `SimulatePoliciesResponse`, `SimulationDailyUsage`, `ImpactReportInput`, `ImpactReportRequest`, `ImpactReportResult`, `ImpactReportResponse`, `PolicyConflictRef`, `PolicyConflict`, `PolicyConflictResponse` ### Security @@ -832,20 +834,20 @@ server-side filtering activates on v7.1.0+ platforms. ### Added -- `LangGraphAdapter` class - wraps LangGraph workflows with AxonFlow governance gates and per-tool policy enforcement. Includes: - - `checkGate()` / `stepCompleted()` - step-level governance at LangGraph node boundaries - - `checkToolGate()` / `toolCompleted()` - per-tool governance within tool_call nodes (each tool gets its own gate check) - - `mcpToolInterceptor()` - factory returning an interceptor enforcing `mcpCheckInput → handler → mcpCheckOutput` around every MCP tool call - - `waitForApproval()` - poll until a step is approved or rejected - - `startWorkflow()` / `completeWorkflow()` / `abortWorkflow()` / `failWorkflow()` - workflow lifecycle management +- `LangGraphAdapter` class — wraps LangGraph workflows with AxonFlow governance gates and per-tool policy enforcement. Includes: + - `checkGate()` / `stepCompleted()` — step-level governance at LangGraph node boundaries + - `checkToolGate()` / `toolCompleted()` — per-tool governance within tool_call nodes (each tool gets its own gate check) + - `mcpToolInterceptor()` — factory returning an interceptor enforcing `mcpCheckInput → handler → mcpCheckOutput` around every MCP tool call + - `waitForApproval()` — poll until a step is approved or rejected + - `startWorkflow()` / `completeWorkflow()` / `abortWorkflow()` / `failWorkflow()` — workflow lifecycle management - Builder pattern construction, implements `AutoCloseable` - `WorkflowBlockedError` and `WorkflowApprovalRequiredError` exception classes - Builder-based option classes: `CheckGateOptions`, `StepCompletedOptions`, `CheckToolGateOptions`, `ToolCompletedOptions` - MCP interceptor types: `MCPInterceptorOptions`, `MCPToolRequest`, `MCPToolHandler`, `MCPToolInterceptor` -- `getCircuitBreakerStatus()` / `getCircuitBreakerStatusAsync()` - query active circuit breaker circuits and emergency stop state -- `getCircuitBreakerHistory(limit)` / `getCircuitBreakerHistoryAsync(limit)` - retrieve circuit breaker trip/reset audit trail -- `getCircuitBreakerConfig(tenantId)` / `getCircuitBreakerConfigAsync(tenantId)` - get effective circuit breaker config (global or tenant-specific) -- `updateCircuitBreakerConfig(config)` / `updateCircuitBreakerConfigAsync(config)` - update per-tenant circuit breaker thresholds +- `getCircuitBreakerStatus()` / `getCircuitBreakerStatusAsync()` — query active circuit breaker circuits and emergency stop state +- `getCircuitBreakerHistory(limit)` / `getCircuitBreakerHistoryAsync(limit)` — retrieve circuit breaker trip/reset audit trail +- `getCircuitBreakerConfig(tenantId)` / `getCircuitBreakerConfigAsync(tenantId)` — get effective circuit breaker config (global or tenant-specific) +- `updateCircuitBreakerConfig(config)` / `updateCircuitBreakerConfigAsync(config)` — update per-tenant circuit breaker thresholds --- @@ -853,9 +855,9 @@ server-side filtering activates on v7.1.0+ platforms. ### Added -- `auditToolCall()` - record non-LLM tool calls (API, MCP, function) in the audit trail. Returns audit ID, status, and timestamp. Requires Platform v5.1.0+ -- `getAuditLogsByTenant()` - retrieve audit logs for a tenant with optional pagination -- `searchAuditLogs()` - search audit logs with filters (client ID, request type, limit) +- `auditToolCall()` — record non-LLM tool calls (API, MCP, function) in the audit trail. Returns audit ID, status, and timestamp. Requires Platform v5.1.0+ +- `getAuditLogsByTenant()` — retrieve audit logs for a tenant with optional pagination +- `searchAuditLogs()` — search audit logs with filters (client ID, request type, limit) ### Fixed diff --git a/scripts/wire_shape/AuditWireKeysProbe.java b/scripts/wire_shape/AuditWireKeysProbe.java new file mode 100644 index 0000000..0769670 --- /dev/null +++ b/scripts/wire_shape/AuditWireKeysProbe.java @@ -0,0 +1,78 @@ +/* + * Copyright 2026 AxonFlow + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import com.fasterxml.jackson.databind.BeanDescription; +import com.fasterxml.jackson.databind.JavaType; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.introspect.BeanPropertyDefinition; +import java.util.TreeMap; +import java.util.TreeSet; + +/** + * Wire-key introspection probe for wire-shape Gate 5 (audit-surface binding, #3254). + * + *

Run by scripts/wire_shape/validate.py in java source-file mode against the COMPILED SDK + * classes ({@code target/classes}) plus the resolved dependency classpath. For every + * fully-qualified class name passed as an argument it asks Jackson itself - the same library that + * puts these types on the wire - for the full set of wire property names, as the union of the + * serialization and deserialization bean descriptions, and prints one JSON object mapping simple + * class name to sorted wire keys. + * + *

Why introspection instead of source-regex discovery: a regex over the source cannot resolve a + * constant-valued annotation ({@code @JsonProperty(SOME_CONSTANT)}) and cannot see Jackson's + * getter auto-detection (an unannotated public {@code getFoo()} serializes {@code foo} with no + * {@code @JsonProperty} anywhere). Both were demonstrated as Gate 5 bypasses in review. The + * compiled-class view resolves constants (the annotation value is a resolved string at bytecode + * level) and applies the exact property-discovery rules the production {@code ObjectMapper} uses, + * so what this probe reports IS what can appear on the wire. + * + *

Failure behavior: any unresolvable input (class not found, introspection error) prints the + * cause to stderr and exits 2. The caller treats any non-zero exit as an unresolvable binding and + * FAILS the gate - never skips. + */ +public final class AuditWireKeysProbe { + + private AuditWireKeysProbe() {} + + public static void main(String[] args) { + if (args.length == 0) { + System.err.println("usage: AuditWireKeysProbe ..."); + System.exit(2); + } + try { + ObjectMapper mapper = new ObjectMapper(); + TreeMap> result = new TreeMap<>(); + for (String fqcn : args) { + Class cls = Class.forName(fqcn); + JavaType type = mapper.constructType(cls); + TreeSet keys = new TreeSet<>(); + BeanDescription ser = mapper.getSerializationConfig().introspect(type); + for (BeanPropertyDefinition p : ser.findProperties()) { + keys.add(p.getName()); + } + BeanDescription deser = mapper.getDeserializationConfig().introspect(type); + for (BeanPropertyDefinition p : deser.findProperties()) { + keys.add(p.getName()); + } + result.put(cls.getSimpleName(), keys); + } + System.out.println(mapper.writeValueAsString(result)); + } catch (Throwable t) { + System.err.println("AuditWireKeysProbe FAILED: " + t); + System.exit(2); + } + } +} diff --git a/scripts/wire_shape/validate.py b/scripts/wire_shape/validate.py index 5515172..8b1881d 100755 --- a/scripts/wire_shape/validate.py +++ b/scripts/wire_shape/validate.py @@ -11,17 +11,31 @@ 3. Per-type SDK-vs-spec drift (baseline-aware) 4. Registered-type coverage (rename-escape guard) (1-4 are the same classes as the Python/Go/TS validators.) -5. Audit-surface field binding (#3254): every @JsonProperty name on the - audit model classes MUST exist as a property of the same-named schema - in the pinned specs, unless it is explicitly allowlisted in - tests/fixtures/audit-binding-allowlist.json with a note naming a - tracking issue. Gate 3 is baseline-aware by design (drift recorded at - refresh time stays green), which is exactly how seven never-served - fields shipped on AuditLogEntry and stayed for months - the baseline - RECORDED the fiction instead of binding the model to the contract. - Gate 5 is the binding: it has no refresh path, only the curated - allowlist, and an unresolvable binding (class or schema missing) - FAILS instead of skipping. +5. Audit-surface field binding (#3254): every wire key the COMPILED + audit model classes actually map MUST exist as a property of the + same-named schema in the pinned specs, unless it is explicitly + allowlisted in tests/fixtures/audit-binding-allowlist.json with a + note naming a tracking issue. Gate 3 is baseline-aware by design + (drift recorded at refresh time stays green), which is exactly how + seven never-served fields shipped on AuditLogEntry and stayed for + months - the baseline RECORDED the fiction instead of binding the + model to the contract. Gate 5 is the binding: it has no refresh + path, only the curated allowlist, and an unresolvable binding + (class, schema, or introspection probe missing) FAILS instead of + skipping. + + Unlike gates 1-4, gate 5 does NOT use the source-regex discovery in + lib.py: a regex cannot resolve a constant-valued annotation + (@JsonProperty(SOME_CONSTANT)) and cannot see Jackson's getter + auto-detection (an unannotated public getFoo() serializes `foo` + with no annotation anywhere) - both were demonstrated as bypasses + in review. Gate 5 asks Jackson itself, via + scripts/wire_shape/AuditWireKeysProbe.java run against + target/classes, so its view of the wire is the serializer's view. + Prerequisites (CI compiles them in the workflow; locally run + `mvn -q compile dependency:build-classpath + -Dmdep.outputFile=target/wire-shape-cp.txt` first): + target/classes and target/wire-shape-cp.txt. Specs dir is passed via AXONFLOW_OPENAPI_SPECS_DIR. Without it, the script exits 0 after a skip message so `mvn test` and local work @@ -36,6 +50,8 @@ import json import os +import shutil +import subprocess import sys from pathlib import Path @@ -56,9 +72,80 @@ "AuditSearchRequest", "AuditSearchResponse", ) +AUDIT_BINDING_PACKAGE = "com.getaxonflow.sdk.types" AUDIT_BINDING_ALLOWLIST_PATH = ( REPO_ROOT / "tests" / "fixtures" / "audit-binding-allowlist.json" ) +AUDIT_PROBE_SOURCE = Path(__file__).resolve().parent / "AuditWireKeysProbe.java" +TARGET_CLASSES = REPO_ROOT / "target" / "classes" +DEP_CLASSPATH_FILE = REPO_ROOT / "target" / "wire-shape-cp.txt" + + +def probe_audit_wire_keys() -> dict[str, list[str]]: + """Ask Jackson (via AuditWireKeysProbe on the compiled classes) for the + real wire-key set of every AUDIT_BINDING_TYPES class. + + Returns {SimpleTypeName: sorted_wire_keys}. Any missing prerequisite or + probe failure raises SystemExit - an unresolvable binding must FAIL the + gate, never weaken it to a skip. + """ + problems: list[str] = [] + if shutil.which("java") is None: + problems.append("`java` not on PATH.") + if not AUDIT_PROBE_SOURCE.is_file(): + problems.append(f"probe source missing: {AUDIT_PROBE_SOURCE}") + if not ( + TARGET_CLASSES / AUDIT_BINDING_PACKAGE.replace(".", "/") + ).is_dir(): + problems.append( + f"compiled SDK classes missing under {TARGET_CLASSES} - run " + f"`mvn -q compile` first." + ) + if not DEP_CLASSPATH_FILE.is_file(): + problems.append( + f"{DEP_CLASSPATH_FILE} missing - run `mvn -q " + f"dependency:build-classpath " + f"-Dmdep.outputFile=target/wire-shape-cp.txt` first." + ) + if problems: + raise SystemExit( + "❌ Audit-surface binding gate (#3254) prerequisites missing; " + "the binding is unresolvable, which FAILS (never skips):\n - " + + "\n - ".join(problems) + ) + + classpath = os.pathsep.join( + [str(TARGET_CLASSES), DEP_CLASSPATH_FILE.read_text().strip()] + ) + cmd = [ + "java", + "-cp", + classpath, + str(AUDIT_PROBE_SOURCE), + ] + [f"{AUDIT_BINDING_PACKAGE}.{t}" for t in AUDIT_BINDING_TYPES] + proc = subprocess.run(cmd, capture_output=True, text=True) + if proc.returncode != 0: + raise SystemExit( + f"❌ AuditWireKeysProbe failed (exit {proc.returncode}) - the " + f"audit binding is unresolvable, which FAILS (never skips).\n" + f"stderr:\n{proc.stderr.strip()}" + ) + try: + parsed = json.loads(proc.stdout) + except json.JSONDecodeError as e: + raise SystemExit( + f"❌ AuditWireKeysProbe emitted unparseable output " + f"({e.__class__.__name__}: {e}):\n{proc.stdout[:2000]}" + ) from None + for type_name in AUDIT_BINDING_TYPES: + if not parsed.get(type_name): + raise SystemExit( + f"❌ AuditWireKeysProbe reported no wire keys for " + f"{type_name} - an audit model type with zero mapped " + f"properties means introspection broke; the binding is " + f"unresolvable, which FAILS (never skips)." + ) + return {k: sorted(v) for k, v in parsed.items()} def load_audit_binding_allowlist() -> dict[str, dict[str, str]]: @@ -298,19 +385,18 @@ def main() -> int: errors += len(missing_sdk) + len(missing_spec) # Gate 5: audit-surface field binding (#3254). Strict, baseline-free. + # Wire keys come from Jackson introspection of the COMPILED classes + # (probe_audit_wire_keys), NOT from the source-regex discovery used by + # gates 1-4 - see the module docstring for the two demonstrated + # regex bypasses (constant-valued annotations, getter auto-detection). + # A class that cannot be loaded fails inside the probe (exit 2 -> + # SystemExit here), so "class missing" is a hard failure, not a skip. allowlist = load_audit_binding_allowlist() + probed = probe_audit_wire_keys() binding_problems: list[str] = [] for type_name in AUDIT_BINDING_TYPES: - sdk_fields = sdk.get(type_name) + sdk_fields = probed[type_name] spec_fields = merged.get(type_name) - if sdk_fields is None: - binding_problems.append( - f" {type_name}: no Java class with @JsonProperty fields " - f"found under src/main/java - the binding is unresolvable. " - f"This gate fails instead of skipping; if the class was " - f"renamed, update AUDIT_BINDING_TYPES in the same PR." - ) - continue if spec_fields is None: binding_problems.append( f" {type_name}: no OpenAPI schema of this name in the " @@ -325,13 +411,15 @@ def main() -> int: ] if unbound: binding_problems.append( - f" {type_name}: SDK @JsonProperty field(s) with NO backing " - f"property in the pinned {type_name} schema: {unbound}. A " - f"field the server never serves is fiction (#3254 class): " - f"either the spec is missing it (fix the contract first) or " - f"the field must not exist. If it must stay temporarily, " - f"allowlist it WITH a tracking-issue note in " - f"tests/fixtures/audit-binding-allowlist.json." + f" {type_name}: wire key(s) mapped by the compiled class " + f"(Jackson introspection: @JsonProperty, constant-valued " + f"annotations, and getter auto-detection alike) with NO " + f"backing property in the pinned {type_name} schema: " + f"{unbound}. A field the server never serves is fiction " + f"(#3254 class): either the spec is missing it (fix the " + f"contract first) or the field must not exist. If it must " + f"stay temporarily, allowlist it WITH a tracking-issue note " + f"in tests/fixtures/audit-binding-allowlist.json." ) # Stale = allowlisted but no longer unbound: either the field left # the SDK class, or the spec now carries it. Both mean the entry diff --git a/src/main/java/com/getaxonflow/sdk/types/AuditLogEntry.java b/src/main/java/com/getaxonflow/sdk/types/AuditLogEntry.java index 036d608..ff1f9b7 100644 --- a/src/main/java/com/getaxonflow/sdk/types/AuditLogEntry.java +++ b/src/main/java/com/getaxonflow/sdk/types/AuditLogEntry.java @@ -378,7 +378,7 @@ public String getDataResidency() { /** * Returns the cross-border transfer basis under Indonesia UU PDP Pasal 56 ({@code adequacy}, * {@code safeguards}, {@code pasal_56b_dpa}, or {@code consent}), or null if not set. Surfaced - * verbatim — see the {@code TRANSFER_BASIS_*} constants. + * verbatim - see the {@code TRANSFER_BASIS_*} constants. */ public String getTransferBasis() { return transferBasis; diff --git a/src/main/java/com/getaxonflow/sdk/types/AuditSearchRequest.java b/src/main/java/com/getaxonflow/sdk/types/AuditSearchRequest.java index df85f55..5a15b3b 100644 --- a/src/main/java/com/getaxonflow/sdk/types/AuditSearchRequest.java +++ b/src/main/java/com/getaxonflow/sdk/types/AuditSearchRequest.java @@ -257,7 +257,7 @@ public Builder action(String action) { } /** - * Filter by decision ID (ADR-043). Use to gather every audit record tied to a single decision — + * Filter by decision ID (ADR-043). Use to gather every audit record tied to a single decision - * the explain-flow cross-reference pivot. */ public Builder decisionId(String decisionId) { diff --git a/src/test/java/com/getaxonflow/sdk/types/AuditRealWireModelTest.java b/src/test/java/com/getaxonflow/sdk/types/AuditRealWireModelTest.java index 90a67d7..75b4d99 100644 --- a/src/test/java/com/getaxonflow/sdk/types/AuditRealWireModelTest.java +++ b/src/test/java/com/getaxonflow/sdk/types/AuditRealWireModelTest.java @@ -42,12 +42,18 @@ *

  • {@code fixtures/audit-search-old-server.json} - HAND-MODIFIED copy of the live capture with * the three real-wire fields removed, simulating a pre-9.x server. *
  • {@code fixtures/audit-search-both-present.json} - HAND-MODIFIED copy of the live capture - * with the seven fiction fields injected alongside the real ones, proving both parse with no - * collision. + * with the seven fiction fields injected alongside the real ones (with non-default values, + * e.g. {@code success:false}, so every assertion can actually fail), proving both parse with + * no collision. + *
  • {@code fixtures/audit-search-explicit-null.json} - HAND-MODIFIED copy of the live capture + * with the three real-wire fields present as explicit JSON {@code null}, pinning the + * null-to-default normalization the canonical constructor performs. * * - *

    The mapper mirrors the production one in {@code AxonFlow} (JavaTimeModule registered; unknown - * properties tolerated via the model's {@code @JsonIgnoreProperties}). + *

    The mapper here is configured the same way {@code AxonFlow} configures its production mapper + * (plain {@code ObjectMapper} + {@code JavaTimeModule}; unknown properties tolerated via the + * model's {@code @JsonIgnoreProperties}). It is a separate instance, not the production object - + * if {@code AxonFlow}'s mapper construction gains configuration, mirror it here. */ @DisplayName("Audit model - real wire fields (#3254)") class AuditRealWireModelTest { @@ -132,9 +138,11 @@ void bothPresentPayloadParses() throws Exception { assertThat(e.getPolicyDecision()).isEqualTo("error"); assertThat(e.getPolicyDetails()).containsEntry("tool_name", "s3254_blocked_probe"); assertThat(e.getResponseTimeMs()).isEqualTo(0L); - // Fiction fields, hand-injected into the fixture: + // Fiction fields, hand-injected into the fixture. Every injected value + // differs from the constructor default (success:false vs default true, + // blocked:true vs default false, ...) so each assertion can fail. assertThat(e.getQuerySummary()).isEqualTo("hand-injected summary"); - assertThat(e.isSuccess()).isTrue(); + assertThat(e.isSuccess()).isFalse(); assertThat(e.isBlocked()).isTrue(); assertThat(e.getRiskScore()).isEqualTo(0.42); assertThat(e.getLatencyMs()).isEqualTo(77); @@ -142,6 +150,23 @@ void bothPresentPayloadParses() throws Exception { assertThat(e.getMetadata()).containsEntry("hand_injected", true); } + @Test + @DisplayName("explicit JSON null on the three new fields - normalized to defaults, no throw") + void explicitNullPayloadNormalized() throws Exception { + AuditSearchResponse response = + mapper.readValue(fixture("audit-search-explicit-null.json"), AuditSearchResponse.class); + + assertThat(response.getEntries()).hasSize(2); + for (AuditLogEntry e : response.getEntries()) { + // Explicit null and absent must land identically: "" / empty map / + // null Long. Pins the constructor's null guards through the real + // mapper (Jackson passes explicit null to the creator). + assertThat(e.getPolicyDecision()).isEmpty(); + assertThat(e.getPolicyDetails()).isEmpty(); + assertThat(e.getResponseTimeMs()).isNull(); + } + } + @Test @DisplayName("pre-#3254 constructor signature still compiles and delegates with defaults") void oldConstructorSignatureStillCompiles() { diff --git a/src/test/resources/fixtures/audit-search-both-present.json b/src/test/resources/fixtures/audit-search-both-present.json index 5583580..19d8bde 100644 --- a/src/test/resources/fixtures/audit-search-both-present.json +++ b/src/test/resources/fixtures/audit-search-both-present.json @@ -31,7 +31,7 @@ "compliance_flags": null, "security_metrics": null, "query_summary": "hand-injected summary", - "success": true, + "success": false, "blocked": true, "risk_score": 0.42, "latency_ms": 77, diff --git a/src/test/resources/fixtures/audit-search-explicit-null.json b/src/test/resources/fixtures/audit-search-explicit-null.json new file mode 100644 index 0000000..4affa67 --- /dev/null +++ b/src/test/resources/fixtures/audit-search-explicit-null.json @@ -0,0 +1,58 @@ +{ + "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": null, + "policy_details": null, + "provider": "", + "model": "", + "response_time_ms": null, + "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": null, + "policy_details": null, + "provider": "", + "model": "", + "response_time_ms": null, + "tokens_used": 0, + "cost": 0, + "redacted_fields": null, + "response_sample": "", + "compliance_flags": null, + "security_metrics": null + } + ], + "total": 2, + "limit": 10, + "offset": 0 +} From 7bc897346fdafc16c62a5a75c18506920ae90d7a Mon Sep 17 00:00:00 2001 From: Saurabh Jain Date: Tue, 4 Aug 2026 01:21:22 +0200 Subject: [PATCH 3/3] fix(gate5): refuse mechanisms introspection cannot see; R3 round-2 items BLOCKER fixed by capability removal, per the gate's own principle (unresolvable FAILS, never skips). BeanDescription.findProperties() reports declared bean properties only; three executed review bypasses rode mechanisms outside that view: a @JsonUnwrapped container whose bean-property name is a bound spec key (emits fiction_unwrapped, never the claimed cost), a @JsonAnyGetter map (arbitrary top-level fiction keys at runtime), and a @JsonAlias fiction read key. The probe now scans each bound type's hierarchy (class-level annotations, fields, methods, constructors and their parameters) and exits 2 - refusing to certify - on @JsonUnwrapped, @JsonAnyGetter, @JsonAnySetter, @JsonAlias, @JsonValue, or class-level @JsonSerialize / @JsonDeserialize / @JsonTypeInfo / @JsonAppend / @JsonNaming. @JsonValue and the three extra class-level shape rewriters are the same escape class as the named five, so the sweep covers them too. The 'what this probe reports IS what can appear on the wire' claim is replaced with a stated-scope paragraph naming the refused mechanisms in the probe Javadoc and the validate.py docstring (and the PR body): certification now means declared properties are spec-bound AND no shape-escaping mechanism is present. Proofs executed (outputs in the PR body): all three reviewer bypasses re-run RED (probe exit 2 -> gate exit 1, each naming the mechanism and member); negative control GREEN - none of the refused mechanisms exist on the three bound types today. Round-2 low items: - Freshness guard: Gate 5 fails if any bound type's .java is newer than its .class ('the probe would certify stale bytecode. Recompile first: mvn -q compile'). Proven: touch a bound source -> RED, recompile -> GREEN. CI unaffected (compile immediately precedes). - The probe's mapper is now obtained by reflecting the private production factory AxonFlow.createObjectMapper() instead of new ObjectMapper() - identical discovery today, but production gaining a module/introspector/naming strategy can no longer diverge silently. A rename breaks the gate loudly; mirror notes point both ways. - pom.xml added to the wire-shape workflow paths filter: a Jackson version bump is exactly what changes the probe's view and now re-runs the job. Signed-off-by: Saurabh Jain --- .github/workflows/wire-shape-contract.yml | 8 ++ scripts/wire_shape/AuditWireKeysProbe.java | 116 ++++++++++++++++-- scripts/wire_shape/validate.py | 38 +++++- .../java/com/getaxonflow/sdk/AxonFlow.java | 6 + 4 files changed, 154 insertions(+), 14 deletions(-) diff --git a/.github/workflows/wire-shape-contract.yml b/.github/workflows/wire-shape-contract.yml index 1ed54f1..dcdcc10 100644 --- a/.github/workflows/wire-shape-contract.yml +++ b/.github/workflows/wire-shape-contract.yml @@ -22,6 +22,10 @@ on: - 'tests/fixtures/wire-shape-baseline.json' - 'tests/fixtures/audit-binding-allowlist.json' - 'scripts/wire_shape/**' + # pom.xml governs the Jackson version - exactly what changes the + # Gate 5 probe's view of the wire - so a dependency bump must + # re-run this job. + - 'pom.xml' - '.github/workflows/wire-shape-contract.yml' push: branches: [main] @@ -30,6 +34,10 @@ on: - 'tests/fixtures/wire-shape-baseline.json' - 'tests/fixtures/audit-binding-allowlist.json' - 'scripts/wire_shape/**' + # pom.xml governs the Jackson version - exactly what changes the + # Gate 5 probe's view of the wire - so a dependency bump must + # re-run this job. + - 'pom.xml' - '.github/workflows/wire-shape-contract.yml' permissions: diff --git a/scripts/wire_shape/AuditWireKeysProbe.java b/scripts/wire_shape/AuditWireKeysProbe.java index 0769670..a7e3bcb 100644 --- a/scripts/wire_shape/AuditWireKeysProbe.java +++ b/scripts/wire_shape/AuditWireKeysProbe.java @@ -18,6 +18,13 @@ import com.fasterxml.jackson.databind.JavaType; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.introspect.BeanPropertyDefinition; +import java.lang.annotation.Annotation; +import java.lang.reflect.AnnotatedElement; +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.lang.reflect.Parameter; +import java.util.List; import java.util.TreeMap; import java.util.TreeSet; @@ -26,37 +33,67 @@ * *

    Run by scripts/wire_shape/validate.py in java source-file mode against the COMPILED SDK * classes ({@code target/classes}) plus the resolved dependency classpath. For every - * fully-qualified class name passed as an argument it asks Jackson itself - the same library that - * puts these types on the wire - for the full set of wire property names, as the union of the - * serialization and deserialization bean descriptions, and prints one JSON object mapping simple - * class name to sorted wire keys. + * fully-qualified class name passed as an argument it asks Jackson - configured EXACTLY as + * production configures it, by reflecting the private {@code AxonFlow.createObjectMapper()} + * factory - for the wire property names, as the union of the serialization and deserialization + * bean descriptions, and prints one JSON object mapping simple class name to sorted wire keys. * *

    Why introspection instead of source-regex discovery: a regex over the source cannot resolve a * constant-valued annotation ({@code @JsonProperty(SOME_CONSTANT)}) and cannot see Jackson's * getter auto-detection (an unannotated public {@code getFoo()} serializes {@code foo} with no - * {@code @JsonProperty} anywhere). Both were demonstrated as Gate 5 bypasses in review. The - * compiled-class view resolves constants (the annotation value is a resolved string at bytecode - * level) and applies the exact property-discovery rules the production {@code ObjectMapper} uses, - * so what this probe reports IS what can appear on the wire. + * {@code @JsonProperty} anywhere). Both were demonstrated as Gate 5 bypasses in review. * - *

    Failure behavior: any unresolvable input (class not found, introspection error) prints the - * cause to stderr and exits 2. The caller treats any non-zero exit as an unresolvable binding and - * FAILS the gate - never skips. + *

    Stated scope - what this probe can and cannot certify. {@code + * BeanDescription.findProperties()} reports declared bean properties only. Jackson mechanisms that + * add, rename, or replace wire keys outside that set are invisible to it: {@code @JsonUnwrapped} + * (inlines a nested object's keys in place of the container's name), {@code @JsonAnyGetter} and + * {@code @JsonAnySetter} (arbitrary top-level keys at runtime), {@code @JsonAlias} (extra + * readable names), {@code @JsonValue} (replaces the whole object shape), and class-level + * {@code @JsonSerialize}, {@code @JsonDeserialize}, {@code @JsonTypeInfo}, {@code @JsonAppend} + * and {@code @JsonNaming} (custom or rewritten shapes). Rather than report a key set it cannot + * vouch for, the probe REFUSES to certify a bound type that uses any of these: it scans the class + * hierarchy (class-level annotations, fields, methods, constructors and their parameters) and + * exits 2 naming the mechanism and member. The caller treats any non-zero exit as an unresolvable + * binding and FAILS the gate - never skips. All three review-demonstrated round-2 bypasses (a + * {@code @JsonUnwrapped} container named after a bound key, a {@code @JsonAnyGetter} map, a + * {@code @JsonAlias} fiction key) land in this refusal. + * + *

    Failure behavior: any unresolvable input (class not found, refused mechanism present, mapper + * factory not reflectable, introspection error) prints the cause to stderr and exits 2. */ public final class AuditWireKeysProbe { private AuditWireKeysProbe() {} + /** Member-level annotations that alter the wire key set invisibly to findProperties(). */ + private static final List REFUSED_MEMBER_ANNOTATIONS = + List.of( + "com.fasterxml.jackson.annotation.JsonUnwrapped", + "com.fasterxml.jackson.annotation.JsonAnyGetter", + "com.fasterxml.jackson.annotation.JsonAnySetter", + "com.fasterxml.jackson.annotation.JsonAlias", + "com.fasterxml.jackson.annotation.JsonValue"); + + /** Class-level annotations that replace or rewrite the whole wire shape. */ + private static final List REFUSED_CLASS_ANNOTATIONS = + List.of( + "com.fasterxml.jackson.databind.annotation.JsonSerialize", + "com.fasterxml.jackson.databind.annotation.JsonDeserialize", + "com.fasterxml.jackson.annotation.JsonTypeInfo", + "com.fasterxml.jackson.databind.annotation.JsonAppend", + "com.fasterxml.jackson.databind.annotation.JsonNaming"); + public static void main(String[] args) { if (args.length == 0) { System.err.println("usage: AuditWireKeysProbe ..."); System.exit(2); } try { - ObjectMapper mapper = new ObjectMapper(); + ObjectMapper mapper = productionConfiguredMapper(); TreeMap> result = new TreeMap<>(); for (String fqcn : args) { Class cls = Class.forName(fqcn); + refuseUnintrospectableMechanisms(cls); JavaType type = mapper.constructType(cls); TreeSet keys = new TreeSet<>(); BeanDescription ser = mapper.getSerializationConfig().introspect(type); @@ -71,8 +108,61 @@ public static void main(String[] args) { } System.out.println(mapper.writeValueAsString(result)); } catch (Throwable t) { - System.err.println("AuditWireKeysProbe FAILED: " + t); + System.err.println("AuditWireKeysProbe FAILED: " + t.getMessage()); System.exit(2); } } + + /** + * Obtains a mapper configured exactly as production configures its own, by reflecting the + * private {@code AxonFlow.createObjectMapper()} factory. Property discovery would be identical + * under a bare {@code new ObjectMapper()} today, but would diverge SILENTLY the day production + * gains a module, annotation introspector, or naming strategy - so the probe refuses to guess. + * If the factory is renamed or removed this throws (exit 2, gate FAILS loudly); + * {@code AxonFlow.createObjectMapper} carries the mirror note pointing back here. + */ + private static ObjectMapper productionConfiguredMapper() throws Exception { + Class axonflow = Class.forName("com.getaxonflow.sdk.AxonFlow"); + Method factory = axonflow.getDeclaredMethod("createObjectMapper"); + factory.setAccessible(true); + return (ObjectMapper) factory.invoke(null); + } + + private static void refuseUnintrospectableMechanisms(Class cls) { + for (Class c = cls; c != null && c != Object.class; c = c.getSuperclass()) { + refuse(cls, c, "class " + c.getSimpleName(), REFUSED_CLASS_ANNOTATIONS); + for (Field f : c.getDeclaredFields()) { + refuse(cls, f, "field " + f.getName(), REFUSED_MEMBER_ANNOTATIONS); + } + for (Method m : c.getDeclaredMethods()) { + refuse(cls, m, "method " + m.getName(), REFUSED_MEMBER_ANNOTATIONS); + } + for (Constructor k : c.getDeclaredConstructors()) { + refuse(cls, k, "constructor", REFUSED_MEMBER_ANNOTATIONS); + for (Parameter p : k.getParameters()) { + refuse(cls, p, "constructor parameter " + p.getName(), REFUSED_MEMBER_ANNOTATIONS); + } + } + } + } + + private static void refuse( + Class boundType, AnnotatedElement element, String where, List refusedNames) { + for (Annotation a : element.getAnnotations()) { + if (refusedNames.contains(a.annotationType().getName())) { + throw new IllegalStateException( + "bound type " + + boundType.getName() + + " uses @" + + a.annotationType().getSimpleName() + + " on " + + where + + " - this mechanism alters the wire key set in ways " + + "BeanDescription.findProperties() cannot see, so the probe refuses to certify " + + "the type (Gate 5 fails rather than reporting a key set it cannot vouch for). " + + "Remove the mechanism from the audit surface, or extend the probe to derive " + + "the real key set for it first."); + } + } + } } diff --git a/scripts/wire_shape/validate.py b/scripts/wire_shape/validate.py index 8b1881d..3b17d1e 100755 --- a/scripts/wire_shape/validate.py +++ b/scripts/wire_shape/validate.py @@ -31,7 +31,16 @@ with no annotation anywhere) - both were demonstrated as bypasses in review. Gate 5 asks Jackson itself, via scripts/wire_shape/AuditWireKeysProbe.java run against - target/classes, so its view of the wire is the serializer's view. + target/classes with the production mapper configuration (reflected + from AxonFlow.createObjectMapper). Stated scope: the probe reports + declared bean properties (BeanDescription.findProperties) and + REFUSES to certify - exit 2, gate FAILS - any bound type using a + Jackson mechanism that alters the wire key set outside that view: + @JsonUnwrapped, @JsonAnyGetter, @JsonAnySetter, @JsonAlias, + @JsonValue, or class-level @JsonSerialize / @JsonDeserialize / + @JsonTypeInfo / @JsonAppend / @JsonNaming. Certification is + therefore: the declared properties are spec-bound AND no + shape-escaping mechanism is present. Prerequisites (CI compiles them in the workflow; locally run `mvn -q compile dependency:build-classpath -Dmdep.outputFile=target/wire-shape-cp.txt` first): @@ -107,6 +116,33 @@ def probe_audit_wire_keys() -> dict[str, list[str]]: f"dependency:build-classpath " f"-Dmdep.outputFile=target/wire-shape-cp.txt` first." ) + # Freshness guard: introspecting STALE bytecode against DIRTY source + # is a false green waiting to happen locally (in CI the compile step + # immediately precedes this validator, so this never fires there). + pkg_dir = AUDIT_BINDING_PACKAGE.replace(".", "/") + for type_name in AUDIT_BINDING_TYPES: + src = REPO_ROOT / "src" / "main" / "java" / pkg_dir / f"{type_name}.java" + cls = TARGET_CLASSES / pkg_dir / f"{type_name}.class" + if not src.is_file(): + # A bound type without a same-named source file would be a + # rename; the probe's Class.forName fails on it anyway, but + # name it here for a better message. + problems.append( + f"source file missing for bound type {type_name}: {src}" + ) + continue + if not cls.is_file(): + problems.append( + f"compiled class missing for bound type {type_name}: {cls} " + f"- run `mvn -q compile` first." + ) + continue + if src.stat().st_mtime > cls.stat().st_mtime: + problems.append( + f"{src.name} is NEWER than its compiled {cls.name} - the " + f"probe would certify stale bytecode. Recompile first: " + f"`mvn -q compile`." + ) if problems: raise SystemExit( "❌ Audit-surface binding gate (#3254) prerequisites missing; " diff --git a/src/main/java/com/getaxonflow/sdk/AxonFlow.java b/src/main/java/com/getaxonflow/sdk/AxonFlow.java index 3f8b7e7..4f38d28 100644 --- a/src/main/java/com/getaxonflow/sdk/AxonFlow.java +++ b/src/main/java/com/getaxonflow/sdk/AxonFlow.java @@ -281,6 +281,12 @@ private Response executeHttp(OkHttpClient client, Request request) throws java.i return client.newCall(request).execute(); } + // MIRROR NOTE: wire-shape Gate 5's introspection probe + // (scripts/wire_shape/AuditWireKeysProbe.java) obtains its mapper by + // reflecting THIS factory, so its view of the wire always matches + // production configuration. Renaming or removing this method breaks the + // gate loudly (probe exit 2 -> gate FAIL), which is intentional - update + // the probe in the same change. private static ObjectMapper createObjectMapper() { ObjectMapper mapper = new ObjectMapper(); mapper.registerModule(new JavaTimeModule());