Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/proposals/v3-identity-bundle-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@ These compose. (1) proves an agent's *cryptographic identity*; (2)
asserts the *commercial relationship*; (3) verifies *per-brand
authorization*. All three checks gate a request.

**Status note:** code names below (`AGENT_SUSPENDED`, `AGENT_BLOCKED`,
`REQUEST_AUTH_UNRECOGNIZED_AGENT`, `INVALID_BILLING_MODEL`) were
superseded in PR #393 by the spec-conformant `PERMISSION_DENIED` /
`BILLING_NOT_PERMITTED_FOR_AGENT` shapes. See the test suite in
`tests/test_tier2_spec_conformance.py` for current behavior.

**Status:** RFC, awaiting review.
**Companions:**
- JS `BuyerAgentRegistry` design — [adcp-client#1269](https://github.com/adcontextprotocol/adcp-client/issues/1269)
Expand Down
6 changes: 5 additions & 1 deletion examples/buyer_agent_registry_sqlalchemy.py
Original file line number Diff line number Diff line change
Expand Up @@ -368,7 +368,11 @@ def session_factory() -> Session:
print("\n== suspended agent ==")
suspended = await signing.resolve_by_agent_url("https://suspended-buyer.example/")
print(f" status: {suspended.status!r}")
print(" → framework dispatch raises AdcpError(code='AGENT_SUSPENDED')")
print(
" → framework dispatch raises "
"AdcpError(code='PERMISSION_DENIED', "
"details={'scope': 'agent', 'status': 'suspended', ...})"
)


if __name__ == "__main__":
Expand Down
5 changes: 3 additions & 2 deletions examples/v3_reference_seller/src/buyer_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,9 @@ class TenantScopedBuyerAgentRegistry:
Resolves to ``None`` when the request has no tenant context
(i.e., :func:`current_tenant` returns ``None`` because the
middleware bypassed routing or the host wasn't registered) — the
framework's dispatch then rejects with
``REQUEST_AUTH_UNRECOGNIZED_AGENT``.
framework's dispatch then rejects with ``PERMISSION_DENIED``
(with ``details`` omitted so the unrecognized-agent path is
wire-indistinguishable from a recognized-but-denied response).

Implements both methods of the
:class:`adcp.decisioning.BuyerAgentRegistry` Protocol so the
Expand Down
4 changes: 3 additions & 1 deletion examples/v3_reference_seller/tests/test_smoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,9 @@ def scalar_one_or_none(self):
async def test_buyer_registry_returns_none_without_tenant() -> None:
"""Without a tenant context (ContextVar unset), the registry
returns None — the framework dispatch then rejects with
REQUEST_AUTH_UNRECOGNIZED_AGENT."""
PERMISSION_DENIED (with no ``details`` so the unrecognized-agent
path is wire-indistinguishable from a recognized-but-denied
response)."""
from src.buyer_registry import TenantScopedBuyerAgentRegistry

from adcp.decisioning import ApiKeyCredential
Expand Down
4 changes: 2 additions & 2 deletions src/adcp/decisioning/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,8 @@ class AuthInfo:
``credential=``.
* **4.5.0** — synthesis is removed; flat-field-only construction
stops auto-populating ``credential``, and the registry dispatch
will reject the request with
``REQUEST_AUTH_UNRECOGNIZED_AGENT``. Adopters must construct
will reject the request with ``PERMISSION_DENIED``. Adopters
must construct
the typed credential explicitly:
``AuthInfo(credential=ApiKeyCredential(kind="api_key", key_id=...))``
or use the bundled signed-request verifier middleware.
Expand Down
140 changes: 88 additions & 52 deletions src/adcp/decisioning/handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -331,14 +331,41 @@ async def _resolve_buyer_agent(
* :class:`ApiKeyCredential` / :class:`OAuthCredential` →
:meth:`BuyerAgentRegistry.resolve_by_credential`.
* No credential at all (unauthenticated dev fixture, ``derived``
auth) → ``REQUEST_AUTH_UNRECOGNIZED_AGENT``. Adopters running
a registry have implicitly opted out of unauthenticated traffic.

:raises AdcpError: ``REQUEST_AUTH_UNRECOGNIZED_AGENT`` (registry
miss / no credential), ``AGENT_SUSPENDED`` (status=suspended),
or ``AGENT_BLOCKED`` (status=blocked). All ``recovery=terminal``
— the buyer cannot retry their way out of a commercial-state
rejection.
auth) → ``PERMISSION_DENIED`` (no ``details.scope``). Adopters
running a registry have implicitly opted out of unauthenticated
traffic.

All four denial paths surface as ``code="PERMISSION_DENIED"`` to
match the spec enum and prevent the cross-tenant onboarding-oracle
risk: an attacker watching the wire MUST NOT be able to
distinguish "this agent_url is unrecognized at this seller" from
"this agent_url is recognized but currently denied". The
discriminator is in ``details``:

* recognized + suspended →
``details = {scope: "agent", status: "suspended", agent_url: ...}``
* recognized + blocked →
``details = {scope: "agent", status: "blocked", agent_url: ...}``
* unrecognized (registry miss / no credential / unknown status) →
``details`` OMITTED — scope MUST NOT be set on the unestablished-
identity path (omit-on-unestablished-identity rule).

Note on parity: the *latency / headers / side-effects* parity
contract between the recognized and unrecognized paths is tracked
as a follow-up — the eager-raise pattern below still completes the
unrecognized path on a different code path than the recognized
one. Renaming closes the wire-code mismatch; folding all four
paths through a common emit point with deliberate latency padding
and identical audit/metric side-effects is the next step.

:raises AdcpError: ``PERMISSION_DENIED`` (all four denial paths).
Recovery is ``correctable`` per the spec's ``enumMetadata``
for ``PERMISSION_DENIED``. The wire-level recovery hint is
independent of the resolution channel: the buyer cannot
auto-retry a commercial-identity rejection, but the
``details.scope == "agent"`` discriminator (when present) is
the signal callers surface to a human operator rather than
loop on the request.
"""
from adcp.decisioning.registry import (
ApiKeyCredential,
Expand Down Expand Up @@ -372,62 +399,68 @@ async def _resolve_buyer_agent(
recovery="terminal",
)

# Generic message used on every denial path — MUST be identical
# across the unrecognized and the recognized-but-denied paths so
# the wire-level error.message is not itself a side channel
# leaking which agent_urls are onboarded with which sellers. The
# discriminator (when present at all) is in details, only on the
# recognized-but-denied paths.
_denied_message = (
"Buyer agent is not authorized for this seller. The seller's "
"commercial allowlist did not authorize this credential. "
"Resolve out-of-band via the seller's onboarding contact; this "
"is not a request-side error the buyer can correct."
)

if agent is None:
# Registry miss / no credential. ``details`` is OMITTED — the
# spec's omit-on-unestablished-identity rule says the
# unrecognized-agent path MUST be indistinguishable on the
# wire from the recognized-but-denied path, and ``scope``
# would itself be the discriminator.
raise AdcpError(
"REQUEST_AUTH_UNRECOGNIZED_AGENT",
message=(
"BuyerAgentRegistry returned no match for the request's "
"credential. The registry is the seller's commercial "
"allowlist — adopters reject auth that's cryptographically "
"valid but not commercially recognized (no onboarding row, "
"revoked, or wrong credential kind for the registry's "
"posture). Check that the agent has been onboarded into the "
"registry's backing store."
),
recovery="terminal",
"PERMISSION_DENIED",
message=_denied_message,
recovery="correctable",
)

if agent.status == "active":
return agent
if agent.status == "suspended":
raise AdcpError(
"AGENT_SUSPENDED",
message=(
f"Buyer agent {agent.agent_url!r} is suspended. Suspension "
"is a temporary commercial pause (credit, compliance review, "
"ops hold) — the seller restores it via their durable "
"store. Retry once the seller restores the agent; escalate "
"through the account contact if the pause is unexpected."
),
recovery="transient",
details={"agent_url": agent.agent_url, "status": agent.status},
"PERMISSION_DENIED",
message=_denied_message,
recovery="correctable",
details={
"scope": "agent",
"status": "suspended",
"agent_url": agent.agent_url,
},
)
if agent.status == "blocked":
raise AdcpError(
"AGENT_BLOCKED",
message=(
f"Buyer agent {agent.agent_url!r} is blocked. Blocked is "
"a hard cutoff (terms violation, fraud, enforcement) — "
"no retry path. Buyer must contact the seller directly."
),
recovery="terminal",
details={"agent_url": agent.agent_url, "status": agent.status},
"PERMISSION_DENIED",
message=_denied_message,
recovery="correctable",
details={
"scope": "agent",
"status": "blocked",
"agent_url": agent.agent_url,
},
)
# Default-reject any non-active status the framework doesn't
# recognize (typo, future enum value, adopter-custom string). A
# silent fall-through to "active" would leak commercial state
# past the gate.
# past the gate. ``details`` is OMITTED for the same reason as
# the registry-miss branch — the framework treats unknown statuses
# as the unrecognized-identity path (the row is in the registry
# but the framework cannot interpret it, which is operationally
# equivalent to "not authorized" without a defensible status
# claim to project on the wire).
raise AdcpError(
"REQUEST_AUTH_UNRECOGNIZED_AGENT",
message=(
f"Buyer agent {agent.agent_url!r} has unrecognized status "
f"{agent.status!r}. The framework only treats ``active`` as "
"live; ``suspended`` / ``blocked`` raise their own structured "
"errors. Unknown statuses are rejected by default to prevent "
"silent fall-through past the commercial-identity gate."
),
recovery="terminal",
details={"agent_url": agent.agent_url, "status": agent.status},
"PERMISSION_DENIED",
message=_denied_message,
recovery="correctable",
)


Expand Down Expand Up @@ -627,10 +660,13 @@ async def _resolve_account(
BEFORE calling ``AccountStore.resolve`` and stashes the result
on ``ctx.metadata['adcp.buyer_agent']`` for :meth:`_build_ctx`
to read into the typed :class:`RequestContext`. Suspended /
blocked agents are rejected here with structured error codes
— buyers see ``AGENT_SUSPENDED`` / ``AGENT_BLOCKED`` /
``REQUEST_AUTH_UNRECOGNIZED_AGENT`` instead of the registry
miss leaking into the AccountStore as ``ACCOUNT_NOT_FOUND``.
blocked / unrecognized agents are rejected here with
``PERMISSION_DENIED`` (recognized-but-denied paths carry
``details.scope="agent"`` + ``details.status``; the
unrecognized-agent path omits ``details`` so the wire shape
does not enumerate which ``agent_url``s are onboarded with
this seller) instead of the registry miss leaking into the
AccountStore as ``ACCOUNT_NOT_FOUND``.
"""
auth_info = self._extract_auth_info(ctx)
if self._buyer_agent_registry is not None:
Expand Down
4 changes: 3 additions & 1 deletion src/adcp/decisioning/pg/buyer_agent_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,9 @@ async def resolve_by_agent_url(self, agent_url: str) -> BuyerAgent | None:
The framework has already validated the RFC 9421 signature
before this point — the registry's only job is the commercial
lookup. Returns ``None`` when the agent isn't recognized;
the framework converts that to ``REQUEST_AUTH_UNRECOGNIZED_AGENT``.
the framework converts that to ``PERMISSION_DENIED`` (with
``details`` omitted so the unrecognized-agent path is
wire-indistinguishable from recognized-but-denied).
"""
return await asyncio.to_thread(self._sync_lookup_by_agent_url, agent_url)

Expand Down
65 changes: 42 additions & 23 deletions src/adcp/decisioning/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@
Today the framework can't enforce that and the resulting commercial
drift is invisible to buyers. With :class:`BuyerAgent.billing_capabilities`
the framework rejects mismatched ``billing`` values with a structured
:class:`adcp.decisioning.AdcpError` ``code="INVALID_BILLING_MODEL"``.
:class:`adcp.decisioning.AdcpError`
``code="BILLING_NOT_PERMITTED_FOR_AGENT"``.

Per :issue:`adcp-client#1269` and the v3-identity-bundle RFC, the
registry is *durable* infrastructure — it stays useful even after
Expand Down Expand Up @@ -197,8 +198,11 @@ class BuyerAgentRegistry(Protocol):
* :meth:`resolve_by_credential` for bearer / API-key / OAuth —
the adopter looks up against their existing key table.

Returning ``None`` rejects the request with
``REQUEST_AUTH_UNRECOGNIZED_AGENT``. Adopters typically construct
Returning ``None`` rejects the request with ``PERMISSION_DENIED``
(``details`` omitted — the unrecognized-agent path MUST be
indistinguishable on the wire from the recognized-but-denied path
to prevent cross-tenant onboarding enumeration). Adopters
typically construct
via the :func:`signing_only_registry` / :func:`bearer_only_registry`
/ :func:`mixed_registry` factories rather than implementing the
Protocol directly — the factories carry the posture choice in
Expand Down Expand Up @@ -286,7 +290,8 @@ def signing_only_registry(

Adopter supplies an async function that maps a verified
``agent_url`` to a :class:`BuyerAgent` (or ``None`` to reject).
Bearer traffic gets ``REQUEST_AUTH_UNRECOGNIZED_AGENT`` at the
Bearer traffic gets ``PERMISSION_DENIED`` (with ``details``
omitted — wire-indistinguishable from any other denial) at the
framework's dispatch layer — the registry deliberately doesn't
implement bearer lookup.
"""
Expand All @@ -301,8 +306,8 @@ def bearer_only_registry(
Adopter supplies an async function that maps an
:class:`ApiKeyCredential` or :class:`OAuthCredential` to a
:class:`BuyerAgent` (or ``None`` to reject). Signed traffic gets
``REQUEST_AUTH_UNRECOGNIZED_AGENT`` — adopt :func:`mixed_registry`
once signed onboarding is wired.
``PERMISSION_DENIED`` — adopt :func:`mixed_registry` once signed
onboarding is wired.
"""
return _BearerOnlyRegistry(_resolve_by_credential=resolve_by_credential)

Expand Down Expand Up @@ -332,39 +337,53 @@ def validate_billing_for_agent(
requested_billing: BillingMode,
agent: BuyerAgent,
) -> None:
"""Raise :class:`adcp.decisioning.AdcpError` ``INVALID_BILLING_MODEL``
when ``requested_billing`` is not in ``agent.billing_capabilities``.
"""Raise :class:`adcp.decisioning.AdcpError`
``BILLING_NOT_PERMITTED_FOR_AGENT`` when ``requested_billing`` is
not in ``agent.billing_capabilities``.

Called by the framework's ``sync_accounts`` shim before invoking
the platform method. Adopters needn't call this directly; the
framework enforces. Re-exported so platform methods that branch
on billing mode can short-circuit to the same structured error.

The wire ``details`` payload deliberately carries only
``rejected_billing`` (and an optional ``suggested_billing``) — it
MUST NOT carry the agent's full ``permitted_billing`` subset. The
full subset is the agent's commercial relationship with the
seller; surfacing it on every rejected request would let a
misconfigured buyer probe and exfiltrate the matrix one mode at
a time.
"""
if requested_billing in agent.billing_capabilities:
return
# Local import to avoid a cycle (types.py → registry.py would
# close on import-load order).
from adcp.decisioning.types import AdcpError

# Suggest a single permitted mode (deterministic — the
# alphabetically-first permitted mode) when the agent has any
# capability at all. We do NOT enumerate the full set; suggesting
# one mode is sufficient remediation hint without leaking the
# subset shape on every failed request.
suggested = sorted(agent.billing_capabilities)[0] if agent.billing_capabilities else None
details: dict[str, Any] = {"rejected_billing": requested_billing}
if suggested is not None:
details["suggested_billing"] = suggested

raise AdcpError(
"INVALID_BILLING_MODEL",
"BILLING_NOT_PERMITTED_FOR_AGENT",
message=(
f"Buyer agent '{agent.agent_url}' is not authorized for "
f"billing={requested_billing!r}; permitted modes are "
f"{sorted(agent.billing_capabilities)!r}. Common cause: "
"this agent has no payments relationship with the seller "
"(passthrough only) — accounts under this agent must be "
"operator-billed. Sellers extending the agent's billing "
"capabilities update the BuyerAgent.billing_capabilities "
"frozenset in their durable store."
f"Buyer agent {agent.agent_url!r} is not authorized for "
f"billing={requested_billing!r}. Common cause: this agent "
"has no payments relationship with the seller (passthrough "
"only) — accounts under this agent must be operator-billed. "
"Sellers extending the agent's billing capabilities update "
"the BuyerAgent.billing_capabilities frozenset in their "
"durable store."
),
field="billing",
recovery="terminal",
details={
"agent_url": agent.agent_url,
"requested_billing": requested_billing,
"permitted_billing": sorted(agent.billing_capabilities),
},
recovery="correctable",
details=details,
)


Expand Down
15 changes: 11 additions & 4 deletions src/adcp/decisioning/serve.py
Original file line number Diff line number Diff line change
Expand Up @@ -147,9 +147,12 @@ def create_adcp_server_from_platform(
identity layer. When wired, the framework calls the registry
BEFORE :meth:`AccountStore.resolve` to gate every request on
the seller's commercial allowlist. Suspended / blocked /
unknown agents are rejected with structured
``AGENT_SUSPENDED`` / ``AGENT_BLOCKED`` /
``REQUEST_AUTH_UNRECOGNIZED_AGENT`` errors. The resolved
unrecognized agents are rejected with structured
``PERMISSION_DENIED`` errors (recognized-but-denied paths
carry ``details.scope="agent"`` + ``details.status``; the
unrecognized-agent path omits ``details`` so the wire shape
does not enumerate which ``agent_url``s are onboarded with
this seller). The resolved
:class:`adcp.decisioning.BuyerAgent` is threaded onto
:attr:`RequestContext.buyer_agent` so platform methods can
read commercial context (billing capabilities, default terms,
Expand Down Expand Up @@ -351,7 +354,11 @@ def serve(
spec-compliance storyboards) pass ``True``.
:param serve_kwargs: Forwarded to :func:`adcp.server.serve`. Use
for ``host``, ``port``, ``transport``, ``test_controller``,
``context_factory``, ``middleware``, etc.
``context_factory``, ``middleware``, ``validation``, etc.
Pass ``validation=ValidationHookConfig(requests="strict",
responses="strict")`` to enable schema-driven request/response
validation against the bundled AdCP JSON schemas — sellers who
want their server to enforce wire conformance turn it on here.
"""
# Local import to avoid a circular at module-load time. Adopter
# serves never run during foundation imports anyway.
Expand Down
Loading
Loading