diff --git a/.changeset/4823-aao-directory-inverse-lookup-endpoint.md b/.changeset/4823-aao-directory-inverse-lookup-endpoint.md new file mode 100644 index 0000000000..592a14abaa --- /dev/null +++ b/.changeset/4823-aao-directory-inverse-lookup-endpoint.md @@ -0,0 +1,29 @@ +--- +--- + +feat(aao): define directory inverse-lookup endpoint — `GET /v1/agents/{agent_url}/publishers` returns the set of publishers whose adagents.json authorizes a given agent_url, with provenance, per-publisher property counts, and lifecycle status. + +**Why.** The push primitive (`verify_agent_authorization`) and the pull-with-list primitive (`fetch_agent_authorizations`) both presuppose the operator already knows the publisher set. At managed-network scale (Raptive: ~6,800 domains delegated to cafemedia.com) that presupposition fails. The AAO directory at `agenticadvertising.org` already indexes publisher adagents.json files — this RFC exposes the inverse map as a public HTTP endpoint so sales-agent operators can answer "what publishers have authorized my agent?" at sync time, without crawling the open web themselves. + +**What this changeset defines** (spec-only — server implementation tracked separately, see Follow-ups). + +1. **Endpoint shape** (`docs/aao/directory-api.mdx`). `GET /v1/agents/{agent_url}/publishers` with `since`, `cursor`, `status`, `limit` query parameters. Canonicalizes the `agent_url` lookup key the same way `verify_agent_authorization` does. Cursor-based pagination; opaque cursors. + +2. **Response envelope schema** (`static/schemas/source/aao/agent-publishers.json`). Returns `{ agent_url, directory_indexed_at, publishers[], next_cursor }`. Each `PublisherEntry` carries `publisher_domain`, `discovery_method`, optional `manager_domain`, `properties_authorized`, `properties_total`, optional `signing_keys_pinned`, `status`, `last_verified_at`. + +3. **`discovery_method` enum** — `direct`, `authoritative_location`, `adagents_authoritative`, `ads_txt_managerdomain`. Distinguishes the four trust profiles. The directory verifies the [`managerdomain` fallback safety rule](/docs/governance/property/adagents#safety-rules-for-this-fallback) before returning a row with `ads_txt_managerdomain` — bilateral verification done once, for everyone. + +4. **Per-publisher scoped counts.** `properties_authorized` and `properties_total` are scoped to the row's `publisher_domain` only, never network-wide. Avoids the "12/12 = full auth but really 12-of-6800-network" misread that a flat count would produce on managed-network rows. + +5. **Lifecycle status enum** (v1) — `authorized` and `revoked`. `unbound`, `pending`, `unreachable`, `no_properties` deferred — directory does not have the crawler state to emit them honestly. `revoked` propagates parent-file `revoked_publisher_domains[]` (from the managed-network-scale changeset) on the next sync, then drops the tombstone. + +6. **HTTP semantics** — `200` (success, MAY be empty), `400` (malformed), `404` (never indexed; distinct from 200 + empty), `429` (rate-limited), `5xx` (retry). `ETag` + `Cache-Control` set; `If-None-Match` for conditional GET. + +7. **Authentication** — none in v1. Publishers are public; the inverse map is public. Rate-limiting keyed on `agent_url` + IP. Identity-bound limits via RFC 9421 request signing arrive in a separate RFC if needed. + +**Dependency.** `properties_total` on managed-network-shape parent files (the cafemedia case) depends on the [adcp#4825](https://github.com/adcontextprotocol/adcp/issues/4825) inline resolution rule. Strict federation at managed-network scale requires N HTTP fetches per directory refresh per publisher — the same scale problem operators have, moved one layer up. With inline resolution endorsed, the directory computes per-publisher counts from the parent file's inline `properties[]` filtered by matching `publisher_domain`. + +**Follow-ups.** +- Server implementation in `server/src/routes/registry-api.ts` (new endpoint), backed by `server/src/db/federated-index-db.ts` (extends existing `getDomainsForAgent` shape with `discovery_method`, `manager_domain`, count resolution, `signing_keys_pinned`). Tracked: `feat(server): implement /v1/agents/{agent_url}/publishers endpoint` — to be filed. +- SDK companion: `fetch_agent_authorizations_from_directory(agent_url, directory_url)` in adcp-client-python (adcp-client-python#746) and TS/Go/Java mirrors. +- `?include=properties` for inline property detail — out of scope for v1; add later if operators ask. diff --git a/docs.json b/docs.json index de3cc5ec29..996d0f809c 100644 --- a/docs.json +++ b/docs.json @@ -1122,7 +1122,8 @@ "docs/aao/connect-addie", "docs/aao/users", "docs/aao/org-admins", - "docs/aao/addie-tools" + "docs/aao/addie-tools", + "docs/aao/directory-api" ] }, { diff --git a/docs/aao/directory-api.mdx b/docs/aao/directory-api.mdx new file mode 100644 index 0000000000..d294a118d7 --- /dev/null +++ b/docs/aao/directory-api.mdx @@ -0,0 +1,175 @@ +--- +title: AAO Directory API — Agent ↔ Publisher Inverse Lookup +description: HTTP API for sales-agent operators to discover which publishers authorize their agent, sourced from the AAO directory's index of publisher adagents.json files. Returns provenance, per-publisher property counts, and lifecycle status. +"og:title": "AdCP — AAO Directory Inverse Lookup API" +--- + +# AAO Directory API + +The AAO directory at `agenticadvertising.org` indexes publisher `adagents.json` files across the open web. This API surfaces the **inverse map** every sales-agent operator needs at sync time: + +> "What publishers have authorized my agent?" + +Without this endpoint, operators have to either maintain the publisher domain list manually and call `fetch_agent_authorizations` against it, or crawl the open web themselves. Both are infeasible at managed-network scale ([cafemedia](https://cafemedia.com/.well-known/adagents.json) alone delegates ~6,800 publisher domains under a single manager file). + +This endpoint is **discovery**, not **authorization**. The publisher's own `adagents.json` remains the trust root. The directory tells you which publishers to verify directly via the SDK's per-domain primitives (`verify_agent_authorization`, `fetch_agent_authorizations`). + +## Endpoint + +``` +GET https://{aao_directory}/v1/agents/{agent_url}/publishers +``` + +`{agent_url}` MUST be percent-encoded. The directory canonicalizes the lookup key (lowercase host, default port stripped, trailing slash on path component normalized) using the same convention the SDK applies in `verify_agent_authorization`. + +### Query parameters + +| Parameter | Type | Default | Semantics | +|---|---|---|---| +| `since` | ISO 8601 | unset | Return only publishers whose `last_verified_at` ≥ `since`. Enables incremental sync. | +| `cursor` | opaque string | unset | Pagination cursor returned by a prior response. Stable across the directory's refresh cycle for the lifetime of the cursor. | +| `status` | comma-separated | `authorized` | Filter by lifecycle status. v1: `authorized`, `revoked`. | +| `limit` | int (1–1000) | 200 | Max publishers per page. | + +### Response + +```json +{ + "agent_url": "https://sales-agent.example.com", + "directory_indexed_at": "2026-05-19T12:00:00Z", + "publishers": [ + { + "publisher_domain": "recipeswithessentialoils.com", + "discovery_method": "ads_txt_managerdomain", + "manager_domain": "cafemedia.com", + "properties_authorized": 1, + "properties_total": 1, + "signing_keys_pinned": false, + "status": "authorized", + "last_verified_at": "2026-05-19T08:00:00Z" + }, + { + "publisher_domain": "wsj.com", + "discovery_method": "direct", + "manager_domain": null, + "properties_authorized": 47, + "properties_total": 200, + "signing_keys_pinned": true, + "status": "authorized", + "last_verified_at": "2026-05-19T10:00:00Z" + }, + { + "publisher_domain": "former-partner.example", + "discovery_method": "authoritative_location", + "manager_domain": "cafemedia.com", + "properties_authorized": 0, + "properties_total": 0, + "signing_keys_pinned": false, + "status": "revoked", + "last_verified_at": "2026-05-19T11:00:00Z" + } + ], + "next_cursor": "eyJv..." +} +``` + +## Field reference + +### Envelope + +| Field | Required | Notes | +|---|---|---| +| `agent_url` | yes | Canonicalized echo of the lookup key. | +| `directory_indexed_at` | yes | Most recent per-publisher refresh in the result set. Provenance for the consumer's own cache. | +| `publishers` | yes | Array. Empty array is a valid response — the directory has indexed this agent but no current authorizations resolve. | +| `next_cursor` | optional | Opaque pagination cursor; absent or null on the terminal page. | + +### `PublisherEntry` + +| Field | Required | Notes | +|---|---|---| +| `publisher_domain` | yes | Publisher whose `adagents.json` authorizes the agent. | +| `discovery_method` | yes | `direct`, `authoritative_location`, `adagents_authoritative`, or `ads_txt_managerdomain`. See below. | +| `manager_domain` | conditional | Required when `discovery_method` ≠ `direct`. Null otherwise. | +| `properties_authorized` | yes | Count of properties under **this publisher_domain only** that the agent's selectors resolve to. Never a network-wide count. | +| `properties_total` | yes | Count of properties under **this publisher_domain only** in the publisher's file (or parent file's inline subset for that domain). Never a network-wide count. | +| `signing_keys_pinned` | optional | Whether the publisher pins `signing_keys[]` on this agent. When `true`, agent's signed responses MUST verify against the pinned set regardless of the agent's own JWKS. | +| `status` | yes | `authorized` or `revoked`. See below. | +| `last_verified_at` | yes | When the directory last fetched and validated this publisher's `adagents.json`. | + +### `discovery_method` values + +| Value | Meaning | Trust profile | +|---|---|---| +| `direct` | Agent listed in the publisher's own `/.well-known/adagents.json`. | Strongest — no delegation hops. | +| `authoritative_location` | Publisher's `/.well-known/adagents.json` declared `authoritative_location` pointing to a manager file that lists the agent. | Strong — publisher actively delegated. | +| `adagents_authoritative` | Discovered via the manager file's own `properties[]` carrying the publisher's domain (per [adcp#4825 inline resolution rule](https://github.com/adcontextprotocol/adcp/issues/4825)). | Medium — publisher named in manager file but didn't host the delegation themselves. | +| `ads_txt_managerdomain` | Discovered via the publisher's `ads.txt` `MANAGERDOMAIN=` directive pointing to the manager file. | Weakest — the [`managerdomain` fallback safety rule](/docs/governance/property/adagents#safety-rules-for-this-fallback) is the only positive cross-check. | + +The directory verifies the `managerdomain` safety rule before returning a row with `discovery_method: ads_txt_managerdomain` — this is the directory's main value-add over a per-operator `ads.txt` crawl. + +### `status` values + +| Value | Meaning | +|---|---| +| `authorized` | Selector resolves to ≥ 1 property under this publisher_domain. The normal case. | +| `revoked` | Publisher previously authorized the agent and now lists this `publisher_domain` in `revoked_publisher_domains[]` of an authoritative file. Emitted as a tombstone on the first sync after revocation lands, then dropped. Lets operators propagate revocations without polling each publisher's cache TTL. | + +`unbound`, `pending`, `unreachable`, and `no_properties` are **intentionally not part of v1**. The directory only indexes publishers whose `adagents.json` was successfully fetched and references the agent. If a publisher disappears, the directory drops it from results rather than returning a tombstone (consumers track membership via set-diff against prior pages). + +## HTTP semantics + +| Status | Meaning | +|---|---| +| `200 OK` | Lookup succeeded. Body MAY have empty `publishers[]`. | +| `400 Bad Request` | Malformed `agent_url`, invalid cursor, or unknown `status` value. | +| `404 Not Found` | Directory has never indexed any publisher referencing this `agent_url`. **Distinct from `200` + empty** (which means the directory has indexed this agent, but no current authorizations resolve). | +| `429 Too Many Requests` | Rate limit. `Retry-After` header set. Bucket key: `agent_url` (anonymous) plus IP (defense-in-depth). | +| `5xx` | Directory error. Consumer SHOULD retry with backoff. | + +The endpoint sets `Cache-Control` and `ETag`. Conditional `GET` (`If-None-Match`) is the wire-level cache mechanism; `directory_indexed_at` in the body is the freshness anchor for consumer logic. + +## Authentication + +V1 is unauthenticated. Publisher `adagents.json` files are public; the inverse map is public. If rate-limiting graduates from IP-based to identity-based, the path is a separate RFC layering [RFC 9421](https://datatracker.ietf.org/doc/html/rfc9421) request signing keyed off the agent's published JWKS — the agent proves it controls `agent_url` by signing the request. Out of scope for this RFC. + +## Pagination + +Cursors are opaque. Treat them as substitutable strings and pass them back verbatim. The directory MAY change cursor format without notice; consumers MUST NOT parse cursor contents. + +A cursor remains valid for at least one directory refresh cycle. Past that, the directory MAY return `400` with `cursor_expired` or `200` with re-traversal from the start — both are conforming. Consumers SHOULD record the wall-clock time of the prior request and refuse to use cursors older than 24 hours. + +## Relationship to other primitives + +The AAO directory complements the existing SDK primitives: + +| Question | Primitive | Direction | +|---|---|---| +| Is *this* agent listed in *this* publisher's `adagents.json`? | `verify_agent_authorization(adagents_data, agent_url)` | Push (publisher → agent) | +| Given a list of publishers, which authorize my agent? | `fetch_agent_authorizations(agent_url, publisher_domains)` | Pull, caller-supplied list | +| **Which publishers authorize my agent?** | **`GET /v1/agents/{agent_url}/publishers`** | **Pull, directory-supplied list** | + +The first two answer questions where the operator already knows the publisher set. The directory endpoint answers the operator's actual sync-time question: "what's my publisher set?" + +The recommended workflow: + +1. Call `GET /v1/agents/{agent_url}/publishers` to discover the publisher set. +2. For each `publisher_domain` in the response, the operator MAY call `verify_agent_authorization` against the publisher's own `adagents.json` to re-confirm against the trust root. The directory's `last_verified_at` reduces but does not eliminate the need for per-domain verification on critical paths. +3. Use the response's `properties_authorized` / `properties_total` for operator-facing scope summaries, and the `signing_keys_pinned` flag to surface which agents must publish a JWKS matching the publisher's pin. + +## Relationship to `publisher_properties` inline resolution + +On managed-network-shape parent files (per [adcp#4825 inline resolution rule](/docs/governance/property/adagents#resolution-paths)), the directory computes `properties_total` from the parent file's inline `properties[]` filtered by `publisher_domain`. Strict federation at this scale would require N HTTP fetches per directory refresh per publisher — the same scale problem operators have, moved one layer up. The directory uses the inline resolution rule the spec endorses. + +## Out of scope (v1) + +- **Authentication.** Public endpoint, anonymous rate limiting. Identity-bound limits arrive in a separate RFC if needed. +- **Cross-directory federation.** Single directory. The endpoint shape is defined such that multiple AAO-compatible directories could implement it; discovery of which directory to query is configuration today. +- **Push notification of new authorizations.** Poll-based v1. +- **Inline property detail.** `properties_authorized` / `properties_total` are counts only; full property lists arrive via a future `?include=properties` if operators ask. + +## See also + +- [adagents.json Tech Spec](/docs/governance/property/adagents) — the trust root. +- [Managed Network Deployment](/docs/governance/property/managed-networks) — the canonical multi-publisher pattern this endpoint indexes. +- [adcp#4825](https://github.com/adcontextprotocol/adcp/issues/4825) — `publisher_properties` inline resolution rule the directory's count fields depend on. diff --git a/static/schemas/source/aao/agent-publishers.json b/static/schemas/source/aao/agent-publishers.json new file mode 100644 index 0000000000..958a9d3373 --- /dev/null +++ b/static/schemas/source/aao/agent-publishers.json @@ -0,0 +1,113 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/aao/agent-publishers.json", + "title": "AAO Directory: Agent → Publishers Inverse Lookup Response", + "description": "Response envelope for `GET /v1/agents/{agent_url}/publishers` against an AAO-compatible directory. Returns the set of publishers whose adagents.json authorizes the given agent_url, with provenance (discovery_method, manager_domain), per-publisher property counts, and lifecycle status. The publisher's own adagents.json remains the trust root; this endpoint is discovery, not authorization. Each `publisher_domain` row tells the operator which publishers to verify directly via the SDK's per-domain primitives.", + "type": "object", + "required": [ + "agent_url", + "directory_indexed_at", + "publishers" + ], + "properties": { + "agent_url": { + "type": "string", + "format": "uri", + "description": "Canonicalized echo of the agent_url that was looked up. Lowercase host, default port stripped, trailing slash on path component normalized — matches the canonicalization the SDK applies in `verify_agent_authorization`." + }, + "directory_indexed_at": { + "type": "string", + "format": "date-time", + "description": "When the directory last completed a refresh of any publisher in this result. Provenance anchor for the consumer's own cache." + }, + "publishers": { + "type": "array", + "description": "Publishers whose adagents.json authorizes this agent. Empty array is a valid response (the directory has indexed this agent but no current authorizations resolve).", + "items": { + "$ref": "#/definitions/PublisherEntry" + } + }, + "next_cursor": { + "type": ["string", "null"], + "description": "Opaque pagination cursor. Absent or null on the terminal page. Stable across the directory's refresh cycle for the lifetime of the cursor." + } + }, + "additionalProperties": false, + "definitions": { + "PublisherEntry": { + "type": "object", + "required": [ + "publisher_domain", + "discovery_method", + "properties_authorized", + "properties_total", + "status", + "last_verified_at" + ], + "properties": { + "publisher_domain": { + "type": "string", + "description": "Publisher whose adagents.json authorizes the agent.", + "pattern": "^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$" + }, + "discovery_method": { + "type": "string", + "description": "How the directory discovered this authorization. `direct`: agent listed in publisher's own /.well-known/adagents.json. `authoritative_location`: publisher's /.well-known/adagents.json declared `authoritative_location` pointing to a manager file that lists the agent. `adagents_authoritative`: discovered via the manager file (publisher-declared in the manager's own properties[]). `ads_txt_managerdomain`: discovered via the publisher's ads.txt `MANAGERDOMAIN=` directive pointing to the manager file. The last three paths converge on the same manager file but have different trust profiles — the `managerdomain` path is the weakest because the manager file's `publisher_domain` anchor (the `managerdomain` fallback safety rule) is the only positive cross-check.", + "enum": [ + "direct", + "authoritative_location", + "adagents_authoritative", + "ads_txt_managerdomain" + ] + }, + "manager_domain": { + "type": ["string", "null"], + "description": "Domain of the manager file that authorizes this publisher → agent edge. Required when `discovery_method` is `authoritative_location`, `adagents_authoritative`, or `ads_txt_managerdomain`. Null or absent when `discovery_method` is `direct`.", + "pattern": "^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$" + }, + "properties_authorized": { + "type": "integer", + "minimum": 0, + "description": "Count of properties under THIS `publisher_domain` only that the agent's selectors resolve to. Never a network-wide count. The directory computes this by applying the publisher's `adagents.json` selector predicates against the publisher's own properties (federated) OR against the parent file's inline properties carrying matching `publisher_domain` (inline, per adcp#4825 resolution rule)." + }, + "properties_total": { + "type": "integer", + "minimum": 0, + "description": "Count of properties under THIS `publisher_domain` only — total inventory the publisher's file declares. Never a network-wide count. On managed-network-shape parent files (per adcp#4825 inline resolution), this is the count of inline `properties[]` entries whose `publisher_domain` field matches this row's domain." + }, + "signing_keys_pinned": { + "type": "boolean", + "description": "Whether the publisher's adagents.json entry for this agent pins `signing_keys[]`. When true, the agent's signed responses MUST verify against the pinned key set regardless of the agent's own JWKS. Operators should treat true as a signal that their published JWKS must match the publisher's pin." + }, + "status": { + "type": "string", + "description": "Lifecycle state for this publisher → agent edge. v1: `authorized` (selector resolves to ≥ 1 property under this publisher) and `revoked` (this publisher_domain newly appears in a parent file's `revoked_publisher_domains[]`; emitted as a tombstone on the next sync after revocation lands, then dropped). Future states (`unbound`, `pending`) deferred — the directory does not have the crawler state to emit them honestly.", + "enum": ["authorized", "revoked"] + }, + "last_verified_at": { + "type": "string", + "format": "date-time", + "description": "When the directory last fetched and validated this publisher's adagents.json. Distinct from the envelope's `directory_indexed_at` — `last_verified_at` is per-publisher freshness, the envelope value is the most recent refresh in the result set." + } + }, + "allOf": [ + { + "if": { + "properties": { + "discovery_method": { + "enum": ["authoritative_location", "adagents_authoritative", "ads_txt_managerdomain"] + } + } + }, + "then": { + "required": ["manager_domain"], + "properties": { + "manager_domain": { "type": "string" } + } + } + } + ], + "additionalProperties": false + } + } +}