"""IMP-33 u5 — AI fallback proposal validator (fallback path only). Defence-in-depth layer between the u4 client output (already u2-schema-valid) and the caller. Adds the four Stage 2 guards that u2 cannot express purely at the schema level: 1. builder-options whitelist (BUILDER_OPTIONS_PATCH may only touch keys already declared in ``frame_contract.payload.builder_options``). 2. dropped-slot guard (PARTIAL_OVERRIDES / SLOT_MAPPING_PROPOSAL must keep every declared ``sub_zones[*].id`` populated — text/table/image/details slots cannot disappear; `feedback_ai_isolation_contract`). 3. frame-swap guard (no ``frame_id`` mutation inside payload — V4 rank-1 protected; `feedback_phase_z_spacing_direction`). 4. Internal Region containment (``payload.region_id`` must match the declared Internal Region id when present). """ from __future__ import annotations from typing import Any from src.phase_z2_ai_fallback.schema import AiFallbackProposal, ProposalKind class AiFallbackValidationError(ValueError): """Raised when a proposal violates an IMP-33 u5 guard.""" _SLOT_KINDS = (ProposalKind.PARTIAL_OVERRIDES, ProposalKind.SLOT_MAPPING_PROPOSAL) def validate_proposal( proposal: AiFallbackProposal, *, frame_contract: dict[str, Any], internal_region: dict[str, Any] | None = None, ) -> None: """Validate an AI fallback proposal against the active frame contract. Raises ``AiFallbackValidationError`` on any guard violation. Returns ``None`` on success — caller is responsible for downstream application. """ AiFallbackProposal.model_validate(proposal.model_dump()) payload = proposal.payload frame_id = frame_contract.get("frame_id") if "frame_id" in payload and payload["frame_id"] != frame_id: raise AiFallbackValidationError( f"frame-swap guard: payload.frame_id={payload['frame_id']!r} " f"differs from contract frame_id={frame_id!r}; V4 rank-1 is locked." ) if proposal.proposal_kind is ProposalKind.BUILDER_OPTIONS_PATCH: declared = (frame_contract.get("payload") or {}).get("builder_options") or {} unknown = set(payload.keys()) - set(declared.keys()) if unknown: raise AiFallbackValidationError( f"builder whitelist: keys {sorted(unknown)} not in " f"frame_contract.payload.builder_options {sorted(declared)}." ) if proposal.proposal_kind in _SLOT_KINDS: declared_slot_ids = [z.get("id") for z in (frame_contract.get("sub_zones") or [])] slots = payload.get("slots") if not isinstance(slots, dict): raise AiFallbackValidationError( "dropped-slot guard: PARTIAL_OVERRIDES / SLOT_MAPPING_PROPOSAL " "payload MUST include a 'slots' mapping." ) missing = [sid for sid in declared_slot_ids if sid not in slots] if missing: raise AiFallbackValidationError( f"dropped-slot guard: declared slots {missing} are absent " "from payload.slots (text/table/image/details must remain populated)." ) region_id = payload.get("region_id") if region_id is not None and internal_region is not None: declared_region_id = internal_region.get("id") if region_id != declared_region_id: raise AiFallbackValidationError( f"Internal Region containment: payload.region_id={region_id!r} " f"differs from internal_region.id={declared_region_id!r}." )