You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Follow-up from #375 (PR for the wire-code rename portion). This issue tracks the parity-contract refactor that was deferred from that PR per the user's stop-rule ("if the parity contract turns out to be more complex than fits in a single PR, file a separate issue for the parity-contract follow-up").
Why
The cross-tenant onboarding-oracle clamp in the AdCP spec requires the unrecognized-agent path and the recognized-but-denied path to be observably indistinguishable to an external attacker — across:
HTTP status code (already aligned via shared PERMISSION_DENIED envelope).
Response headers (Content-Type, Content-Length within negligible tolerance).
Side effects (audit emission, metric increment with the same operation label).
Latency (cannot shortcut the unrecognized path; needs a deliberate budget).
#375's rename portion closed the code mismatch but left the eager-raise structural difference — see the docstring on _resolve_buyer_agent (the "Note on parity" paragraph) and the test-file docstring on tests/test_tier2_spec_conformance.py.
Approach: introduce a PermissionDeniedReason internal enum/struct with (scope, status) fields. The dispatch layer raises a single PermissionDeniedError carrying a reason; the wire-level translator emits the spec-correct PERMISSION_DENIED envelope with details populated only when scope is set. The unrecognized path still does the same audit-emission + metric increment as the denied path. Latency parity may require an explicit small delay budget — document the tradeoff.
Concretely:
Single emit point. Move all four denial paths in _resolve_buyer_agent (and the validate_billing_for_agent path) through one common projection — an internal PermissionDeniedError wrapping a PermissionDeniedReason struct, projected to the wire envelope by a single translator. Today four call sites build the envelope inline.
Latency budget. The unrecognized path needs to do equivalent resolution work to the recognized-but-denied path before raising. The simplest design is an explicit small delay (50–100 ms?) on every denial path so the variance between branches is dominated by the budget rather than the code path. Document the budget tradeoff (extra latency on every denial vs. closing the side channel).
Audit / metric parity. Today the recognized branches log details.agent_url and details.status; the unrecognized branch logs nothing. Need to converge on a uniform log shape — same operation label, same fields (with the discriminator either missing or hashed-then-truncated to prevent log scraping from becoming the side channel).
Latency parity test: assert p99 difference between unrecognized-agent and recognized-but-suspended paths is < 5 ms over N requests (with the explicit budget set high enough that branch-comparison variance is negligible).
Header parity test: same Content-Type, Content-Length within ±N bytes (where N covers the details shape variance).
Side-effects parity test: same audit-sink writes, same metric increments with the same operation label.
Status code parity test: same HTTP status across all four denial paths.
Summary
Follow-up from #375 (PR for the wire-code rename portion). This issue tracks the parity-contract refactor that was deferred from that PR per the user's stop-rule ("if the parity contract turns out to be more complex than fits in a single PR, file a separate issue for the parity-contract follow-up").
Why
The cross-tenant onboarding-oracle clamp in the AdCP spec requires the unrecognized-agent path and the recognized-but-denied path to be observably indistinguishable to an external attacker — across:
PERMISSION_DENIEDenvelope).#375's rename portion closed the code mismatch but left the eager-raise structural difference — see the docstring on
_resolve_buyer_agent(the "Note on parity" paragraph) and the test-file docstring ontests/test_tier2_spec_conformance.py.Required design
Per the issue body of #375:
Concretely:
_resolve_buyer_agent(and thevalidate_billing_for_agentpath) through one common projection — an internalPermissionDeniedErrorwrapping aPermissionDeniedReasonstruct, projected to the wire envelope by a single translator. Today four call sites build the envelope inline.details.agent_urlanddetails.status; the unrecognized branch logs nothing. Need to converge on a uniform log shape — same operation label, same fields (with the discriminator either missing or hashed-then-truncated to prevent log scraping from becoming the side channel).detailssize). Consider padding the unrecognized envelope to match a fixed byte count.Tests
detailsshape variance).Refs
static/schemas/source/error-details/agent-permission-denied.jsonline 10 — the elevation note.error-handling.mdx(Per-Agent Authorization Gate).🤖 Generated with Claude Code