feat(adagents): divergence detector + ?include=properties for directory inverse-lookup (#749 Part 3, adcp#4894)#752
feat(adagents): divergence detector + ?include=properties for directory inverse-lookup (#749 Part 3, adcp#4894)#752bokelley wants to merge 2 commits into
Conversation
…tor (#752) Spec conformance and concurrency fixes from PR review: - Endpoint path corrected: /v1/agents/{agent_url}/publishers (drop /api/). - Response envelope now spec-strict: read `next_cursor` and `publishers` only (no `cursor`/`results` fallbacks); read `directory_indexed_at` required on non-empty pages; remove dead `total` field. - `AgentDirectoryLookup.cursor` renamed to `next_cursor`. - `AgentPublisherEntry.discovery_method` and `.status` now typed as the spec Literal enums (`DiscoveryMethod`, new `PublisherStatus`); a missing or out-of-enum value raises AdagentsValidationError instead of silently defaulting to `"adagents_authoritative"`/`"authorized"`. - `detect_publisher_properties_divergence` now caps concurrent child probes via `max_concurrency` (default 20) using `asyncio.Semaphore`. The `_probe` except clause now also catches `httpx.HTTPError`, `OSError`, `ValueError` so a single network failure no longer aborts the gather. - `sample_size` default flipped from `None` to `200`; pass `sample_size=None` to opt into a full sweep. - `directory_url` default now reads `AAO_PUBLISHER_DIRECTORY_URL` env var at call time via a sentinel, falling back to `"https://agenticadvertising.org"`. - Module-local `import asyncio` / `from urllib.parse import quote` moved to the top of the file. - Tests for the directory wrapper and divergence detector rewritten to use `respx.mock` with spec-shaped JSON payloads instead of bare `MagicMock(httpx.AsyncClient.get)`. New tests cover env-var defaulting, `directory_indexed_at` null-on-empty rule, enum rejection, default sample-size cap, `max_concurrency` peak-in-flight bound, and a selective `httpx.ConnectError` that must not poison sibling probes. - Public API snapshot regenerated to add the six new symbols plus `PublisherStatus`. (See follow-up spec issue, will be linked) for the `?include=properties` extension that would enable full set-diff in the divergence detector. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…tors (#749 Part 1) Layers inline-resolution on top of PR #753's publisher_domains[] fan-out and revoked_publisher_domains[] support. _resolve_agent_properties now returns resolved property dicts (not selector dicts) for publisher_properties authorization_type, sourced from the parent file's top-level properties[] indexed by publisher_domain. - Pre-builds domain → properties index once per call so per-domain lookups are O(1) — avoids O(N×M) at cafemedia scale (6,843 properties × 6,800 domains = ~46M comparisons under the old linear scan). - Inline resolution honors revoked_publisher_domains[] transparently via the existing revoked_top_level pre-filter (revoked domains never enter the per-domain index, so they're skipped by construction). - Fail-closed on unknown selection_type and empty selector criteria (property_tags=[] / property_ids=[]) per CLAUDE.md "no fallbacks" in authorization-decision paths. Fail-fast on properties missing the required property_id field. - Cafemedia/interchange.io scale test (6,843 properties × 6,800 domains) is wall-clock-bounded to catch O(N×M) regressions. - Two pre-existing TestRevokedPublisherDomains tests + the TestPublisherDomainsCompactForm resolution test updated to assert on resolved property dicts (the new contract) instead of selector dicts. - Dead filter_revoked_selectors post-pass removed from get_properties_by_agent and get_all_properties — revocation is enforced upstream via the index, so the post-filter was structurally redundant. Closes #749 Part 1. Federated fallback (when inline yields no match for a domain) lives in companion PR #752's async helpers.
26174e5 to
33525ce
Compare
…ry inverse-lookup (#749 Part 3, adcp#4894) Builds on #769's directory wrapper. Adds: - include=["properties"] parameter on fetch_agent_authorizations_from_directory (adcp#4894). Repeated-key form (?include=properties&include=...), not comma-joined. - property_ids: list[str] | None field on DirectoryPublisherEntry. None signals the directory did not return per-publisher IDs (count-only mode); a list signals the directory supports ?include=properties. - detect_publisher_properties_divergence: compares directory inline resolution against per-publisher federated fetches. Full (publisher_domain, property_id) set-diff when property_ids is available; graceful fallback to count-only against older directories. max_concurrency=20 default semaphore caps concurrent fetches at managed-network scale (cafemedia ~6,800 publishers). sample_size=200 default keeps unbounded sweeps opt-in. - PublisherDivergence / DivergenceReport types (Pydantic, matching #769's style). Closes #749 Part 3. Part 2 superseded by #769 (which closed #746). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
33525ce to
c8e4953
Compare
There was a problem hiding this comment.
LGTM. Approving on the strength of clean additive shape plus comprehensive test coverage — follow-ups below, none blocking.
Things I checked
- Public-API audit. Three new exports (
PublisherDivergence,DivergenceReport,detect_publisher_properties_divergence) plus one new optional field (property_ids: list[str] | None) and one new optional kwarg (include). Purely additive.feat(adagents):prefix is correct — no!warranted, no discriminator churn, no required→optional flips.tests/fixtures/public_api_snapshot.jsonupdated in lockstep withsrc/adcp/__init__.py— that snapshot test is load-bearing and it caught the right delta. - SSRF gate, hostile directory.
_probeatsrc/adcp/adagents.py:2079runsfetch_adagents(entry.publisher_domain, ...)against a directory-supplied value.fetch_adagentsre-runs_validate_publisher_domain+_dns_validate_hostpersrc/adcp/adagents.py:1038, so a directory injecting169.254.169.254or an internal hostname gets blocked at the publisher-side gate. The detector doesn't widen the existing SSRF surface. ?include=query encoding. Each value goes throughquote(v, safe='')atsrc/adcp/adagents.py:1917-1921.include=["properties&admin=1"]producesinclude=properties%26admin%3D1— single value, no extra param. The negative assertion intest_include_multiple_values_repeated_keys(assert "include=properties%2Cfuture" not in raw) is the right anti-test.- Client ownership.
own_client = client is Noneat adagents.py:2052 + thetry/finallywrap covers gather-raises and mid-pagination raises. Caller-provided clients aren't closed; framework-provided ones always are. - Exception stringification at
child_fetch_error.str(exc)onAdagents*andhttpx.RequestErrorexposes the publisher's own domain and HTTP error text only. NoAuthorizationheader is sent by_resolve_direct(onlyUser-Agent), so nothing credential-shaped round-trips into the buyer-visible report. - Concurrency invariant.
test_max_concurrency_respectedbuilds an event-gated 20-publisher × 4-slot peak observation. Not timing-based — works on the sem semantics directly. Solid. - Largest-file rule applied to
tests/test_adagents.py(+422) andsrc/adcp/adagents.py(+190). Read both; cited inline above.
Follow-ups (non-blocking — file as issues)
- Wire-format confirmation against adcp#4894. The repeated-key form (
?include=properties&include=...) is the right REST shape, but most ad-tech / OpenAPI adjacent specs default to comma-joined.ad-tech-protocol-expertflagged this as the load-bearing unverified claim. Worth a quick gh issue view 4894 before the next directory operator hits a mismatch. - Directory-driven request amplification.
security-reviewerMedium: a hostile directory returning 200 rows all withpublisher_domain="victim.example"produces a sustained 20-concurrent burst against one host. The docstring claimsmax_concurrency"caps the burst against publisher origins" — true for benign directories, not for adversarial ones. Dedupecollectedbypublisher_domainbefore fan-out atsrc/adcp/adagents.py:2076, or add a per-host cap. - Pagination loop guard. At
src/adcp/adagents.py:2070,sample_size=Noneopts into a full sweep keyed onnext_cursor. A misbehaving directory that re-emits the same cursor (or a cursor that re-matches undersincesemantics) hangs the sweep. Add acursor-unchanged break or an iteration cap. property_idstyping. Field islist[str]. If upstream defines a canonicalPropertyIdtype (or adds one later), aliaslist[str]to it viaadcp.typesper the CLAUDE.md type-layering rule — keeps the public surface forward-compat without renaming.
Minor nits (non-blocking)
- Falsy
property_idfilter.src/adcp/adagents.py:2083-2085drops federated entries wherep.get(\"property_id\")is falsy. Empty-string IDs are invalid upstream but possible on the wire — silent drop could mask a real divergence. Either pass through and let validation catch it, or document the filter behavior in the docstring. - Redundant
Nonedefaults on error path. adagents.py:2098-2099 setsmissing_in_inline=None, missing_in_federated=Noneexplicitly — already the field defaults. Keep for clarity or drop for line economy. - Naming.
missing_in_inline/missing_in_federatedparses correctly with the docstring but trips the reader for half a beat.missing_from_*would read marginally cleaner. Not worth churning the public type for it now — note for the next API revision.
One interesting choice worth calling out: the count-only fallback returns missing_in_*=None rather than [], and the docstring is explicit that callers cannot treat an empty report as authoritative in that mode. That's the right shape — fail-honest beats fail-clean — but worth a runtime warning emitted once per detector invocation when property_ids is None across the sample, so adopters notice they're not getting full set-diff precision.
Safe to merge.
…tors (#749 Part 1) Layers inline-resolution on top of PR #753's publisher_domains[] fan-out and revoked_publisher_domains[] support. _resolve_agent_properties now returns resolved property dicts (not selector dicts) for publisher_properties authorization_type, sourced from the parent file's top-level properties[] indexed by publisher_domain. - Pre-builds domain → properties index once per call so per-domain lookups are O(1) — avoids O(N×M) at cafemedia scale (6,843 properties × 6,800 domains = ~46M comparisons under the old linear scan). - Inline resolution honors revoked_publisher_domains[] transparently via the existing revoked_top_level pre-filter (revoked domains never enter the per-domain index, so they're skipped by construction). - Fail-closed on unknown selection_type and empty selector criteria (property_tags=[] / property_ids=[]) per CLAUDE.md "no fallbacks" in authorization-decision paths. Fail-fast on properties missing the required property_id field. - Cafemedia/interchange.io scale test (6,843 properties × 6,800 domains) is wall-clock-bounded to catch O(N×M) regressions. - Two pre-existing TestRevokedPublisherDomains tests + the TestPublisherDomainsCompactForm resolution test updated to assert on resolved property dicts (the new contract) instead of selector dicts. - Dead filter_revoked_selectors post-pass removed from get_properties_by_agent and get_all_properties — revocation is enforced upstream via the index, so the post-filter was structurally redundant. Closes #749 Part 1. Federated fallback (when inline yields no match for a domain) lives in companion PR #752's async helpers.
- Dedupe collected entries by publisher_domain before fan-out so a hostile directory can't amplify 1 publisher into a 20-concurrent burst against one host. (Medium-severity security finding.) - Pagination loop guards: detect repeated next_cursor and cap at MAX_DIRECTORY_PAGES=1000 pages; raise AdagentsValidationError on either violation. - Emit a one-shot logging warning when the entire sample comes back without property_ids[] -- adopters notice they're running in count-only mode rather than full set-diff. - Document the falsy property_id filter behavior in a code comment. - Cite the spec wire-format precedent for include= repeated-key form. Bot review: #752
- Cite adcp#4827 in _resolve_publisher_property_selectors docstring. - isinstance(list) guard on property_tags / property_ids in both publisher_properties resolution and the pre-existing property_tags / property_ids authorization branches — string-iterating-as-tags is now caught and resolves to []. - Lift domain index build out of per-agent loop; _build_domain_index module helper used by both get_all_properties and get_properties_by_agent, index passed through _resolve_agent_properties → _resolve_publisher_ property_selectors. O(N+M) instead of O(agents × N). - Document "no federated fallback" on get_properties_by_agent and get_all_properties docstrings; point at PR #752's federated helpers. - Cafemedia scale test wall-clock budget 2.0s → 5.0s for shared-runner margin. Bot review: #750
There was a problem hiding this comment.
LGTM. Follow-ups noted below — one of them is time-sensitive before the next release-please tag.
The shape is right: directory-side ?include=properties (repeated-key wire form, not comma-joined) lights up the full set-diff, the count-only fallback degrades gracefully against older directories, and the dedupe-by-publisher_domain added in commit 2 closes the amplification path against publisher origins. SSRF gate inheritance is real — fetch_adagents independently re-validates the publisher domain through _check_safe_host (adagents.py:311) and _dns_validate_host (adagents.py:1041), so a hostile directory cannot drive the fan-out at 127.0.0.1, 169.254.169.254, or RFC1918 space via publisher_domain.
Things I checked
- Repeated-key wire form (
adagents.py:1924-1932):?include=properties&include=foo, not comma-joined. Spec match verified againstdocs/aao/directory-api.mdx(style: form, explode: true). Negative assertion intest_include_multiple_values_repeated_keyscatches the comma-joined regression. - Pagination guards (
adagents.py:2087-2095):seen_cursorscatchesA→B→Acycles,MAX_DIRECTORY_PAGES=1000catches strictly-monotonic-cursor-forever. Both are load-bearing —test_divergence_aborts_on_repeated_cursorcovers the first. - Dedupe placement (
adagents.py:2097-2110): sits between cursor-walk andasyncio.gather.test_divergence_dedupes_collected_by_publisher_domainexercises the 5-rows-1-victim path → 1 fetch. - SSRF gate on fan-out targets:
fetch_adagentsre-validatespublisher_domainindependently. Directory cannot route fetches to internal IPs. _probeexception coverage (adagents.py:2142-2149):AdagentsNotFoundError,AdagentsValidationError,AdagentsTimeoutError,httpx.HTTPError,OSError,ValueError— comprehensive against everythingfetch_adagentsandget_properties_by_agentraise on the documented contract.- Set-diff determinism (
adagents.py:2162-2163):sorted(...)on both sides, test assertions can rely on order. - Public API surface: three new exports (
PublisherDivergence,DivergenceReport,detect_publisher_properties_divergence) added to__init__.pyANDtests/fixtures/public_api_snapshot.json:124,257,356. Purely additive —feat(adagents):is the right conventional-commit prefix; no!needed.property_ids: list[str] | Nonedefault-None onDirectoryPublisherEntrykeeps existing deserialization paths working. max_concurrencysemaphore is real:test_max_concurrency_respecteduses an event-gated barrier and assertspeak <= 4against 20 concurrent probes. Load-bearing.include=injection surface (adagents.py:1928-1931): values percent-encoded viaquote(v, safe=''). Clean.ctx_metadatafootgun (per CLAUDE.md): not touched. Network helper, not a dispatch path.
Follow-ups (non-blocking — file as issues)
-
Rename
missing_in_inline→missing_in_directorybefore the next release-please tag.ad-tech-protocol-expert: "sound-with-caveats — vocabulary collision." In adcp#4827 §Resolution-paths, inline specifically denotes parent-file inline resolution ofpublisher_propertiesselectors (a publisher-side concept). The SDK uses inline here to mean the directory's pre-resolved answer — a different layer. Spec text consistently says directory path vs federated path. OncePublisherDivergenceships in a tagged release the rename becomes breaking. Field is in the public API surface (__init__.pyre-export + snapshot) and the next push to main will produce a release-please PR — rename pre-tag. -
Move dedupe inside the cursor loop so duplicates don't burn sample-size budget.
security-reviewer(High, but in the threat-model sense the function never promised coverage guarantees — it's observability tooling, not a security gate, so I'm calling this a follow-up rather than a block): truncation tosample_size=200runs before dedupe atadagents.py:2081-2110. A directory that returns 200 duplicates ofvictim.example.comon page 1 collapses to a single probe — amplification still closed, but the divergence detector's sample is steerable. Cleaner: dedupe per-page and break the cursor walk onlen(deduped) >= sample_size. -
Document the count-only-mode caveat in the
detect_publisher_properties_divergencedocstring, not onlyPublisherDivergence.ad-tech-protocol-expertconfirmed the count-equality-implies-no-divergence behavior is the lossy semantic the spec calls out — the one-shotlogger.warningatadagents.py:2117-2123is good operator-facing instrumentation, but the function-level docstring atadagents.py:2057-2061should also surface "empty list ≠ no divergence in count-only mode" so adopters don't have to read two docstrings to get the warning. -
Bound
collectedmemory on thesample_size=Nonepath.security-reviewer(Medium): withsample_size=Noneand a hostile-but-paging directory, up to 1000 pages × 5 MiB ≈ 5 GiB ofDirectoryPublisherEntryrows can land incollectedbefore either guard trips. Either add aMAX_COLLECTED_ENTRIEScap or fold this into the per-page dedupe of follow-up #2. -
Classify
child_fetch_error: str(exc)into a fixed enum.security-reviewer(Low):httpx.RequestError/OSErrorstring forms can include the resolved target IP or local socket path. Acceptable for internal ops use, but ifDivergenceReportever round-trips into a tenant-facing response this leaks egress posture.timeout/not_found/validation/networkis enough granularity.
Minor nits (non-blocking)
-
MAX_DIRECTORY_PAGEScheck ordering (adagents.py:2092). The cap fires on iteration 1001 only after the 1001stfetch_agent_authorizations_from_directoryround-trip has completed. Moving the check above the next-iteration body would save one wire call against a misbehaving directory. Not load-bearing — the cap exists for safety, not perf. -
DivergenceReportshould beTypeAlias-annotated (adagents.py:2015).DivergenceReport = list[PublisherDivergence]is a runtime alias; addingfrom typing import TypeAliasand the annotation helps mypy/IDE tooling resolve it without inferring. -
releaser()magic number 50 (tests/test_adagents.py:769-772). Thefor _ in range(50): await asyncio.sleep(0)value is tied to internal event-loop scheduling — a one-liner naming the worst-case task count this is meant to drain (1 releaser + 20 probes + sem internals) would make the test self-documenting.
Approving on the strength of the dedupe close, the SSRF re-validation chain, and the test coverage for both pagination guards.
Pivot from Part 2
PR #769 (now merged to main) shipped
fetch_agent_authorizations_from_directoryand closed #746 — that's the Part 2 surface this PR originally proposed. Rather than ship a duplicate wrapper, this PR pivots to the Part 3 work layered on top of #769's API.What this adds
1.
?include=propertiesparameter on the directory fetch (adcp#4894).fetch_agent_authorizations_from_directorynow acceptsinclude: list[str] | None. Values are emitted as repeated-key query parameters per the AAO directory-api spec (?include=properties&include=...), not comma-joined.2.
property_ids: list[str] | Nonefield onDirectoryPublisherEntry.Nonesignals the directory did not return per-publisher IDs (count-only mode); a list signals the directory supports?include=propertiesand the IDs are usable for set-diff downstream.3.
detect_publisher_properties_divergence— the divergence detector.Compares the directory's inline resolution against per-publisher federated
adagents.jsonfetches. Always requestsinclude=["properties"]so the full(publisher_domain, property_id)set-diff lights up where supported. Against older directories that return onlyproperties_authorizedcounts, falls back to count-comparison.Safety knobs:
max_concurrency=20default — semaphore-capped concurrent federated fetches. Caps the burst against publisher origins at managed-network scale (cafemedia ~6,800 publishers).sample_size=200default — caps the sweep.Noneopts into a full multi-page sweep, intended for small networks only.4. New public types:
PublisherDivergence,DivergenceReport.Pydantic models (matching #769's style, not the dataclass design from the original Part 2 sketch).
Count-only fallback contract
When the directory does not return
property_ids[],missing_in_inlineandmissing_in_federatedareNone. Count-equality does not guarantee set-equality — same-count substitutions are undetectable. Callers should treat an empty divergence report as authoritative only whenproperty_idsis present on the underlying entries.Out of scope / deferred
Closes #749 Part 3. Part 2 (#746) was closed by #769.