Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
9308739
docs(security): clarify metadata credential screening limits
bokelley Sep 22, 2026
8dc98c7
fix(reporting): preserve typed selectors and exact feed numbers
bokelley Sep 22, 2026
ddaa474
fix(signing): reject mixed webhook signature alphabets before verific…
bokelley Sep 22, 2026
b203bbc
fix(signing): emit webhook signatures as unpadded Base64URL
bokelley Sep 22, 2026
dba15b6
fix(reporting): restore progress for late materializer accounts
bokelley Sep 22, 2026
fe1a1cb
fix(decisioning): return sanitized details on narrowing failure
bokelley Sep 22, 2026
26151fc
fix(reporting): timestamp revisions after source acquisition
bokelley Sep 22, 2026
ccb7713
fix(signing): classify revocation checker trust failures
bokelley Sep 22, 2026
fd69083
ci: run reporting correction checks on the adoption branch
bokelley Sep 22, 2026
34cc4b4
fix(reporting): integrate reviewed late-account progress
bokelley Sep 23, 2026
3f4ae61
fix(reporting): integrate reviewed publication timestamp correction
bokelley Sep 23, 2026
7af5699
fix(signing): integrate reviewed revocation checker classification
bokelley Sep 23, 2026
25e0c72
fix(decisioning): integrate reviewed sanitized fallback hygiene
bokelley Sep 23, 2026
1acd02d
chore(reporting): compose reviewed corrections with integrated rc6 main
bokelley Sep 25, 2026
c28907b
test(reporting): align current routes and installed corrections
bokelley Sep 25, 2026
a124afd
fix(reporting): use shipped signing fixtures and scoped generator imp…
bokelley Sep 25, 2026
8d6eba9
test(reporting): validate publication clocks against shipped rc6 schemas
bokelley Sep 25, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/pr-title-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 17 additions & 10 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down
157 changes: 157 additions & 0 deletions scripts/post_generate_fixes.py
Original file line number Diff line number Diff line change
Expand Up @@ -2593,6 +2593,162 @@ 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
needs_mapping = 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
needs_mapping = True
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" if needs_mapping else "") + (
"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.

Expand Down Expand Up @@ -6052,6 +6208,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,
Expand Down
54 changes: 23 additions & 31 deletions src/adcp/decisioning/dispatch.py
Original file line number Diff line number Diff line change
Expand Up @@ -511,14 +511,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",
Expand All @@ -532,26 +531,26 @@ 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`,
:class:`HttpSigCredential`); ``ctx.metadata`` is for non-secret
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``.
"""
Expand Down Expand Up @@ -595,12 +594,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):
Expand Down Expand Up @@ -1233,17 +1231,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
Expand Down
18 changes: 14 additions & 4 deletions src/adcp/reporting/feed/request.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,19 +43,29 @@ 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:
if isinstance(value, Decimal):
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()}
Expand Down
Loading
Loading