diff --git a/.github/workflows/wire-shape-contract.yml b/.github/workflows/wire-shape-contract.yml index a2f2e06..dcdcc10 100644 --- a/.github/workflows/wire-shape-contract.yml +++ b/.github/workflows/wire-shape-contract.yml @@ -20,14 +20,24 @@ on: paths: - 'src/main/java/**/*.java' - '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] paths: - 'src/main/java/**/*.java' - '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: @@ -132,6 +142,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 201af1c..fd20eb8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,37 @@ 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 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, + schema, or introspection probe 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 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/AuditWireKeysProbe.java b/scripts/wire_shape/AuditWireKeysProbe.java new file mode 100644 index 0000000..a7e3bcb --- /dev/null +++ b/scripts/wire_shape/AuditWireKeysProbe.java @@ -0,0 +1,168 @@ +/* + * 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.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; + +/** + * 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 - 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. + * + *
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 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 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 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 Fixture provenance:
+ *
+ * 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 {
+
+ 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. 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()).isFalse();
+ 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("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() {
+ // 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..19d8bde
--- /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": false,
+ "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-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
+}
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"
]
},
*
*
- *
+ *
+ *
+ *