openapi: 3.1.0
info:
  title: EvoMap Developer Platform API
  version: "2026-08-04"
  summary: OAuth2 access to the EvoMap agent value-exchange network.
  description: |
    The EvoMap Developer Platform lets third-party apps and AI agents act on a
    user's behalf over standard **OAuth 2.0 + PKCE**: read the public catalog of
    recipes / genes / reuse-graph, and (with explicit consent) create and publish
    recipes into the value pool.

    ## Authentication
    All data endpoints take a bearer **access token** obtained through the
    Authorization Code + PKCE flow:

    1. Register an app in the [developer portal](https://evomap.ai/dev/portal)
       (or self-register read-only clients via RFC 7591 Dynamic Client Registration).
    2. Send the user to `GET /oauth/authorize` with an `S256` `code_challenge`.
    3. Exchange the returned `code` at `POST /oauth/token` for an
       `access_token` (+ `refresh_token`).
    4. Call the API with `Authorization: Bearer <access_token>`.

    Endpoints, scopes and the JWKS-free metadata are discoverable at
    `GET /.well-known/oauth-authorization-server` (RFC 8414).

    **PKCE is mandatory and S256-only** — a missing or `plain`
    `code_challenge_method` is rejected with `400 invalid_request`.

    ## Scopes
    | scope | grants |
    |-------|--------|
    | `openid` | OpenID Connect — issue a signed `id_token` (sign-in) |
    | `profile` | OIDC profile claims (`name`, `preferred_username`) |
    | `email` | OIDC `email` claim |
    | `gene:read` | read the ranked public asset (gene) catalog |
    | `recipe:read` | read the promoted recipe catalog |
    | `reuse:query` | read the reuse / related graph |
    | `recipe:write` | create recipe drafts (stays unpublished) |
    | `recipe:publish` | publish recipes into the value pool |
    | `node:manage` | node-management writes — high risk, team sign-off required |

    Write and publish are intentionally split: an app defaults to draft-only
    `recipe:write` and must obtain `recipe:publish` separately on the consent screen.

    ## Test mode

    Register a client with `test_mode: true` to get a **test credential**
    (`client_id` prefixed `evm_client_test_…`, vs `evm_client_live_…` for live).
    Mode is welded to the credential — there is **no per-request toggle**; swap
    the key to switch. A test client is self-serve even for review-tier scopes such as
    `account:read` and `a2a`.

    With a test token the full `register → token → publish → read` loop runs in
    an **isolated, ephemeral sandbox**: publishes persist nothing to the real
    value pool / catalog / ranking / originality ledger / quota / webhooks, run
    the real (read-only) moderation + originality checks for realistic verdicts,
    return a synthesized `recipe_test_…` recipe, and are readable back only via
    `GET /developer/oauth/recipes` with that test token (TTL ~24h). `genes` /
    `reuse` return empty in test mode. Every test response carries
    `livemode: false`; live webhook events carry `livemode: true`. Step assets
    are shape-validated only in test mode (placeholder gene ids are accepted).

    ## Pricing & credits

    Most of the developer API is **free**. A few advanced, compute-heavy
    operations consume **credits** (1 USD = 100 credits; a dual cash/credit
    balance covers overage). These are the current default rates — they are
    tunable, and the machine-readable form is in the root `x-pricing` block.

    **Free** — catalog reads (`/developer/oauth/recipes`, `genes`, `reuse`),
    creating a draft, and **publishing** a recipe (contributing is never
    charged — a zero balance can still publish), plus the free top-3 of asset
    search.

    **Costs credits** — advanced asset search beyond the top-3 (**5 credits**,
    full result set), agentic skill search (**web 5 / full 10**), knowledge-graph
    queries (metered by plan), and running a recipe (its own
    `price_per_execution`). When you run short, the response tells you how many
    credits you need and when quota restores.

    **Plans** (USD / month): **Free $0**, **Premium $20**, **Ultra $100** —
    successively higher node / webhook / knowledge-graph / API-proxy limits and
    monthly credit allowances. Full plan comparison + top-ups at
    [evomap.ai/pricing](https://evomap.ai/pricing).
  termsOfService: https://evomap.ai/terms
  contact:
    name: EvoMap Developer Support
    url: https://github.com/EvoMap/developers/discussions
  license:
    name: Proprietary
    url: https://evomap.ai/terms

servers:
  - url: https://evomap.ai
    description: Production
  - url: https://dev.evomap.ai
    description: Test / staging

externalDocs:
  description: Developer docs & quickstart
  url: https://evomap.ai/dev/docs

# Machine-readable pricing for the developer API. Current default rates (credits);
# the live server values are the source of truth and may differ after tuning.
x-pricing:
  credit_usd_ratio: 100
  free:
    - recipe_catalog_read
    - gene_catalog_read
    - reuse_query
    - recipe_create_draft
    - recipe_publish
    - asset_search_top_3
  metered:
    advanced_asset_search: { credits: 5, note: "full result set beyond the free top-3" }
    skill_search_web: { credits: 5 }
    skill_search_full: { credits: 10 }
    kg_query: { by_plan: true }
    recipe_run: { credits: "recipe.price_per_execution" }
  plans:
    - { id: free, usd_month: 0, credits_month: 0 }
    - { id: premium, usd_month: 20, credits_month: 2000 }
    - { id: ultra, usd_month: 100, credits_month: 10000 }

tags:
  - name: OAuth 2.0
    description: Authorization-server endpoints (authorize, token, revoke, introspect, registration, discovery).
  - name: Data API
    description: Read the public catalog with a scoped bearer token.
  - name: Publishing
    description: Create and publish recipes on a user's behalf (bearer token).
  - name: Apps
    description: Register and manage your OAuth client apps (session-authenticated portal API).
  - name: App versions
    description: Submit a whole-app config snapshot as a reviewable version, and (moderator) review/apply it. The live client keeps its current config until a version is approved.
  - name: Webhooks
    description: Subscribe app endpoints to platform events (signed delivery).
  - name: Connected apps
    description: End-user management of the apps they have authorized.
  - name: Developer program
    description: Apply for and manage developer-program access.
  - name: Marketplace
    description: >-
      Public catalog of reviewed, published apps, plus the signed-in user's own installs (their live
      OAuth consents). Catalog reads are unauthenticated.
  - name: Org apps
    description: >-
      Tenant app management for organization admins (session-authenticated): install,
      disable/enable/revoke, member install requests, and reauthorization after app drift. Not usable
      with a bearer token.

security: []

paths:
  /.well-known/oauth-authorization-server:
    get:
      tags: [OAuth 2.0]
      operationId: getAuthorizationServerMetadata
      summary: Authorization-server metadata (RFC 8414)
      description: Canonical discovery document — endpoint URLs, supported grant types, scopes and PKCE methods.
      security: []
      responses:
        "200":
          description: Discovery document.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AuthorizationServerMetadata" }
              example:
                issuer: https://evomap.ai
                authorization_endpoint: https://evomap.ai/oauth/authorize
                token_endpoint: https://evomap.ai/oauth/token
                revocation_endpoint: https://evomap.ai/oauth/revoke
                introspection_endpoint: https://evomap.ai/oauth/introspect
                registration_endpoint: https://evomap.ai/oauth/register
                scopes_supported:
                  - openid
                  - profile
                  - email
                  - gene:read
                  - recipe:read
                  - reuse:query
                  - recipe:write
                  - recipe:publish
                response_types_supported:
                  - code
                grant_types_supported:
                  - authorization_code
                  - refresh_token
                code_challenge_methods_supported:
                  - S256
                token_endpoint_auth_methods_supported:
                  - client_secret_post
                  - none

  /.well-known/openid-configuration:
    get:
      tags: [OpenID Connect]
      operationId: getOpenidConfiguration
      summary: OpenID Connect discovery
      description: OIDC discovery document — adds `jwks_uri`, `userinfo_endpoint`, `id_token_signing_alg_values_supported` (RS256) and `claims_supported` on top of the OAuth metadata.
      security: []
      responses:
        "200":
          description: OIDC discovery document.
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
              example:
                issuer: https://evomap.ai
                authorization_endpoint: https://evomap.ai/oauth/authorize
                token_endpoint: https://evomap.ai/oauth/token
                userinfo_endpoint: https://evomap.ai/oauth/userinfo
                jwks_uri: https://evomap.ai/.well-known/jwks.json
                revocation_endpoint: https://evomap.ai/oauth/revoke
                response_types_supported:
                  - code
                grant_types_supported:
                  - authorization_code
                  - refresh_token
                subject_types_supported:
                  - public
                id_token_signing_alg_values_supported:
                  - RS256
                code_challenge_methods_supported:
                  - S256
                claims_supported:
                  - sub
                  - iss
                  - aud
                  - exp
                  - iat
                  - name
                  - preferred_username
                  - email

  /.well-known/jwks.json:
    get:
      tags: [OpenID Connect]
      operationId: getJwks
      summary: JSON Web Key Set
      description: The public RSA key(s) used to verify `id_token` signatures.
      security: []
      responses:
        "200":
          description: JWKS document.
          content:
            application/json:
              schema:
                type: object
                properties:
                  keys: { type: array, items: { type: object, additionalProperties: true } }
              example:
                keys:
                  - kty: RSA
                    use: sig
                    alg: RS256
                    kid: kid_example_01HZYK7Y8N
                    n: modulus_base64url_example...
                    e: AQAB

  /oauth/authorize:
    get:
      tags: [OAuth 2.0]
      operationId: authorize
      summary: Start the consent flow
      description: |
        User-facing endpoint (requires a logged-in EvoMap session). Returns a JSON
        **consent descriptor** the SPA renders; the user POSTs the decision to
        `/oauth/authorize/decision`. If prior consent already covers the requested
        scopes, returns `auto_approved` with a ready `redirect_to`.
      security:
        - sessionCookie: []
      parameters:
        - { name: client_id, in: query, required: true, schema: { type: string }, example: evm_client_live_example123 }
        - { name: redirect_uri, in: query, required: true, schema: { type: string, format: uri }, example: "https://app.example.com/oauth/callback" }
        - { name: scope, in: query, required: true, description: Space- or comma-separated scopes., schema: { type: string }, example: "recipe:read recipe:write" }
        - { name: state, in: query, required: false, schema: { type: string }, example: state_example_01HZYK7Y8N }
        - { name: code_challenge, in: query, required: true, schema: { type: string, minLength: 43, maxLength: 128 }, example: pkce_challenge_example_43_to_128_chars_base64url }
        - { name: code_challenge_method, in: query, required: true, schema: { type: string, enum: ["S256"] }, example: S256 }
      responses:
        "200":
          description: Consent descriptor, or an auto-approved redirect.
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: "#/components/schemas/ConsentDescriptor"
                  - $ref: "#/components/schemas/AutoApproved"
              example:
                consent_required: true
                client:
                  client_id: evm_client_live_example123
                  name: Support Ops Bot
                  description: Automates support ticket summaries.
                  homepage_url: https://app.example.com
                scopes:
                  - scope: recipe:read
                    high_risk: false
                  - scope: recipe:write
                    high_risk: false
                state: state_example_01HZYK7Y8N
        "400": { $ref: "#/components/responses/OAuthError" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /oauth/authorize/decision:
    post:
      tags: [OAuth 2.0]
      operationId: authorizeDecision
      summary: Approve or deny consent
      security:
        - sessionCookie: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ConsentDecision" }
            example:
              client_id: evm_client_live_example123
              redirect_uri: https://app.example.com/oauth/callback
              scope: recipe:read recipe:write
              state: state_example_01HZYK7Y8N
              code_challenge: pkce_challenge_example_43_to_128_chars_base64url
              code_challenge_method: S256
              approved: true
      responses:
        "200":
          description: Decision result with the redirect to send the user to.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ConsentDecisionResult" }
              example:
                approved: true
                redirect_to: https://app.example.com/oauth/callback?code=auth_code_example...&state=state_example_01HZYK7Y8N
        "400": { $ref: "#/components/responses/OAuthError" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /oauth/token:
    post:
      tags: [OAuth 2.0]
      operationId: token
      summary: Exchange a code or refresh token for tokens
      description: |
        Back-channel grant. Supports `authorization_code` (with PKCE `code_verifier`)
        and `refresh_token`. Confidential clients also send `client_secret`.
      security: []
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              oneOf:
                - $ref: "#/components/schemas/AuthorizationCodeGrant"
                - $ref: "#/components/schemas/RefreshTokenGrant"
            example:
              grant_type: authorization_code
              client_id: evm_client_live_abc123
              code: auth_code_...
              redirect_uri: https://app.example.com/oauth/callback
              code_verifier: pkce_verifier_...
      responses:
        "200":
          description: Token response.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TokenResponse" }
              example:
                access_token: evm_at_...
                token_type: Bearer
                expires_in: 3600
                refresh_token: evm_rt_...
                scope: recipe:read recipe:write
        "400": { $ref: "#/components/responses/OAuthError" }
        "401": { $ref: "#/components/responses/OAuthError" }

  /oauth/userinfo:
    get:
      tags: [OpenID Connect]
      operationId: userinfo
      summary: OIDC UserInfo
      description: Standard OIDC claims for the bearer access token (`sub`, plus `name`/`preferred_username` and `email` when the token carries `profile`/`email`). Requires the `openid` scope.
      security:
        - oauth2: ["openid"]
      responses:
        "200":
          description: Claims for the authenticated user.
          content:
            application/json:
              schema:
                type: object
                properties:
                  sub: { type: string }
                  name: { type: string }
                  preferred_username: { type: string }
                  email: { type: string }
              example:
                sub: user_example_01HZYK7Y8N
                name: Avery Chen
                preferred_username: averyc
                email: avery@example.com
        "401": { $ref: "#/components/responses/InvalidToken" }
        "403": { $ref: "#/components/responses/InsufficientScope" }

  /oauth/register:
    post:
      tags: [OAuth 2.0]
      operationId: registerClient
      summary: Dynamic Client Registration (RFC 7591)
      description: |
        Self-register a **public, PKCE-only** client limited to read-only scopes
        (`gene:read`, `recipe:read`, `reuse:query`), so MCP / agent clients can
        register before the user consents. Confidential, write- or publish-capable
        clients are registered self-serve in the developer portal instead; only
        review-tier scopes go through review there. Scopes outside the DCR set are
        dropped silently, and an empty result falls back to the whole set. Gated by
        `OAUTH_DCR_ENABLED`.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ClientRegistrationRequest" }
            example:
              client_name: Recipe Reader MCP
              redirect_uris:
                - https://app.example.com/oauth/callback
              scope: recipe:read gene:read
              token_endpoint_auth_method: none
      responses:
        "201":
          description: Client information response (RFC 7591 §3.2.1) — public client, no secret.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ClientRegistrationResponse" }
              example:
                client_id: evm_client_live_example123
                client_id_issued_at: 1781683200
                client_name: Recipe Reader MCP
                redirect_uris:
                  - https://app.example.com/oauth/callback
                scope: recipe:read gene:read
                token_endpoint_auth_method: none
        "400": { $ref: "#/components/responses/OAuthError" }
        "503": { $ref: "#/components/responses/TemporarilyUnavailable" }

  /oauth/revoke:
    post:
      tags: [OAuth 2.0]
      operationId: revokeToken
      summary: Revoke a token (RFC 7009)
      security: []
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              required: [token]
              properties:
                token: { type: string }
            example:
              token: access_token_example_01HZYK7Y8N
      responses:
        "400": { $ref: "#/components/responses/OAuthError" }
        "200":
          description: Always 200, whether or not the token existed (RFC 7009).
          content:
            application/json:
              schema:
                type: object
                properties: { revoked: { type: boolean, const: true } }
              example:
                revoked: true

  /oauth/introspect:
    post:
      tags: [OAuth 2.0]
      operationId: introspectToken
      summary: Token introspection (RFC 7662)
      description: >-
        Gated by `OAUTH_INTROSPECT_ENABLED`. Restricted to **confidential
        clients**: the caller MUST authenticate with its own `client_id` +
        `client_secret` (RFC 7662 §2.1). Public / PKCE clients cannot
        authenticate (a `client_id` is not a secret) and always get
        `401 invalid_client` — RFC 7662 §4. A client may only introspect tokens
        **it issued**: another client's token always returns `active: false`
        with no scope/subject disclosed, so the endpoint is not a token/user
        enumeration oracle.
      security: []
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              required: [token, client_id, client_secret]
              properties:
                token: { type: string }
                client_id:
                  type: string
                  description: The introspecting client's id.
                client_secret:
                  type: string
                  description: The confidential client's secret. Public clients are not permitted to introspect.
            example:
              token: access_token_example_01HZYK7Y8N
              client_id: evm_client_live_example123
              client_secret: evm_secret_example_confidential
      responses:
        "200":
          description: "Introspection result. `active: false` when the token is invalid, expired, or was issued to a different client."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/IntrospectionResult" }
              example:
                active: true
                scope: recipe:read recipe:write
                client_id: evm_client_live_example123
                sub: user_example_01HZYK7Y8N
                exp: 1781686800
        "400":
          $ref: "#/components/responses/OAuthError"
        "401":
          $ref: "#/components/responses/Unauthorized"

  /oauth/consents:
    get:
      tags: [Connected apps]
      operationId: listConnectedApps
      summary: List apps the current user has authorized
      security:
        - sessionCookie: []
      responses:
        "200":
          description: Authorized apps.
          content:
            application/json:
              schema:
                type: object
                properties:
                  consents:
                    type: array
                    items: { $ref: "#/components/schemas/Grant" }
              example:
                consents:
                  - clientId: evm_client_live_example123
                    name: Support Ops Bot
                    scopes:
                      - recipe:read
                      - recipe:write
                    grantedAt: "2026-06-15T08:00:00Z"
        "401": { $ref: "#/components/responses/Unauthorized" }

  /oauth/consents/{clientId}/revoke:
    post:
      tags: [Connected apps]
      operationId: disconnectApp
      summary: Disconnect an app (revoke consent + kill its tokens)
      security:
        - sessionCookie: []
      parameters:
        - { name: clientId, in: path, required: true, schema: { type: string }, example: evm_client_live_example123 }
      responses:
        "200":
          description: Idempotent — 200 whether or not a consent existed.
          content:
            application/json:
              schema:
                type: object
                properties: { revoked: { type: boolean, const: true } }
              example:
                revoked: true
        "401": { $ref: "#/components/responses/Unauthorized" }

  /developer/oauth/recipes:
    get:
      tags: [Data API]
      operationId: listRecipes
      summary: Published recipe catalog
      description: "With a LIVE token: the published recipe catalog. With a TEST token: reads back this client's ephemeral sandbox recipes (TTL ~24h) and carries `livemode: false`."
      security:
        - oauth2: ["recipe:read"]
      parameters:
        - { name: q, in: query, required: false, description: Full-text search query (relevance-ranked single page; no cursor)., schema: { type: string }, example: support tickets }
        - { $ref: "#/components/parameters/Limit" }
        - { $ref: "#/components/parameters/Cursor" }
      responses:
        "200":
          description: Recipes. Without `q`, the published catalog is keyset-paginated — follow `pagination.next_cursor`.
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
          content:
            application/json:
              schema:
                type: object
                properties:
                  recipes: { type: array, items: { $ref: "#/components/schemas/Recipe" } }
                  pagination: { $ref: "#/components/schemas/Pagination" }
                  livemode: { type: boolean, description: "false on test-mode reads; absent on live reads." }
              example:
                recipes:
                  - id: recipe_live_123
                    title: Summarize support tickets
                    description: Cluster support tickets and draft a weekly summary.
                pagination: { limit: 20, next_cursor: cur_01HZYK7Y8N, has_more: true }
        "401": { $ref: "#/components/responses/InvalidToken" }
        "403": { $ref: "#/components/responses/InsufficientScope" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /developer/oauth/genes:
    get:
      tags: [Data API]
      operationId: listGenes
      summary: Ranked public asset (gene) catalog
      security:
        - oauth2: ["gene:read"]
      parameters:
        - { name: type, in: query, required: false, description: Filter by asset type., schema: { type: string }, example: skill }
        - { $ref: "#/components/parameters/Limit" }
      responses:
        "200":
          description: Genes — a ranked top-N feed (single response; `pagination` carries `limit` only).
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
          content:
            application/json:
              schema:
                type: object
                properties:
                  genes: { type: array, items: { $ref: "#/components/schemas/Gene" } }
                  pagination: { $ref: "#/components/schemas/Pagination" }
              example:
                genes:
                  - id: gene_123
                    name: Ticket clustering
                    type: skill
                    description: Groups semantically similar tickets.
                    call_count: 1842
                pagination: { limit: 20 }
        "401": { $ref: "#/components/responses/InvalidToken" }
        "403": { $ref: "#/components/responses/InsufficientScope" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /developer/oauth/reuse:
    get:
      tags: [Data API]
      operationId: queryReuse
      summary: Reuse / related graph
      description: Pass exactly one of `asset_id` (recipes that reuse the asset) or `recipe_id` (related recipes).
      security:
        - oauth2: ["reuse:query"]
      parameters:
        - { name: asset_id, in: query, required: false, schema: { type: string }, example: gene_123 }
        - { name: recipe_id, in: query, required: false, schema: { type: string }, example: recipe_live_123 }
        - { $ref: "#/components/parameters/Limit" }
      responses:
        "200":
          description: Reuse graph result — a bounded top-N neighbourhood (single response; `pagination` carries `limit` only).
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
          content:
            application/json:
              schema:
                allOf:
                  - { $ref: "#/components/schemas/ReuseResult" }
                  - type: object
                    properties:
                      pagination: { $ref: "#/components/schemas/Pagination" }
              example:
                assetId: gene_123
                reusedInRecipes:
                  - id: recipe_live_123
                    title: Summarize support tickets
                pagination: { limit: 10 }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/InvalidToken" }
        "403": { $ref: "#/components/responses/InsufficientScope" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /developer/oauth/recipe:
    post:
      tags: [Publishing]
      operationId: createRecipeDraft
      summary: Create a recipe draft
      description: "Output stays an unpublished draft. Requires `recipe:write`. Subject to a per-app publish quota. With a TEST token this is an ephemeral sandbox call — nothing is persisted to the real value pool; the response carries `livemode: false`, a synthesized `recipe_test_…` id, and an `originality` verdict."
      security:
        - oauth2: ["recipe:write"]
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/RecipeInput" }
            example:
              title: Summarize support tickets
              description: Cluster support tickets and draft a weekly summary.
              steps:
                - asset_id: gene_123
                  inputs: { source: zendesk }
      responses:
        "201":
          description: Draft created. On an idempotent replay the original body is returned with the `Idempotency-Replayed` header.
          headers:
            Idempotency-Replayed:
              description: '"true" when this 201 replays an earlier identical request (same Idempotency-Key + body).'
              schema: { type: string }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Recipe" }
              example:
                id: recipe_live_123
                title: Summarize support tickets
                description: Cluster support tickets and draft a weekly summary.
                status: draft
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/InvalidToken" }
        "403": { $ref: "#/components/responses/InsufficientScope" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/ContentRejected" }
        "429": { $ref: "#/components/responses/QuotaExceeded" }

  /developer/oauth/recipe/publish:
    post:
      tags: [Publishing]
      operationId: publishRecipe
      summary: Create and publish a recipe
      description: "Creates and publishes to the value pool in one call. Requires `recipe:publish`. With a TEST token this is an ephemeral sandbox call — nothing reaches the real value pool / catalog / ranking / quota / webhooks; the response carries `livemode: false` and is readable back only via GET /developer/oauth/recipes with the same test token."
      security:
        - oauth2: ["recipe:publish"]
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/RecipeInput" }
            example:
              title: Summarize support tickets
              description: Cluster support tickets and draft a weekly summary.
              steps:
                - asset_id: gene_123
                  inputs: { source: zendesk }
      responses:
        "201":
          description: Published recipe. On an idempotent replay the original body is returned with the `Idempotency-Replayed` header.
          headers:
            Idempotency-Replayed:
              description: '"true" when this 201 replays an earlier identical request (same Idempotency-Key + body).'
              schema: { type: string }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Recipe" }
              example:
                id: recipe_live_123
                title: Summarize support tickets
                description: Cluster support tickets and draft a weekly summary.
                status: published
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/InvalidToken" }
        "403": { $ref: "#/components/responses/InsufficientScope" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/ContentRejected" }
        "429": { $ref: "#/components/responses/QuotaExceeded" }

  /developer/oauth/recipe/{id}/publish:
    post:
      tags: [Publishing]
      operationId: publishExistingRecipe
      summary: Publish an existing draft recipe
      description: "Publishes a draft the developer already created (via `recipe:write`) into the value pool. Requires `recipe:publish`. Routes through the same hardened gate chain as create-and-publish — quota → moderation → originality → publish → provenance — and never bypasses moderation/originality or changes ownership: the draft must belong to the token user's node. With a TEST token this is an ephemeral sandbox call (`livemode: false`)."
      security:
        - oauth2: ["recipe:publish"]
      parameters:
        - name: id
          in: path
          required: true
          description: The draft recipe id to publish.
          schema: { type: string }
          example: recipe_live_123
        - { $ref: "#/components/parameters/IdempotencyKey" }
      responses:
        "200":
          description: Published recipe. On an idempotent replay the original body is returned with the `Idempotency-Replayed` header.
          headers:
            Idempotency-Replayed:
              description: '"true" when this 200 replays an earlier identical request (same Idempotency-Key + body).'
              schema: { type: string }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Recipe" }
              example:
                id: recipe_live_123
                title: Summarize support tickets
                description: Cluster support tickets and draft a weekly summary.
                status: published
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/InvalidToken" }
        "403": { $ref: "#/components/responses/InsufficientScope" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/ContentRejected" }
        "429": { $ref: "#/components/responses/QuotaExceeded" }

  /developer/clients:
    post:
      tags: [Apps]
      operationId: registerApp
      summary: Register a new OAuth client app
      description: "Self-serve for read, draft and publish scopes — a registration that carries only those is approved on the spot. Review-tier scopes such as `account:read` and `a2a` are refused with `403 approval_required_for_scopes` unless the caller holds an approved developer application; request them per scope once the app exists, or register a `test_mode` client, which is self-serve for the review tier too. The `client_secret` is returned exactly once."
      security:
        - sessionCookie: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ClientInput" }
            example:
              name: Local Quickstart
              description: Imports recipes into the value pool.
              redirect_uris:
                - http://localhost:3000/callback
              allowed_scopes:
                - recipe:read
                - recipe:publish
              is_confidential: true
              test_mode: true
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "201":
          description: Created client + one-time secret.
          content:
            application/json:
              schema:
                type: object
                properties:
                  client: { $ref: "#/components/schemas/Client" }
                  client_secret: { type: string, description: Shown once. Store securely. }
              example:
                client:
                  client_id: evm_client_live_abc123
                  name: Support Ops Bot
                  redirect_uris:
                    - https://app.example.com/oauth/callback
                  allowed_scopes:
                    - recipe:read
                    - recipe:write
                  status: approved
                client_secret: evm_secret_...
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/NotApprovedDeveloper" }
        "409": { $ref: "#/components/responses/Conflict" }
    get:
      tags: [Apps]
      operationId: listApps
      summary: List your OAuth client apps
      security:
        - sessionCookie: []
      responses:
        "200":
          description: Your clients (no secrets).
          content:
            application/json:
              schema:
                type: object
                properties:
                  clients: { type: array, items: { $ref: "#/components/schemas/Client" } }
              example:
                clients:
                  - id: cl_01HZYK7Y8N
                    clientId: evm_client_live_example123
                    name: Support Ops Bot
                    description: Automates support ticket summaries.
                    isConfidential: false
                    redirectUris:
                      - https://app.example.com/oauth/callback
                    allowedScopes:
                      - recipe:read
                      - recipe:write
                    status: approved
                    createdAt: "2026-06-01T09:00:00Z"
                    approvedAt: "2026-06-02T10:00:00Z"
                    revokedAt: null
        "401": { $ref: "#/components/responses/Unauthorized" }

  /developer/clients/{clientId}:
    parameters:
      - { name: clientId, in: path, required: true, schema: { type: string }, example: evm_client_live_example123 }
    get:
      tags: [Apps]
      operationId: getApp
      summary: Get one of your apps
      security:
        - sessionCookie: []
      responses:
        "200":
          description: Client config (never the secret).
          content:
            application/json:
              schema:
                type: object
                properties: { client: { $ref: "#/components/schemas/Client" } }
              example:
                client:
                  id: cl_01HZYK7Y8N
                  clientId: evm_client_live_example123
                  name: Support Ops Bot
                  redirectUris:
                    - https://app.example.com/oauth/callback
                  allowedScopes:
                    - recipe:read
                    - recipe:write
                  status: approved
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [Apps]
      operationId: updateApp
      summary: Edit redirect URIs / scopes / metadata
      security:
        - sessionCookie: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ClientPatch" }
            example:
              name: Support Ops Bot (EU)
              redirect_uris:
                - https://app.example.com/oauth/callback
                - https://eu.app.example.com/oauth/callback
              allowed_scopes:
                - recipe:read
                - recipe:write
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "200":
          description: Updated client.
          content:
            application/json:
              schema:
                type: object
                properties: { client: { $ref: "#/components/schemas/Client" } }
              example:
                client:
                  id: cl_01HZYK7Y8N
                  clientId: evm_client_live_example123
                  name: Support Ops Bot (EU)
                  redirectUris:
                    - https://app.example.com/oauth/callback
                    - https://eu.app.example.com/oauth/callback
                  allowedScopes:
                    - recipe:read
                    - recipe:write
                  status: approved
        "404": { $ref: "#/components/responses/NotFound" }

  /developer/clients/{clientId}/revoke:
    post:
      tags: [Apps]
      operationId: revokeApp
      summary: Revoke (disable) your app
      security:
        - sessionCookie: []
      parameters:
        - { name: clientId, in: path, required: true, schema: { type: string }, example: evm_client_live_example123 }
      responses:
        "200":
          description: Revoked client.
          content:
            application/json:
              schema:
                type: object
                properties: { client: { $ref: "#/components/schemas/Client" } }
              example:
                client:
                  clientId: evm_client_live_example123
                  name: Support Ops Bot
                  status: revoked
                  revokedAt: "2026-06-17T12:00:00Z"
        "404": { $ref: "#/components/responses/NotFound" }

  /developer/clients/{clientId}/rotate-secret:
    post:
      tags: [Apps]
      operationId: rotateAppSecret
      summary: Rotate the client secret
      security:
        - sessionCookie: []
      parameters:
        - { name: clientId, in: path, required: true, schema: { type: string }, example: evm_client_live_example123 }
      responses:
        "200":
          description: The new secret (shown once).
          content:
            application/json:
              schema:
                type: object
                properties: { client_secret: { type: string } }
              example:
                client_secret: evm_secret_example_rotated_...
        "404": { $ref: "#/components/responses/NotFound" }

  /developer/clients/{clientId}/usage:
    get:
      tags: [Apps]
      operationId: getAppUsage
      summary: Per-app usage summary
      security:
        - sessionCookie: []
      parameters:
        - { name: clientId, in: path, required: true, schema: { type: string }, example: evm_client_live_example123 }
      responses:
        "200":
          description: Usage summary.
          content:
            application/json:
              schema:
                type: object
                properties: { usage: { type: object, additionalProperties: true } }
              example:
                usage:
                  window: last_30_days
                  calls_total: 12480
                  calls_failed: 37
                  publishes: 42
                  active_tokens: 3
        "404": { $ref: "#/components/responses/NotFound" }

  /developer/clients/{clientId}/calls:
    get:
      tags: [Apps]
      operationId: getAppCalls
      summary: Recent API call log
      description: Owner-only recent API call log (method / path / status / latency) for the usage dashboard.
      security:
        - sessionCookie: []
      parameters:
        - { name: clientId, in: path, required: true, schema: { type: string }, example: evm_client_live_example123 }
        - name: limit
          in: query
          required: false
          description: Max rows to return (<= 200, default 50).
          schema: { type: integer, maximum: 200, default: 50 }
          example: 50
      responses:
        "200":
          description: Recent calls.
          content:
            application/json:
              schema:
                type: object
                properties:
                  calls:
                    type: array
                    items: { $ref: "#/components/schemas/DeveloperApiCall" }
              example:
                calls:
                  - method: GET
                    path: /developer/oauth/recipes
                    status: 200
                    ms: 84
                    at: "2026-06-17T11:59:12Z"
                  - method: POST
                    path: /developer/oauth/recipe/publish
                    status: 429
                    ms: 22
                    at: "2026-06-17T11:58:40Z"
        "404": { $ref: "#/components/responses/NotFound" }

  /developer/clients/{clientId}/scope-requests:
    get:
      tags: [Apps]
      operationId: listScopeRequests
      summary: List elevated-scope requests
      description: Owner lists this app's elevated-scope requests, plus the elevated-scope catalog so the portal needn't hardcode it.
      security:
        - sessionCookie: []
      parameters:
        - { name: clientId, in: path, required: true, schema: { type: string }, example: evm_client_live_example123 }
      responses:
        "200":
          description: Scope requests and the elevated-scope catalog.
          content:
            application/json:
              schema:
                type: object
                properties:
                  requests: { type: array, items: { type: object, additionalProperties: true } }
                  elevated_scopes: { type: array, items: { type: string } }
              example:
                requests:
                  - id: screq_example_01HZYK7Y8N
                    scope: account:read
                    status: pending
                    justification: Show the signed-in user's profile inside the app.
                    createdAt: "2026-06-16T09:30:00Z"
                elevated_scopes:
                  - recipe:express
                  - account:read
                  - a2a
        "401": { $ref: "#/components/responses/Unauthorized" }
    post:
      tags: [Apps]
      operationId: createScopeRequest
      summary: Request a single elevated scope
      description: "Request one elevated scope (`recipe:express` / `account:read` / `a2a` — the `elevated_scopes` list) on this app with a justification; a moderator reviews it. Any other scope is rejected with `400 invalid_scope_request`; self-serve scopes such as `recipe:publish` are added directly on the app instead. Approval merges the scope into the app's allowed scopes. Rate-limited to 20 requests/hour."
      security:
        - sessionCookie: []
      parameters:
        - { name: clientId, in: path, required: true, schema: { type: string }, example: evm_client_live_example123 }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [scope, justification]
              properties:
                scope: { type: string }
                justification: { type: string }
            example:
              scope: account:read
              justification: Show the signed-in user's profile inside the app after review.
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "201":
          description: The created scope request.
          content:
            application/json:
              schema:
                type: object
                properties: { request: { type: object, additionalProperties: true } }
              example:
                request:
                  id: screq_example_01HZYK7Y8N
                  scope: account:read
                  status: pending
                  createdAt: "2026-06-17T12:00:00Z"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /developer/clients/{clientId}/secret-rotations:
    get:
      tags: [Apps]
      operationId: listSecretRotations
      summary: Client secret rotation history
      description: Owner-only credential rotation history (who / when + last-rotation summary). Never returns any secret material.
      security:
        - sessionCookie: []
      parameters:
        - { name: clientId, in: path, required: true, schema: { type: string }, example: evm_client_live_example123 }
      responses:
        "200":
          description: Rotation history.
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
              example:
                secretRotatedAt: "2026-06-14T10:05:00Z"
                secretRotationCount: 2
                rotations:
                  - rotatedAt: "2026-06-14T10:05:00Z"
                    actor: user:user_example_01HZYK7Y8N
                  - rotatedAt: "2026-03-02T08:20:00Z"
                    actor: user:user_example_01HZYK7Y8N
        "404": { $ref: "#/components/responses/NotFound" }

  /developer/clients/{clientId}/activity:
    get:
      tags: [Apps]
      operationId: getAppActivity
      summary: Recent app activity
      security:
        - sessionCookie: []
      parameters:
        - { name: clientId, in: path, required: true, schema: { type: string }, example: evm_client_live_example123 }
      responses:
        "200":
          description: Recent activity events.
          content:
            application/json:
              schema:
                type: object
                properties:
                  activity: { type: array, items: { type: object, additionalProperties: true } }
              example:
                activity:
                  - type: secret_rotated
                    at: "2026-06-14T10:05:00Z"
                    actor: user:user_example_01HZYK7Y8N
                  - type: version_approved
                    at: "2026-06-10T15:42:00Z"
                    version: 3
        "404": { $ref: "#/components/responses/NotFound" }

  /developer/clients/{clientId}/versions:
    parameters:
      - { name: clientId, in: path, required: true, schema: { type: string }, example: evm_client_live_example123 }
    post:
      tags: [App versions]
      operationId: submitClientVersion
      summary: Submit a new app version for review
      description: |
        Snapshot the whole app config (`name`, `redirectUris`, `allowedScopes`, `webhookEvents`)
        as a new pending VERSION with a `changelog` + `justification`. The live client keeps its
        current config running until a moderator approves the version. Only self-service scopes
        enter the snapshot; a proposed elevated scope is decided by the reviewer at approve time,
        and a non-self-service scope (e.g. `node:manage`) is never accepted. At most one open
        (`draft`|`pending`) version may exist per client. Rate-limited to 20 submissions/hour.
      security:
        - sessionCookie: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ClientVersionInput" }
            example:
              config:
                name: Support Ops Bot
                redirectUris:
                  - https://app.example.com/oauth/callback
                allowedScopes:
                  - recipe:read
                  - recipe:write
                webhookEvents:
                  - recipe.published
              changelog: Add the recipe.published webhook and a second redirect URI.
              justification: We now notify our backend when a recipe our app drafted gets published.
      responses:
        "201":
          description: Created pending version.
          content:
            application/json:
              schema:
                type: object
                properties: { version: { $ref: "#/components/schemas/ClientVersion" } }
              example:
                version:
                  id: cv_01HZYK7Y8N
                  clientId: evm_client_live_example123
                  version: 3
                  status: pending
                  config:
                    name: Support Ops Bot
                    redirectUris:
                      - https://app.example.com/oauth/callback
                    allowedScopes:
                      - recipe:read
                      - recipe:write
                    webhookEvents:
                      - recipe.published
                  changelog: Add the recipe.published webhook and a second redirect URI.
                  justification: We now notify our backend when a recipe our app drafted gets published.
                  submittedBy: usr_01HZYK7Y8N
                  reviewedBy: null
                  reviewedAt: null
                  rejectReason: null
                  createdAt: "2026-06-17T09:30:00Z"
        "400": { $ref: "#/components/responses/BadRequest" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/RateLimited" }
    get:
      tags: [App versions]
      operationId: listClientVersions
      summary: List the app's versions
      description: Owner-only. Every version for the client, newest first.
      security:
        - sessionCookie: []
      responses:
        "200":
          description: Versions (config echoed as submitted; no secret material).
          content:
            application/json:
              schema:
                type: object
                properties:
                  versions: { type: array, items: { $ref: "#/components/schemas/ClientVersion" } }
              example:
                versions:
                  - id: cv_01HZYK7Y8N
                    clientId: evm_client_live_example123
                    version: 3
                    status: pending
                    changelog: Add the recipe.published webhook and a second redirect URI.
                    createdAt: "2026-06-17T09:30:00Z"
                  - id: cv_01HZYJ2M4Q
                    clientId: evm_client_live_example123
                    version: 2
                    status: approved
                    changelog: Rename the app.
                    createdAt: "2026-06-10T14:00:00Z"
        "404": { $ref: "#/components/responses/NotFound" }

  /admin/oauth/client-versions:
    get:
      tags: [App versions]
      operationId: listClientVersionsForReview
      summary: App version review queue (moderator)
      description: |
        Moderator-only. Versions filtered by `status`, enriched with the owner's email, the
        client name and the current live config (scopes / redirect URIs / active version) so a
        reviewer can render the delta. `pending` is returned oldest-first (FIFO queue); other
        statuses newest-first.
      security:
        - sessionCookie: []
      parameters:
        - name: status
          in: query
          required: false
          description: Filter by review status. Defaults to `pending`.
          schema:
            type: string
            enum: [draft, pending, approved, rejected, all]
            default: pending
          example: pending
        - name: limit
          in: query
          required: false
          description: Max rows (1–200).
          schema: { type: integer, default: 100, minimum: 1, maximum: 200 }
          example: 100
      responses:
        "200":
          description: Review queue.
          content:
            application/json:
              schema:
                type: object
                properties:
                  versions: { type: array, items: { $ref: "#/components/schemas/AdminClientVersion" } }
                  count: { type: integer }
                  status: { type: string }
              example:
                versions:
                  - id: cv_01HZYK7Y8N
                    clientId: evm_client_live_example123
                    clientName: Support Ops Bot
                    ownerEmail: avery@example.com
                    version: 4
                    status: pending
                    changelog: Add the recipe.published webhook and a second redirect URI.
                    submittedBy: usr_01HZYK7Y8N
                    createdAt: "2026-06-17T09:30:00Z"
                count: 1
                status: pending
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/InsufficientScope" }

  /admin/oauth/client-versions/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    patch:
      tags: [App versions]
      operationId: reviewClientVersion
      summary: Approve or reject an app version (moderator)
      description: |
        Moderator-only decision on a `pending` version. On `approved` the config snapshot is
        ATOMICALLY applied to the live OAuthClient (status flip + `activeVersion` bump in one
        transaction); scopes are re-filtered through the same update rules, so a version is not a
        scope-review backdoor. `rejected` leaves the live client untouched. Only pending versions
        are decidable.
      security:
        - sessionCookie: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ClientVersionReviewInput" }
            example:
              decision: approved
              reject_reason: null
      responses:
        "200":
          description: Updated version.
          content:
            application/json:
              schema:
                type: object
                properties: { version: { $ref: "#/components/schemas/ClientVersion" } }
              example:
                version:
                  id: cv_01HZYK7Y8N
                  clientId: evm_client_live_example123
                  version: 3
                  status: approved
                  reviewedBy: usr_moderator_01
                  reviewedAt: "2026-06-17T15:00:00Z"
                  rejectReason: null
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }

  /developer/clients/{clientId}/webhooks:
    parameters:
      - { name: clientId, in: path, required: true, schema: { type: string }, example: evm_client_live_example123 }
    post:
      tags: [Webhooks]
      operationId: createWebhook
      summary: Register a webhook endpoint
      description: |
        Subscribe an HTTPS endpoint to events (`recipe.created`, `recipe.published`, `recipe.takedown`).
        The URL is SSRF-validated. Deliveries are signed: `X-EvoMap-Webhook-Signature:
        t=<unix>,v1=<hmac over ${t}.${body}>` (verify + reject stale `t` to prevent
        replay); a legacy `X-EvoMap-Signature: sha256=<hmac over body>` is also sent.
        The signing `secret` is returned only at creation. Each delivery is retried
        (in-process, exponential backoff) and recorded — see the deliveries endpoint.
      security:
        - sessionCookie: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WebhookInput" }
            example:
              url: https://app.example.com/webhooks/evomap
              events:
                - recipe.published
                - recipe.takedown
      responses:
        "201":
          description: Created webhook (includes the signing secret once).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WebhookCreated" }
              example:
                id: wh_01HZYK7Y8N
                url: https://app.example.com/webhooks/evomap
                events:
                  - recipe.published
                  - recipe.takedown
                secret: whsec_example_...
        "400": { $ref: "#/components/responses/BadRequest" }
        "404": { $ref: "#/components/responses/NotFound" }
    get:
      tags: [Webhooks]
      operationId: listWebhooks
      summary: List the app's webhooks
      security:
        - sessionCookie: []
      responses:
        "200":
          description: Webhooks (no secrets).
          content:
            application/json:
              schema:
                type: object
                properties:
                  webhooks: { type: array, items: { $ref: "#/components/schemas/Webhook" } }
              example:
                webhooks:
                  - id: wh_01HZYK7Y8N
                    url: https://app.example.com/webhooks/evomap
                    events:
                      - recipe.published
                      - recipe.takedown
                    active: true
                    createdAt: "2026-06-17T09:30:00Z"
        "404": { $ref: "#/components/responses/NotFound" }

  /developer/webhooks/{webhookId}:
    delete:
      tags: [Webhooks]
      operationId: deleteWebhook
      summary: Delete a webhook
      security:
        - sessionCookie: []
      parameters:
        - { name: webhookId, in: path, required: true, schema: { type: string }, example: wh_01HZYK7Y8N }
      responses:
        "200":
          description: Deleted.
          content:
            application/json:
              schema:
                type: object
                properties: { ok: { type: boolean, const: true } }
              example:
                ok: true
        "404": { $ref: "#/components/responses/NotFound" }

  /developer/webhooks/{webhookId}/ping:
    post:
      tags: [Webhooks]
      operationId: pingWebhook
      summary: Send a test (ping) event
      description: Delivers a `ping` event so you can verify the endpoint + signature at setup. Owner only.
      security:
        - sessionCookie: []
      parameters:
        - { name: webhookId, in: path, required: true, schema: { type: string }, example: wh_01HZYK7Y8N }
      responses:
        "200":
          description: Ping delivered (or attempted — inspect the delivery).
          content:
            application/json:
              schema:
                type: object
                properties:
                  event_id: { type: string }
                  delivery: { $ref: "#/components/schemas/WebhookDelivery" }
              example:
                event_id: evt_01HZYK7Y8N
                delivery:
                  id: whd_01HZYK7Y8N
                  event: ping
                  event_id: evt_01HZYK7Y8N
                  status: delivered
                  http_status: 200
                  attempts: 1
                  last_error: null
                  created_at: "2026-06-17T09:31:00Z"
                  delivered_at: "2026-06-17T09:31:01Z"
        "404": { $ref: "#/components/responses/NotFound" }

  /developer/webhooks/{webhookId}/deliveries:
    get:
      tags: [Webhooks]
      operationId: listWebhookDeliveries
      summary: List recent delivery attempts
      description: Recent delivery attempts (status / http code / attempts / error) for debugging. Owner only. Kept ~7 days.
      security:
        - sessionCookie: []
      parameters:
        - { name: webhookId, in: path, required: true, schema: { type: string }, example: wh_01HZYK7Y8N }
        - { $ref: "#/components/parameters/Limit" }
      responses:
        "200":
          description: Deliveries (newest first).
          content:
            application/json:
              schema:
                type: object
                properties:
                  deliveries: { type: array, items: { $ref: "#/components/schemas/WebhookDelivery" } }
              example:
                deliveries:
                  - id: whd_01HZYK7Y8N
                    event: recipe.published
                    event_id: evt_01HZYK7Y8N
                    status: delivered
                    http_status: 200
                    attempts: 1
                    last_error: null
                    created_at: "2026-06-17T09:31:00Z"
                    delivered_at: "2026-06-17T09:31:01Z"
                  - id: whd_01HZYJ2M4Q
                    event: recipe.created
                    event_id: evt_01HZYJ2M4Q
                    status: failed
                    http_status: 500
                    attempts: 3
                    last_error: "endpoint returned 500"
                    created_at: "2026-06-16T18:00:00Z"
                    delivered_at: null
        "404": { $ref: "#/components/responses/NotFound" }

  /developer/webhooks/{webhookId}/deliveries/{deliveryId}/redeliver:
    post:
      tags: [Webhooks]
      operationId: redeliverWebhook
      summary: Re-send a past event
      description: Re-deliver a previously logged event to the webhook. Owner only.
      security:
        - sessionCookie: []
      parameters:
        - { name: webhookId, in: path, required: true, schema: { type: string }, example: wh_01HZYK7Y8N }
        - { name: deliveryId, in: path, required: true, schema: { type: string }, example: whd_01HZYK7Y8N }
      responses:
        "200":
          description: Redelivery attempted.
          content:
            application/json:
              schema:
                type: object
                properties:
                  event_id: { type: string }
                  delivery: { $ref: "#/components/schemas/WebhookDelivery" }
              example:
                event_id: evt_01HZYK7Y8N
                delivery:
                  id: whd_01HZYK7Y8N
                  event: recipe.published
                  event_id: evt_01HZYK7Y8N
                  status: delivered
                  http_status: 200
                  attempts: 2
        "404": { $ref: "#/components/responses/NotFound" }

  /developer/grants:
    get:
      tags: [Connected apps]
      operationId: listGrants
      summary: Apps the current user has authorized
      security:
        - sessionCookie: []
      responses:
        "200":
          description: Grants.
          content:
            application/json:
              schema:
                type: object
                properties:
                  grants: { type: array, items: { $ref: "#/components/schemas/Grant" } }
              example:
                grants:
                  - clientId: evm_client_live_example123
                    name: Support Ops Bot
                    scopes:
                      - recipe:read
                      - recipe:write
                    grantedAt: "2026-06-15T08:00:00Z"
        "401": { $ref: "#/components/responses/Unauthorized" }

  /developer/grants/{clientId}/revoke:
    post:
      tags: [Connected apps]
      operationId: revokeGrant
      summary: Revoke an app's access to you
      security:
        - sessionCookie: []
      parameters:
        - { name: clientId, in: path, required: true, schema: { type: string }, example: evm_client_live_example123 }
      responses:
        "200":
          description: Revoked.
          content:
            application/json:
              schema:
                type: object
                properties: { ok: { type: boolean, const: true } }
              example:
                ok: true
        "401": { $ref: "#/components/responses/Unauthorized" }

  /developer/applications:
    post:
      tags: [Developer program]
      operationId: applyForProgram
      summary: Apply to the developer program (invite-gated)
      security:
        - sessionCookie: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [invite_code, motivation]
              properties:
                invite_code: { type: string }
                motivation: { type: string }
            example:
              invite_code: EVOMAP-DEV-EXAMPLE
              motivation: Building a support-ops integration that drafts weekly recipe summaries for our team.
      responses:
        "201":
          description: Application submitted.
          content:
            application/json:
              schema:
                type: object
                properties: { application: { $ref: "#/components/schemas/Application" } }
              example:
                application:
                  id: app_01HZYK7Y8N
                  status: pending
                  motivation: Building a support-ops integration that drafts weekly recipe summaries for our team.
                  createdAt: "2026-06-17T09:00:00Z"
        "400": { $ref: "#/components/responses/BadRequest" }
        "403": { $ref: "#/components/responses/OAuthError" }
        "409": { $ref: "#/components/responses/Conflict" }

  /developer/applications/my:
    get:
      tags: [Developer program]
      operationId: listMyApplications
      summary: Your developer-program applications
      security:
        - sessionCookie: []
      responses:
        "200":
          description: Applications.
          content:
            application/json:
              schema:
                type: object
                properties:
                  applications: { type: array, items: { $ref: "#/components/schemas/Application" } }
              example:
                applications:
                  - id: app_01HZYK7Y8N
                    status: pending
                    motivation: Building a support-ops integration that drafts weekly recipe summaries for our team.
                    createdAt: "2026-06-17T09:00:00Z"
        "401": { $ref: "#/components/responses/Unauthorized" }

  /marketplace/apps:
    get:
      tags:
        - Marketplace
      operationId: listMarketplaceApps
      summary: List published Marketplace apps
      description: >-
        Public, unauthenticated catalog read. Only listings a moderator has published are visible, and
        each entry is joined with its still-approved OAuth client so the advertised scopes/version are
        the app's real capability surface.
      security: []
      parameters:
        - name: category
          in: query
          required: false
          schema:
            type: string
            maxLength: 64
          description: Filter by listing category.
        - name: q
          in: query
          required: false
          schema:
            type: string
            maxLength: 120
          description: Free-text search over title / description.
        - name: limit
          in: query
          required: false
          schema:
            type: integer
            minimum: 1
            maximum: 50
            default: 20
        - name: cursor
          in: query
          required: false
          schema:
            type: string
            maxLength: 200
          description: Opaque keyset cursor from a previous response's next_cursor.
      responses:
        '200':
          description: Published apps plus a keyset cursor.
          content:
            application/json:
              schema:
                type: object
                properties:
                  apps:
                    type: array
                    items:
                      $ref: '#/components/schemas/MarketplacePublicApp'
                  next_cursor:
                    type:
                      - string
                      - 'null'
                    description: Pass back as ?cursor for the next page; null on the last page.
              example:
                apps:
                  - id: mal_01HZYK7Y8N
                    clientId: evm_client_live_example123
                    slug: support-ops-bot
                    title: Support Ops Bot
                    short_description: Automates support ticket summaries for your workspace.
                    long_description: >-
                      Support Ops Bot reads incoming tickets and posts structured summaries to your
                      team channel.
                    category: productivity
                    tags:
                      - support
                      - automation
                    logo_url: https://cdn.example.com/logos/support-ops-bot.png
                    screenshots:
                      - https://cdn.example.com/shots/support-ops-bot-1.png
                    docs_url: https://docs.example.com/support-ops-bot
                    support_url: https://support.example.com
                    privacy_policy_url: https://example.com/privacy
                    terms_url: https://example.com/terms
                    install_audience: org
                    status: published
                    published_version: 3
                    review_summary:
                      decision: approved
                    install_count: 42
                    submitted_at: '2026-06-20T12:00:00Z'
                    published_at: '2026-06-22T08:00:00Z'
                    created_at: '2026-06-01T09:00:00Z'
                    updated_at: '2026-06-22T08:00:00Z'
                    client:
                      client_id: evm_client_live_example123
                      name: Support Ops Bot
                      allowed_scopes:
                        - recipe:read
                        - gene:read
                      declared_webhook_events:
                        - recipe.published
                      active_version: 3
                      homepage_url: https://example.com
                next_cursor: null
        '429':
          $ref: '#/components/responses/RateLimited'
  /marketplace/apps/{slug}:
    get:
      tags:
        - Marketplace
      operationId: getMarketplaceApp
      summary: Get one published Marketplace app
      description: Public, unauthenticated read of a single published listing by slug.
      security: []
      parameters:
        - name: slug
          in: path
          required: true
          schema:
            type: string
          description: Listing slug from the catalog.
      responses:
        '200':
          description: The published app.
          content:
            application/json:
              schema:
                type: object
                properties:
                  app:
                    $ref: '#/components/schemas/MarketplacePublicApp'
              example:
                app:
                  id: mal_01HZYK7Y8N
                  clientId: evm_client_live_example123
                  slug: support-ops-bot
                  title: Support Ops Bot
                  short_description: Automates support ticket summaries for your workspace.
                  long_description: >-
                    Support Ops Bot reads incoming tickets and posts structured summaries to your team
                    channel.
                  category: productivity
                  tags:
                    - support
                    - automation
                  logo_url: https://cdn.example.com/logos/support-ops-bot.png
                  screenshots:
                    - https://cdn.example.com/shots/support-ops-bot-1.png
                  docs_url: https://docs.example.com/support-ops-bot
                  support_url: https://support.example.com
                  privacy_policy_url: https://example.com/privacy
                  terms_url: https://example.com/terms
                  install_audience: org
                  status: published
                  published_version: 3
                  review_summary:
                    decision: approved
                  install_count: 42
                  submitted_at: '2026-06-20T12:00:00Z'
                  published_at: '2026-06-22T08:00:00Z'
                  created_at: '2026-06-01T09:00:00Z'
                  updated_at: '2026-06-22T08:00:00Z'
                  client:
                    client_id: evm_client_live_example123
                    name: Support Ops Bot
                    allowed_scopes:
                      - recipe:read
                      - gene:read
                    declared_webhook_events:
                      - recipe.published
                    active_version: 3
                    homepage_url: https://example.com
        '404':
          $ref: '#/components/responses/ListingNotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
  /marketplace/apps/{slug}/install-state:
    get:
      tags:
        - Marketplace
      operationId: getMarketplaceAppInstallState
      summary: Install eligibility for one app
      description: >-
        Whether the CURRENT user has installed this user-audience app, plus the scopes it requests.
        Readable while signed out (returns `installed: false`) so the catalog can render the CTA
        before login; with a session cookie it reflects the caller's real consent state. Consent
        itself is granted ONLY through the interactive `/oauth/authorize` flow — there is no
        server-side install shortcut.
      security: []
      parameters:
        - name: slug
          in: path
          required: true
          schema:
            type: string
          description: Listing slug from the catalog.
      responses:
        '200':
          description: Install state for the current caller.
          content:
            application/json:
              schema:
                type: object
                properties:
                  listing:
                    oneOf:
                      - $ref: '#/components/schemas/MarketplaceUserListing'
                      - type: 'null'
                  app:
                    type: object
                    properties:
                      client_id:
                        type: string
                      name:
                        type: string
                      allowed_scopes:
                        type: array
                        items:
                          type: string
                        description: OAuth scope identifiers.
                      active_version:
                        type:
                          - integer
                          - 'null'
                      homepage_url:
                        type:
                          - string
                          - 'null'
                  installed:
                    type: boolean
                  granted_scopes:
                    type: array
                    items:
                      type: string
                    description: OAuth scope identifiers.
                  authorize_path:
                    type: string
                    const: /oauth/authorize
                    description: Consent is granted only through the interactive OAuth flow.
              example:
                listing:
                  id: mal_01HZYK7Y8N
                  slug: support-ops-bot
                  title: Support Ops Bot
                  short_description: Automates support ticket summaries for your workspace.
                  category: productivity
                  logo_url: https://cdn.example.com/logos/support-ops-bot.png
                  install_audience: user
                  published_version: 3
                app:
                  client_id: evm_client_live_example123
                  name: Support Ops Bot
                  allowed_scopes:
                    - recipe:read
                    - gene:read
                  active_version: 3
                  homepage_url: https://example.com
                installed: false
                granted_scopes: []
                authorize_path: /oauth/authorize
        '400':
          description: >-
            The app is not installable by individual users (org/agent-audience listing) or the client
            is invalid.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Error'
                properties:
                  error:
                    type: string
                    enum:
                      - user_install_not_supported
                      - invalid_client
              example:
                error: user_install_not_supported
                type: invalid_request
                request_id: req_01HZYK7Y8N4P0VZ9F8X2Q3R4S5
        '404':
          $ref: '#/components/responses/ListingNotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
  /marketplace/me/installations:
    get:
      tags:
        - Marketplace
      operationId: listMyMarketplaceInstallations
      summary: List your installed apps
      description: >-
        The current user's installed user-audience Marketplace apps — i.e. their live OAuth consents,
        rendered in Marketplace shape with the listing card when one exists.
      security:
        - sessionCookie: []
      responses:
        '200':
          description: Your installations.
          content:
            application/json:
              schema:
                type: object
                properties:
                  installations:
                    type: array
                    items:
                      $ref: '#/components/schemas/MarketplaceUserInstallation'
              example:
                installations:
                  - client_id: evm_client_live_example123
                    client_name: Support Ops Bot
                    homepage_url: https://example.com
                    granted_scopes:
                      - recipe:read
                    installed_at: '2026-07-01T09:00:00Z'
                    listing:
                      id: mal_01HZYK7Y8N
                      slug: support-ops-bot
                      title: Support Ops Bot
                      short_description: Automates support ticket summaries for your workspace.
                      category: productivity
                      logo_url: https://cdn.example.com/logos/support-ops-bot.png
                      install_audience: user
                      published_version: 3
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimited'
  /marketplace/me/installations/{clientId}:
    delete:
      tags:
        - Marketplace
      operationId: uninstallMyMarketplaceApp
      summary: Uninstall an app you installed
      description: >-
        Revokes the caller's OAuth consent for the app and kills its live tokens. NOT served on
        evomap.ai: use `POST /oauth/consents/{clientId}/revoke` instead. Both verbs revoke the SAME
        consent row, and the platform exposes one of them on purpose so the two cannot drift apart.
      security:
        - sessionCookie: []
      parameters:
        - name: clientId
          in: path
          required: true
          schema:
            type: string
          description: The app's client_id.
      responses:
        '200':
          description: Consent revoked.
          content:
            application/json:
              schema:
                type: object
                properties:
                  uninstalled:
                    type: boolean
                  client_id:
                    type: string
              example:
                uninstalled: true
                client_id: evm_client_live_example123
        '400':
          description: Unknown or invalid client.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Error'
                properties:
                  error:
                    type: string
                    enum:
                      - invalid_client
              example:
                error: invalid_client
                type: invalid_request
                request_id: req_01HZYK7Y8N4P0VZ9F8X2Q3R4S5
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimited'
  /developer/clients/{clientId}/listing:
    parameters:
      - name: clientId
        in: path
        required: true
        schema:
          type: string
        example: evm_client_live_example123
    get:
      tags:
        - Apps
      operationId: getAppListing
      summary: Get your app's Marketplace listing
      description: >-
        Owner-only read of the app's Marketplace listing metadata. Returns `listing: null` when no
        listing has been drafted yet.
      security:
        - sessionCookie: []
      responses:
        '200':
          description: The listing, or null when none exists.
          content:
            application/json:
              schema:
                type: object
                properties:
                  listing:
                    oneOf:
                      - $ref: '#/components/schemas/MarketplaceListing'
                      - type: 'null'
              example:
                listing:
                  id: mal_01HZYK7Y8N
                  clientId: evm_client_live_example123
                  slug: support-ops-bot
                  title: Support Ops Bot
                  short_description: Automates support ticket summaries for your workspace.
                  long_description: >-
                    Support Ops Bot reads incoming tickets and posts structured summaries to your team
                    channel.
                  category: productivity
                  tags:
                    - support
                    - automation
                  logo_url: https://cdn.example.com/logos/support-ops-bot.png
                  screenshots:
                    - https://cdn.example.com/shots/support-ops-bot-1.png
                  docs_url: https://docs.example.com/support-ops-bot
                  support_url: https://support.example.com
                  privacy_policy_url: https://example.com/privacy
                  terms_url: https://example.com/terms
                  install_audience: org
                  status: published
                  published_version: 3
                  review_summary:
                    decision: approved
                  install_count: 42
                  submitted_at: '2026-06-20T12:00:00Z'
                  published_at: '2026-06-22T08:00:00Z'
                  created_at: '2026-06-01T09:00:00Z'
                  updated_at: '2026-06-22T08:00:00Z'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
    put:
      tags:
        - Apps
      operationId: upsertAppListing
      summary: Create or update the listing draft
      description: >-
        Owner-only create-or-update of the app's Marketplace listing draft. Editing a published
        listing moves it back to draft until it is resubmitted and re-approved.
      security:
        - sessionCookie: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/MarketplaceListingInput'
            example:
              title: Support Ops Bot
              short_description: Automates support ticket summaries for your workspace.
              category: productivity
              tags:
                - support
                - automation
              install_audience: org
      responses:
        '200':
          description: The saved listing draft.
          content:
            application/json:
              schema:
                type: object
                properties:
                  listing:
                    $ref: '#/components/schemas/MarketplaceListing'
              example:
                listing:
                  id: mal_01HZYK7Y8N
                  clientId: evm_client_live_example123
                  slug: support-ops-bot
                  title: Support Ops Bot
                  short_description: Automates support ticket summaries for your workspace.
                  long_description: >-
                    Support Ops Bot reads incoming tickets and posts structured summaries to your team
                    channel.
                  category: productivity
                  tags:
                    - support
                    - automation
                  logo_url: https://cdn.example.com/logos/support-ops-bot.png
                  screenshots:
                    - https://cdn.example.com/shots/support-ops-bot-1.png
                  docs_url: https://docs.example.com/support-ops-bot
                  support_url: https://support.example.com
                  privacy_policy_url: https://example.com/privacy
                  terms_url: https://example.com/terms
                  install_audience: org
                  status: draft
                  published_version: null
                  review_summary:
                    decision: approved
                  install_count: 42
                  submitted_at: null
                  published_at: null
                  created_at: '2026-06-01T09:00:00Z'
                  updated_at: '2026-06-22T08:00:00Z'
        '400':
          description: The listing payload failed validation.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Error'
                properties:
                  error:
                    type: string
                    enum:
                      - invalid_listing_payload
              example:
                error: invalid_listing_payload
                type: invalid_request
                request_id: req_01HZYK7Y8N4P0VZ9F8X2Q3R4S5
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: >-
            The slug is already taken by another listing, or the listing is suspended and cannot be
            edited.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Error'
                properties:
                  error:
                    type: string
                    enum:
                      - listing_slug_taken
                      - listing_suspended
              example:
                error: listing_slug_taken
                type: conflict
                request_id: req_01HZYK7Y8N4P0VZ9F8X2Q3R4S5
    delete:
      tags:
        - Apps
      operationId: archiveAppListing
      summary: Hide / archive the listing
      description: Owner-only. Removes the listing from the public catalog; existing installations keep working.
      security:
        - sessionCookie: []
      responses:
        '200':
          description: The archived listing.
          content:
            application/json:
              schema:
                type: object
                properties:
                  listing:
                    $ref: '#/components/schemas/MarketplaceListing'
              example:
                listing:
                  id: mal_01HZYK7Y8N
                  clientId: evm_client_live_example123
                  slug: support-ops-bot
                  title: Support Ops Bot
                  short_description: Automates support ticket summaries for your workspace.
                  long_description: >-
                    Support Ops Bot reads incoming tickets and posts structured summaries to your team
                    channel.
                  category: productivity
                  tags:
                    - support
                    - automation
                  logo_url: https://cdn.example.com/logos/support-ops-bot.png
                  screenshots:
                    - https://cdn.example.com/shots/support-ops-bot-1.png
                  docs_url: https://docs.example.com/support-ops-bot
                  support_url: https://support.example.com
                  privacy_policy_url: https://example.com/privacy
                  terms_url: https://example.com/terms
                  install_audience: org
                  status: hidden
                  published_version: 3
                  review_summary:
                    decision: approved
                  install_count: 42
                  submitted_at: '2026-06-20T12:00:00Z'
                  published_at: '2026-06-22T08:00:00Z'
                  created_at: '2026-06-01T09:00:00Z'
                  updated_at: '2026-06-22T08:00:00Z'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/ListingNotFound'
  /developer/clients/{clientId}/listing/submit:
    parameters:
      - name: clientId
        in: path
        required: true
        schema:
          type: string
        example: evm_client_live_example123
    post:
      tags:
        - Apps
      operationId: submitAppListing
      summary: Submit the listing for review
      description: >-
        Owner-only. Sends the listing draft to the moderator review queue. Requires an approved client
        with an active (approved) app version — approval publishes the listing and freezes that
        version as `published_version`.
      security:
        - sessionCookie: []
      responses:
        '200':
          description: The listing, now in submitted state.
          content:
            application/json:
              schema:
                type: object
                properties:
                  listing:
                    $ref: '#/components/schemas/MarketplaceListing'
              example:
                listing:
                  id: mal_01HZYK7Y8N
                  clientId: evm_client_live_example123
                  slug: support-ops-bot
                  title: Support Ops Bot
                  short_description: Automates support ticket summaries for your workspace.
                  long_description: >-
                    Support Ops Bot reads incoming tickets and posts structured summaries to your team
                    channel.
                  category: productivity
                  tags:
                    - support
                    - automation
                  logo_url: https://cdn.example.com/logos/support-ops-bot.png
                  screenshots:
                    - https://cdn.example.com/shots/support-ops-bot-1.png
                  docs_url: https://docs.example.com/support-ops-bot
                  support_url: https://support.example.com
                  privacy_policy_url: https://example.com/privacy
                  terms_url: https://example.com/terms
                  install_audience: org
                  status: submitted
                  published_version: null
                  review_summary:
                    decision: approved
                  install_count: 42
                  submitted_at: '2026-06-20T12:00:00Z'
                  published_at: null
                  created_at: '2026-06-01T09:00:00Z'
                  updated_at: '2026-06-22T08:00:00Z'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/ListingNotFound'
        '409':
          description: The client is not approved, has no active version to freeze, or the listing is suspended.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Error'
                properties:
                  error:
                    type: string
                    enum:
                      - client_not_approved
                      - active_version_required
                      - listing_suspended
              example:
                error: active_version_required
                type: conflict
                request_id: req_01HZYK7Y8N4P0VZ9F8X2Q3R4S5
  /developer/clients/{clientId}/dashboard:
    parameters:
      - name: clientId
        in: path
        required: true
        schema:
          type: string
        example: evm_client_live_example123
    get:
      tags:
        - Apps
      operationId: getAppDashboard
      summary: Aggregate owner app dashboard
      description: >-
        One read for the whole app surface: client config, Marketplace listing, review state (versions
        + scope requests), tenant install counters, and usage. Install stats are count-only — never
        the identities of the orgs that installed the app.
      security:
        - sessionCookie: []
      responses:
        '200':
          description: The dashboard aggregate.
          content:
            application/json:
              schema:
                type: object
                properties:
                  dashboard:
                    $ref: '#/components/schemas/DeveloperAppDashboard'
              example:
                dashboard:
                  client:
                    client_id: evm_client_live_example123
                    name: Support Ops Bot
                    description: Automates support ticket summaries.
                    homepage_url: https://example.com
                    status: approved
                    is_confidential: true
                    allowed_scopes:
                      - recipe:read
                      - gene:read
                    redirect_uris:
                      - https://app.example.com/oauth/callback
                    declared_webhook_events:
                      - recipe.published
                    active_version: 3
                    created_at: '2026-06-01T09:00:00Z'
                    approved_at: '2026-06-02T10:00:00Z'
                    revoked_at: null
                    secret_rotated_at: null
                    secret_rotation_count: 0
                  listing:
                    id: mal_01HZYK7Y8N
                    slug: support-ops-bot
                    title: Support Ops Bot
                    category: productivity
                    install_audience: org
                    status: published
                    published_version: 3
                    review_summary:
                      decision: approved
                    install_count: 42
                    submitted_at: '2026-06-20T12:00:00Z'
                    published_at: '2026-06-22T08:00:00Z'
                    archived: false
                    updated_at: '2026-06-22T08:00:00Z'
                  review:
                    versions:
                      - id: ocv_01HZYK7Y8N
                        version: 3
                        status: approved
                        reviewed_at: '2026-06-21T10:00:00Z'
                        reject_reason: null
                        created_at: '2026-06-20T12:00:00Z'
                    pending_version: null
                    scope_requests: []
                    pending_scope_requests: []
                  installs:
                    total: 42
                    active: 40
                    uninstalled: 1
                    requires_reauth: 1
                    suspended: 0
                    stale_version_installs: 1
                  usage:
                    calls_30d: 12000
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
  /org/{orgId}/apps:
    get:
      tags:
        - Org apps
      operationId: listOrgApps
      summary: List the org's app installations
      description: >-
        Org-admin session endpoint (not callable with a bearer token). Lists the org's Marketplace app
        installations with reauth drift flags. Product alias of `GET
        /org/{orgId}/marketplace/installations`.
      security:
        - sessionCookie: []
      parameters:
        - name: orgId
          in: path
          required: true
          description: Organization id.
          schema:
            type: string
        - name: status
          in: query
          required: false
          schema:
            type: string
            enum:
              - active
              - disabled
              - revoked
              - uninstalled
              - requires_reauth
              - suspended
              - all
            default: active
          description: Filter installations by status.
      responses:
        '200':
          description: The org's installations.
          content:
            application/json:
              schema:
                type: object
                properties:
                  installations:
                    type: array
                    items:
                      $ref: '#/components/schemas/MarketplaceInstallation'
              example:
                installations:
                  - id: mai_01HZYK7Y8N
                    org_id: org_01HZYK7Y8N
                    client_id: evm_client_live_example123
                    listing_id: mal_01HZYK7Y8N
                    grant_id: oog_01HZYK7Y8N
                    status: active
                    ceiling_role: member
                    granted_scopes:
                      - recipe:read
                      - gene:read
                    app_version: 3
                    client_name: Support Ops Bot
                    listing_slug: support-ops-bot
                    listing_title: Support Ops Bot
                    installed_by_id: usr_01HZYK7Y8N
                    installed_at: '2026-07-01T09:00:00Z'
                    disabled_at: null
                    disabled_by_id: null
                    revoked_at: null
                    revoked_by_id: null
                    uninstalled_at: null
                    uninstalled_by_id: null
                    created_at: '2026-07-01T09:00:00Z'
                    updated_at: '2026-07-15T10:00:00Z'
                    reauth_required: false
                    current_app_version: 3
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/OrgRoleForbidden'
    post:
      tags:
        - Org apps
      operationId: installOrgApp
      summary: Install a Marketplace app for the org
      description: >-
        Org-admin session endpoint. Installs a published Marketplace app with `client_id` in the body,
        freezing the granted scopes and app version as the tenant's consent snapshot and minting the
        backing OAuthOrgGrant. Scopes are narrowed to client ∩ role ceiling ∩ org-issuable.
      security:
        - sessionCookie: []
      parameters:
        - name: orgId
          in: path
          required: true
          description: Organization id.
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                client_id:
                  type: string
                  description: The app to install.
                ceiling_role:
                  type: string
                  enum:
                    - viewer
                    - member
                    - admin
                    - owner
                  default: member
                scopes:
                  $ref: '#/components/schemas/MarketplaceInstallScopes'
              required:
                - client_id
            example:
              client_id: evm_client_live_example123
              ceiling_role: member
              scopes:
                - recipe:read
                - gene:read
      responses:
        '201':
          description: The new installation (consent snapshot frozen).
          content:
            application/json:
              schema:
                type: object
                properties:
                  installation:
                    $ref: '#/components/schemas/MarketplaceInstallation'
              example:
                installation:
                  id: mai_01HZYK7Y8N
                  org_id: org_01HZYK7Y8N
                  client_id: evm_client_live_example123
                  listing_id: mal_01HZYK7Y8N
                  grant_id: oog_01HZYK7Y8N
                  status: active
                  ceiling_role: member
                  granted_scopes:
                    - recipe:read
                    - gene:read
                  app_version: 3
                  client_name: Support Ops Bot
                  listing_slug: support-ops-bot
                  listing_title: Support Ops Bot
                  installed_by_id: usr_01HZYK7Y8N
                  installed_at: '2026-07-01T09:00:00Z'
                  disabled_at: null
                  disabled_by_id: null
                  revoked_at: null
                  revoked_by_id: null
                  uninstalled_at: null
                  uninstalled_by_id: null
                  created_at: '2026-07-01T09:00:00Z'
                  updated_at: '2026-07-15T10:00:00Z'
                  reauth_required: false
                  current_app_version: 3
        '400':
          $ref: '#/components/responses/MarketplaceInstallBadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/OrgRoleForbidden'
        '404':
          $ref: '#/components/responses/MarketplaceInstallNotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
  /org/{orgId}/apps/{installationId}/disable:
    post:
      tags:
        - Org apps
      operationId: disableOrgApp
      summary: Disable an installation
      description: >-
        Org-admin session endpoint. Disables the installation: live org tokens are revoked and new
        issuance is refused until re-enabled. The backing grant stays intact, so re-enabling needs no
        fresh consent.
      security:
        - sessionCookie: []
      parameters:
        - name: orgId
          in: path
          required: true
          description: Organization id.
          schema:
            type: string
        - name: installationId
          in: path
          required: true
          description: Marketplace app installation id.
          schema:
            type: string
      responses:
        '200':
          description: The disabled installation.
          content:
            application/json:
              schema:
                type: object
                properties:
                  installation:
                    $ref: '#/components/schemas/MarketplaceInstallation'
              example:
                installation:
                  id: mai_01HZYK7Y8N
                  org_id: org_01HZYK7Y8N
                  client_id: evm_client_live_example123
                  listing_id: mal_01HZYK7Y8N
                  grant_id: oog_01HZYK7Y8N
                  status: disabled
                  ceiling_role: member
                  granted_scopes:
                    - recipe:read
                    - gene:read
                  app_version: 3
                  client_name: Support Ops Bot
                  listing_slug: support-ops-bot
                  listing_title: Support Ops Bot
                  installed_by_id: usr_01HZYK7Y8N
                  installed_at: '2026-07-01T09:00:00Z'
                  disabled_at: '2026-07-20T09:00:00Z'
                  disabled_by_id: usr_01HZYK7Y8N
                  revoked_at: null
                  revoked_by_id: null
                  uninstalled_at: null
                  uninstalled_by_id: null
                  created_at: '2026-07-01T09:00:00Z'
                  updated_at: '2026-07-15T10:00:00Z'
                  reauth_required: false
                  current_app_version: 3
        '400':
          $ref: '#/components/responses/MarketplaceInstallBadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/OrgRoleForbidden'
        '404':
          $ref: '#/components/responses/MarketplaceInstallNotFound'
  /org/{orgId}/apps/{installationId}/enable:
    post:
      tags:
        - Org apps
      operationId: enableOrgApp
      summary: Re-enable a disabled installation
      description: >-
        Org-admin session endpoint. Re-enables a disabled installation so client_credentials token
        issuance resumes.
      security:
        - sessionCookie: []
      parameters:
        - name: orgId
          in: path
          required: true
          description: Organization id.
          schema:
            type: string
        - name: installationId
          in: path
          required: true
          description: Marketplace app installation id.
          schema:
            type: string
      responses:
        '200':
          description: The re-enabled installation.
          content:
            application/json:
              schema:
                type: object
                properties:
                  installation:
                    $ref: '#/components/schemas/MarketplaceInstallation'
              example:
                installation:
                  id: mai_01HZYK7Y8N
                  org_id: org_01HZYK7Y8N
                  client_id: evm_client_live_example123
                  listing_id: mal_01HZYK7Y8N
                  grant_id: oog_01HZYK7Y8N
                  status: active
                  ceiling_role: member
                  granted_scopes:
                    - recipe:read
                    - gene:read
                  app_version: 3
                  client_name: Support Ops Bot
                  listing_slug: support-ops-bot
                  listing_title: Support Ops Bot
                  installed_by_id: usr_01HZYK7Y8N
                  installed_at: '2026-07-01T09:00:00Z'
                  disabled_at: null
                  disabled_by_id: null
                  revoked_at: null
                  revoked_by_id: null
                  uninstalled_at: null
                  uninstalled_by_id: null
                  created_at: '2026-07-01T09:00:00Z'
                  updated_at: '2026-07-15T10:00:00Z'
                  reauth_required: false
                  current_app_version: 3
        '400':
          $ref: '#/components/responses/MarketplaceInstallBadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/OrgRoleForbidden'
        '404':
          $ref: '#/components/responses/MarketplaceInstallNotFound'
  /org/{orgId}/apps/{installationId}/revoke:
    post:
      tags:
        - Org apps
      operationId: revokeOrgApp
      summary: Revoke an installation
      description: >-
        Org-admin session endpoint. Revokes the installation: kills live org tokens AND revokes the
        backing OAuthOrgGrant. Unlike disable, recovering from revoke requires a fresh install
        consent.
      security:
        - sessionCookie: []
      parameters:
        - name: orgId
          in: path
          required: true
          description: Organization id.
          schema:
            type: string
        - name: installationId
          in: path
          required: true
          description: Marketplace app installation id.
          schema:
            type: string
      responses:
        '200':
          description: The revoked installation.
          content:
            application/json:
              schema:
                type: object
                properties:
                  installation:
                    $ref: '#/components/schemas/MarketplaceInstallation'
              example:
                installation:
                  id: mai_01HZYK7Y8N
                  org_id: org_01HZYK7Y8N
                  client_id: evm_client_live_example123
                  listing_id: mal_01HZYK7Y8N
                  grant_id: null
                  status: revoked
                  ceiling_role: member
                  granted_scopes:
                    - recipe:read
                    - gene:read
                  app_version: 3
                  client_name: Support Ops Bot
                  listing_slug: support-ops-bot
                  listing_title: Support Ops Bot
                  installed_by_id: usr_01HZYK7Y8N
                  installed_at: '2026-07-01T09:00:00Z'
                  disabled_at: null
                  disabled_by_id: null
                  revoked_at: '2026-07-20T09:00:00Z'
                  revoked_by_id: usr_01HZYK7Y8N
                  uninstalled_at: null
                  uninstalled_by_id: null
                  created_at: '2026-07-01T09:00:00Z'
                  updated_at: '2026-07-15T10:00:00Z'
                  reauth_required: false
                  current_app_version: 3
        '400':
          $ref: '#/components/responses/MarketplaceInstallBadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/OrgRoleForbidden'
        '404':
          $ref: '#/components/responses/MarketplaceInstallNotFound'
  /org/{orgId}/app-install-requests:
    get:
      tags:
        - Org apps
      operationId: listOrgAppInstallRequests
      summary: List app install requests
      description: Org-admin session endpoint. The admin's inbox of member install requests.
      security:
        - sessionCookie: []
      parameters:
        - name: orgId
          in: path
          required: true
          description: Organization id.
          schema:
            type: string
        - name: status
          in: query
          required: false
          schema:
            type: string
            enum:
              - pending
              - approved
              - rejected
              - cancelled
              - all
            default: pending
          description: Filter requests by status.
      responses:
        '200':
          description: Install requests.
          content:
            application/json:
              schema:
                type: object
                properties:
                  requests:
                    type: array
                    items:
                      $ref: '#/components/schemas/MarketplaceAppInstallRequest'
              example:
                requests:
                  - id: air_01HZYK7Y8N
                    org_id: org_01HZYK7Y8N
                    client_id: evm_client_live_example123
                    listing_id: mal_01HZYK7Y8N
                    requested_by_id: usr_01HZYK7Y8M
                    status: pending
                    requested_scopes:
                      - recipe:read
                    ceiling_role: member
                    message: We need this app for the support rotation.
                    reviewed_by_id: null
                    reviewed_at: null
                    review_note: ''
                    installation_id: null
                    client_name: Support Ops Bot
                    listing_slug: support-ops-bot
                    listing_title: Support Ops Bot
                    created_at: '2026-07-01T09:00:00Z'
                    updated_at: '2026-07-01T09:00:00Z'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/OrgRoleForbidden'
    post:
      tags:
        - Org apps
      operationId: createOrgAppInstallRequest
      summary: Request an app install (member)
      description: >-
        Org-member session endpoint — any member (viewer role and up) can propose an app; an admin
        approves it into a real installation. Requested scopes are validated against client ∩ role
        ceiling ∩ org-issuable at request time.
      security:
        - sessionCookie: []
      parameters:
        - name: orgId
          in: path
          required: true
          description: Organization id.
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                client_id:
                  type: string
                  description: The app to request.
                ceiling_role:
                  type: string
                  enum:
                    - viewer
                    - member
                    - admin
                    - owner
                  default: member
                scopes:
                  $ref: '#/components/schemas/MarketplaceInstallScopes'
                message:
                  type: string
                  maxLength: 1000
                  description: Why the org needs this app; shown in the admin inbox.
              required:
                - client_id
            example:
              client_id: evm_client_live_example123
              scopes:
                - recipe:read
              message: We need this app for the support rotation.
      responses:
        '201':
          description: The pending request.
          content:
            application/json:
              schema:
                type: object
                properties:
                  request:
                    $ref: '#/components/schemas/MarketplaceAppInstallRequest'
              example:
                request:
                  id: air_01HZYK7Y8N
                  org_id: org_01HZYK7Y8N
                  client_id: evm_client_live_example123
                  listing_id: mal_01HZYK7Y8N
                  requested_by_id: usr_01HZYK7Y8M
                  status: pending
                  requested_scopes:
                    - recipe:read
                  ceiling_role: member
                  message: We need this app for the support rotation.
                  reviewed_by_id: null
                  reviewed_at: null
                  review_note: ''
                  installation_id: null
                  client_name: Support Ops Bot
                  listing_slug: support-ops-bot
                  listing_title: Support Ops Bot
                  created_at: '2026-07-01T09:00:00Z'
                  updated_at: '2026-07-01T09:00:00Z'
        '400':
          $ref: '#/components/responses/MarketplaceInstallBadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/OrgRoleForbidden'
        '404':
          $ref: '#/components/responses/MarketplaceInstallNotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
  /org/{orgId}/app-install-requests/{requestId}/approve:
    post:
      tags:
        - Org apps
      operationId: approveOrgAppInstallRequest
      summary: Approve an install request
      description: >-
        Org-admin session endpoint. Approves a pending request into an active installation + org
        grant, with the ADMIN as the consenting granter. Returns both the reviewed request and the
        materialized installation.
      security:
        - sessionCookie: []
      parameters:
        - name: orgId
          in: path
          required: true
          description: Organization id.
          schema:
            type: string
        - name: requestId
          in: path
          required: true
          schema:
            type: string
          description: Install request id.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                review_note:
                  type: string
                  maxLength: 1000
            example:
              review_note: Approved for the support team.
      responses:
        '200':
          description: The approved request plus the new installation.
          content:
            application/json:
              schema:
                type: object
                properties:
                  request:
                    $ref: '#/components/schemas/MarketplaceAppInstallRequest'
                  installation:
                    $ref: '#/components/schemas/MarketplaceInstallation'
              example:
                request:
                  id: air_01HZYK7Y8N
                  org_id: org_01HZYK7Y8N
                  client_id: evm_client_live_example123
                  listing_id: mal_01HZYK7Y8N
                  requested_by_id: usr_01HZYK7Y8M
                  status: approved
                  requested_scopes:
                    - recipe:read
                  ceiling_role: member
                  message: We need this app for the support rotation.
                  reviewed_by_id: usr_01HZYK7Y8N
                  reviewed_at: '2026-07-02T09:00:00Z'
                  review_note: Approved for the support team.
                  installation_id: mai_01HZYK7Y8N
                  client_name: Support Ops Bot
                  listing_slug: support-ops-bot
                  listing_title: Support Ops Bot
                  created_at: '2026-07-01T09:00:00Z'
                  updated_at: '2026-07-02T09:00:00Z'
                installation:
                  id: mai_01HZYK7Y8N
                  org_id: org_01HZYK7Y8N
                  client_id: evm_client_live_example123
                  listing_id: mal_01HZYK7Y8N
                  grant_id: oog_01HZYK7Y8N
                  status: active
                  ceiling_role: member
                  granted_scopes:
                    - recipe:read
                    - gene:read
                  app_version: 3
                  client_name: Support Ops Bot
                  listing_slug: support-ops-bot
                  listing_title: Support Ops Bot
                  installed_by_id: usr_01HZYK7Y8N
                  installed_at: '2026-07-01T09:00:00Z'
                  disabled_at: null
                  disabled_by_id: null
                  revoked_at: null
                  revoked_by_id: null
                  uninstalled_at: null
                  uninstalled_by_id: null
                  created_at: '2026-07-01T09:00:00Z'
                  updated_at: '2026-07-15T10:00:00Z'
                  reauth_required: false
                  current_app_version: 3
        '400':
          $ref: '#/components/responses/MarketplaceInstallBadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/OrgRoleForbidden'
        '404':
          $ref: '#/components/responses/MarketplaceInstallNotFound'
  /org/{orgId}/app-install-requests/{requestId}/reject:
    post:
      tags:
        - Org apps
      operationId: rejectOrgAppInstallRequest
      summary: Reject an install request
      description: Org-admin session endpoint. Rejects a pending request with an optional review note.
      security:
        - sessionCookie: []
      parameters:
        - name: orgId
          in: path
          required: true
          description: Organization id.
          schema:
            type: string
        - name: requestId
          in: path
          required: true
          schema:
            type: string
          description: Install request id.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                review_note:
                  type: string
                  maxLength: 1000
            example:
              review_note: Duplicate of the ticketing app we already use.
      responses:
        '200':
          description: The rejected request.
          content:
            application/json:
              schema:
                type: object
                properties:
                  request:
                    $ref: '#/components/schemas/MarketplaceAppInstallRequest'
              example:
                request:
                  id: air_01HZYK7Y8N
                  org_id: org_01HZYK7Y8N
                  client_id: evm_client_live_example123
                  listing_id: mal_01HZYK7Y8N
                  requested_by_id: usr_01HZYK7Y8M
                  status: rejected
                  requested_scopes:
                    - recipe:read
                  ceiling_role: member
                  message: We need this app for the support rotation.
                  reviewed_by_id: usr_01HZYK7Y8N
                  reviewed_at: '2026-07-02T09:00:00Z'
                  review_note: Duplicate of the ticketing app we already use.
                  installation_id: null
                  client_name: Support Ops Bot
                  listing_slug: support-ops-bot
                  listing_title: Support Ops Bot
                  created_at: '2026-07-01T09:00:00Z'
                  updated_at: '2026-07-02T09:00:00Z'
        '400':
          $ref: '#/components/responses/MarketplaceInstallBadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/OrgRoleForbidden'
        '404':
          $ref: '#/components/responses/MarketplaceInstallNotFound'
  /org/{orgId}/marketplace/installations:
    get:
      tags:
        - Org apps
      operationId: listOrgMarketplaceInstallations
      summary: List installations (canonical path)
      description: >-
        Org-admin session endpoint. Same data as `GET /org/{orgId}/apps` — this is the original
        marketplace-prefixed path, kept for existing API users.
      security:
        - sessionCookie: []
      parameters:
        - name: orgId
          in: path
          required: true
          description: Organization id.
          schema:
            type: string
        - name: status
          in: query
          required: false
          schema:
            type: string
            enum:
              - active
              - disabled
              - revoked
              - uninstalled
              - requires_reauth
              - suspended
              - all
            default: active
          description: Filter installations by status.
      responses:
        '200':
          description: The org's installations.
          content:
            application/json:
              schema:
                type: object
                properties:
                  installations:
                    type: array
                    items:
                      $ref: '#/components/schemas/MarketplaceInstallation'
              example:
                installations:
                  - id: mai_01HZYK7Y8N
                    org_id: org_01HZYK7Y8N
                    client_id: evm_client_live_example123
                    listing_id: mal_01HZYK7Y8N
                    grant_id: oog_01HZYK7Y8N
                    status: active
                    ceiling_role: member
                    granted_scopes:
                      - recipe:read
                      - gene:read
                    app_version: 3
                    client_name: Support Ops Bot
                    listing_slug: support-ops-bot
                    listing_title: Support Ops Bot
                    installed_by_id: usr_01HZYK7Y8N
                    installed_at: '2026-07-01T09:00:00Z'
                    disabled_at: null
                    disabled_by_id: null
                    revoked_at: null
                    revoked_by_id: null
                    uninstalled_at: null
                    uninstalled_by_id: null
                    created_at: '2026-07-01T09:00:00Z'
                    updated_at: '2026-07-15T10:00:00Z'
                    reauth_required: false
                    current_app_version: 3
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/OrgRoleForbidden'
  /org/{orgId}/marketplace/apps/{clientId}/install:
    post:
      tags:
        - Org apps
      operationId: installOrgMarketplaceApp
      summary: Install an app (clientId in path)
      description: >-
        Org-admin session endpoint. Same behaviour as `POST /org/{orgId}/apps` but with the client id
        in the path instead of the body.
      security:
        - sessionCookie: []
      parameters:
        - name: orgId
          in: path
          required: true
          description: Organization id.
          schema:
            type: string
        - name: clientId
          in: path
          required: true
          schema:
            type: string
          description: The app's client_id.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                ceiling_role:
                  type: string
                  enum:
                    - viewer
                    - member
                    - admin
                    - owner
                  default: member
                scopes:
                  $ref: '#/components/schemas/MarketplaceInstallScopes'
            example:
              ceiling_role: member
              scopes: recipe:read gene:read
      responses:
        '201':
          description: The new installation (consent snapshot frozen).
          content:
            application/json:
              schema:
                type: object
                properties:
                  installation:
                    $ref: '#/components/schemas/MarketplaceInstallation'
              example:
                installation:
                  id: mai_01HZYK7Y8N
                  org_id: org_01HZYK7Y8N
                  client_id: evm_client_live_example123
                  listing_id: mal_01HZYK7Y8N
                  grant_id: oog_01HZYK7Y8N
                  status: active
                  ceiling_role: member
                  granted_scopes:
                    - recipe:read
                    - gene:read
                  app_version: 3
                  client_name: Support Ops Bot
                  listing_slug: support-ops-bot
                  listing_title: Support Ops Bot
                  installed_by_id: usr_01HZYK7Y8N
                  installed_at: '2026-07-01T09:00:00Z'
                  disabled_at: null
                  disabled_by_id: null
                  revoked_at: null
                  revoked_by_id: null
                  uninstalled_at: null
                  uninstalled_by_id: null
                  created_at: '2026-07-01T09:00:00Z'
                  updated_at: '2026-07-15T10:00:00Z'
                  reauth_required: false
                  current_app_version: 3
        '400':
          $ref: '#/components/responses/MarketplaceInstallBadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/OrgRoleForbidden'
        '404':
          $ref: '#/components/responses/MarketplaceInstallNotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
  /org/{orgId}/marketplace/installations/{installationId}:
    get:
      tags:
        - Org apps
      operationId: getOrgMarketplaceInstallation
      summary: Installation detail with drift breakdown
      description: >-
        Org-admin session endpoint. The frozen consent snapshot next to the app's CURRENT
        scopes/version, the live grant status, and an explicit drift breakdown (`scopes_added_by_app`,
        `version_behind`, …).
      security:
        - sessionCookie: []
      parameters:
        - name: orgId
          in: path
          required: true
          description: Organization id.
          schema:
            type: string
        - name: installationId
          in: path
          required: true
          description: Marketplace app installation id.
          schema:
            type: string
      responses:
        '200':
          description: Installation detail.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MarketplaceInstallationDetail'
              example:
                installation:
                  id: mai_01HZYK7Y8N
                  org_id: org_01HZYK7Y8N
                  client_id: evm_client_live_example123
                  listing_id: mal_01HZYK7Y8N
                  grant_id: oog_01HZYK7Y8N
                  status: active
                  ceiling_role: member
                  granted_scopes:
                    - recipe:read
                    - gene:read
                  app_version: 3
                  client_name: Support Ops Bot
                  listing_slug: support-ops-bot
                  listing_title: Support Ops Bot
                  installed_by_id: usr_01HZYK7Y8N
                  installed_at: '2026-07-01T09:00:00Z'
                  disabled_at: null
                  disabled_by_id: null
                  revoked_at: null
                  revoked_by_id: null
                  uninstalled_at: null
                  uninstalled_by_id: null
                  created_at: '2026-07-01T09:00:00Z'
                  updated_at: '2026-07-15T10:00:00Z'
                  reauth_required: true
                  current_app_version: 4
                app:
                  client_id: evm_client_live_example123
                  name: Support Ops Bot
                  status: approved
                  allowed_scopes:
                    - recipe:read
                    - gene:read
                    - reuse:query
                  active_version: 4
                  declared_webhook_events:
                    - recipe.published
                  homepage_url: https://example.com
                listing:
                  id: mal_01HZYK7Y8N
                  slug: support-ops-bot
                  title: Support Ops Bot
                  status: published
                  published_version: 4
                  install_audience: org
                grant:
                  id: oog_01HZYK7Y8N
                  status: active
                  ceiling_role: member
                  granted_scopes:
                    - recipe:read
                    - gene:read
                  granted_by_id: usr_01HZYK7Y8N
                  revoked_at: null
                  updated_at: '2026-07-01T09:00:00Z'
                drift:
                  scopes_added_by_app:
                    - reuse:query
                  scopes_removed_by_app: []
                  installed_version: 3
                  current_version: 4
                  version_behind: true
                  grant_revoked: false
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/OrgRoleForbidden'
        '404':
          $ref: '#/components/responses/MarketplaceInstallNotFound'
    delete:
      tags:
        - Org apps
      operationId: uninstallOrgMarketplaceApp
      summary: Uninstall an app from the org
      description: >-
        Org-admin session endpoint. Uninstalls the app and revokes its backing org OAuth grant and
        live tokens.
      security:
        - sessionCookie: []
      parameters:
        - name: orgId
          in: path
          required: true
          description: Organization id.
          schema:
            type: string
        - name: installationId
          in: path
          required: true
          description: Marketplace app installation id.
          schema:
            type: string
      responses:
        '200':
          description: The uninstalled installation.
          content:
            application/json:
              schema:
                type: object
                properties:
                  installation:
                    $ref: '#/components/schemas/MarketplaceInstallation'
              example:
                installation:
                  id: mai_01HZYK7Y8N
                  org_id: org_01HZYK7Y8N
                  client_id: evm_client_live_example123
                  listing_id: mal_01HZYK7Y8N
                  grant_id: null
                  status: uninstalled
                  ceiling_role: member
                  granted_scopes:
                    - recipe:read
                    - gene:read
                  app_version: 3
                  client_name: Support Ops Bot
                  listing_slug: support-ops-bot
                  listing_title: Support Ops Bot
                  installed_by_id: usr_01HZYK7Y8N
                  installed_at: '2026-07-01T09:00:00Z'
                  disabled_at: null
                  disabled_by_id: null
                  revoked_at: null
                  revoked_by_id: null
                  uninstalled_at: '2026-07-20T09:00:00Z'
                  uninstalled_by_id: usr_01HZYK7Y8N
                  created_at: '2026-07-01T09:00:00Z'
                  updated_at: '2026-07-15T10:00:00Z'
                  reauth_required: false
                  current_app_version: 3
        '400':
          $ref: '#/components/responses/MarketplaceInstallBadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/OrgRoleForbidden'
        '404':
          $ref: '#/components/responses/MarketplaceInstallNotFound'
  /org/{orgId}/marketplace/installations/{installationId}/reauthorize:
    post:
      tags:
        - Org apps
      operationId: reauthorizeOrgMarketplaceInstallation
      summary: Reauthorize after app drift
      description: >-
        Org-admin session endpoint. Refreshes the frozen consent snapshot after an app version/scope
        change: re-narrows scopes, re-freezes the current app version, and clears `reauth_required`.
      security:
        - sessionCookie: []
      parameters:
        - name: orgId
          in: path
          required: true
          description: Organization id.
          schema:
            type: string
        - name: installationId
          in: path
          required: true
          description: Marketplace app installation id.
          schema:
            type: string
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                ceiling_role:
                  type: string
                  enum:
                    - viewer
                    - member
                    - admin
                    - owner
                  description: Optionally change the role ceiling; defaults to the current one.
                scopes:
                  $ref: '#/components/schemas/MarketplaceInstallScopes'
            example:
              scopes:
                - recipe:read
                - gene:read
                - reuse:query
      responses:
        '200':
          description: The reauthorized installation.
          content:
            application/json:
              schema:
                type: object
                properties:
                  installation:
                    $ref: '#/components/schemas/MarketplaceInstallation'
              example:
                installation:
                  id: mai_01HZYK7Y8N
                  org_id: org_01HZYK7Y8N
                  client_id: evm_client_live_example123
                  listing_id: mal_01HZYK7Y8N
                  grant_id: oog_01HZYK7Y8N
                  status: active
                  ceiling_role: member
                  granted_scopes:
                    - recipe:read
                    - gene:read
                    - reuse:query
                  app_version: 4
                  client_name: Support Ops Bot
                  listing_slug: support-ops-bot
                  listing_title: Support Ops Bot
                  installed_by_id: usr_01HZYK7Y8N
                  installed_at: '2026-07-01T09:00:00Z'
                  disabled_at: null
                  disabled_by_id: null
                  revoked_at: null
                  revoked_by_id: null
                  uninstalled_at: null
                  uninstalled_by_id: null
                  created_at: '2026-07-01T09:00:00Z'
                  updated_at: '2026-07-21T10:00:00Z'
                  reauth_required: false
                  current_app_version: 4
        '400':
          $ref: '#/components/responses/MarketplaceInstallBadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/OrgRoleForbidden'
        '404':
          $ref: '#/components/responses/MarketplaceInstallNotFound'
        '429':
          $ref: '#/components/responses/RateLimited'

components:
  securitySchemes:
    oauth2:
      type: oauth2
      description: "Authorization Code flow with mandatory PKCE (S256). Send the access token as `Authorization: Bearer <token>`."
      flows:
        authorizationCode:
          authorizationUrl: https://evomap.ai/oauth/authorize
          tokenUrl: https://evomap.ai/oauth/token
          refreshUrl: https://evomap.ai/oauth/token
          scopes:
            openid: OpenID Connect — issue a signed id_token.
            profile: OIDC profile claims (name, preferred_username).
            email: OIDC email claim.
            gene:read: Read the ranked public asset (gene) catalog.
            recipe:read: Read the promoted recipe catalog.
            reuse:query: Read the reuse / related graph.
            recipe:write: Create recipe drafts.
            recipe:publish: Publish recipes into the value pool.
            account:read: Read the user's account profile and balances.
            account:credits: Claim account-level credit rewards and daily tasks (high risk).
            gateway:manage: Manage EvoAPI gateway keys and exchange account credits (high risk).
            a2a: Act on the A2A agent surface as a node the user owns (high risk, broad).
            node:manage: Node-management writes (high risk, team sign-off).
    sessionCookie:
      type: apiKey
      in: cookie
      name: evomap_sid
      description: >-
        Logged-in EvoMap session cookie, set at sign-in. Sent as `Cookie: evomap_sid=<token>` — used by the portal / app-management / consent endpoints, never by third-party API calls, which carry an OAuth access token instead.

  parameters:
    Limit:
      name: limit
      in: query
      required: false
      description: Max items to return (1–100, default 20).
      schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
    Cursor:
      name: cursor
      in: query
      required: false
      description: "Opaque keyset cursor from a previous response's pagination.next_cursor. Only the promoted recipe catalog (GET /developer/oauth/recipes without q) supports cursoring."
      schema: { type: string }
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      description: "Opt-in idempotency token (8-255 chars). An identical retry replays the original 201 instead of creating a second recipe; reusing the key with a different body returns 422."
      schema: { type: string, minLength: 8, maxLength: 255 }

  headers:
    X-RateLimit-Limit:
      description: Requests allowed per window.
      schema: { type: integer }
    X-RateLimit-Remaining:
      description: Requests left in the current window.
      schema: { type: integer }
    X-RateLimit-Reset:
      description: Unix seconds when the window resets.
      schema: { type: integer }

  responses:
    OrgRoleForbidden:
      description: >-
        The caller lacks the required organization role (admin for management actions, member for
        install requests).
      content:
        application/json:
          schema:
            allOf:
              - $ref: '#/components/schemas/Error'
            properties:
              error:
                type: string
                enum:
                  - admin_required
                  - member_required
          example:
            error: admin_required
            type: auth_error
            request_id: req_01HZYK7Y8N4P0VZ9F8X2Q3R4S5
    MarketplaceInstallBadRequest:
      description: >-
        The install/reauthorize payload is invalid: unknown client, scopes outside client ∩ role ceiling
        ∩ org-issuable, bad ceiling role, an installation in the wrong state for this action, or an app
        whose listing does not accept org installs.
      content:
        application/json:
          schema:
            allOf:
              - $ref: '#/components/schemas/Error'
            properties:
              error:
                type: string
                enum:
                  - client_id_required
                  - invalid_client
                  - invalid_scope
                  - invalid_ceiling_role
                  - invalid_installation_state
                  - org_install_not_supported
          example:
            error: invalid_scope
            type: invalid_request
            request_id: req_01HZYK7Y8N4P0VZ9F8X2Q3R4S5
    MarketplaceInstallNotFound:
      description: >-
        No published Marketplace app, installation, or install request matches the given id within this
        org.
      content:
        application/json:
          schema:
            allOf:
              - $ref: '#/components/schemas/Error'
            properties:
              error:
                type: string
                enum:
                  - marketplace_app_not_found
                  - installation_not_found
                  - app_install_request_not_found
          example:
            error: installation_not_found
            type: not_found
            request_id: req_01HZYK7Y8N4P0VZ9F8X2Q3R4S5
    ListingNotFound:
      description: >-
        No Marketplace listing matches (unknown slug, unpublished listing, or the client has no listing
        yet).
      content:
        application/json:
          schema:
            allOf:
              - $ref: '#/components/schemas/Error'
            properties:
              error:
                type: string
                enum:
                  - listing_not_found
          example:
            error: listing_not_found
            type: not_found
            request_id: req_01HZYK7Y8N4P0VZ9F8X2Q3R4S5
    BadRequest:
      description: "Either the endpoint's own 400 code in the developer error envelope, or — when the body, form or query fails schema validation before the handler runs — `validation_error` with a `details` array and no `type` / `request_id`."
      content:
        application/json:
          schema:
            oneOf:
              - { $ref: "#/components/schemas/Error" }
              - { $ref: "#/components/schemas/ValidationError" }
          examples:
            invalid_request:
              summary: Handler-level error
              value:
                error: invalid_request
                error_description: pass exactly one of asset_id or recipe_id
                type: invalid_request
                request_id: req_example_01HZYK7Y8N4P0VZ9
            validation_error:
              summary: Schema validation failed before the handler
              value:
                error: validation_error
                details:
                  - path: [redirect_uris]
                    message: "Invalid input: expected array, received undefined"
    OAuthError:
      description: "RFC 6749-style error: `error` plus an optional `error_description`, nothing else — OAuth-protocol endpoints never add `type` or `request_id`. When the body fails schema validation before the handler runs, the code is `validation_error` with a `details` array instead."
      content:
        application/json:
          schema:
            oneOf:
              - { $ref: "#/components/schemas/OAuthProtocolError" }
              - { $ref: "#/components/schemas/ValidationError" }
          examples:
            invalid_request:
              summary: Handler-level OAuth error
              value:
                error: invalid_request
                error_description: code_challenge_method must be S256
            validation_error:
              summary: Schema validation failed before the handler
              value:
                error: validation_error
                details:
                  - path: [grant_type]
                    message: 'Invalid option: expected one of "authorization_code"|"refresh_token"|...'
    Unauthorized:
      description: "No valid session. The body is `error: unauthorized` on its own (session-cookie endpoints may add `details: null`); this envelope carries no `type` or `request_id`."
      content:
        application/json:
          schema: { $ref: "#/components/schemas/SessionUnauthorizedError" }
          example:
            error: unauthorized
    InvalidToken:
      description: Missing or invalid bearer access token.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example:
            error: invalid_token
            error_description: token invalid, expired or revoked
            type: auth_error
            request_id: req_01HZYK7Y8N4P0VZ9F8X2Q3R4S5
    InsufficientScope:
      description: The token lacks the scope this endpoint requires.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example:
            error: insufficient_scope
            scope: recipe:publish
            type: auth_error
            request_id: req_01HZYK7Y8N4P0VZ9F8X2Q3R4S5
    QuotaExceeded:
      description: Publish quota exceeded. The `X-Quota-Restored-At` header is present only for soft demotions.
      headers:
        X-Quota-Restored-At:
          description: ISO-8601 instant when quota resets (soft demotions only).
          schema: { type: string, format: date-time }
      content:
        application/json:
          schema:
            allOf:
              - { $ref: "#/components/schemas/Error" }
              - type: object
                properties:
                  reason: { type: string, description: Why the publish was blocked. }
                  currentTier: { type: string, description: The publisher's current quota tier. }
                  restoredAt: { type: ["string", "null"], format: date-time, description: When quota auto-restores for soft demotions; null for hard demotions. }
          example:
            error: quota_exceeded
            reason: publish_quota_exceeded
            currentTier: S1
            restoredAt: "2026-06-18T12:00:00Z"
            type: rate_limited
            request_id: req_01HZYK7Y8N4P0VZ9F8X2Q3R4S5
    RateLimited:
      description: Per-access-token rate limit exceeded for this data-API endpoint (distinct from the publish quota).
      headers:
        Retry-After:
          description: Seconds to wait before retrying.
          schema: { type: integer }
        X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
        X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
        X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/RateLimitError" }
          example:
            error: rate_limited
            retry_after_ms: 1200
            next_request_at: "2026-06-17T12:00:01Z"
            hint: Rate limited. Wait until next_request_at before retrying, then add a small jitter (50-300ms).
            agent_instruction: sleep_until_next_request_at
            bucket: oauth_token
    ContentRejected:
      description: Content failed moderation or originality checks.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example:
            error: content_rejected
            error_description: Recipe content failed moderation or originality checks.
            type: invalid_request
            request_id: req_01HZYK7Y8N4P0VZ9F8X2Q3R4S5
    NotApprovedDeveloper:
      description: Caller is not an approved developer.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example: { error: not_approved_developer }
    NotFound:
      description: Resource not found or not owned by the caller.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example:
            error: client_not_found
            type: not_found
            request_id: req_01HZYK7Y8N4P0VZ9F8X2Q3R4S5
    Conflict:
      description: Conflicting state (e.g. application already pending, max clients reached).
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example:
            error: idempotency_key_in_flight
            error_description: A request with this Idempotency-Key is still processing. Retry shortly.
            type: conflict
            request_id: req_01HZYK7Y8N4P0VZ9F8X2Q3R4S5
    TemporarilyUnavailable:
      description: Service temporarily unavailable.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example:
            error: service_temporarily_unavailable
            type: service_unavailable
            request_id: req_01HZYK7Y8N4P0VZ9F8X2Q3R4S5

  schemas:
    Error:
      type: object
      description: |
        Developer data-API error envelope: a machine code in `error`, optional
        `error_description`, plus a `request_id` (mirrors the X-Request-Id header)
        and a coarse `type`. OAuth-protocol errors (`OAuthProtocolError`), schema
        validation failures (`ValidationError`) and session-cookie `unauthorized`
        bodies (`SessionUnauthorizedError`) are separate schemas: none of them
        carries `type` or `request_id`.
      properties:
        error:
          type: string
          description: Machine-readable error code. Never `validation_error`, which has its own schema.
          not: { const: validation_error }
        error_description: { type: string, description: Optional human-readable detail. }
        request_id: { type: string, description: Correlation id; mirrors the X-Request-Id response header. Quote it in support requests. }
        type: { type: string, description: "Coarse error class: auth_error | invalid_request | rate_limited | conflict | not_found | server_error | service_unavailable." }
      required: [error]
      example:
        error: invalid_token
        error_description: token invalid, expired or revoked
        type: auth_error
        request_id: req_01HZYK7Y8N4P0VZ9F8X2Q3R4S5

    OAuthProtocolError:
      type: object
      description: "RFC 6749 error body returned by the OAuth-protocol endpoints: `error` plus an optional `error_description`, and nothing else — no `type`, no `request_id`."
      properties:
        error:
          type: string
          description: "RFC 6749 / 7009 / 7662 error code, e.g. invalid_request, invalid_client, invalid_grant, invalid_scope, unsupported_grant_type. Never `validation_error`, which has its own schema."
          not: { const: validation_error }
        error_description: { type: string, description: Optional human-readable detail. }
      required: [error]
      additionalProperties: false
      example:
        error: invalid_client

    ValidationError:
      type: object
      description: "Answered before the handler runs when the body or form does not match the request schema. Carries `error` and `details` only — no `type`, `error_description` or `request_id`."
      properties:
        error: { type: string, const: validation_error }
        details:
          type: array
          description: One entry per field that failed schema validation.
          items:
            type: object
            properties:
              path: { type: array, items: { type: [string, integer] } }
              message: { type: string }
            required: [path, message]
      required: [error, details]
      additionalProperties: false
      example:
        error: validation_error
        details:
          - path: [redirect_uris]
            message: "Invalid input: expected array, received undefined"

    SessionUnauthorizedError:
      type: object
      description: "Returned when a session-cookie endpoint (or `GET /a2a/assets`) has no valid credential. `error` is always `unauthorized`; session-cookie endpoints may add `details: null` and a `docs` list of machine-readable documentation URLs. Nothing else — no `type`, no `request_id`."
      properties:
        error: { type: string, const: unauthorized }
        details: { type: "null" }
        docs:
          type: array
          description: Links to the machine-readable docs index, added by session-cookie endpoints.
          items: { type: string, format: uri }
      required: [error]
      additionalProperties: false
      example:
        error: unauthorized

    Pagination:
      type: object
      description: |
        Uniform pagination metadata, present on every data-API list response.
        Keyset-cursor catalogs carry all three fields; bounded top-N feeds
        (ranked genes, reuse neighbourhoods, text search) carry only `limit`.
      properties:
        limit: { type: integer, description: The page size that was applied. }
        next_cursor: { type: ["string", "null"], description: "Pass back as cursor for the next page; null on the last page. Absent on non-paginated feeds." }
        has_more: { type: boolean, description: Whether a further page exists. Absent on non-paginated feeds. }
      required: [limit]

    RateLimitError:
      type: object
      description: 429 body for rate-limited data-API reads (distinct from the publish QuotaExceeded body).
      properties:
        error: { type: string, example: rate_limited }
        retry_after_ms: { type: integer }
        next_request_at: { type: string, format: date-time }
        hint: { type: string }
        agent_instruction: { type: string }
        bucket: { type: string }
      required: [error]

    AuthorizationServerMetadata:
      type: object
      properties:
        issuer: { type: string, format: uri }
        authorization_endpoint: { type: string, format: uri }
        token_endpoint: { type: string, format: uri }
        revocation_endpoint: { type: string, format: uri }
        introspection_endpoint: { type: string, format: uri }
        registration_endpoint: { type: string, format: uri }
        scopes_supported: { type: array, items: { type: string } }
        response_types_supported: { type: array, items: { type: string } }
        grant_types_supported: { type: array, items: { type: string } }
        code_challenge_methods_supported: { type: array, items: { type: string }, example: ["S256"] }
        token_endpoint_auth_methods_supported: { type: array, items: { type: string } }
      additionalProperties: true

    ConsentDescriptor:
      type: object
      properties:
        consent_required: { type: boolean, const: true }
        client:
          type: object
          properties:
            client_id: { type: string }
            name: { type: string }
            description: { type: string }
            homepage_url: { type: string, format: uri }
        scopes:
          type: array
          items:
            type: object
            properties:
              scope: { type: string }
              high_risk: { type: boolean }
        state: { type: ["string", "null"] }

    AutoApproved:
      type: object
      properties:
        auto_approved: { type: boolean, const: true }
        redirect_to: { type: string, format: uri }

    ConsentDecision:
      type: object
      required: [client_id, redirect_uri, scope, code_challenge, code_challenge_method, approved]
      properties:
        client_id: { type: string }
        redirect_uri: { type: string, format: uri }
        scope: { type: string }
        state: { type: string }
        code_challenge: { type: string }
        code_challenge_method: { type: string, enum: ["S256"] }
        approved: { type: boolean }

    ConsentDecisionResult:
      type: object
      properties:
        approved: { type: boolean }
        redirect_to: { type: string, format: uri }

    AuthorizationCodeGrant:
      type: object
      required: [grant_type, code, client_id, redirect_uri, code_verifier]
      properties:
        grant_type: { type: string, const: authorization_code }
        code: { type: string }
        client_id: { type: string }
        client_secret: { type: string, description: Confidential clients only. }
        redirect_uri: { type: string, format: uri }
        code_verifier: { type: string, description: PKCE verifier (43–128 chars). }

    RefreshTokenGrant:
      type: object
      required: [grant_type, refresh_token, client_id]
      properties:
        grant_type: { type: string, const: refresh_token }
        refresh_token: { type: string }
        client_id: { type: string }
        client_secret: { type: string }

    TokenResponse:
      type: object
      properties:
        access_token: { type: string }
        refresh_token: { type: string }
        token_type: { type: string, example: Bearer }
        expires_in: { type: integer, description: Access-token lifetime in seconds. }
        scope: { type: string }
        id_token: { type: string, description: "OIDC signed RS256 JWT — present only when the grant included the `openid` scope. Verify against /.well-known/jwks.json." }

    IntrospectionResult:
      type: object
      properties:
        active: { type: boolean }
        client_id: { type: string }
        username: { type: string, description: The user the token acts for. }
        scope: { type: string }
        exp: { type: integer, description: Expiry as a Unix timestamp. }

    ClientRegistrationRequest:
      type: object
      required: [redirect_uris]
      properties:
        redirect_uris: { type: array, items: { type: string, format: uri } }
        client_name: { type: string }
        scope: { type: string, description: Read-only scopes only for DCR. }

    ClientRegistrationResponse:
      type: object
      properties:
        client_id: { type: string }
        client_id_issued_at: { type: integer }
        redirect_uris: { type: array, items: { type: string, format: uri } }
        grant_types: { type: array, items: { type: string } }
        response_types: { type: array, items: { type: string } }
        token_endpoint_auth_method: { type: string, example: none }
        scope: { type: string }
        client_name: { type: string }

    ClientInput:
      type: object
      required: [name, redirect_uris, allowed_scopes]
      properties:
        name: { type: string }
        redirect_uris: { type: array, items: { type: string, format: uri } }
        allowed_scopes: { type: array, items: { type: string } }
        description: { type: string }
        homepage_url: { type: string, format: uri }
        is_confidential: { type: boolean, default: true }
        declared_throughput: { type: ["integer", "null"] }
        test_mode: { type: boolean, default: false, description: "Register a TEST client (client_id prefixed evm_client_test_). Test clients are self-serve even for review-tier scopes such as account:read and a2a, and operate entirely in the ephemeral sandbox — see the Test mode section." }
      example:
        name: Support Ops Bot
        description: Automates support ticket summaries.
        redirect_uris:
          - https://app.example.com/oauth/callback
        allowed_scopes:
          - recipe:read
          - recipe:write
        is_confidential: false

    ClientPatch:
      type: object
      properties:
        redirect_uris: { type: array, items: { type: string, format: uri } }
        allowed_scopes: { type: array, items: { type: string } }
        name: { type: string }
        description: { type: string }
        homepage_url: { type: string, format: uri }

    Client:
      type: object
      properties:
        id: { type: string }
        clientId: { type: string }
        name: { type: string }
        description: { type: string }
        homepageUrl: { type: string, format: uri }
        clientSecretPrefix: { type: string }
        isConfidential: { type: boolean }
        redirectUris: { type: array, items: { type: string, format: uri } }
        allowedScopes: { type: array, items: { type: string } }
        status: { type: string, enum: [pending, approved, revoked] }
        declaredThroughput: { type: ["integer", "null"] }
        createdAt: { type: string, format: date-time }
        approvedAt: { type: ["string", "null"], format: date-time }
        revokedAt: { type: ["string", "null"], format: date-time }

    DeveloperApiCall:
      type: object
      description: A recent owner-visible API call diagnostic row for one OAuth app.
      additionalProperties: true
      required: [at, method, path, status, ms]
      properties:
        at: { type: string, format: date-time, description: When EvoMap recorded the call. }
        method: { type: string, enum: [GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS], description: HTTP method used by the call, example: GET }
        path: { type: string, pattern: ^/, description: Request path without scheme or host, example: /developer/oauth/recipes }
        status: { type: integer, minimum: 100, maximum: 599, description: HTTP response status. }
        ms: { type: integer, minimum: 0, description: End-to-end latency in milliseconds. }
        error: { type: ["string", "null"], description: Optional machine-readable error code or diagnostic label when available. }

    ClientVersionConfig:
      type: object
      description: The app config snapshot carried by a version. Only self-service scopes are retained; a proposed elevated scope is decided by the reviewer at approve time.
      properties:
        name: { type: string }
        redirectUris: { type: array, items: { type: string, format: uri } }
        allowedScopes: { type: array, items: { type: string } }
        webhookEvents: { type: array, items: { type: string } }

    ClientVersionInput:
      type: object
      required: [config, changelog, justification]
      properties:
        config: { $ref: "#/components/schemas/ClientVersionConfig" }
        changelog: { type: string, minLength: 5, maxLength: 2000, description: What changed in this version. }
        justification: { type: string, minLength: 5, maxLength: 2000, description: Why the change is needed (shown to the reviewer). }

    ClientVersionReviewInput:
      type: object
      required: [decision]
      properties:
        decision: { type: string, enum: [approved, rejected] }
        reject_reason: { type: ["string", "null"], description: "Optional reason, recorded on the version when `decision` is `rejected`." }

    ClientVersion:
      type: object
      properties:
        id: { type: string }
        clientId: { type: string }
        version: { type: integer, description: Per-client incrementing version number. }
        status: { type: string, enum: [draft, pending, approved, rejected] }
        config: { $ref: "#/components/schemas/ClientVersionConfig" }
        changelog: { type: string }
        justification: { type: string }
        submittedBy: { type: string }
        reviewedBy: { type: ["string", "null"] }
        reviewedAt: { type: ["string", "null"], format: date-time }
        rejectReason: { type: ["string", "null"] }
        createdAt: { type: string, format: date-time }

    AdminClientVersion:
      allOf:
        - $ref: "#/components/schemas/ClientVersion"
        - type: object
          description: A version enriched for the moderator review queue with the owner's identity, the client name, and the current live config to diff against.
          properties:
            ownerUserId: { type: string }
            ownerEmail: { type: ["string", "null"] }
            clientName: { type: ["string", "null"] }
            liveScopes: { type: array, items: { type: string } }
            liveRedirectUris: { type: array, items: { type: string } }
            liveActiveVersion: { type: ["integer", "null"] }

    Application:
      type: object
      properties:
        id: { type: string }
        status: { type: string, enum: [pending, approved, rejected] }
        motivation: { type: string }
        createdAt: { type: string, format: date-time }
      additionalProperties: true

    WebhookInput:
      type: object
      required: [url, events]
      properties:
        url: { type: string, format: uri, description: "HTTPS endpoint. Validated against SSRF (localhost / private IPs / cloud metadata are rejected)." }
        events:
          type: array
          items: { type: string, enum: [recipe.created, recipe.published, recipe.takedown] }

    WebhookEvent:
      type: object
      description: |
        The signed delivery envelope POSTed to a webhook endpoint. The signature
        header `X-EvoMap-Webhook-Signature: t=<unix>,v1=<hex>` is HMAC-SHA256 over
        `${t}.${rawBody}` with the webhook `secret`; reject deliveries whose `t` is
        too old to prevent replay. (`X-EvoMap-Signature: sha256=<hmac over body>`
        is also sent for back-compat; `X-EvoMap-Event-Id` mirrors `id`.)
      properties:
        id: { type: string, description: "Unique event id (evt_…) — use it to dedupe redeliveries." }
        type: { type: string, enum: [recipe.created, recipe.published, recipe.takedown, ping] }
        created: { type: string, format: date-time }
        livemode: { type: boolean }
        data: { type: object, additionalProperties: true }

    WebhookDelivery:
      type: object
      description: A delivery attempt record (kept ~7 days) for debugging + redelivery.
      properties:
        id: { type: string, description: "Delivery id (whd_…)." }
        event: { type: string }
        event_id: { type: string }
        status: { type: string, enum: [delivered, failed] }
        http_status: { type: ["integer", "null"] }
        attempts: { type: integer }
        last_error: { type: ["string", "null"] }
        created_at: { type: string, format: date-time }
        delivered_at: { type: ["string", "null"], format: date-time }

    WebhookCreated:
      type: object
      properties:
        id: { type: string }
        url: { type: string, format: uri }
        events: { type: array, items: { type: string } }
        secret: { type: string, description: HMAC signing secret (whsec_…). Shown once. }

    Webhook:
      type: object
      properties:
        id: { type: string }
        url: { type: string, format: uri }
        events: { type: array, items: { type: string } }
        active: { type: boolean }
        createdAt: { type: string, format: date-time }

    Grant:
      type: object
      description: An app the user has authorized.
      properties:
        clientId: { type: string }
        name: { type: string }
        scopes: { type: array, items: { type: string } }
        grantedAt: { type: string, format: date-time }
      additionalProperties: true

    RecipeInput:
      type: object
      description: |
        Recipe payload. `title` and a non-empty `steps` array are required: an empty
        step list is rejected with `at_least_one_step_required` before any other gate.
        Each step needs a non-empty `asset_id`. `asset_type` is optional and defaults
        to `Gene`. The enum below is the contract a client should hold itself to; if a
        value outside it reaches the service anyway, that step is dropped silently, so
        a body that looks populated can still fail as empty. In test mode asset ids are
        shape-validated only. Subject to moderation and originality checks.
      additionalProperties: true
      required: [title, steps]
      properties:
        title: { type: string }
        description: { type: string }
        steps:
          type: array
          minItems: 1
          items:
            type: object
            additionalProperties: true
            required: [asset_id]
            properties:
              asset_id: { type: string, minLength: 1 }
              asset_type: { type: string, enum: [Gene, Capsule], default: Gene }
              position: { type: integer }
      example:
        title: Summarize support tickets
        description: Cluster support tickets and draft a weekly summary.
        steps:
          - asset_id: gene_abc
            asset_type: Gene
            position: 0
          - asset_id: capsule_xyz
            asset_type: Capsule
            position: 1

    Recipe:
      type: object
      description: A recipe catalog object (public fields).
      additionalProperties: true
      properties:
        id: { type: string }
        title: { type: string }
        status: { type: string }
        livemode: { type: boolean, description: "Present and false on a test-mode (sandbox) read; ABSENT on a live read - it is never true here. Branch on `livemode === false`, never on `=== true`. Webhook envelopes are the surface that carries `livemode: true`." }

    Gene:
      type: object
      description: A ranked public asset (gene).
      additionalProperties: true
      properties:
        id: { type: string }
        type: { type: string }

    ReuseResult:
      type: object
      description: Reuse-graph result — one of the two shapes depending on the query parameter.
      properties:
        assetId: { type: string }
        reusedInRecipes: { type: array, items: { $ref: "#/components/schemas/Recipe" } }
        recipeId: { type: string }
        relatedRecipes: { type: array, items: { $ref: "#/components/schemas/Recipe" } }

    MarketplaceListing:
      type: object
      description: >-
        Marketplace listing metadata for an OAuth app — display/search metadata only. The listing never
        carries OAuth secret material; the OAuthClient remains the security principal.
      properties:
        id:
          type: string
        clientId:
          type: string
          description: Owning OAuth client id.
        slug:
          type: string
          description: URL-safe catalog handle, unique across the Marketplace.
        title:
          type: string
        short_description:
          type: string
        long_description:
          type: string
        category:
          type: string
        tags:
          type: array
          items:
            type: string
        logo_url:
          type:
            - string
            - 'null'
        screenshots:
          type: array
          items:
            type: string
        docs_url:
          type:
            - string
            - 'null'
        support_url:
          type:
            - string
            - 'null'
        privacy_policy_url:
          type:
            - string
            - 'null'
        terms_url:
          type:
            - string
            - 'null'
        install_audience:
          type: string
          enum:
            - user
            - org
            - agent
            - mixed
          description: 'Who can install the app: individual users, organizations, agents, or a mix.'
        status:
          type: string
          enum:
            - draft
            - submitted
            - published
            - hidden
            - suspended
            - rejected
        published_version:
          type:
            - integer
            - 'null'
          description: App version frozen at publish time; null while unpublished.
        review_summary:
          type: object
          additionalProperties: true
          description: Latest moderator decision summary.
        install_count:
          type: integer
        submitted_at:
          type:
            - string
            - 'null'
          format: date-time
        published_at:
          type:
            - string
            - 'null'
          format: date-time
        deleted_at:
          type:
            - string
            - 'null'
          format: date-time
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
      required:
        - id
        - clientId
        - slug
        - title
        - short_description
        - category
        - status
        - install_audience
    MarketplaceListingInput:
      type: object
      description: Create-or-update payload for an app's Marketplace listing draft.
      properties:
        slug:
          type: string
          minLength: 3
          maxLength: 80
          description: Optional; generated from the title when omitted.
        title:
          type: string
          minLength: 2
          maxLength: 120
        short_description:
          type: string
          minLength: 10
          maxLength: 240
        long_description:
          type: string
          maxLength: 5000
        category:
          type: string
          minLength: 2
          maxLength: 64
        tags:
          type: array
          maxItems: 12
          items:
            type: string
            maxLength: 40
        logo_url:
          type: string
          format: uri
        screenshots:
          type: array
          maxItems: 6
          items:
            type: string
            format: uri
        docs_url:
          type: string
          format: uri
        support_url:
          type: string
          format: uri
        privacy_policy_url:
          type: string
          format: uri
        terms_url:
          type: string
          format: uri
        install_audience:
          type: string
          enum:
            - user
            - org
            - agent
            - mixed
      required:
        - title
        - short_description
        - category
    MarketplacePublicApp:
      allOf:
        - $ref: '#/components/schemas/MarketplaceListing'
        - type: object
          description: >-
            Public catalog projection: the published listing joined with its still-approved OAuth
            client, so the advertised scopes / version / webhook events are the app's real capability
            surface.
          properties:
            client:
              type:
                - object
                - 'null'
              description: Live OAuth client capability surface; null when the client is no longer approved.
              properties:
                client_id:
                  type: string
                name:
                  type: string
                allowed_scopes:
                  type: array
                  items:
                    type: string
                  description: OAuth scope identifiers.
                declared_webhook_events:
                  type: array
                  items:
                    type: string
                active_version:
                  type:
                    - integer
                    - 'null'
                homepage_url:
                  type:
                    - string
                    - 'null'
    MarketplaceUserListing:
      type: object
      description: Compact listing card used by user-audience install views.
      properties:
        id:
          type: string
        slug:
          type: string
        title:
          type: string
        short_description:
          type: string
        category:
          type: string
        logo_url:
          type:
            - string
            - 'null'
        install_audience:
          type: string
          enum:
            - user
            - org
            - agent
            - mixed
        published_version:
          type:
            - integer
            - 'null'
    MarketplaceUserInstallation:
      type: object
      description: >-
        A user 'install' IS the OAuth consent recorded by /oauth/authorize — there is no separate
        user-install table and no server-side path that mints consent.
      properties:
        client_id:
          type: string
        client_name:
          type: string
        homepage_url:
          type:
            - string
            - 'null'
        granted_scopes:
          type: array
          items:
            type: string
          description: OAuth scope identifiers.
        installed_at:
          type: string
          format: date-time
        listing:
          oneOf:
            - $ref: '#/components/schemas/MarketplaceUserListing'
            - type: 'null'
          description: Null when the app has no published Marketplace listing.
    MarketplaceInstallation:
      type: object
      description: >-
        Tenant (org) app installation. `grant_id` points at the OAuthOrgGrant that actually authorizes
        token issuance; `granted_scopes` / `app_version` are the frozen consent snapshot, so a later app
        change cannot silently widen the tenant's grant (it flips `reauth_required` instead).
      properties:
        id:
          type: string
        org_id:
          type: string
        client_id:
          type: string
        listing_id:
          type:
            - string
            - 'null'
        grant_id:
          type:
            - string
            - 'null'
          description: Backing OAuthOrgGrant id; null once revoked.
        status:
          type: string
          enum:
            - active
            - disabled
            - revoked
            - uninstalled
            - requires_reauth
            - suspended
        ceiling_role:
          type: string
          enum:
            - viewer
            - member
            - admin
            - owner
          description: >-
            Maximum org role the app can act as; scopes are narrowed to client ∩ role ceiling ∩
            org-issuable.
        granted_scopes:
          type: array
          items:
            type: string
          description: Frozen consent snapshot of scopes granted at install/reauthorize time.
        app_version:
          type:
            - integer
            - 'null'
          description: App version frozen at consent time.
        client_name:
          type: string
        listing_slug:
          type:
            - string
            - 'null'
        listing_title:
          type: string
        installed_by_id:
          type: string
        installed_at:
          type: string
          format: date-time
        disabled_at:
          type:
            - string
            - 'null'
          format: date-time
        disabled_by_id:
          type:
            - string
            - 'null'
        revoked_at:
          type:
            - string
            - 'null'
          format: date-time
        revoked_by_id:
          type:
            - string
            - 'null'
        uninstalled_at:
          type:
            - string
            - 'null'
          format: date-time
        uninstalled_by_id:
          type:
            - string
            - 'null'
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        reauth_required:
          type: boolean
          description: >-
            True when the app's current scopes/version drifted from the frozen consent snapshot;
            reauthorize to clear.
        current_app_version:
          type:
            - integer
            - 'null'
          description: The app's CURRENT active version, for drift comparison against app_version.
      required:
        - id
        - org_id
        - client_id
        - status
        - ceiling_role
        - granted_scopes
        - reauth_required
    MarketplaceInstallationDetail:
      type: object
      description: >-
        Single-installation detail for an org admin: the frozen consent snapshot next to the app's
        CURRENT capability surface, an explicit drift breakdown, and the live status of the backing
        OAuthOrgGrant.
      properties:
        installation:
          $ref: '#/components/schemas/MarketplaceInstallation'
        app:
          type:
            - object
            - 'null'
          description: The app's current capability surface; null when the client no longer exists.
          properties:
            client_id:
              type: string
            name:
              type: string
            status:
              type: string
            allowed_scopes:
              type: array
              items:
                type: string
              description: OAuth scope identifiers.
            active_version:
              type:
                - integer
                - 'null'
            declared_webhook_events:
              type: array
              items:
                type: string
            homepage_url:
              type:
                - string
                - 'null'
        listing:
          type:
            - object
            - 'null'
          properties:
            id:
              type: string
            slug:
              type: string
            title:
              type: string
            status:
              type: string
              enum:
                - draft
                - submitted
                - published
                - hidden
                - suspended
                - rejected
            published_version:
              type:
                - integer
                - 'null'
            install_audience:
              type: string
              enum:
                - user
                - org
                - agent
                - mixed
        grant:
          type:
            - object
            - 'null'
          description: Live status of the backing OAuthOrgGrant.
          properties:
            id:
              type: string
            status:
              type: string
            ceiling_role:
              type: string
              enum:
                - viewer
                - member
                - admin
                - owner
            granted_scopes:
              type: array
              items:
                type: string
              description: OAuth scope identifiers.
            granted_by_id:
              type: string
              description: Consenting admin userId — audit only, never a token attribution spine.
            revoked_at:
              type:
                - string
                - 'null'
              format: date-time
            updated_at:
              type: string
              format: date-time
        drift:
          type: object
          properties:
            scopes_added_by_app:
              type: array
              items:
                type: string
              description: >-
                Scopes the app now advertises that this org never consented to; unusable until
                reauthorized.
            scopes_removed_by_app:
              type: array
              items:
                type: string
              description: Consented scopes the app no longer declares.
            installed_version:
              type:
                - integer
                - 'null'
            current_version:
              type:
                - integer
                - 'null'
            version_behind:
              type: boolean
            grant_revoked:
              type: boolean
    MarketplaceAppInstallRequest:
      type: object
      description: >-
        Tenant install request: an org member proposes an app, an admin approves it into a real
        installation or rejects it. Client/listing names are snapshotted so the inbox stays readable
        after a rename.
      properties:
        id:
          type: string
        org_id:
          type: string
        client_id:
          type: string
        listing_id:
          type:
            - string
            - 'null'
        requested_by_id:
          type: string
        status:
          type: string
          enum:
            - pending
            - approved
            - rejected
            - cancelled
        requested_scopes:
          type: array
          items:
            type: string
          description: OAuth scope identifiers.
        ceiling_role:
          type: string
          enum:
            - viewer
            - member
            - admin
            - owner
        message:
          type: string
        reviewed_by_id:
          type:
            - string
            - 'null'
        reviewed_at:
          type:
            - string
            - 'null'
          format: date-time
        review_note:
          type: string
        installation_id:
          type:
            - string
            - 'null'
          description: Set when an approval materialized this request into an installation.
        client_name:
          type: string
        listing_slug:
          type:
            - string
            - 'null'
        listing_title:
          type: string
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
      required:
        - id
        - org_id
        - client_id
        - requested_by_id
        - status
        - ceiling_role
    MarketplaceInstallScopes:
      description: >-
        Requested scopes — a space-separated string or an array; narrowed to client ∩ role ceiling ∩
        org-issuable.
      oneOf:
        - type: string
        - type: array
          items:
            type: string
    DeveloperAppDashboard:
      type: object
      description: >-
        Aggregate owner dashboard for one app: client config, Marketplace listing, review state, tenant
        install counts, and usage. Install stats are count-only — a developer never sees which tenants
        installed the app.
      properties:
        client:
          type: object
          properties:
            client_id:
              type: string
            name:
              type: string
            description:
              type:
                - string
                - 'null'
            homepage_url:
              type:
                - string
                - 'null'
            status:
              type: string
            is_confidential:
              type: boolean
            allowed_scopes:
              type: array
              items:
                type: string
              description: OAuth scope identifiers.
            redirect_uris:
              type: array
              items:
                type: string
            declared_webhook_events:
              type: array
              items:
                type: string
            active_version:
              type:
                - integer
                - 'null'
            created_at:
              type: string
              format: date-time
            approved_at:
              type:
                - string
                - 'null'
              format: date-time
            revoked_at:
              type:
                - string
                - 'null'
              format: date-time
            secret_rotated_at:
              type:
                - string
                - 'null'
              format: date-time
            secret_rotation_count:
              type: integer
        listing:
          type:
            - object
            - 'null'
          properties:
            id:
              type: string
            slug:
              type: string
            title:
              type: string
            category:
              type: string
            install_audience:
              type: string
              enum:
                - user
                - org
                - agent
                - mixed
            status:
              type: string
              enum:
                - draft
                - submitted
                - published
                - hidden
                - suspended
                - rejected
            published_version:
              type:
                - integer
                - 'null'
            review_summary:
              type: object
              additionalProperties: true
            install_count:
              type: integer
            submitted_at:
              type:
                - string
                - 'null'
              format: date-time
            published_at:
              type:
                - string
                - 'null'
              format: date-time
            archived:
              type: boolean
            updated_at:
              type: string
              format: date-time
        review:
          type: object
          properties:
            versions:
              type: array
              items:
                type: object
                properties:
                  id:
                    type: string
                  version:
                    type: integer
                  status:
                    type: string
                  reviewed_at:
                    type:
                      - string
                      - 'null'
                    format: date-time
                  reject_reason:
                    type:
                      - string
                      - 'null'
                  created_at:
                    type: string
                    format: date-time
            pending_version:
              type:
                - integer
                - 'null'
            scope_requests:
              type: array
              items:
                type: object
                properties:
                  id:
                    type: string
                  scope:
                    type: string
                  status:
                    type: string
                  reviewed_at:
                    type:
                      - string
                      - 'null'
                    format: date-time
                  reject_reason:
                    type:
                      - string
                      - 'null'
                  created_at:
                    type: string
                    format: date-time
            pending_scope_requests:
              type: array
              items:
                type: string
              description: OAuth scope identifiers.
        installs:
          type: object
          description: Tenant install counters, never tenant identities.
          properties:
            total:
              type: integer
            active:
              type: integer
            uninstalled:
              type: integer
            requires_reauth:
              type: integer
            suspended:
              type: integer
            stale_version_installs:
              type: integer
        usage:
          type: object
          additionalProperties: true
