Skip to content

feat(decisioning): Tier 3 brand-authz dispatch gate (#350 stage 5, closes #350)#785

Merged
bokelley merged 3 commits into
mainfrom
bokelley/issue-350-stage-5
May 22, 2026
Merged

feat(decisioning): Tier 3 brand-authz dispatch gate (#350 stage 5, closes #350)#785
bokelley merged 3 commits into
mainfrom
bokelley/issue-350-stage-5

Conversation

@bokelley
Copy link
Copy Markdown
Contributor

Summary

Final stage of issue #350 — closes the v3-identity roadmap by wiring the BrandAuthorizationResolver from PR #770 into the framework's dispatch path. Adopters can now opt into per-brand authorization via two new serve() kwargs, matching the opt-in shape of Tier 2's buyer_agent_registry.

After this lands, all three v3 identity tiers are framework-enforced when the adopter wires them:

Tier What Surface
1 Cryptographic identity (signed request → JWKS via brand.json) verify_starlette_request + BrandJsonJwksResolver (PR #770)
2 Commercial recognition (BuyerAgent registry) serve(buyer_agent_registry=...) (shipped earlier)
3 Per-brand authorization (agent ↔ brand binding) serve(brand_authz_resolver=..., brand_identity_resolver=...) (this PR)

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.

Dispatch sequence

inbound request
  → Tier 2: registry.resolve_by_(agent_url|credential) → BuyerAgent | None
       ── suspended/blocked → AGENT_SUSPENDED / AGENT_BLOCKED (short-circuit)
  → AccountStore.resolve(ref, auth_info) → Account
  → NEW: Tier 3 brand-authz gate
       ── extract_identity(account, buyer_agent) → BrandIdentity | None
       ── (if None: skip — adopter says no brand to bind against)
       ── resolver.is_authorized(agent_url, brand_domain, brand_id?) → bool
       ── (if False: PERMISSION_DENIED, identical message bytes to Tier 2)
  → 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).

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 the BrandJsonJwksResolver source discriminant through to the verifier so check_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 PermissionDeniedBudget from 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

File Change
src/adcp/decisioning/brand_authz_gate.py (new) BrandIdentity dataclass (domain + optional id), BrandIdentityResolver callable type (sync OR async), BrandAuthorizationGate frozen bundle pairing (resolver, extractor) atomically
src/adcp/decisioning/handler.py _enforce_brand_authorization helper next to _resolve_buyer_agent; PlatformHandler accepts brand_authorization_gate; _resolve_account invokes the gate after accounts.resolve
src/adcp/decisioning/serve.py New kwargs on both create_adcp_server_from_platform and serve. Bundles into BrandAuthorizationGate. Boot validation raises ValueError on partial wiring
tests/test_decisioning_brand_authz_dispatch.py (new) 11 tests

Tests

11 new tests covering:

  • Boot validation: resolver-only and extractor-only both raise ValueError; neither wired is back-compat.
  • Authorized path: extractor runs → resolver runs → platform method runs.
  • Authorized with brand_id: propagation through to resolver.
  • Denied path: rejection emits PERMISSION_DENIED with the cross-tenant-safe message; platform method does NOT run.
  • Denied details == {}: omit-on-unestablished-identity per Tier 2 parity.
  • No-buyer-agent skip: Tier 3 silently passes when Tier 2 isn't wired.
  • Extractor-returns-None skip: adopter signals "no brand to bind against".
  • Async extractor: framework awaits when the return is an awaitable.
  • Three-tier conformance: Tier 2 rejects a suspended agent BEFORE the Tier 3 resolver is consulted. Pins the ordering against future refactor.

Full impacted surface (1244 tests across decision/buyer_agent/brand/registry/dispatch keyword grep) remains green. ruff + mypy clean.

What's deferred (not blocking)

Closes #350.

Test plan

  • CI green on Python 3.10–3.13
  • Argus AI reviewer pass on the timing-oracle posture and the message-byte-identity property
  • Downstream import smoke (new symbols not re-exported from adcp.decisioning — by design, they're framework-internal until adopters need them)

🤖 Generated with Claude Code

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>
Comment thread src/adcp/decisioning/brand_authz_gate.py Fixed
Comment thread src/adcp/decisioning/brand_authz_gate.py Fixed
Comment thread src/adcp/decisioning/serve.py Fixed
aao-ipr-bot[bot]
aao-ipr-bot Bot previously approved these changes May 21, 2026
Copy link
Copy Markdown

@aao-ipr-bot aao-ipr-bot Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_context stashes the resolved buyer-agent on ctx.metadata[_BUYER_AGENT_METADATA_KEY]; accounts.resolve runs; Tier 3 reads buyer_agent back from ctx.metadata. Reads after writes, correct.
  • Cross-tier message-byte parity: Tier 2 at handler.py:546-551 and Tier 3 at :689-694 are verbatim identical strings, both recovery="correctable", both omit details. 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.isawaitable at handler.py:669 is the correct check over asyncio.iscoroutine — covers async-def, asyncio.Future, and custom __await__-implementing awaitables. The extractor signature declares Awaitable[...], so isawaitable is the right discriminator.
  • Public-API hygiene: BrandIdentity, BrandIdentityResolver, BrandAuthorizationGate are NOT re-exported from src/adcp/decisioning/__init__.py (grep-verified). Adopters import from adcp.decisioning.brand_authz_gate directly. Conventional-commit prefix feat(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_type plumbing to is_authorized. handler.py:677-681 calls the resolver without agent_type=. BuyerAgent at src/adcp/decisioning/registry.py:140 doesn'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 same agent_url under multiple types (a sales agent + a creative agent at the same endpoint) resolves as agent_ambiguous in _find_listed_agents and fails closed. Fail-closed is the correct posture for the gap, but multi-role adopters will see false denials until agent_type is plumbed (either as a BuyerAgent extension or off the wire pinhole). Track separately.
  • Boot warning when Tier 3 is wired without Tier 2. handler.py:665-666 returns immediately when buyer_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 forgot buyer_agent_registry=. A one-shot UserWarning at serve.py:349 would catch the misconfig without breaking the audit-path intent.
  • Extractor exception bypasses the timing budget. gate.extract_identity(...) runs at handler.py:668 BEFORE PermissionDeniedBudget() is constructed at :676. An adopter extractor that raises on cache-miss-vs-hit (network exception, missing-key on account.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 in try/except inside the budget window, or hoist the budget above the extractor and pay the no-brand-skip latency floor on every request. Track separately; the agent_type issue feels like the same follow-up.
  • Extract _denied_message to a module constant. The byte-equality property at handler.py:546-551 vs :689-694 is load-bearing for the onboarding-oracle defense; the string is duplicated verbatim. A module constant plus an assert tier2_msg == tier3_msg test in tests/test_decisioning_brand_authz_dispatch.py would 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)

  1. Hoist import inspect out of _enforce_brand_authorization. handler.py:660 does a per-call import on the hot dispatch path. Same critique applies to the local from adcp.decisioning._permission_denied_budget import PermissionDeniedBudget and from adcp.decisioning.types import AdcpError two lines down — Tier 2's helper at :506-512 already 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>
Copy link
Copy Markdown

@aao-ipr-bot aao-ipr-bot Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-551 and handler.py:689-694 are character-identical. Verified via grep — also lives once at registry_cache.py:114. The cross-tenant message-as-side-channel clamp holds today.
  • Tier 2 → Tier 3 ordering. _resolve_account at handler.py:1232 calls _prime_auth_context first (which calls _resolve_buyer_agent at L443 — that raises AGENT_SUSPENDED / AGENT_BLOCKED before returning). Only after that does it call accounts.resolve and 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-1273 reads _BUYER_AGENT_METADATA_KEY from ctx.metadata — the same key _prime_auth_context writes at L1198. Cast is honest.
  • Public surface. New kwargs are additive on serve and create_adcp_server_from_platform. No removed/renamed exports. BrandAuthorizationGate is internal-only (built by serve()); BrandIdentity is reachable via adcp.decisioning.brand_authz_gate — adopter-instantiable, just at a longer path.
  • Async-extractor handling. inspect.isawaitable at handler.py:669 is the right check for the BrandIdentity | 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)

  1. Hoist PermissionDeniedBudget to function entry in _enforce_brand_authorization. Today the budget is constructed at handler.py:676after extract_identity runs and after the identity is None short-circuit. The headline claim (cache-hit-authorized vs cache-hit-rejected, both fast, indistinguishable) is still delivered because the budget bracketed the resolver call. But PermissionDeniedBudget.__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 at handler.py:514 constructs 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 async extract_identity (the PR docs explicitly mention "adopters fetching brand identity from a remote registry") is not absorbed, and (b) a resolver exception propagates without await budget.enforce() — DNS-fail vs HTTP-404 vs malformed-JSON timings on attacker-influenced brand domains are distinguishable. Fix: move budget = PermissionDeniedBudget() to just after if buyer_agent is None: return, and wrap the is_authorized call in a try/except that enforces before re-raising. Mechanical change.

  2. Extract _denied_message to 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_MESSAGE near _BUYER_AGENT_METADATA_KEY and reference from all three sites.

  3. Rename BrandIdentity.idbrand_id for wire-shape parity. BrandAuthorizationResolver.is_authorized takes brand_id= (signing/brand_authz.py:108); wire AccountReference.brand and brand.json schemas use brand_id as the field name. The dataclass field id is the only id-instead-of-brand_id shape in this code path — at handler.py:680 we pass brand_id=identity.id and the cognitive load is on the reader. Same wire-bytes, friendlier surface.

  4. Boot warning when Tier 3 is wired without Tier 2. handler.py:665-666 returns silently when buyer_agent is None. Documented intent ("read-only audit paths"), but an adopter who wires brand_authz_resolver expecting it to gate without also wiring buyer_agent_registry gets a silent no-op on every request. Same posture as the compliance_testing advertisement warning in serve.py would 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).

  5. Resolver Protocol agent_type kwarg is unused. signing/brand_authz.py:108-118 declares agent_type: BrandAgentType | None = None. handler.py:677-681 never passes it. Forecloses on per-type gating (e.g., media_buy agents vs signals agents) until BrandIdentity grows the field and the gate plumbs it. Worth at least a note on BrandIdentity recording the deliberate omission.

Minor nits (non-blocking)

  1. Lift function-local imports. handler.py:660-663inspect, PermissionDeniedBudget, AdcpError imported 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.

  2. Docstring overstates spec ordering. brand_authz_gate.py:9-15 and handler.py:609-613 describe 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.

  3. BrandIdentity has no field validation. Plain @dataclass(frozen=True). Reference BrandJsonAuthorizationResolver fail-closes on bad domains via host_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.

@bokelley bokelley enabled auto-merge (squash) May 22, 2026 08:27
@bokelley bokelley merged commit 1ad6df9 into main May 22, 2026
23 checks passed
@bokelley bokelley deleted the bokelley/issue-350-stage-5 branch May 22, 2026 08:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(signing): BrandAuthorizationResolver — per-brand agent authorization (Tier 3, gated on ADCP #3690)

1 participant