Skip to content

feat(server): TenantRegistry.as_platform() adapter for serve() integration#649

Merged
bokelley merged 2 commits into
mainfrom
claude/issue-645-tenant-registry-as-platform
May 11, 2026
Merged

feat(server): TenantRegistry.as_platform() adapter for serve() integration#649
bokelley merged 2 commits into
mainfrom
claude/issue-645-tenant-registry-as-platform

Conversation

@bokelley
Copy link
Copy Markdown
Contributor

Summary

Closes #645

Three adopter teams independently hit the gap between TenantRegistry (introduced in 5.0, #619) and adcp.decisioning.serve() — there was no standard way to connect the two without writing a boilerplate DecisioningPlatform subclass per deployment. This PR bridges that gap.

Changes

src/adcp/server/tenant_registry.py

  • Adds resolve_by_id(tenant_id: str) -> TenantResolution | None — async lookup by registry key, triggers lazy factory construction the same way resolve() does but skips the host-map path
  • Adds as_platform(*, accounts, capabilities, serve_states) -> DecisioningPlatform — returns a _RegistryPlatformAdapter that can be passed directly to serve(); lazy-imports from platform_router to avoid circular imports
  • Adds _DEFAULT_SERVE_STATES = frozenset({"healthy", "unverified"}) constant
  • Updates class docstring: as_platform() is the primary example; adds explicit context_factory snippet showing how to wire SubdomainTenantMiddleware so ctx.tenant_id matches the registry key; notes Tenant.id must equal the string passed to register_lazy()

src/adcp/decisioning/platform_router.py

  • Adds _RegistryPlatformAdapter(DecisioningPlatform) — synthesizes delegate methods using the same _all_specialism_methods() + _make_delegate() pattern as PlatformRouter/LazyPlatformRouter
  • Adds _make_registry_platform_adapter() factory (called by as_platform())
  • Topology-hiding: unknown or unhealthy tenants raise SERVICE_UNAVAILABLE, not ACCOUNT_NOT_FOUND, to avoid leaking which tenant IDs are enrolled (deliberate departure from PlatformRouter's static-set rationale, documented in code)
  • get_products/refine_get_products implemented explicitly (same pattern as PlatformRouter) rather than synthesised

tests/test_tenant_registry.py

  • 17 new tests covering resolve_by_id and as_platform paths (58 total)
  • Key cases: eager/lazy happy paths, factory failure → disabled health, concurrent serialisation, pending/disabled → SERVICE_UNAVAILABLE, unverified serves by default, custom serve_states fail-closed, unknown tenant → SERVICE_UNAVAILABLE (not ACCOUNT_NOT_FOUND), missing tenant_idACCOUNT_NOT_FOUND, synthesised method dispatch

Before / after

# Before — adopters wrote this for every deployment
class MyRegistryPlatform(DecisioningPlatform):
    def __init__(self, registry, accounts):
        self.accounts = accounts
        self._registry = registry

    async def get_products(self, *args, **kwargs):
        ctx = _resolve_ctx_from_args(args, kwargs)
        resolution = await self._registry.resolve(ctx.request.host)
        if resolution is None or resolution.health != "healthy":
            raise AdcpError("SERVICE_UNAVAILABLE", ...)
        return await resolution.platform.get_products(*args, **kwargs)
    # ... repeated for every specialism method

serve(MyRegistryPlatform(registry, accounts), ...)
# After — one line
serve(registry.as_platform(accounts=accounts, capabilities=caps), ...)

Usage note — tenant_id wiring

as_platform() reads ctx.tenant_id (set from the transport layer, e.g. Host header via SubdomainTenantMiddleware). That value must equal the string key passed to register_lazy(). If using SubdomainTenantMiddleware, wire it like this:

from adcp.server import current_tenant

def context_factory(request):
    t = current_tenant()
    return {"tenant_id": t.id if t else None}

serve(registry.as_platform(accounts=accounts, capabilities=caps),
      context_factory=context_factory, ...)

Test results

4333 passed, 1 pre-existing failure (unrelated to this change)

Ruff and mypy clean on Python 3.10–3.13.

Pre-PR review sign-offs

code-reviewer ✓ approved after fixes:

  • Renamed test_as_platform_unknown_tenant_raises_account_not_foundtest_as_platform_unknown_tenant_raises_service_unavailable (name matched wrong error; implementation was already correct)
  • Added topology-hiding comment in _resolve_tenant_platform documenting the SERVICE_UNAVAILABLE vs ACCOUNT_NOT_FOUND choice
  • Changed getattr(ctx, "tenant_id", None)ctx.tenant_id (declared field, defensive form was misleading)

dx-expert ✓ approved after fixes:

  • Added context_factory snippet + Tenant.id ↔ registry key mismatch warning to class docstring (silent SERVICE_UNAVAILABLE foot-gun)
  • Added logger.warning when capabilities=None to surface misconfiguration early

Triage-managed PR — opened automatically by the issue triage routine for issue #645.
Human review required before merge.

https://claude.ai/code/session_01DboS2SpnZYd9zC87Mk3juT


Generated by Claude Code

…ation

Adds the missing bridge between TenantRegistry (5.0, #619) and
adcp.decisioning.serve(), eliminating per-adopter DecisioningPlatform
boilerplate that three teams independently wrote during the 5.0 migration.

New public API:
- TenantRegistry.resolve_by_id(tenant_id) — async lookup by tenant_id
  (mirrors resolve() but keyed on ctx.tenant_id instead of Host header)
- TenantRegistry.as_platform(accounts, capabilities=None, serve_states=...) —
  returns a DecisioningPlatform that dispatches per-request via
  resolve_by_id(ctx.tenant_id) with configurable health gating

Closes #645

https://claude.ai/code/session_01DboS2SpnZYd9zC87Mk3juT
The guard fires for any specialism method dispatched via _make_delegate,
not just get_products. Update the inline comment to reflect actual
behaviour.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@bokelley bokelley marked this pull request as ready for review May 11, 2026 07:15
@bokelley bokelley merged commit 0e396ca into main May 11, 2026
16 checks passed
@bokelley bokelley deleted the claude/issue-645-tenant-registry-as-platform branch May 11, 2026 07:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(server): TenantRegistry → serve() adapter — as_platform() or callable platform arg

2 participants