@@ -1207,12 +1207,16 @@ async def verify_agent_for_property(
12071207def _resolve_agent_properties (
12081208 agent : dict [str , Any ],
12091209 top_level_properties : list [dict [str , Any ]],
1210+ domain_index : dict [str , list [dict [str , Any ]]],
12101211) -> list [dict [str , Any ]]:
12111212 """Resolve properties for a single agent entry based on its authorization_type.
12121213
12131214 Args:
12141215 agent: An authorized_agents entry
12151216 top_level_properties: The top-level properties array from adagents.json
1217+ domain_index: Pre-built ``publisher_domain → [property, ...]`` index
1218+ over ``top_level_properties`` (built once per file by the caller
1219+ via :func:`_build_domain_index`).
12161220
12171221 Returns:
12181222 List of resolved property dicts for this agent
@@ -1230,7 +1234,10 @@ def _resolve_agent_properties(
12301234
12311235 # Handle property_ids (filter top-level properties by property_id)
12321236 if authorization_type == "property_ids" :
1233- authorized_ids = set (agent .get ("property_ids" , []))
1237+ raw_ids = agent .get ("property_ids" )
1238+ if not isinstance (raw_ids , list ):
1239+ return []
1240+ authorized_ids = {i for i in raw_ids if isinstance (i , str )}
12341241 return [
12351242 p
12361243 for p in top_level_properties
@@ -1239,7 +1246,10 @@ def _resolve_agent_properties(
12391246
12401247 # Handle property_tags (filter top-level properties by tags)
12411248 if authorization_type == "property_tags" :
1242- authorized_tags = {t for t in agent .get ("property_tags" , []) if isinstance (t , str )}
1249+ raw_tags = agent .get ("property_tags" )
1250+ if not isinstance (raw_tags , list ):
1251+ return []
1252+ authorized_tags = {t for t in raw_tags if isinstance (t , str )}
12431253 return [
12441254 p
12451255 for p in top_level_properties
@@ -1260,20 +1270,45 @@ def _resolve_agent_properties(
12601270 selectors = _fanout_publisher_properties (
12611271 [p for p in publisher_props if isinstance (p , dict )]
12621272 )
1263- return _resolve_publisher_property_selectors (selectors , top_level_properties )
1273+ return _resolve_publisher_property_selectors (selectors , domain_index )
12641274
12651275 return []
12661276
12671277
1278+ def _build_domain_index (
1279+ properties : list [dict [str , Any ]],
1280+ ) -> dict [str , list [dict [str , Any ]]]:
1281+ """Build a ``publisher_domain → [property, ...]`` index.
1282+
1283+ O(N) up-front cost; reused across every selector for that file so the
1284+ per-selector resolution cost drops from O(properties) to O(1) lookup
1285+ plus O(matches) filtering. Malformed entries (non-dict, missing or
1286+ non-string ``publisher_domain``) are skipped.
1287+ """
1288+ domain_index : dict [str , list [dict [str , Any ]]] = {}
1289+ for prop in properties :
1290+ if not isinstance (prop , dict ):
1291+ continue
1292+ domain = prop .get ("publisher_domain" )
1293+ if not isinstance (domain , str ) or not domain :
1294+ continue
1295+ domain_index .setdefault (domain , []).append (prop )
1296+ return domain_index
1297+
1298+
12681299def _resolve_publisher_property_selectors (
12691300 selectors : list [dict [str , Any ]],
1270- top_level_properties : list [dict [str , Any ]],
1301+ domain_index : dict [ str , list [dict [str , Any ] ]],
12711302) -> list [dict [str , Any ]]:
12721303 """Resolve fanned-out publisher_properties selectors against inline data.
12731304
1305+ Resolves selectors per adcp#4827 §Resolution-paths (inline path
1306+ against the parent file's top-level properties[] indexed by
1307+ publisher_domain).
1308+
12741309 For each selector (one per publisher_domain), look up the matching
1275- properties in ``top_level_properties `` by ``publisher_domain`` and
1276- apply the selector's ``selection_type``:
1310+ properties in ``domain_index `` by ``publisher_domain`` and apply the
1311+ selector's ``selection_type``:
12771312
12781313 - ``"all"``: every property under that domain
12791314 - ``"by_tag"``: properties whose ``tags`` intersect ``property_tags``
@@ -1289,25 +1324,16 @@ def _resolve_publisher_property_selectors(
12891324 federated fallback (fetching the publisher's own adagents.json) is
12901325 out of scope for this resolver and lives in companion helpers.
12911326
1327+ ``domain_index`` is built once per file by :func:`_build_domain_index`
1328+ and reused across every agent's selectors in that file.
1329+
12921330 Results are deduplicated by ``(publisher_domain, property_id)``.
12931331 Raises :class:`AdagentsValidationError` if any matching property is
12941332 missing the required ``property_id`` field (fail-fast per CLAUDE.md).
12951333 """
12961334 if not selectors :
12971335 return []
12981336
1299- # Build domain → [property, ...] index once. O(N) up-front trades
1300- # against O(selectors × properties) per-domain scans, which blows
1301- # up at cafemedia scale (6,843 properties × thousands of selectors).
1302- domain_index : dict [str , list [dict [str , Any ]]] = {}
1303- for prop in top_level_properties :
1304- if not isinstance (prop , dict ):
1305- continue
1306- domain = prop .get ("publisher_domain" )
1307- if not isinstance (domain , str ) or not domain :
1308- continue
1309- domain_index .setdefault (domain , []).append (prop )
1310-
13111337 resolved : list [dict [str , Any ]] = []
13121338 seen : set [tuple [str , str ]] = set ()
13131339 for selector in selectors :
@@ -1323,7 +1349,10 @@ def _resolve_publisher_property_selectors(
13231349 if selection_type == "all" :
13241350 matched = list (candidates )
13251351 elif selection_type == "by_tag" :
1326- wanted_tags = {t for t in selector .get ("property_tags" , []) or [] if isinstance (t , str )}
1352+ raw_tags = selector .get ("property_tags" )
1353+ if not isinstance (raw_tags , list ):
1354+ continue
1355+ wanted_tags = {t for t in raw_tags if isinstance (t , str )}
13271356 if not wanted_tags :
13281357 continue
13291358 matched = [
@@ -1332,7 +1361,10 @@ def _resolve_publisher_property_selectors(
13321361 if {t for t in p .get ("tags" , []) or [] if isinstance (t , str )} & wanted_tags
13331362 ]
13341363 elif selection_type == "by_id" :
1335- wanted_ids = {i for i in selector .get ("property_ids" , []) or [] if isinstance (i , str )}
1364+ raw_ids = selector .get ("property_ids" )
1365+ if not isinstance (raw_ids , list ):
1366+ continue
1367+ wanted_ids = {i for i in raw_ids if isinstance (i , str )}
13361368 if not wanted_ids :
13371369 continue
13381370 matched = [p for p in candidates if p .get ("property_id" ) in wanted_ids ]
@@ -1433,6 +1465,17 @@ def get_all_properties(adagents_data: dict[str, Any]) -> list[dict[str, Any]]:
14331465 Handles all authorization types: inline_properties, property_ids,
14341466 property_tags, and publisher_properties.
14351467
1468+ For ``publisher_properties`` selectors whose target ``publisher_domain``
1469+ is NOT present inline in this file's top-level ``properties[]`` array,
1470+ this function returns no properties for that selector. Federated
1471+ fallback (fetching the child publisher's own adagents.json to resolve
1472+ the selector remotely) is out of scope here and lives in
1473+ :func:`fetch_agent_authorizations_from_directory` and
1474+ :func:`detect_publisher_properties_divergence` from companion PR #752.
1475+ Wire-only authorization checks that assume federated resolution will
1476+ under-authorize against managed-network parent files that only inline
1477+ a subset of their child domains.
1478+
14361479 Args:
14371480 adagents_data: Parsed adagents.json data
14381481
@@ -1464,6 +1507,11 @@ def get_all_properties(adagents_data: dict[str, Any]) -> list[dict[str, Any]]:
14641507 )
14651508 ]
14661509
1510+ # Build the domain index once per file — _resolve_agent_properties is
1511+ # called per-agent, and at cafemedia scale (thousands of properties ×
1512+ # multiple agents) rebuilding it inside each call is O(agents × N).
1513+ domain_index = _build_domain_index (revoked_top_level )
1514+
14671515 properties = []
14681516 for agent in authorized_agents :
14691517 if not isinstance (agent , dict ):
@@ -1475,7 +1523,7 @@ def get_all_properties(adagents_data: dict[str, Any]) -> list[dict[str, Any]]:
14751523
14761524 # revoked_top_level pre-filters revoked domains from the per-domain
14771525 # index, so inline resolution honors revocation transparently.
1478- agent_properties = _resolve_agent_properties (agent , revoked_top_level )
1526+ agent_properties = _resolve_agent_properties (agent , revoked_top_level , domain_index )
14791527
14801528 for prop in agent_properties :
14811529 prop_with_agent = {** prop , "agent_url" : agent_url }
@@ -1520,6 +1568,17 @@ def get_properties_by_agent(adagents_data: dict[str, Any], agent_url: str) -> li
15201568 selectors (resolved from the parent file's top-level properties[]
15211569 array per adcp#4827)
15221570
1571+ For ``publisher_properties`` selectors whose target ``publisher_domain``
1572+ is NOT present inline in this file's top-level ``properties[]`` array,
1573+ this function returns no properties for that selector. Federated
1574+ fallback (fetching the child publisher's own adagents.json to resolve
1575+ the selector remotely) is out of scope here and lives in
1576+ :func:`fetch_agent_authorizations_from_directory` and
1577+ :func:`detect_publisher_properties_divergence` from companion PR #752.
1578+ Wire-only authorization checks that assume federated resolution will
1579+ under-authorize against managed-network parent files that only inline
1580+ a subset of their child domains.
1581+
15231582 Args:
15241583 adagents_data: Parsed adagents.json data
15251584 agent_url: URL of the agent to filter by
@@ -1554,6 +1613,8 @@ def get_properties_by_agent(adagents_data: dict[str, Any], agent_url: str) -> li
15541613
15551614 normalized_agent_url = normalize_url (agent_url )
15561615
1616+ domain_index = _build_domain_index (revoked_top_level )
1617+
15571618 for agent in authorized_agents :
15581619 if not isinstance (agent , dict ):
15591620 continue
@@ -1567,7 +1628,7 @@ def get_properties_by_agent(adagents_data: dict[str, Any], agent_url: str) -> li
15671628
15681629 # revoked_top_level pre-filters revoked domains from the per-domain
15691630 # index, so inline resolution honors revocation transparently.
1570- resolved = _resolve_agent_properties (agent , revoked_top_level )
1631+ resolved = _resolve_agent_properties (agent , revoked_top_level , domain_index )
15711632 return resolved
15721633
15731634 return []
0 commit comments