Skip to content
16 changes: 16 additions & 0 deletions docs/reporting-subscriptions-seller.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,20 @@ The SDK reads these surfaces under each principal's identity. A principal sees e

A decisioning `Account` carries `timezone=` and `operator=`. An account without a timezone is refused rather than read as UTC.

### Capabilities and serving

The service fills in what it serves and leaves the rest to you:
- **What the service adds.**
- The `media_buy.reporting_delivery` block.
- The `adcp.principal` block, with `optimistic_concurrency: true` and `reporting_subscriptions` in `supported_sections`.
- `adcp.supported_versions`, when your capabilities do not state it. Without it a buyer negotiates down from `major_versions` and never reaches 3.3.
- **What your capabilities must carry.**
- `adcp.idempotency`, which says whether you honor `idempotency_key` replay. The schema requires it, and only you know it.
- To accept `reporting_destinations`, `adcp.principal.max_reporting_destinations`, `reporting_destination_offerings` and `suspension_interval_seconds`. Without all three, that section is not advertised.
- **Serving over MCP.** Pass `serve(..., context_factory=auth_context_factory)` (or your own factory), so `list_accounts` and `get_media_buys` see the authenticated `caller_identity`. On the managed path, a reporting task with no resolved account reads the account the request names. The subscription grant check then refuses an account the caller holds no grant on, so no `caller_resolver` is needed for `get_reporting_status` over plain MCP.

The reference seller (`python -m adcp reference-seller reporting`) is a complete example.

## AdCP 3.3.0-beta.3 rules the engine applies

- **Admission under `max_accounts`.** Candidates are ordered by `authorization.authorized_at`, then `account_id` in byte order, and an admitted account keeps its slot. A waiting candidate carries a `capacity` exception.
Expand All @@ -41,6 +55,8 @@ A decisioning `Account` carries `timezone=` and `operator=`. An account without
- **Partial grants.** A grant that keeps the account covered refuses a task it does not list with `SCOPE_INSUFFICIENT`.
- **Billing stubs and `regranted_at`.** A billing cutover owes a stub obligation (`stub: true`), and `list_accounts` serves `authorization.regranted_at`.

The reference seller, `adcp.testing.reporting.create_reference_reporting_seller`, is a complete example on this path. A ~100-line handler serves `list_accounts` and `get_media_buys`, and `ReliableReportingService(subscriptions=SubscriptionOptions(...))` adds the rest. Run it with `python -m adcp reference-seller reporting`; see [Testing](reporting-subscriptions-testing.md#the-reference-seller).

## What the SDK derives

- **The principal runtime.** `install()` serves `get_principal` and `sync_principal` and advertises the principal section. The service's `PrincipalService` is `reporting.principals`.
Expand Down
24 changes: 24 additions & 0 deletions docs/reporting-subscriptions-testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,3 +59,27 @@ Python sellers run the TypeScript storyboard CLI against their URL:
```
npx adcp storyboard run https://seller.example/mcp --storyboards reporting_subscriptions --auth "$TOKEN"
```

## The reference seller

`python -m adcp reference-seller reporting --port 3000` (or `python examples/reporting_subscriptions_seller.py`) serves the SDK's reference seller as a standalone sandbox MCP server:
- MCP is at `/mcp` and A2A at `/`.
- The bearer token names the principal, and `storyboard-buyer` is used without one.
- Storage is process-local.
- The virtual clock starts at `--start` (default: now) and moves only through `comply_test_controller`.

Its controller serves `seed_media_buy` and the `reporting_subscription_probe` scenario:
- `revoke_grant`;
- `regrant`, which returns `chain`, `authorized_at` and `regranted_at`;
- `reduce_grant`;
- `set_account_timezone`, which returns `previous_timezone` and the cutovers;
- `advance_time`, which processes every period boundary crossed.

It passes the TypeScript `reporting_subscriptions` storyboard (15 steps, strict schema checks):

```
npx adcp storyboard run http://127.0.0.1:3000/mcp --file reporting-subscriptions.yaml \
--auth storyboard-buyer --allow-http --compliance-version 3.3.0-beta.3
```

The SDK's own suite runs the same storyboard in process on every change (`tests/test_reporting_reference_seller.py`). A negative control there drops `prior_versions` from the readback and fails exactly the steps that read it.
25 changes: 25 additions & 0 deletions examples/reporting_subscriptions_seller.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
"""The reference seller for AdCP 3.3 reporting subscriptions, as a standalone MCP server.

Run it, then drive it with the storyboard runner::

python examples/reporting_subscriptions_seller.py --port 3000
npx adcp storyboard run http://127.0.0.1:3000/mcp --storyboards reporting_subscriptions \
--auth storyboard-buyer --allow-http

It is ``adcp.testing.reporting.create_reference_reporting_seller``: a seller
that already serves ``list_accounts`` and ``get_media_buys`` (about a hundred
lines over an in-memory world), with ``ReliableReportingService(subscriptions=
SubscriptionOptions(...))`` adding everything else. The bearer token names the
principal, storage is process-local, and the virtual clock moves only through
``comply_test_controller`` ``reporting_subscription_probe`` ``advance_time``.
Equivalent: ``python -m adcp reference-seller reporting --port 3000``.
"""

from __future__ import annotations

import sys

from adcp.testing.reporting import main

if __name__ == "__main__":
sys.exit(main(["reporting", *sys.argv[1:]]))
5 changes: 5 additions & 0 deletions src/adcp/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -694,6 +694,11 @@ def main() -> None:
print(error, file=sys.stderr)
sys.exit(2)
sys.exit(migrate_main(sys.argv[2:]))
if len(sys.argv) > 1 and sys.argv[1] == "reference-seller":
# A sandbox reference seller, for storyboards and cross-SDK interop.
from adcp.testing.reporting import main as reference_main

sys.exit(reference_main(sys.argv[2:]))
if len(sys.argv) > 1 and sys.argv[1] == "reporting":
# Reporting-subscription tooling: explain coverage, compare traces.
from adcp.reporting.cli import main as reporting_main
Expand Down
40 changes: 39 additions & 1 deletion src/adcp/reporting/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -600,7 +600,9 @@ def __init__(
self._initialize_source_storage = initialize_source_storage
self.sources = ReportingAdapterRegistry(clock=effective_clock, staging=staging, seals=seals)
self._context_resolver = account_context
self._caller_resolver = caller_resolver or self._default_caller
self._caller_resolver = caller_resolver or (
self._managed_caller if subscriptions is not None else self._default_caller
)
self._consumer_status_enabled = consumer_status_enabled
self._escalation = escalation or ReportingDeliveryEscalation()
self._clock = effective_clock
Expand Down Expand Up @@ -1611,6 +1613,29 @@ async def caller_for(self, request: Any, context: Any | None) -> ReportingStatus
raise TypeError("caller_resolver must resolve ReportingStatusCaller")
return caller

@classmethod
def _managed_caller(cls, request: Any, context: Any | None) -> ReportingStatusCaller:
"""The managed subscription path's default caller, which also works over plain MCP.

As :meth:`_default_caller`; when the context carries an authenticated
caller but no resolved account (a plain ``ToolContext`` from
``serve()``), the account is the one the request names. The
subscription grant check then refuses an account the caller holds no
grant on, as for any other read (adcp-client-python#1771).
"""
try:
return cls._default_caller(request, context)
except ReliableReportingConfigurationError:
from adcp.reporting.subscriptions.managed import request_principal_id

consumer_id = request_principal_id(context)
payload = _wire(request) if request is not None else {}
account = payload.get("account") if isinstance(payload, Mapping) else None
account_id = account.get("account_id") if isinstance(account, Mapping) else None
if not consumer_id or not account_id:
raise
return ReportingStatusCaller(account_id=str(account_id), consumer_id=consumer_id)

@staticmethod
def _default_caller(request: Any, context: Any | None) -> ReportingStatusCaller:
del request
Expand Down Expand Up @@ -1886,6 +1911,19 @@ def inject_capabilities(
serves_subscriptions,
)

if self._managed is not None:
# Subscriptions are served from 3.3; without supported_versions
# a buyer negotiates down from major_versions and never reaches
# them (adcp-client-python#1771). Advertise the releases this
# SDK serves unless the seller states its own.
adcp = dict(payload.get("adcp") or {})
if not adcp.get("supported_versions"):
from adcp._version import get_supported_adcp_versions

adcp["supported_versions"] = [
item for item in get_supported_adcp_versions() if "-rc" not in item
]
payload["adcp"] = adcp
if self._managed is not None and serves_subscriptions(adcp_version):
payload = self._managed.advertise_principal(payload)
try:
Expand Down
27 changes: 23 additions & 4 deletions src/adcp/reporting/subscriptions/managed.py
Original file line number Diff line number Diff line change
Expand Up @@ -1096,16 +1096,35 @@ async def _project_page(self, principal: PrincipalRef, page: list[Any]) -> list[

@staticmethod
def advertise_principal(payload: dict[str, Any]) -> dict[str, Any]:
"""Advertise the principal layer the service serves, merged with the seller's."""
"""Advertise the principal layer the service serves, merged with the seller's.

``reporting_subscriptions`` is always added, and ``optimistic_concurrency``
is ``true``: the principal runtime enforces
``expected_configuration_version``. ``reporting_destinations`` is added
only when the seller's own block carries the facts the schema requires
with it (``max_reporting_destinations``,
``reporting_destination_offerings`` and ``suspension_interval_seconds``),
so the advertisement always validates.
"""
adcp = dict(payload.get("adcp") or {})
principal = dict(adcp.get("principal") or {})
principal.setdefault("supported", True)
principal.setdefault("sync_task", "sync_principal")
principal.setdefault("read_task", "get_principal")
principal.setdefault("optimistic_concurrency", True)
sections = [str(item) for item in principal.get("supported_sections") or ()]
for section in ("reporting_destinations", "reporting_subscriptions"):
if section not in sections:
sections.append(section)
destination_facts = (
"max_reporting_destinations",
"reporting_destination_offerings",
"suspension_interval_seconds",
)
if all(principal.get(name) for name in destination_facts):
if "reporting_destinations" not in sections:
sections.append("reporting_destinations")
elif "reporting_destinations" in sections:
sections.remove("reporting_destinations")
if "reporting_subscriptions" not in sections:
sections.append("reporting_subscriptions")
principal["supported_sections"] = sections
adcp["principal"] = principal
payload = {**payload, "adcp": adcp}
Expand Down
112 changes: 108 additions & 4 deletions src/adcp/testing/reporting.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@
import copy
from collections.abc import AsyncIterator, Mapping, Sequence
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from datetime import datetime, timedelta, timezone
from typing import Any

from adcp.reporting.subscriptions._clock import iso_z, parse_delivery_sla, utc_instant
Expand Down Expand Up @@ -210,7 +210,16 @@ def __init__(self, world: ReportingWorld) -> None:
self.world = world

async def get_adcp_capabilities(self, params: Any, context: Any = None) -> Any:
return {"adcp": {"major_versions": [3]}, "supported_protocols": ["media_buy"]}
return {
"adcp": {
"major_versions": [3],
# Reporting subscriptions need the 3.3 line; advertise it so a
# runner negotiates it rather than falling back to 3.0.
"supported_versions": ["3.3-beta.3"],
"idempotency": {"supported": True, "replay_ttl_seconds": 86_400},
},
"supported_protocols": ["media_buy"],
}

async def list_accounts(self, params: Any, context: Any = None) -> Any:
principal = _principal(context)
Expand Down Expand Up @@ -280,6 +289,17 @@ class ReferenceSeller:
controller: ReportingTestController
offerings: tuple[dict[str, Any], ...]

def account_resolver(self, ref: Mapping[str, Any] | None, *, auth_info: Any = None) -> Any:
"""The controller's sandbox-authority gate: only the world's sandbox accounts."""
if not ref:
return None
account = self.world.accounts.get(str(ref.get("account_id") or ""))
if account is None or not account.get("sandbox", True):
raise TestControllerError("NOT_FOUND", "not a sandbox account of this seller")
from types import SimpleNamespace

return SimpleNamespace(id=account["account_id"], mode="sandbox", sandbox=True)

def context(self, principal: str = DEFAULT_PRINCIPAL, account_id: str | None = None) -> Any:
return AccountAwareToolContext(caller_identity=principal, account_id=account_id)

Expand All @@ -294,7 +314,12 @@ def context_factory(_meta: Any) -> ToolContext:
return ToolContext(caller_identity=principal)

server = create_mcp_server(self.platform, name=name, context_factory=context_factory)
register_test_controller(server, self.controller, context_factory=context_factory)
register_test_controller(
server,
self.controller,
context_factory=context_factory,
account_resolver=self.account_resolver,
)
return server


Expand Down Expand Up @@ -332,7 +357,13 @@ def create_reference_reporting_seller(
"sandbox": True,
},
):
record = {"name": account["account_id"], "status": "active", "timezone": "UTC", **account}
record = {
"name": account["account_id"],
"status": "active",
"timezone": "UTC",
"sandbox": True,
**account,
}
world.accounts[str(record["account_id"])] = record
for buy in buys:
_add_buy(world, buy)
Expand Down Expand Up @@ -586,6 +617,78 @@ def _boundaries(seller: ReferenceSeller, after: datetime, through: datetime) ->
return sorted(points)


def _bearer_principal(meta: Any) -> ToolContext:
"""The sandbox trust model: the bearer token names the principal."""
request = getattr(meta, "request_context", None)
header = ""
if request is not None:
header = str(getattr(request, "headers", {}).get("authorization", ""))
token = header[7:].strip() if header.lower().startswith("bearer ") else ""
return ToolContext(caller_identity=token or DEFAULT_PRINCIPAL)


def serve_reference_reporting_seller(
*,
port: int | None = None,
host: str | None = None,
start: Any = None,
**options: Any,
) -> None:
"""Serve the reference seller over MCP until interrupted (sandbox only).

The bearer token names the principal (``storyboard-buyer`` without one),
storage is process-local, and the virtual clock starts at ``start``
(default: now) and moves only through ``comply_test_controller``
``reporting_subscription_probe`` ``advance_time``. ``comply_test_controller``
also serves ``seed_media_buy`` and the grant and timezone probes.
"""
import os

from adcp.server.serve import serve

os.environ.setdefault("ADCP_ENV", "development")
seller = create_reference_reporting_seller(
start=start or datetime.now(timezone.utc).replace(microsecond=0), **options
)

async def initialize() -> None:
await seller.service.run_worker()

serve(
seller.platform,
name="adcp-reference-reporting-seller",
port=port,
host=host,
# MCP at /mcp and A2A at /; this transport runs the startup hook that
# opens the reporting service.
transport="both",
test_controller=seller.controller,
test_controller_account_resolver=seller.account_resolver,
context_factory=_bearer_principal,
on_startup=[initialize],
)


def main(argv: Sequence[str]) -> int:
"""``python -m adcp reference-seller reporting [--port N] [--host H] [--start ISO]``."""
import argparse

parser = argparse.ArgumentParser(prog="python -m adcp reference-seller reporting")
parser.add_argument("kind", choices=["reporting"])
parser.add_argument("--port", type=int, default=None)
parser.add_argument("--host", default=None)
parser.add_argument("--start", default=None, help="virtual clock start (RFC 3339)")
parser.add_argument("--backfill-on-regrant", action="store_true")
args = parser.parse_args(list(argv))
serve_reference_reporting_seller(
port=args.port,
host=args.host,
start=args.start,
backfill_on_regrant=args.backfill_on_regrant,
)
return 0


# -- the kit ---------------------------------------------------------------------------


Expand Down Expand Up @@ -979,6 +1082,7 @@ async def reporting_test_kit(


__all__ = [
"serve_reference_reporting_seller",
"DEFAULT_PRINCIPAL",
"REFERENCE_ACCOUNT",
"AgreementError",
Expand Down
Loading
Loading