diff --git a/src/adcp/adagents.py b/src/adcp/adagents.py index 306ea08ec..ea5517a26 100644 --- a/src/adcp/adagents.py +++ b/src/adcp/adagents.py @@ -1207,12 +1207,16 @@ async def verify_agent_for_property( def _resolve_agent_properties( agent: dict[str, Any], top_level_properties: list[dict[str, Any]], + domain_index: dict[str, list[dict[str, Any]]], ) -> list[dict[str, Any]]: """Resolve properties for a single agent entry based on its authorization_type. Args: agent: An authorized_agents entry top_level_properties: The top-level properties array from adagents.json + domain_index: Pre-built ``publisher_domain → [property, ...]`` index + over ``top_level_properties`` (built once per file by the caller + via :func:`_build_domain_index`). Returns: List of resolved property dicts for this agent @@ -1230,7 +1234,10 @@ def _resolve_agent_properties( # Handle property_ids (filter top-level properties by property_id) if authorization_type == "property_ids": - authorized_ids = set(agent.get("property_ids", [])) + raw_ids = agent.get("property_ids") + if not isinstance(raw_ids, list): + return [] + authorized_ids = {i for i in raw_ids if isinstance(i, str)} return [ p for p in top_level_properties @@ -1239,7 +1246,10 @@ def _resolve_agent_properties( # Handle property_tags (filter top-level properties by tags) if authorization_type == "property_tags": - authorized_tags = {t for t in agent.get("property_tags", []) if isinstance(t, str)} + raw_tags = agent.get("property_tags") + if not isinstance(raw_tags, list): + return [] + authorized_tags = {t for t in raw_tags if isinstance(t, str)} return [ p for p in top_level_properties @@ -1250,16 +1260,131 @@ def _resolve_agent_properties( # Handle publisher_properties (cross-domain references). # Each entry with publisher_domains[a,b,c] fans out to one selector per # listed domain — the compact form is exactly equivalent to repeating - # the entry once per publisher per adcp#4504. + # the entry once per publisher per adcp#4504. Selectors are then + # resolved inline against the parent file's top-level properties[] + # array, indexed by publisher_domain, per adcp#4827. if authorization_type == "publisher_properties": publisher_props = agent.get("publisher_properties", []) if not isinstance(publisher_props, list): return [] - return _fanout_publisher_properties([p for p in publisher_props if isinstance(p, dict)]) + selectors = _fanout_publisher_properties( + [p for p in publisher_props if isinstance(p, dict)] + ) + return _resolve_publisher_property_selectors(selectors, domain_index) return [] +def _build_domain_index( + properties: list[dict[str, Any]], +) -> dict[str, list[dict[str, Any]]]: + """Build a ``publisher_domain → [property, ...]`` index. + + O(N) up-front cost; reused across every selector for that file so the + per-selector resolution cost drops from O(properties) to O(1) lookup + plus O(matches) filtering. Malformed entries (non-dict, missing or + non-string ``publisher_domain``) are skipped. + """ + domain_index: dict[str, list[dict[str, Any]]] = {} + for prop in properties: + if not isinstance(prop, dict): + continue + domain = prop.get("publisher_domain") + if not isinstance(domain, str) or not domain: + continue + domain_index.setdefault(domain, []).append(prop) + return domain_index + + +def _resolve_publisher_property_selectors( + selectors: list[dict[str, Any]], + domain_index: dict[str, list[dict[str, Any]]], +) -> list[dict[str, Any]]: + """Resolve fanned-out publisher_properties selectors against inline data. + + Resolves selectors per adcp#4827 §Resolution-paths (inline path + against the parent file's top-level properties[] indexed by + publisher_domain). + + For each selector (one per publisher_domain), look up the matching + properties in ``domain_index`` by ``publisher_domain`` and apply the + selector's ``selection_type``: + + - ``"all"``: every property under that domain + - ``"by_tag"``: properties whose ``tags`` intersect ``property_tags`` + (empty ``property_tags`` resolves to ``[]`` — fail-closed, no + "tag list omitted means everything") + - ``"by_id"``: properties whose ``property_id`` is in ``property_ids`` + (empty ``property_ids`` resolves to ``[]`` — same fail-closed rule) + - Anything else: ``[]`` (fail-closed; unknown selection_type does + not authorize anything — see CLAUDE.md "no fallbacks" on + authorization decisions) + + Selectors whose domain has no entries in the index are skipped — + federated fallback (fetching the publisher's own adagents.json) is + out of scope for this resolver and lives in companion helpers. + + ``domain_index`` is built once per file by :func:`_build_domain_index` + and reused across every agent's selectors in that file. + + Results are deduplicated by ``(publisher_domain, property_id)``. + Raises :class:`AdagentsValidationError` if any matching property is + missing the required ``property_id`` field (fail-fast per CLAUDE.md). + """ + if not selectors: + return [] + + resolved: list[dict[str, Any]] = [] + seen: set[tuple[str, str]] = set() + for selector in selectors: + domain = selector.get("publisher_domain") + if not isinstance(domain, str) or not domain: + continue + candidates = domain_index.get(domain) + if not candidates: + continue + + selection_type = selector.get("selection_type") + matched: list[dict[str, Any]] + if selection_type == "all": + matched = list(candidates) + elif selection_type == "by_tag": + raw_tags = selector.get("property_tags") + if not isinstance(raw_tags, list): + continue + wanted_tags = {t for t in raw_tags if isinstance(t, str)} + if not wanted_tags: + continue + matched = [ + p + for p in candidates + if {t for t in p.get("tags", []) or [] if isinstance(t, str)} & wanted_tags + ] + elif selection_type == "by_id": + raw_ids = selector.get("property_ids") + if not isinstance(raw_ids, list): + continue + wanted_ids = {i for i in raw_ids if isinstance(i, str)} + if not wanted_ids: + continue + matched = [p for p in candidates if p.get("property_id") in wanted_ids] + else: + continue + + for prop in matched: + prop_id = prop.get("property_id") + if not isinstance(prop_id, str) or not prop_id: + raise AdagentsValidationError( + f"property under domain={domain!r} missing required 'property_id'" + ) + key = (domain, prop_id) + if key in seen: + continue + seen.add(key) + resolved.append(prop) + return resolved + + def _fanout_publisher_properties( publisher_props: list[dict[str, Any]], ) -> list[dict[str, Any]]: @@ -1340,6 +1465,17 @@ def get_all_properties(adagents_data: dict[str, Any]) -> list[dict[str, Any]]: Handles all authorization types: inline_properties, property_ids, property_tags, and publisher_properties. + For ``publisher_properties`` selectors whose target ``publisher_domain`` + is NOT present inline in this file's top-level ``properties[]`` array, + this function returns no properties for that selector. Federated + fallback (fetching the child publisher's own adagents.json to resolve + the selector remotely) is out of scope here and lives in + :func:`fetch_agent_authorizations_from_directory` and + :func:`detect_publisher_properties_divergence` from companion PR #752. + Wire-only authorization checks that assume federated resolution will + under-authorize against managed-network parent files that only inline + a subset of their child domains. + Args: adagents_data: Parsed adagents.json data @@ -1371,6 +1507,11 @@ def get_all_properties(adagents_data: dict[str, Any]) -> list[dict[str, Any]]: ) ] + # Build the domain index once per file — _resolve_agent_properties is + # called per-agent, and at cafemedia scale (thousands of properties × + # multiple agents) rebuilding it inside each call is O(agents × N). + domain_index = _build_domain_index(revoked_top_level) + properties = [] for agent in authorized_agents: if not isinstance(agent, dict): @@ -1380,9 +1521,9 @@ def get_all_properties(adagents_data: dict[str, Any]) -> list[dict[str, Any]]: if not agent_url: continue - agent_properties = _resolve_agent_properties(agent, revoked_top_level) - if revoked and agent.get("authorization_type") == "publisher_properties": - agent_properties = filter_revoked_selectors(agent_properties, revoked) + # revoked_top_level pre-filters revoked domains from the per-domain + # index, so inline resolution honors revocation transparently. + agent_properties = _resolve_agent_properties(agent, revoked_top_level, domain_index) for prop in agent_properties: prop_with_agent = {**prop, "agent_url": agent_url} @@ -1423,8 +1564,20 @@ def get_properties_by_agent(adagents_data: dict[str, Any], agent_url: str) -> li - inline_properties: Properties defined directly in the agent's properties array - property_ids: Filter top-level properties by property_id - property_tags: Filter top-level properties by tags - - publisher_properties: References properties from other publisher domains - (returns the selector objects, not resolved properties) + - publisher_properties: Inline-resolved properties from cross-publisher + selectors (resolved from the parent file's top-level properties[] + array per adcp#4827) + + For ``publisher_properties`` selectors whose target ``publisher_domain`` + is NOT present inline in this file's top-level ``properties[]`` array, + this function returns no properties for that selector. Federated + fallback (fetching the child publisher's own adagents.json to resolve + the selector remotely) is out of scope here and lives in + :func:`fetch_agent_authorizations_from_directory` and + :func:`detect_publisher_properties_divergence` from companion PR #752. + Wire-only authorization checks that assume federated resolution will + under-authorize against managed-network parent files that only inline + a subset of their child domains. Args: adagents_data: Parsed adagents.json data @@ -1460,6 +1613,8 @@ def get_properties_by_agent(adagents_data: dict[str, Any], agent_url: str) -> li normalized_agent_url = normalize_url(agent_url) + domain_index = _build_domain_index(revoked_top_level) + for agent in authorized_agents: if not isinstance(agent, dict): continue @@ -1471,9 +1626,9 @@ def get_properties_by_agent(adagents_data: dict[str, Any], agent_url: str) -> li if normalize_url(agent_url_from_json) != normalized_agent_url: continue - resolved = _resolve_agent_properties(agent, revoked_top_level) - if revoked and agent.get("authorization_type") == "publisher_properties": - resolved = filter_revoked_selectors(resolved, revoked) + # revoked_top_level pre-filters revoked domains from the per-domain + # index, so inline resolution honors revocation transparently. + resolved = _resolve_agent_properties(agent, revoked_top_level, domain_index) return resolved return [] diff --git a/tests/test_adagents.py b/tests/test_adagents.py index 071977f19..d267e8772 100644 --- a/tests/test_adagents.py +++ b/tests/test_adagents.py @@ -1487,8 +1487,33 @@ def test_get_properties_by_agent_property_tags_multiple(self): assert properties[1]["name"] == "Site 2" def test_get_properties_by_agent_publisher_properties(self): - """Should return publisher_properties selectors for publisher_properties type.""" + """Should inline-resolve publisher_properties selectors against top-level properties.""" adagents_data = { + "properties": [ + { + "property_id": "cnn-ctv-1", + "publisher_domain": "cnn.com", + "property_type": "ctv_app", + "name": "CNN CTV", + "identifiers": [{"type": "bundle_id", "value": "com.cnn.ctv"}], + "tags": ["ctv"], + }, + { + "property_id": "cnn-web-1", + "publisher_domain": "cnn.com", + "property_type": "website", + "name": "CNN Web", + "identifiers": [{"type": "domain", "value": "cnn.com"}], + "tags": ["web"], + }, + { + "property_id": "espn-1", + "publisher_domain": "espn.com", + "property_type": "website", + "name": "ESPN", + "identifiers": [{"type": "domain", "value": "espn.com"}], + }, + ], "authorized_agents": [ { "url": "https://agent1.example.com", @@ -1511,10 +1536,430 @@ def test_get_properties_by_agent_publisher_properties(self): properties = get_properties_by_agent(adagents_data, "https://agent1.example.com") assert len(properties) == 2 - assert properties[0]["publisher_domain"] == "cnn.com" - assert properties[0]["selection_type"] == "by_tag" - assert properties[1]["publisher_domain"] == "espn.com" - assert properties[1]["selection_type"] == "all" + ids = {p["property_id"] for p in properties} + assert ids == {"cnn-ctv-1", "espn-1"} + + def test_get_properties_by_agent_publisher_domains_fanout(self): + """publisher_domains[] compact form expands and resolves per-domain inline.""" + adagents_data = { + "properties": [ + { + "property_id": "a-1", + "publisher_domain": "a.com", + "property_type": "website", + "name": "A", + "identifiers": [{"type": "domain", "value": "a.com"}], + }, + { + "property_id": "b-1", + "publisher_domain": "b.com", + "property_type": "website", + "name": "B", + "identifiers": [{"type": "domain", "value": "b.com"}], + }, + { + "property_id": "c-1", + "publisher_domain": "c.com", + "property_type": "website", + "name": "C", + "identifiers": [{"type": "domain", "value": "c.com"}], + }, + ], + "authorized_agents": [ + { + "url": "https://agent1.example.com", + "authorization_type": "publisher_properties", + "authorized_for": "fanout", + "publisher_properties": [ + { + "publisher_domains": ["a.com", "b.com", "c.com"], + "selection_type": "all", + }, + ], + }, + ], + } + + properties = get_properties_by_agent(adagents_data, "https://agent1.example.com") + assert {p["property_id"] for p in properties} == {"a-1", "b-1", "c-1"} + + def test_get_properties_by_agent_publisher_properties_by_id(self): + """selection_type=by_id with property_ids returns only the named property.""" + adagents_data = { + "properties": [ + { + "property_id": "x", + "publisher_domain": "site.com", + "property_type": "website", + "name": "X", + "identifiers": [{"type": "domain", "value": "site.com/x"}], + }, + { + "property_id": "y", + "publisher_domain": "site.com", + "property_type": "website", + "name": "Y", + "identifiers": [{"type": "domain", "value": "site.com/y"}], + }, + ], + "authorized_agents": [ + { + "url": "https://agent1.example.com", + "authorization_type": "publisher_properties", + "authorized_for": "by_id", + "publisher_properties": [ + { + "publisher_domain": "site.com", + "selection_type": "by_id", + "property_ids": ["x"], + }, + ], + }, + ], + } + + properties = get_properties_by_agent(adagents_data, "https://agent1.example.com") + assert [p["property_id"] for p in properties] == ["x"] + + def test_revocation_honored_on_publisher_domains_fanout(self): + """Selector publisher_domains=[a,b,c] with parent revoking b → b's properties excluded.""" + adagents_data = { + "properties": [ + { + "property_id": "a-1", + "publisher_domain": "a.com", + "property_type": "website", + "name": "A", + "identifiers": [{"type": "domain", "value": "a.com"}], + }, + { + "property_id": "b-1", + "publisher_domain": "b.com", + "property_type": "website", + "name": "B", + "identifiers": [{"type": "domain", "value": "b.com"}], + }, + { + "property_id": "c-1", + "publisher_domain": "c.com", + "property_type": "website", + "name": "C", + "identifiers": [{"type": "domain", "value": "c.com"}], + }, + ], + "revoked_publisher_domains": [{"publisher_domain": "b.com"}], + "authorized_agents": [ + { + "url": "https://agent1.example.com", + "authorization_type": "publisher_properties", + "authorized_for": "fanout", + "publisher_properties": [ + { + "publisher_domains": ["a.com", "b.com", "c.com"], + "selection_type": "all", + }, + ], + }, + ], + } + + properties = get_properties_by_agent(adagents_data, "https://agent1.example.com") + assert {p["property_id"] for p in properties} == {"a-1", "c-1"} + + def test_unknown_selection_type_returns_empty(self): + """Unknown selection_type fails closed (no fallback authorization).""" + adagents_data = { + "properties": [ + { + "property_id": "x", + "publisher_domain": "site.com", + "property_type": "website", + "name": "X", + "identifiers": [{"type": "domain", "value": "site.com"}], + }, + ], + "authorized_agents": [ + { + "url": "https://agent1.example.com", + "authorization_type": "publisher_properties", + "authorized_for": "unknown", + "publisher_properties": [ + { + "publisher_domain": "site.com", + "selection_type": "by_category", + }, + ], + }, + ], + } + + properties = get_properties_by_agent(adagents_data, "https://agent1.example.com") + assert properties == [] + + def test_by_tag_with_empty_property_tags_returns_empty(self): + """selection_type=by_tag with empty property_tags resolves to [] (fail-closed).""" + adagents_data = { + "properties": [ + { + "property_id": "x", + "publisher_domain": "site.com", + "property_type": "website", + "name": "X", + "identifiers": [{"type": "domain", "value": "site.com"}], + "tags": ["ctv"], + }, + ], + "authorized_agents": [ + { + "url": "https://agent1.example.com", + "authorization_type": "publisher_properties", + "authorized_for": "empty tags", + "publisher_properties": [ + { + "publisher_domain": "site.com", + "selection_type": "by_tag", + "property_tags": [], + }, + ], + }, + ], + } + + properties = get_properties_by_agent(adagents_data, "https://agent1.example.com") + assert properties == [] + + def test_property_missing_property_id_raises(self): + """Matching property without property_id raises AdagentsValidationError (fail-fast).""" + adagents_data = { + "properties": [ + { + # no property_id + "publisher_domain": "site.com", + "property_type": "website", + "name": "X", + "identifiers": [{"type": "domain", "value": "site.com"}], + }, + ], + "authorized_agents": [ + { + "url": "https://agent1.example.com", + "authorization_type": "publisher_properties", + "authorized_for": "missing id", + "publisher_properties": [ + { + "publisher_domain": "site.com", + "selection_type": "all", + }, + ], + }, + ], + } + + with pytest.raises(AdagentsValidationError, match="missing required 'property_id'"): + get_properties_by_agent(adagents_data, "https://agent1.example.com") + + def test_get_properties_by_agent_cafemedia_scale(self): + """6,843 inline properties across 6,800 child domains under one publisher_domains[]. + + Wall-clock-bounded to catch O(N×M) regressions in the resolver. + At this scale a naive per-domain linear scan over the property list + is roughly 46M comparisons; the indexed path is ~6,843 + 6,800 ops. + """ + import time + + child_domains = [f"site{i}.example" for i in range(6800)] + # 6,843 total properties across the 6,800 child domains: one per + # domain, plus 43 extra properties on the first 43 domains + # (mirrors a real publisher's mix where some child domains host + # multiple inventory entries — e.g., site + ctv app). + properties = [ + { + "property_id": f"p-{i}", + "publisher_domain": child_domains[i], + "property_type": "website", + "name": f"Site {i}", + "identifiers": [{"type": "domain", "value": child_domains[i]}], + "tags": ["raptive_managed"], + } + for i in range(6800) + ] + [ + { + "property_id": f"p-extra-{i}", + "publisher_domain": child_domains[i], + "property_type": "ctv_app", + "name": f"Site {i} CTV", + "identifiers": [{"type": "bundle_id", "value": f"com.site{i}.ctv"}], + "tags": ["raptive_managed"], + } + for i in range(43) + ] + adagents_data = { + "properties": properties, + "authorized_agents": [ + { + "url": "https://agent1.example.com", + "authorization_type": "publisher_properties", + "authorized_for": "scale", + "publisher_properties": [ + { + "publisher_domains": child_domains, + "selection_type": "by_tag", + "property_tags": ["raptive_managed"], + }, + ], + }, + ], + } + + start = time.perf_counter() + result = get_properties_by_agent(adagents_data, "https://agent1.example.com") + elapsed = time.perf_counter() - start + + assert elapsed < 5.0, f"resolution took {elapsed:.2f}s (>= 5.0s budget)" + assert len(result) == 6843 + assert {p["publisher_domain"] for p in result} == set(child_domains) + + def test_malformed_property_tags_value_resolves_empty(self): + """publisher_properties selector with property_tags as a STRING resolves to []. + + Without the isinstance(list) guard, ``property_tags: "ctv"`` iterates + char-by-char and matches properties tagged ``"c"``/``"t"``/``"v"``. + The resolver must fail-closed on malformed input. + """ + adagents_data = { + "properties": [ + { + "property_id": "p1", + "publisher_domain": "site1.example", + "property_type": "website", + "name": "Site 1", + "identifiers": [{"type": "domain", "value": "site1.example"}], + "tags": ["c"], + }, + { + "property_id": "p2", + "publisher_domain": "site1.example", + "property_type": "website", + "name": "Site 2", + "identifiers": [{"type": "domain", "value": "site2.example"}], + "tags": ["t"], + }, + { + "property_id": "p3", + "publisher_domain": "site1.example", + "property_type": "website", + "name": "Site 3", + "identifiers": [{"type": "domain", "value": "site3.example"}], + "tags": ["v"], + }, + ], + "authorized_agents": [ + { + "url": "https://agent1.example.com", + "authorization_type": "publisher_properties", + "authorized_for": "Test", + "publisher_properties": [ + { + "publisher_domain": "site1.example", + "selection_type": "by_tag", + "property_tags": "ctv", # malformed: string, not list + }, + ], + }, + ], + } + + result = get_properties_by_agent(adagents_data, "https://agent1.example.com") + assert result == [] + + def test_get_all_properties_builds_index_once(self): + """_build_domain_index runs once per file, not once per agent. + + At N agents × M properties, rebuilding the index inside + _resolve_agent_properties is O(agents × M). This test patches the + helper with a counter and asserts a single invocation across a + file with multiple publisher_properties agents. + """ + from unittest.mock import patch + + from adcp import adagents as adagents_module + + adagents_data = { + "properties": [ + { + "property_id": "p1", + "publisher_domain": "site1.example", + "property_type": "website", + "name": "Site 1", + "identifiers": [{"type": "domain", "value": "site1.example"}], + "tags": ["managed"], + }, + { + "property_id": "p2", + "publisher_domain": "site2.example", + "property_type": "website", + "name": "Site 2", + "identifiers": [{"type": "domain", "value": "site2.example"}], + "tags": ["managed"], + }, + ], + "authorized_agents": [ + { + "url": "https://agent1.example.com", + "authorization_type": "publisher_properties", + "authorized_for": "A", + "publisher_properties": [ + { + "publisher_domain": "site1.example", + "selection_type": "all", + }, + ], + }, + { + "url": "https://agent2.example.com", + "authorization_type": "publisher_properties", + "authorized_for": "B", + "publisher_properties": [ + { + "publisher_domain": "site2.example", + "selection_type": "all", + }, + ], + }, + { + "url": "https://agent3.example.com", + "authorization_type": "publisher_properties", + "authorized_for": "C", + "publisher_properties": [ + { + "publisher_domain": "site1.example", + "selection_type": "by_tag", + "property_tags": ["managed"], + }, + ], + }, + ], + } + + original = adagents_module._build_domain_index + with patch.object( + adagents_module, + "_build_domain_index", + side_effect=original, + ) as spy: + result = get_all_properties(adagents_data) + + assert spy.call_count == 1, ( + f"_build_domain_index called {spy.call_count} times; " + "expected exactly once per get_all_properties invocation" + ) + # Sanity: index actually reused — all three agents resolved. + agent_urls = {p["agent_url"] for p in result} + assert agent_urls == { + "https://agent1.example.com", + "https://agent2.example.com", + "https://agent3.example.com", + } def test_get_properties_by_agent_protocol_agnostic(self): """Should match agent URL regardless of protocol.""" @@ -2921,6 +3366,12 @@ def test_fanout_skips_invalid_compact_entries(self): def test_resolve_compact_form_via_get_properties_by_agent(self): adagents = { + "properties": [ + {"property_id": "a-ctv", "publisher_domain": "a.example", "tags": ["ctv"]}, + {"property_id": "a-web", "publisher_domain": "a.example", "tags": ["web"]}, + {"property_id": "b-ctv", "publisher_domain": "b.example", "tags": ["ctv"]}, + {"property_id": "c-ctv", "publisher_domain": "c.example", "tags": ["ctv"]}, + ], "authorized_agents": [ { "url": "https://agent.example", @@ -2934,16 +3385,15 @@ def test_resolve_compact_form_via_get_properties_by_agent(self): } ], } - ] + ], } resolved = get_properties_by_agent(adagents, "https://agent.example") - assert [s["publisher_domain"] for s in resolved] == [ - "a.example", - "b.example", - "c.example", - ] - assert all(s["selection_type"] == "by_tag" for s in resolved) - assert all(s["property_tags"] == ["ctv"] for s in resolved) + # Compact form fans out and inline-resolves against top-level properties[]; + # by_tag=["ctv"] picks the ctv-tagged property per domain (3 total). + assert {p["property_id"] for p in resolved} == {"a-ctv", "b-ctv", "c-ctv"} + assert all( + p.get("publisher_domain") in {"a.example", "b.example", "c.example"} for p in resolved + ) def test_validate_accepts_pydantic_model_instance(self): # With issue #759 (auto-enforce XOR via post-hoc model_validator), @@ -3062,6 +3512,11 @@ def test_revocation_reasons_match_generated_enum(self): def test_revocation_filters_compact_form_selectors(self): adagents = { + "properties": [ + {"property_id": "a-ctv", "publisher_domain": "a.example", "tags": ["ctv"]}, + {"property_id": "b-ctv", "publisher_domain": "b.example", "tags": ["ctv"]}, + {"property_id": "c-ctv", "publisher_domain": "c.example", "tags": ["ctv"]}, + ], "revoked_publisher_domains": [ { "publisher_domain": "b.example", @@ -3085,10 +3540,17 @@ def test_revocation_filters_compact_form_selectors(self): ], } resolved = get_properties_by_agent(adagents, "https://agent.example") - assert [s["publisher_domain"] for s in resolved] == ["a.example", "c.example"] + # b.example is revoked — pre-filter strips its property from the index, + # so inline resolution skips that domain transparently. + assert {p["publisher_domain"] for p in resolved} == {"a.example", "c.example"} + assert {p["property_id"] for p in resolved} == {"a-ctv", "c-ctv"} def test_revocation_filters_singular_selectors(self): adagents = { + "properties": [ + {"property_id": "cnn-1", "publisher_domain": "cnn.com"}, + {"property_id": "espn-1", "publisher_domain": "espn.com"}, + ], "revoked_publisher_domains": [ {"publisher_domain": "cnn.com", "revoked_at": "2026-05-01T00:00:00Z"} ], @@ -3105,7 +3567,10 @@ def test_revocation_filters_singular_selectors(self): ], } resolved = get_properties_by_agent(adagents, "https://agent.example") - assert [s["publisher_domain"] for s in resolved] == ["espn.com"] + # cnn.com revoked → its property is stripped from the index, so the + # cnn selector resolves to nothing; only espn.com's property remains. + assert [p["publisher_domain"] for p in resolved] == ["espn.com"] + assert [p["property_id"] for p in resolved] == ["espn-1"] def test_revocation_filters_top_level_properties(self): adagents = {