Repository navigation
fix: align the Index Exchange connector with the real /v3/deals API - #111
josh-butcher-index wants to merge 6 commits into
Conversation
Rewrites the Index Exchange SSP connector against the deals-web-api /v3/deals contract: Keycloak JWT auth, request/response shapes, and ETag-based PATCH updates. Resolves DSPs from buyer seat IDs, with an approval gate for ambiguous matches. Extends the MCP tools accordingly: distribute_deal_via_ssp params, approve_or_reject modifications, a new resume_approval tool, and create_curated_deal buyer_seat_ids. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
56d22fa to
7a53b86
Compare
|
If two seat IDs resolve to different DSPs, approval asks for one DSP for both seats. That cannot represent the request. Could this case fail clearly or split into separate deals? |
DSP resolution flattened matches across all requested seats, so seats owned by different DSPs gated on picking one DSP for all of them, and a seat with no active match was silently sent under another seat's DSP. Resolve per seat and intersect: fail with a 400 when a seat has no match or the seats share no DSP, auto-resolve when exactly one DSP owns every seat, and gate only among the DSPs shared by all seats. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
|
Good catch, thanks. Resolution was flattening matches across all seats, so seats on different DSPs ended up gated on picking one DSP for both. Fixed in 945dd96: it now resolves per seat and intersects the DSP sets. If a seat has no active match, or the seats share no DSP, it fails with a 400 that lists the seat→DSP mapping. If exactly one DSP owns every seat it's used directly, and the approval gate is only for a real collision, with candidates limited to DSPs shared by all seats. I went with failing clearly rather than splitting, since splitting would turn one call into several deals (multiple deal IDs and external IDs) and change the tool's return shape. Happy to add splitting as a follow-up if you'd prefer it. |
|
Checked One open question while testing that same code path: Is Checked both
Worth a test either way — every existing test in |
|
Failing clearly is the right scope for my multi-seat concern. Resolving each seat and limiting approval candidates to their common DSPs addresses it. No split-deal behavior is needed for that finding. |
Resolve DSPs with one request per seat ID and tag each match with the seat it was queried for, instead of inferring the owning seat from the response's extendedSeatID. Matching no longer assumes Index echoes the requested value unchanged. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
|
Thanks for testing that path, @Kishore-2572. On the API question: Fixed in 03f92ef. |
Kishore-2572
left a comment
There was a problem hiding this comment.
Pulled 03f92ef and independently reproduced the extendedSeatID/seatID mismatch scenario live against the real connector + _resolve_dsp_id_or_pending_approval — resolution now correctly succeeds end-to-end where it previously would have wrongly reported "no active DSP found."
Negative-controlled the new regression test by reintroducing the old matching logic — it fails with exactly the old bug's error, confirming it genuinely catches this regression.
Also checked the case-insensitivity removed from deal_service.py (.casefold()) — not a regression, since matching no longer crosses between request/response strings at all. Verified live with a duplicate-seat-different-casing case.
Minor non-blocking note: seat resolution is now N concurrent per-seat calls instead of one batched call, with no cap on seat-list size. Tested a partial failure (one seat fails, others succeed) — fails cleanly, no silent partial state.
Full suite: 1660 passed, lint/format clean. Both review threads hold up under live testing. Approving.
atc964
left a comment
There was a problem hiding this comment.
Thanks Josh, and thanks to Kishore's team for the local validation. The connector rewrite itself is in good shape: the field mapping matches your fixture end to end, the ambiguous-seat path correctly books nothing while pending, and the error-path tests are genuinely strong. We need changes on the approval/resume flow and ID handling before this can merge, since they sit on the money path:
- The DSP-resolution gate snapshots the create request including
cpminto pending-approval state.list_pending_approvals(MCP) currently has no operator gate, and RESTPOST /approvals/{id}/decideand/resumeaccept any valid API key, while resume applies arbitrarymodifications(includingcpm) and then creates the IX deal. Please add the operator gate tolist_pending_approvalsand the REST decide/resume routes within this PR, or restrictmodificationstodsp_idonly, so a non-operator key can neither read the floor nor price the deal. - Resume is replayable: after approval, repeated resume calls re-run
create_deal. Please add a consumed/terminal state (same pattern as the change-request idempotency fix in #64). _parse_dealkeeps onlyexternalDealIDand dropsinternalDealID, but get/update/clone need the internal ID per your own docstrings, so created deals can't be managed afterward. Suggestdeal_id=str(internalDealID),external_deal_id=externalDealID.- Validate
selected_dsp_idagainst the candidates recorded in the gate context (one-line membership check). index_exchange_api_urlnow defaults to the production FQDN, which makes the factory's unconfigured-connector guard dead code and can send requests with no Authorization header to production IX on a misconfigured deploy. Please keep the default None.SSPDealDistributeRequest.targetingnarrowed dict to list without regeneratingdocs/api/openapi.json; please regenerate (note #83 adds a drift guard that will fail on this).
Worth fixing but not blocking: the ETag is fetched immediately before PATCH, which defeats lost-update protection (accept a caller-held ETag instead); CREATED/ARCHIVED list filters are silently dropped; unknown IX statuses default to CREATED rather than an error state; and a CHANGELOG entry is missing. The static-JWT limitation is documented and fine for now.
Gate approval listing, decide and resume to operator keys, make a resumed dsp_resolution approval single-use, and validate the selected DSP against the recorded candidates. Keep the parsed internalDealID as deal_id with the external ID alongside it, default index_exchange_api_url to unset, and reject unsupported list filters instead of dropping them. Also accept a caller-held ETag on update, map unknown IX statuses to ERROR, sync the OpenAPI spec, and add a changelog entry. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
|
Thanks for the thorough review, all six blocking items and the non-blocking ones are addressed in 00fae3d:
Non-blocking: Tests added for each of the above. |
|
Thanks @josh-butcher-index , I re-reviewed at Verified fixed
Still needs changes1. A lost IX response can lead to a duplicate deal (blocking)
I reproduced this with the real Suggestion: only restore 2. After the
3.
Suggestion: Minor
|
…aging - Look up the deal by exact externalDealID after an ambiguous create failure (timeout, dropped connection, 5xx) instead of blindly allowing a retry; leave the approval consumed when the outcome cannot be confirmed - Restrict decision-time modifications to an allow-list and validate them through the model so dsp_id and external_deal_id cannot be overridden - Accept dict or list targeting on the shared create request again Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
|
Thanks for the re-review, and for reproducing the timeout with the real client. Addressing the remaining items in 5ec942a: 1. Lost IX response → duplicate deal. Agreed.
4xx responses and connect errors never reached Index, so they skip the lookup. Tests drive the real client over 2. 3. Minor ( |
|
Thanks Josh, I re-reviewed Verified fixed
Negative controls: disabling the post-failure lookup fails 3 tests, and letting Still needs changes1. REST The shared 2. Seats can change after DSP resolution
3. "Outcome unknown" has no recovery path On 5xx + failed lookup, resume returns 502 and leaves themessage says to check Index before retrying, but a secondresume returns 409 and the approval can't be re-decided, so there's no way to retry or to record a deal ID that does exist in IX. Please add an operator path to either attach the found IX deal or reved
Pre-existing on
|
…eate outcomes - Accept dict or list targeting on the distribute REST schema and MCP tool - Drop buyer_seat_ids from the decision-time allow-list and reject unknown modifications keys - Validate selected_dsp_id and modifications when the decision is submitted, while the approval is still pending - Add an outcome_unknown approval status; resuming it re-checks the SSP by externalDealID and never creates a second deal - Map an unknown create outcome on the direct distribute path to a 502 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
|
Thanks for the thorough end-to-end re-review. Addressed in f1a001f: 1. REST/MCP 2. Seats after DSP resolution. Agreed. 3. Outcome-unknown recovery. Agreed that 502 plus a permanently consumed approval was a dead end. An unconfirmed create now moves the approval to a new Smaller items
Pre-existing on |
atc964
left a comment
There was a problem hiding this comment.
Approve, with follow-ups.
Three solid fix rounds. Every blocking item from the October 22 review is verifiably fixed at f1a001f: the approval surfaces (REST and MCP) are operator-gated symmetrically, resume is consume-once with a lookup-only outcome_unknown recovery state, deal_id now carries Index's internalDealID with the seller ID in external_deal_id, selected_dsp_id is checked against the stored candidates at decide time and again at resume, modifications go through an allowlisted model_validate (and unknown keys now fail loudly), INDEX_EXCHANGE_API_URL has no production default, and the targeting union is consistent from ssp_base through the REST schema, the MCP parameter, and the regenerated openapi.json. The ETag handling is now real lost-update protection, and list_deals rejecting filters Index can't express beats silently returning everything. Credit to @Kishore-2572's end-to-end rig: the decoy-substring and lost-response scenarios reproduced against a real server are exactly the validation this needed, and the negative controls on the new tests are a nice touch.
Two conditions on the approval:
- CI had not run at f1a001f (workflows awaiting fork approval; last green was 00fae3d). I've approved the workflow runs; needs a green run at head before merge.
- Please open the follow-up issues you offered, covering: Keycloak JWT refresh (currently a documented manual step); the ungated MCP
create_curated_deal, now slightly wider since it acceptsbuyer_seat_idsthat distribute's stored-deal fallback will trust later; the non-CAS consumed-state race you've documented atapproval_service.py:271(including the wrinkle that a racing duplicate-create failure restoresapprovedoverconsumed); and the pre-existing items from Kishore's list (deal_typeto PMP fallback, negative cpm, IX 4xx as 500).
Small nits, fine to fix here or in the follow-up: MCP approve_or_reject validates the operator key but doesn't stamp decided_by_principal, so MCP-decided approvals audit as anonymous while REST records the verified principal; the decision string is still free-text ("aprove" silently records a rejection, worth an enum check in decide_approval); test_outcome_unknown_leaves_approval_consumed asserts outcome_unknown, so the name is stale. And one confirming sentence would help future readers: the recovery lookup assumes GET /v3/deals?search= matches externalDealID and that pageOffset is a page index. Can you confirm both against the real Deals API, since the fake-IX harness necessarily bakes in the same assumptions? All failure modes land safe-side regardless, so this is documentation, not a blocker.
Merge coordination: openapi.json overlaps #83 and #98, deal_service.py overlaps #92, and mcp_server.py/settings.py overlap #108/#107; whoever lands second rebases and (for openapi) regenerates.
Summary
src/ad_seller/clients/ssp_index_exchange.py) was implemented without access to IX API docs, so field names, endpoints, and request/response shapes were invented rather than matching the realdeals-web-api/v3/dealscontract.PATCHupdates, and removal of the nonexistent clone endpoint.resume_approval,approve_or_rejectmodifications,distribute_deal_via_ssp/create_curated_dealparams) — this was verified end-to-end against real IX staging.Test plan
tests/unit/test_ssp_connectors.pyandtests/unit/test_dsp_resolution_gate.pypass against the corrected fixturestests/integration/test_deal_flow_e2e.pypasses/v3/dealsCo-Authored-By: Claude Sonnet 5 noreply@anthropic.com