Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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 MIGRATION_v9_to_v10.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,4 @@ the SDK-relevant inventory is [docs/adcp-3.3-sdk-inventory.md](docs/adcp-3.3-sdk
| A received `null` is re-emitted; a Python `None` keyword argument is "not supplied" | A property whose schema admits `null` and that arrived as `null` (`model_validate`, `model_validate_json`, nested validation) stays in `model_fields_set` and is written back as `null` by `model_dump()`. Passing `None` as a keyword argument to a constructor, assigning `None` to a field, or passing `None` in `model_copy(update=...)` now leaves the field *unset*: it is omitted from the dump, and `model_dump(exclude_unset=True)` no longer lists it. A `None` inside a dict you validate is a wire `null`. Properties whose schema does not admit `null` are unchanged (`NoExplicitNull` refuses a JSON `null` and never emits one). |
| `model_dump()` leaves an unset schema default off | A property the schema lists outside `required` with a `default` (or a `const`) is emitted only when it was set: by the document that was parsed, by a keyword argument, or by assignment. `sandbox: false`, `timezone: "inventory_local"`, `assignment_limit: 50` and the other schema defaults no longer appear on a dump unless you set them; the attribute still reads the default. Required discriminators (`asset_type`, `pricing_model`) are emitted either way. Pass `exclude_unset=False` for the SDK 9 output. Golden-file tests that compare whole dumps need regenerating. If you relied on a default reaching the peer, set it explicitly. `model_dump_as_received()` is deprecated: it returns the same document as `model_dump(mode="json")` and is removed in SDK 11. Generated root models (a union root such as `LocalizedCreativeAsset`) now subclass `AdCPRootModel` and dump with `exclude_none=True` like every other AdCP model. |
| Unknown keys on open objects are kept and written back | `AdCPBaseModel` defaults to `extra='allow'`: a property the pinned model does not declare (a field a newer spec added, a sibling arm's property the schema admits) survives `model_validate` → `model_dump` in `model_extra`, including a `null` value. Generated classes whose schema object is closed (`additionalProperties: false`) keep `extra='forbid'`. SDK 9 dropped unknown keys (`extra='ignore'`). If your code passed stray keyword arguments and relied on them being dropped, remove them; `ADCP_STRICT_VALIDATION=1` turns the default into `forbid` so tests find them. |
| Permissive adagents.json resolution requires the fetched domain | `resolve_properties_for_agent(doc, agent_url, mode="permissive")` raises `ValueError` without `publisher_domain=`. Pass the domain the document was fetched for: a bare entry's top-level properties are then kept only when every identifier is a `type: "domain"` under that domain, so a file on another host can no longer claim a publisher's site, app bundle or DOOH venue through a bare entry. Properties identified by bundle, store or venue IDs, and properties whose `publisher_domain` names another domain, are dropped from the bare fallback. `allow_any_domain=True` restores the SDK 9 result. Typed selectors and strict mode are unchanged. See [docs/adagents-discovery.md](docs/adagents-discovery.md) for `properties_for_publisher_domain` and `verify_cross_domain_properties`. |
66 changes: 66 additions & 0 deletions docs/adagents-discovery.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,69 @@ Malformed sibling entries and non-string or empty URL values are skipped during
lookup. `validate_adagents_structure(document)` reports invalid URL types through
its existing `missing_url` diagnostic. The listing helper returns matching entry
objects in source order and does not modify the document.

## Attributing properties to a publisher domain

One adagents.json is often served at several publisher domains (a media
group's shared file) or names a network's publishers. When syncing a file for
publisher domain `D`, `properties_for_publisher_domain(properties, D)` returns
the properties that are `D`'s:

- a property that declares `publisher_domain` belongs to `D` when that value
names `D` (its identifiers are not consulted);
- otherwise it belongs to `D` when a `type: "domain"` identifier names `D` or
any subdomain (`www.D`, `m.D`, `shop.D`, the pattern `*.D`);
- a property with no domain at all (an app known only by bundle ID, a DOOH
venue) is excluded unless `include_domainless=True`.

`www.D` is treated as `D`, and hosts compare case-insensitively in A-label
form. `D` must be a registrable hostname: an IP literal, a single label or a
public suffix (`co.uk`, `github.io`) raises `AdagentsValidationError`.
Attribution does not authorize anything; apply it to the output of
`get_properties_by_agent` or `resolve_properties_for_agent`.

```python
from adcp import get_properties_by_agent, properties_for_publisher_domain

authorized = get_properties_by_agent(document, agent_url)
ours = properties_for_publisher_domain(authorized, "example.org")
apps = [
p
for p in properties_for_publisher_domain(authorized, "example.org", include_domainless=True)
if p not in ours
]
```

## Permissive resolution is gated to the fetched domain

A bare `authorized_agents` entry has no typed binding, so nothing in the file
stops `attacker.example` from bare-listing an agent and claiming another
publisher's site, app or venue. `resolve_properties_for_agent(...,
mode="permissive")` therefore requires `publisher_domain=`, the domain the
document was fetched for. The bare-entry fallback then keeps only properties
whose identifiers are all `type: "domain"` and name that domain or a
subdomain. A property whose own `publisher_domain` names another domain is
dropped, as is any property with a bundle, store or venue identifier. Typed
selectors are unaffected. `allow_any_domain=True` keeps the unfiltered SDK 9
result for diagnostics.

## Verifying cross-domain claims

When a file served at `A` authorizes an agent for a property on another
publisher's domain `B`, `await verify_cross_domain_properties(properties,
source_domain=A, agent_url=...)` keeps the claim only when `B` vouches: the
document governing `B` (fetched with `fetch_adagents`, so `B`'s
`authoritative_location`, or its ads.txt `MANAGERDOMAIN` when `B` publishes no
file, is followed) authorizes the agent, under strict resolution and `B`'s
revocations, for every identifier of the property. A domain identifier is
vouched for by its registrable domain (`shop.b.com` by `b.com`); a declared
`publisher_domain` by that domain. Properties on `A` or its subdomains are
`A`'s own claims and need no fetch.

The result has `verified`, `unverified` (each an `UnverifiedProperty` with the
`domain` and a `reason`: `not_vouched`, `fetch_failed`, `invalid_domain` or
`domain_limit`) and `domainless`. The check fails closed: a property naming
several publishers needs each to vouch for all of it, a failed fetch or a bare
entry in `B`'s file vouches for nothing, and at most `max_domains` (default 50)
distinct domains are fetched per call. Pass `fetcher=` to reuse a cache; it
receives a domain and returns the parsed document.
8 changes: 7 additions & 1 deletion docs/adagents-fetch.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,16 @@ from adcp import fetch_adagents, validate_adagents_structure, resolve_properties
async def inspect_publisher(domain: str, agent_url: str):
document = await fetch_adagents(domain, validate_structure=False)
report = validate_adagents_structure(document)
properties = resolve_properties_for_agent(document, agent_url, mode="permissive")
properties = resolve_properties_for_agent(
document, agent_url, mode="permissive", publisher_domain=domain
)
return report, properties
```

Permissive mode requires `publisher_domain=`: a bare entry's top-level
properties are kept only when they are domains under the fetched publisher (see
[publisher agent discovery](adagents-discovery.md#permissive-resolution-is-gated-to-the-fetched-domain)).

The opt-out preserves JSON-object and endpoint checks, authorization-array shape,
renderer catalog trust checks, URL/DNS SSRF gates, response size caps, and redirect
constraints. `fetch_adagents_with_cache` and `validate_adagents_domain` accept the
Expand Down
15 changes: 15 additions & 0 deletions src/adcp/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,10 @@ def _resolve_version() -> str:
"DiscoveryMethod",
"DivergenceReport",
"EntryErrorKind",
"CrossDomainFailureReason",
"CrossDomainVerification",
"PublisherDivergence",
"UnverifiedProperty",
"detect_publisher_properties_divergence",
"domain_matches",
"fetch_adagents",
Expand All @@ -180,11 +183,13 @@ def _resolve_version() -> str:
"get_properties_by_agent",
"identifiers_match",
"parse_managerdomains",
"properties_for_publisher_domain",
"resolve_properties_for_agent",
"validate_adagents_domain",
"validate_adagents_structure",
"verify_agent_authorization",
"verify_agent_for_property",
"verify_cross_domain_properties",
),
"adcp.canonical_formats": (
"format_is_supported",
Expand Down Expand Up @@ -1604,6 +1609,11 @@ def get_adcp_version() -> str:
"get_all_tags",
"get_properties_by_agent",
"resolve_properties_for_agent",
"properties_for_publisher_domain",
"verify_cross_domain_properties",
"CrossDomainFailureReason",
"CrossDomainVerification",
"UnverifiedProperty",
# Test helpers
"test_agent",
"test_agent_a2a",
Expand Down Expand Up @@ -1831,13 +1841,16 @@ def get_adcp_version() -> str:
AdAgentsValidationResult,
AgentAuthorizationsDirectoryResult,
AuthorizationContext,
CrossDomainFailureReason,
CrossDomainVerification,
DirectoryDiscoveryMethod,
DirectoryEdgeStatus,
DirectoryPublisherEntry,
DiscoveryMethod,
DivergenceReport,
EntryErrorKind,
PublisherDivergence,
UnverifiedProperty,
detect_publisher_properties_divergence,
domain_matches,
fetch_adagents,
Expand All @@ -1851,11 +1864,13 @@ def get_adcp_version() -> str:
get_properties_by_agent,
identifiers_match,
parse_managerdomains,
properties_for_publisher_domain,
resolve_properties_for_agent,
validate_adagents_domain,
validate_adagents_structure,
verify_agent_authorization,
verify_agent_for_property,
verify_cross_domain_properties,
)
from adcp.canonical_formats import (
format_is_supported,
Expand Down
Loading
Loading