diff --git a/.lint_baselines/falsey_clobber.json b/.lint_baselines/falsey_clobber.json index 1015322..e638452 100644 --- a/.lint_baselines/falsey_clobber.json +++ b/.lint_baselines/falsey_clobber.json @@ -41,16 +41,16 @@ "axonflow/client.py:850:20", "axonflow/client.py:936:20", "axonflow/execution.py:205:19", - "axonflow/masfeat.py:296:23", - "axonflow/masfeat.py:297:24", - "axonflow/masfeat.py:298:25", - "axonflow/masfeat.py:299:23", - "axonflow/masfeat.py:318:12", - "axonflow/masfeat.py:321:12", - "axonflow/masfeat.py:323:30", - "axonflow/masfeat.py:415:25", - "axonflow/masfeat.py:429:23", - "axonflow/masfeat.py:430:23", - "axonflow/masfeat.py:431:23" + "axonflow/masfeat.py:345:23", + "axonflow/masfeat.py:346:24", + "axonflow/masfeat.py:347:25", + "axonflow/masfeat.py:348:23", + "axonflow/masfeat.py:367:12", + "axonflow/masfeat.py:370:12", + "axonflow/masfeat.py:372:30", + "axonflow/masfeat.py:469:25", + "axonflow/masfeat.py:483:23", + "axonflow/masfeat.py:484:23", + "axonflow/masfeat.py:485:23" ] } diff --git a/CHANGELOG.md b/CHANGELOG.md index e5b3228..4c29b4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Real wire fields `policy_decision`, `policy_details`, `response_time_ms` on the audit read model (`AuditLogEntry`), and `action` on audit search (`AuditSearchRequest`). +- Real wire fields `org_id`, `assessments_due`, `kill_switches_triggered` on + `RegistrySummary` (#3254 pin-advance batch). +- The wire-shape contract gate now binds the masfeat dataclass models + (`RegistrySummary`/`KillSwitch`/`AISystemRegistry`) by driving their real + parsers with a key-recording payload (#3262). ### Deprecated @@ -23,6 +28,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `policy_violations`/`metadata` (read model) and `request_type` (search request) - never served/read on the 9.x line (#3254). Removal rides the next major. +- `RegistrySummary.by_use_case`/`by_status` and + `AISystemRegistry.technical_owner` - never served on the 9.x line (#3254 + pin-advance batch). Removal rides the next major. ## [9.0.0] - 2026-07-18 diff --git a/axonflow/masfeat.py b/axonflow/masfeat.py index f2fd734..1667536 100644 --- a/axonflow/masfeat.py +++ b/axonflow/masfeat.py @@ -133,7 +133,25 @@ class Finding: @dataclass class AISystemRegistry: - """Registered AI system in the MAS FEAT registry.""" + """Registered AI system in the MAS FEAT registry. + + Attributes: + technical_owner: Deprecated: never populated on the 9.x line - + the server has never sent this field + (getaxonflow/axonflow-enterprise#3254); the wire carries + ``owner_email`` (read into ``business_owner``) and + ``owner_team``. The register/update write paths still send + it (harmless, unread server-side). Scheduled for removal in + the next major. + business_owner: Populated from the wire field ``owner_email`` + (legacy spelling read first for compatibility). + customer_impact: Populated from the wire field + ``risk_rating_impact`` (legacy spelling read first). + model_complexity: Populated from the wire field + ``risk_rating_complexity`` (legacy spelling read first). + human_reliance: Populated from the wire field + ``risk_rating_reliance`` (legacy spelling read first). + """ id: str org_id: str @@ -157,7 +175,35 @@ class AISystemRegistry: @dataclass class RegistrySummary: - """Summary of all AI systems in the registry.""" + """Summary of all AI systems in the registry. + + Attributes: + total_systems: Total registered systems. + active_systems: Systems with status "active". + high_materiality_count: High-materiality systems. Populated from + the wire field ``high_materiality`` (legacy spelling read + first for compatibility). + medium_materiality_count: Medium-materiality systems (wire: + ``medium_materiality``). + low_materiality_count: Low-materiality systems (wire: + ``low_materiality``). + by_use_case: Deprecated: never populated on the 9.x line - the + server has never sent this field + (getaxonflow/axonflow-enterprise#3254); no wire equivalent + (the wire RegistrySummary serves flat counts only). Read the + flat count fields instead. Scheduled for removal in the next + major. + by_status: Deprecated: never populated on the 9.x line - the + server has never sent this field + (getaxonflow/axonflow-enterprise#3254); no wire equivalent + (the wire RegistrySummary serves flat counts only). Read + ``active_systems`` and the materiality counts instead. + Scheduled for removal in the next major. + org_id: Organization the summary is scoped to (#3254 additive). + assessments_due: Systems with an assessment due (#3254 additive). + kill_switches_triggered: Kill switches currently in triggered + state (#3254 additive). + """ total_systems: int active_systems: int @@ -166,6 +212,9 @@ class RegistrySummary: low_materiality_count: int by_use_case: dict[str, int] = field(default_factory=dict) by_status: dict[str, int] = field(default_factory=dict) + org_id: str = "" + assessments_due: int = 0 + kill_switches_triggered: int = 0 # =========================================================================== @@ -321,8 +370,13 @@ def registry_summary_from_dict(data: dict[str, Any]) -> RegistrySummary: data.get("medium_materiality_count") or data.get("medium_materiality", 0) ), low_materiality_count=data.get("low_materiality_count") or data.get("low_materiality", 0), + # Deprecated (#3254): never served on 9.x; stay {} against real servers. by_use_case=data.get("by_use_case", {}), by_status=data.get("by_status", {}), + # #3254 additive: real wire fields the model previously lacked. + org_id=data.get("org_id", ""), + assessments_due=data.get("assessments_due", 0), + kill_switches_triggered=data.get("kill_switches_triggered", 0), ) diff --git a/runtime-e2e/masfeat_real_wire_fields/test.py b/runtime-e2e/masfeat_real_wire_fields/test.py new file mode 100644 index 0000000..9b12ce2 --- /dev/null +++ b/runtime-e2e/masfeat_real_wire_fields/test.py @@ -0,0 +1,104 @@ +"""Real-stack assertion: RegistrySummary's real wire fields (#3254 +pin-advance batch) parse from a live masfeat registry-summary response. + +Drives the real SDK's `masfeat_get_registry_summary()` against a real +running agent and asserts the TYPED dataclass: + - the #3254 additions (org_id/assessments_due/kill_switches_triggered) + and the correct-by-fallback materiality counts parse from the real + wire names (high_materiality etc., masfeat/types.go @ v9.13.0); + - the deprecated fiction fields (by_use_case/by_status) stay {} on a + real response - the server has never sent them on 9.x. + +Posture note: the masfeat surface is Enterprise-gated. On a COMMUNITY +deployment the route 404s; that outcome is DIAGNOSED (the stack must +still prove reachable via /health through the same client) and reported +as GATED, not silently skipped and not treated as a pass of the +assertions above. Run against an Enterprise stack for full coverage. + +Usage:: + + export AXONFLOW_AGENT_URL=http://localhost:8080 + export AXONFLOW_TENANT_ID= + export AXONFLOW_TENANT_SECRET= + python runtime-e2e/masfeat_real_wire_fields/test.py +""" + +from __future__ import annotations + +import asyncio +import os +import sys + +from axonflow import AxonFlow +from axonflow.exceptions import AxonFlowError + +AGENT_URL = os.environ.get("AXONFLOW_AGENT_URL", "http://localhost:8080") +CLIENT_ID = os.environ.get("AXONFLOW_TENANT_ID", "demo-client") +SECRET = os.environ.get("AXONFLOW_TENANT_SECRET", "demo-secret") + + +def _fail(msg: str) -> None: + sys.stderr.write(f"FAIL: {msg}\n") + sys.exit(1) + + +async def main() -> int: + async with AxonFlow( + endpoint=AGENT_URL, + client_id=CLIENT_ID, + client_secret=SECRET, + ) as client: + try: + summary = await client.masfeat_get_registry_summary() + except AxonFlowError as exc: + if "404" not in str(exc): + _fail(f"masfeat_get_registry_summary failed non-404: {exc}") + # Diagnose, don't skip: the 404 must come from a live stack. + if not await client.health_check(): + _fail( + f"masfeat route 404 AND /health not healthy at {AGENT_URL} - " + "that is an unreachable/broken stack, not a gated surface" + ) + print( + "GATED: masfeat routes are not served by this deployment " + f"(HTTP 404 at {AGENT_URL}, /health healthy) - the masfeat " + "surface is Enterprise-gated; run this suite against an " + "Enterprise stack for full coverage. Diagnosed, not skipped." + ) + return 0 + + # Enterprise path: typed assertions on the real wire shape. + if summary.total_systems < 0: + _fail(f"total_systems parsed negative: {summary.total_systems}") + counts = ( + summary.high_materiality_count + + summary.medium_materiality_count + + summary.low_materiality_count + ) + if counts > summary.total_systems: + _fail( + f"materiality counts {counts} exceed total_systems " + f"{summary.total_systems} - real-name fallback parse suspect" + ) + if not isinstance(summary.assessments_due, int) or not isinstance( + summary.kill_switches_triggered, int + ): + _fail("#3254 additions did not parse as ints") + if summary.by_use_case != {} or summary.by_status != {}: + _fail( + "deprecated fiction fields populated on a real response: " + f"by_use_case={summary.by_use_case!r} by_status={summary.by_status!r} " + "- the 9.x server never sends them; if a future server does, " + "revisit the #3254 deprecation before shipping" + ) + print( + f"PASS: RegistrySummary parsed from live stack: org_id={summary.org_id!r} " + f"total={summary.total_systems} active={summary.active_systems} " + f"assessments_due={summary.assessments_due} " + f"kill_switches_triggered={summary.kill_switches_triggered}" + ) + return 0 + + +if __name__ == "__main__": + sys.exit(asyncio.run(main())) diff --git a/scripts/refresh_wire_shape_baseline.py b/scripts/refresh_wire_shape_baseline.py index 68c065e..d1e02cc 100755 --- a/scripts/refresh_wire_shape_baseline.py +++ b/scripts/refresh_wire_shape_baseline.py @@ -47,6 +47,16 @@ TEST_MODULE_PATH = REPO_ROOT / "tests" / "test_wire_shape.py" BASELINE_PATH = REPO_ROOT / "tests" / "fixtures" / "wire_shape_baseline.json" +# Bind ``import axonflow`` to THIS repo's package, ahead of any installed +# (or editable-installed-from-elsewhere) copy on sys.path. Without this, +# ``python scripts/refresh_wire_shape_baseline.py`` puts scripts/ (not the +# repo root) at sys.path[0], so a stale editable install pointing at a +# DIFFERENT checkout silently wins and the regenerated baseline records +# that other tree's models - observed in practice (#3254 batch 2): a +# sibling checkout's pre-fix masfeat parser produced a wrong-but-plausible +# drift entry with no error. +sys.path.insert(0, str(REPO_ROOT)) + def _load_test_helpers(): spec = importlib.util.spec_from_file_location("_ws", TEST_MODULE_PATH) @@ -129,14 +139,14 @@ def main() -> int: registered: list[str] = [] drift: dict[str, dict[str, Any]] = {} - for name, model in models.items(): + + def _record(name: str, sdk_fields: list[str]) -> None: if name not in merged: - continue + return registered.append(name) - sdk_fields = helpers._wire_fields(model) spec_fields = merged[name] if sdk_fields == spec_fields: - continue + return entry: dict[str, Any] = { "sdk_only": sorted(set(sdk_fields) - set(spec_fields)), "spec_only": sorted(set(spec_fields) - set(sdk_fields)), @@ -145,6 +155,15 @@ def main() -> int: entry["note"] = existing_notes[name] drift[name] = entry + for name, model in models.items(): + _record(name, helpers._wire_fields(model)) + + # #3262: masfeat dataclass bindings (parser-consumed wire keys) join + # the baseline on the same terms as pydantic models, so a pin bump + # regen recomputes their drift instead of silently dropping it. + for name, consumed in helpers._masfeat_dataclass_bindings().items(): + _record(name, consumed) + cross_spec: dict[str, dict[str, list[str]]] = { name: {spec: list(fields) for spec, fields in sorted(decls.items())} for name, decls in sorted(duplicates_by_spec.items()) diff --git a/tests/fixtures/wire_shape_baseline.json b/tests/fixtures/wire_shape_baseline.json index 31e5e3f..6727123 100644 --- a/tests/fixtures/wire_shape_baseline.json +++ b/tests/fixtures/wire_shape_baseline.json @@ -179,6 +179,16 @@ }, "openapi_specs_sha": "0bd9256237ebbffb9c0101126da71f2c940a1695", "per_model_drift": { + "AISystemRegistry": { + "note": "acknowledged-sdk-superset: getaxonflow/axonflow-enterprise#3254 pin-advance batch (dataclass binding, #3262) - the parser reads the REAL server wire names (platform/orchestrator/masfeat/types.go at v9.13.0) ahead of this fiction-era spec pin, which still declares the legacy shape. Resolves when the pin advances to v9.13.0 (PR #214), where the legacy spellings the parser reads first-for-compatibility become the sdk_only set instead. owner_email/risk_rating_impact/risk_rating_complexity/risk_rating_reliance are the real wire names read as fallbacks behind the legacy business_owner/customer_impact/model_complexity/human_reliance spellings; technical_owner is deprecated fiction (never served on 9.x).", + "sdk_only": [ + "owner_email", + "risk_rating_complexity", + "risk_rating_impact", + "risk_rating_reliance" + ], + "spec_only": [] + }, "AuditLogEntry": { "note": "spec-bug-pending: #1745 \u2014 agent-api.yaml AuditLogEntry omits metadata/model/policy_violations the agent emits on every audit-log read. Plus getaxonflow/axonflow-enterprise#3254 additive interim: policy_decision/policy_details/response_time_ms are the REAL 9.x wire fields (platform/orchestrator/audit_logger.go AuditEntry serves them at v9.6.1 and v9.13.0); this pre-v9 spec pin predates them, so they read as sdk_only until the pin moves to v9.13.0 (PR #214).", "sdk_only": [ @@ -307,6 +317,13 @@ ], "spec_only": [] }, + "KillSwitch": { + "note": "acknowledged-sdk-superset: getaxonflow/axonflow-enterprise#3254 pin-advance batch (dataclass binding, #3262) - the parser reads the REAL server wire names (platform/orchestrator/masfeat/types.go at v9.13.0) ahead of this fiction-era spec pin, which still declares the legacy shape. Resolves when the pin advances to v9.13.0 (PR #214), where the legacy spellings the parser reads first-for-compatibility become the sdk_only set instead. triggered_reason is the legacy first-choice read; trigger_reason (masfeat/types.go:288) is the real wire name.", + "sdk_only": [ + "trigger_reason" + ], + "spec_only": [] + }, "MCPCheckInputRequest": { "note": "acknowledged-sdk-superset: tracked in #2563/#2571 \u2014 SDK declares `content_type` (request-redaction detector selector, ADR-056); the agent consumes it on POST /api/v1/mcp/check-input but agent-api.yaml doesn't yet declare it. Also declares `tool` (epic #2905, platform sub-issue #2904) \u2014 the two-field (server, tool) identity contract; #2904 merged to axonflow-enterprise (c8df2006b) and first released in platform v9.10.0, so this drift just tracks the pinned OpenAPI spec (agent-api.yaml) catching up.", "sdk_only": [ @@ -378,6 +395,18 @@ ], "spec_only": [] }, + "RegistrySummary": { + "note": "acknowledged-sdk-superset: getaxonflow/axonflow-enterprise#3254 pin-advance batch (dataclass binding, #3262) - the parser reads the REAL server wire names (platform/orchestrator/masfeat/types.go at v9.13.0) ahead of this fiction-era spec pin, which still declares the legacy shape. Resolves when the pin advances to v9.13.0 (PR #214), where the legacy spellings the parser reads first-for-compatibility become the sdk_only set instead. Real fields org_id/assessments_due/kill_switches_triggered added by this batch; by_use_case/by_status are deprecated fiction (never served on 9.x), spec-declared only by this fiction-era pin.", + "sdk_only": [ + "assessments_due", + "high_materiality", + "kill_switches_triggered", + "low_materiality", + "medium_materiality", + "org_id" + ], + "spec_only": [] + }, "ResumePlanResponse": { "note": "spec-bug-pending: #1745 \u2014 orchestrator-api.yaml ResumePlanResponse omits 7 fields returned on every WCP step approval/resume.", "sdk_only": [ @@ -432,6 +461,7 @@ } }, "registered_models": [ + "AISystemRegistry", "AuditLogEntry", "AuditSearchRequest", "AuditToolCallRequest", @@ -458,6 +488,7 @@ "ExfiltrationCheckInfo", "ExplainPolicy", "ExplainRule", + "KillSwitch", "LLMProviderListResponse", "ListWorkflowsResponse", "MCPCheckInputRequest", @@ -483,6 +514,7 @@ "PolicyVersion", "PricingInfo", "RateLimitInfo", + "RegistrySummary", "ResumeFromCheckpointResponse", "ResumePlanResponse", "RetryContext", diff --git a/tests/test_masfeat.py b/tests/test_masfeat.py index 9597c87..da1bdbb 100644 --- a/tests/test_masfeat.py +++ b/tests/test_masfeat.py @@ -419,6 +419,39 @@ def test_alternate_field_names(self) -> None: assert result.high_materiality_count == 2 assert result.medium_materiality_count == 5 + def test_real_wire_fields_added_by_3254(self) -> None: + """#3254 pin-advance batch: the real wire RegistrySummary + (platform/orchestrator/masfeat/types.go @ v9.13.0) serves + org_id/assessments_due/kill_switches_triggered; the parser must + surface them. Payload is source-derived, not a capture. + """ + data = { + "org_id": "org-1", + "total_systems": 3, + "active_systems": 2, + "high_materiality": 1, + "medium_materiality": 1, + "low_materiality": 1, + "assessments_due": 2, + "kill_switches_triggered": 1, + } + result = registry_summary_from_dict(data) + assert result.org_id == "org-1" + assert result.assessments_due == 2 + assert result.kill_switches_triggered == 1 + # The deprecated fiction fields (#3254) stay empty against a + # real-shaped payload - the server has never sent them on 9.x. + assert result.by_use_case == {} + assert result.by_status == {} + + def test_new_fields_default_when_absent(self) -> None: + """Old-server tolerance for the #3254 additions.""" + data = {"total_systems": 1, "active_systems": 1} + result = registry_summary_from_dict(data) + assert result.org_id == "" + assert result.assessments_due == 0 + assert result.kill_switches_triggered == 0 + class TestFEATAssessmentFromDict: """Test feat_assessment_from_dict conversion.""" diff --git a/tests/test_wire_shape.py b/tests/test_wire_shape.py index e26d98d..ec81148 100644 --- a/tests/test_wire_shape.py +++ b/tests/test_wire_shape.py @@ -446,19 +446,26 @@ def test_baseline_has_not_grown_stale( sdk_models: dict[str, type[BaseModel]], baseline: dict[str, Any], ) -> None: - """Informational: when a baseline entry's drift has been partially or - fully resolved, print that fact so the baseline can be shrunk. Does - not fail — baselines are always allowed to be larger than needed. + """FAIL when a baseline entry allows drift that no longer exists. + + A phantom allowance (a listed sdk_only/spec_only field that is no + longer drifting, or an entry whose model/schema is gone) is a hole + the exact width of a future regression: the field could re-drift + silently under the dead allowance. The Go SDK's equivalent check + hard-fails; print-only here was a cross-SDK asymmetry (#3254 batch + R3). Fix the baseline entry - do not soften this check. """ expected_drift = baseline.get("per_model_drift", {}) stale_entries: list[tuple[str, list[str], list[str]]] = [] + # #3262: dataclass bindings participate in staleness reporting too. + dataclass_bindings = _masfeat_dataclass_bindings() for name, expected in expected_drift.items(): - if name not in sdk_models or name not in openapi_schemas: + is_dataclass = name in dataclass_bindings + if (name not in sdk_models and not is_dataclass) or name not in openapi_schemas: stale_entries.append((name, [""], [])) continue - model = sdk_models[name] - sdk_fields = _wire_fields(model) + sdk_fields = dataclass_bindings[name] if is_dataclass else _wire_fields(sdk_models[name]) spec_fields = openapi_schemas[name] only_sdk = set(sdk_fields) - set(spec_fields) only_spec = set(spec_fields) - set(sdk_fields) @@ -470,13 +477,25 @@ def test_baseline_has_not_grown_stale( stale_entries.append((name, stale_sdk, stale_spec)) if stale_entries: - print("\nBaseline entries that no longer match observed drift (safe to shrink):") + lines = [ + "", + "Baseline entries allow drift that no longer exists (stale allowances):", + "", + ] for name, stale_sdk, stale_spec in stale_entries: - print(f" {name}:") + lines.append(f" {name}:") if stale_sdk: - print(f" sdk_only entries no longer drifting: {stale_sdk}") + lines.append(f" sdk_only entries no longer drifting: {stale_sdk}") if stale_spec: - print(f" spec_only entries no longer drifting: {stale_spec}") + lines.append(f" spec_only entries no longer drifting: {stale_spec}") + lines.append("") + lines.append( + "Fix: remove the burned-down fields (or the whole entry) from " + "tests/fixtures/wire_shape_baseline.json - a dead allowance " + "would let the same field re-drift silently later. Regenerate " + "via scripts/refresh_wire_shape_baseline.py to shrink exactly." + ) + pytest.fail("\n".join(lines)) def test_registered_models_still_map( @@ -499,10 +518,13 @@ class and (b) an OpenAPI schema with concrete properties. "baseline has no registered_models list; rename-escape guard " "is disabled until the baseline is regenerated with that key." ) + # #3262: masfeat dataclass bindings count as SDK models for the + # rename-escape guard too (they are registered in the baseline). + known_sdk_names = set(sdk_models) | set(_masfeat_dataclass_bindings()) missing_model: list[str] = [] missing_schema: list[str] = [] for name in registered: - if name not in sdk_models: + if name not in known_sdk_names: missing_model.append(name) if name not in openapi_schemas: missing_schema.append(name) @@ -513,7 +535,9 @@ class and (b) an OpenAPI schema with concrete properties. "", ] if missing_model: - lines.append(f" No matching SDK pydantic class for: {missing_model}") + lines.append( + f" No matching SDK pydantic class or bound dataclass for: {missing_model}" + ) if missing_schema: lines.append(f" No matching OpenAPI schema for: {missing_schema}") lines.append("") @@ -621,3 +645,338 @@ def test_specs_dir_set_and_present_resolves( """Env set to an existing directory resolves to that path.""" monkeypatch.setenv("AXONFLOW_OPENAPI_SPECS_DIR", str(tmp_path)) assert _specs_dir() == tmp_path + + +# --------------------------------------------------------------------------- +# masfeat dataclass binding (#3262, mechanism class of #3254) +# --------------------------------------------------------------------------- +# The masfeat surface (axonflow/masfeat.py) is plain dataclasses with +# hand-written *_from_dict parsers, so the pydantic walker above cannot +# see it - the exact mechanism that let the audit models certify fiction +# (#3254). Binding choice (stated per #3262): extract the wire names by +# DRIVING THE REAL PARSERS with a key-recording payload, rather than +# migrating the models to pydantic - a migration changes the public +# types (constructor semantics, dataclasses.asdict/astuple consumers, +# isinstance checks) and is next-major work, and a hand-declared +# field->wire-name table would test the declaration instead of the path. +# +# Seed payloads are SOURCE-DERIVED from the server structs at tag +# v9.13.0 (platform/orchestrator/masfeat/types.go, getaxonflow/axonflow +# @ df027c788) - they are not captures. They carry ONLY the real wire +# names: every legacy-name read sits FIRST in an `x or y` fallback +# chain, so it is always attempted (and recorded) regardless, while a +# seed that included a legacy name would satisfy the chain early and +# hide the real-name read from the recorder. + + +class _WireKeyRecorder(dict): + """Payload dict recording every wire key a parser ATTEMPTS to read. + + ``get`` and ``[]`` record into ``consumed``; ``in`` records into the + separate ``probed`` set. A key that is probed but never read is the + ghost-read evasion shape - ``data["k"] if "k" in data else None`` + leaves no ``consumed`` trace when the key is absent from the seed, + which is exactly where a fiction key sits. The binding extractor + FAILS on such keys unless they are declared envelope-dispatch keys + (response-shape unwrapping, not field reads - the + ``kill_switch_from_dict`` envelope at axonflow/masfeat.py). + """ + + def __init__(self, seed: dict[str, Any]) -> None: + super().__init__(seed) + self.consumed: set[str] = set() + self.probed: set[str] = set() + + def get(self, key: Any, default: Any = None) -> Any: + self.consumed.add(key) + return super().get(key, default) + + def __getitem__(self, key: Any) -> Any: + self.consumed.add(key) + return super().__getitem__(key) + + def __contains__(self, key: Any) -> bool: + self.probed.add(key) + return super().__contains__(key) + + +# Envelope-dispatch keys: presence-probed to unwrap a nested response +# shape, legitimately never read as flat fields. Add here only with the +# probing parser named. +_ENVELOPE_DISPATCH_KEYS: dict[str, set[str]] = { + # kill_switch_from_dict: trigger/restore responses arrive as + # {"kill_switch": {...}, "message": ...} and are unwrapped. + "KillSwitch": {"kill_switch"}, +} + + +def _consumed_keys( + name: str, + parser: Any, + seed: dict[str, Any], +) -> set[str]: + """Drive ``parser`` over a recording copy of ``seed`` and return the + consumed wire keys, failing on probed-but-never-read keys (the + ghost-read evasion) unless exempted as envelope dispatch.""" + recorder = _WireKeyRecorder(seed) + parser(recorder) + ghost = recorder.probed - recorder.consumed - _ENVELOPE_DISPATCH_KEYS.get(name, set()) + if ghost: + pytest.fail( + f"{name}: parser presence-probed key(s) it never read: " + f"{sorted(ghost)}. A probe-guarded read " + "(`data[k] if k in data else ...`) of an absent key leaves no " + "consumed trace, so a fiction key could hide from the gate. " + "Either read the key through get/[] so it is recorded, or - if " + "it is genuinely response-shape envelope dispatch - add it to " + "_ENVELOPE_DISPATCH_KEYS with the probing parser named." + ) + return recorder.consumed + + +_SEED_REGISTRY_SUMMARY: dict[str, Any] = { + "org_id": "org-src-derived", + "total_systems": 3, + "active_systems": 2, + "high_materiality": 1, + "medium_materiality": 1, + "low_materiality": 1, + "assessments_due": 1, + "kill_switches_triggered": 1, +} + +_SEED_KILL_SWITCH: dict[str, Any] = { + "id": "ks-src-derived", + "org_id": "org-src-derived", + "system_id": "sys-1", + "status": "triggered", + "trigger_reason": "bias threshold exceeded", + "trigger_conditions": {"metric": "bias"}, + "auto_trigger_enabled": True, + "accuracy_threshold": 0.9, + "bias_threshold": 0.1, + "error_rate_threshold": 0.05, + "triggered_at": "2026-08-01T00:00:00Z", + "triggered_by": "ops@example.com", + "restored_at": "2026-08-02T00:00:00Z", + "restored_by": "ops@example.com", + "restore_reason": "model retrained", + "created_at": "2026-07-01T00:00:00Z", + "updated_at": "2026-08-02T00:00:00Z", +} + +_SEED_AI_SYSTEM_REGISTRY: dict[str, Any] = { + "id": "reg-src-derived", + "org_id": "org-src-derived", + "system_id": "sys-1", + "system_name": "Credit Scorer", + "description": "src-derived", + "use_case": "credit_scoring", + "status": "active", + "risk_rating_impact": 3, + "risk_rating_complexity": 2, + "risk_rating_reliance": 1, + "materiality_classification": "high", + "owner_team": "risk", + "owner_email": "owner@example.com", + "data_sources": ["core-banking"], + "model_type": "gradient-boosting", + "version": "1.2.0", + "deployment_date": "2026-01-01T00:00:00Z", + "last_assessment_date": "2026-06-01T00:00:00Z", + "next_assessment_due": "2026-12-01T00:00:00Z", + "metadata": {}, + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-06-01T00:00:00Z", + "created_by": "owner@example.com", + "updated_by": "owner@example.com", +} + + +def _masfeat_dataclass_bindings() -> dict[str, list[str]]: + """``{class_name: sorted wire keys the REAL parser consumes}``. + + A parser raising on its seed means the seed drifted from the server + struct - that is a test failure, not a skip. + """ + from axonflow import masfeat # noqa: PLC0415 + + bindings: dict[str, list[str]] = {} + for name, parser, seed in ( + ("RegistrySummary", masfeat.registry_summary_from_dict, _SEED_REGISTRY_SUMMARY), + ("KillSwitch", masfeat.kill_switch_from_dict, _SEED_KILL_SWITCH), + ("AISystemRegistry", masfeat.ai_system_registry_from_dict, _SEED_AI_SYSTEM_REGISTRY), + ): + bindings[name] = sorted(_consumed_keys(name, parser, seed)) + return bindings + + +def test_no_new_masfeat_dataclass_vs_spec_drift( + openapi_schemas: dict[str, list[str]], + baseline: dict[str, Any], +) -> None: + """#3262: the masfeat dataclasses' consumed wire names vs their + masfeat-api.yaml schemas, held to the same baseline discipline as + the pydantic gate. sdk_only here means "the parser reads a key the + spec does not declare" (a legacy/fiction spelling); spec_only means + "the spec declares a property no parser path reads" (coverage gap). + """ + expected_drift = baseline.get("per_model_drift", {}) + new_drift: list[tuple[str, list[str], list[str], list[str], list[str]]] = [] + matched = 0 + + for name, consumed in sorted(_masfeat_dataclass_bindings().items()): + if name not in openapi_schemas: + continue + matched += 1 + spec_fields = openapi_schemas[name] + only_sdk = sorted(set(consumed) - set(spec_fields)) + only_spec = sorted(set(spec_fields) - set(consumed)) + + expected = expected_drift.get(name, {}) + unexpected_sdk = sorted(set(only_sdk) - set(expected.get("sdk_only", []))) + unexpected_spec = sorted(set(only_spec) - set(expected.get("spec_only", []))) + + if unexpected_sdk or unexpected_spec: + new_drift.append((name, only_sdk, only_spec, unexpected_sdk, unexpected_spec)) + + if new_drift: + lines = [ + "", + "NEW masfeat dataclass wire-shape drift detected (not covered by baseline):", + "", + ] + for name, only_sdk, only_spec, unexpected_sdk, unexpected_spec in new_drift: + lines.append(f" {name} (dataclass, parser-consumed keys):") + if unexpected_sdk: + lines.append(f" NEW, read by parser but not in OpenAPI: {unexpected_sdk}") + if unexpected_spec: + lines.append(f" NEW, in OpenAPI but never read: {unexpected_spec}") + if only_sdk and set(only_sdk) != set(unexpected_sdk): + lines.append( + f" (baseline, parser-only): {sorted(set(only_sdk) - set(unexpected_sdk))}" + ) + if only_spec and set(only_spec) != set(unexpected_spec): + lines.append( + f" (baseline, spec-only): {sorted(set(only_spec) - set(unexpected_spec))}" + ) + lines.append("") + lines.append( + "Fix: align the parser's wire reads with the OpenAPI property " + "names, OR baseline the drift with a note naming the tracking " + "issue (#3254 for the masfeat legacy spellings)." + ) + pytest.fail("\n".join(lines)) + + assert matched > 0, "No masfeat dataclass matched any OpenAPI schema by name." + + +# --- #3262 machinery self-tests (decoy + negative control). These do not +# need the specs dir, so they run in the regular suite. + + +def test_masfeat_recorder_catches_a_decoy_fiction_read() -> None: + """Decoy self-test: a parser reading a key the schema does not + declare MUST surface as sdk-only drift. Proves the recorder sees + both `[]` and `.get` reads, including reads of ABSENT keys (the + fiction-read shape: get returns None, the read still happened). + """ + schema_fields = ["real_a", "real_b"] + + def decoy_parser(data: dict[str, Any]) -> tuple[Any, Any, Any]: + return ( + data["real_a"], + data.get("decoy_fiction_field") or data.get("real_b"), + data.get("decoy_getitem_missing"), + ) + + recorder = _WireKeyRecorder({"real_a": 1, "real_b": 2}) + decoy_parser(recorder) + only_sdk = sorted(set(recorder.consumed) - set(schema_fields)) + assert only_sdk == ["decoy_fiction_field", "decoy_getitem_missing"], ( + f"decoy fiction reads not flagged: consumed={sorted(recorder.consumed)}" + ) + + +def test_masfeat_recorder_negative_control() -> None: + """Negative control: a parser reading exactly the schema-declared + names produces zero drift in either direction.""" + schema_fields = ["real_a", "real_b"] + + def clean_parser(data: dict[str, Any]) -> tuple[Any, Any]: + return data["real_a"], data.get("real_b") + + recorder = _WireKeyRecorder({"real_a": 1, "real_b": 2}) + clean_parser(recorder) + assert sorted(set(recorder.consumed) - set(schema_fields)) == [] + assert sorted(set(schema_fields) - set(recorder.consumed)) == [] + + +def test_masfeat_recorder_separates_probes_from_reads() -> None: + """`in` probes land in ``probed``, not ``consumed`` - so envelope + dispatch is not misreported as a field read, while the extractor can + still see (and fail on) probed-but-never-read keys.""" + recorder = _WireKeyRecorder({"kill_switch": {}}) + assert "kill_switch" in recorder + assert recorder.consumed == set() + assert recorder.probed == {"kill_switch"} + + +def test_masfeat_extractor_fails_on_ghost_probe_guarded_read() -> None: + """Ghost-read evasion MUST go red: `data[k] if k in data else None` + on a key absent from the seed leaves no consumed trace - exactly + where a fiction key sits. R3 proved the previous recorder stayed + green on this shape at both pins. + """ + + def ghost_parser(data: dict[str, Any]) -> Any: + # The if-in shape IS the evasion under test (hence the suppression). + return data["ghost_fiction_field"] if "ghost_fiction_field" in data else None # noqa: SIM401 + + with pytest.raises(pytest.fail.Exception, match="ghost_fiction_field"): + _consumed_keys("GhostModel", ghost_parser, {"real_a": 1}) + + +def test_masfeat_extractor_allows_declared_envelope_dispatch() -> None: + """The exempted envelope key stays green: the REAL kill_switch + parser probes 'kill_switch' on a flat payload and never reads it, + and the extractor must not flag it (it is declared in + _ENVELOPE_DISPATCH_KEYS).""" + from axonflow import masfeat # noqa: PLC0415 + + consumed = _consumed_keys("KillSwitch", masfeat.kill_switch_from_dict, _SEED_KILL_SWITCH) + assert "kill_switch" not in consumed + assert "trigger_reason" in consumed + + +def test_masfeat_extractor_probe_followed_by_read_is_clean() -> None: + """A probe that IS followed by a read (key present) is a legitimate + guarded read: recorded as consumed, nothing flagged.""" + + def guarded_parser(data: dict[str, Any]) -> Any: + return data["real_a"] if "real_a" in data else None # noqa: SIM401 - guarded-read shape under test + + consumed = _consumed_keys("GuardedModel", guarded_parser, {"real_a": 1}) + assert consumed == {"real_a"} + + +def test_masfeat_seeds_do_not_contain_legacy_spellings() -> None: + """The seeds must carry ONLY real wire names: a legacy name in a + seed satisfies its `x or y` fallback chain early and hides the + real-name read from the recorder (stated in the section comment; + pinned here).""" + legacy = { + "high_materiality_count", + "medium_materiality_count", + "low_materiality_count", + "by_use_case", + "by_status", + "triggered_reason", + "technical_owner", + "business_owner", + "customer_impact", + "model_complexity", + "human_reliance", + } + for seed in (_SEED_REGISTRY_SUMMARY, _SEED_KILL_SWITCH, _SEED_AI_SYSTEM_REGISTRY): + assert not (set(seed) & legacy), f"legacy spelling in seed: {set(seed) & legacy}"