diff --git a/adr/0010-direct-catalog-entry-lookup.md b/adr/0010-direct-catalog-entry-lookup.md new file mode 100644 index 0000000..07f62d7 --- /dev/null +++ b/adr/0010-direct-catalog-entry-lookup.md @@ -0,0 +1,27 @@ +# ADR-0010: Direct Catalog Entry Lookup by Identifier + +## Status + +Proposed + +## Context + +ARD identifiers are stable, globally unique primary keys, but the Registry API previously offered only semantic Search and optional deterministic List operations. Search requires natural-language input and adds ranking metadata. List returns a paginated collection and does not define canonical exact-identifier semantics. Neither operation lets a client efficiently resolve an identifier retained from earlier discovery. + +The lookup operation also needs a clear federation boundary. Automatically forwarding a primary-key lookup would make latency and authority unpredictable, while returning a Search result would expose fields that are not part of the canonical Catalog Entry Object. + +## Decision + +Agent Registries expose mandatory `GET /agents/{identifier}` lookup. + +The complete identifier is UTF-8 percent-encoded as one path segment. A registry decodes it exactly once, validates the decoded ARD identifier, and performs an exact, case-sensitive lookup against its local index. The registry does not query upstream registries for this operation. Entries ingested from external publishers remain eligible because they are part of the local index. + +A successful lookup returns the canonical Catalog Entry Object without Search-only fields. Malformed identifiers return `400 INVALID_ARGUMENT`; valid identifiers absent from the local index return `404 NOT_FOUND`. The endpoint follows the deployment's existing Registry authentication policy and standard HTTP caching semantics. + +## Consequences + +- Clients can resolve a retained ARD identifier in one deterministic request. +- OpenAPI clients receive a typed identifier parameter and Catalog Entry response. +- Registries need an indexed exact-match route in addition to semantic Search. +- Clients retaining federated Search results should retain `source` with the identifier and use it to resolve the entry at its originating registry. +- Conformance testing verifies encoded success, malformed identifiers, and unknown identifiers. diff --git a/conformance/README.md b/conformance/README.md index c2bb017..d14f6e0 100644 --- a/conformance/README.md +++ b/conformance/README.md @@ -53,7 +53,7 @@ Probes and validates a live running Agent Registry REST API server. ./bin/conformance-test registry http://localhost:9010/api ``` -For private or self-hosted registries that require request headers, pass one or more `--header` options. Headers are applied to all Registry API probes (`GET /agents`, `POST /search`, and `POST /explore`): +For private or self-hosted registries that require request headers, pass one or more `--header` options. Headers are applied to all Registry API probes (`GET /agents`, `GET /agents/{identifier}`, `POST /search`, and `POST /explore`): ```bash ARD_REGISTRY_TOKEN=... @@ -63,6 +63,13 @@ ARD_REGISTRY_TOKEN=... This is a conformance tooling option only; it does not require authentication for public registries or define a Registry API security model. +The tester normally obtains an identifier for the successful exact-lookup probe from List or Search. If neither returns an entry, provide a known locally indexed identifier explicitly: + +```bash +./bin/conformance-test registry https://registry.example.com/api/ard \ + --lookup-identifier "urn:air:example.com:agent:assistant" +``` + --- ## 🔍 What It Validates @@ -89,6 +96,11 @@ When checking a live Agent Registry server, the tool executes the following prob * Contacts the GET endpoint to check if the registry supports deterministic browsing. * If supported (returns `200 OK`), it validates that the response contains the paginated `"items"` structure. * If not supported (returns `404` or `501`), it marks this as compliant since deterministic listing is optional. +* **GET `/agents/{identifier}` (Mandatory Exact Lookup Probe)**: + * URL-encodes a known full URN as one path segment and verifies a `200 OK` canonical `CatalogEntry` response with the exact identifier. + * Verifies that Search-only fields such as `score` and `source` are absent. + * Verifies that an unknown valid URN returns the standard `404` / `NOT_FOUND` error envelope. + * Verifies that a malformed identifier returns the standard `400` / `INVALID_ARGUMENT` error envelope. * **POST `/search` (Mandated Search Probe)**: * Probes the search route which is required for dynamic semantic capability discovery. * Sends a mock natural-language query payload with required `query` string and optional `filter` / `limit` parameters. diff --git a/conformance/bin/conformance-test b/conformance/bin/conformance-test index be3513a..bc6ca90 100755 --- a/conformance/bin/conformance-test +++ b/conformance/bin/conformance-test @@ -10,6 +10,7 @@ import re import json import urllib.request import urllib.error +import urllib.parse # Strict URN Regex matching urn:air::: URN_REGEX = re.compile(r"^urn:air:([a-zA-Z0-9.-]+)(?::([a-zA-Z0-9._:-]+))?:([a-zA-Z0-9._-]+)$") @@ -75,6 +76,59 @@ class ConformanceTester: self.warnings.append(msg) print_warning(msg) + def validate_catalog_entry(self, entry, label): + if not isinstance(entry, dict): + self.add_error(f"[{label}] Catalog entry must be a JSON object.") + return False + + valid = True + identifier = entry.get("identifier") + if not identifier: + self.add_error(f"[{label}] Catalog entry is missing 'identifier'.") + valid = False + elif not URN_REGEX.match(identifier): + self.add_error(f"[{label}] Catalog entry identifier '{identifier}' is not a valid ARD URN.") + valid = False + + if not entry.get("displayName"): + self.add_error(f"[{label}] Catalog entry is missing 'displayName'.") + valid = False + if not entry.get("type"): + self.add_error(f"[{label}] Catalog entry is missing 'type'.") + valid = False + + has_url = "url" in entry + has_data = "data" in entry + if has_url == has_data: + self.add_error(f"[{label}] Catalog entry must contain exactly one of 'url' or 'data'.") + valid = False + + return valid + + def validate_http_error(self, error, expected_status, expected_code, label): + valid = True + if error.code != expected_status: + self.add_error(f"{label} returned {error.code}; expected {expected_status}.") + valid = False + + try: + data = json.loads(error.read().decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + self.add_error(f"{label} did not return a valid JSON error envelope: {exc}") + return False + + if not isinstance(data, dict): + self.add_error(f"{label} error response must be a JSON object.") + return False + if data.get("errorCode") != expected_code: + self.add_error(f"{label} returned errorCode '{data.get('errorCode')}'; expected '{expected_code}'.") + valid = False + if not data.get("message"): + self.add_error(f"{label} error response is missing 'message'.") + valid = False + + return valid + def run_json_schema_validation(self, manifest_data): if not self.jsonschema_available: print_warning("Python 'jsonschema' package not installed. Skipping strict JSON Schema check.") @@ -209,7 +263,7 @@ class ConformanceTester: return len(self.errors) == 0 - def validate_registry(self, registry_base_url, request_headers=None): + def validate_registry(self, registry_base_url, request_headers=None, lookup_identifier=None): print_header(f"Validating Agent Registry: {registry_base_url}") base_url = registry_base_url.rstrip('/') request_headers = request_headers or {} @@ -227,8 +281,20 @@ class ConformanceTester: # Check schema conformance if not isinstance(data, dict) or "items" not in data: self.add_error("GET /agents response is not a valid paginated object. Missing 'items' array.") + elif not isinstance(data["items"], list): + self.add_error("GET /agents response 'items' is not a JSON array.") else: - print_success(f"GET /agents response contains valid 'items' list ({len(data.get('items', []))} items).") + items = data["items"] + print_success(f"GET /agents response contains valid 'items' list ({len(items)} items).") + if lookup_identifier is None: + lookup_identifier = next( + ( + item.get("identifier") + for item in items + if isinstance(item, dict) and URN_REGEX.match(item.get("identifier", "")) + ), + None + ) else: self.add_warning(f"GET /agents returned unexpected status: {response.status}") except urllib.error.HTTPError as e: @@ -292,20 +358,9 @@ class ConformanceTester: if not source: self.add_error(f"[{label}] Missing required SearchResultItem field 'source'.") - ident = item.get("identifier") - if not ident: - self.add_error(f"[{label}] Nested catalog entry is missing 'identifier'.") - - disp_name = item.get("displayName") - if not disp_name: - self.add_error(f"[{label}] Nested catalog entry is missing 'displayName'.") - - mtype = item.get("type") - if not mtype: - self.add_error(f"[{label}] Nested catalog entry is missing 'type'.") - - if "url" not in item and "data" not in item: - self.add_error(f"[{label}] Nested catalog entry must contain either 'url' or 'data'.") + self.validate_catalog_entry(item, label) + if lookup_identifier is None and URN_REGEX.match(item.get("identifier", "")): + lookup_identifier = item["identifier"] else: self.add_error(f"POST /search returned unexpected HTTP status: {response.status}") except urllib.error.HTTPError as e: @@ -313,7 +368,81 @@ class ConformanceTester: except Exception as e: self.add_error(f"Failed to execute search probe: {e}") - # 3. Test POST /explore (Optional Introspection Endpoint) + # 3. Test GET /agents/{identifier} (Mandatory Exact Lookup Endpoint) + if lookup_identifier is not None: + encoded_identifier = urllib.parse.quote(lookup_identifier, safe="") + lookup_url = f"{base_url}/agents/{encoded_identifier}" + print(f"\n {COLOR_BOLD}Probing GET /agents/{{identifier}} with an encoded known URN...{COLOR_RESET}") + try: + req = urllib.request.Request(lookup_url, headers=request_headers, method="GET") + with urllib.request.urlopen(req, timeout=5) as response: + body = response.read().decode("utf-8") + data = json.loads(body) + if response.status != 200: + self.add_error(f"GET /agents/{{identifier}} returned unexpected HTTP status: {response.status}") + else: + valid_entry = self.validate_catalog_entry(data, "Lookup response") + if valid_entry: + if data["identifier"] != lookup_identifier: + self.add_error( + "GET /agents/{identifier} did not return the exact requested identifier. " + f"Expected '{lookup_identifier}', got '{data['identifier']}'." + ) + valid_entry = False + for search_only_field in ("score", "source"): + if search_only_field in data: + self.add_error( + f"GET /agents/{{identifier}} response contains Search-only field " + f"'{search_only_field}'." + ) + valid_entry = False + if valid_entry: + print_success("GET /agents/{identifier} returned the exact catalog entry for an encoded URN.") + except urllib.error.HTTPError as e: + self.add_error(f"GET /agents/{{identifier}} request failed: {e.code} {e.reason}") + except (UnicodeDecodeError, json.JSONDecodeError) as e: + self.add_error(f"GET /agents/{{identifier}} returned invalid JSON: {e}") + except Exception as e: + self.add_error(f"Failed to execute exact lookup probe: {e}") + else: + self.add_warning( + "Could not infer a known catalog identifier from List or Search; " + "skipping the successful lookup probe. Pass --lookup-identifier to run it." + ) + + unknown_identifier = "urn:air:ard-conformance.invalid:probe:not-found" + unknown_url = f"{base_url}/agents/{urllib.parse.quote(unknown_identifier, safe='')}" + print(f"\n {COLOR_BOLD}Probing GET /agents/{{identifier}} with an unknown URN...{COLOR_RESET}") + try: + req = urllib.request.Request(unknown_url, headers=request_headers, method="GET") + with urllib.request.urlopen(req, timeout=5) as response: + self.add_error( + "GET /agents/{identifier} returned " + f"{response.status} for an unknown identifier; expected 404." + ) + except urllib.error.HTTPError as e: + if self.validate_http_error(e, 404, "NOT_FOUND", "Unknown identifier lookup"): + print_success("Unknown identifier returned 404 NOT_FOUND.") + except Exception as e: + self.add_error(f"Failed to execute unknown identifier probe: {e}") + + malformed_identifier = "not-a-valid-ard-urn" + malformed_url = f"{base_url}/agents/{urllib.parse.quote(malformed_identifier, safe='')}" + print(f"\n {COLOR_BOLD}Probing GET /agents/{{identifier}} with a malformed identifier...{COLOR_RESET}") + try: + req = urllib.request.Request(malformed_url, headers=request_headers, method="GET") + with urllib.request.urlopen(req, timeout=5) as response: + self.add_error( + "GET /agents/{identifier} returned " + f"{response.status} for a malformed identifier; expected 400." + ) + except urllib.error.HTTPError as e: + if self.validate_http_error(e, 400, "INVALID_ARGUMENT", "Malformed identifier lookup"): + print_success("Malformed identifier returned 400 INVALID_ARGUMENT.") + except Exception as e: + self.add_error(f"Failed to execute malformed identifier probe: {e}") + + # 4. Test POST /explore (Optional Introspection Endpoint) explore_url = f"{base_url}/explore" print(f"\n {COLOR_BOLD}Probing POST /explore (Optional Introspection endpoint)...{COLOR_RESET}") @@ -375,7 +504,7 @@ def main(): print(f"{COLOR_BOLD}Agentic Resource Discovery Conformance CLI v0.5.0{COLOR_RESET}") print("Usage:") print(" conformance-test manifest ") - print(" conformance-test registry [--header 'Name: value']...") + print(" conformance-test registry [--header 'Name: value']... [--lookup-identifier ]") sys.exit(1) command = sys.argv[1].lower() @@ -410,16 +539,26 @@ def main(): sys.exit(1) elif command == "registry": header_args = [] + lookup_identifier = None idx = 0 while idx < len(args): arg = args[idx] - if arg != "--header": - print_failure(f"Unknown registry option '{arg}'. Supported option: --header 'Name: value'") + if arg not in ["--header", "--lookup-identifier"]: + print_failure( + f"Unknown registry option '{arg}'. Supported options: " + "--header 'Name: value', --lookup-identifier " + ) sys.exit(1) if idx + 1 >= len(args): - print_failure("--header requires a value in 'Name: value' format.") + print_failure(f"{arg} requires a value.") sys.exit(1) - header_args.append(args[idx + 1]) + if arg == "--header": + header_args.append(args[idx + 1]) + else: + if lookup_identifier is not None: + print_failure("--lookup-identifier may be specified only once.") + sys.exit(1) + lookup_identifier = args[idx + 1] idx += 2 try: @@ -428,7 +567,15 @@ def main(): print_failure(str(e)) sys.exit(1) - success = tester.validate_registry(target, request_headers=request_headers) + if lookup_identifier is not None and not URN_REGEX.match(lookup_identifier): + print_failure("--lookup-identifier must be a valid urn:air: identifier.") + sys.exit(1) + + success = tester.validate_registry( + target, + request_headers=request_headers, + lookup_identifier=lookup_identifier + ) else: print_failure(f"Unknown test suite command '{command}'. Must be 'manifest' or 'registry'.") sys.exit(1) diff --git a/conformance/bin/run-conformance-demo b/conformance/bin/run-conformance-demo index 8e0953e..2dda50a 100755 --- a/conformance/bin/run-conformance-demo +++ b/conformance/bin/run-conformance-demo @@ -52,7 +52,8 @@ sleep 1.5 # 3. Validate live running registry server echo -e "\n${BOLD}[Step 3/3] Probing and validating live example registry API...${RESET}" -"$PROJECT_ROOT/conformance/bin/conformance-test" registry "http://127.0.0.1:9010" +"$PROJECT_ROOT/conformance/bin/conformance-test" registry "http://127.0.0.1:9010" \ + --lookup-identifier "urn:air:acme.com:agent:assistant" REGISTRY_STATUS=$? if [ $REGISTRY_STATUS -eq 0 ]; then diff --git a/conformance/examples/basic/registry-server.py b/conformance/examples/basic/registry-server.py index 62d534e..b3795ed 100755 --- a/conformance/examples/basic/registry-server.py +++ b/conformance/examples/basic/registry-server.py @@ -1,18 +1,24 @@ #!/usr/bin/env python3 """ Mock Agent Registry REST API Server -Implements standard v0.4 Agentic Resource Discovery REST endpoints: +Implements standard Agentic Resource Discovery REST endpoints: - POST /search - GET /agents + - GET /agents/{identifier} Zero dependencies, uses Python standard library. """ +import hashlib import sys import json +import re from http.server import HTTPServer, BaseHTTPRequestHandler -from urllib.parse import urlparse, parse_qs +from socketserver import TCPServer +from urllib.parse import unquote, urlparse PORT = 9010 +URN_REGEX = re.compile(r"^urn:air:[a-zA-Z0-9.-]+(:[a-zA-Z0-9._-]+)+$") +INVALID_PERCENT_ENCODING = re.compile(r"%(?![0-9A-Fa-f]{2})") # Mock catalog database seeded from ./ai-catalog.json MOCK_CATALOG_ENTRIES = [ @@ -54,14 +60,24 @@ } ] +class MockHTTPServer(HTTPServer): + def server_bind(self): + # HTTPServer performs a reverse DNS lookup here, which can stall offline demos. + TCPServer.server_bind(self) + host, port = self.server_address[:2] + self.server_name = host + self.server_port = port + class MockRegistryHandler(BaseHTTPRequestHandler): def log_message(self, format, *args): # Print logs to stderr with a custom marker sys.stderr.write(f" [Mock Registry] {format%args}\n") - def _send_json(self, status, data): + def _send_json(self, status, data, headers=None): self.send_response(status) self.send_header("Content-Type", "application/json") + for name, value in (headers or {}).items(): + self.send_header(name, value) self.end_headers() self.wfile.write(json.dumps(data).encode('utf-8')) @@ -77,6 +93,54 @@ def do_GET(self): "pageToken": None } self._send_json(200, response) + elif parsed_path.path.startswith("/agents/"): + raw_identifier = parsed_path.path[len("/agents/"):] + try: + if not raw_identifier or INVALID_PERCENT_ENCODING.search(raw_identifier): + raise ValueError("invalid percent-encoding") + identifier = unquote(raw_identifier, encoding="utf-8", errors="strict") + except (UnicodeDecodeError, ValueError): + error_response = { + "errorCode": "INVALID_ARGUMENT", + "message": "The identifier path parameter is not valid UTF-8 percent-encoding." + } + self._send_json(400, error_response) + return + + if not URN_REGEX.match(identifier): + error_response = { + "errorCode": "INVALID_ARGUMENT", + "message": "The identifier path parameter is not a valid urn:air: identifier." + } + self._send_json(400, error_response) + return + + entry = next( + (item for item in MOCK_CATALOG_ENTRIES if item["identifier"] == identifier), + None + ) + if entry is None: + error_response = { + "errorCode": "NOT_FOUND", + "message": f"Catalog entry '{identifier}' was not found." + } + self._send_json(404, error_response, {"Cache-Control": "no-cache"}) + return + + canonical_entry = json.dumps(entry, sort_keys=True, separators=(",", ":")).encode("utf-8") + etag = f'"{hashlib.sha256(canonical_entry).hexdigest()}"' + cache_headers = { + "Cache-Control": "public, max-age=60", + "ETag": etag + } + if self.headers.get("If-None-Match") == etag: + self.send_response(304) + for name, value in cache_headers.items(): + self.send_header(name, value) + self.end_headers() + return + + self._send_json(200, entry, cache_headers) else: # Return 404 for other routes error_response = { @@ -195,7 +259,7 @@ def do_POST(self): def run_server(): server_address = ('127.0.0.1', PORT) - httpd = HTTPServer(server_address, MockRegistryHandler) + httpd = MockHTTPServer(server_address, MockRegistryHandler) print(f"🚀 [Mock Registry] Running server on http://127.0.0.1:{PORT}...") try: httpd.serve_forever() diff --git a/spec/ard.md b/spec/ard.md index 153a01c..68163e8 100644 --- a/spec/ard.md +++ b/spec/ard.md @@ -404,7 +404,7 @@ Agent Registry instances populate their indexes through ingestion pipelines: ## 7\. The ARD API -An Agent Registry **MUST** expose a standard HTTP REST search interface to guarantee universal federation. The operational base URL for these endpoints is discovered dynamically by identifying catalog entries within the static ai-catalog.json manifest that carry the application/ai-registry+json media type, as defined in §4.1. +An Agent Registry **MUST** expose the standard HTTP REST Search (§7.2) and Retrieve (§7.4) interfaces. Search guarantees universal discovery and federation; Retrieve provides deterministic resolution of a known identifier. The operational base URL for these endpoints is discovered dynamically by identifying catalog entries within the static ai-catalog.json manifest that carry the application/ai-registry+json media type, as defined in §4.1. ### 7.1 The Query Model @@ -578,7 +578,36 @@ Facets are computed over the full matched set, not a single page. For semantic t Explore does not federate; it is scoped to the registry queried. Federated discovery is the role of Search (§8). A registry that does not implement Explore returns a `501 Not Implemented` HTTP status code. -### 7.4 List (GET /agents) — Optional +### 7.4 Retrieve (GET /agents/{identifier}) + +Retrieves one catalog entry by its globally unique `identifier`. Every Agent Registry **MUST** implement this endpoint. Lookup is an exact, case-sensitive primary-key operation after URL decoding; it does not perform semantic matching or relevance ranking. + +The client **MUST** encode the complete identifier as one URL path segment using UTF-8 percent-encoding as defined by RFC 3986. For example: + +```http +GET /agents/urn%3Aair%3Aacme.com%3Aagent%3Aassistant +``` + +The registry **MUST** percent-decode the path parameter exactly once and then validate it against the identifier syntax in §4.2.1. Invalid percent-encoding or a decoded value that is not a valid ARD identifier returns `400 Bad Request` with error code `INVALID_ARGUMENT`. + +Lookup is scoped to entries in the queried registry's index, including entries that the registry has ingested from external publishers. The registry **MUST NOT** query upstream registries while processing this endpoint. Clients that retain an identifier from a federated Search result **SHOULD** also retain its `source` and send subsequent Retrieve requests to that registry. A matching entry is returned directly as a Catalog Entry Object (§4.2), without Search-only fields such as `score` or `source`. If the registry's index does not contain an exact match, it returns `404 Not Found` with error code `NOT_FOUND`. + +**Response Schema:** + +```json +{ + "identifier": "urn:air:acme.com:agent:assistant", + "displayName": "Corporate Assistant (A2A)", + "type": "application/a2a-agent-card+json", + "url": "https://api.acme.com/agents/assistant.json" +} +``` + +The endpoint uses the same deployment-defined authentication policy as the other Registry API endpoints. Missing or rejected credentials return `401 Unauthorized` with error code `UNAUTHENTICATED`. + +Successful responses are cacheable under standard HTTP caching semantics. Registries **SHOULD** provide explicit freshness information with `Cache-Control` and a validator such as `ETag` or `Last-Modified`; conditional requests and `304 Not Modified` responses follow the HTTP specifications. Responses to authenticated requests **MUST NOT** be stored in a shared cache unless the response explicitly permits it. To avoid stale negative lookups as registry contents change, `404 Not Found` responses **SHOULD** require revalidation or specify a short freshness lifetime. + +### 7.5 List (GET /agents) — Optional Deterministic browsing, designed for developer portals. Highly cacheable, relies on strict database filtering, and does not support relevance-based sorting. @@ -591,7 +620,7 @@ Deterministic browsing, designed for developer portals. Highly cacheable, relies | pageSize | Integer | Max results (default: 20, max: 100). | | pageToken | String | Pagination token. | -### 7.5 Protocol Wrappers (Optional) +### 7.6 Protocol Wrappers (Optional) While the REST API is mandated as the floor for interoperability, a Registry **MAY** additionally expose its search capability natively via an MCP Tool or an A2A Skill to preserve native orchestrator flows. @@ -730,7 +759,7 @@ npx ajv-cli validate -s spec/schemas/ai-catalog.schema.json -d path/to/ai-catalo ### D.3 The Registry REST API Specification (OpenAPI) -The HTTP query interfaces (`POST /search`, `POST /explore`, and `GET /agents`) exposed by compliant Agent Registries are formally defined using the **OpenAPI 3.1.0 Specification** in YAML. +The HTTP query interfaces (`POST /search`, `POST /explore`, `GET /agents/{identifier}`, and `GET /agents`) exposed by compliant Agent Registries are formally defined using the **OpenAPI 3.1.0 Specification** in YAML. * **Authoritative Specification File**: [`spec/schemas/ard.openapi.yaml`](schemas/ard.openapi.yaml) * **Key Integration Benefits**: @@ -746,7 +775,7 @@ To simplify development and guarantee complete compliance, this repository provi #### Features: * **Manifest validation mode**: Parses JSON manifests, runs strict JSON Schema checks (using the Python `jsonschema` library if installed), and executes custom semantic checks (e.g., URN formatting rules, Value-or-Reference enforcement, `representativeQueries` sizing). -* **Registry validation mode**: Probes live endpoints (`POST /search` and `GET /agents`), sends spec-compliant search requests, and validates status codes, pagination envelopes, search result scores, and catalog entry structures. +* **Registry validation mode**: Probes live endpoints (`POST /search`, `POST /explore`, `GET /agents/{identifier}`, and `GET /agents`), sends spec-compliant requests, and validates status codes, exact identifier lookup behavior, pagination envelopes, search result scores, and catalog entry structures. #### Usage Examples: @@ -770,7 +799,7 @@ To instantly run a complete end-to-end verification suite utilizing a pre-bundle ```bash ./conformance/bin/run-conformance-demo ``` -This script performs manifest schema validation, launches a mock registry server in the background, executes live search and listing queries against it using the conformance tester, and gracefully terminates the server when finished. +This script performs manifest schema validation, launches a mock registry server in the background, executes live search, lookup, exploration, and listing queries against it using the conformance tester, and gracefully terminates the server when finished. ## Acknowledgements diff --git a/spec/schemas/ard.cddl b/spec/schemas/ard.cddl index bfc7a06..5d6aec5 100644 --- a/spec/schemas/ard.cddl +++ b/spec/schemas/ard.cddl @@ -65,7 +65,7 @@ provenance-link = { ? sourceDigest: tstr } -; --- The Search and Explore Registry API Payloads --- +; --- The Registry API Payloads --- query-model = { ? text: tstr, @@ -137,6 +137,8 @@ list-response = { ? pageToken: tstr } +lookup-response = catalog-entry + error-response = { errorCode: tstr, message: tstr diff --git a/spec/schemas/ard.openapi.yaml b/spec/schemas/ard.openapi.yaml index cda215a..363bfe4 100644 --- a/spec/schemas/ard.openapi.yaml +++ b/spec/schemas/ard.openapi.yaml @@ -3,9 +3,9 @@ info: title: Agentic Resource Discovery Registry API version: 0.5.0 description: | - Universal federated discovery API specification for AI agents, tools, and capabilities. - Allows LLM orchestrators to semantically search and discover registries for relevant capabilities - and enables registry-to-registry federated query routing. + Universal discovery API specification for AI agents, tools, and capabilities. + Allows LLM orchestrators to search semantically, retrieve known catalog entries, + explore registries, and use registry-to-registry federated query routing. paths: /search: post: @@ -121,8 +121,50 @@ paths: '500': $ref: '#/components/responses/500InternalError' + /agents/{identifier}: + get: + summary: Retrieve Catalog Entry + description: | + Retrieves one locally indexed catalog entry by exact, case-sensitive identifier. + The identifier is UTF-8 percent-encoded as one path segment. This operation + does not perform semantic matching or query upstream registries. + operationId: getCatalogEntry + parameters: + - name: identifier + in: path + required: true + description: | + Complete ARD identifier. Clients percent-encode it as one path segment; + servers percent-decode it exactly once before validation and lookup. + schema: + type: string + pattern: '^urn:air:[a-zA-Z0-9.-]+(:[a-zA-Z0-9._-]+)+$' + example: "urn:air:acme.com:agent:assistant" + responses: + '200': + description: Exact catalog entry match. + content: + application/json: + schema: + $ref: '#/components/schemas/CatalogEntry' + '304': + description: The cached representation is still current. + '400': + $ref: '#/components/responses/400BadRequest' + '401': + $ref: '#/components/responses/401Unauthorized' + '404': + $ref: '#/components/responses/404NotFound' + '429': + $ref: '#/components/responses/429TooManyRequests' + '500': + $ref: '#/components/responses/500InternalError' + components: schemas: + CatalogEntry: + $ref: './ai-catalog.schema.json#/$defs/catalogEntry' + QueryModel: type: object properties: @@ -357,6 +399,12 @@ components: application/json: schema: $ref: '#/components/schemas/Error' + 404NotFound: + description: No catalog entry has the requested identifier. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' 429TooManyRequests: description: Rate limit exceeded. content: