Skip to content

feat(decisioning): PgBuyerAgentRegistry — durable Tier 2 commercial-identity store#364

Merged
bokelley merged 1 commit intomainfrom
bokelley/v3-tier2-pg-buyer-agent-registry
May 2, 2026
Merged

feat(decisioning): PgBuyerAgentRegistry — durable Tier 2 commercial-identity store#364
bokelley merged 1 commit intomainfrom
bokelley/v3-tier2-pg-buyer-agent-registry

Conversation

@bokelley
Copy link
Copy Markdown
Contributor

@bokelley bokelley commented May 2, 2026

Summary

  • New adcp.decisioning.pg.PgBuyerAgentRegistry — durable Postgres-backed BuyerAgentRegistry for adopters who need real commercial-identity storage without forking the SQLAlchemy reference example.
  • Mirrors the PgReplayStore design: caller-owned ConnectionPool, table-name validation, idempotent create_schema(), separate .sql DDL for migration tools.
  • Admin CRUD: upsert(), set_status(), delete().
  • 15 conformance tests gated on ADCP_PG_TEST_URL; CI's existing pg job (renamed pg-replay-storepg-conformance) runs them. All 15 pass against postgres:16 locally.

Test plan

  • pytest tests/ (no PG): 3054 passed, 18 skipped, 0 failed
  • ADCP_PG_TEST_URL=... pytest tests/conformance/decisioning/: 15/15 pass against postgres:16
  • ruff/mypy/black clean on touched files
  • CI's pg-conformance job runs the new test file
  • Imports OK without psycopg installed (graceful PG_AVAILABLE=False, ImportError with install hint at construction)

🤖 Generated with Claude Code

…dentity store

Postgres-backed BuyerAgentRegistry implementation for adopters who
need durable commercial-identity gating without forking the
SQLAlchemy reference example. Mirrors the design of
adcp.signing.pg.PgReplayStore — caller-owned ConnectionPool,
table-name validation, idempotent ``create_schema()``, raw DDL
ships separately for migration tools (Alembic, Flyway, psql).

* ``src/adcp/decisioning/pg/buyer_agent_registry.py`` — sync
  psycopg-pool implementation; the async BuyerAgentRegistry
  Protocol methods bridge via ``asyncio.to_thread`` so the
  framework's dispatch event loop stays responsive.
* ``src/adcp/decisioning/pg/buyer_agent_registry.sql`` — canonical
  schema for migration tools. JSONB columns for billing_capabilities,
  default_terms, allowed_brands, ext. CHECK constraint on status.
  Partial index on api_key_id for the bearer-credential lookup
  path. Partial index on status WHERE status <> 'active' for
  admin tools.
* Admin CRUD: upsert(), set_status(), delete(). Status validated
  against the framework's literal enum.
* SQL injection / Unicode-homoglyph defenses on the table_name
  kwarg matching PgReplayStore's posture.
* 15 conformance tests gated on ADCP_PG_TEST_URL — all 15 pass
  against postgres:16. CI's existing pg job (renamed
  pg-replay-store → pg-conformance) now runs them too.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@bokelley bokelley merged commit fbdcb31 into main May 2, 2026
12 checks passed
@bokelley bokelley deleted the bokelley/v3-tier2-pg-buyer-agent-registry branch May 2, 2026 19:46
bokelley added a commit that referenced this pull request May 3, 2026
) (#393)

* fix(decisioning): rename Tier 2 codes to spec-conformant PERMISSION_DENIED + parity (#375)

The Tier 2 commercial-identity gate previously raised four error codes
absent from the AdCP spec's `error-code.json` 51-entry vocabulary:
`AGENT_SUSPENDED`, `AGENT_BLOCKED`, `REQUEST_AUTH_UNRECOGNIZED_AGENT`,
`INVALID_BILLING_MODEL`. The cross-tenant onboarding-oracle clamp in
the spec requires the unrecognized-agent path and the recognized-but-
denied path to be observably indistinguishable to an external attacker
— distinct codes per status leak which `agent_url`s are onboarded with
which sellers, enabling enumeration of commercial relationships.

Code changes:

* `_resolve_buyer_agent` raises `PERMISSION_DENIED` on all four
  denial paths. Recognized-but-denied paths (suspended / blocked)
  carry `details.scope="agent"` + `details.status`; unrecognized
  paths (registry miss, no credential, unknown status) OMIT
  `details` per the spec's omit-on-unestablished-identity rule.
* All four paths share a single `_denied_message` constant so
  `error.message` is not a side channel.
* `validate_billing_for_agent` raises
  `BILLING_NOT_PERMITTED_FOR_AGENT` with `details.rejected_billing`
  (required) and an optional `details.suggested_billing` (the
  alphabetically-first permitted mode). The full `permitted_billing`
  subset is no longer leaked — surfacing it on every rejection let a
  misconfigured buyer probe and exfiltrate the billing matrix.

Recovery semantic: `PERMISSION_DENIED` is treated as `terminal` for
the commercial-identity gate (resolution path is operator-onboarding,
not request-side correction). This overrides the spec's `enumMetadata`
default of `correctable` for the code; the override is documented in
`_resolve_buyer_agent`'s docstring.

Spec status of `BILLING_NOT_PERMITTED_FOR_AGENT`: this code is not in
the 51-entry standard enum and lacks the `X_` vendor prefix required
by `vendor-error-codes.json`. The user-facing issue (#375) specifies
this code; raising the spec conflict in the PR body for sign-off.

Tests:

* `tests/test_tier2_spec_conformance.py` — pins the wire shape:
  - All four removed codes are no longer raised.
  - Recognized-but-denied paths carry `scope` + `status` in details.
  - Unrecognized paths omit `details`.
  - Billing path carries `rejected_billing` + optional
    `suggested_billing`; never leaks `permitted_billing` or
    `agent_url`.
* Existing `tests/test_decisioning_buyer_agent_dispatch.py` and
  `tests/test_buyer_agent_registry.py` updated to the new wire shape.

Deferred to follow-up:

The latency / headers / side-effects / observability parity contract
between the four denial paths is a larger dispatch-path refactor
(single emit point, deliberate latency padding, identical audit /
metric side-effects) that does not fit in this PR. Tracking issue to
follow. The eager-raise pattern in `_resolve_buyer_agent` still
completes the unrecognized path on a different code path than the
recognized one — this PR closes the wire-code mismatch only.

Backwards compatibility: this is a behavior change for adopters who
match on the old codes. The codes were just introduced in PRs
#364 / #372, so blast radius is small. Adopters need to migrate to
matching on `code == "PERMISSION_DENIED"` + reading `details.scope`
and `details.status` to discriminate recognized-but-denied paths.

Closes #375 (rename portion).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(decisioning): recovery=correctable per spec enumMetadata (PR #393 fix-pack)

Flip wire-level recovery from terminal to correctable on the four
PERMISSION_DENIED denial paths in _resolve_buyer_agent (handler.py)
and on BILLING_NOT_PERMITTED_FOR_AGENT in validate_billing_for_agent
(registry.py) to match the spec's enumMetadata defaults.

The details.scope == "agent" discriminator (when present) is the
signal callers surface to a human operator rather than auto-retry —
that semantic stays in details, not in the recovery hint.

Also annotates docs/proposals/v3-identity-bundle-design.md with a
status note pointing readers to the test suite for current behavior;
the proposal still uses the pre-rename code names but is preserved
as a historical doc.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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.

1 participant