Skip to content

[#3254] audit model: additive real-wire fields + deprecations - #204

Merged
saurabhjain1592 merged 3 commits into
mainfrom
feat/3254-audit-real-wire-fields
Aug 4, 2026
Merged

[#3254] audit model: additive real-wire fields + deprecations#204
saurabhjain1592 merged 3 commits into
mainfrom
feat/3254-audit-real-wire-fields

Conversation

@saurabhjain1592

@saurabhjain1592 saurabhjain1592 commented Aug 3, 2026

Copy link
Copy Markdown
Member

What

Additive interim for getaxonflow/axonflow-enterprise#3254: the audit read model gains the three fields a 9.x server actually serves, the search request gains the filter the server actually reads, the seven fiction fields plus request_type are deprecated in place, and a new wire-shape binding gate makes this class of drift structurally impossible to reintroduce.

Canonical field list (spec pin already at community v9.13.0, untouched by this PR)

Read model (AuditLogEntry) - ADDED:

Wire tag Java Notes
policy_decision String getPolicyDecision() via @JsonProperty("policy_decision") OPEN string set: allowed/blocked/redacted named in the server struct, error observed live. Documented as a string, NOT an enum. Empty when absent.
policy_details Map<String,Object> getPolicyDetails() via @JsonProperty("policy_details") Arbitrary-key object. Empty map when absent.
response_time_ms Long getResponseTimeMs() via @JsonProperty("response_time_ms") Nullable: null when the server did not send it (pre-9.x / non-LLM planes), never a fake 0.

Read model - DEPRECATED in place, still parsing, JSON tags kept: query_summary, success, blocked, risk_score, latency_ms, policy_violations, metadata (never populated on the 9.x line). Wording per the canonical spec, adapted per field (query_summary -> the wire carries query/query_hash; risk_score -> no wire equivalent; metadata -> the wire carries policy_details/security_metrics). Fields NOT touched (real on the wire): id, request_id, timestamp, user_email, client_id, tenant_id, request_type (read model - it IS served), provider, model, tokens_used, data_residency, transfer_basis.

Search request (AuditSearchRequest) - ADDED action (null-omitted via the class's @JsonInclude(NON_NULL)): "Filters by action/request type with verdict normalization on the server side." DEPRECATED request_type (search-request scope only): the 9.x server does not read this filter; a search filtered only by it returns unfiltered results. The SDK keeps sending it (harmless, ignored).

Constructor-compatibility decision

AuditLogEntry is final and its public 19-argument creator constructor was the only construction path, so appending parameters would have been source-breaking for direct callers. Decision: the OLD 19-argument constructor survives verbatim and delegates to a new canonical 22-argument @JsonCreator constructor (new fields absent -> defaults). @JsonProperty moved to the new constructor's parameters so Jackson has exactly one creator. Java does not allow @Deprecated on constructor parameters, so deprecation is carried on the getters (and the requestType builder method), with the class Javadoc naming the constructor parameters as equally deprecated.

Compile-level proof, AuditRealWireModelTest.oldConstructorSignatureStillCompiles(), invokes the exact pre-#3254 19-argument shape and asserts the new fields default. Exported-surface diff (javap, old jar surface vs new; additions only, nothing removed or changed):

   public com.getaxonflow.sdk.types.AuditLogEntry(String, String, Instant, ... 19 args ...);   // unchanged
+  public com.getaxonflow.sdk.types.AuditLogEntry(... same 19 args ..., String, Map<String,Object>, Long);
+  public java.lang.String getPolicyDecision();
+  public java.util.Map<java.lang.String, java.lang.Object> getPolicyDetails();
+  public java.lang.Long getResponseTimeMs();
+  public java.lang.String getAction();
+  public com.getaxonflow.sdk.types.AuditSearchRequest$Builder action(java.lang.String);

(Full javap diff contains ONLY these + lines; no - lines.)

Binding gate (wire-shape Gate 5) - the structural fix

Gate 3 (per_type_drift) is baseline-aware by design: drift recorded at refresh time stays green forever. That is exactly how the seven fiction fields shipped - the baseline RECORDED them under sdk_only instead of binding the model to the contract. Gate 5 in scripts/wire_shape/validate.py binds AuditLogEntry, AuditSearchRequest and AuditSearchResponse strictly to the pinned spec schemas.

Discovery is capability-level, not source-regex (R3 round 1 items 1+2). Review proved two end-to-end bypasses of the first-cut regex discovery: (a) a constant-valued @JsonProperty(SOME_CONSTANT) - the regex needs a quoted literal, and the plain-field fallback attributes the JAVA field name, which can even credit the fiction as spec coverage; (b) Jackson getter auto-detection - an unannotated public getFoo() serializes foo on every request with no annotation anywhere in the source. Gate 5 therefore asks Jackson itself: scripts/wire_shape/AuditWireKeysProbe.java runs against the COMPILED classes (target/classes + resolved dependency classpath, both produced by a new step in the wire-shape workflow) and emits, per bound type, the union of the serialization and deserialization BeanDescription property names, using a mapper obtained by reflecting the production factory AxonFlow.createObjectMapper() (a rename breaks the gate loudly; mirror notes point both ways). Constants are resolved at bytecode level; auto-detected getters are first-class properties. Gates 1-4 keep the regex discovery unchanged.

Stated scope (R3 round 2). findProperties() reports declared bean properties only. Mechanisms that add, rename, or replace wire keys outside that view are invisible to it, so the probe REFUSES to certify - exit 2, gate FAILS, never skips - any bound type using: @JsonUnwrapped, @JsonAnyGetter, @JsonAnySetter, @JsonAlias, @JsonValue, or class-level @JsonSerialize / @JsonDeserialize / @JsonTypeInfo / @JsonAppend / @JsonNaming (the last four swept in as the same escape class as the reviewer-named five). Certification therefore means: the declared properties are spec-bound AND no shape-escaping mechanism is present on the audit surface. Gate 5 also carries a freshness guard: a bound type whose .java is newer than its .class fails with a recompile instruction (CI is unaffected - the compile step immediately precedes the validator).

Rules: every probed wire key must exist in the same-named pinned schema unless allowlisted in tests/fixtures/audit-binding-allowlist.json with a note naming a tracking issue; stale allowlist entries FAIL, so the allowlist can only shrink toward empty; spec fields the model misses are informational only. Unresolvable bindings FAIL, never skip: missing java, missing probe source, missing target/classes or classpath file, an unloadable class, unparseable probe output, or a bound type reporting zero keys.

Executed proofs (all outputs generated by running the shipped gate; sources reverted after each)

1. Pre-fix RED (rebuilt introspection gate run against the pre-#3254 model compiled from main, allowlist absent), verbatim Gate 5 lines:

Audit-surface binding gate failed (#3254):

  AuditLogEntry: wire key(s) mapped by the compiled class (Jackson introspection: @JsonProperty, constant-valued annotations, and getter auto-detection alike) with NO backing property in the pinned AuditLogEntry schema: ['blocked', 'latency_ms', 'metadata', 'policy_violations', 'query_summary', 'risk_score', 'success']. ...

  AuditSearchRequest: wire key(s) mapped by the compiled class (...) with NO backing property in the pinned AuditSearchRequest schema: ['request_type']. ...

exit code: 1

2. Reviewer evasion (a) re-run RED - added to AuditLogEntry: static final String FICTION_WIRE = "fiction_const_field"; @JsonProperty(FICTION_WIRE) private final String plane = null; (java name plane deliberately chosen because plane IS a spec property - the exact credit-the-fiction-as-coverage shape). Rebuilt gate names the RESOLVED wire key, exit 1:

  AuditLogEntry: wire key(s) mapped by the compiled class (...) with NO backing property in the pinned AuditLogEntry schema: ['fiction_const_field']. ...
exit code: 1

3. Reviewer evasion (b) re-run RED - added to AuditSearchRequest: public String getFictionFilter() { return "leaks"; } with no annotation anywhere. Rebuilt gate, exit 1:

  AuditSearchRequest: wire key(s) mapped by the compiled class (...) with NO backing property in the pinned AuditSearchRequest schema: ['fictionFilter']. ...
exit code: 1

4. Negative control GREEN - unmodified fixed model, curated allowlist in place: exit 0, Gates 1-4 green (97 class/schema pairs), Gate 5 prints only the informational coverage-gap lines.

5. Decoy self-test (round 0, retained) - a temporary literal @JsonProperty("decoy_field_never_on_wire") field: caught, exit 1; removed.

6. Unresolvable-fails self-test (round 0 behavior retained by the rebuild) - a bogus name in AUDIT_BINDING_TYPES fails inside the probe (ClassNotFoundException exits 2, the gate FAILS); additionally every missing prerequisite (no java, no target/classes, no classpath file, zero-key type) is an explicit SystemExit failure, never a skip.

Live-capture excerpt + provenance

src/test/resources/fixtures/audit-search-live.json is the verbatim response captured 2026-08-03 from an isolated community v9.13.0 stack (session 3254; clone of getaxonflow/axonflow tag v9.13.0, df027c788), POST /api/v1/audit/search through the agent proxy. Excerpt:

{
  "id": "audit_1785794706_23m371y7",
  "request_type": "tool_call_audit",
  "query": "Tool: s3254_blocked_probe",
  "policy_decision": "error",
  "policy_details": { "caller_name": "unknown", "error_message": "blocked by policy sys_sqli_or_true", "success": false, "tool_name": "s3254_blocked_probe" },
  "response_time_ms": 0
}

Present on the wire: policy_decision, policy_details, response_time_ms (plus query/query_hash/user_role/org_id/cost/... not modeled in this interim). ABSENT: all seven fiction fields. Observed verdict spellings allowed and error - proof the set is open, hence string-not-enum. The audit-search-old-server.json and audit-search-both-present.json fixtures are hand-modified copies of this capture and say so in the test Javadoc.

Test evidence

  • mvn verify locally green: 1336 unit tests (7 new in AuditRealWireModelTest) + 12 integration, jacoco thresholds met, BUILD SUCCESS.
  • New tests: real-capture deserialization (new fields populated, fiction fields at defaults - including isSuccess() reading true on the error-verdict row, which is exactly why it is fiction), old-server absence tolerance (getResponseTimeMs() null-safe, no throw), explicit-JSON-null normalization (hand-modified fixture; null becomes empty string / empty map / null Long through the real mapper, pinning the canonical constructor null guards), fiction+real both-present with non-default injected values (success:false vs the default true, so every assertion can fail; no collision), old-constructor compile proof, action serialization + null-omission, deprecated request_type still sent.
  • Runtime-e2e leg runtime-e2e/audit_model_real_wire/ (real JVM + built jar + live community v9.13.0 agent, through the SDK's own searchAuditLogs, no mocks):
PASS [real-wire-fields] 6 entries; 6 with policy_decision, 6 with response_time_ms. Sample: id=audit_1785795536_ivr4sqdb policyDecision=allowed responseTimeMs=0 policyDetailsKeys=[caller_name, duration_ms, input, success, tool_name] | deprecated defaults held: blocked=false success=true riskScore=0.0
PASS [action-filter] action="blocked" returned 1 of 6 entries, none with an allowed/empty verdict
ALL PASS

Items not modified in this PR (with justification)

  • Spec fields not yet modeled on AuditLogEntry (query, query_hash, user_id, user_role, org_id, cost, error_message, response_sample, compliance_flags, security_metrics, redacted_fields, session_id, correlation_id, decision_id, plane) and session_id on AuditSearchRequest: the canonical #3254 interim scopes the additive set to the three read fields + action; Gate 5 reports these as informational coverage gaps on every run so they stay visible.
  • AuditSearchRequest.equals/hashCode/toString do not include action: follows the file's existing precedent (decisionId/policyName/overrideId are likewise excluded); changing equality semantics is not additive.
  • OpenAPI spec pin (openapi_specs_sha) untouched, per the brief - already at community v9.13.0 (chore(contract): bump OpenAPI spec pin to community v9.13.0 #195).

R3 round 1 disposition

All seven items addressed in commit 2 (9733de9): (1+2) Gate 5 rebuilt on compiled-class Jackson introspection, both reviewer evasions re-executed RED against the rebuilt gate, negative control GREEN, unresolvable-fails behavior kept (see proofs above). (3) The two added diff lines carrying U+2014 (google-java-format rewraps of pre-existing prose in AuditLogEntry.getTransferBasis and AuditSearchRequest.Builder.decisionId Javadoc) now use hyphens; the full branch diff has zero em/en dashes on added lines. (4) Explicit-null fixture + test added (declared hand-modified). (5) both-present fixture injects success:false so the assertion is falsifiable. (6) The test Javadoc no longer claims to "mirror" the production mapper; it states the configuration matches and the instance does not. (7) CHANGELOG historic entries restored byte-for-byte from main; the CHANGELOG diff now contains ONLY the Unreleased hunk (matching the python train; an earlier revision of this branch had mechanically normalized historical em dashes to hyphens, which is hereby declared and is now reverted).

R3 round 2 disposition (commit 3, 7bc8973)

BLOCKER - three findProperties() bypasses, fixed by capability removal. The probe now scans each bound type hierarchy (class-level annotations, fields, methods, constructors and their parameters) and exits 2 on any of the ten refused mechanisms above, refusing to certify what introspection cannot see. The "IS what can appear on the wire" claim is replaced by the stated-scope paragraph in the probe Javadoc, the validate.py docstring, and this body. Executed proofs (sources reverted after each; verbatim probe lines):

Bypass (1) @JsonUnwrapped container whose bean-property name is the bound spec key cost (real emitted key fiction_unwrapped):

AuditWireKeysProbe FAILED: bound type com.getaxonflow.sdk.types.AuditLogEntry uses @JsonUnwrapped on field cost - 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). ...
exit code: 1

Bypass (2) @JsonAnyGetter map:

AuditWireKeysProbe FAILED: bound type com.getaxonflow.sdk.types.AuditLogEntry uses @JsonAnyGetter on method anyFiction - ...
exit code: 1

Bypass (3) @JsonAlias({"fiction_alias_key"}):

AuditWireKeysProbe FAILED: bound type com.getaxonflow.sdk.types.AuditLogEntry uses @JsonAlias on field policyDecision - ...
exit code: 1

Negative control: unmodified bound types (none of the refused mechanisms exist on them today) - exit 0, all gates green.

Low items. (2) Freshness guard added and proven: touch AuditSearchResponse.java -> AuditSearchResponse.java is NEWER than its compiled AuditSearchResponse.class - the probe would certify stale bytecode. Recompile first: mvn -q compile (exit 1); after mvn -q compile -> exit 0. (3) The probe reflects AxonFlow.createObjectMapper() for its mapper; identical discovery today, but a future production module/introspector/naming strategy can no longer diverge silently, and a factory rename fails the gate loudly (mirror notes in both files). (4) pom.xml added to the wire-shape workflow paths filter for both pull_request and push - a Jackson version bump is exactly what changes the probe view and now re-runs the job.

Local mvn verify after round 2: 1336 unit + 12 integration, BUILD SUCCESS.

…ing gate (#3254)

Read model (AuditLogEntry): add policy_decision (open string set, not an
enum), policy_details (arbitrary-key object) and response_time_ms
(nullable Long - absent on pre-9.x servers stays null, never a fake 0).
The pre-existing 19-argument constructor is retained and delegates to
the new canonical @JsonCreator constructor, so direct constructor
callers keep compiling (additive-only surface, proof in the PR body).

Deprecations, all in place and still parsing: query_summary, success,
blocked, risk_score, latency_ms, policy_violations, metadata on the
read model (never populated on the 9.x line) and request_type on the
search request (the server does not read it as a filter; a search
filtered only by it returns unfiltered results). Java cannot carry
@deprecated on constructor parameters, so deprecation rides the getters
plus the requestType builder method, with the class Javadoc naming the
constructor parameters as equally deprecated.

Search request: add action - the filter the 9.x server actually reads,
with server-side verdict normalization.

Binding gate (the structural fix): wire-shape Gate 5 binds the audit
model classes (AuditLogEntry, AuditSearchRequest, AuditSearchResponse)
strictly to the pinned spec schemas. Gate 3 is baseline-aware by
design, which is exactly how the seven fiction fields shipped - the
baseline RECORDED the drift instead of binding the model to the
contract. Gate 5 has no refresh path, only the curated note-carrying
allowlist in tests/fixtures/audit-binding-allowlist.json (stale entries
fail; unresolvable bindings fail instead of skipping). Verified: RED on
the pre-fix model naming all seven fiction fields + request_type, RED
on a decoy @JsonProperty field, RED on an unresolvable type name, GREEN
post-fix.

Tests: real captured v9.13.0 payload (verbatim fixture, provenance in
the test Javadoc), old-server absence tolerance, fiction+real
both-present, old-constructor source-compat proof, action
serialization. Runtime-e2e leg runtime-e2e/audit_model_real_wire/
passes against a live community v9.13.0 agent through searchAuditLogs.

The per_type_drift baseline entries for the now-modeled fields are
removed so the recorded drift stays accurate.

Signed-off-by: Saurabh Jain <saurabh.jain@getaxonflow.com>
…ound-1 items

Gate 5 rebuilt at the capability level (R3 items 1+2). The source-regex
discovery in lib.py was proven bypassable two ways: a constant-valued
annotation (@JsonProperty(SOME_CONSTANT) - the regex needs a quoted
string, and the plain-field fallback attributes the JAVA field name,
which can even credit the fiction as spec coverage) and Jackson getter
auto-detection (an unannotated public getFoo() serializes foo with no
annotation anywhere in the source). Gate 5 now asks Jackson itself:
scripts/wire_shape/AuditWireKeysProbe.java runs against target/classes
plus the resolved dependency classpath and emits, per bound type, the
union of the serialization and deserialization bean descriptions - the
exact property set the production ObjectMapper can put on or read off
the wire. validate.py consumes that instead of the regex view for the
bound types (gates 1-4 keep the regex discovery unchanged). Missing
java, probe source, target/classes, classpath file, an unloadable
class, unparseable probe output, or a bound type reporting zero keys
all FAIL the gate - never skip. The wire-shape workflow gains a JDK
setup and a compile step so CI provides the artifacts.

Proofs executed against the rebuilt gate (outputs in the PR body):
both reviewer evasions re-run and RED - the constant-valued annotation
is named by its RESOLVED wire key fiction_const_field (not the java
name), the unannotated getter is named as fictionFilter; negative
control on the unmodified model GREEN; evasion sources reverted.

Smaller R3 items:
- Two added diff lines carried U+2014 from google-java-format rewraps
  of pre-existing prose (AuditLogEntry getTransferBasis Javadoc,
  AuditSearchRequest Builder.decisionId Javadoc): now hyphens.
- New hand-modified fixture audit-search-explicit-null.json + test:
  explicit JSON null on all three new fields normalizes to "" / empty
  map / null Long through the real mapper, pinning the canonical
  constructor's null guards (Jackson passes explicit null to creators).
- both-present fixture: injected success flipped true -> false so the
  assertion no longer matches the constructor default and can fail.
- Test Javadoc no longer claims the mapper "mirrors" production; it
  states the configuration is the same and the instance is not.
- CHANGELOG historic entries restored byte-for-byte from main; the
  diff now carries ONLY the Unreleased hunk.

Signed-off-by: Saurabh Jain <saurabh.jain@getaxonflow.com>
BLOCKER fixed by capability removal, per the gate's own principle
(unresolvable FAILS, never skips). BeanDescription.findProperties()
reports declared bean properties only; three executed review bypasses
rode mechanisms outside that view: a @JsonUnwrapped container whose
bean-property name is a bound spec key (emits fiction_unwrapped, never
the claimed cost), a @JsonAnyGetter map (arbitrary top-level fiction
keys at runtime), and a @JsonAlias fiction read key. The probe now
scans each bound type's hierarchy (class-level annotations, fields,
methods, constructors and their parameters) and exits 2 - refusing to
certify - on @JsonUnwrapped, @JsonAnyGetter, @JsonAnySetter,
@JsonAlias, @jsonvalue, or class-level @JsonSerialize /
@JsonDeserialize / @JsonTypeInfo / @JsonAppend / @JsonNaming.
@jsonvalue and the three extra class-level shape rewriters are the
same escape class as the named five, so the sweep covers them too.
The 'what this probe reports IS what can appear on the wire' claim is
replaced with a stated-scope paragraph naming the refused mechanisms
in the probe Javadoc and the validate.py docstring (and the PR body):
certification now means declared properties are spec-bound AND no
shape-escaping mechanism is present.

Proofs executed (outputs in the PR body): all three reviewer bypasses
re-run RED (probe exit 2 -> gate exit 1, each naming the mechanism and
member); negative control GREEN - none of the refused mechanisms exist
on the three bound types today.

Round-2 low items:
- Freshness guard: Gate 5 fails if any bound type's .java is newer
  than its .class ('the probe would certify stale bytecode. Recompile
  first: mvn -q compile'). Proven: touch a bound source -> RED,
  recompile -> GREEN. CI unaffected (compile immediately precedes).
- The probe's mapper is now obtained by reflecting the private
  production factory AxonFlow.createObjectMapper() instead of new
  ObjectMapper() - identical discovery today, but production gaining a
  module/introspector/naming strategy can no longer diverge silently.
  A rename breaks the gate loudly; mirror notes point both ways.
- pom.xml added to the wire-shape workflow paths filter: a Jackson
  version bump is exactly what changes the probe's view and now
  re-runs the job.

Signed-off-by: Saurabh Jain <saurabh.jain@getaxonflow.com>
@saurabhjain1592
saurabhjain1592 merged commit 6fd965e into main Aug 4, 2026
18 checks passed
@saurabhjain1592
saurabhjain1592 deleted the feat/3254-audit-real-wire-fields branch August 4, 2026 07:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant