feat(decisioning): Tier 3 brand-authz dispatch gate (#350 stage 5, closes #350)#785
Conversation
Final stage of issue #350 — wires the BrandAuthorizationResolver (landed in PR #770) into the framework's dispatch path so adopters can opt into per-brand authorization the same way they opt into Tier 2 commercial-identity gating today. Surface (serve()): brand_authz_resolver: BrandAuthorizationResolver | None = None brand_identity_resolver: Callable[[Account, BuyerAgent | None], BrandIdentity | None | Awaitable[BrandIdentity | None]] | None = None Both must be wired together — partial wiring raises ValueError at boot (a resolver without an extractor has no brand to check; an extractor without a resolver has nothing to do). Same opt-in shape as Tier 2's buyer_agent_registry. Dispatch sequence (_resolve_account): 1. Tier 2 — _resolve_buyer_agent (existing) — registry-resolved BuyerAgent on ctx.metadata. Suspended/blocked short-circuit here. 2. AccountStore.resolve — existing. 3. NEW: Tier 3 — _enforce_brand_authorization. Extractor pulls brand identity from (Account, BuyerAgent); resolver answers "is this agent authorized for THIS brand?"; rejection raises PERMISSION_DENIED with the cross-tenant-safe denial message. 4. Platform method runs. The gate is a no-op when no BuyerAgent has been resolved (Tier 2 not wired) — brand authorization without a subject identity has nothing to authorize. Denial surface: PERMISSION_DENIED with recovery=correctable, identical message bytes to the Tier 2 unrecognized-agent rejection so the wire-level error.message is not a side channel discriminating the two gates. Details defaults to {} (omit-on-unestablished-identity rule, same as Tier 2). Verifier-side spec-shape codes (request_signature_brand_origin_mismatch / _agent_not_in_brand_json) belong to the verifier path that issue #776 will plumb. Timing-oracle defense: reuses PermissionDeniedBudget from Tier 2. The brand.json fetch path's natural variance is larger than the registry-lookup variance Tier 2 absorbs, so the budget here is a floor — it prevents the cache-hit-authorized vs cache-hit-rejected paths from leaking timing-distinguishable decisions on the happy-cache path (the common case). New files: - src/adcp/decisioning/brand_authz_gate.py — BrandIdentity dataclass, BrandIdentityResolver callable Protocol, BrandAuthorizationGate bundle pairing (resolver, extractor) atomically. - tests/test_decisioning_brand_authz_dispatch.py — 11 tests covering boot validation, authorized path, denied path, no-buyer-agent skip, extractor-returns-None skip, async extractor, brand_id propagation, three-tier ordering conformance (Tier 2 rejects suspended BEFORE Tier 3 resolver consulted). Modified: - src/adcp/decisioning/handler.py — _enforce_brand_authorization helper; PlatformHandler accepts brand_authorization_gate; _resolve_account invokes the gate after accounts.resolve. - src/adcp/decisioning/serve.py — accepts the two opt-in kwargs on both create_adcp_server_from_platform and serve, bundles them into BrandAuthorizationGate, threads to PlatformHandler. Boot validation on partial wiring. Tests: 11 new (test_decisioning_brand_authz_dispatch). Full impacted surface (1244 tests across decision/buyer_agent/brand/registry/dispatch keyword grep) remains green. ruff + mypy clean. Deferred to issue #776: plumbing the JWKS-source discriminant (brand.json walk vs publisher pin) through BrandJsonJwksResolver so the verifier path can invoke check_key_origin_consistency with the carve-out per spec #3690 step 7. Verifier-side integration, separate concern from this dispatch-layer gate. Closes #350. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
LGTM. Closes the v3-identity roadmap cleanly. Follow-ups below.
The dispatch-layer composition is right. Tier 2 → accounts.resolve → Tier 3 ordering is pinned by the three-tier conformance test; the gate is a no-op when Tier 2 isn't wired (subject-less authorization has nothing to authorize); partial wiring fails closed at boot via the XOR check. The BrandAuthorizationGate frozen-bundle pattern makes "both or neither" unrepresentable past the seam — right shape.
The cross-tenant onboarding-oracle defense holds: _denied_message at src/adcp/decisioning/handler.py:546-551 (Tier 2) and :689-694 (Tier 3) are byte-identical, details is omitted on both denial paths, recovery="correctable" matches the spec's enumMetadata for PERMISSION_DENIED. The verifier-side request_signature_brand_* codes correctly stay out of this dispatch-layer gate — those belong to #776.
Things I checked
- Dispatch ordering at
handler.py:1232→:1247→:1270-1279._prime_auth_contextstashes the resolved buyer-agent onctx.metadata[_BUYER_AGENT_METADATA_KEY];accounts.resolveruns; Tier 3 reads buyer_agent back fromctx.metadata. Reads after writes, correct. - Cross-tier message-byte parity: Tier 2 at
handler.py:546-551and Tier 3 at:689-694are verbatim identical strings, bothrecovery="correctable", both omitdetails. Pinned. - Boot XOR at
src/adcp/decisioning/serve.py:340-347.(brand_authz_resolver is None) != (brand_identity_resolver is None)is symmetric; diagnostic names both kwargs. inspect.isawaitableathandler.py:669is the correct check overasyncio.iscoroutine— covers async-def,asyncio.Future, and custom__await__-implementing awaitables. The extractor signature declaresAwaitable[...], soisawaitableis the right discriminator.- Public-API hygiene:
BrandIdentity,BrandIdentityResolver,BrandAuthorizationGateare NOT re-exported fromsrc/adcp/decisioning/__init__.py(grep-verified). Adopters import fromadcp.decisioning.brand_authz_gatedirectly. Conventional-commit prefixfeat(decisioning):is correct — additive opt-in kwargs, non-breaking. - Test-plan honesty: the "downstream import smoke / new symbols not re-exported" item is satisfied (verified). The "message-byte-identity property" item is satisfied by the byte-equality check above.
Follow-ups (non-blocking — file as issues)
agent_typeplumbing tois_authorized.handler.py:677-681calls the resolver withoutagent_type=.BuyerAgentatsrc/adcp/decisioning/registry.py:140doesn't carry the field, so the framework has no source to plumb it from today. Single-role brand.json entries work; a brand.json that lists the sameagent_urlunder multipletypes (a sales agent + a creative agent at the same endpoint) resolves asagent_ambiguousin_find_listed_agentsand fails closed. Fail-closed is the correct posture for the gap, but multi-role adopters will see false denials untilagent_typeis plumbed (either as aBuyerAgentextension or off the wire pinhole). Track separately.- Boot warning when Tier 3 is wired without Tier 2.
handler.py:665-666returns immediately whenbuyer_agent is None. The docstring legitimizes the read-only-audit-path use case, but the same code path silently disables the gate for misconfigured adopters who forgotbuyer_agent_registry=. A one-shotUserWarningatserve.py:349would catch the misconfig without breaking the audit-path intent. - Extractor exception bypasses the timing budget.
gate.extract_identity(...)runs athandler.py:668BEFOREPermissionDeniedBudget()is constructed at:676. An adopter extractor that raises on cache-miss-vs-hit (network exception, missing-key onaccount.metadata) leaks its own latency to the wire — not absorbed into the budget. Adopters with sync metadata lookups pay nothing; adopters with remote extractors are exposed. Either wrap the extractor call intry/exceptinside the budget window, or hoist the budget above the extractor and pay the no-brand-skip latency floor on every request. Track separately; theagent_typeissue feels like the same follow-up. - Extract
_denied_messageto a module constant. The byte-equality property athandler.py:546-551vs:689-694is load-bearing for the onboarding-oracle defense; the string is duplicated verbatim. A module constant plus anassert tier2_msg == tier3_msgtest intests/test_decisioning_brand_authz_dispatch.pywould pin the invariant against future drift. Notable that the comment block above each site says "MUST be identical" but the strings are hand-copied. - Test coverage for extractor-raises and resolver-raises. The 11 tests cover the happy paths and the documented skip paths; neither exception-on-the-edge case is exercised. Add once.
Minor nits (non-blocking)
- Hoist
import inspectout of_enforce_brand_authorization.handler.py:660does a per-call import on the hot dispatch path. Same critique applies to the localfrom adcp.decisioning._permission_denied_budget import PermissionDeniedBudgetandfrom adcp.decisioning.types import AdcpErrortwo lines down — Tier 2's helper at:506-512already does the same dance for consistency, but neither needs to.
LGTM. Follow-ups noted.
github-code-quality flagged three unused-import findings on PR #785: 1. ``brand_authz_gate.py`` — ``BuyerAgent`` and ``Account`` imports in the ``if TYPE_CHECKING:`` block. They ARE referenced — but only inside the string-quoted ForwardRefs of the ``BrandIdentityResolver`` ``Callable`` alias. ``from __future__ import annotations`` defers annotations only; the ``Callable[...]`` subscription is a module-load expression that needs the names resolvable at that moment. The string-quoting worked at runtime but the linter (correctly) couldn't see the use. Fix: promote both imports out of ``TYPE_CHECKING`` to module-level and drop the string quotes from the Callable alias. Confirmed no circular-import risk — neither ``adcp.decisioning.registry`` nor ``adcp.decisioning.types`` imports from ``brand_authz_gate``. 2. ``serve.py`` — ``BrandAuthorizationGate`` import in ``if TYPE_CHECKING:`` was genuinely unused. The function annotates a local variable with the name, and the deferred-annotation + later-in-block runtime ``from ... import`` resolve via the lazy import; mypy is happy without the top-level reference. Removed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
LGTM. Follow-ups noted below — the headline timing claim holds, but the defense is narrower than the docstring suggests and the byte-identity invariant is now maintained by copy-paste.
The gate is wired the right way: _prime_auth_context → _resolve_buyer_agent (rejects suspended/blocked at Tier 2) → accounts.resolve → _enforce_brand_authorization. The XOR boot validation in serve.py:337-344 fails closed at startup rather than at request time. Conventional-commit prefix is feat:, new kwargs are keyword-only with None defaults — non-breaking. code-reviewer flagged the budget placement and the denial-string duplication; security-reviewer confirmed Medium severity on the timing posture (no auth bypass, no credential leak); ad-tech-protocol-expert: sound-with-caveats — denial code, recovery value, ordering all sound, one field-name drift.
Things I checked
- Byte-identity of the denial message.
handler.py:547-551andhandler.py:689-694are character-identical. Verified via grep — also lives once atregistry_cache.py:114. The cross-tenant message-as-side-channel clamp holds today. - Tier 2 → Tier 3 ordering.
_resolve_accountathandler.py:1232calls_prime_auth_contextfirst (which calls_resolve_buyer_agentat L443 — that raisesAGENT_SUSPENDED/AGENT_BLOCKEDbefore returning). Only after that does it callaccounts.resolveand only after that the new gate at L1270.test_three_tier_chain_orders_tier2_before_tier3(test file L512-L560) pins the ordering. A suspended agent cannot reach the Tier 3 resolver. - XOR boot check.
serve.py:337—(brand_authz_resolver is None) != (brand_identity_resolver is None)is the right shape. Partial wiring raises with a specific diagnostic. - Buyer-agent metadata read.
handler.py:1271-1273reads_BUYER_AGENT_METADATA_KEYfromctx.metadata— the same key_prime_auth_contextwrites at L1198. Cast is honest. - Public surface. New kwargs are additive on
serveandcreate_adcp_server_from_platform. No removed/renamed exports.BrandAuthorizationGateis internal-only (built byserve());BrandIdentityis reachable viaadcp.decisioning.brand_authz_gate— adopter-instantiable, just at a longer path. - Async-extractor handling.
inspect.isawaitableathandler.py:669is the right check for theBrandIdentity | None | Awaitable[BrandIdentity | None]union. - PR description test plan. All three checkboxes unchecked, including
[ ] Argus AI reviewer pass on the timing-oracle posture and the message-byte-identity property— that is what this review is. Message-byte identity passes. Timing-oracle posture: see Follow-up 1.
Follow-ups (non-blocking — file as issues)
-
Hoist
PermissionDeniedBudgetto function entry in_enforce_brand_authorization. Today the budget is constructed athandler.py:676— afterextract_identityruns and after theidentity is Noneshort-circuit. The headline claim (cache-hit-authorized vs cache-hit-rejected, both fast, indistinguishable) is still delivered because the budget bracketed the resolver call. ButPermissionDeniedBudget.__doc__(L100-L106) says "Construct at function entry... measured from the construction site, not from the branch site, so I/O latency variance between branches is absorbed into the budget." The Tier 2 reference athandler.py:514constructs the budget as the first statement after local imports — that pattern absorbs registry I/O exception timing too. Tier 3 has two leaks the Tier 2 placement would close: (a) variance in an asyncextract_identity(the PR docs explicitly mention "adopters fetching brand identity from a remote registry") is not absorbed, and (b) a resolver exception propagates withoutawait budget.enforce()— DNS-fail vs HTTP-404 vs malformed-JSON timings on attacker-influenced brand domains are distinguishable. Fix: movebudget = PermissionDeniedBudget()to just afterif buyer_agent is None: return, and wrap theis_authorizedcall in atry/exceptthat enforces before re-raising. Mechanical change. -
Extract
_denied_messageto a module-level constant. It now lives byte-for-byte at three sites:handler.py:547-551,handler.py:689-694,registry_cache.py:114. The Tier 2 comment at L540-L545 calls this property "MUST be identical" — load-bearing for cross-tenant safety. Maintaining a safety-critical wire invariant by copy-paste is the kind of thing that quietly drifts the third time someone tightens the wording. Hoist to a single_DENIED_MESSAGEnear_BUYER_AGENT_METADATA_KEYand reference from all three sites. -
Rename
BrandIdentity.id→brand_idfor wire-shape parity.BrandAuthorizationResolver.is_authorizedtakesbrand_id=(signing/brand_authz.py:108); wireAccountReference.brandandbrand.jsonschemas usebrand_idas the field name. The dataclass fieldidis the onlyid-instead-of-brand_idshape in this code path — athandler.py:680we passbrand_id=identity.idand the cognitive load is on the reader. Same wire-bytes, friendlier surface. -
Boot warning when Tier 3 is wired without Tier 2.
handler.py:665-666returns silently whenbuyer_agent is None. Documented intent ("read-only audit paths"), but an adopter who wiresbrand_authz_resolverexpecting it to gate without also wiringbuyer_agent_registrygets a silent no-op on every request. Same posture as thecompliance_testingadvertisement warning inserve.pywould be the right shape:warnings.warn("Tier 3 brand-authz wired without a buyer_agent_registry — gate is a no-op on every request", UserWarning). -
Resolver Protocol
agent_typekwarg is unused.signing/brand_authz.py:108-118declaresagent_type: BrandAgentType | None = None.handler.py:677-681never passes it. Forecloses on per-type gating (e.g.,media_buyagents vssignalsagents) untilBrandIdentitygrows the field and the gate plumbs it. Worth at least a note onBrandIdentityrecording the deliberate omission.
Minor nits (non-blocking)
-
Lift function-local imports.
handler.py:660-663—inspect,PermissionDeniedBudget,AdcpErrorimported inside_enforce_brand_authorization. No circular-import risk against any of the three. Module-level matches the rest of the file's style and trims the per-request work. -
Docstring overstates spec ordering.
brand_authz_gate.py:9-15andhandler.py:609-613describe Tier-2-before-Tier-3 as a spec MUST. It's a framework-design invariant — ADCP #3690 defines what each tier checks, not the within-request ordering. The invariant is right; the citation isn't. -
BrandIdentityhas no field validation. Plain@dataclass(frozen=True). ReferenceBrandJsonAuthorizationResolverfail-closes on bad domains viahost_from, but custom adopter resolvers may not. A__post_init__rejecting empty / IP-literal domains is defense in depth. Skip if you'd rather push that constraint downstream.
Approving on the strength of the wire-byte identity holding, the Tier-2-before-Tier-3 ordering pinned by test, and the boot-time XOR check. Land the budget hoist next.
Summary
Final stage of issue #350 — closes the v3-identity roadmap by wiring the
BrandAuthorizationResolverfrom PR #770 into the framework's dispatch path. Adopters can now opt into per-brand authorization via two newserve()kwargs, matching the opt-in shape of Tier 2'sbuyer_agent_registry.After this lands, all three v3 identity tiers are framework-enforced when the adopter wires them:
verify_starlette_request+BrandJsonJwksResolver(PR #770)serve(buyer_agent_registry=...)(shipped earlier)serve(brand_authz_resolver=..., brand_identity_resolver=...)(this PR)Surface
Both must be wired together. Partial wiring raises
ValueErrorat boot — a resolver without an extractor has no brand to check; an extractor without a resolver has nothing to do.Dispatch sequence
The gate is a no-op when no
BuyerAgenthas been resolved (Tier 2 not wired) — brand authorization without a subject identity has nothing to authorize.Denial surface
PERMISSION_DENIEDwithrecovery="correctable", identical message bytes to the Tier 2 unrecognized-agent rejection so the wire-levelerror.messageis not a side channel discriminating the two gates.detailsdefaults to{}(omit-on-unestablished-identity rule, same as Tier 2).The spec-shape codes (
request_signature_brand_origin_mismatch/_agent_not_in_brand_json) belong to the verifier-side wire path — issue #776 will plumb theBrandJsonJwksResolversource discriminant through to the verifier socheck_key_origin_consistency(landed in PR #775) can be invoked with the publisher-pin carve-out per spec #3690 step 7. That's verifier-side integration, separate concern from this dispatch-layer gate.Timing-oracle defense
Reuses the
PermissionDeniedBudgetfrom Tier 2. The brand.json fetch path's natural variance (cache hit vs miss vs stale-on-error) is larger than the registry-lookup variance Tier 2 absorbs, so the budget here is a floor — it prevents the cache-hit-authorized vs cache-hit-rejected paths from leaking timing-distinguishable decisions on the happy-cache path (the common case).What's in this PR
src/adcp/decisioning/brand_authz_gate.py(new)BrandIdentitydataclass (domain + optional id),BrandIdentityResolvercallable type (sync OR async),BrandAuthorizationGatefrozen bundle pairing (resolver, extractor) atomicallysrc/adcp/decisioning/handler.py_enforce_brand_authorizationhelper next to_resolve_buyer_agent; PlatformHandler acceptsbrand_authorization_gate;_resolve_accountinvokes the gate afteraccounts.resolvesrc/adcp/decisioning/serve.pycreate_adcp_server_from_platformandserve. Bundles intoBrandAuthorizationGate. Boot validation raisesValueErroron partial wiringtests/test_decisioning_brand_authz_dispatch.py(new)Tests
11 new tests covering:
ValueError; neither wired is back-compat.brand_id: propagation through to resolver.PERMISSION_DENIEDwith the cross-tenant-safe message; platform method does NOT run.details == {}: omit-on-unestablished-identity per Tier 2 parity.Full impacted surface (1244 tests across
decision/buyer_agent/brand/registry/dispatchkeyword grep) remains green. ruff + mypy clean.What's deferred (not blocking)
BrandJsonJwksResolverso the verifier path can invokecheck_key_origin_consistencywith the publisher-pin carve-out per spec #3690 step 7. Verifier-side integration, separate concern from this dispatch-layer gate.idna(IDNA 2008) across signing/ #777 — package-wide IDNA 2003 → IDNA 2008 migration across the fourhost.encode("idna")callsites. Separate spec-conformance pass.Closes #350.
Test plan
adcp.decisioning— by design, they're framework-internal until adopters need them)🤖 Generated with Claude Code