fix: preserve full sync_accounts request for account stores#796
Conversation
There was a problem hiding this comment.
LGTM. Follow-ups noted below. Right shape: additive Protocol, legacy AccountStoreUpsert path preserved, zero wire-schema delta — purely an adopter-callback ergonomics fix for a real bug where push_notification_config / delete_missing / dry_run were being dropped at handler.py:2114 before reaching the store.
Things I checked
- Dispatch precedence at
src/adcp/decisioning/handler.py:2097-2118—upsert_requestwins over legacyupsertwhen both are present. Richer hook wins, never both invoked. Right call. _call_with_optional_ctxshim is hook-agnostic (accounts.py:111-ish) and handles bothdef upsert_request(self, params, ctx=None)anddef upsert_request(self, params).inspect.signatureon a bound method stripsself, so the"ctx" in paramsprobe works for both shapes.- Advertised-tool gate at
src/adcp/decisioning/handler.py:1054-1061widened symmetrically with the dispatch gate — no mismatch that would advertisesync_accountsthen returnOPERATION_NOT_SUPPORTED. - Router denylist at
src/adcp/decisioning/platform_router.py:155-157addsupsert_request, and the drift-guard testtest_account_store_methods_denylist_matches_protocolsis extended to cover the new Protocol. Both edits required, both present. - Idempotency layer wraps
PlatformHandler.sync_accountsitself, so both hooks land downstream of the cache — replay semantics are identical acrossupsert/upsert_request. - Wire shape unchanged:
SyncAccountsRequest/SyncAccountsResponsePydantic models are untouched.schemas/cache/3.0/account/sync-accounts-request.jsonunchanged. ctx_metadatacredential-shaped-key fail-closed gate still load-bearing — the new path doesn't write totool_ctx.metadataor bypass_build_request_context.- Type-system layering: no breach.
accounts.pyimportsSyncAccountsRequestfromadcp.typesunderTYPE_CHECKING, not fromgenerated_poc/. - Test added for happy path (
test_sync_accounts_prefers_full_request_upsert_hook) —call["params"] is reqidentity check is meaningful becausemodel_constructskips coercion, so it pins zero defensive copy/cast in dispatch.
Follow-ups (non-blocking — file as issues)
- Webhook-credential exposure to adopter store (
security-reviewer: Medium).params.push_notification_config.tokenandparams.push_notification_config.authentication.credentialsnow reachupsert_requestcallees; the legacyupsert(refs, ctx)path stripped them. Bounded risk (requires an adopter to dumpparams), but worth either a docstring warning atsrc/adcp/decisioning/accounts.py:314-340calling out the credential surface, or — better — passingparams.model_copy(update={"push_notification_config": <redacted>})to the hook, since the framework'swebhook_emit.pyalready holds the full copy it needs for signing. - Forward-compat asymmetry (
ad-tech-protocol-expert). Legacyupsert(refs, ctx)adopters will silently drop any future envelope field the spec adds;upsert_requestadopters get it for free. The Protocol docstring ataccounts.py:316says "Prefer this" but doesn't flag the silent-drop hazard. Aversionchanged::note pointing legacy implementers at the migration would help. - Dual-impl diagnostic. An adopter mid-migration with both
upsert_requestandupsertdefined gets silent ignore of the legacy method. A one-timelogger.warningwhen both are callable would catch leftover dead code during migration. Not correctness — call it out in the next release note if not adding. - Dropped-tool log message (
handler.py:1054-1061log path)._log_account_tool_dropped("sync_accounts", "upsert")always names onlyAccountStoreUpsert. Adopters reading the log get pointed at the legacy hook even when wiringAccountStoreUpsertRequestwould be the cleaner choice. Extend to "ImplementAccountStoreUpsert.upsertorAccountStoreUpsertRequest.upsert_request." - Test coverage gap. No test pins the "both hooks defined" precedence. Add a store with both methods and assert
upsert_request_calls == 1,upsert_calls == 0so a future refactor can't silently flip dispatch order.
Minor nits (non-blocking)
- Commit prefix.
fix:for a new public Protocol class + new__all__export is a notable choice — release-please will cut a patch bump, and the new surface lands in the changelog under bug fixes rather than features. The diff does fix #794, sofix:isn't wrong, butfeat:is what gets adopters the minor-bump signal that a new opt-in Protocol exists.
Safe to merge.
e3c3e10 to
efeab58
Compare
efeab58 to
1f1f352
Compare
There was a problem hiding this comment.
LGTM. Clean additive Protocol — upsert_request runs ahead of legacy upsert, and the fail-closed gate keeps OPERATION_NOT_SUPPORTED semantics intact when neither hook is wired.
Real bug. The old extract-params.accounts-and-drop path was silently losing push_notification_config — webhook registrations vanished into a successful-looking response. Right fix shape: new optional Protocol, framework prefers it, legacy callers untouched.
Things I checked
- Routing precedence at
src/adcp/decisioning/handler.py:2097-2115—upsert_requestfirst,upsertfallback, both probed viagetattr(..., None)+callable(...).assert callable(upsert)at L2113 is mypy-narrowing only; the L2099 guard already shorted the_not_supportedcase. advertised_tools_for_instanceatsrc/adcp/decisioning/handler.py:1054-1061fail-closes correctly —accounts is Nonethencan_sync_accounts = Falseandsync_accountsis dropped fromtools/list. No regression for stores that wire neither hook.- Denylist drift coverage at
tests/test_platform_router.py:486-517— set-XOR union now includesAccountStoreUpsertRequest, andupsert_requestis in_ACCOUNT_STORE_METHODSatsrc/adcp/decisioning/platform_router.py:157. Router will not synthesize a tenant-keyed delegate over the new method. runtime_checkableProtocol works structurally —tests/test_decisioning_types.py:287-300confirms a store withupsert_requestonly (noupsert) passesisinstance(_, AccountStoreUpsertRequest).- Backwards compat — legacy
upsert-only path covered bytests/test_decisioning_handler_shims.py:963-1001. Existing adopters keep working. - Security posture —
security-reviewer: None.push_notification_configis a typed Pydantic envelope field, notRequestContext.metadata; the_CREDENTIAL_SHAPED_KEY_SUFFIXESfail-closed atsrc/adcp/decisioning/dispatch.pyis unrelated. Same_prime_auth_context+_make_resolve_contextthreading as the legacy path — no new tenant-isolation surface, no new SSRF surface (framework forwards the URL to the adopter, who already owns outbound posture). Response-projection credential scrubber unchanged and exercised bytests/test_decisioning_handler_shims.py:1252-1316. - Public-API impact — adds
AccountStoreUpsertRequesttoadcp.decisioningexports atsrc/adcp/decisioning/__init__.py. Additive only;fix:defensible since the load-bearing motivation is a real bug, not a feature.
Follow-ups (non-blocking — file as issues)
docs/handler-authoring.md:809-812still names onlyAccountStoreUpsertin the "tool dropped" hint. Worth a pass to mentionAccountStoreUpsertRequestas the preferred surface when request-envelope fields matter.- The
_log_account_tool_dropped("sync_accounts", "upsert")call hardcodes"upsert"in the log message — adopters with neither hook get steered toward the legacy Protocol whenupsert_requestis now the better default.
Minor nits (non-blocking)
- Protocol body inconsistency.
src/adcp/decisioning/accounts.py:341usesraise NotImplementedError; the siblingAccountStoreUpsert.upsertataccounts.py:311uses.... Functionally identical underruntime_checkable(structural name check, body never runs), but...is the convention everywhere else in this file. - Docstring at
src/adcp/decisioning/handler.py:2086reads "AccountStoreUpsertProtocol" in the singular — the gate now covers either Protocol; "either Protocol" reads cleaner.
Safe to merge.
Summary
AccountStoreUpsertRequest.upsert_request(params, ctx)hook for full-requestsync_accountshandlingPlatformHandler.sync_accountspreferupsert_requestwhen present, while preserving the existingupsert(params.accounts, ctx)fallbackpush_notification_configand other request-level fields reaching the storeRoot Cause
PlatformHandler.sync_accountsalways extractedparams.accountsbefore dispatching toAccountStore.upsert. Store implementations could not see request-envelope fields such aspush_notification_config, so a realsync_accountscall could appear successful while losing webhook registration data.Impact
Existing
AccountStore.upsert(accounts, ctx)implementations remain compatible. Stores that need request-level fields can implementupsert_request(params, ctx)and still return the same shapes projected through the existingsync_accountsresponse path.Closes #794.
Validation
PYTHONPATH=/Users/brianokelley/conductor/adcp-client-python/.conductor/bangalore-v12/src pytest tests/test_decisioning_handler_shims.py tests/test_decisioning_types.py tests/test_platform_router.py tests/test_public_api.pygit diff --check