diff --git a/CHANGELOG.md b/CHANGELOG.md index fd20eb8..480602c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- masfeat real wire mapping (#3254 pin-advance batch). `RegistrySummary` + gains the real `org_id` (`getOrgId()`), `assessments_due` + (`getAssessmentsDue()`) and `kill_switches_triggered` + (`getKillSwitchesTriggered()`) fields, and its parser now reads the real + `high_materiality`/`medium_materiality`/`low_materiality` keys first + (previously `medium`/`low` were read ONLY under the never-served `_count` + spelling and were always 0 against a real server; the legacy spelling is + kept as a fallback). `AISystemRegistry` gains `owner_email` + (`getOwnerEmail()`, the real wire key; `getBusinessOwner()` remains as a + populated compatibility alias) and its parser prefers the real + `materiality_classification` key. `KillSwitch`'s parser now prefers the + real `trigger_reason` key (the server has never sent `triggered_reason`). + All three models now carry `@JsonProperty` tags with the REAL wire names + so the Jackson surface tells the truth and the wire-shape binding gate + can bind them; the hand-written parsers remain the IO path. +- Wire-shape Gate 5 extended to the masfeat models `RegistrySummary`, + `KillSwitch` and `AISystemRegistry` (nested-class binding support + + per-type source registration for the freshness guard). + `OJKAuditExportResponse` is not modeled by this SDK - nothing to bind. + - Real wire fields `policy_decision` (`getPolicyDecision()`), `policy_details` (`getPolicyDetails()`), `response_time_ms` (`getResponseTimeMs()`) on the audit read model (`AuditLogEntry`), and `action` (`Builder.action(String)`) @@ -30,6 +50,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Deprecated +- `RegistrySummary.getByUseCase()`/`getByStatus()` (and setters) and + `AISystemRegistry.getTechnicalOwner()`/`setTechnicalOwner()` - never + served on the 9.x line (#3254 pin-advance batch). Removal rides the next + major. + - `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 diff --git a/runtime-e2e/masfeat_registry_summary/MasfeatRegistrySummaryTest.java b/runtime-e2e/masfeat_registry_summary/MasfeatRegistrySummaryTest.java new file mode 100644 index 0000000..249a5d7 --- /dev/null +++ b/runtime-e2e/masfeat_registry_summary/MasfeatRegistrySummaryTest.java @@ -0,0 +1,112 @@ +/* + * runtime-e2e/masfeat_registry_summary/MasfeatRegistrySummaryTest.java + * + * Real-stack leg for the #3254 pin-advance batch (masfeat models). + * + * MAS FEAT is an Enterprise module: on a community build the orchestrator + * registers NO masfeat routes (platform/orchestrator/masfeat/ + * masfeat_community.go RegisterRoutes is a no-op), so against a community + * stack the correct observable behavior of the SDK is a clean HTTP-level + * refusal (404 route-not-found surfaced as an AxonFlowException), NOT a + * parse error and NOT a fabricated summary object. + * + * This test therefore asserts one of two legitimate outcomes, and prints + * which one it exercised: + * + * ENTERPRISE leg: getRegistrySummary() succeeds; the #3254 real fields + * (org_id-derived getOrgId, assessments_due, kill_switches_triggered, + * medium/low materiality counters) are readable and the deprecated + * by_use_case / by_status fiction maps are null. + * + * COMMUNITY leg: the call fails with an HTTP-level error (route absent / + * gated) - and specifically NOT a JSON parse failure, which would mean + * the SDK mis-handled the gate. + * + * 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): + * + * 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/masfeat_registry_summary/MasfeatRegistrySummaryTest.java + */ +import com.getaxonflow.sdk.AxonFlow; +import com.getaxonflow.sdk.AxonFlowConfig; +import com.getaxonflow.sdk.exceptions.AxonFlowException; +import com.getaxonflow.sdk.masfeat.MASFEATTypes.RegistrySummary; + +public class MasfeatRegistrySummaryTest { + + 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"); + + AxonFlow client = + AxonFlow.create( + AxonFlowConfig.builder() + .endpoint(endpoint) + .clientId(env("AXONFLOW_CLIENT_ID", "demo-client")) + .clientSecret(env("AXONFLOW_CLIENT_SECRET", "demo-secret")) + .build()); + + RegistrySummary summary; + try { + summary = client.masfeat().getRegistrySummary(); + } catch (AxonFlowException e) { + String msg = e.getMessage() == null ? "" : e.getMessage(); + if (msg.contains("Failed to parse")) { + fail("community gate surfaced as a PARSE error - the SDK mishandled the refusal: " + msg); + } + System.out.println( + "PASS [community-gate] masfeat route refused cleanly by a community stack " + + "(enterprise-only module, no routes registered): " + + e.getClass().getSimpleName() + + ": " + + msg); + System.out.println( + "NOTE: enterprise leg NOT exercised on this stack - real-field assertions " + + "rest on the source-derived WireMock suite " + + "(src/test/java/com/getaxonflow/sdk/masfeat/MASFEATRealWireTest.java)."); + return; + } + + // Enterprise leg: the real #3254 fields must be readable. + System.out.println( + "PASS [enterprise-live] registry summary: orgId=" + + summary.getOrgId() + + " total=" + + summary.getTotalSystems() + + " active=" + + summary.getActiveSystems() + + " high=" + + summary.getHighMaterialityCount() + + " medium=" + + summary.getMediumMaterialityCount() + + " low=" + + summary.getLowMaterialityCount() + + " assessmentsDue=" + + summary.getAssessmentsDue() + + " killSwitchesTriggered=" + + summary.getKillSwitchesTriggered()); + if (summary.getByUseCase() != null || summary.getByStatus() != null) { + fail("deprecated by_use_case/by_status came back non-null - the server never serves them; " + + "a non-null value means the model regressed into fiction"); + } + System.out.println("PASS [deprecated-defaults] by_use_case/by_status null as expected"); + } +} diff --git a/runtime-e2e/masfeat_registry_summary/README.md b/runtime-e2e/masfeat_registry_summary/README.md new file mode 100644 index 0000000..eca9fec --- /dev/null +++ b/runtime-e2e/masfeat_registry_summary/README.md @@ -0,0 +1,35 @@ +# masfeat_registry_summary (masfeat real-wire fields, #3254 pin-advance batch) + +Real-stack leg for the getaxonflow/axonflow-enterprise#3254 pin-advance +batch: the masfeat models now carry the real wire mapping (see +`MASFEATTypes` and the `MASFEATNamespace` parsers). + +MAS FEAT is an Enterprise module. On a community build the orchestrator +registers no masfeat routes (`masfeat_community.go` `RegisterRoutes` is a +no-op), so this test asserts one of two legitimate outcomes through the +SDK's real public surface (`client.masfeat().getRegistrySummary()`) +against a real running agent, no mocks: + +- **Enterprise leg:** the summary parses; the #3254 real fields + (`org_id`, `assessments_due`, `kill_switches_triggered`, the + suffix-less materiality counters) are readable; the deprecated + `by_use_case`/`by_status` fiction maps are null. +- **Community leg:** the call is refused at the HTTP level (route + absent) and the SDK surfaces a clean `AxonFlowException` - NOT a + parse failure. The test prints a NOTE that the enterprise leg was not + exercised; real-field assertions then rest on the source-derived + WireMock suite (`MASFEATRealWireTest`). + +## Run + +```bash +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/masfeat_registry_summary/MasfeatRegistrySummaryTest.java +``` diff --git a/scripts/wire_shape/AuditWireKeysProbe.java b/scripts/wire_shape/AuditWireKeysProbe.java index a7e3bcb..08514ce 100644 --- a/scripts/wire_shape/AuditWireKeysProbe.java +++ b/scripts/wire_shape/AuditWireKeysProbe.java @@ -36,7 +36,11 @@ * 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. + * bean descriptions, and prints one JSON object of the shape {@code {SimpleName: {"keys": + * [sorted wire keys], "deprecated": [subset whose backing member - field, getter, setter, or + * creator parameter - carries {@code @Deprecated}]}}}. The {@code deprecated} set lets the + * caller enforce the deprecation tie: an allowlisted fiction key must be visibly deprecated in + * the model, not silently tolerated. * *

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 @@ -90,21 +94,31 @@ public static void main(String[] args) { } try { ObjectMapper mapper = productionConfiguredMapper(); - TreeMap> result = new TreeMap<>(); + TreeMap>> result = new TreeMap<>(); for (String fqcn : args) { Class cls = Class.forName(fqcn); refuseUnintrospectableMechanisms(cls); JavaType type = mapper.constructType(cls); TreeSet keys = new TreeSet<>(); + TreeSet deprecated = new TreeSet<>(); BeanDescription ser = mapper.getSerializationConfig().introspect(type); for (BeanPropertyDefinition p : ser.findProperties()) { keys.add(p.getName()); + if (isDeprecated(p)) { + deprecated.add(p.getName()); + } } BeanDescription deser = mapper.getDeserializationConfig().introspect(type); for (BeanPropertyDefinition p : deser.findProperties()) { keys.add(p.getName()); + if (isDeprecated(p)) { + deprecated.add(p.getName()); + } } - result.put(cls.getSimpleName(), keys); + TreeMap> entry = new TreeMap<>(); + entry.put("keys", keys); + entry.put("deprecated", deprecated); + result.put(cls.getSimpleName(), entry); } System.out.println(mapper.writeValueAsString(result)); } catch (Throwable t) { @@ -128,6 +142,19 @@ private static ObjectMapper productionConfiguredMapper() throws Exception { return (ObjectMapper) factory.invoke(null); } + /** + * A property is deprecated if ANY of its backing members (field, getter, setter, creator + * parameter) carries {@code @Deprecated}. {@code java.lang.Deprecated} has runtime retention, + * so the compiled classes carry it. + */ + private static boolean isDeprecated(BeanPropertyDefinition p) { + return (p.getField() != null && p.getField().getAnnotation(Deprecated.class) != null) + || (p.getGetter() != null && p.getGetter().getAnnotation(Deprecated.class) != null) + || (p.getSetter() != null && p.getSetter().getAnnotation(Deprecated.class) != null) + || (p.getConstructorParameter() != null + && p.getConstructorParameter().getAnnotation(Deprecated.class) != 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); diff --git a/scripts/wire_shape/validate.py b/scripts/wire_shape/validate.py index 3b17d1e..0999cee 100755 --- a/scripts/wire_shape/validate.py +++ b/scripts/wire_shape/validate.py @@ -41,6 +41,19 @@ @JsonTypeInfo / @JsonAppend / @JsonNaming. Certification is therefore: the declared properties are spec-bound AND no shape-escaping mechanism is present. + + REGISTRATION IS MANUAL: a model class is under gate 5 ONLY if it is + listed in AUDIT_BINDING_TYPES. An unregistered model class is + invisible to this gate; gate 3 catches its drift only when the + class name happens to match a spec schema name (and only + baseline-aware). When adding a wire model, register it here. + + Deprecation tie: every allowlisted (unbound) key must be backed by + an @Deprecated member in the model - named debt must be VISIBLE to + consumers, not silently tolerated. Exception: allowlist entries + whose note contains the word "alias" (case-insensitive) declare a + parser-populated compatibility alias carrying real data (e.g. + AISystemRegistry.businessOwner) and are exempt. Prerequisites (CI compiles them in the workflow; locally run `mvn -q compile dependency:build-classpath -Dmdep.outputFile=target/wire-shape-cp.txt` first): @@ -73,15 +86,42 @@ load_baseline, ) -# Gate 5 (audit-surface binding, #3254): the audit read/search surface is +# Gate 5 (audit-surface binding, #3254): the audit + masfeat surfaces are # 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" +# does not apply here. Keys are the OpenAPI schema names; each entry maps +# to the binary class name (nested classes use `$`) and the source file +# for the freshness guard. Add an entry to put a type under binding. +AUDIT_BINDING_TYPES = { + "AuditLogEntry": { + "fqcn": "com.getaxonflow.sdk.types.AuditLogEntry", + "source": "src/main/java/com/getaxonflow/sdk/types/AuditLogEntry.java", + }, + "AuditSearchRequest": { + "fqcn": "com.getaxonflow.sdk.types.AuditSearchRequest", + "source": "src/main/java/com/getaxonflow/sdk/types/AuditSearchRequest.java", + }, + "AuditSearchResponse": { + "fqcn": "com.getaxonflow.sdk.types.AuditSearchResponse", + "source": "src/main/java/com/getaxonflow/sdk/types/AuditSearchResponse.java", + }, + # #3254 pin-advance batch: masfeat models. These are populated by + # hand-written parsers in AxonFlow.MASFEATNamespace, not by Jackson + # databind - the @JsonProperty tags exist so the Jackson surface tells + # the truth about the wire and THIS gate can bind it to the spec. + # (OJKAuditExportResponse is not modeled by this SDK - nothing to bind.) + "RegistrySummary": { + "fqcn": "com.getaxonflow.sdk.masfeat.MASFEATTypes$RegistrySummary", + "source": "src/main/java/com/getaxonflow/sdk/masfeat/MASFEATTypes.java", + }, + "KillSwitch": { + "fqcn": "com.getaxonflow.sdk.masfeat.MASFEATTypes$KillSwitch", + "source": "src/main/java/com/getaxonflow/sdk/masfeat/MASFEATTypes.java", + }, + "AISystemRegistry": { + "fqcn": "com.getaxonflow.sdk.masfeat.MASFEATTypes$AISystemRegistry", + "source": "src/main/java/com/getaxonflow/sdk/masfeat/MASFEATTypes.java", + }, +} AUDIT_BINDING_ALLOWLIST_PATH = ( REPO_ROOT / "tests" / "fixtures" / "audit-binding-allowlist.json" ) @@ -90,26 +130,20 @@ DEP_CLASSPATH_FILE = REPO_ROOT / "target" / "wire-shape-cp.txt" -def probe_audit_wire_keys() -> dict[str, list[str]]: +def probe_audit_wire_keys() -> dict[str, 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. + Returns {SimpleTypeName: {"keys": sorted_wire_keys, "deprecated": + sorted_subset_backed_by_an_@Deprecated_member}}. 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 " @@ -119,12 +153,13 @@ def probe_audit_wire_keys() -> dict[str, list[str]]: # 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" + # The class file path is derived from the binary name, so nested + # classes (Outer$Inner.class) are covered automatically. + for type_name, binding in AUDIT_BINDING_TYPES.items(): + src = REPO_ROOT / binding["source"] + cls = TARGET_CLASSES / (binding["fqcn"].replace(".", "/") + ".class") if not src.is_file(): - # A bound type without a same-named source file would be a + # A bound type without its registered 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( @@ -158,7 +193,7 @@ def probe_audit_wire_keys() -> dict[str, list[str]]: "-cp", classpath, str(AUDIT_PROBE_SOURCE), - ] + [f"{AUDIT_BINDING_PACKAGE}.{t}" for t in AUDIT_BINDING_TYPES] + ] + [b["fqcn"] for b in AUDIT_BINDING_TYPES.values()] proc = subprocess.run(cmd, capture_output=True, text=True) if proc.returncode != 0: raise SystemExit( @@ -174,14 +209,21 @@ def probe_audit_wire_keys() -> dict[str, list[str]]: f"({e.__class__.__name__}: {e}):\n{proc.stdout[:2000]}" ) from None for type_name in AUDIT_BINDING_TYPES: - if not parsed.get(type_name): + entry = parsed.get(type_name) or {} + if not entry.get("keys"): 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()} + return { + k: { + "keys": sorted(v.get("keys", [])), + "deprecated": sorted(v.get("deprecated", [])), + } + for k, v in parsed.items() + } def load_audit_binding_allowlist() -> dict[str, dict[str, str]]: @@ -431,7 +473,8 @@ def main() -> int: probed = probe_audit_wire_keys() binding_problems: list[str] = [] for type_name in AUDIT_BINDING_TYPES: - sdk_fields = probed[type_name] + sdk_fields = probed[type_name]["keys"] + deprecated_keys = set(probed[type_name]["deprecated"]) spec_fields = merged.get(type_name) if spec_fields is None: binding_problems.append( @@ -471,6 +514,26 @@ def main() -> int: f"tests/fixtures/audit-binding-allowlist.json so the " f"allowlist only ever names live debt." ) + # Deprecation tie: an allowlisted key that is genuinely unbound + # (live debt) must be backed by an @Deprecated member so the debt + # is visible to consumers - unless its note declares a + # parser-populated compatibility "alias" carrying real data. + untied = sorted( + f + for f in allowed + if f in difference(sdk_fields, spec_fields) + and f not in deprecated_keys + and "alias" not in allowed[f].lower() + ) + if untied: + binding_problems.append( + f" {type_name}: allowlisted fiction key(s) {untied} have " + f"no @Deprecated backing member in the model. Named debt " + f"must be visible: deprecate the accessor(s) with the " + f"canonical #3254 wording, or - ONLY if the field is a " + f"parser-populated compatibility alias carrying real " + f"data - say 'alias' in its allowlist note." + ) spec_missing = difference(spec_fields, sdk_fields) if spec_missing: # Informational only: fields the server serves that the SDK diff --git a/src/main/java/com/getaxonflow/sdk/AxonFlow.java b/src/main/java/com/getaxonflow/sdk/AxonFlow.java index 4f38d28..ebdf795 100644 --- a/src/main/java/com/getaxonflow/sdk/AxonFlow.java +++ b/src/main/java/com/getaxonflow/sdk/AxonFlow.java @@ -7319,6 +7319,7 @@ public List getKillSwitchHistory(String systemId, int limit) { // Response Parsing Helpers // ======================================================================== + @SuppressWarnings("deprecation") // populates deprecated fiction fields for tolerance (#3254) private AISystemRegistry parseSystemResponse(Response response) throws IOException { handleErrorResponse(response); @@ -7338,6 +7339,10 @@ private AISystemRegistry parseSystemResponse(Response response) throws IOExcepti system.setDescription(getTextOrNull(node, "description")); system.setOwnerTeam(getTextOrNull(node, "owner_team")); system.setTechnicalOwner(getTextOrNull(node, "technical_owner")); + // #3254: owner_email is the real wire key. ownerEmail carries it under + // its true name; businessOwner keeps receiving it as the historic + // compatibility alias. + system.setOwnerEmail(getTextOrNull(node, "owner_email")); system.setBusinessOwner(getTextOrNull(node, "owner_email")); system.setCreatedBy(getTextOrNull(node, "created_by")); @@ -7356,10 +7361,11 @@ private AISystemRegistry parseSystemResponse(Response response) throws IOExcepti system.setModelComplexity(getIntOrZero(node, "risk_rating_complexity")); system.setHumanReliance(getIntOrZero(node, "risk_rating_reliance")); - // Handle materiality (may be "materiality" or "materiality_classification") - String materiality = getTextOrNull(node, "materiality"); + // #3254: materiality_classification is the real wire key; read it + // first, keep the legacy "materiality" spelling as a fallback. + String materiality = getTextOrNull(node, "materiality_classification"); if (materiality == null) { - materiality = getTextOrNull(node, "materiality_classification"); + materiality = getTextOrNull(node, "materiality"); } if (materiality != null) { try { @@ -7393,6 +7399,7 @@ private AISystemRegistry parseSystemResponse(Response response) throws IOExcepti return system; } + @SuppressWarnings("deprecation") // populates deprecated fiction fields for tolerance (#3254) private RegistrySummary parseSummaryResponse(Response response) throws IOException { handleErrorResponse(response); @@ -7405,18 +7412,24 @@ private RegistrySummary parseSummaryResponse(Response response) throws IOExcepti JsonNode node = objectMapper.readTree(json); RegistrySummary summary = new RegistrySummary(); + summary.setOrgId(getTextOrNull(node, "org_id")); summary.setTotalSystems(getIntOrZero(node, "total_systems")); summary.setActiveSystems(getIntOrZero(node, "active_systems")); - // Handle high_materiality_count (may be "high_materiality_count" or "high_materiality") - int highMateriality = getIntOrZero(node, "high_materiality_count"); - if (highMateriality == 0) { - highMateriality = getIntOrZero(node, "high_materiality"); - } - summary.setHighMaterialityCount(highMateriality); - - summary.setMediumMaterialityCount(getIntOrZero(node, "medium_materiality_count")); - summary.setLowMaterialityCount(getIntOrZero(node, "low_materiality_count")); + // #3254: the server's RegistrySummary serves high_materiality / + // medium_materiality / low_materiality (no _count suffix). Read the + // real key first; fall back to the legacy _count spelling so a + // hypothetical old payload still parses. The pre-#3254 parser read + // medium/low ONLY under the _count fiction, so both were always 0 + // against a real server. + summary.setHighMaterialityCount( + intWithFallback(node, "high_materiality", "high_materiality_count")); + summary.setMediumMaterialityCount( + intWithFallback(node, "medium_materiality", "medium_materiality_count")); + summary.setLowMaterialityCount( + intWithFallback(node, "low_materiality", "low_materiality_count")); + summary.setAssessmentsDue(getIntOrZero(node, "assessments_due")); + summary.setKillSwitchesTriggered(getIntOrZero(node, "kill_switches_triggered")); if (node.has("by_use_case") && !node.get("by_use_case").isNull()) { summary.setByUseCase( @@ -7544,10 +7557,12 @@ private KillSwitch parseKillSwitchResponse(Response response) throws IOException ks.setTriggeredBy(getTextOrNull(node, "triggered_by")); ks.setRestoredBy(getTextOrNull(node, "restored_by")); - // Handle triggered_reason (may be "triggered_reason" or "trigger_reason") - String triggeredReason = getTextOrNull(node, "triggered_reason"); + // #3254: the server serves trigger_reason; triggered_reason has never + // been sent. Read the real key first, keep the legacy spelling as a + // fallback for tolerance. + String triggeredReason = getTextOrNull(node, "trigger_reason"); if (triggeredReason == null) { - triggeredReason = getTextOrNull(node, "trigger_reason"); + triggeredReason = getTextOrNull(node, "triggered_reason"); } ks.setTriggeredReason(triggeredReason); @@ -7674,6 +7689,18 @@ private int getIntOrZero(JsonNode node, String field) { return 0; } + /** + * Reads {@code primary} if present (even when 0), otherwise {@code fallback}, otherwise 0. + * Used for #3254 real-key-first reads with legacy-spelling tolerance: a PRESENT primary key + * always wins so a genuine 0 is never overridden by a stale fallback value. + */ + private int intWithFallback(JsonNode node, String primary, String fallback) { + if (node.has(primary) && !node.get(primary).isNull()) { + return node.get(primary).asInt(); + } + return getIntOrZero(node, fallback); + } + private Integer getIntegerOrNull(JsonNode node, String field) { if (node.has(field) && !node.get(field).isNull()) { return node.get(field).asInt(); diff --git a/src/main/java/com/getaxonflow/sdk/masfeat/MASFEATTypes.java b/src/main/java/com/getaxonflow/sdk/masfeat/MASFEATTypes.java index 7a0908c..ee2723f 100644 --- a/src/main/java/com/getaxonflow/sdk/masfeat/MASFEATTypes.java +++ b/src/main/java/com/getaxonflow/sdk/masfeat/MASFEATTypes.java @@ -1,5 +1,6 @@ package com.getaxonflow.sdk.masfeat; +import com.fasterxml.jackson.annotation.JsonProperty; import java.time.Instant; import java.util.List; import java.util.Map; @@ -546,28 +547,73 @@ public RegisterSystemRequest build() { } } - /** AI system registry entry. */ + /** + * AI system registry entry. + * + *

Wire mapping note (#3254): the {@code @JsonProperty} tags carry the REAL wire names served + * by the orchestrator's {@code masfeat.AISystemRegistry} struct. The SDK populates this type via + * a hand-written parser ({@code AxonFlow.MASFEATNamespace}); the tags exist so the Jackson + * surface is truthful and the wire-shape binding gate can bind this model to the spec. The + * {@code customerImpact}/{@code modelComplexity}/{@code humanReliance} JAVA names are historic; + * their wire names are {@code risk_rating_impact}/{@code risk_rating_complexity}/{@code + * risk_rating_reliance} and the data is real. {@code businessOwner} is a compatibility alias + * populated from the wire's {@code owner_email}; prefer {@link #getOwnerEmail()}. + */ public static class AISystemRegistry { + @JsonProperty("id") private String id; + + @JsonProperty("org_id") private String orgId; + + @JsonProperty("system_id") private String systemId; + + @JsonProperty("system_name") private String systemName; + + @JsonProperty("use_case") private AISystemUseCase useCase; + + @JsonProperty("owner_team") private String ownerTeam; + + @JsonProperty("risk_rating_impact") private int customerImpact; + + @JsonProperty("risk_rating_complexity") private int modelComplexity; + + @JsonProperty("risk_rating_reliance") private int humanReliance; - @com.fasterxml.jackson.annotation.JsonProperty("materiality_classification") + @JsonProperty("materiality_classification") private MaterialityClassification materialityClassification; + @JsonProperty("status") private SystemStatus status; + + @JsonProperty("created_at") private Instant createdAt; + + @JsonProperty("updated_at") private Instant updatedAt; + + @JsonProperty("description") private String description; + + @JsonProperty("technical_owner") private String technicalOwner; + private String businessOwner; + + @JsonProperty("owner_email") + private String ownerEmail; + + @JsonProperty("metadata") private Map metadata; + + @JsonProperty("created_by") private String createdBy; public String getId() { @@ -682,14 +728,31 @@ public void setDescription(String description) { this.description = description; } + /** + * Returns the technical owner. + * + * @deprecated never populated on the 9.x line - the server has never sent {@code + * technical_owner} (getaxonflow/axonflow-enterprise#3254); the wire carries {@code + * owner_email} and {@code owner_team}. Read {@link #getOwnerEmail()} and {@link + * #getOwnerTeam()}. Scheduled for removal in the next major. + */ + @Deprecated public String getTechnicalOwner() { return technicalOwner; } + /** + * @deprecated see {@link #getTechnicalOwner()} - never populated on the 9.x line (#3254). + */ + @Deprecated public void setTechnicalOwner(String technicalOwner) { this.technicalOwner = technicalOwner; } + /** + * Returns the business owner - a compatibility alias populated from the wire's {@code + * owner_email} (there is no {@code business_owner} wire key). Prefer {@link #getOwnerEmail()}. + */ public String getBusinessOwner() { return businessOwner; } @@ -698,6 +761,15 @@ public void setBusinessOwner(String businessOwner) { this.businessOwner = businessOwner; } + /** Returns the owner email, as served on the wire ({@code owner_email}). */ + public String getOwnerEmail() { + return ownerEmail; + } + + public void setOwnerEmail(String ownerEmail) { + this.ownerEmail = ownerEmail; + } + public Map getMetadata() { return metadata; } @@ -715,16 +787,74 @@ public void setCreatedBy(String createdBy) { } } - /** Registry summary statistics. */ + /** + * Registry summary statistics. + * + *

Wire mapping note (#3254): the server's {@code masfeat.RegistrySummary} serves {@code + * org_id}, {@code total_systems}, {@code active_systems}, {@code high_materiality}, {@code + * medium_materiality}, {@code low_materiality}, {@code assessments_due} and {@code + * kill_switches_triggered}. The {@code *MaterialityCount} JAVA names are historic; their wire + * names have no {@code _count} suffix and the data is real. {@code byUseCase}/{@code byStatus} + * are deprecated fiction - see their getters. + */ public static class RegistrySummary { + @JsonProperty("org_id") + private String orgId; + + @JsonProperty("total_systems") private int totalSystems; + + @JsonProperty("active_systems") private int activeSystems; + + @JsonProperty("high_materiality") private int highMaterialityCount; + + @JsonProperty("medium_materiality") private int mediumMaterialityCount; + + @JsonProperty("low_materiality") private int lowMaterialityCount; + + @JsonProperty("assessments_due") + private int assessmentsDue; + + @JsonProperty("kill_switches_triggered") + private int killSwitchesTriggered; + + @JsonProperty("by_use_case") private Map byUseCase; + + @JsonProperty("by_status") private Map byStatus; + /** Returns the organization ID, as served on the wire ({@code org_id}). */ + public String getOrgId() { + return orgId; + } + + public void setOrgId(String orgId) { + this.orgId = orgId; + } + + /** Returns the number of assessments due ({@code assessments_due}). */ + public int getAssessmentsDue() { + return assessmentsDue; + } + + public void setAssessmentsDue(int assessmentsDue) { + this.assessmentsDue = assessmentsDue; + } + + /** Returns the number of triggered kill switches ({@code kill_switches_triggered}). */ + public int getKillSwitchesTriggered() { + return killSwitchesTriggered; + } + + public void setKillSwitchesTriggered(int killSwitchesTriggered) { + this.killSwitchesTriggered = killSwitchesTriggered; + } + public int getTotalSystems() { return totalSystems; } @@ -765,18 +895,45 @@ public void setLowMaterialityCount(int lowMaterialityCount) { this.lowMaterialityCount = lowMaterialityCount; } + /** + * Returns systems grouped by use case. + * + * @deprecated never populated on the 9.x line - the server's {@code RegistrySummary} has + * never carried {@code by_use_case} (getaxonflow/axonflow-enterprise#3254). Read the + * materiality counters ({@link #getHighMaterialityCount()} etc.), {@link + * #getAssessmentsDue()} and {@link #getKillSwitchesTriggered()}. Scheduled for removal in + * the next major. + */ + @Deprecated public Map getByUseCase() { return byUseCase; } + /** + * @deprecated see {@link #getByUseCase()} - never populated on the 9.x line (#3254). + */ + @Deprecated public void setByUseCase(Map byUseCase) { this.byUseCase = byUseCase; } + /** + * Returns systems grouped by status. + * + * @deprecated never populated on the 9.x line - the server's {@code RegistrySummary} has + * never carried {@code by_status} (getaxonflow/axonflow-enterprise#3254). Read {@link + * #getActiveSystems()} and {@link #getTotalSystems()}. Scheduled for removal in the next + * major. + */ + @Deprecated public Map getByStatus() { return byStatus; } + /** + * @deprecated see {@link #getByStatus()} - never populated on the 9.x line (#3254). + */ + @Deprecated public void setByStatus(Map byStatus) { this.byStatus = byStatus; } @@ -1300,20 +1457,54 @@ public RejectAssessmentRequest build() { /** Kill switch configuration. */ public static class KillSwitch { + @JsonProperty("id") private String id; + + @JsonProperty("org_id") private String orgId; + + @JsonProperty("system_id") private String systemId; + + @JsonProperty("status") private KillSwitchStatus status; + + @JsonProperty("auto_trigger_enabled") private boolean autoTriggerEnabled; + + @JsonProperty("accuracy_threshold") private Double accuracyThreshold; + + @JsonProperty("bias_threshold") private Double biasThreshold; + + @JsonProperty("error_rate_threshold") private Double errorRateThreshold; + + @JsonProperty("triggered_at") private Instant triggeredAt; + + @JsonProperty("triggered_by") private String triggeredBy; + + /** + * Wire mapping note (#3254): the wire key is {@code trigger_reason} (the server has never + * sent {@code triggered_reason}). The JAVA name is historic; the data is real - the parser + * reads {@code trigger_reason} first and falls back to the legacy spelling. + */ + @JsonProperty("trigger_reason") private String triggeredReason; + + @JsonProperty("restored_at") private Instant restoredAt; + + @JsonProperty("restored_by") private String restoredBy; + + @JsonProperty("created_at") private Instant createdAt; + + @JsonProperty("updated_at") private Instant updatedAt; public String getId() { diff --git a/src/test/java/com/getaxonflow/sdk/masfeat/MASFEATRealWireTest.java b/src/test/java/com/getaxonflow/sdk/masfeat/MASFEATRealWireTest.java new file mode 100644 index 0000000..2bff90d --- /dev/null +++ b/src/test/java/com/getaxonflow/sdk/masfeat/MASFEATRealWireTest.java @@ -0,0 +1,293 @@ +/* + * 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.masfeat; + +import static com.github.tomakehurst.wiremock.client.WireMock.*; +import static org.assertj.core.api.Assertions.*; + +import com.getaxonflow.sdk.AxonFlow; +import com.getaxonflow.sdk.masfeat.MASFEATTypes.*; +import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo; +import com.github.tomakehurst.wiremock.junit5.WireMockTest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Real-wire masfeat model tests (getaxonflow/axonflow-enterprise#3254, pin-advance batch). + * + *

Fixture provenance: every payload in this class is SOURCE-DERIVED from the orchestrator's + * masfeat structs ({@code platform/orchestrator/masfeat/types.go} at community tag v9.13.0, + * df027c788) and the pinned {@code masfeat-api.yaml} schemas - these are NOT live captures (the + * masfeat module is an Enterprise feature; the community stack registers no masfeat routes, see + * {@code masfeat_community.go}). Payloads marked "both spellings" are hand-constructed + * discriminators and say so. + * + *

These tests exercise the SDK's REAL parse path - the hand-written parsers in {@code + * AxonFlow.MASFEATNamespace} reached through the public client methods over WireMock - not the + * post-parse object shape (a #3254-class fiction lives in what the parser READS, so only + * driving the parser can catch it). + * + *

Every assertion on a #3254 fix is mutation-proof by construction: the asserted value differs + * from what the PRE-FIX parser would have produced (0 / null / the legacy key's value). + */ +@WireMockTest +@DisplayName("MAS FEAT real-wire parsing (#3254)") +class MASFEATRealWireTest { + + private AxonFlow client; + + @BeforeEach + void setUp(WireMockRuntimeInfo wmRuntimeInfo) { + client = + AxonFlow.create( + AxonFlow.builder() + .endpoint(wmRuntimeInfo.getHttpBaseUrl()) + .clientId("test-client") + .clientSecret("test-secret") + .build()); + } + + @Test + @DisplayName("RegistrySummary - server-shaped payload populates every real field") + @SuppressWarnings("deprecation") + void registrySummaryServerShape() { + // Source-derived from masfeat.RegistrySummary (types.go:431-440): the + // real keys carry NO _count suffix. Distinct values everywhere so a + // wrong-key read cannot accidentally pass. The pre-#3254 parser read + // medium/low ONLY under the _count fiction spelling (always 0 against + // this payload) and never read org_id / assessments_due / + // kill_switches_triggered at all. + String responseJson = + "{" + + "\"org_id\": \"org-mas-1\"," + + "\"total_systems\": 11," + + "\"active_systems\": 7," + + "\"high_materiality\": 2," + + "\"medium_materiality\": 5," + + "\"low_materiality\": 4," + + "\"assessments_due\": 3," + + "\"kill_switches_triggered\": 1" + + "}"; + stubFor( + get(urlEqualTo("/api/v1/masfeat/registry/summary")) + .willReturn( + aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(responseJson))); + + RegistrySummary result = client.masfeat().getRegistrySummary(); + + assertThat(result.getOrgId()).isEqualTo("org-mas-1"); + assertThat(result.getTotalSystems()).isEqualTo(11); + assertThat(result.getActiveSystems()).isEqualTo(7); + assertThat(result.getHighMaterialityCount()).isEqualTo(2); + assertThat(result.getMediumMaterialityCount()).isEqualTo(5); + assertThat(result.getLowMaterialityCount()).isEqualTo(4); + assertThat(result.getAssessmentsDue()).isEqualTo(3); + assertThat(result.getKillSwitchesTriggered()).isEqualTo(1); + // The server's RegistrySummary has no by_use_case / by_status - the + // deprecated fiction maps stay null. + assertThat(result.getByUseCase()).isNull(); + assertThat(result.getByStatus()).isNull(); + } + + @Test + @DisplayName("RegistrySummary - real key wins over legacy _count spelling when both present") + void registrySummaryRealKeyWins() { + // Hand-constructed discriminator (both spellings, different values, not + // a capture): the real key must win even when it is 0. The pre-#3254 + // parser preferred the _count spelling for high and read ONLY the + // _count spelling for medium/low. + String responseJson = + "{" + + "\"total_systems\": 1," + + "\"high_materiality\": 0," + + "\"high_materiality_count\": 9," + + "\"medium_materiality\": 6," + + "\"medium_materiality_count\": 9," + + "\"low_materiality\": 5," + + "\"low_materiality_count\": 9" + + "}"; + stubFor( + get(urlEqualTo("/api/v1/masfeat/registry/summary")) + .willReturn( + aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(responseJson))); + + RegistrySummary result = client.masfeat().getRegistrySummary(); + + assertThat(result.getHighMaterialityCount()).isEqualTo(0); + assertThat(result.getMediumMaterialityCount()).isEqualTo(6); + assertThat(result.getLowMaterialityCount()).isEqualTo(5); + } + + @Test + @DisplayName("RegistrySummary - legacy _count-only payload still parses (fallback tolerance)") + void registrySummaryLegacyFallback() { + // Hand-constructed legacy shape (fiction spellings only, not a capture): + // the fallback keeps tolerating it. + String responseJson = + "{" + + "\"total_systems\": 4," + + "\"high_materiality_count\": 1," + + "\"medium_materiality_count\": 2," + + "\"low_materiality_count\": 1" + + "}"; + stubFor( + get(urlEqualTo("/api/v1/masfeat/registry/summary")) + .willReturn( + aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(responseJson))); + + RegistrySummary result = client.masfeat().getRegistrySummary(); + + assertThat(result.getHighMaterialityCount()).isEqualTo(1); + assertThat(result.getMediumMaterialityCount()).isEqualTo(2); + assertThat(result.getLowMaterialityCount()).isEqualTo(1); + // Fields absent from a legacy payload default cleanly. + assertThat(result.getOrgId()).isNull(); + assertThat(result.getAssessmentsDue()).isEqualTo(0); + assertThat(result.getKillSwitchesTriggered()).isEqualTo(0); + } + + @Test + @DisplayName("KillSwitch - trigger_reason (real key) wins over triggered_reason") + void killSwitchTriggerReasonRealKeyWins() { + // Source-derived from masfeat.KillSwitch (types.go:283-303) plus a + // hand-injected legacy spelling as a discriminator: the server serves + // trigger_reason; triggered_reason has never been sent. The pre-#3254 + // parser preferred the legacy spelling, so it would return + // "legacy-fiction" here. + String responseJson = + "{" + + "\"id\": \"ks-1\"," + + "\"org_id\": \"org-mas-1\"," + + "\"system_id\": \"credit-model-v1\"," + + "\"status\": \"triggered\"," + + "\"trigger_reason\": \"accuracy breach\"," + + "\"triggered_reason\": \"legacy-fiction\"," + + "\"auto_trigger_enabled\": true," + + "\"accuracy_threshold\": 0.95," + + "\"triggered_at\": \"2026-08-01T10:00:00Z\"," + + "\"triggered_by\": \"ops@bank.sg\"," + + "\"created_at\": \"2026-07-01T10:00:00Z\"," + + "\"updated_at\": \"2026-08-01T10:00:00Z\"" + + "}"; + stubFor( + get(urlEqualTo("/api/v1/masfeat/killswitch/credit-model-v1")) + .willReturn( + aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(responseJson))); + + KillSwitch result = client.masfeat().getKillSwitch("credit-model-v1"); + + assertThat(result.getTriggeredReason()).isEqualTo("accuracy breach"); + assertThat(result.getStatus()).isEqualTo(KillSwitchStatus.TRIGGERED); + assertThat(result.isAutoTriggerEnabled()).isTrue(); + assertThat(result.getAccuracyThreshold()).isEqualTo(0.95); + assertThat(result.getTriggeredBy()).isEqualTo("ops@bank.sg"); + } + + @Test + @DisplayName("AISystemRegistry - owner_email lands in ownerEmail AND the businessOwner alias") + void aiSystemOwnerEmail() { + // Source-derived from masfeat.AISystemRegistry (types.go:172-198): the + // wire serves owner_email + owner_team; technical_owner has never been + // sent. materiality_classification carries a both-spellings + // discriminator (hand-injected "materiality" decoy): the real key must + // win - the pre-#3254 parser read "materiality" first, so it would + // report LOW here. + String responseJson = + "{" + + "\"id\": \"sys-9\"," + + "\"org_id\": \"org-mas-1\"," + + "\"system_id\": \"fraud-model-v2\"," + + "\"system_name\": \"Fraud Detection v2\"," + + "\"use_case\": \"fraud_detection\"," + + "\"status\": \"active\"," + + "\"owner_team\": \"risk-analytics\"," + + "\"owner_email\": \"owner@bank.sg\"," + + "\"risk_rating_impact\": 4," + + "\"risk_rating_complexity\": 3," + + "\"risk_rating_reliance\": 5," + + "\"materiality_classification\": \"high\"," + + "\"materiality\": \"low\"," + + "\"created_at\": \"2026-05-01T00:00:00Z\"," + + "\"updated_at\": \"2026-06-01T00:00:00Z\"," + + "\"created_by\": \"admin@bank.sg\"" + + "}"; + stubFor( + get(urlEqualTo("/api/v1/masfeat/registry/fraud-model-v2")) + .willReturn( + aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(responseJson))); + + AISystemRegistry result = client.masfeat().getSystem("fraud-model-v2"); + + assertThat(result.getOwnerEmail()).isEqualTo("owner@bank.sg"); + assertThat(result.getBusinessOwner()).isEqualTo("owner@bank.sg"); + assertThat(result.getOwnerTeam()).isEqualTo("risk-analytics"); + // technical_owner is never served: the deprecated accessor stays null. + @SuppressWarnings("deprecation") + String technicalOwner = result.getTechnicalOwner(); + assertThat(technicalOwner).isNull(); + // Real risk_rating_* keys land in the historic java names. + assertThat(result.getCustomerImpact()).isEqualTo(4); + assertThat(result.getModelComplexity()).isEqualTo(3); + assertThat(result.getHumanReliance()).isEqualTo(5); + // materiality_classification (real) wins over the "materiality" decoy. + assertThat(result.getMaterialityClassification()).isEqualTo(MaterialityClassification.HIGH); + } + + @Test + @DisplayName("Minimal payloads - new fields absent, everything defaults, no throw") + void absenceTolerance() { + stubFor( + get(urlEqualTo("/api/v1/masfeat/registry/summary")) + .willReturn( + aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody("{\"total_systems\": 0}"))); + + RegistrySummary summary = client.masfeat().getRegistrySummary(); + assertThat(summary.getOrgId()).isNull(); + assertThat(summary.getAssessmentsDue()).isEqualTo(0); + assertThat(summary.getKillSwitchesTriggered()).isEqualTo(0); + + stubFor( + get(urlEqualTo("/api/v1/masfeat/registry/minimal-sys")) + .willReturn( + aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody("{\"id\": \"sys-min\", \"system_id\": \"minimal-sys\"}"))); + + AISystemRegistry system = client.masfeat().getSystem("minimal-sys"); + assertThat(system.getOwnerEmail()).isNull(); + assertThat(system.getBusinessOwner()).isNull(); + } +} diff --git a/tests/fixtures/audit-binding-allowlist.json b/tests/fixtures/audit-binding-allowlist.json index 4a777bc..55ba4a0 100644 --- a/tests/fixtures/audit-binding-allowlist.json +++ b/tests/fixtures/audit-binding-allowlist.json @@ -1,5 +1,5 @@ { - "_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.", + "_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. Deprecation tie: every entry must be backed by an @Deprecated member in the model, UNLESS its note contains the word 'alias' - which declares a parser-populated compatibility alias carrying real data.", "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.", @@ -11,5 +11,13 @@ }, "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." + }, + "RegistrySummary": { + "by_use_case": "Never populated on the 9.x line - the server's RegistrySummary has never carried by_use_case (getaxonflow/axonflow-enterprise#3254). Deprecated in-model, removal rides the next major.", + "by_status": "Never populated on the 9.x line - the server's RegistrySummary has never carried by_status (getaxonflow/axonflow-enterprise#3254). Deprecated in-model, removal rides the next major." + }, + "AISystemRegistry": { + "technical_owner": "Never populated on the 9.x line - the server's AISystemRegistry has never carried technical_owner; the wire serves owner_email + owner_team (getaxonflow/axonflow-enterprise#3254). Deprecated in-model, removal rides the next major.", + "businessOwner": "Not a wire key: compatibility alias field populated by the hand-written parser from the wire's owner_email (getaxonflow/axonflow-enterprise#3254). Prefer getOwnerEmail(); alias retained for source compatibility." } } diff --git a/tests/fixtures/wire-shape-baseline.json b/tests/fixtures/wire-shape-baseline.json index 41a3076..8d716bb 100644 --- a/tests/fixtures/wire-shape-baseline.json +++ b/tests/fixtures/wire-shape-baseline.json @@ -176,6 +176,21 @@ "intra_file_duplicates": {}, "openapi_specs_sha": "df027c788b60c18d044278c45aa4bce3a1ac8717", "per_type_drift": { + "AISystemRegistry": { + "sdk_only": [ + "businessOwner", + "technical_owner" + ], + "spec_only": [ + "data_sources", + "deployment_date", + "last_assessment_date", + "model_type", + "next_assessment_due", + "updated_by", + "version" + ] + }, "AuditLogEntry": { "sdk_only": [ "blocked", @@ -240,6 +255,21 @@ "metadata" ] }, + "ConfigureKillSwitchRequest": { + "sdk_only": [ + "accuracyThreshold", + "autoTriggerEnabled", + "biasThreshold", + "errorRateThreshold" + ], + "spec_only": [ + "accuracy_threshold", + "auto_trigger_enabled", + "bias_threshold", + "error_rate_threshold", + "trigger_conditions" + ] + }, "ConnectorInfo": { "sdk_only": [ "config_schema", @@ -249,6 +279,16 @@ "healthy" ] }, + "CreateAssessmentRequest": { + "sdk_only": [ + "assessmentType", + "systemId" + ], + "spec_only": [ + "assessment_type", + "system_id" + ] + }, "CreateStaticPolicyRequest": { "sdk_only": [], "spec_only": [ @@ -344,6 +384,65 @@ ], "spec_only": [] }, + "FEATAssessment": { + "sdk_only": [ + "accountabilityDetails", + "accountabilityScore", + "approvedAt", + "approvedBy", + "assessmentDate", + "assessmentType", + "createdAt", + "createdBy", + "ethicsDetails", + "ethicsScore", + "fairnessDetails", + "fairnessScore", + "orgId", + "overallScore", + "systemId", + "transparencyDetails", + "transparencyScore", + "updatedAt", + "validUntil" + ], + "spec_only": [ + "accountability_details", + "accountability_score", + "approved_at", + "approved_by", + "assessment_date", + "assessment_type", + "created_at", + "created_by", + "ethics_details", + "ethics_score", + "fairness_details", + "fairness_score", + "org_id", + "overall_score", + "rejected_at", + "rejected_by", + "rejection_reason", + "submitted_at", + "submitted_by", + "system_id", + "transparency_details", + "transparency_score", + "updated_at", + "valid_until", + "version" + ] + }, + "Finding": { + "sdk_only": [ + "dueDate", + "pillar" + ], + "spec_only": [ + "article" + ] + }, "HITLApprovalRequest": { "sdk_only": [], "spec_only": [ @@ -359,6 +458,13 @@ "user" ] }, + "KillSwitch": { + "sdk_only": [], + "spec_only": [ + "restore_reason", + "trigger_conditions" + ] + }, "ListWorkflowsResponse": { "sdk_only": [], "spec_only": [ @@ -488,6 +594,25 @@ "snapshot" ] }, + "RegistrySummary": { + "sdk_only": [ + "by_status", + "by_use_case" + ], + "spec_only": [] + }, + "RejectAssessmentRequest": { + "sdk_only": [ + "rejectedBy" + ], + "spec_only": [] + }, + "RestoreKillSwitchRequest": { + "sdk_only": [ + "restoredBy" + ], + "spec_only": [] + }, "ResumePlanResponse": { "sdk_only": [], "spec_only": [ @@ -516,6 +641,12 @@ "decision_id" ] }, + "TriggerKillSwitchRequest": { + "sdk_only": [ + "triggeredBy" + ], + "spec_only": [] + }, "UnifiedStepStatus": { "sdk_only": [ "approvalStatus", @@ -539,6 +670,28 @@ "tokens_out" ] }, + "UpdateAssessmentRequest": { + "sdk_only": [ + "accountabilityDetails", + "accountabilityScore", + "ethicsDetails", + "ethicsScore", + "fairnessDetails", + "fairnessScore", + "transparencyDetails", + "transparencyScore" + ], + "spec_only": [ + "accountability_details", + "accountability_score", + "ethics_details", + "ethics_score", + "fairness_details", + "fairness_score", + "transparency_details", + "transparency_score" + ] + }, "UpdatePlanRequest": { "sdk_only": [], "spec_only": [