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 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 = 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); + 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.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 f4a3ab6..3b17d1e 100755 --- a/scripts/wire_shape/validate.py +++ b/scripts/wire_shape/validate.py @@ -5,11 +5,46 @@ @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 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 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): + 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 @@ -22,18 +57,172 @@ from __future__ import annotations +import json import os +import shutil +import subprocess 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_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." + ) + # 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; " + "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]]: + """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 +420,73 @@ 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 = probed[type_name] + spec_fields = merged.get(type_name) + 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}: 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 + # 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..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()); @@ -621,12 +627,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..ff1f9b7 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 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..5a15b3b 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..75b4d99 --- /dev/null +++ b/src/test/java/com/getaxonflow/sdk/types/AuditRealWireModelTest.java @@ -0,0 +1,223 @@ +/* + * 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: + * + *

+ * + *

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" ] },