Skip to content

Commit 5a1e6b6

Browse files
bokelleyclaude
andauthored
feat(buyer-agent-registry): caching + rate-limit + audit emission (#380) (#407)
* feat(buyer-agent-registry): caching + rate-limit + audit emission (#380) Three composable wrappers around the BuyerAgentRegistry Protocol that the v3 seller's Tier 2 commercial-identity gate hits on every dispatch. Without them, every resolve runs a SQL query — including negative paths an enumeration probe will spam, making the lookup endpoint a credential-stuffing oracle. * CachingBuyerAgentRegistry — TTL + LRU cache, default 60s / 4096 entries. Caches BOTH positive and negative resolutions; the negative cache closes the enumeration probe path so a probe walking arbitrary agent_url strings hits the DB at most once per (tenant, key) per TTL window. Hit-callback hook for Prometheus / OpenTelemetry counters. * RateLimitedBuyerAgentRegistry — per-(tenant, lookup-key) token bucket, default 100 RPS. On exhaustion raises PERMISSION_DENIED with NO details and a generic message — wire-uniform with the registry- miss path from PR #393. A distinct RATE_LIMITED code or populated details would itself be an enumeration oracle. * AuditingBuyerAgentRegistry — terminal wrapper emitting one AuditEvent per DB outcome (resolved / miss). The cache and rate- limit layers also accept the same audit_sink kwarg so cached_hit / cached_miss / rate_limited outcomes land in the same trail. The wrappers stack outside-in. Adopters compose Caching(RateLimited( Auditing(SQL-backed))) — the cache shortcuts repeated lookups before the rate limiter or DB sees them; the rate limiter stops probe traffic before the DB; the audit layer captures every actual lookup. Wires the v3 reference seller's TenantScopedBuyerAgentRegistry into the production stack with the same audit sink at every layer so SecOps can reconstruct every resolve attempt. The make_registry factory accepts ttl_seconds / rps_per_tenant / max_entries overrides for adopters with different SLA / volume requirements. Tests: tests/test_buyer_agent_registry_cache.py (23 tests covering cache hit / miss / TTL expiry / LRU eviction / rate-limit threshold + refill / audit emission per outcome / sink-failure isolation / end-to-end composition). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(buyer-agent-registry): hold lock in invalidate/clear (PR #407 fix-pack) Code reviewer flagged that CachingBuyerAgentRegistry.invalidate() and clear() mutated self._cache without holding self._lock. _store()'s move_to_end / popitem(last=False) eviction races with concurrent admin invalidate calls, risking RuntimeError or LRU-order corruption. Convert both to async + acquire the lock before mutating. Test updated to await the new coroutine. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * ci: retrigger --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent e6dfaad commit 5a1e6b6

5 files changed

Lines changed: 1319 additions & 4 deletions

File tree

examples/v3_reference_seller/src/app.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,8 +106,13 @@ def main() -> None:
106106
asyncio.run(_bootstrap_schema(engine))
107107

108108
router = SqlSubdomainTenantRouter(sessionmaker=sessionmaker)
109-
buyer_registry = make_buyer_registry(sessionmaker)
110109
audit_sink = make_audit_sink(sessionmaker)
110+
# The buyer registry composes cache + rate-limit + audit around
111+
# the SQL-backed lookup. Wiring the same audit_sink at every
112+
# layer means cached_hit / cached_miss / rate_limited / resolved
113+
# / miss outcomes ALL land in the audit trail; SecOps can
114+
# reconstruct every resolve attempt.
115+
buyer_registry = make_buyer_registry(sessionmaker, audit_sink=audit_sink)
111116
# Anti-façade traffic recorder. The reference seller is a dev /
112117
# storyboard target, so we wire the in-memory recorder and flip
113118
# ``enable_debug_endpoints=True`` below to expose

examples/v3_reference_seller/src/buyer_registry.py

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,16 +23,21 @@
2323

2424
from adcp.decisioning import (
2525
ApiKeyCredential,
26+
AuditingBuyerAgentRegistry,
2627
BuyerAgent,
2728
BuyerAgentDefaultTerms,
2829
BuyerAgentRegistry,
30+
CachingBuyerAgentRegistry,
2931
OAuthCredential,
32+
RateLimitedBuyerAgentRegistry,
3033
)
3134
from adcp.server import current_tenant
3235

3336
if TYPE_CHECKING:
3437
from sqlalchemy.ext.asyncio import async_sessionmaker
3538

39+
from adcp.audit_sink import AuditSink
40+
3641
from .models import BuyerAgent as BuyerAgentRow
3742

3843
logger = logging.getLogger(__name__)
@@ -130,11 +135,52 @@ def _row_to_agent(row: BuyerAgentRow) -> BuyerAgent:
130135
)
131136

132137

133-
def make_registry(sessionmaker: async_sessionmaker) -> BuyerAgentRegistry:
138+
def make_registry(
139+
sessionmaker: async_sessionmaker,
140+
*,
141+
audit_sink: AuditSink | None = None,
142+
ttl_seconds: float = 60.0,
143+
rps_per_tenant: float = 100.0,
144+
max_entries: int = 4096,
145+
) -> BuyerAgentRegistry:
134146
"""Factory for the tenant-scoped registry. Returns a
135147
Protocol-typed handle — adopters wire it into
136-
:func:`adcp.decisioning.serve` via ``buyer_agent_registry=``."""
137-
return TenantScopedBuyerAgentRegistry(sessionmaker=sessionmaker)
148+
:func:`adcp.decisioning.serve` via ``buyer_agent_registry=``.
149+
150+
Composes three wrappers around the SQL-backed registry so the
151+
Tier 2 commercial-identity gate has production-grade properties:
152+
153+
* **Cache** (outermost) — TTL + LRU. Both positive and negative
154+
resolutions cached so an enumeration probe at the lookup
155+
endpoint hits the DB at most once per ``(tenant, key)`` per
156+
``ttl_seconds`` window.
157+
* **Rate limit** (middle) — token bucket per
158+
``(tenant, lookup_key)``. On exhaustion raises
159+
``PERMISSION_DENIED`` with no ``details`` so the wire shape
160+
matches the registry-miss path (no enumeration oracle).
161+
* **Audit** (innermost) — emits one
162+
:class:`~adcp.audit_sink.AuditEvent` per DB outcome
163+
(``resolved`` / ``miss``); the cache and rate-limit layers
164+
add ``cached_hit`` / ``cached_miss`` / ``rate_limited``
165+
events to the same sink so compliance teams reconstruct
166+
every resolve attempt.
167+
168+
Adopters needing different defaults pass ``ttl_seconds`` /
169+
``rps_per_tenant`` / ``max_entries`` overrides.
170+
"""
171+
sql_backed = TenantScopedBuyerAgentRegistry(sessionmaker=sessionmaker)
172+
audited = AuditingBuyerAgentRegistry(sql_backed, audit_sink=audit_sink)
173+
rate_limited = RateLimitedBuyerAgentRegistry(
174+
audited,
175+
rps_per_tenant=rps_per_tenant,
176+
audit_sink=audit_sink,
177+
)
178+
return CachingBuyerAgentRegistry(
179+
rate_limited,
180+
ttl_seconds=ttl_seconds,
181+
max_entries=max_entries,
182+
audit_sink=audit_sink,
183+
)
138184

139185

140186
__all__ = ["TenantScopedBuyerAgentRegistry", "make_registry"]

src/adcp/decisioning/__init__.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,11 @@ def create_media_buy(
8787
signing_only_registry,
8888
validate_billing_for_agent,
8989
)
90+
from adcp.decisioning.registry_cache import (
91+
AuditingBuyerAgentRegistry,
92+
CachingBuyerAgentRegistry,
93+
RateLimitedBuyerAgentRegistry,
94+
)
9095
from adcp.decisioning.resolve import (
9196
CollectionList,
9297
Format,
@@ -173,13 +178,15 @@ def __init__(self, *args: object, **kwargs: object) -> None:
173178
"AdcpError",
174179
"ApiKeyCredential",
175180
"AudiencePlatform",
181+
"AuditingBuyerAgentRegistry",
176182
"AuthInfo",
177183
"BillingMode",
178184
"BrandRightsPlatform",
179185
"BuyerAgent",
180186
"BuyerAgentDefaultTerms",
181187
"BuyerAgentRegistry",
182188
"BuyerAgentStatus",
189+
"CachingBuyerAgentRegistry",
183190
"CampaignGovernancePlatform",
184191
"CollectionList",
185192
"CollectionListsPlatform",
@@ -207,6 +214,7 @@ def __init__(self, *args: object, **kwargs: object) -> None:
207214
"PropertyList",
208215
"PropertyListReference",
209216
"PropertyListsPlatform",
217+
"RateLimitedBuyerAgentRegistry",
210218
"RequestContext",
211219
"ResourceResolver",
212220
"SalesPlatform",

0 commit comments

Comments
 (0)