Skip to content

feat(server): pre-validation request hook / spec-default registry #614

@bokelley

Description

@bokelley

Problem

Some 4.4+ schemas mark fields as required at the wire level even though the spec instructs sellers to apply a default for missing values from pre-v3 clients. Examples:

  • GetProductsRequest.buying_mode — spec text: "Sellers receiving requests from pre-v3 clients without buying_mode SHOULD default to 'brief'."
  • account and idempotency_key on tools where the seller resolves identity from the auth chain (we backfill account_id="auth-chain" and idem-{uuid4} placeholders so strict validation passes; the impl ignores them)
  • format_id shape: 4.4 made it a structured {agent_url, id} reference; pre-4.4 buyers send a bare string
  • assets[].asset_type on sync_creatives — buyers send {"image": {...}} without asset_type, which makes the discriminated union unable to pick a branch
  • Image asset variant strictly requires width/height; pre-4.4 buyers often send image URLs without dims (we demote imageurl)

The typed dispatcher validates payloads against the library Pydantic models before the platform handler runs, so a per-handler model_validator cannot apply spec-mandated defaults in time.

Workaround (the most advanced Python adopter)

We ship a 273-LOC ASGI middleware (SpecDefaultsMiddleware) that bytes-rewrites JSON-RPC tools/call bodies and the matching A2A skill payloads, applying defaults in-place before the SDK validator runs. It works, but:

  1. Every adopter who hits AdCP 4.4+ strictness needs the same set of defaults.
  2. The middleware sits outside the SDK's validation boundary by definition.
  3. Bytes-level body rewriting is brittle — adding a new defaulted field means another hand-rolled patcher.

Proposed SDK shape

Either of:

A) Pre-validation request hook (per-tool or global):

serve(
    router,
    pre_validation_hooks={
        "get_products": lambda args: {**args, "buying_mode": args.get("buying_mode", "brief")},
        # or global hook receiving (tool_name, args)
    },
)

B) Declarative spec-default registry:

from adcp.server import SpecDefaults

serve(
    router,
    spec_defaults=SpecDefaults(
        get_products={"buying_mode": "brief"},
        sync_creatives={"asset_type_inference": True, "format_id_normalize": True},
    ),
)

Either approach kills our 273-LOC middleware and gives every Python adopter the same spec-conformant defaulting behaviour.

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