Skip to content

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

@bokelley

Description

@bokelley

Problem

TenantRegistry (5.0, #619) gives adopters a great health-tracked, lazy-factory tenant registry. But there's no way to feed it to serve() without writing a per-request adapter:

  • serve(platform: DecisioningPlatform, ...) — takes a single platform object
  • TenantRegistry.resolve(host) -> TenantResolution — returns a typed resolution with .platform, .health, .tenant_id

The bridge is missing. Adopters who want to compose the two have three options today:

  1. Write a DecisioningPlatform subclass that holds a registry reference and overrides every method to delegate via registry.resolve(current_host()).platform — duplicating what LazyPlatformRouter already does, plus the health-gating logic.
  2. Skip the registry, keep using LazyPlatformRouter — loses the health-state observability that's the registry's headline feature.
  3. Don't adopt 5.0's TenantRegistry, stay on LazyPlatformRouter indefinitely — works but punts the parity goal.

We hit this in bokelley/salesagent (Sprint 3 of our 5.0 migration) and chose option 2. The deferral note in our migration tracker:

SDK 5.0 ships TenantRegistry but serve() takes a DecisioningPlatform not a callable, and TenantRegistry.resolve(host) returns a TenantResolution, not a platform-conformant object. Adopting would require writing a DecisioningPlatform wrapper that delegates registry.resolve(host).platform per-request — duplicating LazyPlatformRouter's purpose. Net cost > benefit until SDK ships a TenantRegistry.as_platform() adapter or serve() accepts a callable.

JS reference

The JS @adcp/sdk pattern composes naturally because serve() takes a resolver callback:

serve(ctx => registry.resolveByHost(ctx.host).server, { port })

Python's serve() doesn't have an equivalent callback shape, so the same composition needs an explicit adapter on the registry side.

Proposed shapes (in increasing order of API surface)

A) TenantRegistry.as_platform() — returns a DecisioningPlatform that resolves per-call.

registry = TenantRegistry(validator=None)
for tenant in load_tenants():
    await registry.register_lazy(tenant.id, agent_url=..., factory=...)

# Returns a DecisioningPlatform that dispatches each method via
# registry.resolve(current_host).platform.<method>(...). 503s when
# resolution returns None or health in {"pending", "disabled"}.
serve(registry.as_platform(), port=os.environ["PORT"])

Closes the gap with one new public method. The platform proxy holds a registry reference and forwards each DecisioningPlatform method through the resolver. Health gating happens inside the proxy — 503 on pending / disabled, serve on healthy / unverified.

B) serve(platform_resolver=Callable[[RequestContext], DecisioningPlatform], ...) — accept a callable as an alternative to a static platform.

def resolve(ctx):
    resolution = registry.resolve_by_host(ctx.host)
    if resolution is None or resolution.health in ("pending", "disabled"):
        raise HTTPException(503)
    return resolution.platform

serve(platform_resolver=resolve, port=...)

More flexible but more public-API surface. Matches JS more closely.

C) Bothas_platform() as the easy default + platform_resolver= as the escape hatch for adopters with custom health logic.

I'd prefer (A) for our use case: less surface, the health-gating is the headline benefit of TenantRegistry and bundling it into the adapter avoids per-adopter divergence. (B) would address the same case but requires every adopter to reimplement the gating logic.

Why this blocks adoption (not just an ergonomic gap)

Without an adapter, the only way to surface TenantRegistry's health states (pending / healthy / unverified / disabled) is to write the gating proxy ourselves — and that proxy IS the integration point with serve(). So it's not "TenantRegistry is hard to compose with serve" — it's "TenantRegistry doesn't actually integrate with serve until adopters write the missing piece."

Three adopters into the migration this will surface as the same question every time. Filing now to make the fix happen at the SDK layer where the registry already lives.

Files

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions