From 93087395b14aee488c69f3ac030211284989c65e Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Tue, 22 Sep 2026 12:17:06 +0000 Subject: [PATCH 01/12] docs(security): clarify metadata credential screening limits --- CLAUDE.md | 27 ++++++++++------ src/adcp/decisioning/dispatch.py | 54 ++++++++++++++------------------ 2 files changed, 40 insertions(+), 41 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index fefdd4206..16c49d550 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -61,19 +61,22 @@ All other source code should import from `adcp.types` (the public API). ## ctx_metadata: write-only credentials prohibited -`RequestContext.metadata` (populated from the wire request's `context` extension) -is **echoed back into responses** per the AdCP context-echo contract. Adopters who -treat `metadata` as a generic KV bucket and store a credential there will discover -it round-trips to the buyer — and lands in the idempotency replay cache. - -The dispatcher fail-closes on credential-shaped keys at `_build_request_context`. +`RequestContext.metadata` is for non-secret request hints, not credentials. The +standard auth context factory adds framework fields and adopter-supplied principal +metadata. Buyer wire `context` is echoed separately; the framework does not project +it into `ToolContext.metadata`. An adopter's custom context factory or response code +can still expose secrets if it copies credentials into metadata or response context. + +The dispatcher performs **best-effort key screening** at `_build_request_context` +as defense in depth against adopter mistakes. Passing this screen does not establish +that metadata is safe to expose or that it contains no credentials. If you see a `ValueError` like `ctx_metadata may not contain credential-shaped keys`, migrate the value to `AuthInfo.credential` or a typed credential class. -**Wrong** — credential stored in metadata, round-trips into response context: +**Wrong** — credential stored in metadata; this key is rejected at dispatch: ```python -ctx = RequestContext(metadata={"upstream.api_token": secret}) # ValueError +ctx = RequestContext(metadata={"upstream.api_token": secret}) # Rejected at dispatch ``` **Right** — credential stored in the typed `AuthInfo.credential` field: @@ -90,9 +93,13 @@ ctx = RequestContext(auth_info=auth, metadata={"correlation_id": "req_xyz"}) The credential-shaped key suffix list is in `adcp.decisioning.dispatch._CREDENTIAL_SHAPED_KEY_SUFFIXES` and matches -case-insensitively at any nesting depth: `credential`, `credentials`, `token`, +case-insensitively through nested dictionaries and lists: `credential`, `credentials`, `token`, `secret`, `api_key`, `apikey`, `password`, `bearer`. Keys that don't match -(`correlation_id`, `feature_flag.beta_pricing`, `trace_id`) pass through. +(`correlation_id`, `feature_flag.beta_pricing`, `trace_id`, `tokenizer`) pass through. +This finite suffix list misses other credential names, including plural or embedded +forms such as `api_tokens` and `access_token_value`, and names such as `private_key` +and `authorization`. Other containers, including tuple values, are not traversed. +An unrecognized key or container is not permission to store a secret in metadata. For credentials the framework propagates to upstream calls (governance agents, signal providers, audience activations), use the typed credential classes from diff --git a/src/adcp/decisioning/dispatch.py b/src/adcp/decisioning/dispatch.py index b1510fa72..faad9898d 100644 --- a/src/adcp/decisioning/dispatch.py +++ b/src/adcp/decisioning/dispatch.py @@ -476,14 +476,13 @@ def _strict_validate_platform() -> bool: #: Substring suffixes that flag a ctx_metadata key as credential-shaped. #: Lowercased for case-insensitive matching against the user-supplied -#: key. The list intentionally errs broad — a key like +#: key. This finite list is best-effort screening — a key like #: ``"upstream.api_key"`` belongs in :class:`AuthInfo.credential`, not -#: ``ctx.metadata`` which round-trips into responses. +#: ``ctx.metadata``, which is reserved for non-secret request hints. #: #: Drift policy: when the spec or adopter conventions add a new -#: credential-shaped suffix, append here. The gate is fail-closed by -#: design — false positives require the adopter to rename the key, NOT -#: silently echo the credential. +#: credential-shaped suffix, evaluate its coverage and false positives before +#: adding it. A passing key is not evidence that its value is safe to expose. _CREDENTIAL_SHAPED_KEY_SUFFIXES: tuple[str, ...] = ( "credential", "credentials", @@ -497,13 +496,13 @@ def _strict_validate_platform() -> bool: def _validate_ctx_metadata_credentials(metadata: Any) -> None: - """Fail-closed gate: ctx.metadata must not carry credential-shaped - keys. + """Best-effort credential-key screening, as defense in depth. - The framework projects buyer-supplied ``context`` extensions into - ``tool_ctx.metadata`` and echoes context back on responses per - the AdCP spec. An adopter who treats ``metadata`` as a generic - KV bucket can accidentally round-trip a credential to the buyer. + Standard auth context construction adds framework fields and adopter + principal metadata. Buyer wire ``context`` is echoed separately and is + not projected into ``tool_ctx.metadata`` by the framework. An adopter's + custom context factory or response code can still expose credentials + copied into metadata. Passing this screen is not a no-credential guarantee. The ergonomic path for credentials is :class:`AuthInfo.credential` / typed credential classes (:class:`ApiKeyCredential`, :class:`OAuthCredential`, @@ -511,12 +510,12 @@ def _validate_ctx_metadata_credentials(metadata: Any) -> None: request-scope hints (correlation ids, feature flags, trace ids). Matches against any key whose lowercased form ends with one of - :data:`_CREDENTIAL_SHAPED_KEY_SUFFIXES`. Sub-keys at any nesting - depth count — a buyer-supplied - ``{"upstream": {"api_token": "..."}}`` is rejected the same as - a flat ``{"api_token": "..."}``. + :data:`_CREDENTIAL_SHAPED_KEY_SUFFIXES`, through dictionaries and lists. + Other containers are not traversed. Unlisted names, including some + plural and embedded credential terms, pass this finite suffix screen; + that does not make their contents safe for metadata or response echo. - :raises ValueError: when any credential-shaped key is found. The + :raises ValueError: when a screened key matches a listed suffix. The exception message names the offending key path so the adopter knows which field to migrate to ``AuthInfo.credential``. """ @@ -560,12 +559,11 @@ def _validate_ctx_metadata_credentials(metadata: Any) -> None: def _walk_ctx_metadata_list(items: list[Any]) -> None: - """Recurse into a list collected from ``ctx_metadata`` and reject - any credential-shaped key found in a dict element. + """Recurse into a metadata list and screen dictionary keys by suffix. Nested lists are walked through this same function. Non-dict, - non-list items (strings, numbers, None) are ignored — only - container types can hide a credential-shaped key. + non-list items (including tuple values) are ignored. This finite + traversal is best-effort screening, not a no-credential guarantee. """ for index, item in enumerate(items): if isinstance(item, dict): @@ -1198,17 +1196,11 @@ def _build_request_context( auth_principal = auth_info.principal if auth_info is not None else None - # ctx_metadata credential gate — fail-closed before any platform - # method sees the metadata. Buyers can populate ``context`` - # extensions on the wire request that the framework projects into - # ``tool_ctx.metadata``; an adopter who treats ``metadata`` as a - # general-purpose KV bucket might shove a credential through it, - # only to discover the value round-trips into the response (the - # framework echoes context into responses per the AdCP spec). - # The ergonomic path for credentials is :class:`AuthInfo.credential` - # / typed credential classes; ``metadata`` is for non-secret - # request-scope hints. See the "ctx_metadata: write-only credentials - # prohibited" section in CLAUDE.md. + # Best-effort screening of adopter metadata before platform dispatch. + # Wire context echo is separate; metadata must contain only non-secret + # request hints even when its keys pass this finite denylist. Credentials + # belong in AuthInfo.credential / typed credential classes. See the + # "ctx_metadata: write-only credentials prohibited" section in CLAUDE.md. _validate_ctx_metadata_credentials(tool_ctx.metadata) # Composite cache scope key when store is supplied (production From 8dc98c7ecaedabaa1612aa6e8744562a6682035a Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Tue, 22 Sep 2026 12:59:54 +0000 Subject: [PATCH 02/12] fix(reporting): preserve typed selectors and exact feed numbers Explicit media-buy scopes no longer acquire the all-media selector, and exact revision reads omit absent aggregate defaults. Preserve aggregate defaults and reject explicitly conflicting selectors in generated models and regeneration. Preserve received integral decimal lexemes exactly in reporting feed bindings; reject fractional precision loss before snapshots or continuations change. Keep financial receipt canonicalization and bounded pagination unchanged. Route mounted A2A feed number failures through the ordinary closed preflight. Regress typed production MCP/A2A onboarding, exact zero/503-row reads, principal isolation, and numeric cursor/checkpoint replay across memory and PostgreSQL. --- scripts/post_generate_fixes.py | 156 +++++++++ src/adcp/reporting/feed/request.py | 18 +- src/adcp/server/a2a_server.py | 17 +- .../core/reporting_delivery_config.py | 40 ++- .../get_media_buy_delivery_request.py | 24 +- .../reporting/_production_packaging.py | 6 + .../reporting/_scope_onboarding_server.py | 293 +++++++++++++++++ .../test_reporting_production_feed_numbers.py | 282 ++++++++++++++++ .../test_reporting_production_scope.py | 306 ++++++++++++++++++ tests/test_reporting_exact_request_models.py | 133 ++++++++ .../test_reporting_feed_numeric_parameters.py | 126 ++++++++ tests/test_reporting_scope_models.py | 156 +++++++++ tests/test_reporting_selector_generation.py | 95 ++++++ 13 files changed, 1640 insertions(+), 12 deletions(-) create mode 100644 tests/conformance/reporting/_scope_onboarding_server.py create mode 100644 tests/conformance/reporting/test_reporting_production_feed_numbers.py create mode 100644 tests/conformance/reporting/test_reporting_production_scope.py create mode 100644 tests/test_reporting_exact_request_models.py create mode 100644 tests/test_reporting_feed_numeric_parameters.py create mode 100644 tests/test_reporting_scope_models.py create mode 100644 tests/test_reporting_selector_generation.py diff --git a/scripts/post_generate_fixes.py b/scripts/post_generate_fixes.py index 83b41f1c9..816567255 100644 --- a/scripts/post_generate_fixes.py +++ b/scripts/post_generate_fixes.py @@ -2593,6 +2593,161 @@ def fix_unchanged_literal_defaults() -> None: print(" No unchanged field defaults needed fixing") +def fix_reporting_request_selectors() -> None: + """Preserve reporting selector modes that codegen flattens into one model. + + A const is not a default for an alternative selector. Likewise an + aggregate default is not legal in an exact-revision request. Keep these + contracts on the generated models, including any self-contained clones, + so nesting and non-client serialization obey the same rules. + """ + scope = json.loads((SCHEMA_DIR / "core/reporting-delivery-config.json").read_text())[ + "properties" + ]["scope"] + if ( + set(scope["properties"]) != {"all_media_buys", "media_buy_ids"} + or scope["minProperties"] != 1 + or scope["maxProperties"] != 1 + or scope["properties"]["all_media_buys"] != {"type": "boolean", "const": True} + ): + raise ValueError("reporting scope schema changed; revisit selector repair") + delivery = json.loads( + (SCHEMA_DIR / "media-buy/get-media-buy-delivery-request.json").read_text() + ) + exact = next( + rule + for rule in delivery["allOf"] + if rule.get("if") == {"required": ["reporting_revision_id"]} + ) + forbidden = tuple(item["required"][0] for item in exact["then"]["not"]["anyOf"]) + repaired = {"scope": 0, "exact": 0} + for path in sorted(OUTPUT_DIR.rglob("*.py")): + original = path.read_text() + if "all_media_buys:" not in original and "class GetMediaBuyDeliveryRequest" not in original: + continue + offsets = [0] + for line in original.splitlines(keepends=True): + offsets.append(offsets[-1] + len(line)) + changes: list[tuple[int, int, str]] = [] + matched = False + for node in ast.parse(original).body: + if not isinstance(node, ast.ClassDef): + continue + fields = { + field.target.id: field + for field in node.body + if isinstance(field, ast.AnnAssign) and isinstance(field.target, ast.Name) + } + methods = {method.name for method in node.body if isinstance(method, ast.FunctionDef)} + if set(fields) == {"all_media_buys", "media_buy_ids"}: + repaired["scope"] += 1 + matched = True + field = fields["all_media_buys"] + if field.value is None: + raise ValueError("reporting scope lost its generated default") + annotation = field.annotation + if isinstance(annotation, ast.Subscript) and isinstance( + annotation.slice, ast.Tuple + ): + annotation = annotation.slice.elts[0] + for part, replacement in ( + (annotation, "Literal[True] | None"), + (field.value, "None"), + ): + changes.append( + ( + offsets[part.lineno - 1] + part.col_offset, + offsets[part.end_lineno - 1] + part.end_col_offset, + replacement, + ) + ) + addition = f""" + @model_validator(mode='before') + @classmethod + def _select_reporting_scope(cls, value: Any) -> Any: + if not isinstance(value, Mapping): + return value + if 'all_media_buys' in value and 'media_buy_ids' in value: + raise ValueError('reporting scope requires exactly one selector') + if 'all_media_buys' in value and value['all_media_buys'] is not True: + raise ValueError('all_media_buys must be true') + if 'media_buy_ids' in value: + if value['media_buy_ids'] is None: + raise ValueError('media_buy_ids must be a nonempty unique list') + return value + # Preserve the typed empty-scope convenience without mutating its input. + return {{**value, 'all_media_buys': True}} + + @model_validator(mode='after') + def _unique_reporting_scope(self) -> {node.name}: + if self.media_buy_ids is not None: + ids = [getattr(item, 'root', item) for item in self.media_buy_ids] + if len(ids) != len(set(ids)): + raise ValueError('media_buy_ids must be unique') + return self + + @model_serializer(mode='wrap') + def _serialize_reporting_scope(self, handler: SerializerFunctionWrapHandler) -> dict[str, Any]: + value: dict[str, Any] = handler(self) + for name in ('all_media_buys', 'media_buy_ids'): + if value.get(name) is None: + value.pop(name, None) + return value +""" + marker = "_select_reporting_scope" + elif re.fullmatch(r"GetMediaBuyDeliveryRequest\d*", node.name): + if not {*forbidden, "reporting_revision_id", "pagination"} <= fields.keys(): + raise ValueError("delivery request fields changed; revisit selector repair") + repaired["exact"] += 1 + matched = True + addition = f""" + @model_validator(mode='after') + def _validate_delivery_selector_mode(self) -> {node.name}: + if self.reporting_revision_id is not None: + if self.model_fields_set.intersection({forbidden!r}): + raise ValueError('exact revision requests forbid aggregate selectors, even false or null') + elif self.pagination is not None: + raise ValueError('pagination requires reporting_revision_id') + return self + + @model_serializer(mode='wrap') + def _serialize_delivery_selector_mode(self, handler: SerializerFunctionWrapHandler) -> dict[str, Any]: + value: dict[str, Any] = handler(self) + if self.reporting_revision_id is not None: + for name in {forbidden!r}: + if name not in self.model_fields_set: + value.pop(name, None) + return value +""" + marker = "_validate_delivery_selector_mode" + else: + continue + if marker not in methods: + changes.append((offsets[node.end_lineno], offsets[node.end_lineno], addition)) + if not matched: + continue + source = original + for start, end, replacement in sorted(changes, reverse=True): + source = source[:start] + replacement + source[end:] + imports = ( + "from collections.abc import Mapping\n" + "from typing import Any\n" + "from pydantic import SerializerFunctionWrapHandler, model_serializer, model_validator\n" + ) + if "from pydantic import SerializerFunctionWrapHandler," not in source: + source = source.replace( + "from __future__ import annotations\n", + "from __future__ import annotations\n\n" + imports, + 1, + ) + ast.parse(source) + if source != original: + path.write_text(source) + print(f" {path.relative_to(OUTPUT_DIR)}: reporting selector modes") + if not all(repaired.values()): + raise ValueError("expected generated reporting selector model missing") + + def fix_reporting_capability_defaults() -> None: """Keep optional reporting promises absent in both generated model graphs. @@ -6052,6 +6207,7 @@ def main(argv: list[str] | None = None): widen_extension_point_lists_to_sequence, fix_canceled_literal_defaults, fix_unchanged_literal_defaults, + fix_reporting_request_selectors, fix_reporting_capability_defaults, fix_protocol_envelope_status_default, fix_trusted_match_runtime_validators, diff --git a/src/adcp/reporting/feed/request.py b/src/adcp/reporting/feed/request.py index 90b3abd40..db8c6d2b5 100644 --- a/src/adcp/reporting/feed/request.py +++ b/src/adcp/reporting/feed/request.py @@ -43,12 +43,14 @@ def _semantic_numbers(value: Any) -> Any: def transport_parameters(params: dict[str, Any]) -> dict[str, Any]: - """Read ordinary JSON without rounding the raw pagination integer first. + """Normalize received JSON numbers without changing their semantic value. The shared transport decoder retains decimal lexemes for financial receipt - admission. Feed context/vendor JSON uses normal finite JSON numbers, while - A2A's exact 1.0 spelling of a page limit must remain distinguishable from a - non-integer such as 1.000000000000000000001. + admission. Feed context/vendor JSON permits ordinary finite fractions, but + conversion must not alias two different received lexemes. Integral Decimal + values become exact integers; fractional values must survive the ordinary + JSON float round trip. This cannot recover precision a client lost before + transmission (for example in a protobuf Struct). """ def convert(value: Any) -> Any: @@ -56,6 +58,14 @@ def convert(value: Any) -> Any: number = float(value) if not math.isfinite(number): raise ValueError("feed parameters require finite JSON numbers") + if value == value.to_integral_value(): + # The finite-float check above also bounds exponent expansion. + # Do not pass exact large integers through a binary float. + return int(value) + if Decimal(repr(number)) != value: + raise ValueError( + "feed fractional parameters require an exact JSON number round trip" + ) return number if type(value) is dict: return {key: convert(item) for key, item in value.items()} diff --git a/src/adcp/server/a2a_server.py b/src/adcp/server/a2a_server.py index 91d944c0c..cc2a0c924 100644 --- a/src/adcp/server/a2a_server.py +++ b/src/adcp/server/a2a_server.py @@ -633,18 +633,25 @@ def _parse_request(self, context: RequestContext) -> tuple[str | None, dict[str, parsed = self._default_parse_request(context) except ValueError: # The protobuf JSON printer refuses non-finite numbers. - # A standard raw receipt invocation still reaches mandatory + # Standard raw receipt/feed invocations still reach their # strict preflight and a closed INVALID_REQUEST, without - # logging a protobuf serialization exception. + # logging a protobuf serialization exception. Feed recovery + # is confined to an explicitly mounted reporting store. if request is not None: from adcp.reporting.receipts.transport import ( RAW_RECEIPT_BODY_SCOPE_KEY, a2a_receipt_parameters, ) - raw = a2a_receipt_parameters(request.scope.get(RAW_RECEIPT_BODY_SCOPE_KEY)) - if raw is not None: - return "sync_reporting_receipts", raw + tasks = ["sync_reporting_receipts"] + if getattr(self._handler, "reporting_feed_store", None) is not None: + tasks.append("get_reporting_status") + for task in tasks: + raw = a2a_receipt_parameters( + request.scope.get(RAW_RECEIPT_BODY_SCOPE_KEY), task=task + ) + if raw is not None: + return task, raw raise if request is not None and ( parsed[0] == "sync_reporting_receipts" diff --git a/src/adcp/types/generated_poc/core/reporting_delivery_config.py b/src/adcp/types/generated_poc/core/reporting_delivery_config.py index b16dc00c0..51a03accc 100644 --- a/src/adcp/types/generated_poc/core/reporting_delivery_config.py +++ b/src/adcp/types/generated_poc/core/reporting_delivery_config.py @@ -1,9 +1,13 @@ # generated by datamodel-codegen: # filename: core/reporting_delivery_config.json -# timestamp: 2026-09-14T14:16:02+00:00 +# timestamp: 2026-09-22T12:02:05+00:00 from __future__ import annotations +from collections.abc import Mapping +from typing import Any +from pydantic import SerializerFunctionWrapHandler, model_serializer, model_validator + from adcp.types._str_enum import StrEnum from typing import Annotated, Literal @@ -24,11 +28,43 @@ class Scope(AdCPBaseModel): model_config = ConfigDict( extra='forbid', ) - all_media_buys: Literal[True] = True + all_media_buys: Literal[True] | None = None media_buy_ids: Annotated[ list[reporting_coverage.ReportingMediaBuyId] | None, Field(min_length=1) ] = None + @model_validator(mode='before') + @classmethod + def _select_reporting_scope(cls, value: Any) -> Any: + if not isinstance(value, Mapping): + return value + if 'all_media_buys' in value and 'media_buy_ids' in value: + raise ValueError('reporting scope requires exactly one selector') + if 'all_media_buys' in value and value['all_media_buys'] is not True: + raise ValueError('all_media_buys must be true') + if 'media_buy_ids' in value: + if value['media_buy_ids'] is None: + raise ValueError('media_buy_ids must be a nonempty unique list') + return value + # Preserve the typed empty-scope convenience without mutating its input. + return {**value, 'all_media_buys': True} + + @model_validator(mode='after') + def _unique_reporting_scope(self) -> Scope: + if self.media_buy_ids is not None: + ids = [getattr(item, 'root', item) for item in self.media_buy_ids] + if len(ids) != len(set(ids)): + raise ValueError('media_buy_ids must be unique') + return self + + @model_serializer(mode='wrap') + def _serialize_reporting_scope(self, handler: SerializerFunctionWrapHandler) -> dict[str, Any]: + value: dict[str, Any] = handler(self) + for name in ('all_media_buys', 'media_buy_ids'): + if value.get(name) is None: + value.pop(name, None) + return value + class CoverageRequirement(StrEnum): full = 'full' diff --git a/src/adcp/types/generated_poc/media_buy/get_media_buy_delivery_request.py b/src/adcp/types/generated_poc/media_buy/get_media_buy_delivery_request.py index d8739e564..a35e3d29a 100644 --- a/src/adcp/types/generated_poc/media_buy/get_media_buy_delivery_request.py +++ b/src/adcp/types/generated_poc/media_buy/get_media_buy_delivery_request.py @@ -1,9 +1,13 @@ # generated by datamodel-codegen: # filename: media_buy/get_media_buy_delivery_request.json -# timestamp: 2026-09-13T18:52:32+00:00 +# timestamp: 2026-09-22T12:02:05+00:00 from __future__ import annotations +from collections.abc import Mapping +from typing import Any +from pydantic import SerializerFunctionWrapHandler, model_serializer, model_validator + from typing import Annotated from adcp.types.base import AdCPBaseModel @@ -539,3 +543,21 @@ class GetMediaBuyDeliveryRequest(AdcpVersionEnvelope): ] = None context: context_1.ContextObject | None = None ext: ext_1.ExtensionObject | None = None + + @model_validator(mode='after') + def _validate_delivery_selector_mode(self) -> GetMediaBuyDeliveryRequest: + if self.reporting_revision_id is not None: + if self.model_fields_set.intersection(('media_buy_ids', 'start_date', 'end_date', 'status_filter', 'requested_metrics', 'reporting_dimensions', 'attribution_window', 'include_package_daily_breakdown', 'time_granularity', 'include_window_breakdown')): + raise ValueError('exact revision requests forbid aggregate selectors, even false or null') + elif self.pagination is not None: + raise ValueError('pagination requires reporting_revision_id') + return self + + @model_serializer(mode='wrap') + def _serialize_delivery_selector_mode(self, handler: SerializerFunctionWrapHandler) -> dict[str, Any]: + value: dict[str, Any] = handler(self) + if self.reporting_revision_id is not None: + for name in ('media_buy_ids', 'start_date', 'end_date', 'status_filter', 'requested_metrics', 'reporting_dimensions', 'attribution_window', 'include_package_daily_breakdown', 'time_granularity', 'include_window_breakdown'): + if name not in self.model_fields_set: + value.pop(name, None) + return value diff --git a/tests/conformance/reporting/_production_packaging.py b/tests/conformance/reporting/_production_packaging.py index 250d147c3..ad9de9d21 100644 --- a/tests/conformance/reporting/_production_packaging.py +++ b/tests/conformance/reporting/_production_packaging.py @@ -50,6 +50,7 @@ def production_modules(): for name in ( "_version.py", "server/mcp_tools.py", + "server/a2a_server.py", "reporting/feed/request.py", "reporting/feed/errors.py", "reporting/ledger/status_server.py", @@ -83,6 +84,8 @@ def production_modules(): "types/v32.py", "types/versioned.py", "types/generated_poc/core/reporting_delivery_capabilities.py", + "types/generated_poc/core/reporting_delivery_config.py", + "types/generated_poc/media_buy/get_media_buy_delivery_request.py", "types/generated_poc/bundled/protocol/get_adcp_capabilities_response.py", ) ] @@ -254,6 +257,9 @@ def installed_production(root, python, wheel, source, *, label, driver_absent): "tests/conformance/reporting/test_reporting_schedule_schema.py", "tests/test_reporting_revision_ownership.py", "tests/test_reporting_capability_models.py", + "tests/test_reporting_scope_models.py", + "tests/test_reporting_exact_request_models.py", + "tests/test_reporting_feed_numeric_parameters.py", "tests/test_reporting_production_public.py", "tests/test_schema_datetime_formats.py", "tests/test_rc4_adoption.py", diff --git a/tests/conformance/reporting/_scope_onboarding_server.py b/tests/conformance/reporting/_scope_onboarding_server.py new file mode 100644 index 000000000..75dd78b73 --- /dev/null +++ b/tests/conformance/reporting/_scope_onboarding_server.py @@ -0,0 +1,293 @@ +"""Separate HTTP process using the real production composition and rc.4 MCP mount.""" + +import asyncio +import copy +import importlib.metadata +import json +import socket +import sys +from contextlib import asynccontextmanager +from dataclasses import replace +from decimal import Decimal +from pathlib import Path + +import uvicorn + +import adcp +from adcp.reporting.ledger.store import LedgerConflictError +from adcp.reporting.production.configuration import ReportingConfigurationAdmission +from adcp.server.auth import BearerTokenAuth, Principal, auth_context_factory +from adcp.server.serve import _build_mcp_and_a2a_app + +from ._feed_support import MountedFeed, feed_harness, feed_request, mixed_case +from ._production_support import production_harness +from .test_reporting_production_configuration import state_for, wire_configuration + + +@asynccontextmanager +async def scope_fixture(backend, root): + audit = {"calls": [], "admissions": []} + selected = {} + + def save(): + temporary = root / "audit.next" + temporary.write_text(json.dumps(audit)) + temporary.replace(root / "audit.json") + + async def account_task(request, context, admit): + h = selected["h"] + audit["calls"].append(copy.deepcopy(request)) + save() + accounts = [] + for entry in request["accounts"]: + who = await h.production.handler._authorize(entry, context) + config, binding = selected[who.account_id] + wire = entry["reporting_delivery_configs"][0] + if set(wire["scope"]) == {"all_media_buys"}: + # The accepted production fixture supports explicit full scopes. + # Reaching this branch proves MCP input acceptance, not dynamic + # all-buy production support or admission of a broader scope. + raise LedgerConflictError("UNSUPPORTED_FEATURE", "explicit scope required") + await admit( + ReportingConfigurationAdmission( + h.production.offerings[0].offering_id, + config, + binding, + configuration_wire=wire, + ) + ) + selected["accepted"][who.account_id] = copy.deepcopy(wire) + audit["admissions"].append( + { + "account_id": who.account_id, + "consumer_id": who.consumer_id, + "scope": wire["scope"], + } + ) + state = state_for(h, copy.deepcopy(wire)) + accounts.append( + { + "account_id": who.account_id, + "brand": {"domain": "advertiser.example.test"}, + "operator": "buyer.example.test", + "action": "unchanged", + "status": "active", + "billing": "operator", + "timezone": "UTC", + "reporting_delivery_configs": [state], + } + ) + save() + return {"accounts": accounts} + + async with production_harness( + backend, root / "destination.sqlite", count=0, account_handler=account_task + ) as h: + selected.update(h=h, accepted={}) + requests = {} + tokens = {} + for account, caller in (("acct_a", "urn:buyer:alpha"), ("acct_b", "urn:buyer:beta")): + config = replace( + h.item.config, + account_id=account, + delivery_config_id="shared-config", + media_buy_ids=("shared-media-buy",), + ) + binding = replace( + h.item.binding, generation_key=config.generation_key, consumer_id=caller + ) + h.item.writer.grant(binding) + h.production.offerings[0].producer._source.bind_generation(config) + h.authorized_bindings.add((account, caller)) + selected[account] = config, binding + wire = wire_configuration(h) + wire.update( + delivery_config_id="shared-config", scope={"media_buy_ids": ["shared-media-buy"]} + ) + requests[account] = { + "adcp_version": "3.2-rc.4", + "idempotency_key": "public-scope-" + account, + "accounts": [ + {"account": {"account_id": account}, "reporting_delivery_configs": [wire]} + ], + } + tokens[account] = Principal(caller_identity=caller, tenant_id="shared-tenant") + # Coverage in the returned states is the requested denominator too. + h.item.config = selected["acct_a"][0] + yield h, h.production.handler, tokens, {"requests": requests}, audit, save + + +@asynccontextmanager +async def exact_fixture(backend, root, count): + # This fixture runs the owned production worker; it neither seeds a + # successful revision nor calls the materializer manually. The source + # clock is fixed at the first period's end; the configuration stays active + # so autonomous materialization is eligible. + async with production_harness( + backend, + root / "destination.sqlite", + count=count, + source_publication=True, + reconciled=True, + poll_seconds=0.02, + ) as h: + await h.production.activate(account_id=h.item.config.account_id) + for _ in range(600): + revisions = await h.store.list_revisions( + account_id=h.item.config.account_id, + reporting_obligation_id=h.item.obligation.reporting_obligation_id, + ) + if len(revisions) == 1 and h.item.writer.writes == 1: + outcomes = await h.item.outcomes() + if outcomes: + break + await asyncio.sleep(0.05) + else: + raise AssertionError( + { + "reason": "public production worker did not materialize within 30 seconds", + "revisions": len(revisions), + "writes": h.item.writer.writes, + "source_requests": len(h.production.offerings[0].producer._source.requests), + } + ) + revision = revisions[0] + assert revision.finality == "official" and revision.row_count == count + audit = {"http": []} + + def save(): + temporary = root / "audit.next" + temporary.write_text(json.dumps(audit)) + temporary.replace(root / "audit.json") + + yield h, h.production.handler, { + "acct_a": Principal( + caller_identity=h.item.binding.consumer_id, tenant_id="shared-tenant" + ), + "acct_b": Principal(caller_identity="urn:buyer:other", tenant_id="shared-tenant"), + }, { + "request": { + "account": {"account_id": h.item.config.account_id}, + "reporting_revision_id": revision.reporting_revision_id, + "pagination": {"max_results": 100}, + }, + "revision": {"row_count": count, "content_sha256": revision.revision_content_sha256}, + "source_requests": len(h.production.offerings[0].producer._source.requests), + "destination_writes": h.item.writer.writes, + }, audit, save + + +@asynccontextmanager +async def feed_fixture(backend, root, notifications): + async with feed_harness(backend, notifications=notifications) as h: + case, _, _ = await mixed_case(h) + mounted = MountedFeed(h, version="3.2-rc.4") + mounted.authorize(case, token="acct_a") + audit = {"http": []} + + def save(): + temporary = root / "audit.next" + temporary.write_text(json.dumps(audit)) + temporary.replace(root / "audit.json") + + yield h, mounted.handler, mounted.tokens, { + "request": feed_request(case, adcp_version="3.2-rc.4"), + }, audit, save + + +async def run(backend, root, socket_fd, scenario, count, notifications): + fixture = ( + scope_fixture(backend, root) + if scenario == "scope" + else ( + exact_fixture(backend, root, count) + if scenario == "exact" + else feed_fixture(backend, root, notifications) + ) + ) + async with fixture as (h, handler, tokens, ready, audit, save): + sock = socket.socket(fileno=socket_fd) + port = sock.getsockname()[1] + app = _build_mcp_and_a2a_app( + handler, + name="public-reporting-wire", + port=port, + host="127.0.0.1", + instructions=None, + test_controller=None, + context_factory=auth_context_factory, + auth=BearerTokenAuth(validate_token=tokens.get), + allowed_hosts=["127.0.0.1", "localhost"], + public_url=f"http://127.0.0.1:{port}", + stateless_http=True, + ) + + async def observed(scope, receive, send): + if scope["type"] != "http" or scope["method"] != "POST": + return await app(scope, receive, send) + before = await h.image() + chunks = [] + + async def capture(): + message = await receive() + if message["type"] == "http.request": + chunks.append(message.get("body", b"")) + return message + + await app(scope, capture, send) + try: + envelope = json.loads(b"".join(chunks), parse_float=Decimal) + except ValueError: + envelope = {} + method = envelope.get("method") + if method in {"tools/call", "message/send", "SendMessage"}: + # Request-local comparison includes snapshots and continuation + # state. It records no authentication headers or body values. + record = { + "method": method, + "store_unchanged": before == await h.image(), + } + if scenario == "feed": + params = envelope["params"] + if method == "tools/call": + params = params["arguments"] + else: + params = params["message"]["parts"][0]["data"]["parameters"] + number = params.get("ext", {}).get("vendor", {}).get("n") + # Only the fixture's numeric control is observed, before + # server conversion. This distinguishes client rounding. + record["received_number"] = {"type": type(number).__name__, "text": str(number)} + audit.setdefault("http", []).append(record) + save() + + server = uvicorn.Server(uvicorn.Config(observed, log_level="warning", lifespan="on")) + running = asyncio.create_task(server.serve(sockets=[sock])) + try: + while not server.started: + if running.done(): + await running + raise RuntimeError("HTTP server stopped before startup") + await asyncio.sleep(0.01) + save() + (root / "ready.json").write_text( + json.dumps( + { + **ready, + "python": list(sys.version_info[:3]), + "adcp_file": adcp.__file__, + "pydantic": importlib.metadata.version("pydantic"), + "mcp": importlib.metadata.version("mcp"), + } + ) + ) + await running + finally: + server.should_exit = True + await running + + +def main(): + backend, root, descriptor, scenario, count, notifications = sys.argv[1:] + asyncio.run( + run(backend, Path(root), int(descriptor), scenario, int(count), notifications == "true") + ) diff --git a/tests/conformance/reporting/test_reporting_production_feed_numbers.py b/tests/conformance/reporting/test_reporting_production_feed_numbers.py new file mode 100644 index 000000000..6b720d34c --- /dev/null +++ b/tests/conformance/reporting/test_reporting_production_feed_numbers.py @@ -0,0 +1,282 @@ +"""Raw lexemes and typed reporting clients over separate-process HTTP mounts.""" + +import json +from copy import deepcopy +from decimal import Decimal + +import httpx +import pytest + +from adcp.types import GetReportingStatusRequest + +from ._receipt_transport import error_code +from .test_reporting_production_scope import ( + audited, + onboarding_server, + public_client, +) + + +@pytest.fixture(autouse=True) +def _a2a_compat_send_and_aggregate(): + # Real SDK network streams, without the repository's unit-mock shim. + pass + + +def assert_invalid_checkpoint(result): + assert not result.success and result.data is None, result + if result.adcp_error is not None: + assert result.adcp_error["code"] == "INVALID_CHECKPOINT", result + else: + # The MCP adapter preserves the closed public task error as text. + assert result.error.endswith( + "restart the reporting walk; this position is unavailable for this scope" + ), result + + +async def raw_call(client, route, request, *, lexeme=None): + headers = {"Authorization": "Bearer acct_a", "Content-Type": "application/json"} + if route == "mcp": + headers["Accept"] = "application/json, text/event-stream" + initial = await client.post( + "/mcp/", + headers=headers, + json={ + "jsonrpc": "2.0", + "id": 0, + "method": "initialize", + "params": { + "protocolVersion": "2025-06-18", + "capabilities": {}, + "clientInfo": {"name": "exact-reporting-numbers", "version": "1"}, + }, + }, + ) + assert initial.status_code == 200 + envelope = { + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": {"name": "get_reporting_status", "arguments": request}, + } + path = "/mcp/" + else: + v1 = route == "a2a-1.0" + headers["A2A-Version"] = "1.0" if v1 else "0.3" + part = {"data": {"skill": "get_reporting_status", "parameters": request}} + if not v1: + part["kind"] = "data" + envelope = { + "jsonrpc": "2.0", + "id": "1", + "method": "SendMessage" if v1 else "message/send", + "params": { + "message": { + "messageId": "exact-number-message", + "role": "ROLE_USER" if v1 else "user", + "parts": [part], + } + }, + } + path = "/" + wire = json.dumps(envelope) + if lexeme is not None: + assert wire.count('"__exact_lexeme__"') == 1 + wire = wire.replace('"__exact_lexeme__"', lexeme) + response = await client.post(path, headers=headers, content=wire) + assert response.status_code in {200, 400}, response.text + payload = next( + (json.loads(line[6:]) for line in response.text.splitlines() if line.startswith("data: ")), + None, + ) + if payload is None: + payload = response.json() + result = payload.get("result", payload) + if "structuredContent" in result: + return result["structuredContent"] + if "task" in result: + result = result["task"] + for artifact in result.get("artifacts", []): + for part in artifact.get("parts", []): + if "data" in part: + return part["data"] + return result + + +@pytest.mark.parametrize("backend", ["memory", "postgres"]) +@pytest.mark.parametrize("notifications", [False, True]) +async def test_large_received_decimal_cannot_replay_a_different_filter( + backend, notifications, tmp_path +): + async with onboarding_server( + backend, tmp_path, scenario="feed", notifications=notifications + ) as (uri, ready): + async with httpx.AsyncClient(base_url=uri.removesuffix("/mcp/"), timeout=30) as client: + routes = ("mcp", "a2a-0.3", "a2a-1.0") + for index, route in enumerate(routes): + other = routes[(index + 1) % len(routes)] + for original, changed in ( + ("9007199254740993.0", "9007199254740992.0"), + ("9007199254740995.0", "9007199254740996.0"), + ("9.007199254740993e15", "9007199254740992"), + ): + raw = {**ready["request"], "ext": {"vendor": {"n": "__exact_lexeme__"}}} + first = await audited(tmp_path, raw_call(client, route, raw, lexeme=original)) + assert first["pagination"]["has_more"], first + continued = deepcopy(raw) + continued["pagination"] = { + "cursor": first["pagination"]["cursor"], + "max_results": 100, + } + # A bare exact integer and its decimal/exponent spellings + # remain the same semantic filter, across protocols. + last = await audited( + tmp_path, + raw_call(client, other, continued, lexeme=str(int(Decimal(original)))), + ) + assert last["pagination"]["has_more"] is False + assert last["ledger_snapshot_id"] == first["ledger_snapshot_id"] + assert last["changes_checkpoint"] == first["changes_checkpoint"] + for position in ( + {"pagination": continued["pagination"]}, + {"changes_after": last["changes_checkpoint"]}, + ): + rejected = await audited( + tmp_path, raw_call(client, other, {**raw, **position}, lexeme=changed) + ) + assert "pagination" not in rejected, rejected + assert error_code(rejected) == "INVALID_CHECKPOINT", rejected + after = json.loads((tmp_path / "audit.json").read_text()) + assert after["http"][-1]["store_unchanged"] + empty = await audited( + tmp_path, + raw_call( + client, + route, + { + **raw, + "changes_after": last["changes_checkpoint"], + }, + lexeme=original, + ), + ) + assert empty["pagination"] == {"total_count": 0, "has_more": False} + + for lexeme in ( + "0.100000000000000000001", + "9007199254740993.25", + "1e-400", + "1e400", + "NaN", + "Infinity", + "-Infinity", + ): + raw = { + **ready["request"], + "ext": { + "vendor": { + "n": "__exact_lexeme__", + "private": "numeric-redaction-control", + } + }, + } + rejected = await audited(tmp_path, raw_call(client, route, raw, lexeme=lexeme)) + assert "pagination" not in rejected + if "error" in rejected: + assert rejected["error"]["code"] in {-32700, -32600, -32602} + else: + assert error_code(rejected) in {"INVALID_REQUEST", "VALIDATION_ERROR"} + assert "numeric-redaction-control" not in json.dumps(rejected) + assert lexeme not in json.dumps(rejected) + assert json.loads((tmp_path / "audit.json").read_text())["http"][-1][ + "store_unchanged" + ] + + +@pytest.mark.parametrize("backend", ["memory", "postgres"]) +async def test_typed_numbers_cross_protocol_and_client_rounding_are_distinct(backend, tmp_path): + async with onboarding_server(backend, tmp_path, scenario="feed") as (uri, ready): + for version in ("0.3", "1.0"): + async with ( + public_client(uri, "acct_a") as mcp, + public_client(uri, "acct_a", "a2a-" + version) as a2a, + ): + for start, finish in ((mcp, a2a), (a2a, mcp)): + raw = { + **ready["request"], + "ext": { + "vendor": { + "n": 1, + "fraction": 4.8, + "nested": [0.5, True, False, "1", None], + } + }, + } + first = await audited( + tmp_path, + start.get_reporting_status(GetReportingStatusRequest.model_validate(raw)), + ) + assert first.success, first + initial = first.data.model_dump(mode="json", exclude_unset=True) + continued = { + **raw, + "pagination": { + "max_results": 100, + "cursor": initial["pagination"]["cursor"], + }, + } + last = await audited( + tmp_path, + finish.get_reporting_status( + GetReportingStatusRequest.model_validate(continued) + ), + ) + assert last.success, last + final = last.data.model_dump(mode="json", exclude_unset=True) + assert final["pagination"] == {"total_count": 6, "has_more": False} + assert final["ledger_snapshot_id"] == initial["ledger_snapshot_id"] + for changed in (True, "1", 0.5, None): + for position in ( + {"pagination": continued["pagination"]}, + {"changes_after": final["changes_checkpoint"]}, + ): + negative = deepcopy({**raw, **position}) + negative["ext"]["vendor"]["n"] = changed + denied = await audited( + tmp_path, + finish.get_reporting_status( + GetReportingStatusRequest.model_validate(negative) + ), + ) + assert_invalid_checkpoint(denied) + assert json.loads((tmp_path / "audit.json").read_text())["http"][-1][ + "store_unchanged" + ] + exact = {**ready["request"], "ext": {"vendor": {"n": 9007199254740993}}} + first = await audited( + tmp_path, + mcp.get_reporting_status(GetReportingStatusRequest.model_validate(exact)), + ) + assert first.success, first + assert json.loads((tmp_path / "audit.json").read_text())["http"][-1][ + "received_number" + ] == { + "type": "int", + "text": "9007199254740993", + } + for position in ( + {"pagination": {"cursor": first.data.pagination.cursor}}, + {"changes_after": first.data.changes_checkpoint}, + ): + denied = await audited( + tmp_path, + a2a.get_reporting_status( + GetReportingStatusRequest.model_validate({**exact, **position}) + ), + ) + assert_invalid_checkpoint(denied) + audit = json.loads((tmp_path / "audit.json").read_text())["http"][-1] + assert Decimal(audit["received_number"]["text"]) == 9007199254740992 + assert audit["store_unchanged"] + # Protobuf rounded before transmission. The server rejects + # the changed filter; it cannot recover the intended value. diff --git a/tests/conformance/reporting/test_reporting_production_scope.py b/tests/conformance/reporting/test_reporting_production_scope.py new file mode 100644 index 000000000..51b2dba0f --- /dev/null +++ b/tests/conformance/reporting/test_reporting_production_scope.py @@ -0,0 +1,306 @@ +"""Public typed onboarding over real MCP HTTP, including installed floor wheels.""" + +import asyncio +import json +import os +import signal +import socket +import subprocess +import sys +from contextlib import asynccontextmanager +from copy import deepcopy +from pathlib import Path + +import pytest +import rfc8785 +from pydantic import ValidationError + +from adcp import ADCPClient, AgentConfig +from adcp.types import GetMediaBuyDeliveryRequest, GetReportingStatusRequest, SyncAccountsRequest +from adcp.validation.schema_loader import get_named_validator + + +@asynccontextmanager +async def onboarding_server(backend, root, *, scenario="scope", count=0, notifications=False): + if backend == "postgres": + if not os.environ.get("ADCP_PG_TEST_URL"): + pytest.skip("requires real PostgreSQL") + pytest.importorskip("psycopg") + fixture_root = Path(__file__).resolve().parents[3] + # Only test fixtures are put on sys.path. SDK imports still come from the + # current interpreter's installation (a wheel in the installed gate). + launcher = ( + "import sys; sys.path.insert(0, sys.argv.pop(1)); " + "from tests.conformance.reporting._scope_onboarding_server import main; main()" + ) + with socket.socket() as listener: + listener.bind(("127.0.0.1", 0)) + listener.listen() + port = listener.getsockname()[1] + with (root / "server.stdout").open("xb") as out, (root / "server.stderr").open("xb") as err: + child = subprocess.Popen( + [ + sys.executable, + "-I", + "-c", + launcher, + str(fixture_root), + backend, + str(root), + str(listener.fileno()), + scenario, + str(count), + str(notifications).lower(), + ], + cwd=root, + stdout=out, + stderr=err, + pass_fds=(listener.fileno(),), + start_new_session=True, + ) + try: + for _ in range(1800): + if (root / "ready.json").exists(): + break + assert child.poll() is None, (root / "server.stderr").read_text() + await asyncio.sleep(0.05) + else: + raise AssertionError("MCP startup exceeded 90 seconds") + yield f"http://127.0.0.1:{port}/mcp/", json.loads((root / "ready.json").read_text()) + finally: + if child.poll() is None: + os.killpg(child.pid, signal.SIGTERM) + try: + await asyncio.to_thread(child.wait, 15) + except subprocess.TimeoutExpired: + os.killpg(child.pid, signal.SIGKILL) + await asyncio.to_thread(child.wait) + + +def public_client(uri, account, route="mcp"): + return ADCPClient( + AgentConfig( + id="scope-" + account, + agent_uri=uri if route == "mcp" else uri.removesuffix("/mcp/"), + protocol="mcp" if route == "mcp" else "a2a", + auth_token=account, + auth_header="Authorization", + auth_type="bearer", + ), + adcp_version="3.2.0-rc.4", + force_a2a_version=route.removeprefix("a2a-") if route != "mcp" else None, + ) + + +@pytest.fixture(autouse=True) +def _a2a_compat_send_and_aggregate(): + # Keep the real a2a-sdk stream; the repository's unit mock shim is not + # appropriate for these separate-process HTTP integrations. + pass + + +async def last_http_audit(root, previous): + for _ in range(100): + value = json.loads((root / "audit.json").read_text()) + if len(value.get("http", [])) > previous: + return value + await asyncio.sleep(0.01) + raise AssertionError("HTTP request did not retain its before/after store comparison") + + +async def audited(root, call): + previous = len(json.loads((root / "audit.json").read_text()).get("http", [])) + result = await call + await last_http_audit(root, previous) + return result + + +def assert_access_denied(result): + # MCP may retain an ADCPTaskError as redacted text, whereas A2A returns + # the structured classification. In either case no account data is sent. + assert not result.success and result.data is None, result + if result.adcp_error is not None: + assert result.adcp_error["code"] == "UNAUTHORIZED", result + else: + assert result.error.endswith( + "the reporting account or authenticated consumer is unavailable" + ), result.error + + +@pytest.mark.parametrize("backend", ["memory", "postgres"]) +async def test_typed_onboarding_reaches_real_mcp_production_admission(backend, tmp_path): + async with onboarding_server(backend, tmp_path) as (uri, ready): + raw = ready["requests"]["acct_a"] + before = deepcopy(raw) + validator = get_named_validator("account/sync-accounts-request.json") + assert validator is not None and not list(validator.iter_errors(raw)) + request = SyncAccountsRequest.model_validate(raw) + async with public_client(uri, "acct_a") as client: + result = await client.sync_accounts(request) + assert result.success, result + assert raw == before + audit = json.loads((tmp_path / "audit.json").read_text()) + assert audit["admissions"] == [ + { + "account_id": "acct_a", + "consumer_id": "urn:buyer:alpha", + "scope": {"media_buy_ids": ["shared-media-buy"]}, + } + ] + + +@pytest.mark.parametrize("backend", ["memory", "postgres"]) +async def test_typed_scope_modes_accounts_and_invalid_requests_over_public_http(backend, tmp_path): + async with onboarding_server(backend, tmp_path) as (uri, ready): + for route in ("mcp", "a2a-0.3", "a2a-1.0"): + for account in ("acct_a", "acct_b"): + request = SyncAccountsRequest.model_validate(ready["requests"][account]) + async with public_client(uri, account, route) as client: + for _ in range(2): + response = await audited(tmp_path, client.sync_accounts(request)) + assert response.success, response + wire = response.data.model_dump(mode="json", exclude_none=True) + assert [a["account_id"] for a in wire["accounts"]] == [account] + assert wire["accounts"][0]["reporting_delivery_configs"][0][ + "configuration" + ]["scope"] == {"media_buy_ids": ["shared-media-buy"]} + own = await audited( + tmp_path, + client.get_reporting_status( + GetReportingStatusRequest.model_validate( + { + "account": {"account_id": account}, + "view": "summary", + } + ) + ), + ) + assert own.success, own + other = "acct_b" if account == "acct_a" else "acct_a" + denied = await audited( + tmp_path, + client.get_reporting_status( + GetReportingStatusRequest.model_validate( + { + "account": {"account_id": other}, + "view": "summary", + } + ) + ), + ) + assert_access_denied(denied) + denied = await audited( + tmp_path, + client.sync_accounts( + SyncAccountsRequest.model_validate(ready["requests"][other]) + ), + ) + assert_access_denied(denied) + + for scope in ({}, {"all_media_buys": True}): + raw = deepcopy(ready["requests"][account]) + raw["accounts"][0]["reporting_delivery_configs"][0]["scope"] = scope + before = json.loads((tmp_path / "audit.json").read_text()) + response = await audited( + tmp_path, client.sync_accounts(SyncAccountsRequest.model_validate(raw)) + ) + assert not response.success and response.data is None, response + assert "VALIDATION_ERROR" not in str(response.error), response + after = await last_http_audit(tmp_path, len(before["http"])) + assert len(after["calls"]) == len(before["calls"]) + 1 + assert after["calls"][-1]["accounts"][0]["reporting_delivery_configs"][0][ + "scope" + ] == {"all_media_buys": True} + assert after["admissions"] == before["admissions"] + assert after["http"][-1]["store_unchanged"] + + bad = deepcopy(ready["requests"][account]) + bad["accounts"][0]["reporting_delivery_configs"][0]["scope"][ + "all_media_buys" + ] = True + with pytest.raises(ValidationError): + SyncAccountsRequest.model_validate(bad) + before = json.loads((tmp_path / "audit.json").read_text()) + # The raw negative control reaches the actual server + # validator; successful onboarding above is always typed. + rejected = await audited(tmp_path, client.adapter.sync_accounts(bad)) + assert ( + not rejected.success and rejected.adcp_error["code"] == "VALIDATION_ERROR" + ), rejected + after = await last_http_audit(tmp_path, len(before["http"])) + assert after["calls"] == before["calls"] + assert after["admissions"] == before["admissions"] + assert after["http"][-1]["store_unchanged"] + + +@pytest.mark.parametrize("backend", ["memory", "postgres"]) +@pytest.mark.parametrize("count", [0, 503]) +async def test_typed_exact_revision_pages_after_actual_publication(backend, count, tmp_path): + import hashlib + + from ._materializer_support import reference_rows + + async with onboarding_server(backend, tmp_path, scenario="exact", count=count) as (uri, ready): + assert ready["source_requests"] == ready["destination_writes"] == 1 + for route in ("mcp", "a2a-0.3", "a2a-1.0"): + raw = deepcopy(ready["request"]) + pages, rows, seen = [], [], set() + async with public_client(uri, "acct_a", route) as client: + for _ in range(7): + result = await client.get_media_buy_delivery( + GetMediaBuyDeliveryRequest.model_validate(raw) + ) + assert result.success, result + page = result.data.model_dump(mode="json", exclude_none=True) + binding = page["reporting_revision_binding"] + assert binding["reporting_revision_id"] == raw["reporting_revision_id"] + assert binding["row_count"] == count + assert binding["content_sha256"] == ready["revision"]["content_sha256"] + assert page["reporting_revision"]["finality"] == "official" + pages.append(page) + rows.extend(page["reporting_rows"]) + if not page["pagination"]["has_more"]: + break + cursor = page["pagination"]["cursor"] + assert cursor not in seen + seen.add(cursor) + raw["pagination"]["cursor"] = cursor + else: + pytest.fail("exact revision traversal failed to terminate") + assert len(pages) == (6 if count else 1) + assert rows == reference_rows(count) + # Protobuf responses spell ordinary integral JSON values as + # doubles. JCS canonicalizes these fixture values identically; + # this comparison does not relax financial ingress validation + # or claim recovery of a value rounded before transmission. + recomputed = hashlib.sha256( + rfc8785.dumps( + { + "reporting_revision_id": raw["reporting_revision_id"], + "row_count": count, + "control_totals": binding["control_totals"], + "reporting_rows": rows, + } + ) + ).hexdigest() + assert recomputed == binding["content_sha256"] + replay = await client.get_media_buy_delivery( + GetMediaBuyDeliveryRequest.model_validate(raw) + ) + assert ( + replay.success + and replay.data.model_dump(mode="json", exclude_none=True) == pages[-1] + ) + for field in ("include_package_daily_breakdown", "include_window_breakdown"): + invalid = {**ready["request"], field: False} + with pytest.raises(ValidationError): + GetMediaBuyDeliveryRequest.model_validate(invalid) + rejected = await client.adapter.get_media_buy_delivery(invalid) + assert ( + not rejected.success and rejected.adcp_error["code"] == "VALIDATION_ERROR" + ), rejected + async with public_client(uri, "acct_b", route) as client: + denied = await client.get_media_buy_delivery( + GetMediaBuyDeliveryRequest.model_validate(raw) + ) + assert_access_denied(denied) diff --git a/tests/test_reporting_exact_request_models.py b/tests/test_reporting_exact_request_models.py new file mode 100644 index 000000000..18e99260e --- /dev/null +++ b/tests/test_reporting_exact_request_models.py @@ -0,0 +1,133 @@ +"""Exact revision reads must not acquire aggregate-only selector defaults.""" + +import json +from copy import deepcopy + +import pytest +from pydantic import BaseModel, ValidationError + +from adcp.types import GetMediaBuyDeliveryRequest +from adcp.validation.schema_loader import get_named_validator + + +def exact_request(): + return { + "account": {"account_id": "acct_a"}, + "reporting_revision_id": "rpr_public_exact_revision", + "pagination": {"max_results": 100}, + } + + +@pytest.mark.parametrize("parser", ["model_validate", "model_validate_json"]) +def test_exact_revision_request_survives_public_serialization(parser): + raw = exact_request() + before = deepcopy(raw) + validator = get_named_validator("media-buy/get-media-buy-delivery-request.json") + assert validator is not None and not list(validator.iter_errors(raw)) + request = getattr(GetMediaBuyDeliveryRequest, parser)( + json.dumps(raw) if parser == "model_validate_json" else raw + ) + encoded = request.model_dump(mode="json", exclude_none=True) + assert "include_package_daily_breakdown" not in encoded + assert "include_window_breakdown" not in encoded + assert not list(validator.iter_errors(encoded)) + assert raw == before + + +AGGREGATE_SELECTORS = ( + "media_buy_ids", + "start_date", + "end_date", + "status_filter", + "requested_metrics", + "reporting_dimensions", + "attribution_window", + "include_package_daily_breakdown", + "time_granularity", + "include_window_breakdown", +) + + +@pytest.mark.parametrize("field", AGGREGATE_SELECTORS) +def test_explicit_aggregate_selectors_are_forbidden_even_when_null(field): + raw = {**exact_request(), field: None} + before = deepcopy(raw) + with pytest.raises(ValidationError, match="exact revision requests forbid aggregate selectors"): + GetMediaBuyDeliveryRequest.model_validate(raw) + assert raw == before + + +@pytest.mark.parametrize("field", ["include_package_daily_breakdown", "include_window_breakdown"]) +@pytest.mark.parametrize("flag", [False, True]) +def test_exact_mode_rejects_explicit_boolean_flags_without_mutation(field, flag): + raw = {**exact_request(), field: flag} + before = deepcopy(raw) + with pytest.raises(ValidationError, match="exact revision requests forbid aggregate selectors"): + GetMediaBuyDeliveryRequest.model_validate(raw) + assert raw == before + + +@pytest.mark.parametrize( + "flags", + [ + {}, + {"include_package_daily_breakdown": False}, + {"include_window_breakdown": False}, + {"include_package_daily_breakdown": True, "include_window_breakdown": True}, + ], +) +def test_aggregate_defaults_and_explicit_flags_are_preserved(flags): + raw = {"media_buy_ids": ["shared-media-buy"], **flags} + request = GetMediaBuyDeliveryRequest.model_validate(raw) + fields = set(request.model_fields_set) + for value in ( + request.model_dump(), + request.model_dump(mode="json"), + json.loads(request.model_dump_json()), + ): + assert value["include_package_daily_breakdown"] is flags.get( + "include_package_daily_breakdown", False + ) + assert value["include_window_breakdown"] is flags.get("include_window_breakdown", False) + validator = get_named_validator("media-buy/get-media-buy-delivery-request.json") + assert not list(validator.iter_errors(value)) + assert request.model_fields_set == fields + + +def test_pagination_requires_an_exact_revision(): + with pytest.raises(ValidationError, match="pagination requires reporting_revision_id"): + GetMediaBuyDeliveryRequest.model_validate({"pagination": {"max_results": 100}}) + + +def test_exact_request_subclasses_and_nesting_preserve_the_selected_mode(): + class Exact(GetMediaBuyDeliveryRequest): + pass + + class OrdinaryParent(BaseModel): + request: Exact + + raw = exact_request() + parent = OrdinaryParent.model_validate({"request": raw}) + fields = set(parent.request.model_fields_set) + for value in ( + parent.model_dump(), + parent.model_dump(mode="json"), + json.loads(parent.model_dump_json()), + {"request": parent.request.model_dump()}, + {"request": json.loads(parent.request.model_dump_json())}, + ): + assert not set(AGGREGATE_SELECTORS).intersection(value["request"]) + assert value["request"]["pagination"]["max_results"] == 100 + assert value["request"]["pagination"].get("cursor") is None + validator = get_named_validator("media-buy/get-media-buy-delivery-request.json") + assert not list( + validator.iter_errors(parent.model_dump(mode="json", exclude_none=True)["request"]) + ) + serialized = parent.request.model_dump(mode="json") + assert GetMediaBuyDeliveryRequest.model_validate(serialized).model_dump() == serialized + assert parent.request.model_fields_set == fields + # Aggregate-mode defaults remain available as attributes; serialization is + # mode-aware and never changes the model or a caller-owned input mapping. + assert parent.request.include_package_daily_breakdown is False + assert parent.request.include_window_breakdown is False + assert raw == exact_request() diff --git a/tests/test_reporting_feed_numeric_parameters.py b/tests/test_reporting_feed_numeric_parameters.py new file mode 100644 index 000000000..3c32eda43 --- /dev/null +++ b/tests/test_reporting_feed_numeric_parameters.py @@ -0,0 +1,126 @@ +"""Exact received JSON numbers must survive reporting filter binding.""" + +import json +from copy import deepcopy +from decimal import Decimal + +import pytest + +from adcp.reporting.feed.request import FeedRequest, transport_parameters +from adcp.reporting.receipts.transport import _raw_json, _receipt_numbers + + +def wire_request(lexeme): + return _raw_json( + '{"view":"periods","account":{"account_id":"acct_a"},"ext":{"vendor":{"n":' + lexeme + "}}}" + ) + + +def binding(lexeme): + return FeedRequest.parse(transport_parameters(wire_request(lexeme))).filters_json + + +@pytest.mark.parametrize( + "left,right", + [ + ("9007199254740993.0", "9007199254740992.0"), + ("9007199254740995.0", "9007199254740996.0"), + ("9.007199254740993e15", "9007199254740992"), + ], +) +def test_received_distinct_large_decimals_never_alias(left, right): + assert binding(left) != binding(right) + + +@pytest.mark.parametrize( + "integer,spelling", + [ + (1, "1.0"), + (10, "1e1"), + (9007199254740993, "9007199254740993.0"), + (9007199254740995, "9.007199254740995e15"), + (10**23, "1e23"), + ], +) +def test_integral_spellings_keep_the_exact_received_integer(integer, spelling): + assert binding(str(integer)) == binding(spelling) + decoded = transport_parameters(wire_request(spelling)) + assert type(decoded["ext"]["vendor"]["n"]) is int + assert decoded["ext"]["vendor"]["n"] == integer + + +@pytest.mark.parametrize("value", ["0.5", "4.8", "4.80", "-0.125", "1.25e-4"]) +def test_ordinary_finite_fractions_round_trip_without_mutating_the_request(value): + raw = wire_request(value) + before = deepcopy(raw) + normalized = transport_parameters(raw) + assert Decimal(repr(normalized["ext"]["vendor"]["n"])) == Decimal(value) + assert FeedRequest.parse(normalized).filters["ext"]["vendor"]["n"] == json.loads(value) + assert raw == before + + +@pytest.mark.parametrize( + "value", + [ + "0.100000000000000000001", + "9007199254740993.25", + "1.000000000000000000001", + "1e-400", + "1e400", + "NaN", + "Infinity", + "-Infinity", + ], +) +def test_unrepresentable_fraction_or_nonfinite_number_is_rejected(value): + with pytest.raises(ValueError): + transport_parameters(wire_request(value)) + + +@pytest.mark.parametrize( + "left,right,equal", + [ + (1, 1.0, True), + ({"n": [10]}, {"n": [10.0]}, True), + (0.5, 1, False), + (4.8, 4.80, True), + (True, 1, False), + (False, 0, False), + ({"b": True}, {"b": 1}, False), + (1, "1", False), + (1, 2, False), + ({"v": None}, {}, False), + (2**53 - 1, 2**53, False), + (2**53 + 1, 2**53, False), + ], +) +def test_existing_in_memory_json_equivalence_is_unchanged(left, right, equal): + def direct(value): + return FeedRequest.parse( + {"view": "periods", "account": {"account_id": "acct_a"}, "ext": {"v": value}} + ).filters_json + + assert (direct(left) == direct(right)) is equal + + +@pytest.mark.parametrize("value,expected", [("1", 1), ("1.0", 1), ("1e2", 100)]) +def test_page_limits_keep_exact_integer_equivalence(value, expected): + raw = wire_request("4.8") + raw["pagination"] = {"max_results": Decimal(value)} + result = transport_parameters(raw) + assert type(result["pagination"]["max_results"]) is int + assert FeedRequest.parse(result).limit == expected + + +@pytest.mark.parametrize("value", ["0", "101", "1.000000000000000000001", "1.5", "1e400", "NaN"]) +def test_page_limits_still_reject_out_of_range_or_inexact_values(value): + raw = wire_request("0.5") + raw["pagination"] = {"max_results": Decimal(value)} + with pytest.raises(ValueError): + transport_parameters(raw) + + +@pytest.mark.parametrize("value", ["9007199254740993.0", "0.5", "NaN", "1e400"]) +def test_financial_receipt_numbers_keep_their_stricter_contract(value): + with pytest.raises(ValueError): + _receipt_numbers(_raw_json(value)) diff --git a/tests/test_reporting_scope_models.py b/tests/test_reporting_scope_models.py new file mode 100644 index 000000000..752929534 --- /dev/null +++ b/tests/test_reporting_scope_models.py @@ -0,0 +1,156 @@ +"""Reporting selectors preserve the caller's scope through public typed models.""" + +import json +from copy import deepcopy +from types import MappingProxyType + +import pytest +from pydantic import BaseModel, ValidationError + +from adcp.types import ReportingDeliveryConfiguration, SyncAccountsRequest +from adcp.validation.schema_loader import get_named_validator + + +def onboarding_request(scope): + return { + "adcp_version": "3.2-rc.4", + "idempotency_key": "scope-onboarding-regression-0001", + "accounts": [ + { + "account": {"account_id": "usd"}, + "reporting_delivery_configs": [ + { + "delivery_config_id": "shared-config", + "delivery_config_version": 1, + "offering_id": "fixture-official-USD", + "active": False, + "feed_purpose": "billing", + "report_definition_id": "reference-report-v1", + "reporting_profile": "paid_media_delivery", + "scope": deepcopy(scope), + "coverage_requirement": "full", + "required_finality": "official", + "reconciliation_mode": "consumer_receipt", + "authoritative_party": "seller", + "schedule": { + "period_duration": "PT1H", + "alignment": "utc", + "delivery_sla": "PT1H", + }, + "method": { + "pattern": "warehouse_materialization", + "transport": "fixture-sql", + "orchestration": "producer_managed", + "destination": { + "mode": "provision", + "provider": {"domain": "fixture.example.test"}, + "location": "reporting/shared-destination", + }, + }, + } + ], + } + ], + "delete_missing": False, + "dry_run": False, + } + + +def test_explicit_media_buy_selector_survives_public_request_serialization(): + raw = onboarding_request({"media_buy_ids": ["shared-media-buy"]}) + before = deepcopy(raw) + validator = get_named_validator("account/sync-accounts-request.json") + assert validator is not None + assert not list(validator.iter_errors(raw)) + request = SyncAccountsRequest.model_validate(raw) + encoded = request.model_dump(mode="json", exclude_none=True) + assert encoded["accounts"][0]["reporting_delivery_configs"][0]["scope"] == { + "media_buy_ids": ["shared-media-buy"] + } + assert not list(validator.iter_errors(encoded)) + assert raw == before + + +@pytest.mark.parametrize( + "scope", [{}, {"all_media_buys": True}, {"media_buy_ids": ["shared-media-buy"]}] +) +def test_scope_modes_round_trip_without_mutating_inputs_or_field_presence(scope): + raw = onboarding_request(scope) + before = deepcopy(raw) + request = SyncAccountsRequest.model_validate(raw) + selected = request.accounts[0].reporting_delivery_configs[0].scope + expected = scope or {"all_media_buys": True} + fields = set(selected.model_fields_set) + assert selected.all_media_buys is (True if "all_media_buys" in expected else None) + validator = get_named_validator("account/sync-accounts-request.json") + for encoded in ( + request.model_dump(), + request.model_dump(mode="json"), + json.loads(request.model_dump_json()), + ): + assert encoded["accounts"][0]["reporting_delivery_configs"][0]["scope"] == expected + assert not list(validator.iter_errors(encoded)) + assert SyncAccountsRequest.model_validate(encoded).model_dump( + mode="json" + ) == request.model_dump(mode="json") + assert raw == before + assert selected.model_fields_set == fields + + +@pytest.mark.parametrize( + "scope", + [ + {"all_media_buys": False}, + {"all_media_buys": 1}, + {"all_media_buys": "true"}, + {"all_media_buys": None}, + {"media_buy_ids": None}, + {"media_buy_ids": []}, + {"media_buy_ids": [""]}, + {"media_buy_ids": ["shared-media-buy", "shared-media-buy"]}, + {"all_media_buys": True, "media_buy_ids": ["shared-media-buy"]}, + {"all_media_buys": True, "media_buy_ids": None}, + {"all_media_buys": None, "media_buy_ids": ["shared-media-buy"]}, + {"unexpected_selector": True}, + ], +) +def test_malformed_or_conflicting_explicit_scopes_fail_closed(scope): + raw = onboarding_request(scope) + before = deepcopy(raw) + with pytest.raises(ValidationError): + SyncAccountsRequest.model_validate(raw) + assert raw == before + + +@pytest.mark.parametrize( + "scope", [{}, {"all_media_buys": True}, {"media_buy_ids": ["shared-media-buy"]}] +) +def test_scope_subclasses_inside_an_ordinary_pydantic_parent(scope): + scope_type = ReportingDeliveryConfiguration.model_fields["scope"].annotation + + class CustomScope(scope_type): + pass + + class OrdinaryParent(BaseModel): + scope: CustomScope + + parent = OrdinaryParent.model_validate({"scope": scope}) + expected = scope or {"all_media_buys": True} + for encoded in ( + parent.model_dump(), + parent.model_dump(mode="json"), + json.loads(parent.model_dump_json()), + ): + assert encoded == {"scope": expected} + assert parent.scope.all_media_buys is (True if "all_media_buys" in expected else None) + + +@pytest.mark.parametrize( + "scope", [{}, {"media_buy_ids": ["shared-media-buy"]}, {"all_media_buys": True}] +) +def test_read_only_scope_mappings_follow_the_same_selector_contract(scope): + scope_type = ReportingDeliveryConfiguration.model_fields["scope"].annotation + wrapped = MappingProxyType(scope) + selected = scope_type.model_validate(wrapped) + assert selected.model_dump(mode="json") == (scope or {"all_media_buys": True}) + assert dict(wrapped) == scope diff --git a/tests/test_reporting_selector_generation.py b/tests/test_reporting_selector_generation.py new file mode 100644 index 000000000..488e56203 --- /dev/null +++ b/tests/test_reporting_selector_generation.py @@ -0,0 +1,95 @@ +"""Code generation must preserve both reporting selector contracts.""" + +import ast +import json +import re +import sys +from pathlib import Path +from types import ModuleType + +import pytest +from pydantic import ValidationError + +from scripts import post_generate_fixes + +ROOT = Path(__file__).parents[1] / "src/adcp/types/generated_poc" +SOURCES = ( + ("core/reporting_delivery_config.py", "Scope", "adcp.types.generated_poc.core"), + ( + "media_buy/get_media_buy_delivery_request.py", + "GetMediaBuyDeliveryRequest", + "adcp.types.generated_poc.media_buy", + ), +) +METHODS = { + "_select_reporting_scope", + "_unique_reporting_scope", + "_serialize_reporting_scope", + "_validate_delivery_selector_mode", + "_serialize_delivery_selector_mode", +} + + +def unrepaired(source): + """Restore the two original codegen defaults without changing field schemas.""" + lines = source.splitlines(keepends=True) + cuts = [] + for node in ast.walk(ast.parse(source)): + if isinstance(node, ast.FunctionDef) and node.name in METHODS: + cuts.append((min(d.lineno for d in node.decorator_list) - 1, node.end_lineno)) + for start, end in sorted(cuts, reverse=True): + del lines[start:end] + return "".join(lines).replace( + "all_media_buys: Literal[True] | None = None", "all_media_buys: Literal[True] = True" + ) + + +def test_regeneration_repairs_canonical_and_self_contained_clones(tmp_path, monkeypatch): + targets = [] + for relative, class_name, package in SOURCES: + source = unrepaired((ROOT / relative).read_text()) + for clone in (False, True): + name = class_name + ("2" if clone else "") + target = tmp_path / (("bundled/" if clone else "") + relative) + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(re.sub(r"\b" + class_name + r"\b", name, source)) + targets.append((target, name, package)) + monkeypatch.setattr(post_generate_fixes, "OUTPUT_DIR", tmp_path) + post_generate_fixes.fix_reporting_request_selectors() + first = [p.read_bytes() for p, _, _ in targets] + post_generate_fixes.fix_reporting_request_selectors() + assert first == [p.read_bytes() for p, _, _ in targets] + for index, (target, name, package) in enumerate(targets): + module = ModuleType(package + ".selector_regeneration_" + str(index)) + module.__package__ = package + monkeypatch.setitem(sys.modules, module.__name__, module) + exec(compile(target.read_bytes(), str(target), "exec"), vars(module)) + model = getattr(module, name) + if name.startswith("Scope"): + explicit = model.model_validate({"media_buy_ids": ["shared-media-buy"]}) + assert json.loads(explicit.model_dump_json()) == {"media_buy_ids": ["shared-media-buy"]} + assert model().model_dump() == {"all_media_buys": True} + with pytest.raises(ValidationError): + model.model_validate( + {"all_media_buys": True, "media_buy_ids": ["shared-media-buy"]} + ) + else: + exact = {"account": {"account_id": "acct_a"}, "reporting_revision_id": "rpr_exact"} + value = model.model_validate(exact) + assert "include_package_daily_breakdown" not in value.model_dump() + assert "include_window_breakdown" not in json.loads(value.model_dump_json()) + with pytest.raises(ValidationError): + model.model_validate({**exact, "include_window_breakdown": False}) + assert model().model_dump()["include_window_breakdown"] is False + + +def test_changed_scope_schema_requires_an_explicit_generator_decision(tmp_path, monkeypatch): + schema = tmp_path / "core/reporting-delivery-config.json" + schema.parent.mkdir() + original = post_generate_fixes.SCHEMA_DIR / "core/reporting-delivery-config.json" + value = json.loads(original.read_bytes()) + value["properties"]["scope"]["maxProperties"] = 2 + schema.write_text(json.dumps(value)) + monkeypatch.setattr(post_generate_fixes, "SCHEMA_DIR", tmp_path) + with pytest.raises(ValueError, match="reporting scope schema changed"): + post_generate_fixes.fix_reporting_request_selectors() From ddaa474705b332943f10cc0e74baa637ee90a4df Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Tue, 22 Sep 2026 13:01:03 +0000 Subject: [PATCH 03/12] fix(signing): reject mixed webhook signature alphabets before verification Reject mixed standard/Base64URL characters on the selected webhook Signature token with webhook_signature_header_malformed before key lookup, crypto or replay state. Keep webhook-v1 route selection distinct from request profile 3.2. Preserve legacy pure-alphabet decoder tolerance without treating standard Base64 emission as webhook-profile conformance. Leave Content-Digest and shared request/JWK/JWT decoding unchanged. Exercise the protocol-owned rc.4 vectors, label selection, binary controls, early rejection without side effects, and configured public revocation state. --- src/adcp/signing/webhook_verifier.py | 35 +++- .../reporting/_production_packaging.py | 2 + .../signing/test_webhook_rc4_vectors.py | 155 ++++++++++++++++++ 3 files changed, 191 insertions(+), 1 deletion(-) create mode 100644 tests/conformance/signing/test_webhook_rc4_vectors.py diff --git a/src/adcp/signing/webhook_verifier.py b/src/adcp/signing/webhook_verifier.py index e122c964c..b2b8271be 100644 --- a/src/adcp/signing/webhook_verifier.py +++ b/src/adcp/signing/webhook_verifier.py @@ -24,7 +24,7 @@ from collections.abc import Callable, Mapping from dataclasses import dataclass, field -from adcp.signing.canonical import _lookup, parse_signature_input_header +from adcp.signing.canonical import _lookup, parse_signature_input_header, split_structured_field from adcp.signing.constants import ( ADCP_USE_REQUEST, ADCP_USE_WEBHOOK, @@ -37,6 +37,7 @@ from adcp.signing.errors import ( REQUEST_TO_WEBHOOK_CODE, WEBHOOK_SIGNATURE_COMPONENTS_INCOMPLETE, + WEBHOOK_SIGNATURE_HEADER_MALFORMED, WEBHOOK_SIGNATURE_INVALID, SignatureVerificationError, ) @@ -137,6 +138,7 @@ def verify_webhook_signature( code on failure. Success returns a :class:`VerifiedWebhookSender` carrying the identity to scope dedup state by. """ + _precheck_webhook_signature_alphabet(headers, options.label) _precheck_webhook_has_required_components(headers) request_options = VerifyOptions( @@ -161,6 +163,9 @@ def verify_webhook_signature( expected_key_origins=options.expected_key_origins, signing_purpose="webhook_signing", posture=options.posture, + # The rc.4 webhook-v1 corpus retains legacy Base64URL signatures. + # Do not inherit the stricter 3.2 *request* profile for this route. + signing_profile_version="3.1", ) try: @@ -179,6 +184,34 @@ def verify_webhook_signature( ) +def _precheck_webhook_signature_alphabet(headers: Mapping[str, str], label: str) -> None: + """Keep legacy decoder tolerance without accepting mixed alphabets. + + Webhook-v1 emits unpadded Base64URL throughout 3.x. The shared legacy + decoder also tolerates standard Base64, but its URL fallback would + accept a mixed token. That tolerance is not conformant emission. + Confine this profile check to the selected webhook Signature value. + Content-Digest and request/JWK/JWT decoders keep their own contracts. + """ + raw = _lookup(headers, "signature") + if raw is None: + return + for entry in split_structured_field(raw, ","): + name, separator, value = entry.strip().partition("=") + if not separator or name.strip() != label: + continue + value = value.strip() + if value.startswith(":") and value.endswith(":"): + token = value[1:-1] + if any(char in token for char in "+/") and any(char in token for char in "-_"): + raise SignatureVerificationError( + WEBHOOK_SIGNATURE_HEADER_MALFORMED, + step=1, + message="webhook Signature must not mix Base64 alphabets", + ) + return + + def _precheck_webhook_has_required_components(headers: Mapping[str, str]) -> None: """Reject before crypto if Signature-Input omits webhook-required components. diff --git a/tests/conformance/reporting/_production_packaging.py b/tests/conformance/reporting/_production_packaging.py index ad9de9d21..f3749e5da 100644 --- a/tests/conformance/reporting/_production_packaging.py +++ b/tests/conformance/reporting/_production_packaging.py @@ -51,6 +51,7 @@ def production_modules(): "_version.py", "server/mcp_tools.py", "server/a2a_server.py", + "signing/webhook_verifier.py", "reporting/feed/request.py", "reporting/feed/errors.py", "reporting/ledger/status_server.py", @@ -260,6 +261,7 @@ def installed_production(root, python, wheel, source, *, label, driver_absent): "tests/test_reporting_scope_models.py", "tests/test_reporting_exact_request_models.py", "tests/test_reporting_feed_numeric_parameters.py", + "tests/conformance/signing/test_webhook_rc4_vectors.py", "tests/test_reporting_production_public.py", "tests/test_schema_datetime_formats.py", "tests/test_rc4_adoption.py", diff --git a/tests/conformance/signing/test_webhook_rc4_vectors.py b/tests/conformance/signing/test_webhook_rc4_vectors.py new file mode 100644 index 000000000..7aaa8c60b --- /dev/null +++ b/tests/conformance/signing/test_webhook_rc4_vectors.py @@ -0,0 +1,155 @@ +"""The installed, protocol-owned rc.4 webhook-v1 corpus through the public API.""" + +from __future__ import annotations + +import base64 +import json +from dataclasses import replace +from datetime import datetime, timezone +from importlib.resources import files + +import pytest + +from adcp.signing import InMemoryReplayStore, SignatureVerificationError +from adcp.signing.revocation import RevocationList +from adcp.webhooks import WebhookVerifyOptions, verify_webhook_signature + +VECTORS = files("adcp").joinpath("_compliance/3.2.0-rc.4/test-vectors/webhook-signing") +KEYS = { + row["kid"]: {name: value for name, value in row.items() if not name.startswith("_")} + for row in json.loads(VECTORS.joinpath("keys.json").read_text())["keys"] +} +NAMES = sorted( + f"{kind}/{path.name}" + for kind in ("positive", "negative") + for path in VECTORS.joinpath(kind).iterdir() + if path.name.endswith(".json") +) + + +def vector_options(vector): + state = vector.get("test_harness_state", {}) + cap_for = state.get("per_keyid_cap_filled_for") + replay = InMemoryReplayStore(per_keyid_cap=1 if cap_for else 1_000_000) + if cap_for: + assert replay.remember(cap_for, "test-capacity-prefill", 3600) + for entry in state.get("replay_cache_entries", []): + assert replay.remember(entry["keyid"], entry["nonce"], 3600) + keys = {kid: KEYS[kid] for kid in vector["jwks_ref"]} + keys.update(vector.get("jwks_override", {})) + revoked = set(state.get("revoked_kids", [])) + revocation = None + if "revocation_list_stale_seconds" in state: + # The corpus permits public in-process state installation. This is + # distinct from its live stale-fetch receiver-runner composition. + stale_at = vector["reference_now"] - state["revocation_list_stale_seconds"] + revocation = RevocationList( + issuer="https://test-agent.example.test", + updated=datetime.fromtimestamp(stale_at - 1, timezone.utc).isoformat(), + next_update=datetime.fromtimestamp(stale_at, timezone.utc).isoformat(), + ) + return WebhookVerifyOptions( + jwks_resolver=keys.get, + replay_store=replay, + revocation_checker=revoked.__contains__, + revocation_list=revocation, + clock=lambda: vector["reference_now"], + ) + + +@pytest.mark.parametrize("name", NAMES) +def test_protocol_owned_webhook_vector(name): + vector = json.loads(VECTORS.joinpath(name).read_text()) + if name == "negative/019-revocation-stale.json": + assert vector["requires_contract"] == "webhook_receiver_runner" + assert vector["black_box_behavior"] == "simulate_stale_revocation_fetch" + assert vector_options(vector).revocation_list is not None + request = vector["request"] + arguments = { + "method": request["method"], + "url": request["url"], + "headers": request["headers"], + "body": request["body"].encode("utf-8"), + "options": vector_options(vector), + } + expected = vector["expected_outcome"] + if expected["success"]: + assert verify_webhook_signature(**arguments).label == "sig1" + else: + with pytest.raises(SignatureVerificationError) as raised: + verify_webhook_signature(**arguments) + assert raised.value.code == expected["error_code"] + if name.split("/")[1][:3] in {"006", "009", "015", "019", "021"}: + assert raised.value.step == expected["failed_step"] + + +@pytest.mark.parametrize( + "signature", + [ + "sig1=:A+B-CDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789AB:", + "sig1=:A/B_CDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789AB:", + ], +) +def test_mixed_alphabet_stops_before_lookup_crypto_or_replay(signature, monkeypatch): + vector = json.loads(VECTORS.joinpath("positive/001-basic-post.json").read_text()) + request = vector["request"] + options = vector_options(vector) + + def forbidden(*args, **kwargs): + pytest.fail("malformed signature reached key lookup, crypto or replay state") + + monkeypatch.setattr("adcp.signing.verifier.verify_signature", forbidden) + for method in ("seen", "remember", "claim", "at_capacity"): + monkeypatch.setattr(options.replay_store, method, forbidden) + options = replace(options, jwks_resolver=forbidden) + with pytest.raises(SignatureVerificationError) as raised: + verify_webhook_signature( + method=request["method"], + url=request["url"], + headers={**request["headers"], "Signature": signature}, + body=request["body"].encode(), + options=options, + ) + assert (raised.value.code, raised.value.step) == ("webhook_signature_header_malformed", 1) + assert str(raised.value) == "webhook Signature must not mix Base64 alphabets" + + +@pytest.mark.parametrize("encoding", ["standard_padded", "url_unpadded"]) +def test_legacy_decoder_tolerance_and_unselected_labels(encoding): + vector = json.loads(VECTORS.joinpath("positive/001-basic-post.json").read_text()) + request = vector["request"] + token = request["headers"]["Signature"].split(":")[1] + raw = base64.urlsafe_b64decode(token + "=" * (-len(token) % 4)) + token = ( + base64.b64encode(raw).decode() + if encoding == "standard_padded" + else base64.urlsafe_b64encode(raw).decode().rstrip("=") + ) + # Standard Base64 remains decoder tolerance, not conformant webhook-v1 + # emission. Only the selected label supplies the verified signature. + header = f"unused=:A+B-CDEF:, sig1=:{token}:" + assert ( + verify_webhook_signature( + method=request["method"], + url=request["url"], + headers={**request["headers"], "Signature": header}, + body=request["body"].encode(), + options=vector_options(vector), + ).label + == "sig1" + ) + + +@pytest.mark.parametrize("header", ['sig1="not-binary"', "sig1=:abcde:", "sig1=:abcde=:"]) +def test_malformed_binary_shape_and_padding_stay_step_one(header): + vector = json.loads(VECTORS.joinpath("positive/001-basic-post.json").read_text()) + request = vector["request"] + with pytest.raises(SignatureVerificationError) as raised: + verify_webhook_signature( + method=request["method"], + url=request["url"], + headers={**request["headers"], "Signature": header}, + body=request["body"].encode(), + options=vector_options(vector), + ) + assert (raised.value.code, raised.value.step) == ("webhook_signature_header_malformed", 1) From b203bbcff51cb648c1fd605224fb74e5a3465a8f Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Tue, 22 Sep 2026 13:02:08 +0000 Subject: [PATCH 04/12] fix(signing): emit webhook signatures as unpadded Base64URL The webhook signer incorrectly selected the AdCP 3.2 request-signing encoding, emitting padded standard Base64 that conformant webhook receivers reject. Select webhook-v1 Signature encoding internally across public sign_webhook and WebhookSender paths without changing caller options or request profiles. Preserve existing vector-compatible Content-Digest bytes. Its protocol wording discrepancy is separate from the settled webhook Signature encoding contract. Regress both algorithms, selected labels, digest bytes, request profiles and real HTTP callbacks through direct, JWK and PEM sender entry points. Installed Python-to-TypeScript and reverse controls cover the Node 22.12 floor. --- src/adcp/signing/webhook_signer.py | 15 +- .../reporting/_production_packaging.py | 3 + .../signing/_webhook_http_server.py | 94 ++++++++ .../test_webhook_signature_emission.py | 82 +++++++ .../signing/test_webhook_signature_http.py | 201 ++++++++++++++++++ 5 files changed, 390 insertions(+), 5 deletions(-) create mode 100644 tests/conformance/signing/_webhook_http_server.py create mode 100644 tests/conformance/signing/test_webhook_signature_emission.py create mode 100644 tests/conformance/signing/test_webhook_signature_http.py diff --git a/src/adcp/signing/webhook_signer.py b/src/adcp/signing/webhook_signer.py index 4170dca31..6494536f4 100644 --- a/src/adcp/signing/webhook_signer.py +++ b/src/adcp/signing/webhook_signer.py @@ -1,10 +1,12 @@ """Signer for the AdCP webhook-signing profile (adcp#2423). -Same 9421 substrate as :func:`adcp.signing.signer.sign_request`, with three -values pinned by the webhook profile: +Same 9421 substrate as :func:`adcp.signing.signer.sign_request`, with values +pinned by the webhook profile: * ``tag`` — ``adcp/webhook-signing/v1`` * ``cover_content_digest`` — always ``True`` (body IS the event) +* ``Signature`` — unpadded Base64URL throughout 3.x, independently of the + AdCP 3.2 request-signing migration to padded standard Base64. * the signing JWK MUST have ``adcp_use: "webhook-signing"`` in the sender's published ``adagents.json``; verifying this at publish time is out of scope for the signer, but callers should enforce it when registering their keyring. @@ -39,8 +41,9 @@ def sign_webhook( ) -> SignedHeaders: """Sign an outgoing webhook POST per adcp/webhook-signing/v1. - ``cover_content_digest=True`` and ``tag=WEBHOOK_TAG`` are pinned. The - caller attaches ``SignedHeaders.as_dict()`` to the outgoing HTTP request. + ``cover_content_digest=True``, ``tag=WEBHOOK_TAG`` and unpadded Base64URL + Signature encoding are pinned. The caller attaches + ``SignedHeaders.as_dict()`` to the outgoing HTTP request. The ``method`` is normally ``"POST"`` for webhook delivery; passed through unchanged so callers signing a retried ``PUT`` or variant delivery verb @@ -65,7 +68,9 @@ def sign_webhook( nonce=nonce, tag=WEBHOOK_TAG, label=label, - signing_profile_version="3.2", + # Webhook-v1 keeps legacy Signature encoding even on AdCP 3.2 routes. + # Explicit digest coverage preserves the existing Content-Digest bytes. + signing_profile_version="3.1", ) diff --git a/tests/conformance/reporting/_production_packaging.py b/tests/conformance/reporting/_production_packaging.py index f3749e5da..5bebe42f8 100644 --- a/tests/conformance/reporting/_production_packaging.py +++ b/tests/conformance/reporting/_production_packaging.py @@ -52,6 +52,7 @@ def production_modules(): "server/mcp_tools.py", "server/a2a_server.py", "signing/webhook_verifier.py", + "signing/webhook_signer.py", "reporting/feed/request.py", "reporting/feed/errors.py", "reporting/ledger/status_server.py", @@ -262,6 +263,8 @@ def installed_production(root, python, wheel, source, *, label, driver_absent): "tests/test_reporting_exact_request_models.py", "tests/test_reporting_feed_numeric_parameters.py", "tests/conformance/signing/test_webhook_rc4_vectors.py", + "tests/conformance/signing/test_webhook_signature_http.py", + "tests/conformance/signing/test_webhook_signature_emission.py", "tests/test_reporting_production_public.py", "tests/test_schema_datetime_formats.py", "tests/test_rc4_adoption.py", diff --git a/tests/conformance/signing/_webhook_http_server.py b/tests/conformance/signing/_webhook_http_server.py new file mode 100644 index 000000000..28452b137 --- /dev/null +++ b/tests/conformance/signing/_webhook_http_server.py @@ -0,0 +1,94 @@ +"""A real loopback HTTP receiver for installed webhook boundary regressions.""" + +import asyncio +import hashlib +import json +import socket +import sys +from contextlib import asynccontextmanager +from importlib.resources import files +from pathlib import Path + +import uvicorn +from starlette.applications import Starlette +from starlette.responses import JSONResponse +from starlette.routing import Route + +from adcp.server.idempotency import MemoryBackend, WebhookDedupStore +from adcp.webhooks import WebhookReceiver, WebhookReceiverConfig, WebhookVerifyOptions + + +async def run(root, socket_fd): + key_rows = json.loads( + files("adcp") + .joinpath("_compliance/3.2.0-rc.4/test-vectors/webhook-signing/keys.json") + .read_text() + )["keys"] + keys = {row["kid"]: {k: v for k, v in row.items() if not k.startswith("_")} for row in key_rows} + observations = {"key_lookups": 0, "processed": 0} + + def resolve(kid): + observations["key_lookups"] += 1 + return keys.get(kid) + + receiver = WebhookReceiver( + config=WebhookReceiverConfig( + verify_options=WebhookVerifyOptions(jwks_resolver=resolve), + dedup=WebhookDedupStore(MemoryBackend(), ttl_seconds=86400), + receiver_scope="loopback-test-receiver", + publisher_scope_for=lambda _signer: "loopback-test-publisher", + ) + ) + + async def receive(request): + body = await request.body() + outcome = await receiver.receive( + method=request.method, + url=str(request.url), + headers=dict(request.headers), + body=body, + ) + if outcome.http_status is None: + observations["processed"] += 1 + outcome = await receiver.acknowledge(outcome) + with (root / "received.jsonl").open("a") as received: + received.write( + json.dumps( + { + "body_sha256": hashlib.sha256(body).hexdigest(), + "signature": request.headers.get("signature"), + "http_status": outcome.http_status or 200, + } + ) + + "\n" + ) + return JSONResponse( + {**observations, "rejected": outcome.rejected, "reason": outcome.rejection_reason}, + status_code=outcome.http_status or 200, + headers=dict(outcome.response_headers), + ) + + @asynccontextmanager + async def lifespan(app): + yield + (root / "stopped").write_text("stopped\n") + + app = Starlette(routes=[Route("/webhook", receive, methods=["POST"])], lifespan=lifespan) + sock = socket.socket(fileno=socket_fd) + server = uvicorn.Server(uvicorn.Config(app, log_level="warning")) + running = asyncio.create_task(server.serve(sockets=[sock])) + try: + while not server.started: + if running.done(): + await running + raise RuntimeError("webhook HTTP server stopped before startup") + await asyncio.sleep(0.01) + (root / "ready").write_text("ready\n") + await running + finally: + server.should_exit = True + await running + + +def main(): + asyncio.run(run(Path(sys.argv[1]), int(sys.argv[2]))) diff --git a/tests/conformance/signing/test_webhook_signature_emission.py b/tests/conformance/signing/test_webhook_signature_emission.py new file mode 100644 index 000000000..0da77f175 --- /dev/null +++ b/tests/conformance/signing/test_webhook_signature_emission.py @@ -0,0 +1,82 @@ +"""Webhook-v1 emission stays distinct from request-signing profile 3.2.""" + +import base64 +import hashlib +import json +import re +from importlib.resources import files + +import pytest + +from adcp.signing import private_key_from_jwk, sign_request +from adcp.webhooks import WebhookVerifyOptions, sign_webhook, verify_webhook_signature + +KEYS = json.loads( + files("adcp") + .joinpath("_compliance/3.2.0-rc.4/test-vectors/webhook-signing/keys.json") + .read_text() +)["keys"] +ALGORITHMS = [ + ("ed25519", "test-ed25519-webhook-2026"), + ("ecdsa-p256-sha256", "test-es256-webhook-2026"), +] +BODY = '{\n "task_id": "raw-body", "text": "é", "status": "completed"\n}\n'.encode() +URL = "https://buyer.example.test/webhook" +NOW = 1776520800 + + +@pytest.mark.parametrize("alg,kid", ALGORITHMS) +@pytest.mark.parametrize("label", ["sig1", "callback"]) +def test_webhook_emits_unpadded_base64url_with_unchanged_digest(alg, kid, label): + key = next(row for row in KEYS if row["kid"] == kid) + signed = sign_webhook( + method="POST", + url=URL, + headers={"Content-Type": "application/json"}, + body=BODY, + private_key=private_key_from_jwk(key, d_field="_private_d_for_test_only"), + key_id=kid, + alg=alg, + created=NOW, + nonce="emission-regression", + label=label, + ) + assert re.fullmatch(rf"{label}=:[A-Za-z0-9_-]+:", signed.signature) + assert signed.content_digest == ( + "sha-256=:" + base64.b64encode(hashlib.sha256(BODY).digest()).decode() + ":" + ) + public = {k: v for k, v in key.items() if not k.startswith("_")} + verified = verify_webhook_signature( + method="POST", + url=URL, + headers={"Content-Type": "application/json", **signed.as_dict()}, + body=BODY, + options=WebhookVerifyOptions( + jwks_resolver={kid: public}.get, clock=lambda: NOW, label=label + ), + ) + assert (verified.key_id, verified.alg, verified.label) == (kid, alg, label) + + +@pytest.mark.parametrize("alg,kid", ALGORITHMS) +@pytest.mark.parametrize("profile", ["3.0", "3.1", "3.2"]) +def test_request_profile_encoding_is_independent(alg, kid, profile): + key = next(row for row in KEYS if row["kid"] == kid) + signed = sign_request( + method="POST", + url=URL, + headers={"Content-Type": "application/json"}, + body=BODY, + private_key=private_key_from_jwk(key, d_field="_private_d_for_test_only"), + key_id=kid, + alg=alg, + created=NOW, + nonce="request-emission-regression", + signing_profile_version=profile, + cover_content_digest=True, + ) + pattern = r"sig1=:[A-Za-z0-9+/]+==:" if profile == "3.2" else r"sig1=:[A-Za-z0-9_-]+:" + assert re.fullmatch(pattern, signed.signature) + assert signed.content_digest == ( + "sha-256=:" + base64.b64encode(hashlib.sha256(BODY).digest()).decode() + ":" + ) diff --git a/tests/conformance/signing/test_webhook_signature_http.py b/tests/conformance/signing/test_webhook_signature_http.py new file mode 100644 index 000000000..fd334187f --- /dev/null +++ b/tests/conformance/signing/test_webhook_signature_http.py @@ -0,0 +1,201 @@ +"""Signed requests cross an actual socket into a separate public receiver.""" + +import asyncio +import base64 +import hashlib +import json +import os +import re +import signal +import socket +import subprocess +import sys +from contextlib import asynccontextmanager +from importlib.resources import files +from pathlib import Path + +import httpx +from cryptography.hazmat.primitives import serialization + +from adcp.signing import private_key_from_jwk +from adcp.webhooks import WebhookSender, create_mcp_webhook_payload, sign_webhook, to_wire_dict + + +@asynccontextmanager +async def receiver(tmp_path): + fixtures = Path(__file__).resolve().parents[3] + launcher = ( + "import sys; sys.path.insert(0, sys.argv.pop(1)); " + "from tests.conformance.signing._webhook_http_server import main; main()" + ) + with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + sock.listen() + url = f"http://127.0.0.1:{sock.getsockname()[1]}/webhook" + with ( + (tmp_path / "server.stdout").open("xb") as out, + (tmp_path / "server.stderr").open("xb") as err, + ): + child = subprocess.Popen( + [ + sys.executable, + "-I", + "-c", + launcher, + str(fixtures), + str(tmp_path), + str(sock.fileno()), + ], + cwd=tmp_path, + pass_fds=(sock.fileno(),), + start_new_session=True, + stdout=out, + stderr=err, + ) + try: + for _ in range(1200): + if (tmp_path / "ready").exists(): + break + assert child.poll() is None, (tmp_path / "server.stderr").read_text() + await asyncio.sleep(0.05) + else: + raise AssertionError("webhook receiver startup exceeded 60 seconds") + yield url + finally: + if child.poll() is None: + os.killpg(child.pid, signal.SIGTERM) + try: + await asyncio.to_thread(child.wait, 15) + except subprocess.TimeoutExpired: + os.killpg(child.pid, signal.SIGKILL) + await asyncio.to_thread(child.wait) + assert (tmp_path / "stopped").is_file() + + +def keys(): + return json.loads( + files("adcp") + .joinpath("_compliance/3.2.0-rc.4/test-vectors/webhook-signing/keys.json") + .read_text() + )["keys"] + + +async def test_malformed_then_valid_signatures_over_http(tmp_path): + key = next(row for row in keys() if row["kid"] == "test-ed25519-webhook-2026") + async with receiver(tmp_path) as url, httpx.AsyncClient(timeout=30) as client: + for index, encoding in enumerate(("legacy_standard", "url")): + body = json.dumps( + to_wire_dict( + create_mcp_webhook_payload( + task_id=f"wire-signature-{index}", + task_type="create_media_buy", + operation_id=f"operation-{index}", + status="completed", + idempotency_key=f"webhook-boundary-{index}-unique-event", + ) + ) + ).encode() + signed = sign_webhook( + method="POST", + url=url, + headers={"Content-Type": "application/json"}, + body=body, + private_key=private_key_from_jwk(key, d_field="_private_d_for_test_only"), + key_id=key["kid"], + alg="ed25519", + ) + headers = {"Content-Type": "application/json", **signed.as_dict()} + if encoding == "legacy_standard": + # Retained decoder tolerance is separate from webhook-v1 emission. + token = headers["Signature"].split(":")[1] + raw = base64.urlsafe_b64decode(token + "=" * (-len(token) % 4)) + headers["Signature"] = f"sig1=:{base64.b64encode(raw).decode()}:" + malformed = { + **headers, + "Signature": ( + "sig1=:A+B-CDEFGHIJKLMNOPQRSTUVWXYZ" "abcdefghijklmnopqrstuvwxyz0123456789AB:" + ), + } + denied = await client.post(url, content=body, headers=malformed) + assert denied.status_code == 401 + assert ( + 'Signature error="webhook_signature_header_malformed"' + in denied.headers["www-authenticate"] + ) + assert denied.json() == { + "key_lookups": index, + "processed": index, + "rejected": True, + "reason": "signature_invalid", + } + assert "A+B-CDE" not in denied.text + # The malformed request consumed neither nonce nor event. + accepted = await client.post(url, content=body, headers=headers) + assert accepted.status_code == 200, accepted.text + assert accepted.json() == { + "key_lookups": index + 1, + "processed": index + 1, + "rejected": False, + "reason": None, + } + + +async def test_public_sender_paths_emit_webhook_profile_over_http(tmp_path): + async with receiver(tmp_path) as url, httpx.AsyncClient(timeout=30) as client: + count = 0 + for alg, kid in ( + ("ed25519", "test-ed25519-webhook-2026"), + ("ecdsa-p256-sha256", "test-es256-webhook-2026"), + ): + key = next(row for row in keys() if row["kid"] == kid) + private_key = private_key_from_jwk(key, d_field="_private_d_for_test_only") + for entry in ("constructor", "jwk", "pem"): + if entry == "constructor": + sender = WebhookSender( + private_key=private_key, key_id=kid, alg=alg, client=client + ) + elif entry == "jwk": + sender = WebhookSender.from_jwk( + key, d_field="_private_d_for_test_only", client=client + ) + else: + pem = private_key.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.PKCS8, + serialization.NoEncryption(), + ) + sender = WebhookSender.from_pem(pem, key_id=kid, alg=alg, client=client) + async with sender: + parameters = dict( + url=url, + task_id=f"sender-{count}", + task_type="create_media_buy", + operation_id=f"operation-{count}", + status="completed", + ) + if entry == "constructor": + result = await sender.send_raw( + url=url, + idempotency_key=f"sender-emission-{count}", + payload=to_wire_dict( + create_mcp_webhook_payload( + task_id=f"sender-{count}", + task_type="create_media_buy", + operation_id=f"operation-{count}", + status="completed", + ) + ), + ) + elif entry == "jwk": + result = await sender.send_mcp(**parameters) + else: + result = await sender.send_prepared(sender.prepare_mcp(**parameters)) + count += 1 + assert result.ok and result.status_code == 200, result.response_body + records = [ + json.loads(row) + for row in (tmp_path / "received.jsonl").read_text().splitlines() + ] + assert len(records) == count + assert records[-1]["body_sha256"] == hashlib.sha256(result.sent_body).hexdigest() + assert re.fullmatch(r"sig1=:[A-Za-z0-9_-]+:", records[-1]["signature"]) From dba15b6b0dd8063d38c2df4196491ae64d9720ae Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Tue, 22 Sep 2026 13:58:39 +0000 Subject: [PATCH 05/12] fix(reporting): restore progress for late materializer accounts Reset the materializer sampling continuation only after a committed account turn moves its durable served_at rank. Keep continuation across lock misses so a busy prefix cannot block later eligible accounts. Cover continuous producer activity, both account orders, size-one pools, more than two busy pages, concurrent workers, commit rollback and fencing. Exercise typed public enrollment, real HTTP/PG delivery, 503-row exact reads, receipts, reconciliation and restart under continuing catch-up load. Include the store regression in installed production distributions. --- src/adcp/reporting/materializer/pg.py | 7 +- .../reporting/_late_account_server.py | 393 ++++++++++++++++ .../reporting/_late_account_support.py | 103 +++++ .../reporting/_production_packaging.py | 1 + .../test_reporting_materializer_progress.py | 311 +++++++++++++ ...test_reporting_production_late_accounts.py | 433 ++++++++++++++++++ 6 files changed, 1247 insertions(+), 1 deletion(-) create mode 100644 tests/conformance/reporting/_late_account_server.py create mode 100644 tests/conformance/reporting/_late_account_support.py create mode 100644 tests/conformance/reporting/test_reporting_materializer_progress.py create mode 100644 tests/conformance/reporting/test_reporting_production_late_accounts.py diff --git a/src/adcp/reporting/materializer/pg.py b/src/adcp/reporting/materializer/pg.py index 642d5a0a2..d638af0e7 100644 --- a/src/adcp/reporting/materializer/pg.py +++ b/src/adcp/reporting/materializer/pg.py @@ -340,7 +340,12 @@ async def claim_materialization( ) result = await self._claim_account_on(connection, account_id, keys, lease_seconds) await self._schedule_account_on(connection, account_id) - return result + # A committed account turn moves its durable served_at rank. Start + # there again so continuously due peers cannot hide newly enrolled + # accounts behind this pre-update cursor. Lock misses above retain + # the continuation, allowing the next bounded page past a busy prefix. + self._materializer_sample_after = None + return result return ReportingMaterializerTurn("idle") async def _claim_account_on( diff --git a/tests/conformance/reporting/_late_account_server.py b/tests/conformance/reporting/_late_account_server.py new file mode 100644 index 000000000..97bdd9482 --- /dev/null +++ b/tests/conformance/reporting/_late_account_server.py @@ -0,0 +1,393 @@ +"""Public production support starts empty; all enrollment arrives over real HTTP.""" + +import argparse +import importlib.metadata +import json +import os +from datetime import datetime, timezone +from pathlib import Path + +from psycopg_pool import AsyncConnectionPool + +import adcp +from adcp.decisioning.capabilities import Account as AccountCapabilities +from adcp.reporting.ledger import ( + ProducerOfferings, + ReportingConfiguration, + ReportingDestinationBinding, + ReportingProducer, + ReportingScheduleSpec, +) +from adcp.reporting.materializer import ( + ReportingDestinationIO, + ReportingMaterializerService, + ReportingRevisionVerifierRegistry, + ReportingWriterCapability, +) +from adcp.reporting.production import ( + PgReportingProductionStore, + ReportingConfigurationAdmission, + ReportingProductionConfigurationTask, + ReportingProductionOffering, + ReportingProductionSupport, +) +from adcp.reporting.projection import PgReportingStatusProjection +from adcp.reporting.receipts import ReportingReceiptError +from adcp.server import serve +from adcp.server.auth import BearerTokenAuth, Principal, auth_context_factory +from adcp.types import ReportingDeliveryOffering + +from ._generation_support import END, START +from ._late_account_support import ACCOUNTS, AccountSource, CurrencyDestination, verifier_for + +CONSUMERS = {"usd": "urn:buyer:usd", "eur": "urn:buyer:eur"} + + +class WireCapture: + def __init__(self, app, path): + self.app, self.path = app, Path(path) + + async def __call__(self, scope, receive, send): + if scope["type"] != "http" or scope.get("method") != "POST": + return await self.app(scope, receive, send) + request, response = bytearray(), bytearray() + status = None + + async def incoming(): + message = await receive() + if message["type"] == "http.request": + request.extend(message.get("body", b"")) + return message + + async def outgoing(message): + nonlocal status + if message["type"] == "http.response.start": + status = message["status"] + if message["type"] == "http.response.body": + response.extend(message.get("body", b"")) + await send(message) + + try: + await self.app(scope, incoming, outgoing) + finally: + assert len(request) < 2_000_000 and len(response) < 4_000_000 + with self.path.open("a") as output: + output.write( + json.dumps( + { + "path": scope["path"], + "status": status, + "request_utf8": request.decode(), + "response_utf8": response.decode(), + } + ) + + "\n" + ) + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--port", type=int, required=True) + parser.add_argument("--root", type=Path, required=True) + parser.add_argument("--schema", required=True) + parser.add_argument("--notifications", action="store_true") + args = parser.parse_args() + root = args.root + pool = AsyncConnectionPool( + os.environ["ADCP_PG_TEST_URL"], + kwargs={ + "autocommit": True, + "application_name": args.schema, + "options": f"-csearch_path={args.schema} -cstatement_timeout=15000", + }, + min_size=1, + max_size=1, + open=False, + ) + store = PgReportingProductionStore(pool=pool, notifications=args.notifications) + capability = ReportingWriterCapability( + "warehouse_materialization", + "fixture-sql", + "jsonl", + "canonical_digest", + "destination", + "immutable_location", + "sha256", + "conditional_create", + ) + verifiers = {currency: verifier_for(currency, capability) for currency in ("USD", "EUR")} + writer = CurrencyDestination( + root / "destination.sqlite", tuple(v.key for v in verifiers.values()) + ) + registry = ReportingRevisionVerifierRegistry(tuple(verifiers.values())) + sources, producers, offerings = {}, {}, {} + # This adopter exposes an immutable historical dataset observed before + # startup. Its source observation remains that timestamp on every fetch; + # production turns and PostgreSQL lease scheduling use their real clocks. + source_observed_at = datetime.now(timezone.utc) + for currency, verifier in verifiers.items(): + key = verifier.key + source = AccountSource( + key, + root / ("source-" + currency), + clock=lambda: source_observed_at, + official=True, + ) + producer = ReportingProducer( + source=source, + store=store, + offerings=ProducerOfferings( + official_offering_id=source.source_id, + publication_namespace=source.capabilities.offerings[0].publication_namespace, + source_scope=source.capabilities.source_scope, + ), + object_reader=source.reader, + revision_verifier=verifier, + # Real clock, bounded catch-up: two actual publications per producer + # turn keep pending work behind a materializer that serves one turn. + # Public reads pin a delivered period while later periods provide + # continuing production load; no scheduler call is injected. + max_periods_per_turn=2, + currency_resolver=lambda config, obligation: ACCOUNTS[config.account_id][0], + ) + profile = { + "id": key.reporting_profile, + "version": key.definition.schema_version, + "schema_uri": key.definition.schema_uri, + "schema_sha256": key.definition.schema_sha256, + "schema_dialect": key.definition.schema_dialect, + "schema_ref_policy": key.definition.schema_ref_policy, + "grain": "row", + "primary_keys": ["row_id"], + "canonicalization_id": key.canonicalization.canonicalization_id, + "canonicalization_uri": key.canonicalization.canonicalization_uri, + "canonicalization_sha256": key.canonicalization.canonicalization_sha256, + } + public_offering = ReportingDeliveryOffering.model_validate( + { + "offering_id": "official-" + currency, + "feed_purpose": "billing", + "report_definition_id": key.report_definition_id, + "report_definition_uri": key.definition.report_definition_uri, + "report_definition_sha256": key.definition.report_definition_sha256, + "reporting_profile": profile, + "schedule": {"period_duration": "PT1H", "alignment": "utc", "delivery_sla": "PT1H"}, + "supported_finality": ["official"], + "reconciliation_mode": "consumer_receipt", + "method": writer.delivery_methods[0].wire(), + } + ) + sources[currency], producers[currency] = source, producer + offerings[currency] = ReportingProductionOffering( + public_offering, producer, key, source.source_id + ) + + def config_for(account): + key = verifiers[ACCOUNTS[account][0]].key + return ReportingConfiguration( + "shared-config", + 1, + account, + key.report_definition_id, + key.reporting_profile, + "billing", + ReportingScheduleSpec("PT1H", "PT1H", period_anchor=START), + "official", + activated_at=START, + media_buy_ids=("shared-media-buy",), + definition=key.definition, + ) + + def binding_for(config): + return ReportingDestinationBinding( + config.generation_key, + CONSUMERS[config.account_id], + "shared-destination", + "trusted-" + config.account_id, + capability.method, + capability.transport, + capability.verification_profile, + "consumer_receipt", + "billing", + 400, + START, + capability.format, + ("fixture-sql-v1",), + "delivered", + ) + + def wire_for(config, binding): + offering = offerings[ACCOUNTS[config.account_id][0]] + key = verifiers[ACCOUNTS[config.account_id][0]].key + return { + "delivery_config_id": config.delivery_config_id, + "delivery_config_version": 1, + "offering_id": offering.offering_id, + "active": True, + "feed_purpose": "billing", + "report_definition_id": key.report_definition_id, + "reporting_profile": key.reporting_profile, + "scope": {"media_buy_ids": list(config.media_buy_ids)}, + "coverage_requirement": "full", + "required_finality": "official", + "reconciliation_mode": "consumer_receipt", + "schedule": offering.configuration_schedule(config), + "method": writer.configuration_binding(binding).wire(), + } + + async def resolve_account(reference, context, consumer): + account = reference.get("account_id") + if ( + account not in ACCOUNTS + or reference != {"account_id": account} + or consumer != CONSUMERS[account] + ): + raise ReportingReceiptError("UNAUTHORIZED") + return account + + async def account_task(request, context, admit): + records = [] + for entry in request["accounts"]: + account = await resolve_account(entry["account"], context, context.caller_identity) + config = config_for(account) + binding = binding_for(config) + currency = ACCOUNTS[account][0] + sources[currency].bind_generation(config) + writer.grant(binding) + states = [] + for supplied in entry.get("reporting_delivery_configs", []): + await admit( + ReportingConfigurationAdmission( + offerings[currency].offering_id, + config, + binding, + configuration_wire=supplied, + ) + ) + states.append( + { + "configuration": supplied, + "state": "ready", + "destination_ref": binding.destination_ref, + "validated_at": END.isoformat(), + "activated_at": START.isoformat(), + "current_coverage": { + "status": "full", + "evaluated_at": END.isoformat(), + "media_buy_ids": list(config.media_buy_ids), + "fully_covered_media_buy_ids": list(config.media_buy_ids), + "partially_covered_media_buy_ids": [], + "unsupported_media_buy_ids": [], + "unknown_media_buy_ids": [], + "package_ids": [], + "covered_package_ids": [], + "unsupported_package_ids": [], + "unknown_package_ids": [], + "limitations": [], + }, + } + ) + records.append( + { + "account_id": account, + "brand": {"domain": account + ".advertiser.example.test"}, + "operator": "buyer.example.test", + "action": "unchanged", + "status": "active", + "billing": "operator", + "timezone": "UTC", + "reporting_delivery_configs": states, + } + ) + return {"accounts": records} + + projection = PgReportingStatusProjection( + store, + consumer_status_enabled=True, + revision_ownership=True, + escalation=producers["USD"].escalation, + ) + support = ReportingProductionSupport( + ReportingMaterializerService(store, ReportingDestinationIO(registry, writer), writer), + projection, + offerings=tuple(offerings.values()), + configuration_task=ReportingProductionConfigurationTask( + account_task, + AccountCapabilities(supported_billing=["operator"], require_operator_auth=True), + ), + resolve_account=resolve_account, + poll_seconds=0.02, + ) + # Only external source/provider configuration is restored after restart. + # No revision, receipt, snapshot or successful work is seeded here. + templates = {} + for account in ACCOUNTS: + config = config_for(account) + sources[ACCOUNTS[account][0]].bind_generation(config) + binding = binding_for(config) + writer.grant(binding) + templates[account] = wire_for(config, binding) + + async def startup(): + await pool.open(wait=True) + await store.create_schema() + async with pool.connection() as connection: + initial = await ( + await connection.execute("SELECT count(*) FROM reporting_configurations") + ).fetchone() + await support.start() + (root / "ready.json").write_text( + json.dumps( + { + "templates": templates, + "initial_configurations": initial[0], + "pool_size": 1, + "source_observed_at": source_observed_at.isoformat(), + "python": __import__("sys").version, + "adcp_file": adcp.__file__, + "pydantic": importlib.metadata.version("pydantic"), + "mcp": importlib.metadata.version("mcp"), + } + ) + ) + + async def shutdown(): + await support.aclose() + await pool.close() + (root / "stopped.json").write_text( + json.dumps( + { + "stopped": True, + "source_requests": { + currency: len(source.requests) for currency, source in sources.items() + }, + "destination_sessions_closed": all( + p.opens == p.closes for p in writer.readers.values() + ), + } + ) + ) + + tokens = { + account + "-test-token": Principal(caller_identity=consumer, tenant_id="shared-tenant") + for account, consumer in CONSUMERS.items() + } + serve( + support.handler, + name="late-account-production", + transport="both", + host="127.0.0.1", + port=args.port, + auth=BearerTokenAuth(validate_token=tokens.get), + context_factory=auth_context_factory, + allowed_hosts=["127.0.0.1", "localhost"], + public_url=f"http://127.0.0.1:{args.port}", + on_startup=[startup], + on_shutdown=[shutdown], + stateless_http=True, + asgi_middleware=[(WireCapture, {"path": str(root / "wire.jsonl")})], + ) + + +if __name__ == "__main__": + main() diff --git a/tests/conformance/reporting/_late_account_support.py b/tests/conformance/reporting/_late_account_support.py new file mode 100644 index 000000000..0fd2dfa24 --- /dev/null +++ b/tests/conformance/reporting/_late_account_support.py @@ -0,0 +1,103 @@ +"""Independent currency contracts and persistent adopter I/O for public progress tests.""" + +import base64 +import hashlib +import json +from dataclasses import replace + +import rfc8785 + +from adcp.reporting.inline_source import InlineFetchResult +from adcp.reporting.materializer import ReportingRevisionVerifier, reference_verifier +from adcp.reporting.materializer.contracts import failure + +from ._production_support import Source, SQLiteDestination + +ACCOUNTS = {"usd": ("USD", 503), "eur": ("EUR", 3)} + + +def rows_for(account): + currency, count = ACCOUNTS[account] + return [ + { + "row_id": f"{number:06d}", + "impressions": number % 3, + "spend": "1.25", + "currency": currency, + "details": {"active": True, "values": [1, None, "é", "e\u0301"]}, + } + for number in range(count) + ] + + +class AccountSource(Source): + async def fetch(self, request): + account = str(request.identity.account_id) + self.requests.append(request) + return InlineFetchResult( + rows_for(account), data_through=request.period.end, currency=ACCOUNTS[account][0] + ) + + +class CurrencyDestination(SQLiteDestination): + """The same SQLite destination, with one independently pinned session per currency.""" + + def __init__(self, path, keys): + super().__init__(path, keys[0]) + self.readers = {key: SQLiteDestination(path, key) for key in keys} + + def resolve(self, request, *, phase, context): + provider = self.readers.get(request.verification_key) + if provider is None: + raise failure("AUTHORIZATION_DENIED") + return provider.resolve(request, phase=phase, context=context) + + +def verifier_for(currency, capability): + base = reference_verifier(capability) + if currency == "USD": + return base + assert currency == "EUR" + definition = json.loads(base.definition_bytes) + definition["report_definition_id"] = "reference-report-eur-v1" + for metric in definition["metrics"]: + if metric.get("unit") == "USD": + metric["unit"] = "EUR" + schema = json.loads(base.schema_bytes) + schema["properties"]["currency"]["const"] = "EUR" + schema["properties"]["spend"]["x-adcp-control-total"]["unit"] = "EUR" + + def encode(value): + return (json.dumps(value, ensure_ascii=False, indent=2) + "\n").encode() + + schema_bytes = encode(schema) + schema_hash = hashlib.sha256(schema_bytes).hexdigest() + contract = json.loads(base.canonicalization_bytes) + contract["schema_sha256"] = schema_hash + for vector in contract["golden_vectors"].values(): + for row in vector["input_rows"]: + row["currency"] = "EUR" + canonical = rfc8785.dumps(sorted(vector["input_rows"], key=lambda row: row["row_id"])) + vector["canonical_utf8_base64"] = base64.b64encode(canonical).decode() + vector["sha256"] = hashlib.sha256(canonical).hexdigest() + definition_bytes, contract_bytes = encode(definition), encode(contract) + key = replace( + base.key, + report_definition_id=definition["report_definition_id"], + definition=replace( + base.key.definition, + report_definition_uri="https://contracts.example.test/reference-eur-definition.json", + report_definition_sha256=hashlib.sha256(definition_bytes).hexdigest(), + schema_uri="https://contracts.example.test/reference-eur-schema.json", + schema_sha256=schema_hash, + monetary_metric_units=(("spend", "EUR"),), + monetary_control_total_units=(("spend", "EUR"),), + ), + canonicalization=replace( + base.key.canonicalization, + canonicalization_id="reference-eur-jcs-rows-v1", + canonicalization_uri="https://contracts.example.test/reference-eur-canonicalization.json", + canonicalization_sha256=hashlib.sha256(contract_bytes).hexdigest(), + ), + ) + return ReportingRevisionVerifier(key, definition_bytes, schema_bytes, contract_bytes) diff --git a/tests/conformance/reporting/_production_packaging.py b/tests/conformance/reporting/_production_packaging.py index 5bebe42f8..d0316d13e 100644 --- a/tests/conformance/reporting/_production_packaging.py +++ b/tests/conformance/reporting/_production_packaging.py @@ -257,6 +257,7 @@ def installed_production(root, python, wheel, source, *, label, driver_absent): for name in ( "tests/conformance/reporting/test_reporting_tier_projection.py", "tests/conformance/reporting/test_reporting_schedule_schema.py", + "tests/conformance/reporting/test_reporting_materializer_progress.py", "tests/test_reporting_revision_ownership.py", "tests/test_reporting_capability_models.py", "tests/test_reporting_scope_models.py", diff --git a/tests/conformance/reporting/test_reporting_materializer_progress.py b/tests/conformance/reporting/test_reporting_materializer_progress.py new file mode 100644 index 000000000..4b3c37cdd --- /dev/null +++ b/tests/conformance/reporting/test_reporting_materializer_progress.py @@ -0,0 +1,311 @@ +"""Account progress while peers stay due, including bounded busy-account scans.""" + +import asyncio +import json +import os +import secrets +from contextlib import asynccontextmanager + +import pytest + +from adcp.reporting.materializer import ( + PgReportingMaterializerStore, + ReportingMaterializerLease, + ReportingWriterError, +) + +from ._durable_materializer_support import durable_case + + +@asynccontextmanager +async def progress_pool(*, autocommit=False): + """Each scenario owns a schema and a size-one pool on the real database.""" + url = os.environ.get("ADCP_PG_TEST_URL") + if not url: + pytest.skip("ADCP_PG_TEST_URL not set — requires real PostgreSQL") + psycopg = pytest.importorskip("psycopg") + psycopg_pool = pytest.importorskip("psycopg_pool") + schema = "adcp_materializer_progress_" + secrets.token_hex(6) + async with await psycopg.AsyncConnection.connect(url, autocommit=True) as admin: + await admin.execute( + psycopg.sql.SQL("CREATE SCHEMA {}").format(psycopg.sql.Identifier(schema)) + ) + try: + async with psycopg_pool.AsyncConnectionPool( + url, + kwargs={ + "options": f"-csearch_path={schema} -cstatement_timeout=15000", + "autocommit": autocommit, + }, + min_size=1, + max_size=1, + open=False, + ) as pool: + await pool.wait(timeout=10) + yield pool, schema + finally: + await admin.execute( + psycopg.sql.SQL("DROP SCHEMA {} CASCADE").format(psycopg.sql.Identifier(schema)) + ) + + +async def wake(pool, account): + # This is the installed function invoked by real producer/binding triggers. + # The store-level regression enrolls due accounts, never successful work. + async with pool.connection() as connection: + await connection.execute("SELECT reporting_materializer_wake(%s)", (account,)) + + +async def served(pool): + async with pool.connection() as connection: + return dict( + await ( + await connection.execute( + "SELECT account_id,served_at::text FROM reporting_materializer_accounts" + " ORDER BY served_at,account_id" + ) + ).fetchall() + ) + + +@pytest.mark.parametrize( + "first,late,keep_waking_first,restart_at_enrollment,expected_late", + [ + ("account-a", "account-z", True, False, 15), + ("account-z", "account-a", True, False, 15), + ("account-a", "account-z", False, False, 30), + ("account-a", "account-z", True, True, 15), + ], + ids=["continuous", "reverse-order", "producer-stops-control", "fresh-store-control"], +) +async def test_late_due_account_is_served_while_the_first_stays_continuously_due( + first, late, keep_waking_first, restart_at_enrollment, expected_late +): + async with progress_pool() as (pool, _): + store = PgReportingMaterializerStore(pool=pool) + await store.create_schema() + await wake(pool, first) + trace = [] + for turn in range(40): + if turn == 10: + await wake(pool, late) + if restart_at_enrollment: + store = PgReportingMaterializerStore(pool=pool) + # Waking from turn zero is essential: a gap before late enrollment + # could reset the old cursor and hide the regression. + if keep_waking_first: + await wake(pool, first) + if turn >= 10: + await wake(pool, late) + before = await served(pool) + await store.claim_materialization(keys=()) + after = await served(pool) + moved = [account for account in after if before.get(account) != after[account]] + assert len(moved) <= 1 + trace.append({"turn": turn, "served": moved}) + late_turns = [row["turn"] for row in trace if late in row["served"]] + print( + json.dumps( + { + "late_account_progress": { + "first": first, + "late": late, + "continuous_first": keep_waking_first, + "restart_at_enrollment": restart_at_enrollment, + "trace": trace, + "late_turns": late_turns, + "pool_size": 1, + } + } + ), + flush=True, + ) + assert late_turns and late_turns[0] <= 11, "late account was excluded from due sampling" + assert len(late_turns) == expected_late + + +def observe_samples(monkeypatch): + psycopg = pytest.importorskip("psycopg") + original = psycopg.AsyncConnection.execute + samples = [] + + async def execute(connection, query, *args, **kwargs): + result = await original(connection, query, *args, **kwargs) + if isinstance(query, str) and query.startswith( + "SELECT account_id,served_at::text FROM reporting_materializer_accounts WHERE due_at" + ): + assert query.endswith("LIMIT 16") + samples.append(result.rowcount) + return result + + monkeypatch.setattr(psycopg.AsyncConnection, "execute", execute) + return samples + + +async def bounded_claim(store, samples, *, keys=()): + before = len(samples) + result = await asyncio.wait_for(store.claim_materialization(keys=keys), 10) + sizes = samples[before:] + assert 1 <= len(sizes) <= 2 and 0 <= sum(sizes) <= 16, sizes + return result + + +@pytest.mark.parametrize("notifications", [False, True]) +async def test_more_than_two_busy_pages_progress_then_recover_after_unlock( + notifications, monkeypatch +): + async with progress_pool(autocommit=True) as (pool, schema): + from psycopg import AsyncConnection + + store = PgReportingMaterializerStore(pool=pool, notifications=notifications) + await store.create_schema() + busy = [f"busy-{number:02}" for number in range(33)] + available = "zz-available" + for account in [*busy, available]: + await wake(pool, account) + samples = observe_samples(monkeypatch) + async with ( + await AsyncConnection.connect( + os.environ["ADCP_PG_TEST_URL"], + options=f"-csearch_path={schema} -cstatement_timeout=15000", + autocommit=True, + ) as holder, + holder.transaction(), + ): + for account in busy: + await store._lock_account(holder, account) + before = await served(pool) + for _ in range(2): + assert (await bounded_claim(store, samples)).state == "idle" + assert await served(pool) == before + assert samples == [16, 16] + await bounded_claim(store, samples) + after = await served(pool) + assert after[available] != before[available] + assert {a: after[a] for a in busy} == {a: before[a] for a in busy} + assert samples[-1] == 2 + # A successful account turn restarts at the oldest durable rank; + # subsequent busy turns must still walk through all three pages. + for expected in (16, 16, 1): + await bounded_claim(store, samples) + assert samples[-1] == expected + await bounded_claim(store, samples) + assert samples[-2:] == [0, 16] + recovered = set() + for _ in range(len(busy) + 3): + before = await served(pool) + await bounded_claim(store, samples) + after = await served(pool) + recovered.update(a for a in busy if after[a] != before[a]) + if recovered == set(busy): + break + assert recovered == set(busy) + assert all(value != "-infinity" for value in (await served(pool)).values()) + + +@pytest.mark.parametrize("notifications", [False, True]) +async def test_commit_failure_rolls_back_rank_and_reservation_then_retries(notifications): + async with progress_pool(autocommit=True) as (pool, _): + store = PgReportingMaterializerStore(pool=pool, notifications=notifications) + await store.create_schema() + case = await durable_case(store) + before = await served(pool) + async with pool.connection() as connection: + # A deferred database error fails COMMIT after all reservation and + # scheduling statements executed. No SDK operation is replaced. + await connection.execute( + "CREATE FUNCTION progress_fail_commit() RETURNS trigger LANGUAGE plpgsql AS $$" + " BEGIN RAISE EXCEPTION 'private-progress-commit-fault'; END $$" + ) + await connection.execute( + "CREATE CONSTRAINT TRIGGER progress_fail_commit" + " AFTER UPDATE ON reporting_materializer_accounts" + " DEFERRABLE INITIALLY DEFERRED FOR EACH ROW" + " EXECUTE FUNCTION progress_fail_commit()" + ) + try: + with pytest.raises(ReportingWriterError) as error: + await store.claim_materialization(keys=case.keys) + assert error.value.failure.code == "RESOURCE_UNAVAILABLE" + assert "private-progress" not in str(error.value) + assert await served(pool) == before + async with pool.connection() as connection: + assert await ( + await connection.execute("SELECT count(*) FROM reporting_materializer_work") + ).fetchone() == (0,) + finally: + async with pool.connection() as connection: + await connection.execute( + "DROP TRIGGER progress_fail_commit ON reporting_materializer_accounts" + ) + await connection.execute("DROP FUNCTION progress_fail_commit()") + lease = await case.claim() + assert isinstance(lease, ReportingMaterializerLease) + assert lease.attempt.attempt == 1 + assert await store.renew_materialization(lease, lease_seconds=30) + assert await served(pool) != before + + +async def test_two_workers_skip_uncommitted_peer_and_reserve_distinct_accounts(monkeypatch): + async with progress_pool(autocommit=True) as (pool, schema): + from psycopg import AsyncConnection + from psycopg_pool import AsyncConnectionPool + + store = PgReportingMaterializerStore(pool=pool) + await store.create_schema() + first = await durable_case(store, account="account-a") + second = await durable_case(store, account="account-z") + async with AsyncConnectionPool( + os.environ["ADCP_PG_TEST_URL"], + kwargs={"options": f"-csearch_path={schema}", "autocommit": True}, + min_size=1, + max_size=1, + open=False, + ) as peer_pool: + await peer_pool.wait(timeout=10) + peer = PgReportingMaterializerStore(pool=peer_pool) + holding, release = asyncio.Event(), asyncio.Event() + original = AsyncConnection.execute + winner = None + + async def execute(connection, query, *args, **kwargs): + result = await original(connection, query, *args, **kwargs) + if ( + asyncio.current_task() is winner + and isinstance(query, str) + and query.startswith("UPDATE reporting_materializer_accounts SET served_at=") + ): + # Only pause after the real update, under the real locks. + holding.set() + await asyncio.wait_for(release.wait(), 10) + return result + + monkeypatch.setattr(AsyncConnection, "execute", execute) + winner = asyncio.create_task(store.claim_materialization(keys=first.keys)) + try: + await asyncio.wait_for(holding.wait(), 10) + other = await asyncio.wait_for(peer.claim_materialization(keys=second.keys), 10) + assert isinstance(other, ReportingMaterializerLease) + assert other.scope.principal.account_id == "account-z" + release.set() + selected = await asyncio.wait_for(winner, 10) + finally: + release.set() + if not winner.done(): + winner.cancel() + await asyncio.gather(winner, return_exceptions=True) + assert isinstance(selected, ReportingMaterializerLease) + assert selected.scope.principal.account_id == "account-a" + assert selected.request.external_id != other.request.external_id + await store.authorize_materialization(selected) + await peer.authorize_materialization(other) + assert await store.renew_materialization(selected, lease_seconds=30) + assert await peer.renew_materialization(other, lease_seconds=30) + for current in (store, peer): + assert not isinstance( + await current.claim_materialization(keys=first.keys), ReportingMaterializerLease + ) + async with pool.connection() as connection: + assert await ( + await connection.execute("SELECT count(*) FROM reporting_materializer_work") + ).fetchone() == (2,) diff --git a/tests/conformance/reporting/test_reporting_production_late_accounts.py b/tests/conformance/reporting/test_reporting_production_late_accounts.py new file mode 100644 index 000000000..c68246c10 --- /dev/null +++ b/tests/conformance/reporting/test_reporting_production_late_accounts.py @@ -0,0 +1,433 @@ +"""Typed public enrollment, autonomous late-account delivery and durable restart.""" + +import asyncio +import hashlib +import json +import os +import signal +import socket +import sqlite3 +import subprocess +import sys +import time +from contextlib import asynccontextmanager +from pathlib import Path + +import pytest +import rfc8785 +from pydantic import TypeAdapter + +from adcp import ADCPClient, AgentConfig +from adcp.reporting import ( + ExpectedReportingPeriod, + ReportingObservation, + load_reporting_ledger, + reconcile_reporting, +) +from adcp.reporting.materializer import ReportingWriterCapability, reference_digest +from adcp.types import ( + GetAdcpCapabilitiesRequest, + GetMediaBuyDeliveryRequest, + GetReportingStatusRequest, + ReportingCanonicalContentDigest, + ReportingControlTotal, + SyncAccountsRequest, +) + +from ._late_account_support import ACCOUNTS, rows_for, verifier_for +from .test_reporting_materializer_progress import progress_pool, served +from .test_reporting_production_scope import assert_access_denied + + +@asynccontextmanager +async def running_server(root, schema, index, *, notifications): + fixture_root = Path(__file__).resolve().parents[3] + launcher = ( + "import sys; sys.path.insert(0,sys.argv.pop(1)); " + "from tests.conformance.reporting._late_account_server import main; main()" + ) + (root / "ready.json").unlink(missing_ok=True) + (root / "stopped.json").unlink(missing_ok=True) + with socket.socket() as listener: + listener.bind(("127.0.0.1", 0)) + port = listener.getsockname()[1] + command = [ + sys.executable, + "-I", + "-c", + launcher, + str(fixture_root), + "--port", + str(port), + "--root", + str(root), + "--schema", + schema, + ] + if notifications: + command.append("--notifications") + with ( + (root / f"server-{index}.stdout").open("xb") as output, + (root / f"server-{index}.stderr").open("xb") as errors, + ): + child = subprocess.Popen( + command, + cwd=root, + stdout=output, + stderr=errors, + start_new_session=True, + ) + try: + for _ in range(1800): + assert child.poll() is None, (root / f"server-{index}.stderr").read_text() + if (root / "ready.json").exists(): + try: + _, writer = await asyncio.open_connection("127.0.0.1", port) + except OSError: + pass + else: + writer.close() + await writer.wait_closed() + break + await asyncio.sleep(0.05) + else: + raise AssertionError("public production startup exceeded 90 seconds") + ready = json.loads((root / "ready.json").read_text()) + (root / f"ready-{index}.json").write_text(json.dumps(ready)) + yield f"http://127.0.0.1:{port}/mcp/", ready + finally: + if child.poll() is None: + os.killpg(child.pid, signal.SIGTERM) + try: + await asyncio.to_thread(child.wait, 25) + except subprocess.TimeoutExpired: + os.killpg(child.pid, signal.SIGKILL) + await asyncio.to_thread(child.wait, 5) + cleanup = { + "pid": child.pid, + "exit": child.returncode, + "reaped": child.poll() is not None, + } + if (root / "stopped.json").exists(): + cleanup.update(json.loads((root / "stopped.json").read_text())) + (root / f"cleanup-{index}.json").write_text(json.dumps(cleanup)) + assert cleanup.get("stopped") and cleanup["destination_sessions_closed"], cleanup + + +def client_for(uri, account): + return ADCPClient( + AgentConfig( + id="late-" + account, + agent_uri=uri, + protocol="mcp", + auth_token=account + "-test-token", + auth_header="Authorization", + auth_type="bearer", + ), + adcp_version="3.2.0-rc.4", + ) + + +def data(result): + assert result.success and result.data is not None, result + return result.data.model_dump(mode="json", exclude_none=True) + + +async def delivered_and_reconciled(client, root, ready, account, *, replay=False, period=None): + currency, count = ACCOUNTS[account] + capability = ReportingWriterCapability( + "warehouse_materialization", + "fixture-sql", + "jsonl", + "canonical_digest", + "destination", + "immutable_location", + "sha256", + "conditional_create", + ) + verifier = verifier_for(currency, capability) + selectors = {"account": {"account_id": account}, "view": "periods"} + if period is not None: + selectors["period"] = period + request = GetReportingStatusRequest.model_validate(selectors) + started = time.monotonic() + ledger = None + calls = 0 + # Every readiness observation is an actual typed RPC. The scheduler and + # producer use real clocks; no worker is stepped, stopped or manually invoked. + while time.monotonic() - started < 45 and calls < 150: + ledger = await load_reporting_ledger(client, request) + calls += 1 + if any( + str(getattr(m.status, "value", m.status)) == "delivered" + for m in ledger.materializations + ): + break + else: + raise AssertionError({"account": account, "materialization_missing_after_calls": calls}) + readiness_seconds = time.monotonic() - started + if period is None: + # Catch-up production is deliberately active throughout this test. Pin + # a publicly delivered period, rather than assuming which candidate the + # materializer chooses first among that account's outstanding periods. + delivered = next( + m + for m in ledger.materializations + if str(getattr(m.status, "value", m.status)) == "delivered" + ) + obligation = next( + o + for o in ledger.obligations + if o.reporting_obligation_id == delivered.reporting_obligation_id + ) + period = {name: getattr(obligation.period, name).isoformat() for name in ("start", "end")} + selectors["period"] = period + request = GetReportingStatusRequest.model_validate(selectors) + ledger = await load_reporting_ledger(client, request) + assert len(ledger.obligations) == len(ledger.revisions) == len(ledger.materializations) == 1 + revision = ledger.revisions[0] + materialization = ledger.materializations[0] + assert revision.account_id == ledger.obligations[0].account_id == account + assert revision.row_count == count + params = { + "account": {"account_id": account}, + "reporting_revision_id": revision.reporting_revision_id, + "pagination": {"max_results": 100}, + } + exact, binding, pages = [], None, 0 + while True: + page = data( + await client.get_media_buy_delivery(GetMediaBuyDeliveryRequest.model_validate(params)) + ) + if binding is None: + binding = page["reporting_revision_binding"] + else: + assert binding == page["reporting_revision_binding"] + exact.extend(page["reporting_rows"]) + pages += 1 + if not page["pagination"]["has_more"]: + break + params["pagination"]["cursor"] = page["pagination"]["cursor"] + assert pages < 10 + assert exact == rows_for(account) + assert pages == (6 if count == 503 else 1) + digest_input = {k: binding[k] for k in ("reporting_revision_id", "row_count", "control_totals")} + digest_input["reporting_rows"] = exact + digest = hashlib.sha256(rfc8785.dumps(digest_input)).hexdigest() + assert digest == binding["content_sha256"] == revision.revision_content_sha256 + + async def inspect(context): + with sqlite3.connect(root / "destination.sqlite") as connection: + records = [ + json.loads(row[0]) for row in connection.execute("SELECT content FROM artifacts") + ] + matched = [ + record + for record in records + if record["revision"] == context.revision.reporting_revision_id + and record["resource"]["location"] == context.materialization.resource.location + ] + assert len(matched) == 1 + rows = [json.loads(row) for row in matched[0]["rows"]] + assert rows == exact + _, totals = verifier.canonicalize(rows) + return ReportingObservation( + len(rows), + [TypeAdapter(ReportingControlTotal).validate_python(t.to_wire()) for t in totals], + ReportingCanonicalContentDigest.model_validate( + reference_digest(verifier, rows).to_wire() + ), + ) + + template = ready["templates"][account] + expected = [ + ExpectedReportingPeriod( + "shared-config", + 1, + template["report_definition_id"], + "billing", + template["reporting_profile"], + ("shared-media-buy",), + period["start"], + period["end"], + ) + ] + outcome = await reconcile_reporting( + client, request, inspect, expected_periods=expected, inspection_retry_backoff_seconds=0 + ) + assert outcome.definitive, [(o.definitive, o.reasons) for o in outcome.obligations] + assert len(outcome.submitted_receipts) == (0 if replay else 1) + repeat = await reconcile_reporting( + client, request, inspect, expected_periods=expected, inspection_retry_backoff_seconds=0 + ) + assert repeat.definitive and not repeat.submitted_receipts + other = "eur" if account == "usd" else "usd" + assert_access_denied( + await client.get_reporting_status( + GetReportingStatusRequest.model_validate( + { + "account": {"account_id": other}, + "view": "periods", + } + ) + ) + ) + assert_access_denied( + await client.get_media_buy_delivery( + GetMediaBuyDeliveryRequest.model_validate( + { + "account": {"account_id": other}, + "reporting_revision_id": revision.reporting_revision_id, + } + ) + ) + ) + return { + "account": account, + "currency": currency, + "rows": count, + "pages": pages, + "revision": revision.reporting_revision_id, + "digest": digest, + "receipt_ids": [receipt.reporting_receipt_id for receipt in repeat.ledger.receipts], + "materialization_id": materialization.reporting_materialization_id, + "period": period, + "readiness_calls": calls, + "readiness_seconds": round(readiness_seconds, 3), + "completed_seconds": round(time.monotonic() - started, 3), + } + + +async def ongoing_first_turns(pool, account): + """Observe real committed turns after first-account RPCs have finished. + + A one-period fixture can settle or encounter a busy-lock wrap during those + RPCs, accidentally passing with the old cursor. Catch-up publications keep + pending work due. These plain MVCC reads neither take account locks nor + wake, advance or execute either SDK worker. + """ + observed = [] + deadline = time.monotonic() + 30 + previous = (await served(pool))[account] + while time.monotonic() < deadline: + async with pool.connection() as connection: + row = await ( + await connection.execute( + "SELECT served_at::text,due_at<=clock_timestamp()," + " (SELECT count(*) FROM reporting_materializer_candidates c" + " WHERE c.account_id=a.account_id AND c.due_at<=clock_timestamp())" + " FROM reporting_materializer_accounts a WHERE account_id=%s", + (account,), + ) + ).fetchone() + if row[0] != previous: + observed.append({"served_at": row[0], "due": row[1], "due_candidates": row[2]}) + previous = row[0] + if len(observed) >= 3 and all( + r["due"] and r["due_candidates"] >= 2 for r in observed[-3:] + ): + return observed[-3:] + await asyncio.sleep(0.02) + raise AssertionError({"continuous_first_work_not_established": observed}) + + +@pytest.mark.parametrize("first", ["usd", "eur"], ids=["late-sorts-first", "late-sorts-last"]) +@pytest.mark.parametrize("notifications", [False, True]) +async def test_late_account_progresses_via_typed_public_support_and_survives_restart( + first, notifications, tmp_path +): + order = (first, "eur" if first == "usd" else "usd") + async with progress_pool(autocommit=True) as (pool, schema): + results = [] + continuing_work = None + for index in range(2): + async with running_server(tmp_path, schema, index, notifications=notifications) as ( + uri, + ready, + ): + assert ready["pool_size"] == 1 + assert ready["initial_configurations"] == (0 if index == 0 else 2) + current = {} + for account in order: + if index == 0 and account != first: + continuing_work = await ongoing_first_turns(pool, first) + async with client_for(uri, account) as client: + caps = data( + await client.get_adcp_capabilities(GetAdcpCapabilitiesRequest()) + ) + assert caps["media_buy"]["reporting_delivery"]["managed_delivery"] + if index == 0: + # The first remains active throughout late admission; + # no worker restart, due-gap workaround or manual turn. + first_before = (await served(pool)).get(first) + onboarding = SyncAccountsRequest.model_validate( + { + "idempotency_key": "late-account-" + account, + "accounts": [ + { + "account": {"account_id": account}, + "reporting_delivery_configs": [ + ready["templates"][account] + ], + } + ], + } + ) + admitted = data(await client.sync_accounts(onboarding)) + assert admitted["accounts"][0]["account_id"] == account + assert ( + admitted["accounts"][0]["reporting_delivery_configs"][0]["state"] + == "ready" + ) + current[account] = await delivered_and_reconciled( + client, + tmp_path, + ready, + account, + replay=bool(index), + period=results[0][account]["period"] if index else None, + ) + if index == 0 and account != first: + assert (await served(pool))[first] != first_before + results.append(current) + async with pool.connection() as connection: + assert await ( + await connection.execute( + "SELECT count(*) FROM pg_stat_activity WHERE application_name=%s", + (schema,), + ) + ).fetchone() == (0,) + for account in order: + for field in ("revision", "digest", "receipt_ids", "materialization_id"): + assert results[0][account][field] == results[1][account][field] + assert results[0]["usd"]["revision"] != results[0]["eur"]["revision"] + assert results[0]["usd"]["materialization_id"] != results[0]["eur"]["materialization_id"] + wire = (tmp_path / "wire.jsonl").read_text() + assert "-test-token" not in wire + for line in wire.splitlines(): + request = json.loads(json.loads(line)["request_utf8"]) + if request.get("method") != "tools/call": + continue + task, arguments = request["params"]["name"], request["params"]["arguments"] + if task == "sync_accounts": + assert arguments["accounts"][0]["reporting_delivery_configs"][0]["scope"] == { + "media_buy_ids": ["shared-media-buy"], + } + if task == "get_media_buy_delivery": + assert "include_package_daily_breakdown" not in arguments + assert "include_window_breakdown" not in arguments + print( + json.dumps( + { + "public_late_account_progress": { + "order": order, + "notifications": notifications, + "runs": results, + "typed_onboarding_and_exact_reads": True, + "autonomous_worker": True, + "observed_due_first_turns_before_late_admission": continuing_work, + } + } + ), + flush=True, + ) From fe1a1cbd070bc94ec32685026ad39ec55058dc73 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Tue, 22 Sep 2026 14:46:22 +0000 Subject: [PATCH 06/12] fix(decisioning): return sanitized details on narrowing failure --- src/adcp/decisioning/dispatch.py | 2 +- tests/test_decisioning_dispatch.py | 34 ++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/src/adcp/decisioning/dispatch.py b/src/adcp/decisioning/dispatch.py index faad9898d..e643021de 100644 --- a/src/adcp/decisioning/dispatch.py +++ b/src/adcp/decisioning/dispatch.py @@ -700,7 +700,7 @@ def _internal_error_details(exc: BaseException) -> dict[str, Any]: except Exception: # Defensive — never let a narrowing bug 500 the wire. # The exception type still lets adopters triage via server logs. - pass + return details return details diff --git a/tests/test_decisioning_dispatch.py b/tests/test_decisioning_dispatch.py index 357da99b0..01f48f4bf 100644 --- a/tests/test_decisioning_dispatch.py +++ b/tests/test_decisioning_dispatch.py @@ -879,6 +879,40 @@ async def get_products(self, req, ctx): assert "eyJhbGciOiJIUzI1NiJ9" not in str(exc_info.value.details) +def test_internal_error_details_survive_a_narrowing_failure_without_raw_values(monkeypatch): + from pydantic import ValidationError + + from adcp.decisioning.dispatch import _internal_error_details + from adcp.types import error_narrowing + + class _InvalidResponse(BaseModel): + count: int + + with pytest.raises(ValidationError) as captured: + _InvalidResponse.model_validate({"count": "raw-input-marker"}) + observed = [] + + def broken_narrowing(errors): + observed.extend(errors) + yield {"msg": "partial-narrowing-marker"} + raise RuntimeError("raw-narrowing-failure-marker") + + monkeypatch.setattr(error_narrowing, "narrow_union_errors", broken_narrowing) + details = _internal_error_details(captured.value) + assert observed and all("input" not in item and "ctx" not in item for item in observed) + assert details == {"caused_by": {"type": "ValidationError"}} + # A partial generator result and either raw value must not enter the error + # response; the secondary narrowing failure must not replace the original. + assert all( + marker not in str(details) + for marker in ( + "raw-input-marker", + "partial-narrowing-marker", + "raw-narrowing-failure-marker", + ) + ) + + @pytest.mark.asyncio async def test_invoke_validation_error_surfaces_narrowed_field_paths( executor: ThreadPoolExecutor, From 26151fc50ef4f3e24308f6cef1304b8faf8603d0 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Tue, 22 Sep 2026 14:52:26 +0000 Subject: [PATCH 07/12] fix(reporting): timestamp revisions after source acquisition --- src/adcp/reporting/ledger/producer.py | 42 +- .../reporting/_late_account_server.py | 57 ++- .../reporting/_production_packaging.py | 1 + ...test_reporting_production_late_accounts.py | 4 +- ...t_reporting_production_publication_time.py | 121 ++++++ .../test_reporting_publication_time.py | 409 ++++++++++++++++++ 6 files changed, 623 insertions(+), 11 deletions(-) create mode 100644 tests/conformance/reporting/test_reporting_production_publication_time.py create mode 100644 tests/conformance/reporting/test_reporting_publication_time.py diff --git a/src/adcp/reporting/ledger/producer.py b/src/adcp/reporting/ledger/producer.py index b35b4e45d..66f6d8fb8 100644 --- a/src/adcp/reporting/ledger/producer.py +++ b/src/adcp/reporting/ledger/producer.py @@ -579,6 +579,9 @@ async def acquire_obligation( snapshot obligation. Without it a satisfied obligation is left alone, because re-reading a settled period on every worker turn would burn upstream quota to republish bytes nobody asked for. + + ``now`` freezes dispatch and the source read cutoff. Revision creation + uses a fresh producer clock sample after the staged objects are read. """ if configuration.generation_key != obligation.generation_key: raise LedgerConflictError( @@ -664,12 +667,21 @@ async def acquire_obligation( manifest = self._verified_manifest(result) self._validate_manifest_currency(obligation, manifest) + rows = await self._read_rows(request, manifest) + # ``now`` freezes dispatch/lease/cutoff decisions, not publication. + # A conforming source can observe finality while acquisition is running. + published_at = self._clock() + if _utc(published_at) < _utc(now): + raise LedgerConflictError( + "PUBLICATION_TIME_INVALID", + "producer clock regressed during acquisition; correct the clock before retrying", + ) return await self.commit_revision_from_manifest( obligation, manifest, - rows=await self._read_rows(request, manifest), + rows=rows, finality=finality, - now=now, + now=published_at, turn=turn, ) @@ -734,7 +746,9 @@ async def commit_revision_from_manifest( A snapshot restatement supersedes the current snapshot leaf; there is no edit path. An official close is terminal, so a later source correction - must arrive as an adjustment instead. + must arrive as an adjustment instead. ``now`` is a trusted publication + instant, unlike the dispatch instant accepted by ``acquire_obligation``. + Replaying a publication retains its original creation time and parent. """ obligation = await self._stored_obligation(obligation) self._validate_manifest_currency(obligation, manifest) @@ -771,6 +785,26 @@ async def commit_revision_from_manifest( control_totals = tuple((total.name, total.value) for total in manifest.control_totals) revision_id = f"rpr_{manifest.publication_id[4:44]}" + prior = next((item for item in existing if item.reporting_revision_id == revision_id), None) + created_at = prior.created_at if prior is not None else now + if prior is not None: + # Still reconstruct and verify the supplied content below. Merely + # finding the ID must not bypass immutable-content validation. + supersedes = prior.supersedes_reporting_revision_id + if ( + _utc(manifest.acquired_at) > _utc(now) + or _utc(manifest.observed_at) > _utc(created_at) + or _utc(manifest.finality_evidence.observed_at) > _utc(created_at) + or ( + finality == "official" + and _utc(manifest.finality_evidence.observed_at) < _utc(obligation.period.end) + ) + ): + raise LedgerConflictError( + "PUBLICATION_TIME_INVALID", + "source observation or finality is outside the publication time bounds; " + "check source evidence and the producer clock before retrying", + ) revision = ReportingRevisionRecord( reporting_revision_id=revision_id, account_id=obligation.account_id, @@ -786,7 +820,7 @@ async def commit_revision_from_manifest( control_totals=control_totals, observed_at=manifest.observed_at, data_through=manifest.data_through, - created_at=now, + created_at=created_at, supersedes_reporting_revision_id=supersedes, finality_basis="source_final" if finality == "official" else None, finality_policy_id=( diff --git a/tests/conformance/reporting/_late_account_server.py b/tests/conformance/reporting/_late_account_server.py index 97bdd9482..8bcd484d3 100644 --- a/tests/conformance/reporting/_late_account_server.py +++ b/tests/conformance/reporting/_late_account_server.py @@ -33,6 +33,7 @@ ) from adcp.reporting.projection import PgReportingStatusProjection from adcp.reporting.receipts import ReportingReceiptError +from adcp.reporting.source import parse_verified_source_batch_manifest_v1 from adcp.server import serve from adcp.server.auth import BearerTokenAuth, Principal, auth_context_factory from adcp.types import ReportingDeliveryOffering @@ -43,6 +44,39 @@ CONSUMERS = {"usd": "urn:buyer:usd", "eur": "urn:buyer:eur"} +class LiveAccountSource(AccountSource): + """Record the public source boundary, without changing its returned evidence.""" + + async def execute(self, request, *, cancel, heartbeat=None): + result = await super().execute(request, cancel=cancel, heartbeat=heartbeat) + if result.ok: + manifest = parse_verified_source_batch_manifest_v1( + result.response.manifest, result.manifest_bytes + ) + with self.observation_log.open("a") as output: + output.write( + json.dumps( + { + "account": request.identity.account_id, + "period": { + "start": request.period.start.isoformat(), + "end": request.period.end.isoformat(), + }, + "source_read_cutoff_at": ( + request.period.source_read_cutoff_at.isoformat() + ), + "observed_at": manifest.observed_at.isoformat(), + "acquired_at": manifest.acquired_at.isoformat(), + "finalized_at": manifest.finality_evidence.observed_at.isoformat(), + "data_through": manifest.data_through.isoformat(), + "publication_id": manifest.publication_id, + } + ) + + "\n" + ) + return result + + class WireCapture: def __init__(self, app, path): self.app, self.path = app, Path(path) @@ -91,6 +125,7 @@ def main(): parser.add_argument("--root", type=Path, required=True) parser.add_argument("--schema", required=True) parser.add_argument("--notifications", action="store_true") + parser.add_argument("--live-source-observation", action="store_true") args = parser.parse_args() root = args.root pool = AsyncConnectionPool( @@ -121,18 +156,25 @@ def main(): ) registry = ReportingRevisionVerifierRegistry(tuple(verifiers.values())) sources, producers, offerings = {}, {}, {} - # This adopter exposes an immutable historical dataset observed before - # startup. Its source observation remains that timestamp on every fetch; - # production turns and PostgreSQL lease scheduling use their real clocks. + # The runtime fairness test keeps a historical source observation. The + # separate publication-time test selects a real UTC sample after fetch. + # Production turns and PostgreSQL lease scheduling always use real clocks. source_observed_at = datetime.now(timezone.utc) for currency, verifier in verifiers.items(): key = verifier.key - source = AccountSource( + source_class = LiveAccountSource if args.live_source_observation else AccountSource + source = source_class( key, root / ("source-" + currency), - clock=lambda: source_observed_at, + clock=( + (lambda: datetime.now(timezone.utc)) + if args.live_source_observation + else (lambda: source_observed_at) + ), official=True, ) + if args.live_source_observation: + source.observation_log = root / "source-observations.jsonl" producer = ReportingProducer( source=source, store=store, @@ -342,7 +384,10 @@ async def startup(): "templates": templates, "initial_configurations": initial[0], "pool_size": 1, - "source_observed_at": source_observed_at.isoformat(), + "source_observed_at": ( + None if args.live_source_observation else source_observed_at.isoformat() + ), + "live_source_observation": args.live_source_observation, "python": __import__("sys").version, "adcp_file": adcp.__file__, "pydantic": importlib.metadata.version("pydantic"), diff --git a/tests/conformance/reporting/_production_packaging.py b/tests/conformance/reporting/_production_packaging.py index d0316d13e..cfc1e639a 100644 --- a/tests/conformance/reporting/_production_packaging.py +++ b/tests/conformance/reporting/_production_packaging.py @@ -258,6 +258,7 @@ def installed_production(root, python, wheel, source, *, label, driver_absent): "tests/conformance/reporting/test_reporting_tier_projection.py", "tests/conformance/reporting/test_reporting_schedule_schema.py", "tests/conformance/reporting/test_reporting_materializer_progress.py", + "tests/conformance/reporting/test_reporting_publication_time.py", "tests/test_reporting_revision_ownership.py", "tests/test_reporting_capability_models.py", "tests/test_reporting_scope_models.py", diff --git a/tests/conformance/reporting/test_reporting_production_late_accounts.py b/tests/conformance/reporting/test_reporting_production_late_accounts.py index c68246c10..1b760157b 100644 --- a/tests/conformance/reporting/test_reporting_production_late_accounts.py +++ b/tests/conformance/reporting/test_reporting_production_late_accounts.py @@ -40,7 +40,7 @@ @asynccontextmanager -async def running_server(root, schema, index, *, notifications): +async def running_server(root, schema, index, *, notifications, live_source_observation=False): fixture_root = Path(__file__).resolve().parents[3] launcher = ( "import sys; sys.path.insert(0,sys.argv.pop(1)); " @@ -66,6 +66,8 @@ async def running_server(root, schema, index, *, notifications): ] if notifications: command.append("--notifications") + if live_source_observation: + command.append("--live-source-observation") with ( (root / f"server-{index}.stdout").open("xb") as output, (root / f"server-{index}.stderr").open("xb") as errors, diff --git a/tests/conformance/reporting/test_reporting_production_publication_time.py b/tests/conformance/reporting/test_reporting_production_publication_time.py new file mode 100644 index 000000000..c1642e2f9 --- /dev/null +++ b/tests/conformance/reporting/test_reporting_production_publication_time.py @@ -0,0 +1,121 @@ +"""Actual MCP/PG publication after real source observation, including restart.""" + +import json +from datetime import datetime + +import pytest + +from adcp.reporting import load_reporting_ledger +from adcp.types import GetReportingStatusRequest, SyncAccountsRequest + +from .test_reporting_materializer_progress import progress_pool +from .test_reporting_production_late_accounts import ( + client_for, + data, + delivered_and_reconciled, + running_server, +) + + +@pytest.mark.parametrize("account", ["usd", "eur"]) +async def test_real_source_observation_before_publication_reconciles_and_replays(account, tmp_path): + async with progress_pool(autocommit=True) as (pool, schema): + runs = [] + for index in range(2): + async with running_server( + tmp_path, + schema, + index, + notifications=False, + live_source_observation=True, + ) as (uri, ready): + assert ready["pool_size"] == 1 and ready["live_source_observation"] + assert ready["initial_configurations"] == (0 if index == 0 else 1) + async with client_for(uri, account) as client: + if index == 0: + admitted = data( + await client.sync_accounts( + SyncAccountsRequest.model_validate( + { + "idempotency_key": "publication-time-" + account, + "accounts": [ + { + "account": {"account_id": account}, + "reporting_delivery_configs": [ + ready["templates"][account] + ], + } + ], + } + ) + ) + ) + assert ( + admitted["accounts"][0]["reporting_delivery_configs"][0]["state"] + == "ready" + ) + result = await delivered_and_reconciled( + client, + tmp_path, + ready, + account, + replay=bool(index), + period=runs[0]["period"] if index else None, + ) + ledger = await load_reporting_ledger( + client, + GetReportingStatusRequest.model_validate( + { + "account": {"account_id": account}, + "view": "periods", + "period": result["period"], + } + ), + ) + assert len(ledger.revisions) == 1 + revision = ledger.revisions[0] + observations = [ + json.loads(line) + for line in (tmp_path / "source-observations.jsonl") + .read_text() + .splitlines() + ] + matches = [ + item + for item in observations + if item["account"] == account and item["period"] == result["period"] + ] + assert len(matches) == 1 + source = matches[0] + # These values come from the actual source's public request + # and result, and the revision comes from a typed HTTP read. + assert datetime.fromisoformat(source["source_read_cutoff_at"]) < ( + revision.observed_at + ) + assert revision.observed_at == datetime.fromisoformat(source["observed_at"]) + assert revision.finalized_at == datetime.fromisoformat(source["finalized_at"]) + assert revision.data_through == datetime.fromisoformat(source["data_through"]) + assert datetime.fromisoformat(source["acquired_at"]) <= revision.created_at + assert revision.finalized_at <= revision.created_at + result["source"] = source + result["revision_evidence"] = revision.model_dump( + mode="json", exclude_none=True + ) + runs.append(result) + async with pool.connection() as connection: + assert await ( + await connection.execute( + "SELECT count(*) FROM pg_stat_activity WHERE application_name=%s", (schema,) + ) + ).fetchone() == (0,) + for field in ( + "revision", + "digest", + "receipt_ids", + "materialization_id", + "source", + "revision_evidence", + ): + assert runs[0][field] == runs[1][field] + assert "-test-token" not in (tmp_path / "wire.jsonl").read_text() + print(json.dumps({"public_real_clock_publication": {"account": account, "runs": runs}})) diff --git a/tests/conformance/reporting/test_reporting_publication_time.py b/tests/conformance/reporting/test_reporting_publication_time.py new file mode 100644 index 000000000..59b8c75a4 --- /dev/null +++ b/tests/conformance/reporting/test_reporting_publication_time.py @@ -0,0 +1,409 @@ +"""Publication clocks are distinct from dispatch and immutable source evidence.""" + +from __future__ import annotations + +import asyncio +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone + +import pytest +from pydantic import ValidationError + +from adcp.reporting import ( + ExpectedReportingPeriod, + ReportingLedger, + evaluate_reporting_ledger, +) +from adcp.reporting.conformance import validate_reporting_source_execution +from adcp.reporting.ledger import ( + InMemoryReportingLedgerStore, + LedgerConflictError, + PgReportingLedgerStore, + ProducerOfferings, + ReportingConfiguration, + ReportingProducer, + ReportingScheduleSpec, + ReportingStatusCaller, + ReportingStatusHandler, +) +from adcp.reporting.materializer import ReportingWriterCapability, reference_verifier +from adcp.reporting.source import SourceBatchManifestV1, parse_verified_source_batch_manifest_v1 +from adcp.types import GetReportingStatusResponse +from adcp.validation.schema_loader import get_named_validator + +from ._generation_support import END, START, isolated_reporting_pool +from ._production_support import Source + +TURN = END + timedelta(hours=1) +PUBLISHED = TURN + timedelta(seconds=2) +ROWS = [{"row_id": "1", "impressions": 5, "spend": "1.25", "currency": "USD"}] + + +@dataclass +class Clock: + now: datetime = TURN + + def __call__(self): + return self.now + + +class RecordedSource(Source): + async def execute(self, request, *, cancel, heartbeat=None): + await asyncio.sleep(0) # The source can complete after the dispatch instant. + result = await super().execute(request, cancel=cancel, heartbeat=heartbeat) + self.executions.append((request, result)) + return result + + +class DelayedReader: + def __init__(self, reader, complete): + self.reader, self.complete = reader, complete + self.calls = 0 + + async def read(self, **kwargs): + result = await self.reader.read(**kwargs) + await asyncio.sleep(0) + self.complete() + self.calls += 1 + return result + + +@pytest.fixture(params=["memory", "postgres"]) +async def store(request): + if request.param == "memory": + yield InMemoryReportingLedgerStore(clock=lambda: datetime.now(timezone.utc)) + else: + async with isolated_reporting_pool() as pool: + ledger = PgReportingLedgerStore(pool=pool) + await ledger.create_schema() + yield ledger + + +async def setup( + store, path, *, observed=TURN, completion=PUBLISHED, real=False, finality="official" +): + verifier = reference_verifier( + ReportingWriterCapability( + "warehouse_materialization", + "fixture-sql", + "jsonl", + "canonical_digest", + "destination", + "immutable_location", + "sha256", + "conditional_create", + ) + ) + key = verifier.key + clock = Clock() + source = RecordedSource( + key, + path, + ROWS, + official=finality == "official", + product_ids=(key.report_definition_id,), + clock=(lambda: datetime.now(timezone.utc)) if real else (lambda: observed), + ) + source.executions = [] + config = ReportingConfiguration( + "publication-clock", + 1, + "account-clock", + key.report_definition_id, + key.reporting_profile, + "analytics", + ReportingScheduleSpec("PT1H", "PT1H", period_anchor=START), + finality, + activated_at=START, + deactivated_at=END, + media_buy_ids=("mb-clock",), + definition=key.definition, + ) + await store.put_configuration(config) + + def complete_read(): + if not real: + clock.now = completion + + reader = DelayedReader(source.reader, complete_read) + + def producer(): + return ReportingProducer( + source=source, + store=store, + object_reader=reader, + offerings=ProducerOfferings( + official_offering_id=source.source_id if finality == "official" else None, + snapshot_offering_id=source.source_id if finality == "snapshot" else None, + publication_namespace=source.capabilities.offerings[0].publication_namespace, + source_scope=source.capabilities.source_scope, + ), + revision_verifier=verifier, + max_periods_per_turn=1, + **({} if real else {"clock": clock}), + ) + + return config, source, reader, clock, producer + + +async def public_outcome(store, config): + payload = await ReportingStatusHandler(store).handle( + { + "adcp_version": "3.2-rc.4", + "account": {"account_id": config.account_id}, + "view": "periods", + "period": {"start": START.isoformat(), "end": END.isoformat()}, + }, + caller=ReportingStatusCaller(account_id=config.account_id, consumer_id="clock-buyer"), + ) + response = GetReportingStatusResponse.model_validate(payload) + validator = get_named_validator("core/reporting-revision.json", version="3.2.0-rc.4") + assert validator is not None + for revision in payload["revisions"]: + validator.validate(revision) + ledger = ReportingLedger( + ledger_snapshot_id=response.ledger_snapshot_id, + ledger_as_of=response.ledger_as_of, + account_id=response.account_id, + scope=response.scope, + obligations=response.periods, + revisions=response.revisions, + materializations=response.materializations, + receipts=response.receipts, + ) + expected = [ + ExpectedReportingPeriod( + config.delivery_config_id, + 1, + config.report_definition_id, + config.feed_purpose, + config.reporting_profile, + config.media_buy_ids, + START.isoformat(), + END.isoformat(), + ) + ] + return response, evaluate_reporting_ledger(ledger, expected_periods=expected) + + +@pytest.mark.parametrize("observed", [END, TURN, TURN + timedelta(seconds=1)]) +async def test_creation_follows_acquisition_and_staged_read(store, tmp_path, observed): + config, source, reader, clock, factory = await setup( + store, tmp_path / "source", observed=observed + ) + turn = await factory().run_worker() + assert len(turn.revisions_committed) == len(source.executions) == reader.calls == 1 + request, result = source.executions[0] + manifest = await validate_reporting_source_execution( + capabilities=source.capabilities, + request=request, + result=result, + object_reader=source.reader, + clock=clock, + ) + response, outcome = await public_outcome(store, config) + revision = response.revisions[0] + assert outcome.definitive, [o.reasons for o in outcome.obligations] + assert request.period.source_read_cutoff_at == TURN + assert revision.created_at == PUBLISHED + assert revision.observed_at == revision.finalized_at == manifest.observed_at == observed + assert revision.data_through == END + + +async def test_real_clock_observation_after_dispatch_is_definitive(store, tmp_path): + config, source, reader, _, factory = await setup(store, tmp_path / "source", real=True) + turn = await factory().run_worker() + assert len(turn.revisions_committed) == reader.calls == 1 + request, result = source.executions[0] + manifest = await validate_reporting_source_execution( + capabilities=source.capabilities, + request=request, + result=result, + object_reader=source.reader, + ) + response, outcome = await public_outcome(store, config) + revision = response.revisions[0] + assert outcome.definitive, [o.reasons for o in outcome.obligations] + assert request.period.source_read_cutoff_at < manifest.observed_at <= revision.created_at + assert revision.observed_at == revision.finalized_at == manifest.observed_at + + +@pytest.mark.parametrize( + "observed,completion", + [(TURN + timedelta(seconds=30), PUBLISHED), (END, TURN - timedelta(seconds=1))], + ids=["source-clock-in-future", "trusted-clock-regresses-after-dispatch"], +) +async def test_invalid_publication_clock_stops_before_immutable_write( + store, tmp_path, observed, completion, monkeypatch +): + config, source, _, _, factory = await setup( + store, + tmp_path / "source", + observed=observed, + completion=completion, + ) + writes = [] + original = store.commit_revision + + async def commit(revision, rows): + writes.append(revision) + return await original(revision, rows) + + monkeypatch.setattr(store, "commit_revision", commit) + with pytest.raises(LedgerConflictError) as raised: + await factory().run_worker() + assert raised.value.code == "PUBLICATION_TIME_INVALID" + assert not writes + request, result = source.executions[0] + manifest = parse_verified_source_batch_manifest_v1( + result.response.manifest, result.manifest_bytes + ) + assert manifest.observed_at == observed # Original source evidence was not backdated. + assert ( + await store.list_revisions( + account_id=config.account_id, + reporting_obligation_id=request.identity.reporting_obligation_id, + ) + == () + ) + + +@pytest.mark.parametrize("finality", ["official", "snapshot"]) +async def test_same_publication_replay_preserves_committed_time_and_source_evidence( + store, tmp_path, finality +): + config, source, reader, clock, factory = await setup( + store, tmp_path / "source", finality=finality + ) + producer = factory() + turn = await producer.run_worker() + assert len(turn.revisions_committed) == 1 + request, result = source.executions[0] + manifest = parse_verified_source_batch_manifest_v1( + result.response.manifest, result.manifest_bytes + ) + obligation = await store.get_obligation( + account_id=config.account_id, + reporting_obligation_id=request.identity.reporting_obligation_id, + ) + first = await store.get_revision( + account_id=config.account_id, reporting_revision_id=turn.revisions_committed[0] + ) + assert obligation is not None and first is not None + expected_revisions = (first,) + if finality == "snapshot": + clock.now = PUBLISHED + timedelta(minutes=1) + reader.complete = lambda: None + restated = await producer.acquire_obligation(config, obligation, restate=True) + assert restated is not None + assert restated.supersedes_reporting_revision_id == first.reporting_revision_id + expected_revisions = (first, restated) + clock.now = PUBLISHED + timedelta(minutes=10) + for caller in (producer, factory()): + replay = await caller.commit_revision_from_manifest( + obligation, + manifest, + rows=ROWS, + finality=finality, + now=clock.now, + ) + assert replay == first + assert ( + await store.list_revisions( + account_id=config.account_id, + reporting_obligation_id=obligation.reporting_obligation_id, + ) + == expected_revisions + ) + changed = [dict(ROWS[0], row_id="changed")] + with pytest.raises(LedgerConflictError) as conflict: + await factory().commit_revision_from_manifest( + obligation, + manifest, + rows=changed, + finality=finality, + now=clock.now, + ) + assert conflict.value.code == "REVISION_IMMUTABLE" + + +async def test_explicit_dispatch_time_and_source_temporal_negatives(store, tmp_path): + config, source, _, _, factory = await setup(store, tmp_path / "source") + producer = factory() + obligations = await producer.close_elapsed_periods(config, now=TURN) + assert len(obligations) == 1 + first = await producer.acquire_obligation(config, obligations[0], now=TURN) + request, result = source.executions[0] + manifest = parse_verified_source_batch_manifest_v1( + result.response.manifest, result.manifest_bytes + ) + assert first is not None and request.period.source_read_cutoff_at == TURN + assert first.created_at == PUBLISHED + for updates in ( + {"observed_at": END - timedelta(seconds=1)}, + { + "finality_evidence": { + **manifest.finality_evidence.model_dump(), + "observed_at": manifest.acquired_at + timedelta(seconds=1), + } + }, + ): + with pytest.raises(ValidationError): + SourceBatchManifestV1.model_validate({**manifest.model_dump(), **updates}) + + +@pytest.mark.parametrize("future_acquisition", [False, True]) +async def test_direct_commit_uses_explicit_publication_time_before_any_write( + store, tmp_path, monkeypatch, future_acquisition +): + config, source, reader, clock, factory = await setup(store, tmp_path / "source") + producer = factory() + obligations = await producer.close_elapsed_periods(config, now=TURN) + + async def unavailable(**kwargs): + raise OSError("fixture staged read interrupted") + + # Retain a real source publication after an interrupted read, before any + # revision exists. The public commit primitive can then retry that manifest. + with monkeypatch.context() as patch: + patch.setattr(reader, "read", unavailable) + with pytest.raises(OSError, match="fixture staged read interrupted"): + await producer.acquire_obligation(config, obligations[0], now=TURN) + _, result = source.executions[0] + manifest = parse_verified_source_batch_manifest_v1( + result.response.manifest, result.manifest_bytes + ) + clock.now = PUBLISHED + timedelta(minutes=10) + if future_acquisition: + # Source-contract-valid evidence may still be in the future relative to + # the trusted publication clock. It must fail at the producer boundary. + manifest = SourceBatchManifestV1.model_validate( + {**manifest.model_dump(), "acquired_at": PUBLISHED + timedelta(seconds=1)} + ) + writes = [] + commit = store.commit_revision + + async def record_write(*args, **kwargs): + writes.append(args) + return await commit(*args, **kwargs) + + monkeypatch.setattr(store, "commit_revision", record_write) + with pytest.raises(LedgerConflictError) as raised: + await producer.commit_revision_from_manifest( + obligations[0], manifest, rows=ROWS, finality="official", now=PUBLISHED + ) + assert raised.value.code == "PUBLICATION_TIME_INVALID" + assert not writes + assert ( + await store.list_revisions( + account_id=config.account_id, + reporting_obligation_id=obligations[0].reporting_obligation_id, + ) + == () + ) + else: + committed = await producer.commit_revision_from_manifest( + obligations[0], manifest, rows=ROWS, finality="official", now=PUBLISHED + ) + assert committed.created_at == PUBLISHED + assert committed.observed_at == committed.finalized_at == TURN From ccb7713fdef0baa87931b3640757f57a6a47808f Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Tue, 22 Sep 2026 15:02:00 +0000 Subject: [PATCH 08/12] fix(signing): classify revocation checker trust failures --- src/adcp/signing/verifier.py | 29 +- .../test_revocation_checker_boundary.py | 428 ++++++++++++++++++ 2 files changed, 451 insertions(+), 6 deletions(-) create mode 100644 tests/conformance/signing/test_revocation_checker_boundary.py diff --git a/src/adcp/signing/verifier.py b/src/adcp/signing/verifier.py index 65f51675c..dcc7a7b7d 100644 --- a/src/adcp/signing/verifier.py +++ b/src/adcp/signing/verifier.py @@ -68,6 +68,11 @@ supports_atomic_claim, ) from adcp.signing.revocation import RevocationChecker, RevocationList +from adcp.signing.revocation_fetcher import ( + RevocationListFetchError, + RevocationListFreshnessError, + RevocationListParseError, +) CoversDigestPolicy = Literal["required", "forbidden", "either"] SigningProfileVersion = Literal["3.0", "3.1", "3.2"] @@ -331,12 +336,24 @@ def verify_request_signature( f"is in the past" ), ) - if options.revocation_checker is not None and options.revocation_checker(keyid): - raise SignatureVerificationError( - REQUEST_SIGNATURE_KEY_REVOKED, - step=9, - message=f"key {keyid!r} is revoked", - ) + if options.revocation_checker is not None: + try: + revoked = options.revocation_checker(keyid) + except (RevocationListFetchError, RevocationListParseError, RevocationListFreshnessError): + # The checker owns caching and grace. Only its documented trust / + # availability failures become signature rejections here; unrelated + # adopter errors remain operational. Never expose transport details. + raise SignatureVerificationError( + REQUEST_SIGNATURE_REVOCATION_STALE, + step=9, + message="revocation status could not be verified from a fresh, trusted list", + ) from None + if revoked: + raise SignatureVerificationError( + REQUEST_SIGNATURE_KEY_REVOKED, + step=9, + message=f"key {keyid!r} is revoked", + ) # Cheap early rejection; ``claim`` repeats the capacity check atomically # after crypto verification so concurrent claims cannot exceed the cap. diff --git a/tests/conformance/signing/test_revocation_checker_boundary.py b/tests/conformance/signing/test_revocation_checker_boundary.py new file mode 100644 index 000000000..7ad1dcef3 --- /dev/null +++ b/tests/conformance/signing/test_revocation_checker_boundary.py @@ -0,0 +1,428 @@ +"""Public verifier classification and live signed-list receiver composition. + +The live test uses real loopback HTTP for both the issuer and callback. Its +injected wall/monotonic clocks exercise the documented cache grace interval; +callback signature verification and revocation-list JWS verification are +observed separately and still execute their real cryptography. +""" + +from __future__ import annotations + +import asyncio +import json +import threading +import traceback +from contextlib import contextmanager +from datetime import datetime, timezone +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from urllib.error import HTTPError, URLError +from urllib.request import Request, urlopen + +import pytest + +from adcp.server.idempotency import MemoryBackend, WebhookDedupStore +from adcp.signing import ( + CachingRevocationChecker, + InMemoryReplayStore, + SignatureVerificationError, + StaticJwksResolver, + VerifierCapability, + VerifyOptions, + sign_request, + verify_request_signature, +) +from adcp.signing.revocation_fetcher import ( + FetchResult, + RevocationListFetchError, + RevocationListFreshnessError, + RevocationListParseError, + RevocationListSignatureError, +) +from adcp.webhooks import ( + WebhookReceiver, + WebhookReceiverConfig, + WebhookVerifyOptions, + create_mcp_webhook_payload, + sign_webhook, + to_wire_dict, + verify_webhook_signature, +) + +from .test_revocation_e2e import _make_operator_key, _make_signer_key, _sign_revocation_list + +NOW = 1_776_520_800 +URL = "https://buyer.example.test/webhook" +ROUTES = ("3.0", "3.1", "3.2", "webhook") +MAPPED_ERRORS = ( + RevocationListFetchError, + RevocationListParseError, + RevocationListSignatureError, + RevocationListFreshnessError, +) + + +def _signed_call(route, checker, resolver, replay, private, kid): + body = b'{"test":"revocation classification"}' + common = dict( + method="POST", + url=URL, + headers={"Content-Type": "application/json"}, + body=body, + private_key=private, + key_id=kid, + alg="ed25519", + created=NOW, + nonce="revocation-boundary-nonce", + ) + if route == "webhook": + signed = sign_webhook(**common) + options = WebhookVerifyOptions( + jwks_resolver=resolver, + revocation_checker=checker, + replay_store=replay, + clock=lambda: NOW, + ) + verifier = verify_webhook_signature + else: + signed = sign_request(**common, signing_profile_version=route, cover_content_digest=True) + options = VerifyOptions( + now=NOW, + capability=VerifierCapability(covers_content_digest="required"), + operation="test", + jwks_resolver=resolver, + revocation_checker=checker, + replay_store=replay, + signing_profile_version=route, + ) + verifier = verify_request_signature + + def run(): + return verifier( + method="POST", + url=URL, + headers={"Content-Type": "application/json", **signed.as_dict()}, + body=body, + options=options, + ) + + return run + + +def _observe_crypto_and_replay(monkeypatch, replay, events): + from adcp.signing import verifier + + original = verifier.verify_signature + + def verify(**kwargs): + events.append("callback_crypto") + return original(**kwargs) + + monkeypatch.setattr(verifier, "verify_signature", verify) + for name in ("seen", "remember", "claim", "at_capacity"): + method = getattr(replay, name) + + def observed(*args, _method=method, _name=name, **kwargs): + events.append("replay_" + _name) + return _method(*args, **kwargs) + + monkeypatch.setattr(replay, name, observed) + + +@pytest.mark.parametrize("route", ROUTES) +@pytest.mark.parametrize("error_type", MAPPED_ERRORS) +def test_checker_failure_is_safe_step_nine_and_does_not_claim_nonce(route, error_type, monkeypatch): + private, jwk = _make_signer_key() + events = [] + failing = True + replay = InMemoryReplayStore() + _observe_crypto_and_replay(monkeypatch, replay, events) + + def resolve(kid): + events.append("jwks") + assert kid == jwk["kid"] + return jwk + + def check(kid): + events.append("checker") + assert kid == jwk["kid"] + if failing: + raise error_type( + "private fixture detail: https://issuer.example.test/untrusted-payload" + ) + return False + + run = _signed_call(route, check, resolve, replay, private, jwk["kid"]) + with pytest.raises(SignatureVerificationError) as raised: + run() + prefix = "webhook" if route == "webhook" else "request" + assert (raised.value.code, raised.value.step) == (prefix + "_signature_revocation_stale", 9) + assert raised.value.detail is None + assert "private fixture" not in str(raised.value) + assert "issuer.example.test" not in str(raised.value) + assert "untrusted-payload" not in str(raised.value) + rendered = "".join(traceback.format_exception(raised.value)) + assert "issuer.example.test" not in rendered and "private fixture detail" not in rendered + assert events == ["jwks", "checker"] + + # The identical signed request remains usable after refresh: stale rejection + # must not consume its nonce. A subsequent genuine replay must be rejected. + failing = False + assert run().key_id == jwk["kid"] + assert events.count("checker") == 2 + assert events.count("callback_crypto") == 1 + assert "replay_claim" in events + with pytest.raises(SignatureVerificationError) as replayed: + run() + assert replayed.value.code == prefix + "_signature_replayed" + + +@pytest.mark.parametrize("route", ROUTES) +@pytest.mark.parametrize("error_type", (RuntimeError, ValueError, asyncio.CancelledError)) +def test_unrelated_checker_errors_remain_operational(route, error_type, monkeypatch): + private, jwk = _make_signer_key() + error = error_type("adopter checker error") + events = [] + replay = InMemoryReplayStore() + _observe_crypto_and_replay(monkeypatch, replay, events) + + def check(_kid): + events.append("checker") + raise error + + run = _signed_call(route, check, lambda _kid: jwk, replay, private, jwk["kid"]) + with pytest.raises(error_type) as raised: + run() + assert raised.value is error + assert events == ["checker"] + + +@pytest.mark.parametrize("route", ROUTES) +@pytest.mark.parametrize("revoked", (False, True)) +def test_boolean_revocation_controls_keep_their_order(route, revoked, monkeypatch): + private, jwk = _make_signer_key() + events = [] + replay = InMemoryReplayStore() + _observe_crypto_and_replay(monkeypatch, replay, events) + + def check(_kid): + events.append("checker") + return revoked + + run = _signed_call(route, check, lambda _kid: jwk, replay, private, jwk["kid"]) + if revoked: + with pytest.raises(SignatureVerificationError) as raised: + run() + prefix = "webhook" if route == "webhook" else "request" + assert (raised.value.code, raised.value.step) == (prefix + "_signature_key_revoked", 9) + assert events == ["checker"] + else: + assert run().key_id == jwk["kid"] + assert events.count("checker") == events.count("callback_crypto") == 1 + assert "replay_claim" in events + + +@contextmanager +def _http_server(handler): + server = ThreadingHTTPServer(("127.0.0.1", 0), handler) + thread = threading.Thread(target=server.serve_forever) + thread.start() + try: + yield "http://127.0.0.1:" + str(server.server_port) + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + assert not thread.is_alive() + + +def _reply(handler, status, body, headers=()): + handler.send_response(status) + for name, value in headers: + handler.send_header(name, value) + handler.send_header("Content-Length", str(len(body))) + handler.end_headers() + handler.wfile.write(body) + + +def _post(url, body, headers): + request = Request(url, data=body, headers=headers, method="POST") + try: + response = urlopen(request, timeout=5) # noqa: S310 - ephemeral loopback fixture + except HTTPError as error: + response = error + with response: + return response.status, dict(response.headers), json.loads(response.read()) + + +def test_live_signed_issuer_grace_stale_rejection_and_recovery(monkeypatch): + from adcp.signing import jws + + operator, operator_jwk = _make_operator_key() + private, signer_jwk = _make_signer_key() + state = {"wall": NOW, "mono": 0, "outage": False, "refreshed": False} + events = [] + fetches = [] + handled = [] + callback_bodies = [] + operational = [] + replay = InMemoryReplayStore() + _observe_crypto_and_replay(monkeypatch, replay, events) + original_jws_verify = jws.verify_signature + + def verify_list(**kwargs): + events.append("list_jws_crypto") + return original_jws_verify(**kwargs) + + monkeypatch.setattr(jws, "verify_signature", verify_list) + + class Issuer(BaseHTTPRequestHandler): + def log_message(self, *args): + pass + + def do_GET(self): # noqa: N802 + assert self.path == "/revocations" + fetches.append((state["wall"], self.headers.get("If-None-Match"))) + if state["outage"]: + _reply(self, 503, b"unavailable") + return + updated, following = (480, 660) if state["refreshed"] else (-60, 60) + payload = { + "version": 1, + "issuer": "https://issuer.example.test", + "updated": datetime.fromtimestamp(NOW + updated, timezone.utc).isoformat(), + "next_update": datetime.fromtimestamp(NOW + following, timezone.utc).isoformat(), + "revoked_kids": [], + "revoked_jtis": [], + } + body = _sign_revocation_list( + operator_key=operator, operator_kid=operator_jwk["kid"], payload=payload + ).encode() + _reply(self, 200, body, (("ETag", '"signed-list"'),)) + + with _http_server(Issuer) as issuer_url: + + def fetch(uri, *, if_none_match=None, if_modified_since=None): + headers = {} if if_none_match is None else {"If-None-Match": if_none_match} + try: + with urlopen(Request(uri, headers=headers), timeout=5) as response: # noqa: S310 + return FetchResult(response.read().decode(), response.headers.get("ETag")) + except (URLError, TimeoutError) as error: + raise RevocationListFetchError("untrusted fixture transport detail") from error + + checker = CachingRevocationChecker( + revocation_uri=issuer_url + "/revocations", + issuer="https://issuer.example.test", + jwks_resolver=StaticJwksResolver({"keys": [operator_jwk]}), + fetcher=fetch, + clock=lambda: state["mono"], + wall_clock=lambda: datetime.fromtimestamp(state["wall"], timezone.utc), + ) + + def check(kid): + events.append("checker") + return checker(kid) + + def resolve(kid): + events.append("callback_jwks") + return signer_jwk if kid == signer_jwk["kid"] else None + + receiver = WebhookReceiver( + WebhookReceiverConfig( + verify_options=WebhookVerifyOptions( + jwks_resolver=resolve, + revocation_checker=check, + replay_store=replay, + clock=lambda: NOW, + ), + dedup=WebhookDedupStore(MemoryBackend(), ttl_seconds=86400), + receiver_scope="live-revocation-test", + publisher_scope_for=lambda _sender: "fixture-publisher", + ) + ) + + class Callback(BaseHTTPRequestHandler): + def log_message(self, *args): + pass + + def do_POST(self): # noqa: N802 + body = self.rfile.read(int(self.headers["Content-Length"])) + callback_bodies.append(body) + try: + outcome = receiver.receive_and_process_sync( + method="POST", + url=callback_url + "/webhook", + headers=dict(self.headers), + body=body, + handler=lambda payload: handled.append(payload.idempotency_key), + ) + except Exception as error: # retain operational errors separately from SDK outcomes + operational.append(type(error).__name__) + _reply(self, 503, json.dumps({"operational": type(error).__name__}).encode()) + return + _reply( + self, + outcome.http_status or 200, + json.dumps({"handled": outcome.handled, "rejected": outcome.rejected}).encode(), + outcome.response_headers.items(), + ) + + with _http_server(Callback) as callback_url: + + def signed(index): + payload = create_mcp_webhook_payload( + task_id=f"live-list-{index}", + task_type="sync_reporting_status", + operation_id=f"live-list-operation-{index}", + status="completed", + idempotency_key=f"whk_revocation_list_fixture_{index:04d}", + ) + body = json.dumps(to_wire_dict(payload), ensure_ascii=False, indent=1).encode() + signed_headers = sign_webhook( + method="POST", + url=callback_url + "/webhook", + headers={"Content-Type": "application/json"}, + body=body, + private_key=private, + key_id=signer_jwk["kid"], + alg="ed25519", + created=NOW, + nonce=f"live-list-nonce-{index:04d}", + ) + return body, {"Content-Type": "application/json", **signed_headers.as_dict()} + + def deliver(sample): + response = _post(callback_url + "/webhook", *sample) + assert callback_bodies[-1] == sample[0] + return response + + assert deliver(signed(1))[0] == 200 + assert len(fetches) == 1 and len(handled) == 1 + assert events.count("list_jws_crypto") == events.count("callback_crypto") == 1 + assert deliver(signed(2))[0] == 200 # fresh cache: no second fetch + assert len(fetches) == 1 and len(handled) == 2 + state.update(wall=NOW + 100, mono=60, outage=True) + assert deliver(signed(3))[0] == 200 # outage within signed interval + 2x grace + assert len(fetches) == 2 and len(handled) == 3 + state.update(wall=NOW + 500, mono=120) + sample = signed(4) + before = len(events) + stale = deliver(sample) + assert events[before:] == ["callback_jwks", "checker"] + assert len(fetches) == 3 and len(handled) == 3 + # Continue recovery even on the old implementation, retaining the + # full causal sequence before asserting the required classification. + state.update(wall=NOW + 500, mono=240, outage=False, refreshed=True) + recovered = deliver(sample) + assert recovered[0] == 200 and recovered[2]["handled"] + assert len(fetches) == 4 and len(handled) == 4 + assert events.count("list_jws_crypto") == 2 + assert events.count("callback_crypto") == 4 + assert events.count("checker") == 5 + assert fetches[0][1] is None and all(etag == '"signed-list"' for _, etag in fetches[1:]) + assert stale[0] == 401, {"stale": stale, "operational": operational} + assert ( + stale[1]["WWW-Authenticate"] + == 'Signature error="webhook_signature_revocation_stale"' + ) + assert stale[2] == {"handled": False, "rejected": True} + assert not operational From fd690832e22f36a28edc7fc7fc3224789486244a Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Tue, 22 Sep 2026 16:05:53 +0000 Subject: [PATCH 09/12] ci: run reporting correction checks on the adoption branch --- .github/workflows/ci.yml | 1 + .github/workflows/pr-title-check.yml | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e8eab780c..acafa50c8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,6 +14,7 @@ on: - conductor/reporting-frozen-account-feed-b23 - conductor/reporting-schema-proof-receipt-diagnostics-hardening - conductor/reporting-production-tier-capabilities-b24 + - conductor/reporting-adcp-rc4-adoption # Default @adcp/sdk runner alias for storyboard jobs. Tracks the current # stable @adcp/sdk release via the ``latest`` npm dist-tag. diff --git a/.github/workflows/pr-title-check.yml b/.github/workflows/pr-title-check.yml index b1be01602..5e2f515e0 100644 --- a/.github/workflows/pr-title-check.yml +++ b/.github/workflows/pr-title-check.yml @@ -3,7 +3,7 @@ name: PR Title Check on: pull_request: types: [opened, edited, synchronize, reopened] - branches: [main, conductor/1167b2-durable-managed-reporting, conductor/reporting-receipt-ingress-b22, conductor/reporting-frozen-account-feed-b23, conductor/reporting-schema-proof-receipt-diagnostics-hardening, conductor/reporting-production-tier-capabilities-b24] + branches: [main, conductor/1167b2-durable-managed-reporting, conductor/reporting-receipt-ingress-b22, conductor/reporting-frozen-account-feed-b23, conductor/reporting-schema-proof-receipt-diagnostics-hardening, conductor/reporting-production-tier-capabilities-b24, conductor/reporting-adcp-rc4-adoption] permissions: contents: read From c28907bbadadd83dadfa1e1c1dc2e80694ffabbe Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Fri, 25 Sep 2026 09:24:22 +0000 Subject: [PATCH 10/12] test(reporting): align current routes and installed corrections --- .../reporting/_production_packaging.py | 3 ++ .../reporting/_scope_onboarding_server.py | 8 ++--- .../test_reporting_materializer_progress.py | 35 +++++++++++++------ ...test_reporting_production_late_accounts.py | 11 +++--- ...t_reporting_production_publication_time.py | 3 +- .../test_reporting_production_scope.py | 2 +- .../test_reporting_publication_time.py | 12 ++++--- tests/test_decisioning_dispatch.py | 6 ++-- 8 files changed, 51 insertions(+), 29 deletions(-) diff --git a/tests/conformance/reporting/_production_packaging.py b/tests/conformance/reporting/_production_packaging.py index 83c30ee70..db9c0a2d5 100644 --- a/tests/conformance/reporting/_production_packaging.py +++ b/tests/conformance/reporting/_production_packaging.py @@ -51,6 +51,8 @@ def production_modules(): "_version.py", "server/mcp_tools.py", "server/a2a_server.py", + "decisioning/dispatch.py", + "signing/verifier.py", "signing/webhook_verifier.py", "signing/webhook_signer.py", "reporting/feed/request.py", @@ -297,6 +299,7 @@ def installed_production(root, python, wheel, source, *, label, driver_absent): "tests/conformance/signing/test_webhook_rc4_vectors.py", "tests/conformance/signing/test_webhook_signature_http.py", "tests/conformance/signing/test_webhook_signature_emission.py", + "tests/conformance/signing/test_revocation_checker_boundary.py", "tests/test_reporting_production_public.py", "tests/test_schema_datetime_formats.py", "tests/test_rc6_adoption.py", diff --git a/tests/conformance/reporting/_scope_onboarding_server.py b/tests/conformance/reporting/_scope_onboarding_server.py index 75dd78b73..961e193a5 100644 --- a/tests/conformance/reporting/_scope_onboarding_server.py +++ b/tests/conformance/reporting/_scope_onboarding_server.py @@ -1,4 +1,4 @@ -"""Separate HTTP process using the real production composition and rc.4 MCP mount.""" +"""Separate HTTP process using the real production composition and rc.6 MCP mount.""" import asyncio import copy @@ -105,7 +105,7 @@ async def account_task(request, context, admit): delivery_config_id="shared-config", scope={"media_buy_ids": ["shared-media-buy"]} ) requests[account] = { - "adcp_version": "3.2-rc.4", + "adcp_version": "3.2-rc.6", "idempotency_key": "public-scope-" + account, "accounts": [ {"account": {"account_id": account}, "reporting_delivery_configs": [wire]} @@ -181,7 +181,7 @@ def save(): async def feed_fixture(backend, root, notifications): async with feed_harness(backend, notifications=notifications) as h: case, _, _ = await mixed_case(h) - mounted = MountedFeed(h, version="3.2-rc.4") + mounted = MountedFeed(h, version="3.2-rc.6") mounted.authorize(case, token="acct_a") audit = {"http": []} @@ -191,7 +191,7 @@ def save(): temporary.replace(root / "audit.json") yield h, mounted.handler, mounted.tokens, { - "request": feed_request(case, adcp_version="3.2-rc.4"), + "request": feed_request(case, adcp_version="3.2-rc.6"), }, audit, save diff --git a/tests/conformance/reporting/test_reporting_materializer_progress.py b/tests/conformance/reporting/test_reporting_materializer_progress.py index 4b3c37cdd..39c82e7da 100644 --- a/tests/conformance/reporting/test_reporting_materializer_progress.py +++ b/tests/conformance/reporting/test_reporting_materializer_progress.py @@ -176,8 +176,10 @@ async def test_more_than_two_busy_pages_progress_then_recover_after_unlock( await store._lock_account(holder, account) before = await served(pool) for _ in range(2): - assert (await bounded_claim(store, samples)).state == "idle" - assert await served(pool) == before + correction_condition_1 = (await bounded_claim(store, samples)).state == "idle" + assert correction_condition_1 + correction_condition_2 = await served(pool) == before + assert correction_condition_2 assert samples == [16, 16] await bounded_claim(store, samples) after = await served(pool) @@ -200,7 +202,10 @@ async def test_more_than_two_busy_pages_progress_then_recover_after_unlock( if recovered == set(busy): break assert recovered == set(busy) - assert all(value != "-infinity" for value in (await served(pool)).values()) + correction_condition_3 = all( + value != "-infinity" for value in (await served(pool)).values() + ) + assert correction_condition_3 @pytest.mark.parametrize("notifications", [False, True]) @@ -228,11 +233,13 @@ async def test_commit_failure_rolls_back_rank_and_reservation_then_retries(notif await store.claim_materialization(keys=case.keys) assert error.value.failure.code == "RESOURCE_UNAVAILABLE" assert "private-progress" not in str(error.value) - assert await served(pool) == before + correction_condition_4 = await served(pool) == before + assert correction_condition_4 async with pool.connection() as connection: - assert await ( + correction_condition_5 = await ( await connection.execute("SELECT count(*) FROM reporting_materializer_work") ).fetchone() == (0,) + assert correction_condition_5 finally: async with pool.connection() as connection: await connection.execute( @@ -242,8 +249,10 @@ async def test_commit_failure_rolls_back_rank_and_reservation_then_retries(notif lease = await case.claim() assert isinstance(lease, ReportingMaterializerLease) assert lease.attempt.attempt == 1 - assert await store.renew_materialization(lease, lease_seconds=30) - assert await served(pool) != before + correction_condition_6 = await store.renew_materialization(lease, lease_seconds=30) + assert correction_condition_6 + correction_condition_7 = await served(pool) != before + assert correction_condition_7 async def test_two_workers_skip_uncommitted_peer_and_reserve_distinct_accounts(monkeypatch): @@ -299,13 +308,17 @@ async def execute(connection, query, *args, **kwargs): assert selected.request.external_id != other.request.external_id await store.authorize_materialization(selected) await peer.authorize_materialization(other) - assert await store.renew_materialization(selected, lease_seconds=30) - assert await peer.renew_materialization(other, lease_seconds=30) + correction_condition_8 = await store.renew_materialization(selected, lease_seconds=30) + assert correction_condition_8 + correction_condition_9 = await peer.renew_materialization(other, lease_seconds=30) + assert correction_condition_9 for current in (store, peer): - assert not isinstance( + correction_condition_10 = not isinstance( await current.claim_materialization(keys=first.keys), ReportingMaterializerLease ) + assert correction_condition_10 async with pool.connection() as connection: - assert await ( + correction_condition_11 = await ( await connection.execute("SELECT count(*) FROM reporting_materializer_work") ).fetchone() == (2,) + assert correction_condition_11 diff --git a/tests/conformance/reporting/test_reporting_production_late_accounts.py b/tests/conformance/reporting/test_reporting_production_late_accounts.py index 1b760157b..bc9d1df1a 100644 --- a/tests/conformance/reporting/test_reporting_production_late_accounts.py +++ b/tests/conformance/reporting/test_reporting_production_late_accounts.py @@ -126,7 +126,7 @@ def client_for(uri, account): auth_header="Authorization", auth_type="bearer", ), - adcp_version="3.2.0-rc.4", + adcp_version="3.2.0-rc.6", ) @@ -390,15 +390,16 @@ async def test_late_account_progresses_via_typed_public_support_and_survives_res period=results[0][account]["period"] if index else None, ) if index == 0 and account != first: - assert (await served(pool))[first] != first_before + correction_condition_1 = (await served(pool))[first] != first_before + assert correction_condition_1 results.append(current) async with pool.connection() as connection: - assert await ( + correction_condition_2 = await ( await connection.execute( - "SELECT count(*) FROM pg_stat_activity WHERE application_name=%s", - (schema,), + "SELECT count(*) FROM pg_stat_activity WHERE application_name=%s", (schema,) ) ).fetchone() == (0,) + assert correction_condition_2 for account in order: for field in ("revision", "digest", "receipt_ids", "materialization_id"): assert results[0][account][field] == results[1][account][field] diff --git a/tests/conformance/reporting/test_reporting_production_publication_time.py b/tests/conformance/reporting/test_reporting_production_publication_time.py index c1642e2f9..026ff948e 100644 --- a/tests/conformance/reporting/test_reporting_production_publication_time.py +++ b/tests/conformance/reporting/test_reporting_production_publication_time.py @@ -103,11 +103,12 @@ async def test_real_source_observation_before_publication_reconciles_and_replays ) runs.append(result) async with pool.connection() as connection: - assert await ( + correction_condition_1 = await ( await connection.execute( "SELECT count(*) FROM pg_stat_activity WHERE application_name=%s", (schema,) ) ).fetchone() == (0,) + assert correction_condition_1 for field in ( "revision", "digest", diff --git a/tests/conformance/reporting/test_reporting_production_scope.py b/tests/conformance/reporting/test_reporting_production_scope.py index 51b2dba0f..0cdcab325 100644 --- a/tests/conformance/reporting/test_reporting_production_scope.py +++ b/tests/conformance/reporting/test_reporting_production_scope.py @@ -87,7 +87,7 @@ def public_client(uri, account, route="mcp"): auth_header="Authorization", auth_type="bearer", ), - adcp_version="3.2.0-rc.4", + adcp_version="3.2.0-rc.6", force_a2a_version=route.removeprefix("a2a-") if route != "mcp" else None, ) diff --git a/tests/conformance/reporting/test_reporting_publication_time.py b/tests/conformance/reporting/test_reporting_publication_time.py index 59b8c75a4..9243d463a 100644 --- a/tests/conformance/reporting/test_reporting_publication_time.py +++ b/tests/conformance/reporting/test_reporting_publication_time.py @@ -259,13 +259,14 @@ async def commit(revision, rows): result.response.manifest, result.manifest_bytes ) assert manifest.observed_at == observed # Original source evidence was not backdated. - assert ( + correction_condition_1 = ( await store.list_revisions( account_id=config.account_id, reporting_obligation_id=request.identity.reporting_obligation_id, ) == () ) + assert correction_condition_1 @pytest.mark.parametrize("finality", ["official", "snapshot"]) @@ -308,13 +309,13 @@ async def test_same_publication_replay_preserves_committed_time_and_source_evide now=clock.now, ) assert replay == first - assert ( + correction_condition_2 = ( await store.list_revisions( - account_id=config.account_id, - reporting_obligation_id=obligation.reporting_obligation_id, + account_id=config.account_id, reporting_obligation_id=obligation.reporting_obligation_id ) == expected_revisions ) + assert correction_condition_2 changed = [dict(ROWS[0], row_id="changed")] with pytest.raises(LedgerConflictError) as conflict: await factory().commit_revision_from_manifest( @@ -394,13 +395,14 @@ async def record_write(*args, **kwargs): ) assert raised.value.code == "PUBLICATION_TIME_INVALID" assert not writes - assert ( + correction_condition_3 = ( await store.list_revisions( account_id=config.account_id, reporting_obligation_id=obligations[0].reporting_obligation_id, ) == () ) + assert correction_condition_3 else: committed = await producer.commit_revision_from_manifest( obligations[0], manifest, rows=ROWS, finality="official", now=PUBLISHED diff --git a/tests/test_decisioning_dispatch.py b/tests/test_decisioning_dispatch.py index 01f48f4bf..045c77297 100644 --- a/tests/test_decisioning_dispatch.py +++ b/tests/test_decisioning_dispatch.py @@ -1920,7 +1920,8 @@ async def _on_complete(result: Any) -> None: on_complete=_on_complete, ) ) - assert await asyncio.to_thread(entered.wait, 1) + correction_condition_1 = await asyncio.to_thread(entered.wait, 1) + assert correction_condition_1 task.cancel("client disconnected") with pytest.raises(asyncio.CancelledError): _ = await task @@ -1965,7 +1966,8 @@ async def _on_failure(exc: BaseException) -> None: on_failure=_on_failure, ) ) - assert await asyncio.to_thread(entered.wait, 1) + correction_condition_2 = await asyncio.to_thread(entered.wait, 1) + assert correction_condition_2 task.cancel() with pytest.raises(asyncio.CancelledError) as exc_info: await asyncio.gather(task) From a124afd3b72f3fb81e30e40b298222a683e71d5f Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Fri, 25 Sep 2026 10:34:05 +0000 Subject: [PATCH 11/12] fix(reporting): use shipped signing fixtures and scoped generator imports --- scripts/post_generate_fixes.py | 5 +++-- .../media_buy/get_media_buy_delivery_request.py | 1 - tests/conformance/signing/_webhook_http_server.py | 2 +- tests/conformance/signing/test_webhook_rc4_vectors.py | 4 ++-- .../signing/test_webhook_signature_emission.py | 2 +- .../signing/test_webhook_signature_http.py | 2 +- tests/test_reporting_selector_generation.py | 11 +++++++++++ 7 files changed, 19 insertions(+), 8 deletions(-) diff --git a/scripts/post_generate_fixes.py b/scripts/post_generate_fixes.py index 816567255..38e1c3c45 100644 --- a/scripts/post_generate_fixes.py +++ b/scripts/post_generate_fixes.py @@ -2630,6 +2630,7 @@ def fix_reporting_request_selectors() -> None: offsets.append(offsets[-1] + len(line)) changes: list[tuple[int, int, str]] = [] matched = False + needs_mapping = False for node in ast.parse(original).body: if not isinstance(node, ast.ClassDef): continue @@ -2641,6 +2642,7 @@ def fix_reporting_request_selectors() -> None: methods = {method.name for method in node.body if isinstance(method, ast.FunctionDef)} if set(fields) == {"all_media_buys", "media_buy_ids"}: repaired["scope"] += 1 + needs_mapping = True matched = True field = fields["all_media_buys"] if field.value is None: @@ -2729,8 +2731,7 @@ def _serialize_delivery_selector_mode(self, handler: SerializerFunctionWrapHandl source = original for start, end, replacement in sorted(changes, reverse=True): source = source[:start] + replacement + source[end:] - imports = ( - "from collections.abc import Mapping\n" + imports = ("from collections.abc import Mapping\n" if needs_mapping else "") + ( "from typing import Any\n" "from pydantic import SerializerFunctionWrapHandler, model_serializer, model_validator\n" ) diff --git a/src/adcp/types/generated_poc/media_buy/get_media_buy_delivery_request.py b/src/adcp/types/generated_poc/media_buy/get_media_buy_delivery_request.py index a35e3d29a..5beaa153a 100644 --- a/src/adcp/types/generated_poc/media_buy/get_media_buy_delivery_request.py +++ b/src/adcp/types/generated_poc/media_buy/get_media_buy_delivery_request.py @@ -4,7 +4,6 @@ from __future__ import annotations -from collections.abc import Mapping from typing import Any from pydantic import SerializerFunctionWrapHandler, model_serializer, model_validator diff --git a/tests/conformance/signing/_webhook_http_server.py b/tests/conformance/signing/_webhook_http_server.py index 28452b137..035e5eb40 100644 --- a/tests/conformance/signing/_webhook_http_server.py +++ b/tests/conformance/signing/_webhook_http_server.py @@ -21,7 +21,7 @@ async def run(root, socket_fd): key_rows = json.loads( files("adcp") - .joinpath("_compliance/3.2.0-rc.4/test-vectors/webhook-signing/keys.json") + .joinpath("_compliance/3.2.0-rc.6/test-vectors/webhook-signing/keys.json") .read_text() )["keys"] keys = {row["kid"]: {k: v for k, v in row.items() if not k.startswith("_")} for row in key_rows} diff --git a/tests/conformance/signing/test_webhook_rc4_vectors.py b/tests/conformance/signing/test_webhook_rc4_vectors.py index 7aaa8c60b..460707c77 100644 --- a/tests/conformance/signing/test_webhook_rc4_vectors.py +++ b/tests/conformance/signing/test_webhook_rc4_vectors.py @@ -1,4 +1,4 @@ -"""The installed, protocol-owned rc.4 webhook-v1 corpus through the public API.""" +"""The rc.4 webhook-v1 corpus, unchanged in the shipped current fixtures.""" from __future__ import annotations @@ -14,7 +14,7 @@ from adcp.signing.revocation import RevocationList from adcp.webhooks import WebhookVerifyOptions, verify_webhook_signature -VECTORS = files("adcp").joinpath("_compliance/3.2.0-rc.4/test-vectors/webhook-signing") +VECTORS = files("adcp").joinpath("_compliance/3.2.0-rc.6/test-vectors/webhook-signing") KEYS = { row["kid"]: {name: value for name, value in row.items() if not name.startswith("_")} for row in json.loads(VECTORS.joinpath("keys.json").read_text())["keys"] diff --git a/tests/conformance/signing/test_webhook_signature_emission.py b/tests/conformance/signing/test_webhook_signature_emission.py index 0da77f175..8f049c378 100644 --- a/tests/conformance/signing/test_webhook_signature_emission.py +++ b/tests/conformance/signing/test_webhook_signature_emission.py @@ -13,7 +13,7 @@ KEYS = json.loads( files("adcp") - .joinpath("_compliance/3.2.0-rc.4/test-vectors/webhook-signing/keys.json") + .joinpath("_compliance/3.2.0-rc.6/test-vectors/webhook-signing/keys.json") .read_text() )["keys"] ALGORITHMS = [ diff --git a/tests/conformance/signing/test_webhook_signature_http.py b/tests/conformance/signing/test_webhook_signature_http.py index fd334187f..8558fed1e 100644 --- a/tests/conformance/signing/test_webhook_signature_http.py +++ b/tests/conformance/signing/test_webhook_signature_http.py @@ -75,7 +75,7 @@ async def receiver(tmp_path): def keys(): return json.loads( files("adcp") - .joinpath("_compliance/3.2.0-rc.4/test-vectors/webhook-signing/keys.json") + .joinpath("_compliance/3.2.0-rc.6/test-vectors/webhook-signing/keys.json") .read_text() )["keys"] diff --git a/tests/test_reporting_selector_generation.py b/tests/test_reporting_selector_generation.py index 488e56203..11077bf12 100644 --- a/tests/test_reporting_selector_generation.py +++ b/tests/test_reporting_selector_generation.py @@ -48,6 +48,16 @@ def test_regeneration_repairs_canonical_and_self_contained_clones(tmp_path, monk targets = [] for relative, class_name, package in SOURCES: source = unrepaired((ROOT / relative).read_text()) + # Exercise fresh codegen without the repair's imports already present. + for added_import in ( + "from collections.abc import Mapping\n", + "from typing import Any\n", + ( + "from pydantic import SerializerFunctionWrapHandler, " + "model_serializer, model_validator\n" + ), + ): + source = source.replace(added_import, "") for clone in (False, True): name = class_name + ("2" if clone else "") target = tmp_path / (("bundled/" if clone else "") + relative) @@ -65,6 +75,7 @@ def test_regeneration_repairs_canonical_and_self_contained_clones(tmp_path, monk monkeypatch.setitem(sys.modules, module.__name__, module) exec(compile(target.read_bytes(), str(target), "exec"), vars(module)) model = getattr(module, name) + assert ("Mapping" in vars(module)) == name.startswith("Scope") if name.startswith("Scope"): explicit = model.model_validate({"media_buy_ids": ["shared-media-buy"]}) assert json.loads(explicit.model_dump_json()) == {"media_buy_ids": ["shared-media-buy"]} From 8d6eba964b86c209a26df5decfef4f47d1c0a576 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Fri, 25 Sep 2026 11:21:47 +0000 Subject: [PATCH 12/12] test(reporting): validate publication clocks against shipped rc6 schemas --- .../conformance/reporting/test_reporting_publication_time.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/conformance/reporting/test_reporting_publication_time.py b/tests/conformance/reporting/test_reporting_publication_time.py index 9243d463a..a10347a37 100644 --- a/tests/conformance/reporting/test_reporting_publication_time.py +++ b/tests/conformance/reporting/test_reporting_publication_time.py @@ -149,7 +149,7 @@ def producer(): async def public_outcome(store, config): payload = await ReportingStatusHandler(store).handle( { - "adcp_version": "3.2-rc.4", + "adcp_version": "3.2-rc.6", "account": {"account_id": config.account_id}, "view": "periods", "period": {"start": START.isoformat(), "end": END.isoformat()}, @@ -157,7 +157,7 @@ async def public_outcome(store, config): caller=ReportingStatusCaller(account_id=config.account_id, consumer_id="clock-buyer"), ) response = GetReportingStatusResponse.model_validate(payload) - validator = get_named_validator("core/reporting-revision.json", version="3.2.0-rc.4") + validator = get_named_validator("core/reporting-revision.json", version="3.2.0-rc.6") assert validator is not None for revision in payload["revisions"]: validator.validate(revision)