Repository navigation
bug(reporting): capability_block() is empty until the service is running, so reporting_delivery comes and goes with the lifecycle #1298
Description
Summary
On 8.0.0, ReliableReportingService.capability_block() returns {} whenever the service isn't in the ready state (adcp/reporting/service.py:1159-1162). Offerings registered with sources.register(..., capability_offerings=...) are ignored until then. inject_capabilities (service.py:1209-1226) adds nothing for an empty block, and the decisioning handler calls it on every get_adcp_capabilities (adcp/decisioning/handler.py:2087-2089).
So before start(), after close(), and after a terminal worker failure, get_adcp_capabilities omits media_buy.reporting_delivery and the media_buy.reporting_delivery experimental feature. Meanwhile tools/list keeps advertising get_reporting_status (reporting_tools is "independent of readiness", service.py:1365-1373). The block is also empty when the platform's capabilities are built and when create_adcp_server_from_platform runs its boot-time capabilities validation, since both happen before the service starts.
Reproduction (adcp 8.0.0)
import asyncio
import logging
import warnings
warnings.simplefilter("ignore")
logging.disable(logging.CRITICAL)
from adcp.decisioning import DecisioningCapabilities, DecisioningPlatform, SingletonAccounts
from adcp.decisioning.serve import create_adcp_server_from_platform
from adcp.reporting import ReliableReportingService
from adcp.reporting.fixtures import redacted_capabilities
from adcp.reporting.testing import ScriptedReportingAdapter
from adcp.server.base import ToolContext
from adcp.types import GetAdcpCapabilitiesRequest
source = redacted_capabilities()
contract = source.offerings[0].contract
OFFERING = { # the public buyer contract, declared before any account configures reporting
"offering_id": "daily-snapshot",
"feed_purpose": "analytics",
"report_definition_id": contract.report_definition_id,
"report_definition_uri": contract.report_definition_uri,
"report_definition_sha256": contract.report_definition_sha256,
"reporting_profile": {
"id": contract.reporting_profile, "version": contract.schema_version,
"schema_uri": contract.schema_uri, "schema_sha256": contract.schema_sha256,
"schema_dialect": "https://json-schema.org/draft/2020-12/schema",
"schema_ref_policy": "local_fragment_only",
"grain": "one row per media buy per period", "primary_keys": ["media_buy_id"],
},
"schedule": {"period_duration": "P1D", "delivery_sla": "PT1H", "alignment": "utc"},
"supported_finality": ["snapshot"],
"reconciliation_mode": "delivery_only",
}
class Seller(DecisioningPlatform):
capabilities = DecisioningCapabilities(specialisms=["sales-non-guaranteed"], supported_billing=["operator"])
accounts = SingletonAccounts(account_id="acct")
def get_products(self, req, ctx): return {"products": []}
def create_media_buy(self, req, ctx): raise NotImplementedError
def update_media_buy(self, media_buy_id, patch, ctx): raise NotImplementedError
def sync_creatives(self, req, ctx): raise NotImplementedError
def get_media_buy_delivery(self, req, ctx): raise NotImplementedError
service = ReliableReportingService.memory(account_context=lambda c: None)
service.sources.register("src", ScriptedReportingAdapter(source), capability_offerings=[OFFERING])
print("at build time:", service.capability_block())
handler, _, _ = create_adcp_server_from_platform(service.install(Seller()))
print("tools advertised:", sorted(service.reporting_tools))
async def caps():
resp = await handler.get_adcp_capabilities(GetAdcpCapabilitiesRequest(), ToolContext())
resp = resp if isinstance(resp, dict) else resp.model_dump(mode="json", exclude_none=True)
return "reporting_delivery" in (resp.get("media_buy") or {}), resp.get("experimental_features")
async def main():
print("before start: reporting_delivery=%s experimental=%s" % await caps())
await service.start()
print("after start: reporting_delivery=%s experimental=%s" % await caps())
await service.close()
print("after close: reporting_delivery=%s experimental=%s" % await caps())
asyncio.run(main())Output:
at build time: {}
tools advertised: ['get_media_buy_delivery', 'get_reporting_status']
before start: reporting_delivery=False experimental=None
after start: reporting_delivery=True experimental=['media_buy.reporting_delivery']
after close: reporting_delivery=False experimental=None
Why an adopter hits this
Capabilities are a discovery contract. A buyer that reads them during a deploy or a worker failure concludes the seller has no Reliable Reporting, and may tear down its configuration, while the tools are still listed. The right signal for "temporarily not running" is SERVICE_UNAVAILABLE on the reporting tools (see the companion issue on wire errors), not a capability that disappears.
Embedded Sales Agent builds its DecisioningCapabilities before the service starts. It hand-writes the block in reporting_delivery_capability() (core/reporting.py, about 25 LOC), duplicating the SDK's private _advertised_reporting_delivery projection. A unit test (tests/unit/test_reliable_reporting.py::test_matches_the_sdk_projection_once_a_generation_is_bound) starts a service only to assert the two still agree. With capability_offerings (new since #1240) ESA no longer needs a bound generation, but it still can't get the block at build time.
Ask
- Derive
capability_block()from registered offerings and installed components, whatever the lifecycle state, and report runtime unavailability through the tools. - If the readiness gate is intentional, add a public
declared_capability_block()(or make_advertised_reporting_deliverypublic) that adopters can put intoDecisioningCapabilities.media_buy.reporting_deliveryat build time, and that boot validation can check.
Activity
bokelley commentedon Oct 2, 2026
Triage
Classification: Bug
Bucket(s): handlers (no
handlerslabel exists in this repo — gap noted)Status: ready-to-implement
What the experts said:
capability_block()computes a static declaration from registered offerings, not a runtime operation;_advertised_reporting_delivery's own docstring says "discovery does not need a producer binding" — the readiness gate contradicts that intent and creates a tools/capabilities split-brain during startup/shutdown. Option 1 (remove the gate) is strictly non-breaking and alignscapability_block()withreporting_tools' already-correct lifecycle stance._bindings,min/maxwithdefault,_materialization_worker is None) and found no safety objection to removing the guard.My take: The
if not self.ready: return {}guard (service.py:1161–1162) misapplies work-admission protection to a static declaration fully determined at registration time. Removing it is non-breaking — theif not offerings: return {}check at line 1185 already handles unconfigured services, so nothing regresses.Implementation scope:
src/adcp/reporting/service.py:1161–1162— removeif not self.ready: return {}.tests/test_reliable_reporting_decisioning.py:231,241–245— update assertions expecting{}beforestart()/ afterclose().tests/test_reliable_reporting_lifecycle.py:215,267,332— update assertions expecting{}in STOPPING/FAILED states (FAILED terminal state is debatable; STOPPING and CREATED are not).tests/test_reliable_reporting_service.py:241–243— update assertion expecting{}afterconfigure()but beforestart().pytest tests/test_reliable_reporting_*.py+mypy src/adcp/reporting/.Triaged by Claude Code. Session: https://claude.ai/code/session_01VR4sgwwoBqRVrvB6GiL5p2
Generated by Claude Code