refactor: enforce typed model boundaries across serialization and data flow#1044
Merged
ChrisHuie merged 51 commits intoprebid:mainfrom Feb 18, 2026
Merged
Conversation
- Add Makefile with quality, pre-pr, lint-fix, typecheck, test-fast targets - Enable C90 (mccabe complexity) and PLR (pylint refactor) ruff rules - Add justification comments to all lint suppressions - Remove stale UP038 rule (removed from ruff) - Suppress structural PLR rules (0911/0912/0913/0915) for incremental fix - PLR5501 (collapsible-else-if) and other fixable rules left enforced
61 files reformatted. No logic changes — formatting only.
Auto-fixed: - PLR5501: 12 collapsible else-if blocks simplified to elif - PLR1711: 1 useless return removed - PLR1730: 1 if-stmt replaced with min/max Manual fixes: - PLR0133: "active" == False always evaluates to False, making it a dead value in the truthy-check tuple. Replaced with "active". - PLR1714: 3 repeated equality comparisons merged into `in` checks Suppressed (structural, fix incrementally): - C901: 216 complex functions
…on engine Phase 1 of j55: Fix serialization boundaries that produce non-JSON-safe output from model_dump() (Enum objects, datetime, AnyUrl, etc.). - database_session.py: Register pydantic_core.to_json as json_serializer on both create_engine() calls so all JSONB columns handle Pydantic types - protocol_envelope.py: model_dump(mode='json') at payload serialization - mcp_server_enhanced.py: model_dump(mode='json') at response serialization - adcp_a2a_server.py: model_dump(mode='json') at all 12 boundary sites
…content Phase 2 of j55: FastMCP's ToolResult already calls pydantic_core.to_jsonable_python() on structured_content, so passing Pydantic models directly is safe and avoids redundant serialization. - Remove model_dump() from 9 ToolResult sites across 7 tool files - Add mode='json' to 1 composite ToolResult in media_buy_create.py - Fix implementation-aware test that used MagicMock with model_dump.return_value instead of a real Pydantic model
Phase 4 of j55: Fix NestedModelSerializerMixin and 5 custom @model_serializer methods to pass mode=info.mode when calling model_dump() on nested Pydantic models. Previously, nested models always serialized in Python mode regardless of the parent's requested mode. Now mode='json' propagates correctly through the entire model tree, ensuring Enum/datetime/AnyUrl values are JSON-safe when the parent requests JSON mode. The Creative.model_dump() status enum normalization is preserved as intentional behavior — Creative status is always returned as a string regardless of serialization mode.
Phase 4 of j55: Fix NestedModelSerializerMixin and 5 custom @model_serializer methods to pass mode=info.mode when calling model_dump() on nested Pydantic models. Previously, nested models always serialized in Python mode regardless of the parent's requested mode. Now mode='json' propagates correctly through the entire model tree, ensuring Enum/datetime/AnyUrl values are JSON-safe when the parent requests JSON mode. The Creative.model_dump() status enum normalization is preserved as intentional behavior — Creative status is always returned as a string regardless of serialization mode.
PolicyCheckService methods accepted dicts instead of typed models, forcing callers to call model_dump() and losing type safety. - check_brief_compliance: brand_manifest dict → BrandManifest | str | None - check_product_eligibility: product dict → Product - Remove dead age-based eligibility logic (targeted_ages/verified_minimum_age fields don't exist on Product, so the code always hit an unintended path that rejected all products for age-restricted advertisers) - Remove unnecessary model_dump() calls in products.py callers - Add 8 unit tests, update 10 integration tests
model_dump() silently drops exclude=True fields, so base format's platform_config was always lost when applying product overrides. Only the override's platform_config survived the merge. Replace dict roundtrip (model_dump + Format(**dict)) with direct model attribute access + model_copy(update=...).
Replace 14 untyped/Any params with a single typed CreateMediaBuyRequest. Both MCP and A2A wrappers now construct the request at the boundary, eliminating Pydantic→dict→Pydantic roundtrips. Remove 6 dead params (pacing, daily_budget, creatives, required_axe_signals, enable_creative_macro, strategy_id) never passed by either caller. Internal model_dump() calls replaced with direct typed access: - PackageRequest→Package: direct construction instead of dict roundtrip - DB storage: pass Pydantic models directly (engine json_serializer handles) - Response packages: direct Package construction instead of manual dicts - Testing hooks: simplified to model_dump() (removed dead model_dump_internal check)
…m:KonstantinMirin/prebid-salesagent into KonstantinMirin/chore-lint-and-formatting # Conflicts: # src/core/schemas.py
…l_dump The _pydantic_json_serializer on the DB engine and pydantic_core.to_jsonable_python() in ToolResult already handle enum serialization at boundaries. No need to normalize in model_dump.
…oksResult Return structured metadata instead of mutating a data dict. Eliminates all dict roundtrips in callers: dead code in products.py, 58-line dict surgery in media_buy_delivery.py, 33-line reconstruction in media_buy_create.py. Only surviving mutation (media_buy_id test_ prefix) now uses model_copy. Co-Authored-By: Constantine Mirin <constantine.mirin@gmail.com>
- Update JSONType.process_bind_param to accept BaseModel instances, completing the deferred Phase 3 from the serialization architecture. The engine's pydantic_core.to_json serializer handles them correctly. - Remove ~170 lines of dead code from schema_validation.py (SchemaMetadata, ResponseWithSchema, enhance_*, validate_response_against_schema, etc.) - Eliminate premature model_dump() in adapters.py save_adapter_config, keeping the validated Pydantic model flowing through to the DB write.
… of dict Eliminates model→dict→model roundtrip: sources no longer model_dump(), downstream consumers no longer reconstruct from dict. Removes dead get_test_header() method and unused BaseModel import. Co-Authored-By: Constantine Mirin <konstantin.mirin@gmail.com>
- ranking_agent.py: accept list[Product] instead of list[dict], use direct attribute access, remove make_json_serializable() hack - products.py: pass eligible_products directly instead of model_dump() - factory.py: keep ModelSettings model instead of calling model_dump() Co-Authored-By: Konstantin Mirin <konstantin.mirin@anthropic.com>
Add pydantic.mypy plugin to mypy.ini, fixing 21 false-positive errors where mypy didn't understand Field(None, ...) defaults. Fix real bug where pkg_data was undefined (should be pkg_obj). Widen function signatures to accept list[MediaPackage] matching actual data flow. Remove 24 now-unnecessary type: ignore comments across 8 files.
…icts Remove model_dump() calls across 4 adapter files, replacing dict access with typed Pydantic model attribute access. Fixes a functional bug in Xandr's _create_targeting_profile which used wrong nested field names (targeting["geo"]["countries"]) that never matched Targeting's flat structure (geo_country_any_of), silently producing empty profiles.
Override model_config with get_pydantic_extra_mode() on 7 buyer-facing models that inherited extra='allow' from the adcp library. This prevents buyer injection of arbitrary fields that were silently accepted, survived model_dump(), and got stored in DB. - Replace AdCPBaseModel __init__ hack with native Pydantic model_config - Add model_config override on CreateMediaBuyRequest, PackageRequest, Targeting, Creative, ListCreativeFormatsRequest, ListCreativesRequest, GetMediaBuyDeliveryRequest - Override packages type on CMR and targeting_overlay on PackageRequest to use our extended types instead of library's - Fix Creative's validate_format_id to pop 'format' alias key - Fix all Creative construction sites using legacy created_at/updated_at - Add Literal return type to get_pydantic_extra_mode() for mypy
…brary base Replace parallel AdCPBaseModel with SalesAgentBaseModel(adcp.types.base.AdCPBaseModel). All ~90 internal models now inherit from SalesAgentBaseModel, gaining: - exclude_none=True (from library base) - model_summary() (from library base) - extra='forbid' in dev (from SalesAgentBaseModel) Removes duplicate model_dump/model_dump_json overrides (library provides these). Fixes tests passing invalid extra fields (buyer_ref, has_more) that were silently accepted under extra='ignore'.
…orting_webhook) GetProductsRequest: add product_selectors (PromotedProducts) and pagination fields. UpdateMediaBuyRequest: add reporting_webhook (ReportingWebhook) field. Fix test helpers to generate valid example values for $ref schema types.
- Remove redundant `from src.core.schemas import Package` inside conditional block that shadowed the module-level import (caused "cannot access local variable 'Package'" at runtime) - Remove invalid extra fields from test fixtures: - Principal: drop tenant_id, access_token (not in schema) - SyncCreativeResult: drop buyer_ref (not in schema) - Creative: use created_date/updated_date instead of created_at/updated_at - Accept 500 status in analyze-ad-server route test (expected in test env)
FastMCP coerces input dicts to the type hint in the function signature. Using AdcpPackageRequest (library parent) meant instances couldn't be assigned to CreateMediaBuyRequest.packages which expects our local PackageRequest (child type) — Pydantic rejects parent→child assignment.
- Update 7 tests from old dict-mutation API to new TestingHooksResult API - Fix PackageRequest test to use local Targeting model (not library TargetingOverlay) - Mark targeting_overlay type boundary test as xfail (pre-existing gap) - Update pre-commit hook regex for new apply_testing_hooks signature - Add __test__ = False to TestingHooksResult to prevent pytest collection warning
…-lint-and-formatting # Conflicts: # src/adapters/xandr.py # src/core/schemas.py # src/core/tools/media_buy_create.py # tests/integration/test_list_creative_formats_params.py # tests/integration_v2/test_create_media_buy_v24.py # tests/unit/test_adcp_contract.py
…PR merge) Cherry-picked from feature/agentic-coding-improvements for local development. These files must be removed or squashed out before merging.
…pdate Replace parallel schema classes with minimal library extensions: - AdCPPackageUpdate extends PackageUpdate1, only adds creative_ids (codegen gap) - UpdateMediaBuyRequest extends UpdateMediaBuyRequest1, inherits all AdCP fields - _update_media_buy_impl accepts typed req object (matches create pattern) - Remove model_dump() calls from MCP wrapper — typed models flow directly - Extract _build_update_request() helper for budget/date pre-processing
…dry-run path changes_applied is Field(exclude=True) and would_update is write-only, so storing the typed model instead of a dict has zero downstream impact.
principal_data was assigned via model_dump() but never referenced. The Principal model is used directly downstream (get_adapter, etc).
Remove model_dump(exclude_none=True) and dead hasattr guard for targeting_overlay in update path. Engine's pydantic_core.to_json serializer handles Pydantic models natively in JSONB columns.
…icts validate_overlay_targeting and validate_geo_overlap now accept Targeting Pydantic models directly. This makes validation actually effective — the previous dict-based approach missed managed-only fields (excluded by model_dump) and removed fields (consumed by the normalizer). Removed model_dump() call in media_buy_create.py caller.
Store Pydantic models directly in package_config JSONB dict instead of pre-serializing via model_dump(). The engine's pydantic_core.to_json serializer handles Pydantic models natively in JSONB columns.
Remove model_dump(mode="json") + hasattr guard + cast(dict) fallback at the call site in media_buy_update.py. The impl already handles both dicts and models at line 98-100. Simplify assignments dict to use .creative_id directly since pkg_update.creatives is typed list[CreativeAsset].
…s_impl Moves the ~85-line validation block (schema_data construction, Creative model validation, business logic checks, format registry validation) into a standalone _validate_creative_input() function. No behavior change — all 1961 tests pass unchanged.
…s_impl DRY up duplicated URL extraction logic (update path and create path) into a standalone pure function with tests.
DRY: consolidate duplicated data dict construction (~15 lines each) from update and create paths into a single pure helper function. Also fixes missing snippet/snippet_type handling in the update path and missing context in the create path.
…es_impl Moves the entire update path (~450 lines) into a standalone helper function that handles field updates, approval mode logic, creative agent validation (generative and static), preview extraction, and data persistence. Returns (SyncCreativeResult, needs_approval) tuple for the caller to handle tracking.
Move the entire create-path (~410 lines) from inside the savepoint loop into a standalone helper function. Covers new creative DB insertion, format validation, creative agent processing (generative build and static preview), approval mode logic, and AI review submission. Return type is tuple[SyncCreativeResult, bool] (result, needs_approval) matching the _update_existing_creative pattern. Caller handles all tracking (failed_creatives, created_count, creatives_needing_approval).
Moves ~200 lines of assignment processing logic (package lookup, format validation, idempotent upsert, media-buy status transitions, and results population) into a standalone _process_assignments() helper. The caller now delegates via a single function call. Part of the DRY refactoring chain for creatives.py.
…c_creatives_impl Extract three helpers from _sync_creatives_impl post-processing section: - _create_sync_workflow_steps: workflow step creation with push_notification_config - _send_creative_notifications: Slack notification logic for pending/rejected creatives - _audit_log_sync: both AdCP-level and principal-level audit log entries The orchestrator now reads as a clear sequence of delegated calls.
Phase 1a of dict-to-model retyping chain. Changed _sync_creatives_impl signature to accept Sequence[CreativeAsset | BaseModel | dict] with typed PushNotificationConfig and ContextObject params. Removed model_dump at orchestrator level — models now flow through to helpers which convert via transitional isinstance guards. Updated MCP wrapper and creative_helpers to pass models directly instead of converting to dicts.
…e access Phase 1b/1c/1d: Remove isinstance guards from 5 extracted helpers and replace 65 .get() calls with direct CreativeAsset attribute access. Normalize dicts to CreativeAsset at orchestrator loop entry. Handle typed Asset models in URL extraction and asset iteration.
…cation_config flow - Update sync_creatives_raw type annotations to accept CreativeAsset, PushNotificationConfig, ContextObject - Construct typed models at A2A boundary in _handle_sync_creatives_skill - Stop destructuring PushNotificationConfig into dict in task_metadata; pass model through - Update _send_protocol_webhook to use model attribute access instead of dict .get() - Normalize push_notification_config to dict in _create_media_buy_impl for downstream compat
Split the monolithic 2584-line creatives.py into a proper Python package with 9 focused submodules: _sync.py (orchestrator), _validation.py, _processing.py, _assets.py, _assignments.py, _workflow.py, listing.py, sync_wrappers.py, and __init__.py (backward-compatible re-exports). Updated mock.patch targets in 4 test files to reference the correct submodule where each dependency is imported.
…overlay Remove noqa comments suppressing globally-ignored rules (E402, F841), non-enabled rules (BLE001), and non-existent violations (F823). Fix duplicate targeting_overlay field definition in PackageRequest that caused mypy no-redef and unused-ignore errors.
Replace getattr(option, "root", option) with option.root in dynamic_pricing_service.py and products.py — adcp 3.2.0 always has .root, giving mypy the proper union type and eliminating 5 union-attr ignores. Replace type: ignore with cast(Any, ...) for MinimalContext duck-typing in adcp_a2a_server.py. Remove stale comments from media_buy_status_scheduler.py.
The file was silently excluded by .gitignore rule `sync_*.py` (intended for one-off diagnostic scripts). Added `!src/**/sync_*.py` exception to allow source files matching that pattern. This was the root cause of all CI import failures after the creatives package conversion.
- Re-export get_principal_id_from_context and get_current_tenant from creatives/__init__.py for mock.patch backward compatibility - Revert CreativeAsset(**c) conversion in A2A handler — library type requires FormatId model but callers pass format_id as plain string; _sync_creatives_impl already handles raw dicts - Serialize FormatId to str in workflow step data to prevent "Object of type FormatId is not JSON serializable" in JSONB columns - Update tests to use new _create_media_buy_impl(req=) and _update_media_buy_impl(req=) signatures instead of exploded kwargs - Remove stale xfail on targeting_overlay test (now passes) - Remove extra kwargs from Principal in smoke test (extra="forbid")
…essions - Remove model_dump() calls in _sync.py and _workflow.py — pass FormatId and other Pydantic models directly (engine's _pydantic_json_serializer handles JSONB serialization automatically) - Add json_serializer to integration test conftest engine (required now that assets contain typed Pydantic models instead of plain dicts) - Replace hacky FormatIdMatcher test helper with real FormatId models from the adcp library — tests now use the same typed objects as production code - Fix _update_media_buy_impl test calls to use req=UpdateMediaBuyRequest() pattern matching the refactored function signature - Fix promoted_offerings test data to conform to typed PromotedOfferings schema (requires brand_manifest field)
- Revert HTTP 500 from acceptable status codes in admin UI route test - Add TestProductionModeBehavior with dynamic model class (old test was broken on main — model_config baked at class definition time) - Fix city targeting to assert exact violation message - Add model_extra is None assertions proving extra='forbid' is active - Add TODO(adcp-lib) tracking comments on drift allowlists - Add PromotedOfferings type check to generative creatives test - Fix mypy error: annotate failed_creatives as list[dict[str, Any]]
PrincipalSchema only has principal_id, name, platform_mappings. tenant_id and access_token were silently accepted when Principal extended BaseModel (extra='ignore'), but now fail with extra='forbid'.
ChrisHuie
approved these changes
Feb 18, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Architecture: Typed Boundaries
Pydantic models are the data representation inside the application. Serialization to/from dicts and JSON happens only at system boundaries. No
model_dump()between layers.flowchart LR classDef boundary fill:#f0f8ff,stroke:#007bff,stroke-width:2px,stroke-dasharray: 5 5,color:#000; classDef internal fill:#28a745,stroke:#28a745,stroke-width:2px,color:#fff; classDef io fill:#333,stroke:#333,stroke-width:2px,color:#fff; subgraph Input_Boundary [Boundary: Coercion] direction TB P_Parse(Pydantic Parse):::boundary P_Validate(model_validate):::boundary end subgraph Internal_Domain [Internal: Typed Models] direction TB M_Request(CreateMediaBuyRequest):::internal M_Pkg(Package / Targeting):::internal M_Prod(Product / Creative):::internal end subgraph Output_Boundary [Boundary: Serialization] direction TB S_Dump("model_dump(mode='json')"):::boundary S_Tool(ToolResult):::boundary S_Serial(json_serializer):::boundary end Buyer[Buyer JSON]:::io DB_Read[(DB Read)]:::io DB_Write[(DB JSONB)]:::io Response[A2A / API Response]:::io Buyer --> P_Parse --> M_Request --> S_Dump M_Pkg --> S_Serial --> DB_Write DB_Read --> P_Validate --> M_Prod --> Response S_Dump -.-> S_ToolBefore: Models were frequently converted to dicts mid-flow (
model_dump()) then reconstructed, creating opportunities for field loss, type confusion, and silent bugs. Internal functions accepteddict[str, Any]orAnywhere typed models should have been used.After: Every internal function accepts and returns typed Pydantic models. Serialization happens exactly twice — once at input (coercion) and once at output (protocol/DB). The
model_dump()calls that previously peppered the business logic have been removed or pushed to the boundaries.Library type inheritance
All buyer-facing models extend adcp library types. Extension classes exist only for:
model_configoverride —ConfigDict(extra=get_pydantic_extra_mode()): forbid extras in dev, ignore in prodcreative_idsonPackageUpdate). Filed upstream, removed once fixedField(exclude=True)What Changed
Model Hierarchy Unification
SalesAgentBaseModelnow extends theadcplibrary'sAdCPBaseModel, inheritingexclude_none=True,model_summary(), and JSON-mode serialization. Replaces the previous parallelAdCPBaseModelthat reimplemented these behaviors with a custom__init__hackBaseModel/AdCPBaseModel→SalesAgentBaseModelextra='forbid'in dev,extra='ignore'in prod) now uses native Pydanticmodel_configinstead of runtime__init__overrideSerialization Boundaries
pydantic_core.to_jsonasjson_serializeron SQLAlchemy engine — all JSONB columns now handle Pydantic models, enums,AnyUrl, and datetimes automaticallyJSONType: Updated to acceptBaseModelinstances directly, eliminating prematuremodel_dump()before DB writesmode='json'at MCP, A2A, and webhook serialization boundariesNestedModelSerializerMixinand 5 custom@model_serializermethods propagatemode=info.modeto child modelsMedia Buy Tool Refactoring
_create_media_buy_impl(3,800-line file, 680-line diff): Replaced 14 untyped parameters with a single typedCreateMediaBuyRequest. Removed 6 dead parameters never passed by either caller. Eliminatedcast(list[Package], packages)workarounds — functions now acceptlist[MediaPackage]directly._update_media_buy_impl: Replaced 17 untyped parameters with a single typedUpdateMediaBuyRequest. MCP wrapper no longermodel_dump()s typed models — they flow directly through. Budget/date pre-processing extracted to shared_build_update_request()helper.Schema extensions for both:
UpdateMediaBuyRequestextends library'sUpdateMediaBuyRequest1— only addsbudget(convenience),today(testing), and field overrides for oneOf/packagesAdCPPackageUpdateextends library'sPackageUpdate1— only addscreative_ids(spec field missing from codegen)Dict-to-Model Migrations
PolicyCheckService: AcceptsProductandBrandManifestmodels instead of dicts. Removed dead age-based eligibility logic that referenced non-existent fieldsranking_agent: Acceptslist[Product]instead oflist[dict], removedmake_json_serializable()hackToolContext.testing_context: Typed asAdCPTestContextinstead ofdict, eliminating model→dict→model roundtripsapply_testing_hooks: Returns structuredTestingHooksResultdataclass instead of mutating a data dict. Eliminated 90+ lines of dict surgery across 3 callersTargetingmodel attributes instead of dict key access. Fixed a functional bug in Xandr's_create_targeting_profilewhere wrong nested field names (targeting["geo"]["countries"]) never matched the flat structure (geo_country_any_of), silently producing empty targeting profilesBuyer-Facing Input Validation
model_configwithextra='forbid'— preventing arbitrary field injection that was previously silently accepted, survivedmodel_dump(), and got stored in the databaseCreateMediaBuyRequest.packagesandPackageRequest.targeting_overlayoverride library types to use our extended versions withextra='forbid'Creative Sync: Package Decomposition
src/core/tools/creatives.py(2,584 lines, 16 functions) decomposed into a Python package with 9 focused submodules:_sync.py_sync_creatives_impl_validation.py_validate_creative_input,_get_field_processing.py_create_new_creative,_update_existing_creative_assets.py_extract_url_from_assets,_build_creative_data_assignments.py_process_assignments_workflow.py_create_sync_workflow_steps,_send_creative_notificationslisting.py_list_creatives_impl,list_creativessync_wrappers.pysync_creatives,sync_creatives_raw__init__.pyBackward compatibility preserved via
__init__.pyre-exports. Prior to the package conversion,_sync_creatives_implwas incrementally decomposed across 7 commits.Integration Test Fixes
Several integration tests were silently passing with incorrect data due to the old
extra='allow'/extra='ignore'defaults:PrincipalSchemaconstruction (inventory.py): Was passingtenant_idandaccess_tokenwhich aren't Principal fields — silently accepted before, now caught byextra='forbid'. Fixed by removing the extra fields_update_media_buy_implcallers: 5 test call sites were passing exploded kwargs that no longer match the new typedreq=UpdateMediaBuyRequest(...)signature. Updated to construct proper request objectsFormatIdMatchermock class: Integration tests used a custom matcher class with__eq__that relied onstr(FormatId)— but the libraryFormatIdhas no custom__str__, so comparisons silently failed. Replaced with realFormatIdmodel instances from the adcp libraryPromotedOfferingstest data: Was{"name": "...", "description": "..."}which doesn't conform to the typed schema (requiresbrand_manifest). Fixed to validPromotedOfferingsstructureLint and Type Check Cleanup
# noqacomments suppressing globally-ignored or non-existent rulestargeting_overlayfield definition inPackageRequesttype: ignorecomments removed after enablingpydantic.mypypluginCode Removal
SchemaMetadata,ResponseWithSchema,enhance_*)model_dump()calls removed from 9ToolResultsites (FastMCP already handles serialization)Quality Infrastructure
Makefilewithquality,pre-pr,lint-fix,typechecktargetsImpact
extra='forbid'Known Limitations
PackageRequest.targeting_overlayaccepts our localTargetingmodel, but the library'sPackageexpectsTargetingOverlay. Structurally different types (flatgeo_country_any_ofvs structuredgeo_countries). Test markedxfail— proper fix requires makingTargetinginherit fromTargetingOverlay(separate task).Test Results
Test Plan
make quality— formatting, linting, mypy (0 errors), unit tests./run_all_tests.sh ci— full CI suite with Docker + PostgreSQL (all suites green)creatives/package re-exportsextra='forbid'rejects unknown fields in dev modeextra='ignore'accepts unknown fields in production mode (dynamic model test)