openapi: 3.0.3
info:
  title: Agentic Identity Broker - Admin Server API
  version: 3.0.0
  description: |
    Administrative REST APIs for the Agentic Identity Broker running on Port 14000.

    ## Overview

    The Admin Server provides full CRUD operations for managing:
    - **AI Agents**: Registry of agents that can request delegated user permissions
    - **Third-Party OAuth2 Services**: External OAuth2 providers (GitHub, Google, Databricks, etc.)
    - **System Health**: Monitoring and health check endpoints

    ### External ID Contract (Admin API 3.0.0)

    - A set `external_id` identifies at most one agent. Comparison is exact and case-sensitive.
    - `POST /api/agents` and `PUT /api/agents/{agent-id}` return `409 conflict` when another
      agent holds the resulting external ID, and `400 validation failed` for empty, overlong,
      invalid UTF-8 or NUL-containing values.
    - `PUT /api/agents?external_id=` creates or updates the agent holding that external ID.
    - `GET /api/agents?external_id=` returns `[]` or a one-element array.
    - Agents without an external ID remain unrestricted in number.

    ### Stable Agent Response Presentation

    Every Admin Agent response (POST, collection PUT, ID-addressed PUT,
    single GET, and list/filter GET) returns `client_uris` in ascending Go string
    byte order. URI spelling, other collection ordering and empty-field omission
    are unchanged. Stored order is irrelevant; equal-URI-set collection PUT replay does not write.

    Every Admin Agent `created_at` and `updated_at` keeps the whole-second UTC
    RFC 3339 presentation of Admin API 2.1.0. Collection PUT creation sets both
    stored times to the current time. An actual reconcile update preserves
    `created_at` and sets `updated_at` to the current time, as POST and ID-addressed
    PUT do. Two changes within one second may therefore present the same
    `updated_at`; it is not a revision or ETag. No-op replay preserves stored times
    and writes nothing.

    ## Dual-Port Architecture

    The Agentic Identity Broker uses a dual-port HTTP server architecture:
    - **Port 8000** (default): End-user server for consent management
    - **Port 14000** (default): Admin server for configuration (this API)

    ## Authentication & Authorization

    All admin operations require authentication at a trusted reverse proxy (oauth2-proxy,
    nginx, or an API gateway). The proxy must authenticate the caller and enforce
    administrator privilege before the request reaches the admin server. The broker does
    not identify administrators. Keep port 14000 internal and remove client-supplied
    identity headers at the proxy.

    Configure authentication separately for the admin server:
    - **Plain-header mode**: Set `server.admin.authentication.preauth.principal_header_name`
      explicitly. The broker reads the principal from that proxy-supplied header.
      There is no global default principal header.
    - **JWT mode**: Configure `server.admin.authentication.jwt`. With `verification: jwks`
      (default), set `jwks_uri`. The broker verifies the signature and requires `exp`.
      With `verification: none`, a trusted gateway sets an unsigned JWT after it
      authenticates the operator and removes client-supplied copies. Do not set
      `jwks_uri` in this mode. The default header is `Authorization` with `Bearer `
      stripped. `header_name` can select another header, such as `X-Userinfo`.
      A missing or invalid JWT supplies no broker principal. The broker ignores
      the plain principal header. The sweep returns `500 server_misconfiguration`
      without an operator principal. The proxy must reject unauthorized admin
      requests before forwarding.

    The proxy enforces administrator privilege in both modes. The health endpoint is
    public and does not require authentication.

    ## Security Considerations

    ### Browser Request Protection

    All `/api` requests must use the Host authority configured by
    `server.admin.public_url`. Hostname comparison is case-insensitive; the
    scheme's default port may be omitted. Forwarded Host headers are ignored.
    Reverse proxies must preserve the configured public Host.

    Unsafe cross-origin browser requests are rejected using Go's
    `http.CrossOriginProtection`, even when the origin is permitted by CORS.
    POST, PUT, and PATCH requests require `Content-Type: application/json`,
    including actions with no request body. Other requests carrying a body
    also require JSON. Media-type parameters such as `charset=utf-8` are accepted.

    These checks return `403 Forbidden` for an unrecognized Host or a
    cross-origin mutation, and `415 Unsupported Media Type` for a missing,
    malformed, or non-JSON Content-Type. Boundary rejection bodies are plain
    text. `/health` is not subject to these checks.

    ### Client Secret Protection (SR-003)

    - Client secrets are **NEVER** transmitted in plaintext to the frontend
    - Stored static confidential-service responses redact `client_secret` as `"REDACTED"`
    - Static public (`none`) and both CIMD service modes omit `client_secret` because they store no shared secret
    - Static confidential-service secrets are encrypted at rest using AES-256-GCM
    - Update operations support static confidential-service secret rotation
    - Discovery-backed DCR client secrets stay encrypted and never appear in responses
    - Filesystem-service responses omit both `client_id` and `client_secret`, including redaction placeholders

    ### Credential Source Contract (Admin API 2.0.0)

    - Every service response reports `credential_source: stored | filesystem`
    - Creation omission selects stored. Update omission preserves the existing source. Explicit null and unknown values are invalid
    - Filesystem mode requires a valid canonical ID and rejects either inline credential field, including null or empty input
    - Static public, all CIMD, Google-flavor, and protected-resource-discovery services reject filesystem mode
    - Administrative requests and responses contain no credential-file paths, file values, or internal source-transition marker
    - Administrative metadata operations never read credential files or require their availability
    - This approved breaking contract replaces released Admin API 1.0.0 on the existing `/api/services` routes
    - ADR 041 and the major contract bump were explicitly approved on 2026-10-08. Schema approval does not establish runtime implementation

    ### Client Registration Contract (Increment 2, issue #264, Admin API 2.1.0)

    `client_registration` selects the source of the client identifier. It is independent of
    `token_endpoint_auth_method`, which selects token-endpoint authentication. Every service
    response reports `static` or `cimd`.

    - `static` with absent or null authentication is static confidential. `static` with `none` is static public.
    - `cimd` with `private_key_jwt` is CIMD confidential. `cimd` with `none` is CIMD public.
    - Discovery-selected hosted CIMD reports `cimd`. Discovery-selected DCR reports `static`.
      `discovery.client_method` remains the authoritative discovery method. Discovery never selects CIMD public.
    - Omission on create or full-replacement PUT resolves to `cimd` for `private_key_jwt`, and `static` otherwise.
      Omission never infers CIMD public. Explicit null, empty, and unknown registrations return `400`.
    - `static` with `private_key_jwt`, `cimd` with absent or null authentication, and `cimd` with
      Google flavor or filesystem credentials return `400` before discovery or provider traffic.
    - A request with `discovery.resource_url` must omit `client_registration`. Presence returns `400` before discovery traffic.
    - Both CIMD modes use the broker-hosted client identifier URL and reject a non-empty caller
      `client_id` or `client_secret`. CIMD public needs no CIMD client-authentication key.
    - Stored services from before this change report `cimd` for `private_key_jwt`, and `static` otherwise.

    ### URL Validation

    - All URLs must use HTTP or HTTPS schemes — except agent `redirect_uris`, which also accept native-app private-use scheme URIs (see the `redirect_uris` schema)
    - OAuth2 issuer URIs **must** use HTTPS
    - Metadata URLs **must** use HTTPS
    - URL validation prevents injection attacks
    - Protected-resource discovery and every discovered endpoint require public HTTPS.
    - Discovery and registration share a 10-second deadline and a 256-KiB cap per response.
    - Discovery and registration use a guarded client without redirects or proxies. It rejects blocked resolved IP addresses before connection.
    - Discovery-backed token calls use a separate guarded client with the configured upstream token timeout, no redirects, and no proxies.

    ### Referential Integrity

    - Services cannot be deleted if user grants reference them (409 Conflict)
    - Agent deletion cascades to associated grants (logged for audit)

    ## OAuth2 Discovery Support

    Direct authorization-server metadata discovery keeps its existing `metadata_url` behavior.
    Protected-resource discovery uses `discovery.resource_url` with `enable_discovery: true`.
    The broker validates the resource and issuer, then selects hosted CIMD or DCR.
    The authenticated discovery-status resource reads stored status without contacting the provider.
    DCR needs a non-blank deployment-wide `third_party_oauth2.client_name`; manual and CIMD modes do not.

    Discovery and registration failures return safe `ErrorResponse.message` codes only.
    They do not return provider bodies, URL queries, secrets, assertions, or tokens.
    A `400` discovery failure uses one of these codes:
    `resource_metadata_not_found`, `resource_metadata_unavailable`,
    `resource_metadata_invalid`, `resource_mismatch`, `authorization_server_missing`,
    `issuer_selection_required`, `issuer_not_advertised`,
    `authorization_server_metadata_not_found`, `authorization_server_metadata_unavailable`,
    `authorization_server_metadata_invalid`, `issuer_mismatch`,
    `unsafe_destination`, `response_too_large`.
    A `400` client-registration failure uses `no_compatible_client_method`,
    `cimd_unavailable`, `client_name_unconfigured`, `client_registration_rejected`,
    `client_registration_invalid`, or `client_method_changed`.
    `timeout` returns `504`; `duplicate_client_identity` and
    `issuer_change_requires_no_sessions` return `409`. Local storage or encryption
    failures return `500` without a failure code. Only `issuer_selection_required`
    adds the validated `authorization_servers` list to its authenticated error response.


    ## Trace Correlation

    By default, every HTTP response from this API includes the additive W3C
    `traceresponse` response header.

    - The header applies globally across this API, including successful responses and error responses.
    - Its `<trace-id>` field is the request correlation identifier operators can use to find matching logs.
    - The header is additive only; no request or response body schema changes are introduced by this feature.
    - Emission is enabled by default and can be disabled only through `request_context.trace.response_enabled`.

    ## Error Handling

    Handler error responses follow the ErrorResponse schema with appropriate
    HTTP status codes. Browser boundary rejections use plain-text bodies as
    described above.

  contact:
    name: Agentic Identity Broker Team
  license:
    name: MIT

servers:
  - url: http://localhost:14000
    description: Admin server (default port 14000)
  - url: http://localhost:8000
    description: End-user server (health endpoint only)

tags:
  - name: Health
    description: Health check and monitoring endpoints
  - name: Agents
    description: Administrative operations for AI agent registry management
  - name: Services
    description: Administrative operations for third-party OAuth2 service configurations
  - name: PermissionSets
    description: CRUD operations for permission set management
  - name: Client Credentials
    description: Broker-issued client credential management for agents
  - name: Signing Keys
    description: OAuth2 server signing key management
  - name: CIMD Client Keys
    description: Administrative lifecycle operations for the broker-global CIMD client-authentication key set
  - name: Sessions
    description: Administrative maintenance of third-party user sessions across all principals

paths:
  /health:
    get:
      summary: Health check endpoint
      description: |
        Returns the current health status of the HTTP server. Each server (end-user and admin)
        has its own independent health endpoint that reports only that server's status.

        This endpoint is **public** and does not require authentication.

        **Status Codes**:
        - `200 OK`: Server is healthy and accepting requests
        - `503 Service Unavailable`: Server is starting, shutting down, or unhealthy

        **Health States**:
        - `healthy`: Server is operational and processing requests normally
        - `starting`: Server is initializing (binding to port, setting up routes)
        - `shutting_down`: Graceful shutdown in progress, completing in-flight requests
        - `unhealthy`: Server encountered an error and requires restart
      operationId: getHealth
      tags:
        - Health
      security: []  # Public endpoint, no authentication required
      responses:
        '200':
          description: Server is healthy
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HealthResponse'
              examples:
                healthy_admin:
                  summary: Healthy admin server
                  value:
                    status: healthy
                    timestamp: '2025-12-19T10:30:00Z'
                    uptime_seconds: 3600
        '503':
          description: Server is unavailable (starting, shutting down, or unhealthy)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HealthResponse'
              examples:
                starting:
                  summary: Server starting
                  value:
                    status: starting
                    timestamp: '2025-12-19T10:25:00Z'
                    uptime_seconds: 0
                shutting_down:
                  summary: Server shutting down
                  value:
                    status: shutting_down
                    timestamp: '2025-12-19T11:30:00Z'
                    uptime_seconds: 7200

  /api/agents:
    get:
      summary: List agents, optionally filtered by external ID
      description: |
        Retrieve registered agents. Without `external_id`, return all agents.
        With one `external_id`, return the exact, case-sensitive match, if any.
        External IDs are unique, so the array contains zero or one agent.
        Do not trim, normalize, or interpret the value as a pattern. The filter
        does not match agent IDs, canonical IDs, or client IDs.

        Percent-encode reserved characters in the value, such as `&`, `+`, and `;`.
        For example, send `urn:example:ai-registry:agent:7f3a9c` as
        `urn%3Aexample%3Aai-registry%3Aagent%3A7f3a9c`.
        Invalid filters return `400` with `error: invalid external_id`.
        This includes empty or repeated values, invalid UTF-8, NUL, more than
        255 Unicode code points, malformed percent-encoding, and an unencoded `;`.
        Unknown parameters and malformed query parts outside `external_id`
        retain their existing handling.

        Returns a direct array of agent objects (no envelope), in no guaranteed order.
      operationId: listAgents
      tags:
        - Agents
      parameters:
        - name: external_id
          in: query
          required: false
          description: Exact, case-sensitive external governance ID. Percent-encode reserved characters.
          schema:
            type: string
            minLength: 1
            maxLength: 255
          example: urn:example:ai-registry:agent:7f3a9c
        - $ref: '#/components/parameters/PreferCanonicalReferences'
      responses:
        '200':
          description: List of agents retrieved successfully
          headers:
            Preference-Applied:
              $ref: '#/components/headers/PreferenceApplied'
            Vary:
              $ref: '#/components/headers/VaryPrefer'
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/Agent'
              examples:
                multiple_agents:
                  summary: Multiple agents
                  value:
                    - id: 550e8400-e29b-41d4-a716-446655440000
                      client_id: agent-research-assistant
                      display_name: Research Assistant
                      description: AI assistant that helps with academic research
                      governance_url: https://example.com/governance/research-assistant
                      user_documentation_url: https://example.com/docs/research-assistant
                      permission_sets:
                        - permission_set_id: a1b2c3d4-e5f6-7890-abcd-ef1234567890
                          requirement_type: mandatory
                      created_at: '2025-12-01T10:00:00Z'
                      updated_at: '2025-12-01T10:00:00Z'
                    - id: 660e8400-e29b-41d4-a716-446655440001
                      client_id: agent-data-analyst
                      external_id: gov-sys-12345
                      display_name: Data Analyst
                      description: Analyzes datasets and generates insights
                      permission_sets:
                        - permission_set_id: b2c3d4e5-f6a7-8901-bcde-f12345678901
                          requirement_type: mandatory
                      created_at: '2025-12-05T14:30:00Z'
                      updated_at: '2025-12-10T09:15:00Z'
                empty_list:
                  summary: No agents match the filter or none are registered
                  value: []
        '400':
          description: Invalid external_id filter
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              examples:
                empty_external_id:
                  summary: Empty external ID
                  value:
                    error: invalid external_id
                    message: external_id must be 1 to 255 characters
                repeated_external_id:
                  summary: Repeated external ID parameter
                  value:
                    error: invalid external_id
                    message: external_id must appear at most once
                malformed_external_id:
                  summary: Malformed percent-encoding
                  value:
                    error: invalid external_id
                    message: external_id contains malformed percent-encoding
        '500':
          $ref: '#/components/responses/InternalServerError'

    post:
      summary: Create a new agent
      description: |
        Register a new AI agent in the identity broker system.

        The `client_id` field is the upstream OAuth2 client ID used when proxying
        authorization requests to the upstream server.

        **client_id uniqueness depends on configuration**:
        - When `multi_agent_client.enabled = false` (default): `client_id` must be unique
          across all agents. Returns `409 Conflict` if a duplicate `client_id` is detected.
        - When `multi_agent_client.enabled = true`: Multiple agents may share the same
          `client_id`. No uniqueness validation is performed.

        **Validation Rules**:
        - `client_id`: Optional; when provided must be non-empty; unique across all agents when `multi_agent_client.enabled = false`
        - `display_name`: Required, max 255 characters
        - `description`: Required, max 1000 characters
        - URLs: Must be valid HTTP/HTTPS URLs if provided; `redirect_uris` entries follow the `redirect_uris` schema rules

        **external_id uniqueness**: When supplied, `external_id` must be valid UTF-8 text with
        1–255 Unicode code points and no NUL character, and must not
        be held by another agent. An empty string returns `400 validation failed`. A held value
        returns `409 conflict` with message `external_id is already held by another agent`.
        Omit `external_id` or send `null` for agents without one; any number of agents may omit it.

        **ID Generation**: System generates a UUID for the agent ID. `client_id` is not auto-generated — omit it for agents that do not need one.
      operationId: createAgent
      tags:
        - Agents
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/AgentCreateRequest'
            examples:
              minimal:
                summary: Minimal agent
                value:
                  client_id: agent-simple-assistant
                  display_name: Simple Assistant
                  description: A basic AI assistant for general tasks
                  permission_sets:
                    - permission_set_id: a1b2c3d4-e5f6-7890-abcd-ef1234567890
                      requirement_type: mandatory
              full:
                summary: Full agent with all fields
                value:
                  client_id: agent-research-assistant
                  external_id: gov-sys-12345
                  display_name: Research Assistant
                  description: AI assistant that helps with academic research by accessing papers, datasets, and citation databases
                  governance_url: https://example.com/governance/research-assistant
                  user_documentation_url: https://example.com/docs/research-assistant
                  agent_interface_url: https://example.com/agent/research-assistant
                  permission_sets:
                    - permission_set_id: a1b2c3d4-e5f6-7890-abcd-ef1234567890
                      requirement_type: mandatory
                    - permission_set_id: b2c3d4e5-f6a7-8901-bcde-f12345678901
                      requirement_type: optional
      responses:
        '201':
          description: Agent created successfully
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Agent'
              example:
                id: 550e8400-e29b-41d4-a716-446655440000
                client_id: agent-research-assistant
                external_id: gov-sys-12345
                display_name: Research Assistant
                description: AI assistant that helps with academic research
                governance_url: https://example.com/governance/research-assistant
                user_documentation_url: https://example.com/docs/research-assistant
                agent_interface_url: https://example.com/agent/research-assistant
                permission_sets:
                  - permission_set_id: a1b2c3d4-e5f6-7890-abcd-ef1234567890
                    requirement_type: mandatory
                created_at: '2025-12-19T10:30:00Z'
                updated_at: '2025-12-19T10:30:00Z'
        '400':
          $ref: '#/components/responses/BadRequestError'
        '409':
          $ref: '#/components/responses/AgentConflictError'
        '500':
          $ref: '#/components/responses/InternalServerError'

    put:
      summary: Reconcile the agent holding an external ID
      description: |
        Idempotently reconcile the agent identified by an external governance ID.

        - No agent holds `external_id`: create one and return `201 Created` with `Location`.
        - An agent holds it: update that agent and return `200 OK`. The selector never matches
          agent IDs, canonical IDs, or client IDs.
        - Replaying an identical request with no intervening change returns `200` with the
          unchanged representation, including `updated_at`. Nothing is written.
        - Concurrent first requests with one external ID create exactly one agent.
          Without `If-Match`, concurrent different definitions are last-writer-wins; each
          successful request applies its complete definition.

        **Selector**: exactly one `external_id` query parameter, 1–255 Unicode code points,
        valid UTF-8, no NUL, valid percent-encoding, and no unencoded `;`. Percent-encode `&`,
        `+`, and `;`. Otherwise `400 invalid external_id`.

        **Body**: `AgentCreateRequest`. Omitted `external_id` takes the selector. A supplied
        `external_id` must equal the decoded selector; a different value, `""`, or `null`
        returns `400 invalid external_id`.

        **Update-branch field policy** (FR-012):

        | Fields | Omitted | `null` | Value |
        | --- | --- | --- | --- |
        | `client_id` | preserved | `400 validation failed` | replaced |
        | `canonical_id` | preserved | removed | replaced |
        | `governance_url`, `user_documentation_url`, `agent_interface_url` | removed | removed | replaced |
        | `service_requirements`, `redirect_uris`, `allowed_scopes`, `client_uris` | emptied | emptied | replaced |
        | `display_name`, `description`, `permission_sets` | `400` | `400` | replaced |

        On creation, omitted optional fields are absent or empty and no `client_id` is generated.
        Grants and other relationships of an updated agent are not changed. To clear an existing
        `client_id`, use `PUT /api/agents/{agent-id}` with `client_id: null`.

        Invalid definitions, attributes, and references on either branch return `400`
        with the existing `validation failed` envelope. Canonical-ID conflicts,
        configuration-dependent `client_id` conflicts (`multi_agent_client.enabled`),
        and client-URI identity conflicts return `409 conflict` on both branches.
        Failed requests change no agent. Agent ordering and timestamps follow
        "Stable Agent Response Presentation" above on create, update, and replay.
      operationId: reconcileAgentByExternalId
      tags:
        - Agents
      parameters:
        - $ref: '#/components/parameters/ExternalIdSelector'
        - $ref: '#/components/parameters/PreferCanonicalReferences'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/AgentCreateRequest'
            examples:
              desired_state:
                summary: Desired agent definition
                value:
                  canonical_id: research-assistant
                  external_id: urn:example:ai-registry:agent:7f3a9c
                  display_name: Research Assistant
                  description: Helps with academic research
                  permission_sets:
                    - permission_set_id: github-read
                      requirement_type: mandatory
      responses:
        '200':
          description: The agent holding the external ID was updated, or already matched the request.
          headers:
            Preference-Applied:
              $ref: '#/components/headers/PreferenceApplied'
            Vary:
              $ref: '#/components/headers/VaryPrefer'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Agent'
              example:
                id: 550e8400-e29b-41d4-a716-446655440000
                canonical_id: research-assistant
                external_id: urn:example:ai-registry:agent:7f3a9c
                display_name: Research Assistant v2
                description: Helps with academic research
                permission_sets:
                  - permission_set_id: github-read
                    requirement_type: mandatory
                created_at: '2026-10-30T08:00:00Z'
                updated_at: '2026-10-30T09:00:00Z'
        '201':
          description: No agent held the external ID; one was created.
          headers:
            Location:
              description: Relative URI of the created agent, addressed by UUID.
              schema:
                type: string
              example: /api/agents/550e8400-e29b-41d4-a716-446655440000
            Preference-Applied:
              $ref: '#/components/headers/PreferenceApplied'
            Vary:
              $ref: '#/components/headers/VaryPrefer'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Agent'
              example:
                id: 550e8400-e29b-41d4-a716-446655440000
                canonical_id: research-assistant
                external_id: urn:example:ai-registry:agent:7f3a9c
                display_name: Research Assistant
                description: Helps with academic research
                permission_sets:
                  - permission_set_id: github-read
                    requirement_type: mandatory
                created_at: '2026-10-30T08:00:00Z'
                updated_at: '2026-10-30T08:00:00Z'
        '400':
          description: Invalid selector, body external_id, or agent definition. No agent changes.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              examples:
                missing_selector:
                  value:
                    error: invalid external_id
                    message: external_id selector is required
                empty_selector:
                  value:
                    error: invalid external_id
                    message: external_id must be 1 to 255 characters
                repeated_selector:
                  value:
                    error: invalid external_id
                    message: external_id must appear at most once
                malformed_selector:
                  value:
                    error: invalid external_id
                    message: external_id contains malformed percent-encoding
                body_mismatch:
                  value:
                    error: invalid external_id
                    message: body external_id must equal the selector
                body_null:
                  value:
                    error: invalid external_id
                    message: external_id must not be null
                null_client_id:
                  value:
                    error: validation failed
                    message: client_id must be a nonempty string when supplied
                missing_permission_sets:
                  value:
                    error: validation failed
                    message: at least one permission set entry is required
        '409':
          $ref: '#/components/responses/AgentConflictError'
        '500':
          $ref: '#/components/responses/InternalServerError'

  /api/agents/{agent-id}:
    get:
      summary: Get agent by ID
      description: |
        Retrieve detailed information about a specific agent.

        Returns the complete agent configuration including all optional fields.
      operationId: getAgent
      tags:
        - Agents
      parameters:
        - $ref: '#/components/parameters/AgentId'
        - $ref: '#/components/parameters/PreferCanonicalReferences'
      responses:
        '200':
          description: Agent retrieved successfully
          headers:
            Preference-Applied:
              $ref: '#/components/headers/PreferenceApplied'
            Vary:
              $ref: '#/components/headers/VaryPrefer'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Agent'
              example:
                id: 550e8400-e29b-41d4-a716-446655440000
                client_id: agent-research-assistant
                display_name: Research Assistant
                description: AI assistant that helps with academic research
                governance_url: https://example.com/governance/research-assistant
                permission_sets:
                  - permission_set_id: a1b2c3d4-e5f6-7890-abcd-ef1234567890
                    requirement_type: mandatory
                created_at: '2025-12-19T10:30:00Z'
                updated_at: '2025-12-19T10:30:00Z'
        '400':
          description: Invalid agent ID format
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error: agent ID is required
        '404':
          $ref: '#/components/responses/NotFoundError'
        '500':
          $ref: '#/components/responses/InternalServerError'

    put:
      summary: Update agent
      description: |
        Update configuration for an existing agent.

        When supplied, `external_id` must be non-empty and must not be held by another agent
        (`400 validation failed` / `409 conflict`). Retaining its own external ID is not a
        conflict. Canonical-ID, client-ID, and client-URI conflicts also return `409`.

        **Update Semantics**:
        - PUT is a full replacement, not PATCH. Send every field you want to keep
        - Omitted or null `external_id`, `governance_url`, `user_documentation_url`, `agent_interface_url`, `service_requirements`, `redirect_uris`, `allowed_scopes`, and `client_uris` are cleared
        - `canonical_id` and `client_id` are the exceptions: omitted preserves the existing value, and explicit null clears it
        - `created_at` timestamp is preserved
        - `updated_at` timestamp is set to current time

        **client_id uniqueness depends on configuration**:
        - When `multi_agent_client.enabled = false` (default): `client_id` must be unique
          across all agents (excluding the current agent). Returns `409 Conflict` if a
          duplicate `client_id` is detected on another agent.
        - When `multi_agent_client.enabled = true`: Multiple agents may share the same
          `client_id`. No uniqueness validation is performed.

        **Validation**:
        - `client_id`: Three-state — omitted preserves existing value, explicit `null` clears it, string value updates it (must be non-empty); unique across all agents (excluding the current agent) when `multi_agent_client.enabled = false`
        - Other fields follow the same rules as creation
      operationId: updateAgent
      tags:
        - Agents
      parameters:
        - $ref: '#/components/parameters/AgentId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/AgentUpdateRequest'
            examples:
              update_display_name:
                summary: Update the display name of an agent with no other optional fields
                value:
                  client_id: agent-research-assistant
                  display_name: Advanced Research Assistant
                  description: AI assistant that helps with academic research
                  permission_sets:
                    - permission_set_id: a1b2c3d4-e5f6-7890-abcd-ef1234567890
                      requirement_type: mandatory
              update_urls:
                summary: Set documentation URLs on an agent with no other optional fields
                value:
                  client_id: agent-research-assistant
                  display_name: Research Assistant
                  description: AI assistant that helps with academic research
                  governance_url: https://example.com/governance/v2/research-assistant
                  user_documentation_url: https://docs.example.com/research-assistant
                  permission_sets:
                    - permission_set_id: a1b2c3d4-e5f6-7890-abcd-ef1234567890
                      requirement_type: mandatory
      responses:
        '200':
          description: Agent updated successfully
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Agent'
              example:
                id: 550e8400-e29b-41d4-a716-446655440000
                client_id: agent-research-assistant
                display_name: Advanced Research Assistant
                description: AI assistant that helps with academic research
                permission_sets:
                  - permission_set_id: a1b2c3d4-e5f6-7890-abcd-ef1234567890
                    requirement_type: mandatory
                created_at: '2025-12-19T10:30:00Z'
                updated_at: '2025-12-19T15:45:00Z'
        '400':
          $ref: '#/components/responses/BadRequestError'
        '409':
          $ref: '#/components/responses/AgentConflictError'
        '404':
          $ref: '#/components/responses/NotFoundError'
        '500':
          $ref: '#/components/responses/InternalServerError'

    delete:
      summary: Delete agent
      description: |
        Remove an agent from the system.

        **Cascade Behavior**:
        - All user grants associated with this agent are automatically deleted
        - Deletion is logged for audit purposes

        **Irreversible**: This operation cannot be undone
      operationId: deleteAgent
      tags:
        - Agents
      parameters:
        - $ref: '#/components/parameters/AgentId'
      responses:
        '204':
          description: Agent deleted successfully (no content)
        '400':
          description: Invalid agent ID format
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error: agent ID is required
        '404':
          $ref: '#/components/responses/NotFoundError'
        '500':
          $ref: '#/components/responses/InternalServerError'

  /api/services:
    get:
      summary: List all OAuth2 services
      description: |
        Retrieve a list of all configured third-party OAuth2 services.

        Returns a direct array of service objects (no envelope).

        Every object reports `credential_source` and `client_registration`.
        Static confidential services return their client ID and `"REDACTED"` secret.
        Static public, CIMD, and DCR services omit the secret. Filesystem services omit inline credentials.
        Listing never reads files or returns paths or file values.
      operationId: listServices
      tags:
        - Services
      parameters:
        - $ref: '#/components/parameters/PreferCanonicalReferences'
      responses:
        '200':
          description: List of services retrieved successfully
          headers:
            Preference-Applied:
              $ref: '#/components/headers/PreferenceApplied'
            Vary:
              $ref: '#/components/headers/VaryPrefer'
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/Service'
              examples:
                multiple_services:
                  summary: Multiple services
                  value:
                    - id: 770e8400-e29b-41d4-a716-446655440002
                      display_name: GitHub Production
                      credential_source: stored
                      client_registration: static
                      oauth2_flavor: github
                      client_id: Iv1.1234567890abcdef
                      client_secret: REDACTED
                      token_endpoint_auth_method: null
                      issuer_uri: https://github.com
                      discovery:
                        enable_discovery: true
                        authorization_param_resource_strategy: null
                      endpoints:
                        token_endpoint: https://github.com/login/oauth/access_token
                        authorize_endpoint: https://github.com/login/oauth/authorize
                      scopes:
                        - scope_value: repo
                          description: Full control of private repositories
                        - scope_value: read:org
                          description: Read organization membership
                      created_at: '2025-12-01T09:00:00Z'
                      updated_at: '2025-12-15T14:30:00Z'
                    - id: 880e8400-e29b-41d4-a716-446655440003
                      display_name: Google Cloud
                      credential_source: stored
                      client_registration: static
                      oauth2_flavor: google
                      client_id: 123456789-abcdef.apps.googleusercontent.com
                      client_secret: REDACTED
                      token_endpoint_auth_method: null
                      issuer_uri: https://accounts.google.com
                      discovery:
                        enable_discovery: true
                        authorization_param_resource_strategy: null
                      endpoints:
                        token_endpoint: https://oauth2.googleapis.com/token
                        authorize_endpoint: https://accounts.google.com/o/oauth2/v2/auth
                      scopes:
                        - scope_value: https://www.googleapis.com/auth/drive.readonly
                          description: View and download Google Drive files
                      created_at: '2025-12-05T11:20:00Z'
                      updated_at: '2025-12-05T11:20:00Z'
                    - id: 990e8400-e29b-41d4-a716-446655440004
                      canonical_id: zalando-platform
                      display_name: Zalando Platform
                      credential_source: filesystem
                      client_registration: static
                      oauth2_flavor: standard
                      token_endpoint_auth_method: null
                      issuer_uri: https://oauth.example.com
                      discovery:
                        enable_discovery: false
                      endpoints:
                        token_endpoint: https://oauth.example.com/token
                        authorize_endpoint: https://oauth.example.com/authorize
                      created_at: '2026-10-08T09:00:00Z'
                      updated_at: '2026-10-08T09:00:00Z'
                empty_list:
                  summary: No services configured
                  value: []
        '500':
          $ref: '#/components/responses/InternalServerError'

    post:
      summary: Create a new OAuth2 service
      description: |
        Configure a new third-party OAuth2 service provider.

        **Validation Rules**:
        - `display_name`: Required, max 255 characters
        - `credential_source`: `stored` or `filesystem`; omission selects `stored`; explicit null and unknown values are invalid
        - `client_registration`: Optional `static` or `cimd`. Omission selects `cimd` for `private_key_jwt` and `static` otherwise. Explicit null, empty, or unknown values return `400`
        - Stored manual `client_id`: Required and unique under existing validation, except Google derives it and CIMD generates it; discovery selects the client identity
        - Stored `client_secret`: Required for static confidential services. Static public and both CIMD modes accept omitted, null, or empty secret input
        - Filesystem: Requires a valid canonical ID, shared-secret authentication, and standard/GitHub flavor
        - Filesystem: Rejects either inline field whenever present, including null or empty input
        - Manual `issuer_uri`: Required and HTTPS, subject to existing Google service-account rules; optional for protected-resource discovery with one issuer
        - `scopes`: Optional. Omit it or provide an empty array when the provider does not use OAuth2 scopes.

        **Filesystem registration** never reads files or requires an available binding.
        Paths belong only to operator configuration and cannot be supplied through this API.
        Every successful response reports the selected source. Filesystem responses omit both inline fields.

        **CIMD modes**: `client_registration: cimd` with `private_key_jwt` is confidential.
        With `none`, it is public and needs no CIMD client-authentication key.
        Both require omitted or null `client_id` and omitted, null, or empty `client_secret`.
        The broker rejects a non-empty caller value, Google flavor, or filesystem source with
        `400` before provider traffic. It assigns the HTTPS client identifier URL after it creates the service ID.
        `static` with `private_key_jwt`, or `cimd` with absent or null authentication, returns `400`.

        **Direct metadata discovery**: Without `resource_url`, `metadata_url` can select an
        authorization-server document. The existing manual-endpoint fallback remains unchanged.

        **Protected-resource discovery**: Set `discovery.resource_url` with
        `enable_discovery: true`. Omit `client_registration`, manual endpoints, client credentials,
        `metadata_url`, and `token_endpoint_auth_method`. A supplied registration returns `400`
        before discovery traffic. An issuer selection is optional for one issuer.
        The broker derives `authorization_params.resource` unless the request supplies an
        explicit value. A selected DCR client needs a non-blank broker client name from
        deployment configuration, not the service display name. Failures do not create a service.

        **Security**: Static and DCR confidential secrets are encrypted before storage.
        A DCR secret never appears in an Admin API response.
      operationId: createService
      tags:
        - Services
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ServiceCreateRequest'
            examples:
              github_discovery:
                summary: GitHub with automatic discovery
                value:
                  display_name: GitHub Production
                  client_id: Iv1.1234567890abcdef
                  client_secret: ghp_secretkey1234567890abcdef
                  issuer_uri: https://github.com
                  discovery:
                    enable_discovery: true
                  scopes:
                    - scope_value: repo
                      description: Full control of private repositories
                    - scope_value: read:org
                      description: Read organization membership
                    - scope_value: user:email
                      description: Read user email addresses
              manual_endpoints:
                summary: Custom OAuth2 provider with manual endpoints
                value:
                  display_name: Corporate SSO
                  client_id: corp-sso-client-123
                  client_secret: secret-value-never-exposed
                  issuer_uri: https://sso.corp.example.com
                  discovery:
                    enable_discovery: false
                  endpoints:
                    token_endpoint: https://sso.corp.example.com/oauth/token
                    authorize_endpoint: https://sso.corp.example.com/oauth/authorize
                  scopes:
                    - scope_value: profile
                      description: Access user profile information
                    - scope_value: corporate_data
                      description: Access corporate data systems
              cimd_public:
                summary: Create a CIMD public ServiceNow client without credentials
                value:
                  display_name: ServiceNow
                  credential_source: stored
                  client_registration: cimd
                  token_endpoint_auth_method: none
                  oauth2_flavor: standard
                  issuer_uri: https://auth.example.com
                  discovery:
                    enable_discovery: false
                  endpoints:
                    token_endpoint: https://auth.example.com/oauth/token
                    authorize_endpoint: https://auth.example.com/oauth/authorize
              protected_resource:
                summary: Discover a protected resource without supplying a client
                value:
                  display_name: Example MCP
                  discovery:
                    enable_discovery: true
                    resource_url: https://mcp.example.com/mcp
                  authorization_params:
                    resource: https://mcp.example.com/api
              filesystem:
                summary: Filesystem registration without inline credentials or paths
                value:
                  canonical_id: zalando-platform
                  display_name: Zalando Platform
                  credential_source: filesystem
                  oauth2_flavor: standard
                  issuer_uri: https://oauth.example.com
                  discovery:
                    enable_discovery: false
                  endpoints:
                    token_endpoint: https://oauth.example.com/token
                    authorize_endpoint: https://oauth.example.com/authorize
      responses:
        '201':
          description: Service created successfully
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Service'
              examples:
                static_service:
                  value:
                    id: 770e8400-e29b-41d4-a716-446655440002
                    display_name: GitHub Production
                    credential_source: stored
                    client_registration: static
                    oauth2_flavor: github
                    client_id: Iv1.1234567890abcdef
                    client_secret: REDACTED
                    token_endpoint_auth_method: null
                    issuer_uri: https://github.com
                    discovery:
                      enable_discovery: true
                      authorization_param_resource_strategy: null
                    endpoints:
                      token_endpoint: https://github.com/login/oauth/access_token
                      authorize_endpoint: https://github.com/login/oauth/authorize
                    scopes:
                      - scope_value: repo
                        description: Full control of private repositories
                      - scope_value: read:org
                        description: Read organization membership
                    created_at: '2025-12-19T10:30:00Z'
                    updated_at: '2025-12-19T10:30:00Z'
                cimd_public:
                  summary: Broker-hosted public client without a secret
                  value:
                    id: 770e8400-e29b-41d4-a716-446655440002
                    display_name: ServiceNow
                    credential_source: stored
                    client_registration: cimd
                    client_id: https://broker.example.com/.well-known/oauth-client/770e8400-e29b-41d4-a716-446655440002
                    token_endpoint_auth_method: none
                    oauth2_flavor: standard
                    issuer_uri: https://auth.example.com
                    discovery:
                      enable_discovery: false
                      client_method: null
                      authorization_param_resource_strategy: null
                    endpoints:
                      token_endpoint: https://auth.example.com/oauth/token
                      authorize_endpoint: https://auth.example.com/oauth/authorize
                    created_at: '2026-10-22T10:00:00Z'
                    updated_at: '2026-10-22T10:00:00Z'
                protected_resource_dcr:
                  value:
                    id: 880e8400-e29b-41d4-a716-446655440003
                    display_name: Example MCP
                    credential_source: stored
                    client_registration: static
                    client_id: dcr-client-123
                    token_endpoint_auth_method: client_secret_basic
                    oauth2_flavor: standard
                    issuer_uri: https://auth.example.com/tenant
                    discovery:
                      enable_discovery: true
                      resource_url: https://mcp.example.com/mcp
                      client_method: dcr
                      authorization_param_resource_strategy: pinned
                    endpoints:
                      token_endpoint: https://auth.example.com/tenant/token
                      authorize_endpoint: https://auth.example.com/tenant/authorize
                    authorization_params:
                      resource: https://mcp.example.com/api
                    created_at: '2026-10-06T10:00:00Z'
                    updated_at: '2026-10-06T10:00:00Z'
                derived_resource:
                  summary: Discovery derives its effective token audience
                  value:
                    id: 880e8400-e29b-41d4-a716-446655440004
                    display_name: Files MCP
                    credential_source: stored
                    client_registration: static
                    client_id: dcr-files-client
                    token_endpoint_auth_method: none
                    oauth2_flavor: standard
                    issuer_uri: https://auth.example.com/tenant
                    discovery:
                      enable_discovery: true
                      resource_url: https://files.example/mcp
                      client_method: dcr
                      authorization_param_resource_strategy: derived
                    endpoints:
                      authorize_endpoint: https://auth.example.com/tenant/authorize
                      token_endpoint: https://auth.example.com/tenant/token
                    authorization_params:
                      resource: https://files.example/mcp
                    created_at: '2026-10-06T10:00:00Z'
                    updated_at: '2026-10-06T10:00:00Z'
                pinned_equal_resource:
                  summary: Administrator pins the same token audience as the discovery URL
                  value:
                    id: 880e8400-e29b-41d4-a716-446655440005
                    display_name: Files MCP
                    credential_source: stored
                    client_registration: static
                    client_id: dcr-files-client-2
                    token_endpoint_auth_method: none
                    oauth2_flavor: standard
                    issuer_uri: https://auth.example.com/tenant
                    discovery:
                      enable_discovery: true
                      resource_url: https://files.example/mcp
                      client_method: dcr
                      authorization_param_resource_strategy: pinned
                    endpoints:
                      authorize_endpoint: https://auth.example.com/tenant/authorize
                      token_endpoint: https://auth.example.com/tenant/token
                    authorization_params:
                      resource: https://files.example/mcp
                    created_at: '2026-10-06T10:00:00Z'
                    updated_at: '2026-10-06T10:00:00Z'
        '400':
          description: Invalid request, discovery failure, or client registration failure
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              examples:
                issuer_selection_required:
                  value:
                    error: discovery failed
                    message: issuer_selection_required
                    authorization_servers:
                      - https://login-a.example.com
                      - https://login-b.example.com
                broker_name_missing:
                  value:
                    error: client registration failed
                    message: client_name_unconfigured
                invalid_client_registration:
                  summary: Explicit null, empty, unknown, or discovery-supplied registration
                  value:
                    error: validation failed
                    message: invalid client_registration
                incompatible_client_registration:
                  summary: Registration and authentication method do not match
                  value:
                    error: validation failed
                    message: incompatible client registration and authentication method
        '409':
          description: Manual client-ID conflict, duplicate issuer-scoped DCR identity, or protected-resource ownership conflict
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              examples:
                duplicate_dcr_identity:
                  value:
                    error: conflict
                    message: duplicate_client_identity
                protected_resource_owned:
                  value:
                    error: conflict
                    message: protected resource URI already configured for another service
                manual_duplicate:
                  value:
                    error: conflict
                    message: Service with client_id 'Iv1.1234567890abcdef' already exists
        '504':
          description: Discovery or registration exceeded the 10-second attempt deadline
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error: discovery failed
                message: timeout
        '500':
          $ref: '#/components/responses/InternalServerError'

  /api/services/{service-id}:
    get:
      summary: Get OAuth2 service by ID
      description: |
        Retrieve detailed configuration for a specific OAuth2 service.

        Every response reports `credential_source` and `client_registration` without reading files.
        Stored responses always include `client_id`. Static confidential secrets return only
        `"REDACTED"`; static public, CIMD, and DCR responses omit the secret.
        Filesystem responses omit both inline credential fields, file paths, and file values.
      operationId: getService
      tags:
        - Services
      parameters:
        - $ref: '#/components/parameters/ServiceId'
        - $ref: '#/components/parameters/PreferCanonicalReferences'
      responses:
        '200':
          description: Service retrieved successfully
          headers:
            ETag:
              description: Current strong service ETag.
              schema:
                type: string
            Preference-Applied:
              $ref: '#/components/headers/PreferenceApplied'
            Vary:
              $ref: '#/components/headers/VaryPrefer'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Service'
              example:
                id: 770e8400-e29b-41d4-a716-446655440002
                display_name: GitHub Production
                credential_source: stored
                client_registration: static
                oauth2_flavor: github
                client_id: Iv1.1234567890abcdef
                client_secret: REDACTED
                token_endpoint_auth_method: null
                issuer_uri: https://github.com
                discovery:
                  enable_discovery: true
                  authorization_param_resource_strategy: null
                endpoints:
                  token_endpoint: https://github.com/login/oauth/access_token
                  authorize_endpoint: https://github.com/login/oauth/authorize
                scopes:
                  - scope_value: repo
                    description: Full control of private repositories
                created_at: '2025-12-19T10:30:00Z'
                updated_at: '2025-12-19T10:30:00Z'
        '400':
          description: Invalid service ID format
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error: client ID is required
        '404':
          $ref: '#/components/responses/NotFoundError'
        '500':
          $ref: '#/components/responses/InternalServerError'

    put:
      summary: Update OAuth2 service
      description: |
        Update configuration for an existing OAuth2 service.

        **Update Semantics**:
        - PUT remains a full replacement, not PATCH. Existing documented omission exceptions remain unchanged
        - `credential_source` omission preserves the current source. Explicit null and unknown selectors are invalid
        - Omitted `client_registration` resolves to `cimd` for `private_key_jwt` and `static` otherwise, not to the old value. Explicit null, empty, or unknown values return `400`
        - CIMD public must repeat `client_registration: cimd`. Without it, PUT resolves to `static` and fails without an operator `client_id`
        - For manual replacements, `token_endpoint_auth_method` is evaluated from the complete request. Omitted or null means shared-secret authentication: stored requires an explicit non-empty secret, while filesystem forbids inline credentials. Protected-resource refresh retains the broker-selected method and credentials
        - `token_endpoint_auth_method: private_key_jwt` requires `client_id` to be omitted or null and `client_secret` to be omitted, null, or empty. A valid replacement removes any prior static secret and assigns the stable broker-generated HTTPS Client ID Metadata Document URL.
        - CIMD confidential to CIMD public or back keeps the client identifier URL. Switching to CIMD confidential requires a usable CIMD client-authentication key
        - Switching `static` to `cimd` replaces the operator client ID and removes a stored secret only after validation. Switching back requires an operator `client_id`. Static confidential also requires a new secret
        - A switch to `static` makes the hosted document return `404`. Providers can cache the previous document for up to 300 seconds. The broker never falls back to another token authentication method
        - Updating a service to `token_endpoint_auth_method: none` removes the stored credential.
        - `created_at` timestamp is preserved
        - `updated_at` timestamp is set to current time

        **Source Transitions**:
        - Stored to filesystem requires explicit `filesystem`, a valid resulting canonical ID, and neither inline field
        - The source change, removal of both stored credentials, and irreversible internal transition evidence commit atomically
        - Filesystem to stored requires explicit `stored` and explicitly supplied non-empty `client_id` and `client_secret`
        - Returning to stored encrypts the supplied secret before the atomic update. Files never supply reverse-transition credentials
        - No-op source selections preserve transition evidence. A source round trip never resets it or rewrites established identities
        - Omitted canonical ID preserves the existing ID. Null cannot clear a filesystem canonical ID
        - Filesystem rejects static public, both CIMD methods, and Google-flavor replacements and either inline field, including null or empty input
        - Validation failures and version conflicts leave the previous source, credentials, and transition evidence unchanged
        - Updates never read files or require available bindings. File paths and transition evidence are not administrative inputs

        **Secret Rotation**:
        - Provide a new `client_secret` to rotate stored static confidential-service credentials
        - New secret is encrypted before storage
        - Old secret is securely discarded

        **Discovery Re-run**:
        - If `enable_discovery: true`, endpoints are re-fetched
        - Useful for services that change endpoint URLs

        **Protected-resource refresh**: An omitted `issuer_uri` retains the active issuer
        only if fresh resource metadata still advertises it. A different issuer requires
        explicit selection and zero user sessions. An effective `authorization_params.resource`
        change also requires zero user sessions, even when the issuer stays the same.
        Keep the client method and the exact token authentication method. Preserve the
        client ID and secret for an unchanged issuer. For a discovery-backed refresh,
        omitted `authorization_params` keeps the map and its explicit or derived source.
        A replacement without `resource` restores the derived resource. On replacement
        with manual configuration, omission keeps the parameter map but clears the
        discovery source and resource source marker. For a manual replacement, an explicit
        empty map clears all parameters. A discovery-backed rejection writes a safe
        failure-only status without changing the active service ETag. A manual conversion
        rejected for an audience change writes no discovery status.
        A request with `discovery.resource_url` must omit `client_registration`. Presence
        returns `400` before discovery traffic. Hosted CIMD reports `cimd`. DCR reports
        `static`. The selected `discovery.client_method` remains authoritative.

        **Security**: Static confidential-service `client_secret` values are redacted in responses.
        Static public, CIMD, and DCR responses omit the property. Filesystem responses omit inline
        credentials, paths, and file values. Every response reports `credential_source` and `client_registration`.
        - `protected_resources` omitted or `null`: preserves the current protected-resource set
        - `protected_resources` present (including `[]`): replaces the entire set and requires `If-Match`

      operationId: updateService
      tags:
        - Services
      parameters:
        - $ref: '#/components/parameters/ServiceId'
        - $ref: '#/components/parameters/IfMatch'

      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ServiceUpdateRequest'
            examples:
              rotate_secret:
                summary: Rotate client secret
                value:
                  display_name: GitHub Production
                  client_id: Iv1.1234567890abcdef
                  client_secret: ghp_newsecretkey9876543210zyxwvu
                  issuer_uri: https://github.com
                  discovery:
                    enable_discovery: true
                  scopes:
                    - scope_value: repo
                      description: Full control of private repositories
                    - scope_value: read:org
                      description: Read organization membership
              add_scopes:
                summary: Add new OAuth2 scopes
                value:
                  display_name: GitHub Production
                  client_id: Iv1.1234567890abcdef
                  client_secret: ghp_secretkey1234567890abcdef
                  issuer_uri: https://github.com
                  discovery:
                    enable_discovery: true
                  scopes:
                    - scope_value: repo
                      description: Full control of private repositories
                    - scope_value: read:org
                      description: Read organization membership
                    - scope_value: workflow
                      description: Update GitHub Actions workflows
              cimd_public:
                summary: Replace a CIMD public service while keeping its hosted client identifier URL
                value:
                  display_name: ServiceNow
                  client_registration: cimd
                  token_endpoint_auth_method: none
                  oauth2_flavor: standard
                  issuer_uri: https://auth.example.com
                  discovery:
                    enable_discovery: false
                  endpoints:
                    token_endpoint: https://auth.example.com/oauth/token
                    authorize_endpoint: https://auth.example.com/oauth/authorize
              refresh_protected_resource:
                summary: Refresh discovery without changing the stored client
                value:
                  display_name: Example MCP
                  discovery:
                    enable_discovery: true
                    resource_url: https://mcp.example.com/mcp
              stored_to_filesystem:
                summary: Remove stored credentials atomically without reading files
                value:
                  canonical_id: zalando-platform
                  display_name: Zalando Platform
                  credential_source: filesystem
                  oauth2_flavor: standard
                  issuer_uri: https://oauth.example.com
                  discovery:
                    enable_discovery: false
                  endpoints:
                    token_endpoint: https://oauth.example.com/token
                    authorize_endpoint: https://oauth.example.com/authorize
              filesystem_to_stored:
                summary: Supply both explicit credentials for the reverse transition
                value:
                  canonical_id: zalando-platform
                  display_name: Zalando Platform
                  credential_source: stored
                  oauth2_flavor: standard
                  client_id: platform-client
                  client_secret: replacement-secret
                  issuer_uri: https://oauth.example.com
                  discovery:
                    enable_discovery: false
                  endpoints:
                    token_endpoint: https://oauth.example.com/token
                    authorize_endpoint: https://oauth.example.com/authorize
              preserve_filesystem_source:
                summary: Full filesystem replacement with omitted source and preserved canonical ID
                value:
                  display_name: Renamed Zalando Platform
                  oauth2_flavor: standard
                  issuer_uri: https://oauth.example.com
                  discovery:
                    enable_discovery: false
                  endpoints:
                    token_endpoint: https://oauth.example.com/token
                    authorize_endpoint: https://oauth.example.com/authorize
      responses:
        '200':
          description: Service updated successfully
          headers:
            ETag:
              description: Current strong service ETag.
              schema:
                type: string

          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Service'
              example:
                id: 770e8400-e29b-41d4-a716-446655440002
                display_name: GitHub Production
                credential_source: stored
                client_registration: static
                oauth2_flavor: github
                client_id: Iv1.1234567890abcdef
                client_secret: REDACTED
                token_endpoint_auth_method: null
                issuer_uri: https://github.com
                discovery:
                  enable_discovery: true
                  authorization_param_resource_strategy: null
                endpoints:
                  token_endpoint: https://github.com/login/oauth/access_token
                  authorize_endpoint: https://github.com/login/oauth/authorize
                scopes:
                  - scope_value: repo
                    description: Full control of private repositories
                  - scope_value: read:org
                    description: Read organization membership
                  - scope_value: workflow
                    description: Update GitHub Actions workflows
                created_at: '2025-12-19T10:30:00Z'
                updated_at: '2025-12-19T16:00:00Z'
        '400':
          description: Invalid request, discovery failure, or client registration failure
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              examples:
                discovery_failure:
                  value:
                    error: discovery failed
                    message: authorization_server_metadata_invalid
                invalid_client_registration:
                  summary: Explicit null, empty, unknown, or discovery-supplied registration
                  value:
                    error: validation failed
                    message: invalid client_registration
                incompatible_client_registration:
                  summary: Registration and authentication method do not match
                  value:
                    error: validation failed
                    message: incompatible client registration and authentication method
        '404':
          $ref: '#/components/responses/NotFoundError'
        '409':
          description: |
            Issuer or audience change with sessions, duplicate client identity or identifier,
            protected-resource ownership conflict, or concurrent service modification.
            A concurrent update leaves this request's source, credentials, and transition evidence unchanged.
            Read the current service and reconcile the full replacement before retrying.
            A stale If-Match on protected-resource replacement returns 412 instead.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              examples:
                issuer_change_with_sessions:
                  value:
                    error: conflict
                    message: issuer_change_requires_no_sessions
                resource_change_with_sessions:
                  value:
                    error: conflict
                    message: resource_change_requires_no_sessions
                protected_resource_owned:
                  value:
                    error: conflict
                    message: protected resource URI already configured for another service
                duplicate_dcr_identity:
                  value:
                    error: conflict
                    message: duplicate_client_identity
                manual_duplicate:
                  value:
                    error: conflict
                    message: Service with client_id 'Iv1.1234567890abcdef' already exists
                duplicate_identifier:
                  value:
                    error: conflict
                    message: Service identifier already exists
                concurrent_modification:
                  value:
                    error: conflict
                    message: provider version is stale
        '412':
          $ref: '#/components/responses/PreconditionFailed'
        '428':
          $ref: '#/components/responses/PreconditionRequired'
        '504':
          description: Discovery or registration exceeded the 10-second attempt deadline
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error: discovery failed
                message: timeout
        '500':
          $ref: '#/components/responses/InternalServerError'

    delete:
      summary: Delete OAuth2 service
      description: |
        Remove an OAuth2 service from the system.

        **Referential Integrity Check**:
        - Operation fails with 409 Conflict if user grants reference this service
        - Administrators must revoke or modify grants before deletion
        - This prevents breaking active user permissions

        **Irreversible**: This operation cannot be undone
      operationId: deleteService
      tags:
        - Services
      parameters:
        - $ref: '#/components/parameters/ServiceId'
      responses:
        '204':
          description: Service deleted successfully (no content)
        '400':
          description: Invalid service ID format
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error: client ID is required
        '404':
          $ref: '#/components/responses/NotFoundError'
        '409':
          description: Cannot delete service with active grants
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              examples:
                grants_exist:
                  summary: Service has active grants
                  value:
                    error: conflict
                    message: Cannot delete service 'GitHub Production' - 15 user grants reference it. Revoke grants first.
        '500':
          $ref: '#/components/responses/InternalServerError'

  /api/services/{service-id}/discovery-status:
    parameters:
      - $ref: '#/components/parameters/ServiceId'
    get:
      summary: Read the last discovery attempt and active configuration
      description: |
        Read stored status without contacting a resource or authorization server.
        A missing operator principal returns `401` before the service is read.
        A failed refresh keeps the last successful issuer, client method, and resource URL.
        Manual services return `not_applicable` and null discovery fields.
      operationId: getServiceDiscoveryStatus
      tags:
        - Services
      responses:
        '200':
          description: Stored discovery status
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ServiceDiscoveryStatus'
              examples:
                ready:
                  value:
                    status: ready
                    resource_url: https://mcp.example.com/mcp
                    issuer_uri: https://auth.example.com/tenant
                    client_method: dcr
                    last_attempt_at: '2026-10-06T10:00:00Z'
                    last_success_at: '2026-10-06T10:00:00Z'
                    failure_reason: null
                failed_refresh:
                  value:
                    status: failed
                    resource_url: https://mcp.example.com/mcp
                    issuer_uri: https://auth.example.com/tenant
                    client_method: dcr
                    last_attempt_at: '2026-10-06T11:00:00Z'
                    last_success_at: '2026-10-06T10:00:00Z'
                    failure_reason: authorization_server_metadata_invalid
                manual_service:
                  value:
                    status: not_applicable
                    resource_url: null
                    issuer_uri: null
                    client_method: null
                    last_attempt_at: null
                    last_success_at: null
                    failure_reason: null
        '401':
          description: Operator principal is missing or empty
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error: missing or empty principal
        '400':
          description: Malformed service identifier
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error: invalid service ID
                message: service ID is invalid
        '404':
          $ref: '#/components/responses/NotFoundError'
        '500':
          $ref: '#/components/responses/InternalServerError'

  /api/services/{service-id}/protected-resources:
    parameters:
      - $ref: '#/components/parameters/ServiceId'
    get:
      summary: List a service's protected resources
      operationId: listProtectedResources
      tags:
        - Services
      responses:
        '200':
          description: The service's normalized protected resource URIs.
          headers:
            ETag:
              description: Strong service ETag (version). Use as If-Match on protected-resource replacement PUTs.
              schema:
                type: string
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProtectedResourceSet'
        '400':
          $ref: '#/components/responses/BadRequestError'
        '404':
          $ref: '#/components/responses/NotFoundError'
        '500':
          $ref: '#/components/responses/InternalServerError'
    post:
      summary: Add one protected resource from the collection
      description: |
        Adds the body `resource_uri` after server-side validation and normalization. A newly added URI
        returns 201. A URI already owned by this service returns 200 with the set unchanged; a URI owned
        by another service returns 409 Conflict.
      operationId: createProtectedResource
      tags:
        - Services
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ProtectedResourceCreateRequest'
            examples:
              add:
                value:
                  resource_uri: https://api.example.com/v2
      responses:
        '201':
          description: Resource added.
          headers:
            ETag:
              description: New strong service ETag.
              schema:
                type: string
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProtectedResourceMutationResult'
        '200':
          description: The service already owned the normalized URI; set unchanged.
          headers:
            ETag:
              schema:
                type: string
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProtectedResourceMutationResult'
        '400':
          $ref: '#/components/responses/BadRequestError'
        '404':
          $ref: '#/components/responses/NotFoundError'
        '409':
          $ref: '#/components/responses/ProtectedResourceConflict'
        '500':
          $ref: '#/components/responses/InternalServerError'

  /api/services/{service-id}/protected-resources/{resource}:
    parameters:
      - $ref: '#/components/parameters/ServiceId'
      - $ref: '#/components/parameters/ResourcePathSegment'
    put:
      summary: Add one protected resource
      description: |
        Idempotently adds the resource URI identified by the fully percent-encoded final path segment.
        The server decodes the segment exactly once, then validates and normalizes it. Adding a URI this
        service already owns returns 200 with the set unchanged; a URI owned by another service returns 409.
      operationId: addProtectedResource
      tags:
        - Services
      responses:
        '201':
          description: Resource added.
          headers:
            ETag:
              description: New strong service ETag.
              schema:
                type: string
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProtectedResourceMutationResult'
        '200':
          description: The service already owned the normalized URI; set unchanged.
          headers:
            ETag:
              schema:
                type: string
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProtectedResourceMutationResult'
        '400':
          $ref: '#/components/responses/BadRequestError'
        '404':
          $ref: '#/components/responses/NotFoundError'
        '409':
          $ref: '#/components/responses/ProtectedResourceConflict'
        '500':
          $ref: '#/components/responses/InternalServerError'
    patch:
      summary: Rename a protected resource in place
      description: |
        Atomically changes the percent-encoded source URI in `{resource}` to the target URI in the body.
        A source equal to the normalized target succeeds as a no-op. A target already owned by any service
        returns 409; a source not owned by this service returns 404.
      operationId: renameProtectedResource
      tags:
        - Services
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ProtectedResourceRenameTarget'
            examples:
              rename:
                value:
                  to: https://api.example.com/v2
      responses:
        '200':
          description: Resource renamed, or unchanged when source and target are equal.
          headers:
            ETag:
              schema:
                type: string
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProtectedResourceMutationResult'
        '400':
          $ref: '#/components/responses/BadRequestError'
        '404':
          $ref: '#/components/responses/NotFoundError'
        '409':
          $ref: '#/components/responses/ProtectedResourceConflict'
        '500':
          $ref: '#/components/responses/InternalServerError'
    delete:
      summary: Remove one protected resource
      description: |
        Removes the resource identified by the fully percent-encoded path segment after decoding exactly
        once and normalizing it. A resource not owned by this service returns 404.
      operationId: removeProtectedResource
      tags:
        - Services
      responses:
        '200':
          description: Resource removed. Returns the removed normalized URI and the resulting set (FR-016).
          headers:
            ETag:
              description: New strong service ETag.
              schema:
                type: string
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProtectedResourceMutationResult'
        '400':
          $ref: '#/components/responses/BadRequestError'
        '404':
          $ref: '#/components/responses/NotFoundError'
        '500':
          $ref: '#/components/responses/InternalServerError'

  /api/permission-sets:
    post:
      summary: Create a permission set
      operationId: createPermissionSet
      tags:
        - PermissionSets
      description: |
        Creates a new named permission set grouping OAuth2 scopes across one or more
        third-party services.

        **Validation rules**:
        - `name` must be unique (409 Conflict if duplicate)
        - `service_scopes` must contain at least one entry (400)
        - Each `service_id` in `service_scopes` must reference an existing service (400)
        - Each `scopes` list may be empty for a scope-less service; supplied values
          must be non-empty (400)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreatePermissionSetRequest'
      responses:
        '201':
          description: Permission set created successfully
          headers:
            Location:
              description: URL of the created permission set
              schema:
                type: string
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PermissionSetResponse'
        '400':
          description: |
            Invalid request format, missing service_scopes, blank supplied scopes, or
            referenced service_id does not exist
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '409':
          description: Permission set name already exists
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          $ref: '#/components/responses/InternalServerError'

    get:
      summary: List permission sets
      operationId: listPermissionSets
      tags:
        - PermissionSets
      description: |
        Returns all permission sets. Optionally filter by service ID to find all
        permission sets that include scopes for a specific third-party service.
      parameters:
        - name: service_id
          in: query
          required: false
          description: UUID or canonical identifier of the service used to filter permission sets
          schema:
            type: string
        - $ref: '#/components/parameters/PreferCanonicalReferences'
      responses:
        '200':
          description: List of permission sets
          headers:
            Preference-Applied:
              $ref: '#/components/headers/PreferenceApplied'
            Vary:
              $ref: '#/components/headers/VaryPrefer'
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items:
                      $ref: '#/components/schemas/PermissionSetResponse'
        '400':
          description: Invalid query parameters
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          $ref: '#/components/responses/InternalServerError'

  /api/permission-sets/{permission-set-id}:
    get:
      summary: Get permission set by ID
      operationId: getPermissionSet
      tags:
        - PermissionSets
      parameters:
        - $ref: '#/components/parameters/PermissionSetId'
        - $ref: '#/components/parameters/PreferCanonicalReferences'
      responses:
        '200':
          description: Permission set found
          headers:
            Preference-Applied:
              $ref: '#/components/headers/PreferenceApplied'
            Vary:
              $ref: '#/components/headers/VaryPrefer'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PermissionSetResponse'
        '400':
          description: Invalid permission set ID format
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          $ref: '#/components/responses/NotFoundError'
        '500':
          $ref: '#/components/responses/InternalServerError'

    put:
      summary: Replace a permission set
      operationId: updatePermissionSet
      tags:
        - PermissionSets
      description: |
        Fully replaces a permission set's name, description, and service_scopes.
        All fields are required (PUT semantics — full replacement).

        **Note**: Changing `service_scopes` takes effect immediately; the next token exchange
        for any grant referencing this set will use the updated scopes (cache TTL ≤ 60s delay).
      parameters:
        - $ref: '#/components/parameters/PermissionSetId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreatePermissionSetRequest'
      responses:
        '200':
          description: Permission set updated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PermissionSetResponse'
        '400':
          description: |
            Invalid request format or ID, missing service_scopes, blank supplied scopes, or
            referenced service_id does not exist
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          $ref: '#/components/responses/NotFoundError'
        '409':
          description: Name conflict with another permission set
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          $ref: '#/components/responses/InternalServerError'

    delete:
      summary: Delete a permission set
      operationId: deletePermissionSet
      tags:
        - PermissionSets
      description: |
        Deletes a permission set by ID.

        **Rejected with 409 Conflict** if any agent's `permission_sets` list references this ID,
        or if any active user grant references this permission set.
        Admins must remove the permission set from all agents and revoke all active grants
        referencing it before deletion.

        **Irreversible**: This operation cannot be undone.
      parameters:
        - $ref: '#/components/parameters/PermissionSetId'
      responses:
        '204':
          description: Permission set deleted successfully (no content). Idempotent — returns 204 even if the permission set does not exist.
        '400':
          description: Invalid permission set ID format
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '409':
          description: Permission set is referenced by one or more agents or active user grants
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          $ref: '#/components/responses/InternalServerError'

  /api/agents/{agent-id}/client-credentials:
    post:
      summary: Generate or rotate broker-issued client credentials for an agent
      description: |
        Generate new broker-issued client credentials for an agent, or rotate existing
        credentials. This allows the broker to act as an OAuth2 authorization server
        and authenticate agents directly.

        **Runtime usage**: Agents use the `client_id` returned here as `client_id` at
        the `/oauth2/token` endpoint. The value is the agent's UUID.

        **First Generation** (no existing credentials):
        - Returns `201 Created` with new `client_id` (metadata) and `client_secret`
        - The `client_secret` is only returned in this response — store it securely

        **Rotation** (existing credentials):
        - Returns `200 OK` with new credentials and `previous_invalidated_at` timestamp
        - Previous credentials are immediately invalidated
        - Active tokens issued with previous credentials remain valid until expiry
      operationId: generateAgentClientCredentials
      tags:
        - Client Credentials
      parameters:
        - $ref: '#/components/parameters/AgentId'
      responses:
        '201':
          description: Client credentials generated for the first time
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BrokerClientCredentialResponse'
              example:
                client_id: 550e8400-e29b-41d4-a716-446655440000
                client_secret: brk_sec_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6
                created_at: '2025-12-19T10:30:00Z'
        '200':
          description: Client credentials rotated (previous credentials invalidated)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BrokerClientCredentialResponse'
              example:
                client_id: 550e8400-e29b-41d4-a716-446655440000
                client_secret: brk_sec_q7r8s9t0u1v2w3x4y5z6a7b8c9d0e1f2
                created_at: '2025-12-19T15:45:00Z'
                previous_invalidated_at: '2025-12-19T15:45:00Z'
        '400':
          description: Invalid agent ID format
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error: invalid request
                message: agent ID must be a valid UUID
        '404':
          $ref: '#/components/responses/NotFoundError'
        '500':
          $ref: '#/components/responses/InternalServerError'

    get:
      summary: Get credential metadata (never returns secret)
      description: |
        Retrieve metadata about an agent's broker-issued client credentials.

        **Security**: The `client_secret` is never returned by this endpoint.
        Only the `client_id` and timestamps are returned.

        Returns `404 Not Found` if no credentials have been generated for the agent.
      operationId: getAgentClientCredentials
      tags:
        - Client Credentials
      parameters:
        - $ref: '#/components/parameters/AgentId'
      responses:
        '200':
          description: Credential metadata retrieved successfully
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BrokerClientCredentialMetadata'
              example:
                client_id: 550e8400-e29b-41d4-a716-446655440000
                created_at: '2025-12-19T10:30:00Z'
                rotated_at: '2025-12-19T15:45:00Z'
        '400':
          description: Invalid agent ID format
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error: invalid request
                message: agent ID must be a valid UUID
        '404':
          $ref: '#/components/responses/NotFoundError'
        '500':
          $ref: '#/components/responses/InternalServerError'

    delete:
      summary: Revoke broker-issued credentials
      description: |
        Revoke an agent's broker-issued client credentials.

        After revocation, the agent can no longer authenticate using these credentials.
        Active tokens issued with these credentials remain valid until expiry.

        **Irreversible**: New credentials must be generated via POST after revocation.
      operationId: revokeAgentClientCredentials
      tags:
        - Client Credentials
      parameters:
        - $ref: '#/components/parameters/AgentId'
      responses:
        '204':
          description: Client credentials revoked successfully (no content)
        '400':
          description: Invalid agent ID format
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error: invalid request
                message: agent ID must be a valid UUID
        '404':
          $ref: '#/components/responses/NotFoundError'
        '500':
          $ref: '#/components/responses/InternalServerError'

  /api/oauth2-server/signing-keys:
    post:
      summary: Add a new signing key
      description: |
        Generate a new signing key for the broker's OAuth2 server.
        The new key is marked as `is_current: true` but does not start signing tokens
        until `activates_at` (600 s after key preparation). This covers a 300 s
        compliant JWKS cache lifetime and a 30 s rebuild budget with 270 s of margin
        under healthy-service conditions. External verifiers must refresh their own
        caches; publication cannot force them to learn the key.

        **Algorithm Support**:
        - `ES256`: ECDSA using P-256 curve and SHA-256 (default, only supported algorithm)

        **Key Lifecycle**:
        - New key is available on the next direct `GET /oauth2/jwks.json` after storage commits
        - New key starts signing tokens only after `activates_at` (key preparation + 600 s)
        - Previous current key continues signing during the grace period
        - Private key material is never exposed via the API
      operationId: createSigningKey
      tags:
        - Signing Keys
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SigningKeyCreateRequest'
            examples:
              default_algorithm:
                summary: Use default algorithm (ES256)
                value: {}
              explicit_es256:
                summary: Explicit ES256
                value:
                  algorithm: ES256
      responses:
        '201':
          description: Signing key created successfully
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SigningKeyResponse'
              example:
                kid: key-2025-12-19-001
                algorithm: ES256
                is_current: true
                activates_at: '2025-12-19T10:40:00Z'
                created_at: '2025-12-19T10:30:00Z'
        '400':
          description: Invalid algorithm specified
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error: validation failed
                message: "algorithm must be ES256"
        '500':
          $ref: '#/components/responses/InternalServerError'

    get:
      summary: List active signing keys (no private material)
      description: |
        Retrieve all active signing keys for the broker's OAuth2 server.

        **Security**: Private key material is never included in the response.
        Only public metadata (kid, algorithm, status, timestamps) is returned.

        Keys are returned in creation order (newest first).
      operationId: listSigningKeys
      tags:
        - Signing Keys
      responses:
        '200':
          description: Signing keys retrieved successfully
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SigningKeyListResponse'
              examples:
                multiple_keys:
                  summary: Multiple signing keys
                  value:
                    items:
                      - kid: key-2025-12-19-001
                        algorithm: ES256
                        is_current: true
                        activates_at: '2025-12-19T10:40:00Z'
                        created_at: '2025-12-19T10:30:00Z'
                      - kid: key-2025-12-01-001
                        algorithm: ES256
                        is_current: false
                        activates_at: '2025-12-01T09:00:00Z'
                        created_at: '2025-12-01T09:00:00Z'
                single_key:
                  summary: Single signing key
                  value:
                    items:
                      - kid: key-2025-12-19-001
                        algorithm: ES256
                        is_current: true
                        activates_at: '2025-12-19T10:00:00Z'
                        created_at: '2025-12-19T10:00:00Z'
        '500':
          $ref: '#/components/responses/InternalServerError'

  /api/oauth2-server/signing-keys/{kid}/current:
    put:
      summary: Promote signing key to current
      description: |
        Promote an existing signing key to be the current signing key.
        The current key is used for signing new tokens.

        **Behavior**:
        - The specified key becomes `is_current: true`
        - The previous current key is set to `is_current: false`
        - Previous key remains active for token verification
      operationId: promoteSigningKey
      tags:
        - Signing Keys
      parameters:
        - $ref: '#/components/parameters/SigningKeyId'
      responses:
        '200':
          description: Signing key promoted to current
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SigningKeyResponse'
              example:
                kid: key-2025-12-01-001
                algorithm: ES256
                is_current: true
                created_at: '2025-12-01T09:00:00Z'
        '404':
          description: Signing key not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error: not found
                message: "Signing key with kid 'key-unknown' does not exist"
        '500':
          $ref: '#/components/responses/InternalServerError'

  /api/oauth2-server/signing-keys/{kid}:
    delete:
      summary: Remove signing key (soft delete)
      description: |
        Remove a signing key from the active key set.

        **Soft Delete**: The key is deactivated but retained for audit purposes.
        Tokens previously signed with this key can no longer be verified.

        **Constraints**:
        - Cannot delete the last remaining signing key (returns `409 Conflict`)
        - Cannot delete the current signing key without first promoting another key
      operationId: deleteSigningKey
      tags:
        - Signing Keys
      parameters:
        - $ref: '#/components/parameters/SigningKeyId'
      responses:
        '204':
          description: Signing key removed successfully (no content)
        '404':
          description: Signing key not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error: not found
                message: "Signing key with kid 'key-unknown' does not exist"
        '409':
          description: Cannot delete the last remaining signing key or the current signing key
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              examples:
                last_key:
                  summary: Last remaining signing key
                  value:
                    error: last_key
                    message: cannot delete the last remaining signing key
                current_key:
                  summary: Current signing key
                  value:
                    error: current_key
                    message: promote another signing key before removing the current key
        '500':
          $ref: '#/components/responses/InternalServerError'

  /api/cimd-client-keys:
    post:
      summary: Generate a CIMD client-authentication key
      description: |
        Generate an ES256 key in the distinct broker-global CIMD client-authentication key domain.
        This key signs outbound `private_key_jwt` client assertions only; it never signs broker-issued
        access tokens.

        **Key Lifecycle**:
        - The first generated CIMD key becomes usable immediately.
        - A later generated current key is published in the CIMD JWK Set during the existing activation
          grace period, while the previous usable key continues signing assertions.
        - Private key material is never exposed by this API.
      operationId: generateCIMDClientKey
      tags:
        - CIMD Client Keys
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CIMDClientKeyGenerateRequest'
            examples:
              default_algorithm:
                summary: Use the default algorithm (ES256)
                value: {}
              explicit_es256:
                summary: Explicit ES256
                value:
                  algorithm: ES256
      responses:
        '201':
          description: CIMD client-authentication key generated successfully
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CIMDClientKey'
              example:
                kid: cimd-key-2026-09-21-001
                algorithm: ES256
                is_current: true
                activates_at: '2026-09-21T10:30:00Z'
                created_at: '2026-09-21T10:30:00Z'
        '400':
          $ref: '#/components/responses/BadRequestError'
        '500':
          $ref: '#/components/responses/InternalServerError'

    get:
      summary: List active CIMD client-authentication keys
      description: |
        Retrieve active CIMD client-authentication keys in creation order (newest first).
        Responses contain metadata only and never include private key bytes, PEM, ciphertext,
        branch-key identifiers, or client assertions.
      operationId: listCIMDClientKeys
      tags:
        - CIMD Client Keys
      responses:
        '200':
          description: CIMD client-authentication keys retrieved successfully
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CIMDClientKeyListResponse'
              example:
                items:
                  - kid: cimd-key-2026-09-21-001
                    algorithm: ES256
                    is_current: true
                    activates_at: '2026-09-21T10:40:00Z'
                    created_at: '2026-09-21T10:30:00Z'
                  - kid: cimd-key-2026-09-01-001
                    algorithm: ES256
                    is_current: false
                    activates_at: '2026-09-01T09:00:00Z'
                    created_at: '2026-09-01T09:00:00Z'
        '500':
          $ref: '#/components/responses/InternalServerError'

  /api/sessions/sweep:
    post:
      summary: Refresh third-party sessions whose access tokens expire soon
      description: |
        Refreshes every stored third-party user session whose access token expires within the
        lookahead window, across all principals and services. Intended to be called on a schedule
        by an external job, such as the CronJob the broker Helm chart renders when
        `sessionSweep.enabled` is true; the broker runs no internal scheduler.

        **Selection**: A session is a candidate when its access token has a recorded expiry at or
        before `now + lookahead_duration`. Sessions with no access-token expiry are never
        candidates. Healthy sessions outside the window are not evaluated and do not appear in any
        count.

        **Processing**: Candidates are read in pages of `page_size` and refreshed one at a time
        using each session's stored refresh token. Newly issued tokens are encrypted before they
        are stored. A candidate that another refresh already renewed, or that was deleted during
        the sweep, is counted as `skipped`. A candidate that cannot be refreshed (no usable refresh
        token, upstream error) is counted as `failed`. The sweep then continues and the stored
        tokens are left unchanged. When the provider rejects the refresh token with a 4xx
        `invalid_grant` (other than `429`), the broker records the rejection and counts that
        attempt as `failed`. Later sweeps exclude the session from their candidates, so it
        contributes to no count and the provider is not contacted again until the user reconnects.

        **Dry run**: With `dry_run: true`, candidates are classified without calling any upstream
        provider and without writing. Candidates that would be refreshed are counted in
        `refreshed`.

        **Concurrency**: Safe to invoke concurrently on several broker replicas. Each session is
        refreshed under a row lock, so concurrent sweeps never refresh the same session twice and
        never leave a partial update. Concurrent sweeps do not divide the work between them.

        **Duration**: The request runs until every candidate has been evaluated. The server
        removes its write timeout for this operation. The caller controls the overall timeout. If
        the client disconnects, the sweep stops after the session in progress, and sessions already
        refreshed stay refreshed.

        **Response semantics**: `200 OK` is returned whenever the sweep itself completes, even if
        every candidate failed. `500` is returned if the sweep cannot continue because of an
        infrastructure failure or the request lacks an operator principal. The response contains
        only counts and never token material.

        Every invocation is audit-logged with the operator principal.
      operationId: sweepSessions
      tags:
        - Sessions
      requestBody:
        required: false
        description: |
          All fields are optional. An empty body and `{}` are equivalent. The
          `Content-Type: application/json` header is still required (see Browser Request Protection).
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SessionSweepRequest'
            examples:
              defaults:
                summary: Use configured lookahead and page size
                value: {}
              scheduled:
                summary: Two-hour window for the chart's hourly CronJob
                value:
                  lookahead_duration: PT2H
              dry_run_wide:
                summary: Preview a one-day window without refreshing
                value:
                  lookahead_duration: P1D
                  dry_run: true
                  page_size: 500
      responses:
        '200':
          description: The sweep completed; counts describe per-session outcomes
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SessionSweepResult'
              examples:
                mixed:
                  summary: Some sessions refreshed, one could not be refreshed
                  value:
                    refreshed: 42
                    skipped: 3
                    failed: 1
                    total_evaluated: 46
                    dry_run: false
                all_failed:
                  summary: Every candidate failed (still 200)
                  value:
                    refreshed: 0
                    skipped: 0
                    failed: 7
                    total_evaluated: 7
                    dry_run: false
                dry_run:
                  summary: Dry run
                  value:
                    refreshed: 120
                    skipped: 0
                    failed: 4
                    total_evaluated: 124
                    dry_run: true
        '400':
          description: Invalid request body
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              examples:
                invalid_duration:
                  summary: Unsupported or malformed duration
                  value:
                    error: invalid request body
                    message: lookahead_duration must be an ISO 8601 duration of days, hours, minutes, or seconds (e.g. PT5M)
                non_positive_duration:
                  summary: Zero duration
                  value:
                    error: invalid request body
                    message: lookahead_duration must be an ISO 8601 duration of days, hours, minutes, or seconds (e.g. PT5M)
                page_size_out_of_range:
                  summary: Page size outside 1..1000
                  value:
                    error: invalid request body
                    message: page_size must be between 1 and 1000
                unknown_field:
                  summary: Unknown property
                  value:
                    error: invalid request body
                    message: 'unknown field "lookahead"'
        '500':
          description: |
            The sweep could not continue because the session store is unavailable, or the request
            reached the broker without an operator principal (`server_misconfiguration`). Sessions
            refreshed before the failure stay refreshed.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              examples:
                storage_unavailable:
                  value:
                    error: internal server error
                missing_operator:
                  value:
                    error: server_misconfiguration
                    message: operator principal required for session sweep

  /api/cimd-client-keys/{kid}/current:
    put:
      summary: Promote a CIMD client-authentication key immediately
      description: |
        Promote an existing CIMD client-authentication key to current immediately. The promoted key
        becomes usable to sign subsequent client assertions at promotion time. The prior key remains
        available for verification through the CIMD JWK Set.
      operationId: promoteCIMDClientKey
      tags:
        - CIMD Client Keys
      parameters:
        - $ref: '#/components/parameters/CIMDClientKeyId'
      responses:
        '200':
          description: CIMD client-authentication key promoted successfully
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CIMDClientKey'
              example:
                kid: cimd-key-2026-09-01-001
                algorithm: ES256
                is_current: true
                activates_at: '2026-09-21T10:45:00Z'
                created_at: '2026-09-01T09:00:00Z'
        '404':
          $ref: '#/components/responses/NotFoundError'
        '500':
          $ref: '#/components/responses/InternalServerError'

  /api/cimd-client-keys/{kid}:
    delete:
      summary: Remove a non-signing CIMD client-authentication key
      description: |
        Remove a CIMD client-authentication key that is neither the last key, the current key, nor an
        effective-current key that can still sign assertions. Removed keys are no longer published in
        the CIMD JWK Set.
      operationId: deleteCIMDClientKey
      tags:
        - CIMD Client Keys
      parameters:
        - $ref: '#/components/parameters/CIMDClientKeyId'
      responses:
        '204':
          description: CIMD client-authentication key removed successfully (no content)
        '404':
          $ref: '#/components/responses/NotFoundError'
        '409':
          description: Cannot remove the last, current, or effective-current CIMD client-authentication key
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          $ref: '#/components/responses/InternalServerError'

components:
  parameters:
    ExternalIdSelector:
      name: external_id
      in: query
      required: true
      description: Exact, case-sensitive external governance ID that selects the agent. Percent-encode reserved characters.
      schema:
        type: string
        minLength: 1
        maxLength: 255
      example: urn:example:ai-registry:agent:7f3a9c
    AgentId:
      name: agent-id
      in: path
      required: true
      description: UUID or canonical identifier of the agent
      schema:
        type: string
      examples:
        uuid: { value: 550e8400-e29b-41d4-a716-446655440000 }
        canonical: { value: research-agent }

    PreferCanonicalReferences:
      name: Prefer
      in: header
      required: false
      description: |
        Request canonical rendering of nested service and permission-set references. The default
        representation uses UUIDs. May be combined with other Prefer preferences (e.g.
        `return=minimal, reference-id=canonical`); unrecognized preferences are ignored.
      schema:
        type: string
      example: reference-id=canonical
    ServiceId:
      name: service-id
      in: path
      required: true
      description: UUID or canonical identifier of the OAuth2 service
      schema:
        type: string
      examples:
        uuid: { value: 770e8400-e29b-41d4-a716-446655440002 }
        canonical: { value: github-prod }

    ResourcePathSegment:
      name: resource
      in: path
      required: true
      description: |
        Normalized protected resource URI, fully RFC 3986 percent-encoded as one path segment (`:` to
        `%3A`, `/` to `%2F`, `?` to `%3F`, and `#` to `%23`). The server preserves the escaped segment,
        decodes it exactly once, then validates it as an absolute URI and normalizes its trailing slash.
      schema:
        type: string
      example: https%3A%2F%2Fapi.example.com%2Fv2

    IfMatch:
      name: If-Match
      in: header
      required: false
      description: |
        Required when a service update request includes `protected_resources`, because that field replaces
        the complete set. Supply the current strong service ETag from a prior GET or protected-resource
        collection GET. Omit it only when `protected_resources` is omitted or `null`; a missing required
        header returns 428 and a stale value returns 412.
      schema:
        type: string

    PermissionSetId:
      name: permission-set-id
      in: path
      required: true
      description: UUID or canonical identifier of the permission set
      schema:
        type: string
      examples:
        uuid: { value: a1b2c3d4-e5f6-7890-abcd-ef1234567890 }
        canonical: { value: github-read }
    SigningKeyId:
      name: kid
      in: path
      required: true
      description: Key identifier of the signing key
      schema:
        type: string
        example: key-2025-12-19-001
    CIMDClientKeyId:
      name: kid
      in: path
      required: true
      description: Globally unique identifier of the CIMD client-authentication key
      schema:
        type: string
        example: cimd-key-2026-09-21-001

  headers:
    PreferenceApplied:
      description: "Sent only when Prefer: reference-id=canonical was honored."
      schema:
        type: string
        enum: [reference-id=canonical]
    VaryPrefer:
      description: Indicates that the read representation varies by the `Prefer` request header.
      schema:
        type: string
        enum: [Prefer]
  schemas:
    HealthResponse:
      type: object
      required:
        - status
        - timestamp
        - uptime_seconds
      properties:
        status:
          type: string
          enum:
            - healthy
            - starting
            - shutting_down
            - unhealthy
          description: Current health state of the server
          example: healthy
        timestamp:
          type: string
          format: date-time
          description: ISO 8601 timestamp of the health check (RFC3339 format)
          example: '2025-12-19T10:30:00Z'
        uptime_seconds:
          type: integer
          format: int64
          minimum: 0
          description: Number of seconds since the server started
          example: 3600
      example:
        status: healthy
        timestamp: '2025-12-19T10:30:00Z'
        uptime_seconds: 3600

    Agent:
      description: |
        Admin Agent timestamps use whole-second UTC RFC 3339 presentation.
        client_uris is presented in ascending Go string byte order on a copy;
        stored order and other collections are unchanged.
      type: object
      required:
        - id
        - display_name
        - description
        - created_at
        - updated_at
      properties:
        id:
          type: string
          format: uuid
          description: Unique identifier for the agent (system-generated UUID)
          example: 550e8400-e29b-41d4-a716-446655440000
          readOnly: true
        canonical_id:
          type: string
          nullable: true
          readOnly: true
          description: Optional canonical administrative identifier
          example: research-agent
        client_id:
          type: string
          nullable: true
          description: |
            OAuth2 client_id for this agent. Absent when the agent has no upstream
            client_id (e.g. CIMD agents resolved via client_uris). When present,
            used to identify the agent in OAuth2 flows.
          example: agent-research-assistant
          maxLength: 255
        external_id:
          type: string
          nullable: true
          description: Optional external governance identifier; valid UTF-8 with 1–255 Unicode code points, no NUL, and globally unique when set, by exact case-sensitive equality.
          example: gov-sys-12345
          minLength: 1
          maxLength: 255
        display_name:
          type: string
          description: Human-readable name for the agent displayed to end users
          example: Research Assistant
          minLength: 1
          maxLength: 255
        description:
          type: string
          description: Detailed description of the agent's purpose and capabilities
          example: AI assistant that helps with academic research by accessing papers and datasets
          minLength: 1
          maxLength: 1000
        governance_url:
          type: string
          format: uri
          nullable: true
          description: URL to governance and compliance information (must be HTTP/HTTPS)
          example: https://example.com/agents/research-assistant/governance
        user_documentation_url:
          type: string
          format: uri
          nullable: true
          description: URL to user-facing documentation (must be HTTP/HTTPS)
          example: https://example.com/docs/research-assistant
        agent_interface_url:
          type: string
          format: uri
          nullable: true
          description: URL where users can interact with the agent (must be HTTP/HTTPS)
          example: https://example.com/agent/research-assistant
        service_requirements:
          type: array
          nullable: true
          description: |
            List of third-party OAuth2 services that the agent requires access to.
            - **Mandatory requirements**: Block authorization if user lacks active sessions with required scopes
            - **Optional requirements**: Display in consent UI but never block authorization
            - Referential integrity enforced: service_id must exist, required_scopes must match service definition
          items:
            $ref: '#/components/schemas/ServiceRequirement'
          example:
            - service_id: 550e8400-e29b-41d4-a716-446655440001
              service_name: GitHub
              requirement_type: mandatory
              required_scopes:
                - repo
                - user:email
            - service_id: 550e8400-e29b-41d4-a716-446655440002
              service_name: Google Drive
              requirement_type: optional
              required_scopes:
                - https://www.googleapis.com/auth/drive.readonly
        permission_sets:
          type: array
          description: Permission set declarations for this agent (FR-006).
          items:
            $ref: '#/components/schemas/AgentPermissionSetEntry'
          example:
            - permission_set_id: a1b2c3d4-e5f6-7890-abcd-ef1234567890
              requirement_type: mandatory
        redirect_uris:
          type: array
          nullable: true
          description: |
            List of allowed redirect URIs for OAuth2 authorization code flow.
            When the broker acts as an OAuth2 server, only these URIs are accepted
            as valid redirect targets for this agent.
            Each entry is an `https` URI or an `http` URI with host `localhost`, `127.0.0.1`, or `[::1]`.
            The port is ignored when matching loopback `http` URIs.
            An entry can also be a native-app private-use scheme URI (RFC 8252 §7.1).
            It can use `scheme:/path` (`com.example.app:/oauth2redirect/example-provider`) or `scheme://host/path` (`github-app://oauth/callback`).
            A private-use URI must contain only ASCII letters, digits, and `-._~:/?@!$&'()+,;=`.
            It must be canonical, have a lowercase scheme of at least two characters, and have no opaque path, userinfo, or empty query delimiter.
            The scheme cannot be `http`, `https`, `about`, `blob`, `data`, `file`, `filesystem`, `ftp`, `javascript`, `vbscript`, `view-source`, `ws`, or `wss`.
            The URI must use either a single-slash path or a nonempty lowercase authority host.
            An authority cannot have an empty, zero, or out-of-range port, and no path segment can be `.` or `..`.
            A private-use URI matches only character for character.
            No entry may contain a fragment. Invalid entries cause `400 validation failed`.
            Accepted entries are stored and returned unchanged.
            For an agent with `client_uris`, the fetched document's `redirect_uris` are authoritative.
            This list then only allowlists private-use callbacks that the document also declares (ADR 042).
          items:
            type: string
            format: uri
          example:
            - https://agent.example.com/callback
            - http://localhost:9002/callback
            - github-app://oauth/callback
        allowed_scopes:
          type: array
          nullable: true
          description: |
            List of OAuth2 scopes that this agent is permitted to request
            when the broker acts as an OAuth2 authorization server.
          items:
            type: string
          example:
            - openid
            - profile
            - email
        client_uris:
          type: array
          nullable: true
          description: |
            Pre-registered Client ID Metadata Document URLs for this agent.
            Each entry must be a globally unique HTTPS URL. A `*` can replace one complete,
            non-empty path segment.
            The broker uses an exact entry before matching patterns. It rejects ambiguous pattern
            matches and URI paths containing literal `\`, `%2F`, or `%5C`.
            When CIMD is enabled, authorization requests with a matching URL as client_id
            are resolved to this agent via CIMD fetch, validation, and cache.
          items:
            type: string
            format: uri
          example:
            - https://chatgpt.com/oauth/codex/*/client.json
        created_at:
          type: string
          format: date-time
          description: Timestamp when the agent was created (whole-second UTC RFC 3339 format)
          example: '2025-12-19T10:30:00Z'
          readOnly: true
        updated_at:
          type: string
          format: date-time
          description: Timestamp when the agent was last updated (whole-second UTC RFC 3339 format)
          example: '2025-12-19T15:45:00Z'
          readOnly: true

    AgentCreateRequest:
      type: object
      required:
        - display_name
        - description
        - permission_sets
      properties:
        canonical_id:
          type: string
          nullable: true
          minLength: 1
          maxLength: 128
          pattern: '^[A-Za-z0-9._-]+$'
          example: research-agent
        client_id:
          type: string
          description: |
            OAuth2 client_id for this agent. Optional — omit for agents resolved
            via client_uris (CIMD) or agents that do not need an upstream client_id.
            When provided, must be non-empty.
          example: agent-research-assistant
          maxLength: 255
        external_id:
          type: string
          nullable: true
          description: Optional external governance identifier; valid UTF-8 with 1–255 Unicode code points, no NUL, and globally unique when set. Collection PUT requires a supplied value to equal its selector and rejects null.
          example: gov-sys-12345
          minLength: 1
          maxLength: 255
        display_name:
          type: string
          description: Human-readable name for the agent
          example: Research Assistant
          minLength: 1
          maxLength: 255
        description:
          type: string
          description: Detailed description of the agent's purpose and capabilities
          example: AI assistant that helps with academic research by accessing papers and datasets
          minLength: 1
          maxLength: 1000
        governance_url:
          type: string
          format: uri
          nullable: true
          description: URL to governance and compliance information (must be HTTP/HTTPS)
          example: https://example.com/agents/research-assistant/governance
        user_documentation_url:
          type: string
          format: uri
          nullable: true
          description: URL to user-facing documentation (must be HTTP/HTTPS)
          example: https://example.com/docs/research-assistant
        agent_interface_url:
          type: string
          format: uri
          nullable: true
          description: URL where users can interact with the agent (must be HTTP/HTTPS)
          example: https://example.com/agent/research-assistant
        service_requirements:
          type: array
          nullable: true
          description: |
            List of third-party OAuth2 services that the agent requires access to.
            Referential integrity is validated: service_id must exist and supplied required_scopes must match the service definition. Omit or provide an empty list for scope-less services.
          items:
            $ref: '#/components/schemas/ServiceRequirementRequest'
          example:
            - service_id: 550e8400-e29b-41d4-a716-446655440001
              requirement_type: mandatory
              required_scopes:
                - repo
                - user:email
        permission_sets:
          type: array
          description: |
            List of permission set declarations for this agent (FR-006).
            Must contain at least one entry — an empty list or omitted field is a 400 error.
            All referenced permission_set_id values must exist.
          items:
            $ref: '#/components/schemas/AgentPermissionSetEntry'
          minItems: 1
          example:
            - permission_set_id: a1b2c3d4-e5f6-7890-abcd-ef1234567890
              requirement_type: mandatory
        redirect_uris:
          type: array
          nullable: true
          description: |
            List of allowed redirect URIs for OAuth2 authorization code flow.
            When the broker acts as an OAuth2 server, only these URIs are accepted
            as valid redirect targets for this agent.
            Each entry is an `https` URI or an `http` URI with host `localhost`, `127.0.0.1`, or `[::1]`.
            The port is ignored when matching loopback `http` URIs.
            An entry can also be a native-app private-use scheme URI (RFC 8252 §7.1).
            It can use `scheme:/path` (`com.example.app:/oauth2redirect/example-provider`) or `scheme://host/path` (`github-app://oauth/callback`).
            A private-use URI must contain only ASCII letters, digits, and `-._~:/?@!$&'()+,;=`.
            It must be canonical, have a lowercase scheme of at least two characters, and have no opaque path, userinfo, or empty query delimiter.
            The scheme cannot be `http`, `https`, `about`, `blob`, `data`, `file`, `filesystem`, `ftp`, `javascript`, `vbscript`, `view-source`, `ws`, or `wss`.
            The URI must use either a single-slash path or a nonempty lowercase authority host.
            An authority cannot have an empty, zero, or out-of-range port, and no path segment can be `.` or `..`.
            A private-use URI matches only character for character.
            No entry may contain a fragment. Invalid entries cause `400 validation failed`.
            Accepted entries are stored and returned unchanged.
            For an agent with `client_uris`, the fetched document's `redirect_uris` are authoritative.
            This list then only allowlists private-use callbacks that the document also declares (ADR 042).
          items:
            type: string
            format: uri
          example:
            - https://agent.example.com/callback
            - http://localhost:9002/callback
            - github-app://oauth/callback
        allowed_scopes:
          type: array
          nullable: true
          description: |
            List of OAuth2 scopes that this agent is permitted to request
            when the broker acts as an OAuth2 authorization server.
          items:
            type: string
          example:
            - openid
            - profile
            - email
        client_uris:
          type: array
          nullable: true
          description: |
            Pre-registered Client ID Metadata Document URLs for this agent.
            Each entry must be a globally unique HTTPS URL. A `*` can replace one complete,
            non-empty path segment.
            The broker uses an exact entry before matching patterns. It rejects ambiguous pattern
            matches and URI paths containing literal `\`, `%2F`, or `%5C`.
            Returns 400 for malformed URLs. Returns 409 Conflict when a URI is already
            registered to a different agent.
          items:
            type: string
            format: uri
          example:
            - https://chatgpt.com/oauth/codex/*/client.json

    AgentUpdateRequest:
      type: object
      required:
        - display_name
        - description
        - permission_sets
      properties:
        canonical_id:
          type: string
          nullable: true
          minLength: 1
          maxLength: 128
          pattern: '^[A-Za-z0-9._-]+$'
          description: Omitted preserves the current canonical ID; null removes it; a string replaces it.
          example: research-agent
        client_id:
          type: string
          nullable: true
          description: |
            OAuth2 client_id for this agent.

            **Three-state semantics**:
            - **Omitted** (key absent from JSON): existing value is preserved
            - **Explicit null** (`"client_id": null`): clears the client_id (sets to NULL)
            - **String value** (`"client_id": "foo"`): updates the client_id; must be non-empty

            Agents without a client_id cannot participate in proxy-mode OAuth2 flows.
          example: agent-research-assistant
          maxLength: 255
        external_id:
          type: string
          nullable: true
          description: Optional external governance identifier; valid UTF-8 with 1–255 Unicode code points, no NUL, and globally unique when set. Omission or null clears it; retaining the current agent's value is not a conflict.
          example: gov-sys-12345
          minLength: 1
          maxLength: 255
        display_name:
          type: string
          description: Human-readable name for the agent
          example: Advanced Research Assistant
          minLength: 1
          maxLength: 255
        description:
          type: string
          description: Detailed description of the agent's purpose and capabilities
          example: AI assistant that helps with academic research by accessing papers and datasets
          minLength: 1
          maxLength: 1000
        governance_url:
          type: string
          format: uri
          nullable: true
          description: URL to governance and compliance information (must be HTTP/HTTPS)
          example: https://example.com/governance/v2/research-assistant
        user_documentation_url:
          type: string
          format: uri
          nullable: true
          description: URL to user-facing documentation (must be HTTP/HTTPS)
          example: https://docs.example.com/research-assistant
        agent_interface_url:
          type: string
          format: uri
          nullable: true
          description: URL where users can interact with the agent (must be HTTP/HTTPS)
          example: https://example.com/agent/research-assistant
        service_requirements:
          type: array
          nullable: true
          description: |
            List of third-party OAuth2 services that the agent requires access to.
            Referential integrity is validated: service_id must exist and supplied required_scopes must match the service definition. Omit or provide an empty list for scope-less services.
          items:
            $ref: '#/components/schemas/ServiceRequirementRequest'
          example:
            - service_id: 550e8400-e29b-41d4-a716-446655440001
              requirement_type: mandatory
              required_scopes:
                - repo
                - user:email
        permission_sets:
          type: array
          description: |
            List of permission set declarations for this agent (FR-006).
            Must contain at least one entry — an empty list or omitted field is a 400 error.
            All referenced permission_set_id values must exist.
          items:
            $ref: '#/components/schemas/AgentPermissionSetEntry'
          minItems: 1
          example:
            - permission_set_id: a1b2c3d4-e5f6-7890-abcd-ef1234567890
              requirement_type: mandatory
        redirect_uris:
          type: array
          nullable: true
          description: |
            List of allowed redirect URIs for OAuth2 authorization code flow.
            When the broker acts as an OAuth2 server, only these URIs are accepted
            as valid redirect targets for this agent.
            Each entry is an `https` URI or an `http` URI with host `localhost`, `127.0.0.1`, or `[::1]`.
            The port is ignored when matching loopback `http` URIs.
            An entry can also be a native-app private-use scheme URI (RFC 8252 §7.1).
            It can use `scheme:/path` (`com.example.app:/oauth2redirect/example-provider`) or `scheme://host/path` (`github-app://oauth/callback`).
            A private-use URI must contain only ASCII letters, digits, and `-._~:/?@!$&'()+,;=`.
            It must be canonical, have a lowercase scheme of at least two characters, and have no opaque path, userinfo, or empty query delimiter.
            The scheme cannot be `http`, `https`, `about`, `blob`, `data`, `file`, `filesystem`, `ftp`, `javascript`, `vbscript`, `view-source`, `ws`, or `wss`.
            The URI must use either a single-slash path or a nonempty lowercase authority host.
            An authority cannot have an empty, zero, or out-of-range port, and no path segment can be `.` or `..`.
            A private-use URI matches only character for character.
            No entry may contain a fragment. Invalid entries cause `400 validation failed`.
            Accepted entries are stored and returned unchanged.
            For an agent with `client_uris`, the fetched document's `redirect_uris` are authoritative.
            This list then only allowlists private-use callbacks that the document also declares (ADR 042).
          items:
            type: string
            format: uri
          example:
            - https://agent.example.com/callback
            - http://localhost:9002/callback
            - github-app://oauth/callback
        allowed_scopes:
          type: array
          nullable: true
          description: |
            List of OAuth2 scopes that this agent is permitted to request
            when the broker acts as an OAuth2 authorization server.
          items:
            type: string
          example:
            - openid
            - profile
            - email
        client_uris:
          type: array
          nullable: true
          description: |
            Pre-registered Client ID Metadata Document URLs for this agent.
            Replaces the previous list on PUT (full replacement semantics).
            Each entry must be a globally unique HTTPS URL. A `*` can replace one complete,
            non-empty path segment.
            The broker uses an exact entry before matching patterns. It rejects ambiguous pattern
            matches and URI paths containing literal `\`, `%2F`, or `%5C`.
            Returns 400 for malformed URLs. Returns 409 Conflict when a URI is already
            registered to a different agent.
          items:
            type: string
            format: uri
          example:
            - https://chatgpt.com/oauth/codex/*/client.json

    ServiceRequirement:
      type: object
      required:
        - service_id
        - requirement_type
      properties:
        service_id:
          type: string
          format: uuid
          description: UUID of the third-party OAuth2 service this requirement references
          example: 550e8400-e29b-41d4-a716-446655440001
        service_name:
          type: string
          description: Display name of the referenced service (resolved from service configuration)
          example: GitHub
          readOnly: true
        requirement_type:
          type: string
          enum:
            - mandatory
            - optional
          description: |
            - **mandatory**: Authorization fails if user lacks active session with required scopes
            - **optional**: Displayed in consent UI but never blocks authorization
          example: mandatory
        required_scopes:
          type: array
          items:
            type: string
          description: |
            Optional OAuth2 scopes the agent requires from this service. Must be empty when
            `require_all_scopes` is true. Omit or provide an empty array for a scope-less service.
            Supplied scopes are case-sensitive and must match service definitions.
          example:
            - repo
            - user:email
        require_all_scopes:
          type: boolean
          default: false
          description: |
            When true, the agent receives the full scope union its granted permission sets
            provide for this service; required_scopes must be empty. When false (default),
            required_scopes is the per-agent scope ceiling.

    ServiceRequirementRequest:
      type: object
      required:
        - service_id
        - requirement_type
      properties:
        service_id:
          type: string
          description: UUID or canonical identifier of the third-party OAuth2 service
          examples:
            uuid: { value: 550e8400-e29b-41d4-a716-446655440001 }
            canonical: { value: github-prod }
        requirement_type:
          type: string
          enum:
            - mandatory
            - optional
          description: |
            - **mandatory**: Authorization fails if user lacks active session with required scopes
            - **optional**: Displayed in consent UI but never blocks authorization
          example: mandatory
        required_scopes:
          type: array
          items:
            type: string
          description: |
            Optional OAuth2 scopes the agent requires from this service. Must be empty when
            `require_all_scopes` is true. Omit or provide an empty array for a scope-less service.
            Supplied scopes must exist in the referenced service's scope definitions.
          example:
            - repo
            - user:email
        require_all_scopes:
          type: boolean
          default: false
          description: |
            When true, the agent receives the full scope union its granted permission sets
            provide for this service; required_scopes must be empty. When false (default),
            required_scopes is the per-agent scope ceiling.

    ClientRegistration:
      type: string
      nullable: false
      enum: [static, cimd]
      description: |
        Client identifier source. `static` uses an operator-supplied identifier or an existing
        Google, filesystem, or discovery-backed DCR identity. `cimd` uses the broker-hosted
        client identifier URL. This field never selects token-endpoint authentication.
      example: static

    ServiceCredentialMetadataBoundary:
      type: object
      description: File paths belong only to operator configuration. The source-transition marker is internal, not administrative input or output.
      not:
        anyOf:
          - required: [client_id_file]
          - required: [client_secret_file]
          - required: [credential_files]
          - required: [credential_source_transitioned]

    FilesystemServiceFields:
      type: object
      description: |
        Eligible filesystem credentials require neither inline field. Presence alone is invalid,
        including null or empty input. Only standard/GitHub shared-secret authentication is eligible.
        Creation and responses require canonical_id. An update can omit it to preserve the current ID,
        but the resulting filesystem service must retain a valid canonical ID.
      properties:
        discovery:
          type: object
          properties:
            resource_url:
              type: string
              nullable: true
              enum: [null]
        canonical_id:
          type: string
          minLength: 1
          maxLength: 128
          pattern: '^[A-Za-z0-9._-]+$'
          not:
            anyOf:
              - pattern: '^[0-9A-Fa-f]{32}$'
              - pattern: '^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$'
              - pattern: '^[A-Za-z0-9._-][0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}[A-Za-z0-9._-]$'
        client_registration:
          type: string
          enum: [static]
        token_endpoint_auth_method:
          type: string
          nullable: true
          enum: [null]
        oauth2_flavor:
          type: string
          enum: [standard, github]
      not:
        anyOf:
          - required: [client_id]
          - required: [client_secret]

    StoredServiceCredentialsRequest:
      type: object
      description: Stored manual credentials and broker-selected protected-resource clients.
      oneOf:
        - title: Manual stored credentials
          allOf:
            - $ref: '#/components/schemas/ManualStoredServiceCredentialsRequest'
          properties:
            discovery:
              type: object
              properties:
                resource_url:
                  type: string
                  nullable: true
                  enum: [null]
        - title: Protected-resource discovery
          type: object
          required: [discovery]
          not:
            required: [client_registration]
          properties:
            discovery:
              type: object
              required: [resource_url, enable_discovery]
              properties:
                enable_discovery:
                  type: boolean
                  enum: [true]
                resource_url:
                  type: string
                  format: uri
                metadata_url:
                  type: string
                  nullable: true
                  enum: [null]
                client_method:
                  type: string
                  nullable: true
                  enum: [null]
            oauth2_flavor:
              type: string
              enum: [standard]
            token_endpoint_auth_method:
              type: string
              nullable: true
              enum: [null]
            client_id:
              type: string
              nullable: true
              enum: ['', null]
            client_secret:
              type: string
              nullable: true
              enum: ['', null]
            endpoints:
              type: object
              nullable: true
              enum: [null]

    ManualStoredServiceCredentialsRequest:
      type: object
      description: Manual stored credentials. Registration and authentication form four valid combinations.
      oneOf:
        - title: Stored shared-secret authentication
          type: object
          required: [client_secret]
          properties:
            client_registration:
              type: string
              enum: [static]
            token_endpoint_auth_method:
              type: string
              nullable: true
              enum: [null]
            client_secret:
              type: string
              minLength: 1
          oneOf:
            - title: Standard or GitHub credentials
              type: object
              required: [client_id]
              properties:
                oauth2_flavor:
                  type: string
                  enum: [standard, github]
                client_id:
                  type: string
                  minLength: 1
            - title: Google service account credentials
              type: object
              required: [oauth2_flavor]
              properties:
                oauth2_flavor:
                  type: string
                  enum: [google]
        - title: Stored static public authentication
          type: object
          required: [token_endpoint_auth_method, client_id]
          properties:
            client_registration:
              type: string
              enum: [static]
            token_endpoint_auth_method:
              type: string
              enum: [none]
            oauth2_flavor:
              type: string
              enum: [standard, github]
            client_id:
              type: string
              minLength: 1
            client_secret:
              type: string
              nullable: true
              enum: ['', null]
        - title: Stored CIMD public authentication
          type: object
          required: [client_registration, token_endpoint_auth_method]
          properties:
            client_registration:
              type: string
              enum: [cimd]
            token_endpoint_auth_method:
              type: string
              enum: [none]
            oauth2_flavor:
              type: string
              enum: [standard, github]
            client_id:
              type: string
              nullable: true
              enum: [null]
            client_secret:
              type: string
              nullable: true
              enum: ['', null]
        - title: Stored CIMD confidential authentication
          type: object
          required: [token_endpoint_auth_method]
          properties:
            client_registration:
              type: string
              enum: [cimd]
            token_endpoint_auth_method:
              type: string
              enum: [private_key_jwt]
            oauth2_flavor:
              type: string
              enum: [standard, github]
            client_id:
              type: string
              nullable: true
              enum: [null]
            client_secret:
              type: string
              nullable: true
              enum: ['', null]

    Service:
      type: object
      description: |
        Source-aware representation for list, get, create, and update responses.
        Stored services always include client_id. Static confidential responses include only REDACTED,
        while static public, CIMD, and DCR responses omit client_secret. Filesystem responses omit both inline fields.
        This representation never resolves files or returns file paths, values, or transition evidence.
      allOf:
        - $ref: '#/components/schemas/ServiceCredentialMetadataBoundary'
      oneOf:
        - title: Stored service response
          type: object
          required: [client_id]
          properties:
            credential_source:
              type: string
              enum: [stored]
          oneOf:
            - title: Stored confidential redaction
              type: object
              required: [client_secret]
              properties:
                client_registration:
                  type: string
                  enum: [static]
                token_endpoint_auth_method:
                  type: string
                  nullable: true
                  enum: [null]
                discovery:
                  type: object
                  properties:
                    client_method:
                      type: string
                      nullable: true
                      enum: [null]
                client_secret:
                  type: string
                  enum: [REDACTED]
            - title: Stored static public or DCR service
              type: object
              properties:
                client_registration:
                  type: string
                  enum: [static]
                token_endpoint_auth_method:
                  type: string
                  enum: [none, client_secret_basic, client_secret_post]
                discovery:
                  type: object
                  properties:
                    client_method:
                      type: string
                      nullable: true
                      enum: [dcr, null]
              not:
                required: [client_secret]
            - title: Stored CIMD public or confidential service
              type: object
              properties:
                client_registration:
                  type: string
                  enum: [cimd]
                token_endpoint_auth_method:
                  type: string
                  enum: [none, private_key_jwt]
                discovery:
                  type: object
                  properties:
                    client_method:
                      type: string
                      nullable: true
                      enum: [cimd, null]
              not:
                required: [client_secret]
        - title: Filesystem service response
          type: object
          required: [canonical_id]
          properties:
            credential_source:
              type: string
              enum: [filesystem]
          allOf:
            - $ref: '#/components/schemas/FilesystemServiceFields'
      required:
        - id
        - display_name
        - credential_source
        - client_registration
        - token_endpoint_auth_method
        - oauth2_flavor
        - issuer_uri
        - discovery
        - endpoints
        - created_at
        - updated_at
      properties:
        credential_source:
          type: string
          enum: [stored, filesystem]
          description: Registered credential source. Reported without resolving files.
          readOnly: true
        client_registration:
          allOf:
            - $ref: '#/components/schemas/ClientRegistration'
          description: |
            Resolved client identifier source. Present on every service response.
            `cimd` uses the broker-hosted client identifier URL. Hosted CIMD discovery reports `cimd`.
            DCR discovery reports `static`. `discovery.client_method` remains the discovery method.
          readOnly: true
        id:
          type: string
          format: uuid
          description: Unique identifier for the service (system-generated UUID)
          example: 770e8400-e29b-41d4-a716-446655440002
          readOnly: true
        canonical_id:
          type: string
          nullable: true
          readOnly: true
          description: Optional canonical administrative identifier
          example: github-prod
        display_name:
          type: string
          description: Human-readable name for the service displayed to end users
          example: GitHub Production
          minLength: 1
          maxLength: 255
        client_id:
          type: string
          description: |
            Stored OAuth2 client_id for this service. Always present in stored mode and omitted in filesystem mode.

            For stored `oauth2_flavor: standard`, an operator supplies the value for static confidential
            and static public services. For `oauth2_flavor: google`, the broker extracts it from the
            service account JSON credential. For `client_registration: cimd`, the broker generates
            `<enduser-public-url>/.well-known/oauth-client/<service-id>` after allocating the ID.
            The URL stays the same while registration is `cimd`. A switch to `static` replaces the identity.
            For discovery-backed DCR, the authorization server issues this value. The broker
            scopes its identity to the selected `issuer_uri`; a client ID alone is not unique.
          example: Iv1.1234567890abcdef
        client_secret:
          type: string
          description: |
            Authentication credential for a static confidential service. The meaning varies by
            `oauth2_flavor`:
            - **standard**: Plain OAuth2 client secret string
            - **google**: Serialized Google service account JSON key document

            **SECURITY**: Stored static confidential services return only `"REDACTED"`.
            Static public, CIMD public, CIMD confidential, filesystem, and discovery-backed DCR
            services omit this property entirely, even when DCR stores an encrypted secret.
            Filesystem services also omit `client_id`. Stored encryption remains unchanged.
            The broker never sends a stored credential in an administrative response.
          example: REDACTED
          readOnly: true
        token_endpoint_auth_method:
          type: string
          nullable: true
          enum: [none, private_key_jwt, client_secret_basic, client_secret_post, null]
          readOnly: true
          description: |
            Client authentication method for this service's upstream token endpoint.

            - `none` — the service is static public or CIMD public, as selected by
              `client_registration`. It stores no credential and sends none upstream.
              Authorization-code exchange uses the PKCE code verifier.
            - `private_key_jwt` — the service is a CIMD **confidential client**. The broker assigns
              its stable HTTPS Client ID Metadata Document URL, stores no shared secret, and
              authenticates upstream with an ES256 client assertion from the dedicated CIMD
              client-authentication key set.
            - `client_secret_basic` or `client_secret_post` — a discovery-backed confidential DCR
              client. The broker uses only the selected method and never returns its secret.
            - `null` — the service is a **shared-secret confidential client**. Stored mode uses its
              stored client secret. Filesystem mode uses its configured file pair at authentication.
          example: none
        oauth2_flavor:
          type: string
          enum:
            - standard
            - google
            - github
          default: standard
          description: |
            OAuth2 authentication variant for this service.

            - **standard**: Plain client secret string (default)
            - **google**: Google service account JSON key; `client_id` is derived from the credential
            - **github**: GitHub OAuth2 app; scopes in token responses are comma-separated

            For `google` flavor, the `client_secret` field contains a serialized Google service account
            JSON document. The `client_id` in responses is extracted automatically from the JSON's
            `client_id` field; it need not be provided in requests.

            If omitted, the flavor is auto-detected as `github` when the token endpoint host
            equals `github.com`; otherwise defaults to `standard`.
          example: standard
        issuer_uri:
          type: string
          format: uri
          description: |
            OAuth2 issuer URI.

            For `oauth2_flavor: standard`: required, must be HTTPS.
            For `oauth2_flavor: google`: optional. If provided, its scheme and host must be consistent
            with the `token_uri` in the service account JSON. If omitted, the `token_uri` from the
            service account JSON is used as the authoritative token endpoint.
          example: https://github.com
        discovery:
          $ref: '#/components/schemas/DiscoveryConfig'
        endpoints:
          $ref: '#/components/schemas/OAuth2Endpoints'
        scopes:
          type: array
          description: Optional OAuth2 scopes for this service. Omit or provide an empty array when the provider does not use scopes.
          items:
            $ref: '#/components/schemas/OAuth2Scope'
        protected_resources:
          type: array
          description: |
            List of protected resource URIs for RFC 8693 token exchange.
            These URIs identify which resources this service can provide tokens for.
            URIs are normalized (trailing slashes removed) for consistent matching.
            Example: ["https://api.github.com", "https://github.com/api/v3"]
          items:
            type: string
            format: uri
          example:
            - https://api.github.com
            - https://github.com/api/v3
        authorization_params:
          type: object
          description: Provider authorization parameters. Discovery-backed services include one effective `resource` audience, derived from verified metadata or explicitly supplied by an administrator. The read-only source is in `discovery.authorization_param_resource_strategy`.
          additionalProperties:
            type: string
          example:
            business_partner_id: "12345"
        created_at:
          type: string
          format: date-time
          description: Timestamp when the service was created (RFC3339 format)
          example: '2025-12-19T10:30:00Z'
          readOnly: true
        updated_at:
          type: string
          format: date-time
          description: Timestamp when the service was last updated (RFC3339 format)
          example: '2025-12-19T15:45:00Z'
          readOnly: true

    ServiceCreateRequest:
      type: object
      description: |
        For protected-resource discovery, use `discovery.resource_url` and omit endpoints,
        client credentials, and token authentication method. The broker selects the issuer
        and client method. An explicit `authorization_params.resource` must be a non-empty
        absolute URI without a fragment. Direct metadata and manual requests retain their
        existing requirements.
        Omitted credential_source selects stored. Explicit null and unknown selectors are invalid.
        Filesystem creation requires a valid canonical_id and neither inline credential field.
        Registration never reads files. Missing bindings or files do not prevent registration.
      allOf:
        - $ref: '#/components/schemas/ServiceCredentialMetadataBoundary'
      oneOf:
        - title: Stored creation (default)
          type: object
          properties:
            credential_source:
              type: string
              enum: [stored]
          allOf:
            - $ref: '#/components/schemas/StoredServiceCredentialsRequest'
        - title: Filesystem creation
          type: object
          required: [credential_source, canonical_id]
          properties:
            credential_source:
              type: string
              enum: [filesystem]
          allOf:
            - $ref: '#/components/schemas/FilesystemServiceFields'
      required:
        - display_name
        - discovery
      properties:
        credential_source:
          type: string
          enum: [stored, filesystem]
          default: stored
          description: Omission selects stored. Null is invalid. Filesystem rejects inline fields and excluded authentication modes.
        client_registration:
          allOf:
            - $ref: '#/components/schemas/ClientRegistration'
          description: |
            Optional on create. Omission resolves to `cimd` for `private_key_jwt`, or `static`
            otherwise. An explicit null, empty, or unknown value returns `400`.
            `cimd` requires `none` or `private_key_jwt` and stored, non-Google credentials.
            `static` rejects `private_key_jwt`. Omit this field with `discovery.resource_url`.
            The broker reports the registration selected by discovery.
        canonical_id:
          type: string
          nullable: true
          minLength: 1
          maxLength: 128
          pattern: '^[A-Za-z0-9._-]+$'
          example: github-prod
        display_name:
          type: string
          description: Human-readable name for the service
          example: GitHub Production
          minLength: 1
          maxLength: 255
        oauth2_flavor:
          type: string
          enum:
            - standard
            - google
            - github
          default: standard
          description: |
            OAuth2 authentication variant. Defaults to `standard` if omitted.
            Auto-detected as `github` when the token endpoint host equals `github.com`.

            - **standard**: Static confidential services require a non-empty plain `client_secret`;
              static public services require `client_id`
            - **google**: `client_secret` must be a valid Google service account JSON key (≤32 KB);
              `client_id` is derived from the JSON's `client_id` field and need not be provided;
              `issuer_uri` is optional; `endpoints` are derived from the service account JSON
            - **github**: Same as static confidential standard but scopes in token responses are parsed as comma-separated

            `private_key_jwt` requires a non-credential-derived variant; `google` is rejected.
            Protected-resource discovery requires omitted or `standard` flavor.
          example: standard
        client_id:
          type: string
          nullable: true
          description: |
            OAuth2 client_id for this service.

            In stored mode, manual static `standard` or `github` clients require this field.
            Stored `google` derives it from the service account JSON. Both CIMD methods require
            omission or null. The broker assigns the HTTPS client identifier URL after it allocates
            the service ID. Protected-resource discovery selects the identity.
            Filesystem mode requires omission, including null or empty input.

            For protected-resource discovery, this property must be omitted, null, or empty.
          example: Iv1.1234567890abcdef
        client_secret:
          type: string
          nullable: true
          description: |
            Authentication credential for a static confidential service.

            The content depends on `oauth2_flavor`:
            - **standard** / **github**: Non-empty OAuth2 client secret string
            - **google**: Serialized Google service account JSON key document. Must contain:
                `type` (must be `"service_account"`), `private_key` (non-empty),
                `client_email` (non-empty), `token_uri` (non-empty), `client_id` (non-empty).
                Maximum size: 32 KB.

            **SECURITY**: Static confidential-service secrets are encrypted at rest using AES-256-GCM
            and return only as `"REDACTED"` on read responses.

            For manual stored services, an omitted or null `token_endpoint_auth_method` requires
            a non-empty static secret. Manual `none` and `private_key_jwt` accept only omitted,
            null, or empty secrets. For protected-resource discovery, this property must also
            be omitted, null, or empty. The broker never accepts a caller-supplied DCR secret.
            Filesystem mode requires this property to be absent, including null or empty input.
          example: ghp_secretkey1234567890abcdef
          writeOnly: true
        token_endpoint_auth_method:
          type: string
          nullable: true
          enum: [none, private_key_jwt, null]
          description: |
            Client authentication method for the upstream token endpoint. Omit it or send `null` for
            shared-secret authentication for manual clients. Stored mode requires client_secret. Filesystem mode forbids inline credentials.

            When `none`, the service is static public unless `client_registration: cimd` is explicit.
            Static public requires `client_id`. CIMD public requires omission or null `client_id`.
            Both reject a non-empty `client_secret` and `oauth2_flavor: google`.

            When `private_key_jwt`, the service is CIMD confidential. The caller omits `client_id`
            or sends null, and omits `client_secret` or sends null or `""`.
            The broker rejects a non-empty value before discovery or provider traffic.
            It assigns the HTTPS client identifier URL after it allocates the service ID.

            With `discovery.resource_url`, omit or set this method to null. The broker
            chooses one method; `client_secret_basic` and `client_secret_post` are response-only.
          example: none
        issuer_uri:
          type: string
          format: uri
          description: |
            OAuth2 issuer URI.

            Required for manual standard services and HTTPS. Optional for protected-resource
            discovery with one issuer. If the resource advertises several issuers, the
            administrator must select an identical `issuer_uri`. Google retains its existing
            service-account rules.
          example: https://github.com
        discovery:
          $ref: '#/components/schemas/DiscoveryConfigRequest'
        endpoints:
          $ref: '#/components/schemas/OAuth2EndpointsRequest'
        scopes:
          type: array
          description: Optional OAuth2 scopes for this service. Omit or provide an empty array when the provider does not use scopes.
          items:
            $ref: '#/components/schemas/OAuth2ScopeRequest'
          example:
            - scope_value: repo
              description: Full control of private repositories
            - scope_value: read:org
              description: Read organization membership
        protected_resources:
          type: array
          description: |
            List of protected resource URIs for RFC 8693 token exchange (optional).
            These URIs identify which resources this service can provide tokens for.
            URIs are normalized (trailing slashes removed) for consistent matching.
          items:
            type: string
            format: uri
          example:
            - https://api.github.com
            - https://github.com/api/v3
        authorization_params:
          type: object
          description: Provider parameters. For protected-resource discovery, `resource` is an optional explicit absolute URI without a fragment. The broker derives it from verified metadata when omitted. Other broker-owned OAuth2 names are rejected.
          additionalProperties:
            type: string
          example:
            business_partner_id: "12345"

    ServiceUpdateRequest:
      type: object
      description: |
        Protected-resource refresh uses fresh resource and issuer metadata. An omitted
        `issuer_uri` keeps the active issuer if still advertised. A new issuer requires
        explicit selection and zero user sessions. Omit manual endpoints and credentials.
        The broker keeps the exact client and token authentication methods. On a
        discovery-backed refresh, omitted `authorization_params` keeps the map and its
        resource source. A replacement without `resource` restores the verified resource
        URL. On replacement with manual configuration, omission keeps the parameter map
        but clears the discovery source and resource source marker. For a manual replacement,
        an explicit empty map clears all parameters.
        PUT remains a full replacement with existing documented omission exceptions.
        Omitted credential_source preserves the current source, not a stored default.
        Explicit null and unknown selectors are invalid. The omission branch models both possible
        request shapes; the domain validates the body against the existing source and canonical ID.
        A resulting filesystem service rejects either inline field and requires a valid canonical ID.
        An explicit stored-to-filesystem transition atomically removes both credentials and records
        irreversible internal transition evidence. The reverse requires explicitly supplied non-empty
        client_id and client_secret, with encryption before the atomic update. Files never supply them.
        Invalid input or a version conflict preserves the entire previous state. No administrative
        operation reads files. Existing If-Match requirements remain unchanged.
      allOf:
        - $ref: '#/components/schemas/ServiceCredentialMetadataBoundary'
      oneOf:
        - title: Explicit stored update
          type: object
          required: [credential_source]
          properties:
            credential_source:
              type: string
              enum: [stored]
          allOf:
            - $ref: '#/components/schemas/StoredServiceCredentialsRequest'
        - title: Explicit filesystem update
          type: object
          required: [credential_source]
          properties:
            credential_source:
              type: string
              enum: [filesystem]
          allOf:
            - $ref: '#/components/schemas/FilesystemServiceFields'
        - title: Preserve existing source
          type: object
          not:
            required: [credential_source]
          oneOf:
            - $ref: '#/components/schemas/StoredServiceCredentialsRequest'
            - $ref: '#/components/schemas/FilesystemServiceFields'
      required:
        - display_name
        - discovery
      properties:
        credential_source:
          type: string
          enum: [stored, filesystem]
          description: Omission preserves the existing source. Explicit null is invalid. No creation default applies to PUT.
        client_registration:
          allOf:
            - $ref: '#/components/schemas/ClientRegistration'
          description: |
            Optional on full-replacement PUT. Omission resolves to `cimd` for `private_key_jwt`,
            or `static` otherwise; it never preserves the existing registration.
            An explicit null, empty, or unknown value returns `400`.
            A CIMD public replacement must send `cimd` again. Without it, the request resolves
            to `static` and fails unless the caller supplies a static `client_id`.
            Omit this field with `discovery.resource_url`. The broker keeps or selects the
            discovery-backed registration from the selected client method.
        canonical_id:
          type: string
          nullable: true
          minLength: 1
          maxLength: 128
          pattern: '^[A-Za-z0-9._-]+$'
          description: Omission preserves the current ID. Null clears it only in stored mode. Filesystem mode requires a valid resulting ID.
          example: github-prod
        display_name:
          type: string
          description: Human-readable name for the service
          example: GitHub Production
          minLength: 1
          maxLength: 255
        oauth2_flavor:
          type: string
          enum:
            - standard
            - google
            - github
          default: standard
          description: |
            OAuth2 authentication variant. Defaults to `standard` if omitted.
            Auto-detected as `github` when the token endpoint host equals `github.com`.

            - **standard**: Static confidential services require a non-empty plain `client_secret`;
              static public services require `client_id`
            - **google**: `client_secret` must be a valid Google service account JSON key (≤32 KB);
              `client_id` is derived from the JSON's `client_id` field and need not be provided;
              `issuer_uri` is optional; `endpoints` are derived from the service account JSON
            - **github**: Same as static confidential standard but scopes in token responses are parsed as comma-separated

            `private_key_jwt` requires a non-credential-derived variant; `google` is rejected.
            Protected-resource discovery requires omitted or `standard` flavor.
          example: standard
        client_id:
          type: string
          nullable: true
          description: |
            OAuth2 client_id for this service.

            In stored mode, manual static `standard` or `github` clients require this field.
            Stored `google` derives it from the service account JSON. Both CIMD methods require
            omission or null. The broker assigns the same HTTPS client identifier URL while
            registration stays `cimd`. Protected-resource discovery keeps or selects the identity.
            Filesystem mode requires omission. A filesystem-to-stored transition requires an explicit
            non-empty value.

            For protected-resource discovery, this property must be omitted, null, or empty.
          example: Iv1.1234567890abcdef
        client_secret:
          type: string
          nullable: true
          description: |
            Authentication credential for a static confidential service. Provide a new value to rotate.

            The content depends on `oauth2_flavor`:
            - **standard** / **github**: Non-empty OAuth2 client secret string
            - **google**: Serialized Google service account JSON key document (≤32 KB)

            **SECURITY**: Static confidential-service secrets are encrypted at rest and never returned
            in responses.

            For manual stored services, an omitted or null authentication method requires a new
            non-empty static secret. Manual public and CIMD modes accept omitted, null, or empty
            values. Protected-resource updates also require an omitted, null, or empty value;
            the broker keeps the stored DCR credential when eligible.
            Filesystem mode forbids this property, including null or empty input.
            Returning to stored requires an explicit non-empty secret.
          example: ghp_newsecretkey9876543210zyxwvu
          writeOnly: true
        token_endpoint_auth_method:
          type: string
          nullable: true
          enum: [none, private_key_jwt, null]
          description: |
            Client authentication method for the upstream token endpoint. This request retains the
            existing full-replacement semantics: the method is evaluated from the complete replacement
            request. Omit this property, or send `null`, for shared-secret authentication.
            Manual stored mode requires a non-empty client_secret. Filesystem mode forbids inline credentials.
            When `none`, the service is static public unless `client_registration: cimd` is explicit.
            Static public requires `client_id`. CIMD public requires omission or null `client_id`.
            Both accept omitted, null, or empty `client_secret` and reject Google flavor.
            A valid update removes any stored secret.

            When `private_key_jwt`, the service is CIMD confidential. Omit `client_id` or send null.
            Omit `client_secret` or send null or `""`. The broker rejects a non-empty value before
            provider traffic. A switch to this mode requires a usable CIMD client-authentication key.

            CIMD confidential and CIMD public keep the same client identifier URL when switching.
            A switch from `static` to `cimd` replaces the operator ID with the broker URL.
            The broker removes a stored secret only after the full replacement validates.
            A switch from `cimd` to `static` requires an operator `client_id`.
            Static confidential also requires a new non-empty `client_secret`.
            The old hosted document returns `404` after a switch to `static`.
            Providers can cache the previous document for up to 300 seconds.
            The broker never retries token acquisition with another method after provider rejection.

            With `discovery.resource_url`, omit or set the method to null. The broker
            keeps the exact active method. Basic and POST DCR modes are response-only.
          example: none
        issuer_uri:
          type: string
          format: uri
          description: |
            OAuth2 issuer URI.

            Required for manual standard services and HTTPS. For protected-resource
            discovery, omission keeps the active issuer if fresh metadata advertises it.
            A different issuer needs explicit selection and zero user sessions.
            Google retains its existing service-account rules.
          example: https://github.com
        discovery:
          $ref: '#/components/schemas/DiscoveryConfigRequest'
        endpoints:
          $ref: '#/components/schemas/OAuth2EndpointsRequest'
        scopes:
          type: array
          description: Optional OAuth2 scopes for this service. Omit or provide an empty array when the provider does not use scopes.
          items:
            $ref: '#/components/schemas/OAuth2ScopeRequest'
        protected_resources:
          type: array
          nullable: true
          description: |
            Optional protected resource URIs for RFC 8693 token exchange. Omit or set to `null` to preserve
            the current set. When present, including an empty array, this field authoritatively replaces the
            entire set and requires the `If-Match` header with the service's current strong ETag. URIs are
            normalized by trailing-slash trim before matching; an empty array clears the set.
          items:
            type: string
            format: uri
          example:
            - https://api.github.com
            - https://github.com/api/v3
        authorization_params:
          type: object
          description: |
            Provider parameters. Omission preserves the map, including on replacement with
            manual configuration. For a manual replacement, an explicit empty object clears
            the map. For discovery-backed services, a replacement with `resource` sets an
            explicit absolute URI without a fragment. A replacement without `resource`
            clears the override and restores the verified resource URL, including when the
            replacement object is empty.
          additionalProperties:
            type: string
          example:
            business_partner_id: "12345"

    ProtectedResourceSet:
      type: object
      required:
        - protected_resources
      properties:
        protected_resources:
          type: array
          items:
            type: string
            format: uri
          example:
            - https://api.github.com
            - https://github.com/api/v3

    ProtectedResourceCreateRequest:
      type: object
      required:
        - resource_uri
      properties:
        resource_uri:
          type: string
          format: uri
          description: Absolute resource URI (scheme and host required), normalized by trailing-slash trim.
          example: https://api.example.com/v2

    ProtectedResourceRenameTarget:
      type: object
      required:
        - to
      properties:
        to:
          type: string
          format: uri
          description: Absolute target URI (scheme and host required), normalized by trailing-slash trim.
          example: https://api.example.com/v2
    ProtectedResourceMutationResult:
      type: object
      required:
        - resource
      properties:
        resource:
          type: string
          format: uri
          description: Normalized URI affected by the operation.
        protected_resources:
          type: array
          description: The service's resulting protected-resource set.
          items:
            type: string
            format: uri

    DiscoveryConfig:
      type: object
      required:
        - enable_discovery
        - authorization_param_resource_strategy
      properties:
        enable_discovery:
          type: boolean
          description: |
            `true` uses the configured discovery source. `metadata_url` selects direct
            authorization-server discovery; `resource_url` selects protected-resource discovery.
            `false` uses manually configured endpoints.
          example: true
        metadata_url:
          type: string
          format: uri
          nullable: true
          description: Direct authorization-server metadata override. Absent for protected-resource discovery.
          example: https://github.com/.well-known/oauth-authorization-server
        resource_url:
          type: string
          format: uri
          nullable: true
          description: Verified public HTTPS resource identifier. Distinct from the effective token audience and the protected-resource ownership set.
          example: https://mcp.example.com/mcp
        client_method:
          type: string
          nullable: true
          enum: [cimd, dcr, null]
          readOnly: true
          description: Broker-selected client bootstrap method. Null for manual and direct-metadata services.
          example: dcr
        authorization_param_resource_strategy:
          type: string
          nullable: true
          readOnly: true
          enum: [derived, pinned, null]
          description: Source of the effective authorization_params.resource audience. Null for manual and direct-metadata services.
          example: derived
    ServiceDiscoveryStatus:
      type: object
      required:
        - status
        - resource_url
        - issuer_uri
        - client_method
        - last_attempt_at
        - last_success_at
        - failure_reason
      properties:
        status:
          type: string
          enum: [ready, failed, not_applicable]
          description: Derived from the last attempt and last successful active configuration.
        resource_url:
          type: string
          format: uri
          nullable: true
          description: Active discovery source URL, not the failed replacement URL.
        issuer_uri:
          type: string
          format: uri
          nullable: true
          description: Active validated issuer.
        client_method:
          type: string
          nullable: true
          enum: [cimd, dcr, null]
          description: Active client bootstrap method.
        last_attempt_at:
          type: string
          format: date-time
          nullable: true
        last_success_at:
          type: string
          format: date-time
          nullable: true
        failure_reason:
          type: string
          nullable: true
          description: Safe failure code without a remote response body, URL query, or credential. Null after success and for manual services.

    DiscoveryConfigRequest:
      type: object
      required:
        - enable_discovery
      properties:
        enable_discovery:
          type: boolean
          description: Set true when `resource_url` is present; false requires manual endpoints.
          example: true
        metadata_url:
          type: string
          format: uri
          nullable: true
          description: Direct authorization-server metadata override. Must be absent or null with `resource_url`.
          example: https://github.com/.well-known/oauth-authorization-server
        resource_url:
          type: string
          format: uri
          nullable: true
          description: >-
            Public HTTPS protected-resource identifier. Requires enable_discovery true.
            Excludes metadata_url, manual endpoints, and caller-supplied credentials.
            No user information or fragment.
          example: https://mcp.example.com/mcp

    OAuth2Endpoints:
      type: object
      required:
        - token_endpoint
        - authorize_endpoint
      properties:
        token_endpoint:
          type: string
          format: uri
          description: OAuth2 token endpoint URL
          example: https://github.com/login/oauth/access_token
        authorize_endpoint:
          type: string
          format: uri
          description: OAuth2 authorization endpoint URL
          example: https://github.com/login/oauth/authorize

    OAuth2EndpointsRequest:
      type: object
      description: |
        OAuth2 endpoints configuration.

        **Required when** `discovery.enable_discovery = false`
        **Optional when** `discovery.enable_discovery = true` without `resource_url` (direct-metadata fallback only). Must be absent with `resource_url`.

        Each supplied `token_endpoint` and `authorize_endpoint` must use HTTPS. Under the default strict policy, HTTP is accepted only for `localhost`, `127.0.0.1`, or `[::1]`; HTTP is accepted for any host only when `security.skip_thirdparty_https_validation` is enabled for development/test.
      properties:
        token_endpoint:
          type: string
          format: uri
          description: OAuth2 token endpoint URL
          example: https://github.com/login/oauth/access_token
        authorize_endpoint:
          type: string
          format: uri
          description: OAuth2 authorization endpoint URL
          example: https://github.com/login/oauth/authorize

    OAuth2Scope:
      type: object
      required:
        - scope_value
        - description
      properties:
        scope_value:
          type: string
          description: OAuth2 scope value used in authorization requests
          example: repo
          minLength: 1
        description:
          type: string
          description: Human-readable description of what this scope allows
          example: Full control of private repositories
          minLength: 1

    OAuth2ScopeRequest:
      type: object
      required:
        - scope_value
        - description
      properties:
        scope_value:
          type: string
          description: OAuth2 scope value used in authorization requests
          example: repo
          minLength: 1
        description:
          type: string
          description: Human-readable description of what this scope allows
          example: Full control of private repositories
          minLength: 1

    BrokerClientCredentialResponse:
      type: object
      required:
        - client_id
        - client_secret
        - created_at
      properties:
        client_id:
          type: string
          format: uuid
          description: Broker-issued client identifier for the agent
          example: 550e8400-e29b-41d4-a716-446655440000
        client_secret:
          type: string
          description: |
            Broker-issued client secret. Only returned on generation or rotation.
            Store securely — this value cannot be retrieved again.
          example: brk_sec_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6
          writeOnly: true
        created_at:
          type: string
          format: date-time
          description: Timestamp when the credentials were created (RFC3339 format)
          example: '2025-12-19T10:30:00Z'
        previous_invalidated_at:
          type: string
          format: date-time
          nullable: true
          description: |
            Timestamp when the previous credentials were invalidated (RFC3339 format).
            Only present on rotation responses (HTTP 200).
          example: '2025-12-19T15:45:00Z'

    BrokerClientCredentialMetadata:
      type: object
      required:
        - client_id
        - created_at
      properties:
        client_id:
          type: string
          format: uuid
          description: Broker-issued client identifier for the agent
          example: 550e8400-e29b-41d4-a716-446655440000
        created_at:
          type: string
          format: date-time
          description: Timestamp when the credentials were created (RFC3339 format)
          example: '2025-12-19T10:30:00Z'
        rotated_at:
          type: string
          format: date-time
          nullable: true
          description: |
            Timestamp when the credentials were last rotated (RFC3339 format).
            Null if credentials have never been rotated.
          example: '2025-12-19T15:45:00Z'

    SigningKeyCreateRequest:
      type: object
      properties:
        algorithm:
          type: string
          enum:
            - ES256
          default: ES256
          description: |
            Signing algorithm for the new key.
            - `ES256`: ECDSA using P-256 curve and SHA-256 (default, only supported algorithm)
          example: ES256

    SigningKeyResponse:
      type: object
      required:
        - kid
        - algorithm
        - is_current
        - activates_at
        - created_at
      properties:
        kid:
          type: string
          description: Key identifier (unique within the signing key set)
          example: key-2025-12-19-001
        algorithm:
          type: string
          description: Signing algorithm used by this key
          example: ES256
        is_current:
          type: boolean
          description: Whether this key is the designated current signing key
          example: true
        activates_at:
          type: string
          format: date-time
          description: |
            Timestamp when the key starts signing tokens (RFC3339 format).
            For keys created via POST, this is `created_at + 600 s` (2 × JWKS Cache-Control
            max-age value). The key is present in the JWKS response immediately so clients
            can cache it before it becomes active. For keys promoted via PUT /…/current,
            this equals the promotion time.
          example: '2025-12-19T10:40:00Z'
        created_at:
          type: string
          format: date-time
          description: Timestamp when the key was created (RFC3339 format)
          example: '2025-12-19T10:30:00Z'

    SigningKeyListResponse:
      type: object
      required:
        - items
      properties:
        items:
          type: array
          description: List of active signing keys (newest first)
          items:
            $ref: '#/components/schemas/SigningKeyResponse'

    CIMDClientKeyGenerateRequest:
      type: object
      properties:
        algorithm:
          type: string
          enum: [ES256]
          default: ES256
          description: ES256 is the default and only accepted CIMD client-authentication key algorithm.
          example: ES256

    CIMDClientKey:
      type: object
      required:
        - kid
        - algorithm
        - is_current
        - activates_at
        - created_at
      description: |
        Public CIMD client-authentication key metadata. This representation never includes private key
        bytes, PEM, ciphertext, branch-key identifiers, or client assertions.
      properties:
        kid:
          type: string
          description: Globally unique identifier of this CIMD client-authentication key
          example: cimd-key-2026-09-21-001
        algorithm:
          type: string
          enum: [ES256]
          description: Signing algorithm used by this key
          example: ES256
        is_current:
          type: boolean
          description: Whether this key is the designated current CIMD client-authentication key
          example: true
        activates_at:
          type: string
          format: date-time
          description: |
            Earliest time this key signs client assertions. The first generated key is usable immediately
            (`activates_at` equals `created_at`). A later generated current key is published in the CIMD
            JWK Set during the existing activation grace period. A promoted key becomes usable immediately
            (`activates_at` equals the promotion time).
          example: '2026-09-21T10:30:00Z'
        created_at:
          type: string
          format: date-time
          description: Timestamp when the key was created (RFC3339 format)
          example: '2026-09-21T10:30:00Z'

    CIMDClientKeyListResponse:
      type: object
      required:
        - items
      properties:
        items:
          type: array
          description: Active CIMD client-authentication keys in creation order (newest first)
          items:
            $ref: '#/components/schemas/CIMDClientKey'

    SessionSweepRequest:
      type: object
      additionalProperties: false
      properties:
        lookahead_duration:
          type: string
          format: duration
          description: |
            ISO 8601 duration. Sessions whose access token expires within this duration from the
            start of the sweep are refreshed. Only day, hour, minute, and second components are
            accepted (`P[nD][T[nH][nM][n[.f]S]]`); year, month, and week components are rejected.
            Must be greater than zero. Defaults to the server-configured
            `token_refresh.lookahead_duration` (default 5 minutes).
          pattern: '^P(?!$)(?:\d+D)?(?:T(?=\d)(?:\d+H)?(?:\d+M)?(?:\d+(?:\.\d+)?S)?)?$'
          example: PT5M
        dry_run:
          type: boolean
          default: false
          description: When true, report what would be refreshed without contacting upstream providers or writing.
        page_size:
          type: integer
          format: int32
          minimum: 1
          maximum: 1000
          description: |
            Number of candidate sessions read per page. Defaults to the server-configured
            `token_refresh.sweep.default_page_size` (default 100).
          example: 100
    SessionSweepResult:
      type: object
      additionalProperties: false
      required:
        - refreshed
        - skipped
        - failed
        - total_evaluated
        - dry_run
      description: |
        Summary of one sweep. `refreshed + skipped + failed = total_evaluated`. Sessions outside
        the lookahead window are not evaluated and appear in no count.
      properties:
        refreshed:
          type: integer
          format: int32
          minimum: 0
          description: Candidates whose tokens were refreshed, or would be refreshed when `dry_run` is true.
        skipped:
          type: integer
          format: int32
          minimum: 0
          description: Candidates that no longer needed a refresh when evaluated (already refreshed by another request or replica, or deleted during the sweep).
        failed:
          type: integer
          format: int32
          minimum: 0
          description: Candidates that needed a refresh but could not be refreshed (refresh token missing or expired, upstream error, provider misconfiguration). Their stored sessions are unchanged.
        total_evaluated:
          type: integer
          format: int32
          minimum: 0
          description: Candidates evaluated in this sweep.
        dry_run:
          type: boolean
          description: Whether this sweep was a dry run.

    ErrorResponse:
      type: object
      required:
        - error
      properties:
        error:
          type: string
          description: Error type or category
          example: validation failed
        message:
          type: string
          description: Additional context about the error
          example: display_name exceeds 255 characters (got 300)
        authorization_servers:
          type: array
          minItems: 2
          uniqueItems: true
          items:
            type: string
            format: uri
          description: Validated issuers from matching protected-resource metadata; present only when `message` is `issuer_selection_required`.

    ServiceScope:
      type: object
      required:
        - service_id
      properties:
        service_id:
          type: string
          description: UUID on responses; UUID or canonical service identifier on writes
          examples:
            uuid: { value: 550e8400-e29b-41d4-a716-446655440001 }
            canonical: { value: github-prod }
        scopes:
          type: array
          items:
            type: string
          description: |
            Optional OAuth2 scope strings for this service. Omit or provide an empty
            array for a scope-less service; supplied values must be non-empty.
          example:
            - repo:read
            - user:email
        requirement_type:
          type: string
          enum:
            - mandatory
            - optional
          default: optional
          description: Whether this service is mandatory or optional within the permission set

    AgentPermissionSetEntry:
      type: object
      required:
        - permission_set_id
        - requirement_type
      properties:
        permission_set_id:
          type: string
          description: UUID on responses; UUID or canonical permission-set identifier on writes
          examples:
            uuid: { value: a1b2c3d4-e5f6-7890-abcd-ef1234567890 }
            canonical: { value: github-read }
        requirement_type:
          type: string
          enum:
            - mandatory
            - optional
          description: |
            Whether this permission set is mandatory (always granted, non-negotiable) or
            optional (user-togglable in the consent UI).
          example: mandatory

    CreatePermissionSetRequest:
      type: object
      required:
        - name
        - description
        - service_scopes
      properties:
        canonical_id:
          type: string
          nullable: true
          minLength: 1
          maxLength: 128
          pattern: '^[A-Za-z0-9._-]+$'
          example: github-read
        name:
          type: string
          maxLength: 255
          description: Unique human-readable name for the permission set
          example: GitHub Read Access
        description:
          type: string
          description: Human-readable explanation of what capabilities this set grants
          example: Read repository contents and user profile from GitHub
        service_scopes:
          type: array
          items:
            $ref: '#/components/schemas/ServiceScope'
          minItems: 1
          description: One or more service/scope entries covered by this permission set

    PermissionSetResponse:
      type: object
      required:
        - id
        - name
        - description
        - service_scopes
        - created_at
        - updated_at
      properties:
        id:
          type: string
          format: uuid
          description: Stable UUID assigned on creation
          example: a1b2c3d4-e5f6-7890-abcd-ef1234567890
          readOnly: true
        canonical_id:
          type: string
          nullable: true
          readOnly: true
          description: Optional canonical administrative identifier
          example: github-read
        name:
          type: string
          description: Unique human-readable name for the permission set
          example: GitHub Read Access
        description:
          type: string
          description: Human-readable explanation of capabilities
          example: Read repository contents and user profile from GitHub
        service_scopes:
          type: array
          items:
            $ref: '#/components/schemas/ServiceScope'
          description: Scopes across one or more third-party services
        created_at:
          type: string
          format: date-time
          description: Timestamp when the permission set was created (RFC3339 format)
          example: '2026-03-25T12:00:00Z'
          readOnly: true
        updated_at:
          type: string
          format: date-time
          description: Timestamp when the permission set was last updated (RFC3339 format)
          example: '2026-03-25T12:00:00Z'
          readOnly: true

  responses:
    AgentConflictError:
      description: Another agent holds a requested unique identifier. No agent changes.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          examples:
            external_id:
              value:
                error: conflict
                message: external_id is already held by another agent
            canonical_id:
              value:
                error: conflict
                message: agent canonical_id already exists
            client_id:
              value:
                error: conflict
                message: agent with this client_id already exists
            client_uri:
              value:
                error: conflict
                message: client URI already registered to another agent

    BadRequestError:
      description: Invalid request parameters or validation errors
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          examples:
            missing_required:
              summary: Missing required field
              value:
                error: invalid request body
                message: display_name is required
            empty_external_id:
              summary: Empty agent external ID
              value:
                error: validation failed
                message: external_id cannot be empty when provided
            validation_failed:
              summary: Validation failure
              value:
                error: validation failed
                message: display_name exceeds 255 characters (got 300)
            invalid_url:
              summary: Invalid URL format
              value:
                error: validation failed
                message: governance_url is not a valid HTTP/HTTPS URL
            invalid_issuer:
              summary: Issuer URI must be HTTPS
              value:
                error: validation failed
                message: issuer_uri must be a valid HTTPS URL
            missing_endpoints:
              summary: Endpoints required when discovery disabled
              value:
                error: validation failed
                message: token_endpoint is required when discovery is disabled
            insecure_token_endpoint:
              summary: Insecure token endpoint
              value:
                error: validation failed
                message: token_endpoint must be a valid HTTPS URL (HTTP allowed only for localhost in dev mode)

    NotFoundError:
      description: Resource not found
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          examples:
            agent_not_found:
              summary: Agent not found
              value:
                error: agent not found
                message: Agent with ID '550e8400-e29b-41d4-a716-446655440000' does not exist
            service_not_found:
              summary: Service not found
              value:
                error: service not found
                message: Service with ID '770e8400-e29b-41d4-a716-446655440002' does not exist

    ConflictError:
      description: Resource conflict (duplicate identifier)
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          examples:
            duplicate_agent:
              summary: Duplicate agent client_id
              value:
                error: conflict
                message: Agent with client_id 'agent-research-assistant' already exists
            duplicate_service:
              summary: Duplicate service client_id
              value:
                error: conflict
                message: Service with client_id 'Iv1.1234567890abcdef' already exists

    ProtectedResourceConflict:
      description: Protected resource URI is already owned by another service, or a rename target already exists.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'

    PreconditionFailed:
      description: The supplied If-Match value does not equal the service's current strong ETag. Re-fetch and retry.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'

    PreconditionRequired:
      description: The request includes protected_resources but omits the required If-Match header.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'

    InternalServerError:
      description: Internal server error
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          example:
            error: internal server error

  securitySchemes:
    PreAuthProxy:
      type: apiKey
      in: header
      name: X-Remote-User
      description: |
        Plain-header pre-authentication via a trusted reverse proxy. Configure
        `server.admin.authentication.preauth.principal_header_name` explicitly for
        the header set by the proxy (for example, `X-Remote-User`). The broker accepts
        the principal from this header only when JWT pre-authentication is not configured.
        The proxy must remove client-supplied copies of the header, authenticate the
        caller, and enforce administrator privilege before forwarding the request.
    JWTPreAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: |
        JWT pre-authentication through a trusted proxy. Configure
        `server.admin.authentication.jwt`. With `verification: jwks` (default),
        set `jwks_uri`. The broker verifies the signature and requires `exp`.
        With `verification: none`, a trusted gateway sets an unsigned JWT after
        it authenticates the operator and removes client-supplied copies. Do not
        set `jwks_uri` in this mode. The default header is `Authorization` with
        `Bearer ` stripped. `header_name` can select another header, such as
        `X-Userinfo`. A missing or invalid JWT supplies no broker principal.
        The broker ignores the plain principal header. The sweep returns
        `500 server_misconfiguration` without an operator principal. The proxy
        must authenticate and authorize callers before forwarding admin requests.

security:
  - PreAuthProxy: []
  - JWTPreAuth: []
