From 96e919e9e49eb9fd68c106c4a0e88a4045847304 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Tue, 19 May 2026 22:18:53 -0700 Subject: [PATCH] feat(aao): define directory inverse-lookup endpoint spec (#4823) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /v1/agents/{agent_url}/publishers returns the set of publishers whose adagents.json authorizes a given agent. Solves "what publishers have authorized my agent?" at the directory layer, instead of forcing every operator to either maintain the publisher list manually or crawl the open web themselves. This changeset defines the spec only — server implementation tracked separately. The endpoint shape, response envelope, discovery_method enum, per-publisher scoped counts, lifecycle status, and HTTP semantics are documented. - New schema at static/schemas/source/aao/agent-publishers.json defines the response envelope (agent_url, directory_indexed_at, publishers[], next_cursor) and the PublisherEntry shape with discovery_method, manager_domain, properties_authorized, properties_total, signing_keys_pinned, status, last_verified_at. - New docs page at docs/aao/directory-api.mdx walks through endpoint shape, fields, HTTP semantics, pagination, and the recommended workflow for chaining against verify_agent_authorization. - Lifecycle status enum scoped to authorized + revoked for v1. unbound/pending deferred — directory does not have crawler state to emit them honestly. - Counts are per-publisher scoped, never network-wide — avoids the "12/12 full auth vs 12-of-6800-network" misread. - properties_total on managed-network-shape parent files depends on adcp#4825 inline resolution rule. Resolves #4823. --- ...3-aao-directory-inverse-lookup-endpoint.md | 29 +++ docs.json | 3 +- docs/aao/directory-api.mdx | 175 ++++++++++++++++++ .../schemas/source/aao/agent-publishers.json | 113 +++++++++++ 4 files changed, 319 insertions(+), 1 deletion(-) create mode 100644 .changeset/4823-aao-directory-inverse-lookup-endpoint.md create mode 100644 docs/aao/directory-api.mdx create mode 100644 static/schemas/source/aao/agent-publishers.json 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 + } + } +}