diff --git a/schemas/registry-openapi.yaml b/schemas/registry-openapi.yaml index 28da0ee2..5fdf0841 100644 --- a/schemas/registry-openapi.yaml +++ b/schemas/registry-openapi.yaml @@ -881,8 +881,10 @@ components: - direct - authoritative_location - ads_txt_managerdomain + - adagents_authoritative + - community_catalog - null - description: "How the publisher's adagents.json was discovered on the most recent successful crawl. `direct`: publisher's own /.well-known/ served the document. `authoritative_location`: publisher's stub redirected to a canonical URL. `ads_txt_managerdomain`: manifest was discovered via ads.txt MANAGERDOMAIN delegation — see `manager_domain` for which manager served it. Null until first crawl after migration 470." + description: "How the publisher's adagents.json was discovered on the most recent successful crawl or registry write. `direct`: publisher's own /.well-known/ served the document. `authoritative_location`: publisher's stub redirected to a canonical URL. `ads_txt_managerdomain`: manifest was discovered via ads.txt MANAGERDOMAIN delegation. `adagents_authoritative`: manager file named this publisher through publisher_properties fan-out. `community_catalog`: moderator-approved community catalog. Null until first crawl after migration 470." manager_domain: type: - string @@ -952,13 +954,17 @@ components: type: string enum: - valid + - community - invalid - unknown - checking - description: What we know about the publisher's adagents.json right now. `valid` = crawler fetched a parsing-and-shape-valid file. `invalid` = crawler fetched a file that failed validation. `unknown` = never crawled or last result is stale. `checking` = an auto-crawl was kicked off by this request; the page should poll for fresh data shortly. + description: What we know about the publisher's adagents.json right now. `valid` = crawler fetched a parsing-and-shape-valid file from the publisher origin. `community` = moderators approved a community adagents.json catalog for this domain. `invalid` = crawler fetched a file that failed validation. `unknown` = never crawled or last result is stale. `checking` = an auto-crawl was kicked off by this request; the page should poll for fresh data shortly. expected_url: type: string description: Where adagents.json should live on the publisher's own origin. + registry_url: + type: string + description: Registry-served adagents.json URL when the document is community or AgenticAdvertising.org hosted rather than served by the publisher origin. required: - status - expected_url @@ -1004,9 +1010,10 @@ components: type: string enum: - adagents_json + - community - discovered - brand_json - description: Where this property came from. `adagents_json`/`discovered` come from the federated index (publisher's own adagents.json or crawler discovery). `brand_json` is hydrated from the publisher's brand.json when no federated-index data exists yet. + description: Where this property came from. `adagents_json` comes from the publisher's own adagents.json, `community` from an approved community adagents.json catalog, `discovered` from crawler or third-party signals, and `brand_json` from the publisher's brand.json when no federated-index data exists yet. delegation_type: type: string enum: @@ -1014,6 +1021,67 @@ components: - delegated - ad_network description: "Delegation relationship declared in brand.json. Populated only when `source` is `brand_json` — for `adagents_json` and `discovered` sources the authoritative value is on the matching `authorized_agents` entry. Mirrors adagents.json `delegation_type` for bilateral verification: `direct` = publisher treats this as a direct buying path, even if a third party operates the software; `delegated` = a rep firm or manager is authorized to sell on the publisher's behalf (operator-declared, unilateral until corroborated by the publisher's adagents.json); `ad_network` = sold as part of a network/exchange package. `owned` properties have no `delegation_type` — ownership is implicit and has no adagents.json counterpart." + brand: + type: object + properties: + name: + type: string + description: Display name from brand.json or the registered brand row. + description: + type: string + description: Short brand or house description when present in brand.json. + logo_url: + type: string + description: First usable logo URL from brand.json. + colors: + type: array + items: + type: string + description: Representative hex colors from brand.json, capped for display. + industries: + type: array + items: + type: string + description: Industry labels from brand.json when present. + description: Display-oriented brand identity summary from brand.json. The full raw document remains available from the publisher's /.well-known/brand.json or hosted registry URL. + formats: + type: array + items: + type: object + properties: + format_option_id: + type: string + description: Stable format option identifier from adagents.json `formats[]`. + display_name: + type: string + description: Human-readable format label for catalog and publisher UI display. + format_kind: + type: string + description: Canonical format discriminator, such as `image`, `video_hosted`, `native_in_feed`, or `custom`. + params: + type: object + additionalProperties: {} + description: Canonical format params from the publisher's adagents.json declaration. + applies_to_property_ids: + type: array + items: + type: string + description: Property IDs this format applies to; absent means all properties. + applies_to_property_tags: + type: array + items: + type: string + description: Property tags this format applies to; absent means all properties. + seller_preference: + type: string + description: Seller preference hint from the format declaration, when present. + experimental: + type: boolean + description: Whether this seller's format declaration is marked experimental. + required: + - display_name + - format_kind + description: Display-oriented summary of top-level adagents.json `formats[]`, normalized for publisher pages and agent discovery clients. Each entry preserves `format_kind`, `format_option_id`, and canonical params. authorized_agents: type: array items: @@ -1072,21 +1140,698 @@ components: PublisherPropertySelector: type: object properties: - publisher_domain: + publisher_domain: + type: string + example: examplepub.com + property_types: + type: array + items: + type: string + property_ids: + type: array + items: + type: string + tags: + type: array + items: + type: string + CreateAdagentsResponse: + type: object + properties: + success: + type: boolean + enum: + - true + data: + type: object + properties: + success: + type: boolean + enum: + - true + adagents_json: + type: string + description: Pretty-printed adagents.json document generated by the service. + validation: + $ref: "#/components/schemas/AdagentsValidationResult" + required: + - success + - adagents_json + - validation + timestamp: + type: string + format: date-time + required: + - success + - data + - timestamp + AdagentsValidationResult: + type: object + properties: + valid: + type: boolean + errors: + type: array + items: + $ref: "#/components/schemas/AdagentsValidationIssue" + warnings: + type: array + items: + $ref: "#/components/schemas/AdagentsValidationWarning" + domain: + type: string + url: + type: string + status_code: + type: integer + response_bytes: + type: integer + minimum: 0 + resolved_url: + type: string + raw_data: {} + discovery_method: + type: string + enum: + - direct + - authoritative_location + - ads_txt_managerdomain + - adagents_authoritative + manager_domain: + type: string + required: + - valid + - errors + - warnings + - domain + - url + - discovery_method + AdagentsValidationIssue: + type: object + properties: + field: + type: string + message: + type: string + severity: + type: string + enum: + - error + required: + - field + - message + - severity + AdagentsValidationWarning: + type: object + properties: + field: + type: string + message: + type: string + suggestion: + type: string + required: + - field + - message + AdagentsAuthorizedAgent: + type: object + properties: + url: + type: string + format: uri + description: Agent endpoint URL. + authorized_for: + type: string + authorization_type: + type: string + enum: + - property_ids + - property_tags + - inline_properties + - publisher_properties + - signal_ids + - signal_tags + property_ids: + type: array + items: + type: string + property_tags: + type: array + items: + type: string + properties: + type: array + items: + type: object + additionalProperties: {} + description: Protocol-defined catalog object. See the adagents.json JSON Schema for the authoritative nested shape. + publisher_properties: + type: array + items: + type: object + properties: + publisher_domain: + type: string + publisher_domains: + type: array + items: + type: string + selection_type: + type: string + enum: + - all + - by_id + - by_tag + property_ids: + type: array + items: + type: string + property_tags: + type: array + items: + type: string + required: + - selection_type + collections: + type: array + items: + type: object + properties: + publisher_domain: + type: string + collection_ids: + type: array + items: + type: string + required: + - publisher_domain + - collection_ids + placement_ids: + type: array + items: + type: string + placement_tags: + type: array + items: + type: string + delegation_type: + type: string + enum: + - direct + - delegated + - ad_network + exclusive: + type: boolean + countries: + type: array + items: + type: string + effective_from: + type: string + effective_until: + type: string + signal_ids: + type: array + items: + type: string + signal_tags: + type: array + items: + type: string + signing_keys: + type: array + items: + type: object + additionalProperties: {} + required: + - url + additionalProperties: {} + CommunityMirrorListResponse: + type: object + properties: + mirrors: + type: array + items: + $ref: "#/components/schemas/CommunityMirrorSummary" + total: + type: integer + minimum: 0 + required: + - mirrors + - total + CommunityMirrorSummary: + type: object + properties: + platform: + type: string + pattern: ^[a-z0-9_-]{1,64}$ + description: Lowercase platform identifier, normalized by the service. + example: example_platform + catalog_etag: + type: + - string + - "null" + superseded_by: + type: + - string + - "null" + pattern: ^https:\/\/ + description: HTTPS successor document URL, when this mirror has been superseded. + updated_at: + type: string + format: date-time + required: + - platform + - catalog_etag + - superseded_by + - updated_at + RateLimitError: + type: object + properties: + error: + type: string + message: + type: string + retryAfter: + type: integer + description: Seconds to wait before retrying. + required: + - error + CommunityMirrorGetResponse: + type: object + properties: + platform: + type: string + pattern: ^[a-z0-9_-]{1,64}$ + description: Lowercase platform identifier, normalized by the service. + example: example_platform + catalog_etag: + type: + - string + - "null" + superseded_by: + type: + - string + - "null" + pattern: ^https:\/\/ + description: HTTPS successor document URL, when this mirror has been superseded. + adagents_json: + $ref: "#/components/schemas/CommunityMirrorAdagentsJson" + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + required: + - platform + - catalog_etag + - superseded_by + - adagents_json + - created_at + - updated_at + CommunityMirrorAdagentsJson: + type: object + properties: + $schema: + type: string + format: uri + authorized_agents: + type: array + items: + $ref: "#/components/schemas/AdagentsAuthorizedAgent" + maxItems: 0 + description: Always empty for community mirrors; these catalogs never assert sales authorization. + properties: + type: array + items: + type: object + additionalProperties: {} + description: Protocol-defined catalog object. See the adagents.json JSON Schema for the authoritative nested shape. + catalog_etag: + type: string + formats: + type: array + items: + type: object + additionalProperties: {} + description: Protocol-defined catalog object. See the adagents.json JSON Schema for the authoritative nested shape. + placements: + type: array + items: + type: object + additionalProperties: {} + description: Protocol-defined catalog object. See the adagents.json JSON Schema for the authoritative nested shape. + placement_tags: + type: object + additionalProperties: {} + collections: + type: array + items: + type: object + additionalProperties: {} + description: Protocol-defined catalog object. See the adagents.json JSON Schema for the authoritative nested shape. + signals: + type: array + items: + type: object + additionalProperties: {} + description: Protocol-defined catalog object. See the adagents.json JSON Schema for the authoritative nested shape. + signal_tags: + type: object + additionalProperties: {} + contact: {} + superseded_by: + type: string + pattern: ^https:\/\/ + description: HTTPS URL for the canonical successor adagents.json document. Clients should re-fetch the successor and update cached mirror references before retiring use of this mirror. + last_updated: + type: string + format: date-time + required: + - authorized_agents + additionalProperties: {} + CommunityMirrorPublishResponse: + type: object + properties: + success: + type: boolean + enum: + - true + platform: + type: string + pattern: ^[a-z0-9_-]{1,64}$ + description: Lowercase platform identifier, normalized by the service. + example: example_platform + catalog_etag: + type: + - string + - "null" + superseded_by: + type: + - string + - "null" + pattern: ^https:\/\/ + description: HTTPS successor document URL, when this mirror has been superseded. + publisher_domains: + type: array + items: + type: string + description: Publisher domains updated from this community mirror catalog. + updated_at: + type: string + format: date-time + required: + - success + - platform + - catalog_etag + - superseded_by + - publisher_domains + - updated_at + CommunityMirrorPublishError: + type: object + properties: + error: + type: string + details: + type: array + items: {} + description: Validation details for request-body parse failures or adagents.json conformance errors. + required: + - error + CommunityMirrorPublishRequest: + anyOf: + - type: object + properties: + catalog_etag: + type: string + minLength: 1 + maxLength: 255 + formats: + type: array + items: + type: object + additionalProperties: {} + description: Protocol-defined catalog object. See the adagents.json JSON Schema for the authoritative nested shape. + minItems: 1 + properties: + type: array + items: + type: object + additionalProperties: {} + description: Protocol-defined catalog object. See the adagents.json JSON Schema for the authoritative nested shape. + placements: + type: array + items: + type: object + additionalProperties: {} + description: Protocol-defined catalog object. See the adagents.json JSON Schema for the authoritative nested shape. + placement_tags: + type: object + additionalProperties: {} + collections: + type: array + items: + type: object + additionalProperties: {} + description: Protocol-defined catalog object. See the adagents.json JSON Schema for the authoritative nested shape. + signals: + type: array + items: + type: object + additionalProperties: {} + description: Protocol-defined catalog object. See the adagents.json JSON Schema for the authoritative nested shape. + signal_tags: + type: object + additionalProperties: {} + contact: {} + superseded_by: + type: string + pattern: ^https:\/\/ + description: HTTPS URL for the canonical successor adagents.json document. Set this before deleting a mirror so buyers can migrate cached references. + required: + - formats + additionalProperties: {} + - type: object + properties: + catalog_etag: + type: string + minLength: 1 + maxLength: 255 + formats: + type: array + items: + type: object + additionalProperties: {} + description: Protocol-defined catalog object. See the adagents.json JSON Schema for the authoritative nested shape. + properties: + type: array + items: + type: object + additionalProperties: {} + description: Protocol-defined catalog object. See the adagents.json JSON Schema for the authoritative nested shape. + minItems: 1 + placements: + type: array + items: + type: object + additionalProperties: {} + description: Protocol-defined catalog object. See the adagents.json JSON Schema for the authoritative nested shape. + placement_tags: + type: object + additionalProperties: {} + collections: + type: array + items: + type: object + additionalProperties: {} + description: Protocol-defined catalog object. See the adagents.json JSON Schema for the authoritative nested shape. + signals: + type: array + items: + type: object + additionalProperties: {} + description: Protocol-defined catalog object. See the adagents.json JSON Schema for the authoritative nested shape. + signal_tags: + type: object + additionalProperties: {} + contact: {} + superseded_by: + type: string + pattern: ^https:\/\/ + description: HTTPS URL for the canonical successor adagents.json document. Set this before deleting a mirror so buyers can migrate cached references. + required: + - properties + additionalProperties: {} + - type: object + properties: + catalog_etag: + type: string + minLength: 1 + maxLength: 255 + formats: + type: array + items: + type: object + additionalProperties: {} + description: Protocol-defined catalog object. See the adagents.json JSON Schema for the authoritative nested shape. + properties: + type: array + items: + type: object + additionalProperties: {} + description: Protocol-defined catalog object. See the adagents.json JSON Schema for the authoritative nested shape. + placements: + type: array + items: + type: object + additionalProperties: {} + description: Protocol-defined catalog object. See the adagents.json JSON Schema for the authoritative nested shape. + minItems: 1 + placement_tags: + type: object + additionalProperties: {} + collections: + type: array + items: + type: object + additionalProperties: {} + description: Protocol-defined catalog object. See the adagents.json JSON Schema for the authoritative nested shape. + signals: + type: array + items: + type: object + additionalProperties: {} + description: Protocol-defined catalog object. See the adagents.json JSON Schema for the authoritative nested shape. + signal_tags: + type: object + additionalProperties: {} + contact: {} + superseded_by: + type: string + pattern: ^https:\/\/ + description: HTTPS URL for the canonical successor adagents.json document. Set this before deleting a mirror so buyers can migrate cached references. + required: + - placements + additionalProperties: {} + - type: object + properties: + catalog_etag: + type: string + minLength: 1 + maxLength: 255 + formats: + type: array + items: + type: object + additionalProperties: {} + description: Protocol-defined catalog object. See the adagents.json JSON Schema for the authoritative nested shape. + properties: + type: array + items: + type: object + additionalProperties: {} + description: Protocol-defined catalog object. See the adagents.json JSON Schema for the authoritative nested shape. + placements: + type: array + items: + type: object + additionalProperties: {} + description: Protocol-defined catalog object. See the adagents.json JSON Schema for the authoritative nested shape. + placement_tags: + type: object + additionalProperties: {} + collections: + type: array + items: + type: object + additionalProperties: {} + description: Protocol-defined catalog object. See the adagents.json JSON Schema for the authoritative nested shape. + minItems: 1 + signals: + type: array + items: + type: object + additionalProperties: {} + description: Protocol-defined catalog object. See the adagents.json JSON Schema for the authoritative nested shape. + signal_tags: + type: object + additionalProperties: {} + contact: {} + superseded_by: + type: string + pattern: ^https:\/\/ + description: HTTPS URL for the canonical successor adagents.json document. Set this before deleting a mirror so buyers can migrate cached references. + required: + - collections + additionalProperties: {} + - type: object + properties: + catalog_etag: + type: string + minLength: 1 + maxLength: 255 + formats: + type: array + items: + type: object + additionalProperties: {} + description: Protocol-defined catalog object. See the adagents.json JSON Schema for the authoritative nested shape. + properties: + type: array + items: + type: object + additionalProperties: {} + description: Protocol-defined catalog object. See the adagents.json JSON Schema for the authoritative nested shape. + placements: + type: array + items: + type: object + additionalProperties: {} + description: Protocol-defined catalog object. See the adagents.json JSON Schema for the authoritative nested shape. + placement_tags: + type: object + additionalProperties: {} + collections: + type: array + items: + type: object + additionalProperties: {} + description: Protocol-defined catalog object. See the adagents.json JSON Schema for the authoritative nested shape. + signals: + type: array + items: + type: object + additionalProperties: {} + description: Protocol-defined catalog object. See the adagents.json JSON Schema for the authoritative nested shape. + minItems: 1 + signal_tags: + type: object + additionalProperties: {} + contact: {} + superseded_by: + type: string + pattern: ^https:\/\/ + description: HTTPS URL for the canonical successor adagents.json document. Set this before deleting a mirror so buyers can migrate cached references. + required: + - signals + additionalProperties: {} + description: Catalog-only adagents.json body for a community mirror. At least one of `formats`, `properties`, `placements`, `collections`, or `signals` must be present and non-empty. The service regenerates `$schema` and `last_updated` before persisting. + CommunityMirrorDeleteResponse: + type: object + properties: + success: + type: boolean + enum: + - true + platform: type: string - example: examplepub.com - property_types: - type: array - items: - type: string - property_ids: - type: array - items: - type: string - tags: - type: array - items: - type: string + pattern: ^[a-z0-9_-]{1,64}$ + description: Lowercase platform identifier, normalized by the service. + example: example_platform + required: + - success + - platform PolicySummary: type: object properties: @@ -2410,6 +3155,65 @@ components: - 250m_1b - 1b_plus description: Annual revenue band, USD. Drives membership-tier eligibility for company-tier seats. + AdagentsJson: + type: object + properties: + $schema: + type: string + format: uri + authorized_agents: + type: array + items: + $ref: "#/components/schemas/AdagentsAuthorizedAgent" + properties: + type: array + items: + type: object + additionalProperties: {} + description: Protocol-defined catalog object. See the adagents.json JSON Schema for the authoritative nested shape. + catalog_etag: + type: string + formats: + type: array + items: + type: object + additionalProperties: {} + description: Protocol-defined catalog object. See the adagents.json JSON Schema for the authoritative nested shape. + placements: + type: array + items: + type: object + additionalProperties: {} + description: Protocol-defined catalog object. See the adagents.json JSON Schema for the authoritative nested shape. + placement_tags: + type: object + additionalProperties: {} + collections: + type: array + items: + type: object + additionalProperties: {} + description: Protocol-defined catalog object. See the adagents.json JSON Schema for the authoritative nested shape. + signals: + type: array + items: + type: object + additionalProperties: {} + description: Protocol-defined catalog object. See the adagents.json JSON Schema for the authoritative nested shape. + signal_tags: + type: object + additionalProperties: {} + contact: {} + superseded_by: + type: string + format: uri + description: HTTPS URL for the canonical successor adagents.json document. Clients should re-fetch the successor and update cached mirror references before retiring use of this mirror. + last_updated: + type: string + format: date-time + required: + - authorized_agents + additionalProperties: {} paths: /api: get: @@ -3881,6 +4685,7 @@ paths: - authoritative_location - ads_txt_managerdomain - adagents_authoritative + - community_catalog manager_domain: type: - string @@ -4033,6 +4838,7 @@ paths: - authoritative_location - ads_txt_managerdomain - adagents_authoritative + - community_catalog manager_domain: type: - string @@ -4162,7 +4968,7 @@ paths: **This endpoint is unauthenticated and returns the same response shape for every caller.** Compare to `/api/registry/operator`, where AAO membership tier and profile ownership unlock additional agent visibility (`members_only`, `private`). AAO membership does not change the `/publisher` response today. - **Property source precedence:** authoritative adagents.json properties win over crawler-discovered rows; both win over brand.json hydration. Each property carries a `source` field (`adagents_json` / `discovered` / `brand_json`). + **Property source precedence:** publisher-attested adagents.json properties win first. When no publisher-attested adagents properties exist for the domain, brand.json properties supplement and override lower-trust rows, followed by approved community catalogs, then crawler-discovered rows. Each property carries a `source` field (`adagents_json` / `brand_json` / `community` / `discovered`). **Per-agent rollup:** each entry in `authorized_agents` may carry `properties_authorized` + `properties_total` + `publisher_wide`. The rollup is suppressed (fields absent) when (a) properties are entirely brand.json-hydrated — no adagents.json claim has been made — or (b) the publisher has more than 50 authorized agents (above-cap entries are returned without rollup; `rollup_truncated` is set with `{ cap, total_agents }`). Use `/api/registry/publisher/authorization?domain=X&agent=Y` for the per-agent count when the index rollup is absent. tags: @@ -4485,14 +5291,7 @@ paths: authorized_agents: type: array items: - type: object - properties: - url: - type: string - authorized_for: - type: string - required: - - url + $ref: "#/components/schemas/AdagentsAuthorizedAgent" include_schema: type: boolean include_timestamp: @@ -4519,25 +5318,233 @@ paths: content: application/json: schema: - type: object - properties: - success: - type: boolean - data: - type: object - properties: - success: - type: boolean - adagents_json: {} - validation: {} - required: - - success - timestamp: - type: string - required: - - success - - data - - timestamp + $ref: "#/components/schemas/CreateAdagentsResponse" + /api/registry/mirrors: + get: + operationId: listCommunityMirrors + summary: List community mirrors + description: List persisted catalog-only adagents.json community mirrors. The list projection includes presence and freshness metadata but omits the full `adagents_json` body; fetch a platform-specific mirror for the full document. + tags: + - Community Mirrors + parameters: + - schema: + type: integer + description: Maximum mirrors to return. The service defaults to 100 and clamps values to the 1-500 range. + required: false + description: Maximum mirrors to return. The service defaults to 100 and clamps values to the 1-500 range. + name: limit + in: query + - schema: + type: integer + description: Zero-based result offset. Defaults to 0; negative values are clamped to 0. + required: false + description: Zero-based result offset. Defaults to 0; negative values are clamped to 0. + name: offset + in: query + responses: + "200": + description: Community mirror list + content: + application/json: + schema: + $ref: "#/components/schemas/CommunityMirrorListResponse" + "429": + description: Rate limit exceeded + content: + application/json: + schema: + $ref: "#/components/schemas/RateLimitError" + "500": + description: Failed to list community mirrors + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + /api/registry/mirrors/{platform}: + get: + operationId: getCommunityMirror + summary: Get community mirror + description: Fetch one persisted community mirror by platform. A present mirror returns the platform metadata plus the stored catalog-only `adagents_json` document; absent mirrors return 404. + tags: + - Community Mirrors + parameters: + - schema: + type: string + pattern: ^[a-z0-9_-]{1,64}$ + description: Lowercase platform identifier. + example: example_platform + required: true + description: Lowercase platform identifier. + name: platform + in: path + responses: + "200": + description: Community mirror + content: + application/json: + schema: + $ref: "#/components/schemas/CommunityMirrorGetResponse" + "400": + description: Invalid platform identifier + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "404": + description: Community mirror not found + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "429": + description: Rate limit exceeded + content: + application/json: + schema: + $ref: "#/components/schemas/RateLimitError" + "500": + description: Failed to read community mirror + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + put: + operationId: publishCommunityMirror + summary: Publish community mirror + description: "Publish or update a catalog-only adagents.json community mirror. Requires a registry moderator or AgenticAdvertising.org admin. The service validates the assembled document against adagents.json, forces `authorized_agents: []`, regenerates `$schema` and `last_updated`, and updates derived publisher-domain catalog rows." + tags: + - Community Mirrors + security: + - bearerAuth: [] + - oauth2: [] + parameters: + - schema: + type: string + pattern: ^[a-z0-9_-]{1,64}$ + description: Lowercase platform identifier. + example: example_platform + required: true + description: Lowercase platform identifier. + name: platform + in: path + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CommunityMirrorPublishRequest" + responses: + "200": + description: Community mirror published + content: + application/json: + schema: + $ref: "#/components/schemas/CommunityMirrorPublishResponse" + "400": + description: Invalid platform, request body, or adagents.json conformance failure + content: + application/json: + schema: + $ref: "#/components/schemas/CommunityMirrorPublishError" + "401": + description: Authentication required + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "403": + description: Only registry moderators or AgenticAdvertising.org admins can manage community mirrors + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "429": + description: Rate limit exceeded + content: + application/json: + schema: + $ref: "#/components/schemas/RateLimitError" + "500": + description: Failed to publish community mirror + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + delete: + operationId: deleteCommunityMirror + summary: Delete community mirror + description: Delete a persisted community mirror and retire derived publisher-domain catalog rows. Requires a registry moderator or AgenticAdvertising.org admin. Without `force=true`, the service refuses to delete a mirror that has not first published a `superseded_by` migration URL. + tags: + - Community Mirrors + security: + - bearerAuth: [] + - oauth2: [] + parameters: + - schema: + type: string + pattern: ^[a-z0-9_-]{1,64}$ + description: Lowercase platform identifier. + example: example_platform + required: true + description: Lowercase platform identifier. + name: platform + in: path + - schema: + type: string + description: Set to `true` to delete a mirror without a `superseded_by` migration URL. + required: false + description: Set to `true` to delete a mirror without a `superseded_by` migration URL. + name: force + in: query + responses: + "200": + description: Community mirror deleted + content: + application/json: + schema: + $ref: "#/components/schemas/CommunityMirrorDeleteResponse" + "400": + description: Invalid platform identifier + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "401": + description: Authentication required + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "403": + description: Only registry moderators or AgenticAdvertising.org admins can manage community mirrors + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "404": + description: Community mirror not found + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "409": + description: Mirror has not been superseded and force was not set + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "429": + description: Rate limit exceeded + content: + application/json: + schema: + $ref: "#/components/schemas/RateLimitError" + "500": + description: Failed to delete community mirror + content: + application/json: + schema: + $ref: "#/components/schemas/Error" /api/search: get: operationId: search @@ -7695,9 +8702,7 @@ paths: compliance_version: type: string supported_versions: - type: array - items: - type: string + type: string declared_specialisms: type: array items: @@ -7879,7 +8884,7 @@ paths: results: type: array items: - $ref: "#/components/schemas/CompanySearchResult" + $ref: "#/components/schemas/FindCompanyResult" required: - results "400": @@ -8887,6 +9892,8 @@ tags: description: Look up agents by domain or property, and validate ad-serving authorization. - name: Validation Tools description: Validate publisher adagents.json files and generate compliant configurations. + - name: Community Mirrors + description: Publish, fetch, list, and retire catalog-only adagents.json mirrors for platforms that have not adopted AdCP. - name: Search description: Cross-entity search across brands, publishers, agents, and properties. - name: Agent Probing diff --git a/scripts/generate_registry_types.py b/scripts/generate_registry_types.py index e92bce14..d61308e7 100644 --- a/scripts/generate_registry_types.py +++ b/scripts/generate_registry_types.py @@ -43,6 +43,13 @@ "Contact1": "AgentDetailedContact", # Avoid collision with core.Error "Error": "RegistryApiError", + # adagents.json validation result enums + "Severity": "AdagentsValidationSeverity", + "DiscoveryMethod1": "AdagentsDiscoveryMethod", + "AuthorizationType": "AdagentsAuthorizationType", + "SelectionType": "PublisherPropertySelectionType", + # `success: true` literal shared across mirror/adagents responses + "Success": "SuccessLiteral", } # Classes to rename for clarity (inline response/request schemas) @@ -61,6 +68,23 @@ "Tool": "AgentTool", "StandardOperations": "AgentStandardOperations", "CreativeCapabilities": "AgentCreativeCapabilities", + # Publisher-lookup display summaries (inline objects on PublisherLookupResult) + "Brand": "BrandSummary", + "Format": "FormatSummary", + "Collection": "CollectionRef", + "PublisherProperty": "AdagentsPublisherProperty", + # createAdagents response: inner `data` object + "Data": "CreateAdagentsData", + # Unreferenced inline duplicate of CommunityMirrorAdagentsJson emitted by + # the codegen for a nested catalog reference; named for clarity. + "AdagentsJson1": "CommunityMirrorCatalogDocument", + # The five anyOf branches of CommunityMirrorPublishRequest, each requiring a + # different non-empty catalog facet. + "CommunityMirrorPublishRequest1": "CommunityMirrorPublishFormatsRequest", + "CommunityMirrorPublishRequest2": "CommunityMirrorPublishPropertiesRequest", + "CommunityMirrorPublishRequest3": "CommunityMirrorPublishPlacementsRequest", + "CommunityMirrorPublishRequest4": "CommunityMirrorPublishCollectionsRequest", + "CommunityMirrorPublishRequest5": "CommunityMirrorPublishSignalsRequest", } diff --git a/src/adcp/registry.py b/src/adcp/registry.py index b460a5bf..f19bdd7c 100644 --- a/src/adcp/registry.py +++ b/src/adcp/registry.py @@ -24,6 +24,10 @@ from adcp.types.registry import ( BrandActivity, BrandRegistryItem, + CommunityMirrorDeleteResponse, + CommunityMirrorGetResponse, + CommunityMirrorListResponse, + CommunityMirrorPublishResponse, DomainLookupResult, FederatedAgentWithDetails, FederatedPublisher, @@ -37,6 +41,57 @@ MAX_BULK_DOMAINS = 100 MAX_BULK_POLICIES = 100 +_COMMUNITY_MIRROR_PLATFORM_RE = re.compile(r"^[a-z0-9_-]{1,64}$") + + +def _normalize_community_mirror_platform(platform: str) -> str: + """Trim, lowercase, and validate a community mirror platform key.""" + normalized = platform.strip().lower() if isinstance(platform, str) else "" + if not normalized: + raise RegistryError("platform is required") + if not _COMMUNITY_MIRROR_PLATFORM_RE.match(normalized): + raise RegistryError("platform must match ^[a-z0-9_-]{1,64}$") + return normalized + + +def build_community_mirror_adagents(config: dict[str, Any]) -> dict[str, Any]: + """Build a catalog-only community mirror adagents.json publish body. + + The publish endpoint is catalog-only: it has no ``authorized_agents`` field + and the service forces ``authorized_agents: []`` server-side. This helper + refuses caller-supplied authorization claims or generator-only flags and + strips the ``platform`` key, which is a publish-time routing key rather than + catalog content. + + Args: + config: Catalog config with ``catalog_etag`` and a non-empty ``formats`` + list. May include ``properties`` and ``superseded_by``. + + Returns: + The catalog publish body (no ``authorized_agents`` key). + + Raises: + RegistryError: If authorization claims, generator-only flags, or the + ``catalog_etag``/``formats`` requirements are violated. + """ + if "authorized_agents" in config: + raise RegistryError( + "authorized_agents is not accepted for community mirror adagents catalogs" + ) + if "include_schema" in config or "include_timestamp" in config: + raise RegistryError( + "include_schema and include_timestamp are not accepted for " + "community mirror adagents catalogs" + ) + catalog_etag = config.get("catalog_etag") + if not isinstance(catalog_etag, str) or not catalog_etag.strip(): + raise RegistryError("catalog_etag is required") + formats = config.get("formats") + if not isinstance(formats, list) or len(formats) == 0: + raise RegistryError("formats must contain at least one catalog format") + + return {k: v for k, v in config.items() if k != "platform"} + class RegistryClient: """Client for the AdCP registry API. @@ -1476,6 +1531,238 @@ async def create_adagents( return cast(dict[str, Any], resp.json()) + # ======================================================================== + # Community Mirror Lifecycle + # ======================================================================== + + async def publish_community_mirror_adagents( + self, + platform: str, + config: dict[str, Any], + *, + auth_token: str, + ) -> CommunityMirrorPublishResponse: + """Publish or update a catalog-only community mirror adagents.json descriptor. + + Persists the mirror under ``PUT /api/registry/mirrors/{platform}``. Use + ``create_adagents`` (the generator endpoint) when you only need to + validate or preview the document without saving it. The publish body is + catalog-only; the service forces ``authorized_agents: []``. + + Args: + platform: Stable platform key. Trimmed/lowercased and validated + against ``^[a-z0-9_-]{1,64}$``. + config: Catalog config (see ``build_community_mirror_adagents``). + Any ``properties[].platform`` values must match ``platform``. + auth_token: Bearer token required for save operations. + + Returns: + The publish response. + + Raises: + RegistryError: On platform/catalog validation or HTTP errors. + """ + normalized_platform = _normalize_community_mirror_platform(platform) + catalog = build_community_mirror_adagents(config) + self._assert_community_mirror_properties_match_platform(normalized_platform, catalog) + resp = await self._request_ok( + "PUT", + f"/api/registry/mirrors/{url_quote(normalized_platform, safe='')}", + json_body=catalog, + auth_token=auth_token, + operation="Community mirror publish", + ) + return self._parse(CommunityMirrorPublishResponse, resp.json(), "Community mirror publish") + + async def get_community_mirror_adagents( + self, platform: str + ) -> CommunityMirrorGetResponse | None: + """Retrieve a published catalog-only community mirror adagents.json descriptor. + + Fetches ``GET /api/registry/mirrors/{platform}``. The response carries the + platform metadata (``platform``, ``catalog_etag``, ``superseded_by``, + ``created_at``, ``updated_at``) plus the stored catalog-only + ``adagents_json`` document. ``superseded_by`` is reported at both the + wrapper level and on ``adagents_json`` by the service, so no hydration is + needed. The catalog-only invariant (``authorized_agents`` empty) is + enforced by the response model. + + Args: + platform: Platform key. Trimmed/lowercased and validated. + + Returns: + The mirror response, or ``None`` if no mirror exists (HTTP 404). + + Raises: + RegistryError: If the registry returns a mismatched platform or an + invalid (non-catalog) mirror body, or on other HTTP errors. + """ + normalized_platform = _normalize_community_mirror_platform(platform) + resp = await self._request( + "GET", + f"/api/registry/mirrors/{url_quote(normalized_platform, safe='')}", + operation="Community mirror fetch", + allow_404=True, + ) + if resp is None: + return None + mirror = self._parse(CommunityMirrorGetResponse, resp.json(), "Community mirror fetch") + + if mirror.platform != normalized_platform: + raise RegistryError("Registry returned mismatched community mirror platform") + return mirror + + async def list_community_mirror_adagents( + self, + *, + limit: int | None = None, + offset: int | None = None, + ) -> CommunityMirrorListResponse: + """List published community mirror catalogs with their current etags. + + Fetches ``GET /api/registry/mirrors``. The list projection includes + presence and freshness metadata but omits the full ``adagents_json`` + body; fetch a platform-specific mirror for the full document. + + Args: + limit: Optional page size. The service defaults to 100 and clamps + values to the 1-500 range. + offset: Optional zero-based result offset. The service defaults to 0 + and clamps negative values to 0. + + Returns: + The list response (``mirrors`` summaries plus ``total``). + """ + params: dict[str, Any] = {} + if limit is not None: + params["limit"] = limit + if offset is not None: + params["offset"] = offset + resp = await self._request_ok( + "GET", + "/api/registry/mirrors", + params=params or None, + operation="Community mirror list", + ) + return self._parse(CommunityMirrorListResponse, resp.json(), "Community mirror list") + + async def upsert_community_mirror_adagents( + self, + config: dict[str, Any], + *, + platform: str | None = None, + auth_token: str, + ) -> CommunityMirrorPublishResponse: + """Publish or update a community mirror, inferring the platform key. + + The platform key is resolved from the ``platform`` argument, then + ``config["platform"]``, then a single consistent ``properties[].platform`` + value. Ambiguous property platforms raise an error. + + Args: + config: Catalog config (see ``build_community_mirror_adagents``). + platform: Explicit platform key. Takes precedence over inference. + auth_token: Bearer token required for save operations. + + Returns: + The publish response. + + Raises: + RegistryError: If a platform key cannot be resolved, property + platforms are ambiguous, or on validation/HTTP errors. + """ + resolved_platform = ( + platform + if platform is not None + else self._community_mirror_platform_from_config(config) + ) + return await self.publish_community_mirror_adagents( + resolved_platform, config, auth_token=auth_token + ) + + async def delete_community_mirror_adagents( + self, + platform: str, + *, + force: bool = False, + auth_token: str, + ) -> CommunityMirrorDeleteResponse: + """Delete a published community mirror and retire its derived rows. + + Issues ``DELETE /api/registry/mirrors/{platform}``. Without ``force``, + the service refuses (HTTP 409) to delete a mirror that has not first + published a ``superseded_by`` migration URL; set ``force=True`` to delete + anyway. + + Args: + platform: Platform key. Trimmed/lowercased and validated. + force: Delete a mirror that has no ``superseded_by`` migration URL. + auth_token: Bearer token required for delete operations. + + Returns: + The delete response. + + Raises: + RegistryError: If the mirror has not been superseded and ``force`` is + not set (HTTP 409), or on other platform/HTTP errors. + """ + normalized_platform = _normalize_community_mirror_platform(platform) + params = {"force": "true"} if force else None + resp = await self._request_ok( + "DELETE", + f"/api/registry/mirrors/{url_quote(normalized_platform, safe='')}", + params=params, + auth_token=auth_token, + operation="Community mirror delete", + ) + return self._parse(CommunityMirrorDeleteResponse, resp.json(), "Community mirror delete") + + def _community_mirror_platform_from_config(self, config: dict[str, Any]) -> str: + """Infer the platform key from a community mirror config.""" + config_platform = config.get("platform") + if isinstance(config_platform, str) and config_platform.strip(): + return config_platform + + properties = config.get("properties") + if isinstance(properties, list): + platforms: set[str] = set() + for prop in properties: + if not isinstance(prop, dict): + continue + prop_platform = prop.get("platform") + if isinstance(prop_platform, str) and prop_platform.strip(): + platforms.add(_normalize_community_mirror_platform(prop_platform)) + if len(platforms) == 1: + return next(iter(platforms)) + if len(platforms) > 1: + raise RegistryError( + "platform is ambiguous; pass " + "upsert_community_mirror_adagents(config, platform=...)" + ) + + raise RegistryError("platform is required for community mirror publish") + + def _assert_community_mirror_properties_match_platform( + self, + normalized_platform: str, + catalog: dict[str, Any], + ) -> None: + """Reject catalogs whose property platforms disagree with the key.""" + properties = catalog.get("properties") + if not isinstance(properties, list): + return + for prop in properties: + if not isinstance(prop, dict): + continue + prop_platform = prop.get("platform") + if prop_platform is None: + continue + if ( + not isinstance(prop_platform, str) + or _normalize_community_mirror_platform(prop_platform) != normalized_platform + ): + raise RegistryError(f"properties[].platform must match {normalized_platform}") + # ======================================================================== # Search # ======================================================================== diff --git a/src/adcp/types/registry.py b/src/adcp/types/registry.py index b6343ad3..b180b0da 100644 --- a/src/adcp/types/registry.py +++ b/src/adcp/types/registry.py @@ -11,7 +11,7 @@ from enum import Enum from typing import Annotated, Any -from pydantic import AnyUrl, Field +from pydantic import AnyUrl, AwareDatetime, Field, RootModel from adcp.types.base import RegistryBaseModel @@ -71,11 +71,35 @@ "Source5", "DelegationType", "Property1", + "BrandSummary", + "FormatSummary", "Source6", "AuthorizedAgent2", "RollupTruncated", "PublisherLookupResult", "PublisherPropertySelector", + "SuccessLiteral", + "AdagentsDiscoveryMethod", + "AdagentsValidationSeverity", + "AdagentsValidationIssue", + "AdagentsValidationWarning", + "AdagentsAuthorizationType", + "PublisherPropertySelectionType", + "AdagentsPublisherProperty", + "CollectionRef", + "AdagentsAuthorizedAgent", + "CommunityMirrorSummary", + "RateLimitError", + "CommunityMirrorAdagentsJson", + "CommunityMirrorPublishResponse", + "CommunityMirrorPublishError", + "CommunityMirrorPublishFormatsRequest", + "CommunityMirrorPublishPropertiesRequest", + "CommunityMirrorPublishPlacementsRequest", + "CommunityMirrorPublishCollectionsRequest", + "CommunityMirrorPublishSignalsRequest", + "CommunityMirrorPublishRequest", + "CommunityMirrorDeleteResponse", "PolicyCategory", "PolicyEnforcement", "PolicySourceType", @@ -128,16 +152,22 @@ "CreateOrganizationResponse", "OrganizationCompanyType", "OrganizationRevenueTier", + "CommunityMirrorCatalogDocument", "ResolvedBrand", "ResolvedPropertyEntry", "ResolvedProperty", "FederatedAgentWithDetails", + "AdagentsValidationResult", + "CommunityMirrorListResponse", + "CommunityMirrorGetResponse", "AgentComplianceDetail", "FindCompanyResult", "MemberAgent", "MemberAgentResponse", "MemberAgentInput", "CreateOrganizationInput", + "CreateAdagentsData", + "CreateAdagentsResponse", "MemberAgentListResponse", "FeedEvent", "FeedPage", @@ -521,6 +551,8 @@ class DiscoveryMethod(Enum): direct = "direct" authoritative_location = "authoritative_location" ads_txt_managerdomain = "ads_txt_managerdomain" + adagents_authoritative = "adagents_authoritative" + community_catalog = "community_catalog" NoneType_None = None @@ -594,6 +626,7 @@ class Hosting(RegistryBaseModel): class Status1(Enum): valid = "valid" + community = "community" invalid = "invalid" unknown = "unknown" checking = "checking" @@ -603,13 +636,19 @@ class AdagentsJson(RegistryBaseModel): status: Annotated[ Status1, Field( - description="What we know about the publisher's adagents.json right now. `valid` = crawler fetched a parsing-and-shape-valid file. `invalid` = crawler fetched a file that failed validation. `unknown` = never crawled or last result is stale. `checking` = an auto-crawl was kicked off by this request; the page should poll for fresh data shortly." + description="What we know about the publisher's adagents.json right now. `valid` = crawler fetched a parsing-and-shape-valid file from the publisher origin. `community` = moderators approved a community adagents.json catalog for this domain. `invalid` = crawler fetched a file that failed validation. `unknown` = never crawled or last result is stale. `checking` = an auto-crawl was kicked off by this request; the page should poll for fresh data shortly." ), ] expected_url: Annotated[ str, Field(description="Where adagents.json should live on the publisher's own origin."), ] + registry_url: Annotated[ + str | None, + Field( + description="Registry-served adagents.json URL when the document is community or AgenticAdvertising.org hosted rather than served by the publisher origin." + ), + ] = None class Status2(Enum): @@ -635,6 +674,7 @@ class Files(RegistryBaseModel): class Source5(Enum): adagents_json = "adagents_json" + community = "community" discovered = "discovered" brand_json = "brand_json" @@ -659,7 +699,7 @@ class Property1(RegistryBaseModel): source: Annotated[ Source5 | None, Field( - description="Where this property came from. `adagents_json`/`discovered` come from the federated index (publisher's own adagents.json or crawler discovery). `brand_json` is hydrated from the publisher's brand.json when no federated-index data exists yet." + description="Where this property came from. `adagents_json` comes from the publisher's own adagents.json, `community` from an approved community adagents.json catalog, `discovered` from crawler or third-party signals, and `brand_json` from the publisher's brand.json when no federated-index data exists yet." ), ] = None delegation_type: Annotated[ @@ -670,6 +710,71 @@ class Property1(RegistryBaseModel): ] = None +class BrandSummary(RegistryBaseModel): + name: Annotated[ + str | None, + Field(description="Display name from brand.json or the registered brand row."), + ] = None + description: Annotated[ + str | None, + Field(description="Short brand or house description when present in brand.json."), + ] = None + logo_url: Annotated[str | None, Field(description="First usable logo URL from brand.json.")] = ( + None + ) + colors: Annotated[ + list[str] | None, + Field(description="Representative hex colors from brand.json, capped for display."), + ] = None + industries: Annotated[ + list[str] | None, + Field(description="Industry labels from brand.json when present."), + ] = None + + +class FormatSummary(RegistryBaseModel): + format_option_id: Annotated[ + str | None, + Field(description="Stable format option identifier from adagents.json `formats[]`."), + ] = None + display_name: Annotated[ + str, + Field(description="Human-readable format label for catalog and publisher UI display."), + ] + format_kind: Annotated[ + str, + Field( + description="Canonical format discriminator, such as `image`, `video_hosted`, `native_in_feed`, or `custom`." + ), + ] + params: Annotated[ + dict[str, Any] | None, + Field( + description="Canonical format params from the publisher's adagents.json declaration." + ), + ] = None + applies_to_property_ids: Annotated[ + list[str] | None, + Field( + description="ResolvedPropertyEntry IDs this format applies to; absent means all properties." + ), + ] = None + applies_to_property_tags: Annotated[ + list[str] | None, + Field( + description="ResolvedPropertyEntry tags this format applies to; absent means all properties." + ), + ] = None + seller_preference: Annotated[ + str | None, + Field(description="Seller preference hint from the format declaration, when present."), + ] = None + experimental: Annotated[ + bool | None, + Field(description="Whether this seller's format declaration is marked experimental."), + ] = None + + class Source6(Enum): adagents_json = "adagents_json" aao_hosted = "aao_hosted" @@ -731,7 +836,7 @@ class PublisherLookupResult(RegistryBaseModel): discovery_method: Annotated[ DiscoveryMethod | None, Field( - description="How the publisher's adagents.json was discovered on the most recent successful crawl. `direct`: publisher's own /.well-known/ served the document. `authoritative_location`: publisher's stub redirected to a canonical URL. `ads_txt_managerdomain`: manifest was discovered via ads.txt MANAGERDOMAIN delegation — see `manager_domain` for which manager served it. Null until first crawl after migration 470." + description="How the publisher's adagents.json was discovered on the most recent successful crawl or registry write. `direct`: publisher's own /.well-known/ served the document. `authoritative_location`: publisher's stub redirected to a canonical URL. `ads_txt_managerdomain`: manifest was discovered via ads.txt MANAGERDOMAIN delegation. `adagents_authoritative`: manager file named this publisher through publisher_properties fan-out. `community_catalog`: moderator-approved community catalog. Null until first crawl after migration 470." ), ] = None manager_domain: Annotated[ @@ -748,6 +853,18 @@ class PublisherLookupResult(RegistryBaseModel): ), ] = None properties: list[Property1] + brand: Annotated[ + BrandSummary | None, + Field( + description="Display-oriented brand identity summary from brand.json. The full raw document remains available from the publisher's /.well-known/brand.json or hosted registry URL." + ), + ] = None + formats: Annotated[ + list[FormatSummary] | None, + Field( + description="Display-oriented summary of top-level adagents.json `formats[]`, normalized for publisher pages and agent discovery clients. Each entry preserves `format_kind`, `format_option_id`, and canonical params." + ), + ] = None authorized_agents: list[AuthorizedAgent2] rollup_truncated: Annotated[ RollupTruncated | None, @@ -770,6 +887,299 @@ class PublisherPropertySelector(RegistryBaseModel): tags: list[str] | None = None +class SuccessLiteral(Enum): + boolean_True = True + + +class AdagentsDiscoveryMethod(Enum): + direct = "direct" + authoritative_location = "authoritative_location" + ads_txt_managerdomain = "ads_txt_managerdomain" + adagents_authoritative = "adagents_authoritative" + + +class AdagentsValidationSeverity(Enum): + error = "error" + + +class AdagentsValidationIssue(RegistryBaseModel): + field: str + message: str + severity: AdagentsValidationSeverity + + +class AdagentsValidationWarning(RegistryBaseModel): + field: str + message: str + suggestion: str | None = None + + +class AdagentsAuthorizationType(Enum): + property_ids = "property_ids" + property_tags = "property_tags" + inline_properties = "inline_properties" + publisher_properties = "publisher_properties" + signal_ids = "signal_ids" + signal_tags = "signal_tags" + + +class PublisherPropertySelectionType(Enum): + all = "all" + by_id = "by_id" + by_tag = "by_tag" + + +class AdagentsPublisherProperty(RegistryBaseModel): + publisher_domain: str | None = None + publisher_domains: list[str] | None = None + selection_type: PublisherPropertySelectionType + property_ids: list[str] | None = None + property_tags: list[str] | None = None + + +class CollectionRef(RegistryBaseModel): + publisher_domain: str + collection_ids: list[str] + + +class AdagentsAuthorizedAgent(RegistryBaseModel): + url: Annotated[AnyUrl, Field(description="Agent endpoint URL.")] + authorized_for: str | None = None + authorization_type: AdagentsAuthorizationType | None = None + property_ids: list[str] | None = None + property_tags: list[str] | None = None + properties: list[dict[str, Any]] | None = None + publisher_properties: list[AdagentsPublisherProperty] | None = None + collections: list[CollectionRef] | None = None + placement_ids: list[str] | None = None + placement_tags: list[str] | None = None + delegation_type: DelegationType | None = None + exclusive: bool | None = None + countries: list[str] | None = None + effective_from: str | None = None + effective_until: str | None = None + signal_ids: list[str] | None = None + signal_tags: list[str] | None = None + signing_keys: list[dict[str, Any]] | None = None + + +class CommunityMirrorSummary(RegistryBaseModel): + platform: Annotated[ + str, + Field( + description="Lowercase platform identifier, normalized by the service.", + examples=["example_platform"], + pattern="^[a-z0-9_-]{1,64}$", + ), + ] + catalog_etag: str | None + superseded_by: Annotated[ + str | None, + Field( + description="HTTPS successor document URL, when this mirror has been superseded.", + pattern="^https:\\/\\/", + ), + ] + updated_at: AwareDatetime + + +class RateLimitError(RegistryBaseModel): + error: str + message: str | None = None + retryAfter: Annotated[int | None, Field(description="Seconds to wait before retrying.")] = None + + +class CommunityMirrorAdagentsJson(RegistryBaseModel): + field_schema: Annotated[AnyUrl | None, Field(alias="$schema")] = None + authorized_agents: Annotated[ + list[AdagentsAuthorizedAgent], + Field( + description="Always empty for community mirrors; these catalogs never assert sales authorization.", + max_length=0, + ), + ] + properties: list[dict[str, Any]] | None = None + catalog_etag: str | None = None + formats: list[dict[str, Any]] | None = None + placements: list[dict[str, Any]] | None = None + placement_tags: dict[str, Any] | None = None + collections: list[dict[str, Any]] | None = None + signals: list[dict[str, Any]] | None = None + signal_tags: dict[str, Any] | None = None + contact: Any | None = None + superseded_by: Annotated[ + str | None, + Field( + description="HTTPS URL for the canonical successor adagents.json document. Clients should re-fetch the successor and update cached mirror references before retiring use of this mirror.", + pattern="^https:\\/\\/", + ), + ] = None + last_updated: AwareDatetime | None = None + + +class CommunityMirrorPublishResponse(RegistryBaseModel): + success: SuccessLiteral + platform: Annotated[ + str, + Field( + description="Lowercase platform identifier, normalized by the service.", + examples=["example_platform"], + pattern="^[a-z0-9_-]{1,64}$", + ), + ] + catalog_etag: str | None + superseded_by: Annotated[ + str | None, + Field( + description="HTTPS successor document URL, when this mirror has been superseded.", + pattern="^https:\\/\\/", + ), + ] + publisher_domains: Annotated[ + list[str], + Field(description="Publisher domains updated from this community mirror catalog."), + ] + updated_at: AwareDatetime + + +class CommunityMirrorPublishError(RegistryBaseModel): + error: str + details: Annotated[ + list[Any] | None, + Field( + description="Validation details for request-body parse failures or adagents.json conformance errors." + ), + ] = None + + +class CommunityMirrorPublishFormatsRequest(RegistryBaseModel): + catalog_etag: Annotated[str | None, Field(max_length=255, min_length=1)] = None + formats: Annotated[list[dict[str, Any]], Field(min_length=1)] + properties: list[dict[str, Any]] | None = None + placements: list[dict[str, Any]] | None = None + placement_tags: dict[str, Any] | None = None + collections: list[dict[str, Any]] | None = None + signals: list[dict[str, Any]] | None = None + signal_tags: dict[str, Any] | None = None + contact: Any | None = None + superseded_by: Annotated[ + str | None, + Field( + description="HTTPS URL for the canonical successor adagents.json document. Set this before deleting a mirror so buyers can migrate cached references.", + pattern="^https:\\/\\/", + ), + ] = None + + +class CommunityMirrorPublishPropertiesRequest(RegistryBaseModel): + catalog_etag: Annotated[str | None, Field(max_length=255, min_length=1)] = None + formats: list[dict[str, Any]] | None = None + properties: Annotated[list[dict[str, Any]], Field(min_length=1)] + placements: list[dict[str, Any]] | None = None + placement_tags: dict[str, Any] | None = None + collections: list[dict[str, Any]] | None = None + signals: list[dict[str, Any]] | None = None + signal_tags: dict[str, Any] | None = None + contact: Any | None = None + superseded_by: Annotated[ + str | None, + Field( + description="HTTPS URL for the canonical successor adagents.json document. Set this before deleting a mirror so buyers can migrate cached references.", + pattern="^https:\\/\\/", + ), + ] = None + + +class CommunityMirrorPublishPlacementsRequest(RegistryBaseModel): + catalog_etag: Annotated[str | None, Field(max_length=255, min_length=1)] = None + formats: list[dict[str, Any]] | None = None + properties: list[dict[str, Any]] | None = None + placements: Annotated[list[dict[str, Any]], Field(min_length=1)] + placement_tags: dict[str, Any] | None = None + collections: list[dict[str, Any]] | None = None + signals: list[dict[str, Any]] | None = None + signal_tags: dict[str, Any] | None = None + contact: Any | None = None + superseded_by: Annotated[ + str | None, + Field( + description="HTTPS URL for the canonical successor adagents.json document. Set this before deleting a mirror so buyers can migrate cached references.", + pattern="^https:\\/\\/", + ), + ] = None + + +class CommunityMirrorPublishCollectionsRequest(RegistryBaseModel): + catalog_etag: Annotated[str | None, Field(max_length=255, min_length=1)] = None + formats: list[dict[str, Any]] | None = None + properties: list[dict[str, Any]] | None = None + placements: list[dict[str, Any]] | None = None + placement_tags: dict[str, Any] | None = None + collections: Annotated[list[dict[str, Any]], Field(min_length=1)] + signals: list[dict[str, Any]] | None = None + signal_tags: dict[str, Any] | None = None + contact: Any | None = None + superseded_by: Annotated[ + str | None, + Field( + description="HTTPS URL for the canonical successor adagents.json document. Set this before deleting a mirror so buyers can migrate cached references.", + pattern="^https:\\/\\/", + ), + ] = None + + +class CommunityMirrorPublishSignalsRequest(RegistryBaseModel): + catalog_etag: Annotated[str | None, Field(max_length=255, min_length=1)] = None + formats: list[dict[str, Any]] | None = None + properties: list[dict[str, Any]] | None = None + placements: list[dict[str, Any]] | None = None + placement_tags: dict[str, Any] | None = None + collections: list[dict[str, Any]] | None = None + signals: Annotated[list[dict[str, Any]], Field(min_length=1)] + signal_tags: dict[str, Any] | None = None + contact: Any | None = None + superseded_by: Annotated[ + str | None, + Field( + description="HTTPS URL for the canonical successor adagents.json document. Set this before deleting a mirror so buyers can migrate cached references.", + pattern="^https:\\/\\/", + ), + ] = None + + +class CommunityMirrorPublishRequest( + RootModel[ + CommunityMirrorPublishFormatsRequest + | CommunityMirrorPublishPropertiesRequest + | CommunityMirrorPublishPlacementsRequest + | CommunityMirrorPublishCollectionsRequest + | CommunityMirrorPublishSignalsRequest + ] +): + root: Annotated[ + CommunityMirrorPublishFormatsRequest + | CommunityMirrorPublishPropertiesRequest + | CommunityMirrorPublishPlacementsRequest + | CommunityMirrorPublishCollectionsRequest + | CommunityMirrorPublishSignalsRequest, + Field( + description="Catalog-only adagents.json body for a community mirror. At least one of `formats`, `properties`, `placements`, `collections`, or `signals` must be present and non-empty. The service regenerates `$schema` and `last_updated` before persisting." + ), + ] + + +class CommunityMirrorDeleteResponse(RegistryBaseModel): + success: SuccessLiteral + platform: Annotated[ + str, + Field( + description="Lowercase platform identifier, normalized by the service.", + examples=["example_platform"], + pattern="^[a-z0-9_-]{1,64}$", + ), + ] + + class PolicyCategory(Enum): regulation = "regulation" standard = "standard" @@ -864,7 +1274,7 @@ class Policy(RegistryBaseModel): str, Field( examples=[ - "Data subjects must provide freely given, specific, informed and unambiguous consent..." + "CreateAdagentsData subjects must provide freely given, specific, informed and unambiguous consent..." ] ), ] @@ -1322,6 +1732,27 @@ class OrganizationRevenueTier(Enum): field_1b_plus = "1b_plus" +class CommunityMirrorCatalogDocument(RegistryBaseModel): + field_schema: Annotated[AnyUrl | None, Field(alias="$schema")] = None + authorized_agents: list[AdagentsAuthorizedAgent] + properties: list[dict[str, Any]] | None = None + catalog_etag: str | None = None + formats: list[dict[str, Any]] | None = None + placements: list[dict[str, Any]] | None = None + placement_tags: dict[str, Any] | None = None + collections: list[dict[str, Any]] | None = None + signals: list[dict[str, Any]] | None = None + signal_tags: dict[str, Any] | None = None + contact: Any | None = None + superseded_by: Annotated[ + AnyUrl | None, + Field( + description="HTTPS URL for the canonical successor adagents.json document. Clients should re-fetch the successor and update cached mirror references before retiring use of this mirror." + ), + ] = None + last_updated: AwareDatetime | None = None + + class ResolvedBrand(RegistryBaseModel): canonical_id: Annotated[str, Field(examples=["acmecorp.com"])] canonical_domain: Annotated[str, Field(examples=["acmecorp.com"])] @@ -1376,6 +1807,47 @@ class FederatedAgentWithDetails(RegistryBaseModel): property_summary: PropertySummary | None = None +class AdagentsValidationResult(RegistryBaseModel): + valid: bool + errors: list[AdagentsValidationIssue] + warnings: list[AdagentsValidationWarning] + domain: str + url: str + status_code: int | None = None + response_bytes: Annotated[int | None, Field(ge=0)] = None + resolved_url: str | None = None + raw_data: Any | None = None + discovery_method: AdagentsDiscoveryMethod + manager_domain: str | None = None + + +class CommunityMirrorListResponse(RegistryBaseModel): + mirrors: list[CommunityMirrorSummary] + total: Annotated[int, Field(ge=0)] + + +class CommunityMirrorGetResponse(RegistryBaseModel): + platform: Annotated[ + str, + Field( + description="Lowercase platform identifier, normalized by the service.", + examples=["example_platform"], + pattern="^[a-z0-9_-]{1,64}$", + ), + ] + catalog_etag: str | None + superseded_by: Annotated[ + str | None, + Field( + description="HTTPS successor document URL, when this mirror has been superseded.", + pattern="^https:\\/\\/", + ), + ] + adagents_json: CommunityMirrorAdagentsJson + created_at: AwareDatetime + updated_at: AwareDatetime + + class AgentComplianceDetail(RegistryBaseModel): agent_url: str requested_compliance_target: Annotated[ @@ -1544,6 +2016,21 @@ class CreateOrganizationInput(RegistryBaseModel): ] = False +class CreateAdagentsData(RegistryBaseModel): + success: SuccessLiteral + adagents_json: Annotated[ + str, + Field(description="Pretty-printed adagents.json document generated by the service."), + ] + validation: AdagentsValidationResult + + +class CreateAdagentsResponse(RegistryBaseModel): + success: SuccessLiteral + data: CreateAdagentsData + timestamp: AwareDatetime + + class MemberAgentListResponse(RegistryBaseModel): agents: list[MemberAgent] diff --git a/tests/test_registry.py b/tests/test_registry.py index 6de0cb05..586b92b8 100644 --- a/tests/test_registry.py +++ b/tests/test_registry.py @@ -8,8 +8,19 @@ import pytest from adcp.exceptions import RegistryError -from adcp.registry import DEFAULT_REGISTRY_URL, MAX_BULK_DOMAINS, RegistryClient +from adcp.registry import ( + DEFAULT_REGISTRY_URL, + MAX_BULK_DOMAINS, + RegistryClient, + build_community_mirror_adagents, +) from adcp.types.core import Member, ResolvedBrand, ResolvedProperty +from adcp.types.registry import ( + CommunityMirrorDeleteResponse, + CommunityMirrorGetResponse, + CommunityMirrorListResponse, + CommunityMirrorPublishResponse, +) BRAND_DATA = { "canonical_id": "nike.com", @@ -1424,3 +1435,469 @@ def test_policy_history_exported_from_root(self): def test_max_bulk_policies_constant(self): assert MAX_BULK_POLICIES == 100 + + +# Catalog config shared across community-mirror tests. Mirrors the JS fixtures +# in adcp-client#2183 / #2187. +_MIRROR_FORMAT = { + "format_option_id": "meta-feed-image", + "format_kind": "image", + "params": {"width": 1080, "height": 1080}, +} +_MIRROR_CONFIG = { + "catalog_etag": "meta-creative-formats-2026-05", + "formats": [_MIRROR_FORMAT], +} + + +def _mirror_get_response( + *, + platform: str = "meta", + superseded_by: object = "https://meta.example/.well-known/adagents.json", + authorized_agents: object = None, +) -> dict[str, object]: + """Build a GET /api/registry/mirrors/{platform} wrapper response.""" + return { + "platform": platform, + "catalog_etag": "meta-creative-formats-2026-05", + "superseded_by": superseded_by, + "adagents_json": { + "authorized_agents": [] if authorized_agents is None else authorized_agents, + "catalog_etag": "meta-creative-formats-2026-05", + "formats": [_MIRROR_FORMAT], + }, + "created_at": "2026-06-05T12:00:00.000Z", + "updated_at": "2026-06-05T12:00:00.000Z", + } + + +_PUBLISH_RESPONSE = { + "success": True, + "platform": "meta", + "catalog_etag": "meta-creative-formats-2026-05", + "superseded_by": None, + "publisher_domains": ["creative.adcontextprotocol.org"], + "updated_at": "2026-06-05T12:00:00.000Z", +} + +_DELETE_RESPONSE = {"success": True, "platform": "meta"} + + +class TestBuildCommunityMirrorAdagents: + """Test the catalog builder helper (no I/O).""" + + def test_omits_authorized_agents_and_strips_platform(self): + catalog = build_community_mirror_adagents( + { + "platform": "meta", + "catalog_etag": "meta-creative-formats-2026-05", + "formats": [_MIRROR_FORMAT], + "superseded_by": "https://meta.example/.well-known/adagents.json", + } + ) + + # The publish body is catalog-only; the service forces authorized_agents: []. + assert "authorized_agents" not in catalog + assert "platform" not in catalog + assert catalog["catalog_etag"] == "meta-creative-formats-2026-05" + assert catalog["formats"][0]["format_kind"] == "image" + assert catalog["superseded_by"] == "https://meta.example/.well-known/adagents.json" + + def test_rejects_authorized_agents(self): + with pytest.raises(RegistryError, match="authorized_agents is not accepted"): + build_community_mirror_adagents( + { + "authorized_agents": [{"url": "https://agent.example.com"}], + "catalog_etag": "x", + "formats": [_MIRROR_FORMAT], + } + ) + + def test_rejects_generator_only_flags(self): + with pytest.raises(RegistryError, match="include_schema and include_timestamp"): + build_community_mirror_adagents( + { + "include_schema": False, + "catalog_etag": "meta-creative-formats-2026-05", + "formats": [_MIRROR_FORMAT], + } + ) + + def test_requires_catalog_etag(self): + with pytest.raises(RegistryError, match="catalog_etag is required"): + build_community_mirror_adagents({"catalog_etag": " ", "formats": [_MIRROR_FORMAT]}) + + def test_requires_non_empty_formats(self): + with pytest.raises(RegistryError, match="formats must contain at least one"): + build_community_mirror_adagents({"catalog_etag": "x", "formats": []}) + + +class TestPublishCommunityMirrorAdagents: + """Test publish_community_mirror_adagents (PUT, authenticated).""" + + @pytest.mark.asyncio + async def test_puts_catalog_with_auth(self): + mock_client = MagicMock() + mock_client.request = AsyncMock(return_value=_mock_response(200, _PUBLISH_RESPONSE)) + + rc = RegistryClient(client=mock_client) + result = await rc.publish_community_mirror_adagents( + "Meta", + { + "catalog_etag": "meta-creative-formats-2026-05", + "superseded_by": "https://meta.example/.well-known/adagents.json", + "properties": [{"domain": "creative.adcontextprotocol.org", "platform": "meta"}], + "formats": [_MIRROR_FORMAT], + }, + auth_token="sk_test", + ) + + assert isinstance(result, CommunityMirrorPublishResponse) + assert result.platform == "meta" + assert result.success.value is True + assert result.catalog_etag == "meta-creative-formats-2026-05" + call = mock_client.request.call_args + assert call.args[0] == "PUT" + assert call.args[1].endswith("/api/registry/mirrors/meta") + assert call.kwargs["headers"]["Authorization"] == "Bearer sk_test" + body = call.kwargs["json"] + # catalog-only publish body: authorized_agents is forced server-side + assert "authorized_agents" not in body + assert body["catalog_etag"] == "meta-creative-formats-2026-05" + assert body["superseded_by"] == "https://meta.example/.well-known/adagents.json" + assert body["formats"][0]["format_kind"] == "image" + # platform is a routing key, never part of the catalog body + assert "platform" not in body + + @pytest.mark.asyncio + async def test_rejects_property_platform_mismatch(self): + mock_client = MagicMock() + mock_client.request = AsyncMock(return_value=_mock_response(200, _PUBLISH_RESPONSE)) + + rc = RegistryClient(client=mock_client) + with pytest.raises(RegistryError, match=r"properties\[\]\.platform must match meta"): + await rc.publish_community_mirror_adagents( + "meta", + { + "catalog_etag": "meta-creative-formats-2026-05", + "properties": [ + {"domain": "creative.adcontextprotocol.org", "platform": "google"} + ], + "formats": [_MIRROR_FORMAT], + }, + auth_token="sk_test", + ) + mock_client.request.assert_not_called() + + @pytest.mark.asyncio + async def test_rejects_empty_platform(self): + mock_client = MagicMock() + mock_client.request = AsyncMock(return_value=_mock_response(200, _PUBLISH_RESPONSE)) + + rc = RegistryClient(client=mock_client) + with pytest.raises(RegistryError, match="platform is required"): + await rc.publish_community_mirror_adagents( + " ", dict(_MIRROR_CONFIG), auth_token="sk_test" + ) + + @pytest.mark.asyncio + async def test_rejects_invalid_platform(self): + mock_client = MagicMock() + mock_client.request = AsyncMock(return_value=_mock_response(200, _PUBLISH_RESPONSE)) + + rc = RegistryClient(client=mock_client) + with pytest.raises(RegistryError, match="platform must match"): + await rc.publish_community_mirror_adagents( + "bad platform!", dict(_MIRROR_CONFIG), auth_token="sk_test" + ) + + @pytest.mark.asyncio + async def test_raises_on_401(self): + mock_client = MagicMock() + mock_client.request = AsyncMock(return_value=_mock_response(401)) + + rc = RegistryClient(client=mock_client) + with pytest.raises(RegistryError) as exc_info: + await rc.publish_community_mirror_adagents( + "meta", dict(_MIRROR_CONFIG), auth_token="bad_token" + ) + assert exc_info.value.status_code == 401 + + +class TestGetCommunityMirrorAdagents: + """Test get_community_mirror_adagents (GET, returns None on 404).""" + + @pytest.mark.asyncio + async def test_returns_stored_catalog(self): + mock_client = MagicMock() + mock_client.get = AsyncMock(return_value=_mock_response(200, _mirror_get_response())) + + rc = RegistryClient(client=mock_client) + result = await rc.get_community_mirror_adagents("meta") + + url = mock_client.get.call_args.args[0] + assert url.endswith("/api/registry/mirrors/meta") + assert isinstance(result, CommunityMirrorGetResponse) + assert result.platform == "meta" + assert result.adagents_json.authorized_agents == [] + assert result.adagents_json.catalog_etag == "meta-creative-formats-2026-05" + assert result.superseded_by == "https://meta.example/.well-known/adagents.json" + assert result.adagents_json.formats is not None + assert result.adagents_json.formats[0]["format_option_id"] == "meta-feed-image" + + @pytest.mark.asyncio + async def test_exposes_wrapper_superseded_by(self): + # The wrapper carries superseded_by; the inner catalog may omit it. + response = _mirror_get_response() + assert "superseded_by" not in response["adagents_json"] # type: ignore[operator] + mock_client = MagicMock() + mock_client.get = AsyncMock(return_value=_mock_response(200, response)) + + rc = RegistryClient(client=mock_client) + result = await rc.get_community_mirror_adagents("meta") + + assert result is not None + assert result.superseded_by == "https://meta.example/.well-known/adagents.json" + assert result.adagents_json.superseded_by is None + + @pytest.mark.asyncio + async def test_returns_none_on_404(self): + mock_client = MagicMock() + mock_client.get = AsyncMock( + return_value=_mock_response(404, {"error": "Community mirror not found"}) + ) + + rc = RegistryClient(client=mock_client) + result = await rc.get_community_mirror_adagents("meta") + + assert result is None + + @pytest.mark.asyncio + async def test_rejects_mismatched_platform(self): + mock_client = MagicMock() + mock_client.get = AsyncMock( + return_value=_mock_response(200, _mirror_get_response(platform="google")) + ) + + rc = RegistryClient(client=mock_client) + with pytest.raises(RegistryError, match="mismatched community mirror platform"): + await rc.get_community_mirror_adagents("meta") + + @pytest.mark.asyncio + async def test_rejects_non_catalog_mirror(self): + mock_client = MagicMock() + mock_client.get = AsyncMock( + return_value=_mock_response( + 200, + _mirror_get_response(authorized_agents=[{"url": "https://agent.example.com"}]), + ) + ) + + rc = RegistryClient(client=mock_client) + with pytest.raises(RegistryError, match="invalid response"): + await rc.get_community_mirror_adagents("meta") + + @pytest.mark.asyncio + async def test_rejects_malformed_success_response(self): + mock_client = MagicMock() + mock_client.get = AsyncMock(return_value=_mock_response(200, {"platform": "meta"})) + + rc = RegistryClient(client=mock_client) + with pytest.raises(RegistryError, match="invalid response"): + await rc.get_community_mirror_adagents("meta") + + @pytest.mark.asyncio + async def test_rejects_invalid_platform(self): + mock_client = MagicMock() + mock_client.get = AsyncMock(return_value=_mock_response(200, _mirror_get_response())) + + rc = RegistryClient(client=mock_client) + with pytest.raises(RegistryError, match="platform must match"): + await rc.get_community_mirror_adagents("bad platform!") + mock_client.get.assert_not_called() + + +class TestListCommunityMirrorAdagents: + """Test list_community_mirror_adagents (GET).""" + + @pytest.mark.asyncio + async def test_lists_without_pagination(self): + listed = { + "mirrors": [ + { + "platform": "meta", + "catalog_etag": "meta-creative-formats-2026-05", + "superseded_by": None, + "updated_at": "2026-06-05T12:00:00.000Z", + } + ], + "total": 1, + } + mock_client = MagicMock() + mock_client.get = AsyncMock(return_value=_mock_response(200, listed)) + + rc = RegistryClient(client=mock_client) + result = await rc.list_community_mirror_adagents() + + call = mock_client.get.call_args + assert call.args[0].endswith("/api/registry/mirrors") + assert call.kwargs["params"] is None + assert isinstance(result, CommunityMirrorListResponse) + assert result.total == 1 + assert result.mirrors[0].platform == "meta" + assert result.mirrors[0].catalog_etag == "meta-creative-formats-2026-05" + + @pytest.mark.asyncio + async def test_encodes_pagination(self): + mock_client = MagicMock() + mock_client.get = AsyncMock(return_value=_mock_response(200, {"mirrors": [], "total": 0})) + + rc = RegistryClient(client=mock_client) + await rc.list_community_mirror_adagents(limit=25, offset=50) + + call = mock_client.get.call_args + assert call.args[0].endswith("/api/registry/mirrors") + assert call.kwargs["params"] == {"limit": 25, "offset": 50} + + +class TestUpsertCommunityMirrorAdagents: + """Test upsert_community_mirror_adagents (platform inference + PUT).""" + + @pytest.mark.asyncio + async def test_upserts_with_explicit_platform_kwarg(self): + mock_client = MagicMock() + mock_client.request = AsyncMock(return_value=_mock_response(200, _PUBLISH_RESPONSE)) + + rc = RegistryClient(client=mock_client) + result = await rc.upsert_community_mirror_adagents( + dict(_MIRROR_CONFIG), platform="Meta", auth_token="sk_test" + ) + + assert isinstance(result, CommunityMirrorPublishResponse) + assert result.platform == "meta" + call = mock_client.request.call_args + assert call.args[0] == "PUT" + assert call.args[1].endswith("/api/registry/mirrors/meta") + assert call.kwargs["headers"]["Authorization"] == "Bearer sk_test" + assert "platform" not in call.kwargs["json"] + + @pytest.mark.asyncio + async def test_infers_platform_from_config(self): + mock_client = MagicMock() + mock_client.request = AsyncMock(return_value=_mock_response(200, _PUBLISH_RESPONSE)) + + rc = RegistryClient(client=mock_client) + await rc.upsert_community_mirror_adagents( + { + "platform": "Meta", + "catalog_etag": "meta-creative-formats-2026-05", + "formats": [_MIRROR_FORMAT], + }, + auth_token="sk_test", + ) + + assert mock_client.request.call_args.args[1].endswith("/api/registry/mirrors/meta") + + @pytest.mark.asyncio + async def test_infers_platform_from_single_property(self): + mock_client = MagicMock() + mock_client.request = AsyncMock(return_value=_mock_response(200, _PUBLISH_RESPONSE)) + + rc = RegistryClient(client=mock_client) + await rc.upsert_community_mirror_adagents( + { + "catalog_etag": "meta-creative-formats-2026-05", + "properties": [{"domain": "creative.adcontextprotocol.org", "platform": "Meta"}], + "formats": [_MIRROR_FORMAT], + }, + auth_token="sk_test", + ) + + assert mock_client.request.call_args.args[1].endswith("/api/registry/mirrors/meta") + + @pytest.mark.asyncio + async def test_requires_platform_identity(self): + mock_client = MagicMock() + mock_client.request = AsyncMock(return_value=_mock_response(200, _PUBLISH_RESPONSE)) + + rc = RegistryClient(client=mock_client) + with pytest.raises( + RegistryError, match="platform is required for community mirror publish" + ): + await rc.upsert_community_mirror_adagents(dict(_MIRROR_CONFIG), auth_token="sk_test") + mock_client.request.assert_not_called() + + @pytest.mark.asyncio + async def test_rejects_ambiguous_property_platforms(self): + mock_client = MagicMock() + mock_client.request = AsyncMock(return_value=_mock_response(200, _PUBLISH_RESPONSE)) + + rc = RegistryClient(client=mock_client) + with pytest.raises(RegistryError, match="platform is ambiguous"): + await rc.upsert_community_mirror_adagents( + { + "catalog_etag": "meta-creative-formats-2026-05", + "properties": [ + {"domain": "creative.adcontextprotocol.org", "platform": "meta"}, + {"domain": "creative.adcontextprotocol.org", "platform": "tiktok"}, + ], + "formats": [_MIRROR_FORMAT], + }, + auth_token="sk_test", + ) + mock_client.request.assert_not_called() + + +class TestDeleteCommunityMirrorAdagents: + """Test delete_community_mirror_adagents (DELETE, authenticated).""" + + @pytest.mark.asyncio + async def test_deletes_with_auth(self): + mock_client = MagicMock() + mock_client.request = AsyncMock(return_value=_mock_response(200, _DELETE_RESPONSE)) + + rc = RegistryClient(client=mock_client) + result = await rc.delete_community_mirror_adagents("Meta", auth_token="sk_test") + + assert isinstance(result, CommunityMirrorDeleteResponse) + assert result.success.value is True + assert result.platform == "meta" + call = mock_client.request.call_args + assert call.args[0] == "DELETE" + assert call.args[1].endswith("/api/registry/mirrors/meta") + assert call.kwargs["headers"]["Authorization"] == "Bearer sk_test" + # No force param unless requested. + assert call.kwargs["params"] is None + + @pytest.mark.asyncio + async def test_passes_force_param(self): + mock_client = MagicMock() + mock_client.request = AsyncMock(return_value=_mock_response(200, _DELETE_RESPONSE)) + + rc = RegistryClient(client=mock_client) + await rc.delete_community_mirror_adagents("meta", force=True, auth_token="sk_test") + + call = mock_client.request.call_args + assert call.kwargs["params"] == {"force": "true"} + + @pytest.mark.asyncio + async def test_maps_409_not_superseded_to_registry_error(self): + mock_client = MagicMock() + mock_client.request = AsyncMock( + return_value=_mock_response(409, {"error": "Mirror has not been superseded"}) + ) + + rc = RegistryClient(client=mock_client) + with pytest.raises(RegistryError) as exc_info: + await rc.delete_community_mirror_adagents("meta", auth_token="sk_test") + assert exc_info.value.status_code == 409 + + @pytest.mark.asyncio + async def test_rejects_invalid_platform(self): + mock_client = MagicMock() + mock_client.request = AsyncMock(return_value=_mock_response(200, _DELETE_RESPONSE)) + + rc = RegistryClient(client=mock_client) + with pytest.raises(RegistryError, match="platform must match"): + await rc.delete_community_mirror_adagents("bad platform!", auth_token="sk_test") + mock_client.request.assert_not_called() diff --git a/tests/test_registry_types_drift.py b/tests/test_registry_types_drift.py index 28e53f04..d8682751 100644 --- a/tests/test_registry_types_drift.py +++ b/tests/test_registry_types_drift.py @@ -178,6 +178,10 @@ def test_all_openapi_endpoints_have_client_methods(self): "updateMemberAgent": "update_member_agent", "removeMemberAgent": "remove_member_agent", "createOrganization": "create_organization", + "listCommunityMirrors": "list_community_mirror_adagents", + "getCommunityMirror": "get_community_mirror_adagents", + "publishCommunityMirror": "publish_community_mirror_adagents", + "deleteCommunityMirror": "delete_community_mirror_adagents", } missing = []