feat(decisioning): wire create_media_buy_store into PlatformHandler dispatch (closes #462)#773
Conversation
…ispatch (closes #462) Add ``media_buy_store`` kwarg to ``create_adcp_server_from_platform`` and thread it through ``PlatformHandler.__init__`` to the three media-buy shims so adopters wire the ``targeting_overlay`` echo contract once instead of per-adapter. * ``create_media_buy`` shim: ``persist_from_create`` rides the existing ``on_complete`` seam, chained with the proposal v1.5 hook when both are wired, so both sync and HITL completions persist. * ``update_media_buy`` shim: ``merge_from_update`` on ``on_complete``, honoring the spec contract that ``targeting_overlay`` is echoed from the most recent ``create_media_buy`` OR ``update_media_buy``. * ``get_media_buys`` shim: ``backfill`` called inline before returning. New ``_to_store_dict`` helper normalizes Pydantic → JSON-compatible dict via ``model_dump(mode='json', exclude_none=False)`` so the adopter contract is shape-stable regardless of whether the platform returned a typed response or a dict, and explicit nulls survive (merge contract treats ``None`` as "clear this field"). API shape uses the kwarg-on-factory grammar (matching ``buyer_agent_registry``, ``webhook_sender``, ``resource_resolver``) instead of the post-construction attribute assignment the issue body originally proposed — consistent with every other framework opt-in. Tests: 7 integration tests covering persist / merge / backfill via handler, the no-op store-missing paths, the no-op specialism-not-claimed path, and a full create→get round trip. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Hold. The wiring shape is right and the closure / chaining work is clean — but _to_store_dict(params) is dumping the full CreateMediaBuyRequest / UpdateMediaBuyRequest across the adopter boundary, and those requests carry webhook bearer tokens.
Must fix before merge
1. Webhook credentials leak into the adopter store. handler.py:1683-1687 and :1740-1744 call _to_store_dict(params) on the full request before handing to store.persist_from_create / store.merge_from_update. The dump uses model_dump(mode='json', exclude_none=False, by_alias=False) with no scrub. CreateMediaBuyRequest.push_notification_config.authentication.credentials, .reporting_webhook.authentication.credentials, and .artifact_webhook.authentication.credentials are all ≥32-char Bearer / HMAC-SHA256 secrets (src/adcp/types/generated_poc/core/push_notification_config.py:27, reporting_webhook.py:28, create_media_buy_request.py:216-231). The adopter store sees the full request including those tokens. The obvious adopter pattern — persist the dict for audit/replay — silently archives buyer-supplied webhook secrets. _CREDENTIAL_SHAPED_KEY_SUFFIXES (dispatch.py:413-422) doesn't help here because that gate is ctx_metadata-only. Fix: project to a minimal {packages, media_buy_id, buyer_ref} dict before dumping, OR extend strip_credentials_from_wire_result / _scrub_dict (account_projection.py:345-436) to cover the three webhook authentication.credentials paths and run it on the dict before crossing the boundary. The store only needs packages[].targeting_overlay — give it that and nothing else. security-reviewer: HIGH.
Things I checked
_to_store_dict(handler.py:807-826):by_alias=Falseis correct — verified noField(alias=...)onCreateMediaBuyRequest,UpdateMediaBuyRequest,Package, or any targeting field.mode='json'normalizesAnyUrl/datetimefor adopters who persist as JSON.- Closure capture on the chained
create_media_buyhook (handler.py:1675-1689):prior_on_complete,captured_store,captured_account_idare bound at definition. No late-binding bug. If_finalize_consumption_hookraises, the persist hook exits early — correct ordering for the happy path. - HITL parity claim:
dispatch.py_project_handofffireson_completewith the typed result for both create and update handoffs, so the PR description holds. - Specialism gating:
_OVERLAY_ECHO_SPECIALISMS = {property-lists, collection-lists}matchesschemas/cache/3.0/enums/specialism.json. Noaudience-listsexists in the canonical enum; gate is correct and conservative. - Echo contract:
schemas/cache/3.0/media-buy/get-media-buys-response.jsonmatches the L1730 comment verbatim — "echoed from the most recent create_media_buy or update_media_buy." - Tenant isolation:
account.idscoping (handler.py:1678,:1736,:1835) matches the canonical scope key used by ProposalStore and TaskRegistry; no new cross-tenant leak._resolve_accountsynthesizesf\"{account_id}:{principal}\"so the test assertion of\"acme:anonymous\"is right. ctx_metadata: none of the new wiring touches it. CLAUDE.md echo warning not implicated.- Test suite: 7 new integration tests cover the persist / merge / backfill paths and the no-op specialism path. Conventional-commit prefix
feat(decisioning):is correct — the kwarg defaults toNoneso this is additive, not breaking.
Follow-ups (non-blocking once the credential leak is fixed)
- Persist-failure / proposal-finalize ordering (
handler.py:1680-1689). Ifpersist_from_createraises after_finalize_consumption_hookmarked the proposal CONSUMED,dispatch.py:1524-1530fires_release_reservation_hookagainst an already-consumed reservation. The media buy was created in the adapter, the buyer sees a failed call, and proposal accounting goes divergent. The store is best-effort backfill, not a source of truth — wrap the persist call in try/except withlogger.exceptionso a downstream persistence outage doesn't unwind a successful inventory commit. - Backfill mutation skips the dispatcher's credential strip (
handler.py:1834-1836)._invoke_platform_methodreturned a credential-stripped result (dispatch.py:1538), butbackfillthen mutates and we return the mutated dict. An adopter store backed by the same DB row as the seller's governance auth could re-inject stripped fields. Re-runstrip_credentials_from_wire_result(\"get_media_buys\", result)afterbackfillreturns. Defense in depth. - Typed-vs-dict return contract (
handler.py:1835). With a store wired, the return is always a dict; without one, it's whatever the platform returned.cast(\"GetMediaBuysResponse\", result)papers over the divergence. Tolerable today; future middleware doingisinstance(result, GetMediaBuysResponse)would break depending on whether a store happens to be wired. Either always normalize to dict, or re-validate into the typed model post-backfill. - Merge semantics in the test fixture (
tests/test_media_buy_store_integration.py:90-93). The recording store'sexisting.update(overlay)is key-level merge —media-buy/package-update.jsonsaystargeting_overlayuses replacement semantics ("the full overlay replaces the previous one"). A buyer sending{collection_list: X}to a package previously{property_list: Y, collection_list: Z}would see{property_list: Y, collection_list: X}from this fixture — spec says they should see{collection_list: X}. Framework code is unaffected; the test happens to encode an adopter pattern the spec doesn't sanction. Either fix the fixture to do full-replacement, or annotate it as deliberately non-spec.
Minor nits
- Test response subscript on a typed cast (
tests/test_media_buy_store_integration.py:312, 458).resp[\"media_buys\"][0]...works because the fixture platform returned a dict and the store-backfill normalization keeps it a dict; future readers seeingcast(\"GetMediaBuysResponse\", result)upstream may think the response is the typed model. One inline comment would prevent the confusion.
Flip to approve once the credential leak fix lands and tests cover the projection so we don't regress it.
|
Thanks @aao-ipr-bot — pushed Blocker: credential leakReplaced the blanket
Whitelist > blacklist exactly as you flagged — future schema additions of credential-shaped fields can't reintroduce the leak. Pydantic's Follow-ups addressed in the same commit
Regression testsAdded four tests in
Other notes
Suite: 4894 passed, 0 regressions. |
Summary
Wires the already-merged
create_media_buy_storehelper into the framework dispatch path. Adopters now opt in via a kwarg oncreate_adcp_server_from_platforminstead of hand-rollingtargeting_overlaypersistence + echo per adapter.create_media_buy—persist_from_createrides the existingon_completeseam, chained with the proposal v1.5 hook when both are wired, so both sync and HITL completions persist.update_media_buy—merge_from_updateonon_complete, honoring the spec contract thattargeting_overlayechoes from the most recentcreate_media_buyorupdate_media_buy.get_media_buys—backfillcalled inline before return.New
_to_store_dicthelper normalizes Pydantic → JSON-compatible dict viamodel_dump(mode='json', exclude_none=False). Adopters see the same shape that would go on the wire (AnyUrl→ str, datetime → ISO-8601 str), and explicit nulls survive the merge contract (None= "clear this field").API shape decision
The triage comment surfaced an open question: kwarg on
create_adcp_server_from_platformvs. post-constructionplatform.media_buy_store = ...(as the issue body proposed). Went with the kwarg — matchesbuyer_agent_registry,webhook_sender,resource_resolver, and every other framework opt-in.Test plan
tests/test_media_buy_store_integration.pycovering persist / merge / backfill throughPlatformHandler, the no-op paths (store missing, seller lacks specialism), and a full create→get round-triptests/test_media_buy_store.pyunit tests still passruff check+mypyclean on changed filesCloses #462.
🤖 Generated with Claude Code