Skip to content

Commit 12f8ffe

Browse files
bokelleyclaude
andauthored
feat(decisioning): emit AGENT_SUSPENDED / AGENT_BLOCKED dedicated codes (closes #409) (#748)
AdCP 3.1 (PR adcontextprotocol/adcp#3906, released as 3.1.0-beta.1) consolidates the 3.0.5 PERMISSION_DENIED + details.status placeholder for per-buyer-agent commercial-status rejections into dedicated codes AGENT_SUSPENDED and AGENT_BLOCKED (recovery="terminal", no details payload — the code itself is the discriminator, mirroring BILLING_NOT_PERMITTED_FOR_AGENT). Wire shape change in _resolve_buyer_agent: - recognized + suspended: PERMISSION_DENIED + details.status="suspended" + recovery="correctable" -> AGENT_SUSPENDED + recovery="terminal" - recognized + blocked: PERMISSION_DENIED + details.status="blocked" + recovery="correctable" -> AGENT_BLOCKED + recovery="terminal" - unrecognized paths (registry miss / no credential / unknown status): unchanged - still PERMISSION_DENIED with no details (cross-tenant onboarding-oracle clamp preserved). The recovery="correctable" -> "terminal" flip is the spec's corrective fix: the placeholder inherited PERMISSION_DENIED's correctable hint, which contradicted the no-retry MUST for commercial-status rejections. Uses the established KNOWN_NON_SPEC_CODES allowlist pattern (same precedent as BILLING_NOT_PERMITTED_FOR_AGENT) - ADCP_VERSION stays pinned at 3.0.7. Allowlist entries carry TODO-on-3.1-bundle-bump markers so they auto-drop when the SDK eventually bumps to 3.1. BREAKING (narrow): PermissionDeniedError(status=...) is removed. The status field is gone from error-details/agent-permission-denied.json in 3.1. Migrate to: raise AdcpError("AGENT_SUSPENDED", recovery="terminal") for per-agent commercial status, or PermissionDeniedError(scope="agent", reason="sandbox_only") for the remaining provisioning gate. Passing status= now raises TypeError with a migration message; an explicit **details guard prevents the kwarg from silently landing in error.details and emitting a wire-invalid response. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 35fdd47 commit 12f8ffe

8 files changed

Lines changed: 183 additions & 132 deletions

examples/buyer_agent_registry_sqlalchemy.py

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -368,11 +368,7 @@ def session_factory() -> Session:
368368
print("\n== suspended agent ==")
369369
suspended = await signing.resolve_by_agent_url("https://suspended-buyer.example/")
370370
print(f" status: {suspended.status!r}")
371-
print(
372-
" → framework dispatch raises "
373-
"AdcpError(code='PERMISSION_DENIED', "
374-
"details={'scope': 'agent', 'status': 'suspended', ...})"
375-
)
371+
print(" → framework dispatch raises " "AdcpError(code='AGENT_SUSPENDED', recovery='terminal')")
376372

377373

378374
if __name__ == "__main__":

src/adcp/decisioning/errors.py

Lines changed: 37 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -43,13 +43,20 @@ class PermissionDeniedError(AdcpError):
4343
requested action under the seller's policies, or a required signed
4444
credential is missing/invalid.
4545
46-
:param scope: When the gate is a per-agent provisioning constraint,
47-
set to ``'agent'`` (with ``status``); when billing-relationship,
48-
set to ``'billing'``. Sellers MUST emit ``scope='agent'`` only
49-
when buyer-agent identity has been established (signed-request
50-
derivation or credential-to-agent mapping); otherwise omit.
51-
:param status: When ``scope='agent'``, the per-agent state
52-
(``'sandbox_only'``, etc.) — see
46+
For per-agent commercial-status rejections (suspended / blocked),
47+
raise :class:`AdcpError` with code ``"AGENT_SUSPENDED"`` /
48+
``"AGENT_BLOCKED"`` directly — those are dedicated AdCP 3.1 codes
49+
with their own ``recovery="terminal"`` semantics, not flavors of
50+
``PERMISSION_DENIED``.
51+
52+
:param scope: When the gate is a per-agent non-status provisioning
53+
constraint, set to ``'agent'`` (with ``reason``); when
54+
billing-relationship, set to ``'billing'``. Sellers MUST emit
55+
``scope='agent'`` only when buyer-agent identity has been
56+
established (signed-request derivation or credential-to-agent
57+
mapping); otherwise omit.
58+
:param reason: When ``scope='agent'``, the registered provisioning
59+
gate that fired (``'sandbox_only'``, etc.) — see
5360
``error-details/agent-permission-denied.json``.
5461
:param message: Optional human-readable override of the default.
5562
:param details: Additional fields merged into ``error.details``.
@@ -59,17 +66,37 @@ def __init__(
5966
self,
6067
*,
6168
scope: Literal["agent", "billing"] | None = None,
62-
status: str | None = None,
69+
reason: str | None = None,
6370
message: str | None = None,
6471
field: str | None = None,
6572
suggestion: str | None = None,
6673
**details: Any,
6774
) -> None:
75+
# Hard-reject the AdCP 3.0.5 placeholder ``status=`` kwarg.
76+
# Without this, the kwarg would land in ``**details`` and emit
77+
# ``error.details.status`` — a field 3.1 removed from
78+
# ``agent-permission-denied.json``, which seller-side
79+
# schema-validating receivers would reject. For per-agent
80+
# commercial status, raise ``AdcpError`` with code
81+
# ``"AGENT_SUSPENDED"`` / ``"AGENT_BLOCKED"`` directly.
82+
if "status" in details:
83+
raise TypeError(
84+
"PermissionDeniedError.status= was removed in AdCP 3.1 "
85+
"(spec PR adcontextprotocol/adcp#3906). The "
86+
"placeholder details.status field is gone from "
87+
"error-details/agent-permission-denied.json. Migrate to: "
88+
"raise AdcpError('AGENT_SUSPENDED', recovery='terminal') "
89+
"or AdcpError('AGENT_BLOCKED', recovery='terminal') for "
90+
"per-agent commercial-status rejections, or pass "
91+
"reason='sandbox_only' for the remaining provisioning "
92+
"gate."
93+
)
94+
6895
merged_details: dict[str, Any] = dict(details)
6996
if scope is not None:
7097
merged_details["scope"] = scope
71-
if status is not None:
72-
merged_details["status"] = status
98+
if reason is not None:
99+
merged_details["reason"] = reason
73100

74101
super().__init__(
75102
"PERMISSION_DENIED",

src/adcp/decisioning/handler.py

Lines changed: 44 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -462,37 +462,39 @@ async def _resolve_buyer_agent(
462462
running a registry have implicitly opted out of unauthenticated
463463
traffic.
464464
465-
All four denial paths surface as ``code="PERMISSION_DENIED"`` to
466-
match the spec enum and prevent the cross-tenant onboarding-oracle
467-
risk: an attacker watching the wire MUST NOT be able to
468-
distinguish "this agent_url is unrecognized at this seller" from
469-
"this agent_url is recognized but currently denied". The
470-
discriminator is in ``details``:
471-
472-
* recognized + suspended →
473-
``details = {scope: "agent", status: "suspended", agent_url: ...}``
474-
* recognized + blocked →
475-
``details = {scope: "agent", status: "blocked", agent_url: ...}``
465+
Per-agent commercial-status rejections surface as dedicated codes
466+
(``AGENT_SUSPENDED`` / ``AGENT_BLOCKED``) per AdCP 3.1; the
467+
unrecognized-identity path surfaces as ``PERMISSION_DENIED`` with
468+
no ``details.scope``. This split prevents the cross-tenant
469+
onboarding-oracle risk on the unrecognized path (no discriminator
470+
that would let an attacker distinguish "this agent_url is
471+
unrecognized at this seller" from a credential-shaped failure
472+
against a known agent), while the dedicated codes carry their own
473+
discriminator without needing a ``details`` payload.
474+
475+
* recognized + suspended → ``code="AGENT_SUSPENDED"``,
476+
``recovery="terminal"``, no ``details`` payload.
477+
* recognized + blocked → ``code="AGENT_BLOCKED"``,
478+
``recovery="terminal"``, no ``details`` payload.
476479
* unrecognized (registry miss / no credential / unknown status) →
477-
``details`` OMITTED — scope MUST NOT be set on the unestablished-
478-
identity path (omit-on-unestablished-identity rule).
480+
``code="PERMISSION_DENIED"``, no ``details`` — scope MUST NOT be
481+
set on the unestablished-identity path
482+
(omit-on-unestablished-identity rule).
479483
480484
Note on parity: the *latency / headers / side-effects* parity
481485
contract between the recognized and unrecognized paths is tracked
482486
as a follow-up — the eager-raise pattern below still completes the
483487
unrecognized path on a different code path than the recognized
484-
one. Renaming closes the wire-code mismatch; folding all four
485-
paths through a common emit point with deliberate latency padding
486-
and identical audit/metric side-effects is the next step.
487-
488-
:raises AdcpError: ``PERMISSION_DENIED`` (all four denial paths).
489-
Recovery is ``correctable`` per the spec's ``enumMetadata``
490-
for ``PERMISSION_DENIED``. The wire-level recovery hint is
491-
independent of the resolution channel: the buyer cannot
492-
auto-retry a commercial-identity rejection, but the
493-
``details.scope == "agent"`` discriminator (when present) is
494-
the signal callers surface to a human operator rather than
495-
loop on the request.
488+
ones.
489+
490+
:raises AdcpError: ``AGENT_SUSPENDED`` / ``AGENT_BLOCKED`` /
491+
``PERMISSION_DENIED`` depending on path (see above). The
492+
dedicated per-agent codes carry ``recovery="terminal"``: the
493+
buyer cannot auto-retry a commercial-identity rejection, and
494+
the code itself is the discriminator surfaced to a human
495+
operator. ``PERMISSION_DENIED`` on the unrecognized path
496+
carries ``recovery="correctable"`` per the spec's
497+
``enumMetadata``.
496498
"""
497499
from adcp.decisioning.registry import (
498500
ApiKeyCredential,
@@ -554,26 +556,21 @@ async def _resolve_buyer_agent(
554556
if agent.status == "active":
555557
return agent
556558
if agent.status == "suspended":
559+
# AdCP 3.1 dedicated code — the code itself is the discriminator,
560+
# no ``details`` payload. ``recovery="terminal"`` per the spec
561+
# (the placeholder ``PERMISSION_DENIED + details.status`` shape
562+
# in 3.0.5 inherited ``correctable`` from PERMISSION_DENIED,
563+
# which contradicted the no-retry MUST for this path).
557564
raise AdcpError(
558-
"PERMISSION_DENIED",
565+
"AGENT_SUSPENDED",
559566
message=_denied_message,
560-
recovery="correctable",
561-
details={
562-
"scope": "agent",
563-
"status": "suspended",
564-
"agent_url": agent.agent_url,
565-
},
567+
recovery="terminal",
566568
)
567569
if agent.status == "blocked":
568570
raise AdcpError(
569-
"PERMISSION_DENIED",
571+
"AGENT_BLOCKED",
570572
message=_denied_message,
571-
recovery="correctable",
572-
details={
573-
"scope": "agent",
574-
"status": "blocked",
575-
"agent_url": agent.agent_url,
576-
},
573+
recovery="terminal",
577574
)
578575
# Default-reject any non-active status the framework doesn't
579576
# recognize (typo, future enum value, adopter-custom string). A
@@ -1085,13 +1082,14 @@ async def _resolve_account(
10851082
BEFORE calling ``AccountStore.resolve`` and stashes the result
10861083
on ``ctx.metadata['adcp.buyer_agent']`` for :meth:`_build_ctx`
10871084
to read into the typed :class:`RequestContext`. Suspended /
1088-
blocked / unrecognized agents are rejected here with
1089-
``PERMISSION_DENIED`` (recognized-but-denied paths carry
1090-
``details.scope="agent"`` + ``details.status``; the
1091-
unrecognized-agent path omits ``details`` so the wire shape
1092-
does not enumerate which ``agent_url``s are onboarded with
1093-
this seller) instead of the registry miss leaking into the
1094-
AccountStore as ``ACCOUNT_NOT_FOUND``.
1085+
blocked / unrecognized agents are rejected here with the
1086+
dedicated per-agent codes per AdCP 3.1:
1087+
suspended → ``AGENT_SUSPENDED``, blocked → ``AGENT_BLOCKED``
1088+
(both ``recovery="terminal"``, no ``details`` payload);
1089+
unrecognized → ``PERMISSION_DENIED`` with no ``details.scope``
1090+
so the wire shape does not enumerate which ``agent_url``s are
1091+
onboarded with this seller. This prevents the registry miss
1092+
from leaking into AccountStore as ``ACCOUNT_NOT_FOUND``.
10951093
"""
10961094
await self._prime_auth_context(ctx)
10971095
auth_info = self._extract_auth_info(ctx)

src/adcp/decisioning/serve.py

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -154,12 +154,13 @@ def create_adcp_server_from_platform(
154154
identity layer. When wired, the framework calls the registry
155155
BEFORE :meth:`AccountStore.resolve` to gate every request on
156156
the seller's commercial allowlist. Suspended / blocked /
157-
unrecognized agents are rejected with structured
158-
``PERMISSION_DENIED`` errors (recognized-but-denied paths
159-
carry ``details.scope="agent"`` + ``details.status``; the
160-
unrecognized-agent path omits ``details`` so the wire shape
161-
does not enumerate which ``agent_url``s are onboarded with
162-
this seller). The resolved
157+
unrecognized agents are rejected with structured errors:
158+
suspended → ``AGENT_SUSPENDED``, blocked → ``AGENT_BLOCKED``
159+
(both ``recovery="terminal"``, no ``details`` payload — the
160+
code itself is the discriminator per AdCP 3.1); unrecognized
161+
→ ``PERMISSION_DENIED`` with no ``details.scope`` so the wire
162+
shape does not enumerate which ``agent_url``s are onboarded
163+
with this seller. The resolved
163164
:class:`adcp.decisioning.BuyerAgent` is threaded onto
164165
:attr:`RequestContext.buyer_agent` so platform methods can
165166
read commercial context (billing capabilities, default terms,

tests/test_decisioning_buyer_agent_dispatch.py

Lines changed: 24 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,15 @@
1414
* :class:`PlatformHandler` calls the registry BEFORE
1515
:meth:`AccountStore.resolve` when one is wired; the resolved
1616
:class:`BuyerAgent` is threaded onto :attr:`RequestContext.buyer_agent`.
17-
* Suspended / blocked / unknown-status agents reject with the
18-
spec-conformant ``PERMISSION_DENIED`` code instead of leaking into
19-
``ACCOUNT_NOT_FOUND``. Recognized-but-denied paths carry
20-
``details.scope="agent"`` + ``details.status``; the unrecognized
21-
paths (registry miss, no credential, unknown status) omit
22-
``details`` so the wire shape is indistinguishable per the
23-
cross-tenant onboarding-oracle clamp.
17+
* Suspended / blocked agents reject with the AdCP 3.1 dedicated codes
18+
``AGENT_SUSPENDED`` / ``AGENT_BLOCKED`` (``recovery="terminal"``, no
19+
``details`` payload — the code itself is the discriminator).
20+
Unrecognized paths (registry miss, no credential, unknown status)
21+
surface ``PERMISSION_DENIED`` and omit ``details`` so the wire shape
22+
does not enumerate which ``agent_url``s are onboarded with this
23+
seller (cross-tenant onboarding-oracle clamp). All four paths reject
24+
before ``AccountStore.resolve`` so the registry miss does not leak
25+
into ``ACCOUNT_NOT_FOUND``.
2426
* No registry wired → existing dispatch path runs unchanged
2527
(back-compat for pre-trust beta adopters).
2628
"""
@@ -694,13 +696,11 @@ async def get_products(self, req, ctx):
694696

695697

696698
@pytest.mark.asyncio
697-
async def test_suspended_agent_raises_permission_denied_terminal(executor) -> None:
698-
"""Status=suspended is rejected as ``PERMISSION_DENIED`` with
699-
``details.scope="agent"`` + ``details.status="suspended"``.
700-
Wire-level ``recovery`` is ``correctable`` per the spec's
701-
``enumMetadata`` for ``PERMISSION_DENIED``; the
702-
``details.scope == "agent"`` discriminator is the signal callers
703-
surface to a human operator rather than auto-retry."""
699+
async def test_suspended_agent_raises_agent_suspended_terminal(executor) -> None:
700+
"""Status=suspended is rejected as ``AGENT_SUSPENDED`` with
701+
``recovery="terminal"`` and no ``details`` payload (AdCP 3.1
702+
dedicated code — the code itself is the discriminator, mirroring
703+
``BILLING_NOT_PERMITTED_FOR_AGENT``)."""
704704
from adcp.types import GetProductsRequest
705705

706706
suspended = BuyerAgent(
@@ -730,21 +730,16 @@ async def get_products(self, req, ctx):
730730
GetProductsRequest(buying_mode="brief", brief="any"),
731731
tool_ctx,
732732
)
733-
assert exc_info.value.code == "PERMISSION_DENIED"
734-
assert exc_info.value.recovery == "correctable"
735-
assert exc_info.value.details["scope"] == "agent"
736-
assert exc_info.value.details["status"] == "suspended"
737-
assert exc_info.value.details["agent_url"] == "https://suspended/"
733+
assert exc_info.value.code == "AGENT_SUSPENDED"
734+
assert exc_info.value.recovery == "terminal"
735+
assert exc_info.value.details == {}
738736

739737

740738
@pytest.mark.asyncio
741-
async def test_blocked_agent_raises_permission_denied_terminal(executor) -> None:
742-
"""Status=blocked is rejected as ``PERMISSION_DENIED`` with
743-
``details.scope="agent"`` + ``details.status="blocked"``.
744-
Wire-level ``recovery`` is ``correctable`` per the spec's
745-
``enumMetadata``; the ``details.scope == "agent"`` discriminator
746-
signals callers to surface to a human operator rather than
747-
auto-retry."""
739+
async def test_blocked_agent_raises_agent_blocked_terminal(executor) -> None:
740+
"""Status=blocked is rejected as ``AGENT_BLOCKED`` with
741+
``recovery="terminal"`` and no ``details`` payload (AdCP 3.1
742+
dedicated code — the code itself is the discriminator)."""
748743
from adcp.types import GetProductsRequest
749744

750745
blocked = BuyerAgent(
@@ -774,10 +769,9 @@ async def get_products(self, req, ctx):
774769
GetProductsRequest(buying_mode="brief", brief="any"),
775770
tool_ctx,
776771
)
777-
assert exc_info.value.code == "PERMISSION_DENIED"
778-
assert exc_info.value.recovery == "correctable"
779-
assert exc_info.value.details["scope"] == "agent"
780-
assert exc_info.value.details["status"] == "blocked"
772+
assert exc_info.value.code == "AGENT_BLOCKED"
773+
assert exc_info.value.recovery == "terminal"
774+
assert exc_info.value.details == {}
781775

782776

783777
@pytest.mark.asyncio

tests/test_error_code_conformance.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,25 @@
115115
"Per-agent billing gate raised by validate_billing_for_agent. "
116116
"In source/main (3.1), absent from 3.0.x dist bundles."
117117
),
118+
# TODO: drop when ADCP_VERSION >= 3.1.
119+
# AdCP 3.1 (PR adcontextprotocol/adcp#3906) consolidates the 3.0.5
120+
# `PERMISSION_DENIED + details.status` placeholder into dedicated
121+
# codes for per-agent commercial-status rejections. The code itself
122+
# is the discriminator (no `details` payload), mirroring
123+
# `BILLING_NOT_PERMITTED_FOR_AGENT`. Both carry `recovery="terminal"`
124+
# at the wire level — the placeholder shape inherited
125+
# `PERMISSION_DENIED`'s `correctable`, which contradicted the
126+
# no-retry MUST. Raised by `_resolve_buyer_agent` in
127+
# `adcp.decisioning.handler` when `BuyerAgent.status` is
128+
# "suspended" / "blocked" respectively.
129+
"AGENT_SUSPENDED": (
130+
"Per-agent suspended status raised by _resolve_buyer_agent. "
131+
"In source/main (3.1.0-beta.1+), absent from 3.0.x dist bundles."
132+
),
133+
"AGENT_BLOCKED": (
134+
"Per-agent blocked status raised by _resolve_buyer_agent. "
135+
"In source/main (3.1.0-beta.1+), absent from 3.0.x dist bundles."
136+
),
118137
}
119138

120139
CANONICAL_CODES: frozenset[str] = frozenset(member.value for member in ErrorCode)

tests/test_mechanical_helpers.py

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -157,10 +157,10 @@ def test_default_code_and_recovery(self) -> None:
157157
assert err.code == "PERMISSION_DENIED"
158158
assert err.recovery == "correctable"
159159

160-
def test_scope_and_status_in_details(self) -> None:
161-
err = PermissionDeniedError(scope="agent", status="sandbox_only")
160+
def test_scope_and_reason_in_details(self) -> None:
161+
err = PermissionDeniedError(scope="agent", reason="sandbox_only")
162162
assert err.details["scope"] == "agent"
163-
assert err.details["status"] == "sandbox_only"
163+
assert err.details["reason"] == "sandbox_only"
164164

165165
def test_billing_scope(self) -> None:
166166
err = PermissionDeniedError(scope="billing")
@@ -171,13 +171,25 @@ def test_message_override(self) -> None:
171171
assert "custom denial reason" in str(err)
172172

173173
def test_extra_details_forwarded(self) -> None:
174-
err = PermissionDeniedError(scope="agent", reason="sandbox_only")
174+
err = PermissionDeniedError(scope="agent", reason="sandbox_only", extra_key="extra_val")
175175
assert err.details["reason"] == "sandbox_only"
176+
assert err.details["extra_key"] == "extra_val"
176177

177178
def test_field_forwarded(self) -> None:
178179
err = PermissionDeniedError(field="governance_context")
179180
assert err.field == "governance_context"
180181

182+
def test_status_kwarg_rejects_with_migration_message(self) -> None:
183+
"""AdCP 3.1 removed the placeholder ``details.status`` field. Adopters
184+
who wrote ``PermissionDeniedError(scope="agent", status="suspended")``
185+
against 3.0.5 must migrate to ``AdcpError("AGENT_SUSPENDED", ...)``.
186+
Without an explicit guard, ``status=`` would silently land in
187+
``**details`` and emit a wire-invalid response."""
188+
with pytest.raises(TypeError) as exc:
189+
PermissionDeniedError(scope="agent", status="suspended")
190+
assert "AGENT_SUSPENDED" in str(exc.value)
191+
assert "AdCP 3.1" in str(exc.value)
192+
181193

182194
class TestAuthRequiredError:
183195
def test_subclass_and_code(self) -> None:

0 commit comments

Comments
 (0)