openapi: 3.1.0
info:
  title: MITRITY API
  description: |-
    Programmatic access to the MITRITY governance platform: agents and the policies that
    govern them, the audit trail, approvals and governance reviews, compliance reports and
    posture, threat intelligence, and the inventories of the tools and models your agents use.

    Authenticate with an API key from **Settings > API Keys**, sent as a bearer token:
    `Authorization: Bearer mk_…`. Rate limits, pagination and error responses are described
    in the [API overview](https://mitrity.com/docs/api/overview).
  version: 0.1.0
  contact:
    name: MITRITY
    url: https://mitrity.com
servers:
  - url: https://api.mitrity.com/api/v1
    description: Production
security:
  - BearerAuth: []
tags:
  - name: Tenants
    description: Tenant (organization) management
  - name: Users
    description: User management within a tenant
  - name: Environments
    description: Environments within a tenant
  - name: Agents
    description: AI agent registration and management
  - name: Platforms
    description: SaaS-agent governance — connect external platforms (GitHub, Salesforce, Microsoft 365)
      to discover, observe, and posture-check the agents living inside them. Read-only connectors;
      Pro plan or higher.
  - name: Policies
    description: Intent policy CRUD
  - name: Audit
    description: Audit log queries
  - name: Approvals
    description: Human-in-the-loop approval queue
  - name: Governance
    description: Governance lifecycle — the human-owned approval axis on an agent (`lifecycle_status`),
      the review queue that moves it, the assessment templates reviews are filled in from, and the
      per-tenant gate that decides what an unapproved agent may do at runtime.
  - name: Billing
    description: Stripe subscription management
  - name: Notifications
    description: Notification preferences
  - name: Tools
    description: Tool definitions and permission boundaries
  - name: DLP
    description: Data Loss Prevention — destination allowlists and DLP events
  - name: InjectionDetection
    description: Prompt injection detection events and false positive management
  - name: DelegationChains
    description: Agent-to-agent delegation chain tracking and governance
  - name: Credentials
    description: Credential definitions, grants, and lease management
  - name: Reports
    description: Compliance reporting — SOC 2, GDPR, ISO 27001, executive summaries
  - name: Compliance
    description: Compliance posture — the framework libraries that ship in the product, the tenant's
      computed state per control with a person's override and owner, the AI risk register and the AI
      policy statements with their attestation roster.
  - name: ThreatIntelligence
    description: Shared threat indicator feed — tenant-scoped matches and settings
  - name: AIBOM
    description: AI Bill of Materials — inventory of MCP servers the tenant's agents have invoked, with
      runtime risk overlay + drift advisories
  - name: Models
    description: Model inventory and repository scanning — the models a tenant governs, the scans run
      against them, and what those scans found
  - name: LLMUsage
    description: LLM-BOM and usage metering — per-call records, token/cost aggregations, and the
      per-agent provider/model inventory derived from LLM Gateway egress events
  - name: GovernedActions
    description: Governed-action meter — the billing-grade per-tenant count of governed agent decisions,
      the unit usage-based billing counts, with summary aggregations and the billable count for a
      period
  - name: Runtime
    description: Runtime observation — events the Linux host observer reported, runtime findings and the
      per-agent / per-host runtime summary
  - name: Redteam
    description: "Governed adversarial testing — campaigns, runs and findings for mitrity-redteam, plus
      the compliance view over them. Attack prompts never leave the customer's environment: the
      corpus ships compiled into the runner, and what crosses this API is campaign definitions
      outbound and hashes, bounded redacted excerpts and counts inbound."
  - name: MLModels
    description: ML model version management — register, list, enable/disable inference models
  - name: Insights
    description: ML insights and drift analytics — anomaly summaries, agent risk scores, drift trends
  - name: Academy
    description: "MITRITY Academy — the free training product: course catalog, module exams,
      certificates (each downloadable as a PDF, with a public verification page), plus the org-admin
      surface for learner invites, learner removal, and the per-course roster. Learner endpoints are
      open to every authenticated role with deliberately no plan gating; org-admin endpoints require
      the owner or manager role; certificate verification needs no authentication."
paths:
  /users:
    get:
      tags:
        - Users
      summary: List the tenant's users
      description: "The tenant's users, pending invitations included (`status: pending`) and Academy
        learners included (`academy_only: true`), a page at a time. Every user of the tenant can
        list them, whatever their role or access scope. `search` narrows the page and `total` alike.
        `sort_by` and `sort_order` select the order: their enums are the values a client sends, and
        the server, more lenient than the schema, answers any other value with the default order
        rather than a 400."
      parameters:
        - name: search
          in: query
          description: Only users whose display name or email address contains this text, ignoring case.
          schema:
            type: string
        - name: sort_by
          in: query
          description: "`name` sorts by display name."
          schema:
            type: string
            enum:
              - name
              - email
              - created_at
            default: created_at
        - name: sort_order
          in: query
          schema:
            type: string
            enum:
              - asc
              - desc
            default: desc
        - name: limit
          in: query
          description: Users per page. The bounds are the values a client sends; the server, more lenient than
            the schema, reads a larger value as 200 and a smaller one as 1 rather than answering
            400.
          schema:
            type: integer
            default: 50
            minimum: 1
            maximum: 200
        - name: offset
          in: query
          description: Users to skip.
          schema:
            type: integer
            default: 0
            minimum: 0
      responses:
        "200":
          description: One page of users.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/UserList"
        "401":
          $ref: "#/components/responses/Unauthorized"
  /users/{user_id}:
    get:
      tags:
        - Users
      summary: Get a user
      description: One user of the tenant. Every user of the tenant can read any other, whatever their
        role or access scope; this is also how a person reads their own record. A user of another
        tenant, or an id that names no user, is 404.
      parameters:
        - name: user_id
          in: path
          required: true
          description: A value that is not a UUID is refused 400.
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: The user.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/User"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
    put:
      tags:
        - Users
      summary: Update a user's display name or role
      description: |
        Changes a user's display name, role, or both; a field left out keeps
        its stored value. Anyone can change their own display name. An owner
        can change anyone's display name and role. A manager can change the
        display name and role of any user but an owner, and cannot make anyone
        an owner. A member or a viewer who sends `role`, or who names another
        user, is refused. Each refusal is 403.

        The tenant keeps at least one owner: taking the owner role from its
        last owner, the owner themselves included, is refused 409. A pending
        invitation to be an owner does not count as an owner. The change
        reads the tenant's owners locked, in the transaction that writes it,
        so two owners demoting each other at the same moment cannot both
        succeed: one of them is 409. A new role applies from the user's next
        request, and the user's sessions are revoked: they sign in again once
        their current sign-in expires. A change of display name alone keeps
        them signed in. A role change is written to the audit trail as
        `user.role_changed`, with the old and the new role.

        An Academy learner (a user the Academy invited, who holds no platform
        seat; `academy_only` is true) keeps the viewer role. Any other role
        for them is refused 409 with the code `academy_only_user` and the
        message "Academy learners keep the viewer role. Use Give platform
        access on the Team page to give them a platform role." in both
        `error` and `message`, and nothing is written. The role refusals
        above (403) come first. Their display name, the name on their
        certificates, can still change, and `role: viewer` is accepted. A
        learner gets a seat and a platform role through `POST
        /users/{user_id}/platform-access`.

        An API key bound to an environment takes no action reserved for
        owners (403, "an API key bound to an environment cannot take an
        action reserved for owners"), an owner's key included: it cannot make
        anyone an owner (that refusal reads "an API key bound to an
        environment cannot make anyone an owner"), change an owner's role,
        its own owner's included, or change another owner's display name.
        Below that it changes users as a manager's key would.
      parameters:
        - name: user_id
          in: path
          required: true
          description: A value that is not a UUID is refused 400.
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                display_name:
                  type: string
                  maxLength: 200
                  description: "The name shown for the user in the dashboard, and the name their Academy certificates
                    are made out to. It is cleaned before it is stored, as a sign-up's name is (POST
                    /auth/register): every run of whitespace becomes one space; control characters,
                    the bidirectional formatting characters (the embedding, override and isolate
                    characters and the LRM, RLM and ALM marks) and U+FEFF are dropped; and the
                    result is trimmed. A name longer than 200 characters once cleaned is refused 400
                    (\"display_name must be at most 200 characters\"), before anything is read or
                    written; it is never cut. A name with nothing left once cleaned is stored empty,
                    as an empty name is."
                role:
                  type: string
                  enum:
                    - owner
                    - manager
                    - member
                    - viewer
                  description: Any other value is refused 400.
      responses:
        "200":
          description: The updated user.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/User"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: The change would leave the tenant without an owner (no code), or would give an Academy
            learner a role other than viewer (code `academy_only_user`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
    delete:
      tags:
        - Users
      summary: Remove a user from the tenant (owner)
      description: |
        Removes a user, or withdraws a pending invitation. Only an owner
        removes a user, and not with an API key bound to an environment
        (403), an owner's key included. Nobody removes themselves here (400):
        `DELETE /users/me` does that. The last owner is not removed (400); a
        pending invitation to be an owner is not an owner. The removal reads
        the tenant's owners locked, in the transaction that deletes the row,
        so two owners removing each other at the same moment cannot both
        succeed.

        The user's sign-in account is deleted last, just before that
        transaction commits: if it cannot be deleted, the removal is rolled
        back, nothing is removed (409), and it can be tried again. The API
        keys the user created stop working with them. The compliance reports
        they generated stay, with no author (`generated_by` null). Recorded
        on the audit trail as `user.removed`, or `user.invitation.revoked`
        for an invitation.
      parameters:
        - name: user_id
          in: path
          required: true
          description: A value that is not a UUID is refused 400.
          schema:
            type: string
            format: uuid
      responses:
        "204":
          description: User removed
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: The user's sign-in account could not be deleted, or the removal met a concurrent change
            to the organization's users or environments on a lock. Nothing was removed; try again.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
  /users/{user_id}/reset-mfa:
    post:
      tags:
        - Users
      summary: Reset MFA for a user (owner, or a manager for members and viewers)
      description: |
        Clears the second factors the user has enrolled with the identity
        provider, so a user who lost their authenticator can sign in and
        enroll again. Then marks the user's MFA as not enrolled, revokes
        their sessions' refresh tokens, and emails the user that their MFA
        was reset. The MFA method on record is not changed.
        If the factors cannot be cleared, nothing is changed and the answer
        is 502; the reset can be tried again. A pending invitation has no
        factors to clear.
        An owner can reset anyone's but their own. A manager can reset a
        member's or a viewer's; a manager's or an owner's takes an owner,
        and a manager asking is 403. So is an API key bound to an
        environment, an owner's key included: it resets as a manager's
        would. Nobody resets their own MFA through this route (403): the
        self-reset does that. A member or a viewer is 403. A refusal writes
        nothing and audits nothing.
      parameters:
        - name: user_id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: The user, with `mfa_enrolled` false.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/User"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "502":
          description: The identity provider did not clear the user's factors. Nothing was changed; try again.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
  /users/{user_id}/platform-access:
    post:
      tags:
        - Users
      summary: Give an Academy learner platform access (owner or manager)
      description: |
        Makes an Academy learner a platform user with the role sent. A
        learner (`academy_only` true) is a user the Academy invited: they
        hold no seat and keep the viewer role. Platform access gives them
        one of the tenant's seats: `academy_only` becomes false and `role`
        becomes the role sent. They keep their exam attempts and
        certificates. This is how a learner becomes a platform user: a
        platform invitation to a learner's address is refused (`POST
        /tenants/{id}/invite`, 409 `academy_learner_exists`), and `PUT
        /users/{user_id}` keeps a learner a viewer.

        An owner or a manager gives platform access, the authority a role
        change takes; anyone else is refused 403. The role is `viewer`,
        `member` or `manager`; any other value, `owner` included, is refused
        400. The user must be a learner of the caller's tenant who has
        accepted their invitation: a pending invitation, or a user who
        already has platform access, is refused 400, and an id that names no
        user of the tenant is 404. An account with Academy access only is
        refused 403 `academy_only_account`, as on every platform endpoint,
        until it chooses a plan. A learner whose row still holds a role other
        than viewer, written before learners kept the viewer role, is given
        the role sent all the same, as a role change gives it. A row that
        holds the owner role takes an owner's authority to change (403 below
        one), and the tenant's last owner keeps it (409, no code).

        The seats are counted as an invitation counts them: the tenant's
        users who are not learners, pending invitations included, against
        its plan's user limit (`max_users`). The count is read with the
        tenant locked, in the transaction that writes the change, and an
        invitation counts under the same lock: two grants, or a grant and an
        invitation, made at the same moment cannot both take the last seat.
        When no seat is free the answer is 409 with the code
        `seat_limit_reached` and the message "Every seat on your plan is in
        use. Remove a user or choose a larger plan to free one." in both
        `error` and `message`, and nothing is written.

        When the caller's own access is scoped (or the request is made with
        an API key bound to an environment), the person's access scope
        becomes `scoped`, as an invitation from such a caller starts, and
        the caller grants what they can see; otherwise it is kept. The
        change applies from the person's next request. Roles rank viewer,
        member, manager, owner. A grant that lowers the role the learner's
        row held revokes their sessions: they sign in again once their
        current sign-in expires. A grant that keeps or raises it, as every
        grant to a learner the Academy invited does, leaves them signed in.
        (A role change with `PUT /users/{user_id}` revokes the sessions on
        any change.) It is written to the audit trail as
        `user.platform_access_granted`, with the role the learner held, the
        role given and the access scope. The person is sent the welcome
        email; a failure to send it does not fail the request.
      parameters:
        - name: user_id
          in: path
          required: true
          description: A value that is not a UUID is refused 400.
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - role
              properties:
                role:
                  type: string
                  enum:
                    - viewer
                    - member
                    - manager
                  description: The person's platform role. Any other value is refused 400. An owner makes someone an
                    owner with a role change afterwards (`PUT /users/{user_id}`).
      responses:
        "200":
          description: The user, with `academy_only` false and the role given.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/User"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: Every seat on the plan is in use (code `seat_limit_reached`), or the change would take
            the owner role from the tenant's last owner (no code). Nothing was written.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
  /users/{user_id}/access-scope:
    put:
      tags:
        - Users
      summary: Set a user's access scope (owner or manager)
      description: "Sets what the user sees of the tenant: `all`, or `scoped` to the agents granted to
        them and the environments they have access to. An owner or a manager sets it, and a manager
        cannot change an owner's (403), nor can an API key bound to an environment, an owner's key
        included; an owner is never scoped, whatever this says. A caller whose own access scope is
        `scoped` cannot set `all`, for anyone, themselves included (403). Setting `all` removes the
        user's agent grants and keeps their environment access. The change applies from the user's
        next request."
      parameters:
        - name: user_id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - access_scope
              properties:
                access_scope:
                  type: string
                  enum:
                    - all
                    - scoped
      responses:
        "200":
          description: The updated user.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/User"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
  /users/{user_id}/grants:
    get:
      tags:
        - Users
      summary: List a user's agent grants
      description: The agents granted to the user. An owner, a manager or the user themselves can read
        them; anyone else is 403. A caller whose own access scope is `scoped` sees only the grants
        on agents they can see.
      parameters:
        - name: user_id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: The user's agent grants.
          content:
            application/json:
              schema:
                type: object
                required:
                  - grants
                properties:
                  grants:
                    type: array
                    items:
                      $ref: "#/components/schemas/ResourceGrant"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
    post:
      tags:
        - Users
      summary: Grant an agent to a user (owner or manager)
      description: Grants one agent to the user. An owner or a manager grants; anyone else is 403. The
        user must be one of the tenant's users and the agent one of its agents (404 otherwise). A
        caller whose own access scope is `scoped` grants only an agent they can see; any other agent
        is 404.
      parameters:
        - name: user_id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - resource_type
                - resource_id
              properties:
                resource_type:
                  type: string
                  enum:
                    - agent
                resource_id:
                  type: string
                  format: uuid
                  description: The agent to grant.
      responses:
        "200":
          description: "The grant the user already holds. Granting it again changes nothing: no second grant
            is written and nothing is audited."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ResourceGrant"
        "201":
          description: The grant.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ResourceGrant"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
  /users/{user_id}/grants/{grant_id}:
    delete:
      tags:
        - Users
      summary: Revoke a user's agent grant (owner or manager)
      description: Revokes one of the user's grants, by the grant's id. An owner or a manager revokes;
        anyone else is 403. A grant that is not the user's is 404, and so is, for a caller whose own
        access scope is `scoped`, a grant on an agent they cannot see.
      parameters:
        - name: user_id
          in: path
          required: true
          schema:
            type: string
            format: uuid
        - name: grant_id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "204":
          description: The grant was revoked.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
  /users/{user_id}/environment-access:
    get:
      tags:
        - Users
      summary: List a user's environment access
      description: The environments the user may work in when their access scope is `scoped`. An owner, a
        manager or the user themselves can read them; anyone else is 403. A caller whose own access
        scope is `scoped` sees only the entries for environments they have access to.
      parameters:
        - name: user_id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: The user's environment access.
          content:
            application/json:
              schema:
                type: object
                required:
                  - environment_access
                properties:
                  environment_access:
                    type: array
                    items:
                      $ref: "#/components/schemas/EnvironmentAccess"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
    post:
      tags:
        - Users
      summary: Give a user access to an environment (owner or manager)
      description: "Gives the user access to one environment. An owner or a manager does it; anyone else
        is 403. The user must be one of the tenant's users and the environment one of its
        environments (404 otherwise). A caller whose own access scope is `scoped` gives access only
        to an environment they have access to themselves; any other is 404. Environment access shows
        the user no agent: agents are granted separately."
      parameters:
        - name: user_id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - environment_id
              properties:
                environment_id:
                  type: string
                  format: uuid
      responses:
        "200":
          description: "The access the user already holds, with its environment's name and slug. Giving it
            again changes nothing: no second entry is written and nothing is audited."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/EnvironmentAccess"
        "201":
          description: The new entry, with its environment's name and slug.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/EnvironmentAccess"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
  /users/{user_id}/environment-access/{access_id}:
    delete:
      tags:
        - Users
      summary: Revoke a user's environment access (owner or manager)
      description: Revokes one of the user's environment-access entries, by the entry's id, not the
        environment's. An owner or a manager revokes; anyone else is 403. An entry that is not the
        user's is 404, and so is, for a caller whose own access scope is `scoped`, an entry for an
        environment they have no access to.
      parameters:
        - name: user_id
          in: path
          required: true
          schema:
            type: string
            format: uuid
        - name: access_id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "204":
          description: The access was revoked.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
  /tenants:
    get:
      tags:
        - Tenants
      summary: Get your tenant
      description: The caller's own tenant, under `tenant`. Every user of the tenant can read it, whatever
        their role or access scope. Its `id` is the tenant id the other `/tenants/{id}` routes take.
      responses:
        "200":
          description: The caller's tenant.
          content:
            application/json:
              schema:
                type: object
                required:
                  - tenant
                properties:
                  tenant:
                    $ref: "#/components/schemas/Tenant"
        "401":
          $ref: "#/components/responses/Unauthorized"
  /tenants/{id}:
    get:
      tags:
        - Tenants
      summary: Get a tenant
      description: "The caller's own tenant, by its id. Every user of the tenant can read it. Any other
        tenant's id is 403, whatever the caller's role: a role is held in one tenant only. A 404
        means the caller's tenant was deleted while the request was on its way."
      parameters:
        - name: id
          in: path
          required: true
          description: The caller's tenant. A value that is not a UUID is refused 400.
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: The tenant.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Tenant"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
    put:
      tags:
        - Tenants
      summary: Update tenant settings
      description: "Updates the caller's own tenant: `id` must be the caller's tenant, and only an `owner`
        may change it (403 otherwise), and not with an API key bound to an environment (403). Send
        only the fields to change: a field left out keeps its stored value. A request that sets a
        `saml_*` field while the result has `saml_enabled: true` (turning SAML on, or changing a
        SAML setting while it stays on) needs the tenant on the Enterprise plan (402 otherwise) and
        every `saml_*` field set, in this request or already stored on the tenant (400 otherwise);
        the plan is checked before the fields. A request that sets no `saml_*` field, such as a
        rename, is not held to either rule, and turning SAML off is never gated."
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name:
                  type: string
                  minLength: 1
                  pattern: \S
                  description: Trimmed; a name of only spaces is empty too (400).
                saml_enabled:
                  type: boolean
                  description: Switches SAML sign-in on or off. Turning it off is never gated. A tenant with SAML on
                    that pins no `saml_provider_id` (one that switched SAML on before that field
                    existed) sets it when it next changes a SAML setting (400 otherwise); until then
                    GET /auth/providers offers it no SAML sign-in and Just-In-Time provisioning
                    refuses it. One that has left the Enterprise plan can rename itself and turn
                    SAML off, but changing a SAML setting while SAML stays on needs the plan (402).
                saml_entity_id:
                  type: string
                  description: The IdP's entity ID (issuer). Not returned.
                saml_sso_url:
                  type: string
                  description: The IdP's SSO URL. Not returned.
                saml_certificate:
                  type: string
                  description: The IdP's X.509 signing certificate, PEM. Not returned.
                saml_email_domain:
                  type: string
                  description: "The email domain whose people sign in through the tenant's identity provider. It is
                    the tenant's own setting: nothing verifies the domain. Stored trimmed and
                    lowercased: sign-in looks the tenant up by the lowercased domain of the address.
                    An empty string clears it, which SAML being on does not allow."
                saml_provider_id:
                  type: string
                  description: The tenant's SAML provider configuration id in Identity Platform, the `<id>` in
                    `firebase.sign_in_provider` = `saml.<id>`. Accepted with or without its `saml.`
                    prefix and stored without it (`saml.acme-okta` is stored as `acme-okta`). The
                    part after `saml.` starts with a lowercase letter, ends with a letter or digit,
                    is at least two characters long and holds only letters of either case, digits,
                    `-`, `_` and `.` (400 otherwise); blank clears it, which SAML being on does not
                    allow. The server trims surrounding whitespace before it applies the rule; the
                    `pattern` gives the rule's shape for a trimmed value, and the server also
                    refuses a prefixed value whose part after `saml.` breaks the rule, such as
                    `saml.x`. GET /auth/providers returns `saml.<id>` for the tenant's domain as the
                    provider the browser signs in with. A SAML sign-in must come from it to be
                    provisioned Just-In-Time into this tenant, and to prove an invited address
                    without the `email_verified` claim. Not returned.
                  pattern: ^$|^(saml\.)?[a-z][A-Za-z0-9._-]*[A-Za-z0-9]$
                  example: saml.acme-okta
      responses:
        "200":
          description: The updated tenant.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Tenant"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          description: "Plan upgrade required: SAML 2.0 SSO is an Enterprise feature. The body carries the
            code `plan_upgrade_required` and, at its top level beside the code, `minimum_plan`
            (`enterprise`) and the tenant's `current_plan`."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
  /tenants/{id}/invite:
    post:
      tags:
        - Tenants
      summary: Invite a user to the tenant
      description: |
        Invites a person to the caller's own tenant: `id` must be the caller's
        tenant (403 otherwise). An owner or a manager invites; anyone else is
        403. The invited user joins with the access scope `all`, except when
        the inviter is a manager whose own access scope is scoped: then the
        invitation starts `scoped`, with nothing granted, and the inviter
        grants what they can see. The invitation takes a seat at once: a
        tenant whose users who are not Academy learners, pending invitations
        included, already reach its plan's user limit is refused 400, with no
        code: the answer this route has always given, kept for the clients
        that read it (a grant of platform access answers the same count with
        409 `seat_limit_reached`). The seats are counted with the tenant
        locked, in the transaction that creates the invitation, so two
        invitations, or an invitation and a grant of platform access (`POST
        /users/{user_id}/platform-access`), made at the same moment cannot
        both take the last seat. The invited person receives an email with a
        link to join. The invitation is created even when that email cannot
        be sent, so a 202 means the invitation exists, not that the email was
        delivered.
        An API key bound to an environment cannot invite an owner (403): an
        owner sees every environment, which the key cannot.

        An address that belongs to an Academy learner of the tenant is
        refused 409 with the code `academy_learner_exists`, before the seats
        are counted, and nothing is created: the person already has a user
        in the tenant, and an owner or a manager gives them a seat and a
        platform role with `POST /users/{user_id}/platform-access`. The
        message, in both `error` and `message`, is "This person is already
        an Academy learner in your organization. Use Give platform access on
        the Team page." for a learner who has accepted their Academy
        invitation, and "This person already has a pending Academy
        invitation in your organization. Once they accept it, use Give
        platform access on the Team page." for one who has not. The address
        is compared without regard to case.
      parameters:
        - name: id
          in: path
          required: true
          description: The caller's tenant. A value that is not a UUID is refused 400.
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - email
                - role
              properties:
                email:
                  type: string
                  format: email
                  description: Trimmed; an empty or malformed address is refused 400.
                role:
                  type: string
                  enum:
                    - owner
                    - manager
                    - member
                    - viewer
                  description: "The role the invited person joins with. Only an owner invites an owner; a manager is
                    refused 403. An owner invitation does not own the invited address until it is
                    taken, so it is accepted whatever tenant the address owns: an address that
                    already owns a tenant is refused when the invitation is taken (409
                    `owner_elsewhere`), and the invitation answers the same either way."
      responses:
        "202":
          description: The invitation was created. The invited person is a user of the tenant from now on and
            completes their account when they join.
          content:
            application/json:
              schema:
                type: object
                required:
                  - user_id
                  - tenant_id
                  - email
                  - role
                  - message
                properties:
                  user_id:
                    type: string
                    format: uuid
                    description: The user record the invitation created.
                  tenant_id:
                    type: string
                    format: uuid
                  email:
                    type: string
                    format: email
                  role:
                    type: string
                    enum:
                      - owner
                      - manager
                      - member
                      - viewer
                  message:
                    type: string
                    example: invitation created; email delivery is pending
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "409":
          description: The address belongs to an Academy learner of the tenant (code
            `academy_learner_exists`). Nothing was created.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
  /environments/{environment_id}/deletion-preview:
    get:
      tags:
        - Environments
      summary: Preview what deleting an environment keeps
      description: >-
        What deleting the environment would keep, for the confirmation shown before it is deleted.
        Deleting an environment keeps its compliance reports as organization-level reports, with
        their evidence and share links; the share links keep working until they expire or are
        revoked. `reports_kept` and `live_share_links` are counted the way the deletion counts what
        it keeps in the organization's audit trail; `reports_listed` leaves out the reports deleted
        from the reports list. Reading the preview changes nothing.

        The preview is answered to exactly the callers who may delete the environment: only an owner
        (403 for anyone else), and not with an API key bound to an environment, its own included
        (403). An environment that is not the organization's, or that a user whose access scope is
        `scoped` has no access to, is 404. A production environment is not deleted, so its preview
        is 400, as its deletion is.
      operationId: getEnvironmentDeletionPreview
      parameters:
        - name: environment_id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: What deleting the environment would keep.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/EnvironmentDeletionPreview"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
  /agents:
    get:
      tags:
        - Agents
      summary: List registered agents
      description: Lists agents in the tenant, including runtime-registered agents and agents DISCOVERED
        inside connected SaaS platforms. Use `source` and `governance_state` to triage the discovery
        queue (e.g. `?source=github&governance_state=discovered`).
      parameters:
        - name: status
          in: query
          schema:
            type: string
            enum:
              - registered
              - active
              - suspended
              - archived
        - name: source
          in: query
          description: Filter by agent origin. `runtime` = registered via an edge/gateway; the others are
            discovered inside a connected SaaS platform.
          schema:
            type: string
            enum:
              - runtime
              - github
              - salesforce
              - m365
              - snowflake
        - name: governance_state
          in: query
          description: Filter by governance lifecycle state. Omitted ⇒ the server default (`governed`).
            Invalid values return 400.
          schema:
            type: string
            enum:
              - discovered
              - governed
              - suppressed
        - name: execution_coverage
          in: query
          description: Filter by execution-coverage state. Omitted ⇒ no filter. Invalid values return 400.
            Evaluated against the same computation the list items carry in
            `execution_coverage_state`, so a filtered list and the badges it shows always agree.
          schema:
            $ref: "#/components/schemas/ExecutionCoverageState"
        - name: lifecycle_status
          in: query
          description: Filter by governance lifecycle state — the human-owned approval axis. Omitted ⇒ no
            filter. Invalid values return 400.
          schema:
            $ref: "#/components/schemas/AgentLifecycleStatus"
        - $ref: "#/components/parameters/PageSize"
        - $ref: "#/components/parameters/PageToken"
      responses:
        "200":
          description: Agent list
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AgentList"
    post:
      tags:
        - Agents
      summary: Register a new agent
      description: Creates an agent and generates a secret key. The key is returned once and cannot be
        retrieved again. Under a lifecycle gate of `alert` or `enforce` the agent is created
        `proposed` with its onboarding review open, requested by the caller; under `off` it is
        created `approved`. With an API key bound to an environment, a request that names no
        environment is placed in the key's, and one that names another environment is refused 403.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/AgentCreate"
      responses:
        "201":
          description: Agent created. Returns the one-time agent key.
          content:
            application/json:
              schema:
                type: object
                properties:
                  agent:
                    $ref: "#/components/schemas/Agent"
                  agent_key:
                    type: string
                    description: One-time display. Store securely.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
  /agents/{agent_id}:
    get:
      tags:
        - Agents
      summary: Get agent details
      description: An agent the caller cannot see answers 404, as an unknown id does. A user whose access
        scope is `scoped` sees only the agents granted to them, and an API key bound to an
        environment only that environment's agents.
      parameters:
        - name: agent_id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: Agent details
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Agent"
        "404":
          $ref: "#/components/responses/NotFound"
    put:
      tags:
        - Agents
      summary: Update an agent
      description: "Updates the mutable agent fields. Every field is optional and an omitted field is LEFT
        UNCHANGED: the body does not replace the resource, although the method is PUT. An agent the
        caller cannot see answers 404, as an unknown id does. A user whose access scope is `scoped`
        sees only the agents granted to them, and an API key bound to an environment only that
        environment's agents."
      parameters:
        - name: agent_id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/AgentUpdate"
      responses:
        "200":
          description: Updated agent
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Agent"
        "404":
          $ref: "#/components/responses/NotFound"
    delete:
      tags:
        - Agents
      summary: Archive an agent
      description: An agent the caller cannot see answers 404, as an unknown id does. A user whose access
        scope is `scoped` sees only the agents granted to them, and an API key bound to an
        environment only that environment's agents.
      parameters:
        - name: agent_id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "204":
          description: Agent archived
        "404":
          $ref: "#/components/responses/NotFound"
  /agents/{agent_id}/assign-policy:
    post:
      tags:
        - Agents
      summary: Assign a policy to an agent (promote discovered → governed)
      description: "Assign a governance policy to an agent. This is how a SaaS-DISCOVERED agent is
        promoted from `discovered` to `governed`: on success its `governance_state` becomes
        `governed` and its computed `governance_mode` follows the policy's `override_mode`. Also
        usable to re-point a runtime agent at a different policy. The policy must belong to the
        tenant; for a runtime agent it must target the agent's environment (this check is skipped
        for SaaS agents, which have no environment). Not plan-gated. **Members and above only.** An
        agent the caller cannot see answers 404, as an unknown id does. A user whose access scope is
        `scoped` sees only the agents granted to them, and an API key bound to an environment only
        that environment's agents."
      parameters:
        - name: agent_id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/AssignPolicyRequest"
      responses:
        "200":
          description: The updated agent (now governed).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Agent"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
  /agents/{agent_id}/lifecycle:
    post:
      tags:
        - Governance
      summary: Set an agent's governance lifecycle state (manager/owner only)
      description: "Moves the human-owned approval axis directly, outside the review queue — the escape
        hatch an operations team needs when an incident requires `blocked` now. A reason is required
        and the transition is audited as `agent.lifecycle.changed` with the actor, the previous
        state and the reason. Setting the state it already has is a no-op that still returns the
        agent. A direct move to `approved` is the owner's (403 for a manager, and for an API key
        bound to an environment, an owner's key included): an approval otherwise goes through a
        governance review. An agent the caller cannot see answers 404, as an unknown id does. A user
        whose access scope is `scoped` sees only the agents granted to them, and an API key bound to
        an environment only that environment's agents."
      parameters:
        - name: agent_id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - lifecycle_status
                - reason
              properties:
                lifecycle_status:
                  $ref: "#/components/schemas/AgentLifecycleStatus"
                reason:
                  type: string
                  minLength: 1
                  description: Why the state was changed. Recorded verbatim on the audit row and shown in the agent's
                    history. Required.
      responses:
        "200":
          description: Updated agent
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Agent"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
  /agents/{agent_id}/suppress:
    post:
      tags:
        - Agents
      summary: Suppress an agent (governance_state → suppressed)
      description: "Mark an agent as intentionally ignored: sets `governance_state` to `suppressed` so it
        drops out of the default discovery queue. Idempotent — suppressing an already-suppressed,
        cross-tenant, or nonexistent agent is a no-op that still returns 204. No request body. Not
        plan-gated. **Members and above only.** That no-op is what a caller with full access gets. A
        caller who sees only some agents gets 404 for any agent they cannot see, unknown and other
        tenants' ids included: a user whose access scope is `scoped` sees only the agents granted to
        them, and an API key bound to an environment only that environment's agents."
      parameters:
        - name: agent_id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "204":
          description: Agent suppressed (or already suppressed / nothing to do).
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
  /agents/{agent_id}/execution-coverage:
    get:
      tags:
        - Agents
      summary: Execution-coverage posture for one agent
      description: "The full `ExecutionCoverage` object: the computed state, the attested axis (the
        runtime's own claim), the observed axis (what actually reached the edge) and the open
        findings with their remediation. Identical to the `execution_coverage` field of `GET
        /agents/{agent_id}`, offered on its own so a dashboard card can refresh it without
        re-fetching the agent. COMPUTED at read time from the latest runtime attestation, the latest
        execution stats and the open conformance findings — the state is never taken from a stored
        value, so an agent whose edge went silent stops reading `governed` without waiting for
        anything to run. Readable by every role of the tenant, so the operation has no `403`. An
        agent the caller cannot see answers 404, as an unknown id does. A user whose access scope is
        `scoped` sees only the agents granted to them, and an API key bound to an environment only
        that environment's agents."
      parameters:
        - name: agent_id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: The agent's execution-coverage posture.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ExecutionCoverage"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
  /platforms:
    get:
      tags:
        - Platforms
      summary: List connected platforms (with sync health)
      description: Lists the tenant's SaaS platform connections and their per-connection sync health
        (`last_sync_status`, `last_sync_at`, `last_error`). The stored auth secret is never returned
        — only `has_auth_secret`. **Pro plan or higher.**
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Offset"
      responses:
        "200":
          description: Connected-platform list.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ConnectedPlatformList"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          description: Plan upgrade required — SaaS platform connectors are a Pro+ feature.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
    post:
      tags:
        - Platforms
      summary: Connect a SaaS platform
      description: "Create a connection to an external SaaS account (a GitHub org, Salesforce org, or M365
        tenant). The `auth_secret` (e.g. a GitHub App private key) is write-only: it is
        envelope-encrypted at rest with the tenant's dedicated connector DEK and is NEVER returned
        by any endpoint. Connecting stamps `auth_rotated_at` and writes a `platform.connected` audit
        event. A second connection for the same (platform, external account) returns 409. **Pro plan
        or higher; members and above only.** Connecting a platform reaches every agent discovered
        through it. It takes full access: a user whose access scope is `scoped`, or a request made
        with an API key bound to an environment, is refused 403."
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ConnectedPlatformCreate"
      responses:
        "201":
          description: Platform connected.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ConnectedPlatform"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          description: Plan upgrade required — SaaS platform connectors are a Pro+ feature.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "403":
          $ref: "#/components/responses/Forbidden"
        "409":
          description: This platform account is already connected for the tenant.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
  /platforms/github/install-url:
    post:
      tags:
        - Platforms
      summary: Start the GitHub App install flow
      description: "Returns the GitHub App install URL for the single MITRITY App, carrying a signed,
        short-lived state that binds the installation to this tenant. Send the browser there; after
        the app is installed, GitHub returns the browser to MITRITY, which creates the connection
        automatically. Pro plan or higher; members and above. Starting the install is the first step
        of connecting a platform, and takes full access as connecting does: a user whose access
        scope is `scoped`, or a request made with an API key bound to an environment, is refused
        403."
      responses:
        "200":
          description: Install URL
          content:
            application/json:
              schema:
                type: object
                properties:
                  install_url:
                    type: string
                    example: https://github.com/apps/mitrity/installations/new?state=...
        "402":
          description: Pro plan required
        "403":
          $ref: "#/components/responses/Forbidden"
        "503":
          description: GitHub App onboarding is not configured
  /platforms/{id}:
    delete:
      tags:
        - Platforms
      summary: Disconnect a platform
      description: "Remove a platform connection and everything owned by it — its posture findings are
        deleted with it; discovered agents are retained but their `agent_id` link on any finding is
        cleared. Writes a `platform.disconnected` audit event. **Pro plan or higher; members and
        above only.** Disconnecting reaches every agent discovered through the connection. It takes
        full access: a user whose access scope is `scoped`, or a request made with an API key bound
        to an environment, is refused 403."
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "204":
          description: Platform disconnected.
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          description: Plan upgrade required — SaaS platform connectors are a Pro+ feature.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
  /platforms/{id}/findings:
    get:
      tags:
        - Platforms
      summary: List a connection's posture findings
      description: Security-posture findings for a connection, one row per `finding_key` (upserted in
        place on each sync). A finding may be connection-level (`agent_id` absent) or about a
        specific discovered agent. A finding that the connection's latest complete posture pass no
        longer reported is removed — the condition cleared, or the agent it concerned is gone — so
        the list reflects live state; a pass whose posture read failed removes nothing. **Pro plan
        or higher.**
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Offset"
      responses:
        "200":
          description: Posture-finding list.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PostureFindingList"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          description: Plan upgrade required — SaaS platform connectors are a Pro+ feature.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          $ref: "#/components/responses/NotFound"
  /platforms/{id}/sync:
    post:
      tags:
        - Platforms
      summary: Trigger a sync for a connection
      description: "Run the connector for this connection now (synchronously, bounded to ~90s) rather than
        waiting for the scheduled sync job: discover agents, import observed activity, and refresh
        posture findings. Decrypting the stored secret writes a `connector.credential_accessed`
        audit event. Returns a summary of what the run changed. **Pro plan or higher; members and
        above only.** A sync reaches every agent discovered through the connection. It takes full
        access: a user whose access scope is `scoped`, or a request made with an API key bound to an
        environment, is refused 403."
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: Sync completed (see per-connection counts; `status` is `error` if the connector
            reported a failure).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PlatformSyncResult"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          description: Plan upgrade required — SaaS platform connectors are a Pro+ feature.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
  /platforms/{id}/enforcement:
    post:
      tags:
        - Platforms
      summary: Toggle opt-in detect-and-respond enforcement
      description: "Turn the per-connection opt-in enforcement switch on or off. `false` (default) keeps
        the connection strictly read-only. Turning it on only permits platform-mutating response
        actions once the stored credentials also carry write scopes. **Pro plan or higher; members
        and above only.** The switch governs every agent discovered through the connection. It takes
        full access: a user whose access scope is `scoped`, or a request made with an API key bound
        to an environment, is refused 403."
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/EnforcementUpdate"
      responses:
        "200":
          description: The updated connection.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ConnectedPlatform"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          description: Plan upgrade required — SaaS platform connectors are a Pro+ feature.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
  /platforms/agents/{agent_id}/respond:
    post:
      tags:
        - Platforms
      summary: Take a detect-and-respond action against a SaaS-embedded agent
      description: Respond to a discovered SaaS-embedded agent. `alert_only` records the response and
        raises an audited alert (`decision=alerted`, forwarded to SIEM) without touching the
        platform. Platform-mutating actions (`contain`/`uncontain`/`revoke_sessions`) require the
        connection's `enforcement_enabled` opt-in and write-scoped credentials; until the connector
        write path ships they return 409. Applies only to SaaS-embedded agents, not runtime agents.
        **Pro plan or higher; members and above only.** An agent the caller cannot see answers 404,
        as an unknown id does. A user whose access scope is `scoped` sees only the agents granted to
        them, and an API key bound to an environment only that environment's agents.
      parameters:
        - name: agent_id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/RespondRequest"
      responses:
        "201":
          description: The recorded response.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PlatformResponse"
        "400":
          description: Invalid action or body, or the agent is a runtime agent.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          description: Plan upgrade required — SaaS platform connectors are a Pro+ feature.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: Platform-mutating action but enforcement is off for the connection, or the connector
            write path is not yet available.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
  /platforms/agents/{agent_id}/responses:
    get:
      tags:
        - Platforms
      summary: List the response ledger for an agent
      description: The detect-and-respond ledger for one SaaS-embedded agent. **Pro plan or higher.** An
        agent the caller cannot see answers 404, as an unknown id does. A user whose access scope is
        `scoped` sees only the agents granted to them, and an API key bound to an environment only
        that environment's agents.
      parameters:
        - name: agent_id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: The agent's response history.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PlatformResponseList"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          description: Plan upgrade required — SaaS platform connectors are a Pro+ feature.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
  /policies:
    get:
      tags:
        - Policies
      summary: List policies
      operationId: listPolicies
      description: |
        Returns the caller's tenant's policies, newest first by default. Each
        entry embeds its full `rules` array, so a policy and its rules come
        back in one request.

        **Pagination.** Offset-paginated with `limit`/`offset` and the
        `data`/`total`/`limit`/`offset` envelope. The `page_size`/`page_token`
        cursor parameters some other list endpoints take are not accepted
        here.
      parameters:
        - name: environment_id
          in: query
          schema:
            type: string
            format: uuid
          description: Restrict to policies bound to this environment.
        - name: search
          in: query
          schema:
            type: string
          description: Case-insensitive substring match on `name`.
        - name: sort_by
          in: query
          schema:
            type: string
            enum:
              - name
              - created_at
            default: created_at
        - name: sort_order
          in: query
          schema:
            type: string
            enum:
              - asc
              - desc
            default: desc
        - name: limit
          in: query
          schema:
            type: integer
            default: 50
            minimum: 1
            maximum: 200
        - name: offset
          in: query
          schema:
            type: integer
            default: 0
            minimum: 0
      responses:
        "200":
          description: Paginated list of policies
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PolicyList"
        "401":
          $ref: "#/components/responses/Unauthorized"
    post:
      tags:
        - Policies
      summary: Create a policy
      operationId: createPolicy
      description: |
        Creates an environment-scoped policy, optionally with an initial set of
        rules. `(environment_id, name)` is unique. Viewers are rejected with
        403; exceeding the tenant plan's `max_policies` is a 400. Inline
        `rules` are validated exactly like `POST /policies/{policy_id}/rules`
        before the policy row is written: an invalid `tool_pattern` or
        `constraints` document, or a `tool_definition_id` naming a tool
        definition the edge could not enforce a rule bound to (see
        `PolicyRule.tool_definition_id`), is a 422 whose `fields[]` entries
        are named `rules[<i>].tool_pattern` /
        `rules[<i>].constraints.<key>` / `rules[<i>].tool_definition_id`. An
        inline rule naming a tool definition the tenant may not use (another
        tenant's, or an id no definition has) is a 400 naming the rule
        (`rules[<i>]: tool definition not found in this tenant`), and nothing
        is written.

        With an API key bound to an environment, a request that names no
        environment is placed in the key's, and one that names another
        environment is refused 403.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/PolicyCreate"
      responses:
        "201":
          description: Policy created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Policy"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "422":
          $ref: "#/components/responses/UnprocessableConstraints"
  /policies/bulk-delete:
    post:
      tags:
        - Policies
      summary: Delete several policies in one call
      operationId: bulkDeletePolicies
      description: |
        Managers and owners only. Deletes every listed policy that belongs to
        the caller's tenant and returns how many rows were removed; ids that
        do not resolve are silently skipped.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - ids
              properties:
                ids:
                  type: array
                  minItems: 1
                  items:
                    type: string
                    format: uuid
      responses:
        "200":
          description: Deletion count
          content:
            application/json:
              schema:
                type: object
                required:
                  - deleted
                properties:
                  deleted:
                    type: integer
                    format: int64
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
  /policies/{policy_id}:
    get:
      tags:
        - Policies
      summary: Get a policy with its rules
      description: A policy in an environment the caller does not work in answers 404, as an unknown id
        does. A user whose access scope is `scoped` works only in the environments granted to them,
        and an API key bound to an environment only in that one.
      operationId: getPolicy
      parameters:
        - $ref: "#/components/parameters/PolicyID"
      responses:
        "200":
          description: Policy details
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Policy"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
    put:
      tags:
        - Policies
      summary: Update a policy
      operationId: updatePolicy
      description: |
        Partial update — every field is optional and omitted fields keep their
        current value. Rules are NOT updated here; use the `/rules` sub-resource.

        **Verb note.** This is `PUT` with partial-update semantics: the body
        does not replace the policy.

        A policy in an environment the caller does not work in answers 404, as
        an unknown id does. A user whose access scope is `scoped` works only
        in the environments granted to them, and an API key bound to an
        environment only in that one.
      parameters:
        - $ref: "#/components/parameters/PolicyID"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/PolicyUpdate"
      responses:
        "200":
          description: Updated policy
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Policy"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          $ref: "#/components/responses/UnprocessableConstraints"
    delete:
      tags:
        - Policies
      summary: Delete a policy
      operationId: deletePolicy
      description: Managers and owners only. Rules cascade. A policy in an environment the caller does not
        work in answers 404, as an unknown id does. A user whose access scope is `scoped` works only
        in the environments granted to them, and an API key bound to an environment only in that
        one.
      parameters:
        - $ref: "#/components/parameters/PolicyID"
      responses:
        "204":
          description: Policy deleted
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
  /policies/{policy_id}/versions:
    get:
      tags:
        - Policies
      summary: List a policy's version history
      operationId: listPolicyVersions
      description: |
        Every save of a policy — create, update, a rule created, updated or
        deleted, a restore — records an immutable version. Newest first. Each
        item carries `change_summary` (what the version changed relative to
        the one before it) and never the snapshot; fetch one version for its
        document and diff.

        A policy in an environment the caller does not work in answers 404, as
        an unknown id does. A user whose access scope is `scoped` works only
        in the environments granted to them, and an API key bound to an
        environment only in that one.
      parameters:
        - $ref: "#/components/parameters/PolicyID"
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Offset"
      responses:
        "200":
          description: Version history, newest first
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PolicyVersionList"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
  /policies/{policy_id}/versions/{version}:
    get:
      tags:
        - Policies
      summary: Get one policy version with its snapshot and diff
      operationId: getPolicyVersion
      description: |
        The version's full snapshot — the policy document as the API served it
        at that version — and the diff against the version before it. `diff`
        is absent for version 1.

        A policy in an environment the caller does not work in answers 404, as
        an unknown id does. A user whose access scope is `scoped` works only
        in the environments granted to them, and an API key bound to an
        environment only in that one.
      parameters:
        - $ref: "#/components/parameters/PolicyID"
        - $ref: "#/components/parameters/PolicyVersionNumber"
      responses:
        "200":
          description: The version
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PolicyVersionDetail"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
  /policies/{policy_id}/versions/{version}/restore:
    post:
      tags:
        - Policies
      summary: Restore a policy version (manager/owner only)
      operationId: restorePolicyVersion
      description: |
        Makes the policy's current document equal to this version's snapshot
        and records that as a **new** version (`current_version + 1`, origin
        `restored`, `restored_from_version` naming this one). History is never
        rewritten. The policy's fields are applied and its rule set replaced
        with the snapshot's rules, each keeping its original `id`. The document
        is validated by the same validators a `PUT` runs before anything is
        written: a version that no longer passes is refused with `422` naming
        the field — among them a rule bound to a tool definition the tenant
        may no longer use, and an enabled rule bound to a definition the edge
        could not enforce (`rules[<i>].tool_definition_id`, see
        `PolicyRule.tool_definition_id`). A disabled rule bound to such a
        definition is restored, as an update may leave one. A restore
        reports every failure of the document as a 422, including one a
        `PUT` of the rule would answer 400. Audited as `policy.restored`.

        A policy in an environment the caller does not work in answers 404, as
        an unknown id does. A user whose access scope is `scoped` works only
        in the environments granted to them, and an API key bound to an
        environment only in that one.
      parameters:
        - $ref: "#/components/parameters/PolicyID"
        - $ref: "#/components/parameters/PolicyVersionNumber"
      responses:
        "201":
          description: The new version the restore produced
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PolicyVersion"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          $ref: "#/components/responses/UnprocessableConstraints"
  /policies/{policy_id}/rules:
    get:
      tags:
        - Policies
      summary: List the rules of a policy
      description: A policy in an environment the caller does not work in answers 404, as an unknown id
        does. A user whose access scope is `scoped` works only in the environments granted to them,
        and an API key bound to an environment only in that one.
      operationId: listPolicyRules
      parameters:
        - $ref: "#/components/parameters/PolicyID"
      responses:
        "200":
          description: Rules of the policy, priority-ordered
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PolicyRuleList"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
    post:
      tags:
        - Policies
      summary: Add a rule to a policy
      operationId: createPolicyRule
      description: |
        Exactly one of `tool_definition_id` or `tool_pattern` must be set
        (400 otherwise). Viewers are
        rejected with 403. `constraints` is validated against the
        `RuleConstraints` schema; an unknown key or a non-scalar value is a
        422. `tool_pattern` must name its surface (see the property) — a
        glob without a known surface prefix, or with a wildcard in the
        surface position, is a 422 naming `tool_pattern`. A
        `tool_definition_id` naming a tool definition the edge could not
        enforce a rule bound to (see the property) is a 422 naming
        `tool_definition_id`; one the tenant may not use (another tenant's,
        or an id no definition has) is a 400.

        A policy in an environment the caller does not work in answers 404, as
        an unknown id does. A user whose access scope is `scoped` works only
        in the environments granted to them, and an API key bound to an
        environment only in that one.
      parameters:
        - $ref: "#/components/parameters/PolicyID"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/PolicyRuleCreate"
      responses:
        "201":
          description: Rule created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PolicyRule"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          $ref: "#/components/responses/UnprocessableConstraints"
  /policies/{policy_id}/rules/{rule_id}:
    put:
      tags:
        - Policies
      summary: Update a rule
      operationId: updatePolicyRule
      description: |
        Partial update — omitted fields keep their current value. There is no
        single-rule GET; read a rule from the policy's `rules` array or from
        `GET /policies/{policy_id}/rules`. A `tool_pattern` sent here is
        validated exactly as on create (surface prefix, `regex:` syntax) and
        rejected with a 422 naming `tool_pattern`.

        A rule names what it applies to one way. Setting `tool_pattern`
        clears `tool_definition_id` and setting `tool_definition_id` clears
        `tool_pattern`, so the rule is retargeted; an empty string clears its
        target and sets nothing. A request that sets both to non-empty values,
        or leaves the rule with neither, is a 400 (`exactly one of
        tool_definition_id or tool_pattern must be set`); `tool_pattern` with
        `tool_definition_id: ""` is a retarget, not both. The tool definition
        the rule is left bound to — the one the request names or the one the
        rule keeps — is
        checked as on create: a 422 naming `tool_definition_id` when the edge
        could not enforce a rule bound to it, a 400 when the tenant may not
        use it. One exception: an update that keeps the rule's definition and
        leaves the rule disabled (switching it off, or editing it while off)
        is not checked against the edge, so a rule the edge cannot enforce
        can always be taken out of force; a disabled rule is not sent to the
        edge. Naming the definition the rule already has counts as keeping
        it, so a client may send the whole rule. Enabling it again, or
        binding it to another definition, is checked.

        A policy in an environment the caller does not work in answers 404, as
        an unknown id does. A user whose access scope is `scoped` works only
        in the environments granted to them, and an API key bound to an
        environment only in that one.
      parameters:
        - $ref: "#/components/parameters/PolicyID"
        - $ref: "#/components/parameters/RuleID"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/PolicyRuleUpdate"
      responses:
        "200":
          description: Updated rule
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PolicyRule"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          $ref: "#/components/responses/UnprocessableConstraints"
    delete:
      tags:
        - Policies
      summary: Delete a rule
      operationId: deletePolicyRule
      description: Managers and owners only. A policy in an environment the caller does not work in
        answers 404, as an unknown id does. A user whose access scope is `scoped` works only in the
        environments granted to them, and an API key bound to an environment only in that one.
      parameters:
        - $ref: "#/components/parameters/PolicyID"
        - $ref: "#/components/parameters/RuleID"
      responses:
        "204":
          description: Rule deleted
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
  /audit:
    get:
      tags:
        - Audit
      summary: Query audit events
      description: "One page of the organization's audit events, newest first unless
        `sort_by`/`sort_order` say otherwise. Every filter narrows both the page and `total`:
        `total` is the number of events that match every filter on the request, so `ceil(total /
        limit)` is the page count. The enum-valued filters (`decision`, `surface`, `traffic_type`,
        `blocked_by`, `source`, `sandbox_mode`) are matched case-insensitively, and a value outside
        its vocabulary is a 400, not an empty page; `event_type` is an exact match. `sort_by` and
        `sort_order` select the order rather than filter: their enums are the values a client sends,
        and the server, more lenient than the schema, answers any other value with the default order
        rather than a 400. The dashboard's per-environment audit view applies the same filters in
        the same way. The events about people and API keys (`user.*`, `access.*` and `api_key.*`,
        with `source` `control_plane`) are shown to owners and managers only. For a member or a
        viewer they are not among the results, on the page or in `total`, whatever the filters, and
        one of them requested by id answers 404, as an unknown id does. The caller's role decides
        this; no parameter changes it. A scoped caller's page holds only the events of the agents
        granted to them."
      parameters:
        - name: agent_id
          in: query
          schema:
            type: string
            format: uuid
        - name: event_type
          in: query
          description: Exact `event_type`, e.g. `agent_action` or `governance.review.resolved`.
          schema:
            type: string
        - name: decision
          in: query
          description: Case-insensitive.
          schema:
            type: string
            enum:
              - allowed
              - blocked
              - alerted
              - held
              - timed_out
              - observed
        - name: surface
          in: query
          description: Filter by the enforcement surface that produced the decision. Every value of
            `EnforcementSurface` is accepted, `agent_hook` included; anything else is a 400.
          schema:
            $ref: "#/components/schemas/EnforcementSurface"
        - name: blocked_by
          in: query
          description: Filter blocked events by the pipeline component that blocked them. Only meaningful
            together with `decision=blocked`.
          schema:
            $ref: "#/components/schemas/BlockedBy"
        - name: traffic_type
          in: query
          description: >
            Filter by traffic classification. Set by the Mesh Authorizer (surface=mesh_extauthz;
            east_west/a2a/egress) and the LLM Gateway (surface=llm_gateway; always egress).
          schema:
            type: string
            enum:
              - east_west
              - a2a
              - egress
        - name: source
          in: query
          description: "Filter by the origin dimension of the audited activity. Invalid values return 400.
            `control_plane` lists the governance records: what people changed, as opposed to what
            agents did."
          schema:
            $ref: "#/components/schemas/AuditEventSource"
        - name: sandbox_mode
          in: query
          description: Keep events whose governed-shell execution ran under, or was denied with, this sandbox
            mode (matched through the event's command analysis).
          schema:
            $ref: "#/components/schemas/SandboxMode"
        - name: start_date
          in: query
          description: Inclusive lower bound on `occurred_at`. Defaults to 30 days before the request.
          schema:
            type: string
            format: date-time
        - name: end_date
          in: query
          description: Inclusive upper bound on `occurred_at`. Defaults to the time of the request.
          schema:
            type: string
            format: date-time
        - name: sort_by
          in: query
          schema:
            type: string
            enum:
              - occurred_at
              - agent_name
              - decision
              - blocked_by
            default: occurred_at
        - name: sort_order
          in: query
          schema:
            type: string
            enum:
              - asc
              - desc
            default: desc
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Offset"
      responses:
        "200":
          description: Audit events
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AuditEventList"
        "400":
          $ref: "#/components/responses/BadRequest"
  /audit/summary:
    get:
      tags:
        - Audit
      summary: Get audit summary statistics
      description: "The organization's audit trail over a window, with every row in exactly one of four
        figures: the agents' actions an enforcement point decided (`total_events`, which the
        dashboard shows as actions evaluated and a compliance report states for the same period);
        the agents' actions a connector observed on a SaaS platform (`observed_actions`, never
        gated); control-plane governance records (`governance_records`); and anything else, such as
        a connector reading its credentials (`other_records`). An agent's action is a row attributed
        to an agent that carries a decision and is not a control-plane record. The window is the
        list's: `start_date` and `end_date` are both inclusive, so the same pair covers the same
        events on the list and in the summary. The dashboard's per-environment summary states the
        same figures over every row of the environment, without a window. The figures count what the
        caller's list shows: for a member or a viewer, `governance_records` leaves out the access
        audit (`user.*`, `access.*`, `api_key.*`), which is for owners and managers."
      parameters:
        - name: start_date
          in: query
          description: Inclusive lower bound on `occurred_at`. Defaults to 30 days before the request.
          schema:
            type: string
            format: date-time
        - name: end_date
          in: query
          description: Inclusive upper bound on `occurred_at`, as on the list. Defaults to the time of the
            request.
          schema:
            type: string
            format: date-time
      responses:
        "200":
          description: Audit summary
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AuditSummary"
        "400":
          $ref: "#/components/responses/BadRequest"
  /audit/checkpoints:
    get:
      tags:
        - Audit
      summary: List signed audit checkpoints
      description: The organization's signed checkpoints over its audit hash chain, newest first. Each
        checkpoint is an Ed25519-signed statement that, as of `period_end`, the chain ran from
        `first_seq` to `last_seq` and ended in `root_hash`; `statement` is the exact bytes the
        signature covers. Managers and owners only.
      parameters:
        - name: from
          in: query
          description: Keep checkpoints whose period overlaps `[from, to)`.
          schema:
            type: string
            format: date-time
        - name: to
          in: query
          description: Exclusive end of the period; must be after `from`.
          schema:
            type: string
            format: date-time
        - name: limit
          in: query
          schema:
            type: integer
            default: 50
            minimum: 1
            maximum: 200
        - name: offset
          in: query
          schema:
            type: integer
            default: 0
            minimum: 0
      responses:
        "200":
          description: Checkpoints
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AuditCheckpointList"
        "400":
          $ref: "#/components/responses/BadRequest"
        "403":
          $ref: "#/components/responses/Forbidden"
  /audit/checkpoints/public-key:
    get:
      tags:
        - Audit
      summary: Get the checkpoint verifying key
      description: The deployment's Ed25519 public key, with which every checkpoint's `signature` verifies
        over its `statement`. The key is the same for every organization on the deployment and is
        not organization data, so any authenticated user may read it. 404 when the deployment does
        not seal checkpoints.
      responses:
        "200":
          description: The verifying key
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AuditCheckpointPublicKey"
        "404":
          $ref: "#/components/responses/NotFound"
  /audit/verify:
    post:
      tags:
        - Audit
      summary: Verify the audit trail over a period
      description: Recomputes the organization's hash chain over every audit event that occurred in the
        period — live rows and the positions retention retired — checks the links, the checkpoints
        in the walked span and the chain head, and answers `valid`, `broken` or `partial` with the
        first divergent event. The walk extends to the first checkpoint sealed at or after the
        period so the rows are anchored to a signature; a span with no such checkpoint is `partial`.
        At most 250,000 chain positions per call (`truncated`). Managers and owners only. The walk
        covers every row, the access audit included, and the result names rows by chain position and
        id only, never by their content. The verification is itself recorded as an
        `audit.chain.verified` control-plane event.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/AuditVerifyRequest"
      responses:
        "200":
          description: The verification result. The HTTP status is about the request; `status` carries the
            verdict.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AuditVerificationResult"
        "400":
          $ref: "#/components/responses/BadRequest"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/RateLimited"
  /approvals:
    get:
      tags:
        - Approvals
      summary: List approval requests
      parameters:
        - name: status
          in: query
          schema:
            type: string
            enum:
              - pending
              - approved
              - denied
              - timed_out
        - $ref: "#/components/parameters/PageSize"
        - $ref: "#/components/parameters/PageToken"
      responses:
        "200":
          description: Approval list
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApprovalList"
  /approvals/count:
    get:
      tags:
        - Approvals
      summary: Get count of pending approvals
      responses:
        "200":
          description: Pending approval count
          content:
            application/json:
              schema:
                type: object
                properties:
                  count:
                    type: integer
  /approvals/{approval_id}:
    get:
      tags:
        - Approvals
      summary: Get approval details
      description: An approval of an agent the caller cannot see answers 404, as an unknown id does. A
        user whose access scope is `scoped` sees only the agents granted to them, and an API key
        bound to an environment only that environment's agents.
      parameters:
        - name: approval_id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: Approval details
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Approval"
        "404":
          $ref: "#/components/responses/NotFound"
  /approvals/{approval_id}/approve:
    post:
      tags:
        - Approvals
      summary: Approve a pending request (owner or manager)
      description: An approval of an agent the caller cannot see answers 404, and nothing changes. A user
        whose access scope is `scoped` sees only the agents granted to them, and an API key bound to
        an environment only that environment's agents.
      parameters:
        - name: approval_id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                note:
                  type: string
                  description: Optional note explaining the decision
      responses:
        "200":
          description: Approval resolved
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Approval"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: Approval already resolved
  /approvals/{approval_id}/deny:
    post:
      tags:
        - Approvals
      summary: Deny a pending request (owner or manager)
      description: An approval of an agent the caller cannot see answers 404, and nothing changes. A user
        whose access scope is `scoped` sees only the agents granted to them, and an API key bound to
        an environment only that environment's agents.
      parameters:
        - name: approval_id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                note:
                  type: string
                  description: Optional note explaining the decision
      responses:
        "200":
          description: Approval resolved
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Approval"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: Approval already resolved
  /governance/reviews:
    get:
      tags:
        - Governance
      summary: List governance reviews
      description: |
        Returns the tenant's governance reviews, newest first. Every role can
        list; a user whose access scope is `scoped` sees only the reviews whose
        subject agent is covered by that user's resource grants.
      parameters:
        - name: status
          in: query
          schema:
            $ref: "#/components/schemas/GovernanceReviewStatus"
        - name: kind
          in: query
          schema:
            $ref: "#/components/schemas/GovernanceReviewKind"
        - name: subject_id
          in: query
          description: Restrict to one subject (an agent id in this version).
          schema:
            type: string
            format: uuid
        - name: overdue
          in: query
          description: When true, only pending reviews whose `due_at` has passed.
          schema:
            type: boolean
        - name: limit
          in: query
          schema:
            type: integer
            default: 50
            minimum: 1
            maximum: 100
        - name: offset
          in: query
          schema:
            type: integer
            default: 0
            minimum: 0
      responses:
        "200":
          description: Governance review list
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/GovernanceReviewList"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
    post:
      tags:
        - Governance
      summary: Open a governance review
      description: |
        Opens a review for a subject and moves an agent subject to
        `under_review`. Member and above. The requester is the authenticated
        user; `due_at` is derived from the tenant's `review_sla_days`.

        A payload that names no `template_slug` — or no payload at all — is
        answered on the tenant's `default_template_slug`, and the review is
        stored naming it; the shipped `mitrity.default` stands in only when
        that setting is empty or names a template that is no longer there. A
        `template_slug` that names neither the shipped template nor one of the
        tenant's, or is longer than 64 characters, is a 400. A review opened
        without answers carries no `score`, `score_max` or `band` (see
        `GovernanceReview.payload`).

        One OPEN review per subject and fingerprint, where open means `pending`
        or `changes_requested` and the fingerprint is (subject, kind) for a
        manual or scheduled review and (subject, trigger source, trigger code)
        for a trigger-driven one — which is what makes a repeating finding
        idempotent. A second create returns 409 with the existing review in the
        body, so a client can navigate to it rather than guess.

        A terminal review — approved, attested, denied or withdrawn — never
        blocks a new one.

        An agent created or discovered under a gate of `alert` or `enforce`
        already has its onboarding review open, so asking for an `onboarding`
        review of a new agent answers 409 with that review.

        A review of an agent the caller cannot see answers 404, as one of an
        unknown agent does. A user whose access scope is `scoped` sees only
        the agents granted to them, and an API key bound to an environment
        only that environment's agents.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/GovernanceReviewCreateRequest"
      responses:
        "201":
          description: Review opened
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/GovernanceReview"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: An open review already exists for this subject and fingerprint. The body is that
            review, so the client can open it instead.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/GovernanceReview"
        "500":
          description: The agent's record could not be observed. The review is refused rather than stored with
            a silently missing observed column; the client retries.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
  /governance/reviews/count:
    get:
      tags:
        - Governance
      summary: Count pending governance reviews
      description: The sidebar badge. Returns the number of pending reviews visible to the caller and how
        many of those are past `due_at`.
      responses:
        "200":
          description: Pending review counts
          content:
            application/json:
              schema:
                type: object
                required:
                  - count
                  - overdue_count
                properties:
                  count:
                    type: integer
                    description: Pending reviews visible to the caller.
                  overdue_count:
                    type: integer
                    description: Of those, the ones past `due_at`.
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
  /governance/reviews/{review_id}:
    get:
      tags:
        - Governance
      summary: Get a governance review
      description: A review of an agent the caller cannot see answers 404, as an unknown id does. A user
        whose access scope is `scoped` sees only the agents granted to them, and an API key bound to
        an environment only that environment's agents.
      parameters:
        - name: review_id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: Governance review
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/GovernanceReview"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
    put:
      tags:
        - Governance
      summary: Revise an open review
      description: |
        Replaces the assessment answers and the editable descriptors on a
        review that is still open. Only the requester may revise. Revising a
        review that was sent back (`changes_requested`) resubmits it: the status
        returns to `pending`, the subject agent returns to `under_review` and
        `revision` increments. Submitting answers while the subject agent is
        still `proposed` — the onboarding review agent creation opens leaves it
        there — moves it to `under_review`.

        Answers that differ from the ones on file, and every resubmission,
        raise `governance.review_requested`. A `null` payload is an absent
        one: it leaves the answers on file as they are.

        A review with no requester on record — one a finding opened, a
        discovered agent's onboarding review, or one whose requester's
        account has since been deleted — takes no answers: this call answers
        403 for everyone. While it is pending it is decided as it stands — it
        cannot be sent back — or a manager or owner withdraws it and opens one
        of their own, which makes them its requester. One that was sent back
        before its requester's account was deleted is withdrawn by a manager
        or owner: only a pending review resolves.

        A review of an agent the caller cannot see answers 404, as an unknown
        id does. A user whose access scope is `scoped` sees only the agents
        granted to them, and an API key bound to an environment only that
        environment's agents.
      parameters:
        - name: review_id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/GovernanceReviewUpdateRequest"
      responses:
        "200":
          description: Updated review
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/GovernanceReview"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: "The review is not open: it was resolved or withdrawn, possibly while this call was on
            its way — a manager or owner may withdraw a review sent back for changes."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "500":
          description: The agent's record could not be observed. The revision is refused rather than stored
            with a silently missing observed column; the client retries.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
  /governance/reviews/{review_id}/resolve:
    post:
      tags:
        - Governance
      summary: Resolve a governance review (manager/owner only)
      description: |
        Records the reviewer's decision and moves the subject agent:
        `approved` and `attested` → `approved` (and set `next_review_due_at`
        from the tenant's `periodic_review_days`), `changes_requested` →
        `changes_requested`, `denied` → `blocked`.

        `attested` requires a non-empty `attestation_text`, which is stored
        verbatim and reproduced in reports; sending it with any other
        disposition is a 400. Onboarding and periodic reviews require an
        attestation.

        Separation of duties: resolving one's own review returns 403 unless the
        tenant has exactly one owner (a pending owner invitation is not one), in
        which case the waiver is recorded on the audit row. A review assigned
        to the owner role takes an owner. An API key bound to an environment
        takes neither owner's part (403), an owner's key included: it resolves
        reviews as a manager's key would. Only a pending review resolves: one already resolved,
        or sent back and waiting for its requester to resubmit it, returns
        409. A sent-back review is decided once it is resubmitted, or a
        manager or owner withdraws it.

        A review with no requester on record cannot be sent back: nobody could
        resubmit it, so `changes_requested` on it returns 409. It is attested,
        approved or denied as it stands, or withdrawn.

        A review opened by a finding also resolves that finding, carrying the
        reviewer and the note across.

        A review of an agent the caller cannot see answers 404, as an unknown
        id does. A user whose access scope is `scoped` sees only the agents
        granted to them, and an API key bound to an environment only that
        environment's agents.
      parameters:
        - name: review_id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - disposition
              properties:
                disposition:
                  type: string
                  enum:
                    - approved
                    - attested
                    - changes_requested
                    - denied
                attestation_text:
                  type: string
                  minLength: 1
                  description: Required and non-empty when the disposition is `attested`; rejected with any other
                    disposition.
                reviewer_note:
                  type: string
                  description: Optional note explaining the decision.
      responses:
        "200":
          description: Review resolved
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/GovernanceReview"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: The review is not pending — already resolved, or sent back and waiting for its
            requester — or the disposition is `changes_requested` on a review with no requester on
            record.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
  /governance/reviews/{review_id}/withdraw:
    post:
      tags:
        - Governance
      summary: Withdraw an open review
      description: |
        Closes an open review — pending, or sent back for changes — without a
        decision. The requester may withdraw their own pending review; a
        manager or owner may withdraw any open review. A review sent back for
        changes is its requester's to revise and resubmit, so only a manager or
        owner withdraws it: that is how it closes once its requester's account
        is gone, since nobody can revise it and only a pending review resolves.
        The subject agent returns to `proposed` from the state the review held
        it in — `under_review` for a pending review, `changes_requested` for a
        sent-back one — and is otherwise left where it is. Audited as
        `governance.review.withdrawn`, with the status the review was withdrawn
        from (`previous_status`); for a sent-back review the row also carries
        the send-back the withdrawal overwrites on the review — who sent it
        back, when, and their note (`sent_back_by`, `sent_back_at`,
        `sent_back_note`). A review that is not open returns 409.

        Who may withdraw depends on the review's status, so the withdrawal
        applies only to the status this call read and checked: a review
        resolved, withdrawn, sent back or resubmitted in between returns 409
        rather than being withdrawn under a rule that was not checked for it.
        Read it again and retry; a resubmitted review is pending, and open.

        A review of an agent the caller cannot see answers 404, as an unknown
        id does. A user whose access scope is `scoped` sees only the agents
        granted to them, and an API key bound to an environment only that
        environment's agents.
      parameters:
        - name: review_id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                reviewer_note:
                  type: string
                  description: Optional note explaining the withdrawal.
      responses:
        "200":
          description: Review withdrawn
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/GovernanceReview"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: The review is not open — already resolved or withdrawn — or its status changed after
            this call read it (see the description), in which case reading it again and retrying
            applies the rule for the status it is in now.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
  /governance/prefill:
    get:
      tags:
        - Governance
      summary: Observe an agent's record for the assessment
      description: |
        Answers every question in the template that names a behavior resolver
        (`prefill_from_behavior`) from the agent's runtime record over a
        lookback window, with the evidence behind each answer. What comes back
        is a PROPOSAL: the server never writes an observed value into a
        review's answers; the person accepts or corrects it on the form, and
        the review records the observed value, the declared value and which
        one was kept (see `GovernanceReview.payload`).

        Confidence is one of two words. `observed` means the window holds data
        that supports the value; `no_data` means it holds nothing on the
        question. There is no word for a guess. A resolver may offer context
        without a value (`value` null with `confidence` `observed`): the
        record shows an agent acts without a person in the loop, not whether
        what it does can be undone, so the autonomy question gets the evidence
        and no proposed option.

        Member and above, like opening a review. A user whose access scope is
        `scoped` can observe only the agents their resource grants cover; any
        other agent is 404, not 403.
      parameters:
        - name: agent_id
          in: query
          required: true
          schema:
            type: string
            format: uuid
        - name: template
          in: query
          description: Template slug. Defaults to the tenant's `default_template_slug`, or the shipped
            template when that is empty or names a template that is no longer there. A tenant fork
            is resolved after the built-in templates.
          schema:
            type: string
        - name: window_days
          in: query
          description: Lookback in days.
          schema:
            type: integer
            minimum: 7
            maximum: 365
            default: 90
      responses:
        "200":
          description: The observation
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AssessmentObservation"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "500":
          description: The agent's record could not be read. Nothing is proposed rather than a partial
            observation.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
  /governance/templates:
    get:
      tags:
        - Governance
      summary: List assessment templates
      description: The tenant's assessment templates. Always includes the template that ships with the
        platform (`mitrity.default`), which is served from the binary rather than stored as a row,
        plus any tenant fork.
      responses:
        "200":
          description: Assessment template list
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AssessmentTemplateList"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
  /governance/templates/{slug}:
    get:
      tags:
        - Governance
      summary: Get an assessment template
      parameters:
        - name: slug
          in: path
          required: true
          schema:
            type: string
          example: mitrity.default
      responses:
        "200":
          description: Assessment template
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AssessmentTemplate"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
    put:
      tags:
        - Governance
      summary: Create or replace a tenant assessment template (owner only)
      description: |
        Writes a tenant-owned template. Only an owner writes one, and not with
        an API key bound to an environment (403). The template that ships with
        the platform cannot be overwritten; writing to a new slug with
        `based_on: mitrity.default` forks it. The body is validated against the
        template grammar — duplicate
        question ids, an unknown question type, or a `conditional_on` that
        references a question that does not exist earlier in the template are
        all 400.

        A tenant template is applied exactly like the shipped one: its answers
        are scored from its own `risk_weight` maps, the answers its questions
        mark with `effects` are checked when submitted and written onto the
        agent on approval, and the `risk_classes` of its answered questions
        seed the risk register on approval. So the body is also refused (400)
        when a `risk_classes` entry is not one of the risk catalog's, when a
        `boolean` question sets an agent field, or when a `single_select` that
        sets `criticality` or `risk_tier` offers an option outside that
        field's values.

        A fork that removes a mapped question makes the controls that depended
        on it unknown; it never makes them covered.
      parameters:
        - name: slug
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/AssessmentTemplateWrite"
      responses:
        "200":
          description: Template written
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AssessmentTemplate"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
  /governance/settings:
    get:
      tags:
        - Governance
      summary: Get the tenant's governance settings
      description: Every role can read. A tenant that has never written settings gets the documented
        defaults, materialized on first read, so the settings page and agent creation always agree
        about the gate.
      responses:
        "200":
          description: Governance settings
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/GovernanceSettings"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
    put:
      tags:
        - Governance
      summary: Update the tenant's governance settings (owner only)
      description: Changes the lifecycle gate and the other governance settings of the tenant. Only an
        owner changes them, and not with an API key bound to an environment (403), an owner's key
        included.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/GovernanceSettingsUpdate"
      responses:
        "200":
          description: Updated settings
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/GovernanceSettings"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
  /agents/{agent_id}/regenerate-key:
    post:
      tags:
        - Agents
      summary: Regenerate an agent's secret key
      description: Invalidates the current key and returns a new one-time secret. An agent the caller
        cannot see answers 404, as an unknown id does. A user whose access scope is `scoped` sees
        only the agents granted to them, and an API key bound to an environment only that
        environment's agents.
      parameters:
        - name: agent_id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: New agent key generated. Returns the one-time secret.
          content:
            application/json:
              schema:
                type: object
                properties:
                  agent:
                    $ref: "#/components/schemas/Agent"
                  agent_key:
                    type: string
                    description: One-time display. Store securely.
        "404":
          $ref: "#/components/responses/NotFound"
  /agents/{agent_id}/revoke:
    post:
      tags:
        - Agents
      summary: Revoke an agent
      description: Sets agent status to revoked and invalidates its key. An agent the caller cannot see
        answers 404, as an unknown id does. A user whose access scope is `scoped` sees only the
        agents granted to them, and an API key bound to an environment only that environment's
        agents.
      parameters:
        - name: agent_id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: Agent revoked
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Agent"
        "404":
          $ref: "#/components/responses/NotFound"
  /billing/subscription:
    get:
      tags:
        - Billing
      summary: Get current subscription details
      responses:
        "200":
          description: Subscription info
          content:
            application/json:
              schema:
                type: object
                properties:
                  plan:
                    type: string
                    enum:
                      - starter
                      - pro
                      - enterprise
                  billing_interval:
                    type: string
                    enum:
                      - monthly
                      - quarterly
                      - yearly
                    description: How often the subscription is invoiced. Quarterly is available under an enterprise
                      agreement.
                  managed_by_contract:
                    type: boolean
                    description: True when the subscription is managed under an enterprise agreement with MITRITY. Such
                      a subscription is changed through MITRITY sales, not through checkout, and the
                      billing portal shows its invoices and payment details without plan changes or
                      cancellation.
                  status:
                    type: string
                  current_period_end:
                    type: string
                    format: date-time
                  cancel_at_period_end:
                    type: boolean
                    description: True when the subscription ends at `current_period_end` instead of renewing.
                  usage:
                    type: object
                    properties:
                      agents:
                        type: object
                        properties:
                          used:
                            type: integer
                          limit:
                            type: integer
                      users:
                        type: object
                        properties:
                          used:
                            type: integer
                          limit:
                            type: integer
  /billing/checkout:
    post:
      tags:
        - Billing
      summary: Create Stripe Checkout session for new subscription
      security:
        - BearerAuth: []
      description: |
        Creates a Stripe Checkout Session and returns the hosted checkout URL.
        Requires authentication. The authenticated user's tenant receives the subscription.
        Both starter and pro plans are valid — all plans start with a 14-day free trial.
        Only an owner may (403 otherwise), and not with an API key bound to an
        environment (403).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateCheckoutSessionRequest"
      responses:
        "200":
          description: Checkout session URL
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CreateCheckoutSessionResponse"
        "400":
          description: Invalid plan or interval
        "409":
          description: The tenant already has an active subscription (`already_subscribed`), or its
            subscription is managed under an enterprise agreement with MITRITY and is changed
            through MITRITY sales (`enterprise_contract`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
  /billing/portal:
    get:
      tags:
        - Billing
      summary: Open the billing portal
      description: A link to a new billing portal session, where the tenant's payment method, invoices and
        subscription are managed. The session returns to the dashboard's billing page. Only an owner
        may open it (403 otherwise), and not with an API key bound to an environment (403). A tenant
        without a billing account, which its first checkout creates, is refused 400. Under an
        enterprise agreement the portal shows the invoices and the payment details, and offers no
        plan change or cancellation.
      responses:
        "200":
          description: The portal session's link.
          content:
            application/json:
              schema:
                type: object
                required:
                  - url
                properties:
                  url:
                    type: string
                    format: uri
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "503":
          description: The billing portal is not available for this organization right now
            (`billing_portal_unavailable`); nothing was created.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
  /notifications/preferences:
    get:
      tags:
        - Notifications
      summary: Get notification preferences
      responses:
        "200":
          description: Preferences
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/NotificationPreferences"
    put:
      tags:
        - Notifications
      summary: Update notification preferences
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/NotificationPreferences"
      responses:
        "200":
          description: Updated preferences
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/NotificationPreferences"
  /notifications/user-preferences:
    get:
      tags:
        - Notifications
      summary: Get per-user notification preferences
      responses:
        "200":
          description: User preferences
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/UserNotificationPreferences"
    put:
      tags:
        - Notifications
      summary: Update per-user notification preferences
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/UpdateUserNotificationPreferencesRequest"
      responses:
        "200":
          description: Updated user preferences
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/UserNotificationPreferences"
  /notifications/types:
    get:
      tags:
        - Notifications
      summary: Notification-type catalog
      description: |
        Every notification type the platform can emit, with display metadata
        and default channels. The dashboard's Settings (tenant channel matrix)
        and Profile (per-user bell mutes) pages render their per-type toggles
        from this, so a new type appears in both automatically. It changes
        only with a release; any authenticated tenant user may read it.
      responses:
        "200":
          description: The registered notification types
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/NotificationTypeInfo"
                  total:
                    type: integer
  /notifications:
    get:
      tags:
        - Notifications
      summary: List notification inbox events for the caller's tenant
      description: |
        Returns events ordered newest-first with per-caller read state
        attached. Use `unread_only=true` for the bell-dropdown query;
        omit it for the full-page list view.
      parameters:
        - in: query
          name: limit
          schema:
            type: integer
            minimum: 1
            maximum: 200
            default: 50
        - in: query
          name: cursor
          description: |
            Opaque cursor from a previous response's `next_cursor`. Omit
            on the first page. Cursors are tenant- and caller-scoped.
          schema:
            type: string
        - in: query
          name: unread_only
          schema:
            type: boolean
            default: false
      responses:
        "200":
          description: Notification page
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/NotificationEventPage"
  /notifications/unread-count:
    get:
      tags:
        - Notifications
      summary: Unread notification count for the caller
      description: |
        A lightweight count for the notification bell's badge, cheap enough
        to poll; the dashboard polls it about every 30 seconds.
      responses:
        "200":
          description: Unread count
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/NotificationUnreadCount"
  /notifications/{eventId}/read:
    post:
      tags:
        - Notifications
      summary: Mark one notification as read for the caller
      description: For a caller who sees only some agents, a notification that names an agent they cannot
        see, or announces an organization-wide report, answers 404 and is not marked. A user whose
        access scope is `scoped` sees only the agents granted to them, and an API key bound to an
        environment only that environment's agents.
      parameters:
        - in: path
          name: eventId
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "204":
          description: Marked read (idempotent — re-marking is a no-op)
        "404":
          description: Event not found in caller's tenant
  /notifications/read-all:
    post:
      tags:
        - Notifications
      summary: Mark all visible notifications as read for the caller
      description: |
        Bulk mark-as-read for every event currently in the caller's
        tenant inbox. Idempotent. Implementations may exclude events
        that are already read — the operation is "set unread count to 0,"
        not "rewrite history."

        A caller who sees only some agents marks only the notifications
        they can see: one that names an agent they cannot see, or announces
        an organization-wide report, stays unread for whoever sees it. A
        user whose access scope is `scoped` sees only the agents granted to
        them, and an API key bound to an environment only that environment's
        agents.
      responses:
        "200":
          description: Marked-read summary
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/NotificationMarkAllReadResult"
  /siem-destinations:
    get:
      tags:
        - Notifications
      summary: List SIEM and webhook destinations
      description: "The organization's destinations, newest first, a page at a time. Every role can list
        them. A destination receives the SIEM forwarding stream: each audited decision whose kind it
        forwards (`forward_blocked`, `forward_alerted`, `forward_allowed`; a decision that is
        neither blocked nor allowed follows `forward_alerted`). It also receives every notification
        kind whose `default_channels`, or the organization's `type_channel_prefs`, list `webhook`,
        as one `NotificationWebhookEnvelope` per event per destination; the model-risk and AIBOM
        drift advisories keep their own richer envelopes. A `syslog_cef` destination receives the
        forwarding stream only, since the notification channel is HTTP. The stored Authorization
        header is never returned; `has_auth_header` says whether one is set."
      parameters:
        - name: page
          in: query
          description: The page to return, from 1. A value that is not a positive number is read as 1.
          schema:
            type: integer
            minimum: 1
            default: 1
        - name: page_size
          in: query
          description: Destinations per page. A value that is not a positive number is read as 50, and a
            larger one than 200 as 200.
          schema:
            type: integer
            minimum: 1
            maximum: 200
            default: 50
      responses:
        "200":
          description: One page of destinations.
          content:
            application/json:
              schema:
                type: object
                required:
                  - destinations
                  - total
                  - limit
                  - offset
                properties:
                  destinations:
                    type: array
                    items:
                      $ref: "#/components/schemas/WebhookDestination"
                  total:
                    type: integer
                    format: int64
                    description: Every destination of the organization.
                  limit:
                    type: integer
                    description: The page size the request was answered with.
                  offset:
                    type: integer
                    description: The rows skipped before this page.
        "401":
          $ref: "#/components/responses/Unauthorized"
    post:
      tags:
        - Notifications
      summary: Create a SIEM or webhook destination (Splunk HEC, syslog CEF, webhook, custom)
      description: Adds a destination. Only an owner or a manager may (403 otherwise). A destination
        belongs to the organization, not to an environment, and receives the events of every
        environment. The Authorization header is stored encrypted and is never returned.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/WebhookDestinationCreate"
      responses:
        "201":
          description: The new destination.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WebhookDestination"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
  /siem-destinations/{id}:
    parameters:
      - name: id
        in: path
        required: true
        description: A value that is not a UUID is refused 400.
        schema:
          type: string
          format: uuid
    get:
      tags:
        - Notifications
      summary: Get a SIEM or webhook destination
      description: One destination of the organization. Every role can read it. The stored Authorization
        header is never returned; `has_auth_header` says whether one is set.
      responses:
        "200":
          description: The destination.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WebhookDestination"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
    put:
      tags:
        - Notifications
      summary: Update a SIEM or webhook destination
      description: |
        Replaces the destination's settings: send every field, as on create,
        since a flag left out is read as `false`. Only an owner or a manager
        may (403 otherwise).

        `auth_header` is the exception, with three states. Leave it out, or
        send `null`, and the stored header is kept, so a rename or a change of
        filters does not drop it. Send an empty string to remove it, or a new
        value to replace it. A stored header is never sent to a new endpoint:
        a request that changes `endpoint_url` while a header is stored must
        send `auth_header` again, or an empty string if the new endpoint needs
        none, and is refused 400 otherwise, with nothing changed.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/WebhookDestinationCreate"
      responses:
        "200":
          description: The updated destination.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WebhookDestination"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
    delete:
      tags:
        - Notifications
      summary: Delete a SIEM or webhook destination
      description: Removes the destination; nothing more is delivered to it. Only an owner or a manager
        may (403 otherwise).
      responses:
        "204":
          description: The destination was deleted.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
  /tools:
    get:
      tags:
        - Tools
      summary: List tool definitions for the tenant
      description: Returns built-in and custom tool definitions.
      responses:
        "200":
          description: List of tool definitions
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/ToolDefinition"
    post:
      tags:
        - Tools
      summary: Create a custom tool definition
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateToolDefinitionRequest"
      responses:
        "201":
          description: Tool definition created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ToolDefinition"
  /tools/{tool_id}:
    get:
      tags:
        - Tools
      summary: Get a tool definition
      parameters:
        - name: tool_id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: Tool definition
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ToolDefinition"
    put:
      tags:
        - Tools
      summary: Update a custom tool definition
      description: "Changes one of the tenant's own tool definitions. Send only the fields to change: a
        field left out keeps its stored value, `match_rules` replaces the stored rules whole, and
        `operations` sent empty keeps the stored operations. A built-in tool, and a tool of the
        shared catalog, cannot be changed (403). An owner, a manager or a member may change one; a
        viewer is refused 403."
      parameters:
        - name: tool_id
          in: path
          required: true
          description: A value that is not a UUID is refused 400.
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/UpdateToolDefinitionRequest"
      responses:
        "200":
          description: The updated tool definition.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ToolDefinition"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
    delete:
      tags:
        - Tools
      summary: Delete a custom tool definition
      description: Only custom (non-built-in) tools can be deleted.
      parameters:
        - name: tool_id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "204":
          description: Tool definition deleted
  /agents/{agent_id}/permissions:
    get:
      tags:
        - Tools
      summary: List tool permissions for an agent
      description: An agent the caller cannot see answers 404, as an unknown id does. A user whose access
        scope is `scoped` sees only the agents granted to them, and an API key bound to an
        environment only that environment's agents.
      parameters:
        - name: agent_id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: List of tool permissions
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/ToolPermission"
        "404":
          $ref: "#/components/responses/NotFound"
    put:
      tags:
        - Tools
      summary: Bulk set tool permissions for an agent
      description: Replaces all tool permissions for the agent. Upserts each entry. An agent the caller
        cannot see answers 404, as an unknown id does. A user whose access scope is `scoped` sees
        only the agents granted to them, and an API key bound to an environment only that
        environment's agents.
      parameters:
        - name: agent_id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: array
              items:
                $ref: "#/components/schemas/SetToolPermissionRequest"
      responses:
        "200":
          description: Tool permissions updated
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/ToolPermission"
        "404":
          $ref: "#/components/responses/NotFound"
  /agents/{agent_id}/permissions/{permission_id}:
    delete:
      tags:
        - Tools
      summary: Delete a single tool permission grant
      description: |
        Removes one `tool_permissions` row scoped by (id, tenant_id, agent_id).
        Used by the Capabilities CRUD UI to revoke individual grants without
        having to PUT the full permission set back (the bulk PUT is upsert-only
        and cannot express a delete).

        The query joins on tenant + agent so a `permission_id` that exists in
        another tenant or against a different agent returns 404 — not 403 —
        to avoid leaking existence across boundaries via a discriminated error.

        An agent the caller cannot see answers 404, as an unknown id does. A
        user whose access scope is `scoped` sees only the agents granted to
        them, and an API key bound to an environment only that environment's
        agents.
      parameters:
        - name: agent_id
          in: path
          required: true
          schema:
            type: string
            format: uuid
        - name: permission_id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "204":
          description: Permission deleted
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
  /agents/{agent_id}/destinations:
    get:
      tags:
        - DLP
      summary: List destination allowlist entries for an agent
      description: An agent the caller cannot see answers 404, as an unknown id does. A user whose access
        scope is `scoped` sees only the agents granted to them, and an API key bound to an
        environment only that environment's agents. For a caller with full access, an agent outside
        the tenant (another tenant's agent, or an id no agent has) lists no entries.
      parameters:
        - name: agent_id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: Destination allowlist entries
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/DestinationAllowlistEntry"
        "404":
          $ref: "#/components/responses/NotFound"
    post:
      tags:
        - DLP
      summary: Add a destination to the agent's allowlist
      description: An agent the caller cannot see answers 404, as an unknown id does. A user whose access
        scope is `scoped` sees only the agents granted to them, and an API key bound to an
        environment only that environment's agents. An agent outside the caller's tenant (another
        tenant's agent, or an id no agent has) answers the same 404 for every caller, and no entry
        is written.
      parameters:
        - name: agent_id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - domain_pattern
              properties:
                domain_pattern:
                  type: string
                  description: Glob pattern, e.g. '*.internal.company.com'
      responses:
        "201":
          description: Destination added
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DestinationAllowlistEntry"
        "404":
          $ref: "#/components/responses/NotFound"
  /agents/{agent_id}/destinations/{destination_id}:
    delete:
      tags:
        - DLP
      summary: Remove a destination from the agent's allowlist
      description: An agent the caller cannot see answers 404, as an unknown id does. A user whose access
        scope is `scoped` sees only the agents granted to them, and an API key bound to an
        environment only that environment's agents. For a caller with full access, an agent outside
        the tenant (another tenant's agent, or an id no agent has) answers 404, as a destination of
        another agent does, and nothing is removed.
      parameters:
        - name: agent_id
          in: path
          required: true
          schema:
            type: string
            format: uuid
        - name: destination_id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "204":
          description: Destination removed
        "404":
          $ref: "#/components/responses/NotFound"
  /dlp/patterns:
    get:
      tags:
        - DLP
      summary: List DLP patterns visible to the tenant
      description: |
        Returns built-in patterns (`tenant_id` = null, `is_builtin` = true,
        shared by every tenant) merged with the caller's tenant-authored
        patterns. Built-ins are read-only over this API.
      parameters:
        - name: pattern_type
          in: query
          schema:
            type: string
            enum:
              - regex
              - string_list
              - classifier
          description: |
            Filter by pattern type. `classifier` is reserved for built-ins
            but accepted as a filter so the catalog UI can show it.
        - name: category
          in: query
          schema:
            type: string
            enum:
              - pii
              - credentials
              - competitor
              - internal
              - legal
              - custom
        - name: is_builtin
          in: query
          schema:
            type: boolean
          description: Filter by built-in vs tenant-authored.
        - name: search
          in: query
          schema:
            type: string
          description: Case-insensitive substring match on `name`.
        - name: sort_by
          in: query
          schema:
            type: string
            enum:
              - name
              - created_at
            default: name
        - name: sort_order
          in: query
          schema:
            type: string
            enum:
              - asc
              - desc
            default: asc
        - name: limit
          in: query
          schema:
            type: integer
            default: 50
            minimum: 1
            maximum: 200
        - name: offset
          in: query
          schema:
            type: integer
            default: 0
            minimum: 0
      responses:
        "200":
          description: Paginated list of DLP patterns
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DLPPatternList"
        "401":
          $ref: "#/components/responses/Unauthorized"
    post:
      tags:
        - DLP
      summary: Create a custom DLP pattern
      description: |
        Creates a tenant-owned DLP pattern. Validation rules enforced at the
        API boundary:

        - `name` is 1–128 characters and unique per tenant (case-sensitive).
        - `pattern_type` must be `regex` or `string_list`. `classifier` is
          reserved for built-ins and returns 400 if a tenant tries to author one.
        - Regex patterns are compile-tested AND ReDoS-fuzz-tested at save
          (10 KB synthetic input, 100 ms timeout); a pattern that doesn't
          return inside the budget is rejected with 400.
        - The request body must NOT contain any field named `value`,
          `matched_text`, or `matched_content` at any depth. The DLP audit
          contract is built on the absence of raw matched content; a buggy
          client that sends one of these fields gets a 400 so the mistake
          is caught at the boundary instead of being silently dropped.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateDLPPatternRequest"
      responses:
        "201":
          description: Pattern created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DLPPattern"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
  /dlp/patterns/{id}:
    get:
      tags:
        - DLP
      summary: Get a single DLP pattern
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: DLP pattern
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DLPPattern"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
    put:
      tags:
        - DLP
      summary: Update a custom DLP pattern
      description: |
        Built-in patterns are read-only — attempting to update one returns 403.
        All fields are optional; only the supplied fields are changed. The
        same validation rules as POST apply (name length / uniqueness,
        classifier rejection, ReDoS fuzz, reserved-field rejection).
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/UpdateDLPPatternRequest"
      responses:
        "200":
          description: Pattern updated
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DLPPattern"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: Built-in pattern is read-only, or caller lacks permission
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          $ref: "#/components/responses/NotFound"
    delete:
      tags:
        - DLP
      summary: Delete a custom DLP pattern
      description: Built-in patterns cannot be deleted — returns 403.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "204":
          description: Pattern deleted
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: Built-in pattern is read-only, or caller lacks permission
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          $ref: "#/components/responses/NotFound"
  /policies/{policy_id}/dlp-bindings:
    get:
      tags:
        - DLP
      summary: List DLP pattern bindings for a policy
      description: |
        Returns every (pattern, policy) binding owned by the caller's tenant
        for the given policy. Each binding carries its own direction + action
        + priority + enabled flag and denormalises a small slice of the
        pattern's columns, so a list can be shown without fetching each
        pattern.

        The response wraps the array in a `{ "bindings": [...] }` envelope.

        A policy in an environment the caller does not work in answers 404, as
        an unknown id does. A user whose access scope is `scoped` works only
        in the environments granted to them, and an API key bound to an
        environment only in that one.
      parameters:
        - name: policy_id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: List of bindings (wrapped)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DLPBindingList"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
    post:
      tags:
        - DLP
      summary: Bind a DLP pattern to a policy
      description: |
        Binds a DLP pattern to the policy. The pattern must be visible to
        the caller's tenant (built-in or tenant-owned); cross-tenant
        `pattern_id` values return 404. A pattern is bound to a policy at most
        once: a duplicate binding returns 409. The reserved-field rejection
        (`value` / `matched_text` / `matched_content`) applies here too.

        A policy in an environment the caller does not work in answers 404, as
        an unknown id does. A user whose access scope is `scoped` works only
        in the environments granted to them, and an API key bound to an
        environment only in that one.
      parameters:
        - name: policy_id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateDLPBindingRequest"
      responses:
        "201":
          description: Binding created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DLPBinding"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: A binding for (policy_id, pattern_id) already exists
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
  /policies/{policy_id}/dlp-bindings/{binding_id}:
    put:
      tags:
        - DLP
      summary: Update a DLP pattern binding
      description: |
        Updates the per-binding enforcement settings (direction, action,
        priority, enabled). The pattern itself is not changed by this call —
        re-pointing a binding at a different pattern requires DELETE + POST.

        A policy in an environment the caller does not work in answers 404, as
        an unknown id does. A user whose access scope is `scoped` works only
        in the environments granted to them, and an API key bound to an
        environment only in that one.
      parameters:
        - name: policy_id
          in: path
          required: true
          schema:
            type: string
            format: uuid
        - name: binding_id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/UpdateDLPBindingRequest"
      responses:
        "200":
          description: Binding updated
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DLPBinding"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
    delete:
      tags:
        - DLP
      summary: Remove a DLP pattern binding from a policy
      description: A policy in an environment the caller does not work in answers 404, as an unknown id
        does. A user whose access scope is `scoped` works only in the environments granted to them,
        and an API key bound to an environment only in that one.
      parameters:
        - name: policy_id
          in: path
          required: true
          schema:
            type: string
            format: uuid
        - name: binding_id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "204":
          description: Binding removed
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
  /injection-events:
    get:
      tags:
        - InjectionDetection
      summary: List injection detection events
      parameters:
        - name: agent_id
          in: query
          schema:
            type: string
            format: uuid
          description: Filter by agent ID
        - name: detection_method
          in: query
          schema:
            type: string
            enum:
              - pattern
              - statistical
              - ml_classifier
              - combined
          description: Filter by detection method
        - $ref: "#/components/parameters/PageSize"
        - $ref: "#/components/parameters/PageToken"
      responses:
        "200":
          description: Injection events
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/InjectionEvent"
  /injection-events/summary:
    get:
      tags:
        - InjectionDetection
      summary: Get injection event summary statistics
      responses:
        "200":
          description: Aggregated injection event statistics
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/InjectionEventSummary"
  /injection-events/{id}:
    get:
      tags:
        - InjectionDetection
      summary: Get a single injection event
      description: An event of an agent the caller cannot see answers 404, as an unknown id does. A user
        whose access scope is `scoped` sees only the agents granted to them, and an API key bound to
        an environment only that environment's agents.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: Injection event details
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/InjectionEvent"
        "404":
          $ref: "#/components/responses/NotFound"
  /injection-events/{id}/false-positive:
    post:
      tags:
        - InjectionDetection
      summary: Mark an injection event as a false positive
      description: An event of an agent the caller cannot see answers 404, and nothing changes. A user
        whose access scope is `scoped` sees only the agents granted to them, and an API key bound to
        an environment only that environment's agents.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: Event marked as false positive
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/InjectionEvent"
        "404":
          $ref: "#/components/responses/NotFound"
  /delegation-chains:
    get:
      tags:
        - DelegationChains
      summary: List delegation chains
      parameters:
        - name: agent_id
          in: query
          schema:
            type: string
            format: uuid
          description: Filter by agent ID (any hop in chain)
        - name: status
          in: query
          schema:
            type: string
            enum:
              - active
              - completed
              - blocked
              - timed_out
          description: Filter by chain status
        - $ref: "#/components/parameters/PageSize"
        - $ref: "#/components/parameters/PageToken"
      responses:
        "200":
          description: Delegation chains
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/DelegationChain"
  /delegation-chains/summary:
    get:
      tags:
        - DelegationChains
      summary: Get delegation chain summary statistics
      responses:
        "200":
          description: Aggregated delegation statistics
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DelegationSummary"
  /delegation-chains/{chain_id}:
    get:
      tags:
        - DelegationChains
      summary: Get a delegation chain with all hops
      description: "A chain answers 404, as an unknown id does, unless the caller can see every agent in
        it: its initiator and both ends of every hop. A user whose access scope is `scoped` sees
        only the agents granted to them, and an API key bound to an environment only that
        environment's agents."
      parameters:
        - name: chain_id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Delegation chain with hops
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DelegationChainDetail"
        "404":
          $ref: "#/components/responses/NotFound"
  /delegation-chains/evaluate:
    post:
      tags:
        - DelegationChains
      summary: Evaluate a proposed continuation delegate_to hop (or probe an A2A downgrade)
      description: |
        Called by the edge node on continuation `delegate_to` hops (when the
        delegating agent is not the chain root). The control plane loads the
        chain recorded under `chain_id`, appends the proposed hop, runs the
        delegation engine against the cumulative chain (cycle / depth /
        privilege-escalation / fan-out / unauthorized-delegate checks), and
        returns a decision. The hop is recorded, whether the decision allows
        or blocks it, before the response is sent.

        Root invocations (delegator missing or equal to current agent) are
        evaluated locally on the edge node and do NOT call this endpoint.

        **A2A downgrade probe.** The delegation context (`chain_id`) arrives at
        the edge as a forgeable `x-mitrity-delegation-chain-id` header that a
        malicious agent could drop to suppress chain-state enforcement
        (cycle / depth / privilege checks). When the edge sees an agent→agent
        hop with NO chain-id header it sends a *probe* call: `chain_presented`
        is `false` and `chain_id` is omitted. The control plane — which knows
        the delegation graph (configured delegates + recorded chain history) —
        answers authoritatively whether a chain was *expected* for this
        (`current_agent_id` → `proposed_target_agent_id`) hop via
        `chain_expected`. If a chain was expected but the header was absent the
        decision is `block` with reason `delegation_downgrade_suspected`
        (the edge denies the header-drop downgrade); if no chain was expected
        the decision is `allow` (a legitimate direct A2A call — normal
        mission-scope still applies at the edge). A probe records no hop.

        **A target outside the tenant.** On a chain-state evaluation, a
        `proposed_target_agent_id` that is not an agent of the caller's tenant
        (another tenant's agent, or an id no agent has) answers the same 404
        for both, and no hop or chain is recorded. The edge handles a 404 as it
        handles an unavailable endpoint, and marks the decision
        `chain_state_fallback`: with `fail_mode` `closed` it denies the hop
        (`fallback_reason` `endpoint_unavailable_fail_closed`); with
        `fail_mode` `open` it decides the hop with its local 1-hop evaluation
        alone (`fallback_reason` `endpoint_unavailable`), so the control
        plane's chain checks do not apply to it. A probe never answers 404: it
        answers from the caller's tenant's delegation graph only, so whether
        an id belongs to another tenant or to no agent never changes its
        answer.

        **Bounds.** `chain_id` and `current_action` are recorded with the hop
        (as its `chain_id` and `action_requested`), so each is held to 256
        bytes. A request over either is a 400 naming the field (`chain_id
        must be at most 256 bytes`), on a probe too, and nothing is read or
        recorded. A missing `chain_id` on a chain-state evaluation, a missing
        `current_agent_id` or `proposed_target_agent_id`, or either of those
        two not a UUID is a 400 as well.

        Authenticated with the calling agent's key in the `X-Agent-Key`
        header.
      security:
        - AgentKeyAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/EvaluateDelegationRequest"
      responses:
        "200":
          description: Delegation engine decision (allow / block / alert / hold)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/EvaluateDelegationResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
  /credentials:
    get:
      tags:
        - Credentials
      summary: List credential definitions
      parameters:
        - $ref: "#/components/parameters/PageSize"
        - $ref: "#/components/parameters/PageToken"
      responses:
        "200":
          description: Credential definitions
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/CredentialDefinition"
    post:
      tags:
        - Credentials
      summary: Create a credential definition
      description: "A credential definition is shared by every agent it is granted to, so creating one
        takes full access: a user whose access scope is `scoped`, or a request made with an API key
        bound to an environment, is refused 403. Granting or leasing a definition to an agent the
        caller can see does not take full access."
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateCredentialRequest"
      responses:
        "201":
          description: Credential created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CredentialDefinition"
        "400":
          $ref: "#/components/responses/BadRequest"
        "403":
          $ref: "#/components/responses/Forbidden"
  /credentials/{id}:
    get:
      tags:
        - Credentials
      summary: Get a credential definition
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: Credential details
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CredentialDefinition"
        "404":
          $ref: "#/components/responses/NotFound"
    patch:
      tags:
        - Credentials
      summary: Update a credential definition
      description: "A change applies to every agent that holds a grant on the definition. It takes full
        access: a user whose access scope is `scoped`, or a request made with an API key bound to an
        environment, is refused 403."
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/UpdateCredentialRequest"
      responses:
        "200":
          description: Credential updated
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CredentialDefinition"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
    put:
      tags:
        - Credentials
      summary: Update a credential definition (alias of PATCH)
      description: >
        Accepts the same partial-update body as PATCH. Both verbs are supported; PUT is the
        historical verb used by setup tooling and the dashboard. A change applies to every agent
        that holds a grant on the definition. It takes full access: a user whose access scope is
        `scoped`, or a request made with an API key bound to an environment, is refused 403.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/UpdateCredentialRequest"
      responses:
        "200":
          description: Credential updated
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CredentialDefinition"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
    delete:
      tags:
        - Credentials
      summary: Delete a credential definition
      description: "Deleting a definition removes it for every agent that holds a grant on it. It takes
        full access: a user whose access scope is `scoped`, or a request made with an API key bound
        to an environment, is refused 403."
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "204":
          description: Credential deleted
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
  /credentials/{id}/grants:
    get:
      tags:
        - Credentials
      summary: List grants for a credential
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: Credential grants
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/CredentialGrant"
    post:
      tags:
        - Credentials
      summary: Grant a credential to an agent (idempotent)
      description: >
        Creates or updates a single agent's grant. Re-granting an agent that already has a grant
        updates it in place (allowed_operations + enabled) rather than erroring — 201 on first
        create, 200 on an idempotent update. An `agent_id` the caller cannot see answers 404, as an
        unknown agent does. A user whose access scope is `scoped` sees only the agents granted to
        them, and an API key bound to an environment only that environment's agents.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateCredentialGrantRequest"
      responses:
        "200":
          description: Existing grant updated (idempotent re-grant)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CredentialGrant"
        "201":
          description: Grant created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CredentialGrant"
        "404":
          $ref: "#/components/responses/NotFound"
    delete:
      tags:
        - Credentials
      summary: Revoke credential grants
      description: >
        Without agent_id, revokes ALL grants for the credential (replace-all). With agent_id,
        revokes only that agent's grant. An `agent_id` the caller cannot see answers 404, as an
        unknown agent does, and without `agent_id` a caller who sees only some agents revokes only
        the grants of those agents. A user whose access scope is `scoped` sees only the agents
        granted to them, and an API key bound to an environment only that environment's agents.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
        - name: agent_id
          in: query
          required: false
          description: When set, revoke only this agent's grant; otherwise revoke all.
          schema:
            type: string
            format: uuid
      responses:
        "204":
          description: Grant(s) revoked
        "404":
          $ref: "#/components/responses/NotFound"
  /credentials/{id}/leases:
    get:
      tags:
        - Credentials
      summary: List lease history for a credential
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
        - name: status
          in: query
          schema:
            type: string
            enum:
              - active
              - expired
              - revoked
        - $ref: "#/components/parameters/PageSize"
        - $ref: "#/components/parameters/PageToken"
      responses:
        "200":
          description: Credential leases
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/CredentialLease"
  /credentials/{credential_id}/leases/{lease_id}/revoke:
    post:
      tags:
        - Credentials
      summary: Revoke an active credential lease
      description: A lease of an agent the caller cannot see answers 404, as an unknown lease does. A user
        whose access scope is `scoped` sees only the agents granted to them, and an API key bound to
        an environment only that environment's agents.
      parameters:
        - name: credential_id
          in: path
          required: true
          schema:
            type: string
            format: uuid
        - name: lease_id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: Lease revoked
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CredentialLease"
        "404":
          $ref: "#/components/responses/NotFound"
  /reports:
    get:
      tags:
        - Reports
      summary: List compliance reports
      description: Newest first, offset-paginated. Deleted reports are not listed. An unrecognized
        `report_type` is rejected with 400 rather than silently listing every type.
      parameters:
        - name: report_type
          in: query
          description: Restrict the list to one report type.
          schema:
            type: string
            enum:
              - soc2
              - gdpr
              - iso27001
              - executive
              - inventory
              - ai_assessment
              - framework
        - name: limit
          in: query
          description: Max rows per page. Out-of-range values fall back to the default rather than 400.
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 50
        - name: offset
          in: query
          description: Zero-based row offset. Out-of-range values fall back to 0.
          schema:
            type: integer
            minimum: 0
            maximum: 1000000
            default: 0
      responses:
        "200":
          description: Compliance reports
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/ComplianceReport"
        "400":
          $ref: "#/components/responses/BadRequest"
    post:
      tags:
        - Reports
      summary: Generate a compliance report
      description: >-
        Queues a report and returns it with `status: queued`; a background worker produces it. Poll
        `GET /reports/{id}` until `status` leaves `queued` and `running`. Members and above may
        generate a report; viewers may not. Every type in the vocabulary is produced.


        An `ai_assessment` is everything on file about one agent (`subject_agent_id` is required):
        identity and ownership; use case, criticality and risk tier; the latest questionnaire
        answered, with its answers, template version and advisory score (a questionnaire opened and
        never answered is reported, as not yet assessed, only when none was answered); every decided
        review with the attestation text reproduced verbatim; the risk register; risk and drift over
        the period; execution coverage with the runtime attestation and every finding against the
        agent — conformance, runtime, connector posture, adversarial testing and model scanning —
        that is open, accepted as a risk, or resolved in the period, each with its remediation;
        holds and approvals; the policy in force; adversarial testing by campaign and category; and
        the models the agent called through the gateway with their inventory state. The
        adversarial-testing and models sections count the same findings the coverage section lists.
        The report reads at most 500 of an agent's findings, open first, then accepted as a risk,
        then resolved, most severe first within each, and looks at the next finding to tell where it
        stopped: the read stopped in that finding's status. For an agent with more on file, a figure
        for a status the read covered in full — a status that ends exactly at the bound included —
        is stated exactly. A figure for the status the read stopped in is stated as a floor ("at
        least N") when the read reached some of that status; a figure for any status of which the
        read reached none is stated as not counted. A section with nothing on file states that it is
        not assessed, and the data-integrity section repeats every such statement, so an absence is
        never rendered as a pass.


        Every report counts an agent's actions the same way: a row attributed to an agent, carrying
        a decision, that is not a control-plane governance record. "Actions evaluated" are the ones
        an enforcement point decided; actions a connector observed on a SaaS platform are stated
        beside them, never inside them. The data-integrity section states the period's governance
        records, and any other record that is not an action, apart from the actions, and cites the
        latest signed audit checkpoint — for every report type, the framework report included.


        A `framework` report is one compliance framework, control by control (`framework_slug` is
        required and must name a framework in the compliance library — see `GET
        /compliance/frameworks`; a well-shaped slug the library does not carry is a 400): the
        framework and the edition of the control library it is read through, with the attribution
        and text policy that edition carries; coverage stated as "N of M applicable controls … K not
        assessed", never as a bare percentage, from the same computed posture the framework page
        shows; every control with its state, the reason for that state, its owner and any override a
        person recorded; and the gap list with what would close each gap. Controls out of scope for
        the organization's declared risk tiers are listed as such, not counted as failures. The
        worker refreshes the compliance posture before assembling the report. Every type but
        `ai_assessment` is an organization-wide report and takes full access: a user whose access
        scope is `scoped`, or a request made with an API key bound to an environment, is refused
        403. An `ai_assessment` of an agent the caller cannot see is refused as one of an unknown
        agent is (400). With an API key bound to an environment, a request that names no environment
        is placed in the key's, and one that names another environment is refused 403.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/GenerateReportRequest"
      responses:
        "202":
          description: "Accepted. The report is queued with `status: queued`, an empty `report_hash` and no
            `storage_path`; those are filled in when it is produced."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ComplianceReport"
        "400":
          $ref: "#/components/responses/BadRequest"
        "403":
          $ref: "#/components/responses/Forbidden"
  /reports/{id}:
    get:
      tags:
        - Reports
      summary: Get a compliance report
      description: An organization-wide report, of any type but `ai_assessment`, answers 404 to a caller
        without full access (a user whose access scope is `scoped`, or an API key bound to an
        environment), and an `ai_assessment` answers 404 when its agent is one the caller cannot
        see.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: Report data
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ComplianceReport"
        "404":
          $ref: "#/components/responses/NotFound"
    delete:
      tags:
        - Reports
      summary: Delete a compliance report
      description: "A soft delete, for managers and owners. Compliance evidence is not erased on request:
        the report leaves the list and detail responses and its evidence stays on the row. Deleting
        a report that is already deleted answers 404, the same as one that never existed. An
        organization-wide report, of any type but `ai_assessment`, answers 404 to a caller without
        full access (a user whose access scope is `scoped`, or an API key bound to an environment),
        and an `ai_assessment` answers 404 when its agent is one the caller cannot see."
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "204":
          description: Report deleted
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
  /reports/{id}/download:
    get:
      tags:
        - Reports
      summary: Get a download link for the report PDF
      description: Returns a signed URL, not the file. The URL is valid for ten minutes. A report whose
        PDF has not been rendered answers 200 with a `message` instead, and a deployment without
        report storage configured answers 503. An organization-wide report, of any type but
        `ai_assessment`, answers 404 to a caller without full access (a user whose access scope is
        `scoped`, or an API key bound to an environment), and an `ai_assessment` answers 404 when
        its agent is one the caller cannot see.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: A signed download URL, or a message when no file exists yet
          content:
            application/json:
              schema:
                oneOf:
                  - type: object
                    required:
                      - download_url
                      - expires_in
                    properties:
                      download_url:
                        type: string
                        format: uri
                      expires_in:
                        type: string
                        description: Lifetime of the signed URL in seconds, serialized as a string.
                  - type: object
                    required:
                      - message
                    properties:
                      message:
                        type: string
                        description: Returned instead when the report has no rendered PDF — use `report_data` on the report.
        "404":
          $ref: "#/components/responses/NotFound"
        "503":
          description: Report storage is not configured for this deployment.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
  /reports/{id}/bundle:
    get:
      tags:
        - Reports
      summary: Export a report as an evidence bundle
      description: >-
        One zip an auditor or a GRC tool takes as-is: the report record (`report.json`), every
        record the report cites in section order (`evidence.json`, up to 10,000), the rendered PDF
        when one is on file (`report.pdf`), the newest signed audit checkpoint whose period overlaps
        the report's (`checkpoint.json`, with its statement, signature, key id and the public-key
        route) and the report hash (`hash.txt`). Only a `ready` report can be exported.


        Like the download route, the response is a signed URL to the archive, valid for ten minutes,
        not the file: a request slot is not held for the length of a download. The archive is an
        immutable object named for its inputs (the report and the covering checkpoint), so a bundle
        an auditor already holds is never overwritten; a newer checkpoint produces a new object, and
        asking again for the same inputs describes the existing object rather than rebuilding it.
        Zip entry timestamps are the report's `generated_at`, so the same inputs give the same
        bytes.


        The response also carries the manifest (`bundle`), so a caller can show or file what the
        archive holds without fetching it; the same manifest is inside the archive as
        `manifest.json`. Anything the bundle could not include — no rendered PDF or one no longer on
        file, no checkpoint sealing the period, a report from before hashed reports, a truncated
        evidence list, fewer records on file than the report cites, a checkpoint whose signature
        does not verify against the deployment's key or one the deployment could not check — is a
        sentence in `bundle.gaps`, stated rather than hidden. Every export appends a
        `report.bundle_exported` event to the organization's audit chain carrying the same list.


        Viewers can download a report but not export one: an export writes an object and an audit
        event in the organization's name, so it takes the roles that can generate a report. The
        route is per-call expensive and is rate limited on the stricter per-tenant tier the other
        expensive routes use. An organization-wide report, of any type but `ai_assessment`, answers
        404 to a caller without full access (a user whose access scope is `scoped`, or an API key
        bound to an environment), and an `ai_assessment` answers 404 when its agent is one the
        caller cannot see.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: A signed URL to the archive, and its manifest
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ReportBundleResponse"
        "400":
          description: The report is not `ready` yet, or the id is malformed.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "503":
          description: Report storage is not configured for this deployment.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
  /reports/schedules:
    get:
      tags:
        - Reports
      summary: List report schedules
      description: Recurring reports for this organization, newest first. The generation worker sweeps due
        schedules on the same tick it drains the report queue, so a schedule needs no infrastructure
        of its own — and states a cadence and an hour rather than a cron expression, because the
        sweep is the clock.
      responses:
        "200":
          description: Report schedules
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/ReportSchedule"
    post:
      tags:
        - Reports
      summary: Create a report schedule
      description: "Managers and owners only. The first run is the next occurrence of `hour_utc`, today if
        that hour is still ahead and tomorrow otherwise, so a schedule produces something the day it
        is created. The per-type subject follows the same rule as generation: `subject_agent_id` is
        required for `ai_assessment` and rejected elsewhere, `framework_slug` is required for
        `framework` and rejected elsewhere, and must name a framework in the compliance library (a
        well-shaped slug the library does not carry is a 400, so a schedule never fails at every
        sweep). At most 50 schedules per organization. A schedule for any type but `ai_assessment`
        produces organization-wide reports and takes full access: a user whose access scope is
        `scoped`, or a request made with an API key bound to an environment, is refused 403. An
        `ai_assessment` schedule names an agent the caller can see. With an API key bound to an
        environment, a request that names no environment is placed in the key's, and one that names
        another environment is refused 403."
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ReportScheduleRequest"
      responses:
        "201":
          description: The schedule
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ReportSchedule"
        "400":
          $ref: "#/components/responses/BadRequest"
        "403":
          $ref: "#/components/responses/Forbidden"
  /reports/schedules/{schedule_id}:
    put:
      tags:
        - Reports
      summary: Update a report schedule
      description: "Managers and owners only. `report_type` and the subject are fixed after creation and
        ignored here: a schedule that changed subject would leave a version chain whose earlier
        reports are about something else. `next_run_at` moves only when the cadence or the hour
        changes, or when a disabled schedule is re-enabled — renaming a schedule does not delay its
        next report. A schedule of organization-wide reports answers 404 to a caller without full
        access (a user whose access scope is `scoped`, or an API key bound to an environment), and
        an `ai_assessment` schedule answers 404 when its agent is one the caller cannot see."
      parameters:
        - name: schedule_id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ReportScheduleRequest"
      responses:
        "200":
          description: The updated schedule
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ReportSchedule"
        "400":
          $ref: "#/components/responses/BadRequest"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
    delete:
      tags:
        - Reports
      summary: Delete a report schedule
      description: "Managers and owners only. The reports the schedule has already produced stay: a
        schedule is the instruction, not the evidence. A schedule of organization-wide reports
        answers 404 to a caller without full access (a user whose access scope is `scoped`, or an
        API key bound to an environment), and an `ai_assessment` schedule answers 404 when its agent
        is one the caller cannot see."
      parameters:
        - name: schedule_id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "204":
          description: Schedule deleted
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
  /reports/{id}/shares:
    get:
      tags:
        - Reports
      summary: List the share links on a report
      description: Newest first. A share is identified by its id and an 8-character token prefix; the
        token itself is returned once, at creation, and can never be read back. An organization-wide
        report, of any type but `ai_assessment`, answers 404 to a caller without full access (a user
        whose access scope is `scoped`, or an API key bound to an environment), and an
        `ai_assessment` answers 404 when its agent is one the caller cannot see.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: Share links
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/ReportShare"
        "404":
          $ref: "#/components/responses/NotFound"
    post:
      tags:
        - Reports
      summary: Create a share link
      description: >-
        Managers and owners only, and only on a `ready` report. Returns the share **with its
        token**, which is the one and only time the token exists in a response: the server stores
        its SHA-256 and an 8-character prefix, so a copy of the database cannot open a link and a
        lost token is replaced rather than recovered.


        Every share expires, no more than 90 days out, and may cap its views. There is deliberately
        no password: a password travelling beside the URL in the same message protects nothing, and
        the expiry, the cap and the revoke are the controls that do. At most 20 live links per
        report. An organization-wide report, of any type but `ai_assessment`, answers 404 to a
        caller without full access (a user whose access scope is `scoped`, or an API key bound to an
        environment), and an `ai_assessment` answers 404 when its agent is one the caller cannot
        see.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateReportShareRequest"
      responses:
        "201":
          description: The share, carrying `token` and `share_url`. Neither appears in any later response.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ReportShare"
        "400":
          $ref: "#/components/responses/BadRequest"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
  /reports/{id}/shares/{share_id}/revoke:
    post:
      tags:
        - Reports
      summary: Revoke a share link
      description: Managers and owners only. A revoked link is indistinguishable from one that never
        existed. Revoking a share that is already revoked answers 404 rather than overwriting who
        revoked it and when. An organization-wide report, of any type but `ai_assessment`, answers
        404 to a caller without full access (a user whose access scope is `scoped`, or an API key
        bound to an environment), and an `ai_assessment` answers 404 when its agent is one the
        caller cannot see.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
        - name: share_id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: The revoked share
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ReportShare"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
  /compliance/frameworks:
    get:
      tags:
        - Compliance
      summary: List frameworks with coverage
      description: Every framework library in this build, each with its descriptor (including the
        attribution line every surface must render) and the tenant's coverage figures. Served from
        the stored posture; computed first when nothing is stored yet or the stored posture was
        computed against an older content edition. Any role.
      responses:
        "200":
          description: Frameworks and coverage
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ComplianceFrameworkList"
        "401":
          $ref: "#/components/responses/Unauthorized"
  /compliance/frameworks/recompute:
    post:
      tags:
        - Compliance
      summary: Recompute the tenant's posture
      description: Runs every evidence probe against the tenant's data, rewrites the computed columns of
        every control status and returns the frameworks with fresh coverage. A person's overrides
        and owners are never touched by a recompute. Member and above.
      responses:
        "200":
          description: Frameworks and coverage, just computed
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ComplianceFrameworkList"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
  /compliance/frameworks/{slug}:
    get:
      tags:
        - Compliance
      summary: Get one framework with coverage
      parameters:
        - $ref: "#/components/parameters/ComplianceFrameworkSlug"
      responses:
        "200":
          description: Framework descriptor and coverage
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ComplianceFrameworkSummary"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
  /compliance/frameworks/{slug}/controls:
    get:
      tags:
        - Compliance
      summary: List a framework's controls with the tenant's state
      description: Every control in library order with its computed state, the person's override, the
        owner, applicability for the tenant's highest declared risk tier and the mappings behind it
        (without live probe results — those are on the single-control read). Category containers are
        listed for grouping and never counted.
      parameters:
        - $ref: "#/components/parameters/ComplianceFrameworkSlug"
      responses:
        "200":
          description: Controls and coverage
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ComplianceControlList"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
  /compliance/frameworks/{slug}/controls/{control_id}:
    get:
      tags:
        - Compliance
      summary: Evaluate one control with live evidence
      description: Runs only the probes this control's mappings name and returns the control with a result
        beside each mapping — level, count, a one-sentence summary made of counts and the deep link
        a reviewer follows. A control id under the wrong framework is 404.
      parameters:
        - $ref: "#/components/parameters/ComplianceFrameworkSlug"
        - $ref: "#/components/parameters/ComplianceControlID"
      responses:
        "200":
          description: The control with live evidence
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ComplianceControlStatus"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
  /compliance/frameworks/{slug}/controls/{control_id}/override:
    put:
      tags:
        - Compliance
      summary: Override a control's state
      description: Records a person's decision over the computed state. `not_applicable` removes the
        control from the coverage denominator, which is why every override needs a note of at least
        10 characters; `null` clears the override and its note. The computed state stays visible
        beside the decision. Manager and above; audited as `compliance.control.overridden`. A
        category container cannot be overridden.
      parameters:
        - $ref: "#/components/parameters/ComplianceFrameworkSlug"
        - $ref: "#/components/parameters/ComplianceControlID"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ComplianceOverrideRequest"
      responses:
        "200":
          description: The control, re-evaluated with the override applied
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ComplianceControlStatus"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
  /compliance/frameworks/{slug}/controls/{control_id}/owner:
    put:
      tags:
        - Compliance
      summary: Set who owns getting a control to covered
      description: Free text, so an owner can be named before they have a login. Either field may be null
        to clear it. Manager and above; audited as `compliance.control.owner_set`.
      parameters:
        - $ref: "#/components/parameters/ComplianceFrameworkSlug"
        - $ref: "#/components/parameters/ComplianceControlID"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ComplianceOwnerRequest"
      responses:
        "200":
          description: The control with its owner
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ComplianceControlStatus"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
  /compliance/risk-catalog:
    get:
      tags:
        - Compliance
      summary: The shared risk catalog and capability catalog
      description: The nine AI risk classes every control, assessment question and register row keys on,
        with their seed scores and scoring guidance, and the MITRITY capabilities a register row may
        cite as mitigations. Content, not tenant data; any role.
      responses:
        "200":
          description: Catalogs
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ComplianceRiskCatalog"
        "401":
          $ref: "#/components/responses/Unauthorized"
  /compliance/risks:
    get:
      tags:
        - Compliance
      summary: List the risk register
      description: Sorted by residual score, highest first — the order a register is read — or, with
        `sort=gap`, by how far each row's computed residual sits above its declared one. Every row
        carries `computed`, the live side of the register (see `ComplianceRiskComputed`). Every
        filter is independent; an unknown filter or sort value is 400 rather than an empty list.
      parameters:
        - name: subject_type
          in: query
          schema:
            type: string
            enum:
              - agent
              - tenant
        - name: subject_id
          in: query
          schema:
            type: string
            format: uuid
        - name: status
          in: query
          schema:
            $ref: "#/components/schemas/ComplianceRiskStatus"
        - name: risk_slug
          in: query
          description: A slug from the risk catalog.
          schema:
            type: string
        - name: sort
          in: query
          description: "`residual` (default) is the register's own order. `gap` puts the widest
            computed-minus-declared gap first and rows without a computed score last; the filtered
            register is read whole — up to 2000 rows, far past any register in practice — and paged
            in memory. Past that bound the response says `truncated` and `total` is the rows
            sorted."
          schema:
            type: string
            enum:
              - residual
              - gap
            default: residual
        - name: limit
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 200
            default: 50
        - name: offset
          in: query
          schema:
            type: integer
            minimum: 0
            default: 0
      responses:
        "200":
          description: Register page
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ComplianceRiskList"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
    post:
      tags:
        - Compliance
      summary: Add a risk to the register
      description: A manual entry against one agent or the tenant's program. The title, likelihood, impact
        and mitigating capabilities default from the catalog entry; residual defaults to inherent
        because nothing is mitigated yet. The generated scores are likelihood × impact. A risk is
        never created `accepted` — acceptance is its own action with a note. Member and above.
        Recorded as `compliance.risk.created`, as is every row an approved assessment seeds. A risk
        against an agent the caller cannot see answers 404, as one against an unknown agent does. A
        user whose access scope is `scoped` sees only the agents granted to them, and an API key
        bound to an environment only that environment's agents.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ComplianceRiskCreateRequest"
      responses:
        "201":
          description: Risk created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ComplianceRisk"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
  /compliance/risks/{id}:
    get:
      tags:
        - Compliance
      summary: Get a risk
      description: One register row with its live side, `computed` (see `ComplianceRiskComputed`). When
        the live signals cannot be read the row is served without it and says `signals_unavailable`.
        A risk about an agent the caller cannot see answers 404, as an unknown id does; the
        program's own risks are visible to everyone. A user whose access scope is `scoped` sees only
        the agents granted to them, and an API key bound to an environment only that environment's
        agents.
      parameters:
        - $ref: "#/components/parameters/ComplianceRiskID"
      responses:
        "200":
          description: The risk
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ComplianceRisk"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
    put:
      tags:
        - Compliance
      summary: Update a risk
      description: Every field is optional; an omitted field keeps its value. `status` moves between
        `open`, `mitigating` and `closed` here; `accepted` is refused — use the accept action.
        Member and above. A risk about an agent the caller cannot see answers 404, as an unknown id
        does. A user whose access scope is `scoped` sees only the agents granted to them, and an API
        key bound to an environment only that environment's agents.
      parameters:
        - $ref: "#/components/parameters/ComplianceRiskID"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ComplianceRiskUpdateRequest"
      responses:
        "200":
          description: The updated risk
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ComplianceRisk"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
    delete:
      tags:
        - Compliance
      summary: Delete a risk
      description: Removes the row. Manager and above. A risk about an agent the caller cannot see answers
        404, as an unknown id does. A user whose access scope is `scoped` sees only the agents
        granted to them, and an API key bound to an environment only that environment's agents.
      parameters:
        - $ref: "#/components/parameters/ComplianceRiskID"
      responses:
        "204":
          description: Deleted
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
  /compliance/risks/{id}/accept:
    post:
      tags:
        - Compliance
      summary: Accept a risk with a justification
      description: Marks the risk `accepted` and records the note (at least 10 characters), who accepted
        and when. A closed risk cannot be accepted (409). Manager and above; audited as
        `compliance.risk.accepted`. A risk about an agent the caller cannot see answers 404, as an
        unknown id does. A user whose access scope is `scoped` sees only the agents granted to them,
        and an API key bound to an environment only that environment's agents.
      parameters:
        - $ref: "#/components/parameters/ComplianceRiskID"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ComplianceRiskAcceptRequest"
      responses:
        "200":
          description: The accepted risk
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ComplianceRisk"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: The risk is closed.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
  /compliance/policy-statements:
    get:
      tags:
        - Compliance
      summary: List AI policy statements
      parameters:
        - name: status
          in: query
          schema:
            $ref: "#/components/schemas/AIPolicyStatementStatus"
      responses:
        "200":
          description: Statements, by slug
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/AIPolicyStatement"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
    post:
      tags:
        - Compliance
      summary: Create a draft statement
      description: A tenant-authored statement, one per slug, starting as a draft at version 1.
        `principle` names a risk class from the catalog or is omitted for a general statement.
        Manager and above.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/AIPolicyStatementCreateRequest"
      responses:
        "201":
          description: Draft created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AIPolicyStatement"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "409":
          description: A statement with this slug already exists.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
  /compliance/policy-statements/{id}:
    get:
      tags:
        - Compliance
      summary: Get a statement
      description: "Includes `attested_by_me`: whether the caller has attested to the version in force."
      parameters:
        - $ref: "#/components/parameters/AIPolicyStatementID"
      responses:
        "200":
          description: The statement
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AIPolicyStatement"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
    put:
      tags:
        - Compliance
      summary: Edit a statement
      description: "A draft is edited in place by a manager or owner. A published statement may only be
        edited by the owner, and not with an API key bound to an environment (403), and a change to
        its body bumps the version, so the attestations pinned to the previous version stop counting
        as attestations to the text in force (audited as `compliance.policy.published` with
        `revision: true`). A published statement cannot be emptied; a retired statement is a record
        and cannot be edited (409)."
      parameters:
        - $ref: "#/components/parameters/AIPolicyStatementID"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/AIPolicyStatementUpdateRequest"
      responses:
        "200":
          description: The updated statement
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AIPolicyStatement"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: The statement is retired.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
    delete:
      tags:
        - Compliance
      summary: Delete a draft statement
      description: Only a draft can be deleted; a published statement is retired instead (409), because
        people attested to it. Owner only, and not with an API key bound to an environment (403).
      parameters:
        - $ref: "#/components/parameters/AIPolicyStatementID"
      responses:
        "204":
          description: Deleted
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: The statement is not a draft.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
  /compliance/policy-statements/{id}/publish:
    post:
      tags:
        - Compliance
      summary: Publish a statement
      description: Moves a draft to `published` and stamps who published and when. An empty statement
        cannot be published (400); only a draft publishes (409). Owner only, and not with an API key
        bound to an environment (403); audited as `compliance.policy.published`.
      parameters:
        - $ref: "#/components/parameters/AIPolicyStatementID"
      responses:
        "200":
          description: The published statement
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AIPolicyStatement"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: The statement is not a draft.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
  /compliance/policy-statements/{id}/retire:
    post:
      tags:
        - Compliance
      summary: Retire a published statement
      description: Moves a published statement to `retired`; its attestations stay on the record. Only a
        published statement retires (409). Owner only, and not with an API key bound to an
        environment (403); audited as `compliance.policy.retired`.
      parameters:
        - $ref: "#/components/parameters/AIPolicyStatementID"
      responses:
        "200":
          description: The retired statement
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AIPolicyStatement"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: The statement is not published.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
  /compliance/policy-statements/{id}/attest:
    post:
      tags:
        - Compliance
      summary: Attest to the version in force
      description: Records that the calling user read the statement's current published version. The
        version is taken from the statement, never from the request, and the name from the user
        record. Any role — attesting is reading. A second attestation to the same version is not a
        new fact and answers 200 with the existing row; only a published statement can be attested
        to (409). Audited as `compliance.policy.attested`.
      parameters:
        - $ref: "#/components/parameters/AIPolicyStatementID"
      responses:
        "200":
          description: Already attested to this version
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AIPolicyAttestation"
        "201":
          description: Attestation recorded
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AIPolicyAttestation"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: The statement is not published.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
  /compliance/policy-statements/{id}/attestations:
    get:
      tags:
        - Compliance
      summary: The attestation roster
      description: Every attestation to any version, newest version first, each marked `current` when it
        is to the version in force. A person removed from the tenant stays on the roster by the
        copied email with a null `user_id`.
      parameters:
        - $ref: "#/components/parameters/AIPolicyStatementID"
      responses:
        "200":
          description: Roster
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AIPolicyAttestationRoster"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
  /public/report-shares/redeem:
    post:
      tags:
        - Reports
      summary: Open a shared report
      description: >-
        Unauthenticated: the token is the whole authorization, which is the point of a share link —
        the auditor opening it has no account. Rate-limited per client.


        The token travels in the request body and never in the URL. A URL is written into the
        request log of every hop that serves it — the load balancer, the platform, any proxy in
        between — and none of them can be told to leave a path segment out, so a token in the path
        could be read by anyone who can read those logs. The share link keeps the token in its
        fragment for the same reason. Redeeming counts a view, so the route is a POST, and there is
        deliberately no GET form of it. A share URL of the form `{origin}/shared/reports#{token}`
        opens the dashboard page, never this route; the page reads the fragment and calls this route
        with it.


        The response carries what identifies the document — type, title, hash, period, expiry and,
        on a capped share, the views remaining — and a signed URL to the PDF valid for ten minutes.
        It deliberately carries nothing that identifies the organization: no tenant, no agent, no
        author. The file is handed over as a signed URL rather than streamed through the API, so an
        unauthenticated download cannot occupy an API request slot for its whole duration.


        Opening a link counts a view and stamps `last_viewed_at`, in the same statement that checks
        the link, so two simultaneous opens of a one-view link open it once. Each view writes an
        audit event (`event_type=report.share_viewed`, `source=control_plane`, no actor) that
        records the token's 8-character prefix and never the token or its hash.
      security: []
      requestBody:
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/RedeemReportShareRequest"
      responses:
        "200":
          description: The shared report
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SharedReport"
        "404":
          description: The link is not available. Unknown, malformed, revoked, expired, past its view cap, and
            pointing at a deleted or unfinished report all answer this, with the same body, and so
            does a body that carries no token, so the route cannot be used to learn which tokens
            exist.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          description: Too many requests from this client.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
  /public/academy/certificates/{verification_code}:
    get:
      operationId: verifyAcademyCertificate
      tags:
        - Academy
      summary: Verify an Academy certificate
      description: >-
        Unauthenticated: anyone given a certificate's verification link can check the certificate,
        which is what the link is for. Rate-limited per client.


        The verification code is printed on the certificate and in the address of its verification
        page, and the holder can add it to a LinkedIn profile: it is public by design. It is 24 hex
        characters (96 random bits), so the certificates in use cannot be enumerated. It is read in
        lower case, and a value of any other shape is answered 404 like an unknown code (the route
        does not answer 400).


        The response confirms the certificate with its course, issue date and certificate ID. It
        names the holder only when they chose to show their name and their account has one; it never
        carries the holder's email address, organization or account identifiers. An unknown or
        malformed code, and the code of a certificate removed since (with its learner, or with its
        organization's Academy data), all answer 404 with the same body. Neither answer may be
        cached or indexed.
      security: []
      parameters:
        - name: verification_code
          in: path
          required: true
          description: "The certificate's verification code: 24 hex characters, read in lower case."
          schema:
            type: string
          example: 3f9a0c7d51e24b86a0d9e2c1
      responses:
        "200":
          description: The certificate is valid
          headers:
            Cache-Control:
              $ref: "#/components/headers/NoStore"
            X-Robots-Tag:
              $ref: "#/components/headers/NoIndex"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AcademyCertificateVerification"
        "404":
          description: No certificate has this code. Unknown, malformed and removed all answer this, with the
            same body.
          headers:
            Cache-Control:
              $ref: "#/components/headers/NoStore"
            X-Robots-Tag:
              $ref: "#/components/headers/NoIndex"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          description: Too many requests from this client.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "500":
          $ref: "#/components/responses/InternalError"
  /threat-intelligence/indicators:
    get:
      tags:
        - ThreatIntelligence
      summary: List threat indicators from subscribed feeds
      description: Returns indicators from feeds this tenant subscribes to. Indicators contain NO
        tenant-specific data. Each entry carries the tenant's per-indicator override (if any)
        inline.
      parameters:
        - name: indicator_type
          in: query
          description: Filter by indicator type. `command_pattern` selects the command-abuse pack — RE2
            patterns evaluated against the canonical command text of execution-shaped actions (after
            wrapper and interpreter unwrapping); unanchored; only actions carrying a command-family
            argument are evaluated.
          schema:
            type: string
            enum:
              - action_pattern
              - behavioral_hash
              - injection_signature
              - tool_abuse_pattern
              - delegation_pattern
              - mcp_tool_risk
              - command_pattern
              - model_risk
        - name: severity
          in: query
          schema:
            type: string
            enum:
              - critical
              - high
              - medium
              - low
              - info
        - name: feed
          in: query
          schema:
            type: string
            enum:
              - mitrity_curated
              - platform_generated
              - community
              - osv
        - name: sort_by
          in: query
          schema:
            type: string
            enum:
              - severity
              - feed
              - name
            default: severity
        - name: sort_order
          in: query
          schema:
            type: string
            enum:
              - asc
              - desc
      responses:
        "200":
          description: List of threat indicators
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/ThreatIndicator"
        "400":
          $ref: "#/components/responses/BadRequest"
  /threat-intelligence/indicators/{id}:
    get:
      tags:
        - ThreatIntelligence
      summary: Get a single threat indicator
      description: Returns one indicator, enriched with the calling tenant's override (if any).
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: Threat indicator details
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ThreatIndicator"
        "404":
          $ref: "#/components/responses/NotFound"
  /threat-intelligence/indicators/{id}/override:
    put:
      tags:
        - ThreatIntelligence
      summary: Set a per-indicator severity / action override
      description: "Set (or change, or clear) this tenant's severity and/or action override for a single
        indicator. Only `severity_override`, `action_override`, and `note` are managed here;
        suppression fields are managed via the suppress endpoints and are preserved. For each of
        `severity_override` / `action_override` / `note`: omitting the key (or sending `null`)
        leaves the existing value alone; sending an empty string `\"\"` explicitly clears that
        field. **Pro plan or higher; members and above only.**"
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/PutThreatOverrideRequest"
      responses:
        "200":
          description: The resulting override row
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ThreatIndicatorOverride"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          description: Plan upgrade required — per-indicator overrides are a Pro+ feature.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "403":
          $ref: "#/components/responses/Forbidden"
    delete:
      tags:
        - ThreatIntelligence
      summary: Remove the entire override for an indicator
      description: Delete the whole override row for this indicator, dropping both the severity/action
        overrides and any suppression. To clear only the suppression while keeping severity/action
        overrides, use `DELETE /threat-intelligence/indicators/{id}/suppress`. Idempotent — returns
        204 even when no override exists. **Pro plan or higher; members and above only.**
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "204":
          description: Override cleared (or nothing to clear)
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          description: Plan upgrade required — per-indicator overrides are a Pro+ feature.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "403":
          $ref: "#/components/responses/Forbidden"
  /threat-intelligence/indicators/{id}/suppress:
    post:
      tags:
        - ThreatIntelligence
      summary: Suppress an indicator for this tenant
      description: Silence an indicator for the calling tenant. `log_only` (the default when `mode` is
        omitted) still records each match, with `action_taken` `suppressed`, so the audit trail
        survives; `silent` drops matches entirely. Severity/action overrides on the same indicator
        are preserved. **Pro plan or higher; members and above only.**
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/SuppressThreatIndicatorRequest"
      responses:
        "200":
          description: The resulting override row (now carrying suppression fields)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ThreatIndicatorOverride"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          description: Plan upgrade required — per-indicator suppression is a Pro+ feature.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "403":
          $ref: "#/components/responses/Forbidden"
    delete:
      tags:
        - ThreatIntelligence
      summary: Clear only the suppression on an indicator
      description: Clear the suppression fields for this indicator while leaving any severity/action
        overrides intact. Idempotent — returns 204 when there was nothing to clear; otherwise
        returns the updated override row. **Pro plan or higher; members and above only.**
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: The updated override row (suppression cleared)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ThreatIndicatorOverride"
        "204":
          description: Nothing to clear (no suppression existed)
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          description: Plan upgrade required — per-indicator suppression is a Pro+ feature.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "403":
          $ref: "#/components/responses/Forbidden"
  /threat-intelligence/trending:
    get:
      tags:
        - ThreatIntelligence
      summary: List trending indicators for this tenant
      description: Returns the indicators most matched within a rolling window for the calling tenant,
        ranked by match count. Each entry bundles the full indicator with per-tenant match stats for
        the window.
      parameters:
        - name: window_days
          in: query
          description: Rolling window length in days (1–90).
          schema:
            type: integer
            minimum: 1
            maximum: 90
            default: 7
        - name: limit
          in: query
          description: Maximum number of trending entries to return (1–100).
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 20
      responses:
        "200":
          description: Trending indicators ranked by match count
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/TrendingThreatIndicator"
        "400":
          $ref: "#/components/responses/BadRequest"
  /threat-intelligence/matches:
    get:
      tags:
        - ThreatIntelligence
      summary: List threat matches for this tenant
      description: Returns matches where this tenant's agent actions hit threat indicators. Fully tenant-scoped.
      parameters:
        - name: agent_id
          in: query
          schema:
            type: string
            format: uuid
        - name: severity
          in: query
          schema:
            type: string
            enum:
              - critical
              - high
              - medium
              - low
              - info
        - name: indicator_type
          in: query
          description: Filter matches by the type of the indicator that fired. `command_pattern` matches come
            from the command-abuse pack — RE2 patterns evaluated against the canonical command text
            of execution-shaped actions.
          schema:
            type: string
            enum:
              - action_pattern
              - behavioral_hash
              - injection_signature
              - tool_abuse_pattern
              - delegation_pattern
              - command_pattern
        - $ref: "#/components/parameters/PageSize"
        - $ref: "#/components/parameters/PageToken"
      responses:
        "200":
          description: List of threat matches
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/ThreatMatch"
  /threat-intelligence/matches/summary:
    get:
      tags:
        - ThreatIntelligence
      summary: Threat match summary statistics
      description: Aggregated stats by severity, type, and action for this tenant.
      responses:
        "200":
          description: Threat match summary
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ThreatMatchSummary"
  /threat-intelligence/settings:
    get:
      tags:
        - ThreatIntelligence
      summary: Get tenant threat intelligence settings
      description: Returns the tenant's feed subscriptions, contribution opt-in status, and default action.
      responses:
        "200":
          description: Tenant threat settings
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TenantThreatSettings"
    put:
      tags:
        - ThreatIntelligence
      summary: Update tenant threat intelligence settings
      description: Update feed subscriptions, opt-in contribution, and default action for threat matches.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/UpdateThreatSettingsRequest"
      responses:
        "200":
          description: Updated settings
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TenantThreatSettings"
  /aibom:
    get:
      tags:
        - AIBOM
      operationId: listAIBOM
      summary: List the tenant's AI Bill of Materials
      description: |
        Returns the inventory of every MCP server identity the gateway has
        observed for this tenant in the lookback window. Each entry includes
        runtime metadata (first/last seen, invocation count, distinct agents)
        and the effective risk overlay (the tool definition's risk level plus
        any matched `mcp_tool_risk` indicators).
      parameters:
        - in: query
          name: include_archived
          schema:
            type: boolean
            default: false
        - in: query
          name: min_severity
          schema:
            type: string
            enum:
              - critical
              - high
              - medium
              - low
              - info
          description: Filter by effective_risk_level.
        - in: query
          name: search
          schema:
            type: string
          description: Substring match on display_name / publisher / source.
        - in: query
          name: sort_by
          schema:
            type: string
            enum:
              - last_seen_at
              - first_seen_at
              - invocation_count_30d
              - effective_risk_level
            default: last_seen_at
        - in: query
          name: sort_order
          schema:
            type: string
            enum:
              - asc
              - desc
            default: desc
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Offset"
      responses:
        "200":
          description: AIBOM list
          content:
            application/json:
              schema:
                type: object
                required:
                  - data
                  - total
                  - limit
                  - offset
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/ToolInventoryEntry"
                  total:
                    type: integer
                  limit:
                    type: integer
                  offset:
                    type: integer
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
  /aibom/{id}:
    parameters:
      - in: path
        name: id
        required: true
        schema:
          type: string
          format: uuid
    get:
      tags:
        - AIBOM
      operationId: getAIBOMEntry
      summary: Get a single AIBOM entry
      responses:
        "200":
          description: AIBOM entry
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ToolInventoryEntry"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
    patch:
      tags:
        - AIBOM
      operationId: updateAIBOMEntry
      summary: Update an AIBOM entry's customer-mutable fields (display_name, notes)
      description: Identity fields are immutable; the gateway owns those. Patch only the annotation fields.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/UpdateToolInventoryEntryRequest"
      responses:
        "200":
          description: Updated entry
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ToolInventoryEntry"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
  /aibom/{id}/archive:
    parameters:
      - in: path
        name: id
        required: true
        schema:
          type: string
          format: uuid
    post:
      tags:
        - AIBOM
      operationId: archiveAIBOMEntry
      summary: Archive an AIBOM entry (soft-delete)
      description: Marks the entry as archived. The gateway will recreate it on next invocation if the
        identity is observed again — archive is annotation, not enforcement.
      responses:
        "200":
          description: Archived
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ToolInventoryEntry"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
  /aibom/{id}/unarchive:
    parameters:
      - in: path
        name: id
        required: true
        schema:
          type: string
          format: uuid
    post:
      tags:
        - AIBOM
      operationId: unarchiveAIBOMEntry
      summary: Unarchive an AIBOM entry
      description: Restores a previously-archived entry. Same effect as the gateway observing the identity
        again on next invocation — this just does it manually.
      responses:
        "200":
          description: Unarchived
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ToolInventoryEntry"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
  /aibom/drift-feed:
    get:
      tags:
        - AIBOM
      operationId: listToolAdvisories
      summary: Chronological feed of advisories affecting this tenant's AIBOM
      description: |
        Returns drift advisories — events fired when a new mcp_tool_risk
        indicator matches an existing AIBOM entry for this tenant. The
        `/app/aibom` drift feed view is built from this endpoint. Includes
        acknowledged + unacknowledged advisories; filter as needed.
      parameters:
        - in: query
          name: unacknowledged_only
          schema:
            type: boolean
            default: false
        - in: query
          name: since
          schema:
            type: string
            format: date-time
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Offset"
      responses:
        "200":
          description: Advisory feed
          content:
            application/json:
              schema:
                type: object
                required:
                  - data
                  - total
                  - limit
                  - offset
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/ToolAdvisory"
                  total:
                    type: integer
                  limit:
                    type: integer
                  offset:
                    type: integer
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
  /aibom/drift-feed/{id}/acknowledge:
    parameters:
      - in: path
        name: id
        required: true
        schema:
          type: string
          format: uuid
        description: The ToolAdvisory id (not the AIBOM entry id).
    post:
      tags:
        - AIBOM
      operationId: acknowledgeToolAdvisory
      summary: Acknowledge a drift advisory
      description: Records the operator who reviewed it + timestamp. Does not change enforcement; that's
        driven by the underlying mcp_tool_risk indicator's status.
      responses:
        "200":
          description: Acknowledged
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ToolAdvisory"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
  /model-inventory:
    get:
      tags:
        - Models
      operationId: listModelInventory
      summary: List the models the tenant governs
      description: Highest severity first, then most recently seen. A null `max_severity` — a repository
        not yet scanned, or a hosted model with no open findings — sorts after `info`. Every filter
        is independent; `severity` matches a stored value, so a row whose severity is null is
        reached by leaving the filter off, never through it.
      parameters:
        - name: kind
          in: query
          schema:
            type: string
            enum:
              - repository
              - hosted
              - local
        - name: source
          in: query
          schema:
            type: string
            enum:
              - declared
              - observed
              - discovered
        - name: severity
          in: query
          schema:
            $ref: "#/components/schemas/ModelSeverity"
        - name: search
          in: query
          schema:
            type: string
          description: Substring of the identity.
        - name: page
          in: query
          schema:
            type: integer
            minimum: 1
            default: 1
        - name: page_size
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 20
      responses:
        "200":
          description: The inventory page
          content:
            application/json:
              schema:
                type: object
                required:
                  - models
                  - total
                  - page
                  - page_size
                properties:
                  models:
                    type: array
                    items:
                      $ref: "#/components/schemas/Model"
                  total:
                    type: integer
                    format: int64
                  page:
                    type: integer
                  page_size:
                    type: integer
        "401":
          $ref: "#/components/responses/Unauthorized"
    post:
      tags:
        - Models
      operationId: declareModelRepository
      summary: Declare a model repository to scan
      description: >
        Owner or manager. Declaring an identity that already exists re-declares it: the revision
        moves, an observed row becomes declared, and the scan state resets so the next sweep reads
        it.


        The identity and the revision are validated against a strict grammar and refused rather than
        sanitized, because both become part of a URL path at scan time.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - identity
              properties:
                identity:
                  type: string
                  pattern: ^hf:[A-Za-z0-9][A-Za-z0-9._-]{0,94}/[A-Za-z0-9][A-Za-z0-9._-]{0,94}$
                  example: hf:meta-llama/Llama-3.1-8B
                  description: A repository identity. Case is preserved and a revision is never part of it.
                revision:
                  type: string
                  nullable: true
                  maxLength: 128
                  pattern: ^[A-Za-z0-9][A-Za-z0-9._/-]{0,127}$
                  description: >-
                    A branch, tag or commit. Omitted means the repository's default branch. A
                    40-character hexadecimal value is treated as pinned; anything else produces an
                    `unpinned_revision` finding naming the commit the Hub resolved.


                    The value becomes part of a Hub URL path, so it is refused rather than
                    sanitized. `..` is rejected in addition to the pattern above, which the pattern
                    alone cannot express.
                display_name:
                  type: string
                  nullable: true
                access_token:
                  type: string
                  nullable: true
                  writeOnly: true
                  description: "A Hugging Face access token for a private or gated repository. Write-only: it is
                    envelope-encrypted on arrival and no response carries it back."
      responses:
        "201":
          description: Declared
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ModelDetail"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
  /model-inventory/{model_id}:
    parameters:
      - in: path
        name: model_id
        required: true
        schema:
          type: string
          format: uuid
    get:
      tags:
        - Models
      operationId: getModelInventoryItem
      summary: Get a model with its findings and last scan
      responses:
        "200":
          description: The model
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ModelDetail"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
    patch:
      tags:
        - Models
      operationId: updateModelInventoryItem
      summary: Update a declared repository
      description: >
        Owner or manager. An omitted key is left alone; an explicit `null` on `access_token` clears
        the stored token.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                revision:
                  type: string
                  nullable: true
                  maxLength: 128
                  pattern: ^[A-Za-z0-9][A-Za-z0-9._/-]{0,127}$
                  description: Same grammar as on the declare route; `..` is rejected in addition to the pattern.
                display_name:
                  type: string
                  nullable: true
                  maxLength: 200
                access_token:
                  type: string
                  nullable: true
                  writeOnly: true
                  maxLength: 512
      responses:
        "200":
          description: Updated
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ModelDetail"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
    delete:
      tags:
        - Models
      operationId: deleteModelInventoryItem
      summary: Remove a model from the inventory
      description: Owner or manager. Its scan runs and findings go with it.
      responses:
        "204":
          description: Removed
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
  /model-inventory/{model_id}/scan:
    parameters:
      - in: path
        name: model_id
        required: true
        schema:
          type: string
          format: uuid
    post:
      tags:
        - Models
      operationId: scanModelRepository
      summary: Scan this repository now
      description: >
        Owner or manager. Unlike the scheduled sweep, a scan a person asked for reads the repository
        even when the resolved commit has not moved — a "scan now" that silently does nothing is not
        an answer.
      responses:
        "200":
          description: The scan ran
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ModelScanResult"
        "400":
          description: A hosted model has nothing to fetch.
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: A scan is already running for this model.
  /model-inventory/{model_id}/findings:
    parameters:
      - in: path
        name: model_id
        required: true
        schema:
          type: string
          format: uuid
    get:
      tags:
        - Models
      operationId: listModelInventoryFindings
      summary: List a model's findings
      parameters:
        - name: code
          in: query
          schema:
            type: string
        - name: category
          in: query
          schema:
            $ref: "#/components/schemas/ModelFindingCategory"
        - name: severity
          in: query
          schema:
            $ref: "#/components/schemas/ModelSeverity"
        - name: status
          in: query
          schema:
            type: string
            enum:
              - open
              - resolved
        - name: page
          in: query
          schema:
            type: integer
            minimum: 1
            default: 1
        - name: page_size
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 20
      responses:
        "200":
          description: The findings page
          content:
            application/json:
              schema:
                type: object
                required:
                  - findings
                  - total
                  - page
                  - page_size
                properties:
                  findings:
                    type: array
                    items:
                      $ref: "#/components/schemas/ModelFinding"
                  total:
                    type: integer
                    format: int64
                  page:
                    type: integer
                  page_size:
                    type: integer
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
  /model-inventory/{model_id}/runs:
    parameters:
      - in: path
        name: model_id
        required: true
        schema:
          type: string
          format: uuid
    get:
      tags:
        - Models
      operationId: listModelInventoryScanRuns
      summary: List a model's scan runs
      parameters:
        - name: page
          in: query
          schema:
            type: integer
            minimum: 1
            default: 1
        - name: page_size
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 20
      responses:
        "200":
          description: The runs page
          content:
            application/json:
              schema:
                type: object
                required:
                  - runs
                  - total
                  - page
                  - page_size
                properties:
                  runs:
                    type: array
                    items:
                      $ref: "#/components/schemas/ModelScanRun"
                  total:
                    type: integer
                    format: int64
                  page:
                    type: integer
                  page_size:
                    type: integer
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
  /model-inventory/{model_id}/findings/{finding_id}/resolve:
    parameters:
      - in: path
        name: model_id
        required: true
        schema:
          type: string
          format: uuid
      - in: path
        name: finding_id
        required: true
        schema:
          type: string
          format: uuid
    post:
      tags:
        - Models
      operationId: resolveModelInventoryFinding
      summary: Resolve or accept the risk of a finding
      description: >
        Owner or manager. `manual` says the artifact was dealt with; `accepted_risk` says it was not
        and the decision is on the record. `fixed` is the scanner's to write, when a complete scan
        no longer produces the finding, and is not accepted here.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                resolution:
                  type: string
                  enum:
                    - manual
                    - accepted_risk
                  default: manual
                note:
                  type: string
                  maxLength: 1000
      responses:
        "200":
          description: Resolved
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ModelFinding"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: The finding is already resolved.
  /llm-usage:
    get:
      tags:
        - LLMUsage
      operationId: listLLMUsage
      summary: List LLM usage records
      description: >
        Returns per-call LLM usage records for the tenant, one row per LLM Gateway-governed call.
        Sorted by occurred_at descending (fixed — matching /audit; there is no sort parameter).
      parameters:
        - name: agent_id
          in: query
          schema:
            type: string
            format: uuid
          description: Filter by agent ID
        - name: provider
          in: query
          schema:
            type: string
          description: Filter by provider (e.g. "anthropic", "openai")
        - name: model
          in: query
          schema:
            type: string
          description: Filter by exact provider model id
        - name: decision
          in: query
          schema:
            type: string
            enum:
              - allowed
              - blocked
              - redacted
          description: Filter by gateway decision
        - name: from
          in: query
          schema:
            type: string
            format: date-time
          description: Inclusive lower bound on occurred_at
        - name: to
          in: query
          schema:
            type: string
            format: date-time
          description: Exclusive upper bound on occurred_at
        - $ref: "#/components/parameters/PageSize"
        - $ref: "#/components/parameters/PageToken"
      responses:
        "200":
          description: LLM usage records
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LLMUsageList"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"
  /llm-usage/summary:
    get:
      tags:
        - LLMUsage
      operationId: summarizeLLMUsage
      summary: Aggregate LLM usage (calls, tokens, estimated cost)
      description: >
        Aggregates the LLM usage records over the requested `occurred_at` range, grouped by model,
        agent, or day. `estimated_cost_usd` is computed from MITRITY's built-in price catalog, keyed
        by (provider, model) — prices are ESTIMATES, not billing data; the catalog is versioned with
        MITRITY releases (`price_catalog_version`) and a group's estimate is null when no call in
        the group matched a catalog entry. Calls priced at 0 tokens (upstream omitted usage)
        contribute 0 to the estimate.
      parameters:
        - name: group_by
          in: query
          required: true
          schema:
            type: string
            enum:
              - model
              - agent
              - day
          description: >
            Aggregation key. `model` groups by (provider, model); `agent` by agent; `day` by UTC
            calendar day of occurred_at.
        - name: from
          in: query
          required: true
          schema:
            type: string
            format: date-time
        - name: to
          in: query
          required: true
          schema:
            type: string
            format: date-time
        - name: agent_id
          in: query
          schema:
            type: string
            format: uuid
          description: Optional filter by agent ID
        - name: provider
          in: query
          schema:
            type: string
          description: Optional filter by provider
      responses:
        "200":
          description: Aggregated LLM usage
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LLMUsageSummary"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"
  /llm-usage/bom:
    get:
      tags:
        - LLMUsage
      operationId: getLLMBOM
      summary: LLM Bill of Materials (per-agent provider/model inventory)
      description: >
        Returns the tenant's LLM-BOM — one entry per distinct (agent, provider, model) ever observed
        through the LLM Gateway, with first_seen/last_seen and lifetime call/token totals. Blocked
        calls are not in it — the gateway's refusals and policy denials alike, whether or not they
        name a model — because a blocked call reached no model; they are listed by GET /llm-usage
        and counted in the blocked figures of GET /llm-usage/summary. Lifetime inventory (no
        time-range filter); recency lives in last_seen. Modeled as a dedicated endpoint (matching
        the AIBOM style) rather than a summary group_by, because its row shape
        (first_seen/last_seen) differs from the aggregation rows. Sorted by last_seen descending.
      parameters:
        - name: agent_id
          in: query
          schema:
            type: string
            format: uuid
          description: Filter by agent ID
        - name: provider
          in: query
          schema:
            type: string
          description: Filter by provider
        - $ref: "#/components/parameters/PageSize"
        - $ref: "#/components/parameters/PageToken"
      responses:
        "200":
          description: LLM-BOM entries
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LLMBOMList"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"
  /governed-actions/summary:
    get:
      tags:
        - GovernedActions
      operationId: summarizeGovernedActions
      summary: Aggregate the governed-action meter
      description: >
        Aggregates the governed-action meter over the requested `occurred_at` range, grouped by
        decision, agent, UTC day, or surface. `total_governed_actions` is the billable count over
        the same range and filter. The meter holds one entry per governed decision, across all
        enforcement surfaces, counted once however often an edge reports the event, and it is kept
        independently of audit-event retention, so it remains the billing record. Read-only. The
        meter is the organization's billing record and is read whole. It takes full access: a user
        whose access scope is `scoped`, or a request made with an API key bound to an environment,
        is refused 403.
      parameters:
        - name: group_by
          in: query
          required: true
          schema:
            type: string
            enum:
              - decision
              - agent
              - day
              - surface
          description: >
            Aggregation key. `decision` groups by decision; `agent` by agent (with agent_name);
            `day` by UTC calendar day of occurred_at; `surface` by enforcement surface.
        - name: from
          in: query
          required: true
          schema:
            type: string
            format: date-time
          description: Inclusive lower bound on occurred_at (RFC3339)
        - name: to
          in: query
          required: true
          schema:
            type: string
            format: date-time
          description: Exclusive upper bound on occurred_at (RFC3339)
        - name: agent_id
          in: query
          schema:
            type: string
            format: uuid
          description: Optional filter by agent ID
      responses:
        "200":
          description: Aggregated governed-action usage
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/GovernedActionUsageSummary"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"
  /governed-actions/usage:
    get:
      tags:
        - GovernedActions
      operationId: getGovernedActionUsage
      summary: Billable governed-action count for a period
      description: >
        Returns the billable governed-action count over the requested `occurred_at` range — the
        figure usage-based billing meters the tenant by. `from` defaults to 30 days ago and `to` to
        now when omitted; `from` is inclusive, `to` exclusive (RFC3339). Read-only. The meter is the
        organization's billing record and is read whole. It takes full access: a user whose access
        scope is `scoped`, or a request made with an API key bound to an environment, is refused
        403.
      parameters:
        - name: from
          in: query
          schema:
            type: string
            format: date-time
          description: Inclusive lower bound on occurred_at (RFC3339); defaults to 30 days ago
        - name: to
          in: query
          schema:
            type: string
            format: date-time
          description: Exclusive upper bound on occurred_at (RFC3339); defaults to now
        - name: agent_id
          in: query
          schema:
            type: string
            format: uuid
          description: Optional filter by agent ID
      responses:
        "200":
          description: Billable governed-action count
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/GovernedActionUsage"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"
  /runtime/events:
    get:
      tags:
        - Runtime
      operationId: listRuntimeEvents
      summary: List runtime events observed on Linux hosts
      description: Events the host observers reported, newest first. All roles may read. `governed` events
        are sampled (each carries `sample_weight`); `ungoverned` events are complete; `unattributed`
        events exist only where a rule matched. Filters combine with AND.
      parameters:
        - name: agent_id
          in: query
          schema:
            type: string
            format: uuid
        - name: classification
          in: query
          schema:
            $ref: "#/components/schemas/RuntimeEventClassification"
        - name: type
          in: query
          schema:
            $ref: "#/components/schemas/RuntimeEventType"
        - name: host_id
          in: query
          schema:
            type: string
            maxLength: 253
        - name: rule
          in: query
          description: Only events whose control-plane evaluation matched this rule (`rules_matched`).
          schema:
            $ref: "#/components/schemas/RuntimeRule"
        - name: helper
          in: query
          description: Omitted ⇒ no filter. `false` hides framework-helper execs (the dashboard default);
            `true` shows only them.
          schema:
            type: boolean
        - name: since
          in: query
          schema:
            type: string
            format: date-time
        - name: until
          in: query
          schema:
            type: string
            format: date-time
        - $ref: "#/components/parameters/PageSize"
        - $ref: "#/components/parameters/PageToken"
      responses:
        "200":
          description: Runtime events
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RuntimeEventList"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
  /runtime/events/{event_id}:
    get:
      tags:
        - Runtime
      operationId: getRuntimeEvent
      summary: Get one runtime event
      description: For a caller who sees only some agents, an event about an agent they cannot see, or
        about no agent, answers 404, as an unknown id does. A user whose access scope is `scoped`
        sees only the agents granted to them, and an API key bound to an environment only that
        environment's agents.
      parameters:
        - name: event_id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: The event, with the control plane's `rules_matched`, `admission_id` and `helper`
            populated.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RuntimeEvent"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
  /runtime/findings:
    get:
      tags:
        - Runtime
      operationId: listRuntimeFindings
      summary: List runtime findings
      description: Findings produced by the runtime rules and the two host-level checks
        (`observer_offline`, `sensor_unavailable`), highest severity first, then most recently seen.
        Defaults to `status=open`. All roles may read.
      parameters:
        - name: agent_id
          in: query
          schema:
            type: string
            format: uuid
        - name: host_id
          in: query
          schema:
            type: string
            maxLength: 253
        - name: code
          in: query
          schema:
            $ref: "#/components/schemas/RuntimeFindingCode"
        - name: severity
          in: query
          schema:
            $ref: "#/components/schemas/RuntimeFindingSeverity"
        - name: status
          in: query
          schema:
            type: string
            enum:
              - open
              - resolved
              - all
            default: open
        - name: since
          in: query
          description: Only findings whose `last_seen` is at or after this instant.
          schema:
            type: string
            format: date-time
        - name: until
          in: query
          schema:
            type: string
            format: date-time
        - $ref: "#/components/parameters/PageSize"
        - $ref: "#/components/parameters/PageToken"
      responses:
        "200":
          description: Runtime findings
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RuntimeFindingList"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
  /runtime/findings/{finding_id}:
    get:
      tags:
        - Runtime
      operationId: getRuntimeFinding
      summary: Get one runtime finding
      description: For a caller who sees only some agents, a finding about an agent they cannot see, or
        about no agent, answers 404, as an unknown id does. A user whose access scope is `scoped`
        sees only the agents granted to them, and an API key bound to an environment only that
        environment's agents.
      parameters:
        - name: finding_id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: The finding.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RuntimeFinding"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
  /runtime/findings/{finding_id}/resolve:
    post:
      tags:
        - Runtime
      operationId: resolveRuntimeFinding
      summary: Resolve a runtime finding
      description: "Marks an open finding `resolved` (`resolution: manual`) with the caller as
        `resolved_by`. Owner or manager role. A recurrence of the same dedup key afterwards opens a
        **new** finding; the resolved row is never reopened. Resolving an already-resolved finding
        is a `409`. For a caller who sees only some agents, a finding about an agent they cannot
        see, or about no agent, answers 404, and nothing changes. A user whose access scope is
        `scoped` sees only the agents granted to them, and an API key bound to an environment only
        that environment's agents."
      parameters:
        - name: finding_id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                note:
                  type: string
                  maxLength: 1000
                  description: Free-text reason, shown on the finding as `resolution_note`.
      responses:
        "200":
          description: The resolved finding.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RuntimeFinding"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: The finding is already resolved.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
  /agents/{agent_id}/runtime-findings:
    get:
      tags:
        - Runtime
      operationId: listAgentRuntimeFindings
      summary: Runtime findings for one agent
      description: "`GET /runtime/findings` scoped to one agent, for the agent detail's runtime tab. Same
        filters minus `agent_id` / `host_id`; same default `status=open`. Host-level findings
        (`agent_id` null) are not included. An agent the caller cannot see answers 404, as an
        unknown id does. A user whose access scope is `scoped` sees only the agents granted to them,
        and an API key bound to an environment only that environment's agents."
      parameters:
        - name: agent_id
          in: path
          required: true
          schema:
            type: string
            format: uuid
        - name: code
          in: query
          schema:
            $ref: "#/components/schemas/RuntimeFindingCode"
        - name: severity
          in: query
          schema:
            $ref: "#/components/schemas/RuntimeFindingSeverity"
        - name: status
          in: query
          schema:
            type: string
            enum:
              - open
              - resolved
              - all
            default: open
        - $ref: "#/components/parameters/PageSize"
        - $ref: "#/components/parameters/PageToken"
      responses:
        "200":
          description: Runtime findings for the agent
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RuntimeFindingList"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
  /runtime/summary:
    get:
      tags:
        - Runtime
      operationId: getRuntimeSummary
      summary: Runtime dashboard summary
      description: "Per-agent governed / ungoverned execution counts over the last 24 hours, observer
        presence and sensor status per host, rule coverage per host and agent, and open-finding
        counts. COMPUTED at read time: `governed_execs_24h` is the sum of `sample_weight` over
        sampled governed events; the ungoverned and helper counts are exact. All roles may read."
      responses:
        "200":
          description: The summary.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RuntimeSummary"
        "401":
          $ref: "#/components/responses/Unauthorized"
  /redteam/campaigns:
    get:
      tags:
        - Redteam
      operationId: listRedteamCampaigns
      summary: List adversarial testing campaigns
      description: Newest first. Each row carries its most recent run and its live findings by severity,
        so the list answers "is this campaign healthy" without a second call. Archived campaigns are
        excluded unless asked for. All roles may read.
      parameters:
        - name: environment_id
          in: query
          schema:
            type: string
            format: uuid
        - name: target_kind
          in: query
          schema:
            $ref: "#/components/schemas/RedteamTargetKind"
        - name: status
          in: query
          schema:
            $ref: "#/components/schemas/RedteamCampaignStatus"
        - name: include_archived
          in: query
          schema:
            type: boolean
            default: false
        - $ref: "#/components/parameters/PageSize"
        - $ref: "#/components/parameters/PageToken"
      responses:
        "200":
          description: Campaigns
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RedteamCampaignList"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
    post:
      tags:
        - Redteam
      operationId: createRedteamCampaign
      summary: Create an adversarial testing campaign
      description: "Owner or manager role. The body has no `dry_run` field: a campaign is always created
        in dry run, which renders every attempt and sends nothing. Leaving dry run is a separate,
        recorded act — see `POST /redteam/campaigns/{campaign_id}/disable-dry-run`. Only `llm_route`
        targets can run today; the other two target kinds reserve their value in the vocabulary and
        are rejected here until they ship. Naming an agent the caller cannot see answers 404, as an
        unknown agent does, and a caller who sees only some agents writes only a campaign that names
        one of them (403 otherwise). A user whose access scope is `scoped` sees only the agents
        granted to them, and an API key bound to an environment only that environment's agents. With
        an API key bound to an environment, a request that names no environment is placed in the
        key's, and one that names another environment is refused 403. A campaign created without an
        environment cannot run until it has one: no runner is served it, and resuming it or queueing
        a run of it answers 409 until `PATCH` gives it an environment."
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/RedteamCampaignCreate"
      responses:
        "201":
          description: The campaign, in dry run.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RedteamCampaign"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: A campaign with that name already exists in this tenant.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
  /redteam/campaigns/{campaign_id}:
    parameters:
      - name: campaign_id
        in: path
        required: true
        schema:
          type: string
          format: uuid
    get:
      tags:
        - Redteam
      operationId: getRedteamCampaign
      summary: Get one adversarial testing campaign
      description: A campaign answers 404, as an unknown id does, unless the caller can see every agent it
        names; a caller who sees only some agents does not see a campaign that names none. A user
        whose access scope is `scoped` sees only the agents granted to them, and an API key bound to
        an environment only that environment's agents.
      responses:
        "200":
          description: The campaign, with its latest run and live findings.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RedteamCampaign"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
    patch:
      tags:
        - Redteam
      operationId: updateRedteamCampaign
      summary: Update an adversarial testing campaign
      description: "Owner or manager role. Partial: an omitted field is left alone. Changing `target_ref`
        or `target_environment` puts the campaign **back into dry run** — the act that took it out
        was an authorization for a target, and it does not survive being repointed at a different
        one. A campaign answers 404 unless the caller can see every agent it names, and so does
        naming an agent the caller cannot see; a caller who sees only some agents cannot leave the
        campaign naming none (403). A user whose access scope is `scoped` sees only the agents
        granted to them, and an API key bound to an environment only that environment's agents. With
        an API key bound to an environment, an `environment_id` naming another environment, or
        `clear_environment`, is refused 403; `target_environment` is not affected. Moving the
        campaign to another environment (`environment_id`), or out of its own (`clear_environment`),
        ends its runs in flight in the same transaction: each `queued` or `running` run becomes
        `aborted` with `abort_reason: operator_stop` and a `completed_at`. A runner reports a run
        only in its own environment under a campaign that is still there, so a run left in flight
        would never end. Naming the environment the campaign is already in moves nothing and ends
        nothing. A move to another environment keeps the campaign's status. Removing the environment
        also pauses an `active` campaign, after its runs end, as deleting its environment would: a
        campaign with no environment is served to no runner, and cannot be resumed or given a run
        until it has one again. The response is the campaign as it then is."
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/RedteamCampaignUpdate"
      responses:
        "200":
          description: The updated campaign.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RedteamCampaign"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: A campaign with that name already exists in this tenant.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
    delete:
      tags:
        - Redteam
      operationId: deleteRedteamCampaign
      summary: Delete an adversarial testing campaign
      description: Owner or manager role. Its runs and findings go with it. To stop a campaign without
        losing its history, pause it instead. A campaign answers 404, as an unknown id does, unless
        the caller can see every agent it names. A user whose access scope is `scoped` sees only the
        agents granted to them, and an API key bound to an environment only that environment's
        agents.
      responses:
        "204":
          description: Deleted.
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
  /redteam/campaigns/{campaign_id}/runs:
    post:
      tags:
        - Redteam
      operationId: enqueueRedteamRun
      summary: Run a campaign now
      description: "Owner or manager role. Queues a run and returns — hence `202`, not `201`. The runner
        holds no inbound port, so it picks the run up on its next poll of `GET
        /edge-gateway/redteam/campaigns`, which is why the customer opens no path into their
        network. The run inherits the campaign's `dry_run`. A paused or archived campaign is a
        `409`, and so is a campaign with no environment (kept after its environment was deleted, or
        created without one): no runner is served it, so the run would stay queued for good. A
        campaign answers 404, as an unknown id does, unless the caller can see every agent it names.
        A user whose access scope is `scoped` sees only the agents granted to them, and an API key
        bound to an environment only that environment's agents."
      parameters:
        - name: campaign_id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "202":
          description: The queued run.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RedteamRun"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: The campaign is paused or archived, or it has no environment.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
  /redteam/campaigns/{campaign_id}/pause:
    post:
      tags:
        - Redteam
      operationId: pauseRedteamCampaign
      summary: Pause an adversarial testing campaign
      description: "Owner or manager role. The runner checks campaign status before every attempt, so
        pausing stops a run in flight within one attempt rather than at the end of it, with
        `abort_reason: campaign_paused`. A campaign answers 404, as an unknown id does, unless the
        caller can see every agent it names. A user whose access scope is `scoped` sees only the
        agents granted to them, and an API key bound to an environment only that environment's
        agents."
      parameters:
        - name: campaign_id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: The paused campaign.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RedteamCampaign"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
  /redteam/campaigns/{campaign_id}/resume:
    post:
      tags:
        - Redteam
      operationId: resumeRedteamCampaign
      summary: Resume an adversarial testing campaign
      description: "Owner or manager role. Returns the campaign to `active`. Only a campaign with an
        environment is resumed: no runner is served a campaign without one, so a campaign with no
        environment (kept after its environment was deleted, or created without one) is refused
        `409` until it is given one. A campaign answers 404, as an unknown id does, unless the
        caller can see every agent it names. A user whose access scope is `scoped` sees only the
        agents granted to them, and an API key bound to an environment only that environment's
        agents."
      parameters:
        - name: campaign_id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: The active campaign.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RedteamCampaign"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: The campaign has no environment.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
  /redteam/campaigns/{campaign_id}/disable-dry-run:
    post:
      tags:
        - Redteam
      operationId: disableRedteamCampaignDryRun
      summary: Take a campaign out of dry run
      description: >-
        Owner or manager role. Until this is called the campaign renders every attempt, hashes it
        and sends nothing — and therefore produces no findings, because the primary oracle is the
        target's own verdict and a dry run never gets one.

        The caller types the campaign's name back in `confirm_name` and the server checks it: a
        guard that lives only in the dashboard is a guard against a misclick, not against a script.
        The dashboard warns harder when `target_environment` is `production`, and the product
        guidance is to point a campaign at staging.

        The act is recorded on the campaign row and in the server log — when it happened, and which
        user did it. `dry_run_disabled_at` is served; the user id is not, because no response in
        this section carries one. A campaign that is already out of dry run is a `409`. A campaign
        answers 404, as an unknown id does, unless the caller can see every agent it names. A user
        whose access scope is `scoped` sees only the agents granted to them, and an API key bound to
        an environment only that environment's agents.
      parameters:
        - name: campaign_id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - confirm_name
              properties:
                confirm_name:
                  type: string
                  description: The campaign's `name`, exactly. A mismatch is a `400`.
      responses:
        "200":
          description: The campaign, no longer in dry run.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RedteamCampaign"
        "400":
          description: "`confirm_name` does not match the campaign name."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: The campaign is already out of dry run.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
  /redteam/runs:
    get:
      tags:
        - Redteam
      operationId: listRedteamRuns
      summary: List adversarial testing runs
      description: Newest first. All roles may read.
      parameters:
        - name: campaign_id
          in: query
          schema:
            type: string
            format: uuid
        - name: status
          in: query
          schema:
            $ref: "#/components/schemas/RedteamRunStatus"
        - name: since
          in: query
          description: Only runs queued at or after this instant.
          schema:
            type: string
            format: date-time
        - $ref: "#/components/parameters/PageSize"
        - $ref: "#/components/parameters/PageToken"
      responses:
        "200":
          description: Runs
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RedteamRunList"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
  /redteam/runs/{run_id}:
    get:
      tags:
        - Redteam
      operationId: getRedteamRun
      summary: Get one adversarial testing run
      description: A run answers 404, as an unknown id does, unless the caller can see its campaign, which
        takes seeing every agent the campaign names. A user whose access scope is `scoped` sees only
        the agents granted to them, and an API key bound to an environment only that environment's
        agents.
      parameters:
        - name: run_id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: The run.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RedteamRun"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
  /redteam/assurance:
    get:
      tags:
        - Redteam
      operationId: getRedteamAssurance
      summary: The guardrail assurance score for an agent or a policy
      description: >-
        "Of N attack classes exercised, M held", computed at read time from the latest scored run of
        each of the subject's campaigns — for an agent, the campaigns whose `target_ref.agent_id`
        names it or, failing that, whose runner identity it is; for a policy, the union over the
        agents bound to it. All roles may read.

        The denominator is what was exercised, never what was planned. A class is `held` only when a
        run could have said otherwise: the run finished, every attempt reached a decision, and none
        of them produced a finding in the class. A class the run never reached, or whose attempts no
        oracle decided, is `undecided` — never folded into `held`. Exactly one of `agent_id` or
        `policy_id` is required. An `agent_id` the caller cannot see answers 404, as an unknown
        agent does; for a `policy_id`, only the agents the caller can see are scored. A user whose
        access scope is `scoped` sees only the agents granted to them, and an API key bound to an
        environment only that environment's agents.
      parameters:
        - name: agent_id
          in: query
          schema:
            type: string
            format: uuid
        - name: policy_id
          in: query
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: The score. A subject with no scored run answers with `status` `no_runs`, not an error.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RedteamAssurance"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
  /redteam/findings/summary:
    get:
      tags:
        - Redteam
      operationId: getRedteamFindingSummary
      summary: Summarize live adversarial testing findings
      description: "The compliance view: live findings grouped by attack category and by framework
        identifier. The five framework keys the corpus ships lead, in the order the specification
        gives them; a key a corpus mapping adds later sorts after them rather than being dropped.
        All roles may read."
      parameters:
        - name: campaign_id
          in: query
          description: Restrict the summary to one campaign.
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: The summary.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RedteamFindingSummary"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
  /redteam/findings:
    get:
      tags:
        - Redteam
      operationId: listRedteamFindings
      summary: List adversarial testing findings
      description: Highest severity first, then most recently seen. Defaults to `status=live`, which is
        everything that still needs a decision — open findings and accepted risks, not resolved
        ones. All roles may read.
      parameters:
        - name: campaign_id
          in: query
          schema:
            type: string
            format: uuid
        - name: run_id
          in: query
          description: Findings whose most recent occurrence was in this run.
          schema:
            type: string
            format: uuid
        - name: agent_id
          in: query
          schema:
            type: string
            format: uuid
        - name: code
          in: query
          schema:
            $ref: "#/components/schemas/RedteamFindingCode"
        - name: category
          in: query
          schema:
            $ref: "#/components/schemas/RedteamCategory"
        - name: severity
          in: query
          schema:
            $ref: "#/components/schemas/RedteamSeverity"
        - name: status
          in: query
          schema:
            type: string
            enum:
              - open
              - accepted_risk
              - resolved
              - live
              - all
            default: live
        - name: target_environment
          in: query
          schema:
            $ref: "#/components/schemas/RedteamEnvironment"
        - name: since
          in: query
          description: Only findings whose `last_seen` is at or after this instant.
          schema:
            type: string
            format: date-time
        - name: until
          in: query
          schema:
            type: string
            format: date-time
        - $ref: "#/components/parameters/PageSize"
        - $ref: "#/components/parameters/PageToken"
      responses:
        "200":
          description: Findings
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RedteamFindingList"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
  /redteam/findings/{finding_id}:
    get:
      tags:
        - Redteam
      operationId: getRedteamFinding
      summary: Get one adversarial testing finding
      description: For a caller who sees only some agents, a finding about an agent they cannot see, or
        about no agent, answers 404, as an unknown id does. A user whose access scope is `scoped`
        sees only the agents granted to them, and an API key bound to an environment only that
        environment's agents.
      parameters:
        - name: finding_id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: The finding.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RedteamFinding"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
  /redteam/findings/{finding_id}/resolve:
    post:
      tags:
        - Redteam
      operationId: resolveRedteamFinding
      summary: Resolve an adversarial testing finding
      description: "Owner or manager role. Marks the finding `resolved` (`resolution: manual`) with the
        caller as `resolved_by`. An accepted risk may be resolved too: the compensating control can
        be removed, and then the fix has to be recordable — without that the row would hold its
        dedup key forever and the finding could never reopen. A recurrence of the same dedup key
        afterwards opens a **new** finding: the resolved row is never reopened, so a regression is
        visible rather than silent. A finding that is already resolved is a `409`. For a caller who
        sees only some agents, a finding about an agent they cannot see, or about no agent, answers
        404, and nothing changes. A user whose access scope is `scoped` sees only the agents granted
        to them, and an API key bound to an environment only that environment's agents."
      parameters:
        - name: finding_id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/RedteamResolveRequest"
      responses:
        "200":
          description: The resolved finding.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RedteamFinding"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: The finding is already resolved.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
  /redteam/findings/{finding_id}/accept-risk:
    post:
      tags:
        - Redteam
      operationId: acceptRedteamFindingRisk
      summary: Accept the risk of an adversarial testing finding
      description: "Owner or manager role. Records that somebody decided to live with the gap. This is not
        a resolution: the finding stays **live**, so the next run bumps its `occurrence_count`
        instead of filing it again, and the compliance view still counts it. Moves an open finding
        only, so accepting twice is a `409` rather than a silent re-stamp: it moves a finding that
        is `open`, and nothing else. For a caller who sees only some agents, a finding about an
        agent they cannot see, or about no agent, answers 404, and nothing changes. A user whose
        access scope is `scoped` sees only the agents granted to them, and an API key bound to an
        environment only that environment's agents."
      parameters:
        - name: finding_id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/RedteamResolveRequest"
      responses:
        "200":
          description: The finding, with the risk accepted.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RedteamFinding"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: The finding is already an accepted risk, or resolved.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
  /redteam/findings/{finding_id}/propose:
    post:
      tags:
        - Redteam
      operationId: proposeRedteamIndicator
      summary: Propose the finding's pattern for review
      description: >-
        Owner or manager role. Opens a governance review (`kind: change`, `trigger_kind: finding`)
        carrying the finding's `proposed_indicator` and the hashes that justify it — never the
        attempt. As every review, it is due `review_sla_days` after it opens and its opening is
        recorded as `governance.review.created`, as the person who proposed it.

        This is the only route a proposed DLP or injection pattern takes into a curation flow. A run
        that could write its own enforcement rules would be an unreviewed path into production
        policy, so the pattern takes effect when a reviewer approves it, and the re-run proves the
        fix.

        A finding with no `proposed_indicator`, or one not attributed to an agent, is a `422` — a
        review's subject is an agent, and a campaign can address a route without naming one. A
        finding that already has a review, or an agent that already has a pending review for this
        finding code, is a `409`. For a caller who sees only some agents, a finding about an agent
        they cannot see, or about no agent, answers 404, and nothing changes. A user whose access
        scope is `scoped` sees only the agents granted to them, and an API key bound to an
        environment only that environment's agents.
      parameters:
        - name: finding_id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "201":
          description: The review that was opened, and the linked finding.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RedteamProposeResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: A governance review is already open for this finding.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "422":
          description: The finding carries no proposed pattern, or is not attributed to an agent.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "500":
          description: The review could not be opened — the tenant's governance settings, which give it its
            due date, could not be read, or the write failed. Nothing was opened; the client
            retries.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
  /models:
    get:
      operationId: listModels
      tags:
        - MLModels
      summary: List ML models
      description: Returns ML models for the tenant, filtered by entitled plan. Supports model_type filter
        and pagination.
      parameters:
        - name: model_type
          in: query
          schema:
            type: string
            enum:
              - tcn
              - lstm
              - transformer
              - gnn
              - vae
              - vae-tenant
        - name: page
          in: query
          schema:
            type: integer
            default: 1
            minimum: 1
        - name: page_size
          in: query
          schema:
            type: integer
            default: 20
            minimum: 1
            maximum: 100
      responses:
        "200":
          description: Paginated list of ML models
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MLModelList"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"
    post:
      operationId: createModel
      tags:
        - MLModels
      summary: Register a new ML model version
      description: Creates a new model version for the tenant. Viewers cannot create models.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateMLModelRequest"
      responses:
        "201":
          description: Model created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MLModel"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "409":
          description: A model with this type and version already exists
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"
  /models/{id}:
    get:
      operationId: getModel
      tags:
        - MLModels
      summary: Get ML model by ID
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: Model details
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MLModel"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"
  /models/{id}/status:
    put:
      operationId: updateModelStatus
      tags:
        - MLModels
      summary: Update model status
      description: Updates a model's status. Viewers cannot update model status.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - status
              properties:
                status:
                  type: string
                  enum:
                    - pending
                    - active
                    - deprecated
                    - failed
      responses:
        "200":
          description: Updated model
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MLModel"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"
  /models/{id}/enabled:
    put:
      operationId: toggleModelEnabled
      tags:
        - MLModels
      summary: Toggle model enabled state
      description: >
        Toggles a model's enabled state for the requesting tenant. For custom (tenant-scoped)
        models, the model's own enabled field is updated. For global models, a per-tenant preference
        is stored — other tenants are not affected. TCN models cannot be toggled (controlled via
        intent policies). Viewers are blocked.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - enabled
              properties:
                enabled:
                  type: boolean
      responses:
        "200":
          description: Updated model
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MLModel"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"
  /insights/summary:
    get:
      operationId: getInsightsSummary
      tags:
        - Insights
      summary: Get insights summary
      description: Returns aggregate ML insights for the tenant — anomaly count, average drift, active
        models, and monitored agents.
      responses:
        "200":
          description: Insights summary
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/InsightsSummary"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"
  /insights/agent-risks:
    get:
      operationId: getAgentRisks
      tags:
        - Insights
      summary: Get per-agent risk scores
      description: Returns per-agent risk scores with anomaly counts and trend direction. Ordered by risk
        score descending.
      parameters:
        - name: limit
          in: query
          schema:
            type: integer
            default: 10
            minimum: 1
            maximum: 50
      responses:
        "200":
          description: Agent risk scores
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/AgentRiskScore"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"
  /insights/drift-trends:
    get:
      operationId: getDriftTrends
      tags:
        - Insights
      summary: Get daily drift score trends
      description: Returns daily drift score aggregations (average, max, event count) for the tenant.
      parameters:
        - name: days
          in: query
          schema:
            type: integer
            default: 14
            minimum: 1
            maximum: 90
      responses:
        "200":
          description: Daily drift trends
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/DriftTrend"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"
  /academy/courses:
    get:
      operationId: listAcademyCourses
      tags:
        - Academy
      summary: List published Academy courses with caller progress
      description: Returns the published course catalog plus the calling user's per-course progress
        (passed module count) and certificate serial where earned. Draft (unpublished) courses are
        invisible to learners. Open to every authenticated role — the Academy is free, with no plan
        gating and no viewer write-restriction. passed_count only counts modules that still exist in
        the course manifest.
      responses:
        "200":
          description: Course catalog with progress
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AcademyCourseList"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"
  /academy/courses/{slug}:
    get:
      operationId: getAcademyCourse
      tags:
        - Academy
      summary: Get full course content with progress and unlock state
      description: "Returns the full course: module metadata, lesson markdown (GFM), and exam questions
        (without answer keys — see AcademyQuestion), plus the caller's passed module ids and the
        derived unlock map. A module is unlocked iff it is first in manifest order or its manifest
        predecessor is passed. Draft courses return 404 identically to unknown slugs, so their
        existence never leaks to learners."
      parameters:
        - name: slug
          in: path
          required: true
          schema:
            type: string
          description: Course slug (e.g. foundations, aarm-practitioner)
      responses:
        "200":
          description: Full course detail
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AcademyCourseDetail"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"
  /academy/courses/{slug}/modules/{module_id}/attempts:
    post:
      operationId: submitAcademyExamAttempt
      tags:
        - Academy
      summary: Submit a module exam attempt
      description: "Grades the submitted answers synchronously and records the attempt. Modules unlock
        sequentially: attempting a module whose manifest predecessor is not passed returns 409
        (retakes of already-passed modules are always allowed). Answers are 0-based option indexes,
        one per question in question order — a count mismatch or out-of-range index returns 400.
        When the attempt completes the course (every module passed), a certificate is issued
        idempotently — one per (user, course) — and returned inline. If certificate issuance fails
        after a completing attempt, the attempt is still recorded and the response is 500;
        re-submitting retries issuance."
      parameters:
        - name: slug
          in: path
          required: true
          schema:
            type: string
          description: Course slug
        - name: module_id
          in: path
          required: true
          schema:
            type: string
          description: Stable module slug from the course manifest (never a positional index)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/AcademyAttemptRequest"
      responses:
        "200":
          description: Graded attempt result
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AcademyAttemptResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: Module is locked — its manifest predecessor has not been passed yet (message names the
            module to pass first).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"
  /academy/certificates:
    get:
      operationId: listAcademyCertificates
      tags:
        - Academy
      summary: List the caller's earned certificates
      responses:
        "200":
          description: Earned certificates
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AcademyCertificateList"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"
  /academy/certificates/{serial}/pdf:
    get:
      operationId: downloadAcademyCertificatePdf
      tags:
        - Academy
      summary: Download one of the caller's certificates as a PDF
      description: >-
        Renders one of the caller's own certificates as a one-page landscape PDF: the holder's name,
        the course, the issue date (UTC), the certificate ID (its serial), "MITRITY Academy", and
        the address of the certificate's public verification page, printed and linked.


        The name is the display name on the caller's account, the one the certificate list returns
        as `holder_name`. A letter the certificate's fonts lack in a script they do cover is drawn
        without that accent (Ł as L, ș as s). An account without a name, or with a name written in a
        script the certificate cannot draw (Arabic, Hebrew, Devanagari and Thai among them), gets a
        certificate made out to no one: it states the course it was awarded for, and its
        verification page confirms it. The certificate never carries the sign-in email address.
        Setting a display name (`PUT /users/{id}` on the caller's own account) puts it on the next
        download.


        Only the holder can download a certificate: another learner's serial, whatever the caller's
        role, answers 404 exactly as an unknown serial does. The PDF is rendered on request, so
        downloads have a per-tenant budget of their own (see the rate limits).
      parameters:
        - $ref: "#/components/parameters/AcademyCertificateSerial"
      responses:
        "200":
          description: The certificate
          headers:
            Content-Disposition:
              schema:
                type: string
              description: attachment; filename="<serial>.pdf"
            Cache-Control:
              schema:
                type: string
              description: "`no-store`: the PDF names its holder, and no cache keeps it."
          content:
            application/pdf:
              schema:
                type: string
                format: binary
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          description: No certificate of the caller's has this serial.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"
  /academy/certificates/{serial}/visibility:
    put:
      operationId: setAcademyCertificateNameVisibility
      tags:
        - Academy
      summary: Show or hide the caller's name on a certificate's verification page
      description: "Records whether the public verification page of one of the caller's own certificates
        shows the caller's name, and responds with the certificate as it now stands. Every
        certificate starts with the name hidden: the page then confirms the certificate, the course,
        the issue date and the certificate ID, and nothing about who holds it. Another learner's
        serial, whatever the caller's role, answers 404 exactly as an unknown serial does."
      parameters:
        - $ref: "#/components/parameters/AcademyCertificateSerial"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/AcademyCertificateVisibilityRequest"
      responses:
        "200":
          description: The certificate, with the choice recorded
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AcademyCertificate"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          description: No certificate of the caller's has this serial.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"
  /academy/invites:
    post:
      operationId: createAcademyInvite
      tags:
        - Academy
      summary: Invite a learner to the tenant's Academy workspace
      description: "Requires the owner or manager role. Creates an academy-only placeholder user
        (role=viewer, academy_only=true — an Academy invite can never mint an elevated or
        seat-consuming row) and dispatches the invitation email asynchronously; email failure is
        non-fatal (the invite exists and can be resent), hence 202. Academy learners never consume a
        paid platform seat: there is deliberately NO max_users seat check — the only bound is the
        anti-abuse learner cap (currently 500 academy-only users per tenant, registered + pending)."
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/AcademyInviteRequest"
      responses:
        "202":
          description: Invitation created; email delivery is pending
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AcademyInviteResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "409":
          description: 'Duplicate email — a user with this email already exists in the organization
            (registered platform user, registered learner, or any pending invite) — or the Academy
            learner cap is reached. The cap case carries the machine-readable body field code:
            "academy_learner_cap_reached"; the duplicate case has no machine code.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"
  /academy/invites/{user_id}/resend:
    post:
      operationId: resendAcademyInvite
      tags:
        - Academy
      summary: Resend a pending Academy invitation email
      description: Requires the owner or manager role. Only valid for PENDING Academy invitations —
        placeholder users that are academy_only and have not registered yet.
      parameters:
        - name: user_id
          in: path
          required: true
          schema:
            type: string
            format: uuid
          description: The placeholder user id returned when the invite was created
      responses:
        "202":
          description: Invitation email resent
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AcademyInviteResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: Target is not a pending Academy invitation. Either the target is a platform user (body
            carries code "not_academy_user" — platform invitations are managed via platform user
            management, not the Academy surface), or the learner has already registered (no pending
            invitation; no machine code).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"
  /academy/invites/{user_id}:
    delete:
      operationId: revokeAcademyInvite
      tags:
        - Academy
      summary: Revoke a pending Academy invitation
      description: Requires the owner or manager role. Deletes the pending placeholder user. Registered
        learners cannot be revoked here — remove them via DELETE /academy/members/{user_id} instead.
      parameters:
        - name: user_id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "204":
          description: Invitation revoked
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: Target is not a pending Academy invitation — a platform user (body carries code
            "not_academy_user") or an already-registered learner.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"
  /academy/members/{user_id}:
    delete:
      operationId: removeAcademyMember
      tags:
        - Academy
      summary: Remove an Academy learner from the tenant
      description: 'Requires the owner or manager role. Removes an academy-only learner (pending or
        registered): their sign-in account is deleted first where one exists, then their user
        record; the removal takes effect at once, and a removal notification email is sent (a
        failure to send does not fail the request). A target that is not an academy-only user is
        refused 409 with code "not_academy_user" before anything is deleted — platform users (who
        may hold a paid seat) cannot be removed through the Academy and are managed only through
        platform user management (DELETE /users/{user_id}). Self-removal returns 400.'
      parameters:
        - name: user_id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "204":
          description: Learner removed
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: Removal refused. The target is a platform user (the body carries code
            "not_academy_user"; platform users are managed through platform user management, never
            the Academy), or the target is an owner that does not pass the academy-only check, or
            deleting the learner's sign-in account failed (retry later).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"
  /academy/admin/roster:
    get:
      operationId: getAcademyRoster
      tags:
        - Academy
      summary: Get the per-course training roster
      description: Requires the owner or manager role. Returns every academy-relevant member (academy-only
        users, pending invites, and platform users who have attempted the course) with per-module
        pass state, derived status, and certificate. Unlike the learner API, DRAFT courses are
        visible here — org admins get pre-publish visibility, with the published flag exposed so
        clients can badge drafts.
      parameters:
        - name: course
          in: query
          required: true
          schema:
            type: string
          description: Course slug to build the roster for
      responses:
        "200":
          description: Roster for the requested course
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AcademyRosterResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"
  /academy/admin/roster/export:
    get:
      operationId: exportAcademyRoster
      tags:
        - Academy
      summary: Export the full Academy roster as CSV
      description: 'Requires the owner or manager role. Streams a long-format CSV covering ALL courses
        (published and draft): one row per (course, member, module). The exact column header is
        "course,member_email,member_name,status,module_id,module_title,passed,first_passed_at,certificate_serial,certificate_issued_at"
        — column order is a contract with customer tooling and is never reordered. passed is
        true/false; first_passed_at and the certificate columns are RFC 3339 timestamps or empty.
        All data is fetched before the first byte is written, so failures yield a clean 500 rather
        than a truncated file.'
      responses:
        "200":
          description: CSV attachment (academy-roster.csv)
          headers:
            Content-Disposition:
              schema:
                type: string
              description: attachment; filename="academy-roster.csv"
          content:
            text/csv:
              schema:
                type: string
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"
  /academy/account:
    delete:
      operationId: deleteAcademyAccount
      tags:
        - Academy
      summary: Delete the Academy account (owner only)
      description: "OWNER ONLY — stricter than the rest of the org-admin operations, which managers may
        also use: a manager is refused 403, since deleting the account is a higher privilege than
        learner management (as tenant deletion is), and so is an API key bound to an environment, an
        owner's key included. The effect is decided from the tenant's stored state, never the
        client, with one of two outcomes. Academy-only tenant (`signup_source` `academy`, billed
        through Stripe, and no subscription; an Academy sign-up billed through a cloud marketplace
        has the platform and is a combined tenant): the tenant exists only for the Academy, so the
        ENTIRE tenant is deleted — every user and their sign-in account, with all Academy exam
        attempts and certificates — and the response is {\"deleted\":\"tenant\"}; the caller's own
        account is gone, so the client must end the session. Combined tenant (carries a platform
        subscription and/or platform users): ONLY the Academy training data is removed — exam
        attempts, certificates, and the academy-only learner accounts, each with its sign-in account
        — while the tenant, its platform users, and the subscription are all KEPT, and the response
        is {\"deleted\":\"academy_data\",\"learners_removed\":<n>}; the caller stays signed in."
      responses:
        "200":
          description: "Account deleted. The deleted field distinguishes the two outcomes: \"tenant\" (the
            whole academy-only organization was removed — the caller's session must end) or
            \"academy_data\" (only Academy data and academy-only learners were removed from a
            combined tenant, with learners_removed counting the learner accounts deleted)."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AcademyAccountDeleteResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: "Deletion aborted before anything was deleted, because a sign-in account could not be
            deleted (retry later). Applies to both outcomes: the full-tenant deletion answers as
            tenant deletion does, and the combined path stops at the first learner whose sign-in
            account cannot be deleted — no orphaned identities and no partial teardown."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"
components:
  securitySchemes:
    BearerAuth:
      type: http
      scheme: bearer
      bearerFormat: Firebase ID Token or management API key (mk_ prefix)
      description: >
        Accepts either a Firebase ID token (interactive sessions, MFA-enforced) or a management API
        key with the `mk_` prefix (programmatic access). An API key acts with its creating user's
        role. Additionally, a key MAY carry scopes that further narrow it to `<resource>:<action>`
        grants — where `resource` is the leading path segment under `/api/v1` (e.g. `policies`) and
        `action` is `read`, `write`, or `*`, with `*` wildcards allowed on either side (e.g.
        `*:read`, `policies:*`, or `*` for full access). Scopes only ever narrow authority: a key
        scoped `["*:read"]` is permitted every GET and denied every mutating request regardless of
        its user's role. A key with no scopes is unrestricted (role-only). Read-only credentials
        issued to agents that process untrusted data SHOULD use `["*:read"]` so the token itself —
        not just a tool's allowlist — enforces the read boundary.
    AgentKeyAuth:
      type: apiKey
      in: header
      name: X-Agent-Key
      description: Agent API key issued during agent registration
  headers:
    NoStore:
      description: "`no-store`: the answer can change at any time, so no cache keeps it."
      schema:
        type: string
    NoIndex:
      description: "`noindex`: the answer is never for a search index."
      schema:
        type: string
  parameters:
    PageSize:
      name: page_size
      in: query
      schema:
        type: integer
        default: 20
        maximum: 100
    PageToken:
      name: page_token
      in: query
      schema:
        type: string
      description: Opaque cursor for next page
    Limit:
      name: limit
      in: query
      description: Max rows per page for offset-paginated list endpoints (the AIBOM list, the drift feed,
        a policy's version history, and others that reference this parameter). Out-of-range values
        silently fall back to the default rather than 400.
      schema:
        type: integer
        format: int32
        minimum: 1
        maximum: 200
        default: 50
    Offset:
      name: offset
      in: query
      description: Zero-based row offset for offset-paginated list endpoints. Out-of-range values silently
        fall back to 0.
      schema:
        type: integer
        format: int32
        minimum: 0
        maximum: 1000000
        default: 0
    PolicyID:
      name: policy_id
      in: path
      required: true
      description: The policy's UUID.
      schema:
        type: string
        format: uuid
    PolicyVersionNumber:
      name: version
      in: path
      required: true
      description: A policy version number (1-based, contiguous per policy).
      schema:
        type: integer
        format: int32
        minimum: 1
    RuleID:
      name: rule_id
      in: path
      required: true
      description: The policy rule's UUID.
      schema:
        type: string
        format: uuid
    ComplianceFrameworkSlug:
      name: slug
      in: path
      required: true
      description: A framework library slug, e.g. `eu_ai_act` or `nist_ai_rmf`.
      schema:
        type: string
    ComplianceControlID:
      name: control_id
      in: path
      required: true
      description: A control id from the library, e.g. `EUAIA-12` or `NISTAI-GOVERN-1.1`.
      schema:
        type: string
    ComplianceRiskID:
      name: id
      in: path
      required: true
      schema:
        type: string
        format: uuid
    AIPolicyStatementID:
      name: id
      in: path
      required: true
      schema:
        type: string
        format: uuid
    AcademyCertificateSerial:
      name: serial
      in: path
      required: true
      description: One of the caller's certificate serials, as the certificate list returns it.
      schema:
        type: string
      example: MITRITY-ACAD-2026-F-7XK9
  responses:
    BadRequest:
      description: Invalid request
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    Unauthorized:
      description: Missing or invalid authentication
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    Forbidden:
      description: >-
        Insufficient permissions. An account with Academy access only — one that signed up through
        the Academy, is billed through Stripe and has no subscription — is refused every platform
        endpoint, through an API key or a session alike, with the code `academy_only_account` and
        the message "This account has Academy access only. Choose a plan to use the platform." It
        keeps the Academy endpoints, the list and the update of its people (`GET /users`, `PUT
        /users/{user_id}`), its organization (`GET /tenants`), and the checkout (`POST
        /billing/checkout`, `GET /billing/subscription`).


        A suspended account is refused every endpoint, the Academy's included, through an API key or
        a session alike, with the code `tenant_suspended` and the message "This account is
        suspended. Contact support@mitrity.com." in both `error` and `message`. It keeps `GET
        /users` and `GET /tenants`, whose tenant carries `suspended_at`. A suspension takes effect
        within 5 minutes; lifting one takes effect on the next request. The suspension is answered
        before the Academy refusal and before any role check. The edge gateway is not refused: the
        account's edge keeps enforcing the policies it has.


        An account whose enterprise contract has ended is refused the platform for the contract's
        30-day grace period, through an API key or a session alike, with the code `contract_ended`,
        the message "This account's contract has ended. Your data is kept until {date}. Contact
        sales@mitrity.com to renew." in both `error` and `message` (the date written as 12 November
        2026, in UTC), and `contract_grace_ends_at`. It keeps the Academy endpoints, `PUT
        /users/{user_id}`, `GET /users` and `GET /tenants`, whose tenant carries
        `contract_grace_ends_at`. The refusal is answered after the suspension and before the
        Academy refusal and any role check. A renewal of the contract takes effect on the next
        request. The edge gateway is not refused: the account's edge keeps enforcing the policies it
        has.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    NotFound:
      description: Resource not found
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    RateLimited:
      description: |
        Too many requests. Requests are counted in one-minute windows:

        - The dashboard and the API: 100 per person for a dashboard session,
          with all of a tenant's sessions together held to 1,000, and 100 per
          tenant for its API keys, which share one budget.
        - Invitations, repository scans, evidence bundles and compliance
          recomputes: 15 per tenant, on top of the above.
        - Academy certificate PDF downloads: 30 per tenant, on top of the
          above, and apart from the budget of the previous line.
        - The edge gateway: 1,000 per client address.
        - The `/auth` endpoints, signup checkout, shared report links,
          Academy certificate verification and the enterprise lead form: 10
          per client address.

        Responses carry `X-RateLimit-Limit` and `X-RateLimit-Remaining`; a 429
        carries `Retry-After`, the seconds until the window resets.
      headers:
        X-RateLimit-Limit:
          description: "The requests the window allows the caller: for a dashboard session, its own budget,
            even when the tenant's ceiling is what refused it."
          schema:
            type: integer
        X-RateLimit-Remaining:
          description: "Requests left in the window; 0 on a 429, including one the tenant's session ceiling
            refused while the caller's own window still had requests left. The two headers then
            disagree by design: `X-RateLimit-Limit` still reports the caller's own budget, so limit
            minus remaining overstates what the caller used."
          schema:
            type: integer
        Retry-After:
          description: Seconds until the window that refused the request resets.
          schema:
            type: integer
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    InternalError:
      description: Internal server error
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    UnprocessableConstraints:
      description: "The request was well-formed but a rule field failed validation. Either a `constraints`
        document — an unrecognised key, or a value that is not a string, a number, or an array of
        strings (see `RuleConstraints`) — or a `tool_pattern` the edge could never match: a `regex:`
        pattern that does not compile or exceeds 1024 bytes, or a glob that does not start with a
        known surface (`mcp`, `llm`, `http`, `egress`, `delegate`, `builtin`, `shell`), or that puts
        a wildcard in the surface position (`*:read_file`). The body carries a `fields` array of
        `{field, message}` naming each offending field — `tool_pattern`, `constraints.<key>`, or,
        for inline rules on a policy create, `rules[<i>].tool_pattern` /
        `rules[<i>].constraints.<key>` — and the message says what a valid value looks like."
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
  schemas:
    Error:
      type: object
      required:
        - code
        - message
      properties:
        code:
          type: string
        message:
          type: string
        details:
          type: object
        contract_grace_ends_at:
          type: string
          format: date-time
          description: "Only with the code `contract_ended`: when the grace period of the account's ended
            contract ends. The account's data is deleted then unless the contract is renewed."
    Tenant:
      type: object
      required:
        - id
        - name
        - slug
        - plan
        - subscription_status
        - signup_source
        - has_stripe_subscription
        - billing_source
        - max_agents
        - max_users
        - max_policies
        - saml_enabled
        - created_at
        - updated_at
        - suspended_at
        - contract_grace_ends_at
      properties:
        id:
          type: string
          format: uuid
        name:
          type: string
        slug:
          type: string
        plan:
          type: string
          enum:
            - starter
            - pro
            - enterprise
        subscription_status:
          type: string
          enum:
            - trialing
            - active
            - past_due
            - canceled
            - unpaid
        signup_source:
          type: string
          enum:
            - platform
            - academy
          description: Origin surface the tenant signed up from — "platform" (the default) or "academy". An
            academy tenant uses the free Academy without a platform subscription, and the web app
            uses this to take its users to the Academy.
        has_stripe_subscription:
          type: boolean
          description: Derived — whether the tenant has an active Stripe subscription. The raw Stripe
            subscription ID is intentionally not exposed on this object.
        billing_source:
          type: string
          enum:
            - stripe
            - aws_marketplace
            - azure_marketplace
            - gcp_marketplace
          description: "Who bills the tenant: Stripe, or a cloud marketplace. A tenant a marketplace bills has
            no Stripe subscription and is a customer all the same. With `signup_source` and
            `has_stripe_subscription` it decides whether the tenant has the Academy only: signed up
            through the Academy, billed through Stripe, and no Stripe subscription."
        max_agents:
          type: integer
        max_users:
          type: integer
        max_policies:
          type: integer
        saml_enabled:
          type: boolean
        saml_email_domain:
          type: string
          description: The email domain whose people sign in through the tenant's identity provider. Omitted
            when none is set. The identity provider's details and `saml_provider_id` are not
            returned.
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        suspended_at:
          type:
            - string
            - "null"
          format: date-time
          description: When MITRITY suspended the account; null when it is not suspended. A suspended account
            is refused every endpoint but `GET /users` and `GET /tenants` with 403
            `tenant_suspended`, and a client shows "This account is suspended. Contact
            support@mitrity.com." The reason for the suspension is not returned.
        contract_grace_ends_at:
          type:
            - string
            - "null"
          format: date-time
          description: "While the account's enterprise contract has ended and its 30-day grace period runs,
            when that period ends: the account's data is deleted then (an account that signed up
            through the Academy keeps the Academy) unless the contract is renewed. Null otherwise.
            Until then the account is refused the platform with 403 `contract_ended` and keeps the
            Academy, and a client shows the contract-ended screen."
    User:
      type: object
      required:
        - id
        - tenant_id
        - email
        - role
        - access_scope
        - academy_only
        - mfa_method
        - mfa_enrolled
        - onboarding_completed
        - auth_provider
        - status
        - created_at
        - updated_at
      properties:
        id:
          type: string
          format: uuid
        tenant_id:
          type: string
          format: uuid
        email:
          type: string
        display_name:
          type: string
          description: Omitted when the user has none, as a pending invitation does.
        avatar_url:
          type: string
          description: Omitted when the user has none.
        role:
          type: string
          enum:
            - owner
            - manager
            - member
            - viewer
        access_scope:
          type: string
          enum:
            - all
            - scoped
          description: What the user sees of the tenant. `all` sees every agent and environment. `scoped` sees
            only the agents granted to the user, with the records about them, and works only in the
            environments granted to the user. An owner is never scoped, whatever this field holds.
        academy_only:
          type: boolean
          description: "Whether the user is an Academy learner: invited through the Academy, holding no seat,
            and kept to the viewer role. An owner or a manager gives a learner a seat and a platform
            role with `POST /users/{user_id}/platform-access`."
        mfa_method:
          type: string
          enum:
            - email
            - totp
        mfa_enrolled:
          type: boolean
        onboarding_completed:
          type: boolean
          description: Whether the user has finished the dashboard's first-run onboarding.
        auth_provider:
          type: string
          enum:
            - password
            - google
            - saml
        status:
          type: string
          enum:
            - active
            - pending
          description: "`pending` for an invitation the invited person has not accepted yet, `active`
            otherwise."
        last_login_at:
          type: string
          format: date-time
          description: Omitted until the user first signs in.
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    ResourceGrant:
      type: object
      description: One agent granted to a user whose access scope is `scoped`.
      required:
        - id
        - user_id
        - tenant_id
        - resource_type
        - resource_id
        - created_at
      properties:
        id:
          type: string
          format: uuid
        user_id:
          type: string
          format: uuid
        tenant_id:
          type: string
          format: uuid
        resource_type:
          type: string
          enum:
            - agent
        resource_id:
          type: string
          format: uuid
          description: The granted agent.
        created_at:
          type: string
          format: date-time
    EnvironmentAccess:
      type: object
      description: One environment a user whose access scope is `scoped` may work in.
      required:
        - id
        - user_id
        - tenant_id
        - environment_id
        - environment_name
        - environment_slug
        - created_at
      properties:
        id:
          type: string
          format: uuid
        user_id:
          type: string
          format: uuid
        tenant_id:
          type: string
          format: uuid
        environment_id:
          type: string
          format: uuid
        environment_name:
          type: string
        environment_slug:
          type: string
        created_at:
          type: string
          format: date-time
    EnvironmentDeletionPreview:
      type: object
      description: "What deleting an environment keeps: its compliance reports, which become
        organization-level reports, and the share links on them that still open."
      required:
        - reports_kept
        - reports_listed
        - live_share_links
      properties:
        reports_kept:
          type: integer
          format: int64
          minimum: 0
          description: The environment's compliance reports, a report deleted from the reports list included.
            The deletion keeps them as organization-level reports, with their evidence and share
            links. This is the count the deletion records in the audit trail.
          example: 4
        reports_listed:
          type: integer
          format: int64
          minimum: 0
          description: "Those of the environment's compliance reports that are still in the reports list, not
            deleted from it: the reports the delete confirmation names. Never more than
            `reports_kept`."
          example: 3
        live_share_links:
          type: integer
          format: int64
          minimum: 0
          description: "The share links on those reports that still open: not revoked, not expired, under
            their view limit, on a report that is ready and not deleted. They keep working after the
            deletion until they expire or are revoked."
          example: 2
    UserList:
      type: object
      description: One page of users.
      required:
        - data
        - total
        - limit
        - offset
      properties:
        data:
          type: array
          items:
            $ref: "#/components/schemas/User"
        total:
          type: integer
          format: int64
          description: The users that match the request's `search`, across all pages.
        limit:
          type: integer
          description: The page size the request was answered with.
        offset:
          type: integer
          description: The offset the request was answered with.
    Agent:
      type: object
      properties:
        id:
          type: string
          format: uuid
        tenant_id:
          type: string
          format: uuid
        name:
          type: string
        agent_type:
          type: string
          enum:
            - llm_agent
            - code_interpreter
            - workflow_bot
            - rag_agent
            - custom
          default: custom
        description:
          type: string
        mission_scope:
          type: string
          nullable: true
          description: The intent this agent is authorized for. Null for a SaaS-discovered agent until it is
            governed; always set for runtime agents.
        status:
          type: string
          enum:
            - registered
            - active
            - suspended
            - archived
            - revoked
        agent_key_prefix:
          type: string
          description: First 8 chars of agent key for identification
        mesh_namespace:
          type: string
          nullable: true
          maxLength: 63
          pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$
          description: >
            Kubernetes namespace the Mitrity Mesh Authorizer maps to this agent via its SPIFFE
            identity. DNS-1123 label. Null ⇒ agent is not mesh-bound. Trust domain is
            environment-level and not stored here. Must be set together with mesh_service_account;
            the pair is unique per tenant.
          example: payments
        mesh_service_account:
          type: string
          nullable: true
          maxLength: 253
          pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$
          description: >
            Kubernetes ServiceAccount the Mitrity Mesh Authorizer maps to this agent via its SPIFFE
            identity. DNS-1123 subdomain. Null ⇒ agent is not mesh-bound. Must be set together with
            mesh_namespace; the (namespace, ServiceAccount) pair is unique per tenant.
          example: checkout-bot
        is_mesh_edge:
          type: boolean
          default: false
          description: >
            When true, this agent is its environment's mesh-edge service identity: on heartbeat it
            additionally receives the mission profiles of every mesh-bound agent in its environment,
            so a centralized mesh-mode edge (the LLM Gateway in mesh mode, or the Mitrity Mesh
            Authorizer) can resolve the whole fleet's (namespace, ServiceAccount) → agent identity
            per request. Delivery is scoped to the agent's own environment (least privilege).
            Settable only via this API, never the edge self-registration (X-Agent-Key) path.
        is_runtime_observer:
          type: boolean
          default: false
          description: >
            When true, this agent is a host-observer credential: its key authenticates
            `mitrity-host-observer` instances, which receive the runtime contexts of every agent in
            the agent's environment when they start (the roster). Scoped to the agent's own
            environment (least privilege). It is not an interception edge and is never served a
            mission profile. Settable only through this API, never by an edge registering itself.
        platform:
          type: string
          enum:
            - aws
            - azure
            - gcp
            - on-prem
            - unknown
          default: unknown
        region:
          type: string
        version:
          type: string
        last_heartbeat:
          type: string
          format: date-time
        policy_id:
          type: string
          format: uuid
          nullable: true
          description: ID of the governance policy assigned to this agent. Null for a SaaS-discovered agent
            that has not been governed yet; always set for runtime agents and for any agent with
            governance_state=governed.
        metadata:
          type: object
        created_at:
          type: string
          format: date-time
        source:
          type: string
          enum:
            - runtime
            - github
            - salesforce
            - m365
            - snowflake
          default: runtime
          description: Origin of the agent. `runtime` = registered via an edge/gateway; the others are
            discovered inside a connected SaaS platform.
        external_id:
          type: string
          nullable: true
          description: Stable identity of the agent on its source platform (e.g. a GitHub Actions workflow
            path / coding-agent id). Null for runtime agents.
        external_url:
          type: string
          nullable: true
          description: Deep link to the agent on its source platform's console.
        governance_state:
          type: string
          enum:
            - discovered
            - governed
            - suppressed
          default: governed
          description: Triage lifecycle. `discovered` (found on a platform, not yet governed), `governed` (a
            policy is assigned / actively enforced), `suppressed` (intentionally ignored). Move to
            `governed` via POST /agents/{agent_id}/assign-policy; to `suppressed` via POST
            /agents/{agent_id}/suppress.
        governance_mode:
          type: string
          enum:
            - inline
            - constrained
            - detect
          readOnly: true
          description: COMPUTED at read time from `source` and the assigned policy's `override_mode` — never
            stored (the enforcement mode is set on the policy, not on the agent). `inline` = a
            runtime agent whose policy override_mode is active (enforced in-band); `constrained` = a
            SaaS agent with a policy assigned (governed out-of-band via the connector); `detect` =
            observe-only (a runtime agent whose policy is not active, or any agent with no policy).
        lifecycle_status:
          $ref: "#/components/schemas/AgentLifecycleStatus"
        criticality:
          type: string
          enum:
            - low
            - medium
            - high
            - critical
          nullable: true
          description: Business criticality, set by a person during assessment. Null ⇒ not classified, and
            reported as such.
        risk_tier:
          type: string
          enum:
            - minimal
            - limited
            - high
            - unacceptable
          nullable: true
          description: Risk classification, set by a person during assessment. Null ⇒ not classified yet, and
            reported as *not classified*, never as a pass.
        use_case:
          type: string
          nullable: true
          maxLength: 200
          description: One line describing what the agent is for, in the operator's words.
        lifecycle_changed_at:
          type: string
          format: date-time
          nullable: true
          description: When the lifecycle axis last moved.
        lifecycle_changed_by:
          type: string
          format: uuid
          nullable: true
          description: "Who last moved the lifecycle axis. Null when it has never moved, or when a background
            sweeper moved it — which is honest: nobody did. The full history, with the reason, is in
            the audit trail."
        next_review_due_at:
          type: string
          format: date-time
          nullable: true
          description: When the next periodic review falls due. Set on approval from the tenant's
            `periodic_review_days`. Null ⇒ none scheduled.
        open_review_id:
          type: string
          format: uuid
          nullable: true
          readOnly: true
          description: "COMPUTED at read time: the agent's pending governance review, so the dashboard can
            link a badge straight to the queue item. Null when no review is open."
        policy_versions:
          $ref: "#/components/schemas/AgentPolicyVersions"
          description: "COMPUTED at read time on `GET /agents/{agent_id}` only: the bound policy's current
            version, the version the approval of record names, the version the edge last reported
            enforcing, and whether the two differ. Absent when the agent has no policy."
        last_signal:
          $ref: "#/components/schemas/AgentLastSignal"
        platform_metadata:
          type: object
          description: Non-secret platform-specific attributes captured at discovery.
        execution_coverage_state:
          $ref: "#/components/schemas/ExecutionCoverageState"
          description: COMPUTED at read time (never stored) on list and single-agent reads; omitted from write
            responses. The summary the agents list renders as the coverage badge. See
            `ExecutionCoverageState` for the rules.
        execution_coverage:
          $ref: "#/components/schemas/ExecutionCoverage"
          description: The full posture — attested axis, observed axis, open findings with remediation.
            Present on `GET /agents/{agent_id}` only; list items carry `execution_coverage_state`
            alone. Also served on its own by `GET /agents/{agent_id}/execution-coverage`.
    AgentLastSignal:
      type: object
      description: Polymorphic "last signal" — the most recent time MITRITY heard from or about this
        agent, and how. For a runtime agent this reflects its heartbeat; for a SaaS agent it
        reflects the last connector sync or imported audit event.
      properties:
        type:
          type: string
          enum:
            - heartbeat
            - sync
            - audit_event
          description: What kind of signal was last seen.
        at:
          type: string
          format: date-time
          nullable: true
          description: When the last signal was seen; null if none yet.
    ExecutionCoverageState:
      type: string
      enum:
        - governed
        - partial
        - degraded
        - unattested
        - unknown
      description: >
        Shared by the response fields (where it is computed, never written by

        a client) and the `execution_coverage` list filter (where a client

        sends one value to match against). COMPUTED at read time from the runtime attestation
        (self-asserted), the

        execution stats the edge reports (observed) and the open conformance

        findings — never stored on the agent. Rules, in precedence order:


        - `unknown` — nothing to assess against: the runtime has not attested,
          and either no framework is named (`framework_slug`, the MCP
          `clientInfo.name`) or the named framework has no catalogued built-in
          execution tools, so there is nothing an attestation could cover.
          Not a pass and not a failure.
        - `unattested` — the framework has built-in execution tools
          (as the framework catalog lists them) and the runtime has never
          attested: the hook or adapter is not installed, or never reached the
          edge. Finding `attestation_missing`.
        - `degraded` — attested, and at least one open finding of severity
          `high`. Only COVERAGE findings count: the state is computed from the
          coverage codes the recompute itself produces, so an open
          `policy_drift_unapproved` finding (severity `high`, raised by the
          policy-version comparison) never moves the state and never appears in
          `ExecutionCoverage.findings`.
        - `partial` — attested, and at least one open finding of severity
          `medium` or `low`. `no_corroborating_traffic` is `medium`, so an
          attestation that traffic has not yet corroborated reads `partial`,
          never `governed`.
        - `governed` — attested, fresh, no open finding above `info`, and
          corroborated by observed traffic.
    ExecutionCoverage:
      type: object
      readOnly: true
      description: |
        The execution-coverage posture of one agent. `attested` is the runtime's
        own claim; `observed` is what actually reached the edge; `findings` are
        the open gaps between the two and the claim itself, each with the
        remediation the operator can act on. The dashboard renders `state` as
        the badge and never implies coverage or isolation the findings deny.
      required:
        - state
        - attested
        - observed
        - findings
        - computed_at
      properties:
        state:
          $ref: "#/components/schemas/ExecutionCoverageState"
        attested:
          $ref: "#/components/schemas/ExecutionCoverageAttested"
        observed:
          $ref: "#/components/schemas/ExecutionCoverageObserved"
        findings:
          type: array
          description: "Open coverage findings, highest severity first, then oldest first. Never carries
            `policy_drift_unapproved`: that finding shares the code vocabulary but is not a coverage
            finding — it is read as `Agent.policy_versions.drift` on a single-agent read and as the
            `change` review it opens, and it reaches reports as evidence like every other
            conformance finding."
          items:
            $ref: "#/components/schemas/ExecutionCoverageFinding"
        computed_at:
          type: string
          format: date-time
    ExecutionCoverageAttested:
      type: object
      description: "The attested axis — the latest `RuntimeAttestation` the edge shipped for this agent,
        SELF-ASSERTED by the runtime. `present: false` means the runtime has never attested (or the
        edge restarted and the runtime has not re-attested); every other field is then absent."
      required:
        - present
        - stale
      properties:
        present:
          type: boolean
        stale:
          type: boolean
          description: "True when the last heartbeat that carried this attestation (`received_at`) is older
            than the staleness threshold — max(3 × heartbeat interval, 15 minutes), i.e. 15 minutes
            with the edge's 30-second heartbeat. Measured on `received_at`, NOT `attested_at`: the
            hook attests once per session and on configuration change, and the edge re-ships the
            stored attestation on every heartbeat, so a live edge keeps it fresh and a silent one
            lets it go stale. Raises `attestation_stale`."
        framework:
          type: string
        framework_version:
          type: string
        framework_version_source:
          type: string
          enum:
            - pinned
            - probed
            - unknown
          description: "How the attested `framework_version` was learned, as stored from the heartbeat:
            `probed` (the hook ran the framework binary that started the session), `pinned` (an
            operator's `CLAUDE_CODE_VERSION`, or any version sent without a recognised source),
            `unknown` (no version). A heartbeat from an edge that predates the field is read by the
            same rule — `pinned` when `framework_version` is present, else `unknown`. Says what kind
            of evidence the routing gate has for this agent; it does not change the coverage state."
        adapter:
          type: string
        adapter_version:
          type: string
        hooked_tools:
          type: array
          items:
            type: string
        unhooked_exec_tools:
          type: array
          items:
            type: string
          description: "As attested. The `unhooked_exec_tools` finding evaluates this list MINUS
            `disallowed_tools`: a tool the runtime disables outright is not an execution path."
        disallowed_tools:
          type: array
          items:
            type: string
        other_mcp_servers:
          type: array
          items:
            type: string
        permission_mode:
          type: string
          description: "The framework's own value, verbatim. For Claude Code: `default`, `acceptEdits`,
            `plan`, `bypassPermissions`, `dontAsk`; the last two raise `permission_mode_bypass`."
        sandbox:
          $ref: "#/components/schemas/ExecutionCoverageSandbox"
        config_hash:
          type: string
        attested_at:
          type: string
          format: date-time
          description: The edge's clock when the runtime called `POST /v1/attest`.
        received_at:
          type: string
          format: date-time
          description: The last heartbeat that carried this attestation.
    ExecutionCoverageSandbox:
      type: object
      description: The attested OS-sandbox posture. Every boolean is NULLABLE and null means "not
        reported". A null key is evaluated as the UNSAFE default — `enabled` false,
        `allow_unsandboxed_commands` true, `fail_if_unavailable` false — so an adapter's silence can
        never suppress a finding (`unsandboxed_commands_allowed` fires on an absent key exactly as
        on `true`). The Claude Code hook reports all three whenever any settings file has a
        `sandbox` block, substituting the framework's own defaults for keys the files omit (the same
        values), and omits the whole object when no file configures a sandbox — which, for a
        framework that has one, is `sandbox_disabled`.
      properties:
        enabled:
          type: boolean
          nullable: true
        allow_unsandboxed_commands:
          type: boolean
          nullable: true
        fail_if_unavailable:
          type: boolean
          nullable: true
    ExecutionCoverageObserved:
      type: object
      description: The observed axis — what actually reached the edge, as reported in `ExecutionStats` on
        the heartbeat. The rolling counters are the latest heartbeat's; the `last_*` timestamps are
        high-water marks kept across heartbeats, so they survive an edge restart. This is what
        corroborates (or fails to corroborate) the attested axis.
      required:
        - observer
      properties:
        admission_decisions_1m:
          type: integer
          format: int64
        mcp_calls_1m:
          type: integer
          format: int64
        tools_served:
          type: integer
          format: int32
        tools_called_distinct:
          type: integer
          format: int32
        last_admission_at:
          type: string
          format: date-time
          nullable: true
          description: The last admission-API decision ever observed for this agent. Null until the first one
            — which is `no_corroborating_traffic` while the attestation claims hooked tools.
        last_mcp_call_at:
          type: string
          format: date-time
          nullable: true
          description: From `ExecutionStats.last_mcp_call_at` when the edge sends it; otherwise the receipt
            time of the last heartbeat whose `mcp_calls_1m` was non-zero.
        last_heartbeat_at:
          type: string
          format: date-time
          nullable: true
        observer:
          $ref: "#/components/schemas/ExecutionCoverageObserver"
    ExecutionCoverageObserver:
      type: object
      description: "The Linux host observer's contribution. RESERVED: until the observer ships, `present`
        is always false, `last_event_at` null and `ungoverned_execs_24h` 0. When it ships, `present`
        says whether an observer has reported for this agent's process tree (joined on
        `RuntimeContext`), `last_event_at` when it last did, and `ungoverned_execs_24h` how many
        execs in the last 24 hours had no MITRITY binary in their ancestry."
      required:
        - present
        - ungoverned_execs_24h
      properties:
        present:
          type: boolean
        last_event_at:
          type: string
          format: date-time
          nullable: true
        ungoverned_execs_24h:
          type: integer
    ExecutionCoverageFindingCode:
      type: string
      description: One gap between the runtime's claim and full coverage. `policy_drift_unapproved` shares
        this vocabulary but is not a coverage finding — it never moves the coverage state.
      enum:
        - attestation_missing
        - attestation_stale
        - unhooked_exec_tools
        - other_mcp_servers
        - sandbox_disabled
        - unsandboxed_commands_allowed
        - sandbox_not_required
        - permission_mode_bypass
        - hook_silent
        - no_corroborating_traffic
        - config_drift
        - observer_ungoverned_execution
        - observer_offline
        - framework_unrecognized
        - policy_drift_unapproved
    ExecutionCoverageSeverity:
      type: string
      enum:
        - high
        - medium
        - low
        - info
      description: Finding severity. `high` findings make the state `degraded`; `medium`/`low` make it
        `partial`; `info` never moves the state.
    ExecutionCoverageFinding:
      type: object
      required:
        - code
        - severity
        - message
        - remediation
        - first_seen
        - last_seen
      properties:
        code:
          $ref: "#/components/schemas/ExecutionCoverageFindingCode"
        severity:
          $ref: "#/components/schemas/ExecutionCoverageSeverity"
        message:
          type: string
          description: What was found, in one sentence, naming the tools or servers involved.
        remediation:
          type: string
          description: What to change to close it.
        details:
          type: object
          additionalProperties: true
          description: "Code-specific structure: `{tools}` for `unhooked_exec_tools`, `{servers}` for
            `other_mcp_servers`, `{silent_heartbeats}` for `hook_silent`, `{previous_hash,
            current_hash, changed_at}` for `config_drift`, `{received_at, stale_after_seconds}` for
            `attestation_stale`, `{ungoverned_execs_24h, examples}` for
            `observer_ungoverned_execution`, `{host_id, last_heartbeat_at}` for `observer_offline`,
            `{framework, candidates}` for `framework_unrecognized`, `{running_policy_id,
            running_version, approved_policy_id, approved_version, approved_review_id, reported_at}`
            for `policy_drift_unapproved`."
        first_seen:
          type: string
          format: date-time
        last_seen:
          type: string
          format: date-time
    AssignPolicyRequest:
      type: object
      required:
        - policy_id
      properties:
        policy_id:
          type: string
          format: uuid
          description: ID of a policy in the tenant to assign to the agent.
    AgentCreate:
      type: object
      required:
        - environment_id
        - name
        - mission_scope
        - policy_id
      properties:
        environment_id:
          type: string
          format: uuid
          description: The environment the agent is registered in. With an API key bound to an environment it
            may be left out, and the key's environment is used; any other environment is refused
            403.
        name:
          type: string
        agent_type:
          type: string
          enum:
            - llm_agent
            - code_interpreter
            - workflow_bot
            - rag_agent
            - custom
          default: custom
        description:
          type: string
        mission_scope:
          type: string
        policy_id:
          type: string
          format: uuid
          description: ID of the governance policy to assign
        criticality:
          type: string
          enum:
            - low
            - medium
            - high
            - critical
          default: medium
          description: Business criticality. Defaults to `medium` so a new agent always carries a stated value
            rather than an implicit one; a person can correct it here or through an assessment.
        risk_tier:
          type: string
          enum:
            - minimal
            - limited
            - high
            - unacceptable
          nullable: true
          description: "Risk classification. Deliberately has no default: null means NOT CLASSIFIED and is
            reported as such, never as a pass. An operator classifies it here or through an
            assessment."
        use_case:
          type: string
          maxLength: 200
          description: One line describing what the agent is for, in the operator's words.
        mesh_namespace:
          type: string
          nullable: true
          maxLength: 63
          pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$
          description: >
            Kubernetes namespace the Mitrity Mesh Authorizer maps to this agent via its SPIFFE
            identity. DNS-1123 label. Null/omitted ⇒ not mesh-bound. Must be supplied together with
            mesh_service_account; the (namespace, ServiceAccount) pair is unique per tenant.
          example: payments
        mesh_service_account:
          type: string
          nullable: true
          maxLength: 253
          pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$
          description: >
            Kubernetes ServiceAccount the Mitrity Mesh Authorizer maps to this agent via its SPIFFE
            identity. DNS-1123 subdomain. Null/omitted ⇒ not mesh-bound. Must be supplied together
            with mesh_namespace.
          example: checkout-bot
        is_mesh_edge:
          type: boolean
          default: false
          description: >
            Designate this agent as the environment's mesh-edge service identity (see the same field
            on the Agent schema). Defaults to false.
        is_runtime_observer:
          type: boolean
          default: false
          description: >
            Designate this agent as a host-observer credential (see the same field on the Agent
            schema). Defaults to false.
        platform:
          type: string
          enum:
            - aws
            - azure
            - gcp
            - on-prem
            - unknown
          default: unknown
        region:
          type: string
        metadata:
          type: object
    AgentUpdate:
      type: object
      properties:
        name:
          type: string
        description:
          type: string
        mission_scope:
          type: string
        policy_id:
          type: string
          format: uuid
          description: ID of the governance policy to assign
        status:
          type: string
          enum:
            - active
            - suspended
            - archived
        criticality:
          type: string
          enum:
            - low
            - medium
            - high
            - critical
          description: Business criticality. Omitted ⇒ unchanged.
        risk_tier:
          type: string
          enum:
            - minimal
            - limited
            - high
            - unacceptable
          nullable: true
          description: Risk classification. Omitted ⇒ unchanged; explicit null clears it back to NOT
            CLASSIFIED, which is a legitimate correction and is reported as such, never as a pass.
        use_case:
          type: string
          maxLength: 200
          description: One line describing what the agent is for, in the operator's words. Omitted ⇒ unchanged.
        metadata:
          type: object
        platform:
          type: string
          description: Deployment platform (e.g., gcp, aws, azure)
        region:
          type: string
          description: Deployment region
        mesh_namespace:
          type: string
          nullable: true
          maxLength: 63
          pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$
          description: >
            Kubernetes namespace the Mitrity Mesh Authorizer maps to this agent via its SPIFFE
            identity. DNS-1123 label. Set null to unbind. Must be set together with
            mesh_service_account; the (namespace, ServiceAccount) pair is unique per tenant.
          example: payments
        mesh_service_account:
          type: string
          nullable: true
          maxLength: 253
          pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$
          description: >
            Kubernetes ServiceAccount the Mitrity Mesh Authorizer maps to this agent via its SPIFFE
            identity. DNS-1123 subdomain. Set null to unbind. Must be set together with
            mesh_namespace.
          example: checkout-bot
        is_mesh_edge:
          type: boolean
          description: >
            Designate this agent as the environment's mesh-edge service identity (see the same field
            on the Agent schema). Omit to leave the flag unchanged; send true/false to set it.
        is_runtime_observer:
          type: boolean
          description: >
            Designate this agent as a host-observer credential (see the same field on the Agent
            schema). Omit to leave the flag unchanged; send true/false to set it.
    AgentList:
      type: object
      properties:
        agents:
          type: array
          items:
            $ref: "#/components/schemas/Agent"
        next_page_token:
          type: string
    ConnectedPlatform:
      type: object
      description: A tenant's connection to an external SaaS account (a GitHub org, Salesforce org, or
        M365 tenant). The stored auth secret is never exposed — only `has_auth_secret`.
      properties:
        id:
          type: string
          format: uuid
        tenant_id:
          type: string
          format: uuid
        platform:
          type: string
          enum:
            - github
            - salesforce
            - m365
            - snowflake
        external_account_id:
          type: string
          description: GitHub org login / Salesforce org id / M365 tenant id / Snowflake account identifier.
        auth_metadata:
          type: object
          description: "Non-secret, per-platform auth metadata (the secret `auth_secret` is separate +
            write-only). Shapes: `github` → {app_id, installation_id}; `salesforce` → {client_id,
            username, login_url}; `m365` → {tenant_id, client_id} plus, for sovereign clouds, the
            optional `login_base_url`, `graph_base_url`, `power_platform_base_url`,
            `power_platform_scope`; `snowflake` → {username, role, warehouse, database, schema,
            public_key_fingerprint}."
        has_auth_secret:
          type: boolean
          description: Whether an encrypted auth secret is stored for this connection. The secret itself is
            never returned by any endpoint.
        auth_rotated_at:
          type: string
          format: date-time
          nullable: true
          description: When the stored auth secret was last set/rotated. Drives the `credential_age` posture
            finding.
        sync_cursor:
          type: string
          nullable: true
          description: Opaque per-connector resume cursor.
        last_sync_at:
          type: string
          format: date-time
          nullable: true
        last_sync_status:
          type: string
          enum:
            - pending
            - ok
            - error
          description: Health of the most recent sync attempt.
        last_error:
          type: string
          nullable: true
          description: Error message from the most recent failed sync, if any.
        enabled:
          type: boolean
        enforcement_enabled:
          type: boolean
          description: Opt-in switch for detect-and-respond. `false` (default) keeps the connection strictly
            read-only; only when `true` (and the stored credentials carry write scopes) may a
            response action mutate the platform.
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    ConnectedPlatformCreate:
      type: object
      required:
        - platform
        - external_account_id
        - auth_secret
      properties:
        platform:
          type: string
          enum:
            - github
            - salesforce
            - m365
            - snowflake
        external_account_id:
          type: string
          description: GitHub org login / Salesforce org id / M365 tenant id / Snowflake account identifier.
        auth_secret:
          type: string
          writeOnly: true
          maxLength: 100000
          description: "The platform auth secret (e.g. a GitHub App private key). Write-only:
            envelope-encrypted at rest with the tenant's dedicated connector DEK and never returned
            by any endpoint."
        auth_metadata:
          type: object
          description: Non-secret, per-platform auth metadata. `github` → {app_id, installation_id};
            `salesforce` → {client_id, username, login_url}; `m365` → {tenant_id, client_id} plus,
            for sovereign clouds, the optional `login_base_url`, `graph_base_url`,
            `power_platform_base_url`, `power_platform_scope`; `snowflake` → {username, role,
            warehouse, database, schema, public_key_fingerprint}. Defaults to {} when omitted.
    ConnectedPlatformList:
      type: object
      description: |
        Paginated list response — mirrors the `httputil.ListResponse` wrapper.
      required:
        - data
        - total
        - limit
        - offset
      properties:
        data:
          type: array
          items:
            $ref: "#/components/schemas/ConnectedPlatform"
        total:
          type: integer
          format: int64
          description: Total rows matching the filter, before limit/offset.
        limit:
          type: integer
          description: The effective page size.
        offset:
          type: integer
          description: The effective page offset.
    EnforcementUpdate:
      type: object
      required:
        - enabled
      properties:
        enabled:
          type: boolean
          description: Turn opt-in detect-and-respond enforcement on/off for the connection.
    RespondRequest:
      type: object
      required:
        - action
      properties:
        action:
          type: string
          enum:
            - alert_only
            - contain
            - uncontain
            - revoke_sessions
          description: The response to take. `alert_only` records + raises an audited alert and never touches
            the platform. The rest are platform-mutating and require `enforcement_enabled` on the
            connection (plus write-scoped credentials); until the connector write path ships they
            return 409.
        reason:
          type: string
          description: Optional operator note recorded on the response.
    PlatformResponse:
      type: object
      description: A detect-and-respond action taken (or attempted) against a SaaS-embedded agent. Backs
        audit, idempotency, and reversal. `alert_only` rows never wrote to the platform.
      properties:
        id:
          type: string
          format: uuid
        agent_id:
          type: string
          format: uuid
          nullable: true
        connected_platform_id:
          type: string
          format: uuid
        action:
          type: string
          enum:
            - alert_only
            - contain
            - uncontain
            - revoke_sessions
        trigger:
          type: string
          enum:
            - manual
            - auto_drift
            - auto_policy
        reason:
          type: string
          nullable: true
        status:
          type: string
          enum:
            - pending
            - approved
            - rejected
            - executed
            - failed
            - reversed
        target:
          type: object
          description: What the response acted on (never a secret).
        result:
          type: object
          description: Platform response detail / error (never a secret).
        created_at:
          type: string
          format: date-time
        resolved_at:
          type: string
          format: date-time
          nullable: true
    PlatformResponseList:
      type: object
      description: |
        Paginated list response — mirrors the `httputil.ListResponse` wrapper.
      required:
        - data
        - total
        - limit
        - offset
      properties:
        data:
          type: array
          items:
            $ref: "#/components/schemas/PlatformResponse"
        total:
          type: integer
          format: int64
        limit:
          type: integer
        offset:
          type: integer
    PostureFinding:
      type: object
      description: A security-posture check for a connection, upserted per `finding_key` (idempotent
        across syncs) and removed once a complete posture pass no longer reports it. Connection- and
        environment-level checks are always reported (`ok` / `warn` / `unknown`); agent-level checks
        may be reported only while their condition holds — the connector's design record says which.
      properties:
        id:
          type: string
          format: uuid
        connected_platform_id:
          type: string
          format: uuid
        agent_id:
          type: string
          format: uuid
          nullable: true
          description: The discovered agent this finding concerns; absent for connection-level findings.
        finding_key:
          type: string
          description: Stable key for the check (e.g. `credential_age`, `org_audit_log_access`).
        status:
          type: string
          enum:
            - ok
            - warn
            - unknown
        detail:
          type: object
          description: "Structured, non-secret detail for the finding. Common keys: `severity` (`critical` |
            `high` | `medium` | `low` | `info`) when the connector rates the finding — the
            governance review a `warn` finding on an agent may open uses it (`high` when unset),
            subject to the tenant's `finding_review_severity`; `message`; `remediation`; `evidence`.
            Other keys are connector-specific."
        observed_at:
          type: string
          format: date-time
    PostureFindingList:
      type: object
      description: |
        Paginated list response — mirrors the `httputil.ListResponse` wrapper.
      required:
        - data
        - total
        - limit
        - offset
      properties:
        data:
          type: array
          items:
            $ref: "#/components/schemas/PostureFinding"
        total:
          type: integer
          format: int64
          description: Total rows matching the filter, before limit/offset.
        limit:
          type: integer
          description: The effective page size.
        offset:
          type: integer
          description: The effective page offset.
    PlatformSyncResult:
      type: object
      description: Summary of what a triggered sync changed for the connection.
      properties:
        connections_processed:
          type: integer
        agents_discovered:
          type: integer
        findings_upserted:
          type: integer
        events_ingested:
          type: integer
        status:
          type: string
          enum:
            - ok
            - error
          description: "`ok` when the run completed cleanly; `error` if the connector reported a failure."
        errors:
          type: array
          items:
            type: string
          description: Human-readable errors; present only when `status` is `error`.
    Policy:
      type: object
      description: |
        An environment-scoped policy. A policy is a container for rules plus
        the per-feature enforcement modes the edge applies around them; agents
        bind to exactly one policy (`agents.policy_id`) and the whole thing is
        pushed to the edge as a MissionProfile on heartbeat.
      required:
        - id
        - tenant_id
        - environment_id
        - name
        - default_action
        - fail_mode
        - override_mode
        - default_alert
        - injection_mode
        - llm_injection_mode
        - dlp_mode
        - threat_intel_mode
        - drift_guard_mode
        - command_analysis_mode
        - sandbox_required
        - exec_timeout_seconds
        - builtin_exec_routing
        - workspace_roots
        - write_then_run_window_minutes
        - taint_action
        - autorun_write_action
        - script_inspection_roots
        - unjudged_code_action
        - code_execution_tools
        - created_at
        - updated_at
      properties:
        id:
          type: string
          format: uuid
        tenant_id:
          type: string
          format: uuid
        environment_id:
          type: string
          format: uuid
          description: The environment this policy governs. `(environment_id, name)` is unique.
        name:
          type: string
        description:
          type: string
        default_action:
          type: string
          enum:
            - allow
            - deny
          default: allow
          description: Applied when no rule matches the action.
        fail_mode:
          type: string
          enum:
            - open
            - closed
          default: open
          description: |
            What the edge does when it cannot reach the control plane. Note
            that execution surfaces (`agent_hook` admission, command analysis)
            fail CLOSED regardless of this setting.
        override_mode:
          type: string
          enum:
            - active
            - log_only
            - disabled
          default: active
          description: |
            `active` = enforce; `log_only` = evaluate but always allow;
            `disabled` = quarantine (block everything).

            Two carve-outs from `log_only`, both by design. Execution surfaces
            (`agent_hook` admission, command analysis) fail CLOSED regardless.
            And a denial by the governance lifecycle gate is not relaxed
            either: `alert` is that gate's own log-only mode, so a tenant that
            wants the label without the block moves `lifecycle_gate`, not this
            field.
        default_alert:
          type: boolean
          default: true
          description: Emit a log/alert event when `default_action` applies.
        injection_mode:
          $ref: "#/components/schemas/FeatureMode"
          description: |
            Governs the injection scan on every tool-call surface (`mcp:*`,
            `builtin:*`, `shell:*`, `http:request`, `egress:*`, `delegate:*`).
            LLM prompts follow `llm_injection_mode` instead; `off` here
            disables the scan everywhere, LLM prompts included.
        llm_injection_mode:
          $ref: "#/components/schemas/LlmInjectionMode"
          default: alert
          description: |
            How the injection scan treats an LLM prompt — the `llm:*` action
            types the LLM gateway governs. `enforce` blocks the prompt on a
            detection; `alert` (the default) lets the call proceed, records
            the injection event with `action_taken: alerted` and sends the
            injection notification; `off` skips the scan for LLM prompts.
            Every tool-call surface keeps following `injection_mode`, and
            when `injection_mode` is `off` nothing is scanned regardless of
            this field.

            An injection inside a prompt does no harm by itself; the harm is
            the tool call it induces, which the tool-call surfaces still
            enforce. Agents that analyze content (logs, web pages, tickets)
            legitimately carry injection-shaped text, so blocking the model
            call by default produces false denials — hence `alert`.

            Shipped to the edge as `injection_config.llm_mode`; an edge whose
            profile lacks the field falls back to `injection_mode` for LLM
            prompts as well.
        dlp_mode:
          $ref: "#/components/schemas/FeatureMode"
        threat_intel_mode:
          $ref: "#/components/schemas/FeatureMode"
        drift_guard_mode:
          $ref: "#/components/schemas/FeatureMode"
        drift_guard_threshold:
          type: number
          format: double
          default: 0.7
          description: Drift score at or above which DriftGuard acts (0.0–1.0).
        drift_guard_logging:
          type: boolean
          default: true
        command_analysis_mode:
          $ref: "#/components/schemas/FeatureMode"
          default: enforce
          description: |
            Governs the command-analysis stage: the edge parses every
            execution-shaped action with a real shell grammar, unwraps
            wrappers and interpreters, and evaluates the `resolved_commands`,
            `resolved_commands_contains`, `exec_features`, `script_paths` and
            `max_pipeline_depth` constraint keys against the result.

            `enforce` (the default) blocks/holds per the matching rule;
            `log` evaluates and records a `CommandAnalysis` on the event
            without changing the decision; `off` skips parsing entirely and
            leaves execution-shaped actions matched by first-word globbing
            only. Ships enforce-by-default — `log` exists as a bake-in knob,
            not a compatibility mode.
        sandbox_required:
          type: boolean
          default: true
          description: |
            Governed shell. What happens when no sandbox
            backend is available for a `shell:execute` — bubblewrap missing or
            unprivileged user namespaces disabled on Linux, `sandbox-exec`
            missing on macOS, Windows. `true` (the default) DENIES the call
            with reason `sandbox_unavailable` and stamps
            `sandbox_mode: unavailable_denied`; `false` runs it unsandboxed,
            stamps `sandbox_mode: none`, and fires `execution.sandbox_degraded`.
            Either way the event says what happened. An edge whose profile
            lacks the field treats it as `true`.
        exec_timeout_seconds:
          type: integer
          minimum: 1
          maximum: 3600
          default: 120
          description: |
            Wall-clock ceiling for one governed-shell execution, in seconds. A
            call may ask for less through its `timeout_seconds` argument (or
            Claude Code's per-call `timeout` when routed), never for more —
            larger values are clamped down and the effective value is
            recorded as `ExecutionResult.timeout_seconds`. The edge's own
            `exec.limits.max_timeout_seconds` may cap it lower. On expiry the
            sandbox receives SIGTERM, then SIGKILL after five seconds, and the
            partial output is returned with a truncation marker.
        builtin_exec_routing:
          $ref: "#/components/schemas/BuiltinExecRouting"
          default: framework
          description: |
            Where a framework built-in execution tool (Claude Code / Agent SDK
            `Bash`) admitted through the loopback admission API actually
            runs. `framework`: the framework runs it itself after the
            decision — isolation is the framework's own sandbox, attested
            through `RuntimeAttestation`, not enforced by us.
            `governed_shell`: the decision evaluates the call as
            `shell:execute`, fixes the execution plan (pins included), and
            answers with `updated_input` rewriting `command` to
            `mitrity-hook exec <ticket>`; the governed shell executes the
            judged bytes inside its own sandbox and `sandbox_mode` is stamped
            on the event.
        workspace_roots:
          type: array
          maxItems: 32
          default: []
          items:
            type: string
            minLength: 1
            maxLength: 512
            pattern: ^/
          description: |
            Absolute directory prefixes that are WRITABLE inside the governed
            shell's sandbox, and the set the call's `cwd` must resolve inside
            (absolute → clean → symlinks resolved). Everything else on the
            host is read-only (minus the mandatory deny-read set). Empty — the
            default — means the single directory `exec.default_cwd` from the
            edge configuration. Deliberately separate from
            `script_inspection_roots`: that list says where the edge may READ
            scripts at decision time; a `/` inspection root is a reasonable
            single-purpose-host setting, a `/` workspace root would make the
            host writable. Validation (422 on failure): each entry starts
            with `/`, is at most 512 bytes, cleaned, duplicates dropped, at
            most 32 entries — `workspace_roots[<index>]` for a bad entry,
            `workspace_roots` for the count.
        write_then_run_window_minutes:
          type: integer
          minimum: 1
          maximum: 1440
          default: 30
          description: |
            How long a governed write stays linked to a later execution of the
            same path, in minutes. On the edge it is the lifetime of a taint
            store entry; on the control plane it is the lookback of the
            write-then-run retro-correlation. Shipped to the edge on the
            MissionProfile.
        taint_action:
          $ref: "#/components/schemas/TaintAction"
          default: alert
          description: |
            What the edge does when an execution carries a taint finding —
            `write_then_run` (the execution reads a path a governed action
            wrote within the window) or `modified_outside_governance` (the
            referenced script's bytes no longer match the governed write).
            `alert` records the finding and lets the rest of the pipeline
            decide; `hold` parks the action for human approval; `deny` blocks
            it. Only consulted when `command_analysis_mode` is `enforce`;
            under `log` the finding is recorded and nothing else happens.
        autorun_write_action:
          $ref: "#/components/schemas/TaintAction"
          default: alert
          description: |
            What the edge does with a governed write whose destination
            executes without anyone running it — the `write_to_autorun_path`
            finding: cron tables, systemd units, launchd plists, shell rc and
            profile files, `.git/hooks/**`, `package.json` lifecycle scripts,
            `.claude/settings*.json`, `.mcp.json`. Same semantics as
            `taint_action`; same `command_analysis_mode` gating.
        script_inspection_roots:
          type: array
          maxItems: 32
          default: []
          items:
            type: string
            minLength: 1
            maxLength: 512
            pattern: ^/
          description: |
            Absolute directory prefixes the edge may read script files from at
            decision time. Every script reference and interpreter file
            argument in an execution-shaped action is resolved against the
            action's working directory and read under the safe-read pattern
            (absolute → clean → symlinks resolved → the resolved path must
            sit under one of these roots; regular files only; 256 KiB cap).
            Empty — the default — means "the action's working directory".
            A script that resolves outside every root, is missing, is
            oversize, or escapes through a symlink is **not** read: the
            execution carries the `script_unresolvable` finding and the
            matching rule's `on_unparseable` outcome applies (deny by
            default).

            Validation (422 on failure): each entry must start with `/`, be
            at most 512 bytes, and there may be at most 32 of them.
        unjudged_code_action:
          $ref: "#/components/schemas/TaintAction"
          default: alert
          description: |
            What the edge does with an action carrying the `code_body_unjudged`
            finding — a non-blank body under a code-shaped key (`code`) on a
            tool the edge does not treat as executing (neither the interim
            execution-name gate nor `code_execution_tools` names it): the body
            was fingerprinted, injection- and DLP-scanned and, on a write,
            classified and hashed, but never parsed as a command the tool
            runs. `alert` records the finding and reports the decision as
            alerted; `hold` parks the action for human approval; `deny`
            blocks it (`blocked_by: command`). Same `command_analysis_mode`
            gating as `taint_action`.
        code_execution_tools:
          type: array
          maxItems: 64
          default: []
          items:
            type: string
            minLength: 1
            maxLength: 1024
          description: |
            Tool patterns the edge treats as executing the code they are
            handed, in addition to the interim execution-name gate: a glob
            over the whole action type (`mcp:pyodide`, `mcp:*repl*`,
            `mcp:sandbox__*`) or a `regex:` pattern, matched exactly as
            `PolicyRule.tool_pattern` is. A matching tool's `code` body is
            parsed and judged as a command — consulted after `command`,
            `cmd`, `script`, `args` and `argv`, merged beside one of them
            when both are present — and never raises `code_body_unjudged`; a
            body the shell grammar cannot read on such a tool routes to the
            rule's `on_unparseable` (deny by default). The list only widens
            the gate. Empty — the default — means the name gate alone.

            Validation (422 on failure): each entry is validated exactly as
            `PolicyRule.tool_pattern` is — a glob must name its surface
            before the first `:` (`mcp:`, `builtin:`, …) or be `*`, a
            `regex:` pattern must compile, and on the `mcp` surface the
            namespace separator is `__`, never a second `:` — then trimmed,
            non-blank and at most 1024 bytes; at most 64 entries; exact
            duplicates dropped. A bare `pyodide` is therefore a 422, not a
            pattern that never matches.
        current_version:
          type: integer
          format: int32
          minimum: 1
          readOnly: true
          description: |
            The policy's newest version number — what the mission profile
            carries as `policy_version` and what
            `GET /policies/{policy_id}/versions` lists first. Starts at 1 and
            increases by one on every save.
        rules:
          type: array
          description: |
            The policy's rules, priority-ordered. Always embedded on `GET` and
            on the create/update responses.
          items:
            $ref: "#/components/schemas/PolicyRule"
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    FeatureMode:
      type: string
      enum:
        - off
        - enforce
        - log
      description: |
        Tri-state per-feature toggle. `off` skips the stage; `enforce`
        blocks/holds on a finding; `log` detects and records without changing
        the decision.
    LlmInjectionMode:
      type: string
      enum:
        - enforce
        - alert
        - off
      description: |
        How the injection scan treats an LLM prompt
        (`Policy.llm_injection_mode`; `injection_config.llm_mode` on the
        wire). `enforce` blocks the prompt on a detection; `alert` lets the
        call proceed, records the injection event with `action_taken:
        alerted` and sends the injection notification; `off` skips the scan
        for LLM prompts. Distinct from `FeatureMode`: the tool-call surfaces
        keep the policy-wide `injection_mode`, which also switches the scan
        off everywhere when it is `off`.
    BuiltinExecRouting:
      type: string
      enum:
        - framework
        - governed_shell
      description: |
        Where a framework built-in execution tool admitted through the
        loopback admission API runs (`Policy.builtin_exec_routing`).
        `framework` — the framework executes
        it after the decision (its own sandbox, attested). `governed_shell` —
        the admission decision rewrites the call so the first-party governed
        shell executes the judged bytes inside MITRITY's sandbox — for a
        framework whose version is at or above the edge's
        `admission.routing_min_framework_version` (default `2.0.10`, Claude
        Code's PreToolUse `updatedInput`). The version judged is the
        session's when the session attested a `probed` version at session
        start (measured from the framework binary that started the hook); a
        `pinned` attestation sets no session version, so such a call is
        judged by its own `framework_version`. Below it, absent or
        unparseable, the edge's `admission.routing_unsupported_action`
        decides: `fallback` (default) judges the call in `framework` mode
        and lets the framework run it; `deny` refuses it with reason
        `routing_unsupported`. Either way the event carries
        `metadata.routing_unsupported: "true"` with
        `routing_unsupported_reason`, and every admitted event carries
        `metadata.framework_version_source`.
    SandboxMode:
      type: string
      enum:
        - bubblewrap
        - bubblewrap_no_seccomp
        - seatbelt
        - none
        - unavailable_denied
      description: |
        The isolation actually in force for an execution the governed shell
        ran — or refused to run. Closed vocabulary.

        - `bubblewrap` — Linux: bwrap with user/pid/net/ipc/uts namespaces,
          read-only host root, writable workspace roots only, and the seccomp
          stage installed. Full strength.
        - `bubblewrap_no_seccomp` — bwrap as above without the seccomp stage
          (only reachable under `exec.sandbox.seccomp: preferred`); host
          Unix sockets are reachable from inside. Degraded; notifies.
        - `seatbelt` — macOS: `sandbox-exec` with the generated profile. Full
          strength for the platform.
        - `none` — the command ran with no kernel isolation
          (`sandbox_required: false` and no backend). Degraded; notifies.
        - `unavailable_denied` — no backend and `sandbox_required: true`:
          the call was denied and nothing ran. Notifies.

        Events for executions the edge did not run itself (a hooked `Bash`
        the framework executed, an upstream MCP tool) carry no `sandbox_mode`
        at all — absent means "not ours to vouch for", never `none`.
    TaintAction:
      type: string
      enum:
        - alert
        - hold
        - deny
      description: |
        What the edge does with an execution-governance finding it can act on.
        `alert` records the finding on the event
        and leaves the verdict to the rest of the pipeline (`action_taken:
        alerted`); `hold` parks the action for human approval through the
        ordinary hold path (`held`); `deny` blocks it (`blocked`). Used by
        `Policy.taint_action`, `Policy.autorun_write_action` and
        `Policy.unjudged_code_action`; all three are gated by
        `Policy.command_analysis_mode`.
    PolicyCreate:
      type: object
      description: |
        `default_action`, `fail_mode` and `override_mode` are required even
        though each has a stored default (`allow`, `open`, `active`): all three
        are validated unconditionally and an unrecognised value is a 400, so
        omitting one is a rejection, not a fallback to the default.
      required:
        - environment_id
        - name
        - default_action
        - fail_mode
        - override_mode
      properties:
        environment_id:
          type: string
          format: uuid
          description: The environment the policy governs. With an API key bound to an environment it may be
            left out, and the key's environment is used; any other environment is refused 403.
        name:
          type: string
        description:
          type: string
        default_action:
          type: string
          enum:
            - allow
            - deny
        fail_mode:
          type: string
          enum:
            - open
            - closed
        override_mode:
          type: string
          enum:
            - active
            - log_only
            - disabled
        default_alert:
          type: boolean
          default: true
        injection_mode:
          $ref: "#/components/schemas/FeatureMode"
        llm_injection_mode:
          $ref: "#/components/schemas/LlmInjectionMode"
          default: alert
          description: See `Policy.llm_injection_mode`.
        dlp_mode:
          $ref: "#/components/schemas/FeatureMode"
        threat_intel_mode:
          $ref: "#/components/schemas/FeatureMode"
        drift_guard_mode:
          $ref: "#/components/schemas/FeatureMode"
        drift_guard_threshold:
          type: number
          format: double
        drift_guard_logging:
          type: boolean
        command_analysis_mode:
          $ref: "#/components/schemas/FeatureMode"
          default: enforce
        sandbox_required:
          type: boolean
          default: true
        exec_timeout_seconds:
          type: integer
          minimum: 1
          maximum: 3600
          default: 120
        builtin_exec_routing:
          $ref: "#/components/schemas/BuiltinExecRouting"
          default: framework
        workspace_roots:
          type: array
          maxItems: 32
          default: []
          items:
            type: string
            minLength: 1
            maxLength: 512
            pattern: ^/
          description: See `Policy.workspace_roots`. Entries are cleaned and exact duplicates dropped; a
            non-absolute, empty or oversize entry, or more than 32 entries, is a `422` naming the
            field.
        write_then_run_window_minutes:
          type: integer
          minimum: 1
          maximum: 1440
          default: 30
        taint_action:
          $ref: "#/components/schemas/TaintAction"
          default: alert
        autorun_write_action:
          $ref: "#/components/schemas/TaintAction"
          default: alert
        script_inspection_roots:
          type: array
          maxItems: 32
          default: []
          items:
            type: string
            minLength: 1
            maxLength: 512
            pattern: ^/
          description: See `Policy.script_inspection_roots`. Entries are cleaned (`/a/b/../c/` → `/a/c`) and
            exact duplicates dropped; a non-absolute, empty, oversize entry or more than 32 entries
            is a `422` naming the field (`script_inspection_roots[<index>]` for a bad entry,
            `script_inspection_roots` for the count).
        unjudged_code_action:
          $ref: "#/components/schemas/TaintAction"
          default: alert
          description: See `Policy.unjudged_code_action`.
        code_execution_tools:
          type: array
          maxItems: 64
          default: []
          items:
            type: string
            minLength: 1
            maxLength: 1024
          description: See `Policy.code_execution_tools`. Entries are validated as `PolicyRule.tool_pattern`
            is, trimmed, and exact duplicates dropped; a blank, oversize or invalid entry, or more
            than 64 entries, is a `422` naming the field (`code_execution_tools[<index>]` for a bad
            entry, `code_execution_tools` for the count).
        rules:
          type: array
          description: Optional initial rules, created in the same request.
          items:
            $ref: "#/components/schemas/PolicyRuleCreate"
    PolicyUpdate:
      type: object
      description: |
        Partial update. Every field is optional; omitted fields keep their
        current value. Rules are managed through the `/rules` sub-resource.
      properties:
        name:
          type: string
        description:
          type: string
        default_action:
          type: string
          enum:
            - allow
            - deny
        fail_mode:
          type: string
          enum:
            - open
            - closed
        override_mode:
          type: string
          enum:
            - active
            - log_only
            - disabled
        default_alert:
          type: boolean
        injection_mode:
          $ref: "#/components/schemas/FeatureMode"
        llm_injection_mode:
          $ref: "#/components/schemas/LlmInjectionMode"
          description: Omitted means "leave unchanged". The column default is `alert`; see
            `Policy.llm_injection_mode`.
        dlp_mode:
          $ref: "#/components/schemas/FeatureMode"
        threat_intel_mode:
          $ref: "#/components/schemas/FeatureMode"
        drift_guard_mode:
          $ref: "#/components/schemas/FeatureMode"
        drift_guard_threshold:
          type: number
          format: double
        drift_guard_logging:
          type: boolean
        command_analysis_mode:
          $ref: "#/components/schemas/FeatureMode"
          description: Omitted means "leave unchanged". The column default is `enforce`; see
            `Policy.command_analysis_mode`.
        sandbox_required:
          type: boolean
          description: Omitted means "leave unchanged".
        exec_timeout_seconds:
          type: integer
          minimum: 1
          maximum: 3600
          description: Omitted means "leave unchanged".
        builtin_exec_routing:
          $ref: "#/components/schemas/BuiltinExecRouting"
          description: Omitted means "leave unchanged".
        workspace_roots:
          type: array
          maxItems: 32
          items:
            type: string
            minLength: 1
            maxLength: 512
            pattern: ^/
          description: Omitted means "leave unchanged"; an empty array clears the list (back to
            `exec.default_cwd`). Validated exactly as on create.
        write_then_run_window_minutes:
          type: integer
          minimum: 1
          maximum: 1440
          description: Omitted means "leave unchanged".
        taint_action:
          $ref: "#/components/schemas/TaintAction"
          description: Omitted means "leave unchanged".
        autorun_write_action:
          $ref: "#/components/schemas/TaintAction"
          description: Omitted means "leave unchanged".
        script_inspection_roots:
          type: array
          maxItems: 32
          items:
            type: string
            minLength: 1
            maxLength: 512
            pattern: ^/
          description: Omitted means "leave unchanged"; an empty array clears the list (back to "the action's
            working directory"). Validated exactly as on create.
        unjudged_code_action:
          $ref: "#/components/schemas/TaintAction"
          description: Omitted means "leave unchanged".
        code_execution_tools:
          type: array
          maxItems: 64
          items:
            type: string
            minLength: 1
            maxLength: 1024
          description: Omitted means "leave unchanged"; an empty array clears the list (back to the name gate
            alone). Validated exactly as on create.
    PolicyList:
      type: object
      description: |
        Paginated list response — mirrors the `httputil.ListResponse` wrapper
        used across the tenant-facing API.
      required:
        - data
        - total
        - limit
        - offset
      properties:
        data:
          type: array
          items:
            $ref: "#/components/schemas/Policy"
        total:
          type: integer
          format: int64
          description: Total rows matching the filter, before limit/offset.
        limit:
          type: integer
          description: The effective page size.
        offset:
          type: integer
          description: The effective page offset.
    PolicyRule:
      type: object
      description: |
        One rule inside a policy. Rules are evaluated highest-`priority` first;
        the first match wins. Exactly one of `tool_definition_id` or
        `tool_pattern` identifies what the rule matches.
      required:
        - id
        - policy_id
        - action
        - alert
        - hold
        - priority
        - enabled
        - created_at
      properties:
        id:
          type: string
          format: uuid
        policy_id:
          type: string
          format: uuid
        tool_definition_id:
          type: string
          format: uuid
          description: |
            Match by a tool definition — one of the tenant's own or a
            built-in one. Mutually exclusive with `tool_pattern` — exactly
            one must be set. The edge knows only tool patterns: the mission
            profile carries the rule with the definition's
            `match_rules.action_type_pattern` as its `tool_pattern`, read
            when the profile is built (a later change to the definition takes
            effect on the next heartbeat). The definition's `operations` list
            does not narrow the rule; the rule's own `operations` does.

            A definition the edge could not enforce a rule bound to is
            refused when the rule is written (422 naming
            `tool_definition_id`), and a rule whose definition became one
            later is left out of the mission profile, with a warning in the
            control plane's log; it is never sent with an empty
            `tool_pattern`, which the edge would match against every action.
            That is a definition with no `match_rules.action_type_pattern`,
            or an empty one (a definition can be updated to one, or was
            written before the field was required); one whose pattern is not
            a valid `tool_pattern` (see the
            property) — the catalog's `<vendor>:*` patterns for native-SDK
            integrations, such as `s3:*`, among them; and one with a
            `match_rules.resource_pattern`, which a rule cannot enforce yet
            (sending only the action pattern would widen the rule to every
            resource of the tool).
        tool_pattern:
          type: string
          description: |
            Glob over the **whole** action type, e.g. `mcp:*`,
            `mcp:github__*`, `llm:send:openai:*`, `shell:execute`,
            `builtin:bash`, or a `regex:` pattern. The edge prepends nothing,
            and every action type starts with a surface — `mcp:<tool>`,
            `mcp:<namespace>__<tool>` (a gateway tool: the namespace and the
            upstream's tool name joined by a double underscore — each half
            letters, digits, `_` and `-`, beginning and ending with a letter
            or digit, so a served name carries exactly one `__` and never a
            `___` run; on the mesh the first half is the destination
            workload's ServiceAccount),
            `llm:send:<provider>:<model>`,
            `egress:send:…`, `delegate:…`, `builtin:<tool>`, `shell:execute`,
            `http:request` — so a glob must name one of `mcp`, `llm`, `http`,
            `egress`, `delegate`, `builtin`, `shell` before its first `:`
            (or be `*` alone). On the `mcp` surface a colon in the namespace
            position — one that follows `mcp:` before any `/` or space, i.e.
            the pattern matches `^mcp:[^:/ ]*:` (`mcp:github:*`, the former
            namespace separator; `mcp:*:read_file`, `mcp:_ns:tool`,
            `mcp:stripe__admin:*`) — can match no action type the edge
            emits and is rejected with 422 — write `mcp:github__*`. A colon
            after a `/` or a space is inside the operation half and is
            reachable: a mesh REST rule such as `mcp:svc__POST /v1/jobs:cancel`
            is accepted. Mutually exclusive with `tool_definition_id`.
        operations:
          type: array
          items:
            type: string
          description: |
            Operation names the rule is scoped to (e.g. `read`, `write`).
            Empty/absent = every operation.
        action:
          type: string
          enum:
            - allow
            - deny
        alert:
          type: boolean
          default: true
          description: Emit an alert event when this rule matches.
        hold:
          type: boolean
          default: false
          description: Suspend the action pending human approval.
        hold_timeout_minutes:
          type: integer
          format: int32
          description: How long a hold waits before `timeout_action` applies.
        timeout_action:
          type: string
          enum:
            - allow
            - deny
          description: Applied when a hold expires.
        constraints:
          $ref: "#/components/schemas/RuleConstraints"
        priority:
          type: integer
          format: int32
          default: 50
          description: Higher is evaluated first.
        enabled:
          type: boolean
          default: true
        created_at:
          type: string
          format: date-time
    PolicyRuleCreate:
      type: object
      description: |
        Exactly one of `tool_definition_id` or `tool_pattern` must be set — a
        400 otherwise — expressed here as a `oneOf` so it is machine-checkable.
      required:
        - action
      oneOf:
        - required:
            - tool_definition_id
          not:
            required:
              - tool_pattern
        - required:
            - tool_pattern
          not:
            required:
              - tool_definition_id
      properties:
        tool_definition_id:
          type: string
          format: uuid
          description: |
            A tool definition of the tenant's own or a built-in one, which
            the edge enforces with its `match_rules.action_type_pattern`. One
            the edge could not enforce a rule bound to is a 422 naming
            `tool_definition_id` (see `PolicyRule.tool_definition_id`); one
            the tenant may not use is a 400.
        tool_pattern:
          type: string
          description: |
            Glob over the whole action type, or a `regex:` pattern. A glob
            must start with a known surface — one of `mcp`, `llm`, `http`,
            `egress`, `delegate`, `builtin`, `shell` before the first `:`
            (`mcp:read_crm_*`, `mcp:github__*`, `llm:send:openai:*`,
            `builtin:bash`, `shell:execute`) — or be `*` alone. Anything
            else (`read_crm_*`, `db.*`, `crm:read_*`, `*:read_file`, and on
            the `mcp` surface a pattern that keeps the former namespace
            colon, `mcp:github:*`) could never match an action type and is
            rejected with a 422 naming `tool_pattern`; the message says what
            a valid pattern looks like (`mcp:github__*`) and links the
            policy-writing docs. A `regex:` pattern is only checked for RE2
            syntax and length; its anchoring is the author's.
        operations:
          type: array
          items:
            type: string
        action:
          type: string
          enum:
            - allow
            - deny
        alert:
          type: boolean
        hold:
          type: boolean
        hold_timeout_minutes:
          type: integer
          format: int32
        timeout_action:
          type: string
          enum:
            - allow
            - deny
        constraints:
          $ref: "#/components/schemas/RuleConstraints"
        priority:
          type: integer
          format: int32
          default: 50
        enabled:
          type: boolean
          default: true
    PolicyRuleUpdate:
      type: object
      description: Partial update; omitted fields keep their current value.
      properties:
        tool_definition_id:
          type: string
          description: |
            A UUID, or the **empty string** to clear the tool binding. Not
            `format: uuid`, because `""` is a legal value here: the API
            treats an empty string as "unbind" and a JSON `null` as "field
            omitted, keep the current value". Sending `null` therefore does NOT
            clear the binding. A UUID clears the rule's `tool_pattern`; the
            definition is checked as on create.
        tool_pattern:
          type: string
          description: |
            Same contract as on create: a glob must start with one of `mcp`,
            `llm`, `http`, `egress`, `delegate`, `builtin`, `shell` (or be
            `*` alone), or be a `regex:` pattern; anything else is a 422
            naming `tool_pattern`. A pattern clears the rule's
            `tool_definition_id`; the empty string clears the pattern, which
            leaves the rule with no target (a 400) unless the request also
            sets `tool_definition_id`.
        operations:
          type: array
          items:
            type: string
        action:
          type: string
          enum:
            - allow
            - deny
        alert:
          type: boolean
        hold:
          type: boolean
        hold_timeout_minutes:
          type: integer
          format: int32
        timeout_action:
          type: string
          enum:
            - allow
            - deny
        constraints:
          $ref: "#/components/schemas/RuleConstraints"
        priority:
          type: integer
          format: int32
        enabled:
          type: boolean
    PolicyRuleList:
      type: object
      description: |
        Response of `GET /policies/{policy_id}/rules`. This endpoint predates
        the `ListResponse` envelope and returns a bare `rules` array — it is
        not paginated.
      required:
        - rules
      properties:
        rules:
          type: array
          items:
            $ref: "#/components/schemas/PolicyRule"
    PolicyChangeSummary:
      type: object
      description: |
        What a version changed relative to the version before it, computed
        when the version is written and stored on it.
      required:
        - origin
        - fields
        - rules
      properties:
        origin:
          type: string
          enum:
            - created
            - updated
            - rule_created
            - rule_updated
            - rule_deleted
            - restored
            - backfilled
          description: The operation that produced the version.
        fields:
          type: array
          items:
            type: string
          description: |
            The top-level snapshot keys whose value differs from the previous
            version, in snapshot key order; `rules` appears when the rule set
            differs. Empty for version 1 and for a save that changed nothing.
        rules:
          type: object
          required:
            - added
            - removed
            - changed
          properties:
            added:
              type: integer
              description: Rule ids present only in this version.
            removed:
              type: integer
              description: Rule ids present only in the previous version.
            changed:
              type: integer
              description: Rule ids present in both with a different document.
    PolicyVersion:
      type: object
      description: |
        One immutable version of a policy: who saved it, when, and what the
        save changed. The snapshot itself is served by
        `GET /policies/{policy_id}/versions/{version}` only.
      required:
        - id
        - policy_id
        - version
        - change_summary
        - created_at
      properties:
        id:
          type: string
          format: uuid
        policy_id:
          type: string
          format: uuid
        version:
          type: integer
          format: int32
          minimum: 1
        changed_by:
          type: string
          format: uuid
          nullable: true
          description: The user who made the save. Null for a backfilled version 1 and for a user since
            removed from the tenant.
        change_summary:
          $ref: "#/components/schemas/PolicyChangeSummary"
        restored_from_version:
          type: integer
          format: int32
          nullable: true
          description: When origin is `restored`, the version whose snapshot this one reproduces. Null
            otherwise.
        created_at:
          type: string
          format: date-time
    PolicyVersionList:
      type: object
      description: Paginated version history, newest first (`httputil.ListResponse`).
      required:
        - data
        - total
        - limit
        - offset
      properties:
        data:
          type: array
          items:
            $ref: "#/components/schemas/PolicyVersion"
        total:
          type: integer
          format: int64
        limit:
          type: integer
        offset:
          type: integer
    PolicySnapshot:
      type: object
      description: |
        The policy document as `GET /policies/{policy_id}` served it at a
        version, minus identity and timestamps. Every
        key is always present (`description` is `null` when the policy has
        none — the served document omits the key, the snapshot normalizes it),
        arrays are `[]` never null, and `rules` is ordered by priority
        descending, then created_at, then id.

        Deliberately looser than `Policy`: the enumerated fields are typed as
        plain strings here, uniformly, because a snapshot holds whatever the
        validator accepted when the version was written, and a validator can
        tighten afterwards. That is exactly why a restore re-validates and
        answers `422` when an old document no longer passes. The served
        `Policy` keeps the enums; the snapshot keeps history.
      required:
        - name
        - description
        - default_action
        - fail_mode
        - override_mode
        - default_alert
        - injection_mode
        - llm_injection_mode
        - dlp_mode
        - threat_intel_mode
        - drift_guard_mode
        - drift_guard_threshold
        - drift_guard_logging
        - command_analysis_mode
        - write_then_run_window_minutes
        - taint_action
        - autorun_write_action
        - script_inspection_roots
        - sandbox_required
        - exec_timeout_seconds
        - builtin_exec_routing
        - workspace_roots
        - unjudged_code_action
        - code_execution_tools
        - rules
      properties:
        name:
          type: string
        description:
          type: string
          nullable: true
        default_action:
          type: string
          description: "`allow` | `deny` when written; see the schema description for why this is not an enum."
        fail_mode:
          type: string
        override_mode:
          type: string
        default_alert:
          type: boolean
        injection_mode:
          type: string
        llm_injection_mode:
          type: string
        dlp_mode:
          type: string
        threat_intel_mode:
          type: string
        drift_guard_mode:
          type: string
        drift_guard_threshold:
          type: number
          format: double
        drift_guard_logging:
          type: boolean
        command_analysis_mode:
          type: string
        write_then_run_window_minutes:
          type: integer
          format: int32
        taint_action:
          type: string
        autorun_write_action:
          type: string
        script_inspection_roots:
          type: array
          items:
            type: string
        sandbox_required:
          type: boolean
        exec_timeout_seconds:
          type: integer
          format: int32
        builtin_exec_routing:
          type: string
        workspace_roots:
          type: array
          items:
            type: string
        unjudged_code_action:
          type: string
        code_execution_tools:
          type: array
          items:
            type: string
        rules:
          type: array
          items:
            type: object
            required:
              - id
              - tool_definition_id
              - tool_pattern
              - operations
              - action
              - alert
              - hold
              - hold_timeout_minutes
              - timeout_action
              - constraints
              - priority
              - enabled
            properties:
              id:
                type: string
                format: uuid
              tool_definition_id:
                type: string
                format: uuid
                nullable: true
              tool_pattern:
                type: string
                nullable: true
              operations:
                type: array
                items:
                  type: string
              action:
                type: string
              alert:
                type: boolean
              hold:
                type: boolean
              hold_timeout_minutes:
                type: integer
                nullable: true
              timeout_action:
                type: string
                nullable: true
              constraints:
                type: object
                additionalProperties: true
                description: The rule's constraint document as written (`RuleConstraints` at the time); `{}` when
                  the rule has none. Not `$ref`ed for the same reason the enums above are strings.
              priority:
                type: integer
              enabled:
                type: boolean
    PolicyFieldChange:
      type: object
      description: One top-level snapshot key whose value changed.
      required:
        - field
        - from
        - to
      properties:
        field:
          type: string
        from:
          description: The previous value (any JSON value, null included).
          nullable: true
        to:
          description: The value at this version.
          nullable: true
    PolicyVersionDiff:
      type: object
      description: |
        The difference between a version and the one before it, computed on
        read. Rules are compared by `id`.
      required:
        - fields
        - rules
      properties:
        fields:
          type: array
          items:
            $ref: "#/components/schemas/PolicyFieldChange"
          description: Changed top-level keys other than `rules`, in snapshot key order.
        rules:
          type: object
          required:
            - added
            - removed
            - changed
          properties:
            added:
              type: array
              description: Rule documents present only in this version.
              items:
                type: object
                additionalProperties: true
            removed:
              type: array
              description: Rule documents present only in the previous version.
              items:
                type: object
                additionalProperties: true
            changed:
              type: array
              description: Rules present in both versions with a different document.
              items:
                type: object
                required:
                  - id
                  - from
                  - to
                properties:
                  id:
                    type: string
                    format: uuid
                  from:
                    type: object
                    additionalProperties: true
                  to:
                    type: object
                    additionalProperties: true
    PolicyVersionDetail:
      allOf:
        - $ref: "#/components/schemas/PolicyVersion"
        - type: object
          required:
            - snapshot
          properties:
            snapshot:
              $ref: "#/components/schemas/PolicySnapshot"
            diff:
              $ref: "#/components/schemas/PolicyVersionDiff"
              description: Against the previous version. Absent for version 1.
    AgentPolicyVersions:
      type: object
      description: |
        The three policy versions that matter for one agent, side by side:
        what the control plane serves now, what the approval of record names,
        and what the edge last reported enforcing. A missing approval or a
        missing report is reported as null, never as a match; `drift` is true
        only when both exist and differ.
      required:
        - policy_id
        - current_version
        - drift
      properties:
        policy_id:
          type: string
          format: uuid
          description: The policy the agent is bound to.
        current_version:
          type: integer
          format: int32
          description: That policy's newest version — what the mission profile carries.
        approved_policy_id:
          type: string
          format: uuid
          nullable: true
          description: The policy the approval of record names. Null when no approval is on file.
        approved_version:
          type: integer
          format: int32
          nullable: true
        approved_at:
          type: string
          format: date-time
          nullable: true
          description: When the approval of record was resolved.
        approved_review_id:
          type: string
          format: uuid
          nullable: true
          description: The review that is the approval of record.
        running_policy_id:
          type: string
          format: uuid
          nullable: true
          description: The policy the edge last reported enforcing. Null when the edge has never reported one.
        running_version:
          type: integer
          format: int32
          nullable: true
        running_reported_at:
          type: string
          format: date-time
          nullable: true
        drift:
          type: boolean
          description: True when both an approval of record and an edge report exist and name different
            (policy, version) pairs — the condition that raises the `policy_drift_unapproved`
            finding.
    RuleConstraints:
      type: object
      description: |
        Typed constraint document on a policy rule. Stored verbatim and
        evaluated on the edge; **every** listed key must match for the rule
        to apply.

        The vocabulary is open at the edges — any `<param>` or
        `<param>_contains` key matches the action parameter of that name, and
        numeric keys express limits — but the keys documented below are the
        known vocabulary and the only ones with engine support beyond generic
        parameter matching. Authoring a key outside the known set is allowed
        (it degrades to generic `<param>` matching) but a key the API does
        not recognise as either known or `<param>`-shaped is rejected.

        **Validation (422 on failure).** A constraint value must be one of:
        an array of strings, a string, or a number. Object values are
        **rejected** — the edge silently drops them, so accepting one at the
        API boundary would create a rule that reads as enforcing and is not.
        `null`, booleans and arrays of non-strings are rejected for the same
        reason.

        **Glob syntax and negation.** Path- and command-pattern values are
        globs. A leading `!` marks an *exclusion*. A value `v` matches a
        pattern list when:

        1. it matches at least one positive pattern — or the list has no
           positive patterns at all — **and**
        2. it matches no `!`-prefixed pattern.

        So `["/repo/**", "!/repo/.git/**"]` matches anything under `/repo`
        except `.git`.

        **Negation composes with, and does not replace, the rule's action.**
        Exclusions carve holes in a pattern list; whether the rule then denies
        or allows is `PolicyRule.action`, and whether it needs ANY or ALL of the
        resolved commands to match is decided by that same action (see
        `resolved_commands`). Writing the polarity twice inverts it:

        - Deny `rm`: `action: deny`, `resolved_commands: ["rm"]`. **Not**
          `["!rm"]` — under ANY-match that reads "deny as soon as any command
          in the tree is something other than `rm`".
        - Allow everything except `rm` and `dd`: `action: allow`,
          `resolved_commands: ["*", "!rm", "!dd"]`. ALL-match plus the two
          exclusions is exactly "every command in the tree is something, and
          none of them is `rm` or `dd`". This is what `!` is for.
      additionalProperties:
        description: |
          The open part of the vocabulary. `<param>` (any action-parameter
          name) takes an array of glob strings matched against that
          parameter's value; a numeric limit takes a number. Object and
          boolean values are rejected.
        oneOf:
          - type: array
            items:
              type: string
          - type: string
          - type: number
      patternProperties:
        ^[A-Za-z0-9_]+_contains$:
          type: array
          items:
            type: string
          description: |
            `<param>_contains` — case-insensitive substring match against the
            action parameter `<param>`. `resolved_commands_contains` is the
            execution-governance member of this family and matches
            `CommandAnalysis.canonical`.
      properties:
        paths:
          type: array
          items:
            type: string
          description: |
            Globs over the filesystem paths an action touches. `!`-prefixed
            entries are exclusions.
        commands:
          type: array
          items:
            type: string
          deprecated: true
          description: |
            **DEPRECATED — being replaced by `resolved_commands`.** Matches
            `basename(argv[0])` of the `command` parameter only. Trivially
            evaded by any wrapper (`bash -c "rm -rf /"`, `sh script.sh`,
            `curl … | sh`) and blind to tools that name the parameter `cmd`,
            `args`, `script` or `argv`. Kept so existing rules keep parsing;
            authoring new rules with it is a finding.
        resolved_commands:
          type: array
          items:
            type: string
          description: |
            Globs matched against the **canonical base command of every
            command in the parsed tree**, after wrapper and interpreter
            unwrapping. `bash -lc "curl x | sudo rm -rf /"` resolves to
            `["curl", "rm"]` and matches a `["rm"]` denylist.

            Match semantics differ by rule action, so both the deny and the
            allow intent are expressible:

            - **deny rules — ANY match**: the rule fires if *any* resolved
              command matches *any* pattern. One bad command in a pipeline is
              enough.
            - **allow rules — ALL match**: the rule applies only if *every*
              resolved command matches *some* pattern. An allowlist that does
              not cover the whole tree does not allow the tree.
        resolved_commands_contains:
          type: array
          items:
            type: string
          description: |
            Case-insensitive substring match over the canonical reserialised
            command text (`CommandAnalysis.canonical`). For argument-level
            intent that a base-command glob cannot express — `--no-preserve-root`,
            `-rf /`, a specific URL. ANY/ALL semantics follow `resolved_commands`.
        exec_features:
          type: array
          items:
            type: string
            enum:
              - pipe_to_shell
              - remote_fetch_to_shell
              - base64_decode
              - eval
              - cmd_subst
              - subshell
              - inline_interpreter
              - redirect_write
              - heredoc
              - background
              - find_exec
              - xargs
              - unparseable
              - depth_exceeded
              - nul_byte
              - unsupported_dialect
              - unresolved_command
              - paths_truncated
          description: |
            Structural features of the parsed command tree. The rule matches
            when the analysis reports ANY of the listed features. These are
            evasion shapes, not commands: `remote_fetch_to_shell` is
            `curl … | sh` however it is spelled, `inline_interpreter` is
            `python -c` / `node -e` / `perl -e`, `unsupported_dialect` is a
            shell we cannot parse faithfully (PowerShell).

            Two of them are about the analysis itself rather than the shape of
            the command:

            - `unresolved_command` — the command word is dynamic (a variable, a
              substitution or an unreadable token), so no base command could be
              resolved; the edge fails closed on it. `$CMD -rf /` and
              `$(which rm) -rf /` are the canonical shapes, and neither
              contributes a `resolved_commands` base, so a denylist alone never
              sees them. Name this feature to deny them explicitly.
            - `paths_truncated` — more than 64 paths were referenced by a single
              command; the list is truncated, coverage is not. The tree,
              `resolved_commands` and `script_paths` are all complete, so this
              is lost audit detail, not a hole. Deliberately distinct from
              `depth_exceeded`, which does mean the analysis stopped early.
        script_paths:
          type: array
          items:
            type: string
          description: |
            Globs over the script files an execution references — the argument
            of `sh file`, `. file`, `source file`, `python file`, and the like.
            `!`-prefixed entries are exclusions. Lets a policy allow running
            scripts from a reviewed directory and nothing else.
        max_pipeline_depth:
          type: integer
          minimum: 1
          description: |
            Maximum wrapper/interpreter nesting depth the rule tolerates.
            `bash -c "sh -c 'python -c …'"` is depth 3. The parser's own hard
            cap is 8; exceeding it raises the `depth_exceeded` feature.
        on_unparseable:
          type: string
          enum:
            - deny
            - hold
            - allow
          default: deny
          description: |
            What to do when the command cannot be parsed into a tree — a
            syntax error, a NUL byte, an input over the 64 KiB cap, an
            unsupported dialect, or a command word that could not be resolved
            (the `unresolved_command` feature). Defaults to `deny`: an execution
            surface that cannot be read is not an execution surface that can be
            allowed. `paths_truncated` is **not** routed here — it loses audit
            detail, not coverage.
      examples:
        - resolved_commands:
            - rm
            - dd
            - mkfs*
          resolved_commands_contains:
            - --no-preserve-root
          exec_features:
            - remote_fetch_to_shell
            - base64_decode
          script_paths:
            - "!/workspace/scripts/**"
          max_pipeline_depth: 3
          on_unparseable: deny
        - resolved_commands:
            - "*"
            - "!rm"
            - "!dd"
          script_paths:
            - /workspace/scripts/**
            - "!/workspace/scripts/vendor/**"
    AuditEvent:
      type: object
      required:
        - id
        - tenant_id
        - event_type
        - source
        - occurred_at
        - created_at
      properties:
        id:
          type: string
          format: uuid
        event_type:
          type: string
        agent_id:
          type: string
          format: uuid
        user_id:
          type: string
          format: uuid
        action:
          type: string
        mission_scope:
          type: string
        policy_id:
          type: string
          format: uuid
        decision:
          type: string
          enum:
            - allowed
            - blocked
            - alerted
            - held
            - timed_out
            - observed
          description: "`observed` marks SaaS activity imported by a connector that was never gated at an edge
            (see `source`); the others are live enforcement decisions."
        source:
          $ref: "#/components/schemas/AuditEventSource"
          description: Where the row came from. Always present — it is a list filter, and a field of the
            canonical record the audit hash chain covers, so an export that omits it cannot be
            verified offline.
        surface:
          $ref: "#/components/schemas/EnforcementSurface"
        blocked_by:
          $ref: "#/components/schemas/BlockedBy"
          description: Which component produced the block. Present only when `decision=blocked`; null/omitted
            otherwise, and null on events recorded before the field existed.
        traffic_type:
          type: string
          enum:
            - east_west
            - a2a
            - egress
          description: >
            Traffic classification. Set for Mesh Authorizer (`surface=mesh_extauthz`) decisions
            (east_west/a2a/egress) and LLM Gateway (`surface=llm_gateway`) decisions (always
            egress); omitted/empty for the MCP gateway/sidecar surfaces.
        enforcement_mode:
          type: string
          enum:
            - monitor
            - alert
            - enforce
        response_time_ms:
          type: integer
        behavioral_hash:
          type: string
        drift_score:
          type: number
          format: double
          description: The stored double. The canonical record hashes this exact value, so a client that
            recomputes a `row_hash` must not round it through a single-precision float.
        risk_score:
          type: number
          format: double
          description: Composite risk score from ML analysis (0.0–1.0)
        anomaly_detected:
          type: boolean
          description: Whether ML inference flagged this event as anomalous
        command_analysis:
          $ref: "#/components/schemas/CommandAnalysis"
        execution_result:
          $ref: "#/components/schemas/ExecutionResult"
          description: Present only on events the governed shell produced (a `shell:execute` it ran, or a
            routed execution it refused to start).
        metadata:
          type: object
        occurred_at:
          type: string
          format: date-time
        chain_seq:
          type: integer
          format: int64
          description: The event's position on its organization's audit hash chain. Absent on rows written
            before the chain began.
        prev_hash:
          type: string
          description: Hex SHA-256 `row_hash` of the event at `chain_seq - 1`; the genesis hash at position 1.
        row_hash:
          type: string
          description: Hex SHA-256 over the event's canonical record (RFC 8785), which includes `chain_seq`
            and `prev_hash`.
        v:
          type: string
          enum:
            - mitrity-audit-chain/v1
          description: The canonical-record version the event's `row_hash` was computed under. Present with
            `chain_seq`; absent on rows written before the chain.
        tenant_id:
          type: string
          format: uuid
          description: The organization the event belongs to; part of the canonical record.
        environment_id:
          type: string
          format: uuid
          description: The environment the event is scoped to, when it is; part of the canonical record
            (`null` there when absent here).
        edge_node_id:
          type: string
          description: The edge node that reported the event, when one did; part of the canonical record
            (`null` there when absent here).
        created_at:
          type: string
          format: date-time
          description: When the row was recorded, as opposed to `occurred_at`, when the event happened; part
            of the canonical record.
    EnforcementSurface:
      type: string
      enum:
        - mesh_extauthz
        - mcp_gateway
        - mcp_sidecar
        - llm_gateway
        - agent_hook
      description: >
        Enforcement surface that produced a decision. `mesh_extauthz` is the Mitrity Mesh Authorizer
        (Envoy ext_authz Check); `mcp_gateway` and `mcp_sidecar` are the MCP enforcement paths;
        `llm_gateway` is the Mitrity LLM Gateway (governed LLM egress); `agent_hook` is the loopback
        admission API — a framework built-in tool (Claude Code / Agent SDK Bash, Write, Edit,
        WebFetch, …) admitted through `POST /v1/admit` before the framework runs it. See the
        [Admission API](https://mitrity.com/docs/integrations/admission-api).
    AuditEventSource:
      type: string
      enum:
        - runtime
        - github
        - salesforce
        - m365
        - snowflake
        - control_plane
      description: Origin dimension of the audited activity. `runtime` = a live edge/gateway decision;
        `github`/`salesforce`/`m365`/`snowflake` = activity imported from a connected SaaS platform
        (always written with `decision=observed`); `control_plane` = a governance mutation made
        through this API — a review transition, a lifecycle change, an agent or policy change, a
        change to who may do what — written with a NULL `decision` and the acting user in `user_id`.
    AuditCheckpoint:
      type: object
      description: A signed statement about a contiguous run of the organization's audit hash chain.
      required:
        - id
        - period_start
        - period_end
        - first_seq
        - last_seq
        - event_count
        - root_hash
        - prev_root_hash
        - key_id
        - signature
        - statement
        - signed_at
      properties:
        id:
          type: string
          format: uuid
        period_start:
          type: string
          format: date-time
          description: The previous checkpoint's `period_end`, or when the chain began.
        period_end:
          type: string
          format: date-time
          description: When the checkpoint was sealed.
        first_seq:
          type: integer
          format: int64
        last_seq:
          type: integer
          format: int64
        event_count:
          type: integer
          format: int64
          description: Always `last_seq - first_seq + 1`.
        root_hash:
          type: string
          description: The `row_hash` at `last_seq`.
        prev_root_hash:
          type: string
          description: The previous checkpoint's `root_hash`, or the genesis hash for the first.
        key_id:
          type: string
          description: First 16 hex characters of SHA-256 over the raw public key that signed this checkpoint.
        signature:
          type: string
          description: Standard base64 Ed25519 signature over `statement`.
        statement:
          type: string
          description: The RFC 8785 canonical JSON the signature covers, stored verbatim at seal time and
            returned as stored.
        signed_at:
          type: string
          format: date-time
    AuditCheckpointList:
      type: object
      required:
        - data
        - total
        - limit
        - offset
      properties:
        data:
          type: array
          items:
            $ref: "#/components/schemas/AuditCheckpoint"
        total:
          type: integer
        limit:
          type: integer
        offset:
          type: integer
    AuditCheckpointPublicKey:
      type: object
      required:
        - algorithm
        - key_id
        - public_key
        - statement_format
      properties:
        algorithm:
          type: string
          enum:
            - ed25519
        key_id:
          type: string
        public_key:
          type: string
          description: The raw 32-byte Ed25519 public key, standard base64.
        statement_format:
          type: string
          enum:
            - mitrity-audit-checkpoint/v1
    AuditVerifyRequest:
      type: object
      description: The period to verify. Both bounds are optional; the default is the last 30 days. `to`
        must be after `from`.
      properties:
        from:
          type: string
          format: date-time
        to:
          type: string
          format: date-time
    AuditVerificationResult:
      type: object
      description: "What a verification found. Counts are over chain positions: `events_verified +
        events_failed + retired_events + missing_events` is the span walked (plus any duplicated
        positions), which contains every chained event that occurred in the period and may contain
        events outside it that were chained between them. `events_failed` and `events_verified` are
        disjoint."
      required:
        - status
        - period_start
        - period_end
        - events_in_period
        - unchained_events
        - first_seq
        - last_seq
        - events_verified
        - events_failed
        - retired_events
        - missing_events
        - checkpoints_checked
        - checkpoints_failed
        - truncated
        - notes
      properties:
        status:
          type: string
          enum:
            - valid
            - broken
            - partial
          description: "`valid` — every position recomputed and linked, every checkpoint in the span matched
            and verified, and the span is anchored to a checkpoint; a period with no event at all is
            vacuously valid with `first_seq` and `last_seq` both 0. `broken` — at least one row,
            link, position or checkpoint failed. `partial` — nothing failed, but rows predate the
            chain, the walk was capped, no checkpoint anchors the span yet, or a checkpoint's
            signature could not be checked; `notes` says which."
        period_start:
          type: string
          format: date-time
        period_end:
          type: string
          format: date-time
        chain_started_at:
          type: string
          format: date-time
          description: When the organization's chain began; absent when nothing has been chained yet.
        events_in_period:
          type: integer
          format: int64
          description: Every audit event that occurred in the period — live rows, chained or not, and retired
            positions.
        unchained_events:
          type: integer
          format: int64
          description: Events in the period that predate the chain and cannot be verified.
        first_seq:
          type: integer
          format: int64
        last_seq:
          type: integer
          format: int64
        events_verified:
          type: integer
          format: int64
        events_failed:
          type: integer
          format: int64
        retired_events:
          type: integer
          format: int64
          description: Positions retention retired to a tombstone, single or range; their hashes still link
            the chain.
        missing_events:
          type: integer
          format: int64
          description: Positions with neither a row nor a tombstone.
        checkpoints_checked:
          type: integer
        checkpoints_failed:
          type: integer
        first_divergent_seq:
          type: integer
          format: int64
        first_divergent_event_id:
          type: string
          format: uuid
        failure:
          type: string
          description: The first failure, in one phrase.
        truncated:
          type: boolean
        notes:
          type: array
          items:
            type: string
    BlockedBy:
      type: string
      enum:
        - policy_rule
        - tool_permission
        - injection
        - threat_intel
        - delegation
        - dlp
        - drift_guard
        - command
        - lifecycle
        - quarantine
        - unknown
      description: >
        The pipeline component that produced a block, stamped at event ingest from the edge's own
        `blocked_by` hint and derived locally only for edges that do not send one. `command` is the
        command-analysis stage: the parsed command tree matched a `resolved_commands` /
        `resolved_commands_contains` / `exec_features` / `script_paths` / `max_pipeline_depth`
        constraint, or the command was unparseable under `on_unparseable: deny`. The governed
        shell's own execution-safety denials also stamp `command`, with the reason in
        `metadata.reason`: `sandbox_unavailable`, `credential_in_argv`, `pin_mismatch`,
        `cwd_outside_workspace`, `command_analysis_off`, and, for a routed `Bash` refused before the
        shell is asked — no ticket, no hold, no execution event — `routing_unavailable` (the policy
        routes Bash but this edge serves no governed shell), `hook_path_invalid`
        (`admission.hook_path` cannot be rendered into the relay command) and `routing_unsupported`
        (the framework's version is not known to honor `updatedInput` and
        `admission.routing_unsupported_action` is `deny`; the event also carries
        `routing_unsupported: "true"` and `routing_unsupported_reason`). `lifecycle` is the
        governance gate: the tenant's `lifecycle_gate` is `enforce` and the acting agent's
        `lifecycle_status` is not `approved`. An edge's `lifecycle` hint is stamped only when the
        control plane's records confirm it at ingest; otherwise the block is derived from the event.
    CommandAnalysis:
      type: object
      description: |
        The result of parsing an execution-shaped action with a real shell
        grammar. Produced by the edge, carried on the edge event, stored with
        it and echoed back on the audit event.

        Present only when the action was execution-shaped (a shell-family MCP
        tool, `shell:execute`, or a `builtin:bash`-class admission) AND the
        policy's `command_analysis_mode` is not `off`. Two records are
        tree-less: a call carrying a non-blank `code` body on a tool the edge
        does not treat as executing gets `scanned: true`, the
        `code_body_unjudged` finding and the `action_taken` the policy's
        `unjudged_code_action` produced (`alerted` by default, `held`,
        `blocked`; `logged` under `command_analysis_mode: log`), and nothing
        else; a write whose several distinct bodies are all prose gets
        `scanned: true`, `ambiguous_write_content` and `action_taken:
        logged`, and nothing else.

        **Raw command text never leaves the customer environment beyond
        `canonical`**, which is the reserialised, truncated command; script
        *contents* are represented only by their SHA-256.
      required:
        - scanned
      properties:
        scanned:
          type: boolean
          description: False when the stage was skipped (mode `off`, or the action was not execution-shaped).
            Every other field is then absent.
        argv0s:
          type: array
          items:
            type: string
          description: The raw `argv[0]` of every command in the parsed tree, in execution order and before
            canonicalisation.
        wrapper_chains:
          type: array
          items:
            type: array
            items:
              type: string
          description: >-
            Per command, the ordered chain of wrappers it was reached through — e.g. `["bash -c",
            "sudo", "timeout"]`. Parallel to `argv0s`; an empty inner array means the command was
            invoked directly.

            **Encoding.** On the HTTP path (`POST /edge-gateway/events`, and this audit response)
            **this schema is authoritative**: a plain array of string arrays, produced by the edge's
            own JSON marshaller. The proto boxes each chain in a `WrapperChain` message because
            proto3 has no repeated-of-repeated, so a protojson rendering of `CommandAnalysis` emits
            `[{"wrappers":[…]}]` and does NOT validate against this schema. Do not feed protojson
            output to the HTTP ingest; a gRPC transport uses the proto shape end to end.
        features:
          type: array
          items:
            type: string
          description: Structural features detected in the tree. Same vocabulary as the `exec_features`
            constraint key, including `unresolved_command` (no base command could be resolved —
            fails closed) and `paths_truncated` (more than 64 paths on one command; detail lost,
            coverage intact). The wire type is free-form so an edge running ahead of the control
            plane can report a feature this enum does not list yet; readers must render unknown
            values rather than drop them.
        findings:
          type: array
          items:
            type: string
            enum:
              - stdin_exec
              - dynamic_eval
              - script_ref
              - interpreter_inline
              - write_then_run
              - script_authoring
              - modified_outside_governance
              - write_to_autorun_path
              - script_unresolvable
              - unparseable
              - credential_in_argv
              - code_body_unjudged
              - ambiguous_write_content
          description: "Semantic findings — what the execution means, as opposed to `features`, which is what
            it looks like. `script_unresolvable` means a referenced script could not be read at
            decision time (outside `script_inspection_roots`, missing, oversize, beyond the
            32-scripts-per-action cap, or a symlink escape) and the rule's `on_unparseable` outcome
            was applied — deny by default. `credential_in_argv` is raised only by the governed
            shell: a credential-bearing environment variable is referenced in an argument word of an
            external command in the judged tree, so its value would land in that process's argv; the
            execution is denied. `code_body_unjudged` and `ambiguous_write_content` are coverage
            findings: the first marks a non-blank `code` body on a tool the edge does not treat as
            executing — scanned, never parsed as a command; the event metadata carries
            `code_body_tool` and `code_body_keys`; `Policy.unjudged_code_action` decides the outcome
            (alert by default) — and the second a write with more than one distinct non-blank
            content body, whose taint entry records every body's hash as a candidate
            (`ambiguous_write_keys`); it leaves the outcome unchanged."
        script_sha256s:
          type: array
          items:
            type: string
            pattern: ^[0-9a-f]{64}$
          description: SHA-256 of every script file the execution references that the edge could read at
            decision time, under the policy's `script_inspection_roots`, and — on a write that
            raised `script_authoring` — of the content being written, one hash per program body when
            the write carries more than one content key. Empty when no script was referenced and
            nothing was authored. A script that could not be read contributes no hash and raises the
            `script_unresolvable` finding instead, so this list can be shorter than `script_paths`.
        script_paths:
          type: array
          maxItems: 64
          items:
            type: string
          description: "The script files the execution references — every script reference and interpreter
            file argument in the tree, resolved against the action's working directory — whether or
            not the edge could read them. Paths only, never contents; this is what a
            `script_unresolvable` notification names. Optional: an edge that does not resolve script
            references omits it. The edge inspects at most 32 scripts per action: a tree referencing
            more carries the first 32 here, the execution is `script_unresolvable`, and the rule's
            `on_unparseable` decides — so the list never exceeds 32, the 64-entry wire bound is
            never reached, and an ingest that receives more than 64 rejects the analysis as
            malformed. `paths_truncated` concerns resolved paths per command, never this list."
        taint_hits:
          type: array
          description: "Write-then-run linkage: script or data paths this execution reads that a governed
            action wrote earlier in the session, within the policy's
            `write_then_run_window_minutes`."
          items:
            type: object
            required:
              - path
            properties:
              path:
                type: string
              age_seconds:
                type: integer
                description: Seconds between the governed write and this execution.
              writer_tool:
                type: string
                description: The action type that wrote the path, e.g. `builtin:write`.
              content_sha256:
                type: string
                pattern: ^[0-9a-f]{64}$
                description: "SHA-256 of the bytes as written. **Omitted when the bytes were not known at write
                  time** — an Edit-style patch, a shell redirect, `tee`, `cp`, `curl -o` — because
                  the taint store only records the path and the writer for those. Never an empty
                  string. For a write that carried several distinct bodies
                  (`ambiguous_write_content`) the store holds every body's hash as a candidate: this
                  is the candidate the file matches, or the first candidate (key-precedence order)
                  when it matches none — in which case `hash_matches_current` is false."
              hash_matches_current:
                type: boolean
                description: "Whether the file's current bytes still match `content_sha256`. **Only meaningful when
                  `content_sha256` is present**, and omitted otherwise — an absent value is \"not
                  known\", not \"unchanged\". False means the file changed after the governed write:
                  the `modified_outside_governance` finding."
        sandbox_mode:
          $ref: "#/components/schemas/SandboxMode"
          description: The isolation actually in force for this execution, stamped by the governed shell on
            every event it produces. `none` on an execution the policy expected to isolate is a
            degraded-mode event, not a silent pass. Absent on executions the framework or an
            upstream tool performed itself — the edge has no isolation to vouch for there and says
            nothing rather than guessing.
        depth:
          type: integer
          description: Deepest wrapper/interpreter nesting in the tree.
        unparseable:
          type: boolean
          description: "The analysis does not describe the whole command line: the command could not be parsed
            into a tree, **or** the command word itself could not be resolved (a dynamic command
            word — the `unresolved_command` feature — where the parse succeeded but nothing can be
            named). `action_taken` then follows the rule's `on_unparseable` (default `deny`).
            `paths_truncated` does not set this flag."
        action_taken:
          type: string
          enum:
            - allowed
            - blocked
            - held
            - alerted
            - logged
          description: "What the command-analysis stage did. `alerted` is the outcome of an alert-level
            finding (`taint_action` / `autorun_write_action` / `unjudged_code_action` `alert`): the
            finding was recorded, the alert flag raised, and the action proceeded. `logged` is the
            outcome under `command_analysis_mode: log` — a finding was recorded and the decision was
            left to the rest of the pipeline."
        canonical:
          type: string
          maxLength: 4096
          description: The reserialised canonical command text the decision was made against, truncated to
            4096 characters. This is the text `resolved_commands_contains` matches and the text a
            reviewer reads in the audit trail.
    ExecutionResult:
      type: object
      description: |
        What the governed shell did with an allowed `shell:execute`. `status`
        is the execution outcome; the audit `decision` stays the policy's
        (`allowed`) — a non-zero exit code is a result, not a block. Present
        on every event the shell produced, including a `failed_to_start` for
        a call the pipeline had allowed.
      required:
        - status
        - sandbox_mode
        - duration_ms
      properties:
        status:
          type: string
          enum:
            - completed
            - timed_out
            - killed
            - failed_to_start
            - not_redeemed
          description: "`completed` — the process exited (`exit_code` set). `timed_out` — the wall-clock
            ceiling expired; partial output returned. `killed` — signalled for another reason
            (`reason`, `signal`). `failed_to_start` — nothing ran (`reason`: `pin_mismatch`,
            `concurrency_limit`, `spawn_error`; routed: `stdin_unjudged`, `profile_changed`).
            `not_redeemed` — a routed execution whose ticket expired unredeemed: the framework never
            ran the rewritten command."
        reason:
          type: string
          description: |
            Machine-readable cause for a non-`completed` status. Never contains
            a credential value. For `failed_to_start`: `pin_mismatch`,
            `concurrency_limit`, `spawn_error`, and on a routed execution
            `stdin_unjudged` (the executor was handed bytes other than the
            judged plan's stdin — a conforming relay sends none) or
            `profile_changed` (the ticket was redeemed under a mission profile
            other than the one its plan was judged under; the ticket is spent
            and nothing ran).
        exit_code:
          type: integer
          nullable: true
          description: The process exit status; null unless `status` is `completed`.
        signal:
          type: string
          description: Terminating signal name (`SIGKILL`, `SIGTERM`) when the process was signalled.
        duration_ms:
          type: integer
          description: Spawn to reap, milliseconds. 0 when nothing ran.
        timeout_seconds:
          type: integer
          description: The effective ceiling applied — min of the policy's `exec_timeout_seconds`, the edge's
            `exec.limits.max_timeout_seconds` and the call's request.
        sandbox_mode:
          $ref: "#/components/schemas/SandboxMode"
        sandbox_backend:
          type: string
          description: The backend and version actually used, e.g. `bubblewrap 0.9.0`, `seatbelt`, `none`.
        sandbox_relaxations:
          type: array
          items:
            type: string
          description: Settings-file relaxations honoured for this execution, e.g.
            `allowUnixSockets:/var/run/docker.sock` (macOS). Empty in the normal case; every entry
            is a widening the operator chose.
        shell:
          type: string
          enum:
            - bash
            - sh
          description: The shell dialect the parser used and the interpreter that ran.
        cwd:
          type: string
          description: The effective working directory after symlink resolution; inside a workspace root.
        command_sha256:
          type: string
          pattern: ^[0-9a-f]{64}$
          description: SHA-256 of the judged command text exactly as executed.
        stdin_bytes:
          type: integer
          format: int64
          description: Bytes of the `stdin` argument as delivered to the process. Absent when the call carried
            none.
        stdin_sha256:
          type: string
          pattern: ^[0-9a-f]{64}$
          description: "SHA-256 of the `stdin` argument. What the bytes were read as is the event metadata key
            `stdin_role`: `program` (scanned as a script body; parsed and merged into the tree when
            a shell reads it), `data_egress` (nothing runs it, but a command in the tree can send it
            off the host: the DLP pass only) or `data` (hashed and handed to the process, not
            content-scanned)."
        stdout_bytes:
          type: integer
          format: int64
          description: Total bytes the process produced on stdout, including bytes discarded past the cap.
        stdout_sha256:
          type: string
          pattern: ^[0-9a-f]{64}$
          description: SHA-256 of the stdout bytes AS DELIVERED to the agent — after truncation and redaction.
        stdout_truncated:
          type: boolean
        stderr_bytes:
          type: integer
          format: int64
        stderr_sha256:
          type: string
          pattern: ^[0-9a-f]{64}$
        stderr_truncated:
          type: boolean
        truncated:
          type: boolean
          description: Either stream was truncated to its cap.
        redactions:
          type: array
          items:
            $ref: "#/components/schemas/ExecutionRedaction"
        pinned_scripts:
          type: array
          maxItems: 64
          items:
            $ref: "#/components/schemas/PinnedScript"
        pin_coverage:
          type: string
          enum:
            - full
            - partial
            - none
          description: "`full` — every inspected script executed as its pinned bytes. `partial` — a nested
            reference from inside a pinned script was re-read from the workspace at run time (macOS
            `path_rewrite` only). `none` — no script was referenced."
        unpinned_scripts:
          type: array
          items:
            type: string
          description: "Script paths executed WITHOUT a pin — only possible when a rule set `on_unparseable:
            allow` for a `script_unresolvable` reference. A degraded-mode detail; the notification
            already fired."
        credential_refs:
          type: array
          items:
            type: string
          description: Credential ids injected into the execution (environment and files). Names only, never
            values.
        egress:
          $ref: "#/components/schemas/ExecutionEgress"
        limits:
          $ref: "#/components/schemas/ExecutionLimitsEnforced"
        decision_event_id:
          type: string
          format: uuid
          description: For a routed execution, the admission decision event this redeemed.
    ExecutionRedaction:
      type: object
      required:
        - source
        - name
        - count
      properties:
        source:
          type: string
          enum:
            - credential
            - dlp_pattern
        name:
          type: string
          description: The credential id, or the DLP pattern name. Never the matched bytes.
        count:
          type: integer
    PinnedScript:
      type: object
      required:
        - path
        - sha256
        - bytes
        - pin
      properties:
        path:
          type: string
          description: The script's resolved path as referenced by the execution.
        sha256:
          type: string
          pattern: ^[0-9a-f]{64}$
          description: SHA-256 of the pinned bytes — the same value as the matching
            `CommandAnalysis.script_sha256s` entry.
        bytes:
          type: integer
        pin:
          type: string
          enum:
            - bind_over
            - path_rewrite
          description: "`bind_over` — a sealed in-memory copy bound read-only over the original path inside
            the sandbox (Linux). `path_rewrite` — the copy was written to the private directory and
            the command line rewritten to it before the decision (macOS, `none`)."
    ExecutionEgress:
      type: object
      required:
        - connects_allowed
        - connects_denied
      properties:
        connects_allowed:
          type: integer
        connects_denied:
          type: integer
        denied_hosts:
          type: array
          maxItems: 16
          items:
            type: string
          description: Distinct `host:port` destinations the proxy refused, first 16. Each also produced a DLP
            event with `detection_reason` `unauthorized_destination`.
    ExecutionLimitsEnforced:
      type: object
      description: "Which resource bounds the platform actually enforced for this execution. Honest
        posture: a `false` here is a documented gap (no delegated cgroup, bubblewrap older than
        0.7.0, macOS), not a misconfiguration."
      properties:
        pids_enforced:
          type: boolean
        memory_enforced:
          type: boolean
        tmpfs_size_enforced:
          type: boolean
        daemon_kill_enforced:
          type: boolean
          description: True on Linux (pid-namespace teardown). False on macOS, where a `setsid` descendant
            survives the process-group kill, confined but alive.
    AuditEventList:
      type: object
      required:
        - data
        - total
        - limit
        - offset
      properties:
        data:
          type: array
          items:
            $ref: "#/components/schemas/AuditEvent"
        total:
          type: integer
          format: int64
          description: The number of events that match every filter on the request — the rows the pages hold,
            not the size of the organization's trail.
        limit:
          type: integer
        offset:
          type: integer
    AuditSummary:
      type: object
      required:
        - tenant_id
        - total_events
        - blocked_actions
        - held_actions
        - allowed_actions
        - observed_actions
        - governance_records
        - other_records
      description: "Every audit row the summary covers is in exactly one of `total_events`,
        `observed_actions`, `governance_records` and `other_records`. `blocked_actions`,
        `held_actions` and `allowed_actions` are among `total_events` but do not add up to it:
        alerted and timed-out decisions are counted in `total_events` without a figure of their
        own."
      properties:
        tenant_id:
          type: string
          format: uuid
        period_start:
          type: string
          format: date-time
        period_end:
          type: string
          format: date-time
        total_events:
          type: integer
          format: int64
          description: "The agents' actions an enforcement point decided: rows attributed to an agent,
            carrying a decision other than `observed`, that are not control-plane governance
            records."
        blocked_actions:
          type: integer
          format: int64
        held_actions:
          type: integer
          format: int64
        allowed_actions:
          type: integer
          format: int64
        observed_actions:
          type: integer
          format: int64
          description: The agents' actions a connector observed on a connected SaaS platform
            (`decision=observed`). Actions, but never gated, so not among `total_events`.
        governance_records:
          type: integer
          format: int64
          description: "Control-plane records (`source=control_plane`): reviews, lifecycle and policy changes
            and the other changes people made through the API. On the same trail; never counted as
            actions."
        other_records:
          type: integer
          format: int64
          description: "Rows that are neither an agent's action nor a governance record: one that names no
            agent, such as a connector reading its credentials, or one written without a decision
            outside the control plane."
    NotificationPreferences:
      type: object
      properties:
        slack_webhook_url:
          type: string
        slack_channel:
          type: string
        slack_enabled:
          type: boolean
        email_recipients:
          type: array
          items:
            type: string
            format: email
        email_enabled:
          type: boolean
        notify_on_block:
          type: boolean
        notify_on_drift:
          type: boolean
        notify_on_agent_disconnect:
          type: boolean
        drift_score_threshold:
          type: number
          format: float
        type_channel_prefs:
          $ref: "#/components/schemas/TypeChannelPrefs"
    UserNotificationPreferences:
      type: object
      properties:
        id:
          type: string
          format: uuid
        user_id:
          type: string
          format: uuid
        tenant_id:
          type: string
          format: uuid
        notify_on_block:
          type: boolean
        notify_on_drift:
          type: boolean
        notify_on_agent_disconnect:
          type: boolean
        email_enabled:
          type: boolean
        weekly_report_enabled:
          type: boolean
        drift_score_threshold:
          type: number
          format: double
          description: Drift score threshold for notifications (0.0-1.0)
          default: 0.7
        type_channel_prefs:
          $ref: "#/components/schemas/TypeChannelPrefs"
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    UpdateUserNotificationPreferencesRequest:
      type: object
      properties:
        notify_on_block:
          type: boolean
        notify_on_drift:
          type: boolean
        notify_on_agent_disconnect:
          type: boolean
        email_enabled:
          type: boolean
        weekly_report_enabled:
          type: boolean
        drift_score_threshold:
          type: number
          format: double
          description: Drift score threshold for notifications (0.0-1.0)
          default: 0.7
        type_channel_prefs:
          $ref: "#/components/schemas/TypeChannelPrefs"
    TypeChannelPrefs:
      type: object
      description: |
        Per-notification-type channel override map, keyed by NotificationKind.
        Absent kind/channel falls back to the type's default_channels (see
        GET /notifications/types). On the tenant preferences the slack, email
        and webhook channels drive the notification engine's external
        fan-out; on the per-user preferences only the bell channel is
        meaningful (false mutes that kind in the caller's in-app inbox and
        badge). A PUT replaces the whole map when the field is provided;
        omitting it leaves the stored map untouched. Unknown kinds or
        channels are rejected with 400.


        The webhook channel posts the event to every enabled webhook
        destination of the organization (`GET /siem-destinations`) as a
        `NotificationWebhookEnvelope`; there is no account-wide switch beside
        the destinations themselves. A syslog destination is skipped by this
        channel — it is not an HTTP endpoint.
      additionalProperties:
        type: object
        properties:
          bell:
            type: boolean
          slack:
            type: boolean
          email:
            type: boolean
          webhook:
            type: boolean
    NotificationTypeInfo:
      type: object
      description: |
        One notification type. The dashboard's Settings and Profile pages
        render their per-type channel toggles from this catalog, so a new type
        appears in both automatically.
      required:
        - kind
        - category
        - severity
        - display_name
        - description
        - default_channels
      properties:
        kind:
          $ref: "#/components/schemas/NotificationKind"
        category:
          type: string
          description: UI grouping (e.g. policy, behavioral, agent, llm, saas_agent).
        severity:
          type: string
          enum:
            - info
            - warning
            - critical
        display_name:
          type: string
          description: English label; frontends may localize by kind and fall back to this.
        description:
          type: string
          description: One sentence explaining when this type fires.
        default_channels:
          type: array
          items:
            type: string
            enum:
              - bell
              - slack
              - email
              - webhook
    NotificationKind:
      type: string
      description: |
        Event class. Open-ended: new kinds can appear without a change to this
        API. A client should render an unknown kind with the generic
        title/body/link_url fields and ignore the payload.
        The `runtime.*` kinds carry typed payloads —
        `RuntimeUngovernedExecutionPayload`, `RuntimeRuleMatchPayload`,
        `RuntimeObserverOfflinePayload`.
      enum:
        - aibom.drift_advisory
        - saas_agent.discovered
        - policy.block
        - behavioral.drift
        - agent.disconnect
        - llm.budget
        - execution.write_then_run_observed
        - execution.evasion_blocked
        - execution.script_unresolvable
        - execution.sandbox_degraded
        - execution.output_redacted
        - agent.execution_coverage_degraded
        - runtime.ungoverned_execution
        - runtime.rule_match
        - runtime.observer_offline
        - governance.review_requested
        - governance.review_resolved
        - governance.review_overdue
        - governance.periodic_review_due
        - governance.unapproved_agent_active
        - governance.risk_register_drift
        - approval.requested
        - approval.resolved
        - approval.timed_out
        - model.risk_advisory
        - redteam.finding
        - report.ready
      x-extensible-enum: true
    NotificationEvent:
      type: object
      required:
        - id
        - kind
        - title
        - body
        - is_read
        - created_at
      properties:
        id:
          type: string
          format: uuid
        kind:
          $ref: "#/components/schemas/NotificationKind"
        title:
          type: string
          description: One-line headline rendered in the bell dropdown row.
        body:
          type: string
          description: |
            Subtitle / secondary text. May be empty. Bell-dropdown rows
            typically truncate after ~80 chars; full body shows on the
            /app/notifications list page.
        link_url:
          type: string
          description: |
            Route in the MITRITY web app the row links to, relative to its
            origin (e.g. `/app/aibom/advisories/<id>`). Omitted when the event
            has no actionable detail page.
        payload:
          type: object
          additionalProperties: true
          description: |
            Kind-specific structured detail. Shape varies by kind; see
            the producer documentation for each `kind`. For
            `aibom.drift_advisory`:
            `{ advisory_id, indicator_id, severity }`.
            For `execution.sandbox_degraded` (fires when the governed shell
            stamps `sandbox_mode` `none`, `bubblewrap_no_seccomp` or
            `unavailable_denied`; deduplicated per agent, mode and reason with
            a one-hour cooldown):
            `{ agent_id, agent_name, event_id, policy_id, sandbox_mode,
            requested_backend, reason, host_os, executed, sandbox_required,
            dedup_key }` — `executed` is false for `unavailable_denied`.
            For `execution.output_redacted` (fires when a credential value was
            redacted from a command's output; deduplicated per agent and
            credential, one hour):
            `{ agent_id, agent_name, event_id, credential_ids, redaction_count,
            stdout_bytes, stderr_bytes, command_sha256, dedup_key }`.

            For `agent.execution_coverage_degraded` (fires on the transition
            INTO `degraded`, once per episode):
            `{ agent_id, agent_name, state, previous_state,
            findings: [{ code, severity, message, remediation }],
            unhooked_exec_tools, other_mcp_servers, reason, dedup_key }`
            — `findings` are the open `high` findings.
        is_read:
          type: boolean
          description: |
            Read state for the AUTHENTICATED CALLER. Two users in the
            same tenant can see the same event with different
            `is_read` values.
        read_at:
          type: string
          format: date-time
          description: When the caller marked this event read; null if unread.
        created_at:
          type: string
          format: date-time
    NotificationEventPage:
      type: object
      required:
        - events
      properties:
        events:
          type: array
          items:
            $ref: "#/components/schemas/NotificationEvent"
        next_cursor:
          type: string
          description: |
            Pass to the next /notifications call to fetch the page after
            this one. Omitted on the last page.
    NotificationUnreadCount:
      type: object
      required:
        - unread
      properties:
        unread:
          type: integer
          description: |
            Total unread events visible to the caller in the current
            tenant. Capped at 99 client-side for the badge — server
            may return the exact number.
    NotificationMarkAllReadResult:
      type: object
      required:
        - marked_read
      properties:
        marked_read:
          type: integer
          description: |
            Number of events newly marked read by this request. Events
            already read are not re-marked and do not count.
    WebhookDestination:
      type: object
      description: A SIEM or webhook destination of the organization. The stored Authorization header is
        never returned.
      required:
        - id
        - name
        - destination_type
        - endpoint_url
        - has_auth_header
        - forward_blocked
        - forward_alerted
        - forward_allowed
        - enabled
        - last_sent_at
        - last_error
        - created_at
        - updated_at
      properties:
        id:
          type: string
          format: uuid
        name:
          type: string
        destination_type:
          type: string
          enum:
            - splunk_hec
            - syslog_cef
            - webhook
            - custom
        endpoint_url:
          type: string
        has_auth_header:
          type: boolean
          description: Whether an Authorization header is stored.
        forward_blocked:
          type: boolean
        forward_alerted:
          type: boolean
        forward_allowed:
          type: boolean
        enabled:
          type: boolean
        last_sent_at:
          type: string
          format: date-time
          nullable: true
          description: When a delivery to the destination last succeeded. Null before the first success, and
            after a forwarding delivery that failed.
        last_error:
          type: string
          nullable: true
          description: Why the latest failed delivery failed. Null after a successful one.
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    WebhookDestinationCreate:
      type: object
      description: The body of a create, and of an update, which replaces every setting but `auth_header`
        (see `PUT /siem-destinations/{id}`).
      required:
        - name
        - destination_type
        - endpoint_url
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 255
        destination_type:
          type: string
          enum:
            - splunk_hec
            - syslog_cef
            - webhook
            - custom
          description: "How events are formatted: `splunk_hec` in the HEC event envelope, `syslog_cef` as CEF
            lines posted over HTTPS, `webhook` and `custom` as JSON."
        endpoint_url:
          type: string
          maxLength: 2048
          description: An absolute `http` or `https` URL. Deliveries are made only to an `https` URL whose
            host resolves to a public address; any other endpoint records the failure in
            `last_error` rather than receiving events.
        auth_header:
          type: string
          nullable: true
          description: Sent as the `Authorization` header on every delivery, for example `Splunk <HEC token>`
            or `Bearer <token>`; stored encrypted and never returned. On an update, leaving it out
            or sending `null` keeps the stored header, an empty string removes it, and a value
            replaces it; an update that changes `endpoint_url` while a header is stored must send it
            again, or an empty string (400 otherwise).
        forward_blocked:
          type: boolean
          default: false
          description: Forward blocked decisions. Left out, it is false.
        forward_alerted:
          type: boolean
          default: false
          description: Forward alerted decisions, and any decision that is neither blocked nor allowed. Left
            out, it is false.
        forward_allowed:
          type: boolean
          default: false
          description: Forward allowed decisions. Left out, it is false.
        enabled:
          type: boolean
          default: false
          description: "Whether the destination receives anything. Left out, it is false: send `true` to
            switch a destination on."
    Approval:
      type: object
      properties:
        id:
          type: string
          format: uuid
        tenant_id:
          type: string
          format: uuid
        event_id:
          type: string
          format: uuid
        agent_id:
          type: string
          format: uuid
        agent_name:
          type: string
        action_type:
          type: string
        resource_pattern:
          type: string
        policy_id:
          type: string
          format: uuid
        policy_name:
          type: string
        drift_score:
          type: number
          format: float
        status:
          type: string
          enum:
            - pending
            - approved
            - denied
            - timed_out
        timeout_at:
          type: string
          format: date-time
        resolved_by:
          type: string
          format: uuid
        resolved_at:
          type: string
          format: date-time
        resolution_note:
          type: string
        created_at:
          type: string
          format: date-time
    ApprovalList:
      type: object
      properties:
        approvals:
          type: array
          items:
            $ref: "#/components/schemas/Approval"
        next_page_token:
          type: string
    AgentLifecycleStatus:
      type: string
      enum:
        - proposed
        - under_review
        - changes_requested
        - approved
        - blocked
        - retired
      description: |
        The human-owned approval axis on an agent — distinct from `status`
        (owned by the edge and key management) and `governance_state` (the SaaS
        discovery triage axis). The only axis a person sets.

        - `proposed` — exists, not yet assessed. The state a new agent enters
          when the tenant gate is `alert` or `enforce`, with its onboarding
          review open.
        - `under_review` — an assessment was submitted and a review is open.
        - `changes_requested` — a reviewer sent it back.
        - `approved` — a reviewer approved or attested. The only state the gate
          treats as cleared.
        - `blocked` — denied by a reviewer, or blocked directly by an operator.
        - `retired` — out of service as a governance matter.

        Agents that existed before the lifecycle axis shipped read `approved`.
    GovernanceReviewStatus:
      type: string
      enum:
        - pending
        - approved
        - changes_requested
        - denied
        - attested
        - withdrawn
      description: "`pending` and `changes_requested` are OPEN — the question is still unanswered, and a
        second review for the same subject and fingerprint is refused while either is in force.
        `approved`, `attested`, `denied` and `withdrawn` are terminal. `attested` is an approval
        that carries a named person's attestation text; `approved` is an approval without one."
    GovernanceReviewKind:
      type: string
      enum:
        - onboarding
        - risk_assessment
        - periodic_review
        - change
        - incident
      description: What the review is for. `onboarding` and `periodic_review` require an attestation to
        resolve as approved.
    GovernanceReviewTriggerKind:
      type: string
      enum:
        - manual
        - finding
        - schedule
        - policy_change
      description: What opened the review.
    GovernanceReview:
      type: object
      required:
        - id
        - tenant_id
        - subject_type
        - subject_id
        - kind
        - trigger_kind
        - title
        - status
        - assigned_role
        - revision
        - created_at
      properties:
        id:
          type: string
          format: uuid
        tenant_id:
          type: string
          format: uuid
        subject_type:
          type: string
          enum:
            - agent
          description: Polymorphic from day one; `agent` is the only subject in this version.
        subject_id:
          type: string
          format: uuid
        subject_name:
          type: string
          description: Display name of the subject, resolved at read time.
        kind:
          $ref: "#/components/schemas/GovernanceReviewKind"
        trigger_kind:
          $ref: "#/components/schemas/GovernanceReviewTriggerKind"
        trigger_ref:
          type: object
          additionalProperties: true
          description: Machine identity of the trigger — `{source, code}` for a finding, plus `{finding_id}`
            for the row it came from, `{policy_id}` for a policy change. Empty for `manual`. The
            (subject, source, code) tuple is what makes a repeating trigger idempotent while a
            review is open.
        title:
          type: string
        payload:
          type: object
          additionalProperties: true
          description: >-
            Template identity (`template_slug`, `template_version`), the `answers` map keyed by
            question id, and the advisory `score`, `score_max` and `band`.

            The score is COMPUTED BY THE SERVER on create and on every revision from the
            `risk_weight` maps of the template the answers are given on — the shipped one or a
            tenant's own alike — and written into this payload, which is the source of truth; a
            `score` or `band` sent by a client is ignored. The dashboard previews the same number
            from the same maps as the reviewer fills the form in. The score is advisory: it is
            displayed to the reviewer and never resolves a review by itself.

            The three keys are present only once a question the answers make visible holds an answer
            (a boolean, or text with something in it): a review opened without answers, or whose
            answers were cleared, carries none of them and is not yet assessed. They are absent too
            when the questions shown carry no `risk_weight` (not scored), or when the payload names
            a template that is not there. A review stored before this rule, with a score and no
            answer, is served without the three keys.

            `approved_policy_version` (`{policy_id, version, recorded_at}`) is WRITTEN BY THE SERVER
            when the review resolves `approved` or `attested`, from the subject's bound policy and
            its `current_version` at that moment. A value under that key in a payload sent by a
            client — on create, on a revision, or on resolve — is dropped before the payload is
            stored, so the key is present exactly when the server recorded it and never when the
            subject had no policy. The most recently resolved `approved`/`attested` review carrying
            it is the approval of record the drift comparison uses.

            Observed beside declared. On create and on every revision the server also observes the
            subject agent's runtime record for every question the template pre-fills from behavior,
            and writes four more keys: `observed_answers` (an `AssessmentObservedAnswer` per
            question id — server-owned, anything a client sends under it is discarded),
            `answer_sources` (per observed question that was answered: `observed` when the kept
            answer equals the observed value, `declared` when it differs or the record offered
            context without a value), `declared_answers` (the kept answer for every question whose
            source is `declared`, normalized from `answers`), and `observation` (`{window,
            observed_at}`). The `answers` map is never written to by the server: an observed answer
            is a proposal until a person moves it there.

            A review the adversarial testing plane opened (`trigger_ref.source: redteam`) carries no
            template: its payload is the proposal — `proposed_indicator` (kind, name, pattern,
            rationale) with the finding's identifiers, reproduction key, oracle, confidence,
            remediation, mappings and evidence hashes, never its excerpt — and, once resolved,
            `proposal_outcome` (`kind`, `landed`, `reason`, `pattern_id`, `pattern_name`,
            `applied_at`): what the approval did, or why it landed nothing.
        requested_by:
          type: string
          format: uuid
          nullable: true
          description: Null when nobody requested the review — a sweeper or a trigger opened it, or a
            connector discovered its agent — or when the requester's account has since been deleted.
            A review with no requester takes no answers and cannot be sent back.
        assigned_role:
          type: string
          enum:
            - manager
            - owner
          description: The minimum role that may resolve this review.
        due_at:
          type: string
          format: date-time
          nullable: true
        status:
          $ref: "#/components/schemas/GovernanceReviewStatus"
        attestation_text:
          type: string
          nullable: true
          description: Present and non-empty only when the status is `attested`. Stored verbatim and
            reproduced in reports.
        reviewer_note:
          type: string
          nullable: true
        resolved_by:
          type: string
          format: uuid
          nullable: true
        resolved_at:
          type: string
          format: date-time
          nullable: true
        revision:
          type: integer
          description: Starts at 1; increments on each resubmit after `changes_requested`.
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    GovernanceReviewList:
      type: object
      required:
        - reviews
      properties:
        reviews:
          type: array
          items:
            $ref: "#/components/schemas/GovernanceReview"
    GovernanceReviewCreateRequest:
      type: object
      required:
        - subject_type
        - subject_id
        - kind
      properties:
        subject_type:
          type: string
          enum:
            - agent
        subject_id:
          type: string
          format: uuid
        kind:
          $ref: "#/components/schemas/GovernanceReviewKind"
        title:
          type: string
          description: Defaults to a generated title naming the subject and the kind.
        payload:
          type: object
          additionalProperties: true
          description: "Template identity and the assessment answers. Without a `template_slug`, the tenant's
            `default_template_slug` is named (the shipped template when that is empty or names a
            template that is no longer there); a `template_slug` must name the shipped template or
            one of the tenant's, in at most 64 characters, or the request is a 400. The server
            scores the answers and observes the agent's record before storing it: `score`,
            `score_max`, `band`, `observed_answers`, `answer_sources` and `observation` are
            server-owned and replaced; `declared_answers` is normalized from `answers`. Nothing is
            scored until something is answered."
        assigned_role:
          type: string
          enum:
            - manager
            - owner
          default: manager
    GovernanceReviewUpdateRequest:
      type: object
      properties:
        title:
          type: string
        payload:
          type: object
          additionalProperties: true
          description: "Replaces the stored payload wholesale. The revised answers are rescored and the agent
            re-observed, so the observed column is as current as the declared one. A payload that
            names no `template_slug` is answered on the template the review was opened with, or the
            tenant's `default_template_slug` when the review names none. A 400 when the template it
            would be answered on is not there: a `template_slug` that names neither the shipped
            template nor one of the tenant's, or a review whose own template is no longer there when
            the payload names none."
        assigned_role:
          type: string
          enum:
            - manager
            - owner
    AssessmentQuestion:
      type: object
      required:
        - id
        - type
        - prompt
      properties:
        id:
          type: string
          description: Unique across the whole template, not only within its section.
        type:
          type: string
          enum:
            - text
            - boolean
            - single_select
        prompt:
          type: string
        help:
          type: string
        required:
          type: boolean
          default: false
        options:
          type: array
          description: Required for `single_select`; each option has an `id` and a `label`.
          items:
            type: object
            required:
              - id
              - label
            properties:
              id:
                type: string
              label:
                type: string
        conditional_on:
          type: object
          description: Shows the question only when another question holds a given value. The referenced
            question must appear earlier in the template.
          required:
            - question_id
            - equals
          properties:
            question_id:
              type: string
            equals: {}
        prefill_from_agent:
          type: string
          description: Agent field whose current value is offered as the draft answer.
        prefill_from_behavior:
          type: string
          enum:
            - external_destinations
            - personal_data
            - data_categories
            - untrusted_input
            - credential_access
            - credential_handling
            - oversight_mechanism
            - monitoring
            - kill_switch
            - autonomy
          description: Names a resolver over the agent's runtime record whose observed answer is offered
            beside the declared one (`GET /governance/prefill`). Validated at load against the
            resolver's answer type and, for a single select, the option ids it may propose
            (`credential_handling` proposes `brokered`; `oversight_mechanism` proposes
            `approval_required` or `alerts_only`; `autonomy` offers context only), so a fork cannot
            attach a boolean resolver to a text question or drop an option a resolver proposes.
        effects:
          type: object
          description: "`set_agent_field` writes the answer to `use_case`, `criticality` or `risk_tier` when
            the review is approved, on a tenant's template as on the shipped one. A `boolean`
            question cannot set a field, and a `single_select` that sets `criticality` (`low`,
            `medium`, `high`, `critical`) or `risk_tier` (`minimal`, `limited`, `high`,
            `unacceptable`) offers only those values. An answer outside a field's values is refused
            when it is submitted, and skipped when the review is approved (an answer stored before
            the check)."
          properties:
            set_agent_field:
              type: string
              enum:
                - use_case
                - criticality
                - risk_tier
        risk_classes:
          type: array
          description: Risk classes from the shared risk catalog (`bias`, `fairness`, `robustness`, `privacy`,
            `security`, `transparency`, `accountability`, `human_oversight`, `safety`). When the
            review is approved or attested, each class an answered question names seeds one entry in
            the risk register.
          items:
            type: string
        risk_weight:
          type: object
          additionalProperties:
            type: number
            minimum: 0
          description: What each answer contributes to the advisory score, keyed by option id for a
            `single_select` and by `"true"` / `"false"` for a `boolean`. A `text` question cannot
            carry weights — free text cannot be scored without pretending to understand it — and a
            key that is not one of the question's answers is rejected, because it is a rule that
            could never fire. The server computes the score; the client renders a live preview from
            this same map.
        maps_to:
          type: array
          description: Control identifiers this answer is evidence for.
          items:
            type: string
        evidence_prompt:
          type: string
    AssessmentSection:
      type: object
      required:
        - id
        - title
        - questions
      properties:
        id:
          type: string
        title:
          type: string
        description:
          type: string
        questions:
          type: array
          items:
            $ref: "#/components/schemas/AssessmentQuestion"
    AssessmentTemplate:
      type: object
      required:
        - slug
        - version
        - title
        - sections
        - is_builtin
      properties:
        id:
          type: string
          format: uuid
          nullable: true
          description: Null for the template that ships with the platform.
        slug:
          type: string
          example: mitrity.default
        version:
          type: integer
        title:
          type: string
        description:
          type: string
        based_on:
          type: string
          nullable: true
          description: Slug of the template this one was forked from.
        is_builtin:
          type: boolean
          description: True for the template served from the binary. Built-in templates cannot be overwritten;
            fork them under a new slug instead.
        sections:
          type: array
          items:
            $ref: "#/components/schemas/AssessmentSection"
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    AssessmentEvidence:
      type: object
      description: One cited record behind an observed answer, in the same shape as a report's evidence items.
      required:
        - kind
        - summary
      properties:
        kind:
          type: string
          description: The evidence kinds a report cites.
          enum:
            - audit_event
            - approval
            - review
            - attestation
            - finding
            - policy
            - coverage
            - dlp_event
            - injection_event
            - risk
            - metric
        ref_table:
          type: string
        ref_id:
          type: string
          description: A row id, or an aggregate key such as `agent:<id>` or `class:pii`.
        summary:
          type: string
          description: One sentence that never carries a secret.
        occurred_at:
          type: string
          format: date-time
        deep_link:
          type: string
          description: A dashboard path a reviewer can follow, e.g. `/app/audit?agent_id=…`.
    AssessmentObservedAnswer:
      type: object
      description: What the runtime record says about one question.
      required:
        - question_id
        - resolver
        - value
        - confidence
        - summary
        - evidence
      properties:
        question_id:
          type: string
        resolver:
          type: string
          description: The `prefill_from_behavior` resolver that produced it.
        value:
          oneOf:
            - type: boolean
            - type: string
            - type: "null"
          description: "The proposed answer in the question's own type — a boolean for a `boolean` question,
            an option id for a `single_select`, a string for `text`. Always present: an explicit
            `null` means the resolver offers context only (the `autonomy` resolver), which is a
            different statement from the key being absent."
        confidence:
          type: string
          enum:
            - observed
            - no_data
          description: "`observed`: the window holds data that supports the value. `no_data`: it holds nothing
            on the question. Never a guess."
        summary:
          type: string
        evidence:
          type: array
          items:
            $ref: "#/components/schemas/AssessmentEvidence"
    AssessmentObservation:
      type: object
      description: The response of `GET /governance/prefill`.
      required:
        - agent_id
        - template_slug
        - template_version
        - window
        - observed_at
        - observed_answers
      properties:
        agent_id:
          type: string
          format: uuid
        template_slug:
          type: string
        template_version:
          type: integer
        window:
          type: object
          required:
            - from
            - to
            - days
          properties:
            from:
              type: string
              format: date-time
            to:
              type: string
              format: date-time
            days:
              type: integer
        observed_at:
          type: string
          format: date-time
        observed_answers:
          type: object
          description: Keyed by question id; only questions with a resolver appear.
          additionalProperties:
            $ref: "#/components/schemas/AssessmentObservedAnswer"
    AssessmentTemplateList:
      type: object
      required:
        - templates
      properties:
        templates:
          type: array
          items:
            $ref: "#/components/schemas/AssessmentTemplate"
    AssessmentTemplateWrite:
      type: object
      required:
        - title
        - sections
      properties:
        title:
          type: string
        description:
          type: string
        based_on:
          type: string
        sections:
          type: array
          items:
            $ref: "#/components/schemas/AssessmentSection"
    LifecycleGate:
      type: string
      enum:
        - off
        - alert
        - enforce
      description: |
        What the lifecycle axis means at runtime, per tenant.

        - `off` — a label. New agents are created `approved`.
        - `alert` — new agents start `proposed`. The edge admits their actions
          and stamps each audit row's metadata with the agent's
          `lifecycle_status`; the control plane raises
          `governance.unapproved_agent_active` once per agent.
        - `enforce` — as `alert`, and a non-approved agent's actions are denied
          at the edge with `blocked_by = 'lifecycle'`.

        Default `alert`, because the honest first answer to "what is running
        without approval" is a list, not an outage.
    GovernanceSettings:
      type: object
      required:
        - tenant_id
        - lifecycle_gate
        - default_template_slug
        - periodic_review_days
        - review_sla_days
        - finding_review_severity
      properties:
        tenant_id:
          type: string
          format: uuid
        lifecycle_gate:
          $ref: "#/components/schemas/LifecycleGate"
          default: alert
          description: The tenant's gate. A tenant that has never written settings reads `alert`, materialized
            on first read from the column default.
        default_template_slug:
          type: string
          default: mitrity.default
          description: "The questionnaire of every review opened without naming one — the onboarding review of
            an agent a person registers, and a review opened with `POST /governance/reviews` without
            a `template_slug` — and the template `GET /governance/prefill` reads when none is named.
            The shipped `mitrity.default` stands in when it is empty or names a template that is no
            longer there: a new slug is checked when it is set, so that is a slug stored before
            slugs were checked."
        periodic_review_days:
          type: integer
          default: 365
          minimum: 1
          maximum: 3650
          description: Days from an approval to the next periodic review. Sets `next_review_due_at` on the
            agent.
        review_sla_days:
          type: integer
          default: 5
          minimum: 1
          maximum: 365
          description: Days from opening a review to its `due_at`.
        finding_review_severity:
          type: string
          enum:
            - critical
            - high
            - medium
            - low
          default: critical
          description: Lowest finding severity that opens a review automatically. Findings below it are
            recorded but do not create governance work.
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    GovernanceSettingsUpdate:
      type: object
      description: Every field is optional; omitted fields keep their current value.
      properties:
        lifecycle_gate:
          $ref: "#/components/schemas/LifecycleGate"
          description: Omitted ⇒ unchanged. The tenant default is on GovernanceSettings.
        default_template_slug:
          type: string
          maxLength: 64
          description: A new slug must name a template the tenant can open — the shipped `mitrity.default` or
            one of the tenant's forks — because every onboarding review the platform opens asks it;
            anything else is a 400. The slug already on file is accepted unchanged, so a client can
            send back the settings it read.
        periodic_review_days:
          type: integer
          minimum: 1
          maximum: 3650
        review_sla_days:
          type: integer
          minimum: 1
          maximum: 365
        finding_review_severity:
          type: string
          enum:
            - critical
            - high
            - medium
            - low
    ToolDefinition:
      type: object
      properties:
        id:
          type: string
          format: uuid
        tenant_id:
          type: string
          format: uuid
        name:
          type: string
          example: AWS S3
        category:
          type: string
          enum:
            - storage
            - database
            - messaging
            - llm
            - code_execution
            - code
            - network
            - custom
            - monitoring
            - infrastructure
            - project_management
            - identity
            - search
        match_rules:
          $ref: "#/components/schemas/ToolMatchRules"
        operations:
          type: array
          items:
            type: string
          example:
            - read
            - write
            - delete
            - list
        scopes:
          type: array
          items:
            type: string
          example:
            - bucket:*
          nullable: true
        is_builtin:
          type: boolean
        risk_level:
          type: string
          enum:
            - critical
            - high
            - medium
            - low
            - info
            - unrated
          nullable: true
          description: MITRITY-curated risk rating. unrated = not yet reviewed; absence of risk does NOT imply
            'safe' (see last_reviewed_at).
        risk_category:
          allOf:
            - $ref: "#/components/schemas/RiskCategory"
          nullable: true
        risk_rationale:
          type: string
          nullable: true
          description: MITRITY's explanation of the rating.
        risk_references:
          type: array
          items:
            $ref: "#/components/schemas/RiskReference"
          nullable: true
        last_reviewed_at:
          type: string
          format: date-time
          nullable: true
          description: When MITRITY last reviewed this tool, shown as 'reviewed YYYY-MM-DD — no current
            concerns identified' in place of a 'safe' rating.
        last_reviewed_by:
          type: string
          format: uuid
          nullable: true
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    RiskReference:
      type: object
      required:
        - kind
        - url
      properties:
        kind:
          type: string
          enum:
            - cve
            - blog
            - docs
            - github
            - advisory
        url:
          type: string
          format: uri
        label:
          type: string
          example: CVE-2026-XXXX
    ToolMatchRules:
      type: object
      required:
        - action_type_pattern
      properties:
        action_type_pattern:
          type: string
          description: Glob pattern to match action_type, e.g. 's3:*'
        resource_pattern:
          type: string
          description: Glob pattern to match resource, e.g. '*.slack.com/*'
    CreateToolDefinitionRequest:
      type: object
      required:
        - name
        - category
        - match_rules
        - operations
      properties:
        name:
          type: string
        category:
          type: string
          enum:
            - storage
            - database
            - messaging
            - llm
            - code_execution
            - code
            - network
            - custom
            - monitoring
            - infrastructure
            - project_management
            - identity
            - search
        match_rules:
          $ref: "#/components/schemas/ToolMatchRules"
        operations:
          type: array
          items:
            type: string
        scopes:
          type: array
          items:
            type: string
    UpdateToolDefinitionRequest:
      type: object
      description: Only the fields sent change.
      properties:
        name:
          type: string
          description: Surrounding whitespace is trimmed.
        category:
          type: string
          enum:
            - storage
            - database
            - messaging
            - llm
            - code_execution
            - code
            - network
            - custom
            - monitoring
            - infrastructure
            - project_management
            - identity
            - search
          description: Any other value is refused 400.
        match_rules:
          $ref: "#/components/schemas/ToolMatchRules"
          description: Replaces the stored match rules whole.
        operations:
          type: array
          items:
            type: string
          description: Replaces the stored operations; an empty array keeps them.
        scopes:
          type: array
          items:
            type: string
          description: Replaces the stored scopes; an empty array leaves none.
    TimeWindow:
      type: object
      properties:
        start:
          type: string
          example: 08:00
        end:
          type: string
          example: 18:00
        timezone:
          type: string
          example: UTC
    ToolPermission:
      type: object
      properties:
        id:
          type: string
          format: uuid
        tenant_id:
          type: string
          format: uuid
        agent_id:
          type: string
          format: uuid
        tool_id:
          type: string
          format: uuid
        tool_name:
          type: string
        allowed_operations:
          type: array
          items:
            type: string
        allowed_scopes:
          type: array
          items:
            type: string
          nullable: true
        denied_scopes:
          type: array
          items:
            type: string
          nullable: true
        rate_limit_per_minute:
          type: integer
          nullable: true
        max_payload_bytes:
          type: integer
          format: int64
          nullable: true
        time_window:
          $ref: "#/components/schemas/TimeWindow"
          nullable: true
        enabled:
          type: boolean
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    SetToolPermissionRequest:
      type: object
      required:
        - tool_id
        - allowed_operations
        - enabled
      properties:
        tool_id:
          type: string
          format: uuid
        allowed_operations:
          type: array
          items:
            type: string
        allowed_scopes:
          type: array
          items:
            type: string
        denied_scopes:
          type: array
          items:
            type: string
        rate_limit_per_minute:
          type: integer
        max_payload_bytes:
          type: integer
          format: int64
        time_window:
          $ref: "#/components/schemas/TimeWindow"
        enabled:
          type: boolean
    DestinationAllowlistEntry:
      type: object
      properties:
        id:
          type: string
          format: uuid
        tenant_id:
          type: string
          format: uuid
        agent_id:
          type: string
          format: uuid
        domain_pattern:
          type: string
          example: "*.internal.company.com"
        created_at:
          type: string
          format: date-time
    DLPPattern:
      type: object
      description: |
        A DLP content-pattern definition. Built-in patterns are
        MITRITY-maintained, shared by every tenant, and read-only over this
        API (`is_builtin=true`, `tenant_id=null`). Tenant-authored patterns
        are owned by the tenant and freely editable.
      required:
        - id
        - name
        - pattern_type
        - pattern_data
        - category
        - is_builtin
        - created_at
        - updated_at
      properties:
        id:
          type: string
          format: uuid
        tenant_id:
          type: string
          format: uuid
          nullable: true
          description: Null for built-in patterns. Set to the owning tenant for custom patterns.
        name:
          type: string
          minLength: 1
          maxLength: 128
          description: Unique per tenant. Built-in names are unique platform-wide.
        description:
          type: string
          nullable: true
        pattern_type:
          type: string
          enum:
            - regex
            - string_list
            - classifier
          description: |
            `classifier` is reserved for built-in patterns — POST/PUT from a
            tenant returns 400 if this value is supplied.
        pattern_data:
          description: |
            Pattern body. Shape varies by `pattern_type`:
            `regex` → `RegexPatternData`,
            `string_list` → `StringListPatternData`,
            `classifier` → `ClassifierPatternData`.
          oneOf:
            - $ref: "#/components/schemas/RegexPatternData"
            - $ref: "#/components/schemas/StringListPatternData"
            - $ref: "#/components/schemas/ClassifierPatternData"
        category:
          type: string
          enum:
            - pii
            - credentials
            - competitor
            - internal
            - legal
            - custom
          description: |
            Drives UI grouping and reporting. `custom` is the default for
            tenant-authored patterns that don't fit a built-in bucket.
        is_builtin:
          type: boolean
          description: True for MITRITY-maintained patterns; read-only from this API.
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    RegexPatternData:
      type: object
      description: |
        Shape for `pattern_type='regex'`. The pattern is compile-tested at
        save and ReDoS-fuzzed against a 10 KB synthetic input with a 100 ms
        timeout; catastrophic-backtracking patterns are rejected with 400.
      required:
        - pattern
      properties:
        pattern:
          type: string
          minLength: 1
          description: Go `regexp`-compatible expression (RE2 syntax).
          example: ACME-[A-Z0-9]{8}
        flags:
          type: string
          description: |
            Optional inline flags. Allowed characters: `i` (case-insensitive),
            `m` (multiline), `s` (dot-matches-newline). Any other character
            is rejected with 400.
          example: i
          default: ""
    StringListPatternData:
      type: object
      description: Shape for `pattern_type='string_list'`.
      required:
        - strings
      properties:
        strings:
          type: array
          minItems: 1
          maxItems: 10000
          items:
            type: string
            minLength: 1
          description: Literal strings to match. Each entry must be non-empty after trim.
          example:
            - Project Polaris
            - Polaris launch
        case_insensitive:
          type: boolean
          default: false
    ClassifierPatternData:
      type: object
      description: |
        Shape for `pattern_type='classifier'` (built-ins only). The
        `classifier_id` resolves to a built-in Go classifier in the edge
        engine (e.g. `ssn_us_v1` for US Social Security Numbers with
        checksum validation).
      required:
        - classifier_id
      properties:
        classifier_id:
          type: string
          minLength: 1
          example: ssn_us_v1
    DLPPatternList:
      type: object
      description: |
        Paginated list response — mirrors the `httputil.ListResponse`
        wrapper used across the tenant-facing API.
      required:
        - data
        - total
        - limit
        - offset
      properties:
        data:
          type: array
          items:
            $ref: "#/components/schemas/DLPPattern"
        total:
          type: integer
          format: int64
          description: Total rows matching the filter, before limit/offset.
        limit:
          type: integer
          description: The effective page size.
        offset:
          type: integer
          description: The effective page offset.
    CreateDLPPatternRequest:
      type: object
      description: |
        Body for POST /dlp/patterns. The request body MUST NOT contain any
        field named `value`, `matched_text`, or `matched_content` at any
        depth — the DLP audit contract is built on the absence of raw
        matched content, and a buggy client that ships one of these fields
        is rejected with 400 so the mistake is caught at the boundary.
      required:
        - name
        - pattern_type
        - pattern_data
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 128
        description:
          type: string
          nullable: true
        pattern_type:
          type: string
          enum:
            - regex
            - string_list
          description: |
            `classifier` is reserved for built-ins and is rejected with 400
            if supplied here.
        pattern_data:
          oneOf:
            - $ref: "#/components/schemas/RegexPatternData"
            - $ref: "#/components/schemas/StringListPatternData"
        category:
          type: string
          enum:
            - pii
            - credentials
            - competitor
            - internal
            - legal
            - custom
          default: custom
    UpdateDLPPatternRequest:
      type: object
      description: |
        Body for PUT /dlp/patterns/{id}. All fields are optional — only the
        supplied fields are changed. Same reserved-field rejection as POST.
        Built-in patterns return 403; tenant-owned patterns are updated in
        place. If either `pattern_type` or `pattern_data` is supplied, the
        full validation (compile + ReDoS fuzz for regex, length cap for
        string_list) re-runs over the merged result.
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 128
        description:
          type: string
          nullable: true
        pattern_type:
          type: string
          enum:
            - regex
            - string_list
        pattern_data:
          oneOf:
            - $ref: "#/components/schemas/RegexPatternData"
            - $ref: "#/components/schemas/StringListPatternData"
        category:
          type: string
          enum:
            - pii
            - credentials
            - competitor
            - internal
            - legal
            - custom
    DLPBinding:
      type: object
      description: |
        A single (policy, pattern) binding. Carries the per-binding
        enforcement settings plus a copy of a few of the pattern's fields, so
        a list can be shown without fetching each pattern.
      required:
        - id
        - tenant_id
        - policy_id
        - pattern_id
        - pattern_name
        - pattern_type
        - pattern_category
        - pattern_is_builtin
        - direction
        - action
        - priority
        - enabled
        - created_at
        - updated_at
      properties:
        id:
          type: string
          format: uuid
        tenant_id:
          type: string
          format: uuid
        policy_id:
          type: string
          format: uuid
        pattern_id:
          type: string
          format: uuid
        pattern_name:
          type: string
          description: Copied from the pattern's `name`.
        pattern_type:
          type: string
          enum:
            - regex
            - string_list
            - classifier
          description: Copied from the pattern's `pattern_type`.
        pattern_category:
          type: string
          enum:
            - pii
            - credentials
            - competitor
            - internal
            - legal
            - custom
          description: Copied from the pattern's `category`.
        pattern_is_builtin:
          type: boolean
          description: Copied from the pattern's `is_builtin`.
        direction:
          type: string
          enum:
            - inbound
            - outbound
            - both
          description: Which traffic direction the pattern applies to for this policy.
        action:
          type: string
          enum:
            - block
            - redact
            - flag
          description: |
            Enforcement action on match. `block` denies the call; `redact`
            replaces the matched substring with `[REDACTED-<pattern_name>]`
            before forwarding; `flag` records an audit event and passes
            content through unchanged.
        priority:
          type: integer
          description: Evaluation order — higher = evaluated first; first match wins.
          default: 50
        enabled:
          type: boolean
          default: true
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    DLPBindingList:
      type: object
      description: |
        List response wrapper for GET /policies/{policy_id}/dlp-bindings.
      required:
        - bindings
      properties:
        bindings:
          type: array
          items:
            $ref: "#/components/schemas/DLPBinding"
    CreateDLPBindingRequest:
      type: object
      description: |
        Body for POST /policies/{policy_id}/dlp-bindings. The reserved-field
        rejection (`value` / `matched_text` / `matched_content`) applies.
      required:
        - pattern_id
        - action
      properties:
        pattern_id:
          type: string
          format: uuid
          description: Pattern to bind. Must be visible to the caller's tenant.
        direction:
          type: string
          enum:
            - inbound
            - outbound
            - both
          default: both
        action:
          type: string
          enum:
            - block
            - redact
            - flag
        priority:
          type: integer
          default: 50
        enabled:
          type: boolean
          default: true
    UpdateDLPBindingRequest:
      type: object
      description: |
        Body for PUT /policies/{policy_id}/dlp-bindings/{binding_id}. Every
        field is optional; only supplied fields are changed. The pattern
        itself is not changed by this call — re-pointing a binding at a
        different pattern requires DELETE + POST.
      properties:
        direction:
          type: string
          enum:
            - inbound
            - outbound
            - both
        action:
          type: string
          enum:
            - block
            - redact
            - flag
        priority:
          type: integer
        enabled:
          type: boolean
    InjectionEvent:
      type: object
      properties:
        id:
          type: string
          format: uuid
        tenant_id:
          type: string
          format: uuid
        agent_id:
          type: string
          format: uuid
        agent_name:
          type: string
        event_id:
          type: string
          format: uuid
          nullable: true
        detection_method:
          type: string
          enum:
            - pattern
            - statistical
            - ml_classifier
            - combined
        injection_probability:
          type: number
          format: float
          minimum: 0
          maximum: 1
        matched_patterns:
          type: array
          items:
            type: string
          example:
            - instruction_override
            - system_prompt_injection
        flagged_content_hash:
          type: string
          description: SHA-256 hash of the flagged content (privacy-safe)
        action_taken:
          type: string
          enum:
            - blocked
            - alerted
            - logged
        action_type:
          type: string
          description: The action type that was scanned (e.g. "llm:call", "mcp:tool")
        false_positive:
          type: boolean
          nullable: true
        occurred_at:
          type: string
          format: date-time
        created_at:
          type: string
          format: date-time
    InjectionEventSummary:
      type: object
      properties:
        total_events:
          type: integer
        blocked:
          type: integer
        alerted:
          type: integer
        by_method:
          type: object
          properties:
            pattern:
              type: integer
            statistical:
              type: integer
            ml_classifier:
              type: integer
            combined:
              type: integer
        by_agent:
          type: array
          items:
            type: object
            properties:
              agent_id:
                type: string
                format: uuid
              agent_name:
                type: string
              count:
                type: integer
    DelegationChain:
      type: object
      properties:
        id:
          type: string
          format: uuid
        tenant_id:
          type: string
          format: uuid
        chain_id:
          type: string
        initiator_agent_id:
          type: string
          format: uuid
        initiator_agent_name:
          type: string
        chain_depth:
          type: integer
        status:
          type: string
          enum:
            - active
            - completed
            - blocked
            - timed_out
        blocked_at_hop:
          type: integer
          nullable: true
        blocked_reason:
          type: string
          nullable: true
        started_at:
          type: string
          format: date-time
        completed_at:
          type: string
          format: date-time
          nullable: true
        created_at:
          type: string
          format: date-time
    DelegationChainDetail:
      allOf:
        - $ref: "#/components/schemas/DelegationChain"
        - type: object
          properties:
            hops:
              type: array
              items:
                $ref: "#/components/schemas/DelegationHop"
    DelegationHop:
      type: object
      properties:
        id:
          type: string
          format: uuid
        chain_id:
          type: string
        hop_index:
          type: integer
        from_agent_id:
          type: string
          format: uuid
          nullable: true
        from_agent_name:
          type: string
          nullable: true
        to_agent_id:
          type: string
          format: uuid
        to_agent_name:
          type: string
        action_requested:
          type: string
        decision:
          type: string
          enum:
            - allowed
            - blocked
        effective_permissions:
          type: array
          items:
            type: string
          nullable: true
        occurred_at:
          type: string
          format: date-time
    DelegationSummary:
      type: object
      properties:
        total_chains:
          type: integer
        active_chains:
          type: integer
        blocked_chains:
          type: integer
        avg_chain_depth:
          type: number
          format: float
        by_reason:
          type: object
          properties:
            circular:
              type: integer
            depth_exceeded:
              type: integer
            privilege_escalation:
              type: integer
            fan_out:
              type: integer
        top_delegators:
          type: array
          items:
            type: object
            properties:
              agent_id:
                type: string
                format: uuid
              agent_name:
                type: string
              chain_count:
                type: integer
    EvaluateDelegationRequest:
      type: object
      required:
        - current_agent_id
        - proposed_target_agent_id
      properties:
        chain_id:
          type: string
          maxLength: 256
          description: |
            Stable chain identifier across all hops. Required for a normal
            chain-state evaluation. Omitted (empty) on an A2A *downgrade
            probe* — when `chain_presented` is `false`, the control plane
            answers `chain_expected` instead of running the chain engine.
            At most 256 bytes of UTF-8 (`maxLength` counts characters, so a
            multi-byte id can be refused below it); a longer one is a 400.
            Runtimes send a UUID.
        current_agent_id:
          type: string
          format: uuid
          description: Agent issuing the delegate_to call
        current_agent_name:
          type: string
        current_mission_scope:
          type: string
          description: Delegator's mission scope at call time
        current_action:
          type: string
          maxLength: 256
          example: delegate:delegate_to
          description: |
            The action type the edge evaluated, recorded as the hop's
            `action_requested`. At most 256 bytes of UTF-8; a longer one is a
            400.
        proposed_target_agent_id:
          type: string
          format: uuid
          description: Agent being delegated to
        proposed_target_worker:
          type: string
          description: Human-readable worker / role name of the target
        chain_presented:
          type: boolean
          default: true
          description: |
            Whether the edge observed a delegation-chain-id header on this
            agent→agent hop. `true` (default) is a normal continuation
            evaluation with `chain_id` set. `false` is an A2A downgrade
            probe: the hop arrived with NO `x-mitrity-delegation-chain-id`
            header, so the edge asks the control plane whether a chain was
            *expected* for this hop. On a probe the control plane returns
            `chain_expected` and a verdict (deny header-drop downgrades,
            allow legitimate chain-less A2A) and records no hop.

            Presence-aware: an absent key is treated as `true` (the default),
            so an edge that predates the probe field — which never sends the
            key — is handled as a normal evaluation and never misread as a
            probe. Only an explicit `false` is a probe.

            INVARIANT (enforced by the API, not the schema): when
            `chain_presented` is `true` (or absent), `chain_id` MUST be
            present; when `chain_presented` is `false`, `chain_id` MUST be
            absent/empty. `chain_id` is therefore not unconditionally
            `required` at the schema level — the API rejects requests
            that violate the invariant.
    EvaluateDelegationResponse:
      type: object
      required:
        - allowed
        - reason
        - chain_depth
        - agents_in_chain
        - action_taken
      properties:
        allowed:
          type: boolean
        reason:
          type: string
          description: |
            Empty string when allowed; otherwise the violation code.
            `delegation_downgrade_suspected` is returned only on an A2A
            downgrade probe (`chain_presented=false`) when a chain was
            expected for the hop but no chain-id header was presented — the
            edge denies the suspected header-drop downgrade.
          enum:
            - ""
            - circular_delegation
            - depth_exceeded
            - unauthorized_delegate
            - fan_out_exceeded
            - privilege_escalation
            - delegation_downgrade_suspected
        chain_expected:
          type: boolean
          description: |
            Authoritative control-plane signal, set on an A2A downgrade probe
            (`chain_presented=false`), of whether a delegation chain was
            *expected* for the (`current_agent_id` → `proposed_target_agent_id`)
            hop. `true` means the target is a configured delegate of the
            source and/or the pair has an established delegation relationship in
            the recorded chain history — dropping the chain-id header on such a
            hop is a suspected downgrade, so `allowed` is `false`. `false`
            means no chain was expected (a legitimate direct A2A call), so
            `allowed` is `true`. Always `false` on a normal (non-probe)
            evaluation.
        chain_depth:
          type: integer
          description: |
            Depth after the proposed hop is appended. `0` on a downgrade probe
            (no chain was evaluated).
        agents_in_chain:
          type: array
          description: |
            Ordered agent IDs, including the proposed target. Empty on a
            downgrade probe (no chain was evaluated).
          items:
            type: string
        action_taken:
          type: string
          description: |
            What was done with the hop, in the past tense, matching the
            `decision` of audit events.
          enum:
            - allowed
            - blocked
            - alerted
            - held
        audit_event_id:
          type: string
          description: UUID of the audit event recorded for this evaluation, or an empty string
    CredentialDefinition:
      type: object
      properties:
        id:
          type: string
          format: uuid
        tenant_id:
          type: string
          format: uuid
        name:
          type: string
        credential_type:
          type: string
          enum:
            - api_key
            - oauth_token
            - db_password
            - service_account
        store_type:
          type: string
          enum:
            - builtin
            - gcp_secret_manager
            - aws_secrets_manager
            - vault
        store_reference:
          type: string
          nullable: true
        has_value:
          type: boolean
          readOnly: true
          description: Whether this credential has a stored value
        max_ttl_minutes:
          type: integer
        max_concurrent_leases:
          type: integer
        rotation_days:
          type: integer
          nullable: true
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    CreateCredentialRequest:
      type: object
      required:
        - name
        - credential_type
      properties:
        name:
          type: string
        credential_type:
          type: string
          enum:
            - api_key
            - oauth_token
            - db_password
            - service_account
        store_type:
          type: string
          enum:
            - builtin
            - gcp_secret_manager
            - aws_secrets_manager
            - vault
          default: builtin
        store_reference:
          type: string
          nullable: true
        credential_value:
          type: string
          description: The credential value (encrypted at rest, only for builtin store)
          writeOnly: true
        max_ttl_minutes:
          type: integer
          default: 60
        max_concurrent_leases:
          type: integer
          default: 5
        rotation_days:
          type: integer
          nullable: true
    UpdateCredentialRequest:
      type: object
      properties:
        name:
          type: string
        credential_value:
          type: string
          writeOnly: true
        max_ttl_minutes:
          type: integer
        max_concurrent_leases:
          type: integer
        rotation_days:
          type: integer
          nullable: true
    CredentialGrant:
      type: object
      properties:
        id:
          type: string
          format: uuid
        credential_id:
          type: string
          format: uuid
        agent_id:
          type: string
          format: uuid
        agent_name:
          type: string
        allowed_operations:
          type: array
          items:
            type: string
          example:
            - read
            - write
        enabled:
          type: boolean
        created_at:
          type: string
          format: date-time
    CreateCredentialGrantRequest:
      type: object
      required:
        - agent_id
      properties:
        agent_id:
          type: string
          format: uuid
        allowed_operations:
          type: array
          items:
            type: string
        enabled:
          type: boolean
          default: true
    CredentialLease:
      type: object
      properties:
        id:
          type: string
          format: uuid
        credential_id:
          type: string
          format: uuid
        credential_name:
          type: string
        agent_id:
          type: string
          format: uuid
        agent_name:
          type: string
        issued_at:
          type: string
          format: date-time
        expires_at:
          type: string
          format: date-time
        revoked_at:
          type: string
          format: date-time
          nullable: true
        revoke_reason:
          type: string
          nullable: true
        status:
          type: string
          enum:
            - active
            - expired
            - revoked
        created_at:
          type: string
          format: date-time
    ComplianceReport:
      type: object
      properties:
        id:
          type: string
          format: uuid
        tenant_id:
          type: string
          format: uuid
        environment_id:
          type: string
          format: uuid
          nullable: true
          description: "The environment the report was requested in. Null once that environment is deleted:
            deleting an environment keeps its reports, with their evidence items and share links, as
            organization-level reports. What a report says does not depend on its environment, and
            who may read it is decided by its type and subject."
        report_type:
          type: string
          enum:
            - soc2
            - gdpr
            - iso27001
            - executive
            - inventory
            - ai_assessment
            - framework
        title:
          type: string
          nullable: true
          description: The caller's name for the report; null means the type names it.
        subject_agent_id:
          type: string
          format: uuid
          nullable: true
          description: The agent an ai_assessment report is about; null on the other types.
        framework_slug:
          type: string
          nullable: true
          description: The framework a framework report covers; null on the other types.
        status:
          type: string
          enum:
            - queued
            - running
            - ready
            - failed
          description: Generation lifecycle. A report is queued on acceptance, running while the worker
            produces it, and then ready or failed. Reports that predate asynchronous generation are
            ready.
        error:
          type: string
          nullable: true
          description: Why a failed report failed, in one sentence. Null otherwise.
        period_start:
          type: string
          format: date-time
          nullable: true
        period_end:
          type: string
          format: date-time
          nullable: true
        report_data:
          type: object
          description: Structured report content (varies by report type)
        storage_path:
          type: string
          nullable: true
          description: Storage path of the rendered PDF, not a URL — `GET /reports/{id}/download` returns a
            signed URL for it. Null until a PDF has been rendered.
        report_hash:
          type: string
          nullable: true
          description: Canonical SHA-256 over the report's figures and the records it cites. Identical
            evidence produces an identical hash. Null until the report is ready.
        content_version:
          type: string
          nullable: true
          description: Which version of the report collectors produced it.
        evidence_count:
          type: integer
          description: How many records the report cites. Zero until it is ready.
        page_count:
          type: integer
          nullable: true
          description: Pages in the rendered PDF, null when no PDF was rendered.
        file_size_bytes:
          type: integer
          format: int64
          nullable: true
        previous_report_id:
          type: string
          format: uuid
          nullable: true
          description: "The report this one supersedes: the last `ready` report of the same type about the
            same subject. A schedule's reports form their own chain; reports produced by hand chain
            across the organization. Null on the first report of a chain."
        schedule_id:
          type: string
          format: uuid
          nullable: true
          description: The schedule that produced the report; null when a person asked for it.
        generated_by:
          type: string
          format: uuid
          nullable: true
          description: The user who asked for the report; null for a scheduled report, and once that user has
            been removed (the report stays).
        generated_at:
          type: string
          format: date-time
        completed_at:
          type: string
          format: date-time
          nullable: true
          description: When generation finished, whether it succeeded or failed.
    ReportShare:
      type: object
      description: An unauthenticated link to one finished report. Only the SHA-256 of the token and an
        8-character prefix are stored, so the link cannot be reconstructed from the database.
      properties:
        id:
          type: string
          format: uuid
        report_id:
          type: string
          format: uuid
        token_prefix:
          type: string
          description: The first 8 characters of the token, so two links can be told apart.
        label:
          type: string
          nullable: true
        expires_at:
          type: string
          format: date-time
        max_views:
          type: integer
          nullable: true
          description: How many times the link may be opened; null means no cap.
        view_count:
          type: integer
        last_viewed_at:
          type: string
          format: date-time
          nullable: true
        created_by:
          type: string
          format: uuid
          nullable: true
        created_at:
          type: string
          format: date-time
        revoked_at:
          type: string
          format: date-time
          nullable: true
        revoked_by:
          type: string
          format: uuid
          nullable: true
        revoked:
          type: boolean
        expired:
          type: boolean
          description: Derived server-side, so a client does not compare the expiry against its own clock.
        token:
          type: string
          description: "The share token: 32 random bytes, base64url-encoded without padding, so 43 characters
            from `A–Z a–z 0–9 - _`. Present ONLY in the response to the call that created the share;
            it is never stored in a readable form and never appears again."
        share_url:
          type: string
          format: uri
          description: "The link to hand out, present alongside `token` and only then:
            `{origin}/shared/reports#{token}`. The token rides in the fragment, which the browser
            never sends to a server; the page reads it and redeems it with `POST
            /public/report-shares/redeem`. Absent when the deployment has not configured a public
            origin."
    CreateReportShareRequest:
      type: object
      required:
        - expires_at
      properties:
        label:
          type: string
          maxLength: 120
          description: A note saying who the link is for.
        expires_at:
          type: string
          format: date-time
          description: When the link stops working. Required, must be in the future, and no more than 90 days
            out.
        max_views:
          type: integer
          minimum: 1
          maximum: 10000
          nullable: true
          description: How many opens the link allows. Omit for no cap.
    RedeemReportShareRequest:
      type: object
      description: "What a request that opens a link carries: the token. The schema deliberately asserts
        nothing — no required body, no required field, no length, no pattern — because the route
        validates the body itself and answers an empty body, a missing token or a malformed one with
        the same 404 as a token that matches nothing, never a 400. An implementation must not reject
        any body at a validation layer first."
      properties:
        token:
          type: string
          description: The share token handed out when the link was created (see `ReportShare.token`) — the
            part of the share URL after `#`. A request without one, or with a malformed one, is
            answered 404 like a token that matches nothing, never 400.
    SharedReport:
      type: object
      description: What an unauthenticated reader is told about a shared report. It identifies the
        document and nothing about the organization behind it.
      properties:
        report_type:
          type: string
          enum:
            - soc2
            - gdpr
            - iso27001
            - executive
            - inventory
            - ai_assessment
            - framework
        title:
          type: string
          nullable: true
        report_hash:
          type: string
          nullable: true
          description: The report's integrity hash, the same value printed in its footer.
        generated_at:
          type: string
          format: date-time
        period_start:
          type: string
          format: date-time
          nullable: true
        period_end:
          type: string
          format: date-time
          nullable: true
        expires_at:
          type: string
          format: date-time
          description: When this link stops working.
        views_remaining:
          type: integer
          description: Present only on a capped link, so the reader knows before the last view that there will
            not be another.
        download_url:
          type: string
          format: uri
          description: Signed link to the PDF, minted for this view. Absent when the report has no rendered
            file.
        download_expires_in:
          type: integer
          description: Lifetime of the signed link in seconds.
    ReportSchedule:
      type: object
      description: A recurring report, swept by the generation worker.
      properties:
        id:
          type: string
          format: uuid
        tenant_id:
          type: string
          format: uuid
        environment_id:
          type: string
          format: uuid
        report_type:
          type: string
          enum:
            - soc2
            - gdpr
            - iso27001
            - executive
            - inventory
            - ai_assessment
            - framework
        framework_slug:
          type: string
          nullable: true
        subject_agent_id:
          type: string
          format: uuid
          nullable: true
        subject_agent_name:
          type: string
          nullable: true
          description: The subject agent's name, so a list needs no second call.
        title:
          type: string
          nullable: true
        cadence:
          type: string
          enum:
            - weekly
            - monthly
            - quarterly
        period_days:
          type: integer
          minimum: 1
          maximum: 366
          description: How many days back each report covers.
        hour_utc:
          type: integer
          minimum: 0
          maximum: 23
        enabled:
          type: boolean
        next_run_at:
          type: string
          format: date-time
        last_run_at:
          type: string
          format: date-time
          nullable: true
        created_by:
          type: string
          format: uuid
          nullable: true
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    ReportScheduleRequest:
      type: object
      required:
        - report_type
        - environment_id
        - cadence
      properties:
        environment_id:
          type: string
          format: uuid
          description: The environment the reports cover. With an API key bound to an environment it may be
            left out, and the key's environment is used; any other environment is refused 403.
        report_type:
          type: string
          enum:
            - soc2
            - gdpr
            - iso27001
            - executive
            - inventory
            - ai_assessment
            - framework
          description: Fixed after creation; ignored on update.
        framework_slug:
          type: string
          pattern: ^[a-z][a-z0-9_]{1,62}[a-z0-9]$
          description: Required for `framework`, rejected on every other type. Checked for shape and for
            membership in the compliance library (`GET /compliance/frameworks`) like the generation
            request.
        subject_agent_id:
          type: string
          format: uuid
          description: Required for `ai_assessment`, rejected on every other type.
        title:
          type: string
          maxLength: 200
        cadence:
          type: string
          enum:
            - weekly
            - monthly
            - quarterly
        period_days:
          type: integer
          minimum: 1
          maximum: 366
          default: 30
        hour_utc:
          type: integer
          minimum: 0
          maximum: 23
          default: 6
        enabled:
          type: boolean
          default: true
    GenerateReportRequest:
      type: object
      required:
        - report_type
        - environment_id
      properties:
        report_type:
          type: string
          enum:
            - soc2
            - gdpr
            - iso27001
            - executive
            - inventory
            - ai_assessment
            - framework
          description: "`ai_assessment` requires `subject_agent_id`; `framework` requires `framework_slug`,
            which must name a framework in the compliance library (`GET /compliance/frameworks`)."
        environment_id:
          type: string
          format: uuid
          description: The environment the report covers; must belong to the caller's tenant. With an API key
            bound to an environment it may be left out, and the key's environment is used; any other
            environment is refused 403.
        title:
          type: string
          description: A name for the report. Surrounding whitespace is trimmed.
        subject_agent_id:
          type: string
          format: uuid
          description: The agent an `ai_assessment` report is about. Required for that type, where it must
            resolve to an agent in the caller's tenant, and rejected on every other type rather than
            ignored.
        framework_slug:
          type: string
          pattern: ^[a-z][a-z0-9_]{1,62}[a-z0-9]$
          description: "The compliance framework a `framework` report covers, for example `eu_ai_act`.
            Required for that type and rejected on every other type. Checked for shape, then for
            membership in the compliance library (`GET /compliance/frameworks`): a well-shaped slug
            the library does not carry is a 400, not a queued report that fails."
        period_start:
          type: string
          format: date-time
          description: Defaults to 30 days before the request.
        period_end:
          type: string
          format: date-time
          description: Defaults to the time of the request.
    ReportBundleResponse:
      type: object
      description: "What `GET /reports/{id}/bundle` answers: where to fetch the archive and what it holds."
      required:
        - download_url
        - expires_in
        - exported_at
        - bundle
      properties:
        download_url:
          type: string
          format: uri
          description: Signed URL to the zip archive.
        expires_in:
          type: string
          description: Lifetime of the signed URL in seconds, serialized as a string like the download route's.
        exported_at:
          type: string
          format: date-time
          description: "When this export was answered and recorded. It is on the response, not in the archive:
            nothing in the archive depends on when it was built, so the same report and checkpoint
            give the same bytes however many times they are exported."
        bundle:
          $ref: "#/components/schemas/ReportBundleManifest"
    ReportBundleManifest:
      type: object
      description: "The archive's description, returned beside the URL and written into the archive as
        `manifest.json`: the files with their sizes and SHA-256 digests (every file but the manifest
        itself), the checkpoint proof and what could not be included."
      required:
        - version
        - report_id
        - report_type
        - title
        - period_start
        - period_end
        - generated_at
        - completed_at
        - content_version
        - report_hash
        - evidence_count
        - checkpoint
        - files
        - gaps
      properties:
        version:
          type: string
          enum:
            - "1.0"
          description: Bundle layout version. A file added keeps it; a file removed or renamed bumps it.
        report_id:
          type: string
          format: uuid
        report_type:
          type: string
          enum:
            - soc2
            - gdpr
            - iso27001
            - executive
            - inventory
            - ai_assessment
            - framework
        title:
          type: string
          nullable: true
        period_start:
          type: string
          format: date-time
          nullable: true
        period_end:
          type: string
          format: date-time
          nullable: true
        generated_at:
          type: string
          format: date-time
        completed_at:
          type: string
          format: date-time
          nullable: true
        content_version:
          type: string
          nullable: true
        report_hash:
          type: string
          nullable: true
          description: "The report's own hash, as printed in the PDF footer: SHA-256 over the figures and the
            cited records in canonical form. Null on a report from before hashed reports, and `gaps`
            says so."
        evidence_count:
          type: integer
          description: How many cited records `evidence.json` holds.
        checkpoint:
          nullable: true
          allOf:
            - $ref: "#/components/schemas/ReportBundleCheckpoint"
          description: The newest signed audit checkpoint whose period overlaps the report's. Null when none
            does, and `gaps` says so.
        files:
          type: array
          items:
            $ref: "#/components/schemas/ReportBundleFile"
        gaps:
          type: array
          items:
            type: string
          description: What the bundle could not include, one sentence each, in the voice of the report's
            data-integrity section. Empty when nothing is missing.
    ReportBundleCheckpoint:
      description: "The covering checkpoint as `checkpoint.json` carries it: an `AuditCheckpoint` plus
        what an offline verifier needs, so offline verification needs nothing from the API but the
        key."
      allOf:
        - $ref: "#/components/schemas/AuditCheckpoint"
        - type: object
          required:
            - statement_format
            - public_key_url
            - verified
          properties:
            statement_format:
              type: string
              enum:
                - mitrity-audit-checkpoint/v1
            public_key:
              type: string
              description: The raw Ed25519 verifying key, standard base64, when the deployment has one wired.
                Absent otherwise, in which case `public_key_url` says where it is.
            public_key_url:
              type: string
              description: The route that serves the key, `/api/v1/audit/checkpoints/public-key`.
            verified:
              type: boolean
              description: Whether the server checked the signature against its wired key before writing the
                bundle. False with a key wired means the proof did not verify; false without one
                means this deployment did not check it. `gaps` says which.
    ReportBundleFile:
      type: object
      required:
        - name
        - bytes
        - sha256
      properties:
        name:
          type: string
          enum:
            - report.json
            - evidence.json
            - report.pdf
            - checkpoint.json
            - hash.txt
        bytes:
          type: integer
        sha256:
          type: string
          description: Hex SHA-256 over the file's bytes.
    ComplianceControlState:
      type: string
      enum:
        - covered
        - partial
        - not_covered
        - unknown
      description: Computed from the probes behind a control's mappings. `unknown` means the control
        carries no mapping and MITRITY evidences nothing for it — never a synonym for `not_covered`,
        which is a finding about the tenant.
    ComplianceOverrideState:
      type: string
      enum:
        - covered
        - partial
        - not_covered
        - not_applicable
      description: A person's decision over the computed state. `not_applicable` removes the control from
        the coverage denominator and always carries a note.
    ComplianceEvidenceLevel:
      type: string
      enum:
        - none
        - partial
        - full
    ComplianceRiskStatus:
      type: string
      enum:
        - open
        - mitigating
        - accepted
        - closed
    ComplianceRiskSource:
      type: string
      enum:
        - manual
        - assessment
        - finding
    AIPolicyStatementStatus:
      type: string
      enum:
        - draft
        - published
        - retired
    ComplianceFramework:
      type: object
      description: "A framework library's descriptor. `text_policy`, `attribution` and `source_url` are
        the licensing triple: every surface that renders a control renders the attribution line."
      required:
        - slug
        - title
        - short_title
        - authority
        - version_label
        - revision
        - status
        - text_policy
        - attribution
        - source_url
        - applies_to_values
        - categories
        - control_count
      properties:
        slug:
          type: string
          example: eu_ai_act
        title:
          type: string
        short_title:
          type: string
        authority:
          type: string
        jurisdiction:
          type: string
        version_label:
          type: string
          description: The edition of the source text the library was written against.
        revision:
          type: integer
        updated_on:
          type: string
        status:
          type: string
          enum:
            - draft
            - released
        text_policy:
          type: string
          enum:
            - paraphrase_only
            - public_text
        attribution:
          type: string
        source_url:
          type: string
          format: uri
        disclaimer:
          type: string
        applies_to_values:
          type: object
          additionalProperties:
            type: string
          description: The framework's applicability vocabulary, keyed by the value a control's `applies_to`
            carries, with a one-line description each.
        functions:
          type: array
          items:
            type: object
            required:
              - slug
              - title
            properties:
              slug:
                type: string
              title:
                type: string
              summary:
                type: string
        categories:
          type: array
          items:
            type: object
            required:
              - slug
              - title
            properties:
              slug:
                type: string
              function:
                type: string
              title:
                type: string
        control_count:
          type: integer
    ComplianceCoverage:
      type: object
      description: A framework's figures for one tenant. `coverage_percent` is `covered / applicable`,
        where `applicable` excludes controls out of scope for the tenant's highest declared risk
        tier and controls a person declared not applicable, and INCLUDES the unknown ones. It is
        never shown without `statement`.
      required:
        - framework_slug
        - content_version
        - total_controls
        - applicable
        - out_of_scope
        - not_applicable
        - assessed
        - not_assessed
        - covered
        - partial
        - not_covered
        - overridden
        - coverage_percent
        - statement
        - highest_risk_tier
        - unclassified_agents
      properties:
        framework_slug:
          type: string
        content_version:
          type: integer
        computed_at:
          type: string
          format: date-time
          nullable: true
        total_controls:
          type: integer
        applicable:
          type: integer
        out_of_scope:
          type: integer
        not_applicable:
          type: integer
        assessed:
          type: integer
          description: Applicable controls with a computed state other than unknown.
        not_assessed:
          type: integer
          description: Applicable controls MITRITY evidences nothing for.
        covered:
          type: integer
        partial:
          type: integer
        not_covered:
          type: integer
        overridden:
          type: integer
        coverage_percent:
          type: integer
        statement:
          type: string
          description: The sentence printed beside the figure on every surface.
          example: 3 of 9 applicable controls covered (33%); 7 assessed by MITRITY, 2 not assessed; 14 out of
            scope for the limited tier.
        highest_risk_tier:
          type: string
          enum:
            - unclassified
            - minimal
            - limited
            - high
            - unacceptable
        unclassified_agents:
          type: integer
    ComplianceFrameworkSummary:
      allOf:
        - $ref: "#/components/schemas/ComplianceFramework"
        - type: object
          required:
            - coverage
          properties:
            coverage:
              $ref: "#/components/schemas/ComplianceCoverage"
    ComplianceFrameworkList:
      type: object
      required:
        - content_version
        - frameworks
      properties:
        content_version:
          type: integer
        computed_at:
          type: string
          format: date-time
          nullable: true
        frameworks:
          type: array
          items:
            $ref: "#/components/schemas/ComplianceFrameworkSummary"
    ComplianceControl:
      type: object
      description: One entry in a framework library. `summary` is MITRITY's own wording, never a quotation.
      required:
        - id
        - framework
        - category
        - title
        - applies_to
        - evidence_scope
        - summary
      properties:
        id:
          type: string
          example: EUAIA-12
        framework:
          type: string
        article:
          type: string
        function:
          type: string
        category:
          type: string
        level:
          type: string
          enum:
            - category
            - subcategory
          description: Present on grouped libraries; a `category` is a container and never counted.
        title:
          type: string
        applies_to:
          type: array
          items:
            type: string
        evidence_scope:
          type: string
          enum:
            - runtime
            - mixed
            - organizational
        risk_classes:
          type: array
          items:
            type: string
        summary:
          type: string
    ComplianceProbeResult:
      type: object
      required:
        - probe
        - level
        - evidence_count
        - summary
      properties:
        probe:
          type: string
        level:
          $ref: "#/components/schemas/ComplianceEvidenceLevel"
        evidence_count:
          type: integer
        summary:
          type: string
          description: One sentence made of counts. Never a name, an address, a pattern or a secret.
        last_seen_at:
          type: string
          format: date-time
        figures:
          type: object
          additionalProperties:
            type: integer
    ComplianceMappingEvidence:
      type: object
      required:
        - capability
        - capability_title
        - probe
        - evidence_strength
        - rationale
        - deep_link
        - route_status
      properties:
        capability:
          type: string
        capability_title:
          type: string
        probe:
          type: string
        evidence_strength:
          type: string
          enum:
            - primary
            - supporting
        rationale:
          type: string
        deep_link:
          type: string
        route_status:
          type: string
          enum:
            - live
            - planned
        docs_url:
          type: string
        result:
          $ref: "#/components/schemas/ComplianceProbeResult"
    ComplianceControlStatus:
      allOf:
        - $ref: "#/components/schemas/ComplianceControl"
        - type: object
          required:
            - state
            - effective_state
            - applicable
            - applicability_reason
            - evidence_count
            - content_version
            - mappings
          properties:
            state:
              $ref: "#/components/schemas/ComplianceControlState"
            override_state:
              allOf:
                - $ref: "#/components/schemas/ComplianceOverrideState"
              nullable: true
            override_note:
              type: string
              nullable: true
            override_by:
              type: string
              format: uuid
              nullable: true
            override_at:
              type: string
              format: date-time
              nullable: true
            effective_state:
              type: string
              enum:
                - covered
                - partial
                - not_covered
                - unknown
                - not_applicable
              description: What the coverage counts — the override when set, the computed state otherwise.
            applicable:
              type: boolean
            applicability_reason:
              type: string
            owner_name:
              type: string
              nullable: true
            owner_email:
              type: string
              nullable: true
            evidence_count:
              type: integer
            computed_at:
              type: string
              format: date-time
              nullable: true
            content_version:
              type: integer
            mappings:
              type: array
              items:
                $ref: "#/components/schemas/ComplianceMappingEvidence"
            unmapped_reason:
              type: string
              description: Present on an unmapped control — the written reason MITRITY evidences nothing for it.
    ComplianceControlList:
      type: object
      required:
        - framework
        - coverage
        - controls
      properties:
        framework:
          $ref: "#/components/schemas/ComplianceFramework"
        coverage:
          $ref: "#/components/schemas/ComplianceCoverage"
        controls:
          type: array
          items:
            $ref: "#/components/schemas/ComplianceControlStatus"
    ComplianceOverrideRequest:
      type: object
      required:
        - override_state
      properties:
        override_state:
          allOf:
            - $ref: "#/components/schemas/ComplianceOverrideState"
          nullable: true
          description: Null clears the override.
        note:
          type: string
          minLength: 10
          maxLength: 2000
          description: Required whenever override_state is set.
    ComplianceOwnerRequest:
      type: object
      properties:
        owner_name:
          type: string
          maxLength: 200
          nullable: true
        owner_email:
          type: string
          maxLength: 200
          nullable: true
    ComplianceCatalogRisk:
      type: object
      required:
        - slug
        - title
        - description
        - sources
        - default_likelihood
        - default_impact
        - scoring_guidance
        - mitigating_capabilities
      properties:
        slug:
          type: string
          example: privacy
        title:
          type: string
        description:
          type: string
        sources:
          type: array
          items:
            type: string
        default_likelihood:
          type: integer
          minimum: 1
          maximum: 5
        default_impact:
          type: integer
          minimum: 1
          maximum: 5
        scoring_guidance:
          type: string
        mitigating_capabilities:
          type: array
          items:
            type: string
    ComplianceCapability:
      type: object
      required:
        - slug
        - title
        - probe
        - probe_asserts
        - deep_link
        - route_status
        - description
      properties:
        slug:
          type: string
        title:
          type: string
        probe:
          type: string
        probe_asserts:
          type: string
        deep_link:
          type: string
        route_status:
          type: string
          enum:
            - live
            - planned
        docs_url:
          type: string
        description:
          type: string
    ComplianceRiskCatalog:
      type: object
      required:
        - content_version
        - risks
        - capabilities
      properties:
        content_version:
          type: integer
        risks:
          type: array
          items:
            $ref: "#/components/schemas/ComplianceCatalogRisk"
        capabilities:
          type: array
          items:
            $ref: "#/components/schemas/ComplianceCapability"
    ComplianceRisk:
      type: object
      description: One register row. The scores are generated from likelihood × impact and cannot be written.
      required:
        - id
        - tenant_id
        - subject_type
        - risk_slug
        - title
        - inherent_likelihood
        - inherent_impact
        - inherent_score
        - residual_likelihood
        - residual_impact
        - residual_score
        - mitigating_capabilities
        - status
        - source
        - created_at
        - updated_at
      properties:
        id:
          type: string
          format: uuid
        tenant_id:
          type: string
          format: uuid
        subject_type:
          type: string
          enum:
            - agent
            - tenant
        subject_id:
          type: string
          format: uuid
          nullable: true
          description: The agent; null for a tenant-level risk.
        subject_name:
          type: string
          description: The agent's name, resolved at read time.
        risk_slug:
          type: string
        title:
          type: string
        description:
          type: string
          nullable: true
        inherent_likelihood:
          type: integer
          minimum: 1
          maximum: 5
        inherent_impact:
          type: integer
          minimum: 1
          maximum: 5
        inherent_score:
          type: integer
        residual_likelihood:
          type: integer
          minimum: 1
          maximum: 5
        residual_impact:
          type: integer
          minimum: 1
          maximum: 5
        residual_score:
          type: integer
        mitigations:
          type: string
          nullable: true
        mitigating_capabilities:
          type: array
          items:
            type: string
        owner_name:
          type: string
          nullable: true
        owner_email:
          type: string
          nullable: true
        status:
          $ref: "#/components/schemas/ComplianceRiskStatus"
        acceptance_note:
          type: string
          nullable: true
        accepted_by:
          type: string
          format: uuid
          nullable: true
        accepted_at:
          type: string
          format: date-time
          nullable: true
        source:
          $ref: "#/components/schemas/ComplianceRiskSource"
        review_id:
          type: string
          format: uuid
          nullable: true
          description: The governance review an assessment-seeded row came from.
        created_by:
          type: string
          format: uuid
          nullable: true
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        computed:
          $ref: "#/components/schemas/ComplianceRiskComputed"
        signals_unavailable:
          type: boolean
          description: "Set on a read when the live signals could not be read: the row is served without
            `computed` and says so, rather than the register failing because one aggregate did."
    ComplianceRiskComputed:
      type: object
      description: |
        The live side of a register row: the residual the signals put the
        risk at, beside the residual a person declared. Present on reads
        (the list and the single row) except when the response sets
        `signals_unavailable`; a write returns the row as stored.

        Likelihood starts at the row's inherent likelihood and moves one step
        per signal — execution coverage `governed` −1, `degraded`,
        `unattested` or `unknown` +1; a lifecycle nobody has approved
        (`proposed`, `under_review`, `changes_requested`) +1, `blocked` or
        `retired` −1; elevated drift over the window (average past the
        anomaly line, or a tenth of the actions past it) +1; a critical open
        finding +2 or a high one +1; DLP matches that went through with only
        an alert +1 — and is clamped to the 1–5 axis. Impact is the owner's
        residual impact, untouched: what a failure costs is a judgement no
        runtime signal can make. The computed residual is their product on
        the same 1–25 scale. Findings and DLP move only the classes they
        speak to (security, robustness, safety, privacy; DLP: privacy and
        security) and are listed with no effect on the others. A
        program-level row aggregates every agent.

        The human-set likelihood, impact and residual are never written by
        the computation. A window with no activity for the subject yields no
        score and a `note`, never a number. The list and the single-row read
        share one per-tenant signals cache: the signals are read at most once
        per tenant per minute however many reads of either arrive; the daily
        sweep reads them fresh.
      required:
        - residual_declared
        - residual_computed
        - computed_likelihood
        - computed_impact
        - gap
        - computed_at
        - window_days
        - signals
      properties:
        residual_declared:
          type: integer
          description: The row's own residual score, repeated so the pair reads together.
        residual_computed:
          type: integer
          nullable: true
          description: Null when the window held no activity for the subject.
        computed_likelihood:
          type: integer
          nullable: true
          minimum: 1
          maximum: 5
        computed_impact:
          type: integer
          minimum: 1
          maximum: 5
          description: The owner's residual impact, unchanged.
        gap:
          type: integer
          nullable: true
          description: "`residual_computed` minus `residual_declared`; positive means the signals put the risk
            above where the register has it. A gap of six or more on an open or mitigating row
            raises `governance.risk_register_drift` the first time it appears, again when it has
            widened since it was last announced, and otherwise as a weekly reminder while it holds;
            accepting the risk silences it."
        computed_at:
          type: string
          format: date-time
        window_days:
          type: integer
          example: 90
        signals:
          type: array
          items:
            $ref: "#/components/schemas/ComplianceRiskSignal"
        note:
          type: string
          description: Why there is no computed score, when there is none.
    ComplianceRiskSignal:
      type: object
      description: One step in the computed likelihood, with the value that caused it. A signal that was
        read and moved nothing is still listed, so a reader can see what was looked at.
      required:
        - signal
        - value
        - effect
        - summary
      properties:
        signal:
          type: string
          enum:
            - coverage_state
            - lifecycle_status
            - drift
            - open_findings
            - dlp_events
        value:
          type: string
          description: The observed value, rendered for a person.
        effect:
          type: integer
          description: Likelihood steps, in either direction; zero when listed for context.
        summary:
          type: string
        deep_link:
          type: string
    ComplianceRiskList:
      type: object
      required:
        - risks
        - total
        - limit
        - offset
      properties:
        risks:
          type: array
          items:
            $ref: "#/components/schemas/ComplianceRisk"
        total:
          type: integer
        limit:
          type: integer
        offset:
          type: integer
        sort:
          type: string
          enum:
            - residual
            - gap
          description: The order the page is in.
        truncated:
          type: boolean
          description: Set on a gap sort when the register held more live rows than the sort reads (2000);
            `total` is then the rows sorted, so the pages that exist are the pages the client is
            told about.
        signals_unavailable:
          type: boolean
          description: Set when the live signals could not be read; the rows are served without their computed
            side.
    ComplianceRiskCreateRequest:
      type: object
      required:
        - subject_type
        - risk_slug
      properties:
        subject_type:
          type: string
          enum:
            - agent
            - tenant
        subject_id:
          type: string
          format: uuid
          description: Required for an agent risk; must be absent for a tenant risk.
        risk_slug:
          type: string
          description: A slug from the risk catalog.
        title:
          type: string
          maxLength: 200
          description: Defaults to the catalog title.
        description:
          type: string
          maxLength: 4000
        inherent_likelihood:
          type: integer
          minimum: 1
          maximum: 5
        inherent_impact:
          type: integer
          minimum: 1
          maximum: 5
        residual_likelihood:
          type: integer
          minimum: 1
          maximum: 5
        residual_impact:
          type: integer
          minimum: 1
          maximum: 5
        mitigations:
          type: string
          maxLength: 4000
        mitigating_capabilities:
          type: array
          items:
            type: string
          description: Capability slugs from the catalog. Defaults to the catalog entry's.
        owner_name:
          type: string
          maxLength: 200
        owner_email:
          type: string
          maxLength: 200
        status:
          type: string
          enum:
            - open
            - mitigating
            - closed
    ComplianceRiskUpdateRequest:
      type: object
      properties:
        title:
          type: string
          maxLength: 200
        description:
          type: string
          maxLength: 4000
        inherent_likelihood:
          type: integer
          minimum: 1
          maximum: 5
        inherent_impact:
          type: integer
          minimum: 1
          maximum: 5
        residual_likelihood:
          type: integer
          minimum: 1
          maximum: 5
        residual_impact:
          type: integer
          minimum: 1
          maximum: 5
        mitigations:
          type: string
          maxLength: 4000
        mitigating_capabilities:
          type: array
          items:
            type: string
        owner_name:
          type: string
          maxLength: 200
        owner_email:
          type: string
          maxLength: 200
        status:
          type: string
          enum:
            - open
            - mitigating
            - closed
    ComplianceRiskAcceptRequest:
      type: object
      required:
        - note
      properties:
        note:
          type: string
          minLength: 10
          maxLength: 2000
    AIPolicyStatement:
      type: object
      required:
        - id
        - tenant_id
        - slug
        - title
        - body_markdown
        - version
        - status
        - requires_attestation
        - created_at
        - updated_at
      properties:
        id:
          type: string
          format: uuid
        tenant_id:
          type: string
          format: uuid
        slug:
          type: string
        title:
          type: string
        principle:
          type: string
          nullable: true
          description: The risk class the statement speaks to; null for a general statement.
        body_markdown:
          type: string
        version:
          type: integer
          description: Bumped when the body of a published statement changes; attestations are pinned to it.
        status:
          $ref: "#/components/schemas/AIPolicyStatementStatus"
        requires_attestation:
          type: boolean
        published_at:
          type: string
          format: date-time
          nullable: true
        published_by:
          type: string
          format: uuid
          nullable: true
        retired_at:
          type: string
          format: date-time
          nullable: true
        created_by:
          type: string
          format: uuid
          nullable: true
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        attested_by_me:
          type: boolean
          description: On the single-statement read — whether the caller attested to the version in force.
    AIPolicyStatementCreateRequest:
      type: object
      required:
        - slug
        - title
      properties:
        slug:
          type: string
          pattern: ^[a-z][a-z0-9-]{1,62}[a-z0-9]$
        title:
          type: string
          maxLength: 200
        principle:
          type: string
        body_markdown:
          type: string
          maxLength: 65536
        requires_attestation:
          type: boolean
          default: false
    AIPolicyStatementUpdateRequest:
      type: object
      properties:
        title:
          type: string
          maxLength: 200
        principle:
          type: string
        body_markdown:
          type: string
          maxLength: 65536
        requires_attestation:
          type: boolean
    AIPolicyAttestation:
      type: object
      required:
        - id
        - statement_id
        - statement_version
        - user_email
        - attested_at
        - current
      properties:
        id:
          type: string
          format: uuid
        statement_id:
          type: string
          format: uuid
        statement_version:
          type: integer
        user_id:
          type: string
          format: uuid
          nullable: true
          description: Null once the person has been removed from the tenant; the copied email still names them.
        user_email:
          type: string
        user_name:
          type: string
          nullable: true
        attested_at:
          type: string
          format: date-time
        current:
          type: boolean
          description: Whether this attestation is to the version in force.
    AIPolicyAttestationRoster:
      type: object
      required:
        - statement_id
        - current_version
        - current_count
        - attestations
      properties:
        statement_id:
          type: string
          format: uuid
        current_version:
          type: integer
        current_count:
          type: integer
        attestations:
          type: array
          items:
            $ref: "#/components/schemas/AIPolicyAttestation"
    ThreatIndicator:
      type: object
      description: A shared threat indicator as returned on the customer threat-intel surface (`GET
        /threat-intelligence/indicators` and `/{id}`). Contains NO tenant-specific data beyond the
        calling tenant's inline `override`. This public schema carries CVE / version provenance plus
        `artifact_identity` and `mcp_risk_category`, but NOT the raw matching identity (source,
        hostname, binary hash, TLS SAN) or the curation lifecycle fields (status, soak timestamps,
        ingest timestamps), which are not returned.
      required:
        - id
        - indicator_type
        - pattern
        - name
        - description
        - severity
        - feed
        - contributing_tenant_count
        - total_event_count
        - enabled
        - expires_at
        - in_kev
        - created_at
        - updated_at
      properties:
        id:
          type: string
          format: uuid
        indicator_type:
          type: string
          enum:
            - action_pattern
            - behavioral_hash
            - injection_signature
            - tool_abuse_pattern
            - delegation_pattern
            - mcp_tool_risk
            - command_pattern
            - model_risk
          description: "`command_pattern`: RE2 pattern evaluated against the canonical command text of
            execution-shaped actions (after wrapper and interpreter unwrapping); unanchored; only
            actions carrying a command-family argument are evaluated. The MITRITY-curated
            command-abuse pack ships as `command_pattern` indicators."
        pattern:
          type: string
          description: "For most types: regex pattern or exact SHA-256 hash. For mcp_tool_risk: canonical
            identity string `publisher|source|hostname|hash`. For command_pattern: an unanchored RE2
            pattern matched against the canonical command text."
        name:
          type: string
        description:
          type: string
          nullable: true
        severity:
          type: string
          enum:
            - critical
            - high
            - medium
            - low
            - info
        feed:
          type: string
          enum:
            - mitrity_curated
            - platform_generated
            - community
            - osv
          description: Provenance of the indicator. `osv` marks indicators ingested daily from OSV.dev and
            CISA KEV; `mitrity_curated` marks indicators MITRITY authored. A tenant is shown the
            feeds its threat-intelligence settings subscribe to (`subscribed_feeds`).
        contributing_tenant_count:
          type: integer
          format: int32
          nullable: true
          description: "For platform_generated / community indicators: how many distinct tenants contributed
            the signals behind this indicator. NULL when not applicable (e.g. curator-authored
            indicators)."
        total_event_count:
          type: integer
          format: int32
          nullable: true
          description: Total number of observed events that contributed to this indicator across all
            contributing tenants. NULL when not applicable.
        enabled:
          type: boolean
        expires_at:
          type: string
          format: date-time
          nullable: true
        override:
          allOf:
            - $ref: "#/components/schemas/ThreatIndicatorOverride"
          nullable: true
          description: The calling tenant's per-indicator override, when one exists. Present on the list and
            single-get responses; omitted (absent) when the tenant has no override for this
            indicator. Pro+ feature.
        artifact_identity:
          type: string
          nullable: true
          description: >
            The artifact the indicator is about, in the AIBOM identity grammar:
            `npm:`/`pypi:`/`go:`/`crates:` for an MCP package, `hf:<owner>/<name>` for a model
            repository, `model:<provider>:<model>` for a model observed at the LLM gateway. A
            `model_risk` indicator names a repository exactly, and a hosted model exactly or by a
            trailing `*` on the model part — `model:<provider>:*` for a whole provider,
            `model:<provider>:<family>*` for a family — which matches any run of characters, `/`
            included; the provider is always a literal key and no other wildcard exists.


            Earlier versions of the API named this field `mcp_publisher`. The rename shipped without
            an alias: a client that read `mcp_publisher` reads the same value from
            `artifact_identity`.
        mcp_risk_category:
          allOf:
            - $ref: "#/components/schemas/RiskCategory"
          nullable: true
          description: Category for mcp_tool_risk indicators. Mirrors tool_definitions.risk_category
            vocabulary so the two risk layers stay in sync.
        external_id:
          type: string
          nullable: true
          description: OSV/GHSA advisory id, e.g. 'GHSA-xxxx-yyyy-zzzz' or 'OSV-2026-123'. Null for
            MITRITY-authored and other non-OSV indicators. Together with `artifact_identity` it
            identifies an ingested advisory.
        cve_ids:
          type: array
          nullable: true
          items:
            type: string
          description: CVE aliases of the advisory, e.g. ['CVE-2026-1234']. Used to render CVE links and to
            cross-reference CISA KEV.
        source_url:
          type: string
          nullable: true
          description: Canonical advisory URL, e.g. https://osv.dev/vulnerability/GHSA-xxxx-yyyy-zzzz.
        cvss_score:
          type: number
          format: float
          nullable: true
          description: Max CVSS base score across the advisory's severity entries (NUMERIC(3,1), e.g. 9.8).
        cvss_vector:
          type: string
          nullable: true
          description: The CVSS vector string the score was derived from (provenance).
        in_kev:
          type: boolean
          default: false
          description: TRUE when an aliased CVE is on the CISA Known Exploited Vulnerabilities catalog. Forces
            severity=critical (Decision D2) and drives the 'actively exploited' badge.
        kev_date_added:
          type: string
          format: date
          nullable: true
          description: CISA KEV dateAdded for the matched CVE.
        affected_ranges:
          type: array
          nullable: true
          description: OSV-aligned version-affected ranges, populated from OSV. Null or empty means
            version-blind (match on identity only). Drives version-aware matching. See
            ToolAdvisory.affected_inventory[].version_status for how the match surfaces to tenants.
          items:
            type: object
            required:
              - ecosystem
            properties:
              ecosystem:
                type: string
                description: Matched against the inventory entry's ecosystem (the <ecosystem> prefix of its
                  canonical publisher), e.g. 'npm', 'PyPI', 'Go', 'crates.io'.
              introduced:
                type: string
                nullable: true
                description: Inclusive lower bound. Empty or '0' = affected from the start.
              fixed:
                type: string
                nullable: true
                description: Exclusive upper bound. A version equal to fixed is patched (not affected). Empty = no
                  known fix.
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    RiskCategory:
      type: string
      description: Shared enum used by both tool_definitions inline ratings and mcp_tool_risk indicators.
        Single source of truth so the two layers stay in sync.
      enum:
        - shell_execution
        - arbitrary_code
        - credential_access
        - data_exfiltration
        - destructive_filesystem
        - destructive_database
        - network_egress
        - unverified_publisher
        - known_cve
        - supply_chain
    ToolInventoryEntry:
      type: object
      description: One AI Bill of Materials entry — an MCP server identity observed by the gateway at
        runtime. Customers don't create entries; the gateway populates them on first invocation.
        Customers can annotate (display_name, notes) and archive.
      required:
        - id
        - tenant_id
        - transport
        - publisher
        - source
        - first_seen_at
        - last_seen_at
        - invocation_count_30d
        - agent_ids_30d
        - matched_indicators
        - created_at
        - updated_at
      properties:
        id:
          type: string
          format: uuid
        tenant_id:
          type: string
          format: uuid
        transport:
          type: string
          enum:
            - stdio
            - http
            - sse
        publisher:
          type: string
          example: npm:@modelcontextprotocol/server-slack
          description: Canonical publisher. 'unknown' when the gateway couldn't derive one.
        source:
          type: string
          example: npx -y @modelcontextprotocol/server-slack
          description: The customer-configured connection string the gateway observed.
        binary_hash:
          type: string
          nullable: true
          description: sha256 of the MCP server binary; stdio with binary access only.
        hostname:
          type: string
          nullable: true
          description: Resolved hostname for http/sse transports.
        tls_cert_san:
          type: string
          nullable: true
          description: TLS cert SAN for https; informational, not used for matching.
        advertised_name:
          type: string
          nullable: true
          description: serverInfo.serverName from the MCP handshake. UNTRUSTED — captured for diagnostics,
            never used to match indicators (trivially spoofable).
        version:
          type: string
          nullable: true
        first_seen_at:
          type: string
          format: date-time
        last_seen_at:
          type: string
          format: date-time
        invocation_count_30d:
          type: integer
          format: int64
          description: Count of invocations over the **trailing 30 days**, NOT an all-time total — older
            invocations age out of the window. **Computed at read time.** Read-only. int64 to
            accommodate high-volume tenants without truncation.
        agent_ids_30d:
          type: array
          items:
            type: string
            format: uuid
          description: Distinct agents that have invoked this tool in the last 30 days. **Capped to 50 entries
            in list responses** to keep payloads bounded; the single-entry endpoint `/aibom/{id}`
            returns the full set.
        display_name:
          type: string
          nullable: true
        notes:
          type: string
          nullable: true
        effective_risk_level:
          type: string
          enum:
            - critical
            - high
            - medium
            - low
            - info
            - unrated
          nullable: true
          description: "Resolved risk: max(matched indicator severity, inline tool_definitions.risk_level).
            Includes `unrated` (when neither source has rated this tool); advisories — which only
            fire for rated mcp_tool_risk indicators — never carry `unrated` in their
            `indicator_severity` field."
        matched_indicators:
          type: array
          items:
            type: string
            format: uuid
          description: Active mcp_tool_risk indicators whose identity matches this entry.
        runtime_only:
          type: boolean
          nullable: true
          description: "Derived flag — `true` when the gateway observed this tool being launched via an
            ephemeral package runner (`npx` / `uvx` / `bunx` / `pnpm dlx`) and it would therefore
            NOT appear in any lockfile. This is the runtime-only / \"SCA can't see this\" signal:
            the whole reason AIBOM exists is to surface tools that static manifest scanners (Snyk,
            etc.) are structurally blind to. Computed as a read-time heuristic from the observed
            `source` connection string, not stored."
        archived_at:
          type: string
          format: date-time
          nullable: true
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    UpdateToolInventoryEntryRequest:
      type: object
      description: Customers can only mutate annotation fields. Identity is immutable.
      properties:
        display_name:
          type: string
          nullable: true
        notes:
          type: string
          nullable: true
    ToolAdvisory:
      type: object
      description: A drift-notification advisory — surfaced when a new mcp_tool_risk indicator matches
        existing AIBOM entries for the tenant. The drift feed view at /app/aibom shows these
        chronologically. `published_at` is the creation timestamp; `updated_at` tracks subsequent
        mutations (e.g. acknowledgement).
      required:
        - id
        - tenant_id
        - indicator_id
        - indicator_name
        - indicator_severity
        - affected_inventory
        - published_at
        - updated_at
      properties:
        id:
          type: string
          format: uuid
        tenant_id:
          type: string
          format: uuid
        indicator_id:
          type: string
          format: uuid
        indicator_name:
          type: string
        indicator_severity:
          type: string
          enum:
            - critical
            - high
            - medium
            - low
            - info
        affected_inventory:
          type: array
          items:
            type: object
            required:
              - tool_inventory_id
              - tool_display_name
              - agent_count
            properties:
              tool_inventory_id:
                type: string
                format: uuid
              tool_display_name:
                type: string
              agent_count:
                type: integer
                format: int32
              last_invoked_at:
                type: string
                format: date-time
                nullable: true
              version_status:
                type: string
                enum:
                  - affected_confirmed
                  - version_unknown
                  - not_checked
                description: Optional. Per-entry version-match outcome captured in the affected_inventory snapshot,
                  so how certain the match is can be stated. Present on advisories created after
                  version-aware matching shipped; ABSENT on legacy advisory snapshots created before
                  then (read it as `not_checked` when missing). `affected_confirmed` — the observed
                  version was known and fell inside the indicator's affected_ranges for this entry's
                  ecosystem. `version_unknown` — matched on identity only (the indicator is
                  version-blind, or the observed version was absent/unparseable, the ecosystem was
                  unknown, or no range covered it); the advisory FAILS OPEN and alerts, and should
                  be presented as "version unknown — verify manually" rather than as "affected".
                  `not_checked` — version matching was not run for this entry (e.g. legacy
                  advisories snapshotted before version-aware matching shipped); treat as
                  identity-only and present it as `version_unknown`. An entry whose known version
                  fell OUTSIDE the range is SUPPRESSED and never appears here. The customer-facing
                  drift-feed response (`GET /aibom/drift-feed`) returns this field via the shared
                  `ToolAdvisory.affected_inventory[]` schema.
        published_at:
          type: string
          format: date-time
          description: When the advisory was created (indicator matched an inventory entry).
        updated_at:
          type: string
          format: date-time
          description: Last mutation timestamp — bumps on acknowledgement and any future field mutation.
        acknowledged_at:
          type: string
          format: date-time
          nullable: true
        acknowledged_by:
          type: string
          format: uuid
          nullable: true
    LLMUsageRecord:
      type: object
      description: >
        One call governed by the LLM Gateway. Metadata only — never prompt/response content. Derived
        from the LLM Gateway's egress events when the edge reports them.
      properties:
        id:
          type: string
          format: uuid
        agent_id:
          type: string
          format: uuid
          nullable: true
          description: >
            Null when the gateway allowed an unmapped identity (on_unmapped_identity=allow) or the
            agent was deleted.
        agent_name:
          type: string
          nullable: true
          description: Denormalized agent name for display; null with agent_id.
        edge_node_id:
          type: string
          format: uuid
          nullable: true
          description: The edge node that reported the call.
        provider:
          type: string
          description: LLM provider, e.g. "anthropic", "openai"
        model:
          type: string
          description: >
            Provider model id, verbatim from the request. Empty on a call the gateway refused before
            any model resolved — a deployment no model map covers — because the record is a governed
            decision and there was no model to name. Blocked records, with a model or without one,
            are excluded from GET /llm-usage/bom, which inventories models an agent reached.
        input_tokens:
          type: integer
          format: int64
          minimum: 0
          description: >
            Provider-reported usage. 0 when the upstream omitted usage, the call was blocked before
            forwarding, or an older edge fingerprinted the metadata value.
        output_tokens:
          type: integer
          format: int64
          minimum: 0
          description: >
            Provider-reported usage, with one exception that `output_tokens_estimated` identifies: a
            streamed call whose upstream sent no usage block has this counted from the generated
            text instead. 0 on the same terms as `input_tokens`.
        total_tokens:
          type: integer
          format: int64
          minimum: 0
          description: >
            Provider-reported total, which need not equal `input_tokens` + `output_tokens` — a usage
            block may count cache reads or writes separately. When `output_tokens_estimated` is true
            there is no provider total, so the gateway sums the two after counting and this carries
            that sum.
        decision:
          type: string
          enum:
            - allowed
            - blocked
            - redacted
          description: >
            Gateway decision. `redacted` = forwarded after request-phase DLP prompt redaction;
            response-phase DLP outcomes surface as llm:receive audit/DLP events, not here.
        deployment:
          type: string
          nullable: true
          minLength: 1
          maxLength: 256
          description: >
            Operator-chosen deployment label the call was routed through, on a provider whose
            request path names a deployment rather than a model (Azure OpenAI's deployment surface).
            The gateway resolves it to `model` before the verdict, so `model` stays the governed
            identity; this is the label an operator recognizes. Null for providers with no
            deployment concept and for edges that do not report one. On a blocked record it can name
            the deployment whose missing model mapping caused the refusal.
        output_tokens_estimated:
          type: boolean
          nullable: true
          description: >
            Whether `output_tokens` was counted from the streamed response text because the provider
            sent no usage block. true = counted, false = provider-reported, null = the reporting
            edge did not say. A cost figure derived from a counted value is an estimate of an
            estimate; null is not the same claim as false.
        occurred_at:
          type: string
          format: date-time
    LLMUsageList:
      type: object
      properties:
        records:
          type: array
          items:
            $ref: "#/components/schemas/LLMUsageRecord"
        next_page_token:
          type: string
    LLMUsageSummary:
      type: object
      properties:
        group_by:
          type: string
          enum:
            - model
            - agent
            - day
        from:
          type: string
          format: date-time
        to:
          type: string
          format: date-time
        price_catalog_version:
          type: string
          description: >
            Version of MITRITY's built-in price catalog used for `estimated_cost_usd` (the catalog
            ships with MITRITY releases).
        rows:
          type: array
          items:
            $ref: "#/components/schemas/LLMUsageSummaryRow"
    LLMUsageSummaryRow:
      type: object
      description: >
        One aggregation group. The populated key fields depend on group_by: `model` → provider +
        model; `agent` → agent_id + agent_name; `day` → day. Non-key fields are null/omitted.
      properties:
        provider:
          type: string
          nullable: true
          description: Group key (group_by=model)
        model:
          type: string
          nullable: true
          description: >
            Group key (group_by=model). Empty for the group of calls the gateway refused before any
            model resolved; those calls are real governed decisions and appear in `calls_blocked`,
            so they are counted rather than dropped.
        agent_id:
          type: string
          format: uuid
          nullable: true
          description: Group key (group_by=agent); null for unmapped-identity calls
        agent_name:
          type: string
          nullable: true
        day:
          type: string
          format: date
          nullable: true
          description: Group key (group_by=day), UTC calendar day
        calls_total:
          type: integer
        calls_allowed:
          type: integer
        calls_blocked:
          type: integer
        calls_redacted:
          type: integer
        input_tokens:
          type: integer
          format: int64
        output_tokens:
          type: integer
          format: int64
        total_tokens:
          type: integer
          format: int64
        estimated_cost_usd:
          type: number
          format: double
          nullable: true
          description: >
            Estimated spend in USD from the static price catalog — an ESTIMATE, never billing data.
            Sum over calls whose (provider, model) is in the catalog; null when no call in the group
            matched a catalog entry.
        uncosted_calls:
          type: integer
          description: >
            Calls in the group excluded from estimated_cost_usd because their (provider, model) is
            not in the price catalog.
    GovernedActionUsageSummary:
      type: object
      description: >
        Aggregated governed-action meter over an occurred_at range. total_governed_actions is the
        billable count over the same range and filter.
      properties:
        group_by:
          type: string
          enum:
            - decision
            - agent
            - day
            - surface
        from:
          type: string
          format: date-time
        to:
          type: string
          format: date-time
        total_governed_actions:
          type: integer
          format: int64
          description: Billable governed-action count over the range and filter.
        rows:
          type: array
          items:
            $ref: "#/components/schemas/GovernedActionUsageSummaryRow"
    GovernedActionUsageSummaryRow:
      type: object
      description: >
        One aggregation group. The populated key field depends on group_by: `decision` → decision;
        `agent` → agent_id + agent_name; `day` → day; `surface` → surface. Non-key fields are
        null/omitted.
      properties:
        decision:
          type: string
          enum:
            - allowed
            - blocked
            - alerted
            - held
            - timed_out
          nullable: true
          description: Group key (group_by=decision)
        agent_id:
          type: string
          format: uuid
          nullable: true
          description: Group key (group_by=agent); null when the agent was deleted
        agent_name:
          type: string
          nullable: true
        day:
          type: string
          format: date
          nullable: true
          description: Group key (group_by=day), UTC calendar day
        surface:
          oneOf:
            - $ref: "#/components/schemas/EnforcementSurface"
            - type: "null"
          description: Group key (group_by=surface); null when the surface was unknown
        count:
          type: integer
          format: int64
    GovernedActionUsage:
      type: object
      description: >
        Billable governed-action count for a period — the value a cloud-marketplace usage-reporter
        consumes.
      properties:
        from:
          type: string
          format: date-time
        to:
          type: string
          format: date-time
        governed_actions:
          type: integer
          format: int64
    ModelSeverity:
      type: string
      enum:
        - critical
        - high
        - medium
        - low
        - info
      description: A model's severity is the highest among its open findings. A scanned repository with no
        findings is `info`, never 'clean' as a separate state; a hosted model with no open findings
        has a null `max_severity`, because nothing about it was scanned and there is no result to
        record.
    ModelFindingCategory:
      type: string
      enum:
        - security
        - supply_chain
        - compliance
        - hygiene
      description: >
        `security` — loading the artifact can run code, exfiltrate data or change the model's
        instructions. `supply_chain` — its provenance or integrity is in question. `compliance` —
        using it carries licensing or policy obligations. `hygiene` — a practice worth improving;
        never a vulnerability, and never ranked above `medium`.
    Model:
      type: object
      required:
        - id
        - identity
        - kind
        - source
        - pinned
        - access_token_set
        - scan_state
        - scan_incomplete
        - open_findings
        - first_seen
        - last_seen
        - created_at
        - updated_at
      properties:
        id:
          type: string
          format: uuid
        identity:
          type: string
          description: "`hf:<owner>/<name>` for a repository, `model:<provider>:<model>` for a hosted model."
          example: hf:meta-llama/Llama-3.1-8B
        kind:
          type: string
          enum:
            - repository
            - hosted
            - local
          description: "`repository` — a Hugging Face repository, scanned. `hosted` — a model reached through
            a provider API, materialized from governed LLM calls and never scanned, because nothing
            about it is fetchable. `local` is RESERVED for the edge-side scanner (a model on a path
            or in an OCI image) and has no producer today."
        source:
          type: string
          enum:
            - declared
            - observed
            - discovered
          description: "`declared` — a customer added it. `observed` — a governed LLM call named it; an
            observed row never overwrites a declared one. `discovered` is RESERVED for references
            found in a customer's own code and configuration, and has no producer today."
        display_name:
          type: string
          nullable: true
        revision:
          type: string
          nullable: true
          description: The branch, tag or commit the scan asks for. Null means the repository's default branch.
        pinned:
          type: boolean
          description: Whether the requested revision names a commit. An unpinned repository can change under
            you between scans, which is why an unpinned one produces an unpinned_revision finding
            naming the commit to pin to.
        resolved_commit:
          type: string
          nullable: true
          description: The commit the Hub served. It fixes the whole tree, which is why a scheduled scan skips
            a repository whose commit has not moved.
        license:
          type: string
          nullable: true
        gated:
          type: boolean
          nullable: true
        private:
          type: boolean
          nullable: true
        provider:
          type: string
          nullable: true
          description: Hosted models only.
        model_name:
          type: string
          nullable: true
          description: Hosted models only.
        access_token_set:
          type: boolean
          description: Whether an access token is stored. The token itself has no route out of the system.
        access_token_set_at:
          type: string
          format: date-time
          nullable: true
        scan_state:
          type: string
          enum:
            - pending
            - scanning
            - scanned
            - failed
            - not_applicable
        last_scan_at:
          type: string
          format: date-time
          nullable: true
        last_scan_error:
          type: string
          nullable: true
          description: "Why the most recent attempt did not land. The previous result is kept: a stale finding
            list is more useful than a cleared one."
        max_severity:
          allOf:
            - $ref: "#/components/schemas/ModelSeverity"
          nullable: true
        scan_incomplete:
          type: boolean
          description: Part of the repository could not be read to the extent its scanner needs, so the result
            is unknown rather than clean. Never present this as a pass.
        open_findings:
          type: integer
          format: int64
        open_findings_by_severity:
          type: object
          additionalProperties:
            type: integer
            format: int64
        usage:
          allOf:
            - $ref: "#/components/schemas/HostedModelUsage"
          nullable: true
          description: "The governed traffic behind a hosted model: every call through the LLM gateway that
            named it, whatever the decision, and the agents that made those calls. Computed from the
            usage records at read time, the way `/llm-usage/bom` is, so it is never older than the
            last call the gateway reported. Null for a repository, which is scanned rather than
            called. On the list route `agents` is absent; the detail route fills it."
        first_seen:
          type: string
          format: date-time
        last_seen:
          type: string
          format: date-time
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    HostedModelUsage:
      type: object
      required:
        - call_count
        - calls_blocked
        - agents_count
        - input_tokens
        - output_tokens
        - total_tokens
        - deployments
      properties:
        call_count:
          type: integer
          format: int64
        calls_blocked:
          type: integer
          format: int64
          description: Calls a policy refused before they reached the provider. A model an agent keeps asking
            for and never reaches is a different fact from one it uses.
        agents_count:
          type: integer
          format: int64
          description: "Distinct agents the calls were attributed to. Calls the gateway could not attribute
            are counted in call_count and not here. Not a row count for `agents`: that array is
            capped and may carry the unattributed group as a row."
        input_tokens:
          type: integer
          format: int64
        output_tokens:
          type: integer
          format: int64
        total_tokens:
          type: integer
          format: int64
        deployments:
          type: array
          items:
            type: string
          description: The operator labels the calls were routed through, for a provider that has them (Azure
            OpenAI deployments), sorted. A deployment is an attribute of the model, not part of its
            identity.
        agents:
          type: array
          items:
            $ref: "#/components/schemas/HostedModelAgentUsage"
          description: Per-agent breakdown, most recently active first, at most 50 rows; the unattributed
            group (null `agent_id`), when present, is one of those rows. Detail route only.
    HostedModelAgentUsage:
      type: object
      required:
        - agent_id
        - agent_name
        - call_count
        - calls_blocked
        - total_tokens
        - first_seen
        - last_seen
      properties:
        agent_id:
          type: string
          format: uuid
          nullable: true
          description: Null groups the calls the gateway could not attribute to an agent.
        agent_name:
          type: string
          nullable: true
        call_count:
          type: integer
          format: int64
        calls_blocked:
          type: integer
          format: int64
        total_tokens:
          type: integer
          format: int64
        first_seen:
          type: string
          format: date-time
        last_seen:
          type: string
          format: date-time
    ModelDetail:
      allOf:
        - $ref: "#/components/schemas/Model"
        - type: object
          properties:
            findings:
              type: array
              items:
                $ref: "#/components/schemas/ModelFinding"
              description: Open first, highest severity first, at most 100. The findings route pages through the
                rest.
            latest_run:
              allOf:
                - $ref: "#/components/schemas/ModelScanRun"
              nullable: true
              description: Null for a hosted model, which is never scanned; its findings carry no run either
                (first_run_id and last_run_id are null on the row), because the sweep that raises
                them is not a scan.
    ModelFinding:
      type: object
      required:
        - id
        - model_id
        - code
        - category
        - severity
        - status
        - detail
        - remediation
        - occurrence_count
        - first_seen
        - last_seen
      properties:
        id:
          type: string
          format: uuid
        model_id:
          type: string
          format: uuid
        model_identity:
          type: string
        code:
          type: string
          description: The normative finding code. Stable across releases; a changed meaning gets a new code.
          example: pickle_critical_global
        category:
          $ref: "#/components/schemas/ModelFindingCategory"
        severity:
          $ref: "#/components/schemas/ModelSeverity"
        status:
          type: string
          enum:
            - open
            - resolved
        file:
          type: string
          nullable: true
          description: Repository-relative path. Absent for a finding about the repository itself.
        member:
          type: string
          nullable: true
          description: Path inside a container file.
        detail:
          type: string
          description: What was observed, in one sentence.
        remediation:
          type: string
          description: What to do about it, in one sentence.
        evidence:
          type: object
          description: >
            What makes the finding verifiable. A pickle finding carries `offset`, `opcode`, `module`
            and `name`, so a customer can open the file at that offset and see the opcode the
            finding is about. A text finding carries an `excerpt` bounded at 240 characters —
            evidence, not a copy of the document. A finding whose severity came from a curated table
            carries that table's `allowlist_version`, so a re-scan after a curation change clears
            stale results.
          properties:
            offset:
              type: integer
              format: int64
            opcode:
              type: string
            module:
              type: string
            name:
              type: string
            excerpt:
              type: string
              maxLength: 240
            allowlist_version:
              type: string
            indicator_id:
              type: string
              format: uuid
              description: The curated model_risk indicator a model_known_bad finding matched; the same value as
                the finding's indicator_id.
        occurrence_count:
          type: integer
          format: int64
          description: "How many observations have seen this finding: one per scan of a repository, and on a
            hosted model one per sweep pass that found it changed or at least 24 hours after it last
            counted, so a finding re-seen every few minutes is not counted hundreds of times a day.
            A recurrence bumps this rather than opening a second row; a resolved finding whose key
            recurs opens a NEW row, so a repeat offender's history is a list."
        first_seen:
          type: string
          format: date-time
        last_seen:
          type: string
          format: date-time
        resolved_at:
          type: string
          format: date-time
          nullable: true
        resolution:
          type: string
          nullable: true
          enum:
            - fixed
            - accepted_risk
            - manual
          description: "`fixed` is written by the scanner when a complete scan no longer produces the finding
            — or, on a hosted model, by the sweep when the rule was written or the indicator
            retired. `accepted_risk` and `manual` are a person's. Null while the finding is open;
            nullability is carried by `nullable` alone, as everywhere else in this document."
        resolution_note:
          type: string
          nullable: true
        indicator_id:
          type: string
          format: uuid
          nullable: true
          description: The curated `model_risk` threat indicator a `model_known_bad` finding matched. Null for
            every other code. Two indicators naming one artifact are two findings.
    ModelScanResult:
      type: object
      required:
        - run_id
        - status
        - findings_opened
        - findings_recurred
        - findings_resolved
        - incomplete
      properties:
        run_id:
          type: string
          format: uuid
        status:
          type: string
          enum:
            - succeeded
            - skipped
            - failed
        skip_reason:
          type: string
          nullable: true
        findings_opened:
          type: integer
        findings_recurred:
          type: integer
        findings_resolved:
          type: integer
        max_severity:
          allOf:
            - $ref: "#/components/schemas/ModelSeverity"
          nullable: true
        incomplete:
          type: boolean
        error:
          type: string
          nullable: true
    ModelScanRun:
      type: object
      required:
        - id
        - model_id
        - trigger
        - status
        - incomplete
        - started_at
      description: >
        One scan attempt. The file counts are the honest part: a file the scan could not read to the
        extent its scanner needs is counted as unknown, never as scanned. `bytes_fetched` is what
        the scan received; `bytes_charged` is what it spent against its budget, which charges the
        requested cap up front so a server that ignores `Range` cannot overrun it.
      properties:
        id:
          type: string
          format: uuid
        model_id:
          type: string
          format: uuid
        trigger:
          type: string
          enum:
            - scheduled
            - manual
            - onboarding
        status:
          type: string
          enum:
            - running
            - succeeded
            - failed
            - skipped
        skip_reason:
          type: string
          nullable: true
          description: Why nothing was read — normally that the resolved commit had already been scanned. A
            skipped run is a real answer, not a missing one.
        requested_revision:
          type: string
          nullable: true
        resolved_commit:
          type: string
          nullable: true
        incomplete:
          type: boolean
        max_severity:
          type: string
          nullable: true
        files_listed:
          type: integer
        files_scanned:
          type: integer
        files_unknown:
          type: integer
        files_skipped:
          type: integer
        bytes_fetched:
          type: integer
          format: int64
        bytes_charged:
          type: integer
          format: int64
        findings_opened:
          type: integer
        findings_recurred:
          type: integer
        findings_resolved:
          type: integer
        error:
          type: string
          nullable: true
        started_at:
          type: string
          format: date-time
        finished_at:
          type: string
          format: date-time
          nullable: true
    LLMBOMEntry:
      type: object
      description: >
        LLM-BOM inventory entry — one distinct (agent, provider, model) observed through the LLM
        Gateway, with lifetime totals.
      properties:
        agent_id:
          type: string
          format: uuid
          nullable: true
          description: Null for unmapped-identity calls.
        agent_name:
          type: string
          nullable: true
        provider:
          type: string
        model:
          type: string
          minLength: 1
          description: >
            Entries always name a resolved model. A blocked call — refused before any model resolved
            (a deployment no model map covers) or after (a brokered credential the broker did not
            resolve) — is a governed decision on GET /llm-usage, not an inventory entry: the BOM
            answers which models an agent uses, and that call reached none.
        first_seen:
          type: string
          format: date-time
        last_seen:
          type: string
          format: date-time
        call_count:
          type: integer
          format: int64
        total_tokens:
          type: integer
          format: int64
        deployments:
          type: array
          default: []
          items:
            type: string
            minLength: 1
            maxLength: 256
          description: >
            Every deployment label seen under this entry, sorted. Always present; empty for
            providers with no deployment concept and for entries whose calls named none, so a client
            never has to tell [] from an absent field before iterating. An attribute of the entry,
            NOT part of its key: several labels can resolve to one model and a label can be
            repointed at another model, so grouping by it would split one model into several
            inventory entries and make the BOM churn on a naming decision. The entry keeps its
            (agent, provider, model) identity and its totals cover every deployment listed here.
    LLMBOMList:
      type: object
      properties:
        entries:
          type: array
          items:
            $ref: "#/components/schemas/LLMBOMEntry"
        next_page_token:
          type: string
    ThreatMatch:
      type: object
      description: A tenant-scoped threat match — when an agent action hit a shared indicator.
      properties:
        id:
          type: string
          format: uuid
        tenant_id:
          type: string
          format: uuid
        indicator_id:
          type: string
          format: uuid
        indicator_name:
          type: string
        indicator_type:
          type: string
          enum:
            - action_pattern
            - behavioral_hash
            - injection_signature
            - tool_abuse_pattern
            - delegation_pattern
            - mcp_tool_risk
            - command_pattern
          description: >-
            Type of the indicator that fired. For `command_pattern` the matched_value is the
            canonical command text (after wrapper and interpreter unwrapping) that the unanchored
            RE2 pattern hit.


            `model_risk` is deliberately absent. A threat match is produced when an indicator fires
            against an ACTION the edge evaluated; a `model_risk` indicator is matched against an
            artifact identity — during a repository scan, and on every pass of the hosted-model
            sweep for the models observed at the LLM gateway — and its result is a model finding
            with code `model_known_bad` — see `/model-inventory/{model_id}/findings`. Nothing in a
            mission profile acts on a model identity, so the type never reaches the matcher.
        tool_inventory_id:
          type: string
          format: uuid
          nullable: true
        tool_identity:
          type: object
          nullable: true
          required:
            - transport
            - publisher
            - source
          properties:
            transport:
              type: string
              enum:
                - stdio
                - http
                - sse
            publisher:
              type: string
            source:
              type: string
            binary_hash:
              type: string
              nullable: true
            hostname:
              type: string
              nullable: true
        agent_id:
          type: string
          format: uuid
          nullable: true
        agent_name:
          type: string
          nullable: true
        event_id:
          type: string
          format: uuid
          nullable: true
        matched_value:
          type: string
        action_taken:
          type: string
          enum:
            - blocked
            - alerted
            - logged
        severity:
          type: string
          enum:
            - critical
            - high
            - medium
            - low
            - info
        occurred_at:
          type: string
          format: date-time
        created_at:
          type: string
          format: date-time
    ThreatMatchSummary:
      type: object
      properties:
        total_matches:
          type: integer
        by_severity:
          type: object
          additionalProperties:
            type: integer
        by_type:
          type: object
          additionalProperties:
            type: integer
        by_action:
          type: object
          additionalProperties:
            type: integer
        top_agents:
          type: array
          items:
            type: object
            properties:
              agent_id:
                type: string
                format: uuid
              agent_name:
                type: string
              match_count:
                type: integer
    TenantThreatSettings:
      type: object
      properties:
        id:
          type: string
          format: uuid
          description: Settings row id. Absent in the defaults returned when the tenant has no settings row yet.
        tenant_id:
          type: string
          format: uuid
        contribute_data:
          type: boolean
          description: Opt-in to contribute anonymized threat signals. Defaults to false.
        subscribed_feeds:
          type: array
          items:
            type: string
            enum:
              - mitrity_curated
              - platform_generated
              - community
              - osv
        default_action:
          type: string
          enum:
            - block
            - alert
            - log
          description: Default action when an agent action matches a threat indicator
        critical_action:
          type: string
          enum:
            - block
            - alert
            - log
          nullable: true
          description: Per-severity action override for critical matches. NULL falls back to default_action at
            match time.
        high_action:
          type: string
          enum:
            - block
            - alert
            - log
          nullable: true
          description: Per-severity action override for high matches. NULL falls back to default_action at
            match time.
        medium_action:
          type: string
          enum:
            - block
            - alert
            - log
          nullable: true
          description: Per-severity action override for medium matches. NULL falls back to default_action at
            match time.
        low_action:
          type: string
          enum:
            - block
            - alert
            - log
          nullable: true
          description: Per-severity action override for low matches. NULL falls back to default_action at
            match time.
        info_action:
          type: string
          enum:
            - block
            - alert
            - log
          nullable: true
          description: Per-severity action override for info matches. NULL falls back to default_action at
            match time.
        created_at:
          type: string
          format: date-time
          description: Absent in the defaults returned when the tenant has no settings row yet.
        updated_at:
          type: string
          format: date-time
          description: Absent in the defaults returned when the tenant has no settings row yet.
    UpdateThreatSettingsRequest:
      type: object
      required:
        - contribute_data
        - subscribed_feeds
        - default_action
      properties:
        contribute_data:
          type: boolean
        subscribed_feeds:
          type: array
          items:
            type: string
            enum:
              - mitrity_curated
              - platform_generated
              - community
              - osv
        default_action:
          type: string
          enum:
            - block
            - alert
            - log
        critical_action:
          type: string
          enum:
            - block
            - alert
            - log
            - ""
          nullable: true
          description: Per-severity action override for critical matches. Omit (or send `null`) to leave the
            current value unchanged; send empty string `""` to explicitly clear it (revert to
            default_action); send a valid action to set it.
        high_action:
          type: string
          enum:
            - block
            - alert
            - log
            - ""
          nullable: true
          description: Per-severity action override for high matches. Same set/clear/leave semantics as
            critical_action.
        medium_action:
          type: string
          enum:
            - block
            - alert
            - log
            - ""
          nullable: true
          description: Per-severity action override for medium matches. Same set/clear/leave semantics as
            critical_action.
        low_action:
          type: string
          enum:
            - block
            - alert
            - log
            - ""
          nullable: true
          description: Per-severity action override for low matches. Same set/clear/leave semantics as
            critical_action.
        info_action:
          type: string
          enum:
            - block
            - alert
            - log
            - ""
          nullable: true
          description: Per-severity action override for info matches. Same set/clear/leave semantics as
            critical_action.
    ThreatIndicatorOverride:
      type: object
      description: A tenant's per-indicator override row, returned by the override / suppress endpoints
        and embedded in ThreatIndicator.override on the list / single-get responses.
        severity_override / action_override are managed via PUT /override; the suppressed_* fields
        via POST /suppress.
      required:
        - created_at
        - updated_at
      properties:
        severity_override:
          type: string
          enum:
            - critical
            - high
            - medium
            - low
            - info
          nullable: true
          description: Tenant-set severity that replaces the shared indicator's severity for this tenant. NULL
            = no override.
        action_override:
          type: string
          enum:
            - block
            - alert
            - log
          nullable: true
          description: Tenant-set action that replaces the resolved per-severity action for this tenant. NULL
            = no override.
        suppressed_mode:
          type: string
          enum:
            - log_only
            - silent
          nullable: true
          description: When set, the indicator is suppressed for this tenant. `log_only` still records each
            match, with `action_taken` `suppressed`; `silent` drops matches entirely. Null means not
            suppressed.
        suppressed_until:
          type: string
          format: date-time
          nullable: true
          description: Suppression auto-expires at this time. NULL = suppression has no expiry (or none is set).
        suppressed_reason:
          type: string
          nullable: true
          description: Free-text reason recorded when the indicator was suppressed.
        note:
          type: string
          nullable: true
          description: Free-text operator note attached to the override.
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    PutThreatOverrideRequest:
      type: object
      description: 'Body for PUT /threat-intelligence/indicators/{id}/override. Every field is optional.
        For each field: omit (or send `null`) to leave the existing value unchanged; send empty
        string `""` to explicitly clear it; send a valid value to set it.'
      properties:
        severity_override:
          type: string
          enum:
            - critical
            - high
            - medium
            - low
            - info
            - ""
          nullable: true
        action_override:
          type: string
          enum:
            - block
            - alert
            - log
            - ""
          nullable: true
        note:
          type: string
          nullable: true
    SuppressThreatIndicatorRequest:
      type: object
      description: Body for POST /threat-intelligence/indicators/{id}/suppress.
      properties:
        mode:
          type: string
          enum:
            - log_only
            - silent
          default: log_only
          description: Suppression mode. Defaults to `log_only` when omitted (keeps the audit trail); `silent`
            drops matches entirely.
        until:
          type: string
          format: date-time
          nullable: true
          description: Optional RFC3339 expiry for the suppression. Must be in the future. Omit or send null
            for no expiry.
        reason:
          type: string
          nullable: true
          description: Optional free-text reason for the suppression.
    TrendingThreatIndicator:
      type: object
      required:
        - indicator
        - match_count
        - affected_agent_count
        - first_match_at
        - last_match_at
      description: One entry in GET /threat-intelligence/trending — an indicator bundled with this
        tenant's match stats over the requested window.
      properties:
        indicator:
          $ref: "#/components/schemas/ThreatIndicator"
        match_count:
          type: integer
          format: int64
          description: Number of matches this indicator produced for the tenant within the window.
        affected_agent_count:
          type: integer
          format: int64
          description: Distinct agents that hit this indicator within the window.
        first_match_at:
          type: string
          format: date-time
        last_match_at:
          type: string
          format: date-time
    CreateCheckoutSessionRequest:
      type: object
      required:
        - plan
        - billing_interval
      properties:
        plan:
          type: string
          enum:
            - starter
            - pro
          description: Subscription plan to checkout (enterprise uses quote flow)
        billing_interval:
          type: string
          enum:
            - monthly
            - yearly
          description: Billing interval
    CreateCheckoutSessionResponse:
      type: object
      required:
        - checkout_url
      properties:
        checkout_url:
          type: string
          format: uri
          description: Stripe Checkout session URL to redirect the user to
    MLModel:
      type: object
      required:
        - id
        - tenant_id
        - model_type
        - version
        - model_url
        - model_hash
        - status
        - scope
        - enabled
        - created_at
        - updated_at
      properties:
        id:
          type: string
          format: uuid
        tenant_id:
          type: string
          format: uuid
        model_type:
          type: string
          enum:
            - tcn
            - lstm
            - transformer
            - gnn
            - vae
            - vae-tenant
        version:
          type: string
        model_url:
          type: string
          description: GCS path to the model artifact
        model_hash:
          type: string
          description: SHA-256 hash of the model file
        status:
          type: string
          enum:
            - pending
            - active
            - deprecated
            - failed
        scope:
          type: string
          enum:
            - global
            - custom
        enabled:
          type: boolean
        metrics:
          type: object
          description: Training/validation metrics (loss, accuracy, etc.)
        file_size_bytes:
          type: integer
          format: int64
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    MLModelList:
      type: object
      required:
        - models
        - total
        - page
        - page_size
      properties:
        models:
          type: array
          items:
            $ref: "#/components/schemas/MLModel"
        total:
          type: integer
          format: int64
        page:
          type: integer
        page_size:
          type: integer
    CreateMLModelRequest:
      type: object
      required:
        - model_type
        - version
        - model_url
        - model_hash
      properties:
        model_type:
          type: string
          enum:
            - tcn
            - lstm
            - transformer
            - gnn
            - vae
            - vae-tenant
        version:
          type: string
          minLength: 1
        model_url:
          type: string
          format: uri
        model_hash:
          type: string
        status:
          type: string
          enum:
            - pending
            - active
            - deprecated
            - failed
          default: pending
        scope:
          type: string
          enum:
            - global
            - custom
          default: global
        metrics:
          type: object
        file_size_bytes:
          type: integer
          format: int64
    InsightsSummary:
      type: object
      required:
        - total_anomalies
        - avg_drift_score
        - active_models
        - agents_monitored
      properties:
        total_anomalies:
          type: integer
          format: int64
        avg_drift_score:
          type: number
          format: double
        active_models:
          type: integer
          format: int64
        agents_monitored:
          type: integer
          format: int64
    AgentRiskScore:
      type: object
      required:
        - agent_id
        - agent_name
        - risk_score
        - anomaly_count
        - trend
      properties:
        agent_id:
          type: string
          format: uuid
        agent_name:
          type: string
        risk_score:
          type: number
          format: double
        anomaly_count:
          type: integer
          format: int64
        last_anomaly_at:
          type:
            - string
            - "null"
          format: date-time
        trend:
          type: string
          enum:
            - increasing
            - decreasing
            - stable
    DriftTrend:
      type: object
      required:
        - date
        - avg_score
        - max_score
        - event_count
      properties:
        date:
          type: string
          format: date
        avg_score:
          type: number
          format: double
        max_score:
          type: number
          format: double
        event_count:
          type: integer
          format: int64
    AcademyQuestion:
      type: object
      description: "One multiple-choice exam question as served to learners. This schema intentionally has
        NO correct-answer field: answer keys are never serialized by the API (the learner-facing DTO
        is defined without the field, so the key cannot leak no matter what marshals it). Grading is
        server-side and results come back as per-question correct booleans only."
      required:
        - text
        - options
      properties:
        text:
          type: string
        options:
          type: array
          description: Answer options; submit the selected 0-based index
          items:
            type: string
    AcademyModule:
      type: object
      required:
        - id
        - title
        - short
        - lesson
        - pass_score
        - questions
      properties:
        id:
          type: string
          description: Stable module slug (never a positional index) — recorded progress is keyed by it, so
            reordering or inserting modules never corrupts progress
        title:
          type: string
        short:
          type: string
          description: 1–2 word label used as the roster column header
        lesson:
          type: string
          description: Lesson document, GitHub-Flavored Markdown
        pass_score:
          type: integer
          description: Minimum number of correct answers required to pass the module exam (between 1 and the
            number of questions)
        questions:
          type: array
          items:
            $ref: "#/components/schemas/AcademyQuestion"
    AcademyCourseSummary:
      type: object
      required:
        - slug
        - code
        - title
        - tagline
        - duration_minutes
        - version
        - total_modules
        - passed_count
      properties:
        slug:
          type: string
        code:
          type: string
          description: Short certificate code (e.g. F, A, IR) used in certificate serials
        title:
          type: string
        tagline:
          type: string
        duration_minutes:
          type: integer
        version:
          type: integer
          description: Course content version; stamped on attempts as content_version
        total_modules:
          type: integer
        passed_count:
          type: integer
          description: Modules the caller has passed (counting only modules still in the manifest)
        certificate_serial:
          type: string
          description: Present only when the caller has earned the course certificate
    AcademyCourseList:
      type: object
      required:
        - courses
      properties:
        courses:
          type: array
          items:
            $ref: "#/components/schemas/AcademyCourseSummary"
    AcademyCourseDetail:
      type: object
      required:
        - slug
        - code
        - title
        - tagline
        - duration_minutes
        - version
        - modules
        - passed_module_ids
        - unlocked
      properties:
        slug:
          type: string
        code:
          type: string
        title:
          type: string
        tagline:
          type: string
        duration_minutes:
          type: integer
        version:
          type: integer
        modules:
          type: array
          description: Modules in manifest order (order drives display and unlock sequence)
          items:
            $ref: "#/components/schemas/AcademyModule"
        passed_module_ids:
          type: array
          items:
            type: string
        unlocked:
          type: object
          description: Module id → whether the caller may attempt it. A module is unlocked iff it is first in
            manifest order or its manifest predecessor is passed.
          additionalProperties:
            type: boolean
    AcademyAttemptRequest:
      type: object
      required:
        - answers
      properties:
        answers:
          type: array
          description: Selected 0-based option index per question, in question order. Must contain exactly one
            entry per question; each index must be within the question's options range.
          items:
            type: integer
            minimum: 0
    AcademyPerQuestionResult:
      type: object
      description: Booleans only — the correct option index is never revealed, so a failed learner cannot
        binary-search the answer key faster than honest retakes allow.
      required:
        - correct
      properties:
        correct:
          type: boolean
    AcademyCertificateInfo:
      type: object
      required:
        - serial
        - issued_at
        - verification_code
        - verification_url
        - show_name_publicly
      properties:
        serial:
          type: string
          description: "Globally unique printable certificate serial: MITRITY-ACAD-<year>-<course
            code>-<4-char Crockford base32 suffix>"
          example: MITRITY-ACAD-2026-F-7XK9
        issued_at:
          type: string
          format: date-time
        holder_name:
          $ref: "#/components/schemas/AcademyCertificateHolderName"
        verification_code:
          $ref: "#/components/schemas/AcademyCertificateVerificationCode"
        verification_url:
          $ref: "#/components/schemas/AcademyCertificateVerificationURL"
        show_name_publicly:
          $ref: "#/components/schemas/AcademyCertificateShowNamePublicly"
    AcademyAttemptResponse:
      type: object
      required:
        - score
        - total
        - passed
        - per_question
        - passed_modules
        - course_completed
      properties:
        score:
          type: integer
          description: Number of correctly answered questions
        total:
          type: integer
          description: Number of questions in the module exam
        passed:
          type: boolean
          description: true iff score >= the module's pass_score
        per_question:
          type: array
          description: Per-question correctness, in question order
          items:
            $ref: "#/components/schemas/AcademyPerQuestionResult"
        passed_modules:
          type: array
          description: All module ids the caller has passed in this course, in manifest order
          items:
            type: string
        course_completed:
          type: boolean
          description: true iff every module in the course is passed
        certificate:
          $ref: "#/components/schemas/AcademyCertificateInfo"
          description: Present iff course_completed and issuance succeeded. Issuance is idempotent — one
            certificate per (user, course); concurrent final submissions return the same
            certificate.
    AcademyCertificate:
      type: object
      required:
        - course_slug
        - serial
        - issued_at
        - verification_code
        - verification_url
        - show_name_publicly
      properties:
        course_slug:
          type: string
        course_title:
          type: string
          description: Omitted when the course is no longer in the catalog
        serial:
          type: string
        issued_at:
          type: string
          format: date-time
        holder_name:
          $ref: "#/components/schemas/AcademyCertificateHolderName"
        verification_code:
          $ref: "#/components/schemas/AcademyCertificateVerificationCode"
        verification_url:
          $ref: "#/components/schemas/AcademyCertificateVerificationURL"
        show_name_publicly:
          $ref: "#/components/schemas/AcademyCertificateShowNamePublicly"
    AcademyCertificateHolderName:
      type: string
      maxLength: 200
      description: "The name the certificate PDF is made out to: the display name on the holder's account,
        cut to 200 characters, with any letter the certificate's fonts lack drawn without that
        accent. Omitted when the certificate is made out to no one, because the account has no name
        or its name is in a script the certificate cannot draw."
      example: Jane Doe
    AcademyCertificateVerificationCode:
      type: string
      description: "Key of the certificate's public verification page: 24 lowercase hex characters (96
        random bits), unique to the certificate. It is public by design: the certificate prints it
        and its holder hands it out."
      pattern: ^[0-9a-f]{24}$
      example: 3f9a0c7d51e24b86a0d9e2c1
    AcademyCertificateVerificationURL:
      type: string
      format: uri
      description: Address of the certificate's public verification page, the one the certificate PDF
        prints. Share it, or add it to a LinkedIn profile.
      example: https://mitrity.com/academy/verify/3f9a0c7d51e24b86a0d9e2c1
    AcademyCertificateShowNamePublicly:
      type: boolean
      description: Whether the certificate's public verification page shows the holder's name. False until
        the holder turns it on.
    AcademyCertificateVisibilityRequest:
      type: object
      required:
        - show_name
      properties:
        show_name:
          type: boolean
          description: true shows the caller's name on the certificate's public verification page; false hides
            it.
    AcademyCertificateVerification:
      type: object
      description: What the public verification route says about a valid certificate. It carries nothing
        that identifies the holder's organization or account, and the holder's name only with their
        consent.
      required:
        - valid
        - issued_at
        - serial
      properties:
        valid:
          type: boolean
          description: Always true; a code that confirms no certificate answers 404
        course_title:
          type: string
          description: Omitted when the course is no longer in the catalog
        issued_at:
          type: string
          format: date-time
        serial:
          type: string
          description: The certificate ID printed on the certificate
          example: MITRITY-ACAD-2026-F-7XK9
        name:
          type: string
          maxLength: 200
          description: The holder's name. Present only when the holder chose to show it and their account has
            one; at most 200 characters.
    AcademyCertificateList:
      type: object
      required:
        - certificates
      properties:
        certificates:
          type: array
          items:
            $ref: "#/components/schemas/AcademyCertificate"
    AcademyInviteRequest:
      type: object
      required:
        - email
      properties:
        email:
          type: string
          format: email
    AcademyInviteResponse:
      type: object
      required:
        - user_id
        - email
        - status
        - message
      properties:
        user_id:
          type: string
          format: uuid
          description: Id of the placeholder user row that represents the pending invitation
        email:
          type: string
        status:
          type: string
          description: Always "invited" for invite create/resend
        message:
          type: string
    AcademyRosterModuleMeta:
      type: object
      required:
        - id
        - title
        - short
      properties:
        id:
          type: string
        title:
          type: string
        short:
          type: string
    AcademyRosterCourse:
      type: object
      required:
        - slug
        - code
        - title
        - published
        - version
        - modules
      properties:
        slug:
          type: string
        code:
          type: string
        title:
          type: string
        published:
          type: boolean
          description: Org admins see draft courses (pre-publish visibility); this flag lets clients badge them
        version:
          type: integer
        modules:
          type: array
          description: Module columns of the roster grid, in manifest order
          items:
            $ref: "#/components/schemas/AcademyRosterModuleMeta"
    AcademyRosterModuleProgress:
      type: object
      required:
        - module_id
        - passed
      properties:
        module_id:
          type: string
        passed:
          type: boolean
        first_passed_at:
          type: string
          format: date-time
          description: Present iff passed
    AcademyRosterCertificate:
      type: object
      required:
        - serial
        - issued_at
      properties:
        serial:
          type: string
        issued_at:
          type: string
          format: date-time
    AcademyRosterMember:
      type: object
      required:
        - user_id
        - name
        - email
        - is_placeholder
        - academy_only
        - status
        - modules
      properties:
        user_id:
          type: string
          format: uuid
        name:
          type: string
          description: Display name; empty string when not set
        email:
          type: string
        is_placeholder:
          type: boolean
          description: true for pending invitations (the learner has not registered yet)
        academy_only:
          type: boolean
          description: true for academy-only learners (never consume a paid platform seat); false for platform
            users who appear because they attempted the course
        status:
          type: string
          enum:
            - invited
            - certified
            - in_progress
            - not_started
        modules:
          type: array
          description: Per-module pass state, in manifest order
          items:
            $ref: "#/components/schemas/AcademyRosterModuleProgress"
        certificate:
          $ref: "#/components/schemas/AcademyRosterCertificate"
          description: Present iff the member holds the course certificate
    AcademyRosterResponse:
      type: object
      required:
        - course
        - members
      properties:
        course:
          $ref: "#/components/schemas/AcademyRosterCourse"
        members:
          type: array
          items:
            $ref: "#/components/schemas/AcademyRosterMember"
    AcademyAccountDeleteResponse:
      type: object
      description: Result of DELETE /academy/account. The server decides the outcome from the
        authoritative tenant row and reports which one occurred in deleted.
      required:
        - deleted
      properties:
        deleted:
          type: string
          enum:
            - tenant
            - academy_data
          description: "\"tenant\" — the whole academy-only tenant (signup_source=academy, billed through
            Stripe, with no subscription) was deleted, including every user, their Firebase
            accounts, and all Academy attempts and certificates; the caller's own account is gone.
            \"academy_data\" — a combined tenant kept its tenant row, platform users, and
            subscription, and only Academy attempts, certificates, and academy-only learner accounts
            were removed."
        learners_removed:
          type: integer
          description: Count of academy-only learner accounts deleted. Present only on the "academy_data"
            outcome — omitted for "tenant", where the entire tenant (and thus every user) is already
            gone.
    RuntimeEventClassification:
      type: string
      enum:
        - governed
        - ungoverned
        - unattributed
      description: |
        The observer's deterministic attribution of an execution, from process
        ancestry joined with the agents' `RuntimeContext`:

        - `governed` — the execution passed through a MITRITY entrance: an
          ancestor is the agent's `mitrity-gateway` / `mitrity-mcp-sidecar` or
          an attested upstream, the process is itself a MITRITY binary, or the
          control plane joined it to an admission decision.
        - `ungoverned` — inside an agent's process tree, and no entrance in
          its ancestry. The finding.
        - `unattributed` — outside every known agent tree. Stored only when a
          rule matched.

        `agent_tree` is a predicate in the tests, not a value: an exec in an
        agent's tree is one of the first two.
    RuntimeAttributionBasis:
      type: string
      enum:
        - self
        - ancestor_pid
        - ancestor_exe
        - upstream_pid
        - admission
      description: >
        Which test attributed a `governed` event. `self`: the process is a

        MITRITY entrance, corroborated — it matched an attested pid, or was

        confirmed by its container entry as an entrance seen by name. `ancestor_pid`: an ancestor
        matched the agent's attested

        `gateway_pid` (basename confirmed, hash when readable).

        `ancestor_exe`: an ancestor is an entrance by basename, tied to the

        agent by container identity (container id, else mount namespace) and

        confirmed (no attested pid could be held against it, the hash

        agreeing when readable).

        `upstream_pid`: an ancestor matched an attested upstream pid.

        `admission`: set by the control plane — the exec was joined to an

        `allowed` admission decision by inline-body digest (`admission_id`).

        Absent on `ungoverned` and `unattributed` events.
    RuntimeEventType:
      type: string
      enum:
        - exec
        - file
        - connect
      description: "`exec` from `process_exec`; `file` and `connect` from `process_kprobe` events of the
        observer's TracingPolicy. `process_exit` is consumed but never shipped."
    RuntimeAncestorRole:
      type: string
      enum:
        - gateway
        - sidecar
        - upstream
        - agent
        - hook
        - mitrity_other
        - container_init
        - init
        - other
      description: What an ancestry entry is. `gateway` / `sidecar` — an entrance (`mitrity-gateway` or
        `mitrity-mcp-gateway`; `mitrity-mcp-sidecar`); `upstream` — matched
        `RuntimeContext.upstream_pids`; `agent` — matched `agent_pid`; `hook` — `mitrity-hook`;
        `mitrity_other` — another MITRITY binary that is not an entrance (`mitrity-llm-gateway`,
        `mitrity-extauthz`, `mitrity-host-observer`); `container_init` — the container's pid 1 or
        its runtime shim; `init` — the host's pid 1; `other` — anything else.
    RuntimeAncestor:
      type: object
      required:
        - pid
        - exec_id
        - exe
        - role
      properties:
        pid:
          type: integer
          format: int64
          description: Host pid.
        nspid:
          type: integer
          format: int64
          nullable: true
          description: In-container pid (Tetragon `pod.container.pid`); null outside Kubernetes.
        exec_id:
          type: string
          maxLength: 128
          description: Tetragon's process identity; opaque.
        exe:
          type: string
          maxLength: 255
          description: Basename of the executable. Never the full path, never arguments.
        role:
          $ref: "#/components/schemas/RuntimeAncestorRole"
    RuntimeContainer:
      type: object
      description: Container and Kubernetes identity of the acting process. Absent when the process is not
        in a container.
      properties:
        id:
          type: string
          maxLength: 64
          description: The container id, runtime prefix stripped. Compared with `RuntimeContext.container_id`
            on the first 15 characters — the length Tetragon guarantees for non-Kubernetes
            containers.
        name:
          type: string
          maxLength: 253
        image:
          type: string
          maxLength: 256
          description: Image name as Tetragon reports it (`pod.container.image.name`); never the digest.
        pod_namespace:
          type: string
          maxLength: 63
        pod_name:
          type: string
          maxLength: 253
        workload:
          type: string
          maxLength: 253
        workload_kind:
          type: string
          maxLength: 63
    RuntimeProcess:
      type: object
      required:
        - pid
        - exec_id
        - binary
      properties:
        pid:
          type: integer
          format: int64
        nspid:
          type: integer
          format: int64
          nullable: true
        exec_id:
          type: string
          maxLength: 128
        parent_exec_id:
          type: string
          maxLength: 128
        uid:
          type: integer
          format: int64
        binary:
          type: string
          maxLength: 1024
          description: Executable path as the kernel saw it. `binary_truncated` when Tetragon truncated it.
        binary_truncated:
          type: boolean
          default: false
        cwd:
          type: string
          maxLength: 1024
        start_time:
          type: string
          format: date-time
        container:
          $ref: "#/components/schemas/RuntimeContainer"
        in_init_tree:
          type: boolean
          description: Tetragon's flag — the process descends from its container's init.
        argv_truncated:
          type: boolean
          default: false
          description: Tetragon truncated the arguments (`truncArgs`); `exec.canonical` then describes what
            was captured, not what ran.
    RuntimeExec:
      type: object
      description: The exec detail — the observer's canonicalisation of the redacted argv with the grammar
        the edge's command analysis uses. Raw arguments never leave the host; `canonical` is the
        reserialised, redacted, truncated command.
      required:
        - argv0
        - base
        - canonical
        - digest
      properties:
        argv0:
          type: string
          maxLength: 512
        base:
          type: string
          maxLength: 255
          description: Canonical base command of the outermost command after wrapper unwrapping.
        bases:
          type: array
          maxItems: 64
          items:
            type: string
          description: Every canonical base command in the tree, in execution order.
        canonical:
          type: string
          maxLength: 4096
        canonical_truncated:
          type: boolean
          default: false
        digest:
          type: string
          pattern: ^[0-9a-f]{64}$
          description: SHA-256 of `canonical` (before truncation).
        body_digests:
          type: array
          maxItems: 8
          items:
            type: string
            pattern: ^[0-9a-f]{64}$
          description: SHA-256 of the canonical of each inline shell body in the tree — every `sh -c`-family
            body and every `eval` argument, outermost first. What the control plane's admission join
            compares with `CommandAnalysis.digest`.
        features:
          type: array
          items:
            type: string
          description: The `exec_features` vocabulary of the edge's command analysis. Free-form on the wire;
            readers render unknown values.
        paths:
          type: array
          maxItems: 64
          items:
            type: string
            maxLength: 1024
          description: Filesystem paths the tree touches, resolved against `process.cwd`.
        paths_truncated:
          type: boolean
          default: false
        script_paths:
          type: array
          maxItems: 16
          items:
            type: string
            maxLength: 1024
        detail_shed:
          type: boolean
          default: false
          description: The observer cut `script_paths`, `bases` or `features` to fit the 16 KiB per-event cap;
            `paths_truncated`, `canonical_truncated` and `ancestry_truncated` mark the other cuts. A
            workload can choose when shedding happens by padding its arguments, so the control plane
            must be able to tell "had none" from "was cut".
        argv_ambiguous:
          type: boolean
          default: false
          description: "The observer could not decode Tetragon's rendering of argv[1:] unambiguously (an
            argument carrying a double quote), so `canonical`, `digest`, `bases`, `features`,
            `paths` and `body_digests` describe a split that may not be what ran. The observer sends
            such an exec unsampled, like a parser cap; the control plane reads the decoded detail as
            degraded, never authoritative: the digests join no admission (attribution test 6), the
            exec is never a framework helper, and a `command_pattern` match on the canonical is
            recorded at low confidence (`RuntimeFinding.detail.confidence`) rather than withheld.
            The event still lands, as classified, with this flag on it."
        redactions:
          type: integer
          minimum: 0
          description: Argv elements (or parts) replaced by secret-flag masking or DLP before canonicalisation.
        session_exec_id:
          type: string
          maxLength: 128
          description: "For `governed` events: the `exec_id` of the entrance process instance whose tree this
            exec is in — the sampling session."
    RuntimeFileOp:
      type: object
      required:
        - op
        - path
        - function_name
      properties:
        op:
          type: string
          enum:
            - open_write
            - open_read
            - rename
            - unlink
            - chmod
        path:
          type: string
          maxLength: 1024
        new_path:
          type: string
          maxLength: 1024
          description: Destination of a `rename`.
        mode:
          type: string
          pattern: ^[0-7]{4}$
          description: "For `chmod`: the requested mode, octal, four digits (`4755`)."
        function_name:
          type: string
          maxLength: 64
          description: The Tetragon hook that produced it (`security_file_permission`, `security_path_rename`,
            `security_path_unlink`, `security_path_chmod`).
    RuntimeConnect:
      type: object
      description: TCP connects aggregated per (agent, `daddr`, `dport`) into the observer's connect
        bucket (default 60 s). Loopback and link-local destinations are never reported.
      required:
        - family
        - protocol
        - daddr
        - dport
        - count
        - first_at
        - last_at
      properties:
        family:
          type: string
          enum:
            - inet
            - inet6
        protocol:
          type: string
          enum:
            - tcp
        daddr:
          type: string
          maxLength: 45
        dport:
          type: integer
          minimum: 1
          maximum: 65535
        saddr:
          type: string
          maxLength: 45
        sport:
          type: integer
          minimum: 1
          maximum: 65535
          description: The source port of the first connect in the bucket.
        count:
          type: integer
          minimum: 1
        first_at:
          type: string
          format: date-time
        last_at:
          type: string
          format: date-time
    RuntimeEvent:
      type: object
      description: |
        One observed execution, file operation or connect, as the host observer
        classified it. The observer sets everything except `admission_id`,
        `helper`, `rules_matched` and `received_at`, which the control plane
        adds at ingest and returns on reads. Exactly one of `exec`, `file`,
        `connect` is present, matching `type`.

        Privacy: never environment variables, file contents, tool output or
        the raw arguments string.
      required:
        - event_id
        - type
        - classification
        - ancestry_complete
        - host_id
        - boot_id
        - occurred_at
        - process
        - sampled
        - sample_weight
      properties:
        event_id:
          type: string
          format: uuid
          description: Observer-generated; ingest is idempotent on `(tenant_id, event_id)`.
        type:
          $ref: "#/components/schemas/RuntimeEventType"
        classification:
          $ref: "#/components/schemas/RuntimeEventClassification"
        attribution_basis:
          $ref: "#/components/schemas/RuntimeAttributionBasis"
        agent_id:
          type: string
          format: uuid
          nullable: true
          description: The attributed agent; null for `unattributed`.
        admission_id:
          type: string
          format: uuid
          nullable: true
          readOnly: true
          description: "The audit `event_id` of the admission decision this exec was joined to
            (`attribution_basis: admission`)."
        helper:
          type: boolean
          readOnly: true
          description: "Control-plane flag: a framework-helper exec — the base is in the attested framework's
            helper set (`rg`, `git`, `mitrity-hook` for the Claude sets), spawned by the attested
            agent process, no exec feature, paths inside the workspace, and the decode is not
            degraded: never on `argv_ambiguous`, `argv_truncated`, `paths_truncated` or
            `detail_shed` (the exemption is a privilege a cut or ambiguous detail cannot earn).
            Still `ungoverned`; excluded from the `ungoverned_execution` rule and from
            `ungoverned_execs_24h`."
        mitrity_ancestor:
          type: boolean
          description: "`unattributed` only: a MITRITY entrance was in the chain but no roster entry could
            name the agent (no pid or container match yet)."
        ancestry_complete:
          type: boolean
          description: The chain was resolved to its root. False means an unknown link was hit (cache mode);
            such an event is never `ungoverned`.
        host_id:
          type: string
          maxLength: 253
        boot_id:
          type: string
          maxLength: 64
        node_name:
          type: string
          maxLength: 253
        occurred_at:
          type: string
          format: date-time
          description: Tetragon's event time.
        observed_at:
          type: string
          format: date-time
          description: When the observer received the event from Tetragon.
        received_at:
          type: string
          format: date-time
          readOnly: true
        process:
          $ref: "#/components/schemas/RuntimeProcess"
        ancestry:
          type: array
          maxItems: 16
          items:
            $ref: "#/components/schemas/RuntimeAncestor"
          description: Nearest first — the parent is entry 0. Capped at 16 on the wire; the full chain was
            still used to classify.
        ancestry_truncated:
          type: boolean
          default: false
        exec:
          $ref: "#/components/schemas/RuntimeExec"
        file:
          $ref: "#/components/schemas/RuntimeFileOp"
        connect:
          $ref: "#/components/schemas/RuntimeConnect"
        sampled:
          type: boolean
          description: True when this event was chosen by the governed sampler rather than sent because it was
            ungoverned, first-of-session or a rule match.
        sample_weight:
          type: integer
          minimum: 1
          description: "How many execs this event stands for: 1 + the governed execs of the same agent
            suppressed since the previous sent one. Always 1 for `ungoverned`, `unattributed`,
            `file` and `connect`."
        rules_matched_local:
          type: array
          items:
            $ref: "#/components/schemas/RuntimeRule"
          description: The observer's pre-filter matches. Advisory; the control plane re-evaluates.
        rules_matched:
          type: array
          readOnly: true
          items:
            $ref: "#/components/schemas/RuntimeRule"
          description: The control plane's authoritative evaluation.
    RuntimeEventList:
      type: object
      required:
        - events
      properties:
        events:
          type: array
          items:
            $ref: "#/components/schemas/RuntimeEvent"
        next_page_token:
          type: string
    RuntimeSensorName:
      type: string
      enum:
        - exec
        - exit
        - file_write
        - file_read
        - file_rename
        - file_unlink
        - chmod
        - connect
    RuntimeObserverSensor:
      type: object
      required:
        - name
        - status
      properties:
        name:
          $ref: "#/components/schemas/RuntimeSensorName"
        status:
          type: string
          enum:
            - active
            - unavailable
            - not_installed
          description: "`active`: events flow (or the hook is loaded); `unavailable`: the TracingPolicy is
            installed but this hook failed to load or emits malformed events on this kernel — raises
            `sensor_unavailable`; `not_installed`: no TracingPolicy declares it — expected on hosts
            without the kprobe policy, no finding."
        policy:
          type: string
          maxLength: 253
          description: The TracingPolicy that provides it.
        detail:
          type: string
          maxLength: 512
    RuntimeRule:
      type: string
      description: |
        The built-in runtime rules the control plane evaluates on ingest
        against `ungoverned` and `unattributed` events — never `governed`
        ones. Default action for every rule: record and notify; there is no
        block.
      enum:
        - ungoverned_execution
        - destructive_outside_workspace
        - remote_fetch_to_shell
        - setuid_bit
        - autorun_write
        - non_allowlisted_connect
        - credential_file_read
        - command_pattern_match
      x-extensible-enum: true
    RuntimeFindingCode:
      type: string
      description: |
        `RuntimeFinding.code`: every `RuntimeRule` value plus the two
        host-level checks — `observer_offline` (an observer that heartbeated
        within 24 h has been silent for 10 min; auto-resolves on the next
        heartbeat) and `sensor_unavailable` (an expected sensor failed to load;
        auto-resolves when it reports `active`). Readers render unknown codes
        rather than drop them.
      enum:
        - ungoverned_execution
        - destructive_outside_workspace
        - remote_fetch_to_shell
        - setuid_bit
        - autorun_write
        - non_allowlisted_connect
        - credential_file_read
        - command_pattern_match
        - observer_offline
        - sensor_unavailable
      x-extensible-enum: true
    RuntimeFindingSeverity:
      type: string
      enum:
        - critical
        - high
        - medium
        - low
        - info
    RuntimeFinding:
      type: object
      description: "A runtime finding in the posture-findings shape with a lifecycle: one open finding per
        `dedup_key`; recurrences increment `occurrence_count`; a resolved finding whose key recurs
        opens a new row. Host-level findings have `agent_id` null and `host_id` set."
      required:
        - id
        - code
        - severity
        - status
        - message
        - remediation
        - dedup_key
        - occurrence_count
        - first_seen
        - last_seen
        - created_at
        - updated_at
      properties:
        id:
          type: string
          format: uuid
        agent_id:
          type: string
          format: uuid
          nullable: true
        agent_name:
          type: string
          nullable: true
        host_id:
          type: string
          nullable: true
        boot_id:
          type: string
          nullable: true
        code:
          $ref: "#/components/schemas/RuntimeFindingCode"
        rule:
          $ref: "#/components/schemas/RuntimeRule"
          nullable: true
          description: Equal to `code` for rule findings; null for `observer_offline` and `sensor_unavailable`.
        severity:
          $ref: "#/components/schemas/RuntimeFindingSeverity"
        status:
          type: string
          enum:
            - open
            - resolved
        message:
          type: string
          maxLength: 512
          description: One sentence naming the command, path or destination. Never a secret value.
        remediation:
          type: string
          maxLength: 1024
          description: The fixed remediation text for the finding's code.
        detail:
          type: object
          additionalProperties: true
          description: "Code-specific structure. A `command_pattern_match` carries `confidence` (`high` |
            `low`) and, on a low-confidence match (the indicator matched an ambiguously decoded
            argv, `RuntimeExec.argv_ambiguous`), `argv_ambiguous: true`."
        dedup_key:
          type: string
          maxLength: 1024
        occurrence_count:
          type: integer
          minimum: 1
        first_seen:
          type: string
          format: date-time
        last_seen:
          type: string
          format: date-time
        last_event_id:
          type: string
          format: uuid
          nullable: true
          description: The `event_id` of the latest occurrence's runtime event.
        indicator_id:
          type: string
          format: uuid
          nullable: true
          description: "For `command_pattern_match`: the threat indicator that matched."
        resolved_at:
          type: string
          format: date-time
          nullable: true
        resolved_by:
          type: string
          format: uuid
          nullable: true
          description: The resolving user; null when `resolution` is `auto`.
        resolution:
          type: string
          enum:
            - manual
            - auto
          nullable: true
        resolution_note:
          type: string
          nullable: true
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    RuntimeFindingList:
      type: object
      required:
        - findings
      properties:
        findings:
          type: array
          items:
            $ref: "#/components/schemas/RuntimeFinding"
        next_page_token:
          type: string
    RuntimeRuleCoverage:
      type: string
      enum:
        - evaluated
        - sensor_unavailable
        - sensor_not_installed
        - undecidable_wildcard_allowlist
        - no_allowlist
      description: "Whether a rule is actually being evaluated for a host or agent: `evaluated`;
        `sensor_unavailable` / `sensor_not_installed` (the file or connect sensor it needs is
        missing on the host); `undecidable_wildcard_allowlist` and `no_allowlist`
        (`non_allowlisted_connect` only). The dashboard never renders a rule as clean when it is not
        `evaluated`."
    RuntimeSummaryHost:
      type: object
      required:
        - host_id
        - boot_id
        - observer
        - agents
        - rule_coverage
      properties:
        host_id:
          type: string
        boot_id:
          type: string
        node_name:
          type: string
          nullable: true
        observer:
          type: object
          required:
            - present
            - status
            - last_heartbeat_at
          properties:
            present:
              type: boolean
              description: A heartbeat within the last 5 minutes.
            status:
              type: string
              enum:
                - healthy
                - degraded
                - error
                - stopping
                - offline
            last_heartbeat_at:
              type: string
              format: date-time
              nullable: true
            observer_version:
              type: string
              nullable: true
            tetragon_version:
              type: string
              nullable: true
            ancestors_enabled:
              type: boolean
              nullable: true
            namespaces_enabled:
              type: boolean
              nullable: true
              description: The last heartbeat's `tetragon.namespaces_enabled`; null when the observer sent none.
            sensors:
              type: array
              items:
                $ref: "#/components/schemas/RuntimeObserverSensor"
            events_per_second:
              type: number
              format: double
              nullable: true
            dropped_24h:
              type: integer
              format: int64
            argv_ambiguous:
              type: integer
              format: int64
              description: "The last heartbeat's `counters.argv_ambiguous`: execs of that interval whose argv the
                observer could not decode unambiguously, so their stored exec detail is degraded. 0
                when the observer sent no counter."
        agents:
          type: array
          items:
            type: string
            format: uuid
          description: Agents whose latest `RuntimeContext.boot_id` is this host's.
        rule_coverage:
          type: object
          additionalProperties:
            $ref: "#/components/schemas/RuntimeRuleCoverage"
          description: Keyed by `RuntimeRule`.
    RuntimeSummaryAgent:
      type: object
      required:
        - agent_id
        - agent_name
        - observer_present
        - governed_execs_24h
        - ungoverned_execs_24h
        - helper_execs_24h
        - admitted_execs_24h
        - open_findings
      properties:
        agent_id:
          type: string
          format: uuid
        agent_name:
          type: string
        host_id:
          type: string
          nullable: true
        boot_id:
          type: string
          nullable: true
        observer_present:
          type: boolean
          description: Identical to `ExecutionCoverage.observed.observer.present` for this agent.
        governed_execs_24h:
          type: integer
          format: int64
          description: Σ `sample_weight` over governed exec events in the window (admission-joined execs
            included).
        ungoverned_execs_24h:
          type: integer
          format: int64
          description: Exact; excludes helpers. Identical to
            `ExecutionCoverage.observed.observer.ungoverned_execs_24h`.
        helper_execs_24h:
          type: integer
          format: int64
        admitted_execs_24h:
          type: integer
          format: int64
          description: Execs joined to an admission decision (`attribution_basis` of `admission`).
        last_event_at:
          type: string
          format: date-time
          nullable: true
        rule_coverage:
          type: object
          additionalProperties:
            $ref: "#/components/schemas/RuntimeRuleCoverage"
          description: Per-agent view; adds the allowlist states for `non_allowlisted_connect`.
        open_findings:
          type: object
          required:
            - critical
            - high
            - medium
            - low
            - info
          properties:
            critical:
              type: integer
            high:
              type: integer
            medium:
              type: integer
            low:
              type: integer
            info:
              type: integer
    RuntimeSummary:
      type: object
      required:
        - window_hours
        - computed_at
        - hosts
        - agents
        - unattributed
        - totals
      properties:
        window_hours:
          type: integer
          enum:
            - 24
        computed_at:
          type: string
          format: date-time
        hosts:
          type: array
          items:
            $ref: "#/components/schemas/RuntimeSummaryHost"
          description: "Every host that heartbeated in the last 24 h, offline ones included (`observer.status:
            offline`)."
        agents:
          type: array
          items:
            $ref: "#/components/schemas/RuntimeSummaryAgent"
          description: Every agent with a `RuntimeContext` on a known host, plus any agent with runtime events
            in the window.
        unattributed:
          type: object
          required:
            - rule_matches_24h
            - open_findings
          properties:
            rule_matches_24h:
              type: integer
              format: int64
            open_findings:
              type: integer
        totals:
          type: object
          required:
            - hosts_present
            - hosts_offline
            - governed_execs_24h
            - ungoverned_execs_24h
            - open_findings
          properties:
            hosts_present:
              type: integer
            hosts_offline:
              type: integer
            governed_execs_24h:
              type: integer
              format: int64
            ungoverned_execs_24h:
              type: integer
              format: int64
            open_findings:
              type: integer
    RedteamTargetKind:
      type: string
      description: "What a campaign attacks. `llm_route` is a route on the customer's own
        `mitrity-llm-gateway`, which tests the guardrails with the customer's own policy in force.
        `agent_endpoint` is the customer's own agent, reached over HTTP one turn at a time, which
        tests the agent: what it discloses, whether it can be talked off its mission, whether it
        acts outside its scope or misuses the tools it holds. `mcp_tool` reserves its value so a
        campaign, finding or notification stored today keeps its meaning when it ships."
      enum:
        - llm_route
        - agent_endpoint
        - mcp_tool
      x-extensible-enum: true
    RedteamEnvironment:
      type: string
      description: The environment a target lives in, recorded on the campaign, every run and every
        finding. The dashboard warns harder before a `production` campaign leaves dry run; the
        product guidance is to point a campaign at staging.
      enum:
        - production
        - staging
        - development
    RedteamCampaignStatus:
      type: string
      description: "`paused` is the operator's stop button: the runner checks it before every attempt, so
        pausing stops a run in flight within one attempt."
      enum:
        - active
        - paused
        - archived
    RedteamRunStatus:
      type: string
      enum:
        - queued
        - running
        - completed
        - aborted
        - failed
    RedteamSeverity:
      type: string
      description: "A finding's severity, taken from the corpus entry and raised to the floor its code
        carries. There is no informational level: a finding says what happened to a control, and
        \"nothing happened\" is not a finding."
      enum:
        - critical
        - high
        - medium
        - low
    RedteamFindingStatus:
      type: string
      description: "`open` and `accepted_risk` are both **live** — one live finding per dedup key — so a
        recurrence bumps the live row rather than filing a duplicate. `resolved` is terminal: a
        resolved key that recurs opens a new finding, which makes a regression visible instead of
        silent."
      enum:
        - open
        - accepted_risk
        - resolved
    RedteamCategory:
      type: string
      description: The attack family a corpus entry belongs to. Readers render an unknown category rather
        than drop it.
      enum:
        - prompt_injection
        - jailbreak
        - data_leakage
        - denial_of_wallet
        - harmful_content
        - goal_drift
        - scope_violation
        - supply_chain
      x-extensible-enum: true
    RedteamFindingCode:
      type: string
      description: |
        The eleven finding codes. The eight attack-succeeded codes carry the
        `_succeeded` suffix, including the ones whose stems would otherwise be
        identical to a `RedteamCategory` value — two closed vocabularies
        sharing string values is how a consumer that stores both ends up
        mapping one to the other's enum, or shipping one label for two
        meanings.

        The three control-gap codes describe a control that is not doing what
        the configuration claims, independent of whether the attack would have
        worked. `detection_gap` is the most common useful result: the attack
        did not have to succeed for the gap to be real.

        Readers render unknown codes rather than drop them.
      enum:
        - prompt_injection_succeeded
        - jailbreak_succeeded
        - data_leakage_succeeded
        - denial_of_wallet_succeeded
        - harmful_content_succeeded
        - goal_drift_succeeded
        - scope_violation_succeeded
        - supply_chain_succeeded
        - detection_gap
        - encoding_evasion
        - dlp_binding_gap
      x-extensible-enum: true
    RedteamOracleKind:
      type: string
      description: Which signal decided that an attempt succeeded. `gateway_verdict` is the primary one —
        MITRITY already sits inline, before the action, with the decision, so an attempt is scored
        by what the enforcement point did with it rather than by asking a model whether the answer
        looked bad.
      enum:
        - canary
        - gateway_verdict
        - dlp_redaction
        - response_regex
        - tool_call_observed
        - judge
      x-extensible-enum: true
    RedteamConfidence:
      type: string
      description: The confidence of the oracle that fired. A deterministic oracle is always evaluated
        before a heuristic or judged one, and a `judge` oracle never fires alone where a fact was
        obtainable.
      enum:
        - deterministic
        - heuristic
        - judged
    RedteamPressureStage:
      type: string
      description: The stage of the turn that produced the result. A multi-turn entry escalates through
        these in order, and the stage is stored on the finding because "it refused twice and
        complied under an authority claim on turn four" is the actionable part of a multi-turn
        result.
      enum:
        - rapport
        - request
        - payload
        - authority
        - urgency
      x-extensible-enum: true
    RedteamAbortReason:
      type: string
      description: "Why a run stopped early. Each is checked before every attempt, in this order. A run
        that aborts reports a partial result honestly: the entries it never reached are counted as
        `not_attempted`, never as controls that held."
      enum:
        - campaign_paused
        - deadline_exceeded
        - attempt_ceiling
        - token_budget
        - usd_budget
        - target_error_rate
        - target_unreachable
        - operator_stop
      x-extensible-enum: true
    RedteamSeverityCounts:
      type: object
      description: A severity rollup. `total` is carried explicitly rather than summed from the four
        buckets, because a severity the server does not yet know still belongs in the total.
      required:
        - critical
        - high
        - medium
        - low
        - total
      properties:
        critical:
          type: integer
          format: int64
        high:
          type: integer
          format: int64
        medium:
          type: integer
          format: int64
        low:
          type: integer
          format: int64
        total:
          type: integer
          format: int64
    RedteamCampaign:
      type: object
      description: What to attack, with what selector, on what schedule, under what budget. `target_ref`
        is an address — a base URL, a route, a model — and never a credential.
      required:
        - id
        - name
        - target_kind
        - target_ref
        - target_environment
        - corpus_selector
        - status
        - dry_run
        - max_attempts
        - max_duration_seconds
        - target_error_rate
        - created_at
        - updated_at
      properties:
        id:
          type: string
          format: uuid
        environment_id:
          type: string
          format: uuid
          nullable: true
        environment_name:
          type: string
          nullable: true
        agent_id:
          type: string
          format: uuid
          nullable: true
          description: "The identity the runner authenticates as: a dedicated red-team agent with its own
            mission profile, its own budget and its own audit trail. It has no privileged path."
        agent_name:
          type: string
          nullable: true
        name:
          type: string
          maxLength: 200
        description:
          type: string
          nullable: true
        target_kind:
          $ref: "#/components/schemas/RedteamTargetKind"
        target_ref:
          type: object
          additionalProperties: true
          description: The target's address. Never a secret.
        target_environment:
          $ref: "#/components/schemas/RedteamEnvironment"
        corpus_selector:
          type: object
          additionalProperties: true
          description: Which corpus entries this campaign sends — categories, severities, entry ids,
            mutations. Empty selects the whole corpus. The corpus itself ships compiled into the
            runner and is never served by this API.
        schedule:
          type: string
          nullable: true
          description: Cron expression; null for a campaign that only runs on demand.
        status:
          $ref: "#/components/schemas/RedteamCampaignStatus"
        dry_run:
          type: boolean
          description: True by default. A campaign in dry run renders every attempt, hashes it and sends
            nothing — and therefore produces **no findings**, because the primary oracle is the
            target's own verdict and a dry run never gets one. It proves the campaign is well formed
            and reproducible, never that the guardrails hold.
        dry_run_disabled_at:
          type: string
          format: date-time
          nullable: true
          description: "When dry run was left. Leaving it is a deliberate, recorded act: the row also holds
            which user performed it, and the server logs it with the target and environment. That
            user id is deliberately not served — no response in this section carries one, which is
            also why a run's `triggered_by` is absent from `RedteamRun`."
        max_attempts:
          type: integer
        max_duration_seconds:
          type: integer
        token_budget:
          type: integer
          format: int64
          nullable: true
        usd_budget:
          type: number
          format: double
          nullable: true
        target_error_rate:
          type: number
          format: double
          description: The share of transport or 5xx failures over a sliding window above which the run stops.
            The campaign is degrading the target, or the target is already degraded, and either way
            the run must stop.
        last_run_at:
          type: string
          format: date-time
          nullable: true
        next_run_at:
          type: string
          format: date-time
          nullable: true
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        last_run:
          $ref: "#/components/schemas/RedteamRun"
        open_findings:
          $ref: "#/components/schemas/RedteamSeverityCounts"
        assurance:
          allOf:
            - $ref: "#/components/schemas/RedteamAssurance"
          description: The campaign's own guardrail assurance score, from its latest scored run. Present on
            `GET /redteam/campaigns/{campaign_id}` only, never on the list.
    RedteamCampaignCreate:
      type: object
      description: >-
        There is deliberately no `dry_run` field: a campaign is always created in dry run and leaves
        it through its own endpoint, which records who did it.

        `llm_route` and `agent_endpoint` campaigns can be created; `mcp_tool` is refused until it
        ships. An `agent_endpoint` campaign's `target_ref` names the endpoint and, optionally, the
        request shape and the agent under test — `url` (https, required), `method` (`POST` | `PUT`),
        `body_template` (a JSON body carrying `{{prompt}}` and optionally `{{session_id}}`,
        `{{context}}`, `{{attempt_id}}`, `{{turn}}`; checked for the runner's rules),
        `response_path` (a dotted path into the reply, e.g. `reply.text`) and `agent_id` (the agent
        the findings are about). A reference is an address, never a credential: a key that names one
        (`api_key`, `token`, `authorization`, …) at any depth, or a value shaped like a bearer
        token, is refused. The runner presents its credential from its own environment.
      required:
        - name
        - target_kind
        - target_environment
      properties:
        name:
          type: string
          maxLength: 200
        description:
          type: string
          maxLength: 2000
          nullable: true
        environment_id:
          type: string
          format: uuid
          nullable: true
          description: With an API key bound to an environment it may be left out, and the key's environment
            is used; any other environment is refused 403.
        agent_id:
          type: string
          format: uuid
          nullable: true
        target_kind:
          $ref: "#/components/schemas/RedteamTargetKind"
        target_ref:
          type: object
          additionalProperties: true
          default: {}
          description: Defaults to `{}`, so a campaign can be drafted before its address is known. An
            `llm_route` campaign with no address has nothing for the runner to reach, and its runs
            abort `target_unreachable`.
        target_environment:
          $ref: "#/components/schemas/RedteamEnvironment"
        corpus_selector:
          type: object
          additionalProperties: true
        schedule:
          type: string
          maxLength: 128
          nullable: true
        max_attempts:
          type: integer
          minimum: 1
          maximum: 100000
          default: 500
        max_duration_seconds:
          type: integer
          minimum: 1
          maximum: 86400
          default: 3600
        token_budget:
          type: integer
          format: int64
          minimum: 1
          nullable: true
        usd_budget:
          type: number
          format: double
          exclusiveMinimum: 0
          nullable: true
        target_error_rate:
          type: number
          format: double
          exclusiveMinimum: 0
          maximum: 1
          default: 0.2
    RedteamCampaignUpdate:
      type: object
      description: "Every field is optional; an omitted one is left alone. The `clear_*` flags remove a
        value, which a null pointer cannot express. `dry_run` is not here: leaving dry run is its
        own recorded act, and changing `target_ref` or `target_environment` puts the campaign back
        into it."
      properties:
        name:
          type: string
          maxLength: 200
        description:
          type: string
          maxLength: 2000
        environment_id:
          type: string
          format: uuid
        clear_environment:
          type: boolean
          description: Takes the campaign out of its environment. Its `queued` and `running` runs end
            (`operator_stop`), and an `active` campaign is paused.
        agent_id:
          type: string
          format: uuid
        clear_agent:
          type: boolean
        target_ref:
          type: object
          additionalProperties: true
        target_environment:
          $ref: "#/components/schemas/RedteamEnvironment"
        corpus_selector:
          type: object
          additionalProperties: true
        schedule:
          type: string
          maxLength: 128
        clear_schedule:
          type: boolean
        max_attempts:
          type: integer
          minimum: 1
          maximum: 100000
        max_duration_seconds:
          type: integer
          minimum: 1
          maximum: 86400
        token_budget:
          type: integer
          format: int64
          minimum: 1
        usd_budget:
          type: number
          format: double
          exclusiveMinimum: 0
        target_error_rate:
          type: number
          format: double
          exclusiveMinimum: 0
          maximum: 1
    RedteamCampaignList:
      type: object
      required:
        - campaigns
        - total
      properties:
        campaigns:
          type: array
          items:
            $ref: "#/components/schemas/RedteamCampaign"
        total:
          type: integer
          format: int64
        next_page_token:
          type: string
    RedteamRunOutcomes:
      type: object
      description: |
        What became of each planned attempt. The vocabulary is closed because
        these counts are read as a guardrail score.

        `not_attempted` means the run stopped before reaching the entry, so the
        control is **untested**. It MUST NOT be folded into `blocked` anywhere
        — in a count, a percentage or a badge — and a guardrail assurance score
        states the attempted set as its denominator.

        `expected_allowed_got_blocked` is a precision case that was blocked. It
        is reported here and never as a security finding: counting a false
        positive as a vulnerability makes the numbers meaningless.
      required:
        - blocked
        - finding
        - expected_allowed_got_blocked
        - target_error
        - not_attempted
        - unclassified
      properties:
        blocked:
          type: integer
          description: The enforcement point stopped the attempt, as the entry expected. The result a healthy
            configuration produces.
        finding:
          type: integer
          description: An oracle fired and a finding was recorded.
        expected_allowed_got_blocked:
          type: integer
        target_error:
          type: integer
          description: A transport or 5xx failure. Not a verdict, and never scored as one.
        not_attempted:
          type: integer
          description: The run stopped before reaching this entry. The control is untested.
        unclassified:
          type: integer
          description: A result no finding code covers.
    RedteamRun:
      type: object
      description: "One bounded job. `corpus_version` and `seed` are the reproduction key: together they
        determine the exact variants, canary values and rendered turns for every entry,
        independently of which other entries the selector included — so a run narrowed to one
        finding replays byte-identically what the full run sent for that entry."
      required:
        - id
        - campaign_id
        - status
        - trigger_kind
        - dry_run
        - target_kind
        - target_ref
        - target_environment
        - attempts_planned
        - attempts_made
        - outcomes
        - tokens_used
        - usd_spent
        - queued_at
      properties:
        id:
          type: string
          format: uuid
        campaign_id:
          type: string
          format: uuid
        campaign_name:
          type: string
          nullable: true
        environment_id:
          type: string
          format: uuid
          nullable: true
        status:
          $ref: "#/components/schemas/RedteamRunStatus"
        trigger_kind:
          type: string
          enum:
            - schedule
            - manual
        dry_run:
          type: boolean
          description: Taken from the campaign. A run cannot set its own.
        target_kind:
          $ref: "#/components/schemas/RedteamTargetKind"
        target_ref:
          type: object
          additionalProperties: true
        target_environment:
          $ref: "#/components/schemas/RedteamEnvironment"
        corpus_version:
          type: integer
          nullable: true
        seed:
          type: integer
          format: int64
          nullable: true
        pattern_set:
          type: string
          nullable: true
          description: The detection pattern set the corpus was last checked against.
        runner_version:
          type: string
          nullable: true
        attempts_planned:
          type: integer
        attempts_made:
          type: integer
        outcomes:
          $ref: "#/components/schemas/RedteamRunOutcomes"
        tokens_used:
          type: integer
          format: int64
        usd_spent:
          type: number
          format: double
        token_budget:
          type: integer
          format: int64
          nullable: true
        usd_budget:
          type: number
          format: double
          nullable: true
        max_attempts:
          type: integer
          nullable: true
        abort_reason:
          allOf:
            - $ref: "#/components/schemas/RedteamAbortReason"
          nullable: true
        abort_attempt_index:
          type: integer
          nullable: true
        error:
          type: string
          nullable: true
          maxLength: 2000
          description: The runner's own failure text, truncated and stripped of control characters. It never
            carries a target response.
        queued_at:
          type: string
          format: date-time
        started_at:
          type: string
          format: date-time
          nullable: true
        completed_at:
          type: string
          format: date-time
          nullable: true
        deadline_at:
          type: string
          format: date-time
          nullable: true
        assurance:
          allOf:
            - $ref: "#/components/schemas/RedteamAssurance"
          description: "This run's own score. Present on `GET /redteam/runs/{run_id}` only. A dry run, or a
            run that has not finished, answers `status: no_runs` with a caveat saying so."
    RedteamRunList:
      type: object
      required:
        - runs
        - total
      properties:
        runs:
          type: array
          items:
            $ref: "#/components/schemas/RedteamRun"
        total:
          type: integer
          format: int64
        next_page_token:
          type: string
    RedteamEvidence:
      type: object
      description: |
        Everything a finding may say about what was sent and received. This is
        the boundary, not a preference: the control plane stores hashes and a
        bounded, redacted excerpt, and full transcripts stay in the customer's
        environment under the run's evidence directory.

        No prompt, no rendered variant, no model response and no tool argument
        appears here or anywhere else in this API. A finding without an excerpt
        is complete; a finding carrying the prompt is a data-handling incident.

        `additionalProperties: false` is the point rather than tidiness: the
        ingest rejects an unknown field by name, and the schema says so too, so
        a producer that starts sending the prompt under a new key fails against
        the contract and not only against the server.
      additionalProperties: false
      properties:
        prompt_hash:
          type: string
          pattern: ^[0-9a-f]{64}$
          description: SHA-256 over the exact bytes sent, hex.
        response_hash:
          type: string
          pattern: ^[0-9a-f]{64}$
          description: SHA-256 over the exact response bytes received. Absent when nothing was received.
        excerpt_redacted:
          type: string
          maxLength: 512
          description: At most 512 characters from the span the oracle matched in the response, run through
            the tenant's own DLP redactor at the edge before it was transmitted. A canary value
            appears as `<canary>`, a synthetic secret as `<synthetic-secret>` and echoed prompt text
            as `<echoed-prompt>`. Absent when the oracle matched no span — a verdict oracle fires on
            the verdict, not on anything in the body, and an excerpt is never manufactured out of a
            response prefix.
        excerpt_omitted:
          type: boolean
          description: "The excerpt was dropped rather than risked: redaction was unavailable, or the finished
            excerpt still showed sent text through an encoding layer. Redaction failure is
            fail-closed."
    RedteamMappings:
      type: object
      description: |
        Framework identifiers copied from the corpus entry, so a finding states
        its mappings without a lookup. The keys the corpus ships are
        `owasp_llm`, `atlas`, `nist_ai_rmf`, `iso_42001` and `eu_ai_act`; a
        reader renders a key it does not know rather than dropping it.

        A reported block is sanitized, never rejected. The control plane drops
        a key longer than 64 bytes, trims every identifier, drops a blank one
        or one longer than 64 characters, keeps at most 32 identifiers under a
        key and drops a key left with none. It then keeps at most 16 keys: the
        five above first, in that order, then the others by name. What does
        not fit is dropped without an error and the finding is stored, so the
        finding as read back, not the report, says which identifiers were
        kept.

        These are identifiers from published frameworks, used as identifiers.
        MITRITY does not reproduce the text of any standard: an entry carries a
        control's id, never its wording. The assertion that a given corpus
        entry evidences a given control is MITRITY's own, is reviewed before a
        corpus version is released, and is versioned with the corpus, so a
        report can state which mapping set produced it. The `owasp_llm` and
        `atlas` identifiers are read in the editions the finding's
        `corpus_version` names: from corpus version 3, the OWASP Top 10 for
        LLM Applications 2025 and MITRE ATLAS release 2026.09. The control
        library an identifier resolves to states the edition it was written
        against in its descriptor's `version_label` (`ComplianceFramework`),
        and every control status and report computed from it carries its
        `content_version`.
      additionalProperties:
        type: array
        items:
          type: string
          maxLength: 64
    RedteamProposedIndicator:
      type: object
      description: >-
        A DLP or injection pattern derived from an attempt. It is a proposal and nothing more: it
        takes effect only after a governance review approves it, which is what `POST
        /redteam/findings/{id}/propose` opens.

        The runner derives a proposal only from what it planted, never from prompt text: a canary
        finding proposes the canary's shape (a pattern that matches every session reference the
        runner mints, and no value of one) as a `dlp_pattern`. A response expression is the corpus's
        success heuristic, written to recognize an answer rather than redact one, and is never
        offered. An approved `dlp_pattern` lands as an unbound custom pattern in the tenant's
        library; an approved `injection_pattern` is recorded, with the reason it did not land, until
        the edge has a delivery channel for it.
      required:
        - kind
        - name
        - pattern
      properties:
        kind:
          type: string
          enum:
            - dlp_pattern
            - injection_pattern
          x-extensible-enum: true
        name:
          type: string
          maxLength: 128
        pattern:
          type: string
          maxLength: 1024
        rationale:
          type: string
          maxLength: 1000
    RedteamFinding:
      type: object
      description: |
        The shape of a runtime finding with a lifecycle, plus the reproduction
        key, the oracle that decided and the framework mappings.

        One **live** finding per dedup key, where the key is the campaign, the
        target, the corpus entry, the mutation and the code. Including the
        mutation is deliberate: a plain attempt that was allowed and an encoded
        variant that was allowed are separate findings with separate
        lifecycles, because one is a policy that does not block and the other
        is a detector matching surface text, and they have different fixes.

        A finding describes one attempt against one target under one
        configuration. It is never a general property of the agent, the model
        or the product — the dashboard and the report render its own `message`
        and `remediation`, never a summary that generalizes them.
      required:
        - id
        - campaign_id
        - code
        - category
        - severity
        - status
        - message
        - remediation
        - target_kind
        - target_ref
        - target_environment
        - corpus_entry_id
        - corpus_version
        - mutation
        - seed
        - oracle
        - confidence
        - evidence
        - mappings
        - dedup_key
        - occurrence_count
        - first_seen
        - last_seen
        - created_at
        - updated_at
      properties:
        id:
          type: string
          format: uuid
        campaign_id:
          type: string
          format: uuid
        campaign_name:
          type: string
          nullable: true
        first_run_id:
          type: string
          format: uuid
          nullable: true
        last_run_id:
          type: string
          format: uuid
          nullable: true
          description: The run that most recently produced it. Runs may be pruned before findings, so a run id
            that no longer resolves is normal.
        agent_id:
          type: string
          format: uuid
          nullable: true
        agent_name:
          type: string
          nullable: true
        environment_id:
          type: string
          format: uuid
          nullable: true
        code:
          $ref: "#/components/schemas/RedteamFindingCode"
        category:
          $ref: "#/components/schemas/RedteamCategory"
        technique:
          type: string
          nullable: true
          description: The named method inside the category.
        severity:
          $ref: "#/components/schemas/RedteamSeverity"
        status:
          $ref: "#/components/schemas/RedteamFindingStatus"
        message:
          type: string
          description: One sentence naming what happened. Rendered verbatim.
        remediation:
          type: string
          description: The fixed text for this code, naming a configuration the customer owns. Rendered
            verbatim rather than paraphrased, so the advice a customer acts on is the same in the
            dashboard, the report and the notification.
        target_kind:
          $ref: "#/components/schemas/RedteamTargetKind"
        target_ref:
          type: object
          additionalProperties: true
        target_environment:
          $ref: "#/components/schemas/RedteamEnvironment"
        corpus_entry_id:
          type: string
          maxLength: 64
          description: The corpus entry that produced the attempt, as an identifier. Its content is not served
            by this API.
        corpus_version:
          type: integer
        mutation:
          type: string
          description: The obfuscation applied to the entry, or the **empty string** for a plain, unmutated
            attempt. Part of the dedup key.
        seed:
          type: integer
          format: int64
        attempt_id:
          type: string
          nullable: true
        oracle:
          $ref: "#/components/schemas/RedteamOracleKind"
        confidence:
          $ref: "#/components/schemas/RedteamConfidence"
        turn:
          type: integer
          nullable: true
          description: The 1-based turn index that produced the result.
        stage:
          allOf:
            - $ref: "#/components/schemas/RedteamPressureStage"
          nullable: true
        evidence:
          $ref: "#/components/schemas/RedteamEvidence"
        mappings:
          $ref: "#/components/schemas/RedteamMappings"
        proposed_indicator:
          allOf:
            - $ref: "#/components/schemas/RedteamProposedIndicator"
          nullable: true
        governance_review_id:
          type: string
          format: uuid
          nullable: true
          description: The review opened for this finding's proposed pattern, if any.
        dedup_key:
          type: string
          maxLength: 512
        occurrence_count:
          type: integer
        first_seen:
          type: string
          format: date-time
        last_seen:
          type: string
          format: date-time
        resolved_at:
          type: string
          format: date-time
          nullable: true
          description: 'When the finding left `open` — which an **accepted risk** also does. These three
            fields are set for `accepted_risk` as well as `resolved`, so read `status` before
            rendering them: a finding with `resolved_at` and `status: accepted_risk` is live, and
            labelling it "resolved" would report an accepted gap as a closed one.'
        resolved_by:
          type: string
          format: uuid
          nullable: true
        resolution:
          type: string
          enum:
            - manual
            - auto
            - accepted_risk
          nullable: true
        resolution_note:
          type: string
          nullable: true
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    RedteamFindingList:
      type: object
      description: "Cursor-paginated, with no `total`: matching `RuntimeFindingList`, whose components the
        dashboard reuses. The campaign and run lists carry one because their pages are small enough
        to count cheaply."
      required:
        - findings
      properties:
        findings:
          type: array
          items:
            $ref: "#/components/schemas/RedteamFinding"
        next_page_token:
          type: string
    RedteamResolveRequest:
      type: object
      properties:
        note:
          type: string
          maxLength: 1000
    RedteamProposeResponse:
      type: object
      required:
        - review_id
        - review_url
        - finding
      properties:
        review_id:
          type: string
          format: uuid
        review_url:
          type: string
          description: "Where the reviewer picks it up in the dashboard: `/app/governance/{review_id}`."
        finding:
          $ref: "#/components/schemas/RedteamFinding"
    RedteamCategoryCount:
      allOf:
        - $ref: "#/components/schemas/RedteamSeverityCounts"
        - type: object
          required:
            - category
          properties:
            category:
              $ref: "#/components/schemas/RedteamCategory"
    RedteamControlCount:
      allOf:
        - $ref: "#/components/schemas/RedteamSeverityCounts"
        - type: object
          required:
            - identifier
          properties:
            identifier:
              type: string
              description: One framework control identifier, e.g. an OWASP LLM entry.
    RedteamFrameworkCount:
      type: object
      required:
        - framework
        - total
        - controls
      properties:
        framework:
          type: string
          description: A `RedteamMappings` key. The five the corpus ships lead, in the order the specification
            gives them.
        total:
          type: integer
          format: int64
          description: "The number of (finding, identifier) pairs, not distinct findings: a finding mapped to
            two controls of the same framework is evidence about both."
        controls:
          type: array
          items:
            $ref: "#/components/schemas/RedteamControlCount"
    RedteamFindingSummary:
      type: object
      description: The compliance view over live findings — open ones and accepted risks.
      required:
        - totals
        - by_category
        - by_framework
      properties:
        totals:
          $ref: "#/components/schemas/RedteamSeverityCounts"
        by_category:
          type: array
          items:
            $ref: "#/components/schemas/RedteamCategoryCount"
        by_framework:
          type: array
          items:
            $ref: "#/components/schemas/RedteamFrameworkCount"
    RedteamAssurance:
      type: object
      description: The guardrail assurance score. `classes_held + classes_failed + classes_undecided =
        classes_exercised`, always.
      required:
        - subject
        - status
        - classes_exercised
        - classes_held
        - classes_failed
        - classes_undecided
        - score_pct
        - classes
        - runs
        - attempts_made
        - attempts
        - findings_by_confidence
        - caveats
        - computed_at
      properties:
        subject:
          $ref: "#/components/schemas/RedteamAssuranceSubject"
        status:
          type: string
          enum:
            - scored
            - partial
            - no_runs
            - unavailable
          x-extensible-enum: true
          description: "`scored`: every exercised class was decided. `partial`: at least one class is
            undecided. `no_runs`: no campaign of the subject has a scored run — a terminal, non-dry
            run. `unavailable`: runs exist but none exercised a derivable set of classes (every
            campaign selects entries by id, which the control plane cannot map to classes)."
        classes_exercised:
          type: integer
        classes_held:
          type: integer
        classes_failed:
          type: integer
        classes_undecided:
          type: integer
        score_pct:
          type: number
          format: double
          nullable: true
          description: "`classes_held / classes_exercised`, as a percentage. Null when nothing was exercised.
            Never computed over the planned set."
        classes:
          type: array
          items:
            $ref: "#/components/schemas/RedteamAssuranceClass"
        runs:
          type: array
          description: The runs the score was computed from, one per campaign.
          items:
            $ref: "#/components/schemas/RedteamAssuranceRun"
        attempts_made:
          type: integer
        attempts:
          $ref: "#/components/schemas/RedteamRunOutcomes"
        findings_by_confidence:
          type: object
          additionalProperties:
            type: integer
          description: Findings in the scored runs, keyed by `RedteamConfidence`.
        caveats:
          type: array
          items:
            type: string
          description: Why a class is undecided or a campaign uncounted, one sentence each, naming a number
            and a reason. None generalizes.
        computed_at:
          type: string
          format: date-time
    RedteamAssuranceSubject:
      type: object
      required:
        - kind
        - id
      properties:
        kind:
          type: string
          enum:
            - agent
            - policy
            - campaign
            - run
          x-extensible-enum: true
        id:
          type: string
          format: uuid
        name:
          type: string
          nullable: true
    RedteamAssuranceClass:
      type: object
      required:
        - category
        - status
        - findings
        - runs
      properties:
        category:
          $ref: "#/components/schemas/RedteamCategory"
        status:
          type: string
          enum:
            - held
            - failed
            - undecided
          x-extensible-enum: true
          description: "`failed`: a scored run produced a finding in the class. `held`: no finding, and every
            run that exercised the class was decisive — it finished with nothing unsent, nothing
            undecided and no target error. `undecided`: otherwise."
        findings:
          type: integer
          description: Findings the scored runs produced in the class, whatever their status now.
        confidence:
          allOf:
            - $ref: "#/components/schemas/RedteamConfidence"
          description: The strongest confidence behind the verdict — of the class's findings when it failed;
            the enforcement point's own verdict, which is deterministic, when it held. Absent for an
            undecided class.
        runs:
          type: integer
          description: How many scored runs exercised the class.
    RedteamAssuranceRun:
      type: object
      required:
        - run_id
        - campaign_id
        - target_kind
        - target_environment
        - status
        - attempts_made
        - outcomes
        - findings
        - decisive
      properties:
        run_id:
          type: string
          format: uuid
        campaign_id:
          type: string
          format: uuid
        campaign_name:
          type: string
        target_kind:
          $ref: "#/components/schemas/RedteamTargetKind"
        target_environment:
          $ref: "#/components/schemas/RedteamEnvironment"
        status:
          $ref: "#/components/schemas/RedteamRunStatus"
        abort_reason:
          allOf:
            - $ref: "#/components/schemas/RedteamAbortReason"
          nullable: true
        completed_at:
          type: string
          format: date-time
          nullable: true
        attempts_made:
          type: integer
        outcomes:
          $ref: "#/components/schemas/RedteamRunOutcomes"
        findings:
          type: integer
        classes_exercised:
          type: array
          nullable: true
          items:
            $ref: "#/components/schemas/RedteamCategory"
          description: The classes the campaign's selector covered, in the corpus order; null when the
            campaign selects entries by id.
        decisive:
          type: boolean
          description: "Every attempt reached a decision: the run completed with nothing unsent, nothing
            undecided and no target error."
